expose SHT40 temperature and humidity readings - #152
Merged
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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
force-pushed
the
feat/sht40-readings
branch
from
August 3, 2026 12:19
db6353e to
1656a2a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
0x23→SensorData,SensorType.SHT40, andopendisplay infoprintingSensor 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:(v & 0x3FF) / 10(((v >> 10) & 0x7FF) - 400) / 10Two 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 °Cinto consumers' long-term statistics. The cost, documented in the tests, is that a genuine-40.0 °Cat exactly0 %RHis dropped.Two read paths
Passive —
AdvertisementData.sht40_reading(start_byte), mirroring the existingbutton_event()/touch_event(). This is the path Home Assistant wants: it already parses every advertisement and already cachesGlobalConfig, so it can build one entity per measurement with the offset closed over at setup.Connected —
CMD_READ_MSD(0x0044), new here. The response carries the 16-byte record with its company-ID prefix intact, whichparse_advertisementalready strips, sodevice.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 whyinfouses it instead of scanning after disconnect.read_sensor_values(config, advertisement)in the newsensors.pyjoins the two halves for scripts and the CLI. Deliberately noSht40Tracker: 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
0x28decodes 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-freescancolumn was considered and rejected for exactly this reason.SensorData.sht40_msd_start_byteis named for the SHT40 on purpose. The fuel gauges readmsd_data_start_byteliterally —0means byte 0,0xFFmeans "do not publish" — so the SHT40's0/0xFF→ 7 rule must not be applied to them.Also
Adds
SensorType.NPM1300(6), which shipped boards use andinfopreviously rendered as0x0006. Its readings are not decoded: its SOC is a linear voltage estimate, less accurate than our existingvoltage_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:
27.7 → 28.0 °Cas the loop counter advanced, with chip temperature correctly higher than ambient.READ_MSD— round-trip against an E1003 on firmware 2.26.0.0x23packet returns[]and renders unchanged.0x23packet to an E1003 (see add missing SHT40 sensor to reTerminal E1003 preset opendisplay.org#74),inforenders:Follow-ups, not in this PR
BASE_PLATFORMSomitsPlatform.SENSOR, so a non-Flex SHT40 board would decode fine and show zero entities.AdvertisementTrackerdecodes all 11 dynamic bytes as buttons, so SHT40 and touch slots emit phantombutton_slot_changedevents when a reading changes. This predates this PR (touch has always triggered it) and is masked in HA by itsbyte_indexfiltering, but any other consumer would trust it.