Separate Companion message history from pending inbox

Default to retained history with a separate pending count, and add persistent display.inbox CLI modes for history, pending, and unread. Preserve browsing while clients are connected and show the active USB or Bluetooth connection.

Fixes #5
This commit is contained in:
mikecarper committed 2026-09-10 21:42:57 -07:00
1 parent 96ef0ae905
commit bbfe26b01d
26 files changed
+1178 -666

No files matched your search

+1
View File
@@ -109,6 +109,7 @@ jobs:
- name: Verify Companion Bluetooth settings and WebConfig
run: |
python3 -B test/test_display_power.py
python3 -B test/test_companion_inbox.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
+1
View File
@@ -84,6 +84,7 @@ over the normal binary USB, BLE, or TCP connection:
| [`get/set bluetooth.name`](cli_commands.md#view-or-change-the-independent-bluetooth-name-companion) | Companion firmware |
| [`get/set bluetooth.mac`](cli_commands.md#view-or-change-the-bluetooth-address-ble-companion) | Every Companion build with Bluetooth |
| [`get/set display.touch`](cli_commands.md#show-touchscreen-touch-areas) | Touchscreen Companions using the shared UI; text terminal and framed CLI; off after reboot |
| [`get/set display.inbox`](cli_commands.md#set-companion-inbox-behavior) | Shared Companion UI; `history` (default), `pending`, or `unread`; saved across reboot |
| [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | Every nRF52 Companion text terminal and local command `0x42`; local only |
Deprecated binary aliases remain receive-only for older clients; new clients
+36
View File
@@ -833,6 +833,42 @@ 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 Companion inbox behavior
The shared Companion UI (including T114 and R8) defaults to **History**: the
last 32 received text previews remain available on the radio after an app
downloads them. **Pending** separately counts frames waiting for the app,
including non-text frames. Downloading a frame clears it from Pending; it does
not mean someone read it. The phone's delivery queue is independent of the
32-entry preview history and is never drained by reading on the radio.
```text
get display.inbox
set display.inbox history
set display.inbox pending
set display.inbox unread
```
| Mode | Display behavior |
| --- | --- |
| `history` (default) | Shows **HISTORY** with the retained preview count, plus **Pending**. Downloaded previews remain browseable. |
| `pending` | Shows **INBOX** with the app queue count and limits the reader to previews still waiting for the app. Non-text frames count toward Pending but have no text preview. |
| `unread` | Shows **UNREAD** with the number of retained previews not yet displayed on the radio, plus **Pending**. Opening a preview on a lit display marks it read; app downloads do not. Read previews remain browseable. |
All modes retain up to 32 previews internally; switching from `pending` back to
`history` makes retained downloaded previews visible again. Evicted or rejected
queue entries are not shown as pending. Previews and local read state are in RAM
and reset on reboot. The mode applies immediately and survives reboot in
`/display_prefs`; existing version-1 display settings load with `history` as the
default. Legacy and tiny Companion UIs report `Error: inbox modes unsupported`.
Incoming messages automatically open their preview only when no client is
connected. While connected, new messages neither select another page nor move
the preview being read; history stays accessible by button or touch. The home
screen identifies connected USB and Bluetooth clients (or both), and TCP,
Ethernet, or serial clients where supported. Actual BLE pairing prompts retain
priority over connection status.
## Set MQTT observer display flip
Supported observer displays, including the R8 OLED and ST7789 panels, can also
+20 -2
View File
@@ -54,17 +54,35 @@ protected:
public:
void setShutdownGuard(UIShutdownGuard* guard) { _shutdownGuard = guard; }
void setHasConnection(bool connected) { _connected = connected; }
bool hasConnection() const { return _connected; }
bool hasConnection() const { return _interfaceManager->isConnected(); }
bool hasBluetoothConnection() const { return _interfaceManager->isBluetoothConnected(); }
const char* connectedClientLabel() const {
const bool ble = hasBluetoothConnection();
const bool usb = _interfaceManager->isInterfaceConnected(InterfaceType::USB);
if (ble && usb) return "BLE + USB";
if (ble) return "BLUETOOTH";
if (usb) return "USB";
if (_interfaceManager->isInterfaceConnected(InterfaceType::WiFi)) return "TCP";
if (_interfaceManager->isInterfaceConnected(InterfaceType::Ethernet)) return "ETHERNET";
if (_interfaceManager->isInterfaceConnected(InterfaceType::HardwareSerial)) return "SERIAL";
return nullptr;
}
uint16_t getBattMilliVolts() const { return _board->getBattMilliVolts(); }
bool isBluetoothEnabled() const { return _interfaceManager->isBluetoothEnabled(); }
void enableBluetooth() { _interfaceManager->enableBluetooth(); }
void disableBluetooth() { _interfaceManager->disableBluetooth(); }
virtual void msgRead(int msgcount) = 0;
virtual void syncMessageQueue(int msgcount, int removed_index = -1) {
(void)removed_index;
msgRead(msgcount);
}
virtual bool supportsInboxModes() const { return false; }
virtual void inboxModeChanged() {}
virtual void newMsg(uint8_t path_len, const char* from_name,
const char* text, int msgcount,
int channel_idx = -1,
const char* channel_name = nullptr) = 0;
const char* channel_name = nullptr,
int queue_index = -1) = 0;
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual bool supportsDisplayRotation() const { return false; }
virtual bool setDisplayRotationDegrees(uint16_t degrees) {
+26 -11
View File
@@ -470,11 +470,11 @@ void MyMesh::initializeOfflineQueue() {
#endif
}
void MyMesh::addToOfflineQueue(const uint8_t frame[], int len) {
bool MyMesh::addToOfflineQueue(const uint8_t frame[], int len) {
const int capacity = getOfflineQueueCapacity();
if (!frame || len <= 0 || len > MAX_FRAME_SIZE || capacity <= 0) {
MESH_DEBUG_PRINTLN("WARN: invalid offline queue frame length: %d", len);
return;
return false;
}
if (offline_queue_len >= capacity) {
@@ -489,7 +489,10 @@ void MyMesh::addToOfflineQueue(const uint8_t frame[], int len) {
Frame& tail = offlineQueueFrameAt(offline_queue_len - 1);
tail.len = len;
memcpy(tail.buf, frame, len);
return;
#ifdef DISPLAY_CLASS
if (_ui) _ui->syncMessageQueue(offline_queue_len, pos);
#endif
return true;
}
pos++;
}
@@ -499,7 +502,12 @@ void MyMesh::addToOfflineQueue(const uint8_t frame[], int len) {
tail.len = len;
memcpy(tail.buf, frame, len);
offline_queue_len++;
#ifdef DISPLAY_CLASS
if (_ui) _ui->syncMessageQueue(offline_queue_len);
#endif
return true;
}
return false;
}
int MyMesh::getFromOfflineQueue(uint8_t frame[]) {
@@ -514,6 +522,9 @@ int MyMesh::getFromOfflineQueue(uint8_t frame[]) {
} else {
offline_queue_head = (offline_queue_head + 1) % getOfflineQueueCapacity();
}
#ifdef DISPLAY_CLASS
if (_ui) _ui->syncMessageQueue(offline_queue_len, 0);
#endif
return len;
}
return 0; // queue is empty
@@ -923,7 +934,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
}
memcpy(&out_frame[i], text, tlen);
i += tlen;
addToOfflineQueue(out_frame, i);
const bool queued = addToOfflineQueue(out_frame, i);
if (_serial->isConnected()) {
uint8_t frame[1];
@@ -950,7 +961,8 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
// we only want to show text messages on display, not cli data
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
if (should_display && _ui) {
_ui->newMsg(path_len, from.name, text, offline_queue_len);
_ui->newMsg(path_len, from.name, text, offline_queue_len,
-1, nullptr, queued ? offline_queue_len - 1 : -1);
if (!_serial->isConnected()) {
_ui->notify(UIEventType::contactMessage);
}
@@ -1120,7 +1132,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
}
memcpy(&out_frame[i], text, tlen);
i += tlen;
addToOfflineQueue(out_frame, i);
const bool queued = addToOfflineQueue(out_frame, i);
if (_serial->isConnected()) {
uint8_t frame[1];
@@ -1154,7 +1166,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
(unsigned int)channel_idx, channel_name);
if (_ui) {
_ui->newMsg(path_len, channel_label, text, offline_queue_len,
channel_idx, channel_name);
channel_idx, channel_name, queued ? offline_queue_len - 1 : -1);
}
#endif
@@ -2555,7 +2567,11 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
return true;
}
if (mesh::ui::handleDisplayPowerCommand(command, reply, reply_size)) return true;
if (mesh::ui::handleDisplayPowerCommand(command, reply, reply_size,
_ui != nullptr && _ui->supportsInboxModes())) {
if (_ui) _ui->inboxModeChanged();
return true;
}
if (strcmp(command, "get display.touch") == 0) {
if (_ui == NULL || !_ui->supportsTouchDebug()) {
@@ -4573,9 +4589,6 @@ void MyMesh::handleCmdFrame(size_t len) {
int out_len;
if ((out_len = getFromOfflineQueue(out_frame)) > 0) {
_serial->writeFrame(out_frame, out_len);
#ifdef DISPLAY_CLASS
if (_ui) _ui->msgRead(offline_queue_len);
#endif
} else {
out_frame[0] = RESP_CODE_NO_MORE_MESSAGES;
_serial->writeFrame(out_frame, 1);
@@ -7932,6 +7945,8 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" memory\r\n");
#endif
terminalOutput().print(" get display.rotation\r\n");
terminalOutput().print(" get display.inbox\r\n");
terminalOutput().print(" set display.inbox <history|pending|unread>\r\n");
terminalOutput().print(" set display.rotation <0|90|180|270>\r\n");
terminalOutput().print(" get display.touch\r\n");
terminalOutput().print(" set display.touch <on|off> (this boot only)\r\n");
+1 -1
View File
@@ -363,7 +363,7 @@ private:
bool writeContactRespFrame(uint8_t code, const ContactInfo &contact);
void stopContactsIterator();
bool updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len);
void addToOfflineQueue(const uint8_t frame[], int len);
bool addToOfflineQueue(const uint8_t frame[], int len);
int getFromOfflineQueue(uint8_t frame[]);
int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override {
return _store->getBlobByKey(key, key_len, dest_buf);
+97 -58
View File
@@ -35,9 +35,6 @@
#ifndef AUTO_OFF_MILLIS
#define AUTO_OFF_MILLIS 15000 // 15 seconds
#endif
#ifndef USB_MESSAGE_PREVIEW_MILLIS
#define USB_MESSAGE_PREVIEW_MILLIS 15000UL
#endif
#ifndef UI_RADIO_REFRESH_MILLIS
#define UI_RADIO_REFRESH_MILLIS 2000UL
#endif
@@ -665,20 +662,30 @@ public:
display.renderWidth(), display.renderHeight());
if (expanded_home) {
display.setTextSize(4);
display.drawTextCentered(display.width() / 2, 20, "INBOX");
if (display.getTextWidth(_task->inboxTitle()) > display.width()) display.setTextSize(3);
display.drawTextCentered(display.width() / 2, 20, _task->inboxTitle());
} else {
sprintf(tmp, "INBOX: %d", _task->getPreviewCount());
snprintf(tmp, sizeof(tmp), "%s: %d", _task->inboxTitle(), _task->getPreviewCount());
display.setTextSize(2);
if (display.getTextWidth(tmp) > display.width()) display.setTextSize(1);
display.drawTextCentered(display.width() / 2, body_top + 2, tmp);
}
#else
display.setTextSize(2);
sprintf(tmp, "INBOX: %d", _task->getPreviewCount());
const int home_content_top = body_top + 2 + display.textLineHeight()
snprintf(tmp, sizeof(tmp), "%s: %d", _task->inboxTitle(), _task->getPreviewCount());
if (display.getTextWidth(tmp) > display.width()) display.setTextSize(1);
int home_content_top = body_top + 2 + display.textLineHeight()
+ (display.height() > 64 ? 2 : 0);
mesh::ui::drawTextCenteredEllipsized(display,
{0, body_top + 2, display.width(), display.textLineHeight()},
body_top + 2, tmp);
display.setTextSize(1);
display.setColor(UIColor::secondary_txt);
snprintf(tmp, sizeof(tmp), "Pending: %d", _task->getMsgCount());
mesh::ui::drawTextCenteredEllipsized(display,
{0, home_content_top, display.width(), display.textLineHeight()},
home_content_top, tmp);
home_content_top += display.textLineHeight() + 1;
#endif
#ifdef UI_DEDICATED_PAIRING_BLOCK
const mesh::ui::CompanionHomeLayout layout =
@@ -695,19 +702,23 @@ public:
if (expanded_home) {
snprintf(tmp, sizeof(tmp), "%d", _task->getPreviewCount());
display.setTextSize(4);
display.setTextSize(3);
if (display.getTextWidth(tmp) > layout.info.width) {
display.setTextSize(3);
display.setTextSize(2);
}
display.setColor(UIColor::primary_txt);
mesh::ui::drawTextCenteredEllipsized(
display, layout.info, layout.instruction_y, tmp);
display.setTextSize(1);
snprintf(tmp, sizeof(tmp), "Pending: %d", _task->getMsgCount());
mesh::ui::drawTextCenteredEllipsized(
display, layout.info, layout.info.bottom() - display.textLineHeight(), tmp);
} else {
display.setTextSize(1);
display.setColor(UIColor::secondary_txt);
snprintf(tmp, sizeof(tmp), "Pending: %d", _task->getMsgCount());
mesh::ui::drawTextCenteredEllipsized(
display, layout.info, layout.instruction_y,
PRESS_LABEL ": inbox");
display, layout.info, layout.instruction_y, tmp);
#ifdef WIFI_SSID
if (!isCompanionWiFiEnabled()) {
@@ -731,11 +742,12 @@ public:
const bool bluetooth_connected = _task->hasBluetoothConnection();
const uint32_t bluetooth_pin = the_mesh.getBLEPin();
const char* pairing_label = nullptr;
const char* client_label = _task->connectedClientLabel();
const char* pairing_value = nullptr;
int pairing_value_size = 2;
char pairing_pin[16];
if (bluetooth_connected) {
pairing_label = expanded_home ? "BLE" : "BLUETOOTH";
if (client_label != nullptr && !_task->isPairingPromptActive()) {
pairing_label = expanded_home && strcmp(client_label, "BLUETOOTH") == 0 ? "BLE" : client_label;
pairing_value = expanded_home ? "LINKED" : "CONNECTED";
if (expanded_home) pairing_value_size = 3;
} else if (mesh::ui::shouldDisplayBluetoothPairingPin(
@@ -792,12 +804,13 @@ public:
mesh::ui::shouldDisplayBluetoothPairingPin(
bluetooth_enabled, bluetooth_connected, bluetooth_pin)
&& (!_task->isPairingPromptActive() || display.height() > 64);
if (bluetooth_connected || show_bluetooth_pin) {
const char* client_label = _task->connectedClientLabel();
if (client_label != nullptr || show_bluetooth_pin) {
char pairing_pin[16];
const char* pairing_label = bluetooth_connected
? "BLUETOOTH" : "BLUETOOTH PIN";
const bool show_client = client_label != nullptr && !_task->isPairingPromptActive();
const char* pairing_label = show_client ? client_label : "BLUETOOTH PIN";
const char* pairing_value = "CONNECTED";
if (!bluetooth_connected) {
if (!show_client) {
snprintf(pairing_pin, sizeof(pairing_pin), "%06u",
(unsigned int)bluetooth_pin);
pairing_value = pairing_pin;
@@ -811,7 +824,7 @@ public:
display.height() - home_content_top});
display.setTextSize(1);
display.setColor(UIColor::secondary_txt);
rows.draw(PRESS_LABEL ": inbox");
rows.draw(PRESS_LABEL ": messages");
#ifdef UI_SHOW_CLOCK
display.setTextSize(3);
@@ -1257,9 +1270,14 @@ class MsgPreviewScreen : public UIScreen {
int view_offset;
int channel_filter;
bool visible(const MsgEntry& entry) const {
return mesh::ui::displayPowerPrefs().inbox != mesh::ui::DisplayInboxMode::Pending
|| entry.queue_index >= 0;
}
bool matchesFilter(const MsgEntry& entry) const {
return channel_filter == CHANNEL_FILTER_ALL
|| entry.channel_idx == channel_filter;
return visible(entry) && (channel_filter == CHANNEL_FILTER_ALL
|| entry.channel_idx == channel_filter);
}
int filteredCount() const {
@@ -1380,18 +1398,37 @@ public:
explicit MsgPreviewScreen(UITask* task)
: _task(task), view_offset(0), channel_filter(CHANNEL_FILTER_ALL) {}
bool hasMessages() const { return !history.empty(); }
int messageCount() const { return (int)history.count(); }
bool hasMessages() const {
for (size_t age = 0; age < history.count(); ++age)
if (visible(*history.newest(age))) return true;
return false;
}
int messageCount() const {
if (mesh::ui::displayPowerPrefs().inbox == mesh::ui::DisplayInboxMode::Unread)
return (int)history.unreadCount();
if (mesh::ui::displayPowerPrefs().inbox == mesh::ui::DisplayInboxMode::Pending)
return _task->getMsgCount();
return (int)history.count();
}
void queueRemoved(int index) { history.queueRemoved(index); }
void addPreview(uint8_t path_len, const char* from_name, const char* msg,
int channel_idx, const char* channel_name) {
int channel_idx, const char* channel_name,
int queue_index = -1, bool select = true) {
if (channel_idx < CHANNEL_FILTER_DIRECT
|| channel_idx >= MAX_GROUP_CHANNELS) {
channel_idx = CHANNEL_FILTER_DIRECT;
channel_name = nullptr;
}
view_offset = 0;
channel_filter = channel_idx;
if (select) {
view_offset = 0;
channel_filter = channel_idx;
} else if ((mesh::ui::displayPowerPrefs().inbox != mesh::ui::DisplayInboxMode::Pending
|| queue_index >= 0)
&& (channel_filter == CHANNEL_FILTER_ALL || channel_filter == channel_idx)) {
// Retain the message being read when a connected app receives another.
if (filteredCount() > 0) ++view_offset;
}
char origin[62];
if (path_len == 0xFF) {
@@ -1401,7 +1438,7 @@ public:
(unsigned int)path_len);
}
history.add(companionMessageNowMillis(), channel_idx, channel_name,
origin, msg);
origin, msg, queue_index);
}
void renderSummary(DisplayDriver& display) const {
@@ -1416,9 +1453,16 @@ public:
for (size_t age = 0;
age < history.count() && rendered < layout.visible_rows;
++age) {
if (history.hasNewerEntryForThread(age)) continue;
const MsgEntry* entry = history.newest(age);
if (entry == nullptr) continue;
if (entry == nullptr || !visible(*entry)) continue;
bool newer_in_thread = false;
for (size_t newer = 0; newer < age; ++newer) {
const MsgEntry* candidate = history.newest(newer);
if (visible(*candidate) && MessageHistory::sameThread(*entry, *candidate)) {
newer_in_thread = true; break;
}
}
if (newer_in_thread) continue;
const int row_y = layout.top + rendered * layout.row_height;
char label[48];
@@ -1535,6 +1579,7 @@ public:
filtered_count == 0 ? 0 : view_offset + 1, filtered_count);
#endif
const MsgEntry* p = filteredEntry(view_offset);
if (p != nullptr && display.isOn()) history.markRead(p);
char age[16] = {};
if (p != nullptr) {
mesh::ui::formatCompanionMessageAge(
@@ -1741,6 +1786,14 @@ int UITask::getPreviewCount() const {
return static_cast<const MsgPreviewScreen*>(msg_preview)->messageCount();
}
const char* UITask::inboxTitle() const {
switch (mesh::ui::displayPowerPrefs().inbox) {
case mesh::ui::DisplayInboxMode::Pending: return "INBOX";
case mesh::ui::DisplayInboxMode::Unread: return "UNREAD";
default: return "HISTORY";
}
}
void UITask::renderMessageSummary(DisplayDriver& display) const {
static_cast<const MsgPreviewScreen*>(msg_preview)->renderSummary(display);
}
@@ -1776,28 +1829,27 @@ switch(t){
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
_deferred_msg_preview = false;
const bool holding_usb_preview = curr == msg_preview && _msg_preview_until != 0
&& static_cast<int32_t>(millis() - _msg_preview_until) < 0;
if (!holding_usb_preview
#if COMPANION_FEATURE_JOHN
&& !isJohnReaderActive()
#endif
) {
gotoHomeScreen();
}
}
_next_refresh = 0;
}
void UITask::syncMessageQueue(int msgcount, int removed_index) {
if (msg_preview != nullptr)
static_cast<MsgPreviewScreen*>(msg_preview)->queueRemoved(removed_index);
msgRead(msgcount);
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx,
const char* channel_name) {
const char* channel_name, int queue_index) {
_msgcount = msgcount;
((MsgPreviewScreen *)msg_preview)
->addPreview(path_len, from_name, text, channel_idx, channel_name);
if (isPairingScreenActive()) {
->addPreview(path_len, from_name, text, channel_idx, channel_name,
queue_index, !hasConnection());
if (hasConnection()) {
// Keep history, but do not change the page or wake for a connected client.
_deferred_msg_preview = false;
} else if (isPairingScreenActive()) {
// Keep the PIN visible, but retain the preview so it can be shown after
// pairing completes or the pairing display window expires.
_deferred_msg_preview = true;
@@ -1812,14 +1864,6 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
setCurrScreen(msg_preview);
}
// A connected app drains the offline queue almost immediately, which calls
// msgRead(0). While attached to a computer, retain the actual message screen
// for the configured preview interval even though the app has already
// consumed the message.
_msg_preview_until = _board->isUsbHostConnected()
? millis() + USB_MESSAGE_PREVIEW_MILLIS
: 0;
if (_display != NULL) {
if (shouldWakeDisplayForMessage()) {
_display->wake(mesh::ui::DisplayWake::Message);
@@ -1901,7 +1945,8 @@ void UITask::showPairingPin() {
void UITask::finishPairingScreen(bool timed_out) {
_pairing_screen_until = 0;
if (_deferred_msg_preview && _msgcount > 0) {
if (_deferred_msg_preview && !hasConnection()
&& static_cast<MsgPreviewScreen*>(msg_preview)->hasMessages()) {
_deferred_msg_preview = false;
setCurrScreen(msg_preview);
} else {
@@ -2179,12 +2224,6 @@ void UITask::loop() {
if (john_reader && curr != john_reader) john_reader->poll();
#endif
if (_msgcount == 0 && _msg_preview_until != 0
&& static_cast<int32_t>(millis() - _msg_preview_until) >= 0) {
_msg_preview_until = 0;
if (curr == msg_preview) gotoHomeScreen();
}
if (_display != NULL && _display->isOn()) {
if (millis() >= _next_refresh && curr && !isButtonGesturePending()) {
_display->startFrame();
+7 -4
View File
@@ -36,7 +36,6 @@ class UITask : public AbstractUITask {
GenericVibration vibration;
#endif
unsigned long _next_refresh;
unsigned long _msg_preview_until;
unsigned long _pairing_screen_until;
bool _deferred_msg_preview;
CompanionNodePrefs* _node_prefs;
@@ -93,7 +92,7 @@ class UITask : public AbstractUITask {
UIScreen* splash;
UIScreen* home;
UIScreen* msg_preview;
UIScreen* msg_preview = nullptr;
UIScreen* curr;
#if COMPANION_FEATURE_JOHN
UIScreen* john_reader = nullptr;
@@ -118,7 +117,6 @@ public:
UITask(mesh::MainBoard* board, MultiSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
next_batt_chck = _next_refresh = 0;
_msg_preview_until = 0;
_pairing_screen_until = 0;
_deferred_msg_preview = false;
_msgcount = 0;
@@ -180,9 +178,14 @@ public:
// from AbstractUITask
void msgRead(int msgcount) override;
void syncMessageQueue(int msgcount, int removed_index = -1) override;
bool supportsInboxModes() const override { return _display != nullptr; }
void inboxModeChanged() override { _next_refresh = 0; }
const char* inboxTitle() const;
void newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx = -1,
const char* channel_name = nullptr) override;
const char* channel_name = nullptr,
int queue_index = -1) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;
+2 -1
View File
@@ -165,7 +165,8 @@ void UITask::clearMsgPreview() {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx,
const char* channel_name) {
const char* channel_name, int queue_index) {
(void)queue_index;
(void)channel_idx;
(void)channel_name;
_msgcount = msgcount;
+2 -1
View File
@@ -94,7 +94,8 @@ public:
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx = -1,
const char* channel_name = nullptr) override;
const char* channel_name = nullptr,
int queue_index = -1) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;
+2 -1
View File
@@ -564,7 +564,8 @@ void UITask::msgRead(int msgcount) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx,
const char* channel_name) {
const char* channel_name, int queue_index) {
(void)queue_index;
(void)channel_idx;
(void)channel_name;
_msgcount = msgcount;
+2 -1
View File
@@ -122,7 +122,8 @@ public:
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx = -1,
const char* channel_name = nullptr) override;
const char* channel_name = nullptr,
int queue_index = -1) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;
+5 -1
View File
@@ -119,10 +119,14 @@ public:
}
bool isBluetoothConnected() const {
return isInterfaceConnected(InterfaceType::Bluetooth);
}
bool isInterfaceConnected(InterfaceType type) const {
if (!_enabled) return false;
for (auto iface : _interfaces) {
if (iface.instance && iface.type == InterfaceType::Bluetooth
if (iface.instance && iface.type == type
&& iface.instance->isEnabled() && iface.instance->isConnected()) {
return true;
}
+1
View File
@@ -23,6 +23,7 @@ static const char* const WC_ALLOWED_SET_KEYS[] = {
"cad", "radio.rxgain", "radio.fem.rxgain", "radio.rxps", "powersaving",
"repeat",
"display.mode", "display.timeout", "display.usb.mode", "display.usb.timeout",
"display.inbox",
"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
+29 -1
View File
@@ -84,6 +84,8 @@ class CompanionMessageHistory {
public:
struct Entry {
uint64_t heard_millis;
int queue_index;
bool unread;
int channel_idx;
char channel_name[32];
char origin[62];
@@ -133,12 +135,14 @@ public:
void add(uint64_t heard_millis, int channel_idx,
const char* channel_name, const char* origin,
const char* message) {
const char* message, int queue_index = -1) {
_head = (_head + 1) % Capacity;
if (_count < Capacity) ++_count;
Entry& entry = _entries[_head];
entry.heard_millis = heard_millis;
entry.queue_index = queue_index;
entry.unread = true;
entry.channel_idx = channel_idx;
copyField(entry.channel_name, sizeof(entry.channel_name), channel_name);
copyField(entry.origin, sizeof(entry.origin), origin);
@@ -151,6 +155,30 @@ public:
return &_entries[index];
}
// Queue positions also account for non-display frames and channel eviction.
// Downloading to an app never marks a message as read on the radio.
void queueRemoved(int index) {
if (index < 0) return;
for (size_t age = 0; age < _count; ++age) {
Entry& entry = _entries[(_head + Capacity - age) % Capacity];
if (entry.queue_index == index) entry.queue_index = -1;
else if (entry.queue_index > index) --entry.queue_index;
}
}
void markRead(const Entry* selected) {
for (size_t age = 0; age < _count; ++age) {
Entry& entry = _entries[(_head + Capacity - age) % Capacity];
if (&entry == selected) { entry.unread = false; return; }
}
}
size_t unreadCount() const {
size_t count = 0;
for (size_t age = 0; age < _count; ++age) if (newest(age)->unread) ++count;
return count;
}
static bool sameThread(const Entry& left, const Entry& right) {
if (left.channel_idx >= 0 || right.channel_idx >= 0) {
return left.channel_idx >= 0 && right.channel_idx >= 0
+10
View File
@@ -7,6 +7,15 @@ namespace mesh { namespace ui {
enum class DisplayMode : uint8_t { Off, On, Button, Pairing, ButtonPairing, Automatic };
enum class DisplayWake : uint8_t { Boot, Button, Message };
enum class DisplayInboxMode : uint8_t { History, Pending, Unread };
inline const char* displayInboxModeName(DisplayInboxMode mode) {
switch (mode) {
case DisplayInboxMode::Pending: return "pending";
case DisplayInboxMode::Unread: return "unread";
default: return "history";
}
}
struct DisplayPowerProfile {
DisplayMode mode = DisplayMode::Button;
@@ -15,6 +24,7 @@ struct DisplayPowerProfile {
struct DisplayPowerPrefs {
DisplayPowerProfile battery;
DisplayPowerProfile usb;
DisplayInboxMode inbox = DisplayInboxMode::History;
};
inline DisplayPowerPrefs& displayPowerPrefs() {
+29 -7
View File
@@ -12,12 +12,12 @@ 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[0] = 'D'; data[1] = 'P'; data[2] = 2;
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;
data[9] = uint8_t(prefs.inbox); data[10] = 0; data[11] = 0;
for (unsigned i = 0; i < 11; ++i) data[11] ^= data[i];
}
bool readPrefs(const char* name, DisplayPowerPrefs& prefs) {
@@ -37,9 +37,16 @@ bool readPrefs(const char* name, DisplayPowerPrefs& prefs) {
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);
prefs.inbox = data[2] == 1 ? DisplayInboxMode::History : DisplayInboxMode(data[9]);
encode(prefs, expected);
if (data[2] == 1) {
// Version 1 reserved byte 9 and had no inbox preference.
expected[2] = 1;
expected[11] ^= 0x02 ^ 0x01;
}
return memcmp(data, expected, sizeof(data)) == 0
&& validDisplayProfile(prefs.battery) && validDisplayProfile(prefs.usb);
&& validDisplayProfile(prefs.battery) && validDisplayProfile(prefs.usb)
&& uint8_t(prefs.inbox) <= uint8_t(DisplayInboxMode::Unread);
}
bool savePrefs(const DisplayPowerPrefs& prefs) {
if (!settings_fs) return false;
@@ -106,14 +113,15 @@ void migrateLegacyDisplayTimeout(uint16_t seconds) {
savePrefs(prefs);
}
bool handleDisplayPowerCommand(const char* command, char* reply, size_t reply_size) {
bool handleDisplayPowerCommand(const char* command, char* reply, size_t reply_size,
bool inbox_supported) {
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"};
const char* keys[] = {"display.mode", "display.timeout", "display.usb.mode", "display.usb.timeout", "display.inbox"};
int index = -1;
for (int i = 0; i < 4; ++i) {
for (int i = 0; i < 5; ++i) {
const size_t len = strlen(keys[i]);
if (strncmp(key, keys[i], len) == 0 && (key[len] == 0 || key[len] == ' ')) {
index = i; break;
@@ -121,20 +129,34 @@ bool handleDisplayPowerCommand(const char* command, char* reply, size_t reply_si
}
if (index < 0) return false;
#ifndef MESHCORE_HAS_REAL_DISPLAY
(void)inbox_supported;
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 (index == 4 && !inbox_supported) {
snprintf(reply, reply_size, "Error: inbox modes unsupported");
return true;
}
if (get) {
if (*value) snprintf(reply, reply_size, "Error: unexpected argument");
else if (index == 4) snprintf(reply, reply_size, "> %s", displayInboxModeName(candidate.inbox));
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) {
if (index == 4) {
unsigned mode = 0;
while (mode <= unsigned(DisplayInboxMode::Unread)
&& strcmp(value, displayInboxModeName(DisplayInboxMode(mode))) != 0) ++mode;
if (mode > unsigned(DisplayInboxMode::Unread)) {
snprintf(reply, reply_size, "Error: use history|pending|unread"); return true;
}
candidate.inbox = DisplayInboxMode(mode);
} else if (index & 1) {
uint32_t seconds = 0;
bool valid = *value != 0;
for (const char* p = value; *p && valid; ++p) {
+2 -1
View File
@@ -8,7 +8,8 @@ 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 handleDisplayPowerCommand(const char* command, char* reply, size_t reply_size,
bool inbox_supported = false);
bool displayPairingSupported();
void migrateLegacyDisplayTimeout(uint16_t seconds);
+1 -1
View File
@@ -84,7 +84,7 @@ static_assert(mesh::ui::isBluetoothPairingPromptActive(
full_ui = UI_VARIANTS[0].read_text(encoding="utf-8")
self.assertNotIn("else if (bluetooth_pin != 0)", full_ui)
self.assertIn("if (bluetooth_connected || show_bluetooth_pin)", full_ui)
self.assertIn("if (client_label != nullptr || show_bluetooth_pin)", full_ui)
self.assertIn("drawBottomPairingBlock(display, home_content_top,", full_ui)
self.assertIn("_pairing_screen_until = 0;", full_ui)
+294
View File
@@ -0,0 +1,294 @@
"""Exercise the real message UI and queue together, including app downloads."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_message_navigation import PREAMBLE
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
UI_MEMBERS = r'''
int _msgcount = 0;
bool connected = false, pairing = false, _deferred_msg_preview = false;
int getMsgCount() const { return _msgcount; }
bool hasConnection() const { return connected; }
bool isPairingScreenActive() const { return pairing; }
bool shouldWakeDisplayForMessage() const { return !connected; }
void setCurrScreen(UIScreen* screen) { curr = screen; }
void newMsg(uint8_t, const char*, const char*, int, int, const char*, int);
void msgRead(int);
void syncMessageQueue(int, int = -1);
int getPreviewCount() const;
const char* inboxTitle() const;
const char* client = "USB";
const char* connectedClientLabel() const { return connected ? client : nullptr; }
bool hasBluetoothConnection() const { return connected && std::string(client) == "BLUETOOTH"; }
bool isBluetoothEnabled() const { return true; }
bool isPairingPromptActive() const { return pairing; }
'''
QUEUE = r'''
#define DISPLAY_CLASS TestDisplay
#define MAX_FRAME_SIZE 32
class MyMesh {
struct Frame {
int len;
uint8_t buf[MAX_FRAME_SIZE];
bool isChannelMsg() const { return buf[0] == 17; }
} frames[4];
public:
UITask* _ui;
int offline_queue_len = 0, offline_queue_head = 0;
explicit MyMesh(UITask* ui) : _ui(ui) {}
int getOfflineQueueCapacity() const { return 4; }
Frame& offlineQueueFrameAt(int index) { return frames[(offline_queue_head + index) % 4]; }
bool addToOfflineQueue(const uint8_t*, int);
int getFromOfflineQueue(uint8_t*);
bool receive(const char* text, uint8_t kind = 17) {
uint8_t bytes[2] = {kind, uint8_t(text[0])};
bool queued = addToOfflineQueue(bytes, sizeof(bytes));
_ui->newMsg(1, "Alice", text, offline_queue_len, 0, "Public",
queued ? offline_queue_len - 1 : -1);
return queued;
}
void download() { uint8_t frame[MAX_FRAME_SIZE]; getFromOfflineQueue(frame); }
};
'''
SCENARIOS = r'''
using namespace mesh::ui;
int main() {
resetArduinoMock();
displayPowerPrefs().battery.mode = DisplayMode::On;
Display display;
display.servicePower(false);
Screen home;
UITask task(display);
MsgPreviewScreen messages(&task);
task.home = task.curr = &home;
task.msg_preview = &messages;
MyMesh mesh(&task);
// Reproduce issue #5: twelve messages arrive and the connected app fetches
// every one. History retains twelve, while Pending returns to zero.
task.connected = true;
for (int i = 0; i < 12; ++i) {
mesh.receive("received by phone");
assert(task.curr == &home);
assert(task.getMsgCount() == 1);
mesh.download();
assert(task.curr == &home && task.getMsgCount() == 0);
assert(task.getPreviewCount() == i + 1);
}
assert(std::string(task.inboxTitle()) == "HISTORY");
displayPowerPrefs().inbox = DisplayInboxMode::Pending;
assert(task.getPreviewCount() == 0 && !messages.hasMessages());
assert(std::string(task.inboxTitle()) == "INBOX");
displayPowerPrefs().inbox = DisplayInboxMode::Unread;
assert(task.getPreviewCount() == 12); // App delivery is not a local read.
assert(std::string(task.inboxTitle()) == "UNREAD");
task.curr = &messages;
display.turnOff();
messages.render(display);
assert(task.getPreviewCount() == 12); // A dark display cannot mark it read.
display.turnOn();
messages.render(display);
assert(task.getPreviewCount() == 11);
messages.render(display);
assert(task.getPreviewCount() == 11); // Redraws do not consume another item.
// A connected client's new message must not replace the text being browsed.
displayPowerPrefs().inbox = DisplayInboxMode::History;
mesh.receive("new while browsing");
display.clear(); messages.render(display);
assert(display.contains("received by phone"));
mesh.download();
assert(task.curr == &messages && task.getPreviewCount() == 13);
// Without a client, select the new arrival. Reading it does not drain the
// delivery queue; it remains available to the next phone connection.
task.connected = false;
task.curr = &home;
mesh.receive("offline arrival");
assert(task.curr == &messages && task.getMsgCount() == 1);
display.clear(); messages.render(display);
assert(display.contains("offline arrival"));
assert(mesh.offline_queue_len == 1);
mesh.download();
// Non-text frames occupy queue positions too. Full queues can evict a
// channel message in the middle; Pending must hide only that exact entry.
displayPowerPrefs().inbox = DisplayInboxMode::Pending;
const uint8_t other[1] = {99};
assert(mesh.addToOfflineQueue(other, 1));
assert(task.getMsgCount() == 1 && !messages.hasMessages());
mesh.receive("evicted channel");
mesh.receive("retained channel");
assert(mesh.addToOfflineQueue(other, 1));
mesh.receive("replacement channel");
assert(task.getMsgCount() == 4);
assert(messages.messageCount() == 4); // Includes the two non-text frames.
display.clear(); messages.render(display);
assert(display.contains("replacement channel"));
messages.handleInput(KEY_NEXT);
display.clear(); messages.render(display);
assert(display.contains("retained channel"));
messages.handleInput(KEY_NEXT);
assert(task.curr == &home); // Evicted entry was excluded.
mesh.download(); // Non-text head, with no matching history entry.
assert(task.getMsgCount() == 3);
mesh.download(); // Retained channel.
mesh.download(); // Other non-text frame.
assert(task.getMsgCount() == 1 && messages.hasMessages());
mesh.download();
assert(task.getMsgCount() == 0 && !messages.hasMessages());
// Rejecting a fifth direct message must not assign it another frame's slot.
for (int i = 0; i < 4; ++i) assert(mesh.addToOfflineQueue(other, 1));
assert(!mesh.receive("not queued", 7));
assert(!messages.hasMessages());
while (mesh.offline_queue_len) mesh.download();
assert(!messages.hasMessages());
displayPowerPrefs().inbox = DisplayInboxMode::History;
for (int i = 0; i < 40; ++i) { mesh.receive("ring wrap"); mesh.download(); }
assert(task.getPreviewCount() == 32 && task.getMsgCount() == 0);
displayPowerPrefs().inbox = DisplayInboxMode::Pending;
assert(!messages.hasMessages());
}
'''
class CompanionInboxTest(unittest.TestCase):
def test_labels_use_enabled_connected_interfaces(self):
abstract = (ROOT / "examples/companion_radio/AbstractUITask.h").read_text()
label = extract_braced(abstract, "const char* connectedClientLabel() const")
source = r'''
#include <helpers/MultiSerialInterface.h>
#include <cassert>
#include <string>
struct Link : BaseSerialInterface {
bool enabled = false, connected = false;
void enable() override { enabled = true; }
void disable() override { enabled = false; }
bool isEnabled() const override { return enabled; }
bool isConnected() const override { return connected; }
bool isReadBusy() const override { return false; }
bool isWriteBusy() const override { return false; }
size_t writeFrame(const uint8_t*, size_t len) override { return len; }
size_t checkRecvFrame(uint8_t*) override { return 0; }
};
struct UI {
MultiSerialInterface* _interfaceManager;
bool hasBluetoothConnection() const { return _interfaceManager->isBluetoothConnected(); }
'''+ label + r'''
};
int main() {
MultiSerialInterface interfaces;
Link usb, ble, wifi;
interfaces.addInterface(InterfaceType::USB, &usb);
interfaces.addInterface(InterfaceType::Bluetooth, &ble);
interfaces.addInterface(InterfaceType::WiFi, &wifi);
UI ui{&interfaces};
interfaces.enable();
assert(ui.connectedClientLabel() == nullptr);
usb.connected = true;
assert(std::string(ui.connectedClientLabel()) == "USB");
ble.connected = true;
assert(std::string(ui.connectedClientLabel()) == "BLE + USB");
usb.disable();
assert(std::string(ui.connectedClientLabel()) == "BLUETOOTH");
ble.connected = false;
wifi.connected = true;
assert(std::string(ui.connectedClientLabel()) == "TCP");
interfaces.disable();
assert(ui.connectedClientLabel() == nullptr);
}
'''
self.compile_and_run(source)
def test_home_history_pending_and_connections_fit(self):
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
first_page = extract_braced(source, "if (_page == HomePage::FIRST)")
first_page = first_page.rsplit("#if UI_MESSAGES_HOME_PAGE == 1", 1)[0] + "}"
first_page = first_page.replace("if (_page == HomePage::FIRST)", "")
preamble = PREAMBLE.replace("int getMsgCount() const { return 0; }", UI_MEMBERS)
preamble = preamble.replace("bool getChannel(int channel", "uint32_t getBLEPin() const { return 123456; }\n bool getChannel(int channel")
implementation = r'''
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#define PRESS_LABEL "Press"
int UITask::getPreviewCount() const { return 12; }
const char* UITask::inboxTitle() const { return "HISTORY"; }
void renderHome(DisplayDriver& display, UITask* _task) {
char tmp[80];
const int body_top = 14;
'''+ first_page + r'''
}
struct MeasuredDisplay : Display {
int size = 1;
MeasuredDisplay(int w, int h) { setDimensions(w, h); }
void setTextSize(int value) override { size = value; }
uint16_t getTextWidth(const char* text) override { return strlen(text) * 6 * size; }
void print(const char* text) override {
assert(y >= 0 && y + 8 * size <= height());
for (const auto& line : lines) {
// Home text owns distinct horizontal bands; every text run is centered.
assert(y >= line.y + line.color || y + 8 * size <= line.y);
}
Display::print(text);
lines.back().color = 8 * size; // Save glyph height for overlap detection.
}
};
int main() {
for (const auto dims : {std::pair<int,int>{240,135}, {135,240}, {128,64}, {160,80}}) {
for (const char* client : {"USB", "BLUETOOTH", "BLE + USB", "TCP"}) {
MeasuredDisplay display(dims.first, dims.second);
UITask task(display);
task._msgcount = 0; task.connected = true; task.client = client;
renderHome(display, &task);
assert(display.contains("HISTORY: 12"));
assert(display.contains("Pending: 0"));
assert(display.contains("CONNECTED"));
if (dims.second >= 135) assert(display.contains(client));
}
}
}
'''
self.compile_and_run(preamble + implementation)
def compile_and_run(self, source):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix="mesh-inbox-test-") as directory:
executable = Path(directory) / "inbox.exe"
result = subprocess.run([
compiler, "-std=c++17", "-DCOMPANION_FEATURE_JOHN=0",
"-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"),
"-x", "c++", "-", str(ROOT / "src/helpers/ui/MomentaryButton.cpp"),
str(ROOT / "src/helpers/ui/DisplayDriver.cpp"), "-o", str(executable),
], input=source, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(executable)], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_real_ui_and_delivery_queue(self):
ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
preamble = PREAMBLE.replace("int getMsgCount() const { return 0; }", UI_MEMBERS)
implementation = extract_braced(ui, "class MsgPreviewScreen :") + ";\n"
for signature in ("void UITask::newMsg(", "void UITask::msgRead(",
"void UITask::syncMessageQueue(", "int UITask::getPreviewCount(",
"const char* UITask::inboxTitle("):
implementation += extract_braced(ui, signature) + "\n"
implementation += QUEUE
for signature in ("bool MyMesh::addToOfflineQueue(", "int MyMesh::getFromOfflineQueue("):
implementation += extract_braced(mesh, signature) + "\n"
self.compile_and_run(preamble + implementation + SCENARIOS)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -127,6 +127,31 @@ void commandTests() {
run("get display.timeout", "> 27");
run("get display.usb.mode", "> on");
run("get display.usb.timeout", "> 123");
// Earlier 12-byte v1 profiles retain their power settings and gain History.
auto v1 = fs.files["/display_prefs"];
v1[2] = 1; v1[9] = 0; v1[11] = 0;
for (unsigned i = 0; i < 11; ++i) v1[11] ^= v1[i];
fs.files["/display_prefs"] = v1;
assert(loadDisplayPowerSettings(&fs, true));
assert(displayPowerPrefs().inbox == DisplayInboxMode::History);
run("get display.timeout", "> 27");
run("get display.inbox", "Error: inbox modes unsupported");
auto inbox = [&](const char* text, const char* expected) {
assert(handleDisplayPowerCommand(text, reply, sizeof(reply), true));
assert(std::string(reply) == expected);
};
inbox("get display.inbox", "> history");
for (const char* mode : {"pending", "unread", "history"}) {
inbox((std::string("set display.inbox ") + mode).c_str(), "OK");
assert(loadDisplayPowerSettings(&fs, true));
inbox("get display.inbox", (std::string("> ") + mode).c_str());
run("get display.timeout", "> 27");
run("get display.usb.timeout", "> 123");
}
inbox("set display.inbox typo", "Error: use history|pending|unread");
inbox("set display.inbox", "Error: use history|pending|unread");
inbox("get display.inbox extra", "Error: unexpected argument");
assert(!handleDisplayPowerCommand("get display.inboxes", reply, sizeof(reply), true));
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;
@@ -136,6 +161,8 @@ void commandTests() {
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;
inbox("set display.inbox unread", "Error: display settings save failed");
assert(displayPowerPrefs().inbox == DisplayInboxMode::History);
run("set display.mode off", "Error: display settings save failed");
assert(displayPowerPrefs().battery.mode == DisplayMode::Automatic);
assert(fs.files["/display_prefs"] == saved);
+3
View File
@@ -58,6 +58,9 @@ struct Task {
int count=0;
bool isBluetoothEnabled(){return enabled;} bool hasBluetoothConnection(){return connected;}
bool isPairingPromptActive(){return prompt;} int getPreviewCount(){return count;}
int getMsgCount(){return count;}
const char* inboxTitle(){return "HISTORY";}
const char* connectedClientLabel(){return connected ? "BLUETOOTH" : nullptr;}
};
struct Mesh { uint32_t pin=123456; uint32_t getBLEPin(){return pin;} } the_mesh;
struct HomePage { enum { FIRST=0 }; };
+3 -3
View File
@@ -171,9 +171,9 @@ class IndicatorDisplayProfileTest(unittest.TestCase):
# Native 480 reflows the spacious home page instead of globally
# enlarging every dense UI row. The title, count, action, and pairing
# state each occupy their own large row.
native_title = dimensions("INBOX", 4, 3, 1.2)
native_count = dimensions("9999", 4, 3, 1.2)
native_count_fallback = dimensions("99999", 3, 3, 1.2)
native_title = dimensions("HISTORY", 3, 3, 1.2)
native_count = dimensions("9999", 3, 3, 1.2)
native_count_fallback = dimensions("99999", 2, 3, 1.2)
self.assertLessEqual(native_title[0], 156)
self.assertLessEqual(native_title[1], 39)
self.assertLessEqual(native_count[0], 156)
+1
View File
@@ -120,6 +120,7 @@ public:
UIScreen* john_reader=nullptr;
uint32_t _auto_off=0,_next_refresh=0;
int buzzer_changes=0;
int getMsgCount() const { return 0; }
explicit UITask(DisplayDriver& display) : _display(&display) {}
bool isJohnReaderActive() const { return john_reader && curr==john_reader; }
void gotoHomeScreen() { curr=home; }
+2
View File
@@ -1886,6 +1886,7 @@ 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.inbox","Companion inbox behavior",0,"history|pending|unread"],
["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"],
@@ -2030,6 +2031,7 @@ var CLI_COMPANION_VERBS=[
["reboot","Restart the node"]
];
var CLI_COMPANION_KEYS=[
["display.inbox","Companion inbox behavior",0,"history|pending|unread"],
["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"],