Skip to content

feat: battery measurement and SHT40/BQ27220 sensor telemetry#7

Merged
jonasniesner merged 5 commits into
OpenDisplay:mainfrom
balloob:feat/sensors-battery
Jul 8, 2026
Merged

feat: battery measurement and SHT40/BQ27220 sensor telemetry#7
jonasniesner merged 5 commits into
OpenDisplay:mainfrom
balloob:feat/sensors-battery

Conversation

@balloob

@balloob balloob commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Brings the nRF54 port to telemetry parity with the nRF52840 reference firmware: real battery voltage, SHT40 temperature/humidity, and BQ27220 fuel-gauge state, all fed into the advertisement MSD payload. Before this, battery_voltage_10mv was hardcoded to 0 and the parsed sensor_data (0x23) packets were never consumed.

New modules (mirroring Firmware/src/display_service.cpp + sensor_sht40.cpp + sensor_bq27220.cpp):

  • opendisplay_battery.c/.h — SAADC battery read with a 30 s TTL cache, source priority BQ27220 then ADC (matches readBatteryVoltageUncached).
  • opendisplay_i2c.c/.h — a minimal bit-banged I2C master over the nrf54_gpio helpers (runtime pins from the data_bus 0x24 config), with bounded clock-stretch timeouts so a stuck line fails the transfer instead of hanging the main loop.
  • opendisplay_sensor_sht40.c/.h — measurement command, CRC-8, and the exact 3-byte MSD dynamic-slot encoding.
  • opendisplay_sensor_bq27220.c/.h — voltage register (preferred battery source), SOC + charging dynamic byte.

Wiring: update_msd_payload() now ticks the sensors and reads the battery source before packing the frame (mirrors updatemsdata()); the boot screen's boot_read_battery_voltage() is wired to the new module. SensorData/PowerOption gain named fields (i2c_addr_7bit, msd_data_start_byte, charge_*) over previously-reserved bytes — struct sizes are unchanged (30 B / 30 B), so the config wire format and memcpy-based parser are untouched.

Verification

CI (PlatformIO CI, this branch): both environments green — build (seeed-xiao-nrf54l15): success, build (seeed-xiao-nrf54lm20a): success. Cannot be built locally (no arm-none-eabi + Zephyr SDK), verified only via Actions on the fork.

ADC transfer-function math (parity with the reference)

The reference reads the sense pin with Arduino analogRead(sensePin) on the Adafruit nRF52 core and never calls analogReadResolution/analogReference, so it uses the core defaults: 10-bit resolution with AR_DEFAULT (0.6 V internal ref, 1/6 gain, i.e. 3.6 V full scale). Existing device configs calibrated voltage_scaling_factor for:

raw10  = pin_volts / 3.6 * 1024          # 0..1023
volts  = raw10 * voltage_scaling_factor / 100000

On nRF54L the SAADC channels come from the board DTS (channel@0..7 -> AIN0..AIN7, gain 1/4, ADC_REF_INTERNAL, 12-bit, 8x oversample). adc_raw_to_millivolts_dt() gives the pin voltage in millivolts, which I convert back into the reference's 10-bit / 3.6 V count space before applying the unchanged formula:

raw10  = avg_pin_mv * 1024 / 3600
volts  = raw10 * voltage_scaling_factor / 100000

Worked example — 4.2 V battery through a 2:1 divider (2.1 V at pin), voltage_scaling_factor = 703:

Step nRF52840 (reference) nRF54L (this PR)
pin 2.100 V 2100 mV
raw10 2.1/3.6*1024 = 597 2100*1024/3600 = 597
volts 597*703/100000 = 4.197 V 597*703/100000 = 4.197 V

So configs calibrated on the reference read the same voltage here without recalibration. The 10-sample average and 30 s TTL are preserved; the optional enable pin is asserted HIGH / settled 10 ms / released exactly as the reference (note: battery_sense_flags ENABLE_INVERTED is intentionally not honored — the reference ignores it too).

Analog-pin constraint: on nRF54L only P1.00–P1.07 are AIN-capable (P1.0n -> AINn). The compact pin byte (port<<4)|pin is mapped to the channel index; a non-analog pin is rejected with a log and battery reads as absent (0), never a hang.

SHT40 3-byte MSD dynamic-slot encoding

Little-endian 24-bit value written at the sensor's msd_data_start_byte (default 7): v = (rh_deci & 0x3FF) | (tu << 10), where tu = temp_deci + 400 (temp in 0.1 C, clamped -40.0..125.0 C) and rh_deci is 0.1 %RH steps (0..1000). All-FF is the invalid marker.

Verified against both the reference encoder (Firmware/src/sensor_sht40.cpp:193-213) and the browser client decoder (opendisplay.org httpdocs/js/opendisplay-msd.js:19-34 decodeSht40Three). The battery/temp/status bytes round-trip through the Python client decoder py-opendisplay src/opendisplay/models/advertisement.py:380-391.

Temp RH t_deci tu rh_deci v bytes (LE) decodes to
25.0 C 50.0 % 250 650 (0x28A) 500 (0x1F4) 0x0A29F4 F4 29 0A 25.0 C / 50.0 %
-40.0 C 100.0 % -400 0 1000 (0x3E8) 0x0003E8 E8 03 00 -40.0 C / 100.0 %
0.0 C 0.0 % 0 400 (0x190) 0 0x064000 00 40 06 0.0 C / 0.0 %
invalid FF FF FF (no reading)

The default start byte is 7 (never 0), so the 0.0 C/0.0 % case encodes as 00 40 06 — not all-zero — sidestepping the known website gotcha where a literal 0 start byte decodes as -40 C.

BQ27220 dynamic byte

One byte at msd_data_start_byte (0xFF = skip): bit7 = charging, bits0-6 = SOC (0-100 %); 0xFF when the gauge is unreadable or SOC is out of range. Voltage comes from register 0x08 (mV, LE) and, when the gauge reads a nonzero voltage, becomes the preferred battery-voltage source over the ADC (as display_service.cpp:1218-1223). SOC is register 0x2C. Charging state is read from the optional charge_state_pin GPIO with charger_flags polarity, ported verbatim from charger_gpio_charging().

Decisions / deviations

  • AXP2101 skipped (documented). On the reference the AXP2101 is a PMIC power-management chip (main.cpp init/power-down of rails); it is not a battery/SOC MSD source — readBatteryVoltageUncached only consults BQ27220 then the ADC. So there is nothing telemetry-shaped to port. Left out deliberately.
  • Chip-temperature freshness: boot-once cache kept (byte 13). The reference sets MSD byte 13 from the die temperature (readChipTemperature, sd_temp_get on nRF52) and keeps SHT40 in the dynamic slot — it does not prefer SHT40 for byte 13, so this port doesn't either (parity). The existing boot-once cache is preserved because the nRF54L TEMP peripheral IRQ stops arriving once the Zephyr BLE controller is up and would hang the caller with K_FOREVER. The clean analog to sd_temp_get would be MPSL's timeslot-mediated mpsl_temperature_get(), but MPSL availability on this Seeed Zephyr build is unconfirmed and cannot be validated without hardware, so a periodic die-temp refresh is deliberately deferred rather than risk regressing the documented boot hang.
  • SHT40 address retry simplified. Single pass over the unique candidate addresses {configured, 0x44, 0x45} (the reference's 2-pass Wire re-init dance is an Arduino-Wire quirk; the bit-bang bus is re-initialized per transfer).

Self-review

  • No unbounded I/O: every I2C clock-stretch wait and the ADC read are bounded; all failure paths degrade to "field absent / 0", never a hang. The blocking work (~10 ms ADC settle + 10x2 ms samples; SHT40 12 ms measurement) only runs behind 30 s TTL caches, on the main thread, in the disconnected idle path.
  • Boot path unchanged w.r.t. the TEMP workaround — no new die-temp read added.

Notes for reviewers

🤖 Generated with Claude Code

https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR

balloob and others added 5 commits July 8, 2026 01:28
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR
CMSIS 6 (framework-zephyr 3.40400 for the LM20A) gates DSP intrinsic
wrappers on __ARM_FEATURE_DSP; forcing the macro exposes __sxtb16/__sxtab16
which g++ never declares as builtins, failing every C++ TU. No code in this
tree uses DSP intrinsics, and the compiler predefines the macro itself when
the target arch actually supports DSP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR
Port the nRF52840 reference telemetry path to the nRF54 firmware:

- Battery voltage via SAADC (opendisplay_battery.c). Selects the AIN
  channel at runtime from the configured battery_sense_pin, honors the
  optional enable pin, 10-sample average, 30 s TTL cache. Scales the
  reading into the reference's 10-bit / 3.6 V count space so existing
  voltage_scaling_factor calibrations work unchanged.
- Bit-banged I2C master over the nRF54 GPIO helpers (opendisplay_i2c.c),
  bounded clock-stretch timeouts, never hangs the main loop.
- SHT40 driver (opendisplay_sensor_sht40.c): measurement, CRC, and the
  exact 3-byte MSD dynamic-slot encoding.
- BQ27220 driver (opendisplay_sensor_bq27220.c): voltage register as the
  preferred battery source, SOC + charging dynamic byte.
- MSD payload now feeds real battery 10 mV (9th bit in status bit0) and
  ticks the sensors before each update, mirroring updatemsdata().
- Boot screen battery read wired to the new module.

AXP2101 skipped (PMIC power-mgmt, not an MSD telemetry source on the
reference). Chip-temp byte 13 keeps the boot-once cache (no safe periodic
die-temp path under the BLE controller).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR
@jonasniesner jonasniesner merged commit bac988e into OpenDisplay:main Jul 8, 2026
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.

2 participants