From 18872e79f53ada4642fa4073b75861021023f793 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Jul 2026 01:28:33 -0400 Subject: [PATCH 1/5] ci: build both nRF54 environments on push and PR Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR --- .github/workflows/main.yaml | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/main.yaml diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..31afa30 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,40 @@ +name: PlatformIO CI + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + environment: + - seeed-xiao-nrf54l15 + - seeed-xiao-nrf54lm20a + + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: | + ~/.cache/pip + ~/.platformio/.cache + key: ${{ runner.os }}-pio-${{ matrix.environment }} + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install PlatformIO Core + run: pip install --upgrade platformio + + - name: Build firmware + run: | + platformio run --environment ${{ matrix.environment }} + + - name: Upload firmware artifact + uses: actions/upload-artifact@v4 + with: + name: firmware-${{ matrix.environment }} + path: | + .pio/build/${{ matrix.environment }}/firmware.hex + .pio/build/${{ matrix.environment }}/zephyr/zephyr.hex + if-no-files-found: error From b00f88b3b7004c385904e9f440da92cac73fb912 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Jul 2026 01:33:53 -0400 Subject: [PATCH 2/5] ci: disable fail-fast so both env results are visible Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR --- .github/workflows/main.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 31afa30..2e63253 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -7,6 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: environment: - seeed-xiao-nrf54l15 From 910ba02b59bb6cbc16c044b0fd6ffd8b6f57bd8d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Jul 2026 01:38:47 -0400 Subject: [PATCH 3/5] fix: drop forced __ARM_FEATURE_DSP define breaking lm20a C++ builds 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 Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR --- platformio.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index ff4b442..aea315a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -13,7 +13,6 @@ build_flags = -DOD_APP_VERSION=0x0100 -DOPENDISPLAY_BUILD_ID=\"nrf54\" -DOPENDISPLAY_ZLIB_USE_HEAP_WINDOW=0 - -D__ARM_FEATURE_DSP=1 [env:seeed-xiao-nrf54l15] platform = https://github.com/Seeed-Studio/platform-seeedboards.git From fb2e75ac3be88d0944d3bbf9d860171cb3a4729f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Jul 2026 01:45:27 -0400 Subject: [PATCH 4/5] fix: undefine __ARM_FEATURE_DSP on lm20a to work around CMSIS 6 C++ breakage Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR --- platformio.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/platformio.ini b/platformio.ini index aea315a..307990f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -29,3 +29,7 @@ board = seeed-xiao-nrf54lm20a build_flags = ${env.build_flags} -DNRF54_BOARD_LM20 +; CMSIS 6 (this env's framework-zephyr) gates DSP wrappers on __ARM_FEATURE_DSP, +; but g++ never declares the __sxtb16/__sxtab16 ACLE builtins they call, so every +; C++ TU including a Zephyr header fails. Nothing here uses DSP intrinsics. + -U__ARM_FEATURE_DSP From 8aaffd526c624178a3fed3e772e7ff0fe9ae50c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Jul 2026 02:35:49 -0400 Subject: [PATCH 5/5] feat: battery measurement and SHT40/BQ27220 sensor telemetry 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 Claude-Session: https://claude.ai/code/session_012g2e8mr132vcizx92WsgiR --- src/boot_screen.cpp | 3 +- src/opendisplay_battery.c | 211 ++++++++++++++++++++++++++++ src/opendisplay_battery.h | 27 ++++ src/opendisplay_ble.c | 15 +- src/opendisplay_ble.h | 1 + src/opendisplay_i2c.c | 219 +++++++++++++++++++++++++++++ src/opendisplay_i2c.h | 48 +++++++ src/opendisplay_sensor_bq27220.c | 183 ++++++++++++++++++++++++ src/opendisplay_sensor_bq27220.h | 27 ++++ src/opendisplay_sensor_common.h | 41 ++++++ src/opendisplay_sensor_sht40.c | 233 +++++++++++++++++++++++++++++++ src/opendisplay_sensor_sht40.h | 19 +++ src/opendisplay_structs.h | 21 ++- zephyr/CMakeLists.txt | 4 + zephyr/app.overlay | 10 ++ zephyr/prj.conf | 5 + 16 files changed, 1063 insertions(+), 4 deletions(-) create mode 100644 src/opendisplay_battery.c create mode 100644 src/opendisplay_battery.h create mode 100644 src/opendisplay_i2c.c create mode 100644 src/opendisplay_i2c.h create mode 100644 src/opendisplay_sensor_bq27220.c create mode 100644 src/opendisplay_sensor_bq27220.h create mode 100644 src/opendisplay_sensor_common.h create mode 100644 src/opendisplay_sensor_sht40.c create mode 100644 src/opendisplay_sensor_sht40.h diff --git a/src/boot_screen.cpp b/src/boot_screen.cpp index 2e8506b..f5cba0f 100644 --- a/src/boot_screen.cpp +++ b/src/boot_screen.cpp @@ -1,5 +1,6 @@ #include "boot_screen.h" #include "opendisplay_ble.h" +#include "opendisplay_battery.h" #include "opendisplay_config_parser.h" #include "opendisplay_display_color.h" #include "opendisplay_structs.h" @@ -76,7 +77,7 @@ static int boot_get_plane(uint8_t color_scheme) static float boot_read_battery_voltage(void) { - return -999.0f; + return opendisplay_battery_read_voltage_volts(); } static float boot_read_chip_temperature(void) diff --git a/src/opendisplay_battery.c b/src/opendisplay_battery.c new file mode 100644 index 0000000..bde388a --- /dev/null +++ b/src/opendisplay_battery.c @@ -0,0 +1,211 @@ +#include "opendisplay_battery.h" +#include "opendisplay_ble.h" +#include "opendisplay_sensor_bq27220.h" +#include "opendisplay_structs.h" +#include "nrf54_gpio.h" + +#include +#include +#include +#include + +/* + * SAADC battery read. + * + * The 8 SAADC channels are defined in the board devicetree (channel@0..7 -> + * NRF_SAADC_AIN0..AIN7, gain 1/4, internal reference, 12-bit, 8x oversample). + * app.overlay exposes them through /zephyr,user io-channels so we get one + * ADC_DT_SPEC per channel and can select at runtime by index. On nRF54L the + * analog inputs are on P1.00..P1.07 -> AIN0..AIN7, so only pins on port 1 with + * pin 0..7 are analog-capable; anything else is rejected with a log. + * + * Transfer-function parity with the reference: + * nRF52840 Arduino analogRead() is 10-bit with AR_DEFAULT (0.6V internal ref, + * 1/6 gain => 3.6V full scale), so raw10 = pin_volts / 3.6 * 1024. Existing + * device configs calibrated voltage_scaling_factor for + * volts = raw10 * factor / 100000. + * Here the SAADC yields pin millivolts (adc_raw_to_millivolts_dt), which we + * convert to the same 10-bit count space before applying the formula: + * raw10 = pin_mv * 1024 / 3600 + * volts = raw10 * factor / 100000 + * so unchanged configs produce the same voltage. + */ + +#define OD_BATTERY_TTL_MS 30000u +#define OD_BATTERY_SAMPLES 10 + +#if DT_NODE_EXISTS(DT_PATH(zephyr_user)) && DT_NODE_HAS_PROP(DT_PATH(zephyr_user), io_channels) +#define OD_ADC_AVAILABLE 1 +static const struct adc_dt_spec s_adc_specs[] = { + ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), 0), + ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), 1), + ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), 2), + ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), 3), + ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), 4), + ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), 5), + ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), 6), + ADC_DT_SPEC_GET_BY_IDX(DT_PATH(zephyr_user), 7), +}; +#else +#define OD_ADC_AVAILABLE 0 +#endif + +/* Map a compact nRF54 pin byte to a SAADC channel index (== AIN index). + * Returns -1 for non-analog pins. */ +static int battery_pin_to_ain(uint8_t pin_cfg) +{ + uint8_t port; + uint8_t pin; + + if (!nrf54_pin_decode(pin_cfg, &port, &pin)) { + return -1; + } + if (port != 1u || pin > 7u) { + return -1; + } + return (int)pin; +} + +#if OD_ADC_AVAILABLE +/* Read one channel and return the pin voltage in millivolts, or -1 on error. */ +static int32_t saadc_read_mv(const struct adc_dt_spec *spec) +{ + int16_t sample = 0; + struct adc_sequence seq = { + .buffer = &sample, + .buffer_size = sizeof(sample), + }; + + if (adc_sequence_init_dt(spec, &seq) != 0) { + return -1; + } + if (adc_read(spec->dev, &seq) != 0) { + return -1; + } + int32_t mv = sample; + /* Single-ended readings can dip slightly negative near 0. */ + if (mv < 0) { + mv = 0; + } + if (adc_raw_to_millivolts_dt(spec, &mv) != 0) { + return -1; + } + return mv; +} +#endif + +/* Uncached SAADC path. Returns volts, or -1.0f if unavailable. */ +static float battery_read_saadc_volts(void) +{ + const struct GlobalConfig *cfg = opendisplay_get_global_config(); + + if (cfg == NULL) { + return -1.0f; + } + uint8_t sense_pin = cfg->power_option.battery_sense_pin; + uint8_t enable_pin = cfg->power_option.battery_sense_enable_pin; + uint16_t scaling = cfg->power_option.voltage_scaling_factor; + + if (sense_pin == 0xFFu) { + return -1.0f; + } + int ain = battery_pin_to_ain(sense_pin); + if (ain < 0) { + printf("[OD] battery: sense pin 0x%02X is not SAADC-capable " + "(need P1.00-P1.07)\r\n", sense_pin); + return -1.0f; + } +#if !OD_ADC_AVAILABLE + printf("[OD] battery: ADC not available in this build\r\n"); + return -1.0f; +#else + const struct adc_dt_spec *spec = &s_adc_specs[ain]; + + if (!adc_is_ready_dt(spec)) { + printf("[OD] battery: ADC device not ready\r\n"); + return -1.0f; + } + if (adc_channel_setup_dt(spec) != 0) { + printf("[OD] battery: adc_channel_setup failed (AIN%d)\r\n", ain); + return -1.0f; + } + + /* Enable the sense divider (reference drives it HIGH; battery_sense_flags + * ENABLE_INVERTED is not honored, matching readBatteryVoltageUncached). */ + if (enable_pin != 0xFFu) { + nrf54_gpio_configure_output(enable_pin, true); + k_msleep(10); + } + + int64_t mv_sum = 0; + int good = 0; + for (int i = 0; i < OD_BATTERY_SAMPLES; i++) { + int32_t mv = saadc_read_mv(spec); + if (mv >= 0) { + mv_sum += mv; + good++; + } + k_msleep(2); + } + + if (enable_pin != 0xFFu) { + nrf54_gpio_write(enable_pin, false); + nrf54_gpio_park(enable_pin); + } + + if (good == 0) { + return -1.0f; + } + int32_t avg_mv = (int32_t)(mv_sum / good); + /* Convert to the reference's 10-bit / 3.6V full-scale count space. */ + int32_t raw10 = (avg_mv * 1024) / 3600; + if (scaling > 0u) { + return (float)((double)raw10 * (double)scaling / 100000.0); + } + return -1.0f; +#endif +} + +/* Uncached: BQ27220 fuel gauge first, then SAADC. */ +static float battery_read_uncached(void) +{ + if (opendisplay_sensor_bq27220_is_configured()) { + float gauge_v = opendisplay_sensor_bq27220_voltage_volts(); + if (gauge_v >= 0.0f) { + return gauge_v; + } + } + return battery_read_saadc_volts(); +} + +float opendisplay_battery_read_voltage_volts(void) +{ + static uint32_t last_read_ms; + static float cached = -1.0f; + static bool have_reading; + + uint32_t now = k_uptime_get_32(); + + if (have_reading && (now - last_read_ms) < OD_BATTERY_TTL_MS) { + return cached; + } + cached = battery_read_uncached(); + last_read_ms = now; + have_reading = true; + return cached; +} + +uint16_t opendisplay_battery_get_10mv(void) +{ + float volts = opendisplay_battery_read_voltage_volts(); + + if (volts < 0.0f) { + return 0u; + } + uint16_t mv = (uint16_t)(volts * 1000.0f); + uint16_t v10 = (uint16_t)(mv / 10u); + if (v10 > 511u) { + v10 = 511u; + } + return v10; +} diff --git a/src/opendisplay_battery.h b/src/opendisplay_battery.h new file mode 100644 index 0000000..4317fa2 --- /dev/null +++ b/src/opendisplay_battery.h @@ -0,0 +1,27 @@ +#ifndef OPENDISPLAY_BATTERY_H +#define OPENDISPLAY_BATTERY_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Battery voltage measurement. Source priority mirrors the nRF52840 reference + * readBatteryVoltageUncached(): a configured BQ27220 fuel gauge wins, otherwise + * the SAADC on the configured battery_sense_pin. Results are cached for 30 s. + */ + +/* Cached battery voltage in volts, or -1.0f if not configured / unavailable. */ +float opendisplay_battery_read_voltage_volts(void); + +/* Cached battery voltage in 10 mV units, clamped to 0..511 (9-bit MSD field); + * 0 when unavailable. */ +uint16_t opendisplay_battery_get_10mv(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/opendisplay_ble.c b/src/opendisplay_ble.c index 47427ae..a49f2bf 100644 --- a/src/opendisplay_ble.c +++ b/src/opendisplay_ble.c @@ -6,6 +6,9 @@ #include "opendisplay_button.h" #include "opendisplay_led.h" #include "opendisplay_pipe.h" +#include "opendisplay_battery.h" +#include "opendisplay_sensor_sht40.h" +#include "opendisplay_sensor_bq27220.h" #include "board_nrf54.h" #include @@ -128,12 +131,19 @@ static void read_chip_temperature_once(void) static void update_msd_payload(void) { - uint16_t battery_voltage_10mv = 0; + uint16_t battery_voltage_10mv; int16_t temp_encoded; uint8_t temperature_byte; uint8_t battery_voltage_low_byte; uint8_t status_byte; + /* Mirror the reference updatemsdata() ordering: refresh the sensor + * dynamic slots, then the battery source (BQ27220-preferred, else SAADC), + * before packing the frame. All three are TTL-cached (30 s). */ + opendisplay_sensor_sht40_poll(); + opendisplay_sensor_bq27220_poll(); + battery_voltage_10mv = opendisplay_battery_get_10mv(); + temp_encoded = (int16_t)((s_chip_temperature + 40.0f) * 2.0f); if (temp_encoded < 0) { temp_encoded = 0; @@ -454,6 +464,9 @@ void opendisplay_ble_init(void) read_chip_temperature_once(); + opendisplay_sensor_bq27220_init(); + opendisplay_sensor_sht40_init(); + opendisplay_led_init(); opendisplay_display_boot_apply(); diff --git a/src/opendisplay_ble.h b/src/opendisplay_ble.h index 5d4fc44..f933ee4 100644 --- a/src/opendisplay_ble.h +++ b/src/opendisplay_ble.h @@ -23,6 +23,7 @@ void opendisplay_ble_restart_advertising(void); uint16_t opendisplay_ble_get_app_version(void); void opendisplay_ble_copy_msd_bytes(uint8_t out[16]); void opendisplay_ble_update_msd(bool refresh_advertising); +void opendisplay_ble_set_dynamic_byte(uint8_t index, uint8_t value); float opendisplay_ble_get_chip_temperature(void); bool opendisplay_ble_pipe_notify(const uint8_t *data, uint16_t len); diff --git a/src/opendisplay_i2c.c b/src/opendisplay_i2c.c new file mode 100644 index 0000000..3829193 --- /dev/null +++ b/src/opendisplay_i2c.c @@ -0,0 +1,219 @@ +#include "opendisplay_i2c.h" +#include "nrf54_gpio.h" + +#include + +/* Bounded clock-stretch timeout: never spin forever on a stuck SCL. */ +#define OD_I2C_STRETCH_TIMEOUT_US 1000u + +static inline void od_delay(const struct od_i2c_bus *bus) +{ + k_busy_wait(bus->half_period_us); +} + +static inline void scl_low(const struct od_i2c_bus *bus) +{ + nrf54_gpio_configure_output(bus->scl_cfg, false); +} + +static inline void sda_low(const struct od_i2c_bus *bus) +{ + nrf54_gpio_configure_output(bus->sda_cfg, false); +} + +static inline void sda_release(const struct od_i2c_bus *bus) +{ + nrf54_gpio_configure_input(bus->sda_cfg, bus->sda_pullup, false); +} + +static inline int sda_read(const struct od_i2c_bus *bus) +{ + return nrf54_gpio_read(bus->sda_cfg); +} + +/* Release SCL and wait (bounded) for it to actually go high (clock stretch). */ +static bool scl_release(const struct od_i2c_bus *bus) +{ + uint32_t waited = 0; + + nrf54_gpio_configure_input(bus->scl_cfg, bus->scl_pullup, false); + while (nrf54_gpio_read(bus->scl_cfg) == 0) { + if (waited >= OD_I2C_STRETCH_TIMEOUT_US) { + return false; + } + k_busy_wait(1); + waited++; + } + return true; +} + +static void od_i2c_start(const struct od_i2c_bus *bus) +{ + /* Both lines high, then SDA falls while SCL high. */ + sda_release(bus); + (void)scl_release(bus); + od_delay(bus); + sda_low(bus); + od_delay(bus); + scl_low(bus); +} + +static void od_i2c_stop(const struct od_i2c_bus *bus) +{ + scl_low(bus); + sda_low(bus); + od_delay(bus); + (void)scl_release(bus); + od_delay(bus); + sda_release(bus); + od_delay(bus); +} + +/* Clock out one bit (SDA already set). Returns false on SCL stretch timeout. */ +static bool od_i2c_clock(const struct od_i2c_bus *bus) +{ + od_delay(bus); + if (!scl_release(bus)) { + return false; + } + od_delay(bus); + scl_low(bus); + return true; +} + +/* Write a byte, return true if the slave ACKed. */ +static bool od_i2c_write_byte(const struct od_i2c_bus *bus, uint8_t val) +{ + for (int i = 0; i < 8; i++) { + scl_low(bus); + if (val & 0x80u) { + sda_release(bus); + } else { + sda_low(bus); + } + val = (uint8_t)(val << 1); + if (!od_i2c_clock(bus)) { + return false; + } + } + /* ACK clock: release SDA, sample on the high edge. */ + scl_low(bus); + sda_release(bus); + od_delay(bus); + if (!scl_release(bus)) { + return false; + } + int ack = sda_read(bus); + od_delay(bus); + scl_low(bus); + return ack == 0; +} + +/* Read a byte, master drives ACK (ack=true) or NACK on the 9th clock. */ +static bool od_i2c_read_byte(const struct od_i2c_bus *bus, uint8_t *out, bool ack) +{ + uint8_t val = 0; + + sda_release(bus); + for (int i = 0; i < 8; i++) { + scl_low(bus); + od_delay(bus); + if (!scl_release(bus)) { + return false; + } + val = (uint8_t)(val << 1); + if (sda_read(bus)) { + val |= 1u; + } + od_delay(bus); + scl_low(bus); + } + /* ACK/NACK bit. */ + scl_low(bus); + if (ack) { + sda_low(bus); + } else { + sda_release(bus); + } + if (!od_i2c_clock(bus)) { + return false; + } + sda_release(bus); + *out = val; + return true; +} + +bool od_i2c_init(struct od_i2c_bus *bus, uint8_t scl_cfg, uint8_t sda_cfg, + uint32_t speed_hz, bool scl_pullup, bool sda_pullup) +{ + uint8_t port; + uint8_t pin; + + bus->ready = false; + if (!nrf54_pin_decode(scl_cfg, &port, &pin) || + !nrf54_pin_decode(sda_cfg, &port, &pin)) { + return false; + } + if (speed_hz == 0u) { + speed_hz = 100000u; + } + /* Half bit period in microseconds; clamp to >=1us (=> <=500 kHz). */ + uint32_t half = 500000u / speed_hz; + if (half == 0u) { + half = 1u; + } + bus->scl_cfg = scl_cfg; + bus->sda_cfg = sda_cfg; + bus->half_period_us = half; + bus->scl_pullup = scl_pullup; + bus->sda_pullup = sda_pullup; + /* Idle both lines high. */ + sda_release(bus); + (void)scl_release(bus); + bus->ready = true; + return true; +} + +bool od_i2c_write(struct od_i2c_bus *bus, uint8_t addr7, const uint8_t *data, + size_t len, bool stop) +{ + if (!bus->ready) { + return false; + } + od_i2c_start(bus); + if (!od_i2c_write_byte(bus, (uint8_t)((addr7 << 1) | 0u))) { + od_i2c_stop(bus); + return false; + } + for (size_t i = 0; i < len; i++) { + if (!od_i2c_write_byte(bus, data[i])) { + od_i2c_stop(bus); + return false; + } + } + if (stop) { + od_i2c_stop(bus); + } + return true; +} + +bool od_i2c_read(struct od_i2c_bus *bus, uint8_t addr7, uint8_t *data, size_t len) +{ + if (!bus->ready || len == 0u) { + return false; + } + od_i2c_start(bus); + if (!od_i2c_write_byte(bus, (uint8_t)((addr7 << 1) | 1u))) { + od_i2c_stop(bus); + return false; + } + for (size_t i = 0; i < len; i++) { + bool ack = (i + 1u) < len; /* ACK all but the last byte */ + if (!od_i2c_read_byte(bus, &data[i], ack)) { + od_i2c_stop(bus); + return false; + } + } + od_i2c_stop(bus); + return true; +} diff --git a/src/opendisplay_i2c.h b/src/opendisplay_i2c.h new file mode 100644 index 0000000..b5502ee --- /dev/null +++ b/src/opendisplay_i2c.h @@ -0,0 +1,48 @@ +#ifndef OPENDISPLAY_I2C_H +#define OPENDISPLAY_I2C_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Minimal bit-banged I2C master over the nRF54 GPIO helpers. Sensors run on a + * data_bus (0x24) whose pins come from the runtime config, so the Zephyr I2C + * peripheral (bound to fixed pads) cannot be used. Open-drain lines: a line is + * driven low by configuring the pin as an output-low and released by + * configuring it as an input with an (optional) internal pull-up so the bus + * pull-up floats it high. Every wait (clock stretching) is bounded, so a stuck + * line degrades to a failed transfer instead of hanging the main loop. + */ +struct od_i2c_bus { + uint8_t scl_cfg; /* nRF54 pin byte (port<<4)|pin, SCL */ + uint8_t sda_cfg; /* nRF54 pin byte (port<<4)|pin, SDA */ + uint32_t half_period_us; + bool scl_pullup; + bool sda_pullup; + bool ready; +}; + +/* Configure the bus from a data_bus definition. scl=pin_1, sda=pin_2. + * speed_hz==0 defaults to 100 kHz. Returns false if either pin is invalid. */ +bool od_i2c_init(struct od_i2c_bus *bus, uint8_t scl_cfg, uint8_t sda_cfg, + uint32_t speed_hz, bool scl_pullup, bool sda_pullup); + +/* Write len bytes to addr7. If stop is false a repeated-start is expected next + * (bus left with SCL low, no STOP). Returns true on full ACKed transfer. */ +bool od_i2c_write(struct od_i2c_bus *bus, uint8_t addr7, const uint8_t *data, + size_t len, bool stop); + +/* Read len bytes from addr7 (issues START + addr|R, master ACKs all but the + * last byte, then STOP). Returns true on success. */ +bool od_i2c_read(struct od_i2c_bus *bus, uint8_t addr7, uint8_t *data, size_t len); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/opendisplay_sensor_bq27220.c b/src/opendisplay_sensor_bq27220.c new file mode 100644 index 0000000..8ccd84e --- /dev/null +++ b/src/opendisplay_sensor_bq27220.c @@ -0,0 +1,183 @@ +#include "opendisplay_sensor_bq27220.h" +#include "opendisplay_sensor_common.h" +#include "opendisplay_ble.h" +#include "opendisplay_structs.h" +#include "nrf54_gpio.h" + +#include +#include + +#define BQ27220_CMD_VOLTAGE 0x08u +#define BQ27220_CMD_SOC 0x2Cu +#define BQ27220_MSD_CHARGING_BIT 0x80u +#define BQ27220_MSD_POLL_TTL_MS 30000u + +static float s_batt_v = -1.0f; +static bool s_gauge_ok; + +static bool valid_pin(uint8_t pin) +{ + return pin != 0u && pin != 0xFFu; +} + +static const struct SensorData *bq27220_config(void) +{ + const struct GlobalConfig *cfg = opendisplay_get_global_config(); + + if (cfg == NULL) { + return NULL; + } + for (uint8_t i = 0; i < cfg->sensor_count; i++) { + if (cfg->sensors[i].sensor_type == SENSOR_TYPE_BQ27220) { + return &cfg->sensors[i]; + } + } + return NULL; +} + +static uint8_t bq27220_addr_7bit(const struct SensorData *s) +{ + uint8_t a = s->i2c_addr_7bit; + + if (a == 0u || a == 0xFFu) { + return 0x55u; + } + return a; +} + +/* Register read: write command byte (repeated start, no STOP) then read len. */ +static bool bq27220_read_block(const struct SensorData *s, uint8_t cmd, + uint8_t *buf, uint8_t len) +{ + struct od_i2c_bus bus; + + if (!od_sensor_bus_for(s->bus_id, &bus)) { + return false; + } + uint8_t addr = bq27220_addr_7bit(s); + + if (!od_i2c_write(&bus, addr, &cmd, 1, false)) { + return false; + } + return od_i2c_read(&bus, addr, buf, len); +} + +static bool charger_gpio_charging(void) +{ + const struct GlobalConfig *cfg = opendisplay_get_global_config(); + + if (cfg == NULL) { + return false; + } + uint8_t st = cfg->power_option.charge_state_pin; + + if (!valid_pin(st)) { + return false; + } + bool active_low = (cfg->power_option.charger_flags & CHARGER_FLAG_STATE_ACTIVE_LOW) != 0u; + int level = nrf54_gpio_read(st); + + /* Matches reference charger_gpio_charging(). */ + return active_low ? (level == 1) : (level == 0); +} + +bool opendisplay_sensor_bq27220_is_configured(void) +{ + return bq27220_config() != NULL; +} + +float opendisplay_sensor_bq27220_voltage_volts(void) +{ + return s_gauge_ok ? s_batt_v : -1.0f; +} + +void opendisplay_sensor_bq27220_init(void) +{ + const struct GlobalConfig *cfg = opendisplay_get_global_config(); + + if (cfg == NULL) { + return; + } + uint8_t en = cfg->power_option.charge_enable_pin; + + if (valid_pin(en)) { + bool active_low = (cfg->power_option.charger_flags & + CHARGER_FLAG_ENABLE_ACTIVE_LOW) != 0u; + nrf54_gpio_configure_output(en, !active_low); + } + uint8_t st = cfg->power_option.charge_state_pin; + + if (valid_pin(st)) { + nrf54_gpio_configure_input(st, true, false); + } + + const struct SensorData *s = bq27220_config(); + + if (s == NULL) { + return; + } + uint8_t raw[2]; + + if (!bq27220_read_block(s, BQ27220_CMD_VOLTAGE, raw, 2)) { + printf("[OD] BQ27220: not found @0x%02X\r\n", bq27220_addr_7bit(s)); + return; + } + printf("[OD] BQ27220: fuel gauge @0x%02X\r\n", bq27220_addr_7bit(s)); +} + +void opendisplay_sensor_bq27220_poll(void) +{ + static uint32_t last_poll_ms; + static bool have_polled; + + const struct SensorData *s = bq27220_config(); + + if (s == NULL) { + return; + } + uint32_t now = k_uptime_get_32(); + + if (have_polled && (now - last_poll_ms) < BQ27220_MSD_POLL_TTL_MS) { + return; + } + last_poll_ms = now; + have_polled = true; + + uint8_t raw[2]; + + if (!bq27220_read_block(s, BQ27220_CMD_VOLTAGE, raw, 2)) { + s_gauge_ok = false; + s_batt_v = -1.0f; + return; + } + uint16_t mv = (uint16_t)raw[0] | ((uint16_t)raw[1] << 8); + + s_batt_v = mv / 1000.0f; + s_gauge_ok = mv > 0u; + + uint8_t soc = 0xFFu; + + if (bq27220_read_block(s, BQ27220_CMD_SOC, &soc, 1)) { + if (soc > 100u) { + soc = 100u; + } + } else { + soc = 0xFFu; + } + + bool charging = charger_gpio_charging(); + uint8_t msd_idx = s->msd_data_start_byte; + + if (msd_idx <= 10u) { + if (!s_gauge_ok || soc > 100u) { + opendisplay_ble_set_dynamic_byte(msd_idx, 0xFFu); + } else { + uint8_t packed = (uint8_t)(soc & 0x7Fu); + + if (charging) { + packed |= BQ27220_MSD_CHARGING_BIT; + } + opendisplay_ble_set_dynamic_byte(msd_idx, packed); + } + } +} diff --git a/src/opendisplay_sensor_bq27220.h b/src/opendisplay_sensor_bq27220.h new file mode 100644 index 0000000..0bd0392 --- /dev/null +++ b/src/opendisplay_sensor_bq27220.h @@ -0,0 +1,27 @@ +#ifndef OPENDISPLAY_SENSOR_BQ27220_H +#define OPENDISPLAY_SENSOR_BQ27220_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Configure charger GPIO and probe a configured BQ27220 fuel gauge. */ +void opendisplay_sensor_bq27220_init(void); + +/* Poll the gauge (voltage + SOC), cache the voltage for the battery source, + * and write the SOC/charging byte into the MSD dynamic region. 30 s TTL. */ +void opendisplay_sensor_bq27220_poll(void); + +/* True when a BQ27220 sensor is present in the config. */ +bool opendisplay_sensor_bq27220_is_configured(void); + +/* Last polled gauge voltage in volts, or -1.0f if unavailable. */ +float opendisplay_sensor_bq27220_voltage_volts(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/opendisplay_sensor_common.h b/src/opendisplay_sensor_common.h new file mode 100644 index 0000000..2b5a628 --- /dev/null +++ b/src/opendisplay_sensor_common.h @@ -0,0 +1,41 @@ +#ifndef OPENDISPLAY_SENSOR_COMMON_H +#define OPENDISPLAY_SENSOR_COMMON_H + +#include "opendisplay_ble.h" +#include "opendisplay_i2c.h" +#include "opendisplay_structs.h" + +/* + * Resolve a sensor's data_bus (0x24) instance into a bit-banged I2C bus. + * bus_type 0x01 = I2C, pin_1 = SCL, pin_2 = SDA (matches the reference + * initOrRestoreWireForBus). Internal pull-ups are always enabled on release so + * the bus works whether or not external pull-ups are fitted. Returns false when + * the bus is missing, not I2C, or has invalid pins. + */ +static inline bool od_sensor_bus_for(uint8_t bus_id, struct od_i2c_bus *out) +{ + const struct GlobalConfig *cfg = opendisplay_get_global_config(); + + if (cfg == NULL) { + return false; + } + if (bus_id == 0xFFu) { + bus_id = 0u; + } + if (bus_id >= cfg->data_bus_count || bus_id >= 4u) { + return false; + } + const struct DataBus *bus = &cfg->data_buses[bus_id]; + + if (bus->bus_type != 0x01u) { + return false; + } + if (bus->pin_1 == 0xFFu || bus->pin_2 == 0xFFu) { + return false; + } + uint32_t hz = bus->bus_speed_hz ? bus->bus_speed_hz : 100000u; + + return od_i2c_init(out, bus->pin_1, bus->pin_2, hz, true, true); +} + +#endif diff --git a/src/opendisplay_sensor_sht40.c b/src/opendisplay_sensor_sht40.c new file mode 100644 index 0000000..fa509f5 --- /dev/null +++ b/src/opendisplay_sensor_sht40.c @@ -0,0 +1,233 @@ +#include "opendisplay_sensor_sht40.h" +#include "opendisplay_sensor_common.h" +#include "opendisplay_ble.h" +#include "opendisplay_structs.h" + +#include +#include + +#define SHT40_CMD_MEASURE_HIGH 0xFDu +#define SHT40_CMD_SOFT_RESET 0x94u +#define SHT40_MEASURE_DELAY_MS 12u +#define SHT40_MSD_POLL_TTL_MS 30000u + +static uint8_t sht40_crc8(const uint8_t *data, uint8_t len) +{ + uint8_t crc = 0xFFu; + + for (uint8_t i = 0; i < len; i++) { + crc ^= data[i]; + for (uint8_t bit = 8; bit > 0; bit--) { + if (crc & 0x80u) { + crc = (uint8_t)((crc << 1) ^ 0x31u); + } else { + crc = (uint8_t)(crc << 1); + } + } + } + return crc; +} + +static uint8_t sht40_addr_7bit(const struct SensorData *s) +{ + uint8_t a = s->i2c_addr_7bit; + + if (a == 0u || a == 0xFFu) { + return 0x44u; + } + return a; +} + +static uint8_t sht40_msd_start(const struct SensorData *s) +{ + uint8_t st = s->msd_data_start_byte; + + if (st == 0xFFu || st == 0u) { + return 7u; + } + return st; +} + +static bool sht40_read_measurement(struct od_i2c_bus *bus, uint8_t addr7, + int16_t *temp_centi, uint16_t *rh_centi) +{ + uint8_t cmd = SHT40_CMD_MEASURE_HIGH; + + if (!od_i2c_write(bus, addr7, &cmd, 1, true)) { + return false; + } + k_msleep(SHT40_MEASURE_DELAY_MS); + + uint8_t b[6]; + + if (!od_i2c_read(bus, addr7, b, sizeof(b))) { + return false; + } + if (sht40_crc8(b, 2) != b[2] || sht40_crc8(b + 3, 2) != b[5]) { + return false; + } + uint16_t raw_t = (uint16_t)(((uint16_t)b[0] << 8) | b[1]); + uint16_t raw_rh = (uint16_t)(((uint16_t)b[3] << 8) | b[4]); + float tc = -45.0f + 175.0f * ((float)raw_t / 65535.0f); + float rh = -6.0f + 125.0f * ((float)raw_rh / 65535.0f); + + if (rh < 0.0f) { + rh = 0.0f; + } + if (rh > 100.0f) { + rh = 100.0f; + } + *temp_centi = (int16_t)(tc * 100.0f); + *rh_centi = (uint16_t)(rh * 100.0f); + return true; +} + +static bool read_sht40_sample(const struct SensorData *s, int16_t *temp_centi, + uint16_t *rh_centi) +{ + struct od_i2c_bus bus; + + if (!od_sensor_bus_for(s->bus_id, &bus)) { + return false; + } + const uint8_t candidates[] = {sht40_addr_7bit(s), 0x44u, 0x45u}; + + for (uint8_t i = 0; i < sizeof(candidates); i++) { + bool dup = false; + + for (uint8_t j = 0; j < i; j++) { + if (candidates[j] == candidates[i]) { + dup = true; + break; + } + } + if (dup) { + continue; + } + if (sht40_read_measurement(&bus, candidates[i], temp_centi, rh_centi)) { + return true; + } + } + return false; +} + +static int round_centi_to_deci(int16_t c) +{ + if (c >= 0) { + return (int)((c + 5) / 10); + } + return (int)((c - 5) / 10); +} + +/* MSD (3 bytes LE): v = rh_deci | (tu << 10); rh_deci 0..1000 (0.1% steps); + * tu = temp(0.1C) + 400. Decode matches opendisplay-msd.js decodeSht40Three(). */ +static void write_sht40_msd(uint8_t start, int16_t temp_centi, uint16_t rh_centi) +{ + int t_deci = round_centi_to_deci(temp_centi); + + if (t_deci < -400) { + t_deci = -400; + } + if (t_deci > 1250) { + t_deci = 1250; + } + uint32_t tu = (uint32_t)(t_deci + 400); + uint32_t rh_d = ((uint32_t)rh_centi + 5u) / 10u; + + if (rh_d > 1000u) { + rh_d = 1000u; + } + uint32_t v = (rh_d & 0x3FFu) | (tu << 10); + + opendisplay_ble_set_dynamic_byte(start, (uint8_t)(v & 0xFFu)); + opendisplay_ble_set_dynamic_byte((uint8_t)(start + 1u), (uint8_t)((v >> 8) & 0xFFu)); + opendisplay_ble_set_dynamic_byte((uint8_t)(start + 2u), (uint8_t)((v >> 16) & 0xFFu)); + if ((uint16_t)start + 3u < 11u) { + opendisplay_ble_set_dynamic_byte((uint8_t)(start + 3u), 0u); + } +} + +static void write_sht40_invalid(uint8_t start) +{ + opendisplay_ble_set_dynamic_byte(start, 0xFFu); + opendisplay_ble_set_dynamic_byte((uint8_t)(start + 1u), 0xFFu); + opendisplay_ble_set_dynamic_byte((uint8_t)(start + 2u), 0xFFu); + if ((uint16_t)start + 3u < 11u) { + opendisplay_ble_set_dynamic_byte((uint8_t)(start + 3u), 0u); + } +} + +void opendisplay_sensor_sht40_init(void) +{ + const struct GlobalConfig *cfg = opendisplay_get_global_config(); + + if (cfg == NULL) { + return; + } + for (uint8_t i = 0; i < cfg->sensor_count; i++) { + const struct SensorData *s = &cfg->sensors[i]; + + if (s->sensor_type != SENSOR_TYPE_SHT40) { + continue; + } + struct od_i2c_bus bus; + + if (!od_sensor_bus_for(s->bus_id, &bus)) { + continue; + } + uint8_t cmd = SHT40_CMD_SOFT_RESET; + + if (!od_i2c_write(&bus, sht40_addr_7bit(s), &cmd, 1, true)) { + (void)od_i2c_write(&bus, 0x44u, &cmd, 1, true); + (void)od_i2c_write(&bus, 0x45u, &cmd, 1, true); + } + k_msleep(2); + } +} + +void opendisplay_sensor_sht40_poll(void) +{ + static uint32_t last_poll_ms; + static bool have_polled; + static bool logged_fail; + + const struct GlobalConfig *cfg = opendisplay_get_global_config(); + + if (cfg == NULL) { + return; + } + uint32_t now = k_uptime_get_32(); + + if (have_polled && (now - last_poll_ms) < SHT40_MSD_POLL_TTL_MS) { + return; + } + last_poll_ms = now; + have_polled = true; + + for (uint8_t i = 0; i < cfg->sensor_count; i++) { + const struct SensorData *s = &cfg->sensors[i]; + + if (s->sensor_type != SENSOR_TYPE_SHT40) { + continue; + } + uint8_t start = sht40_msd_start(s); + + if (start > 8u) { + continue; + } + int16_t tc = 0; + uint16_t rhc = 0; + + if (!read_sht40_sample(s, &tc, &rhc)) { + if (!logged_fail) { + printf("[OD] SHT40: read failed (bus %u)\r\n", + (unsigned)s->bus_id); + logged_fail = true; + } + write_sht40_invalid(start); + continue; + } + logged_fail = false; + write_sht40_msd(start, tc, rhc); + } +} diff --git a/src/opendisplay_sensor_sht40.h b/src/opendisplay_sensor_sht40.h new file mode 100644 index 0000000..b825890 --- /dev/null +++ b/src/opendisplay_sensor_sht40.h @@ -0,0 +1,19 @@ +#ifndef OPENDISPLAY_SENSOR_SHT40_H +#define OPENDISPLAY_SENSOR_SHT40_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Soft-reset any configured SHT40 sensors (call once after config load). */ +void opendisplay_sensor_sht40_init(void); + +/* Poll configured SHT40 sensors and write their 3-byte block into the MSD + * dynamic-return region. Rate-limited to once per 30 s. */ +void opendisplay_sensor_sht40_poll(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/opendisplay_structs.h b/src/opendisplay_structs.h index b774a58..dcbc368 100644 --- a/src/opendisplay_structs.h +++ b/src/opendisplay_structs.h @@ -36,9 +36,17 @@ struct PowerOption { uint16_t voltage_scaling_factor; uint32_t deep_sleep_current_ua; uint16_t deep_sleep_time_seconds; - uint8_t reserved[10]; + uint8_t charge_enable_pin; /* BQ25616 CE (0 or 0xFF = unused) */ + uint8_t charge_state_pin; /* charge-state GPIO (0 or 0xFF = unused) */ + uint8_t charger_flags; /* bit0 enable active-low; bit1 state active-low when charging */ + uint8_t reserved[7]; } __attribute__((packed)); +#define CHARGER_FLAG_ENABLE_ACTIVE_LOW (1u << 0) +#define CHARGER_FLAG_STATE_ACTIVE_LOW (1u << 1) +/* battery_sense_flags (power_option) */ +#define BATTERY_SENSE_FLAG_ENABLE_INVERTED (1u << 0) + struct DisplayConfig { uint8_t instance_number; uint8_t display_technology; @@ -79,11 +87,20 @@ struct LedConfig { uint8_t reserved[15]; } __attribute__((packed)); +/* 0x23: sensor_data (repeatable, max 4 instances) */ +#define SENSOR_TYPE_TEMPERATURE 0x0001u +#define SENSOR_TYPE_HUMIDITY 0x0002u +#define SENSOR_TYPE_AXP2101 0x0003u +#define SENSOR_TYPE_SHT40 0x0004u +#define SENSOR_TYPE_BQ27220 0x0005u + struct SensorData { uint8_t instance_number; uint16_t sensor_type; uint8_t bus_id; - uint8_t reserved[26]; + uint8_t i2c_addr_7bit; /* 0 or 0xFF = per-sensor default (SHT40 0x44, BQ27220 0x55) */ + uint8_t msd_data_start_byte; /* SHT40: 3-byte block (0/0xFF=default 7); BQ27220: 1 byte (0xFF=skip) */ + uint8_t reserved[24]; } __attribute__((packed)); struct DataBus { diff --git a/zephyr/CMakeLists.txt b/zephyr/CMakeLists.txt index c3f36d8..2511b6a 100644 --- a/zephyr/CMakeLists.txt +++ b/zephyr/CMakeLists.txt @@ -26,6 +26,10 @@ target_sources(app PRIVATE ${SRC_DIR}/nrf54_zephyr_time.c ${SRC_DIR}/newlib_stubs.c ${SRC_DIR}/opendisplay_ble.c + ${SRC_DIR}/opendisplay_battery.c + ${SRC_DIR}/opendisplay_i2c.c + ${SRC_DIR}/opendisplay_sensor_sht40.c + ${SRC_DIR}/opendisplay_sensor_bq27220.c ${SRC_DIR}/opendisplay_button.c ${SRC_DIR}/opendisplay_config_parser.c ${SRC_DIR}/opendisplay_config_storage.c diff --git a/zephyr/app.overlay b/zephyr/app.overlay index 562851a..6f8b2b4 100644 --- a/zephyr/app.overlay +++ b/zephyr/app.overlay @@ -5,6 +5,16 @@ chosen { zephyr,console = &uart20; }; + + /* + * Expose all 8 SAADC channels (board DTS channel@0..7 => AIN0..AIN7) so + * the battery driver can pick one at runtime by index from the + * configured battery_sense_pin (P1.0n => AINn). + */ + zephyr,user { + io-channels = <&adc 0>, <&adc 1>, <&adc 2>, <&adc 3>, + <&adc 4>, <&adc 5>, <&adc 6>, <&adc 7>; + }; }; /* diff --git a/zephyr/prj.conf b/zephyr/prj.conf index f407b1f..55bf317 100644 --- a/zephyr/prj.conf +++ b/zephyr/prj.conf @@ -21,6 +21,11 @@ CONFIG_HWINFO=y CONFIG_SENSOR=y CONFIG_TEMP_NRF5=y +# Battery voltage measurement via SAADC (channels defined in the board DTS, +# selected at runtime by the configured battery_sense_pin). +CONFIG_ADC=y +CONFIG_ADC_NRFX_SAADC=y + CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_DEVICE_NAME="OpenDisplay"