diff --git a/FEATURES.md b/FEATURES.md index cf1e14e..3ddccac 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -384,3 +384,11 @@ This file describes finished work for `rlcd-panel`. Open work is in `ROADMAP.md` - `calibratedRegistry()` in `rlcd_panel.ino` feeds the dashboard views, the freezer and mold alerts, the hourly history and the CSV log. Each of them shows the corrected value. - The six floats add 24 bytes to the config trailer in RTC memory. The state version is 18. The old format gives a clean start. A NaN value or a value outside the range is rejected, even with a valid CRC. - Acceptance criteria: host tests cover the defaults, the range edges, malformed values, the application to the correct slot only (SwitchBot and BTHome), the humidity clamp, the effect on the freezer and mold alerts, the history sample and the RTC round trip. A slot without an offset key keeps its raw value. + +## BLE signal strength in the sensor stats view + +- **Code:** `rlcd_panel/switchbot_ble.h`, `rlcd_panel/dashboard_view.h`, `rlcd_panel/power_manager.h`, `rlcd_panel/rlcd_panel.ino`, `tests/test_firmware_logic.cpp`. `rlcd_panel/bthome_ble.h` needs no change: BTHome and SwitchBot packets use the same registry slots and the same scan callback path. `tools/preview_scenes.h` already holds sample RSSI values. +- `SwitchBotReading::rssi` is now `int8_t` (dBm). `SwitchBotBleParser::clampRssi()` limits the value of the BLE stack to -128 to 127 dBm. A valid packet updates the RSSI of its slot. A rejected packet leaves it unchanged. +- The sensor stats view shows `RSSI -NN DBM` in the first line of each sensor block. `DashboardView::formatRssi()` prints `RSSI --` for a stale slot (no packet for 30 minutes) and for an empty reading. A slot that never received a packet keeps the existing `S1 NO SIGNAL` line. +- The RSSI uses one byte in the RTC slot record instead of two. The state version is 19. The old format gives a clean start. +- Acceptance criteria: host tests cover the RSSI update on a valid packet, no update on a rejected packet, the text for -30 dBm and -100 dBm, the dashes for a stale slot, the line bounds in each block, and the RTC round trip. `docs/display_preview_stats.png` shows the line. diff --git a/ROADMAP.md b/ROADMAP.md index b666015..368b672 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,12 +18,6 @@ Do the work in priority order, unless the maintainer gives a different order. ## Priority 1: Core Tooling and Calibration (Score 34–40) -- [ ] BLE signal strength in the sensor stats view - - Rank: [Total: 34, Use: 6, Setup: 10, Harmony: 8, Safety: 10, Effort: S] - - Location: `rlcd_panel/switchbot_ble.h`, `rlcd_panel/bthome_ble.h`, `rlcd_panel/dashboard_view.h`, `rlcd_panel/power_manager.h`, `rlcd_panel/rlcd_panel.ino`, `tools/preview_scenes.h` - - Goal: Store the RSSI of the last valid advertisement for each registry slot (`int8_t`, dBm). Show `RSSI -NN DBM` in each sensor block of the stats view. Show `RSSI --` for a stale or empty slot. The user can then find a good position for the panel and for each sensor. - - Acceptance test: Host tests cover the RSSI update on a valid packet, no update on a rejected packet, the text for -30 dBm and -100 dBm, the dashes for a stale slot, the line bounds in each block, and the RTC round trip. `docs/display_preview_stats.png` shows the line. - ## Priority 2: Audio, Storage, and Configuration (Score 28–33) - [ ] Serial console command to show the active configuration diff --git a/docs/display_preview_stats.png b/docs/display_preview_stats.png index 4c1968f..a334ce9 100644 Binary files a/docs/display_preview_stats.png and b/docs/display_preview_stats.png differ diff --git a/rlcd_panel/dashboard_view.h b/rlcd_panel/dashboard_view.h index cf42680..0269b3b 100644 --- a/rlcd_panel/dashboard_view.h +++ b/rlcd_panel/dashboard_view.h @@ -332,6 +332,17 @@ class DashboardView { snprintf(out, capacity, "24H T%s H%s", temp, hum); } + // "RSSI -NN DBM" for a live slot, "RSSI --" for an empty or stale slot. + static void formatRssi(char* out, size_t capacity, const SwitchBotReading& sb, uint32_t now_ms) { + // A packet stamped shortly after now_ms counts as fresh, like the age display. + const bool future = (now_ms - sb.updated_ms) >= 0xFFFF0000u; + if (!sb.valid || (!future && sb.isStale(now_ms))) { + snprintf(out, capacity, "RSSI --"); + return; + } + snprintf(out, capacity, "RSSI %d DBM", static_cast(sb.rssi)); + } + static void renderSensorStats( St7305Framebuffer& fb, const BatteryReading& battery, @@ -357,7 +368,9 @@ class DashboardView { continue; } - snprintf(line, sizeof(line), "S%u RSSI %d DBM", static_cast(i + 1), sb.rssi); + char rssi[24]; + formatRssi(rssi, sizeof(rssi), sb, now_ms); + snprintf(line, sizeof(line), "S%u %s", static_cast(i + 1), rssi); drawLine(fb, 10, y, line); snprintf( diff --git a/rlcd_panel/power_manager.h b/rlcd_panel/power_manager.h index 322a018..697f6e5 100644 --- a/rlcd_panel/power_manager.h +++ b/rlcd_panel/power_manager.h @@ -126,13 +126,13 @@ class UsbPowerWatch { class PowerManager { public: static constexpr uint32_t STATE_MAGIC = 0x52504C43; // 'RPLC' - static constexpr uint8_t STATE_VERSION = 18; + static constexpr uint8_t STATE_VERSION = 19; static constexpr uint64_t MIN_SLEEP_US = 1000000ULL; // Sleep time while the empty battery protection is set: one hour, then the voltage is checked. static constexpr uint64_t PROTECTED_SLEEP_US = 3600ULL * 1000000ULL; // Slot layout: valid, mac[6], temperature (float bits), humidity, battery, fahrenheit, - // rssi (int16), updated_ms, packet_count. - static constexpr size_t SLOT_SIZE = 1 + 6 + 4 + 1 + 1 + 1 + 2 + 4 + 4; + // rssi (int8), updated_ms, packet_count. + static constexpr size_t SLOT_SIZE = 1 + 6 + 4 + 1 + 1 + 1 + 1 + 4 + 4; // History ring (humidity, then temperature, per slot): count, head, 24 samples (int16). Trailer: last sample time, sampled flag. static constexpr size_t HISTORY_RING_SIZE = 1 + 1 + HumidityHistory::CAPACITY * 2; // Each slot has a humidity ring and a temperature ring of the same layout. @@ -294,11 +294,7 @@ class PowerManager { out[pos++] = r.humidity_pct; out[pos++] = static_cast(r.battery_pct); out[pos++] = r.fahrenheit_mode ? 1 : 0; - int rssi = r.rssi; - rssi = rssi < -32768 ? -32768 : (rssi > 32767 ? 32767 : rssi); - const uint16_t rssi_bits = static_cast(static_cast(rssi)); - out[pos++] = static_cast(rssi_bits & 0xFF); - out[pos++] = static_cast(rssi_bits >> 8); + out[pos++] = static_cast(r.rssi); putU32(out, pos, r.updated_ms); putU32(out, pos, r.packet_count); } @@ -426,10 +422,7 @@ class PowerManager { r.humidity_pct = data[pos++]; r.battery_pct = static_cast(data[pos++]); const uint8_t fahrenheit = data[pos++]; - const uint16_t rssi_bits = - static_cast(data[pos]) | (static_cast(data[pos + 1]) << 8); - pos += 2; - r.rssi = static_cast(rssi_bits); + r.rssi = static_cast(data[pos++]); r.updated_ms = getU32(data, pos); r.packet_count = getU32(data, pos); if (!(r.temperature_c >= -100.0f && r.temperature_c <= 100.0f) || r.humidity_pct > 100 || diff --git a/rlcd_panel/rlcd_panel.ino b/rlcd_panel/rlcd_panel.ino index 525e975..a1aa0a8 100644 --- a/rlcd_panel/rlcd_panel.ino +++ b/rlcd_panel/rlcd_panel.ino @@ -104,7 +104,7 @@ class SwitchBotAdvertisedCallbacks : public BLEAdvertisedDeviceCallbacks { reading)) { return; } - reading.rssi = advertisedDevice.getRSSI(); + reading.rssi = SwitchBotBleParser::clampRssi(advertisedDevice.getRSSI()); reading.updated_ms = nowMs(); portENTER_CRITICAL(&g_registry_mux); @@ -145,7 +145,7 @@ class SwitchBotAdvertisedCallbacks : public BLEAdvertisedDeviceCallbacks { } } - reading.rssi = advertisedDevice.getRSSI(); + reading.rssi = SwitchBotBleParser::clampRssi(advertisedDevice.getRSSI()); // Keep this timestamp at or before the nowMs() read in renderDashboard() so a fresh reading // never appears to lie in the future (unsigned subtraction would flag it stale). reading.updated_ms = nowMs(); diff --git a/rlcd_panel/switchbot_ble.h b/rlcd_panel/switchbot_ble.h index 6d21b4c..3a95ff2 100644 --- a/rlcd_panel/switchbot_ble.h +++ b/rlcd_panel/switchbot_ble.h @@ -22,7 +22,7 @@ struct SwitchBotReading { uint8_t humidity_pct = 0; int8_t battery_pct = -1; // -1 if not present in the packet bool fahrenheit_mode = false; - int rssi = 0; + int8_t rssi = 0; // dBm of the last valid advertisement uint32_t updated_ms = 0; uint32_t packet_count = 0; // advertisements received from this sensor since power-on @@ -34,6 +34,11 @@ struct SwitchBotReading { class SwitchBotBleParser { public: + // Clamps a dBm value from the BLE stack to the int8_t range of the registry. + static int8_t clampRssi(int dbm) { + return static_cast(dbm < -128 ? -128 : (dbm > 127 ? 127 : dbm)); + } + // Decode Manufacturer Specific Data buffer starting with the 2-byte little-endian Company ID (0x69, 0x09) // followed by the 11+ byte WoIOSensor payload: // Payload[0..5]: Device MAC (6 bytes) diff --git a/tests/test_firmware_logic.cpp b/tests/test_firmware_logic.cpp index a9256de..6e485bd 100644 --- a/tests/test_firmware_logic.cpp +++ b/tests/test_firmware_logic.cpp @@ -961,6 +961,108 @@ void testButtonDebounceAndModeTransitions() { } } +SwitchBotReading makeReading(uint8_t id, float temp, uint8_t hum, int8_t batt); + +void testBleSignalStrength() { + // clampRssi keeps dBm in the int8_t range. + assert(SwitchBotBleParser::clampRssi(-30) == -30); + assert(SwitchBotBleParser::clampRssi(-100) == -100); + assert(SwitchBotBleParser::clampRssi(-500) == -128); + assert(SwitchBotBleParser::clampRssi(500) == 127); + + // A valid packet updates the RSSI of its slot; a rejected packet leaves it. + SwitchBotRegistry registry; + SwitchBotReading a = makeReading(1, 20.0f, 50, 80); + a.rssi = -60; + assert(registry.upsert(a)); + assert(registry.slot(0).rssi == -60); + a.rssi = -75; + assert(registry.upsert(a)); + assert(registry.slot(0).rssi == -75); + SwitchBotReading rejected = a; + rejected.valid = false; + rejected.rssi = -10; + assert(!registry.upsert(rejected)); + assert(registry.slot(0).rssi == -75); + + // Text: dBm value, dashes for a stale or empty slot. + char text[24]; + SwitchBotReading r; + r.valid = true; + r.updated_ms = 1000; + r.rssi = -30; + DashboardView::formatRssi(text, sizeof(text), r, 2000); + assert(strcmp(text, "RSSI -30 DBM") == 0); + r.rssi = -100; + DashboardView::formatRssi(text, sizeof(text), r, 2000); + assert(strcmp(text, "RSSI -100 DBM") == 0); + DashboardView::formatRssi(text, sizeof(text), r, 1000 + SENSOR_STALE_TIMEOUT_MS + 1); + assert(strcmp(text, "RSSI --") == 0); + DashboardView::formatRssi(text, sizeof(text), SwitchBotReading{}, 2000); + assert(strcmp(text, "RSSI --") == 0); + + // Rendered line: ink in its rows, none between the first and the second line of a block. + Shtc3Reading onboard; + BatteryReading battery; + SystemInfo info; + SwitchBotReading slots[MAX_SWITCHBOT_SENSORS]; + for (size_t i = 0; i < MAX_SWITCHBOT_SENSORS; ++i) { + slots[i] = makeReading(static_cast(i + 1), 20.0f, 50, 80); + slots[i].rssi = -100; + slots[i].updated_ms = 1000; + } + slots[2].updated_ms = 0; // stale at now_ms below + const uint32_t now_ms = SENSOR_STALE_TIMEOUT_MS + 5; + slots[0].updated_ms = slots[1].updated_ms = now_ms - 1000; + St7305Framebuffer fb; + DashboardView::renderMode( + fb, DisplayMode::SENSOR_STATS, onboard, battery, slots, MAX_SWITCHBOT_SENSORS, info, "88:88", now_ms); + size_t width_fresh = 0; + for (uint16_t i = 0; i < MAX_SWITCHBOT_SENSORS; ++i) { + const uint16_t y0 = static_cast(34 + i * DashboardView::SENSOR_BLOCK_PITCH); + size_t ink = 0; + uint16_t max_x = 0; + for (uint16_t y = y0; y < y0 + 14; ++y) { + for (uint16_t x = 0; x < St7305Framebuffer::WIDTH; ++x) { + if (fb.getPixel(x, y)) { + ++ink; + max_x = x > max_x ? x : max_x; + } + } + } + assert(ink > 20); + assert(max_x < St7305Framebuffer::WIDTH - 1); + if (i == 0) { + width_fresh = max_x; + } + if (i == 2) { + assert(max_x < width_fresh); // "RSSI --" is shorter than "RSSI -100 DBM" + } + for (uint16_t y = y0 + 14; y < y0 + 18; ++y) { + for (uint16_t x = 0; x < St7305Framebuffer::WIDTH; ++x) { + assert(!fb.getPixel(x, y)); + } + } + } + + // RTC round trip keeps the RSSI and gives the previous state version a clean start. + PersistentState s; + s.registry.upsert(a); + uint8_t buf[PowerManager::SERIALIZED_SIZE]; + assert(PowerManager::serialize(s, buf, sizeof(buf))); + PersistentState back; + assert(PowerManager::deserialize(buf, sizeof(buf), back)); + assert(back.registry.slot(0).rssi == -75); + assert(PowerManager::STATE_VERSION == 19); + uint8_t old[PowerManager::SERIALIZED_SIZE]; + memcpy(old, buf, sizeof(buf)); + old[4] = 18; + const uint32_t crc = PowerManager::crc32(old, sizeof(old) - 4); + for (int k = 0; k < 4; ++k) old[sizeof(old) - 4 + k] = static_cast(crc >> (8 * k)); + PersistentState clean; + assert(!PowerManager::deserialize(old, sizeof(old), clean)); +} + void testDisplayModesRenderWithoutClipping() { Shtc3Reading onboard; onboard.valid = true; @@ -1838,7 +1940,7 @@ void testRecordsPersistence() { memcpy(old, buf, sizeof(old)); old[4] = 13; reseal(old); - assert(PowerManager::STATE_VERSION == 18); + assert(PowerManager::STATE_VERSION == 19); assert(!PowerManager::deserialize(old, sizeof(old), out)); } @@ -2273,7 +2375,7 @@ void testNoiseHistoryPersistence() { } // The previous state version gives a clean start - assert(PowerManager::STATE_VERSION == 18); + assert(PowerManager::STATE_VERSION == 19); uint8_t old[PowerManager::SERIALIZED_SIZE]; memcpy(old, buf, sizeof(old)); old[4] = 8; @@ -3852,7 +3954,7 @@ void testNtpStatePersistence() { PersistentState d; assert(!d.ntp.has_attempted && NtpSchedule::due(d.ntp, 0, 24)); // The previous format (version 4) is rejected, even with a valid CRC - assert(PowerManager::STATE_VERSION == 18); + assert(PowerManager::STATE_VERSION == 19); buf[4] = 4; uint32_t crc = PowerManager::crc32(buf, sizeof(buf) - 4); for (int i = 0; i < 4; ++i) buf[sizeof(buf) - 4 + i] = static_cast(crc >> (8 * i)); @@ -4633,7 +4735,7 @@ void testShtc3HumidityOffset() { assert(PowerManager::serialize(s, buf, sizeof(buf))); PersistentState r; assert(PowerManager::deserialize(buf, sizeof(buf), r) && approxEqual(r.config.rh_offset_pct, -4.25f)); - assert(PowerManager::STATE_VERSION == 18); + assert(PowerManager::STATE_VERSION == 19); buf[4] = 16; const uint32_t crc = PowerManager::crc32(buf, sizeof(buf) - 4); for (int i = 0; i < 4; ++i) buf[sizeof(buf) - 4 + i] = static_cast(crc >> (8 * i)); @@ -4803,7 +4905,7 @@ void testSlotCalibrationOffsets() { assert(r.config.slot_temp_offset_c[i] == s.config.slot_temp_offset_c[i]); assert(r.config.slot_rh_offset_pct[i] == s.config.slot_rh_offset_pct[i]); } - assert(PowerManager::STATE_VERSION == 18); + assert(PowerManager::STATE_VERSION == 19); uint8_t old[PowerManager::SERIALIZED_SIZE]; memcpy(old, buf, sizeof(old)); old[4] = 17; @@ -7559,6 +7661,7 @@ int main() { testDashboardStaleIndicator(); testButtonDebounceAndModeTransitions(); testDisplayModesRenderWithoutClipping(); + testBleSignalStrength(); testSpectrumBandEdges(); testSpectrumFft(); testSpectrumSinePeaks();