Skip to content

expose SHT40 temperature and humidity readings - #152

Merged
g4bri3lDev merged 1 commit into
mainfrom
feat/sht40-readings
Aug 3, 2026
Merged

expose SHT40 temperature and humidity readings#152
g4bri3lDev merged 1 commit into
mainfrom
feat/sht40-readings

Conversation

@g4bri3lDev

@g4bri3lDev g4bri3lDev commented Aug 3, 2026

Copy link
Copy Markdown
Member

Why

Some boards ship an SHT40 which the firmware exposes, yet this library ignored it. That was half true: we already parsed the sensor config (TLV packet 0x23SensorData, SensorType.SHT40, and opendisplay info printing Sensor 0 SHT40 (bus 2)), but never the readings. SensorData.msd_data_start_byte — the field naming where the reading lives — had zero consumers.

The reason it was missed: the firmware gives the SHT40 no GATT characteristic. It bit-packs the reading into 3 bytes of the 11-byte dynamic block broadcast in every advertisement — the same block already decoded for buttons and touch. The third decoder was simply never written.

Wire format

Verified against firmware upstream/main (sensor_sht40.cpp, opendisplay_sensor_sht40.c, identical in both). 24-bit little-endian, 21 bits used:

bits field decode
0–9 humidity, 0.1 %RH steps (v & 0x3FF) / 10
10–20 temperature, biased (((v >> 10) & 0x7FF) - 400) / 10

Two patterns are not measurements and decode to None:

  • FF FF FF — the firmware's read-failure sentinel; ordinary range checks reject it.
  • 00 00 00 — a slot never written. It decodes to exactly -40.0 °C / 0.0 %RH, the simultaneous floor of both ranges, so range checks alone would pass it as a plausible reading. Rejected explicitly, so an uninitialised sensor reads as "no data" rather than writing a hard -40 °C into consumers' long-term statistics. The cost, documented in the tests, is that a genuine -40.0 °C at exactly 0 %RH is dropped.

Two read paths

PassiveAdvertisementData.sht40_reading(start_byte), mirroring the existing button_event() / touch_event(). This is the path Home Assistant wants: it already parses every advertisement and already caches GlobalConfig, so it can build one entity per measurement with the offset closed over at setup.

ConnectedCMD_READ_MSD (0x0044), new here. The response carries the 16-byte record with its company-ID prefix intact, which parse_advertisement already strips, so device.read_msd() is pure reuse with no new parsing. It works on transports where no BLE advertisement is observable and needs no scan, which is why info uses it instead of scanning after disconnect.

read_sensor_values(config, advertisement) in the new sensors.py joins the two halves for scripts and the CLI. Deliberately no Sht40Tracker: the existing trackers exist to diff state into transition events, whereas a reading is a stateless value per advertisement.

The offset must come from config

Decoding at the wrong offset yields plausible garbage — a real button byte of 0x28 decodes as -39.9 °C / 4.0 %RH. And shipped boards disagree with the firmware default of 7: reTerminal E1001/E1002/E1004 use 1. A connection-free scan column was considered and rejected for exactly this reason.

SensorData.sht40_msd_start_byte is named for the SHT40 on purpose. The fuel gauges read msd_data_start_byte literally0 means byte 0, 0xFF means "do not publish" — so the SHT40's 0/0xFF → 7 rule must not be applied to them.

Also

Adds SensorType.NPM1300 (6), which shipped boards use and info previously rendered as 0x0006. Its readings are not decoded: its SOC is a linear voltage estimate, less accurate than our existing voltage_to_percent(), so only its charging bit would be new information. Same for the BQ27220 — left for a follow-up.

Verification

1008 tests pass; ruff, ruff-format, mypy strict and pylint all clean.

Beyond mocks, verified on real hardware:

  • Decode — live on a reTerminal broadcasting at offset 1, tracking 27.7 → 28.0 °C as the loop counter advanced, with chip temperature correctly higher than ambient.
  • READ_MSD — round-trip against an E1003 on firmware 2.26.0.
  • No-sensor path — a device with no 0x23 packet returns [] and renders unchanged.
  • End to end — after adding a 0x23 packet to an E1003 (see add missing SHT40 sensor to reTerminal E1003 preset opendisplay.org#74), info renders:
├── Sensors
│   └── Sensor 0 SHT40  (bus 0)  28.0 °C  63.6 %RH

Follow-ups, not in this PR

  • Home Assistant needs a core-side change beyond a version bump: BASE_PLATFORMS omits Platform.SENSOR, so a non-Flex SHT40 board would decode fine and show zero entities.
  • Pre-existing bug: AdvertisementTracker decodes all 11 dynamic bytes as buttons, so SHT40 and touch slots emit phantom button_slot_changed events when a reading changes. This predates this PR (touch has always triggered it) and is masked in HA by its byte_index filtering, but any other consumer would trust it.
  • Fuel-gauge (BQ27220 / nPM1300) decoding.

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.55285% with 19 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/opendisplay/cli.py 71.87% 9 Missing ⚠️
src/opendisplay/device.py 41.66% 7 Missing ⚠️
src/opendisplay/models/advertisement.py 94.44% 2 Missing ⚠️
src/opendisplay/protocol/commands.py 66.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@g4bri3lDev g4bri3lDev changed the title feat(sensors): expose SHT40 temperature and humidity readings expose SHT40 temperature and humidity readings Aug 3, 2026
The SHT40 config was already parsed (TLV packet 0x23, SensorType.SHT40),
but the readings never were, so a board with a sensor reported its
hardware and nothing else. SensorData.msd_data_start_byte -- the field
naming where the reading lives -- had no consumers at all.

The firmware does not expose the sensor as a GATT characteristic. It
bit-packs the reading into 3 bytes of the 11-byte dynamic block carried
in every advertisement, the same block already decoded for buttons and
touch. Add the third decoder, plus a connected read path.

Wire format (24-bit LE, 21 bits used), verified against firmware
upstream/main sensor_sht40.cpp / opendisplay_sensor_sht40.c:

  bits 0-9   humidity, 0.1 %RH steps, 0..1000
  bits 10-20 temperature, (0.1 C steps) + 400 bias, -40.0..125.0 C

Two byte patterns are not measurements and decode to None. FF FF FF is
the firmware's read-failure sentinel. 00 00 00 is a slot that was never
written; it decodes to exactly -40.0 C / 0.0 %RH, the simultaneous floor
of both ranges, so range checks alone would pass it as plausible. It is
rejected so an uninitialised sensor reads as "no data" rather than
writing a hard -40 C into consumers' long-term statistics; the cost is
that a genuine -40.0 C at exactly 0 %RH is dropped.

Add CMD_READ_MSD (0x0044) for reading the same bytes over an open
connection. The response carries the 16-byte record with its company-ID
prefix intact, which parse_advertisement already strips, so
device.read_msd() is pure reuse. This works on transports where no BLE
advertisement is observable and needs no scan, which is why `info`
uses it rather than scanning after disconnect.

The offset must always come from config, never be guessed: decoding at
the wrong offset yields plausible garbage (a button byte of 0x28 decodes
as -39.9 C / 4.0 %RH), and shipped boards disagree with the firmware
default of 7 -- reTerminal E1001/E1002/E1004 use 1.

SensorData.sht40_msd_start_byte is named for the SHT40 deliberately. The
fuel gauges read msd_data_start_byte literally, where 0 means byte 0 and
0xFF means "do not publish", so the SHT40's 0/0xFF -> 7 rule must not be
applied to them.

Also add SensorType.NPM1300 (6), which shipped boards use and `info`
previously rendered as 0x0006. Its readings are not decoded: its SOC is
a linear voltage estimate, less accurate than the existing
voltage_to_percent(), so only its charging bit would be new information.

Verified on hardware: decode live on a reTerminal (27.7 -> 28.0 C
tracking chip temperature), READ_MSD round-trip on an E1003 running
firmware 2.26.0, and the no-sensor path returning an empty list.
@g4bri3lDev
g4bri3lDev force-pushed the feat/sht40-readings branch from db6353e to 1656a2a Compare August 3, 2026 12:19
@g4bri3lDev
g4bri3lDev merged commit ac6c3fa into main Aug 3, 2026
3 checks passed
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