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 index 345f7c5782..79315fc999 100644 --- 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 @@ -4,6 +4,7 @@ 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 = {} @@ -24,19 +25,16 @@ end function AirQualitySensorAttributeHandlers.measured_value_factory(capability_name, attribute, target_unit) return function(driver, device, ib, response) - if ib.data.value then - local reporting_unit = device:get_field(capability_name.."_unit") or aqs_fields.unit_default[capability_name] - local conversion_function = aqs_fields.conversion_tables[reporting_unit][target_unit] - if conversion_function then - local converted_value = conversion_function(ib.data.value) - 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 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 - else - device.log.info_with({hub_logs=true}, string.format("Unsupported unit conversion from %s to %s", aqs_fields.unit_strings[reporting_unit], aqs_fields.unit_strings[target_unit])) - end end end end 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 index 1e4658c99e..538af70968 100644 --- 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 @@ -1,8 +1,9 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local log = require "log" local version = require "version" -local utils = require "st.utils" +local st_utils = require "st.utils" local clusters = require "st.matter.clusters" local capabilities = require "st.capabilities" @@ -149,30 +150,64 @@ AirQualitySensorFields.level_strings = { [clusters.CarbonMonoxideConcentrationMeasurement.types.LevelValueEnum.CRITICAL] = "hazardous", } -AirQualitySensorFields.conversion_tables = { +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 utils.round(value) end, - [units.PPB] = function(value) return utils.round(value * (10^3)) end - }, + [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 utils.round(value/(10^3)) end, - [units.PPB] = function(value) return utils.round(value) end + [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 utils.round(value/(10^6)) end + [units.PPM] = function(value) return st_utils.round(value/(10^6)) end }, [units.MGM3] = { - [units.UGM3] = function(value) return utils.round(value * (10^3)) end + [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 utils.round(value) end + [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 utils.round(value/(10^3)) end + [units.UGM3] = function(value) return st_utils.round(value/(10^3)) end }, [units.BQM3] = { - [units.PCIL] = function(value) return utils.round(value/37) end - } + [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/utils.lua b/drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/air_quality_sensor_utils/utils.lua index c11ac46605..2e8d9e487b 100644 --- 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 @@ -1,6 +1,7 @@ -- 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" @@ -77,6 +78,18 @@ function AirQualitySensorUtils.set_supported_health_concern_values(device) 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. --- 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..e4f1106d76 --- /dev/null +++ b/drivers/SmartThings/matter-sensor/src/test/test_unsupported_unit_conversion.lua @@ -0,0 +1,65 @@ +-- 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" + +test.set_rpc_version(0) + +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-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..8aafe4c7ad --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_unsupported_unit_conversion.lua @@ -0,0 +1,65 @@ +-- 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" + +test.set_rpc_version(0) + +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 index f0a210bc16..e5b3728b61 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_handlers/attribute_handlers.lua @@ -494,23 +494,15 @@ end function AttributeHandlers.concentration_measured_value_factory(capability_name, attribute, target_unit) return function(driver, device, ib, response) - local reporting_unit = device:get_field(capability_name.."_unit") + 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 not reporting_unit then - reporting_unit = fields.unit_default[capability_name] - device:set_field(capability_name.."_unit", reporting_unit, {persist = true}) - end - - local value = nil - if reporting_unit then - value = thermostat_utils.unit_conversion(ib.data.value, reporting_unit, target_unit, capability_name) - end - - if value then - device:emit_event_for_endpoint(ib.endpoint_id, attribute({value = value, unit = fields.unit_strings[target_unit]})) + 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 = value, unit = fields.unit_strings[target_unit]})) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.dustSensor.fineDustLevel({value = converted_value, unit = fields.unit_strings[target_unit]})) end end end diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua index 770fd7d65e..ddedab385d 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/fields.lua @@ -1,6 +1,7 @@ -- 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" @@ -205,13 +206,24 @@ ThermostatFields.molecular_weights = { [capabilities.tvocMeasurement.NAME] = "N/A", } -ThermostatFields.conversion_tables = { +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) return st_utils.round((value * molecular_weight * 10^3) / ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) end, - [units.MGM3] = function(value, molecular_weight) return st_utils.round((value * molecular_weight) / ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) 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, @@ -221,11 +233,15 @@ ThermostatFields.conversion_tables = { }, [units.MGM3] = { [units.UGM3] = function(value) return st_utils.round(value * (10^3)) end, - [units.PPM] = function(value, molecular_weight) return st_utils.round((value * ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) / molecular_weight) 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) return st_utils.round((value * ThermostatFields.MGM3_PPM_CONVERSION_FACTOR) / (molecular_weight * 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 * 10^3)) end + end, }, [units.NGM3] = { [units.UGM3] = function(value) return st_utils.round(value/(10^3)) end diff --git a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua index a233a0c0e7..940fe9835e 100644 --- a/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua +++ b/drivers/SmartThings/matter-thermostat/src/thermostat_utils/utils.lua @@ -180,18 +180,15 @@ function ThermostatUtils.get_device_type(device) return main_device_type end -function ThermostatUtils.unit_conversion(value, from_unit, to_unit, capability_name) - local conversion_function = fields.conversion_tables[from_unit] and fields.conversion_tables[from_unit][to_unit] or nil +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 - end - - if not value then - log.info_with( {hub_logs = true} , "unit conversion value is nil") + 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