Skip to content

Matter Sensor: Formaldehyde measurement can be dropped or rounded to 0 due to unit conversion handling #2932

@ldeora

Description

@ldeora

Summary

A Matter air quality sensor / purifier reporting a small but valid formaldehyde value can appear as 0 in the SmartThings app.

A real-world report from the SmartThings Community shows a Dyson purifier paired through Matter. The Dyson device/app reports:

0.002 mg/m³ formaldehyde

but the SmartThings app shows:

0

Community report:

https://community.smartthings.com/t/formaldehyde-value-on-smart-things-app-always-0/309130

This looks like an issue in the Matter Sensor Edge Driver’s air quality subdriver, specifically in the formaldehyde unit handling and conversion/rounding logic.

Affected driver area

drivers/SmartThings/matter-sensor/src/sub_drivers/air_quality_sensor/

Relevant files:

air_quality_sensor_utils/fields.lua
air_quality_sensor_handlers/attribute_handlers.lua
init.lua

Current behavior

In init.lua, FormaldehydeConcentrationMeasurement.MeasuredValue is handled with fields.units.PPM as the target unit:

[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),
},

In fields.lua, formaldehydeMeasurement also defaults to PPM:

[capabilities.formaldehydeMeasurement.NAME] = units.PPM,

The conversion table supports PPM, PPB, and PPT conversions toward PPM, but those conversions round to whole numbers:

[units.PPM] = {
  [units.PPM] = function(value) return utils.round(value) end,
  [units.PPB] = function(value) return utils.round(value * (10^3)) end
},

[units.PPB] = {
  [units.PPM] = function(value) return utils.round(value/(10^3)) end,
  [units.PPB] = function(value) return utils.round(value) end
},

[units.PPT] = {
  [units.PPM] = function(value) return utils.round(value/(10^6)) end
},

Mass concentration units are only converted toward UGM3:

[units.MGM3] = {
  [units.UGM3] = function(value) return utils.round(value * (10^3)) end
},

[units.UGM3] = {
  [units.UGM3] = function(value) return utils.round(value) end
},

[units.NGM3] = {
  [units.UGM3] = function(value) return utils.round(value/(10^3)) end
},

There is currently no conversion path from MGM3, UGM3, or NGM3 to PPM.

Why this is a problem

Matter’s FormaldehydeConcentrationMeasurement cluster has a mandatory MeasurementUnit attribute. The MeasurementUnit enum can represent units such as:

PPM  = 0
PPB  = 1
PPT  = 2
MGM3 = 3
UGM3 = 4
NGM3 = 5
PM3  = 6
BQM3 = 7

So a Matter device can report formaldehyde as a mass concentration, for example mg/m³ or µg/m³.

If the device reports:

MeasurementUnit = MGM3
MeasuredValue   = 0.002

the current driver tries to convert from MGM3 to target PPM.

However, conversion_tables[MGM3][PPM] does not exist. As a result, the driver does not emit a formaldehydeMeasurement event for that value.

This can make the SmartThings app show 0 even though the Matter device is reporting a valid non-zero value.

There is a second related problem: if a device reports a small value in PPM, PPB, or PPT, the current conversion table rounds to whole numbers. For formaldehyde, useful indoor values are often far below 1 ppm, so whole-number rounding destroys meaningful precision.

For example:

2 ppb = 0.002 ppm

but the current conversion does:

utils.round(2 / 1000) -- 0

So a valid small formaldehyde value can become 0.

Formaldehyde-specific unit conversion issue

Converting between mg/m³ and ppm is not a generic unit conversion. It depends on the substance’s molecular weight and on temperature/pressure assumptions.

For formaldehyde, using the common 25 °C / 1 atm assumption:

1 ppm formaldehyde ≈ 1.228–1.24 mg/m³
1 mg/m³ formaldehyde ≈ 0.81 ppm

So the reported Dyson value:

0.002 mg/m³

is approximately:

0.0016 ppm

This is small, but it is not zero.

Adding a global MGM3 -> PPM conversion would not be correct for all gases, because CO, CO₂, NO₂, ozone, TVOC, and formaldehyde have different molecular weights.

Expected behavior

Small but valid formaldehyde values should not be dropped or rounded to zero.

For example, a device reporting:

MeasurementUnit = MGM3
MeasuredValue   = 0.002

should result in either:

0.002 mg/m³

or, if SmartThings prefers ppm:

~0.0016 ppm

but not:

0

Suggested fix

Handle formaldehyde separately instead of relying only on the generic concentration conversion table.

Option A: Preserve mass units for formaldehyde

If the Matter device reports formaldehyde in MGM3, UGM3, or NGM3, emit the value as mg/m^3 with sufficient decimal precision rather than forcing it to PPM.

Example behavior:

MGM3 -> mg/m^3
UGM3 -> mg/m^3
NGM3 -> mg/m^3

Option B: Convert formaldehyde mass concentration to ppm using an HCHO-specific conversion

If SmartThings prefers ppm, use a formaldehyde-specific conversion factor rather than a generic one.

At 25 °C / 1 atm:

ppm = mg/m³ * 24.45 / 30.026

or approximately:

ppm = mg/m³ * 0.814

Also avoid integer rounding for gas concentrations

For gas measurements, especially formaldehyde, ozone, NO₂, and CO, conversions should preserve useful decimal precision.

For example:

local function round_to(value, decimals)
  local p = 10 ^ decimals
  return utils.round(value * p) / p
end

Then:

PPB -> PPM: round_to(value / 1000, 3)
PPT -> PPM: round_to(value / 1000000, 6)
PPM -> PPM: round_to(value, 3)

For formaldehyde specifically, more precision may be useful for very low indoor readings.

Defensive improvement

measured_value_factory() currently does this:

local conversion_function = aqs_fields.conversion_tables[reporting_unit][target_unit]

This works when reporting_unit exists in the conversion table, even if the target conversion is missing. But if a device reports a unit that has no top-level entry in conversion_tables, this can throw before reaching the existing “Unsupported unit conversion” log message.

A safer version would be:

local conversions = aqs_fields.conversion_tables[reporting_unit]
local conversion_function = conversions and conversions[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]
    })
  )
else
  device.log.info_with(
    { hub_logs = true },
    string.format(
      "Unsupported unit conversion from %s to %s",
      aqs_fields.unit_strings[reporting_unit] or tostring(reporting_unit),
      aqs_fields.unit_strings[target_unit] or tostring(target_unit)
    )
  )
end

Suggested test cases

The driver should preserve non-zero formaldehyde values in these cases:

Reporting unit: MGM3
MeasuredValue: 0.002
Expected: non-zero formaldehyde value, preferably 0.002 mg/m^3 or ~0.0016 ppm

Reporting unit: UGM3
MeasuredValue: 2
Expected: non-zero formaldehyde value, preferably 0.002 mg/m^3 or ~0.0016 ppm

Reporting unit: PPB
MeasuredValue: 2
Expected: 0.002 ppm, not 0

Reporting unit: PPB
MeasuredValue: 80
Expected: 0.08 ppm, not 0

User impact

Matter air quality devices that report formaldehyde in realistic indoor ranges can appear broken or misleading in SmartThings because the app may show 0 even when the device is reporting a valid non-zero value.

This makes the formaldehyde capability unreliable for Matter air quality sensors and purifiers, including devices such as Dyson purifiers that expose formaldehyde measurements over Matter.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions