Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 0 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified docs/display_preview_stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 14 additions & 1 deletion rlcd_panel/dashboard_view.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(sb.rssi));
}

static void renderSensorStats(
St7305Framebuffer& fb,
const BatteryReading& battery,
Expand All @@ -357,7 +368,9 @@ class DashboardView {
continue;
}

snprintf(line, sizeof(line), "S%u RSSI %d DBM", static_cast<unsigned>(i + 1), sb.rssi);
char rssi[24];
formatRssi(rssi, sizeof(rssi), sb, now_ms);
snprintf(line, sizeof(line), "S%u %s", static_cast<unsigned>(i + 1), rssi);
drawLine(fb, 10, y, line);

snprintf(
Expand Down
17 changes: 5 additions & 12 deletions rlcd_panel/power_manager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -294,11 +294,7 @@ class PowerManager {
out[pos++] = r.humidity_pct;
out[pos++] = static_cast<uint8_t>(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<uint16_t>(static_cast<int16_t>(rssi));
out[pos++] = static_cast<uint8_t>(rssi_bits & 0xFF);
out[pos++] = static_cast<uint8_t>(rssi_bits >> 8);
out[pos++] = static_cast<uint8_t>(r.rssi);
putU32(out, pos, r.updated_ms);
putU32(out, pos, r.packet_count);
}
Expand Down Expand Up @@ -426,10 +422,7 @@ class PowerManager {
r.humidity_pct = data[pos++];
r.battery_pct = static_cast<int8_t>(data[pos++]);
const uint8_t fahrenheit = data[pos++];
const uint16_t rssi_bits =
static_cast<uint16_t>(data[pos]) | (static_cast<uint16_t>(data[pos + 1]) << 8);
pos += 2;
r.rssi = static_cast<int16_t>(rssi_bits);
r.rssi = static_cast<int8_t>(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 ||
Expand Down
4 changes: 2 additions & 2 deletions rlcd_panel/rlcd_panel.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion rlcd_panel/switchbot_ble.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<int8_t>(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)
Expand Down
113 changes: 108 additions & 5 deletions tests/test_firmware_logic.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint8_t>(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<uint16_t>(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<uint8_t>(crc >> (8 * k));
PersistentState clean;
assert(!PowerManager::deserialize(old, sizeof(old), clean));
}

void testDisplayModesRenderWithoutClipping() {
Shtc3Reading onboard;
onboard.valid = true;
Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<uint8_t>(crc >> (8 * i));
Expand Down Expand Up @@ -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<uint8_t>(crc >> (8 * i));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -7559,6 +7661,7 @@ int main() {
testDashboardStaleIndicator();
testButtonDebounceAndModeTransitions();
testDisplayModesRenderWithoutClipping();
testBleSignalStrength();
testSpectrumBandEdges();
testSpectrumFft();
testSpectrumSinePeaks();
Expand Down
Loading