Files
MeshCore/test/test_companion_inbox.py
T
mikecarper ddf625e9d0 Fix packet, delivery, clock and radio stability regressions
Reject truncated anonymous reply paths and advert metadata before copying,
while preserving timestamp-only empty-password logins. Limit room posting to
read-write/admin ACL roles, retain inbox frames until the requesting transport
accepts them, and lock raw LittleFS traversal against concurrent writes.

Separate ISR event generations from main-loop radio state. Confirm RX/TX IRQs,
detach concrete wrapper callbacks safely, and discard inconsistent LR2021 FIFO
snapshots before rearming. Preserve bridge RX frames through packet-pool
exhaustion and bound ESP-NOW retries and missing-callback recovery. Screen RTC
register layouts without losing DS1307 startup, and ignore poisoned future
contact timestamps during clock bootstrap.

Keep the nRF52 loop stack at 8 KiB while reducing the OTA listing call chain
from 7,832 to 6,056 static bytes. Retain all target names through lossless Full
Companion compression and the canonical size optimizer. Preserve constrained
STM32 filesystems and bind corrected ESP32 UART/OLED budgets to source size
assertions. Document the separately measured preexisting W12 package limits.

Adapted for this branch from meshcore-dev/MeshCore PRs #3521, #3340, #3362,
#2062, #3503, #3038, #3534, #3544 and #3512.

Validation: 1,829 native tests; sanitized parser, inbox, LittleFS, ACL, clock,
RTC, IRQ/FIFO and bridge fault tests; 332 additional Companion cases; real
RAK4631 Full and both repeater profiles, G2 Full/standard, V3 Full and both
constrained STM32 release recipes. Radio-family compile coverage continues
separately; W12 full/portable package guards remain enforced.
2026-10-09 19:56:08 -07:00

404 lines
17 KiB
Python

"""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"
|| std::string(client) == "BLE + USB");
}
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 peekOfflineQueue(uint8_t*);
void popOfflineQueue();
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());
#if COMPANION_FEATURE_READER
// Receiving and downloading messages must leave an open reader alone.
Screen reader;
task.reader = task.curr = &reader;
for (bool connected : {false, true}) {
task.connected = connected;
for (int i = 0; i < 2; ++i) {
mesh.receive("while reading");
assert(task.isReaderActive());
}
for (int remaining : {1, 0}) {
mesh.download();
assert(task.getMsgCount() == remaining);
assert(task.isReaderActive());
}
}
#endif
}
'''
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"));
if (task.hasBluetoothConnection()) {
assert(display.contains("CONNECTED"));
if (dims.second >= 135) assert(display.contains(client));
} else {
// A wired/TCP connection must not conceal an unpaired BLE PIN.
assert(display.contains("123456"));
assert(!display.contains("CONNECTED"));
}
}
}
}
'''
self.compile_and_run(preamble + implementation)
def compile_and_run(self, source, reader_enabled=0):
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", f"-DCOMPANION_FEATURE_READER={reader_enabled}",
"-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::peekOfflineQueue(",
"void MyMesh::popOfflineQueue(", "int MyMesh::getFromOfflineQueue("):
implementation += extract_braced(mesh, signature) + "\n"
for reader_enabled in (0, 1):
with self.subTest(reader_enabled=reader_enabled):
self.compile_and_run(preamble + implementation + SCENARIOS, reader_enabled)
def test_summary_shows_newest_visible_message_per_thread(self):
ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
implementation = extract_braced(ui, "class MsgPreviewScreen :") + ";\n"
scenarios = r'''
struct SummaryDisplay : Display {
SummaryDisplay() { setDimensions(160, 160); }
};
int main() {
using namespace mesh::ui;
SummaryDisplay display;
UITask task(display);
MsgPreviewScreen messages(&task);
messages.addPreview(1, "Alice", "old public", 0, "Public", 0);
messages.addPreview(1, "Bob", "pending public", 0, "Public", 1);
messages.addPreview(1, "Alice", "delivered public", 0, "Public");
messages.addPreview(1, "Carol", "second channel", 2, "Second", 2);
messages.addPreview(0xFF, "Dan", "old direct", -1, nullptr, 3);
messages.addPreview(0xFF, "Dan", "new direct", -1, nullptr, 4);
for (auto mode : {DisplayInboxMode::History, DisplayInboxMode::Pending,
DisplayInboxMode::Unread}) {
displayPowerPrefs().inbox = mode;
display.clear();
messages.renderSummary(display);
assert(display.rectangles.size() == 3); // One divider per thread.
assert(display.contains("Public") && display.contains("Second"));
assert(display.contains("Direct: Dan"));
assert(display.contains("second channel") && display.contains("new direct"));
assert(!display.contains("old public") && !display.contains("old direct"));
// A delivered newer entry cannot hide the newest pending entry.
const bool pending = mode == DisplayInboxMode::Pending;
assert(display.contains("pending public") == pending);
assert(display.contains("delivered public") == !pending);
}
}
'''
self.compile_and_run(PREAMBLE + implementation + scenarios)
def test_summary_uses_newest_visible_message_per_thread(self):
ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
preamble = PREAMBLE.replace("int getMsgCount() const { return 0; }", UI_MEMBERS)
implementation = extract_braced(ui, "class MsgPreviewScreen :") + ";\n"
scenarios = r'''
int main() {
using namespace mesh::ui;
resetArduinoMock();
displayPowerPrefs().battery.mode = DisplayMode::On;
Display display;
display.servicePower(false);
UITask task(display);
MsgPreviewScreen messages(&task);
task.msg_preview = task.curr = &messages;
messages.addPreview(1, "Alice", "older pending", 0, "Public", 0);
messages.addPreview(1, "Bob", "newer delivered", 0, "Public");
auto expect = [&](DisplayInboxMode mode, const char* wanted, const char* hidden) {
displayPowerPrefs().inbox = mode;
display.clear(); messages.renderSummary(display);
if (!display.contains(wanted) || display.contains(hidden)) {
fprintf(stderr, "summary mode=%u expected=%s hidden=%s\n", unsigned(mode), wanted, hidden);
for (const auto& line : display.lines) fprintf(stderr, "rendered: %s\n", line.text.c_str());
}
assert(display.contains(wanted) && !display.contains(hidden));
};
expect(DisplayInboxMode::History, "newer delivered", "older pending");
expect(DisplayInboxMode::Pending, "older pending", "newer delivered");
// Unread changes the count; read previews remain browseable as documented.
displayPowerPrefs().inbox = DisplayInboxMode::History;
display.clear(); messages.render(display);
expect(DisplayInboxMode::Unread, "newer delivered", "older pending");
assert(messages.messageCount() == 1);
messages.queueRemoved(0);
expect(DisplayInboxMode::Pending, "No messages heard", "older pending");
expect(DisplayInboxMode::History, "newer delivered", "older pending");
}
'''
self.compile_and_run(preamble + implementation + scenarios)
if __name__ == "__main__":
unittest.main()