feat(display): add R8 observer TFT dashboard, touch toggle and display.timeout

Replace the sparse Heltec V4 R8 observer home screen with a padded dark
analytics dashboard, add manual display control, and make blanking a runtime
setting.

Dashboard (DISPLAY_ACTIVITY_DASHBOARD, the four R8 TFT observer envs):

- RadioActivityWindow: 20 one-minute buckets of valid RX packets, no heap.
  The caller's 32-bit millis() is extended to a monotonic 64-bit clock, so
  nothing downstream has a rollover case; an always-on node passes 2^32 ms
  after ~49.7 days, which would otherwise re-enter warm-up and divide 20
  minutes of traffic by seconds. Rates use 19 whole minutes plus the elapsed
  part of the current one rather than a fixed 1200 s.
- ObserverDashboard: header, radio strip, headline totals, a 20-bar
  packets-per-minute graph and RF/status footers, with separate portrait and
  landscape layouts. A text row is a fixed 16 px, which is 3.2 logical units
  in portrait but 4.27 in landscape, so one shared grid would overlap.
  Text is trimmed by character budget, not measured width: getTextWidth()
  reports an over-long string at the portrait driver's fallback scale, so
  DisplayDriver::drawTextEllipsized() under-trims and the row renders at half
  height.
- Six per-row signatures computed from what is actually drawn, so only the
  rows whose pixels changed repaint. No startFrame(), no whole-screen clear.
  Link state moved out of the full-frame signature, so a DHCP renewal or WiFi
  flap repaints one footer row instead of the panel.
- Dark theme by retuning the UIColor statics at runtime, which needs no
  display-driver edit and carries boot, setup, reboot and power-off with it.

Touch and button (DISPLAY_TOUCH_TOGGLE):

- CHSC6X at I2C 0x2E, polled; TP_INT is unusable (optional R13, and GPIO 43
  is U0TXD). The point-count byte is tested against a valid count, never
  against non-zero: an idle read returns 0xFF, which reads as a finger held
  down forever and latches the tap detector after one event.
- turnOff() no longer parks PIN_TFT_RST low on this board. GPIO 21 is a
  shared LCD_RST/TP_RST net, so doing that held the touch controller in
  reset for as long as the display was off. Verified against Heltec's
  expansion-board and mainboard schematics and the V4-R8 datasheet pinout,
  which also correct the pin comment in HeltecV4R8Board.cpp.
- The USER button click now toggles the display too; it previously did
  nothing whenever the display was already on.

display.timeout:

- `set display.timeout <secs>` / `get display.timeout`, 0 = stay on, 60 s
  default, 3600 max. Read live, so a change applies without a reboot and
  restarts the countdown rather than firing on the old deadline.
- Stored in MQTTPrefs (/mqtt.json), keeping NodePrefs aligned with upstream.
  Runtime-only: LegacyV1MQTTPrefs and the four frozen binary payload sizes
  are unchanged. No JSON format-version bump - the loader skips keys no
  def() claims, so older firmware reads newer files and this firmware reads
  older ones with the default applied. Both directions are covered by tests.
- Joins the observer atomic-setter contract, so a failed save rolls the live
  value back instead of only claiming to.

New periodic work uses a wrap-safe deadline check; `millis() >= deadline`
fires every loop for a whole interval before each rollover.

Adds test_radio_activity_window, test_observer_dashboard (driving the real
renderer against a recording DisplayDriver in both orientation profiles) and
test_touch_tap_detector. 440 native cases pass.
This commit is contained in:
agessaman committed 2026-08-28 13:39:16 -07:00
1 parent fc361ca94b
commit fcd92e985f
27 files changed
+2709 -25

No files matched your search

+28 -1
View File
@@ -116,7 +116,8 @@ static bool isObserverPrefsSetCommand(const char* config) {
strncmp(config, "mqtt", 4) == 0 ||
strncmp(config, "wifi.", 5) == 0 ||
strncmp(config, "timezone", 8) == 0 ||
strncmp(config, "alert", 5) == 0;
strncmp(config, "alert", 5) == 0 ||
strncmp(config, "display.", 8) == 0;
}
// Keep observer setters atomic from the caller's perspective. The live object
@@ -265,6 +266,30 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
}
}
}
} else if (memcmp(config, "display.timeout ", 16) == 0) {
const char* val = &config[16];
bool all_digits = (*val != '\0');
for (const char* sp = val; *sp; sp++) {
if (*sp < '0' || *sp > '9') { all_digits = false; break; }
}
if (*val == '\0') {
strcpy(reply, "Error: missing display.timeout seconds");
} else if (!all_digits) {
sprintf(reply, "Error: display.timeout must be an integer 0-%d", DISPLAY_TIMEOUT_MAX_SECS);
} else {
long secs = atol(val);
if (secs > DISPLAY_TIMEOUT_MAX_SECS) {
sprintf(reply, "Error: display.timeout must be 0-%d seconds", DISPLAY_TIMEOUT_MAX_SECS);
} else {
_mqtt_prefs.display_timeout_secs = (uint16_t)secs;
if (!persistObserverPrefs(reply)) return true;
if (secs == 0) {
strcpy(reply, "OK - display stays on");
} else {
sprintf(reply, "OK - display off after %ld s", secs);
}
}
}
#ifdef WITH_MQTT_BRIDGE
} else if (strcmp(config, "mqtt.origin") == 0) {
_mqtt_prefs.mqtt_origin[0] = '\0';
@@ -860,6 +885,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
strcpy(reply, _mqtt_prefs.snmp_enabled ? "> on" : "> off");
} else if (memcmp(config, "radio.watchdog", 14) == 0) {
sprintf(reply, "> %d", (uint32_t)_mqtt_prefs.radio_watchdog_minutes);
} else if (memcmp(config, "display.timeout", 15) == 0) {
sprintf(reply, "> %d", (uint32_t)_mqtt_prefs.display_timeout_secs);
#ifdef WITH_MQTT_BRIDGE
} else if (memcmp(config, "mqtt.origin", 11) == 0) {
char effective_origin[32];
+2
View File
@@ -109,6 +109,8 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) {
// (not 0) so an in-lineage upgrade from a pre-neighbors payload is sane.
prefs->mqtt_neighbors_enabled = 0;
prefs->mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS;
prefs->display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS;
}
#endif // WITH_MQTT_BRIDGE
+22 -1
View File
@@ -366,6 +366,24 @@ class MQTTPrefsSerializer : public ConfigSerializer {
}
};
class DisplayPrefs : public ConfigSerializer {
MQTTPrefs* _prefs;
int32_t _timeout_s;
bool _seen_timeout = false;
protected:
void structure() override { defStrict("timeout_s", _timeout_s, _seen_timeout); }
public:
explicit DisplayPrefs(MQTTPrefs* prefs)
: _prefs(prefs), _timeout_s(prefs->display_timeout_secs) {}
void apply(bool* repaired) {
if (_timeout_s < 0 || _timeout_s > DISPLAY_TIMEOUT_MAX_SECS) {
_timeout_s = DISPLAY_TIMEOUT_DEFAULT_SECS;
*repaired = true;
}
_prefs->display_timeout_secs = static_cast<uint16_t>(_timeout_s);
}
};
MQTTPrefs* _prefs;
int32_t _version = MQTT_PREFS_JSON_FORMAT_VERSION;
bool _seen_version = false;
@@ -375,6 +393,7 @@ class MQTTPrefsSerializer : public ConfigSerializer {
SnmpPrefs _snmp;
RadioPrefs _radio;
AlertPrefs _alert;
DisplayPrefs _display;
protected:
void structure() override {
@@ -385,6 +404,7 @@ protected:
def("snmp", _snmp);
def("radio", _radio);
def("alert", _alert);
def("display", _display);
}
public:
@@ -392,7 +412,7 @@ public:
: _prefs(prefs), _wifi(prefs),
_time(prefs, repair_defaults ? repair_defaults : prefs),
_mqtt(prefs, repair_defaults ? repair_defaults : prefs), _snmp(prefs),
_radio(prefs), _alert(prefs) {}
_radio(prefs), _alert(prefs), _display(prefs) {}
bool hasSupportedVersion() const {
return _seen_version && _version == MQTT_PREFS_JSON_FORMAT_VERSION;
@@ -410,6 +430,7 @@ public:
_snmp.apply(repaired);
_radio.apply(repaired);
_alert.apply(repaired);
_display.apply(repaired);
return true;
}
+8
View File
@@ -117,8 +117,16 @@ struct MQTTPrefs {
// Per-slot payload-type allow masks. Bit N controls MeshCore packet type N
// for both packets and raw MQTT topics.
uint16_t mqtt_slot_packet_filter[MQTT_PREFS_SLOT_COUNT];
// Seconds of inactivity before the display blanks; 0 keeps it lit. Runtime
// only - deliberately absent from LegacyV1MQTTPrefs, so the frozen binary
// layout and its four payload sizes are unchanged.
uint16_t display_timeout_secs;
};
static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60;
static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600;
// Frozen payload written by the version-1 binary format. Keep this distinct
// from the runtime MQTTPrefs type: JSON persistence must not make the runtime
// object's padding or member order an on-flash ABI again. Legacy decoding reads
+207
View File
@@ -0,0 +1,207 @@
#pragma once
#include <stdint.h>
#include <string.h>
// Fixed-memory rolling window of RF receive activity, bucketed by minute.
//
// Pure logic: no Arduino, radio, display or role headers. Callers supply a
// millisecond counter and the per-packet values.
//
// The caller's 32-bit millis() is extended to a monotonic 64-bit clock on entry
// (see tick()), so nothing downstream has a rollover case. Unsigned-subtraction
// tricks are not enough here: they only survive a single wrap crossing, while an
// always-on observer accumulates uptime past 2^32 ms (~49.7 days), at which
// point a 32-bit tracker age would collapse back to a small value and the
// window would re-enter warm-up and divide 20 minutes of traffic by seconds.
#define RADIO_ACTIVITY_BUCKETS 20
#define RADIO_ACTIVITY_BUCKET_MS 60000UL
// Beyond this, "time since last packet" stops being reported rather than shown
// as a stale or (after a 49-day rollover) nonsensical age.
#define RADIO_ACTIVITY_MAX_AGE_MS (100UL * RADIO_ACTIVITY_BUCKET_MS)
struct RadioActivitySnapshot {
uint32_t packets;
uint32_t wire_bytes;
uint32_t airtime_ms;
int32_t snr_q4_sum;
int32_t rssi_sum;
// Span the totals actually cover: 19 whole minutes plus the elapsed part of
// the current one, so it tops out just under 20 minutes and never claims
// coverage the ring does not have.
uint32_t window_ms;
uint32_t tracking_ms; // how long the tracker has been running
uint32_t last_packet_age_ms;
bool has_last_packet; // false until the first packet, and once stale
uint16_t peak_per_min;
uint16_t buckets[RADIO_ACTIVITY_BUCKETS]; // [0] oldest .. [N-1] current minute
bool isEmpty() const { return packets == 0; }
bool isWarmingUp() const {
return tracking_ms < (uint32_t)RADIO_ACTIVITY_BUCKETS * RADIO_ACTIVITY_BUCKET_MS;
}
uint32_t warmupMinutes() const { return tracking_ms / RADIO_ACTIVITY_BUCKET_MS; }
// Derived values, in integer fixed point so host tests are exact and the
// formatting path stays off the FPU. All are zero when the window is empty.
uint32_t packetsPerMinuteX10() const {
if (window_ms == 0) return 0;
return (uint32_t)(((uint64_t)packets * RADIO_ACTIVITY_BUCKET_MS * 10) / window_ms);
}
uint32_t bytesPerSecondX10() const {
if (window_ms == 0) return 0;
return (uint32_t)(((uint64_t)wire_bytes * 1000 * 10) / window_ms);
}
uint32_t avgBytesPerPacket() const {
if (packets == 0) return 0;
return (wire_bytes + packets / 2) / packets;
}
// Receive airtime as tenths of a percent of the window.
uint32_t airtimePercentX10() const {
if (window_ms == 0) return 0;
return (uint32_t)(((uint64_t)airtime_ms * 1000) / window_ms);
}
// Average SNR in tenths of a dB (sums are quarter-dB units).
int32_t avgSnrX10() const {
if (packets == 0) return 0;
return (snr_q4_sum * 10) / ((int32_t)packets * 4);
}
int32_t avgRssi() const {
if (packets == 0) return 0;
return rssi_sum / (int32_t)packets;
}
};
class RadioActivityWindow {
public:
RadioActivityWindow() { reset(0); }
void reset(uint32_t now_ms) {
memset(_buckets, 0, sizeof(_buckets));
_head = 0;
_now_ms = 0;
_last_input_ms = now_ms;
_bucket_start_ms = 0;
_tracking_since_ms = 0;
_last_packet_ms = 0;
_ever_received = false;
}
void recordPacket(uint32_t now_ms, uint16_t wire_bytes, uint32_t airtime_ms, int8_t snr_q4,
int16_t rssi_dbm) {
advance(now_ms);
Bucket& b = _buckets[_head];
// Saturated: drop the event whole so the bucket's averages stay consistent
// with its packet count. Unreachable at any real LoRa packet rate.
if (b.packets == 0xFFFF) return;
b.packets++;
b.wire_bytes += wire_bytes;
b.airtime_ms += airtime_ms;
b.snr_q4_sum += snr_q4;
b.rssi_sum += rssi_dbm;
_last_packet_ms = _now_ms;
_ever_received = true;
}
void snapshot(uint32_t now_ms, RadioActivitySnapshot* out) {
advance(now_ms);
memset(out, 0, sizeof(*out));
for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) {
const Bucket& b = _buckets[(_head + 1 + i) % RADIO_ACTIVITY_BUCKETS];
out->buckets[i] = b.packets;
out->packets += b.packets;
out->wire_bytes += b.wire_bytes;
out->airtime_ms += b.airtime_ms;
out->snr_q4_sum += b.snr_q4_sum;
out->rssi_sum += b.rssi_sum;
if (b.packets > out->peak_per_min) out->peak_per_min = b.packets;
}
uint64_t elapsed_in_current = _now_ms - _bucket_start_ms; // < BUCKET_MS after advance()
uint64_t max_span =
(uint64_t)(RADIO_ACTIVITY_BUCKETS - 1) * RADIO_ACTIVITY_BUCKET_MS + elapsed_in_current;
uint64_t tracking = _now_ms - _tracking_since_ms;
const uint64_t full_span = (uint64_t)RADIO_ACTIVITY_BUCKETS * RADIO_ACTIVITY_BUCKET_MS;
// Clamped, so the 32-bit snapshot fields stay in range on a long-lived node.
// Past full_span the exact tracker age is not needed: the window is warm.
out->tracking_ms = (uint32_t)(tracking < full_span ? tracking : full_span);
out->window_ms = (uint32_t)(tracking < max_span ? tracking : max_span);
if (_ever_received) {
uint64_t age = _now_ms - _last_packet_ms;
if (age <= RADIO_ACTIVITY_MAX_AGE_MS) {
out->last_packet_age_ms = (uint32_t)age;
out->has_last_packet = true;
}
}
}
private:
struct Bucket {
uint32_t wire_bytes;
uint32_t airtime_ms;
int32_t snr_q4_sum;
int32_t rssi_sum;
uint16_t packets;
uint16_t _reserved;
};
Bucket _buckets[RADIO_ACTIVITY_BUCKETS];
uint64_t _now_ms; // monotonic clock, extended from the caller's
uint64_t _bucket_start_ms; // start of the current (newest) minute
uint64_t _tracking_since_ms;
uint64_t _last_packet_ms;
uint32_t _last_input_ms; // last 32-bit value the caller handed in
uint8_t _head; // ring index of the current minute
bool _ever_received;
// Accumulates the delta since the previous call, which is correct across one
// millis() wrap. Read as signed so a caller handing back a slightly older
// reading counts as no time passing, rather than as a ~49-day leap forward
// that would expire the whole ring. Successive calls must therefore be less
// than 2^31 ms (~24.8 days) apart - guaranteed while the tracker is being
// serviced, and when it is not the ring is empty anyway.
void tick(uint32_t now_ms) {
int32_t delta = (int32_t)(now_ms - _last_input_ms);
if (delta <= 0) return; // stale or repeated reading: no time has passed
_now_ms += (uint32_t)delta;
_last_input_ms = now_ms;
}
// Retires expired buckets lazily, advancing the boundary by whole BUCKET_MS
// steps so the minute phase is preserved across gaps.
void advance(uint32_t now_ms) {
tick(now_ms);
uint64_t elapsed = _now_ms - _bucket_start_ms;
if (elapsed < RADIO_ACTIVITY_BUCKET_MS) return;
uint64_t steps = elapsed / RADIO_ACTIVITY_BUCKET_MS;
_bucket_start_ms += steps * RADIO_ACTIVITY_BUCKET_MS;
if (steps >= RADIO_ACTIVITY_BUCKETS) {
memset(_buckets, 0, sizeof(_buckets));
_head = 0;
_tracking_since_ms = _bucket_start_ms;
return;
}
for (uint64_t i = 0; i < steps; i++) {
_head = (uint8_t)((_head + 1) % RADIO_ACTIVITY_BUCKETS);
memset(&_buckets[_head], 0, sizeof(Bucket));
}
}
};
static_assert(sizeof(RadioActivityWindow) <= 1024, "RadioActivityWindow must stay under 1 KiB");
+108
View File
@@ -0,0 +1,108 @@
#pragma once
#include <Arduino.h>
#include <Wire.h>
#include "TouchTapDetector.h"
// Minimal polled driver for the CHSC6X capacitive touch controller on the
// Heltec V4 R8 Expansion Kit V2 panel.
//
// Only "is a finger down" is needed to toggle the display, so no coordinates
// and no calibration are read. TP_INT is not used: on this board it is an
// optional link (R13) on GPIO 43, which is also U0TXD - see HeltecV4R8Board.cpp
// for the verified pin map.
#ifndef CHSC6X_I2C_ADDR
#define CHSC6X_I2C_ADDR 0x2E
#endif
#define CHSC6X_READ_LEN 5
#define CHSC6X_MAX_POINTS 1
class CHSC6XTouch {
public:
// Probes the bus. Returns false (and disables itself) when nothing answers,
// so a board without the touch panel simply carries on without it.
bool begin(TwoWire& wire = Wire) {
_wire = &wire;
_wire->beginTransmission((uint8_t)CHSC6X_I2C_ADDR);
_present = (_wire->endTransmission() == 0);
_detector.reset(millis());
#if defined(DISPLAY_TOUCH_DEBUG) && defined(PIN_TOUCH_INT)
pinMode(PIN_TOUCH_INT, INPUT_PULLUP);
#endif
if (_present) {
Serial.printf("Touch: CHSC6X found at 0x%02X\n", CHSC6X_I2C_ADDR);
} else {
// Report what is actually on the bus, so an unexpected controller or
// address can be identified from a normal boot log.
Serial.printf("Touch: nothing at 0x%02X; I2C bus holds:", CHSC6X_I2C_ADDR);
for (uint8_t addr = 8; addr < 0x78; addr++) {
_wire->beginTransmission(addr);
if (_wire->endTransmission() == 0) Serial.printf(" 0x%02X", addr);
}
Serial.println();
}
return _present;
}
bool isPresent() const { return _present; }
// True exactly once per new touch.
bool checkTap(uint32_t now_ms) {
if (!_present) return false;
return _detector.update(now_ms, readPressed());
}
private:
TwoWire* _wire = NULL;
bool _present = false;
TouchTapDetector _detector;
bool readPressed() {
uint8_t got = _wire->requestFrom((uint8_t)CHSC6X_I2C_ADDR, (uint8_t)CHSC6X_READ_LEN);
if (got != CHSC6X_READ_LEN) {
while (_wire->available()) _wire->read(); // drain a short read
logRaw(got, NULL);
return false;
}
uint8_t buf[CHSC6X_READ_LEN];
for (uint8_t i = 0; i < CHSC6X_READ_LEN; i++) buf[i] = (uint8_t)_wire->read();
logRaw(got, buf);
// buf[0] is the reported touch-point count (buf[2]/buf[4] are x/y). It must
// be tested against a *valid* count, not merely against zero: an idle or
// NACKed read can come back as 0xFF, which "non-zero" reads as a finger
// held down forever - the tap detector then fires once and, seeing no
// release, never fires again.
return buf[0] >= 1 && buf[0] <= CHSC6X_MAX_POINTS;
}
#ifdef DISPLAY_TOUCH_DEBUG
int16_t _logged = -1;
// Logs on change only, so a normal boot stays quiet.
void logRaw(uint8_t got, const uint8_t* buf) {
int16_t key = buf ? (int16_t)buf[0] : (int16_t)(-2 - (int16_t)got);
if (key == _logged) return;
_logged = key;
if (!buf) {
Serial.printf("Touch: short read (%u of %u bytes)\n", got, CHSC6X_READ_LEN);
return;
}
Serial.printf("Touch: raw %02X %02X %02X %02X %02X", buf[0], buf[1], buf[2], buf[3], buf[4]);
#ifdef PIN_TOUCH_INT
// Pulled up, so an unfitted R13 sits steady HIGH and a wired INT pulses LOW.
Serial.printf(" INT=%d", digitalRead(PIN_TOUCH_INT));
#endif
Serial.println();
}
#else
void logRaw(uint8_t, const uint8_t*) {}
#endif
};
+524
View File
@@ -0,0 +1,524 @@
#pragma once
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "../RadioActivityWindow.h"
#include "DisplayDriver.h"
#include "DisplayFrameSignature.h"
// Observer analytics dashboard for the Heltec V4 R8 TFT targets.
//
// Fork-owned and self-contained: the role UITasks only pick a layout, hand over
// a Context plus a snapshot, and ask for a full frame or a single changed row.
// Everything here is host-buildable against DisplayDriver, so the layout, the
// formatting and the redraw policy are all covered by test_observer_dashboard.
namespace ObserverDashboard {
// ---------------------------------------------------------------- palette ---
// Kept local to the dashboard. applyDarkPalette() retunes the shared UIColor
// slots at runtime, which is why no display driver needs editing for the theme.
constexpr ColorVal rgb565(uint8_t r, uint8_t g, uint8_t b) {
return (ColorVal)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3));
}
constexpr ColorVal BG = rgb565(10, 12, 16);
constexpr ColorVal HEADER_BG = rgb565(18, 38, 74);
constexpr ColorVal HEADER_SUB = rgb565(130, 170, 214);
constexpr ColorVal TEXT = rgb565(232, 238, 246);
constexpr ColorVal MUTED = rgb565(122, 134, 150);
constexpr ColorVal ACCENT = rgb565(64, 176, 240);
constexpr ColorVal BAR = rgb565(38, 116, 168);
constexpr ColorVal BAR_NOW = rgb565(96, 208, 255);
constexpr ColorVal GRID = rgb565(38, 46, 60);
constexpr ColorVal GOOD = rgb565(72, 208, 136);
constexpr ColorVal WARN = rgb565(248, 176, 72);
// Retunes the shared colour slots so every screen this UITask draws - boot,
// setup portal, reboot, power-off and the dashboard - is coherently dark.
inline void applyDarkPalette() {
UIColor::window_bkg = BG;
UIColor::title_bkg = HEADER_BG;
UIColor::title_txt = TEXT;
UIColor::primary_txt = TEXT;
UIColor::secondary_txt = MUTED;
UIColor::warning_txt = WARN;
UIColor::popup_bkg = HEADER_BG;
UIColor::popup_txt = TEXT;
UIColor::corp_blue = ACCENT;
}
// ----------------------------------------------------------------- layout ---
// Logical 128x64 coordinates, as the shared DisplayDriver API expects. The two
// orientations need separate row pitches: a text row is a fixed 16 physical
// pixels, which is 3.2 logical units in portrait (y scale 5) but 4.27 in
// landscape (y scale 3.75), so one shared grid would overlap in landscape.
struct Layout {
int16_t margin_x;
int16_t right_x; // right edge of the content column (exclusive)
int16_t header_h;
int16_t header_text_y;
int16_t header_sub_y; // second header row for the role label; -1 = same row
int16_t radio_y;
int16_t window_y;
int16_t headline_y;
int16_t headline_h;
int16_t rate_y;
int16_t graph_y;
int16_t graph_h; // includes the one-unit baseline at the bottom
int16_t rf_y;
int16_t status_y;
int16_t text_h; // logical height of one size-1 text row
int16_t max_chars; // size-1 characters that fit between the margins
int16_t max_chars_big; // size-2 characters that fit
};
// 240x320 panel: x scale 1.875, y scale 5, 12x16 glyphs (24x32 at size 2).
constexpr Layout portraitLayout() {
return Layout{
/*margin_x*/ 4, /*right_x*/ 124,
/*header_h*/ 9, /*header_text_y*/ 1, /*header_sub_y*/ 5,
/*radio_y*/ 11,
/*window_y*/ 16,
/*headline_y*/ 21, /*headline_h*/ 7,
/*rate_y*/ 29,
/*graph_y*/ 34, /*graph_h*/ 16,
/*rf_y*/ 52,
/*status_y*/ 57,
/*text_h*/ 4,
/*max_chars*/ 18, /*max_chars_big*/ 9};
}
// 320x240 panel: x scale 2.5, y scale 3.75, 12x16 glyphs (30x40 at size 2).
constexpr Layout landscapeLayout() {
return Layout{
/*margin_x*/ 4, /*right_x*/ 124,
/*header_h*/ 7, /*header_text_y*/ 1, /*header_sub_y*/ -1,
/*radio_y*/ 9,
/*window_y*/ 14,
/*headline_y*/ 19, /*headline_h*/ 12,
/*rate_y*/ 31,
/*graph_y*/ 36, /*graph_h*/ 12,
/*rf_y*/ 50,
/*status_y*/ 56,
/*text_h*/ 5,
/*max_chars*/ 25, /*max_chars_big*/ 10};
}
inline const Layout& activeLayout() {
#ifdef ST7789_PORTRAIT_PROFILE
static const Layout layout = portraitLayout();
#else
static const Layout layout = landscapeLayout();
#endif
return layout;
}
// ------------------------------------------------------------- formatting ---
// Every value is formatted from integers. Arguments are widened explicitly so
// the same code is correct on a 32-bit target and on a 64-bit host.
inline void formatCompactCount(char* out, size_t n, uint32_t v) {
if (v < 10000) {
snprintf(out, n, "%lu", (unsigned long)v);
} else if (v < 100000) {
snprintf(out, n, "%lu.%luk", (unsigned long)(v / 1000), (unsigned long)((v % 1000) / 100));
} else if (v < 1000000) {
snprintf(out, n, "%luk", (unsigned long)(v / 1000));
} else if (v < 100000000) {
snprintf(out, n, "%lu.%luM", (unsigned long)(v / 1000000),
(unsigned long)((v % 1000000) / 100000));
} else {
snprintf(out, n, "%luM", (unsigned long)(v / 1000000));
}
}
inline void formatCompactBytes(char* out, size_t n, uint32_t v) {
if (v < 1024) {
snprintf(out, n, "%lu B", (unsigned long)v);
return;
}
if (v < 1048576UL) {
uint32_t tenths = (uint32_t)(((uint64_t)v * 10) / 1024);
if (tenths < 1000) {
snprintf(out, n, "%lu.%lu KB", (unsigned long)(tenths / 10), (unsigned long)(tenths % 10));
} else {
snprintf(out, n, "%lu KB", (unsigned long)(tenths / 10));
}
return;
}
uint32_t tenths = (uint32_t)(((uint64_t)v * 10) / 1048576UL);
if (tenths < 1000) {
snprintf(out, n, "%lu.%lu MB", (unsigned long)(tenths / 10), (unsigned long)(tenths % 10));
} else {
snprintf(out, n, "%lu MB", (unsigned long)(tenths / 10));
}
}
// One decimal below 100, whole numbers above, so the field cannot grow wide.
inline void formatTenths(char* out, size_t n, uint32_t tenths) {
if (tenths < 1000) {
snprintf(out, n, "%lu.%lu", (unsigned long)(tenths / 10), (unsigned long)(tenths % 10));
} else {
formatCompactCount(out, n, tenths / 10);
}
}
inline void formatSignedTenths(char* out, size_t n, int32_t tenths) {
const char* sign = tenths < 0 ? "-" : "+";
uint32_t mag = (uint32_t)(tenths < 0 ? -tenths : tenths);
snprintf(out, n, "%s%lu.%lu", sign, (unsigned long)(mag / 10), (unsigned long)(mag % 10));
}
// Quantised to the 5 s activity cadence so the string is stable within a tick
// and cannot make the status row repaint more often than the panel updates.
inline uint32_t quantizeAgeSecs(uint32_t age_ms) { return (age_ms / 5000) * 5; }
inline void formatAge(char* out, size_t n, uint32_t age_ms, bool valid) {
if (!valid) {
snprintf(out, n, "--");
return;
}
uint32_t secs = quantizeAgeSecs(age_ms);
if (secs < 5) {
snprintf(out, n, "now");
} else if (secs < 60) {
snprintf(out, n, "%lus", (unsigned long)secs);
} else if (secs < 3600) {
snprintf(out, n, "%lum", (unsigned long)(secs / 60));
} else if (secs < 86400) {
snprintf(out, n, "%luh", (unsigned long)(secs / 3600));
} else {
snprintf(out, n, "%lud", (unsigned long)(secs / 86400));
}
}
// "910.525 SF7 BW62.5" / "869.618 SF8 BW250" - a trailing ".0" on the
// bandwidth would push the widest case past the content column.
inline void formatRadioStrip(char* out, size_t n, float freq, uint8_t sf, float bw) {
int32_t tenths = (int32_t)(bw * 10.0f + 0.5f);
char bw_str[12];
if (tenths % 10 == 0) {
snprintf(bw_str, sizeof(bw_str), "%ld", (long)(tenths / 10));
} else {
snprintf(bw_str, sizeof(bw_str), "%ld.%ld", (long)(tenths / 10), (long)(tenths % 10));
}
snprintf(out, n, "%.3f SF%u BW%s", (double)freq, (unsigned)sf, bw_str);
}
// Trims to a character budget rather than a measured width. The font is fixed
// width, so the budget is exact - and DisplayDriver::drawTextEllipsized() must
// not be used here: it trims against getTextWidth(), which reports an
// over-long string at the portrait driver's *fallback* scale and so stops
// trimming while the string is still too wide to draw at full size.
inline void fitToChars(char* out, size_t n, const char* src, int max_chars) {
if (max_chars < 0) max_chars = 0;
if ((size_t)max_chars > n - 1) max_chars = (int)(n - 1);
size_t len = src ? strlen(src) : 0;
if (len <= (size_t)max_chars) {
memcpy(out, src ? src : "", len);
out[len] = 0;
return;
}
if (max_chars <= 3) {
memcpy(out, src, (size_t)max_chars);
out[max_chars] = 0;
return;
}
memcpy(out, src, (size_t)max_chars - 3);
memcpy(out + max_chars - 3, "...", 4);
}
// ------------------------------------------------------------- row content --
enum Row { ROW_WINDOW = 0, ROW_HEADLINE, ROW_RATE, ROW_GRAPH, ROW_RF, ROW_STATUS, ROW_COUNT };
struct Context {
const char* node_name;
const char* role_label; // "REPEATER" / "ROOM SERVER"
float freq;
uint8_t sf;
float bw;
bool link_up;
};
struct RowText {
char left[24];
char right[24];
ColorVal left_color;
ColorVal right_color;
};
inline void composeRow(Row row, const Context& ctx, const RadioActivitySnapshot& s, RowText* out) {
out->left[0] = out->right[0] = 0;
out->left_color = TEXT;
out->right_color = MUTED;
char scratch[24];
switch (row) {
case ROW_WINDOW:
if (s.isWarmingUp()) {
snprintf(out->left, sizeof(out->left), "LIVE %lum", (unsigned long)s.warmupMinutes());
} else {
snprintf(out->left, sizeof(out->left), "LAST 20m");
}
out->left_color = MUTED;
if (s.peak_per_min > 0) {
formatCompactCount(scratch, sizeof(scratch), s.peak_per_min);
snprintf(out->right, sizeof(out->right), "max %s/m", scratch);
}
break;
case ROW_HEADLINE:
if (s.isEmpty()) {
snprintf(out->left, sizeof(out->left), "No RF yet");
out->left_color = MUTED;
} else {
formatCompactCount(scratch, sizeof(scratch), s.packets);
snprintf(out->left, sizeof(out->left), "%s pkt", scratch);
out->left_color = BAR_NOW;
}
break;
case ROW_RATE:
formatCompactBytes(out->left, sizeof(out->left), s.wire_bytes);
formatTenths(scratch, sizeof(scratch), s.packetsPerMinuteX10());
snprintf(out->right, sizeof(out->right), "%s/min", scratch);
if (s.isEmpty()) {
out->left_color = MUTED;
}
break;
case ROW_RF:
if (s.isEmpty()) {
snprintf(out->left, sizeof(out->left), "SNR --");
out->left_color = MUTED;
} else {
int32_t snr = s.avgSnrX10();
formatSignedTenths(scratch, sizeof(scratch), snr);
snprintf(out->left, sizeof(out->left), "SNR %s", scratch);
out->left_color = snr >= 0 ? GOOD : (snr >= -70 ? TEXT : WARN);
}
{
uint32_t air = s.airtimePercentX10();
formatTenths(scratch, sizeof(scratch), air);
snprintf(out->right, sizeof(out->right), "AIR %s%%", scratch);
out->right_color = air >= 100 ? WARN : MUTED;
}
break;
case ROW_STATUS:
formatAge(scratch, sizeof(scratch), s.last_packet_age_ms, s.has_last_packet);
snprintf(out->left, sizeof(out->left), "RX %s", scratch);
out->left_color = s.has_last_packet ? TEXT : MUTED;
snprintf(out->right, sizeof(out->right), ctx.link_up ? "WiFi OK" : "WiFi --");
out->right_color = ctx.link_up ? GOOD : WARN;
break;
default:
break;
}
}
inline int16_t rowY(const Layout& l, Row row) {
switch (row) {
case ROW_WINDOW: return l.window_y;
case ROW_HEADLINE: return l.headline_y;
case ROW_RATE: return l.rate_y;
case ROW_GRAPH: return l.graph_y;
case ROW_RF: return l.rf_y;
default: return l.status_y;
}
}
inline int16_t rowH(const Layout& l, Row row) {
if (row == ROW_HEADLINE) return l.headline_h;
if (row == ROW_GRAPH) return l.graph_h;
return l.text_h;
}
// --------------------------------------------------------------- the graph --
// Bar heights in logical units, oldest first. Any minute with traffic rounds up
// to at least one unit; empty minutes stay empty.
inline void barHeights(const Layout& l, const RadioActivitySnapshot& s,
uint8_t out[RADIO_ACTIVITY_BUCKETS]) {
uint16_t scale = s.peak_per_min > 0 ? s.peak_per_min : 1;
int16_t max_h = l.graph_h - 1; // the last unit is the baseline
for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) {
uint32_t v = s.buckets[i];
out[i] = v == 0 ? 0 : (uint8_t)((v * max_h + scale - 1) / scale);
}
}
inline int16_t barSlot(const Layout& l) {
return (int16_t)((l.right_x - l.margin_x) / RADIO_ACTIVITY_BUCKETS);
}
// ------------------------------------------------------------- signatures ---
// One signature per row, computed from exactly what is drawn, so a repaint
// happens only where the pixels actually differ.
inline uint32_t rowSignature(const Layout& l, Row row, const Context& ctx,
const RadioActivitySnapshot& s) {
uint32_t sig = DisplayFrameSignature::INITIAL;
if (row == ROW_GRAPH) {
uint8_t h[RADIO_ACTIVITY_BUCKETS];
barHeights(l, s, h);
char buf[4 * RADIO_ACTIVITY_BUCKETS];
size_t p = 0;
for (int i = 0; i < RADIO_ACTIVITY_BUCKETS && p + 4 < sizeof(buf); i++) {
p += (size_t)snprintf(buf + p, sizeof(buf) - p, "%u,", (unsigned)h[i]);
}
return DisplayFrameSignature::append(sig, buf);
}
RowText t;
composeRow(row, ctx, s, &t);
sig = DisplayFrameSignature::append(sig, t.left);
return DisplayFrameSignature::append(sig, t.right);
}
inline void allRowSignatures(const Layout& l, const Context& ctx, const RadioActivitySnapshot& s,
uint32_t out[ROW_COUNT]) {
for (int r = 0; r < ROW_COUNT; r++) out[r] = rowSignature(l, (Row)r, ctx, s);
}
// ------------------------------------------------------------------ render --
// Right-hand text is measured and placed first, then the left text is
// ellipsized into whatever column is left, so the two can never collide.
//
// The right edge gets a one-unit gutter: getTextWidth() converts physical
// glyph widths back to logical units and rounds, so anchoring flush at right_x
// can land a pixel past it.
inline void drawPair(DisplayDriver& d, const Layout& l, int16_t y, const RowText& t,
int max_chars) {
int right_len = (int)strlen(t.right);
if (right_len > 0) {
int16_t rw = (int16_t)d.getTextWidth(t.right);
d.setColor(t.right_color);
d.setCursor((int16_t)(l.right_x - rw - 1), y);
d.print(t.right);
}
int budget = max_chars - (right_len > 0 ? right_len + 1 : 0);
if (t.left[0] && budget > 0) {
char fitted[32];
fitToChars(fitted, sizeof(fitted), t.left, budget);
d.setColor(t.left_color);
d.setCursor(l.margin_x, y);
d.print(fitted);
}
}
inline void clearRow(DisplayDriver& d, const Layout& l, Row row) {
d.setColor(BG);
d.fillRect(l.margin_x, rowY(l, row), l.right_x - l.margin_x, rowH(l, row));
}
inline void drawGraph(DisplayDriver& d, const Layout& l, const RadioActivitySnapshot& s) {
int16_t base_y = (int16_t)(l.graph_y + l.graph_h - 1);
d.setColor(GRID);
d.fillRect(l.margin_x, base_y, l.right_x - l.margin_x, 1);
uint8_t h[RADIO_ACTIVITY_BUCKETS];
barHeights(l, s, h);
int16_t slot = barSlot(l);
for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) {
if (h[i] == 0) continue;
d.setColor(i == RADIO_ACTIVITY_BUCKETS - 1 ? BAR_NOW : BAR);
d.fillRect((int16_t)(l.margin_x + i * slot), (int16_t)(base_y - h[i]), (int16_t)(slot - 1),
h[i]);
}
}
inline void drawRow(DisplayDriver& d, const Layout& l, Row row, const Context& ctx,
const RadioActivitySnapshot& s, bool clear_first) {
if (clear_first) clearRow(d, l, row);
if (row == ROW_GRAPH) {
drawGraph(d, l, s);
return;
}
RowText t;
composeRow(row, ctx, s, &t);
d.setTextSize(row == ROW_HEADLINE ? 2 : 1);
drawPair(d, l, rowY(l, row), t, row == ROW_HEADLINE ? l.max_chars_big : l.max_chars);
if (row == ROW_HEADLINE) d.setTextSize(1);
}
inline void drawHeader(DisplayDriver& d, const Layout& l, const Context& ctx) {
d.setColor(HEADER_BG);
d.fillRect(0, 0, 128, l.header_h);
d.setTextSize(1);
RowText t{};
t.left_color = TEXT;
t.right_color = HEADER_SUB;
snprintf(t.left, sizeof(t.left), "%s", ctx.node_name ? ctx.node_name : "");
snprintf(t.right, sizeof(t.right), "%s", ctx.role_label ? ctx.role_label : "");
if (l.header_sub_y < 0) {
drawPair(d, l, l.header_text_y, t, l.max_chars); // both fit on one line
return;
}
// Narrow panel: the node name keeps the whole width and the role drops to a
// second line, rather than the name being ellipsized down to a few letters.
RowText name{};
name.left_color = t.left_color;
memcpy(name.left, t.left, sizeof(name.left));
drawPair(d, l, l.header_text_y, name, l.max_chars);
RowText role{};
role.left_color = t.right_color;
memcpy(role.left, t.right, sizeof(role.left));
drawPair(d, l, l.header_sub_y, role, l.max_chars);
}
inline void drawRadioStrip(DisplayDriver& d, const Layout& l, const Context& ctx) {
char tmp[32];
formatRadioStrip(tmp, sizeof(tmp), ctx.freq, ctx.sf, ctx.bw);
char fitted[32];
fitToChars(fitted, sizeof(fitted), tmp, l.max_chars);
d.setTextSize(1);
d.setColor(MUTED);
d.setCursor(l.margin_x, l.radio_y);
d.print(fitted);
}
// Complete repaint. The caller has already run startFrame(), which clears to
// UIColor::window_bkg - the dark background applyDarkPalette() installed.
inline void drawFull(DisplayDriver& d, const Layout& l, const Context& ctx,
const RadioActivitySnapshot& s) {
drawHeader(d, l, ctx);
drawRadioStrip(d, l, ctx);
for (int r = 0; r < ROW_COUNT; r++) drawRow(d, l, (Row)r, ctx, s, false);
}
// Repaints only the rows whose signature moved. Never touches the header, the
// radio strip, or anything outside the analytics rows, so no startFrame() and
// no whole-screen clear is involved.
inline bool drawChangedRows(DisplayDriver& d, const Layout& l, const Context& ctx,
const RadioActivitySnapshot& s, uint32_t signatures[ROW_COUNT]) {
uint32_t fresh[ROW_COUNT];
allRowSignatures(l, ctx, s, fresh);
bool drew = false;
for (int r = 0; r < ROW_COUNT; r++) {
if (fresh[r] == signatures[r]) continue;
drawRow(d, l, (Row)r, ctx, s, true);
signatures[r] = fresh[r];
drew = true;
}
return drew;
}
} // namespace ObserverDashboard
+6 -1
View File
@@ -112,13 +112,18 @@ void ST7789LCDDisplay::turnOff() {
digitalWrite(PIN_TFT_LEDA_CTL, HIGH);
#endif
}
#ifndef HELTEC_V4_R8_TFT
if (PIN_TFT_RST != -1) {
digitalWrite(PIN_TFT_RST, LOW);
}
#ifndef HELTEC_V4_R8_TFT
if (PIN_TFT_LEDA_CTL != -1) {
digitalWrite(PIN_TFT_LEDA_CTL, LOW);
}
#else
// On the V4 R8 Expansion Kit this reset line is shared with the touch
// panel's TP_RST, so parking it low would hold the touch controller in
// reset for as long as the display is off. Killing the backlight is what
// "off" means for this LCD anyway.
#endif
_isOn = false;
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <stdint.h>
// Debounced rising-edge detector for a polled touch panel.
//
// Pure logic: no Arduino, no I2C. The caller polls the panel and hands over a
// raw "finger down" reading; this decides when that counts as a new tap. All
// elapsed-time comparisons are unsigned subtractions, so millis() rollover is
// a non-event.
#ifndef TOUCH_TAP_DEBOUNCE_MS
#define TOUCH_TAP_DEBOUNCE_MS 40
#endif
// Ignores a second tap arriving this soon after an accepted one, so a bouncy
// panel or a slightly long press cannot toggle the display twice.
#ifndef TOUCH_TAP_MIN_GAP_MS
#define TOUCH_TAP_MIN_GAP_MS 400
#endif
class TouchTapDetector {
public:
TouchTapDetector() { reset(0); }
void reset(uint32_t now_ms = 0) {
_raw = false;
_stable = false;
_changed_at = now_ms;
_last_tap = now_ms;
_tapped_before = false;
}
// Returns true exactly once per accepted finger-down.
bool update(uint32_t now_ms, bool pressed) {
if (pressed != _raw) { // reading moved; restart the settling window
_raw = pressed;
_changed_at = now_ms;
return false;
}
if (now_ms - _changed_at < TOUCH_TAP_DEBOUNCE_MS) return false; // not settled
if (_raw == _stable) return false; // nothing new
_stable = _raw;
if (!_stable) return false; // this is the release, not a tap
if (_tapped_before && (now_ms - _last_tap) < TOUCH_TAP_MIN_GAP_MS) return false;
_last_tap = now_ms;
_tapped_before = true;
return true;
}
bool isTouched() const { return _stable; }
private:
uint32_t _changed_at;
uint32_t _last_tap;
bool _raw;
bool _stable;
bool _tapped_before;
};