Skip to content

Commit 4f1be7e

Browse files
authored
Matter AQS: Break AQS files further apart, add supported air quality sensor value handling (#2587)
1 parent 419a3ec commit 4f1be7e

File tree

8 files changed

+407
-342
lines changed

8 files changed

+407
-342
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
-- Copyright © 2025 SmartThings, Inc.
2+
-- Licensed under the Apache License, Version 2.0
3+
4+
local st_utils = require "st.utils"
5+
local capabilities = require "st.capabilities"
6+
local aqs_utils = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.utils"
7+
local aqs_fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields"
8+
9+
local AirQualitySensorAttributeHandlers = {}
10+
11+
12+
-- [[ GENERIC CONCENTRATION MEASUREMENT CLUSTER ATTRIBUTES ]]
13+
14+
function AirQualitySensorAttributeHandlers.measurement_unit_factory(capability_name)
15+
return function(driver, device, ib, response)
16+
device:set_field(capability_name.."_unit", ib.data.value, {persist = true})
17+
end
18+
end
19+
20+
function AirQualitySensorAttributeHandlers.level_value_factory(attribute)
21+
return function(driver, device, ib, response)
22+
device:emit_event_for_endpoint(ib.endpoint_id, attribute(aqs_fields.level_strings[ib.data.value]))
23+
end
24+
end
25+
26+
function AirQualitySensorAttributeHandlers.measured_value_factory(capability_name, attribute, target_unit)
27+
return function(driver, device, ib, response)
28+
local reporting_unit = device:get_field(capability_name.."_unit")
29+
30+
if reporting_unit == nil then
31+
reporting_unit = aqs_fields.unit_default[capability_name]
32+
device:set_field(capability_name.."_unit", reporting_unit, {persist = true})
33+
end
34+
35+
if reporting_unit then
36+
local value = aqs_utils.unit_conversion(device, ib.data.value, reporting_unit, target_unit)
37+
device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = value, unit = aqs_fields.unit_strings[target_unit]}))
38+
39+
-- handle case where device profile supports both fineDustLevel and dustLevel
40+
if capability_name == capabilities.fineDustSensor.NAME and device:supports_capability(capabilities.dustSensor) then
41+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.dustSensor.fineDustLevel({value = value, unit = aqs_fields.unit_strings[target_unit]}))
42+
end
43+
end
44+
end
45+
end
46+
47+
48+
-- [[ AIR QUALITY CLUSTER ATTRIBUTES ]] --
49+
50+
function AirQualitySensorAttributeHandlers.air_quality_handler(driver, device, ib, response)
51+
local state = ib.data.value
52+
if state == 0 then -- Unknown
53+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.unknown())
54+
elseif state == 1 then -- Good
55+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.good())
56+
elseif state == 2 then -- Fair
57+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.moderate())
58+
elseif state == 3 then -- Moderate
59+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.slightlyUnhealthy())
60+
elseif state == 4 then -- Poor
61+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.unhealthy())
62+
elseif state == 5 then -- VeryPoor
63+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.veryUnhealthy())
64+
elseif state == 6 then -- ExtremelyPoor
65+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.airQualityHealthConcern.airQualityHealthConcern.hazardous())
66+
end
67+
end
68+
69+
70+
-- [[ PRESSURE MEASUREMENT CLUSTER ATTRIBUTES ]] --
71+
72+
function AirQualitySensorAttributeHandlers.pressure_measured_value_handler(driver, device, ib, response)
73+
local pressure = st_utils.round(ib.data.value / 10.0)
74+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.atmosphericPressureMeasurement.atmosphericPressure(pressure))
75+
end
76+
77+
return AirQualitySensorAttributeHandlers

drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/device_configuration.lua renamed to drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/device_configuration.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
-- Copyright © 2025 SmartThings, Inc.
22
-- Licensed under the Apache License, Version 2.0
33

4+
local version = require "version"
45
local capabilities = require "st.capabilities"
56
local clusters = require "st.matter.clusters"
67
local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils"
7-
local fields = require "sub_drivers.air_quality_sensor.fields"
8-
local version = require "version"
8+
local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields"
99

1010
local DeviceConfiguration = {}
1111

drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/fields.lua renamed to drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/fields.lua

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
-- Copyright © 2025 SmartThings, Inc.
22
-- Licensed under the Apache License, Version 2.0
33

4-
local capabilities = require "st.capabilities"
5-
local clusters = require "st.matter.clusters"
6-
local utils = require "st.utils"
74
local version = require "version"
5+
local utils = require "st.utils"
6+
local clusters = require "st.matter.clusters"
7+
local capabilities = require "st.capabilities"
88

99
-- Include driver-side definitions when lua libs api version is < 10
1010
if version.api < 10 then

drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/legacy_device_configuration.lua renamed to drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/legacy_device_configuration.lua

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,13 @@
11
-- Copyright © 2025 SmartThings, Inc.
22
-- Licensed under the Apache License, Version 2.0
33

4-
local capabilities = require "st.capabilities"
54
local clusters = require "st.matter.clusters"
6-
local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils"
7-
local fields = require "sub_drivers.air_quality_sensor.fields"
85
local sensor_utils = require "sensor_utils.utils"
6+
local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils"
7+
local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields"
98

109
local LegacyDeviceConfiguration = {}
1110

12-
local function set_supported_health_concern_values(device, setter_function, cluster, cluster_ep)
13-
-- read_datatype_value works since all the healthConcern capabilities' datatypes are equivalent to the one in airQualityHealthConcern
14-
local read_datatype_value = capabilities.airQualityHealthConcern.airQualityHealthConcern
15-
local supported_values = {read_datatype_value.unknown.NAME, read_datatype_value.good.NAME, read_datatype_value.unhealthy.NAME}
16-
if cluster == clusters.AirQuality then
17-
if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.FAIR }) > 0 then
18-
table.insert(supported_values, 3, read_datatype_value.moderate.NAME)
19-
end
20-
if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.MODERATE }) > 0 then
21-
table.insert(supported_values, 4, read_datatype_value.slightlyUnhealthy.NAME)
22-
end
23-
if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.VERY_POOR }) > 0 then
24-
table.insert(supported_values, read_datatype_value.veryUnhealthy.NAME)
25-
end
26-
if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.EXTREMELY_POOR }) > 0 then
27-
table.insert(supported_values, read_datatype_value.hazardous.NAME)
28-
end
29-
else -- ConcentrationMeasurement clusters
30-
if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.MEDIUM_LEVEL }) > 0 then
31-
table.insert(supported_values, 3, read_datatype_value.moderate.NAME)
32-
end
33-
if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.CRITICAL_LEVEL }) > 0 then
34-
table.insert(supported_values, read_datatype_value.hazardous.NAME)
35-
end
36-
end
37-
device:emit_event_for_endpoint(cluster_ep, setter_function(supported_values, { visibility = { displayed = false }}))
38-
end
39-
4011
function LegacyDeviceConfiguration.create_level_measurement_profile(device)
4112
local meas_name, level_name = "", ""
4213
for _, cap in ipairs(fields.CONCENTRATION_MEASUREMENT_PROFILE_ORDERING) do
@@ -47,7 +18,6 @@ function LegacyDeviceConfiguration.create_level_measurement_profile(device)
4718
local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.LEVEL_INDICATION })
4819
if #attr_eps > 0 then
4920
level_name = level_name .. fields.CONCENTRATION_MEASUREMENT_MAP[cap][1]
50-
set_supported_health_concern_values(device, fields.CONCENTRATION_MEASUREMENT_MAP[cap][3], cluster, attr_eps[1])
5121
end
5222
elseif (cap_id:match("Measurement$") or cap_id:match("Sensor$")) then
5323
local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.NUMERIC_MEASUREMENT })
@@ -65,8 +35,6 @@ function LegacyDeviceConfiguration.match_profile(device)
6535
local humidity_eps = embedded_cluster_utils.get_endpoints(device, clusters.RelativeHumidityMeasurement.ID)
6636

6737
local profile_name = "aqs"
68-
local aq_eps = embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID)
69-
set_supported_health_concern_values(device, capabilities.airQualityHealthConcern.supportedAirQualityValues, clusters.AirQuality, aq_eps[1])
7038

7139
if #temp_eps > 0 then
7240
profile_name = profile_name .. "-temp"
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
-- Copyright © 2025 SmartThings, Inc.
2+
-- Licensed under the Apache License, Version 2.0
3+
4+
local capabilities = require "st.capabilities"
5+
local clusters = require "st.matter.clusters"
6+
local embedded_cluster_utils = require "sensor_utils.embedded_cluster_utils"
7+
local fields = require "sub_drivers.air_quality_sensor.air_quality_sensor_utils.fields"
8+
9+
10+
local AirQualitySensorUtils = {}
11+
12+
function AirQualitySensorUtils.is_matter_air_quality_sensor(opts, driver, device)
13+
for _, ep in ipairs(device.endpoints) do
14+
for _, dt in ipairs(ep.device_types) do
15+
if dt.device_type_id == fields.AIR_QUALITY_SENSOR_DEVICE_TYPE_ID then
16+
return true
17+
end
18+
end
19+
end
20+
21+
return false
22+
end
23+
24+
function AirQualitySensorUtils.supports_capability_by_id_modular(device, capability, component)
25+
if not device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES) then
26+
device.log.warn_with({hub_logs = true}, "Device has overriden supports_capability_by_id, but does not have supported capabilities set.")
27+
return false
28+
end
29+
for _, component_capabilities in ipairs(device:get_field(fields.SUPPORTED_COMPONENT_CAPABILITIES)) do
30+
local comp_id = component_capabilities[1]
31+
local capability_ids = component_capabilities[2]
32+
if (component == nil) or (component == comp_id) then
33+
for _, cap in ipairs(capability_ids) do
34+
if cap == capability then
35+
return true
36+
end
37+
end
38+
end
39+
end
40+
return false
41+
end
42+
43+
function AirQualitySensorUtils.unit_conversion(device, value, from_unit, to_unit)
44+
local conversion_function = fields.conversion_tables[from_unit][to_unit]
45+
if conversion_function == nil then
46+
device.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]))
47+
return 1
48+
end
49+
50+
if value == nil then
51+
device.log.info_with( {hub_logs = true} , "unit conversion value is nil")
52+
return 1
53+
end
54+
return conversion_function(value)
55+
end
56+
57+
local function get_supported_health_concern_values_for_air_quality(device)
58+
local health_concern_datatype = capabilities.airQualityHealthConcern.airQualityHealthConcern
59+
local supported_values = {health_concern_datatype.unknown.NAME, health_concern_datatype.good.NAME, health_concern_datatype.unhealthy.NAME}
60+
if #embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID, { feature_bitmap = clusters.AirQuality.types.Feature.FAIR }) > 0 then
61+
table.insert(supported_values, health_concern_datatype.moderate.NAME)
62+
end
63+
if #embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID, { feature_bitmap = clusters.AirQuality.types.Feature.MODERATE }) > 0 then
64+
table.insert(supported_values, health_concern_datatype.slightlyUnhealthy.NAME)
65+
end
66+
if #embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID, { feature_bitmap = clusters.AirQuality.types.Feature.VERY_POOR }) > 0 then
67+
table.insert(supported_values, health_concern_datatype.veryUnhealthy.NAME)
68+
end
69+
if #embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID, { feature_bitmap = clusters.AirQuality.types.Feature.EXTREMELY_POOR }) > 0 then
70+
table.insert(supported_values, health_concern_datatype.hazardous.NAME)
71+
end
72+
return supported_values
73+
end
74+
75+
local function get_supported_health_concern_values_for_concentration_cluster(device, cluster)
76+
-- note: health_concern_datatype is generic since all the healthConcern capabilities' datatypes are equivalent to those in airQualityHealthConcern
77+
local health_concern_datatype = capabilities.airQualityHealthConcern.airQualityHealthConcern
78+
local supported_values = {health_concern_datatype.unknown.NAME, health_concern_datatype.good.NAME, health_concern_datatype.unhealthy.NAME}
79+
if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.MEDIUM_LEVEL }) > 0 then
80+
table.insert(supported_values, health_concern_datatype.moderate.NAME)
81+
end
82+
if #embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.CRITICAL_LEVEL }) > 0 then
83+
table.insert(supported_values, health_concern_datatype.hazardous.NAME)
84+
end
85+
return supported_values
86+
end
87+
88+
function AirQualitySensorUtils.set_supported_health_concern_values(device)
89+
-- handle AQ Health Concern, since this is a mandatory capability
90+
local supported_aqs_values = get_supported_health_concern_values_for_air_quality(device)
91+
local aqs_ep_ids = embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID) or {}
92+
device:emit_event_for_endpoint(aqs_ep_ids[1], capabilities.airQualityHealthConcern.supportedAirQualityValues(supported_aqs_values, { visibility = { displayed = false }}))
93+
94+
for _, capability in ipairs(fields.CONCENTRATION_MEASUREMENT_PROFILE_ORDERING) do
95+
-- all of these capabilities are optional, and capabilities stored in this field are for either a HealthConcern or a Measurement/Sensor
96+
if device:supports_capability_by_id(capability.ID) and capability.ID:match("HealthConcern$") then
97+
local cluster_info = fields.CONCENTRATION_MEASUREMENT_MAP[capability][2]
98+
local supported_values_setter = fields.CONCENTRATION_MEASUREMENT_MAP[capability][3]
99+
local supported_values = get_supported_health_concern_values_for_concentration_cluster(device, cluster_info)
100+
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
101+
device:emit_event_for_endpoint(cluster_ep_ids[1], supported_values_setter(supported_values, { visibility = { displayed = false }}))
102+
end
103+
end
104+
end
105+
106+
return AirQualitySensorUtils

0 commit comments

Comments
 (0)