mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 14:32:22 +00:00
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:
1 parent
96ef0ae905
commit
bbfe26b01d
26 files changed
+1178
-666
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+574
-572
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }; };
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user