Skip to content

Commit a7cf557

Browse files
Matter Thermostat: Improve test coverage and backwards compatibility
Make tests backwards compatible down to lua libs api v10 and add test coverage for the following: * batteryLevel attribute handler * air purifier and air conditioner fan mode * water heater on lower lua libs versions * setpoint capabilities cached as Fahrenheit values
1 parent 0a6ab88 commit a7cf557

9 files changed

+955
-185
lines changed

drivers/SmartThings/matter-thermostat/src/init.lua

+91-96
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
-- Copyright 2022 SmartThings
1+
-- Copyright 2025 SmartThings
22
--
33
-- Licensed under the Apache License, Version 2.0 (the "License");
44
-- you may not use this file except in compliance with the License.
@@ -17,12 +17,11 @@ local log = require "log"
1717
local clusters = require "st.matter.clusters"
1818
local embedded_cluster_utils = require "embedded-cluster-utils"
1919
local im = require "st.matter.interaction_model"
20-
2120
local MatterDriver = require "st.matter.driver"
2221
local utils = require "st.utils"
22+
local version = require "version"
2323

2424
-- Include driver-side definitions when lua libs api version is < 10
25-
local version = require "version"
2625
if version.api < 10 then
2726
clusters.HepaFilterMonitoring = require "HepaFilterMonitoring"
2827
clusters.ActivatedCarbonFilterMonitoring = require "ActivatedCarbonFilterMonitoring"
@@ -1000,69 +999,67 @@ end
1000999

10011000
local function temp_event_handler(attribute)
10021001
return function(driver, device, ib, response)
1003-
if ib.data.value == nil then
1004-
return
1005-
end
1006-
local unit = "C"
1007-
1008-
-- Only emit the capability for RPC version >= 5, since unit conversion for
1009-
-- range capabilities is only supported in that case.
1010-
if version.rpc >= 5 then
1011-
local event
1012-
if attribute == capabilities.thermostatCoolingSetpoint.coolingSetpoint then
1013-
local range = {
1014-
minimum = device:get_field(setpoint_limit_device_field.MIN_COOL) or THERMOSTAT_MIN_TEMP_IN_C,
1015-
maximum = device:get_field(setpoint_limit_device_field.MAX_COOL) or THERMOSTAT_MAX_TEMP_IN_C,
1016-
step = 0.1
1017-
}
1018-
event = capabilities.thermostatCoolingSetpoint.coolingSetpointRange({value = range, unit = unit})
1019-
device:emit_event_for_endpoint(ib.endpoint_id, event)
1020-
elseif attribute == capabilities.thermostatHeatingSetpoint.heatingSetpoint then
1021-
local MAX_TEMP_IN_C = THERMOSTAT_MAX_TEMP_IN_C
1022-
local MIN_TEMP_IN_C = THERMOSTAT_MIN_TEMP_IN_C
1023-
local is_water_heater_device = get_device_type(driver, device) == WATER_HEATER_DEVICE_TYPE_ID
1024-
if is_water_heater_device then
1025-
MAX_TEMP_IN_C = WATER_HEATER_MAX_TEMP_IN_C
1026-
MIN_TEMP_IN_C = WATER_HEATER_MIN_TEMP_IN_C
1002+
if ib.data.value ~= nil then
1003+
local unit = "C"
1004+
1005+
-- Only emit the capability for RPC version >= 5, since unit conversion for
1006+
-- range capabilities is only supported in that case.
1007+
if version.rpc >= 5 then
1008+
local event
1009+
if attribute == capabilities.thermostatCoolingSetpoint.coolingSetpoint then
1010+
local range = {
1011+
minimum = device:get_field(setpoint_limit_device_field.MIN_COOL) or THERMOSTAT_MIN_TEMP_IN_C,
1012+
maximum = device:get_field(setpoint_limit_device_field.MAX_COOL) or THERMOSTAT_MAX_TEMP_IN_C,
1013+
step = 0.1
1014+
}
1015+
event = capabilities.thermostatCoolingSetpoint.coolingSetpointRange({value = range, unit = unit})
1016+
device:emit_event_for_endpoint(ib.endpoint_id, event)
1017+
elseif attribute == capabilities.thermostatHeatingSetpoint.heatingSetpoint then
1018+
local MAX_TEMP_IN_C = THERMOSTAT_MAX_TEMP_IN_C
1019+
local MIN_TEMP_IN_C = THERMOSTAT_MIN_TEMP_IN_C
1020+
local is_water_heater_device = get_device_type(driver, device) == WATER_HEATER_DEVICE_TYPE_ID
1021+
if is_water_heater_device then
1022+
MAX_TEMP_IN_C = WATER_HEATER_MAX_TEMP_IN_C
1023+
MIN_TEMP_IN_C = WATER_HEATER_MIN_TEMP_IN_C
1024+
end
1025+
1026+
local range = {
1027+
minimum = device:get_field(setpoint_limit_device_field.MIN_HEAT) or MIN_TEMP_IN_C,
1028+
maximum = device:get_field(setpoint_limit_device_field.MAX_HEAT) or MAX_TEMP_IN_C,
1029+
step = 0.1
1030+
}
1031+
event = capabilities.thermostatHeatingSetpoint.heatingSetpointRange({value = range, unit = unit})
1032+
device:emit_event_for_endpoint(ib.endpoint_id, event)
10271033
end
1028-
1029-
local range = {
1030-
minimum = device:get_field(setpoint_limit_device_field.MIN_HEAT) or MIN_TEMP_IN_C,
1031-
maximum = device:get_field(setpoint_limit_device_field.MAX_HEAT) or MAX_TEMP_IN_C,
1032-
step = 0.1
1033-
}
1034-
event = capabilities.thermostatHeatingSetpoint.heatingSetpointRange({value = range, unit = unit})
1035-
device:emit_event_for_endpoint(ib.endpoint_id, event)
10361034
end
1037-
end
10381035

1039-
local temp = ib.data.value / 100.0
1040-
device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = temp, unit = unit}))
1036+
local temp = ib.data.value / 100.0
1037+
device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = temp, unit = unit}))
1038+
end
10411039
end
10421040
end
10431041

10441042
local temp_attr_handler_factory = function(minOrMax)
10451043
return function(driver, device, ib, response)
1046-
if ib.data.value == nil then
1047-
return
1048-
end
1049-
local temp = ib.data.value / 100.0
1050-
local unit = "C"
1051-
temp = utils.clamp_value(temp, THERMOSTAT_MIN_TEMP_IN_C, THERMOSTAT_MAX_TEMP_IN_C)
1052-
set_field_for_endpoint(device, minOrMax, ib.endpoint_id, temp)
1053-
local min = get_field_for_endpoint(device, setpoint_limit_device_field.MIN_TEMP, ib.endpoint_id)
1054-
local max = get_field_for_endpoint(device, setpoint_limit_device_field.MAX_TEMP, ib.endpoint_id)
1055-
if min ~= nil and max ~= nil then
1056-
if min < max then
1057-
-- Only emit the capability for RPC version >= 5 (unit conversion for
1058-
-- temperature range capability is only supported for RPC >= 5)
1059-
if version.rpc >= 5 then
1060-
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = unit }))
1044+
if ib.data.value ~= nil then
1045+
local temp = ib.data.value / 100.0
1046+
local unit = "C"
1047+
temp = utils.clamp_value(temp, THERMOSTAT_MIN_TEMP_IN_C, THERMOSTAT_MAX_TEMP_IN_C)
1048+
set_field_for_endpoint(device, minOrMax, ib.endpoint_id, temp)
1049+
local min = get_field_for_endpoint(device, setpoint_limit_device_field.MIN_TEMP, ib.endpoint_id)
1050+
local max = get_field_for_endpoint(device, setpoint_limit_device_field.MAX_TEMP, ib.endpoint_id)
1051+
if min ~= nil and max ~= nil then
1052+
if min < max then
1053+
-- Only emit the capability for RPC version >= 5 (unit conversion for
1054+
-- temperature range capability is only supported for RPC >= 5)
1055+
if version.rpc >= 5 then
1056+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.temperatureMeasurement.temperatureRange({ value = { minimum = min, maximum = max }, unit = unit }))
1057+
end
1058+
set_field_for_endpoint(device, setpoint_limit_device_field.MIN_TEMP, ib.endpoint_id, nil)
1059+
set_field_for_endpoint(device, setpoint_limit_device_field.MAX_TEMP, ib.endpoint_id, nil)
1060+
else
1061+
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))
10611062
end
1062-
set_field_for_endpoint(device, setpoint_limit_device_field.MIN_TEMP, ib.endpoint_id, nil)
1063-
set_field_for_endpoint(device, setpoint_limit_device_field.MAX_TEMP, ib.endpoint_id, nil)
1064-
else
1065-
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))
10661063
end
10671064
end
10681065
end
@@ -1532,54 +1529,52 @@ end
15321529

15331530
local heating_setpoint_limit_handler_factory = function(minOrMax)
15341531
return function(driver, device, ib, response)
1535-
if ib.data.value == nil then
1536-
return
1537-
end
1538-
local MAX_TEMP_IN_C = THERMOSTAT_MAX_TEMP_IN_C
1539-
local MIN_TEMP_IN_C = THERMOSTAT_MIN_TEMP_IN_C
1540-
local is_water_heater_device = (get_device_type(driver, device) == WATER_HEATER_DEVICE_TYPE_ID)
1541-
if is_water_heater_device then
1542-
MAX_TEMP_IN_C = WATER_HEATER_MAX_TEMP_IN_C
1543-
MIN_TEMP_IN_C = WATER_HEATER_MIN_TEMP_IN_C
1544-
end
1545-
local val = ib.data.value / 100.0
1546-
val = utils.clamp_value(val, MIN_TEMP_IN_C, MAX_TEMP_IN_C)
1547-
device:set_field(minOrMax, val)
1548-
local min = device:get_field(setpoint_limit_device_field.MIN_HEAT)
1549-
local max = device:get_field(setpoint_limit_device_field.MAX_HEAT)
1550-
if min ~= nil and max ~= nil then
1551-
if min < max then
1552-
-- Only emit the capability for RPC version >= 5 (unit conversion for
1553-
-- heating setpoint range capability is only supported for RPC >= 5)
1554-
if version.rpc >= 5 then
1555-
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = min, maximum = max, step = 0.1 }, unit = "C" }))
1532+
if ib.data.value ~= nil then
1533+
local MAX_TEMP_IN_C = THERMOSTAT_MAX_TEMP_IN_C
1534+
local MIN_TEMP_IN_C = THERMOSTAT_MIN_TEMP_IN_C
1535+
local is_water_heater_device = (get_device_type(driver, device) == WATER_HEATER_DEVICE_TYPE_ID)
1536+
if is_water_heater_device then
1537+
MAX_TEMP_IN_C = WATER_HEATER_MAX_TEMP_IN_C
1538+
MIN_TEMP_IN_C = WATER_HEATER_MIN_TEMP_IN_C
1539+
end
1540+
local val = ib.data.value / 100.0
1541+
val = utils.clamp_value(val, MIN_TEMP_IN_C, MAX_TEMP_IN_C)
1542+
device:set_field(minOrMax, val)
1543+
local min = device:get_field(setpoint_limit_device_field.MIN_HEAT)
1544+
local max = device:get_field(setpoint_limit_device_field.MAX_HEAT)
1545+
if min ~= nil and max ~= nil then
1546+
if min < max then
1547+
-- Only emit the capability for RPC version >= 5 (unit conversion for
1548+
-- heating setpoint range capability is only supported for RPC >= 5)
1549+
if version.rpc >= 5 then
1550+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ value = { minimum = min, maximum = max, step = 0.1 }, unit = "C" }))
1551+
end
1552+
else
1553+
device.log.warn_with({hub_logs = true}, string.format("Device reported a min heating setpoint %s that is not lower than the reported max %s", min, max))
15561554
end
1557-
else
1558-
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))
15591555
end
15601556
end
15611557
end
15621558
end
15631559

15641560
local cooling_setpoint_limit_handler_factory = function(minOrMax)
15651561
return function(driver, device, ib, response)
1566-
if ib.data.value == nil then
1567-
return
1568-
end
1569-
local val = ib.data.value / 100.0
1570-
val = utils.clamp_value(val, THERMOSTAT_MIN_TEMP_IN_C, THERMOSTAT_MAX_TEMP_IN_C)
1571-
device:set_field(minOrMax, val)
1572-
local min = device:get_field(setpoint_limit_device_field.MIN_COOL)
1573-
local max = device:get_field(setpoint_limit_device_field.MAX_COOL)
1574-
if min ~= nil and max ~= nil then
1575-
if min < max then
1576-
-- Only emit the capability for RPC version >= 5 (unit conversion for
1577-
-- cooling setpoint range capability is only supported for RPC >= 5)
1578-
if version.rpc >= 5 then
1579-
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = min, maximum = max, step = 0.1 }, unit = "C" }))
1562+
if ib.data.value ~= nil then
1563+
local val = ib.data.value / 100.0
1564+
val = utils.clamp_value(val, THERMOSTAT_MIN_TEMP_IN_C, THERMOSTAT_MAX_TEMP_IN_C)
1565+
device:set_field(minOrMax, val)
1566+
local min = device:get_field(setpoint_limit_device_field.MIN_COOL)
1567+
local max = device:get_field(setpoint_limit_device_field.MAX_COOL)
1568+
if min ~= nil and max ~= nil then
1569+
if min < max then
1570+
-- Only emit the capability for RPC version >= 5 (unit conversion for
1571+
-- cooling setpoint range capability is only supported for RPC >= 5)
1572+
if version.rpc >= 5 then
1573+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.thermostatCoolingSetpoint.coolingSetpointRange({ value = { minimum = min, maximum = max, step = 0.1 }, unit = "C" }))
1574+
end
1575+
else
1576+
device.log.warn_with({hub_logs = true}, string.format("Device reported a min cooling setpoint %s that is not lower than the reported max %s", min, max))
15801577
end
1581-
else
1582-
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))
15831578
end
15841579
end
15851580
end

drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier.lua

+130-2
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,87 @@ test.register_message_test(
513513
clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW)
514514
}
515515
},
516-
{
516+
{
517+
channel = "capability",
518+
direction = "receive",
519+
message = {
520+
mock_device.id,
521+
{ capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "quiet" } }
522+
}
523+
},
524+
{
525+
channel = "matter",
526+
direction = "send",
527+
message = {
528+
mock_device.id,
529+
clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW)
530+
}
531+
},
532+
{
533+
channel = "capability",
534+
direction = "receive",
535+
message = {
536+
mock_device.id,
537+
{ capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "windFree" } }
538+
}
539+
},
540+
{
541+
channel = "matter",
542+
direction = "send",
543+
message = {
544+
mock_device.id,
545+
clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.LOW)
546+
}
547+
},
548+
{
549+
channel = "capability",
550+
direction = "receive",
551+
message = {
552+
mock_device.id,
553+
{ capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "medium" } }
554+
}
555+
},
556+
{
557+
channel = "matter",
558+
direction = "send",
559+
message = {
560+
mock_device.id,
561+
clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.MEDIUM)
562+
}
563+
},
564+
{
565+
channel = "capability",
566+
direction = "receive",
567+
message = {
568+
mock_device.id,
569+
{ capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "high" } }
570+
}
571+
},
572+
{
573+
channel = "matter",
574+
direction = "send",
575+
message = {
576+
mock_device.id,
577+
clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.HIGH)
578+
}
579+
},
580+
{
581+
channel = "capability",
582+
direction = "receive",
583+
message = {
584+
mock_device.id,
585+
{ capability = "airPurifierFanMode", component = "main", command = "setAirPurifierFanMode", args = { "off" } }
586+
}
587+
},
588+
{
589+
channel = "matter",
590+
direction = "send",
591+
message = {
592+
mock_device.id,
593+
clusters.FanControl.attributes.FanMode:write(mock_device, 1, clusters.FanControl.attributes.FanMode.OFF)
594+
}
595+
},
596+
{
517597
channel = "capability",
518598
direction = "receive",
519599
message = {
@@ -760,7 +840,23 @@ test.register_message_test(
760840
mock_device.id,
761841
clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.NATURAL_WIND)
762842
}
763-
}
843+
},
844+
{
845+
channel = "capability",
846+
direction = "receive",
847+
message = {
848+
mock_device.id,
849+
{ capability = "windMode", component = "main", command = "setWindMode", args = { "sleepWind" } }
850+
}
851+
},
852+
{
853+
channel = "matter",
854+
direction = "send",
855+
message = {
856+
mock_device.id,
857+
clusters.FanControl.attributes.WindSetting:write(mock_device, 1, clusters.FanControl.types.WindSettingMask.SLEEP_WIND)
858+
}
859+
},
764860
}
765861
)
766862

@@ -847,6 +943,38 @@ test.register_message_test(
847943
mock_device_rock.id,
848944
clusters.FanControl.attributes.RockSetting:write(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_UP_DOWN)
849945
}
946+
},
947+
{
948+
channel = "capability",
949+
direction = "receive",
950+
message = {
951+
mock_device_rock.id,
952+
{ capability = "fanOscillationMode", component = "main", command = "setFanOscillationMode", args = { "horizontal" } }
953+
}
954+
},
955+
{
956+
channel = "matter",
957+
direction = "send",
958+
message = {
959+
mock_device_rock.id,
960+
clusters.FanControl.attributes.RockSetting:write(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_LEFT_RIGHT)
961+
}
962+
},
963+
{
964+
channel = "capability",
965+
direction = "receive",
966+
message = {
967+
mock_device_rock.id,
968+
{ capability = "fanOscillationMode", component = "main", command = "setFanOscillationMode", args = { "swing" } }
969+
}
970+
},
971+
{
972+
channel = "matter",
973+
direction = "send",
974+
message = {
975+
mock_device_rock.id,
976+
clusters.FanControl.attributes.RockSetting:write(mock_device_rock, 1, clusters.FanControl.types.RockBitmap.ROCK_ROUND)
977+
}
850978
}
851979
}
852980
)

0 commit comments

Comments
 (0)