Skip to content

Add a Sensirion SCD41 CO2 gadget example - #13

Merged
jwinarske merged 1 commit into
mainfrom
feat/scd41-co2-example
Jul 29, 2026
Merged

Add a Sensirion SCD41 CO2 gadget example#13
jwinarske merged 1 commit into
mainfrom
feat/scd41-co2-example

Conversation

@jwinarske

Copy link
Copy Markdown
Owner

Adds a Sensirion SCD41 CO2 gadget example: advertisement decoding, live
monitoring, history download with CSV export and terminal plots, logging
interval, device info, and SCD4x forced recalibration.

Two new files under example/, following the existing flat convention:
sensirion_gadget.dart holds the wire protocol, scd41_co2_sensor.dart is the
CLI. test/sensirion_gadget_test.dart covers the protocol.

Where the protocol came from

It is not published as a spec, and the vendor's Android app is a Flutter build,
so there is no Java to decompile. The UUIDs and byte layouts come from
Sensirion's own firmware instead: arduino-ble-gadget for the services,
advertisement header and download packets, arduino-upt-core for the sample
layouts and decode functions. The 21-entry layout table is generated from
BLEProtocol.cpp rather than transcribed, because a wrong offset in one of
those entries decodes as a plausible reading rather than as an error.

Two paths to live values

The gadget broadcasts temperature, humidity and CO2 in its manufacturer-specific
advertisement data, so scan and monitor need no connection at all. live
connects and subscribes instead, which updates faster.

History download

Driven by subscribing to the packet characteristic, which means the subscription
is the request and a sample limit has to be written first. The header is
identified by its sequence number of zero rather than by arrival order, because
subscribing can surface a value cached from an earlier transfer. The gadget has
no clock, so timestamps are reconstructed from the host clock and the age the
header reports for the newest sample; readout is oldest-first, which fixes the
direction.

Changing the logging interval erases the gadget's ring buffer, so that command
reports how many samples would be lost and requires --yes.

Verified against a production MyCO2

Its GATT table diverges from the reference firmware more than the shared service
UUIDs suggest. Reading each characteristic's 0x2901 user-description
descriptor off the device names them directly, which is how the following were
established rather than guessed:

  • It advertises T_RH_CO2_ALT but omits the two trailing reserved bytes, so the
    sample is shorter than the declared stride. Slots are decoded individually;
    requiring a full-width sample discarded every value.
  • Requested Samples is 16-bit and rejects a four-byte write with
    Invalid Length. A limit falls back to 16 bits, and is skipped entirely when
    downloading everything, since zero already means everything.
  • The settings service holds LED Brightness and Ping Minion, not the Wi-Fi
    credentials and alternative device name the arduino build puts there.
  • 0x2A26 is Firmware Revision, not Manufacturer Name, which is 0x2A29.

Download verified end to end at 25/25 samples, and --max 10 returns exactly
the ten newest, matching the firmware's startReadOut(_head - n) semantics.

Two things deliberately not shipped

LED brightness is read-only. The characteristic accepts both a 1-byte and a
4-byte write without error but reads back unchanged as ff 00 00 00 either way,
so its write encoding is unconfirmed and a setter would silently do nothing.

Rename is reported as unavailable rather than attempted. GAP Device Name is
advertised as writable but rejects the write with NotAuthorized on an
unencrypted link. Bonding would be required, and the device does not accept
pairing: this library and bluetoothctl both time out with no SMP response, and
the agent never receives a request. The vendor app keeps its own label instead --
its schema carries a shouldFetchNameFromGadget flag, which only makes sense
for a local rename.

Forced recalibration is unverified against hardware. It rewrites the
sensor's calibration, and doing that against a reference the sensor is not
actually sitting in would skew it. Argument validation and service detection are
tested; the write itself is not.

Testing

dart analyze clean, dart format clean, 36 tests passing. Protocol tests
round-trip every layout through the firmware's encoding functions and decode
captured MyCO2 advertisement and live-value payloads.

Noted separately

The existing example/pair_device.dart never registers an agent, despite the
README describing it as driving the pairing agent, so it times out against any
device that needs one; it also pairs before connecting, which fails with
Page Timeout on a cold LE device. Not touched here.

The gadget protocol is not published as a spec, and the vendor's Android app is
a Flutter build, so there is no Java to decompile. The UUIDs and byte layouts
here come from Sensirion's own firmware instead: arduino-ble-gadget for the
services, advertisement header and download packets, arduino-upt-core for the
sample layouts and the decode functions. The 21-entry layout table is generated
from BLEProtocol.cpp rather than transcribed, because a wrong offset in one of
those entries decodes as a plausible reading rather than as an error.

There are two paths to live values. The gadget broadcasts temperature, humidity
and CO2 in its manufacturer-specific advertisement data, so scan and monitor
need no connection at all. Connecting and subscribing updates faster, so live
does that instead.

The download is driven by subscribing to the packet characteristic, which means
the subscription is the request and a sample limit has to be written first. The
header is identified by its sequence number of zero rather than by arrival
order, because subscribing can surface a value cached from an earlier transfer.
The gadget has no clock, so timestamps are reconstructed from the host clock and
the age the header reports for the newest sample; readout is oldest-first, which
fixes the direction.

A production MyCO2 diverges from the reference firmware more than its shared
service UUIDs suggest. Reading each characteristic's 0x2901 user-description
descriptor off the device names them directly, which is how the following were
established rather than guessed:

It advertises T_RH_CO2_ALT but omits the two trailing reserved bytes, so the
sample is shorter than the declared stride. Slots are decoded individually;
requiring a full-width sample discarded every value.

Requested Samples is 16-bit and rejects a four-byte write with Invalid Length. A
limit now falls back to 16 bits, and is skipped entirely when downloading
everything, since zero already means everything.

The settings service holds LED Brightness and Ping Minion, not the Wi-Fi
credentials and alternative device name the arduino build puts there.

Renaming goes through GAP Device Name, which is advertised as writable but
rejects the write with NotAuthorized on an unencrypted link, so characteristic
flags alone do not tell you whether a write will be accepted. Bonding would be
required and the device does not accept pairing: this library and bluetoothctl
both time out with no SMP response, so the name cannot be changed over BLE at
all. The vendor app keeps its own label instead -- its schema carries a
shouldFetchNameFromGadget flag, which only makes sense for a local rename --
so the command explains that rather than suggesting a pairing that cannot
succeed.

0x2A26 is Firmware Revision, not Manufacturer Name, which is 0x2A29.

LED brightness is exposed read-only. The characteristic accepts both a 1-byte
and a 4-byte write without error but reads back unchanged either way, so its
write encoding is unconfirmed and a setter would silently do nothing.

Changing the logging interval erases the gadget's ring buffer, so that command
reports how many samples would be lost and requires --yes.

Tests round-trip every layout through the firmware's encoding functions and
decode captured MyCO2 advertisement and live-value payloads. Forced
recalibration is unverified against hardware: it rewrites the sensor's
calibration, and doing that against a reference the sensor is not actually
sitting in would skew it.

Signed-off-by: Joel Winarske <[email protected]>
@jwinarske
jwinarske merged commit 77d4c4c into main Jul 29, 2026
6 checks passed
@jwinarske
jwinarske deleted the feat/scd41-co2-example branch July 29, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant