mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
feat(ui): message delivery status (DM ACK + channel relay)
End-to-end delivery indicators on the device UI, drawn next to outgoing messages and auto-scaled to the font (legible on landscape e-ink). DM (and room servers, which share the DM path): - Pending / delivered / failed marker driven by the real end-to-end ACK. Pending shows a row of dots — one per send — so auto-resend progress is visible before it resolves to ✓ / ✗. - Auto-resend: new pref dm_resend_count (0-5, default 2), Settings › Messages › Resend. A pending DM whose ACK times out is re-sent (reusing the original timestamp) until resends run out, then ✗. Driven from UITask::loop so it completes in the background, independent of screen. - Incoming dedup: a retry reuses the sender timestamp + text but carries a fresh packet hash, so addDMMsg drops copies matching prefix+ts+text. Channels (flood, no recipient ACK): - ✓ only once a repeater echo confirms the send was relayed into the mesh; no echo is normal, not a failure (no pending/fail shown). A small ring tracks a burst of sends so each matches its echo. Receive-path hashing is gated so the hot flood path is untouched when idle. Shared: - Markers shown in both the history list and the fullscreen message view. - Reusable scalable mini-icon facility in icons.h (bitmap + auto-scale); adding a new status icon is a bitmap plus one draw call. No changes to the upstream mesh library (src/). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,12 @@ public:
|
|||||||
}
|
}
|
||||||
bool hasConnection() const { return _connected; }
|
bool hasConnection() const { return _connected; }
|
||||||
virtual void onBLEDisconnected() {}
|
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; }
|
||||||
|
// A repeater rebroadcast of one of our channel sends was heard (seq from
|
||||||
|
// lastChannelRelaySeq()) — drives the channel "relayed into mesh" marker.
|
||||||
|
virtual void onChannelRelayed(uint32_t seq) { (void)seq; }
|
||||||
// True only when a BLE central is actually bonded/connected. On a dual
|
// True only when a BLE central is actually bonded/connected. On a dual
|
||||||
// (BLE+USB) interface hasConnection() is always true (USB counts), so use
|
// (BLE+USB) interface hasConnection() is always true (USB counts), so use
|
||||||
// this for BLE-specific UI like the pairing-PIN prompt.
|
// this for BLE-specific UI like the pairing-PIN prompt.
|
||||||
@@ -56,6 +62,6 @@ public:
|
|||||||
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 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;
|
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||||
virtual void addChannelMsg(uint8_t channel_idx, const char* text) {}
|
virtual void addChannelMsg(uint8_t channel_idx, const char* text) {}
|
||||||
virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {}
|
virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) {}
|
||||||
virtual void loop() = 0;
|
virtual void loop() = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
rd(&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
|
rd(&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
|
||||||
rd(&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
|
rd(&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
|
||||||
rd(&_prefs.tx_apc, sizeof(_prefs.tx_apc));
|
rd(&_prefs.tx_apc, sizeof(_prefs.tx_apc));
|
||||||
|
rd(&_prefs.dm_resend_count, sizeof(_prefs.dm_resend_count));
|
||||||
// These fields were appended over successive schema bumps; an older file
|
// These fields were appended over successive schema bumps; an older file
|
||||||
// can leave stray bytes here, so clamp out-of-range values back to defaults.
|
// can leave stray bytes here, so clamp out-of-range values back to defaults.
|
||||||
// Values for notif_melody_ad: 0=built-in, 1=melody1, 2=melody2, 3=none.
|
// Values for notif_melody_ad: 0=built-in, 1=melody1, 2=melody2, 3=none.
|
||||||
@@ -333,6 +334,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
if (_prefs.advert_sound_scope > 1) _prefs.advert_sound_scope = ADVERT_SOUND_SCOPE_ALL;
|
if (_prefs.advert_sound_scope > 1) _prefs.advert_sound_scope = ADVERT_SOUND_SCOPE_ALL;
|
||||||
if (_prefs.rx_powersave > 1) _prefs.rx_powersave = 0;
|
if (_prefs.rx_powersave > 1) _prefs.rx_powersave = 0;
|
||||||
if (_prefs.tx_apc > 1) _prefs.tx_apc = 0;
|
if (_prefs.tx_apc > 1) _prefs.tx_apc = 0;
|
||||||
|
// An old (0xC0DE0009) file leaves the low byte of its sentinel here (0x09),
|
||||||
|
// which is out of range — fall back to the default of 2 resends.
|
||||||
|
if (_prefs.dm_resend_count > 5) _prefs.dm_resend_count = 2;
|
||||||
|
|
||||||
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
||||||
// (or a different schema); rd() already zero-inits any fields not present,
|
// (or a different schema); rd() already zero-inits any fields not present,
|
||||||
@@ -456,6 +460,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
|||||||
file.write((uint8_t *)&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
|
file.write((uint8_t *)&_prefs.advert_sound_scope, sizeof(_prefs.advert_sound_scope));
|
||||||
file.write((uint8_t *)&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
|
file.write((uint8_t *)&_prefs.rx_powersave, sizeof(_prefs.rx_powersave));
|
||||||
file.write((uint8_t *)&_prefs.tx_apc, sizeof(_prefs.tx_apc));
|
file.write((uint8_t *)&_prefs.tx_apc, sizeof(_prefs.tx_apc));
|
||||||
|
file.write((uint8_t *)&_prefs.dm_resend_count, sizeof(_prefs.dm_resend_count));
|
||||||
|
|
||||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
|
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
|
||||||
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;
|
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;
|
||||||
|
|||||||
@@ -481,7 +481,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
|||||||
_ui->newMsg(path_len, from.name, text, offline_queue_len, from.type, from.id.pub_key);
|
_ui->newMsg(path_len, from.name, text, offline_queue_len, from.type, from.id.pub_key);
|
||||||
_ui->notify(UIEventType::contactMessage);
|
_ui->notify(UIEventType::contactMessage);
|
||||||
if (from.type == ADV_TYPE_CHAT)
|
if (from.type == ADV_TYPE_CHAT)
|
||||||
_ui->addDMMsg(from.id.pub_key, false, text);
|
_ui->addDMMsg(from.id.pub_key, false, text, sender_timestamp);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -499,6 +499,23 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
|||||||
apcSampleSnr(packet->getSNR());
|
apcSampleSnr(packet->getSNR());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// UI relayed-into-mesh marker: same heard-echo idea, runs regardless of APC.
|
||||||
|
// Gated on _relay_active so the hash is only computed while a send is pending.
|
||||||
|
if (_relay_active > 0) {
|
||||||
|
uint8_t h[MAX_HASH_SIZE];
|
||||||
|
bool hashed = false;
|
||||||
|
for (int i = 0; i < RELAY_RING; i++) {
|
||||||
|
RelaySlot& s = _relay[i];
|
||||||
|
if (!s.pending || s.len != packet->payload_len) continue;
|
||||||
|
if (!hashed) { packet->calculatePacketHash(h); hashed = true; }
|
||||||
|
if (memcmp(h, s.hash, MAX_HASH_SIZE) == 0) {
|
||||||
|
s.pending = false;
|
||||||
|
_relay_active--;
|
||||||
|
if (_ui) _ui->onChannelRelayed(s.seq);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
|
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
|
||||||
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
|
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
|
||||||
return false;
|
return false;
|
||||||
@@ -534,6 +551,7 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
|
|||||||
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||||
// TODO: have per-channel send_scope
|
// TODO: have per-channel send_scope
|
||||||
if (_prefs.tx_apc) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK)
|
if (_prefs.tx_apc) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK)
|
||||||
|
trackRelaySend(pkt); // and for the UI "relayed" marker
|
||||||
if (send_unscoped) {
|
if (send_unscoped) {
|
||||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
|
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
|
||||||
} else {
|
} else {
|
||||||
@@ -1152,6 +1170,22 @@ void MyMesh::apcTrackFloodSend(const mesh::Packet* pkt) {
|
|||||||
_apc_flood_pending = true;
|
_apc_flood_pending = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arm the UI "relayed into mesh" tracker for a channel send (independent of APC).
|
||||||
|
// A repeater rebroadcast heard within the window = relayed; no echo = simply not
|
||||||
|
// shown as relayed (NOT a failure — direct/0-hop neighbours never echo).
|
||||||
|
void MyMesh::trackRelaySend(const mesh::Packet* pkt) {
|
||||||
|
RelaySlot& s = _relay[_relay_head];
|
||||||
|
if (!s.pending) _relay_active++; // overwriting an empty slot adds one pending
|
||||||
|
pkt->calculatePacketHash(s.hash);
|
||||||
|
s.len = pkt->payload_len;
|
||||||
|
s.deadline = futureMillis(APC_FLOOD_ECHO_WINDOW_MS);
|
||||||
|
_relay_seq = (_relay_seq == 0xFFFFFFFFu) ? 1 : _relay_seq + 1; // never 0 (0 = "no relay")
|
||||||
|
s.seq = _relay_seq;
|
||||||
|
s.pending = true;
|
||||||
|
_last_relay_seq = _relay_seq;
|
||||||
|
_relay_head = (_relay_head + 1) % RELAY_RING;
|
||||||
|
}
|
||||||
|
|
||||||
void MyMesh::onSendTimeout() {
|
void MyMesh::onSendTimeout() {
|
||||||
if (_prefs.tx_apc) apcOnFailure();
|
if (_prefs.tx_apc) apcOnFailure();
|
||||||
}
|
}
|
||||||
@@ -1160,9 +1194,15 @@ void MyMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
|
|||||||
// onAckRecv also fires for ACKs we merely route or overhear (see Mesh.cpp), whose
|
// onAckRecv also fires for ACKs we merely route or overhear (see Mesh.cpp), whose
|
||||||
// SNR belongs to an unrelated link — only feed APC for ACKs to our own sends.
|
// SNR belongs to an unrelated link — only feed APC for ACKs to our own sends.
|
||||||
// Capture the match before the base handler's processAck() clears the entry.
|
// Capture the match before the base handler's processAck() clears the entry.
|
||||||
bool ours = _prefs.tx_apc && isAckPending(ack_crc);
|
bool mine = isAckPending(ack_crc);
|
||||||
BaseChatMesh::onAckRecv(packet, ack_crc);
|
BaseChatMesh::onAckRecv(packet, ack_crc);
|
||||||
if (ours) apcSampleSnr(radio_driver.getLastSNR());
|
if (mine && _prefs.tx_apc) apcSampleSnr(radio_driver.getLastSNR());
|
||||||
|
// Drive the DM delivery-status marker. Note isAckPending() only covers
|
||||||
|
// app/serial-initiated sends (expected_ack_table); a DM composed on the
|
||||||
|
// device UI registers in BaseChatMesh's own ack table instead, so gating on
|
||||||
|
// `mine` here would leave every on-device DM stuck at ✗. The UI matches the
|
||||||
|
// crc against its own pending tag, so an unrelated/overheard ACK is ignored.
|
||||||
|
if (_ui) _ui->onMsgAck(ack_crc);
|
||||||
}
|
}
|
||||||
|
|
||||||
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)
|
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)
|
||||||
@@ -1170,6 +1210,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
|||||||
_serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui) {
|
_serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui) {
|
||||||
_iter_started = false;
|
_iter_started = false;
|
||||||
_cli_rescue = false;
|
_cli_rescue = false;
|
||||||
|
for (int i = 0; i < RELAY_RING; i++) _relay[i].pending = false;
|
||||||
|
_relay_head = 0;
|
||||||
|
_relay_active = 0;
|
||||||
|
_relay_seq = 0;
|
||||||
|
_last_relay_seq = 0;
|
||||||
offline_queue_len = 0;
|
offline_queue_len = 0;
|
||||||
app_target_ver = 0;
|
app_target_ver = 0;
|
||||||
_bot_last_dm_reply_ms = 0;
|
_bot_last_dm_reply_ms = 0;
|
||||||
@@ -1218,6 +1263,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
|||||||
_prefs.bot_reply_dm[0] = '\0';
|
_prefs.bot_reply_dm[0] = '\0';
|
||||||
_prefs.bot_reply_ch[0] = '\0';
|
_prefs.bot_reply_ch[0] = '\0';
|
||||||
_prefs.dm_show_all = 1; // show all contacts by default
|
_prefs.dm_show_all = 1; // show all contacts by default
|
||||||
|
_prefs.dm_resend_count = 2; // auto-resend on-device DMs twice by default
|
||||||
memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif));
|
memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif));
|
||||||
_prefs.auto_off_secs = 15; // 15 seconds auto-off by default
|
_prefs.auto_off_secs = 15; // 15 seconds auto-off by default
|
||||||
_prefs.clock_hide_seconds = Features::CLOCK_HIDE_SECONDS_DEFAULT ? 1 : 0;
|
_prefs.clock_hide_seconds = Features::CLOCK_HIDE_SECONDS_DEFAULT ? 1 : 0;
|
||||||
@@ -2650,6 +2696,16 @@ void MyMesh::loop() {
|
|||||||
_apc_flood_pending = false;
|
_apc_flood_pending = false;
|
||||||
if (_prefs.tx_apc) apcOnFailure();
|
if (_prefs.tx_apc) apcOnFailure();
|
||||||
}
|
}
|
||||||
|
// UI relay windows expired with no echo — just drop them (no echo is not a
|
||||||
|
// failure for channels; the marker simply stays "sent").
|
||||||
|
if (_relay_active > 0) {
|
||||||
|
for (int i = 0; i < RELAY_RING; i++) {
|
||||||
|
if (_relay[i].pending && millisHasNowPassed(_relay[i].deadline)) {
|
||||||
|
_relay[i].pending = false;
|
||||||
|
_relay_active--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_cli_rescue) {
|
if (_cli_rescue) {
|
||||||
checkCLIRescueCmd();
|
checkCLIRescueCmd();
|
||||||
|
|||||||
@@ -201,6 +201,12 @@ protected:
|
|||||||
void apcSampleSnr(float snr);
|
void apcSampleSnr(float snr);
|
||||||
void apcOnFailure();
|
void apcOnFailure();
|
||||||
void apcTrackFloodSend(const mesh::Packet* pkt);
|
void apcTrackFloodSend(const mesh::Packet* pkt);
|
||||||
|
void trackRelaySend(const mesh::Packet* pkt); // arm the UI relayed-into-mesh tracker
|
||||||
|
public:
|
||||||
|
// Seq of the most recently tracked channel send — the UI records it on the
|
||||||
|
// outgoing history entry so a heard echo (onChannelRelayed) can match it back.
|
||||||
|
uint32_t lastChannelRelaySeq() const { return _last_relay_seq; }
|
||||||
|
private:
|
||||||
|
|
||||||
// DataStoreHost methods
|
// DataStoreHost methods
|
||||||
bool onContactLoaded(const ContactInfo& contact) override { return addContact(contact); }
|
bool onContactLoaded(const ContactInfo& contact) override { return addContact(contact); }
|
||||||
@@ -291,6 +297,23 @@ private:
|
|||||||
uint16_t _apc_flood_len; // its payload length — cheap pre-filter before hashing
|
uint16_t _apc_flood_len; // its payload length — cheap pre-filter before hashing
|
||||||
uint32_t _apc_flood_deadline; // echo-wait deadline for that send
|
uint32_t _apc_flood_deadline; // echo-wait deadline for that send
|
||||||
bool _apc_flood_pending; // a tracked flood send is awaiting its echo
|
bool _apc_flood_pending; // a tracked flood send is awaiting its echo
|
||||||
|
// UI "relayed into mesh" tracker for channel sends — independent of APC. A small
|
||||||
|
// ring so a quick burst of channel sends are each tracked (not just the latest).
|
||||||
|
// Hashing on receive only runs while at least one slot is pending, so the hot
|
||||||
|
// flood-recv path is untouched otherwise.
|
||||||
|
static const int RELAY_RING = 4;
|
||||||
|
struct RelaySlot {
|
||||||
|
uint8_t hash[MAX_HASH_SIZE];
|
||||||
|
uint16_t len;
|
||||||
|
uint32_t deadline;
|
||||||
|
uint32_t seq;
|
||||||
|
bool pending;
|
||||||
|
};
|
||||||
|
RelaySlot _relay[RELAY_RING];
|
||||||
|
int _relay_head; // next ring slot to overwrite
|
||||||
|
int _relay_active; // number of slots currently pending (cheap gate)
|
||||||
|
uint32_t _relay_seq; // monotonic id counter for tracked sends
|
||||||
|
uint32_t _last_relay_seq; // seq of the most recent tracked send (for the UI to record)
|
||||||
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
|
bool send_unscoped; // force un-scoped flood (instead of using send_scope)
|
||||||
char cli_command[80];
|
char cli_command[80];
|
||||||
uint8_t app_target_ver;
|
uint8_t app_target_ver;
|
||||||
|
|||||||
@@ -131,11 +131,16 @@ struct NodePrefs { // persisted to file
|
|||||||
// below the ceiling, so disabling restores the user's configured power.
|
// below the ceiling, so disabling restores the user's configured power.
|
||||||
uint8_t tx_apc;
|
uint8_t tx_apc;
|
||||||
|
|
||||||
|
// Auto-resend for on-device DMs: number of extra send attempts (0..5) made when
|
||||||
|
// no end-to-end ACK arrives before the deadline, before the delivery marker
|
||||||
|
// shows ✗. 0 = no auto-resend (single attempt). Default 2.
|
||||||
|
uint8_t dm_resend_count;
|
||||||
|
|
||||||
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
|
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
|
||||||
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
||||||
// older saves are detected on load and skipped (zero-init defaults kept).
|
// older saves are detected on load and skipped (zero-init defaults kept).
|
||||||
// High 24 bits identify the file format; low byte is the schema revision.
|
// High 24 bits identify the file format; low byte is the schema revision.
|
||||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0009;
|
static const uint32_t SCHEMA_SENTINEL = 0xC0DE000A;
|
||||||
|
|
||||||
// Bit-index for each home page. Used by page_order (entries store bit+1) and
|
// Bit-index for each home page. Used by page_order (entries store bit+1) and
|
||||||
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
|
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Included by UITask.cpp after SettingsScreen.h is defined.
|
// Included by UITask.cpp after SettingsScreen.h is defined.
|
||||||
|
|
||||||
#include "NavView.h" // navigate to a location shared inside a message
|
#include "NavView.h" // navigate to a location shared inside a message
|
||||||
|
#include "icons.h" // scalable mini-icons (delivery markers)
|
||||||
|
|
||||||
class QuickMsgScreen : public UIScreen {
|
class QuickMsgScreen : public UIScreen {
|
||||||
UITask* _task;
|
UITask* _task;
|
||||||
@@ -28,6 +29,12 @@ class QuickMsgScreen : public UIScreen {
|
|||||||
int _sel_channel_idx;
|
int _sel_channel_idx;
|
||||||
bool _sending_to_channel;
|
bool _sending_to_channel;
|
||||||
|
|
||||||
|
// Carries the just-sent DM's ACK tag + deadline + send timestamp from
|
||||||
|
// sendText() to afterSend().
|
||||||
|
uint32_t _last_ack_tag = 0;
|
||||||
|
uint32_t _last_ack_deadline_ms = 0;
|
||||||
|
uint32_t _last_send_ts = 0;
|
||||||
|
|
||||||
// MSG_PICK (shared)
|
// MSG_PICK (shared)
|
||||||
int _msg_sel, _msg_scroll;
|
int _msg_sel, _msg_scroll;
|
||||||
int _active_msgs[QUICK_MSGS_MAX];
|
int _active_msgs[QUICK_MSGS_MAX];
|
||||||
@@ -85,12 +92,37 @@ class QuickMsgScreen : public UIScreen {
|
|||||||
// UTF-8 bytes) get their tail clipped.
|
// UTF-8 bytes) get their tail clipped.
|
||||||
static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1;
|
static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1;
|
||||||
|
|
||||||
struct ChHistEntry { uint8_t ch_idx; char text[MSG_TEXT_BUF]; uint32_t timestamp; };
|
// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to
|
||||||
|
// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no
|
||||||
|
// recipient ACK exists for floods), so a missing echo is NOT shown as failure.
|
||||||
|
enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL };
|
||||||
|
|
||||||
|
struct ChHistEntry {
|
||||||
|
uint8_t ch_idx;
|
||||||
|
char text[MSG_TEXT_BUF];
|
||||||
|
uint32_t timestamp;
|
||||||
|
uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods)
|
||||||
|
uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed()
|
||||||
|
};
|
||||||
ChHistEntry _hist[CH_HIST_MAX];
|
ChHistEntry _hist[CH_HIST_MAX];
|
||||||
int _hist_head, _hist_count;
|
int _hist_head, _hist_count;
|
||||||
|
|
||||||
// DM_HIST
|
// DM_HIST
|
||||||
struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[MSG_TEXT_BUF]; uint32_t timestamp; };
|
struct DmHistEntry {
|
||||||
|
uint8_t prefix[4];
|
||||||
|
uint8_t outgoing;
|
||||||
|
char text[MSG_TEXT_BUF];
|
||||||
|
uint32_t timestamp;
|
||||||
|
uint8_t ack_status; // AckState; meaningful only when outgoing
|
||||||
|
uint32_t ack_tag; // expected_ack CRC to match against onMsgAck()
|
||||||
|
uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive
|
||||||
|
// Sender-perspective message timestamp: the send timestamp for outgoing
|
||||||
|
// (reused verbatim on resend so the recipient treats it as a retry), or the
|
||||||
|
// sender_timestamp for incoming (used to dedup retried copies). 0 = unknown.
|
||||||
|
uint32_t msg_ts;
|
||||||
|
uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1
|
||||||
|
uint8_t resends_left; // remaining auto-resends before the marker shows ✗
|
||||||
|
};
|
||||||
static const int DM_HIST_MAX = 64;
|
static const int DM_HIST_MAX = 64;
|
||||||
DmHistEntry _dm_hist[DM_HIST_MAX];
|
DmHistEntry _dm_hist[DM_HIST_MAX];
|
||||||
int _dm_hist_head, _dm_hist_count;
|
int _dm_hist_head, _dm_hist_count;
|
||||||
@@ -252,7 +284,25 @@ class QuickMsgScreen : public UIScreen {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {
|
// Delivery marker, drawn with the current ink colour and auto-scaled to the
|
||||||
|
// font (see icons.h). Pending = a row of dots, one per send (so it grows with
|
||||||
|
// each auto-resend); delivered = ✓; failed = ✗; ACK_NONE = nothing.
|
||||||
|
static void drawAckGlyph(DisplayDriver& d, int x, int top_y, AckState s, int sends = 1) {
|
||||||
|
switch (s) {
|
||||||
|
case ACK_PENDING: miniIconDotRow(d, x, top_y, sends); break;
|
||||||
|
case ACK_OK: miniIconDraw(d, x, top_y, ICON_CHECK, 5, 4); break;
|
||||||
|
case ACK_FAIL: miniIconDraw(d, x, top_y, ICON_CROSS, 4, 4); break;
|
||||||
|
default: break; // ACK_NONE → nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by
|
||||||
|
// ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming).
|
||||||
|
// msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp
|
||||||
|
// for incoming); resends = remaining auto-resends for an outgoing pending DM.
|
||||||
|
void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
|
||||||
|
uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0,
|
||||||
|
uint32_t msg_ts = 0, uint8_t resends = 0) {
|
||||||
int pos;
|
int pos;
|
||||||
if (_dm_hist_count < DM_HIST_MAX) {
|
if (_dm_hist_count < DM_HIST_MAX) {
|
||||||
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
|
pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX;
|
||||||
@@ -266,6 +316,23 @@ class QuickMsgScreen : public UIScreen {
|
|||||||
_dm_hist[pos].timestamp = rtc_clock.getCurrentTime();
|
_dm_hist[pos].timestamp = rtc_clock.getCurrentTime();
|
||||||
strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1);
|
strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1);
|
||||||
_dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0';
|
_dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0';
|
||||||
|
_dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE;
|
||||||
|
_dm_hist[pos].ack_tag = ack_tag;
|
||||||
|
_dm_hist[pos].ack_deadline_ms = ack_deadline_ms;
|
||||||
|
_dm_hist[pos].msg_ts = msg_ts;
|
||||||
|
_dm_hist[pos].attempt = 0;
|
||||||
|
_dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective status for display. A pending ACK only reads as failed once its
|
||||||
|
// deadline has passed AND no auto-resends remain — while resends_left > 0 the
|
||||||
|
// entry stays pending (tickDmResends() retries / finalises it). Safety net for
|
||||||
|
// when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL.
|
||||||
|
AckState dmEffectiveStatus(const DmHistEntry& e) const {
|
||||||
|
if (e.ack_status == ACK_PENDING && e.resends_left == 0 &&
|
||||||
|
(int32_t)(millis() - e.ack_deadline_ms) >= 0)
|
||||||
|
return ACK_FAIL;
|
||||||
|
return (AckState)e.ack_status;
|
||||||
}
|
}
|
||||||
|
|
||||||
int dmHistCountForContact(const uint8_t* prefix) const {
|
int dmHistCountForContact(const uint8_t* prefix) const {
|
||||||
@@ -296,7 +363,13 @@ class QuickMsgScreen : public UIScreen {
|
|||||||
_phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump
|
_phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump
|
||||||
char entry[sizeof(ChHistEntry::text)];
|
char entry[sizeof(ChHistEntry::text)];
|
||||||
snprintf(entry, sizeof(entry), "Me: %s", msg);
|
snprintf(entry, sizeof(entry), "Me: %s", msg);
|
||||||
addChannelMsg(_sel_channel_idx, entry);
|
int pos = 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) {
|
||||||
|
_hist[pos].relay_status = ACK_PENDING;
|
||||||
|
_hist[pos].relay_seq = the_mesh.lastChannelRelaySeq();
|
||||||
|
}
|
||||||
// After inserting sent msg at index 0, the unread index range is stale.
|
// After inserting sent msg at index 0, the unread index range is stale.
|
||||||
// User is active in this channel — treat as fully read.
|
// User is active in this channel — treat as fully read.
|
||||||
_ch_unread[_sel_channel_idx] = 0;
|
_ch_unread[_sel_channel_idx] = 0;
|
||||||
@@ -304,7 +377,10 @@ class QuickMsgScreen : public UIScreen {
|
|||||||
_viewing_max_seen = 0;
|
_viewing_max_seen = 0;
|
||||||
_task->showAlert("Sent!", 600);
|
_task->showAlert("Sent!", 600);
|
||||||
} else if (ok) {
|
} else if (ok) {
|
||||||
storeDMMsg(_sel_contact.id.pub_key, true, msg);
|
NodePrefs* np = _task->getNodePrefs();
|
||||||
|
uint8_t resends = np ? np->dm_resend_count : 0;
|
||||||
|
storeDMMsg(_sel_contact.id.pub_key, true, msg, _last_ack_tag,
|
||||||
|
_last_ack_deadline_ms, _last_send_ts, resends);
|
||||||
_dm_hist_sel = 0;
|
_dm_hist_sel = 0;
|
||||||
_dm_hist_scroll = 0;
|
_dm_hist_scroll = 0;
|
||||||
_phase = DM_HIST;
|
_phase = DM_HIST;
|
||||||
@@ -316,15 +392,27 @@ class QuickMsgScreen : public UIScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool sendText(const char* msg) {
|
bool sendText(const char* msg) {
|
||||||
|
_last_ack_tag = 0;
|
||||||
|
_last_ack_deadline_ms = 0;
|
||||||
|
_last_send_ts = 0;
|
||||||
if (_sending_to_channel) {
|
if (_sending_to_channel) {
|
||||||
ChannelDetails ch;
|
ChannelDetails ch;
|
||||||
if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false;
|
if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false;
|
||||||
return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel,
|
return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel,
|
||||||
the_mesh.getNodeName(), msg, strlen(msg));
|
the_mesh.getNodeName(), msg, strlen(msg));
|
||||||
} else {
|
} else {
|
||||||
|
uint32_t send_ts = rtc_clock.getCurrentTime();
|
||||||
uint32_t expected_ack = 0, est_timeout = 0;
|
uint32_t expected_ack = 0, est_timeout = 0;
|
||||||
return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0,
|
bool ok = the_mesh.sendMessage(_sel_contact, send_ts, 0,
|
||||||
msg, expected_ack, est_timeout) > 0;
|
msg, expected_ack, est_timeout) > 0;
|
||||||
|
if (ok && expected_ack) {
|
||||||
|
_last_send_ts = send_ts;
|
||||||
|
_last_ack_tag = expected_ack;
|
||||||
|
// Generous margin over the base estimate so a slow multi-hop ACK isn't
|
||||||
|
// prematurely shown as failed.
|
||||||
|
_last_ack_deadline_ms = millis() + est_timeout + 4000;
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,11 +591,13 @@ public:
|
|||||||
memset(_ch_unread, 0, sizeof(_ch_unread));
|
memset(_ch_unread, 0, sizeof(_ch_unread));
|
||||||
}
|
}
|
||||||
|
|
||||||
void addChannelMsg(uint8_t ch_idx, const char* text) {
|
// Returns the ring position the message was stored at, or -1 if rejected, so
|
||||||
|
// the outgoing path can attach a relay seq to that exact entry.
|
||||||
|
int addChannelMsg(uint8_t ch_idx, const char* text) {
|
||||||
// Guard against bogus channel indices (e.g. findChannelIdx() returned -1
|
// Guard against bogus channel indices (e.g. findChannelIdx() returned -1
|
||||||
// and was cast to uint8_t → 255). Storing such an entry would burn a ring
|
// and was cast to uint8_t → 255). Storing such an entry would burn a ring
|
||||||
// slot for a message that no visible channel can ever surface.
|
// slot for a message that no visible channel can ever surface.
|
||||||
if (ch_idx >= MAX_GROUP_CHANNELS) return;
|
if (ch_idx >= MAX_GROUP_CHANNELS) return -1;
|
||||||
int pos;
|
int pos;
|
||||||
if (_hist_count < CH_HIST_MAX) {
|
if (_hist_count < CH_HIST_MAX) {
|
||||||
pos = (_hist_head + _hist_count) % CH_HIST_MAX;
|
pos = (_hist_head + _hist_count) % CH_HIST_MAX;
|
||||||
@@ -526,13 +616,93 @@ public:
|
|||||||
_hist[pos].timestamp = rtc_clock.getCurrentTime();
|
_hist[pos].timestamp = rtc_clock.getCurrentTime();
|
||||||
strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1);
|
strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1);
|
||||||
_hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0';
|
_hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0';
|
||||||
|
_hist[pos].relay_status = ACK_NONE;
|
||||||
|
_hist[pos].relay_seq = 0;
|
||||||
|
|
||||||
bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
|
bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
|
||||||
if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++;
|
if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++;
|
||||||
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {
|
// Called when a repeater echo of one of our channel sends is heard.
|
||||||
storeDMMsg(pub_key, outgoing, text);
|
void markChannelRelayed(uint32_t seq) {
|
||||||
|
if (seq == 0) return;
|
||||||
|
for (int i = 0; i < _hist_count; i++) {
|
||||||
|
ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX];
|
||||||
|
if (e.relay_status == ACK_PENDING && e.relay_seq == seq) {
|
||||||
|
e.relay_status = ACK_OK;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
|
||||||
|
uint32_t sender_timestamp = 0) {
|
||||||
|
// Drop retried copies of an incoming DM: a resend reuses the sender's
|
||||||
|
// timestamp and text but carries a fresh packet hash, so the mesh dup-filter
|
||||||
|
// lets it through. Match on prefix + sender_timestamp + text to suppress it.
|
||||||
|
if (!outgoing && sender_timestamp != 0) {
|
||||||
|
for (int i = 0; i < _dm_hist_count; i++) {
|
||||||
|
const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
||||||
|
if (!e.outgoing && e.msg_ts == sender_timestamp &&
|
||||||
|
memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0)
|
||||||
|
return; // duplicate retry — already in history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storeDMMsg(pub_key, outgoing, text, 0, 0, outgoing ? 0 : sender_timestamp, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv).
|
||||||
|
// Marks the matching pending outgoing DM as delivered.
|
||||||
|
void markDmDelivered(uint32_t ack_crc) {
|
||||||
|
if (ack_crc == 0) return;
|
||||||
|
for (int i = 0; i < _dm_hist_count; i++) {
|
||||||
|
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
||||||
|
if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) {
|
||||||
|
e.ack_status = ACK_OK;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry).
|
||||||
|
bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const {
|
||||||
|
int total = the_mesh.getNumContacts();
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
ContactInfo c;
|
||||||
|
if (the_mesh.getContactByIdx(i, c) && memcmp(c.id.pub_key, prefix, 4) == 0) {
|
||||||
|
out = c;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background tick (called every UI loop, regardless of the active screen) that
|
||||||
|
// drives auto-resend of on-device DMs. When a pending DM passes its deadline
|
||||||
|
// with no ACK: resend with the next attempt# (reusing the original timestamp so
|
||||||
|
// the recipient dedups) while resends remain, else mark it failed (✗).
|
||||||
|
void tickDmResends() {
|
||||||
|
uint32_t now = millis();
|
||||||
|
for (int i = 0; i < _dm_hist_count; i++) {
|
||||||
|
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
||||||
|
if (!e.outgoing || e.ack_status != ACK_PENDING) continue;
|
||||||
|
if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting
|
||||||
|
if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; }
|
||||||
|
ContactInfo c;
|
||||||
|
if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; }
|
||||||
|
uint32_t expected_ack = 0, est_timeout = 0;
|
||||||
|
uint8_t next_attempt = e.attempt + 1;
|
||||||
|
if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text,
|
||||||
|
expected_ack, est_timeout) > 0 && expected_ack) {
|
||||||
|
e.attempt = next_attempt;
|
||||||
|
e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC
|
||||||
|
e.ack_deadline_ms = now + est_timeout + 4000;
|
||||||
|
e.resends_left--;
|
||||||
|
} else {
|
||||||
|
e.ack_status = ACK_FAIL; // couldn't compose/send — give up
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int getDMUnreadTotal() const {
|
int getDMUnreadTotal() const {
|
||||||
@@ -775,6 +945,12 @@ public:
|
|||||||
int ret = _dm_fs.render(display, sender, e.text,
|
int ret = _dm_fs.render(display, sender, e.text,
|
||||||
_dm_hist_sel < dm_count - 1,
|
_dm_hist_sel < dm_count - 1,
|
||||||
_dm_hist_sel > 0);
|
_dm_hist_sel > 0);
|
||||||
|
if (e.outgoing) { // delivery marker in the (inverted) header bar
|
||||||
|
display.setColor(DisplayDriver::DARK);
|
||||||
|
drawAckGlyph(display, 2 + display.getTextWidth(sender) + 3, 1,
|
||||||
|
dmEffectiveStatus(e), e.attempt + 1);
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
}
|
||||||
if (_ctx_menu.active) _ctx_menu.render(display);
|
if (_ctx_menu.active) _ctx_menu.render(display);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
@@ -846,6 +1022,10 @@ public:
|
|||||||
display.setColor(DisplayDriver::DARK);
|
display.setColor(DisplayDriver::DARK);
|
||||||
}
|
}
|
||||||
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
|
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
|
||||||
|
if (e.outgoing) { // delivery marker after "Me"
|
||||||
|
int gx = 3 + display.getTextWidth(sender) + 3;
|
||||||
|
drawAckGlyph(display, gx, y + 1, dmEffectiveStatus(e), e.attempt + 1);
|
||||||
|
}
|
||||||
if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); }
|
if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); }
|
||||||
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
||||||
if (portrait_expand) {
|
if (portrait_expand) {
|
||||||
@@ -903,6 +1083,12 @@ public:
|
|||||||
int ret = _fs.render(display, fsender, fmsg,
|
int ret = _fs.render(display, fsender, fmsg,
|
||||||
_hist_sel < fs_hist_count - 1,
|
_hist_sel < fs_hist_count - 1,
|
||||||
_hist_sel > 0);
|
_hist_sel > 0);
|
||||||
|
// Channels: ✓ only once a repeater echo confirms relay (see list view).
|
||||||
|
if (strcmp(fsender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) {
|
||||||
|
display.setColor(DisplayDriver::DARK);
|
||||||
|
drawAckGlyph(display, 2 + display.getTextWidth(fsender) + 3, 1, ACK_OK);
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
}
|
||||||
if (_ctx_menu.active) _ctx_menu.render(display);
|
if (_ctx_menu.active) _ctx_menu.render(display);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
@@ -989,6 +1175,13 @@ public:
|
|||||||
display.setColor(DisplayDriver::DARK);
|
display.setColor(DisplayDriver::DARK);
|
||||||
}
|
}
|
||||||
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
|
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender);
|
||||||
|
// Channels have no recipient ACK — only show ✓ once a repeater echo
|
||||||
|
// confirms the send was relayed into the mesh; otherwise no marker
|
||||||
|
// (absence is normal, not a failure).
|
||||||
|
if (strcmp(sender, "Me") == 0 && _hist[ring_pos].relay_status == ACK_OK) {
|
||||||
|
int gx = 3 + display.getTextWidth(sender) + 3;
|
||||||
|
drawAckGlyph(display, gx, y + 1, ACK_OK);
|
||||||
|
}
|
||||||
if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); }
|
if (age[0]) { display.setCursor(display.width() - age_w, y + 1); display.print(age); }
|
||||||
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
||||||
if (portrait_expand) {
|
if (portrait_expand) {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class SettingsScreen : public UIScreen {
|
|||||||
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER,
|
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER,
|
||||||
// Messages section
|
// Messages section
|
||||||
SECTION_MESSAGES,
|
SECTION_MESSAGES,
|
||||||
|
DM_RESEND,
|
||||||
MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4,
|
MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4,
|
||||||
MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9,
|
MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9,
|
||||||
Count
|
Count
|
||||||
@@ -570,6 +571,12 @@ class SettingsScreen : public UIScreen {
|
|||||||
display.print("Rooms");
|
display.print("Rooms");
|
||||||
display.setCursor(display.valCol(), y);
|
display.setCursor(display.valCol(), y);
|
||||||
display.print((p && p->room_fav_only) ? "fav" : "all");
|
display.print((p && p->room_fav_only) ? "fav" : "all");
|
||||||
|
} else if (item == DM_RESEND) {
|
||||||
|
display.print("Resend");
|
||||||
|
display.setCursor(display.valCol(), y);
|
||||||
|
uint8_t n = p ? p->dm_resend_count : 0;
|
||||||
|
if (n == 0) display.print("OFF");
|
||||||
|
else { char buf[6]; snprintf(buf, sizeof(buf), "%ux", (unsigned)n); display.print(buf); }
|
||||||
} else if (isMsgSlot(item)) {
|
} else if (isMsgSlot(item)) {
|
||||||
int slot = msgSlotIndex(item);
|
int slot = msgSlotIndex(item);
|
||||||
char label[5];
|
char label[5];
|
||||||
@@ -777,6 +784,12 @@ public:
|
|||||||
_dirty = true;
|
_dirty = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (_selected == DM_RESEND && p) {
|
||||||
|
int n = p->dm_resend_count;
|
||||||
|
if (right || enter) n = (n + 1) % 6; // 0..5, wraps
|
||||||
|
else if (left) n = (n + 5) % 6;
|
||||||
|
if (left || right || enter) { p->dm_resend_count = (uint8_t)n; _dirty = true; return true; }
|
||||||
|
}
|
||||||
if (_selected == BATT_DISPLAY && p) {
|
if (_selected == BATT_DISPLAY && p) {
|
||||||
int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0;
|
int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0;
|
||||||
if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT;
|
if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT;
|
||||||
|
|||||||
@@ -1372,8 +1372,16 @@ int UITask::getChannelUnreadCount() const {
|
|||||||
return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread();
|
return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread();
|
||||||
}
|
}
|
||||||
|
|
||||||
void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {
|
void UITask::onMsgAck(uint32_t ack_crc) {
|
||||||
((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text);
|
((QuickMsgScreen*)quick_msg)->markDmDelivered(ack_crc);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UITask::onChannelRelayed(uint32_t seq) {
|
||||||
|
((QuickMsgScreen*)quick_msg)->markChannelRelayed(seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp) {
|
||||||
|
((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text, sender_timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
int UITask::getDMUnreadTotal() const {
|
int UITask::getDMUnreadTotal() const {
|
||||||
@@ -1602,6 +1610,9 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m
|
|||||||
|
|
||||||
void UITask::loop() {
|
void UITask::loop() {
|
||||||
char c = 0;
|
char c = 0;
|
||||||
|
// Background delivery: resend pending on-device DMs whose ACK timed out, and
|
||||||
|
// finalise the ✗ marker — runs regardless of which screen is active.
|
||||||
|
((QuickMsgScreen*)quick_msg)->tickDmResends();
|
||||||
#if UI_HAS_JOYSTICK
|
#if UI_HAS_JOYSTICK
|
||||||
uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION;
|
uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION;
|
||||||
int ev = user_btn.check();
|
int ev = user_btn.check();
|
||||||
|
|||||||
@@ -171,7 +171,9 @@ public:
|
|||||||
bool isMelodyPlaying();
|
bool isMelodyPlaying();
|
||||||
void showAlert(const char* text, int duration_millis);
|
void showAlert(const char* text, int duration_millis);
|
||||||
void addChannelMsg(uint8_t channel_idx, const char* text) override;
|
void addChannelMsg(uint8_t channel_idx, const char* text) override;
|
||||||
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) override;
|
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0) override;
|
||||||
|
void onMsgAck(uint32_t ack_crc) override;
|
||||||
|
void onChannelRelayed(uint32_t seq) override;
|
||||||
int getDMUnreadTotal() const;
|
int getDMUnreadTotal() const;
|
||||||
int getMsgCount() const { return _msgcount; }
|
int getMsgCount() const { return _msgcount; }
|
||||||
int getChannelUnreadCount() const;
|
int getChannelUnreadCount() const;
|
||||||
|
|||||||
@@ -1,6 +1,52 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
#include <helpers/ui/DisplayDriver.h>
|
||||||
|
|
||||||
|
// ── Scalable mini-icons ──────────────────────────────────────────────────────
|
||||||
|
// Small procedural glyphs (delivery markers, etc.) authored on a 1× pixel grid
|
||||||
|
// and scaled to the current font, so they stay legible on large-font layouts
|
||||||
|
// (e.g. landscape e-ink renders text at 2×). Distinct from the XBM page icons
|
||||||
|
// further down, which are fixed-size full bitmaps drawn via drawXbm().
|
||||||
|
//
|
||||||
|
// To add a mini-icon:
|
||||||
|
// 1. Define a bitmap: H rows top→bottom, one byte per row, bit (1<<col) set =
|
||||||
|
// filled pixel (width must be ≤ 8).
|
||||||
|
// 2. Draw it with miniIconDraw(display, x, topY, BITMAP, width, height).
|
||||||
|
// Scaling is automatic — no per-display handling needed.
|
||||||
|
|
||||||
|
// Pixel scale from the font: 1× on an 8px OLED line, 2× on a 16px landscape
|
||||||
|
// e-ink line, etc. Bitmaps are authored on the 1× grid.
|
||||||
|
inline int miniIconScale(DisplayDriver& d) {
|
||||||
|
int s = d.getLineHeight() / 8;
|
||||||
|
return s < 1 ? 1 : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a w×h (w ≤ 8) bitmap with the current ink colour, scaled by the font and
|
||||||
|
// vertically centred in the text line that starts at top_y.
|
||||||
|
inline void miniIconDraw(DisplayDriver& d, int x, int top_y,
|
||||||
|
const uint8_t* rows, int w, int h) {
|
||||||
|
const int s = miniIconScale(d);
|
||||||
|
int y = top_y + (d.getLineHeight() - h * s) / 2;
|
||||||
|
if (y < top_y) y = top_y;
|
||||||
|
for (int r = 0; r < h; r++)
|
||||||
|
for (int c = 0; c < w; c++)
|
||||||
|
if (rows[r] & (1 << c)) d.fillRect(x + c * s, y + r * s, s, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal row of `count` square dots (scaled, vertically centred). Used by
|
||||||
|
// the "awaiting ACK" marker, where the dot count = number of send attempts.
|
||||||
|
inline void miniIconDotRow(DisplayDriver& d, int x, int top_y, int count) {
|
||||||
|
const int s = miniIconScale(d);
|
||||||
|
const int dot = 2 * s, pitch = 3 * s; // 2px dot + 1px gap, scaled
|
||||||
|
int y = top_y + (d.getLineHeight() - dot) / 2;
|
||||||
|
if (y < top_y) y = top_y;
|
||||||
|
for (int i = 0; i < count; i++) d.fillRect(x + i * pitch, y, dot, dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini-icon bitmaps (authored on the 1× grid).
|
||||||
|
static const uint8_t ICON_CHECK[4] = { 0x10, 0x08, 0x05, 0x02 }; // ✓ 5×4
|
||||||
|
static const uint8_t ICON_CROSS[4] = { 0x09, 0x06, 0x06, 0x09 }; // ✗ 4×4
|
||||||
|
|
||||||
// 'meshcore', 128x13px
|
// 'meshcore', 128x13px
|
||||||
static const uint8_t meshcore_logo [] = {
|
static const uint8_t meshcore_logo [] = {
|
||||||
|
|||||||
Reference in New Issue
Block a user