From 1e22f6326d196f761fc6919c10c8c70eb854e30b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 27 Oct 2026 23:45:06 -0700 Subject: [PATCH] Fix Cardputer history, notifications and hardware lifecycle --- .github/workflows/run-unit-tests.yml | 2 + examples/companion_radio/MyMesh.cpp | 82 ++++- examples/companion_radio/MyMesh.h | 15 +- test/test_cardputer_channel_identity.py | 185 +++++++++++ test/test_cardputer_delivery.py | 167 +++++++++- test/test_cardputer_gps.py | 2 +- test/test_cardputer_hardware.py | 299 ++++++++++++++++++ test/test_cardputer_mesh_storage.py | 39 ++- test/test_cardputer_messaging.py | 41 ++- test/test_cardputer_ui.py | 120 ++++++- test/test_cardputer_ui_lifecycle.py | 153 ++++++++- .../CardputerDataStore.cpp | 17 +- .../CardputerDataStore.h | 4 + .../CardputerSensorManager.cpp | 69 +++- .../CardputerSensorManager.h | 41 ++- variants/m5stack_cardputer_adv/README.md | 27 +- .../m5stack_cardputer_adv/UnreadCounter.h | 16 +- .../companion_radio/CardputerMesh.cpp | 57 +++- .../companion_radio/CardputerMesh.h | 6 +- .../ui-cardputer/CardputerUITask.cpp | 67 +++- .../ui-cardputer/CardputerUITask.h | 8 +- .../ui-cardputer/screen/MainScreen.cpp | 19 +- .../ui-cardputer/screen/MainScreen.h | 4 +- .../ui-cardputer/screen/SettingsScreen.cpp | 23 +- .../ui-cardputer/screen/SettingsScreen.h | 1 + .../ui-cardputer/screen/ToolsScreen.cpp | 41 ++- .../ui-cardputer/screen/ToolsScreen.h | 7 + .../ui-cardputer/screen/main/ChannelsPage.cpp | 4 +- .../ui-cardputer/screen/main/ChatPage.cpp | 50 ++- .../ui-cardputer/screen/main/ChatPage.h | 6 +- .../ui-cardputer/screen/main/ContactsPage.cpp | 2 +- .../hw/CardputerSpeaker.cpp | 151 +++++---- .../hw/CardputerSpeaker.h | 34 +- variants/m5stack_cardputer_adv/platformio.ini | 1 + variants/m5stack_cardputer_adv/target.cpp | 8 +- variants/m5stack_cardputer_adv/target.h | 8 +- 36 files changed, 1558 insertions(+), 218 deletions(-) create mode 100644 test/test_cardputer_channel_identity.py create mode 100644 test/test_cardputer_hardware.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index c3ece928f..052aeb281 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -403,8 +403,10 @@ jobs: python3 -B test/test_cardputer_ui_lifecycle.py -v python3 -B test/test_cardputer_mesh_storage.py -v python3 -B test/test_cardputer_messaging.py -v + python3 -B test/test_cardputer_channel_identity.py -v python3 -B test/test_cardputer_delivery.py -v python3 -B test/test_cardputer_gps.py -v + python3 -B test/test_cardputer_hardware.py -v python3 -B test/test_cardputer_runtime_memory.py -v python3 -B test/test_cardputer_license.py -v python3 -B test/test_protocol_parser_bounds.py -v diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fc2c33de5..7f81df74d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2344,7 +2344,7 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo if (!queued) return false; #if CARDPUTER_ADV_UI - onUiChannelMessage(secret, text); + onUiChannelMessage(channel_idx, text); #endif #if COMPANION_FEATURE_NOTIFICATIONS const bool notification_managed = notify && _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis()); @@ -6703,6 +6703,9 @@ void MyMesh::handleCmdFrame(size_t len) { #if MESH_ENABLE_MESHTEXT_CHAT if (success && isMeshtextChannel(channel)) { if (sendMeshtextText(channel, text, len - i)) { +#if CARDPUTER_ADV_UI + onUiChannelMessageSent(channel_idx, text, len - i); +#endif #if UI_POCKET_GUI if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i); #endif @@ -6715,6 +6718,13 @@ void MyMesh::handleCmdFrame(size_t len) { && parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } else if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) { +#if CARDPUTER_ADV_UI + // Native group packets include ": "; BaseChatMesh limits + // the text to the remaining payload. History must match that packet. + const size_t available = MAX_TEXT_LEN - (strlen(_prefs.node_name) + 2); + const size_t sent_length = len - i < available ? len - i : available; + onUiChannelMessageSent(channel_idx, text, sent_length); +#endif #if UI_POCKET_GUI if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i); #endif @@ -9551,19 +9561,45 @@ void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size, } #if CARDPUTER_ADV_UI +int MyMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& expected_ack, uint32_t& est_timeout, + uint8_t* packet_hash, const uint8_t* replace_retry_key, + const uint8_t* message_retry_key, uint32_t delay_millis) { + const int result = BaseChatMesh::sendMessage(recipient, timestamp, attempt, text, + expected_ack, est_timeout, packet_hash, replace_retry_key, message_retry_key, delay_millis); + if (result != MSG_SEND_FAILED && attempt == 0) onUiContactMessageSent(recipient, text); + return result; +} + +void MyMesh::syncUiNotificationSettings() { + if (!_ui) return; + _ui->setHasConnection(_serial && _serial->isConnected()); +#if COMPANION_FEATURE_NOTIFICATIONS + const auto& notifications = _notifications.settings(); + _ui->setNotificationOutputs(notifications.enabled ? notifications.outputs : 31); + _ui->setNotificationLedOwnership(_notifications.ownsLed()); +#endif +} + bool MyMesh::sendUiChannelMessage(int channel_idx, uint32_t timestamp, const char* text, size_t length) { ChannelDetails channel; if (!text || !length || length != strlen(text) || !getChannel(channel_idx, channel) || !channel.name[0]) return false; #if MESH_ENABLE_MESHTEXT_CHAT - if (isMeshtextChannel(channel)) return sendMeshtextText(channel, text, length); + if (isMeshtextChannel(channel)) { + if (!sendMeshtextText(channel, text, length)) return false; + onUiChannelMessageSent(channel_idx, text, length); + return true; + } #endif if (isAliasRadio2Defined() && parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) return false; const size_t prefix_length = strlen(_prefs.node_name) + 2; if (prefix_length >= MAX_TEXT_LEN || length > MAX_TEXT_LEN - prefix_length) return false; - return sendGroupMessage(timestamp, channel.channel, _prefs.node_name, text, length); + if (!sendGroupMessage(timestamp, channel.channel, _prefs.node_name, text, length)) return false; + onUiChannelMessageSent(channel_idx, text, length); + return true; } #endif @@ -9940,10 +9976,17 @@ void MyMesh::handleTerminalDisplayCommand(const char* arguments) { } void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel, - const char* text) { + const char* text +#if CARDPUTER_ADV_UI + , uint8_t channel_idx +#endif + ) { #if MESH_ENABLE_MESHTEXT_CHAT if (isMeshtextChannel(channel)) { if (text && sendMeshtextText(channel, text, strlen(text))) { +#if CARDPUTER_ADV_UI + onUiChannelMessageSent(channel_idx, text, strlen(text)); +#endif terminalOutput().printf(" Queued to %s (Meshtext).\r\n", channel.name); } else { terminalOutput().printf(" ERROR: %s; use get mt.status\r\n", _mt_status); @@ -9968,6 +10011,9 @@ void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel, } else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(), channel.channel, _prefs.node_name, text, text_len)) { +#if CARDPUTER_ADV_UI + onUiChannelMessageSent(channel_idx, text, text_len); +#endif terminalOutput().printf(" Sent to %s.\r\n", channel.name); } else { terminalOutput().print(" ERROR: unable to send\r\n"); @@ -10432,16 +10478,25 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, ChannelDetails channel; bool found = false; size_t requested_index = 0; +#if CARDPUTER_ADV_UI + uint8_t selected_channel_idx = 0; +#endif if (mesh::cli::parseTerminalChannelIndex( channel_message, MAX_GROUP_CHANNELS, requested_index)) { found = getChannel((int)requested_index, channel) && channel.name[0] != 0; +#if CARDPUTER_ADV_UI + selected_channel_idx = static_cast(requested_index); +#endif } else { for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { if (getChannel(i, channel) && channel.name[0] != 0 && mesh::cli::terminalChannelNameMatches(channel_message, channel.name)) { found = true; +#if CARDPUTER_ADV_UI + selected_channel_idx = static_cast(i); +#endif break; } } @@ -10450,7 +10505,11 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, if (!found) { terminalOutput().print(" ERROR: channel not found (use 'channels')\r\n"); } else { - sendTerminalChannelMessage(channel, channel_message.text); + sendTerminalChannelMessage(channel, channel_message.text +#if CARDPUTER_ADV_UI + , selected_channel_idx +#endif + ); } } else if (strncmp(command, "send ", 5) == 0) { ContactInfo* recipient = getTerminalRecipient(); @@ -10488,7 +10547,11 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, if (!getChannel(0, channel) || channel.name[0] == 0) { terminalOutput().print(" ERROR: Public channel is unavailable\r\n"); } else { - sendTerminalChannelMessage(channel, text); + sendTerminalChannelMessage(channel, text +#if CARDPUTER_ADV_UI + , 0 +#endif + ); } } else if (strcmp(command, "list") == 0 || strncmp(command, "list ", 5) == 0) { int count = command[4] == ' ' ? atoi(command + 5) : 0; @@ -11752,6 +11815,11 @@ void MyMesh::checkSerialInterface() { } void MyMesh::loop() { +#if CARDPUTER_ADV_UI + // Apply saved output masks before the first received packet, not only at + // the end of the loop or after a later settings change. + syncUiNotificationSettings(); +#endif #if COMPANION_FEATURE_DIRECT_OTA_ADMIN if (_ota_direct_contact.type && !_ota_direct_admin.active(_ms->getMillis())) closeDirectOtaAdmin(); #endif @@ -11890,7 +11958,7 @@ void MyMesh::loop() { } #endif -#ifdef DISPLAY_CLASS +#if defined(DISPLAY_CLASS) || CARDPUTER_ADV_UI if (_ui) { _ui->setHasConnection(_serial->isConnected()); #if COMPANION_FEATURE_NOTIFICATIONS diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 04f8ef6a9..1d0b85818 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -393,7 +393,10 @@ protected: // Run after the shared duplicate/retention checks, including room posts and // Meshtext. Variant history must not bypass the Companion delivery policy. virtual void onUiContactMessage(const ContactInfo&, const char*) {} - virtual void onUiChannelMessage(const uint8_t[32], const char*) {} + virtual void onUiChannelMessage(uint8_t, const char*) {} + virtual void onUiContactMessageSent(const ContactInfo&, const char*) {} + virtual void onUiChannelMessageSent(uint8_t, const char*, size_t) {} + void syncUiNotificationSettings(); #endif #if COMPANION_FEATURE_TEMP_RADIO bool isTempRadioActive() const override { @@ -618,6 +621,10 @@ public: #if CARDPUTER_ADV_UI uint16_t getPrimaryRadioPreamble() const { return _radio_profiles.primaryPreamble(); } bool queueUiContactWrite(const ContactInfo& contact) { return scheduleContactWrite(contact); } + int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, + const char* text, uint32_t& expected_ack, uint32_t& est_timeout, + uint8_t* packet_hash = nullptr, const uint8_t* replace_retry_key = nullptr, + const uint8_t* message_retry_key = nullptr, uint32_t delay_millis = 0); bool sendUiChannelMessage(int channel_idx, uint32_t timestamp, const char* text, size_t length); #endif @@ -769,7 +776,11 @@ private: void handleTerminalPath(ContactInfo& recipient, const char* path_spec); void importTerminalCard(char* command); void listTerminalChannels(); - void sendTerminalChannelMessage(ChannelDetails& channel, const char* text); + void sendTerminalChannelMessage(ChannelDetails& channel, const char* text +#if CARDPUTER_ADV_UI + , uint8_t channel_idx +#endif + ); void handleTerminalDisplayCommand(const char* arguments); void printTerminalSendStatus(const char* operation, const ContactInfo& recipient, int result, diff --git a/test/test_cardputer_channel_identity.py b/test/test_cardputer_channel_identity.py new file mode 100644 index 000000000..cd05f16e7 --- /dev/null +++ b/test/test_cardputer_channel_identity.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Execute channel IDs, accepted sent history, and visible-chat unread policy.""" +from pathlib import Path +import unittest + +from cpp_source import body +import test_cardputer_mesh_storage as storage_tests + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/m5stack_cardputer_adv" + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include "RingBuffer.h" +#include "UnreadCounter.h" +#define PUB_KEY_SIZE 32 +namespace mesh { +struct Utils { + static void sha256(uint8_t* out, size_t count, const uint8_t* data, int length) { + assert(count == 32); SHA256(data, length, out); + } +}; +} +struct ContactInfo { struct { uint8_t pub_key[32] = {}; } id; }; +struct ChannelDetails { struct { uint8_t secret[32] = {}; } channel; char name[32] = {}; }; +struct HistoryMessage { char text[MAX_MESSAGE_LENGTH] = {}; bool out = false; }; +using ChatHistory = RingBuffer; +struct MainScreen; +struct UI { + MainScreen* screen; + bool visible = false; + bool isChatVisible() const { return visible; } + void onChannelMessageRecv(uint8_t, const uint8_t[32], const char*); + void onChannelMessageSent(uint8_t, const uint8_t[32], const char*); + void onContactMessageRecv(const ContactInfo&, const char*); + void onContactMessageSent(const ContactInfo&, const char*); +}; +struct ChatPage { + MainScreen* _p; + int current_channel_idx = -1, current_contact_idx = -1; + uint8_t current_channel_history_id[32] = {}; + ContactInfo current_contact; + ChatHistory chat_history; + int chat_history_offset = 0; + std::string last_sent_message; + bool onChannelMessageRecv(uint8_t, const uint8_t[32], const char*); + bool onContactMessageRecv(const ContactInfo&, const char*); + void onChannelMessageSent(uint8_t, const uint8_t[32], const char*); + void onContactMessageSent(const ContactInfo&, const char*); + void markRead(UnreadCounter&) const; +}; +struct MainScreen { + UI* _task; + ChatPage* chat_page; + UnreadCounter unread; + UI* getUiTask() { return _task; } + void onChannelMessageRecv(uint8_t, const uint8_t[32], const char*); + void onContactMessageRecv(const ContactInfo&, const char*); + void onChannelMessageSent(uint8_t, const uint8_t[32], const char*); + void onContactMessageSent(const ContactInfo&, const char*); +}; +void UI::onChannelMessageRecv(uint8_t index, const uint8_t* id, const char* text) { screen->onChannelMessageRecv(index,id,text); } +void UI::onChannelMessageSent(uint8_t index, const uint8_t* id, const char* text) { screen->onChannelMessageSent(index,id,text); } +void UI::onContactMessageRecv(const ContactInfo& contact, const char* text) { screen->onContactMessageRecv(contact,text); } +void UI::onContactMessageSent(const ContactInfo& contact, const char* text) { screen->onContactMessageSent(contact,text); } +struct Stored { std::vector id; std::string text; bool out, channel; }; +struct Store { + std::vector messages; + void storeMessage(const uint8_t* id, const char* text, bool out, bool channel) { + messages.push_back({std::vector(id,id+32), text, out, channel}); + } +}; +struct CardputerMesh { + Store* _store; + UI* _ui; + ChannelDetails channels[4]; + bool getChannel(int index, ChannelDetails& channel) { + if (index < 0 || index >= 4) return false; + channel = channels[index]; return true; + } + static bool channelHistoryId(const ChannelDetails&, uint8_t[32]); + bool getChannelHistoryId(int, uint8_t[32]); + void onUiChannelMessage(uint8_t, const char*); + void onUiContactMessage(const ContactInfo&, const char*); + void onUiChannelMessageSent(uint8_t, const char*, size_t); + void onUiContactMessageSent(const ContactInfo&, const char*); +}; +@METHODS@ +int main() { + UI ui{}; ChatPage page{}; MainScreen main{&ui,&page,{}}; + ui.screen = &main; page._p = &main; + Store store; CardputerMesh node{&store,&ui,{}}; + const char* labels[] = {"alpha", "r2-alpha", "mt-alpha", "mt-LongFast"}; + uint8_t ids[4][32]; + for (unsigned i=0; i<4; ++i) { + strcpy(node.channels[i].name, labels[i]); + memset(node.channels[i].channel.secret, 17, 16); + assert(node.getChannelHistoryId(i, ids[i])); + for (unsigned j=0; j(ids[2], ids[2]+32)); + node.onUiChannelMessage(3, "hidden selected chat"); + assert(page.chat_history.count() == 1 && main.unread.countChats() == 2); + ui.visible = true; + page.markRead(main.unread); + assert(main.unread.countChats() == 1); + node.onUiChannelMessage(3, "visible selected chat"); + assert(page.chat_history.count() == 2 && main.unread.countMessages() == 1); + + page.current_channel_idx = -1; page.current_contact_idx = 1; + page.current_contact.id.pub_key[31] = 1; + ContactInfo other = page.current_contact; other.id.pub_key[31] = 2; + node.onUiContactMessage(other, "same six-byte prefix"); + assert(main.unread.countChats() == 2); + ui.visible = false; + node.onUiContactMessage(page.current_contact, "settings overlay"); + assert(main.unread.countChats() == 3); + ui.visible = true; page.markRead(main.unread); + assert(main.unread.countChats() == 2); + + page.chat_history.clear(); store.messages.clear(); + for (const char* origin : {"app", "USB terminal", "keyboard"}) { + node.onUiContactMessageSent(page.current_contact, origin); + } + assert(page.chat_history.count() == 3 && store.messages.size() == 3); + for (const auto& message : store.messages) assert(message.out && !message.channel); + page.current_contact_idx = -1; page.current_channel_idx = 3; + page.chat_history.clear(); store.messages.clear(); + for (const char* origin : {"app", "USB terminal", "keyboard"}) { + node.onUiChannelMessageSent(3, origin, strlen(origin)); + } + assert(page.chat_history.count() == 3 && store.messages.size() == 3); + for (const auto& message : store.messages) { + assert(message.out && message.channel && message.id == std::vector(ids[3],ids[3]+32)); + } + assert(main.unread.countChats() == 2); + char bounded[MAX_MESSAGE_LENGTH + 20]; memset(bounded, 'x', sizeof(bounded)); + node.onUiChannelMessageSent(3, bounded, sizeof(bounded)); + assert(store.messages.back().text.size() == MAX_MESSAGE_LENGTH); +} +''' + + +class CardputerChannelIdentityTests(unittest.TestCase): + def test_identity_delivery_sent_history_and_visible_unread(self): + mesh = (VARIANT / "companion_radio/CardputerMesh.cpp").read_text() + main = (VARIANT / "companion_radio/ui-cardputer/screen/MainScreen.cpp").read_text() + chat = (VARIANT / "companion_radio/ui-cardputer/screen/main/ChatPage.cpp").read_text() + methods = [] + for source, signatures in ( + (mesh, ("bool CardputerMesh::channelHistoryId(", "bool CardputerMesh::getChannelHistoryId(", + "void CardputerMesh::onUiChannelMessage(", "void CardputerMesh::onUiContactMessage(", + "void CardputerMesh::onUiChannelMessageSent(", "void CardputerMesh::onUiContactMessageSent(")), + (main, ("void MainScreen::onChannelMessageRecv(", "void MainScreen::onContactMessageRecv(", + "void MainScreen::onChannelMessageSent(", "void MainScreen::onContactMessageSent(")), + (chat, ("bool ChatPage::onChannelMessageRecv(", "bool ChatPage::onContactMessageRecv(", + "void ChatPage::onChannelMessageSent(", "void ChatPage::onContactMessageSent(", + "void ChatPage::markRead("))): + methods.extend(body(source, signature) for signature in signatures) + storage_tests.CardputerMeshStorageTests.run_cpp( + self, HARNESS.replace("@METHODS@", "\n".join(methods)), + flags=("-Wl,--no-as-needed", "-lcrypto")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_delivery.py b/test/test_cardputer_delivery.py index 3e572d95d..4f2bff8e9 100644 --- a/test/test_cardputer_delivery.py +++ b/test/test_cardputer_delivery.py @@ -41,6 +41,7 @@ using std::isfinite; #define RESP_CODE_CHANNEL_MSG_RECV_V3 0x90 #define RESP_CODE_CHANNEL_MSG_RECV 0x83 #define PUSH_CODE_MSG_WAITING 0x80 +#define MSG_SEND_FAILED 0 static unsigned long millis() { return 1234; } enum class UIEventType { contactMessage, channelMessage }; namespace mesh { @@ -62,6 +63,11 @@ struct Serial { }; struct UI { int received = 0, sounds = 0; + uint8_t outputs = 31; + bool connected = false, led_owned = false; + void setHasConnection(bool value) { connected = value; } + void setNotificationOutputs(uint8_t value) { outputs = value; } + void setNotificationLedOwnership(bool value) { led_owned = value; } void setMessageNotificationOverride(bool, bool = false) {} void newMsg(uint8_t, const char*, const char*, int, int, const char*, int) { ++received; } void notify(UIEventType) { ++sounds; } @@ -103,7 +109,12 @@ struct MyMesh { bool millisHasNowPassed(unsigned long) const { return false; } bool addToOfflineQueue(const uint8_t*, int) { if (accept) ++offline_queue_len; return accept; } void onUiContactMessage(const ContactInfo&, const char*) { ++contact_history; } - void onUiChannelMessage(const uint8_t[32], const char*) { ++channel_history; } + void onUiChannelMessage(uint8_t slot, const char*) { assert(slot == 9); ++channel_history; } + int channel_sent_history = 0; + size_t last_history_length = 0; + void onUiChannelMessageSent(uint8_t slot, const char*, size_t length) { + assert(slot == 9); ++channel_sent_history; last_history_length = length; + } void makeOneKeyDMId(uint8_t* out, uint32_t, const char*) { memset(out, 7, 8); } bool wasDeliveredOneKeyDM(const uint8_t*, const uint8_t*) const { return delivered; } void rememberDeliveredOneKeyDM(const uint8_t*, const uint8_t*) { delivered = true; } @@ -168,9 +179,11 @@ int main() { #if MESH_ENABLE_MESHTEXT_CHAT assert(m.sendUiChannelMessage(9, 101, "text", 4)); assert(m.meshtext_sends == 1 && m.meshcore_sends == 0 && m.sent_key == 71); + assert(m.channel_sent_history == 1); m.transport_accept = false; assert(!m.sendUiChannelMessage(9, 101, "text", 4)); assert(m.meshcore_sends == 0); // Rejection never leaks onto the other protocol. + assert(m.channel_sent_history == 1); m.transport_accept = true; #else assert(!m.sendUiChannelMessage(9, 101, "text", 4)); @@ -188,6 +201,7 @@ int main() { assert(m.sendUiChannelMessage(9, 101, "text", 4)); assert(m.sent_key == 73); int sends = m.meshcore_sends; + const int sent_history = m.channel_sent_history; assert(!m.sendUiChannelMessage(1, 101, "text", 4)); assert(!m.sendUiChannelMessage(9, 101, nullptr, 4)); assert(!m.sendUiChannelMessage(9, 101, "", 0)); @@ -195,6 +209,7 @@ int main() { std::string oversized(160, 'x'); assert(!m.sendUiChannelMessage(9, 101, oversized.c_str(), oversized.size())); assert(m.meshcore_sends == sends); + assert(m.channel_sent_history == sent_history); #else assert(m.channel_history == 0 && m.ui.received == 0); #endif @@ -213,13 +228,17 @@ class CardputerDeliveryTests(unittest.TestCase): if cardputer: signatures.append("bool MyMesh::sendUiChannelMessage(") code = HARNESS.replace("@METHODS@", "\n".join(body(source, sig) for sig in signatures)) + self.run_program(code, f"-DCARDPUTER_ADV_UI={int(cardputer)}", + f"-DMESH_ENABLE_MESHTEXT_CHAT={int(meshtext)}") + + def run_program(self, code, *defines): with tempfile.TemporaryDirectory(prefix="meshcore-cardputer-delivery-") as temp: binary = Path(temp) / "delivery" built = subprocess.run([ os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-Wno-unused-variable", "-Wno-unused-function", "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", - f"-DCARDPUTER_ADV_UI={int(cardputer)}", f"-DMESH_ENABLE_MESHTEXT_CHAT={int(meshtext)}", + *defines, "-I", str(ROOT / "src"), "-x", "c++", "-", "-o", str(binary), ], input=code, text=True, capture_output=True) self.assertEqual(built.returncode, 0, built.stderr) @@ -235,6 +254,150 @@ class CardputerDeliveryTests(unittest.TestCase): def test_headless_delivery_keeps_its_existing_behavior(self): self.run_cpp(cardputer=False) + def test_contact_origin_hooks_and_saved_notification_masks(self): + source = SOURCE.read_text() + code = r''' +#include +#include +#include +#include +constexpr int MSG_SEND_FAILED=0; +struct ContactInfo { uint8_t key[32]{}; }; +struct BaseChatMesh { + int result=1, sends=0; + uint8_t last_attempt=0; + int sendMessage(const ContactInfo&, uint32_t stamp, uint8_t attempt, const char* text, + uint32_t& ack, uint32_t& timeout, uint8_t* hash, const uint8_t* replace, + const uint8_t* semantic, uint32_t delay) { + assert(stamp==123 && !strcmp(text,"outgoing") && hash && replace && semantic && delay==42); + ++sends; last_attempt=attempt; ack=0x12345678; timeout=5000; return result; + } +}; +struct UI { + uint8_t mask=31; bool connected=false, led=false; + void setHasConnection(bool value) { connected=value; } + void setNotificationOutputs(uint8_t value) { mask=value; } + void setNotificationLedOwnership(bool value) { led=value; } +}; +struct Serial { bool connected=false; bool isConnected() const { return connected; } }; +struct Engine { + struct Settings { bool enabled=true; uint8_t outputs=0; } saved; + const Settings& settings() const { return saved; } + bool ownsLed() const { return true; } +}; +struct MyMesh : BaseChatMesh { + UI ui; Serial serial; UI* _ui=&ui; Serial* _serial=&serial; Engine _notifications; + int stored=0; uint8_t stored_key=0; + void onUiContactMessageSent(const ContactInfo& c,const char* text) { + assert(!strcmp(text,"outgoing")); ++stored; stored_key=c.key[31]; + } + int sendMessage(const ContactInfo&,uint32_t,uint8_t,const char*,uint32_t&,uint32_t&, + uint8_t*,const uint8_t*,const uint8_t*,uint32_t); + void syncUiNotificationSettings(); +}; +@METHODS@ +int main() { + MyMesh m; ContactInfo recipient; recipient.key[31]=77; + uint32_t ack=0,timeout=0; uint8_t hash[8]{},replace[8]{},semantic[8]{}; + assert(m.sendMessage(recipient,123,0,"outgoing",ack,timeout,hash,replace,semantic,42)==1); + assert(m.stored==1 && m.stored_key==77 && ack==0x12345678 && timeout==5000); + for(unsigned attempt=1;attempt<6;++attempt) + assert(m.sendMessage(recipient,123,attempt,"outgoing",ack,timeout,hash,replace,semantic,42)==1); + assert(m.stored==1 && m.sends==6); // Radio retries never duplicate local history. + m.result=MSG_SEND_FAILED; + assert(m.sendMessage(recipient,123,0,"outgoing",ack,timeout,hash,replace,semantic,42)==MSG_SEND_FAILED); + assert(m.stored==1); + for(unsigned mask=0;mask<32;++mask) { + m._notifications.saved.outputs=mask; + m.serial.connected=(mask&1); + m.syncUiNotificationSettings(); + assert(m.ui.mask==mask && m.ui.connected==m.serial.connected && m.ui.led); + } + m._notifications.saved.enabled=false; m.syncUiNotificationSettings(); + assert(m.ui.mask==31); // Disabled custom controller restores ordinary UI defaults. + m._serial=nullptr; m.syncUiNotificationSettings(); assert(!m.ui.connected); + m._ui=nullptr; m.syncUiNotificationSettings(); +} +''' + code = code.replace("@METHODS@", "\n".join(body(source, sig) for sig in ( + "int MyMesh::sendMessage(", "void MyMesh::syncUiNotificationSettings("))) + self.run_program(code, "-DCARDPUTER_ADV_UI=1", "-DCOMPANION_FEATURE_NOTIFICATIONS=1") + loop = body(source, "void MyMesh::loop()") + self.assertLess(loop.index("syncUiNotificationSettings();"), loop.index("BaseChatMesh::loop();")) + + def test_actual_app_and_terminal_channel_admission_hooks(self): + source = SOURCE.read_text() + prefix = HARNESS.split("@METHODS@")[0] + prefix = prefix.replace("struct MyMesh {", r''' +struct Output { template void printf(const char*,Args...) {} void print(const char*) {} }; +struct Clock { uint32_t getCurrentTimeUnique() { return 123; } }; +#define TXT_TYPE_PLAIN 0 +#define ERR_CODE_ILLEGAL_ARG 1 +#define ERR_CODE_UNSUPPORTED_CMD 2 +#define ERR_CODE_BAD_STATE 3 +#define ERR_CODE_NOT_FOUND 4 +struct MyMesh {''') + prefix = prefix.replace(" bool sendUiChannelMessage(int, uint32_t, const char*, size_t);", r''' + bool sendUiChannelMessage(int, uint32_t, const char*, size_t); + uint8_t cmd_frame[176]{}; int ok=0,errors=0; + const char* _mt_status="test"; Output output; Clock clock; + Output& terminalOutput() { return output; } + Clock* getRTCClock() { return &clock; } + void writeOKFrame() { ++ok; } + void writeErrFrame(int) { ++errors; } + void appChannel(size_t len); + void sendTerminalChannelMessage(ChannelDetails&,const char*,uint8_t); +''') + app = body(source, "} else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG)") + app = "void MyMesh::appChannel(size_t len) " + app[app.index("{"):] + methods = app + "\n" + body(source, "void MyMesh::sendTerminalChannelMessage(") + exercise = r''' +int main() { + MyMesh m; strcpy(m.configured.name,"#mt-longfast"); + m.cmd_frame[1]=TXT_TYPE_PLAIN; m.cmd_frame[2]=9; + uint32_t stamp=123; memcpy(m.cmd_frame+3,&stamp,4); memcpy(m.cmd_frame+7,"text",4); + m.appChannel(11); + assert(m.ok==1 && m.channel_sent_history==1 && m.meshtext_sends==1 && m.meshcore_sends==0); + m.transport_accept=false; m.appChannel(11); + assert(m.errors==1 && m.channel_sent_history==1); + m.transport_accept=true; strcpy(m.configured.name,"#r2-testing"); + m.appChannel(11); + assert(m.ok==2 && m.channel_sent_history==2 && m.meshcore_sends==1); + assert(m.last_history_length==4); + m.transport_accept=false; m.appChannel(11); + assert(m.errors==2 && m.channel_sent_history==2); + m.transport_accept=true; + m.sendTerminalChannelMessage(m.configured,"text",9); + assert(m.channel_sent_history==3); + strcpy(m.configured.name,"#mt-longfast"); + m.sendTerminalChannelMessage(m.configured,"text",9); + assert(m.channel_sent_history==4); + m.transport_accept=false; + m.sendTerminalChannelMessage(m.configured,"text",9); + assert(m.channel_sent_history==4); + + // Native packets reserve room for the sender name, while Meshtext sends + // have their own framing and must retain the complete admitted text. + MyMesh long_message; + strcpy(long_message.configured.name,"#r2-testing"); + memset(long_message._prefs.node_name,'N',31); + long_message._prefs.node_name[31]=0; + long_message.cmd_frame[1]=TXT_TYPE_PLAIN; + long_message.cmd_frame[2]=9; + memcpy(long_message.cmd_frame+3,&stamp,4); + memset(long_message.cmd_frame+7,'x',150); + long_message.appChannel(157); + assert(long_message.ok==1 && long_message.channel_sent_history==1); + assert(long_message.last_history_length==127); + strcpy(long_message.configured.name,"#mt-longfast"); + long_message.appChannel(157); + assert(long_message.ok==2 && long_message.channel_sent_history==2); + assert(long_message.last_history_length==150); +} +''' + self.run_program(prefix + methods + exercise, + "-DCARDPUTER_ADV_UI=1", "-DMESH_ENABLE_MESHTEXT_CHAT=1") + def test_keyboard_transport_does_not_depend_on_text_terminal(self): source = re.sub(r"^\s*#\s*include[^\n]*", "", SOURCE.read_text(), flags=re.MULTILINE) result = subprocess.run([ diff --git a/test/test_cardputer_gps.py b/test/test_cardputer_gps.py index 1eff7b9ef..93141ecae 100644 --- a/test/test_cardputer_gps.py +++ b/test/test_cardputer_gps.py @@ -200,7 +200,7 @@ class CardputerGpsTests(unittest.TestCase): sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] built = subprocess.run([compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", - "-Wno-unused-parameter", *sanitizer, "-DENV_INCLUDE_GPS=1", + "-Wno-unused-parameter", *sanitizer, "-DENV_INCLUDE_GPS=1", "-DCARDPUTER_ADV_UI=1", "-DPIN_GPS_TX=15", "-DPIN_GPS_RX=13", "-DGPS_BAUD_RATE=115200", "-I", str(work), "-I", str(ROOT / "src"), "-I", str(VARIANT), "-I", str(nmea.parent), str(work / "main.cpp"), str(nmea), diff --git a/test/test_cardputer_hardware.py b/test/test_cardputer_hardware.py new file mode 100644 index 000000000..650e7cea6 --- /dev/null +++ b/test/test_cardputer_hardware.py @@ -0,0 +1,299 @@ +"""Execute bounded Cardputer audio and headless GNSS/environment ownership.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_cardputer_gps import ARDUINO, MESH, RTC, CAYENNE + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/m5stack_cardputer_adv" + +I2S = r''' +#pragma once +#include +#include +#include +#include +#include +using esp_err_t=int; +using i2s_mode_t=int; +constexpr int ESP_OK=0,ESP_ERR_TIMEOUT=1,ESP_FAIL=2; +constexpr int I2S_NUM_0=0,I2S_MODE_MASTER=1,I2S_MODE_TX=2,I2S_BITS_PER_SAMPLE_16BIT=16; +constexpr int I2S_CHANNEL_FMT_ONLY_LEFT=0,I2S_COMM_FORMAT_STAND_I2S=0,ESP_INTR_FLAG_LEVEL1=1; +constexpr int I2S_PIN_NO_CHANGE=-1; +struct i2s_config_t { + int mode,sample_rate,bits_per_sample,channel_format,communication_format,intr_alloc_flags; + int dma_buf_count,dma_buf_len;bool use_apll,tx_desc_auto_clear;int fixed_mclk; +}; +struct i2s_pin_config_t {int bck_io_num,ws_io_num,data_out_num,data_in_num;}; +struct WriteReply {int result;size_t limit;}; +inline std::deque replies; +inline std::vector audio; +inline unsigned installs=0,pins=0,uninstalls=0,writes=0; +inline int install_result=ESP_OK,pin_result=ESP_OK; +inline esp_err_t i2s_driver_install(int,const i2s_config_t* cfg,int,void*) { + ++installs;assert(cfg->dma_buf_count==4&&cfg->dma_buf_len==256);return install_result; +} +inline esp_err_t i2s_set_pin(int,const i2s_pin_config_t* cfg) { + ++pins;assert(cfg->bck_io_num==41&&cfg->ws_io_num==43&&cfg->data_out_num==42);return pin_result; +} +inline esp_err_t i2s_driver_uninstall(int){++uninstalls;return ESP_OK;} +inline esp_err_t i2s_write(int,const void* src,size_t size,size_t* written,unsigned wait) { + ++writes;assert(wait==0&&size>0&&size<=512); + WriteReply reply{ESP_OK,size};if(!replies.empty()){reply=replies.front();replies.pop_front();} + *written=std::min(size,reply.limit); + const auto* bytes=static_cast(src);audio.insert(audio.end(),bytes,bytes+*written); + return reply.result; +} +''' +WIRE = r''' +#pragma once +#include +#include +struct TwoWire { + unsigned transactions=0,fail_at=0;uint8_t address=0; + void beginTransmission(uint8_t addr){address=addr;++transactions;} + size_t write(uint8_t){return 1;} + uint8_t endTransmission(bool=true){return fail_at==transactions?2:0;} + void begin(int=0,int=0,unsigned=100000){} + void setPins(uint8_t,uint8_t){} + void setClock(unsigned){} + void end(){} +}; +inline TwoWire Wire,Wire1; +''' +SPEAKER = r''' +#include +#include +#include +#include +#include +#include +#include "hw/CardputerSpeaker.h" +static void reset(uint32_t now=100) { + now_ms=now;delay_count=0;replies.clear();audio.clear();installs=pins=uninstalls=writes=0; + install_result=pin_result=ESP_OK;Wire1=TwoWire(); +} +static void poll(CardputerSpeaker& speaker) { + const auto before=writes;speaker.processQueue();assert(writes-before<=1&&delay_count==0); +} +static void finish(CardputerSpeaker& speaker) { + for(unsigned i=0;i<100&&speaker.isBusy();++i){poll(speaker);++now_ms;} + assert(!speaker.isBusy()); +} +static void normalAndPartial() { + reset();CardputerSpeaker speaker;speaker.queueTone(1000,100,0.5f); + assert(speaker.isBusy()&&writes==0&&installs==1&&pins==1); + now_ms+=1099;poll(speaker);assert(writes==0); + ++now_ms;replies={{ESP_OK,1},{ESP_ERR_TIMEOUT,3},{ESP_OK,0},{ESP_OK,17},{ESP_OK,511}}; + finish(speaker);assert(audio.size()==3200); + for(size_t i=0;i7000); // new tone has no unsent old suffix + speaker.queueTone(3000,100);speaker.sleep();const auto before=writes;poll(speaker); + assert(!speaker.isBusy()&&writes==before&&uninstalls==1); + speaker.queueTone(3000,1);assert(installs==2);now_ms+=1100;finish(speaker); + for(float volume:{0.0f,-1.0f,std::numeric_limits::quiet_NaN()}) { + speaker.queueTone(4000,20,volume);assert(!speaker.isBusy()); + } + speaker.queueTone(0,20);assert(!speaker.isBusy()); + speaker.queueTone(9000,20);assert(!speaker.isBusy()); + speaker.queueTone(1000,0);assert(!speaker.isBusy()); +} +static void failures() { + for(int fault=0;fault<4;++fault) { + reset();CardputerSpeaker speaker; + if(fault==0)install_result=ESP_FAIL; + if(fault==1)pin_result=ESP_FAIL; + if(fault==2)Wire1.fail_at=4; + speaker.queueTone(1000,100); + if(fault<3) { + assert(!speaker.isBusy()&&writes==0&&uninstalls==(fault==0?0U:1U)); + } else { + now_ms+=1100;replies={{ESP_FAIL,0}};poll(speaker); + assert(!speaker.isBusy()&&uninstalls==1); + } + install_result=pin_result=ESP_OK;Wire1.fail_at=0; + speaker.queueTone(1000,1);assert(speaker.isBusy());now_ms+=1100;finish(speaker); + } + reset();CardputerSpeaker stalled;stalled.queueTone(1000,100);now_ms+=1100; + replies={{ESP_OK,0}};poll(stalled);assert(stalled.isBusy()); + now_ms+=1000;replies={{ESP_ERR_TIMEOUT,0}};poll(stalled); + assert(!stalled.isBusy()&&uninstalls==1); +} +static void rollover() { + reset(UINT32_MAX-500);CardputerSpeaker speaker;speaker.queueTone(1000,1); + now_ms=598;poll(speaker);assert(writes==0); + now_ms=599;poll(speaker);assert(writes==1&&speaker.isBusy()); + now_ms=662;poll(speaker);assert(speaker.isBusy()); + now_ms=663;poll(speaker);assert(!speaker.isBusy()); +} +int main(){normalAndPartial();replacementAndCancellation();failures();rollover();} +''' +INA260 = r''' +#pragma once +#include +class Adafruit_INA260 { +public: + inline static unsigned begins=0,reads=0; + bool begin(uint8_t addr=0x41,TwoWire* wire=&Wire){++begins;return addr==0x41&&wire==&Wire;} + float readBusVoltage(){++reads;return 6500.0f;} + float readCurrent(){return 250.0f;} + float readPower(){return 1600.0f;} +}; +''' +HEADLESS = r''' +#include +#include +#include +#include "CardputerSensorManager.h" +#if ENV_INCLUDE_GPS +struct Provider:LocationProvider { + unsigned starts=0,stops=0,polls=0,resets=0;bool valid=false; + void begin()override{++starts;} + void stop()override{++stops;} + void reset()override{++resets;} + void loop()override{++polls;} + bool clearPositionFix()override{valid=false;return true;} + long getLatitude()override{return 47610000;} + long getLongitude()override{return -122330000;} + long getAltitude()override{return 125000;} + bool isValid()override{return valid;} + bool isEnabled()override{return true;} + long satellitesCount()override{return 8;} + long getTimestamp()override{return 0;} + void sendSentence(const char* sentence)override { + Serial1.print(!std::strcmp(sentence,"$PCAS12,65535")?"$PCAS12,65535*1E\r\n":"$PCAS10,0*1C\r\n"); + } +}; +#endif +void LocationProvider::sendSentence(const char*){} +static void checkExternal(CardputerSensorManager& manager) { + uint8_t channels[2]={};assert(manager.getVoltageSensorChannels(channels,2)==1&&channels[0]==2); + SensorManager::VoltageSensorReading readings[2];assert(manager.queryVoltageSensors(readings,2)==1); + assert(readings[0].valid&&readings[0].voltage==6.5f); + CayenneLPP telemetry(64);const auto before=Adafruit_INA260::reads; + assert(manager.querySensors(TELEM_PERM_ENVIRONMENT,telemetry)&&Adafruit_INA260::reads==before+1); + assert(telemetry.getSize()>0&&telemetry.gps_count==0); + assert(manager.querySensors(TELEM_PERM_BASE,telemetry)&&Adafruit_INA260::reads==before+1); +} +int main() { + now_ms=100; +#if ENV_INCLUDE_GPS + Provider provider;CardputerSensorManager manager(provider); +#else + CardputerSensorManager manager; +#endif + assert(manager.begin()&&Serial1.starts==1&&Adafruit_INA260::begins==1); + assert(Serial1.output=="$PCAS12,65535*1E\r\n"&&!manager.isGpsPowered()); + assert(Serial1.rx==15&&Serial1.tx==13&&Serial1.baud==115200); + assert(manager.gpsUsesSerialUart(1)&&manager.gpsSerialTransportMayConflict(1)); + assert(!manager.gpsSerialTransportCanYield(1)&&!manager.setGpsSerialTransportBlocked(1,true)); + checkExternal(manager); +#if ENV_INCLUDE_GPS + assert(manager.isGPSDetected()&&manager.getNumSettings()==1); + assert(!std::strcmp(manager.getSettingName(0),"gps")&&manager.getSettingName(1)==nullptr); + assert(!std::strcmp(manager.getSettingValue(0),"0")); + assert(provider.starts==0&&provider.resets==0&&provider.polls==0); + manager.setPowerSavingEnabled(true); + for(unsigned i=0;i<4;++i){now_ms+=3600000;provider.syncTime();manager.loop();} + assert(provider.starts==0&&provider.resets==0&&provider.polls==0&&Serial1.starts==1); + assert(manager.setSettingValue("gps","1")&&manager.isGpsPowered()); + assert(provider.starts==1&&provider.resets==0); + assert(manager.setSettingValue("gps_interval","2"));provider.valid=true;manager.loop(); + CayenneLPP telemetry(64);assert(manager.querySensors(TELEM_PERM_LOCATION,telemetry)); + assert(telemetry.gps_count==1); // one cache, one GPS field, generic query delegated exactly once + manager.setSettingValue("gps","0");assert(!manager.isGpsPowered()); + const auto polls=provider.polls;now_ms+=3600000;manager.loop();assert(provider.polls==polls); +#else + assert(!manager.isGPSDetected()&&manager.getNumSettings()==0&&manager.getLocationProvider()==nullptr); + assert(manager.getSettingName(0)==nullptr&&manager.getSettingValue(0)==nullptr); + assert(!manager.setSettingValue("gps","1")&&!manager.setSettingValue("gps_interval","2")); + manager.setPowerSavingEnabled(true);now_ms+=3600000;manager.loop(); + assert(!manager.isGpsPowered()&&Serial1.output.find("$PCAS10")==std::string::npos); +#endif + assert(Serial1.output.find("$PCAS12",1)!=std::string::npos); + manager.shutdownGpsTransportForDeepSleep();assert(Serial1.ends==1&&!manager.isGpsPowered()); +} +''' + + +class CardputerHardwareTests(unittest.TestCase): + def compile_run(self, code, *, gps=None): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + arduino = ARDUINO.replace("inline uint32_t delay_count=0;", "inline uint32_t delay_count=0;\n#define MESH_DEBUG_PRINTLN(...)\n#define POWERSAVING_DEBUG_PRINTLN(...)") + arduino = arduino.replace(" void end(){++ends;}", " explicit operator bool()const{return true;}\n void end(){++ends;}") + lpp = CAYENNE.replace(" void addTemperature", " void addCurrent(uint8_t,float){}\n void addPower(uint8_t,float){}\n void addTemperature") + with tempfile.TemporaryDirectory(prefix="cardputer-hardware-") as directory: + work = Path(directory) + (work / "driver").mkdir() + headers = {"Arduino.h": arduino, "Mesh.h": MESH, "RTClib.h": RTC, "Wire.h": WIRE, + "CayenneLPP.h": lpp, "driver/i2s.h": I2S, + "Adafruit_INA260.h": INA260, "main.cpp": code} + for name, content in headers.items(): + (work / name).write_text(content, encoding="ascii") + definitions, sources = [], [] + if gps is None: + sources = [VARIANT / "hw/CardputerSpeaker.cpp"] + else: + definitions = [f"-DENV_INCLUDE_GPS={int(gps)}", "-DCARDPUTER_ADV_UI=0", + "-DESP32_PLATFORM", "-DENV_INCLUDE_INA260=1", "-DPIN_GPS_TX=15", + "-DPIN_GPS_RX=13", "-DGPS_BAUD_RATE=115200"] + if not gps: + # The unchanged generic no-GPS settings methods retain a + # local index even when every conditional setting is absent. + definitions += ["-Wno-unused-variable"] + sources = [ROOT / "src/helpers/SensorManager.cpp", + ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp", + VARIANT / "CardputerSensorManager.cpp"] + binary = work / ("hardware.exe" if os.name == "nt" else "hardware") + sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built = subprocess.run([compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", *sanitizer, *definitions, + "-I", str(work), "-I", str(ROOT / "src"), "-I", str(VARIANT), + str(work / "main.cpp"), *map(str, sources), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + def test_speaker_nonblocking_partial_replacement_failure_and_wrap(self): + self.compile_run(SPEAKER) + + def test_headless_without_gps_provider_preserves_environment_and_standby(self): + self.compile_run(HEADLESS, gps=False) + + def test_headless_with_gps_has_one_owner_cache_and_external_sensors(self): + self.compile_run(HEADLESS, gps=True) + + def test_all_roles_compile_the_fixed_manager_without_forcing_gps(self): + config = (VARIANT / "platformio.ini").read_text(encoding="ascii") + base = config.split("[M5Stack_Cardputer_Adv]", 1)[1].split("[M5Stack_Cardputer_Adv_ui]", 1)[0] + self.assertIn("+", base) + self.assertIn("+<../variants/m5stack_cardputer_adv/CardputerSensorManager.cpp>", base) + self.assertNotIn("-D ENV_INCLUDE_GPS=1", base) + target = (VARIANT / "target.cpp").read_text(encoding="ascii") + self.assertIn("#if ENV_INCLUDE_GPS", target) + self.assertIn("CardputerSensorManager sensors(nmea);", target) + self.assertIn("CardputerSensorManager sensors;", target) + self.assertNotIn("EnvironmentSensorManager sensors", target) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_mesh_storage.py b/test/test_cardputer_mesh_storage.py index 5dbc6daae..0b32da424 100644 --- a/test/test_cardputer_mesh_storage.py +++ b/test/test_cardputer_mesh_storage.py @@ -130,7 +130,7 @@ int main() { memset(bounded, 'x', MAX_MESSAGE_LENGTH); store.storeMessage(key, bounded, false, true); delete[] bounded; - const std::string channel_path = std::string(HISTORY_DIR_CHANNEL) + "/" + std::string(32, '0'); + const std::string channel_path = std::string(CHANNEL_HISTORY_ID_DIR) + "/" + std::string(64, '0'); assert(sd.files.at(channel_path)->bytes == "<< " + std::string(MAX_MESSAGE_LENGTH, 'x') + "\n"); store.storeMessage(key, "line1\r\nline2", true, true); assert(sd.files.at(channel_path)->bytes.find(">> line1 line2\n") != std::string::npos); @@ -235,7 +235,7 @@ int main() { assert(node.sendFloodScoped(channel, other, 0)); assert(node.last_message_hash[0] == 77 && node.last_message_heard_repeats == 3); delete other; - assert(node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 1); + assert(node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 0); node.resend_timeout = 10; node.now = 9; node.loop(); @@ -422,6 +422,41 @@ int main() { assert(!memcmp(&restored, &candidate, sizeof(candidate))); assert(!store.saveCustomPrefs(previous)); } +''' + self.run_cpp((VARIANT / "CardputerDataStore.cpp").read_text() + checks, + headers=self.storage_headers()) + + def test_channel_identity_storage_preserves_ambiguous_legacy_logs(self): + checks = r''' +#include +int main() { + mesh::RTCClock clock; + FILESYSTEM primary, sd; stat_fs = &primary; + CardputerDataStore store(primary, clock); store.beginCustom(&sd); + const std::string legacy_path = std::string(HISTORY_DIR_CHANNEL) + "/" + std::string(32,'0'); + auto legacy = sd.open(legacy_path.c_str(), FILE_WRITE); + legacy.println("<< ambiguous old conversation"); legacy.close(); + const std::string previous = sd.files.at(legacy_path)->bytes; + uint8_t first[32] = {}, second[32] = {}; first[31] = 1; second[31] = 2; + ChatHistory history; + store.loadMessages(first, true, history); + assert(history.count() == 0); + store.storeMessage(first, "first identity", false, true); + store.storeMessage(second, "second identity", true, true); + store.loadMessages(first, true, history); + const HistoryMessage* message = nullptr; + assert(history.count() == 1 && history.get(0,message)); + assert(!strcmp(message->text,"first identity") && !message->out); + store.loadMessages(second, true, history); + assert(history.count() == 1 && history.get(0,message)); + assert(!strcmp(message->text,"second identity") && message->out); + assert(sd.files.size() == 3 && sd.files.at(legacy_path)->bytes == previous); + for (const auto& entry : sd.files) { + if (entry.first == legacy_path) continue; + assert(entry.first.find(std::string(HISTORY_DIR) + "/chan2/") == 0); + assert(entry.first.substr(entry.first.find_last_of('/')+1).size() == 64); + } +} ''' self.run_cpp((VARIANT / "CardputerDataStore.cpp").read_text() + checks, headers=self.storage_headers()) diff --git a/test/test_cardputer_messaging.py b/test/test_cardputer_messaging.py index 36dddc20b..48d5372e1 100644 --- a/test/test_cardputer_messaging.py +++ b/test/test_cardputer_messaging.py @@ -42,15 +42,17 @@ struct Store { }; struct UI { std::vector events; - unsigned contacts = 0, channels = 0; + unsigned contacts = 0, channels = 0, sent_contacts = 0, sent_channels = 0; uint8_t key[32] = {}; void onMessageSendAttempt(uint8_t, uint8_t, MessageSendState state) { events.push_back(state); } void onContactMessageRecv(const ContactInfo& contact, const char*) { ++contacts; memcpy(key, contact.id.pub_key, 32); } - void onChannelMessageRecv(const mesh::GroupChannel& channel, const char*) { - ++channels; memcpy(key, channel.secret, 32); + void onChannelMessageRecv(uint8_t, const uint8_t* id, const char*) { + ++channels; memcpy(key, id, 32); } + void onContactMessageSent(const ContactInfo&, const char*) { ++sent_contacts; } + void onChannelMessageSent(uint8_t, const uint8_t*, const char*) { ++sent_channels; } }; struct MyMesh { bool admit = true, present = true, persist = true, standard_save = true; @@ -58,12 +60,15 @@ struct MyMesh { unsigned sends = 0, standard_saves = 0, path_saves = 0; ContactInfo current; uint8_t sent_path = 0, sent_attempt = 0; - int sendMessage(const ContactInfo& contact, uint32_t, uint8_t attempt, const char*, + virtual void onUiContactMessageSent(const ContactInfo&, const char*) {} + int sendMessage(const ContactInfo& contact, uint32_t, uint8_t attempt, const char* text, uint32_t& ack, uint32_t& estimate) { ++sends; if (!admit) { ack = 0; estimate = 0; return MSG_SEND_FAILED; } sent_path = contact.out_path_len; sent_attempt = attempt; - ack = 1000 + attempt; estimate = timeout; return 1; + ack = 1000 + attempt; estimate = timeout; + if (attempt == 0) onUiContactMessageSent(contact, text); + return 1; } ContactInfo* lookupContactByPubKey(const uint8_t* key, size_t count) { assert(count == 32); @@ -86,7 +91,11 @@ struct CardputerMesh : MyMesh { void checkResend(); void cancelResending(); void onUiContactMessage(const ContactInfo&, const char*); - void onUiChannelMessage(const uint8_t[32], const char*); + void onUiChannelMessage(uint8_t, const char*); + void onUiContactMessageSent(const ContactInfo&, const char*) override; + bool getChannelHistoryId(int index, uint8_t* id) { + assert(index == 7); memset(id, 0, 32); id[31] = 88; return true; + } bool savePrefs(); bool saveCustomPrefs(); void beginCustom(); @@ -104,6 +113,7 @@ int main() { node.current.out_path_len = 2; assert(node.sendDirectMessage(node.current, 5, "message") != MSG_SEND_FAILED); assert(node.resend_timeout == node.now + node.timeout && node.store.writes == 1); + assert(node.ui.sent_contacts == 1); assert(node.last_msg.expected_ack == 1000 && node.last_msg.attempt == 1); assert(node.sendDirectMessage(node.current, 6, "other") == MSG_SEND_FAILED && node.sends == 1); node.current.out_path_len = 3; @@ -111,6 +121,7 @@ int main() { node.checkResend(); assert(node.sent_path == 3 && node.sent_attempt == 1 && node.last_msg.attempt == 2); assert(node.resend_timeout == node.now + 32000 && node.store.writes == 1); + assert(node.ui.sent_contacts == 1); node.admit = false; node.checkResend(); assert(!node.isMessageSending() && node.resend_timeout == 0 && node.last_msg.expected_ack == 0); @@ -149,8 +160,7 @@ int main() { accepted.current.id.pub_key[31] = 77; accepted.onUiContactMessage(accepted.current, "direct"); assert(accepted.ui.contacts == 1 && accepted.store.writes == 1 && accepted.store.key[31] == 77); - uint8_t channel_key[32] = {}; channel_key[31] = 88; - accepted.onUiChannelMessage(channel_key, "group"); + accepted.onUiChannelMessage(7, "group"); assert(accepted.ui.channels == 1 && accepted.store.writes == 2 && accepted.store.key[31] == 88); assert(accepted.ui.key[31] == 88); } @@ -165,6 +175,14 @@ SELECTION = r''' #define PUB_KEY_SIZE 32 struct ContactInfo { struct { uint8_t pub_key[32] = {}; } id; int marker = 0; }; struct ChannelDetails { struct { uint8_t secret[32] = {}; } channel; char name[32] = "channel"; }; +struct CardputerMesh { + static bool channelHistoryId(const ChannelDetails& channel, uint8_t* id) { + memset(id, 0, 32); + for (size_t i=0; i contacts; ChannelDetails channel; @@ -185,6 +203,7 @@ struct ChatPage { int current_contact_idx = 2, current_channel_idx = -1; ContactInfo current_contact; ChannelDetails current_channel; + uint8_t current_channel_history_id[32] = {}; History chat_history; int chat_history_offset = 2; std::string last_sent_message = "previous"; @@ -205,8 +224,11 @@ int main() { page.refreshSelectedContact(); assert(page.current_contact_idx == -1 && page.chat_history.count == 0 && page.last_sent_message.empty()); page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel; + CardputerMesh::channelHistoryId(page.current_channel, page.current_channel_history_id); strcpy(the_mesh_cp.channel.name, "renamed"); - assert(page.refreshSelectedChannel() && !strcmp(page.current_channel.name, "renamed")); + assert(!page.refreshSelectedChannel() && page.current_channel_idx == -1); + page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel; + CardputerMesh::channelHistoryId(page.current_channel, page.current_channel_history_id); the_mesh_cp.channel.channel.secret[31] = 1; assert(!page.refreshSelectedChannel() && page.current_channel_idx == -1); page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel; @@ -225,6 +247,7 @@ class CardputerMessagingTests(unittest.TestCase): methods = "\n".join(body(source, signature) for signature in ( "int CardputerMesh::sendDirectMessage(", "void CardputerMesh::checkResend()", "void CardputerMesh::cancelResending()", "void CardputerMesh::onUiContactMessage(", + "void CardputerMesh::onUiContactMessageSent(", "void CardputerMesh::onUiChannelMessage(", "bool CardputerMesh::saveCustomPrefs()", "bool CardputerMesh::savePrefs()", "void CardputerMesh::beginCustom()")) last_message = body((VARIANT / "types.h").read_text(), "struct LastSentMessage {") + ";" diff --git a/test/test_cardputer_ui.py b/test/test_cardputer_ui.py index 1e3800a9d..d550dbd22 100644 --- a/test/test_cardputer_ui.py +++ b/test/test_cardputer_ui.py @@ -158,7 +158,7 @@ struct CardputerDisplay { static const char* textLevelName(int) { return "Large"; } void setColor(PaletteColor) {} void setTextSize(int) {} - void drawTextCentered(int,int,const char*) {} + void drawTextCentered(int,int,const char* text) { last=text; } void drawTextLeftAlign(int,int,const char*) {} void drawTextEllipsized(int,int,int,const char*) {} void drawTextLeftAlignWithScroll(int,int,int,const char* s) { last=s; } @@ -174,6 +174,7 @@ struct SettingsScreen { Task* _task; String edit_buffer; uint8_t edit_u8=0; + int32_t edit_tx_power=0; int menu_index=RadioFreq; char edit_error[80]="Invalid value"; bool is_editing=true; @@ -261,7 +262,7 @@ int main() { assert(prefs.bw==125 && the_mesh_cp.saves==2); s.edit_u8=4; assert(!s.commitItemEdit(SettingsScreen::RadioSf)); assert(!s.commitItemEdit(SettingsScreen::RadioCr)); - s.edit_u8=23; assert(!s.commitItemEdit(SettingsScreen::RadioPwr)); + s.edit_tx_power=23; assert(!s.commitItemEdit(SettingsScreen::RadioPwr)); assert(prefs.sf==8 && prefs.cr==5 && prefs.tx_power_dbm==22 && the_mesh_cp.saves==2); // The UI must use the live shared command path and leave prefs untouched on failure. assert(the_mesh_cp.command=="set radio 915.250000,125.000000,8,5,96"); @@ -286,6 +287,19 @@ int main() { } s.edit_buffer="4400"; assert(s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection)); assert(custom.battery_correction==1.1f); + // Signed TX values remain visible and editable through zero and both driver limits. + s.menu_index=SettingsScreen::RadioPwr; s.edit_tx_power=-9; + s.render(display); assert(display.last=="- -9 +"); + Keyboard::Event left{Keyboard::ARROW_LEFT,""},right{Keyboard::ARROW_RIGHT,""}; + s.handleEditInput(SettingsScreen::RadioPwr,left); assert(s.edit_tx_power==-9); + s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==-8); + assert(s.commitItemEdit(SettingsScreen::RadioPwr) && prefs.tx_power_dbm==-8); + s.edit_tx_power=-1; s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==0); + assert(s.commitItemEdit(SettingsScreen::RadioPwr) && prefs.tx_power_dbm==0); + s.edit_tx_power=22; s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==22); + for(int32_t invalid:{-129,-10,23,128}) { + s.edit_tx_power=invalid; assert(!s.commitItemEdit(SettingsScreen::RadioPwr)); + } // A missing/failed display cannot turn a battery shutdown into a null dereference. CardputerUITask ui{nullptr,&task.board}; ui.shutdown(); assert(task.board.off==1 && radio_driver.off==1); @@ -330,6 +344,108 @@ int main() { executed = subprocess.run([str(binary)], capture_output=True, text=True) self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + def test_actual_tools_shutdown_release_timeout_and_guard(self): + tools = (UI / "screen/ToolsScreen.cpp").read_text(encoding="ascii") + header = (UI / "screen/ToolsScreen.h").read_text(encoding="ascii") + task = (UI / "CardputerUITask.cpp").read_text(encoding="ascii") + enums = re.search(r"enum ToolsPage.*?enum ToolsMenuItem.*?};", header, re.S).group() + timeout = re.search(r"static constexpr uint32_t POWER_OFF_RELEASE_TIMEOUT_MS = \d+;", header).group() + code = r''' +#include +#include +#include +#include +uint32_t fake_now=0; +unsigned long millis() { return fake_now; } +namespace Keyboard { +enum Key { KEY_RETURN,KEY_ESC,KEY_A }; +struct Event { Key key; bool changed,down; }; +} +struct Board { + unsigned boots=0,offs=0; + void reboot() { ++boots; } + void powerOff() { ++offs; } +}; +struct Display { void turnOff() {} }; +struct Radio { unsigned offs=0; void powerOff() { ++offs; } } radio_driver; +struct CardputerUITask { + Board* _board; + Display* _display=nullptr; + bool allow_shutdown=true; + std::string alert; + bool prepareForShutdown() { return allow_shutdown; } + void showAlert(const char* text,int) { alert=text; } + void dismissAlert() { alert.clear(); } + void shutdown(bool restart=false); +}; +struct Mesh { + bool sendAdvert(bool) { return true; } + bool sendRepeatersDiscover() { return true; } +} the_mesh_cp; +struct ToolsScreen { +''' + code += enums + "\n" + timeout + r''' + CardputerUITask* _task; + ToolsPage page=MenuPage; + uint16_t discovered_repeaters_count=0; + bool power_off_pending=false,power_off_released=false; + uint32_t power_off_deadline=0; + void menuItemEnter(ToolsMenuItem); + bool handlePowerOffInput(const Keyboard::Event&); + void poll(); +}; +''' + code += function(task, "void CardputerUITask::shutdown(") + "\n" + for signature in ("void ToolsScreen::menuItemEnter(", "bool ToolsScreen::handlePowerOffInput(", "void ToolsScreen::poll("): + code += function(tools, signature) + "\n" + code += r''' +int main() { + Board board; CardputerUITask task{&board}; ToolsScreen tools{&task}; + Keyboard::Event held{Keyboard::KEY_RETURN,true,true},released{Keyboard::KEY_RETURN,true,false}; + Keyboard::Event idle{Keyboard::KEY_A,false,false},escape{Keyboard::KEY_ESC,true,true}; + // Menu activation returns immediately while the main loop can continue servicing work. + tools.menuItemEnter(ToolsScreen::PowerOff); + assert(tools.power_off_pending && board.offs==0); + for(unsigned tick=0; ticknotificationPower(mode); } +}; +struct NotificationStore: mesh::notify::Store { + bool save(const mesh::notify::Settings&) override { return true; } +}; struct Mesh { + mesh::notify::Controller* notifications=nullptr; + unsigned button_calls=0; + bool notificationButton() { ++button_calls; return notifications && notifications->button(); } Prefs* prefs=nullptr; CustomPrefs* custom=nullptr; CustomPrefs committed; @@ -181,6 +197,9 @@ struct CardputerUITask { void scheduleRefresh(unsigned ms=0) { next_refresh=millis()+ms; } void setCurrScreen(CardputerScreen* screen) { current_screen=screen; scheduleRefresh(); } bool shouldWakeDisplayForMessage() const; + bool isAlertActive() const; + bool isChatVisible() const; + bool handleNotificationInput(); void showAlert(const char*,int); void serviceDisplayPower(); void servicePairingState(); @@ -204,6 +223,8 @@ struct CardputerUITask { code += "bool CardputerUITask::" + function(abstract, "bool shouldWakeDisplayForMessage() const").removeprefix("bool ") + "\n" for signature in ( "void CardputerUITask::showAlert(", "void CardputerUITask::serviceDisplayPower(", + "bool CardputerUITask::isAlertActive(", "bool CardputerUITask::isChatVisible(", + "bool CardputerUITask::handleNotificationInput(", "void CardputerUITask::servicePairingState(", "bool CardputerUITask::isPairingPromptActive(", "void CardputerUITask::renderPairingPrompt(", "bool CardputerUITask::handlePairingInput(", "bool CardputerUITask::turnDisplayOn(", "bool CardputerUITask::getGPSState(", @@ -327,6 +348,34 @@ int main() { assert(ui.isPairingPromptActive()); fake_now+=119999U; ui.servicePairingState(); assert(ui.isPairingPromptActive()); ++fake_now; ui.servicePairingState(); assert(!ui.isPairingPromptActive()); + // Visibility means the matching conversation is actually readable, including overlays. + ui.current_screen=&main; main.page=MainScreen::CHAT; ui.alert_expiry=0; + board.usb=false; power.battery.mode=mesh::ui::DisplayMode::On; ui.serviceDisplayPower(); + assert(ui.isChatVisible()); + ui.showAlert("overlay",1000); assert(!ui.isChatVisible()); + ui.alert_expiry=0; ui.current_screen=&messages; assert(!ui.isChatVisible()); + ui.current_screen=&main; display.turnOff(); assert(!ui.isChatVisible()); + display.turnOn(); main.page=MainScreen::FIRST; assert(!ui.isChatVisible()); + main.page=MainScreen::CHAT; + interfaces.pending=true; ui.servicePairingState(); assert(!ui.isChatVisible()); + ui.handlePairingInput(escape); assert(ui.isChatVisible()); + // Actual shared controller stops infinite screen overrides on a physical-input action. + NotificationSink sink(&display); NotificationStore store; + mesh::notify::Controller notifications(sink,store); the_mesh_cp.notifications=¬ifications; + auto& rules=notifications.settings(); rules.enabled=1; + auto& rule=rules.rules[0]; rule.used=1; rule.fields=mesh::notify::FScreen; + rule.screen=1; rule.repeat=0; rule.stop=mesh::notify::Button; + assert(notifications.message(nullptr,nullptr,false,fake_now)); + assert(notifications.active()); + unsigned button_calls=the_mesh_cp.button_calls; + assert(ui.handleNotificationInput()); + assert(!notifications.active() && the_mesh_cp.button_calls==button_calls+1); + assert(!ui.handleNotificationInput()); + // Dismissing a notification during a pairing prompt leaves that modal prompt intact. + assert(notifications.message(nullptr,nullptr,false,fake_now)); + interfaces.pending=true; ui.servicePairingState(); + assert(ui.handleNotificationInput() && ui.isPairingPromptActive()); + ui.handlePairingInput(escape); // Failed display initialization leaves every display control and notification safe. ui._display=nullptr; auto saved=the_mesh_cp.saves; @@ -338,8 +387,11 @@ int main() { std::puts("PASS: Cardputer live controls, notifications, shared power and night drawing"); } ''' - self.assertIn("event.changed && event.down && !handlePairingInput(event)", task) - self.assertIn("BUTTON_EVENT_CLICK && !isPairingPromptActive()", task) + self.assertIn("if (handleNotificationInput())", task) + self.assertIn("else if (!power_off_input && !handlePairingInput(event))", task) + self.assertIn("if (!handleNotificationInput() && !isPairingPromptActive()", task) + self.assertIn("tools->handlePowerOffInput(event)", task) + self.assertIn("current_screen != tools_screen) tools->poll()", task) self.assertIn("renderPairingPrompt();\n _display->endFrame();", task) with tempfile.TemporaryDirectory(prefix="cardputer-lifecycle-") as tmp: work = Path(tmp) @@ -357,6 +409,103 @@ int main() { executed = subprocess.run([str(binary)], capture_output=True, text=True) self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + def test_actual_audio_loop_cleanup_waits_for_drain_and_keeps_visible_codec_warm(self): + task = (VARIANT / "companion_radio/ui-cardputer/CardputerUITask.cpp").read_text(encoding="ascii") + speaker = (VARIANT / "hw/CardputerSpeaker.h").read_text(encoding="ascii") + audio_prefix = function(task, "void CardputerUITask::loop(").split(" CardputerKeyboard *kb", 1)[0] + code = r''' +#include +#include +struct Speaker { + bool tone_pending=false,draining=false,finish_drain=false,initialized=true; + unsigned polls=0,sleeps=0,shutdowns=0; + void processQueue() { + ++polls; + if(finish_drain) { draining=false; finish_drain=false; } + } + void sleep() { + ++sleeps; + if(initialized) { initialized=false; ++shutdowns; } + tone_pending=draining=false; + } +''' + code += function(speaker, "bool isBusy() const") + r''' +}; +struct Board { + Speaker speaker; + Speaker* getSpeaker() { return &speaker; } + bool isExternalPowered() const { return false; } + bool isUsbHostConnected() const { return false; } +}; +struct Display { + bool on=false,desired=false; + bool isOn() const { return on; } + bool servicePower(bool,bool,bool) { bool changed=on!=desired; on=desired; return changed; } +}; +struct CardputerUITask { + Board* _board; + Display* _display; + unsigned refreshes=0; + bool hasConnection() const { return false; } + bool isPairingPromptActive() const { return false; } + void scheduleRefresh() { ++refreshes; } + void serviceDisplayPower(); + void servicePairingState() { serviceDisplayPower(); } + void loop(); +}; +''' + code += function(task, "void CardputerUITask::serviceDisplayPower(") + "\n" + code += audio_prefix + "}\n" + code += r''' +int main() { + Board board; Display display; + CardputerUITask ui{&board,&display}; + auto& audio=board.speaker; + // A sound-only notification plays while the screen is already dark. + audio.tone_pending=true; + ui.loop(); assert(audio.polls==1 && audio.sleeps==0 && audio.initialized); // warmup + ui.loop(); assert(audio.polls==2 && audio.sleeps==0 && audio.initialized); // transmitting + audio.tone_pending=false; audio.draining=true; + ui.loop(); assert(audio.polls==3 && audio.sleeps==0 && audio.initialized); // DMA still draining + audio.finish_drain=true; + ui.loop(); assert(audio.polls==4 && audio.sleeps==1 && !audio.initialized && audio.shutdowns==1); + ui.loop(); assert(audio.shutdowns==1); // repeated idle cleanup performs no further hardware shutdown + + // A display timeout must not cancel audio before the post-processQueue idle check. + display.on=true; display.desired=false; + audio.initialized=true; audio.tone_pending=true; + unsigned sleeps=audio.sleeps; + ui.loop(); assert(!display.on && audio.tone_pending && audio.sleeps==sleeps && audio.initialized); + audio.tone_pending=false; audio.draining=true; + ui.loop(); assert(audio.draining && audio.sleeps==sleeps); + audio.finish_drain=true; + ui.loop(); assert(!audio.initialized && audio.sleeps==sleeps+1); + + // Headless sound uses the same complete-playback-then-cleanup lifecycle. + ui._display=nullptr; audio.initialized=true; audio.tone_pending=true; + sleeps=audio.sleeps; ui.loop(); assert(audio.sleeps==sleeps && audio.tone_pending); + audio.tone_pending=false; audio.draining=true; + ui.loop(); assert(audio.sleeps==sleeps && audio.draining); + audio.finish_drain=true; ui.loop(); assert(audio.sleeps==sleeps+1 && !audio.initialized); + + // Visible idle operation retains the warmed codec for keyboard tones. + ui._display=&display; display.on=display.desired=true; audio.initialized=true; + sleeps=audio.sleeps; ui.loop(); assert(audio.sleeps==sleeps && audio.initialized); + std::puts("PASS: actual UI audio loop waits for DMA drain, powers down dark/headless idle, and stays warm while visible"); +} +''' + with tempfile.TemporaryDirectory(prefix="cardputer-audio-lifecycle-") as tmp: + source, binary = Path(tmp) / "test.cpp", Path(tmp) / "test" + source.write_text(code, encoding="ascii") + compiled = subprocess.run([ + "g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie", + str(source), "-o", str(binary), + ], capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + executed = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + if __name__ == "__main__": unittest.main() diff --git a/variants/m5stack_cardputer_adv/CardputerDataStore.cpp b/variants/m5stack_cardputer_adv/CardputerDataStore.cpp index d68c8e309..4c4e2f905 100644 --- a/variants/m5stack_cardputer_adv/CardputerDataStore.cpp +++ b/variants/m5stack_cardputer_adv/CardputerDataStore.cpp @@ -7,6 +7,8 @@ #include "globals.h" +static const char CHANNEL_HISTORY_ID_DIR[] = HISTORY_DIR "/chan2"; + static void buf_to_hex_fast(const unsigned char *src, size_t src_len, char *dst) { static const char hex_digits[] = "0123456789abcdef"; @@ -24,7 +26,8 @@ void CardputerDataStore::beginCustom(FILESYSTEM *history_fs) { } _history_fs->mkdir(CUSTOM_DATA_DIR); _history_fs->mkdir(HISTORY_DIR); - _history_fs->mkdir(HISTORY_DIR_CHANNEL); + // Leave ambiguous legacy PSK-only /chan histories intact for recovery. + _history_fs->mkdir(CHANNEL_HISTORY_ID_DIR); _history_fs->mkdir(HISTORY_DIR_DIRECT); } @@ -129,9 +132,7 @@ void CardputerDataStore::storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const ch char path[128]; char msg_copy[MAX_MESSAGE_LENGTH + 1] = { 0 }; char pkey_hex[PUB_KEY_SIZE * 2 + 1]; - uint8_t key_len = is_channel ? (PUB_KEY_SIZE / 2) : PUB_KEY_SIZE; - - buf_to_hex_fast(pkey, key_len, pkey_hex); + buf_to_hex_fast(pkey, PUB_KEY_SIZE, pkey_hex); int msg_len; for (msg_len = 0; msg_len < MAX_MESSAGE_LENGTH && text[msg_len] != '\0'; msg_len++) { @@ -144,7 +145,7 @@ void CardputerDataStore::storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const ch msg_copy[msg_len] = '\0'; - snprintf(path, sizeof(path), "%s/%s", is_channel ? HISTORY_DIR_CHANNEL : HISTORY_DIR_DIRECT, pkey_hex); + snprintf(path, sizeof(path), "%s/%s", is_channel ? CHANNEL_HISTORY_ID_DIR : HISTORY_DIR_DIRECT, pkey_hex); File file = _history_fs->open(path, FILE_APPEND, true); @@ -183,11 +184,9 @@ void CardputerDataStore::loadMessages(const uint8_t pkey[PUB_KEY_SIZE], bool is_ char line_buf[256] = { 0 }; uint8_t file_read_buf[1024]; - uint8_t key_len = is_channel ? (PUB_KEY_SIZE / 2) : PUB_KEY_SIZE; + buf_to_hex_fast(pkey, PUB_KEY_SIZE, pkey_hex); - buf_to_hex_fast(pkey, key_len, pkey_hex); - - snprintf(path, sizeof(path), "%s/%s", is_channel ? HISTORY_DIR_CHANNEL : HISTORY_DIR_DIRECT, pkey_hex); + snprintf(path, sizeof(path), "%s/%s", is_channel ? CHANNEL_HISTORY_ID_DIR : HISTORY_DIR_DIRECT, pkey_hex); if (!_history_fs->exists(path)) { return; diff --git a/variants/m5stack_cardputer_adv/CardputerDataStore.h b/variants/m5stack_cardputer_adv/CardputerDataStore.h index b9f8552ea..111e3c856 100644 --- a/variants/m5stack_cardputer_adv/CardputerDataStore.h +++ b/variants/m5stack_cardputer_adv/CardputerDataStore.h @@ -22,6 +22,9 @@ using ChatHistory = RingBuffer; // Contacts, channels and node prefs stay on the primary (internal) FS managed // by DataStore. Cardputer UI prefs live there too; per-chat history is kept // only when a microSD card is mounted. +// Channel identities include the complete name/protocol alias and 32-byte key. +// Legacy /history/chan PSK-only logs remain on SD for manual recovery; they +// cannot be assigned safely to one channel and are never silently imported. class CardputerDataStore : public DataStore { private: FILESYSTEM *_fs; @@ -36,6 +39,7 @@ public: bool hasHistory() const { return _history_fs != nullptr; } bool loadCustomPrefs(CustomNodePrefs &prefs); bool saveCustomPrefs(const CustomNodePrefs &prefs); + // For channels, pkey is the 32-byte channelHistoryId, not the wire PSK. void storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const char *text, bool is_sent, bool is_channel); void loadMessages(const uint8_t pkey[PUB_KEY_SIZE], bool is_channel, ChatHistory &history); }; diff --git a/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp b/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp index 4372c57aa..202f6124a 100644 --- a/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp +++ b/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp @@ -7,6 +7,7 @@ bool due(uint32_t now, uint32_t deadline) { } } +#if ENV_INCLUDE_GPS void CardputerSensorManager::armGpsPowerSavingCycle() { if (!powersaving_enabled || !_location->getGPSPowerSaving()) return; _location->syncTimeForPowerSavingCycle(); @@ -40,11 +41,18 @@ void CardputerSensorManager::stop_gps() { else _location->setNextGPSOn(0); _location->stop(); } +#endif void CardputerSensorManager::gps_standby(bool active) { // CASIC protocol sections CAS10/CAS12: hot start and standby for at most // 65535 seconds. The provider's MicroNMEA sender generates the checksum. +#if ENV_INCLUDE_GPS _location->sendSentence(active ? "$PCAS12,65535" : "$PCAS10,0"); +#else + // GPS-free roles still have a powered physical receiver. Keep it in standby + // without linking a parser/provider or advertising a GPS firmware feature. + Serial1.print(active ? "$PCAS12,65535*1E\r\n" : "$PCAS10,0*1C\r\n"); +#endif standby_renew_at = static_cast(millis()) + STANDBY_RENEW_MS; } @@ -55,25 +63,49 @@ bool CardputerSensorManager::begin() { Serial1.begin(GPS_BAUD_RATE); gps_serial_open = true; gps_active = false; +#if ENV_INCLUDE_GPS _location->setGPSPowerSaving(false); _location->stopTimeSync(); _location->stop(); +#endif // A fixed cap powers GNSS at boot even when the saved preference is off. // Silence it explicitly; UART traffic during startup is not a user choice. gps_standby(true); +#if !CARDPUTER_ADV_UI +#if ENV_INCLUDE_GPS + // The fixed cap uses command-based standby. Keep the generic manager's GPS + // discovery/transport permanently blocked: it must not reset or reopen this + // UART independently. Its environmental discovery and telemetry stay intact. + CardputerSensorBase::gps_serial_transport_blocked = true; +#endif + return CardputerSensorBase::begin(); +#else return true; +#endif } bool CardputerSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) { // Shared cache, permissions, bounded acquisition and manual-off cancellation. +#if !CARDPUTER_ADV_UI + return CardputerSensorBase::querySensors(requester_permissions, telemetry); +#elif ENV_INCLUDE_GPS queryGpsTelemetry(requester_permissions, telemetry); +#endif return true; } void CardputerSensorManager::loop() { if (!gps_serial_open) return; +#if !CARDPUTER_ADV_UI && (ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC)) + // Generic GPS discovery is blocked and its gps_detected/active remain false. + // This services external BSEC sensors and the one shared GPS telemetry cache. + CardputerSensorBase::loop(); +#endif const uint32_t now = millis(); +#if ENV_INCLUDE_GPS +#if CARDPUTER_ADV_UI loopGpsTelemetry(now); +#endif if (powersaving_enabled && isGpsTelemetryUserEnabled() && !isTrackerGpsModeEnabled() && _location->getGPSPowerSaving()) { const uint32_t next_off = _location->getNextGPSOff(); @@ -86,12 +118,14 @@ void CardputerSensorManager::loop() { start_gps(); } } +#endif if (!gps_active) { // CAS12 eventually wakes on its own. Renew off/standby without changing // saved intent or creating a GPS telemetry/clock acquisition. if (due(now, standby_renew_at)) gps_standby(true); return; } +#if ENV_INCLUDE_GPS _location->loop(); if (due(now, next_gps_update)) { if (_location->isValid()) { @@ -102,20 +136,36 @@ void CardputerSensorManager::loop() { } next_gps_update = now + getGpsUpdateIntervalMillis(); } +#endif } -int CardputerSensorManager::getNumSettings() const { return 1; } +int CardputerSensorManager::getNumSettings() const { + return CardputerSensorBase::getNumSettings() +#if ENV_INCLUDE_GPS + + 1 +#endif + ; +} const char *CardputerSensorManager::getSettingName(int i) const { - return i == 0 ? "gps" : nullptr; +#if ENV_INCLUDE_GPS + if (i == 0) return "gps"; + --i; +#endif + return CardputerSensorBase::getSettingName(i); } const char *CardputerSensorManager::getSettingValue(int i) const { - return i == 0 ? (isGpsTelemetryUserEnabled() ? "1" : "0") : nullptr; +#if ENV_INCLUDE_GPS + if (i == 0) return isGpsTelemetryUserEnabled() ? "1" : "0"; + --i; +#endif + return CardputerSensorBase::getSettingValue(i); } bool CardputerSensorManager::setSettingValue(const char *name, const char *value) { if (!name || !value) return false; +#if ENV_INCLUDE_GPS if (strcmp(name, "gps") == 0) { const bool enabled = strcmp(value, "0") != 0; const bool was_active = gps_active; @@ -129,12 +179,16 @@ bool CardputerSensorManager::setSettingValue(const char *name, const char *value } return true; } - const bool accepted = SensorManager::setSettingValue(name, value); +#endif + const bool accepted = CardputerSensorBase::setSettingValue(name, value); +#if ENV_INCLUDE_GPS if (accepted && strcmp(name, "gps_interval") == 0) next_gps_update = millis(); +#endif return accepted; } void CardputerSensorManager::setPowerSavingEnabled(bool enabled) { +#if ENV_INCLUDE_GPS if (powersaving_enabled == enabled) return; powersaving_enabled = enabled; if (isTrackerGpsModeEnabled()) { @@ -146,11 +200,18 @@ void CardputerSensorManager::setPowerSavingEnabled(bool enabled) { if (!user_enabled) return; if (!gps_active) start_gps(); else if (enabled) armGpsPowerSavingCycle(); +#else + CardputerSensorBase::setPowerSavingEnabled(enabled); +#endif } void CardputerSensorManager::shutdownGpsTransportForDeepSleep() { if (!gps_serial_open) return; +#if ENV_INCLUDE_GPS stop_gps(); +#else + gps_standby(true); +#endif Serial1.end(); gps_serial_open = false; } diff --git a/variants/m5stack_cardputer_adv/CardputerSensorManager.h b/variants/m5stack_cardputer_adv/CardputerSensorManager.h index 37f821296..c8d27f48c 100644 --- a/variants/m5stack_cardputer_adv/CardputerSensorManager.h +++ b/variants/m5stack_cardputer_adv/CardputerSensorManager.h @@ -2,13 +2,20 @@ #include #include +#if CARDPUTER_ADV_UI +using CardputerSensorBase = SensorManager; +#else +#include +using CardputerSensorBase = EnvironmentSensorManager; +#endif -class CardputerSensorManager : public SensorManager { +class CardputerSensorManager : public CardputerSensorBase { private: bool gps_active = false; bool gps_serial_open = false; uint32_t next_gps_update = 0; uint32_t standby_renew_at = 0; +#if ENV_INCLUDE_GPS LocationProvider *_location; void armGpsPowerSavingCycle(); @@ -17,9 +24,18 @@ protected: bool telemetryGpsActive() const override { return gps_active; } void telemetryGpsStart() override { start_gps(); } void telemetryGpsStop() override { stop_gps(); } +#endif public: +#if ENV_INCLUDE_GPS +#if CARDPUTER_ADV_UI CardputerSensorManager(LocationProvider &location) : _location(&location) {} +#else + CardputerSensorManager(LocationProvider &location) : CardputerSensorBase(location), _location(&location) {} +#endif +#else + CardputerSensorManager() = default; +#endif bool begin() override; bool querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) override; void loop() override; @@ -28,14 +44,33 @@ public: const char *getSettingValue(int i) const override; bool setSettingValue(const char *name, const char *value) override; void setPowerSavingEnabled(bool enabled) override; - bool isGPSDetected() const override { return gps_serial_open; } + bool isGPSDetected() const override { +#if ENV_INCLUDE_GPS + return gps_serial_open; +#else + return false; +#endif + } // The cap has command-controlled standby, not a GPIO power switch. A // pinless LocationProvider::isEnabled() cannot report its logical state. bool isGpsPowered() const override { return gps_active; } bool gpsUsesSerialUart(uint8_t uart) const override { return uart == 1 && gps_serial_open; } + // The cap cannot prove electrical silence without an enable pin. Do not + // let the environmental base or a serial bridge release this fixed UART. + bool gpsSerialTransportMayConflict(uint8_t uart) const override { return gpsUsesSerialUart(uart); } + bool gpsSerialTransportCanYield(uint8_t) const override { return false; } + bool setGpsSerialTransportBlocked(uint8_t, bool) override { return false; } void shutdownGpsTransportForDeepSleep() override; - LocationProvider *getLocationProvider() override { return _location; } + LocationProvider *getLocationProvider() override { +#if ENV_INCLUDE_GPS + return _location; +#else + return nullptr; +#endif + } +#if ENV_INCLUDE_GPS void start_gps(); void stop_gps(); +#endif void gps_standby(bool active); }; diff --git a/variants/m5stack_cardputer_adv/README.md b/variants/m5stack_cardputer_adv/README.md index c00e10bcf..7ff22915b 100644 --- a/variants/m5stack_cardputer_adv/README.md +++ b/variants/m5stack_cardputer_adv/README.md @@ -37,6 +37,17 @@ history and keyboard layouts. Companion builds retain all 350 contacts and use the existing bounded contact path cache to leave RAM available for Bluetooth, the display and keyboard layouts. +Accepted incoming messages and successful sends from the keyboard, phone app +or USB terminal share the same local history. Retries do not add another copy. +Only the conversation actually visible on the screen is marked read. + +Channel history identifies each channel by its full name (including protocol +prefix) and full key, so channels sharing a key keep separate conversations. +New channel logs use `/meshcore_custom/history/chan2` on the SD card. Older +`/meshcore_custom/history/chan` logs remain on the card for manual retrieval; +they are not imported because their channel identity can be ambiguous. Direct +message logs keep their existing paths. + When the device is managed by bmorcelli's Launcher, install updates by writing the app image to the MeshCore app slot over USB rather than reinstalling from the SD card: Launcher can repartition the flash to fit the image and drop the @@ -72,7 +83,8 @@ Extra large (19 px). The larger fonts are generated from DejaVu Sans with Radio settings use the same live apply and save path as the USB terminal; a reboot is not required. A busy radio applies the saved setting once idle. Rejected settings or storage failures show an error -and retain the previous saved value. Display brightness, text size and battery +and retain the previous saved value. TX power supports the radio's signed range, +including negative values. Display brightness, text size and battery correction are saved separately from the standard node preferences. Screen wake and timeout follow the shared `display.mode` and `display.timeout` @@ -91,8 +103,14 @@ Bluetooth pairing requests show the PIN for up to two minutes when the selected screen mode permits it. Esc dismisses the prompt; the current chat and draft remain selected. An explicit screen-off setting keeps the screen dark. -The speaker plays fixed UI tones and honors notification sound output and mute -settings. Custom RTTTL notification melodies are unsupported on this board. +The speaker plays fixed UI tones without blocking the mesh loop and honors +the saved notification outputs and mute settings. A key or top-button press +dismisses notifications configured to stop on button input. Custom RTTTL +notification melodies are unsupported on this board. + +Tools -> Restart and Power off flush pending contact writes before shutdown. +Power off waits for Enter release without blocking radio processing; holding +the key for two seconds or pressing Esc cancels it. ## Third-party notices @@ -112,6 +130,9 @@ neither dependency is bundled here. or at the end of its 10-minute acquisition window, then wakes after 30 minutes. Location telemetry can temporarily hold it awake under the common GPS policy. Turning GPS off cancels current acquisitions; saved GPS intervals apply. +- Repeater and room-server builds also put the Cap's fixed GNSS receiver into + standby when unused, while retaining external sensor support. GPS-free builds + send the standby command without adding a GPS parser or GPS settings. - Below 3.4 V the UI shows "Battery low" and powers off (no LoRa wake). ## Building on Windows diff --git a/variants/m5stack_cardputer_adv/UnreadCounter.h b/variants/m5stack_cardputer_adv/UnreadCounter.h index 933c9378f..25def1034 100644 --- a/variants/m5stack_cardputer_adv/UnreadCounter.h +++ b/variants/m5stack_cardputer_adv/UnreadCounter.h @@ -6,7 +6,7 @@ struct UnreadCounterItem { bool is_channel; - uint8_t pkey[CONTACT_LOOKUP_BYTES]; // todo: maybe replace to index, but what should we do if it gets deleted? + uint8_t pkey[32]; // Full contact public key or domain-separated channel ID. int count; }; @@ -15,7 +15,7 @@ class UnreadCounter { private: UnreadCounterItem _items[UNREAD_COUNTER_MAX_ITEMS]; uint16_t _count = 0; - using LookupPkey = uint8_t[CONTACT_LOOKUP_BYTES]; + using LookupPkey = uint8_t[32]; public: UnreadCounter() {} @@ -27,8 +27,8 @@ public: return &_items[index]; } - inline const uint16_t countChats() const { return _count; } - const uint16_t countMessages() const { + inline uint16_t countChats() const { return _count; } + uint16_t countMessages() const { uint16_t total = 0; for (uint16_t i = 0; i < _count; i++) { total += _items[i].count; @@ -38,7 +38,7 @@ public: bool add(bool is_channel, const LookupPkey pkey, int count) { for (uint16_t i = 0; i < _count; i++) { - if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, CONTACT_LOOKUP_BYTES) == 0) { + if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, sizeof(_items[i].pkey)) == 0) { _items[i].count += count; return true; } @@ -47,7 +47,7 @@ public: if (_count < UNREAD_COUNTER_MAX_ITEMS) { _items[_count].is_channel = is_channel; _items[_count].count = count; - memcpy(_items[_count].pkey, pkey, CONTACT_LOOKUP_BYTES); + memcpy(_items[_count].pkey, pkey, sizeof(_items[_count].pkey)); _count++; return true; } @@ -56,14 +56,14 @@ public: void reset(bool is_channel, const LookupPkey pkey) { for (uint16_t i = 0; i < _count; i++) { - if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, CONTACT_LOOKUP_BYTES) == 0) { + if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, sizeof(_items[i].pkey)) == 0) { // Swap the target item with the last active item if (i < _count - 1) { _items[i] = _items[_count - 1]; } _count--; - _items[_count] = { false, 0, 0 }; + _items[_count] = {}; return; } } diff --git a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp index d4b263c07..72a326b29 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp @@ -59,11 +59,31 @@ mesh::DispatcherAction CardputerMesh::onRecvPacket(mesh::Packet *pkt) { return action; } -void CardputerMesh::onUiChannelMessage(const uint8_t secret[32], const char *text) { - mesh::GroupChannel channel{}; - memcpy(channel.secret, secret, sizeof(channel.secret)); - _store->storeMessage(secret, text, false, true); - _ui->onChannelMessageRecv(channel, text); +bool CardputerMesh::channelHistoryId(const ChannelDetails &channel, uint8_t id[PUB_KEY_SIZE]) { + static const uint8_t domain[] = "Cardputer channel history v2"; + const size_t name_length = strnlen(channel.name, sizeof(channel.name)); + if (!name_length || name_length == sizeof(channel.name)) return false; + uint8_t identity[sizeof(domain) + 1 + sizeof(channel.name) + sizeof(channel.channel.secret)]; + size_t used = 0; + memcpy(identity, domain, sizeof(domain)); used += sizeof(domain); + identity[used++] = static_cast(name_length); + memcpy(identity + used, channel.name, name_length); used += name_length; + memcpy(identity + used, channel.channel.secret, sizeof(channel.channel.secret)); + used += sizeof(channel.channel.secret); + mesh::Utils::sha256(id, PUB_KEY_SIZE, identity, used); + return true; +} + +bool CardputerMesh::getChannelHistoryId(int channel_idx, uint8_t id[PUB_KEY_SIZE]) { + ChannelDetails channel; + return getChannel(channel_idx, channel) && channelHistoryId(channel, id); +} + +void CardputerMesh::onUiChannelMessage(uint8_t channel_idx, const char *text) { + uint8_t id[PUB_KEY_SIZE]; + if (!getChannelHistoryId(channel_idx, id)) return; + _store->storeMessage(id, text, false, true); + _ui->onChannelMessageRecv(channel_idx, id, text); } void CardputerMesh::onUiContactMessage(const ContactInfo &from, const char *text) { @@ -71,6 +91,22 @@ void CardputerMesh::onUiContactMessage(const ContactInfo &from, const char *text _ui->onContactMessageRecv(from, text); } +void CardputerMesh::onUiContactMessageSent(const ContactInfo &recipient, const char *text) { + _store->storeMessage(recipient.id.pub_key, text, true, false); + _ui->onContactMessageSent(recipient, text); +} + +void CardputerMesh::onUiChannelMessageSent(uint8_t channel_idx, const char *text, size_t length) { + uint8_t id[PUB_KEY_SIZE]; + if (!getChannelHistoryId(channel_idx, id)) return; + char stored_text[MAX_MESSAGE_LENGTH + 1]; + const size_t stored_length = length < MAX_MESSAGE_LENGTH ? length : MAX_MESSAGE_LENGTH; + memcpy(stored_text, text, stored_length); + stored_text[stored_length] = 0; + _store->storeMessage(id, stored_text, true, true); + _ui->onChannelMessageSent(channel_idx, id, stored_text); +} + bool CardputerMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis) { const bool is_group_text = pkt && pkt->getPayloadType() == PAYLOAD_TYPE_GRP_TXT; @@ -90,15 +126,7 @@ bool CardputerMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Pac } bool CardputerMesh::sendChannelMessage(int channel_idx, uint32_t timestamp, const char *text, size_t length) { - ChannelDetails channel; - if (!getChannel(channel_idx, channel) || !channel.name[0] - || !sendUiChannelMessage(channel_idx, timestamp, text, length)) return false; - char stored_text[MAX_MESSAGE_LENGTH + 1]; - const size_t stored_length = length < MAX_MESSAGE_LENGTH ? length : MAX_MESSAGE_LENGTH; - memcpy(stored_text, text, stored_length); - stored_text[stored_length] = 0; - _store->storeMessage(channel.channel.secret, stored_text, true, true); - return true; + return sendUiChannelMessage(channel_idx, timestamp, text, length); } int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text) { @@ -121,7 +149,6 @@ int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t time const uint32_t minimum_timeout = flood ? DIRECT_SEND_FLOOD_RESEND_RELAY : DIRECT_SEND_ROUTE_RESEND_RELAY; resend_timeout = futureMillis(est_timeout > minimum_timeout ? est_timeout : minimum_timeout); _ui->onMessageSendAttempt(last_msg.attempt, last_msg.total_attempts, last_msg.state); - _store->storeMessage(recipient.id.pub_key, text, true, false); return rc; } diff --git a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h index 68230788e..4a5d7e3d2 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h +++ b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h @@ -31,12 +31,16 @@ public: const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) override; void onControlDataRecv(mesh::Packet *packet) override; mesh::DispatcherAction onRecvPacket(mesh::Packet *pkt) override; - void onUiChannelMessage(const uint8_t secret[32], const char *text) override; + void onUiChannelMessage(uint8_t channel_idx, const char *text) override; void onUiContactMessage(const ContactInfo &from, const char *text) override; + void onUiContactMessageSent(const ContactInfo &recipient, const char *text) override; + void onUiChannelMessageSent(uint8_t channel_idx, const char *text, size_t length) override; bool sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis = 0) override; using MyMesh::sendFloodScoped; bool sendChannelMessage(int channel_idx, uint32_t timestamp, const char *text, size_t length); + static bool channelHistoryId(const ChannelDetails &channel, uint8_t id[PUB_KEY_SIZE]); + bool getChannelHistoryId(int channel_idx, uint8_t id[PUB_KEY_SIZE]); int sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp index 2fcbea56b..7255ff8d9 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp @@ -47,10 +47,16 @@ void CardputerUITask::dismissAlert() { scheduleRefresh(); } -bool CardputerUITask::isAlertActive() { +bool CardputerUITask::isAlertActive() const { return millis() < alert_expiry; } +bool CardputerUITask::isChatVisible() const { + return _display && _display->isOn() && current_screen == main_screen + && static_cast(main_screen)->getCurrentPage() == MainScreen::CHAT + && !isPairingPromptActive() && !isAlertActive(); +} + void CardputerUITask::notify(UIEventType t) { if ((_notification_outputs & mesh::notify::Sound) && !_message_notification_override && (t == UIEventType::channelMessage || t == UIEventType::contactMessage)) { @@ -120,7 +126,7 @@ void CardputerUITask::serviceDisplayPower() { if (_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection(), isPairingPromptActive())) { scheduleRefresh(); - if (!_display->isOn()) _board->getSpeaker()->sleep(); + if (!_display->isOn() && !_board->getSpeaker()->isBusy()) _board->getSpeaker()->sleep(); } } @@ -157,6 +163,13 @@ bool CardputerUITask::handlePairingInput(const Keyboard::Event &event) { return true; } +bool CardputerUITask::handleNotificationInput() { + if (!the_mesh_cp.notificationButton()) return false; + serviceDisplayPower(); + scheduleRefresh(); + return true; +} + void CardputerUITask::renderPairingPrompt() { if (!_display || !_display->isOn() || !isPairingPromptActive()) return; char pin[16]; @@ -172,8 +185,18 @@ void CardputerUITask::renderPairingPrompt() { _display->drawTextCentered(_display->width() / 2, 94, "Enter PIN on phone"); } -void CardputerUITask::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { - ((MainScreen *)main_screen)->onChannelMessageRecv(channel, text); +void CardputerUITask::onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + static_cast(main_screen)->onChannelMessageRecv(channel_idx, history_id, text); +} + +void CardputerUITask::onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + static_cast(main_screen)->onChannelMessageSent(channel_idx, history_id, text); + scheduleRefresh(); +} + +void CardputerUITask::onContactMessageSent(const ContactInfo &contact, const char *text) { + static_cast(main_screen)->onContactMessageSent(contact, text); + scheduleRefresh(); } void CardputerUITask::onContactMessageRecv(const ContactInfo &contact, const char *text) { @@ -214,7 +237,7 @@ void CardputerUITask::setCurrScreen(CardputerScreen *c) { */ void CardputerUITask::shutdown(bool restart) { if (!prepareForShutdown()) { - showAlert("Shutdown blocked: update in progress", 2000); + showAlert("Shutdown blocked: storage not ready", 2000); return; } if (restart) { @@ -229,26 +252,40 @@ void CardputerUITask::shutdown(bool restart) { void CardputerUITask::loop() { servicePairingState(); _board->getSpeaker()->processQueue(); + if ((!_display || !_display->isOn()) && !_board->getSpeaker()->isBusy()) { + _board->getSpeaker()->sleep(); + } CardputerKeyboard *kb = _board->getKeyboard(); const Keyboard::Modifiers latched = kb->getModifiers(); auto event = kb->poll(); - if (event.changed && event.down && !handlePairingInput(event)) { - if (turnDisplayOn()) { - kb->setModifiers(latched); // a wake-up press must not flip Shift/Fn - } else { - playSound(SoundType::Keyboard); - if (!handleShortcut(event) && current_screen) { - current_screen->handleInput(event); + ToolsScreen *tools = static_cast(tools_screen); + const bool power_off_input = tools && tools->handlePowerOffInput(event); + if (event.changed && event.down) { + if (handleNotificationInput()) { + kb->setModifiers(latched); // dismissal must not also arm a shortcut + } else if (!power_off_input && !handlePairingInput(event)) { + if (turnDisplayOn()) { + kb->setModifiers(latched); // a wake-up press must not flip Shift/Fn + } else { + playSound(SoundType::Keyboard); + if (!handleShortcut(event) && current_screen) { + current_screen->handleInput(event); + } } } } - if (_top_btn.check() == BUTTON_EVENT_CLICK && !isPairingPromptActive()) { - turnDisplayOn(); - sendFloodAdvert(); + if (_top_btn.check() == BUTTON_EVENT_CLICK) { + if (!handleNotificationInput() && !isPairingPromptActive() + && !(tools && tools->isPowerOffPending())) { + turnDisplayOn(); + sendFloodAdvert(); + } } + // A received-message preview must not suspend an armed release/timeout. + if (tools && current_screen != tools_screen) tools->poll(); if (current_screen) { current_screen->poll(); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h index 711a762c4..f3cc54342 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h @@ -48,6 +48,7 @@ class CardputerUITask : public AbstractUITask { void serviceDisplayPower(); void renderPairingPrompt(); bool handlePairingInput(const Keyboard::Event &event); + bool handleNotificationInput(); // Returns false if display is already on bool turnDisplayOn(); @@ -68,7 +69,8 @@ public: int drawModifierBadges(CardputerDisplay &lcd, int x, int y); void showAlert(const char *text, int duration_millis); void dismissAlert(); - bool isAlertActive(); + bool isAlertActive() const; + bool isChatVisible() const; inline int getMsgCount() const { return unsynced_msg_count; } inline bool hasDisplay() const { return _display != NULL; } inline bool isBuzzerQuiet() { return _node_prefs->buzzer_quiet; } @@ -88,7 +90,9 @@ public: void newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount, int channel_idx = -1, const char *channel_name = nullptr, int queue_index = -1) override; - void onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text); + void onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text); + void onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text); + void onContactMessageSent(const ContactInfo &contact, const char *text); void onContactMessageRecv(const ContactInfo &contact, const char *text); void onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp index d0857d438..c227a6f1d 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp @@ -92,6 +92,7 @@ void MainScreen::renderStatusIcons(CardputerDisplay &lcd) { } int MainScreen::render(CardputerDisplay &lcd) { + if (_task->isChatVisible()) static_cast(chat_page)->markRead(unread); lcd.setTextSize(1); renderStatusIcons(lcd); @@ -139,14 +140,22 @@ void MainScreen::messageRepeatsRecv(uint16_t count) { } } -void MainScreen::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { - bool to_selected = static_cast(chat_page)->onChannelMessageRecv(channel, text); +void MainScreen::onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + bool to_selected = static_cast(chat_page)->onChannelMessageRecv(channel_idx, history_id, text); if (!to_selected) { - unread.addChannel(channel.secret, 1); + unread.addChannel(history_id, 1); } } +void MainScreen::onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + static_cast(chat_page)->onChannelMessageSent(channel_idx, history_id, text); +} + +void MainScreen::onContactMessageSent(const ContactInfo &contact, const char *text) { + static_cast(chat_page)->onContactMessageSent(contact, text); +} + void MainScreen::onContactMessageRecv(const ContactInfo &contact, const char *text) { bool to_selected = static_cast(chat_page)->onContactMessageRecv(contact, text); @@ -244,9 +253,11 @@ void MainScreen::selectContact(ContactInfo &contact, int idx) { } void MainScreen::selectChannel(const ChannelDetails &channel, int idx) { + uint8_t history_id[PUB_KEY_SIZE]; + if (!CardputerMesh::channelHistoryId(channel, history_id)) return; setCurrentPage(MainScreenPage::CHAT); page_to_return = MainScreenPage::CHANNELS; - unread.resetChannel(channel.channel.secret); + unread.resetChannel(history_id); static_cast(chat_page)->selectChannel(channel, idx); } diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.h index 5bb181a18..58b35c568 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.h @@ -48,8 +48,10 @@ public: MainScreen(CardputerUITask *task, mesh::RTCClock *rtc, SensorManager *sensors, CompanionNodePrefs *node_prefs, CustomNodePrefs *custom_prefs); void messageRepeatsRecv(uint16_t count); - void onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text); + void onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text); void onContactMessageRecv(const ContactInfo &contact, const char *text); + void onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text); + void onContactMessageSent(const ContactInfo &contact, const char *text); void onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state); void poll() override; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp index 17246b783..a689c830b 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp @@ -139,7 +139,7 @@ bool SettingsScreen::enterItemEdit(SettingsItem item) { return true; case SettingsItem::RadioPwr: - edit_u8 = _node_prefs->tx_power_dbm; + edit_tx_power = _node_prefs->tx_power_dbm; is_editing = true; return true; @@ -236,9 +236,10 @@ bool SettingsScreen::commitItemEdit(SettingsItem item) { case SettingsItem::RadioCr: return commitRadio(_node_prefs->freq, _node_prefs->bw, _node_prefs->sf, edit_u8); case SettingsItem::RadioPwr: { - if (!radio_driver.isTxPowerValid(static_cast(edit_u8))) return false; + if (edit_tx_power < INT8_MIN || edit_tx_power > INT8_MAX + || !radio_driver.isTxPowerValid(static_cast(edit_tx_power))) return false; char command[24]; - snprintf(command, sizeof(command), "set tx %d", static_cast(edit_u8)); + snprintf(command, sizeof(command), "set tx %d", static_cast(edit_tx_power)); return commitCommand(command); } case SettingsItem::MeshPathSize: { @@ -295,10 +296,12 @@ void SettingsScreen::handleEditInput(SettingsItem item, Keyboard::Event &e) { break; case SettingsItem::RadioPwr: - if (e.key == Keyboard::ARROW_LEFT && edit_u8 > 1) { - edit_u8--; - } else if (e.key == Keyboard::ARROW_RIGHT && edit_u8 < LORA_TX_POWER) { - edit_u8++; + if (e.key == Keyboard::ARROW_LEFT && edit_tx_power > INT8_MIN + && radio_driver.isTxPowerValid(static_cast(edit_tx_power - 1))) { + --edit_tx_power; + } else if (e.key == Keyboard::ARROW_RIGHT && edit_tx_power < INT8_MAX + && radio_driver.isTxPowerValid(static_cast(edit_tx_power + 1))) { + ++edit_tx_power; } break; case SettingsItem::MeshPathSize: @@ -436,12 +439,16 @@ int SettingsScreen::render(CardputerDisplay &display) { switch (menu_index) { case SettingsItem::RadioSf: case SettingsItem::RadioCr: - case SettingsItem::RadioPwr: case SettingsItem::MeshPathSize: display.setTextSize(2); snprintf(buf, sizeof(buf), "- %d +", edit_u8); display.drawTextCentered(display.width() / 2, value_y, buf); break; + case SettingsItem::RadioPwr: + display.setTextSize(2); + snprintf(buf, sizeof(buf), "- %d +", static_cast(edit_tx_power)); + display.drawTextCentered(display.width() / 2, value_y, buf); + break; case SettingsItem::PublicInfoName: case SettingsItem::RadioFreq: case SettingsItem::RadioBw: diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h index 4b41b7e8a..f81ce828c 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h @@ -35,6 +35,7 @@ class SettingsScreen : public CardputerScreen { char edit_error[80] = "Invalid value"; String edit_buffer; uint8_t edit_u8; + int32_t edit_tx_power = 0; static constexpr size_t MAX_NUMERIC_EDIT_BYTES = 31; void renderItem(CardputerDisplay &display, SettingsItem item, int x, int y); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.cpp index 65ca33cb3..c48f32659 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.cpp @@ -84,19 +84,15 @@ void ToolsScreen::menuItemEnter(ToolsMenuItem item) { break; - case ToolsMenuItem::PowerOff: { - bool done = false; - // Wait for key release to prevent firing interrupt - do { - Keyboard::Event e = _task->getBoard()->getKeyboard()->poll(); - done = (e.changed && e.key == Keyboard::KEY_RETURN && e.down == false); - delay(10); - } while (!done); - _task->shutdown(); - } break; + case ToolsMenuItem::PowerOff: + power_off_pending = true; + power_off_released = false; + power_off_deadline = static_cast(millis()) + POWER_OFF_RELEASE_TIMEOUT_MS; + _task->showAlert("Release Enter to power off", POWER_OFF_RELEASE_TIMEOUT_MS); + break; case ToolsMenuItem::Restart: - ESP.restart(); + _task->shutdown(true); break; default: @@ -104,6 +100,29 @@ void ToolsScreen::menuItemEnter(ToolsMenuItem item) { } } +bool ToolsScreen::handlePowerOffInput(const Keyboard::Event &e) { + if (!power_off_pending) return false; + if (e.changed && e.key == Keyboard::KEY_RETURN && !e.down) { + power_off_released = true; + } else if (e.changed && e.key == Keyboard::KEY_ESC && e.down) { + power_off_pending = false; + _task->dismissAlert(); + } + return true; +} + +void ToolsScreen::poll() { + if (!power_off_pending) return; + if (power_off_released) { + power_off_pending = false; + _task->dismissAlert(); + _task->shutdown(); + } else if (static_cast(static_cast(millis()) - power_off_deadline) >= 0) { + power_off_pending = false; + _task->showAlert("Power off cancelled: release Enter", 2000); + } +} + bool ToolsScreen::handleInput(Keyboard::Event &e) { if (_task->isAlertActive()) { return false; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.h index d395308d2..cc345c1a2 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/ToolsScreen.h @@ -37,6 +37,10 @@ class ToolsScreen : public CardputerScreen { ToolsPage page = ToolsPage::MenuPage; int menu_index = 0; + bool power_off_pending = false; + bool power_off_released = false; + uint32_t power_off_deadline = 0; + static constexpr uint32_t POWER_OFF_RELEASE_TIMEOUT_MS = 2000; DiscoveredRepeater discovered_repeaters[MAX_DISCOVERED_REPEATERS]; uint16_t discovered_repeaters_count = 0; @@ -46,6 +50,9 @@ public: int render(CardputerDisplay &lcd) override; void menuItemEnter(ToolsMenuItem item); bool handleInput(Keyboard::Event &e) override; + bool handlePowerOffInput(const Keyboard::Event &e); + bool isPowerOffPending() const { return power_off_pending; } + void poll() override; void discoverRecv(const mesh::Identity &id, float snr); void startDiscover() { menuItemEnter(ToolsMenuItem::DiscoverRepeaters); } diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChannelsPage.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChannelsPage.cpp index 363e495c5..fd33b5644 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChannelsPage.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChannelsPage.cpp @@ -51,10 +51,12 @@ int ChannelsPage::render(CardputerDisplay &lcd) { } int unread_count = 0; + uint8_t history_id[PUB_KEY_SIZE]; + if (!CardputerMesh::channelHistoryId(channel, history_id)) continue; for (int j = 0; j < _p->getUnread()->countChats(); j++) { const UnreadCounterItem *item = _p->getUnread()->get(j); - if (item && item->is_channel && memcmp(item->pkey, channel.channel.secret, CONTACT_LOOKUP_BYTES) == 0) { + if (item && item->is_channel && memcmp(item->pkey, history_id, sizeof(history_id)) == 0) { unread_count = item->count; break; } diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp index e4c9ea46e..ef16ecfb9 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.cpp @@ -111,10 +111,6 @@ void ChatPage::sendChatMessage() { int result = the_mesh_cp.sendDirectMessage(current_contact, ts, chat_text_box.c_str()); if (result != MSG_SEND_FAILED) { - HistoryMessage *msg = chat_history.push_ref(); - msg->out = true; - snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", chat_text_box.c_str()); - last_sent_message = chat_text_box; chat_text_box.clear(); chat_history_offset = 0; @@ -129,11 +125,6 @@ void ChatPage::sendChatMessage() { bool ok = the_mesh_cp.sendChannelMessage(current_channel_idx, ts, chat_text_box.c_str(), chat_text_box.length()); if (ok) { - HistoryMessage *msg = chat_history.push_ref(); - msg->out = true; - - snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", chat_text_box.c_str()); - last_sent_message = chat_text_box; chat_text_box.clear(); chat_history_offset = 0; @@ -232,10 +223,11 @@ void ChatPage::selectContact(const ContactInfo &contact, int idx) { } void ChatPage::selectChannel(const ChannelDetails &channel, int idx) { + if (!CardputerMesh::channelHistoryId(channel, current_channel_history_id)) return; current_channel = channel; current_channel_idx = idx; current_contact_idx = -1; - the_mesh_cp.loadMessageHistory(channel.channel.secret, true, chat_history); + the_mesh_cp.loadMessageHistory(current_channel_history_id, true, chat_history); chat_history_offset = 0; last_sent_message = ""; @@ -248,15 +240,15 @@ void ChatPage::selectChannel(const ChannelDetails &channel, int idx) { } } -bool ChatPage::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { - +bool ChatPage::onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + (void)channel_idx; // Identical logical channels can occupy more than one slot. if (current_channel_idx != -1 && - memcmp(channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) == 0) { + memcmp(history_id, current_channel_history_id, PUB_KEY_SIZE) == 0) { HistoryMessage *msg = chat_history.push_ref(); msg->out = false; snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text); chat_history_offset = 0; - return true; + return _p->getUiTask()->isChatVisible(); } return false; } @@ -268,11 +260,35 @@ bool ChatPage::onContactMessageRecv(const ContactInfo &contact, const char *text msg->out = false; snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text); chat_history_offset = 0; - return true; + return _p->getUiTask()->isChatVisible(); } return false; } +void ChatPage::onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text) { + (void)channel_idx; + if (current_channel_idx == -1 || memcmp(history_id, current_channel_history_id, PUB_KEY_SIZE) != 0) return; + HistoryMessage *msg = chat_history.push_ref(); + msg->out = true; + snprintf(msg->text, sizeof(msg->text), "%s", text); + last_sent_message = text; + chat_history_offset = 0; +} + +void ChatPage::onContactMessageSent(const ContactInfo &contact, const char *text) { + if (current_contact_idx == -1 || memcmp(contact.id.pub_key, current_contact.id.pub_key, PUB_KEY_SIZE) != 0) return; + HistoryMessage *msg = chat_history.push_ref(); + msg->out = true; + snprintf(msg->text, sizeof(msg->text), "%s", text); + last_sent_message = text; + chat_history_offset = 0; +} + +void ChatPage::markRead(UnreadCounter &unread) const { + if (current_contact_idx != -1) unread.resetContact(current_contact.id.pub_key); + if (current_channel_idx != -1) unread.resetChannel(current_channel_history_id); +} + void ChatPage::refreshSelectedContact() { if (current_contact_idx != -1) { ContactInfo *contact = the_mesh_cp.lookupPersistentContactByPubKey(current_contact.id.pub_key, PUB_KEY_SIZE); @@ -291,8 +307,10 @@ void ChatPage::refreshSelectedContact() { bool ChatPage::refreshSelectedChannel() { if (current_channel_idx == -1) return false; ChannelDetails channel; + uint8_t history_id[PUB_KEY_SIZE]; if (!the_mesh_cp.getChannel(current_channel_idx, channel) || !channel.name[0] - || memcmp(channel.channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) != 0) { + || !CardputerMesh::channelHistoryId(channel, history_id) + || memcmp(history_id, current_channel_history_id, PUB_KEY_SIZE) != 0) { current_channel_idx = -1; current_channel = ChannelDetails{}; chat_history.clear(); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h index 431561bf5..c87c8a8e5 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ChatPage.h @@ -11,6 +11,7 @@ private: String last_sent_message; ChannelDetails current_channel; + uint8_t current_channel_history_id[PUB_KEY_SIZE] = {}; int current_channel_idx = -1; ContactInfo current_contact; @@ -29,8 +30,11 @@ public: void selectContact(const ContactInfo &contact, int idx); void selectChannel(const ChannelDetails &channel, int idx); - bool onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text); + bool onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text); bool onContactMessageRecv(const ContactInfo &contact, const char *text); + void onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text); + void onContactMessageSent(const ContactInfo &contact, const char *text); + void markRead(UnreadCounter &unread) const; void refreshSelectedContact(); }; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp index 0f04a55ad..0edee2a43 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/main/ContactsPage.cpp @@ -75,7 +75,7 @@ int ContactsPage::render(CardputerDisplay &lcd) { for (int j = 0; j < _p->getUnread()->countChats(); j++) { const UnreadCounterItem *item = _p->getUnread()->get(j); - if (item && !item->is_channel && memcmp(item->pkey, contact.id.pub_key, CONTACT_LOOKUP_BYTES) == 0) { + if (item && !item->is_channel && memcmp(item->pkey, contact.id.pub_key, PUB_KEY_SIZE) == 0) { unread_count = item->count; break; } diff --git a/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.cpp b/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.cpp index b49cbd14e..ebe72c440 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.cpp +++ b/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.cpp @@ -4,24 +4,36 @@ #include #include // because i2s_std.h not exists for some reason +#include -void CardputerSpeaker::initI2C() { +namespace { +bool due(uint32_t now, uint32_t deadline) { + return static_cast(now - deadline) >= 0; +} +const uint32_t AUDIO_STALL_MS = 1000; +const uint32_t AUDIO_DMA_DRAIN_MS = + (4UL * AUDIO_BUFFER_SIZE * 1000UL + AUDIO_SAMPLE_RATE - 1) / AUDIO_SAMPLE_RATE; +} + +bool CardputerSpeaker::initI2C() { // From M5Unified - writeReg(0x00, 0x80); // 0x00 RESET/ CSM POWER ON - writeReg(0x01, 0xB5); // 0x01 CLOCK_MANAGER/ MCLK=BCLK - writeReg(0x02, 0x18); // 0x02 CLOCK_MANAGER/ MULT_PRE=3 - writeReg(0x0D, 0x01); // 0x0D SYSTEM/ Power up analog circuitry - writeReg(0x12, 0x00); // 0x12 SYSTEM/ power-up DAC - NOT default - writeReg(0x13, 0x10); // 0x13 SYSTEM/ Enable output to HP drive - NOT default - writeReg(0x32, 0xBF); // 0x32 DAC/ DAC volume (0xBF == +/-0 dB) - writeReg(0x37, 0x08); // 0x37 DAC/ Bypass DAC equalizer - NOT default + // Mark partial initialization too, so ready() can power it down on failure. + i2c_initialized = true; + if (!writeReg(0x00, 0x80) // RESET / CSM POWER ON + || !writeReg(0x01, 0xB5) // CLOCK_MANAGER / MCLK=BCLK + || !writeReg(0x02, 0x18) // CLOCK_MANAGER / MULT_PRE=3 + || !writeReg(0x0D, 0x01) // Power up analog circuitry + || !writeReg(0x12, 0x00) // Power up DAC + || !writeReg(0x13, 0x10) // Enable output to HP drive + || !writeReg(0x32, 0xBF) // DAC volume (+/-0 dB) + || !writeReg(0x37, 0x08)) return false; // Bypass DAC equalizer // Codec is not ready immediately, it need some time to stabilize busy_time = millis() + AUDIO_WARMUP_MS; - i2c_initialized = true; + return true; } -void CardputerSpeaker::initI2S() { +bool CardputerSpeaker::initI2S() { i2s_config_t i2s_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX), .sample_rate = AUDIO_SAMPLE_RATE, .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, @@ -41,10 +53,9 @@ void CardputerSpeaker::initI2S() { .data_in_num = I2S_PIN_NO_CHANGE // Mic is not needed }; - i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL); - i2s_set_pin(I2S_NUM_0, &pin_config); - + if (i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL) != ESP_OK) return false; i2s_initialized = true; + return i2s_set_pin(I2S_NUM_0, &pin_config) == ESP_OK; } bool CardputerSpeaker::writeReg(uint8_t reg, uint8_t val) { @@ -54,51 +65,23 @@ bool CardputerSpeaker::writeReg(uint8_t reg, uint8_t val) { return Wire1.endTransmission() == 0; } -void CardputerSpeaker::begin() { - if (!i2s_initialized) { - initI2S(); - } - - if (!i2c_initialized) { - initI2C(); +bool CardputerSpeaker::ready() { + if ((!i2s_initialized && !initI2S()) || (!i2c_initialized && !initI2C())) { + sleep(); + return false; } + return true; } -void CardputerSpeaker::playTone(int32_t frequency_hz, uint32_t duration_ms, float volume) { - begin(); +void CardputerSpeaker::begin() { (void)ready(); } - static double current_phase = 0.0; - - const double phase_increment = 2.0 * M_PI * frequency_hz / AUDIO_SAMPLE_RATE; - const int16_t target_amplitude = static_cast(volume * AUDIO_MAX_SAFE_VOLUME); - - uint64_t total_samples_to_play = (uint64_t)AUDIO_SAMPLE_RATE * duration_ms / 1000ULL; - uint64_t samples_played = 0; - - int16_t audio_buffer[AUDIO_BUFFER_SIZE]; - size_t bytes_written; - - while (samples_played < total_samples_to_play) { - uint64_t remaining_samples = total_samples_to_play - samples_played; - int samples_in_chunk = - (int)(remaining_samples < AUDIO_BUFFER_SIZE ? remaining_samples : AUDIO_BUFFER_SIZE); - - for (int i = 0; i < samples_in_chunk; ++i) { - audio_buffer[i] = static_cast(sin(current_phase) * target_amplitude); - - current_phase += phase_increment; - if (current_phase >= 2.0 * M_PI) { - current_phase -= 2.0 * M_PI; - } - } - - i2s_write(I2S_NUM_0, audio_buffer, samples_in_chunk * sizeof(int16_t), &bytes_written, portMAX_DELAY); - - samples_played += samples_in_chunk; - } +void CardputerSpeaker::playTone(int32_t hz, uint32_t duration_ms, float level) { + queueTone(hz, duration_ms, level); } void CardputerSpeaker::sleep() { + tone_pending = draining = false; + total_bytes = bytes_sent = 0; if (i2c_initialized) { writeReg(0x0E, 0xFF); // SYSTEM writeReg(0x12, 0x02); // SYSTEM @@ -112,19 +95,63 @@ void CardputerSpeaker::sleep() { } } -void CardputerSpeaker::queueTone(int32_t frequency_hz, uint32_t duration_ms, float volume) { - begin(); - queue_tone.frequency_hz = frequency_hz; - queue_tone.duration_ms = duration_ms; - queue_tone.volume = volume; - queue_tone.done = false; +void CardputerSpeaker::queueTone(int32_t hz, uint32_t duration_ms, float level) { + if (hz <= 0 || hz > AUDIO_SAMPLE_RATE / 2 || !std::isfinite(level) + || level <= 0 || duration_ms == 0) { + sleep(); + return; + } + if (!ready()) return; + frequency_hz = hz; + volume = level > 1.0f ? 1.0f : level; + bytes_sent = 0; + total_bytes = (uint64_t(AUDIO_SAMPLE_RATE) * duration_ms / 1000ULL) * sizeof(int16_t); + tone_pending = total_bytes != 0; + draining = false; + // A replacement discards all unsent old samples. Already submitted DMA is + // bounded to four chunks and can finish without stalling the mesh loop. + last_progress = millis(); } void CardputerSpeaker::processQueue() { - if (!queue_tone.done) { - if (millis() > busy_time) { - queue_tone.done = true; - playTone(queue_tone.frequency_hz, queue_tone.duration_ms, queue_tone.volume); + const uint32_t now = millis(); + if (draining && due(now, drain_until)) draining = false; + if (!tone_pending || !due(now, busy_time)) return; + + // Regenerate the unsubmitted suffix from its exact byte offset. This also + // handles an odd-byte partial write without retaining a heap/instance buffer. + const uint64_t first_sample = bytes_sent / sizeof(int16_t); + const size_t byte_offset = bytes_sent % sizeof(int16_t); + const uint64_t remaining_samples = (total_bytes / sizeof(int16_t)) - first_sample; + const size_t count = remaining_samples < AUDIO_BUFFER_SIZE + ? size_t(remaining_samples) : AUDIO_BUFFER_SIZE; + int16_t audio_buffer[AUDIO_BUFFER_SIZE]; + const double increment = 2.0 * M_PI * frequency_hz / AUDIO_SAMPLE_RATE; + double phase = std::fmod(double(first_sample) * increment, 2.0 * M_PI); + const int16_t amplitude = static_cast(volume * AUDIO_MAX_SAFE_VOLUME); + for (size_t i = 0; i < count; ++i) { + audio_buffer[i] = static_cast(std::sin(phase) * amplitude); + phase += increment; + if (phase >= 2.0 * M_PI) phase -= 2.0 * M_PI; + } + const size_t requested = count * sizeof(int16_t) - byte_offset; + size_t written = 0; + const esp_err_t result = i2s_write(I2S_NUM_0, + reinterpret_cast(audio_buffer) + byte_offset, requested, &written, 0); + if ((result != ESP_OK && result != ESP_ERR_TIMEOUT) || written > requested) { + sleep(); + return; + } + if (written != 0) { + bytes_sent += written; + last_progress = now; + if (bytes_sent == total_bytes) { + tone_pending = false; + draining = true; + drain_until = now + AUDIO_DMA_DRAIN_MS; } + } else if (static_cast(now - last_progress) >= AUDIO_STALL_MS + AUDIO_WARMUP_MS) { + // A wedged driver must not hold the node awake indefinitely. + sleep(); } } diff --git a/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.h b/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.h index 241ff86c0..ca620b028 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.h +++ b/variants/m5stack_cardputer_adv/hw/CardputerSpeaker.h @@ -2,36 +2,36 @@ #include -/** Basic ES8311 blocking driver */ +/** ES8311 tone driver serviced in bounded foreground chunks. */ class CardputerSpeaker { private: - struct queue_tone_t { - int32_t frequency_hz = 0; - uint32_t duration_ms = 0; - float volume = 0; - bool done = true; - }; - bool i2c_initialized = false; bool i2s_initialized = false; - + bool tone_pending = false; + bool draining = false; uint32_t busy_time = 0; - queue_tone_t queue_tone; + uint32_t last_progress = 0; + uint32_t drain_until = 0; + uint64_t bytes_sent = 0; + uint64_t total_bytes = 0; + int32_t frequency_hz = 0; + float volume = 0; - void initI2C(); - void initI2S(); + bool initI2C(); + bool initI2S(); + bool ready(); bool writeReg(uint8_t reg, uint8_t val); public: CardputerSpeaker() {} void begin(); + // Both entry points queue playback; neither waits for audio to finish. void playTone(int32_t frequency_hz, uint32_t duration_ms, float volume = 0.5f); - void sleep(); - - /** Queue a tone. Will be played when processQueue() is called and the codec read. */ void queueTone(int32_t frequency_hz, uint32_t duration_ms, float volume = 0.5f); + // Cancel pending playback, stop DMA and power down the codec. + void sleep(); + // At most one AUDIO_BUFFER_SIZE chunk and one zero-timeout write per call. void processQueue(); - /** True while a queued tone has not been played yet. */ - bool isBusy() const { return !queue_tone.done; } + bool isBusy() const { return tone_pending || draining; } }; diff --git a/variants/m5stack_cardputer_adv/platformio.ini b/variants/m5stack_cardputer_adv/platformio.ini index 333747b63..b754d674a 100644 --- a/variants/m5stack_cardputer_adv/platformio.ini +++ b/variants/m5stack_cardputer_adv/platformio.ini @@ -47,6 +47,7 @@ build_flags = ${esp32_base.build_flags} -D LORA_TX_POWER=22 build_src_filter = ${esp32_base.build_src_filter} + + +<../variants/m5stack_cardputer_adv/CardputerSensorManager.cpp> +<../variants/m5stack_cardputer_adv/target.cpp> lib_deps = ${esp32_base.lib_deps} diff --git a/variants/m5stack_cardputer_adv/target.cpp b/variants/m5stack_cardputer_adv/target.cpp index f607d168a..1f2e2b585 100644 --- a/variants/m5stack_cardputer_adv/target.cpp +++ b/variants/m5stack_cardputer_adv/target.cpp @@ -14,13 +14,15 @@ WRAPPER_CLASS radio_driver(radio, board); ESP32RTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); -#if CARDPUTER_ADV_UI +#if ENV_INCLUDE_GPS #include static MicroNMEALocationProvider nmea(Serial1, &rtc_clock); CardputerSensorManager sensors(nmea); - CardputerDisplay display; #else - EnvironmentSensorManager sensors; + CardputerSensorManager sensors; +#endif +#if CARDPUTER_ADV_UI + CardputerDisplay display; #endif bool radio_init() { diff --git a/variants/m5stack_cardputer_adv/target.h b/variants/m5stack_cardputer_adv/target.h index b390ff942..fc4207b7e 100644 --- a/variants/m5stack_cardputer_adv/target.h +++ b/variants/m5stack_cardputer_adv/target.h @@ -8,13 +8,11 @@ #include #include #include "CardputerAdvBoard.h" +#include "CardputerSensorManager.h" #if CARDPUTER_ADV_UI #include - #include "CardputerSensorManager.h" #include "hw/CardputerDisplay.h" -#else - #include #endif extern CardputerAdvBoard board; @@ -23,11 +21,9 @@ extern AutoDiscoverRTCClock rtc_clock; // The microSD slot shares SCK/MISO/MOSI with the Cap LoRa-1262. extern SPIClass lora_spi; -#if CARDPUTER_ADV_UI extern CardputerSensorManager sensors; +#if CARDPUTER_ADV_UI extern CardputerDisplay display; -#else -extern EnvironmentSensorManager sensors; #endif bool radio_init();