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
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user