diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index aa2bd5fe..27a24e71 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -33,7 +33,7 @@ interleaved. Classified: **Engines — logic living inside `UITask.cpp` / `UITask.h`** - Unread tracking — DM unread table ✅ (`ui-core/DmUnreadTable.h`), room unread (still in `UITask`). -- Notifications — `showAlert`, `notify`, `SoundNotifier`, LED (`userLedHandler`), wake-on-message (`checkDisplayOn`, auto-off). +- Notifications ✅ split: the Core decides *what* happened (`MessageArrived` with kind / DM sender / channel slot, `AdvertHeard`); the frontend decides *how* to show it (`showAlert`, `SoundNotifier`, vibration, LED, wake-on-message) — those are platform services. - Live share ✅ `ui-core/LiveShareEngine.h` — session timer, movement/heartbeat gate, send + scope guard, peers' `LiveTrackStore` + expiry. - Locator ✅ `ui-core/LocatorEngine.h` — active target, geofence state machine, proximity beeper. - Trail ✅ `ui-core/TrailEngine.h` — store, sampling, auto-pause, low-battery auto-save. @@ -70,11 +70,16 @@ fork's extensions (own-send mirroring, relay echoes, room login/admin replies, `[LOC]` shares, contact/channel removal, bot device actions, `requestShutdown`), each defaulting to a no-op. -Today `AbstractUITask` is the Listener and carries the glue `MyMesh` used to -run for the UI (display filter, room-post labelling, notifications). In the -target design `UiCore` becomes the Listener instead: it updates models/engines -and emits events, and the frontends no longer implement it at all. This is the -single point that makes both frontends receive identical behaviour. +For `ui-new`, `UiCore` is the Listener (`UITaskBase::meshListener()` hands it +to `MyMesh::setListener()`): it applies the display filter, labels room posts, +files history, keeps unread counters, runs the engines and emits events. The +frontend implements `UiCoreHost` (`ui-core/UiCoreHost.h`) for what the Core +still asks of it synchronously: "is this conversation on screen", "keep the +selection after an insert", and forwarding for not-yet-extracted parts (room +login / admin sessions, bot device actions, prefs cleanup on contact/channel +removal, shutdown). `ui-orig` / `ui-tiny` keep `AbstractUITask`, which is +`UITaskBase` + the Listener glue, unchanged. This is the single point that +makes both frontends receive identical behaviour. ### 2. Engines @@ -151,8 +156,8 @@ checked in the sim plus on L1 hardware before the next one. 0. **Listener boundary** ✅ (upstream `MyMesh::Listener` ported; `MyMesh` has no UI calls left). 1. **Skeleton** ✅. `examples/companion_radio/ui-core/` with `UiCore.h` (facade), `MessageHistory.h`, `DmUnreadTable.h`. Header-only for now, reached from `ui-new/UITask.cpp` by relative include, so none of the 66 variant `platformio.ini` files that build `ui-new` change. `UITask` heap-allocates one `UiCore` in `begin()` (before the screens, as `MessagesScreen` used to own the history on the heap); `MessagesScreen` binds to `core().history` by reference. When the Core grows real `.cpp` files, compile them through a unity `.cpp` inside each frontend directory. -2. **Engines, one per commit.** Clock tools ✅ (also introduced `ui-core/UiEvents.h`, the Core → frontend event queue; `UITask::tickCore()` runs `UiCore::loop()` and drains it) → ping ✅ → course-over-ground ✅ → live share ✅ → locator ✅ → trail ✅ → notifications. `UITask` shrinks to screen management + drawing. -3. **Flip the interface.** `UiCore` implements `AbstractUITask`; `ui-new`'s `UITask` becomes a frontend fed by events. +2. **Engines, one per commit.** Clock tools ✅ (also introduced `ui-core/UiEvents.h`, the Core → frontend event queue; `UITask::tickCore()` runs `UiCore::loop()` and drains it) → ping ✅ → course-over-ground ✅ → live share ✅ → locator ✅ → trail ✅ → notifications ✅ (with step 3). `UITask` shrinks to screen management + drawing. +3. **Flip the interface** ✅. `UiCore` is `MyMesh::Listener`; `ui-new`'s `UITask` derives `UITaskBase` + `UiCoreHost` and is fed by events, drained at the start (mesh-originated) and end (engines) of its `loop()`. Mesh callbacks no longer touch the display or buzzer directly. 4. **Settings schema.** Convert `SettingsScreen` section by section. 5. **`ui-lvgl` skeleton** for L2 + sim target: boot, home, message list/conversation, keyboard. Then screens by priority. 6. Contacts/Nearby, Admin, Bot logic extraction as the LVGL screens for them are built. diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 48772780..3871f050 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -26,19 +26,16 @@ enum class UIEventType { ack }; -// Every on-device UI (ui-new / ui-orig / ui-tiny) is MyMesh's Listener. The -// glue MyMesh used to run for the UI itself -- which message types show on -// screen, how a room post is labelled, which notification plays -- lives here -// now, so each UI variant keeps receiving the same newMsg()/addDMMsg()/ -// notify() calls it always did. -class AbstractUITask : public MyMesh::Listener { +// What every on-device UI (ui-new / ui-orig / ui-tiny) offers main.cpp and +// MyMesh: lifecycle, the companion-link state, board/serial helpers, and the +// object MyMesh should talk to (meshListener()). +class UITaskBase { protected: mesh::MainBoard* _board; BaseSerialInterface* _serial; bool _connected; - int _queue_len = 0; // last onQueueSizeChanged() -- newMsg()'s msgcount - AbstractUITask(mesh::MainBoard* board, BaseSerialInterface* serial) : _board(board), _serial(serial) { + UITaskBase(mesh::MainBoard* board, BaseSerialInterface* serial) : _board(board), _serial(serial) { _connected = false; } @@ -58,9 +55,6 @@ public: } bool hasConnection() const { return _connected; } virtual void onBLEDisconnected() {} - // An end-to-end ACK (CRC) arrived for one of our sent messages — drives the - // DM delivery-status marker. Default no-op for UIs that don't track it. - virtual void onMsgAck(uint32_t ack_crc) { (void)ack_crc; } // True only when a BLE central is actually bonded/connected. On a dual // (BLE+USB) interface hasConnection() is always true (USB counts), so use // this for BLE-specific UI like the pairing-PIN prompt. @@ -72,8 +66,6 @@ public: bool isSerialEnabled() const { return _serial->isEnabled(); } void enableSerial() { _serial->enable(); } void disableSerial() { _serial->disable(); } - virtual void msgRead(int msgcount) = 0; - virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; // Single choke point for every controlled power-down (low-battery auto-off, // long-press power-off, and every board.reboot() caller too): flush @@ -84,6 +76,31 @@ public: virtual void shutdown(bool restart = false) = 0; virtual void loop() = 0; + // What main.cpp hands to MyMesh::setListener(): the UI itself (ui-orig / + // ui-tiny, via AbstractUITask below) or the object it delegates mesh events + // to (ui-new: the UI Core). + virtual MyMesh::Listener* meshListener() = 0; +}; + +// A UI that is MyMesh's Listener itself (ui-orig / ui-tiny). The glue MyMesh +// used to run for the UI -- which message types show on screen, how a room post +// is labelled, which notification plays -- lives here, so each of those UIs +// keeps receiving the same newMsg()/addDMMsg()/notify() calls it always did. +// (ui-new instead hands MyMesh the UI Core, which does the same job.) +class AbstractUITask : public MyMesh::Listener, public UITaskBase { +protected: + int _queue_len = 0; // last onQueueSizeChanged() -- newMsg()'s msgcount + + AbstractUITask(mesh::MainBoard* board, BaseSerialInterface* serial) : UITaskBase(board, serial) { } + +public: + // An end-to-end ACK (CRC) arrived for one of our sent messages — drives the + // DM delivery-status marker. Default no-op for UIs that don't track it. + virtual void onMsgAck(uint32_t ack_crc) { (void)ack_crc; } + virtual void msgRead(int msgcount) = 0; + virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) = 0; + MyMesh::Listener* meshListener() override { return this; } + // ---- MyMesh::Listener ---- void onQueueSizeChanged(int msgcount) override { _queue_len = msgcount; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 489900c3..e97b566e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3124,13 +3124,13 @@ void MyMesh::handleCmdFrame(size_t len) { #ifdef ENABLE_SCREENSHOT void MyMesh::handleScreenshotRequest() { #ifdef DISPLAY_CLASS - UITask* ui_task = static_cast(getListener()); - if (!ui_task || !ui_task->hasDisplay()) { + extern UITask ui_task; // main.cpp -- the Listener may be the UI Core, not UITask + if (!ui_task.hasDisplay()) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); return; } - DisplayDriver* display = ui_task->getDisplay(); + DisplayDriver* display = ui_task.getDisplay(); if (!display) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); return; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index e1eb191b..764e50b4 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -326,7 +326,7 @@ void setup() { if (disp && the_mesh.getNodePrefs()) disp->setBrightness(the_mesh.getNodePrefs()->display_brightness); ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved - the_mesh.setListener(&ui_task); + the_mesh.setListener(ui_task.meshListener()); #ifdef DISPLAY_HAS_BUSY_PUMP if (disp) disp->setBusyPumpFn(pumpRadioDuringDisplayBusyWait, nullptr); #endif diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index c6ab69d6..287b3e3a 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -2,6 +2,11 @@ // UI Core: hardware-independent UI state and logic shared by every frontend // (ui-new today, ui-lvgl later). See docs/development/ui-core.md. // +// The Core is MyMesh's Listener: it files incoming/outgoing messages into the +// history, keeps the unread counters, runs the engines, and tells the frontend +// what happened through `events` (drained from the frontend's loop()). The +// frontend implements UiCoreHost for the few things the Core still asks of it. +// // For now the Core is header-only and compiled as part of the frontend's single // translation unit (ui-new/UITask.cpp includes this file), so no platformio.ini // needs a new source filter or include path. It relies on the same globals the @@ -10,6 +15,7 @@ #include "MessageHistory.h" #include "DmUnreadTable.h" #include "UiEvents.h" +#include "UiCoreHost.h" #include "ClockEngine.h" #include "PingEngine.h" #include "CourseEngine.h" @@ -17,9 +23,10 @@ #include "LocatorEngine.h" #include "TrailEngine.h" -class UiCore { +class UiCore : public MyMesh::Listener { public: - void begin(NodePrefs* prefs, SensorManager* sensors) { + void begin(NodePrefs* prefs, SensorManager* sensors, UiCoreHost* host) { + _host = host; clock.begin(prefs, &events); ping.begin(prefs); course.begin(sensors); @@ -51,9 +58,12 @@ public: MessageHistory history; // channel + DM rings, delivery state, channel unread DmUnreadTable dm_unread; // per-contact DM unread counters - // ── DM unread (clamped to what the DM ring still holds) ────────────────── - void noteIncomingDM(const uint8_t* pub_key) { dm_unread.onIncoming(pub_key); } - void afterDMInsert() { dm_unread.afterInsert(history); } + // ── Unread ──────────────────────────────────────────────────────────────── + // Messages waiting in the companion-app offline queue (0 = the app synced). + int msgCount() const { return _queue_len; } + int roomUnread() const { return _room_unread; } + void clearRoomUnread() { _room_unread = 0; } + // DM unread, clamped to what the DM ring still holds. int dmUnreadTotal() const { return dm_unread.total(history); } uint8_t dmUnread(const uint8_t* pub_key) const { return dm_unread.get(history, pub_key); } bool dmUnreadOverflow(const uint8_t* pub_key) const { return dm_unread.overflow(pub_key); } @@ -62,10 +72,122 @@ public: void clearAllDMUnread() { dm_unread.clearAll(); } void reconcileDMUnread() { dm_unread.reconcile(history); } - // All messages synced to the app: nothing is unread any more (room unread - // still lives in the frontend). - void clearAllUnread() { - dm_unread.clearAll(); - history.clearAllChannelUnread(); + // ════ MyMesh::Listener ════════════════════════════════════════════════════ + + void onQueueSizeChanged(int msgcount) override { + _queue_len = msgcount; + if (msgcount == 0) { // the app drained the queue: nothing is unread any more + _room_unread = 0; + dm_unread.clearAll(); + history.clearAllChannelUnread(); + } } + + void onACKRecv(uint32_t ack_crc) override { history.markDmDelivered(ack_crc); } + void onAdvertHeard(bool was_flood) override { events.push(UiEventType::AdvertHeard, nullptr, was_flood); } + bool requestShutdown(bool restart) override { _host->shutdown(restart); return true; } + + void onMessageRecv(mesh::Packet *pkt, const ContactInfo &from, uint8_t txt_type, uint32_t sender_timestamp, + const char* text) override { + onMessageRecvEx(pkt, from, txt_type, sender_timestamp, nullptr, 0, text); + } + void onMessageRecvEx(mesh::Packet *pkt, const ContactInfo &from, uint8_t txt_type, uint32_t sender_timestamp, + const uint8_t* extra, int extra_len, const char* text) override { + // we only want to show text messages on display, not cli data + if (!(txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN)) return; + if (from.type == ADV_TYPE_ROOM && _room_unread < _queue_len) _room_unread++; + if (from.type == ADV_TYPE_CHAT) dm_unread.onIncoming(from.id.pub_key); // before the ring insert below + UiEvent& ev = pushMessageArrived(from.type == ADV_TYPE_ROOM ? UIEventType::roomMessage : UIEventType::contactMessage, + from.name, -1); + if (from.type == ADV_TYPE_CHAT) { memcpy(ev.key, from.id.pub_key, 4); ev.flag = true; } + // Add to the on-device conversation history. Room servers (ADV_TYPE_ROOM) are + // viewed through the same history list as chat contacts (keyed by the server's + // pubkey), so their posts must be stored too — otherwise an incoming room + // message fires the notification and reaches the app via the offline queue but + // never shows when the room is opened directly on the device. + if (from.type == ADV_TYPE_CHAT) { + addDMMsg(from.id.pub_key, false, text, sender_timestamp, 0, 0, 0, pkt->path, (uint8_t)pkt->path_len); + } else if (from.type == ADV_TYPE_ROOM) { + // A room carries many guests, so prefix the post with its author so the UI + // can attribute each line. The signed message's `extra` holds the sender's + // pubkey prefix; resolve it to a contact name, falling back to a short hex. + char labeled[MAX_TEXT_LEN + 40]; // room text + "Sender: " (history store truncates) + if (extra && extra_len >= 4) { + ContactInfo* sc = the_mesh.lookupContactByPubKey(extra, extra_len); + if (sc && sc->name[0]) + snprintf(labeled, sizeof(labeled), "%s: %s", sc->name, text); + else + snprintf(labeled, sizeof(labeled), "%02X%02X: %s", extra[0], extra[1], text); + } else { + snprintf(labeled, sizeof(labeled), "%s", text); + } + addDMMsg(from.id.pub_key, false, labeled, sender_timestamp, 0, 0, 0, pkt->path, (uint8_t)pkt->path_len); + } + } + + // Upstream-shaped entry point without the channel slot: MyMesh itself always + // calls the Ex variant below, so this only serves a caller that doesn't know + // the slot -- notify, but there's no history ring to file it under. + void onChannelMessageRecv(mesh::Packet *pkt, ChannelDetails& channel_details, const char* text) override { + pushMessageArrived(UIEventType::channelMessage, channel_details.name, -1); + } + void onChannelMessageRecvEx(mesh::Packet *pkt, uint8_t channel_idx, ChannelDetails& channel_details, + uint32_t timestamp, const char* text) override { + addChannelMsg(channel_idx, text, timestamp, pkt->path, (uint8_t)pkt->path_len); + pushMessageArrived(UIEventType::channelMessage, channel_details.name, channel_idx); + } + + // Also the entry point for our own sends mirrored from the app / the bot + // (own_message) and for on-device composes. Returns the ring position. + int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0, + const uint8_t* path = nullptr, uint8_t path_len = 0, + bool own_message = false) override { + bool viewing = _host->isViewingChannel(channel_idx); + int pos = history.addChannelMsg(channel_idx, text, viewing, timestamp, path, path_len, own_message); + if (viewing && pos >= 0) _host->onViewedHistoryGrew(true); + return pos; + } + void armChannelRelay(int pos, uint32_t seq) override { history.armChannelRelay(pos, seq); } + void onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) override { + history.markChannelRelayed(seq, repeater_hash, hash_size); + } + + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0, + uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0, + const uint8_t* path = nullptr, uint8_t path_len = 0) override { + bool viewing = _host->isViewingDM(pub_key); + history.addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends, path, path_len); + if (viewing) _host->onViewedHistoryGrew(false); + dm_unread.afterInsert(history); + } + + void onSharedLocation(const uint8_t* pub_key, const char* name, int32_t lat_1e6, int32_t lon_1e6, + uint32_t ts, bool verified) override { + live_share.onSharedLocation(pub_key, name, lat_1e6, lon_1e6, ts, verified); + } + + // Not yet extracted -- the frontend still owns these. + void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override { + _host->onRoomLoginResult(pub_key, success, permissions); + } + void onAdminReply(const uint8_t* pub_key, const char* text) override { _host->onAdminReply(pub_key, text); } + void onContactRemoved(const uint8_t* pub_key) override { _host->onContactRemoved(pub_key); } + void onChannelRemoved(uint8_t channel_idx) override { _host->onChannelRemoved(channel_idx); } + void botSetGPS(bool on) override { _host->botSetGPS(on); } + void botBuzz(int seconds) override { _host->botBuzz(seconds); } + bool botSetGPIO(int idx, bool on) override { return _host->botSetGPIO(idx, on); } + bool botGetGPIO(int idx, bool& is_output, bool& value) override { return _host->botGetGPIO(idx, is_output, value); } + bool botGetGPIOAnalog(int idx, int& millivolts) override { return _host->botGetGPIOAnalog(idx, millivolts); } + +private: + UiEvent& pushMessageArrived(UIEventType kind, const char* name, int channel_idx) { + UiEvent& ev = events.push(UiEventType::MessageArrived, name); + ev.kind = kind; + ev.idx = (int16_t)channel_idx; + return ev; + } + + UiCoreHost* _host = nullptr; + int _queue_len = 0; // last onQueueSizeChanged() + int _room_unread = 0; }; diff --git a/examples/companion_radio/ui-core/UiCoreHost.h b/examples/companion_radio/ui-core/UiCoreHost.h new file mode 100644 index 00000000..7095c8b7 --- /dev/null +++ b/examples/companion_radio/ui-core/UiCoreHost.h @@ -0,0 +1,36 @@ +#pragma once +// What the UI Core needs from the frontend that hosts it. The Core is MyMesh's +// Listener; everything it can decide on its own (history, unread, engines) it +// does, and reports to the frontend through UiEventQueue. This interface is the +// remainder -- calls that must stay synchronous with mesh processing, or whose +// logic still lives in the frontend until its extraction step +// (docs/development/ui-core.md). None of these may draw or block. + +class UiCoreHost { +public: + virtual ~UiCoreHost() {} + + // ── View state consulted while filing an incoming message ──────────────── + // "Viewing" = that conversation is open on screen, so the message isn't + // counted unread. After a message is filed into a conversation being viewed, + // onViewedHistoryGrew() lets the view keep its selection on the same entry + // (ring entries are numbered newest-first). + virtual bool isViewingChannel(uint8_t channel_idx) { (void)channel_idx; return false; } + virtual bool isViewingDM(const uint8_t* pub_key) { (void)pub_key; return false; } + virtual void onViewedHistoryGrew(bool channel) { (void)channel; } + + // ── Not yet extracted (screen sessions, device controls, prefs cleanup) ── + virtual void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {} + virtual void onAdminReply(const uint8_t* pub_key, const char* text) {} + virtual void onContactRemoved(const uint8_t* pub_key) {} + virtual void onChannelRemoved(uint8_t channel_idx) {} + virtual void botSetGPS(bool on) {} + virtual void botBuzz(int seconds) {} + virtual bool botSetGPIO(int idx, bool on) { return false; } + virtual bool botGetGPIO(int idx, bool& is_output, bool& value) { return false; } + virtual bool botGetGPIOAnalog(int idx, int& millivolts) { return false; } + + // Controlled power-down / restart (flush state first). See + // AbstractUITask::shutdown(). + virtual void shutdown(bool restart) = 0; +}; diff --git a/examples/companion_radio/ui-core/UiEvents.h b/examples/companion_radio/ui-core/UiEvents.h index 62b87cb4..dd0d13be 100644 --- a/examples/companion_radio/ui-core/UiEvents.h +++ b/examples/companion_radio/ui-core/UiEvents.h @@ -14,32 +14,44 @@ enum class UiEventType : uint8_t { LiveShareEnded, // live-share session reached its duration and switched itself off LocatorCrossed, // geofence crossing: show `text`; flag = arrived (else left) LocatorBeep, // one proximity-beeper tick + MessageArrived, // incoming text: text = sender / channel name; `kind` = contact / + // room / channel message; `key` (flag = valid) = DM sender prefix + // for per-contact sounds; `idx` = channel slot (-1 = unknown) + AdvertHeard, // an advert was heard; flag = flood (else zero-hop) }; struct UiEvent { UiEventType type; bool flag; + UIEventType kind; // MessageArrived: which notification (AbstractUITask.h) + int16_t idx; + uint8_t key[4]; char text[24]; }; class UiEventQueue { public: - static const int SIZE = 8; + static const int SIZE = 12; UiEventQueue() : _head(0), _count(0) {} - void push(UiEventType type, const char* text = nullptr, bool flag = false) { + // Returns the queued event so a caller can fill the optional fields. + UiEvent& push(UiEventType type, const char* text = nullptr, bool flag = false) { int pos; if (_count < SIZE) { pos = (_head + _count) % SIZE; _count++; } else { pos = _head; _head = (_head + 1) % SIZE; } // drop oldest _q[pos].type = type; _q[pos].flag = flag; + _q[pos].kind = UIEventType::none; + _q[pos].idx = -1; + memset(_q[pos].key, 0, sizeof(_q[pos].key)); if (text) { strncpy(_q[pos].text, text, sizeof(_q[pos].text) - 1); _q[pos].text[sizeof(_q[pos].text) - 1] = '\0'; } else { _q[pos].text[0] = '\0'; } + return _q[pos]; } bool pop(UiEvent& out) { diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index c25afaa3..908a5e54 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -553,10 +553,10 @@ class MessagesScreen : public UIScreen { if (ok && _sending_to_channel) { _hist_sel = 0; _hist_scroll = 0; - _phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump + _phase = CHANNEL_HIST; // set before filing so the Core sees viewing=true, no unread bump char entry[sizeof(ChHistEntry::text)]; snprintf(entry, sizeof(entry), "Me: %s", msg); - int pos = addChannelMsg(_sel_channel_idx, entry); + int pos = _task->core().addChannelMsg(_sel_channel_idx, entry); // Arm the "relayed into mesh" marker on this exact entry — MyMesh tracked // the flood it just originated and reports a heard repeater echo by seq. if (pos >= 0) _history.armChannelRelay(pos, the_mesh.lastChannelRelaySeq()); @@ -967,37 +967,24 @@ public: // (suppressed while picking a channel for the bot). int channelPickTotal() const { return _num_channels + (_pick_bot_channel ? 0 : 1); } - // Public entry points (routed from MyMesh / the bot via UITask) — thin - // forwarders to the history store. addChannelMsg computes the "viewing" flag - // (a phase-machine fact the store can't see) and returns the ring position so - // the outgoing path can attach a relay seq to that exact entry. - int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0, - const uint8_t* path = nullptr, uint8_t path_len = 0, - bool own_message = false) { - bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); - int pos = _history.addChannelMsg(ch_idx, text, viewing, timestamp, path, path_len, own_message); - // Ring entries are numbered newest-first (0 == newest), so a new insert - // shifts every older message's index up by one. If the user has scrolled - // up to an older message (_hist_sel > 0), re-point the selection at that - // same message instead of silently relabeling a different one in under - // them. At _hist_sel <= 0 (already at newest, or -1 == compose button - // focused) there's nothing to preserve. - if (viewing && _hist_sel > 0) { _hist_sel++; _hist_scroll++; } - return pos; + // View state the UI Core consults while filing a message (via UiCoreHost): + // a conversation open on screen doesn't count the message unread, and after + // the insert the selection is kept on the same message. Ring entries are + // numbered newest-first (0 == newest), so a new insert shifts every older + // message's index up by one; if the user has scrolled up to an older message + // (sel > 0), re-point the selection at that same message instead of silently + // relabeling a different one in under them. At sel <= 0 (already at newest, + // or -1 == compose button focused) there's nothing to preserve. + bool isViewingChannel(uint8_t ch_idx) const { + return _phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx; } - void markChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) { - _history.markChannelRelayed(seq, repeater_hash, hash_size); + bool isViewingDM(const uint8_t* pub_key) const { + return _phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0; } - void armChannelRelay(int pos, uint32_t seq) { _history.armChannelRelay(pos, seq); } - void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, - uint32_t sender_timestamp = 0, uint32_t ack_tag = 0, - uint32_t ack_deadline_ms = 0, uint8_t resends = 0, - const uint8_t* path = nullptr, uint8_t path_len = 0) { - bool viewing = (_phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0); - _history.addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends, path, path_len); - if (viewing && _dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; } // see addChannelMsg + void onViewedHistoryGrew(bool channel) { + if (channel) { if (_hist_sel > 0) { _hist_sel++; _hist_scroll++; } } + else { if (_dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; } } } - void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); } // Rooms successfully logged in to this power-on session. RAM-only — the // server's ACL (see ClientACL) is the real permission store and survives @@ -1066,7 +1053,7 @@ public: } // Result of an on-device sendRoomLogin() (MyMesh::onContactResponse(), routed - // via AbstractUITask::onRoomLoginResult()). Surfaces as a transient alert. + // via the UI Core and UITask::onRoomLoginResult()). Surfaces as a transient alert. void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { (void)permissions; if (success) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 34db4fdf..13fc6bd0 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1635,7 +1635,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no ui_started_at = millis(); _alert_expiry = 0; - _batt_mv = AbstractUITask::getBattMilliVolts(); // seed EMA with first reading + _batt_mv = UITaskBase::getBattMilliVolts(); // seed EMA with first reading // Load persisted waypoints (table survives reboots, unlike the RAM trail). { @@ -1647,7 +1647,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no } _core = new UiCore(); // before any screen -- MessagesScreen binds to its history - _core->begin(node_prefs, sensors); + _core->begin(node_prefs, sensors, this); splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); syncLockToHome(); // booted locked (e.g. cover closed) → home starts on the LOCK page @@ -1730,6 +1730,12 @@ void UITask::dismissRing() { stopMelody(); _core->clock.dismi void UITask::tickCore() { _core->loop(); + drainCoreEvents(); + // Repeat the ring melody until dismissed or the ring window elapses. + if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY); +} + +void UITask::drainCoreEvents() { UiEvent ev; while (_core->events.pop(ev)) { switch (ev.type) { @@ -1753,12 +1759,16 @@ void UITask::tickCore() { case UiEventType::LocatorBeep: playMelody("locp:d=32,o=7,b=200:c"); break; + case UiEventType::MessageArrived: + onMessageArrived(ev); + break; + case UiEventType::AdvertHeard: + notify(ev.flag ? UIEventType::advertReceivedFlood : UIEventType::advertReceivedZeroHop); + break; default: break; } } - // Repeat the ring melody until dismissed or the ring window elapses. - if (_core->clock.isRinging() && !isMelodyPlaying()) playMelody(CLOCK_ALARM_MELODY); } // Ringtone takes a slot argument that onShow() can't carry — pass it after the @@ -1857,15 +1867,22 @@ void UITask::pickBotRoomTarget() { setCurrScreen(messages_screen); } -int UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp, - const uint8_t* path, uint8_t path_len, bool own_message) { - _last_notif_ch_idx = (int)channel_idx; - return ((MessagesScreen*)messages_screen)->addChannelMsg(channel_idx, text, timestamp, path, path_len, own_message); +// ── UI Core wiring ────────────────────────────────────────────────────────── +MyMesh::Listener* UITask::meshListener() { return _core; } + +bool UITask::isViewingChannel(uint8_t channel_idx) { + return ((MessagesScreen*)messages_screen)->isViewingChannel(channel_idx); +} +bool UITask::isViewingDM(const uint8_t* pub_key) { + return ((MessagesScreen*)messages_screen)->isViewingDM(pub_key); +} +void UITask::onViewedHistoryGrew(bool channel) { + ((MessagesScreen*)messages_screen)->onViewedHistoryGrew(channel); } -void UITask::armChannelRelay(int pos, uint32_t seq) { - ((MessagesScreen*)messages_screen)->armChannelRelay(pos, seq); -} +int UITask::getMsgCount() const { return _core->msgCount(); } +int UITask::getRoomUnreadCount() const { return _core->roomUnread(); } +void UITask::clearRoomUnread() { _core->clearRoomUnread(); } int UITask::getChannelUnreadCount() const { return ((MessagesScreen*)messages_screen)->getTotalChannelUnread(); @@ -1887,14 +1904,6 @@ bool UITask::getAnyUnreadOverflow() const { return getAnyChannelUnreadOverflow() || getAnyDMUnreadOverflow(); } -void UITask::onMsgAck(uint32_t ack_crc) { - ((MessagesScreen*)messages_screen)->markDmDelivered(ack_crc); -} - -void UITask::onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash, uint8_t hash_size) { - ((MessagesScreen*)messages_screen)->markChannelRelayed(seq, repeater_hash, hash_size); -} - void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { // Only one on-device login can be in flight at a time (MyMesh::ui_pending_login // is a single slot) -- route the result to whichever of the two screens that @@ -1913,13 +1922,6 @@ void UITask::onAdminReply(const uint8_t* pub_key, const char* text) { _next_refresh = 0; // same reasoning as onRoomLoginResult above } -void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp, - uint32_t ack_tag, uint32_t ack_deadline_ms, uint8_t resends, - const uint8_t* path, uint8_t path_len) { - ((MessagesScreen*)messages_screen)->addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends, path, path_len); - _core->afterDMInsert(); -} - int UITask::getDMUnreadTotal() const { return _core->dmUnreadTotal(); } uint8_t UITask::getDMUnread(const uint8_t* pub_key) const { return _core->dmUnread(pub_key); } bool UITask::getDMUnreadOverflow(const uint8_t* pub_key) const { return _core->dmUnreadOverflow(pub_key); } @@ -1973,25 +1975,17 @@ void UITask::notify(UIEventType t) { } -void UITask::msgRead(int msgcount) { - _msgcount = msgcount; - if (msgcount == 0) { - _room_unread = 0; - _core->clearAllUnread(); - } -} - -void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type, const uint8_t* pub_key) { - _msgcount = msgcount; - if (contact_type == ADV_TYPE_ROOM && _room_unread < _msgcount) _room_unread++; - if (contact_type == ADV_TYPE_CHAT && pub_key != nullptr) { - memcpy(_last_notif_dm_prefix, pub_key, 4); +// Incoming message (UiEventType::MessageArrived, filed by the Core already): +// alert overlay, wake the display, and the per-contact / per-channel sound. +void UITask::onMessageArrived(const UiEvent& ev) { + if (ev.kind == UIEventType::contactMessage && ev.flag) { + memcpy(_last_notif_dm_prefix, ev.key, 4); _last_notif_dm_valid = true; - _core->noteIncomingDM(pub_key); // eviction/overflow is checked in addDMMsg(), after the ring insert } + if (ev.kind == UIEventType::channelMessage) _last_notif_ch_idx = ev.idx; char alert_buf[80]; - snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name); + snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", ev.text); showAlert(alert_buf, 3000); if (_display != NULL && !_locked) { @@ -2005,6 +1999,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i _next_refresh = 100; } } + notify(ev.kind); } void UITask::userLedHandler() { @@ -2013,7 +2008,7 @@ void UITask::userLedHandler() { if (cur_time > next_led_change) { if (led_state == 0) { led_state = 1; - if (_msgcount > 0) { + if (_core->msgCount() > 0) { last_led_increment = LED_ON_MSG_MILLIS; } else { last_led_increment = LED_ON_MILLIS; @@ -2488,7 +2483,8 @@ void UITask::pollHallSensor() { } void UITask::loop() { - pollConnection(); // BLE link state -> hasConnection() (see AbstractUITask) + pollConnection(); // BLE link state -> hasConnection() (see UITaskBase) + drainCoreEvents(); // react to what the Core filed during mesh processing (alerts, wake, sounds) // Background delivery: resend pending on-device DMs whose ACK timed out, and // finalise the ✗ marker — runs regardless of which screen is active. ((MessagesScreen*)messages_screen)->tickDmResends(); @@ -2807,7 +2803,7 @@ void UITask::loop() { #endif if ((int32_t)(millis() - next_batt_chck) >= 0) { - uint16_t raw = AbstractUITask::getBattMilliVolts(); + uint16_t raw = UITaskBase::getBattMilliVolts(); if (raw > 0) { #ifdef SIM_PLATFORM // SimMainBoard::getBattMilliVolts() returns exactly whatever value the @@ -3017,12 +3013,6 @@ TrailStore& UITask::trail() { return _core->trail.store(); } bool UITask::currentCourse(int& deg_out) const { return _core->course.currentCourse(deg_out); } bool UITask::currentLocation(int32_t& lat, int32_t& lon) const { return _core->course.currentLocation(lat, lon); } -void UITask::onSharedLocation(const uint8_t* pub_key, const char* name, - int32_t lat_1e6, int32_t lon_1e6, - uint32_t ts, bool verified) { - _core->live_share.onSharedLocation(pub_key, name, lat_1e6, lon_1e6, ts, verified); -} - bool UITask::sendLocationShare(int32_t lat, int32_t lon) { return _core->live_share.send(lat, lon); } void UITask::restartLocShareSession() { _core->live_share.restartSession(); } void UITask::restartLocShareClock() { _core->live_share.restartClock(); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 239aa616..9b9ea870 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -41,10 +41,14 @@ #include "../Waypoint.h" #include "../LiveTrack.h" #include "KeyboardWidget.h" +#include "../ui-core/UiCoreHost.h" class UiCore; +struct UiEvent; -class UITask : public AbstractUITask { +// ui-new is the lite frontend of the UI Core: MyMesh talks to the Core (see +// meshListener()), the Core calls back through UiCoreHost and UiEventQueue. +class UITask : public UITaskBase, public UiCoreHost { DisplayDriver* _display; SensorManager* _sensors; #ifdef PIN_BUZZER @@ -68,8 +72,6 @@ class UITask : public AbstractUITask { char _bot_buzz_buf[400]; KeyboardWidget _kb; // shared across all screens — only one active at a time unsigned long _alert_expiry; - int _msgcount; - int _room_unread; int _last_notif_ch_idx; uint8_t _last_notif_dm_prefix[4]; bool _last_notif_dm_valid; @@ -121,6 +123,8 @@ class UITask : public AbstractUITask { // Runs the UI Core engines and reacts to their events (alert overlay, buzzer, // display wake). Driven from loop() regardless of the current screen. void tickCore(); + void drainCoreEvents(); + void onMessageArrived(const UiEvent& ev); // alert + wake + sound for an incoming message @@ -228,11 +232,10 @@ private: public: - UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL), _node_prefs(NULL) { + UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : UITaskBase(board, serial), _display(NULL), _sensors(NULL), _node_prefs(NULL) { next_batt_chck = _next_refresh = 0; ui_started_at = 0; _batt_mv = 0; - _msgcount = _room_unread = 0; _locked = false; _lock_wake_until = 0; _lock_seq_count = 0; _lock_seq_ms = 0; _lock_seq_used = false; @@ -247,7 +250,7 @@ public: NodePrefs* getNodePrefs() const { return _node_prefs; } // Global metric/imperial preference for distance/speed display. bool useImperial() const { return _node_prefs && _node_prefs->units_imperial; } - uint16_t getBattMilliVolts() const { return _batt_mv > 0 ? _batt_mv : AbstractUITask::getBattMilliVolts(); } + uint16_t getBattMilliVolts() const { return _batt_mv > 0 ? _batt_mv : UITaskBase::getBattMilliVolts(); } void gotoHomeScreen() { setCurrScreen(home); } void gotoSettingsScreen(); void gotoMessagesScreen(); @@ -357,25 +360,21 @@ public: void stopMelody(); bool isMelodyPlaying(); void showAlert(const char* text, int duration_millis); - int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0, - const uint8_t* path = nullptr, uint8_t path_len = 0, - bool own_message = false) override; - void armChannelRelay(int pos, uint32_t seq) override; - void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0, - uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0, - const uint8_t* path = nullptr, uint8_t path_len = 0) override; - void onMsgAck(uint32_t ack_crc) override; - void onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) override; + MyMesh::Listener* meshListener() override; // MyMesh talks to the UI Core, not to UITask + // UiCoreHost + bool isViewingChannel(uint8_t channel_idx) override; + bool isViewingDM(const uint8_t* pub_key) override; + void onViewedHistoryGrew(bool channel) override; void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override; void onAdminReply(const uint8_t* pub_key, const char* text) override; int getDMUnreadTotal() const; - int getMsgCount() const { return _msgcount; } + int getMsgCount() const; int getChannelUnreadCount() const; uint8_t getChannelUnread(uint8_t channel_idx) const; bool getChannelUnreadOverflow(uint8_t channel_idx) const; bool getAnyChannelUnreadOverflow() const; - int getRoomUnreadCount() const { return _room_unread; } - void clearRoomUnread() { _room_unread = 0; } + int getRoomUnreadCount() const; + void clearRoomUnread(); // Clamped to the DM ring's actual occupancy for this contact (UiCore). uint8_t getDMUnread(const uint8_t* pub_key) const; bool getDMUnreadOverflow(const uint8_t* pub_key) const; @@ -512,13 +511,8 @@ public: } - // from AbstractUITask - void msgRead(int msgcount) override; - void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) override; + // from UITaskBase void notify(UIEventType t = UIEventType::none) override; - void onSharedLocation(const uint8_t* pub_key, const char* name, - int32_t lat_1e6, int32_t lon_1e6, - uint32_t ts, bool verified) override; void loop() override; // Send one [LOC] message to the configured live-share target. Returns false // if the target can't be resolved (no such channel / contact).