mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 19:12:23 +00:00
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:
1 parent
fc361ca94b
commit
fcd92e985f
27 files changed
+2709
-25
No files matched your search
@@ -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];
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user