Add configurable battery and USB display behavior

Persist separate display modes and timeouts in seconds for battery and USB power, expose four controls in WebConfig and CLI, and apply one shared policy across display-equipped roles.

Detect R8 external power from a USB host or calibrated battery voltage above 4.21 V, with cached sampling and hysteresis. Add policy, persistence, browser, and R8 power-detection regression coverage.
This commit is contained in:
mikecarper committed 2026-09-10 21:03:15 -07:00
1 parent 3d4432e848
commit 96ef0ae905
39 files changed
+3187 -2488

No files matched your search

+2
View File
@@ -108,6 +108,8 @@ jobs:
- name: Verify Companion Bluetooth settings and WebConfig
run: |
python3 -B test/test_display_power.py
python3 -B test/test_heltec_r8_external_power.py
python3 -B test/test_bluetooth_mac_contract.py
python3 -B test/test_companion_settings_persistence_contract.py
python3 -B test/test_webconfig_ui.py
+2 -1
View File
@@ -268,7 +268,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Operational | [`advert`](cli_commands.md#send-a-flood-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes |
| Operational | [`advert.zerohop`](cli_commands.md#send-a-zero-hop-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes |
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local connection | Local | Local | Local | Local | Local |
| Display | [`get/set display.timeout`; `get/set display.flip`](cli_commands.md#set-mqtt-observer-display-timeout-and-flip) | MQTT observer with display; flip needs a supported driver | No | No | No | Feature | No |
| Display | [`get/set display.mode`; `display.timeout`; `display.usb.mode`; `display.usb.timeout`](cli_commands.md#set-display-modes-and-timeouts) | Display-equipped roles; pairing modes require BLE Companion | Feature | Feature | Feature | Feature | Feature |
| Display | [`get/set display.flip`](cli_commands.md#set-mqtt-observer-display-flip) | MQTT observer with supported display driver | No | No | No | Feature | No |
| Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Diagnostics | [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Detected flash size and live partition table | Yes | Yes | Yes | Yes | Yes |
+52 -11
View File
@@ -779,20 +779,61 @@ build flag is needed.
---
## Set MQTT observer display timeout and flip
## Set display modes and timeouts
MQTT observer builds with a display support a persisted inactivity timeout:
Display-equipped Companions, repeaters, room servers, and sensors have four
saved settings. The WebConfig **Display** card exposes the same controls:
| Setting | CLI | Default |
| --- | --- | --- |
| Battery mode | `get/set display.mode` | `button-pairing` on BLE Companions; `button` elsewhere |
| Battery timeout, seconds | `get/set display.timeout` | `15` |
| USB mode | `get/set display.usb.mode` | Same as battery mode |
| USB timeout, seconds | `get/set display.usb.timeout` | `15` |
Modes are `off`, `on`, `button`, `pairing`, `button-pairing`, and `automatic`.
Pairing modes are available only on builds supporting BLE pairing.
- `off`: stays dark, including boot and pairing.
- `on`: stays on while the device is awake.
- `button`: a physical button, keyboard/navigation input, or supported touch
wakes the display and restarts the timeout.
- `pairing`: wakes only for an actual BLE PIN request, until pairing ends or
expires. The inactivity timeout does not shorten the pairing prompt.
- `button-pairing`: combines the button and pairing behaviors.
- `automatic`: shows startup briefly (at most four seconds), and wakes for
messages while the app is disconnected. An app connection blanks the screen;
buttons and pairing requests can still wake it. Disconnecting alone does not
wake it. Network traffic and screen redraws do not extend the timeout.
Timeouts are whole seconds from `1` to `3600`. Use mode `on` for a permanent
display rather than timeout `0`. The timeout is retained but unused in `off`,
`on`, and `pairing` modes. Settings apply immediately, including while blanked;
changing power source applies the other profile without carrying over a wake
from the previous profile. Settings survive reboot and filesystem-preserving
updates in `/display_prefs`; erasing the filesystem restores the defaults.
An existing observer timeout that differs from its old 60-second default is
migrated once to both power profiles; an old zero timeout becomes mode `on`.
```text
get display.timeout
set display.timeout 0
set display.timeout 60
set display.mode button-pairing
set display.timeout 15
set display.usb.mode on
set display.usb.timeout 60
```
The value is seconds. `0` keeps the display on; `1` through `3600` blanks it
after that much inactivity. The default is `60`. A change applies immediately
and restarts the countdown. On the Heltec V4 R8 Expansion Kit V2 observer, a
panel tap or USER-button click can also blank or wake the display.
USB profile selection uses the board's external-power detector, with USB-host
detection as a fallback. VBUS-capable boards detect chargers without a data
connection. Heltec V4 R8 uses either a detected USB host or a calibrated battery
voltage above 4.21 V. The voltage estimate is checked every five seconds and
clears at 4.20 V or below; host detection takes effect immediately. This estimate
can miss a charger while the battery is low, depends on ADC calibration, and
cannot distinguish USB from solar charging. Other boards without a power
detector may require a USB host to recognize USB power. Wi-Fi/MQTT connection or
BLE pairing is not evidence of USB power. E-paper panels can retain their last
image when powered off.
## Set MQTT observer display flip
Supported observer displays, including the R8 OLED and ST7789 panels, can also
be turned 180 degrees relative to their compiled orientation:
@@ -809,8 +850,8 @@ relative 180-degree mounting choice and cannot switch between portrait and
landscape. The value is harmless on an observer display driver that does not
support flipping.
Both settings survive reboot and firmware updates that preserve the filesystem;
erasing flash restores the `60`/`off` defaults. The boot log reports the saved
Flip survives reboot and firmware updates that preserve the filesystem;
erasing flash restores the `off` default. The boot log reports the saved
flip state on display-enabled observer builds.
---
+2 -22
View File
@@ -13,9 +13,6 @@
#include "NodePrefs.h"
#ifndef UI_USB_AUTO_OFF_MULTIPLIER
#define UI_USB_AUTO_OFF_MULTIPLIER 5UL
#endif
enum class UIEventType {
none,
@@ -50,26 +47,9 @@ protected:
|| _shutdownGuard->prepareForUiShutdown();
}
bool isDisplayAutoOffDue(unsigned long configured_deadline,
unsigned long configured_timeout_millis) const {
unsigned long deadline = configured_deadline;
#if UI_USB_AUTO_OFF_MULTIPLIER > 1
if (_board->isUsbHostConnected()) {
// The configured deadline already includes the first timeout period.
// Add the remaining periods while attached to a computer.
deadline += configured_timeout_millis
* (UI_USB_AUTO_OFF_MULTIPLIER - 1UL);
}
#endif
return static_cast<int32_t>(millis() - deadline) > 0;
}
bool shouldWakeDisplayForMessage() const {
// Keep the existing BLE-only behavior, where the connected companion is
// expected to surface the notification. A computer attached over USB is
// the exception: show the message on the device even if BLE is connected.
return !hasConnection() || _board->isUsbHostConnected();
}
bool shouldWakeDisplayForMessage() const { return !hasConnection(); }
public:
void setShutdownGuard(UIShutdownGuard* guard) { _shutdownGuard = guard; }
+11
View File
@@ -1,3 +1,4 @@
#include <helpers/ui/DisplayPowerSettings.h>
#include "MyMesh.h"
#include <Arduino.h> // needed for PlatformIO
@@ -1723,6 +1724,14 @@ void MyMesh::begin(bool has_display, bool radio_available) {
}
#endif
mesh::ui::loadDisplayPowerSettings(_store->getPrimaryFS(),
#ifdef BLE_PIN_CODE
true
#else
false
#endif
);
// load persisted prefs
const bool prefs_ready =
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
@@ -2546,6 +2555,8 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
return true;
}
if (mesh::ui::handleDisplayPowerCommand(command, reply, reply_size)) return true;
if (strcmp(command, "get display.touch") == 0) {
if (_ui == NULL || !_ui->supportsTouchDebug()) {
snprintf(reply, reply_size, "Error: touchscreen diagnostics unsupported");
+3 -7
View File
@@ -1591,7 +1591,7 @@ void halt() {
|| static_cast<int32_t>(millis() - companion_setup_display_refresh) < 0) return;
companion_setup_display_refresh = millis() + 1000;
companion_setup_display->turnOn();
if (!companion_setup_display->isOn()) return;
companion_setup_display->startFrame();
companion_setup_display->setCompactText(false);
companion_setup_display->setTextSize(1);
@@ -2294,12 +2294,7 @@ void setup() {
&& UI_WIFI_SETUP_HOME_PAGE != 1
companion_setup_display = disp;
#endif
disp->startFrame();
#ifdef ST7789
disp->setTextSize(2);
#endif
disp->drawTextCentered(disp->width() / 2, 28, "Loading...");
disp->endFrame();
disp->turnOff(); // No boot content before the saved policy is loaded.
}
#if defined(ESP32) && defined(WIFI_SSID)
companion_display_available = disp != NULL;
@@ -2737,6 +2732,7 @@ void loop() {
#if defined(TBEAM_1W) && defined(PIN_WIFI_BTN)
ui_task.serviceWiFiToggleButton();
#endif
ui_task.loop(); // Continue button/touch handling while the portal is active.
renderCompanionSetupDisplay();
} else {
ui_task.loop();
+27 -46
View File
@@ -427,7 +427,6 @@ class HomeScreen : public UIScreen {
uint64_t _uptime_millis;
AdvertPath recent[UI_RECENT_LIST_SIZE];
int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
// Convert millivolts to percentage
#ifndef BATT_MIN_MILLIVOLTS
@@ -1662,7 +1661,6 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
if (_display != NULL) {
_display->setRotationDegrees(node_prefs->display_rotation_degrees);
}
_auto_off = millis() + AUTO_OFF_MILLIS;
#if defined(PIN_USER_BTN)
user_btn.begin();
@@ -1682,7 +1680,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
_node_prefs = node_prefs;
if (_display != NULL) {
_display->turnOn();
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection());
}
#ifdef PIN_BUZZER
@@ -1726,18 +1724,16 @@ void UITask::showMessages() {
#if COMPANION_FEATURE_JOHN
void UITask::showJohnReader() {
if (!_display || isPairingScreenActive()) return;
if (!_display->isOn()) _display->turnOn();
if (!_display->isOn()) _display->wake(mesh::ui::DisplayWake::Button);
if (!john_reader) john_reader = new (std::nothrow) JohnReaderScreen(this, _display);
if (!john_reader) { showAlert("Reader: no memory", 1500); return; }
static_cast<JohnReaderScreen*>(john_reader)->open();
setCurrScreen(john_reader);
_auto_off = millis() + AUTO_OFF_MILLIS;
}
void UITask::closeJohnReader() {
// Home retains the selected radio page while the reader is open.
setCurrScreen(home);
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#endif
@@ -1778,7 +1774,6 @@ switch(t){
#endif
}
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
@@ -1826,13 +1821,10 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
: 0;
if (_display != NULL) {
if (!_display->isOn() && shouldWakeDisplayForMessage()) {
_display->turnOn();
}
if (_display->isOn()) {
_auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer
_next_refresh = 100; // trigger refresh
if (shouldWakeDisplayForMessage()) {
_display->wake(mesh::ui::DisplayWake::Message);
}
if (_display->isOn()) _next_refresh = 0;
}
}
@@ -1900,10 +1892,8 @@ void UITask::showPairingPin() {
}
static_cast<HomeScreen*>(home)->showFirstPage();
setCurrScreen(home);
if (_display != NULL) {
if (!_display->isOn()) _display->turnOn();
_auto_off = now + AUTO_OFF_MILLIS;
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true);
_next_refresh = 0;
}
}
@@ -1914,14 +1904,12 @@ void UITask::finishPairingScreen(bool timed_out) {
if (_deferred_msg_preview && _msgcount > 0) {
_deferred_msg_preview = false;
setCurrScreen(msg_preview);
_auto_off = millis() + AUTO_OFF_MILLIS;
} else {
_deferred_msg_preview = false;
gotoHomeScreen();
if (timed_out && _display != NULL) {
_display->turnOff();
_display->dismiss();
} else {
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 0;
}
}
@@ -1939,6 +1927,15 @@ void UITask::servicePairingState() {
finishPairingScreen(timed_out);
}
}
if (_display != NULL && _display->servicePower(
_board->isExternalPowered() || _board->isUsbHostConnected(),
hasConnection(), isPairingScreenActive())) {
_next_refresh = 0;
#if COMPANION_FEATURE_JOHN
if (!_display->isOn() && isJohnReaderActive())
static_cast<JohnReaderScreen*>(john_reader)->flush();
#endif
}
}
/*
@@ -2032,7 +2029,7 @@ void UITask::loop() {
c = handleLongPress(KEY_ENTER);
else
#endif
display.turnOff();
display.dismiss();
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
c = handleDoubleClick(KEY_SELECT);
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK || ev == BUTTON_EVENT_QUADRUPLE_CLICK) {
@@ -2149,12 +2146,11 @@ void UITask::loop() {
#endif
#if defined(BACKLIGHT_BTN)
if (millis() > next_backlight_btn_check) {
bool touch_state = digitalRead(PIN_BUTTON2);
#if defined(DISP_BACKLIGHT)
digitalWrite(DISP_BACKLIGHT, !touch_state);
#elif defined(EXP_PIN_BACKLIGHT)
expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state);
#endif
static bool was_pressed = false;
const bool pressed = digitalRead(PIN_BUTTON2) == LOW;
if (pressed && !was_pressed && _display != NULL)
_display->wake(mesh::ui::DisplayWake::Button);
was_pressed = pressed;
next_backlight_btn_check = millis() + 300;
}
#endif
@@ -2167,8 +2163,8 @@ void UITask::loop() {
}
if (c != 0 && curr) {
_display->wake(mesh::ui::DisplayWake::Button);
curr->handleInput(c);
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_next_refresh = 100; // trigger refresh
}
@@ -2225,24 +2221,9 @@ void UITask::loop() {
_display->endFrame();
}
#if AUTO_OFF_MILLIS > 0
#ifdef KEEP_DISPLAY_ON_USB
// Opt-in: refresh the auto-off deadline while externally powered, so the
// timer counts from the moment external power is removed. Off by default
// because OLED panels burn in quickly; only enable for LCD targets or
// where the display is replaceable.
if (board.isExternalPowered()) {
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#endif
#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN)
if (user_btn.isWakeHoldActive()) _auto_off = millis() + AUTO_OFF_MILLIS;
if (user_btn.isWakeHoldActive()) _display->wake(mesh::ui::DisplayWake::Button);
#endif
if (!isPairingScreenActive() && isDisplayAutoOffDue(_auto_off, AUTO_OFF_MILLIS)) {
#if COMPANION_FEATURE_JOHN
if (isJohnReaderActive()) static_cast<JohnReaderScreen*>(john_reader)->flush();
#endif
_display->turnOff();
}
#endif
}
@@ -2305,11 +2286,11 @@ void UITask::getTouchControls(mesh::ui::TouchSplitSelector& transport_touch_sele
char UITask::checkDisplayOn(char c) {
if (_display != NULL) {
if (!_display->isOn()) {
_display->turnOn(); // turn display on and consume event
const bool was_on = _display->isOn();
_display->wake(mesh::ui::DisplayWake::Button);
if (!was_on) {
c = 0;
}
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_next_refresh = 0; // trigger refresh
}
return c;
+1 -1
View File
@@ -35,7 +35,7 @@ class UITask : public AbstractUITask {
#ifdef PIN_VIBRATION
GenericVibration vibration;
#endif
unsigned long _next_refresh, _auto_off;
unsigned long _next_refresh;
unsigned long _msg_preview_until;
unsigned long _pairing_screen_until;
bool _deferred_msg_preview;
+14 -28
View File
@@ -53,11 +53,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
if (_display != NULL) {
_display->setRotationDegrees(node_prefs->display_rotation_degrees);
}
_auto_off = millis() + AUTO_OFF_MILLIS;
clearMsgPreview();
_node_prefs = node_prefs;
if (_display != NULL) {
_display->turnOn();
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection());
}
// strip off dash and commit hash by changing dash to null terminator
@@ -182,13 +182,10 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
StrHelper::strncpy(_msg, text, sizeof(_msg));
if (_display != NULL) {
if (!_display->isOn() && shouldWakeDisplayForMessage()) {
_display->turnOn();
}
if (_display->isOn()) {
_auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer
_need_refresh = true;
if (shouldWakeDisplayForMessage()) {
_display->wake(mesh::ui::DisplayWake::Message);
}
if (_display->isOn()) _need_refresh = true;
}
}
@@ -485,7 +482,7 @@ void UITask::showPairingPin() {
_need_refresh = true;
_next_refresh = 0;
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true);
}
}
@@ -496,9 +493,7 @@ void UITask::finishPairingScreen(bool timed_out) {
if (_display == NULL) return;
if (timed_out) {
_display->turnOff();
} else {
_auto_off = millis() + AUTO_OFF_MILLIS;
_display->dismiss();
}
}
@@ -514,6 +509,12 @@ void UITask::servicePairingState() {
finishPairingScreen(timed_out);
}
}
if (_display != NULL && _display->servicePower(
_board->isExternalPowered() || _board->isUsbHostConnected(),
hasConnection(), isPairingScreenActive())) {
_next_refresh = 0;
_need_refresh = true;
}
}
void UITask::loop() {
@@ -548,18 +549,6 @@ void UITask::loop() {
_next_refresh = millis() + 1000; // refresh every second
}
#ifdef KEEP_DISPLAY_ON_USB
// Opt-in: refresh the auto-off deadline while externally powered, so the
// timer counts from the moment external power is removed. Off by default
// because OLED panels burn in quickly; only enable for LCD targets or
// where the display is replaceable.
if (board.isExternalPowered()) {
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#endif
if (!isPairingScreenActive() && isDisplayAutoOffDue(_auto_off, AUTO_OFF_MILLIS)) {
_display->turnOff();
}
}
}
@@ -569,10 +558,7 @@ void UITask::handleButtonAnyPress() {
// do not refresh the display here, as it may block the button handler
if (_display != NULL) {
_displayWasOn = _display->isOn(); // Track display state before any action
if (!_displayWasOn) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_display->wake(mesh::ui::DisplayWake::Button);
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ class UITask : public AbstractUITask {
#ifdef HAS_DRV2605
DRV2605Vibration vibration;
#endif
unsigned long _next_refresh, _auto_off;
unsigned long _next_refresh;
CompanionNodePrefs* _node_prefs;
char _version_info[32];
char _origin[62];
+21 -38
View File
@@ -171,7 +171,6 @@ public:
// display.setCursor(0, 0);
// display.print(filtered_name);
display.setColor(UIColor::primary_txt);
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getMsgCount());
@@ -441,7 +440,6 @@ public:
}
};
#if UI_SMALL_MESSAGE_FONT == 1
class TinyMessageScreen : public UIScreen {
UITask* _task;
@@ -480,7 +478,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
if (_display != NULL) {
_display->setRotationDegrees(node_prefs->display_rotation_degrees);
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_cached_batt_mv = getBattMilliVolts();
#if defined(PIN_USER_BTN)
@@ -493,11 +491,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
_node_prefs = node_prefs;
if (_display != NULL) {
_display->turnOn();
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection());
}
_statusBar.begin(_display->width());
#ifdef PIN_BUZZER
buzzer.begin();
buzzer.quiet(_node_prefs->buzzer_quiet);
@@ -553,7 +550,6 @@ switch(t){
#endif
}
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
@@ -580,13 +576,10 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
#endif
if (_display != NULL) {
if (!_display->isOn() && shouldWakeDisplayForMessage()) {
_display->turnOn();
}
if (_display->isOn()) {
_auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer
_next_refresh = 100; // trigger refresh
if (shouldWakeDisplayForMessage()) {
_display->wake(mesh::ui::DisplayWake::Message);
}
if (_display->isOn()) _next_refresh = 0;
}
}
@@ -630,9 +623,8 @@ void UITask::showPairingPin() {
_pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS;
static_cast<HomeScreen*>(home)->showFirstPage();
setCurrScreen(home);
if (_display != NULL) {
if (!_display->isOn()) _display->turnOn();
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), true);
_next_refresh = 0;
}
}
@@ -649,9 +641,7 @@ void UITask::finishPairingScreen(bool timed_out) {
if (_display == NULL) return;
if (timed_out) {
_display->turnOff();
} else {
_auto_off = millis() + AUTO_OFF_MILLIS;
_display->dismiss();
}
}
@@ -667,6 +657,9 @@ void UITask::servicePairingState() {
finishPairingScreen(timed_out);
}
}
if (_display != NULL && _display->servicePower(
_board->isExternalPowered() || _board->isUsbHostConnected(),
hasConnection(), isPairingScreenActive())) _next_refresh = 0;
}
/*
@@ -760,12 +753,11 @@ void UITask::loop() {
#endif
#if defined(BACKLIGHT_BTN)
if (millis() > next_backlight_btn_check) {
bool touch_state = digitalRead(PIN_BUTTON2);
#if defined(DISP_BACKLIGHT)
digitalWrite(DISP_BACKLIGHT, !touch_state);
#elif defined(EXP_PIN_BACKLIGHT)
expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state);
#endif
static bool was_pressed = false;
const bool pressed = digitalRead(PIN_BUTTON2) == LOW;
if (pressed && !was_pressed && _display != NULL)
_display->wake(mesh::ui::DisplayWake::Button);
was_pressed = pressed;
next_backlight_btn_check = millis() + 300;
}
#endif
@@ -786,8 +778,8 @@ void UITask::loop() {
}
if (c != 0 && curr) {
_display->wake(mesh::ui::DisplayWake::Button);
curr->handleInput(c);
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_next_refresh = 100; // trigger refresh
}
@@ -836,18 +828,9 @@ void UITask::loop() {
_display->endFrame();
}
#if AUTO_OFF_MILLIS > 0
#ifdef KEEP_DISPLAY_ON_USB
// Opt-in: refresh the auto-off deadline while externally powered, so the
// timer counts from the moment external power is removed. Off by default
// because OLED panels burn in quickly; only enable for LCD targets or
// where the display is replaceable.
if (board.isExternalPowered()) {
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN)
if (user_btn.isWakeHoldActive()) _display->wake(mesh::ui::DisplayWake::Button);
#endif
if (!isPairingScreenActive() && isDisplayAutoOffDue(_auto_off, AUTO_OFF_MILLIS)) {
_display->turnOff();
}
#endif
}
@@ -884,11 +867,11 @@ void UITask::loop() {
char UITask::checkDisplayOn(char c) {
if (_display != NULL) {
if (!_display->isOn()) {
_display->turnOn(); // turn display on and consume event
const bool was_on = _display->isOn();
_display->wake(mesh::ui::DisplayWake::Button);
if (!was_on) {
c = 0;
}
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_next_refresh = 0; // trigger refresh
}
return c;
+1 -1
View File
@@ -35,7 +35,7 @@ class UITask : public AbstractUITask {
#ifdef PIN_VIBRATION
GenericVibration vibration;
#endif
unsigned long _next_refresh, _auto_off;
unsigned long _next_refresh;
CompanionNodePrefs* _node_prefs;
char _alert[80];
unsigned long _alert_expiry;
+15 -77
View File
@@ -19,9 +19,6 @@
#include <helpers/esp32/WebConfigServer.h> // defines WITH_WEBCONFIG on ESP32
#endif
#ifndef AUTO_OFF_MILLIS
#define AUTO_OFF_MILLIS 20000 // 20 seconds; 0 keeps the screen on
#endif
#ifdef DISPLAY_TOUCH_TOGGLE
#define TOUCH_POLL_MILLIS 50
@@ -55,15 +52,6 @@ void UITask::applyDisplayFlip() {
#endif
}
// `display.timeout` when the observer prefs are available, otherwise the
// compiled-in default. Read on every use so a `set display.timeout` takes
// effect immediately.
unsigned long UITask::displayTimeoutMillis() const {
#ifdef WITH_MQTT_BRIDGE
if (_observer_prefs) return (unsigned long)_observer_prefs->display_timeout_secs * 1000UL;
#endif
return AUTO_OFF_MILLIS;
}
#define BOOT_SCREEN_MILLIS 4000 // 4 seconds
#define POWEROFF_DELAY 3000
@@ -87,14 +75,12 @@ static const uint8_t meshcore_logo [] PROGMEM = {
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
_prevBtnState = HIGH;
_timeout_seen = displayTimeoutMillis();
_auto_off = millis() + displayTimeoutMillis();
_started_at = millis();
_node_prefs = node_prefs;
#ifdef DISPLAY_ACTIVITY_DASHBOARD
ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only
#endif
_display->turnOn();
_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected());
applyDisplayFlip();
#ifdef DISPLAY_TOUCH_TOGGLE
_touch.begin();
@@ -360,11 +346,7 @@ void UITask::updateActivityRows() {
#ifdef DISPLAY_TOUCH_TOGGLE
void UITask::toggleDisplay(const char* source) {
if (_display->isOn()) {
_display->turnOff();
} else {
_display->turnOn();
}
_display->wake(mesh::ui::DisplayWake::Button);
#ifdef DISPLAY_TOUCH_DEBUG
mesh::usbConsolePort().printf("Display: %s -> %s\n", source, _display->isOn() ? "on" : "off");
#else
@@ -377,11 +359,19 @@ void UITask::toggleDisplay(const char* source) {
_rows_valid = false;
#endif
_next_refresh = 0; // redraw at once rather than showing the stale frame
_auto_off = millis() + displayTimeoutMillis();
}
#endif
void UITask::loop() {
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
_next_refresh = 0;
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS)
int ev = user_btn.check();
// Multiclick stays enabled on the R8 observer so triple-click can toggle
@@ -394,7 +384,7 @@ void UITask::loop() {
if (_display->isOn()) {
// TODO: any action ?
} else {
_display->turnOn();
_display->wake(mesh::ui::DisplayWake::Button);
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
@@ -402,10 +392,10 @@ void UITask::loop() {
_rows_valid = false;
#endif
}
_auto_off = millis() + displayTimeoutMillis(); // extend auto-off timer
_display->wake(mesh::ui::DisplayWake::Button);
#endif
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
_display->turnOn();
_display->wake(mesh::ui::DisplayWake::Button);
mesh::usbConsolePort().printf("Powering Off\r\n");
_powering_off_at = millis() + POWEROFF_DELAY;
#ifdef DISPLAY_REDRAW_ON_CHANGE
@@ -420,29 +410,11 @@ void UITask::loop() {
// Preserve the fork's persistent WebUI toggle. R8 observer repeaters keep
// multiclick enabled specifically for this action.
WebConfigServer::requestToggleFromButton();
_display->turnOn();
_auto_off = millis() + displayTimeoutMillis();
_display->wake(mesh::ui::DisplayWake::Button);
#endif
}
#endif
#ifdef WITH_WEBCONFIG
// While the setup portal is up there's no user button to wake the screen
// reliably - keep it on so the join instructions stay visible.
if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) {
if (!_display->isOn()) {
_display->turnOn();
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
_auto_off = millis() + displayTimeoutMillis();
}
#endif
#ifdef DISPLAY_TOUCH_TOGGLE
{
unsigned long now = millis();
@@ -453,25 +425,6 @@ void UITask::loop() {
}
#endif
// Observe timeout changes even while the panel is blanked. In particular,
// `set display.timeout 0` means the display must stay on, so wake it now
// rather than waiting for a button/touch event that may never arrive.
unsigned long timeout = displayTimeoutMillis();
if (timeout != _timeout_seen) {
_timeout_seen = timeout;
_auto_off = millis() + timeout;
if (timeout == 0 && !_display->isOn()) {
_display->turnOn();
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
_next_refresh = 0;
}
}
if (_display->isOn()) {
// Apply a live orientation change before drawing, including the first frame
// after a preference was changed while the panel was blanked.
@@ -500,21 +453,6 @@ void UITask::loop() {
_next_refresh = millis() + 1000; // check for visible changes every second
}
// `_auto_off` is only armed on activity, so a timeout changed at runtime has
// to restart the countdown here - otherwise 0 -> 60 blanks instantly off a
// boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark.
#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN)
if (user_btn.isWakeHoldActive()) _auto_off = millis() + timeout;
#endif
if (_powering_off_at == 0 && timeout > 0 && millisReached(millis(), _auto_off)) {
_display->turnOff();
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
}
if (_powering_off_at > 0) { // power off timer armed
+2 -5
View File
@@ -19,7 +19,7 @@
class UITask {
mesh::MainBoard* _board;
DisplayDriver* _display;
unsigned long _next_read, _next_refresh, _auto_off;
unsigned long _next_read, _next_refresh;
int _prevBtnState;
NodePrefs* _node_prefs;
char _version_info[32];
@@ -54,11 +54,9 @@ class UITask {
#ifdef WITH_MQTT_BRIDGE
MQTTPrefs* _observer_prefs = NULL;
#endif
unsigned long _timeout_seen = 0; // to notice a live `display.timeout` change
uint8_t _flip_seen = 0xFF; // 0xFF forces the first apply
void applyDisplayFlip();
unsigned long displayTimeoutMillis() const;
void renderCurrScreen();
public:
@@ -66,8 +64,7 @@ public:
void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version);
#ifdef WITH_MQTT_BRIDGE
// Supplies `display.timeout`, which is read live so a config change applies
// without a reboot. Call before begin().
// Supplies display.flip. Call before begin().
void setObserverPrefs(MQTTPrefs* prefs) { _observer_prefs = prefs; }
#endif
+1 -4
View File
@@ -96,10 +96,7 @@ void setup() {
#ifdef DISPLAY_CLASS
display_ready = display.begin();
if (display_ready) {
display.startFrame();
display.setCursor(0, 0);
display.print("Please wait...");
display.endFrame();
display.turnOff(); // Stay dark until the saved display policy is loaded.
}
#endif
+16 -81
View File
@@ -18,9 +18,6 @@
#include <helpers/esp32/WebConfigServer.h> // defines WITH_WEBCONFIG on ESP32
#endif
#ifndef AUTO_OFF_MILLIS
#define AUTO_OFF_MILLIS 20000 // 20 seconds; 0 keeps the screen on
#endif
#ifdef DISPLAY_TOUCH_TOGGLE
#define TOUCH_POLL_MILLIS 50
@@ -54,15 +51,6 @@ void UITask::applyDisplayFlip() {
#endif
}
// `display.timeout` when the observer prefs are available, otherwise the
// compiled-in default. Read on every use so a `set display.timeout` takes
// effect immediately.
unsigned long UITask::displayTimeoutMillis() const {
#ifdef WITH_MQTT_BRIDGE
if (_observer_prefs) return (unsigned long)_observer_prefs->display_timeout_secs * 1000UL;
#endif
return AUTO_OFF_MILLIS;
}
#define BOOT_SCREEN_MILLIS 4000 // 4 seconds
#ifdef DISPLAY_TOUCH_TOGGLE
@@ -88,14 +76,12 @@ static const uint8_t meshcore_logo [] PROGMEM = {
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
_prevBtnState = HIGH;
_timeout_seen = displayTimeoutMillis();
_auto_off = millis() + displayTimeoutMillis();
_started_at = millis();
_node_prefs = node_prefs;
#ifdef DISPLAY_ACTIVITY_DASHBOARD
ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only
#endif
_display->turnOn();
_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected());
applyDisplayFlip();
#ifdef DISPLAY_TOUCH_TOGGLE
_touch.begin();
@@ -342,11 +328,7 @@ void UITask::updateActivityRows() {
#ifdef DISPLAY_TOUCH_TOGGLE
void UITask::toggleDisplay(const char* source) {
if (_display->isOn()) {
_display->turnOff();
} else {
_display->turnOn();
}
_display->wake(mesh::ui::DisplayWake::Button);
#ifdef DISPLAY_TOUCH_DEBUG
mesh::usbConsolePort().printf("Display: %s -> %s\n", source, _display->isOn() ? "on" : "off");
#else
@@ -359,17 +341,25 @@ void UITask::toggleDisplay(const char* source) {
_rows_valid = false;
#endif
_next_refresh = 0; // redraw at once rather than showing the stale frame
_auto_off = millis() + displayTimeoutMillis();
}
#endif
void UITask::loop() {
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
_next_refresh = 0;
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
#if defined(PIN_USER_BTN) && defined(DISPLAY_TOUCH_TOGGLE)
int ev = user_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
toggleDisplay("button");
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
_display->turnOn();
_display->wake(mesh::ui::DisplayWake::Button);
mesh::usbConsolePort().printf("Powering Off\r\n");
_powering_off_at = millis() + POWEROFF_DELAY;
#ifdef DISPLAY_REDRAW_ON_CHANGE
@@ -390,7 +380,7 @@ void UITask::loop() {
int ev = user_btn.check();
if (ev != BUTTON_EVENT_NONE) {
if (!_display->isOn()) {
_display->turnOn();
_display->wake(mesh::ui::DisplayWake::Button);
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
@@ -399,7 +389,7 @@ void UITask::loop() {
#endif
_next_refresh = 0;
}
_auto_off = millis() + displayTimeoutMillis();
_display->wake(mesh::ui::DisplayWake::Button);
}
#elif defined(PIN_USER_BTN)
if (millisReached(millis(), _next_read)) {
@@ -412,7 +402,7 @@ void UITask::loop() {
if (_display->isOn()) {
// TODO: any action ?
} else {
_display->turnOn();
_display->wake(mesh::ui::DisplayWake::Button);
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
@@ -420,7 +410,7 @@ void UITask::loop() {
_rows_valid = false;
#endif
}
_auto_off = millis() + displayTimeoutMillis(); // extend auto-off timer
_display->wake(mesh::ui::DisplayWake::Button);
#endif
}
_prevBtnState = btnState;
@@ -429,23 +419,6 @@ void UITask::loop() {
}
#endif
#ifdef WITH_WEBCONFIG
// While the setup portal is up there's no user button to wake the screen
// reliably - keep it on so the join instructions stay visible.
if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) {
if (!_display->isOn()) {
_display->turnOn();
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
_auto_off = millis() + displayTimeoutMillis();
}
#endif
#ifdef DISPLAY_TOUCH_TOGGLE
{
unsigned long now = millis();
@@ -456,25 +429,6 @@ void UITask::loop() {
}
#endif
// Observe timeout changes even while the panel is blanked. In particular,
// `set display.timeout 0` means the display must stay on, so wake it now
// rather than waiting for a button/touch event that may never arrive.
unsigned long timeout = displayTimeoutMillis();
if (timeout != _timeout_seen) {
_timeout_seen = timeout;
_auto_off = millis() + timeout;
if (timeout == 0 && !_display->isOn()) {
_display->turnOn();
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
_next_refresh = 0;
}
}
if (_display->isOn()) {
// Apply a live orientation change before drawing, including the first frame
// after a preference was changed while the panel was blanked.
@@ -503,25 +457,6 @@ void UITask::loop() {
_next_refresh = millis() + 1000; // check for visible changes every second
}
// `_auto_off` is only armed on activity, so a timeout changed at runtime has
// to restart the countdown here - otherwise 0 -> 60 blanks instantly off a
// boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark.
#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN)
if (user_btn.isWakeHoldActive()) _auto_off = millis() + timeout;
#endif
#ifdef DISPLAY_TOUCH_TOGGLE
if (_powering_off_at == 0 && timeout > 0 && millisReached(millis(), _auto_off)) {
#else
if (timeout > 0 && millisReached(millis(), _auto_off)) {
#endif
_display->turnOff();
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
}
#ifdef DISPLAY_TOUCH_TOGGLE
+2 -5
View File
@@ -21,7 +21,7 @@ class UITask {
mesh::MainBoard* _board;
#endif
DisplayDriver* _display;
unsigned long _next_read, _next_refresh, _auto_off;
unsigned long _next_read, _next_refresh;
int _prevBtnState;
NodePrefs* _node_prefs;
char _version_info[32];
@@ -58,11 +58,9 @@ class UITask {
#ifdef WITH_MQTT_BRIDGE
MQTTPrefs* _observer_prefs = NULL;
#endif
unsigned long _timeout_seen = 0; // to notice a live `display.timeout` change
uint8_t _flip_seen = 0xFF; // 0xFF forces the first apply
void applyDisplayFlip();
unsigned long displayTimeoutMillis() const;
void renderCurrScreen();
public:
@@ -76,8 +74,7 @@ public:
void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version);
#ifdef WITH_MQTT_BRIDGE
// Supplies `display.timeout`, which is read live so a config change applies
// without a reboot. Call before begin().
// Supplies display.flip. Call before begin().
void setObserverPrefs(MQTTPrefs* prefs) { _observer_prefs = prefs; }
#endif
+1 -4
View File
@@ -62,10 +62,7 @@ void setup() {
#ifdef DISPLAY_CLASS
display_ready = display.begin();
if (display_ready) {
display.startFrame();
display.setCursor(0, 0);
display.print("Please wait...");
display.endFrame();
display.turnOff(); // Stay dark until the saved display policy is loaded.
}
#endif
+8 -11
View File
@@ -7,7 +7,6 @@
#define USER_BTN_PRESSED LOW
#endif
#define AUTO_OFF_MILLIS 20000 // 20 seconds
#define BOOT_SCREEN_MILLIS 4000 // 4 seconds
// 'meshcore', 128x13px
@@ -29,9 +28,8 @@ static const uint8_t meshcore_logo [] PROGMEM = {
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
_prevBtnState = HIGH;
_auto_off = millis() + AUTO_OFF_MILLIS;
_node_prefs = node_prefs;
_display->turnOn();
_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected());
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \
&& defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP
@@ -98,16 +96,17 @@ void UITask::renderCurrScreen() {
}
void UITask::loop() {
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) _next_refresh = 0;
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \
&& defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP
int ev = user_btn.check();
if (ev != BUTTON_EVENT_NONE) {
if (!_display->isOn()) {
_display->turnOn();
_display->wake(mesh::ui::DisplayWake::Button);
_next_refresh = 0;
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_display->wake(mesh::ui::DisplayWake::Button);
}
#elif defined(PIN_USER_BTN)
if (millis() >= _next_read) {
@@ -117,9 +116,9 @@ void UITask::loop() {
if (_display->isOn()) {
// TODO: any action ?
} else {
_display->turnOn();
_display->wake(mesh::ui::DisplayWake::Button);
}
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_display->wake(mesh::ui::DisplayWake::Button); // extend auto-off timer
}
_prevBtnState = btnState;
}
@@ -136,10 +135,8 @@ void UITask::loop() {
_next_refresh = millis() + 1000; // refresh every second
}
#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN)
if (user_btn.isWakeHoldActive()) _auto_off = millis() + AUTO_OFF_MILLIS;
if (user_btn.isWakeHoldActive()) _display->wake(mesh::ui::DisplayWake::Button);
#endif
if (millis() > _auto_off) {
_display->turnOff();
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
class UITask {
DisplayDriver* _display;
unsigned long _next_read, _next_refresh, _auto_off;
unsigned long _next_read, _next_refresh;
int _prevBtnState;
NodePrefs* _node_prefs;
char _version_info[32];
+2 -3
View File
@@ -75,9 +75,8 @@ void setup() {
#ifdef DISPLAY_CLASS
if (display.begin()) {
display.startFrame();
display.print("Please wait...");
display.endFrame();
display.turnOff();
// Saved display policy is applied by UITask::begin().
}
#endif
+2
View File
@@ -58,6 +58,8 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
build_src_filter =
+<*.cpp>
+<helpers/*.cpp>
+<helpers/ui/DisplayDriver.cpp>
+<helpers/ui/DisplayPowerSettings.cpp>
+<helpers/ota/OtaTinf.c>
; MQTT-only sources (need Timezone/NTPClient); observer envs add them back.
-<helpers/MQTTMessageBuilder.cpp>
+6
View File
@@ -1,5 +1,6 @@
#include <Arduino.h>
#include "CommonCLI.h"
#include <helpers/ui/DisplayPowerSettings.h>
#include "CLICommandUtils.h"
#include "FloodAdvertCLI.h"
#include "StorageLayout.h"
@@ -731,6 +732,8 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) {
}
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
const bool display_settings_loaded = mesh::ui::loadDisplayPowerSettings(fs, false);
(void)display_settings_loaded;
bool is_fresh_install = false;
bool is_upgrade = false;
bool loaded = false;
@@ -785,6 +788,8 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
MQTTPrefsAtomicStore::LegacyUpgradeGate legacy_upgrade(
_com_prefs_needs_upgrade || node_prefs_needs_migration);
loadMQTTPrefs(fs, &legacy_upgrade);
if (!display_settings_loaded && _mqtt_prefs.display_timeout_secs != DISPLAY_TIMEOUT_DEFAULT_SECS)
mesh::ui::migrateLegacyDisplayTimeout(_mqtt_prefs.display_timeout_secs);
if (_mqtt_prefs_hold) legacy_upgrade.holdMqttSource();
// For MQTT bridge, migrate bridge.source to RX (logRx) only on fresh installs or upgrades
@@ -2457,6 +2462,7 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
mesh::cli::normalizeCommandVerb(command);
if (mesh::ui::handleDisplayPowerCommand(command, reply, 160)) return;
// Observer-only top-level commands (ota check/update, tls.bundletest, alert test)
// live in CommonCLI_Observer.cpp.
+1
View File
@@ -22,6 +22,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
"lat", "lon", "radio", "tx", "af", "rxdelay", "txdelay",
"cad", "radio.rxgain", "radio.fem.rxgain", "radio.rxps", "powersaving",
"repeat",
"display.mode", "display.timeout", "display.usb.mode", "display.usb.timeout",
"advert.interval", "flood.advert.interval",
"flood.max", "flood.max.advert", "flood.max.unscoped", "loop.detect",
// MQTTPrefs (WiFi / MQTT / misc observer)
File diff suppressed because it is too large. Load diff
+11
View File
@@ -10,6 +10,8 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
#include <ESPAsyncWebServer.h>
#include <DNSServer.h>
#include <ArduinoJson.h>
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/ui/DisplayBuildFlags.h>
#include <Preferences.h>
#include <esp_wifi.h>
#include <esp_system.h>
@@ -1791,6 +1793,15 @@ void WebConfigServer::handleConfigGet(AsyncWebServerRequest* req) {
radio["rxps_rx_us"] = node.rx_ps_rx_us;
radio["rxps_sleep_us"] = node.rx_ps_sleep_us;
radio["powersaving"] = (bool)node.power_saving;
#ifdef MESHCORE_HAS_REAL_DISPLAY
JsonObject screen = doc["display"].to<JsonObject>();
const auto& display_prefs = mesh::ui::displayPowerPrefs();
screen["mode"] = mesh::ui::displayModeName(display_prefs.battery.mode);
screen["timeout"] = display_prefs.battery.seconds;
screen["usb_mode"] = mesh::ui::displayModeName(display_prefs.usb.mode);
screen["usb_timeout"] = display_prefs.usb.seconds;
screen["pairing"] = mesh::ui::displayPairingSupported();
#endif
radio["repeat"] = (bool)node.repeat;
radio["flood_max"] = node.flood_max;
radio["flood_max_advert"] = node.flood_max_advert;
+19
View File
@@ -1,4 +1,23 @@
#include "DisplayDriver.h"
#include <Arduino.h>
bool DisplayDriver::servicePower(bool usb_power, bool app_connected, bool pairing) {
_power_policy.update(mesh::ui::displayPowerPrefs(), usb_power, app_connected, pairing, millis());
const bool changed = isOn() != _power_policy.on();
if (changed) { if (_power_policy.on()) turnOn(); else turnOff(); }
return changed;
}
bool DisplayDriver::wake(mesh::ui::DisplayWake reason) {
if (!_power_policy.wake(reason, millis())) return false;
if (_power_policy.on() && !isOn()) turnOn();
return true;
}
void DisplayDriver::dismiss() {
_power_policy.dismiss();
if (!_power_policy.on() && isOn()) turnOff();
}
#if defined(ESP32_PLATFORM) && defined(MESHCORE_HAS_REAL_DISPLAY)
+6
View File
@@ -1,6 +1,7 @@
#pragma once
#include "DisplayBuildFlags.h"
#include "DisplayPowerPolicy.h"
#include <stdint.h>
#include <string.h>
@@ -15,6 +16,7 @@ public:
class DisplayDriver {
int _w, _h;
mesh::ui::DisplayPowerPolicy _power_policy;
protected:
DisplayDriver(int w, int h) { _w = w; _h = h; }
void setDimensions(int w, int h) { _w = w; _h = h; }
@@ -52,6 +54,10 @@ public:
virtual void forceFullRefresh() {} // next refresh will be full for eink
virtual void turnOn() = 0;
virtual void turnOff() = 0;
// Returns whether the panel state changed, allowing cached UIs to redraw.
bool servicePower(bool usb_power, bool app_connected = false, bool pairing = false);
bool wake(mesh::ui::DisplayWake reason);
void dismiss();
virtual void clear() = 0;
virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0;
virtual void setTextSize(int sz) = 0;
+107
View File
@@ -0,0 +1,107 @@
#pragma once
#include <stdint.h>
#include <string.h>
namespace mesh { namespace ui {
enum class DisplayMode : uint8_t { Off, On, Button, Pairing, ButtonPairing, Automatic };
enum class DisplayWake : uint8_t { Boot, Button, Message };
struct DisplayPowerProfile {
DisplayMode mode = DisplayMode::Button;
uint16_t seconds = 15;
};
struct DisplayPowerPrefs {
DisplayPowerProfile battery;
DisplayPowerProfile usb;
};
inline DisplayPowerPrefs& displayPowerPrefs() {
static DisplayPowerPrefs prefs;
return prefs;
}
inline const char* displayModeName(DisplayMode mode) {
switch (mode) {
case DisplayMode::Off: return "off";
case DisplayMode::On: return "on";
case DisplayMode::Button: return "button";
case DisplayMode::Pairing: return "pairing";
case DisplayMode::ButtonPairing: return "button-pairing";
case DisplayMode::Automatic: return "automatic";
}
return "off";
}
inline bool parseDisplayMode(const char* text, DisplayMode& mode) {
for (uint8_t i = 0; i <= uint8_t(DisplayMode::Automatic); ++i) {
if (strcmp(text, displayModeName(DisplayMode(i))) == 0) {
mode = DisplayMode(i);
return true;
}
}
return false;
}
inline bool displayModeSupportsPairing(DisplayMode mode) {
return mode == DisplayMode::Pairing || mode == DisplayMode::ButtonPairing;
}
inline bool validDisplayProfile(const DisplayPowerProfile& profile) {
return uint8_t(profile.mode) <= uint8_t(DisplayMode::Automatic)
&& profile.seconds >= 1 && profile.seconds <= 3600;
}
// Pure, wrap-safe policy shared by every UI. Radio traffic, refreshes and USB
// presence are not user activity. An active pairing request has its own expiry.
class DisplayPowerPolicy {
DisplayPowerProfile _profile;
bool _initialized = false, _usb = false, _connected = false, _pairing = false;
bool _timed = false;
uint32_t _deadline = 0;
public:
void update(const DisplayPowerPrefs& prefs, bool usb, bool connected,
bool pairing, uint32_t now) {
const auto& selected = usb ? prefs.usb : prefs.battery;
const bool reset = !_initialized || _usb != usb || _profile.mode != selected.mode;
const bool timeout_changed = _profile.seconds != selected.seconds;
const bool just_connected = connected && !_connected;
_profile = selected;
_usb = usb;
_connected = connected;
_pairing = pairing;
if (reset) {
_timed = false;
_initialized = true;
wake(DisplayWake::Boot, now);
} else if (timeout_changed && _timed) {
_deadline = now + uint32_t(_profile.seconds) * 1000;
}
if (just_connected && _profile.mode == DisplayMode::Automatic) _timed = false;
if (_timed && int32_t(now - _deadline) >= 0) _timed = false;
}
bool wake(DisplayWake reason, uint32_t now) {
if (!_initialized) return false;
bool allowed = _profile.mode == DisplayMode::On;
if (reason == DisplayWake::Button) {
allowed |= _profile.mode == DisplayMode::Button
|| _profile.mode == DisplayMode::ButtonPairing
|| _profile.mode == DisplayMode::Automatic;
} else {
allowed |= _profile.mode == DisplayMode::Automatic && !_connected;
}
if (!allowed) return false;
_timed = true;
uint32_t duration = uint32_t(_profile.seconds) * 1000;
if (reason == DisplayWake::Boot && duration > 4000) duration = 4000;
_deadline = now + duration;
return true;
}
void dismiss() { _timed = false; }
bool on() const {
if (!_initialized || _profile.mode == DisplayMode::Off) return false;
if (_profile.mode == DisplayMode::On) return true;
if (_pairing && (_profile.mode == DisplayMode::Automatic
|| displayModeSupportsPairing(_profile.mode))) return true;
return _profile.mode != DisplayMode::Pairing && _timed;
}
};
} }
+159
View File
@@ -0,0 +1,159 @@
#include "DisplayPowerSettings.h"
#include "DisplayBuildFlags.h"
#include <stdio.h>
namespace mesh { namespace ui {
namespace {
FILESYSTEM* settings_fs = nullptr;
bool pairing_available = false;
const char* const path = "/display_prefs";
const char* const backup = "/display_prefs.bak";
const char* const temporary = "/display_prefs.tmp";
// Explicit bytes avoid compiler padding and preserve a small fixed format.
void encode(const DisplayPowerPrefs& prefs, uint8_t (&data)[12]) {
data[0] = 'D'; data[1] = 'P'; data[2] = 1;
data[3] = uint8_t(prefs.battery.mode);
data[4] = prefs.battery.seconds & 255; data[5] = prefs.battery.seconds >> 8;
data[6] = uint8_t(prefs.usb.mode);
data[7] = prefs.usb.seconds & 255; data[8] = prefs.usb.seconds >> 8;
data[9] = 0; data[10] = 0; data[11] = 0;
for (unsigned i = 0; i < 11; ++i) data[11] ^= data[i];
}
bool readPrefs(const char* name, DisplayPowerPrefs& prefs) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
File file(*settings_fs);
if (!file.open(name, FILE_O_READ)) return false;
#else
File file = settings_fs->open(name, "r");
if (!file) return false;
#endif
uint8_t data[12], expected[12];
const bool complete = file.size() == sizeof(data)
&& file.read(data, sizeof(data)) == sizeof(data);
file.close();
if (!complete) return false;
prefs.battery.mode = DisplayMode(data[3]);
prefs.battery.seconds = uint16_t(data[4]) | (uint16_t(data[5]) << 8);
prefs.usb.mode = DisplayMode(data[6]);
prefs.usb.seconds = uint16_t(data[7]) | (uint16_t(data[8]) << 8);
encode(prefs, expected);
return memcmp(data, expected, sizeof(data)) == 0
&& validDisplayProfile(prefs.battery) && validDisplayProfile(prefs.usb);
}
bool savePrefs(const DisplayPowerPrefs& prefs) {
if (!settings_fs) return false;
// Preserve the previous complete file until the replacement is verified.
settings_fs->remove(temporary);
if (settings_fs->exists(temporary)) return false;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
File file(*settings_fs);
if (!file.open(temporary, FILE_O_WRITE)) return false;
#elif defined(RP2040_PLATFORM)
File file = settings_fs->open(temporary, "w");
if (!file) return false;
#else
File file = settings_fs->open(temporary, "w", true);
if (!file) return false;
#endif
uint8_t data[12];
encode(prefs, data);
const bool written = file.write(data, sizeof(data)) == sizeof(data);
file.close();
DisplayPowerPrefs checked;
if (!written || !readPrefs(temporary, checked)) return false;
if (settings_fs->exists(backup) && !settings_fs->remove(backup)) return false;
if (settings_fs->exists(path) && !settings_fs->rename(path, backup)) return false;
if (!settings_fs->rename(temporary, path)) {
if (settings_fs->exists(backup)) settings_fs->rename(backup, path);
return false;
}
settings_fs->remove(backup);
return true;
}
}
bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported) {
settings_fs = fs;
pairing_available = pairing_supported;
DisplayPowerPrefs prefs;
if (pairing_supported) prefs.battery.mode = prefs.usb.mode = DisplayMode::ButtonPairing;
displayPowerPrefs() = prefs;
if (!fs) return false;
bool loaded = readPrefs(path, prefs);
if (!loaded && readPrefs(backup, prefs)) {
loaded = true;
fs->remove(path);
fs->rename(backup, path);
}
if (!loaded) return false;
if (!pairing_supported) {
if (displayModeSupportsPairing(prefs.battery.mode)) prefs.battery.mode = DisplayMode::Button;
if (displayModeSupportsPairing(prefs.usb.mode)) prefs.usb.mode = DisplayMode::Button;
}
displayPowerPrefs() = prefs;
return true;
}
bool displayPairingSupported() { return pairing_available; }
void migrateLegacyDisplayTimeout(uint16_t seconds) {
if (seconds > 3600) return;
auto prefs = displayPowerPrefs();
prefs.battery.mode = prefs.usb.mode = seconds == 0 ? DisplayMode::On : DisplayMode::Automatic;
if (seconds) prefs.battery.seconds = prefs.usb.seconds = seconds;
// Keep the old behavior in memory even if flash is temporarily unavailable.
displayPowerPrefs() = prefs;
savePrefs(prefs);
}
bool handleDisplayPowerCommand(const char* command, char* reply, size_t reply_size) {
const bool get = strncmp(command, "get ", 4) == 0;
const bool set = strncmp(command, "set ", 4) == 0;
if (!get && !set) return false;
const char* key = command + 4;
const char* keys[] = {"display.mode", "display.timeout", "display.usb.mode", "display.usb.timeout"};
int index = -1;
for (int i = 0; i < 4; ++i) {
const size_t len = strlen(keys[i]);
if (strncmp(key, keys[i], len) == 0 && (key[len] == 0 || key[len] == ' ')) {
index = i; break;
}
}
if (index < 0) return false;
#ifndef MESHCORE_HAS_REAL_DISPLAY
snprintf(reply, reply_size, "Error: display unsupported");
return true;
#else
auto candidate = displayPowerPrefs();
auto& profile = index < 2 ? candidate.battery : candidate.usb;
const char* value = key + strlen(keys[index]);
if (get) {
if (*value) snprintf(reply, reply_size, "Error: unexpected argument");
else if (index & 1) snprintf(reply, reply_size, "> %u", unsigned(profile.seconds));
else snprintf(reply, reply_size, "> %s", displayModeName(profile.mode));
return true;
}
while (*value == ' ') ++value;
if (index & 1) {
uint32_t seconds = 0;
bool valid = *value != 0;
for (const char* p = value; *p && valid; ++p) {
valid = *p >= '0' && *p <= '9';
if (valid) { seconds = seconds * 10 + (*p - '0'); valid = seconds <= 3600; }
}
if (!valid || seconds == 0) {
snprintf(reply, reply_size, "Error: timeout must be 1-3600 seconds"); return true;
}
profile.seconds = uint16_t(seconds);
} else if (!parseDisplayMode(value, profile.mode)) {
snprintf(reply, reply_size, "Error: use off|on|button|pairing|button-pairing|automatic"); return true;
} else if (displayModeSupportsPairing(profile.mode) && !pairing_available) {
snprintf(reply, reply_size, "Error: BLE pairing display unsupported"); return true;
}
if (!savePrefs(candidate)) snprintf(reply, reply_size, "Error: display settings save failed");
else { displayPowerPrefs() = candidate; snprintf(reply, reply_size, "OK"); }
return true;
#endif
}
} }
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <helpers/IdentityStore.h>
#include "DisplayPowerPolicy.h"
namespace mesh { namespace ui {
// Separate, versioned file: existing node and MQTT preference layouts stay
// readable by older firmware. Returns true when a saved profile was loaded.
bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported);
bool handleDisplayPowerCommand(const char* command, char* reply, size_t reply_size);
bool displayPairingSupported();
void migrateLegacyDisplayTimeout(uint16_t seconds);
} }
+193
View File
@@ -0,0 +1,193 @@
"""Exercise the shared display policy and real settings/CLI code on the host."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
FS_STUB = r'''
#pragma once
#include <map>
#include <string>
#include <vector>
#include <cstring>
#include <cstdint>
struct FakeFS;
struct File {
FakeFS* fs = nullptr;
std::string name;
explicit operator bool() const { return fs != nullptr; }
size_t size() const;
size_t read(uint8_t*, size_t);
size_t write(const uint8_t*, size_t);
void close() {}
};
struct FakeFS {
std::map<std::string, std::vector<uint8_t>> files;
bool fail_write = false, fail_publish = false;
bool exists(const char* name) { return files.count(name); }
bool remove(const char* name) { files.erase(name); return true; }
bool rename(const char* from, const char* to) {
if (fail_publish && std::string(from) == "/display_prefs.tmp") return false;
if (!exists(from) || exists(to)) return false;
files[to] = files[from]; files.erase(from); return true;
}
File open(const char* name, const char* mode, bool = false) {
if (*mode == 'w') files[name].clear();
if (!exists(name)) return {};
return File{this, name};
}
};
inline size_t File::size() const { return fs->files[name].size(); }
inline size_t File::read(uint8_t* out, size_t count) {
const auto& bytes = fs->files[name];
count = count < bytes.size() ? count : bytes.size();
memcpy(out, bytes.data(), count); return count;
}
inline size_t File::write(const uint8_t* in, size_t count) {
if (fs->fail_write) return 0;
fs->files[name].assign(in, in + count); return count;
}
#define FILESYSTEM FakeFS
'''
TEST = r'''
#include <cassert>
#include <helpers/ui/DisplayPowerSettings.h>
using namespace mesh::ui;
void policyTests() {
for (unsigned mode = 0; mode <= 5; ++mode) {
DisplayPowerPrefs prefs;
prefs.battery.mode = DisplayMode(mode);
DisplayPowerPolicy p;
p.update(prefs, false, false, false, 0);
assert(p.on() == (mode == 1 || mode == 5));
p.update(prefs, false, false, false, 4000);
assert(p.on() == (mode == 1));
bool button = mode == 1 || mode == 2 || mode == 4 || mode == 5;
assert(p.wake(DisplayWake::Button, 5000) == button);
assert(p.on() == button);
p.update(prefs, false, false, false, 20000);
assert(p.on() == (mode == 1));
assert(p.wake(DisplayWake::Message, 21000) == (mode == 1 || mode == 5));
p.update(prefs, false, true, false, 22000);
assert(p.on() == (mode == 1));
assert(p.wake(DisplayWake::Message, 23000) == (mode == 1));
assert(p.wake(DisplayWake::Button, 24000) == button);
p.update(prefs, false, true, false, 40000);
p.update(prefs, false, false, true, 41000);
assert(p.on() == (mode == 1 || mode == 3 || mode == 4 || mode == 5));
p.update(prefs, false, false, true, 100000);
assert(p.on() == (mode == 1 || mode == 3 || mode == 4 || mode == 5));
p.update(prefs, false, true, false, 100001);
assert(p.on() == (mode == 1));
}
DisplayPowerPrefs prefs;
prefs.usb.mode = DisplayMode::On;
DisplayPowerPolicy p;
p.update(prefs, false, false, false, 100);
p.wake(DisplayWake::Button, 200);
p.update(prefs, true, false, false, 300);
assert(p.on());
p.update(prefs, false, false, false, 400);
assert(!p.on()); // USB wake does not leak into the battery profile.
p.wake(DisplayWake::Button, 500);
prefs.battery.seconds = 1;
p.update(prefs, false, false, false, 600);
assert(p.on());
p.update(prefs, false, false, false, 1600);
assert(!p.on());
p.wake(DisplayWake::Button, UINT32_MAX - 500);
p.update(prefs, false, false, false, 498);
assert(p.on());
p.update(prefs, false, false, false, 499);
assert(!p.on());
p.wake(DisplayWake::Button, 1000);
prefs.battery.mode = DisplayMode::Off;
p.update(prefs, false, false, true, 1001);
assert(!p.on()); // Off wins over a pending button wake and pairing.
}
void commandTests() {
FakeFS fs;
assert(!loadDisplayPowerSettings(&fs, true));
assert(displayPowerPrefs().battery.mode == DisplayMode::ButtonPairing);
char reply[160];
auto run = [&](const char* text, const char* expected) {
assert(handleDisplayPowerCommand(text, reply, sizeof(reply)));
assert(std::string(reply) == expected);
};
run("set display.mode automatic", "OK");
run("set display.timeout 27", "OK");
run("set display.usb.mode on", "OK");
run("set display.usb.timeout 123", "OK");
assert(loadDisplayPowerSettings(&fs, true));
run("get display.mode", "> automatic");
run("get display.timeout", "> 27");
run("get display.usb.mode", "> on");
run("get display.usb.timeout", "> 123");
const auto saved = fs.files["/display_prefs"];
for (const char* value : {"", "0", "-1", "+2", "1.5", "15seconds", "3601", "9999999999999999999999"}) {
const std::string command = std::string("set display.timeout ") + value;
run(command.c_str(), "Error: timeout must be 1-3600 seconds");
assert(fs.files["/display_prefs"] == saved);
}
run("set display.mode typo", "Error: use off|on|button|pairing|button-pairing|automatic");
assert(!handleDisplayPowerCommand("set display.model off", reply, sizeof(reply)));
fs.fail_write = true;
run("set display.mode off", "Error: display settings save failed");
assert(displayPowerPrefs().battery.mode == DisplayMode::Automatic);
assert(fs.files["/display_prefs"] == saved);
fs.fail_write = false; fs.fail_publish = true;
run("set display.mode off", "Error: display settings save failed");
assert(loadDisplayPowerSettings(&fs, true));
run("get display.mode", "> automatic");
fs.fail_publish = false;
fs.files["/display_prefs.bak"] = saved;
fs.files["/display_prefs"][4] ^= 0x80;
assert(loadDisplayPowerSettings(&fs, true));
run("get display.timeout", "> 27");
assert(loadDisplayPowerSettings(&fs, false));
run("set display.mode pairing", "Error: BLE pairing display unsupported");
run("set display.usb.mode button-pairing", "Error: BLE pairing display unsupported");
FakeFS legacy;
assert(!loadDisplayPowerSettings(&legacy, false));
migrateLegacyDisplayTimeout(0);
assert(loadDisplayPowerSettings(&legacy, false));
run("get display.mode", "> on");
run("get display.usb.mode", "> on");
migrateLegacyDisplayTimeout(42);
assert(loadDisplayPowerSettings(&legacy, false));
run("get display.mode", "> automatic");
run("get display.timeout", "> 42");
run("get display.usb.timeout", "> 42");
}
int main() { policyTests(); commandTests(); }
'''
class DisplayPowerTest(unittest.TestCase):
def test_policy_and_persistent_commands(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "host C++ compiler required")
with tempfile.TemporaryDirectory(prefix="mesh-display-test-") as name:
work = Path(name)
(work / "helpers").mkdir()
(work / "helpers/IdentityStore.h").write_text(FS_STUB)
(work / "test.cpp").write_text(TEST)
executable = work / "display-test.exe"
result = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-DDISPLAY_CLASS=SSD1306Display", "-I" + str(work),
"-I" + str(ROOT / "src"), str(work / "test.cpp"),
str(ROOT / "src/helpers/ui/DisplayPowerSettings.cpp"),
"-o", str(executable),
], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+212
View File
@@ -0,0 +1,212 @@
"""Run the real R8 board's power detector with simulated USB, ADC and time."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
ARDUINO = r'''
#pragma once
#include <cstdint>
#define PIN_VEXT_EN 40
#define PIN_VEXT_EN_ACTIVE 0
#define PIN_VBAT_READ 1
#define P_LORA_DIO_1 14
#define P_LORA_NSS 8
#define P_LORA_TX_LED 46
#define P_LORA_PA_POWER 7
#define HIGH 1
#define LOW 0
#define BD_STARTUP_RX_PACKET 1
inline uint32_t test_now = 0;
inline uint32_t millis() { return test_now; }
inline void digitalWrite(int, int) {}
inline void analogReadResolution(int) {}
inline uint32_t analogReadMilliVolts(int) { return 830; }
'''
BASE_BOARD = r'''
#pragma once
#include <Arduino.h>
class ESP32Board {
protected:
uint8_t startup_reason = 0;
public:
void begin() {}
virtual ~ESP32Board() = default;
virtual void onBeforeTransmit() {}
virtual void onAfterTransmit() {}
virtual void powerOff() {}
virtual bool setLoRaFemLnaEnabled(bool) { return false; }
virtual bool canControlLoRaFemLna() const { return false; }
virtual bool isLoRaFemLnaEnabled() const { return false; }
virtual uint16_t getBattMilliVolts() { return 0; }
virtual bool isExternalPowered() { return false; }
virtual bool isUsbHostConnected() { return false; }
virtual bool setAdcMultiplier(float) { return false; }
virtual float getAdcMultiplier() const { return 0; }
virtual const char* getManufacturerName() const { return "test"; }
};
'''
TEST = r'''
#include <cassert>
#include "HeltecV4R8Board.h"
#include <helpers/ui/DisplayPowerPolicy.h>
void LoRaFEMControl::init() {}
void LoRaFEMControl::setSleepModeEnable() {}
void LoRaFEMControl::setTxModeEnable() {}
void LoRaFEMControl::setRxModeEnable() {}
void LoRaFEMControl::setLNAEnable(bool enabled) { lna_enabled = enabled; }
class TestBoard : public HeltecV4R8Board {
public:
bool host = false;
uint16_t voltage = 3700;
unsigned reads = 0;
bool isUsbHostConnected() override { return host; }
uint16_t getBattMilliVolts() override { ++reads; return voltage; }
};
int main() {
TestBoard board;
assert(!board.isExternalPowered());
assert(board.reads == 1); // Sample immediately, including at millis() == 0.
board.voltage = 4230;
test_now = 4999;
assert(!board.isExternalPowered());
assert(board.reads == 1); // Frequent UI polling must not repeatedly use ADC.
test_now = 5000;
assert(board.isExternalPowered());
assert(board.reads == 2);
board.voltage = 4205;
test_now = 10000;
assert(board.isExternalPowered()); // Noise inside the margin retains state.
board.voltage = 4200;
test_now = 15000;
assert(!board.isExternalPowered());
board.voltage = 4210;
test_now = 20000;
assert(!board.isExternalPowered()); // Exactly 4.21 V is not above threshold.
board.voltage = 4211;
test_now = 25000;
assert(board.isExternalPowered());
board.voltage = 0;
test_now = 30000;
assert(!board.isExternalPowered()); // An absent/invalid low reading clears it.
// Host detection overrides a cached negative voltage result immediately,
// including an empty battery; removing the host must not latch external power.
const unsigned reads = board.reads;
board.host = true;
assert(board.isExternalPowered());
assert(board.reads == reads);
board.host = false;
assert(!board.isExternalPowered());
// A long host connection must not preserve stale high battery voltage.
board.voltage = 4250;
test_now = 35000;
assert(board.isExternalPowered());
board.host = true;
board.voltage = 3700;
test_now = 50000;
assert(board.isExternalPowered());
board.host = false;
assert(!board.isExternalPowered());
TestBoard wrapped;
test_now = UINT32_MAX - 1000;
assert(!wrapped.isExternalPowered());
wrapped.voltage = 4230;
test_now = 3998;
assert(!wrapped.isExternalPowered());
test_now = 3999;
assert(wrapped.isExternalPowered()); // Five-second sampling survives wrap.
// Both sources select the actual USB display profile, returning to battery
// when neither source is detected. No additional display setting is needed.
mesh::ui::DisplayPowerPrefs prefs;
prefs.battery.mode = mesh::ui::DisplayMode::Off;
prefs.usb.mode = mesh::ui::DisplayMode::On;
mesh::ui::DisplayPowerPolicy display;
TestBoard source;
test_now = 0;
const auto update = [&]() {
display.update(prefs, source.isExternalPowered() || source.isUsbHostConnected(),
false, false, test_now);
};
update(); assert(!display.on());
source.host = true;
update(); assert(display.on());
source.host = false;
update(); assert(!display.on());
source.voltage = 4230;
test_now = 5000;
update(); assert(display.on());
source.voltage = 4100;
test_now = 10000;
update(); assert(!display.on());
// The estimate uses the real calibrated ADC path, including user calibration.
HeltecV4R8Board calibrated;
calibrated.setAdcMultiplier(5.1f);
assert(calibrated.isExternalPowered()); // 830 mV * 5.1 = 4233 mV.
calibrated.setAdcMultiplier(5.0f);
test_now += 5000;
assert(!calibrated.isExternalPowered()); // Same raw sample becomes 4150 mV.
}
'''
class R8ExternalPowerTest(unittest.TestCase):
def test_host_voltage_and_display_profile(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "host C++ compiler required")
with tempfile.TemporaryDirectory(prefix="mesh-r8-power-") as directory:
work = Path(directory)
files = {
"Arduino.h": ARDUINO,
"helpers/ESP32Board.h": BASE_BOARD,
"helpers/RefCountedDigitalPin.h": """#pragma once
class RefCountedDigitalPin {
public:
RefCountedDigitalPin(int, int) {}
void begin() {}
void claim() {}
void release() {}
};
""",
"driver/rtc_io.h": """#pragma once
using gpio_num_t = int;
using esp_reset_reason_t = int;
constexpr int ESP_RST_DEEPSLEEP = 1;
inline int esp_reset_reason() { return 0; }
inline long esp_sleep_get_ext1_wakeup_status() { return 0; }
inline void rtc_gpio_hold_dis(int) {}
inline void rtc_gpio_hold_en(int) {}
inline void rtc_gpio_deinit(int) {}
""",
"test.cpp": TEST,
}
for path, content in files.items():
target = work / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
executable = work / "r8-power.exe"
variant = ROOT / "variants/heltec_v4_r8"
result = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-I" + str(work), "-I" + str(variant), "-I" + str(ROOT / "src"),
str(work / "test.cpp"), str(variant / "HeltecV4R8Board.cpp"),
"-o", str(executable),
], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -152,6 +152,8 @@ int main() {
g_mock_pin_levels[7]=HIGH;
user_btn.begin(); user_btn.enableQuadrupleClick();
Display display;
display.servicePower(false);
display.wake(mesh::ui::DisplayWake::Button);
UITask task(display);
HomeScreen home(&task);
Screen group;
@@ -393,6 +395,7 @@ class MessageNavigationTest(unittest.TestCase):
"-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"),
"-fsanitize=address,undefined", "-fno-pie", "-no-pie",
"-x", "c++", "-", str(ROOT / "src/helpers/ui/MomentaryButton.cpp"),
str(ROOT / "src/helpers/ui/DisplayDriver.cpp"),
"-o", str(binary),
], input=preamble + implementation + SCENARIOS, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
+37
View File
@@ -47,6 +47,43 @@ def embedded_page():
class WebConfigUiRuntimeTest(unittest.TestCase):
def test_four_display_controls_and_pairing_capability(self):
status, config = self.setup_values()
config["display"] = {
"mode": "button", "timeout": 23,
"usb_mode": "on", "usb_timeout": 55, "pairing": False,
}
prelude = r'''<script>
window.fetch=function(path){
var value=path==="/api/status"?%s:%s;
return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}});
};
window.addEventListener("load",function(){
setTimeout(function(){enterApp()},50);
setTimeout(function(){
var card=document.getElementById("display-settings"), body=document.body;
body.setAttribute("data-test-display-visible",!card.classList.contains("hide"));
body.setAttribute("data-test-display-count",card.querySelectorAll("[data-k]").length);
body.setAttribute("data-test-battery-timeout",card.querySelector('[data-k="display.timeout"]').value);
body.setAttribute("data-test-usb-timeout",card.querySelector('[data-k="display.usb.timeout"]').value);
body.setAttribute("data-test-usb-disabled",card.querySelector('[data-k="display.usb.timeout"]').disabled);
body.setAttribute("data-test-pairing-disabled",card.querySelector('option[value="pairing"]').disabled);
var mode=card.querySelector('[data-k="display.mode"]');
mode.value="off";mode.dispatchEvent(new Event("input",{bubbles:true}));
body.setAttribute("data-test-battery-disabled",card.querySelector('[data-k="display.timeout"]').disabled);
body.setAttribute("data-test-dirty-display",st.dirty["display.mode"]);
},650);
});
</script>''' % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
for attribute, value in (
("display-visible", "true"), ("display-count", "4"),
("battery-timeout", "23"), ("usb-timeout", "55"),
("usb-disabled", "true"), ("pairing-disabled", "true"),
("battery-disabled", "true"), ("dirty-display", "off"),
):
self.assertIn('data-test-%s="%s"' % (attribute, value), dom)
def setup_values(self):
sys.path.insert(0, str(ROOT / "scripts"))
try:
+18
View File
@@ -65,6 +65,24 @@ uint16_t HeltecV4R8Board::getBattMilliVolts() {
return (adc_mult * raw);
}
bool HeltecV4R8Board::isExternalPowered() {
// R8 has no MCU-connected VBUS/charger-status pin. A USB host is definitive;
// otherwise use the calibrated battery voltage as a best-effort estimate.
if (isUsbHostConnected()) return true;
const uint32_t now = millis();
if (!power_voltage_sampled || uint32_t(now - last_power_check_ms) >= 5000) {
last_power_check_ms = now;
power_voltage_sampled = true;
// Like Meshtastic's analog power detection, enter above a full Li-ion cell
// plus 10 mV. Release at 4.20 V to reduce display-profile chatter. This can
// miss a charger while the battery is low; it is not a charging-status flag.
const uint16_t millivolts = getBattMilliVolts();
voltage_external_power = millivolts > (voltage_external_power ? 4200 : 4210);
}
return voltage_external_power;
}
const char* HeltecV4R8Board::getManufacturerName() const {
#ifdef HELTEC_V4_R8_TFT
return "Heltec V4 R8 TFT";
+4
View File
@@ -13,6 +13,9 @@
class HeltecV4R8Board : public ESP32Board {
protected:
float adc_mult = ADC_MULTIPLIER;
uint32_t last_power_check_ms = 0;
bool power_voltage_sampled = false;
bool voltage_external_power = false;
public:
RefCountedDigitalPin periph_power;
@@ -28,6 +31,7 @@ public:
bool canControlLoRaFemLna() const override;
bool isLoRaFemLnaEnabled() const override;
uint16_t getBattMilliVolts() override;
bool isExternalPowered() override;
bool setAdcMultiplier(float multiplier) override {
if (multiplier == 0.0f) {
adc_mult = ADC_MULTIPLIER;
+4 -4
View File
@@ -347,8 +347,8 @@ build_flags =
-D DISPLAY_ACTIVITY_DASHBOARD=1
-D USER_BTN_LONG_PRESS_MILLIS=3000
-D USER_BTN_MULTICLICK=1
; display.timeout controls runtime blanking; touch and the existing button
; handling can toggle the panel manually. Multiclick remains enabled.
; Battery/USB display modes and timeouts control runtime blanking.
; Touch and button presses request a wake when allowed. Multiclick stays enabled.
-D DISPLAY_TOUCH_TOGGLE=1
build_src_filter = ${heltec_v4_r8_tft.build_src_filter}
+<helpers/bridges/MQTTBridge.cpp>
@@ -422,8 +422,8 @@ build_flags =
-D DISPLAY_ACTIVITY_DASHBOARD=1
-D USER_BTN_LONG_PRESS_MILLIS=3000
-D USER_BTN_MULTICLICK=0
; display.timeout controls runtime blanking; touch and the existing button
; handling can toggle the panel manually. Room has no multiclick action, so a
; Battery/USB display modes and timeouts control runtime blanking. Touch and
; button presses request a wake when allowed. Room has no multiclick action, so a
; click is delivered immediately instead of waiting for a second click.
-D DISPLAY_TOUCH_TOGGLE=1
build_src_filter = ${heltec_v4_r8_tft.build_src_filter}
+34
View File
@@ -393,6 +393,18 @@ body.tab-cli{padding-bottom:0}
</div>
<div class="f"><label>TX power (dBm)</label><input type="number" data-k="tx" min="-9" max="30" style="max-width:120px"></div>
</div>
<div class="card hide" id="display-settings">
<h2>Display</h2>
<div class="row">
<div class="f"><label>On battery</label><select data-k="display.mode"><option value="off">Off</option><option value="on">On</option><option value="button">Button</option><option value="pairing">Pairing only</option><option value="button-pairing">Button + pairing</option><option value="automatic">Automatic</option></select></div>
<div class="f"><label>Timeout (seconds)</label><input type="number" data-k="display.timeout" min="1" max="3600" step="1"></div>
</div>
<div class="row">
<div class="f"><label>On USB power</label><select data-k="display.usb.mode"><option value="off">Off</option><option value="on">On</option><option value="button">Button</option><option value="pairing">Pairing only</option><option value="button-pairing">Button + pairing</option><option value="automatic">Automatic</option></select></div>
<div class="f"><label>Timeout (seconds)</label><input type="number" data-k="display.usb.timeout" min="1" max="3600" step="1"></div>
</div>
<div class="hint">Automatic shows startup and messages while the app is disconnected. Connecting blanks the display; a button or pairing request can wake it. USB power detection depends on the board.</div>
</div>
<div class="card">
<h2>Advanced</h2>
<div class="row">
@@ -800,6 +812,10 @@ function cfgVal(k){ // map a `set` key to its current value string, from st.cfg
case"radio.fem.rxgain":return r.fem_rxgain?"on":"off";
case"radio.rxps":return rxpsCfgValue(r);
case"powersaving":return r.powersaving?"on":"off";
case"display.mode":return (st.cfg.display||{}).mode||"button";
case"display.timeout":return String((st.cfg.display||{}).timeout||15);
case"display.usb.mode":return (st.cfg.display||{}).usb_mode||"button";
case"display.usb.timeout":return String((st.cfg.display||{}).usb_timeout||15);
case"repeat":return r.repeat?"on":"off";
case"flood.max":return String(r.flood_max);
case"flood.max.advert":return String(r.flood_max_advert);
@@ -945,6 +961,9 @@ function loadConfig(loadCapture){
});
$$("[data-cfm]").forEach(function(el){el.value=""});
$$("[data-rg]").forEach(function(el){el.value=String(c.radio[el.dataset.rg]);el.classList.remove("dirty")});
$("#display-settings").classList.toggle("hide",!c.display);
$$('#display-settings option[value="pairing"],#display-settings option[value="button-pairing"]').forEach(function(el){el.hidden=!(c.display&&c.display.pairing);el.disabled=el.hidden});
syncDisplayTimeouts();
loadRxps(c.radio);
st.orig.radio=radioCombo();afHint();
for(var i=1;i<=st.nslots;i++){refreshSlotFields($("#app-slots"),i);refreshSlotFields($("#wz-slots"),i)}
@@ -957,10 +976,17 @@ function loadConfig(loadCapture){
return true;
});
}
function syncDisplayTimeouts(){
["display","display.usb"].forEach(function(prefix){
var mode=$('[data-k="'+prefix+'.mode"]').value;
$('[data-k="'+prefix+'.timeout"]').disabled=(mode==="off"||mode==="on"||mode==="pairing");
});
}
function markDirty(k,v,el){
if(v===st.orig[k]){delete st.dirty[k]}else{st.dirty[k]=v}
if(el)el.classList.toggle("dirty",k in st.dirty);
if(k==="af")afHint();
if(k==="display.mode"||k==="display.usb.mode")syncDisplayTimeouts();
updateSaveBar();
}
// The CLI's `dutycycle` is not its own pref - it just writes airtime_factor as
@@ -1860,6 +1886,10 @@ var CLI_VERBS=[
mode: 0 = get and set, 1 = get only, 2 = set only
values: enum offered as value completions after `set <key> ` */
var CLI_KEYS=[
["display.mode","Display on battery",0,"off|on|button|pairing|button-pairing|automatic"],
["display.timeout","Battery display timeout in seconds (1-3600)",0],
["display.usb.mode","Display on USB power",0,"off|on|button|pairing|button-pairing|automatic"],
["display.usb.timeout","USB display timeout in seconds (1-3600)",0],
["name","Node name",0],
["lat","Advert latitude",0],
["lon","Advert longitude",0],
@@ -2000,6 +2030,10 @@ var CLI_COMPANION_VERBS=[
["reboot","Restart the node"]
];
var CLI_COMPANION_KEYS=[
["display.mode","Display on battery",0,"off|on|button|pairing|button-pairing|automatic"],
["display.timeout","Battery display timeout in seconds (1-3600)",0],
["display.usb.mode","Display on USB power",0,"off|on|button|pairing|button-pairing|automatic"],
["display.usb.timeout","USB display timeout in seconds (1-3600)",0],
["prv.key","Read this node's private identity key",1],
["name","Node name",0], ["role","Node role",1],
["lat","Advert latitude",0], ["lon","Advert longitude",0],