From 9a2211e85a7f2bde1ba577e0b8e7ffedd39190b6 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 27 Oct 2026 22:54:19 -0700 Subject: [PATCH] Fix Cardputer delivery, live settings and power management Keep selected chats bound to full contact keys and route keyboard channel sends through the active transport. Deliver accepted plain, room and Meshtext messages to local history and notifications once; disarm rejected sends and retries. Use shared live radio settings, GPS acquisition and power-saving policy, screen timers and red night palette. Report save failures, preserve verified custom preferences, and guard display controls and shutdown. Add executable sanitizer regressions for routing, retries, selection, storage faults, UI lifecycle and GPS. Both Companion builds and the shared T-Deck regression pass; no physical Cardputer testing performed. --- .github/workflows/run-unit-tests.yml | 4 + examples/companion_radio/MyMesh.cpp | 29 +- examples/companion_radio/MyMesh.h | 12 + examples/companion_radio/main.cpp | 2 + src/helpers/ui/DisplayBuildFlags.h | 10 +- src/helpers/ui/DisplayPowerSettings.cpp | 5 + test/test_cardputer_delivery.py | 293 ++++++++++++++ test/test_cardputer_gps.py | 218 +++++++++++ test/test_cardputer_mesh_storage.py | 115 +++++- test/test_cardputer_messaging.py | 241 ++++++++++++ test/test_cardputer_ui.py | 69 +++- test/test_cardputer_ui_lifecycle.py | 362 ++++++++++++++++++ .../CardputerDataStore.cpp | 111 +++++- .../CardputerDataStore.h | 5 +- .../CardputerSensorManager.cpp | 170 +++++--- .../CardputerSensorManager.h | 17 + variants/m5stack_cardputer_adv/README.md | 29 ++ .../companion_radio/CardputerMesh.cpp | 106 ++--- .../companion_radio/CardputerMesh.h | 16 +- .../ui-cardputer/CardputerUITask.cpp | 212 ++++++---- .../ui-cardputer/CardputerUITask.h | 18 +- .../ui-cardputer/screen/SettingsScreen.cpp | 158 ++++---- .../ui-cardputer/screen/SettingsScreen.h | 4 +- .../ui-cardputer/screen/main/ChatPage.cpp | 50 ++- .../ui-cardputer/screen/main/ChatPage.h | 1 + .../ui-cardputer/screen/main/ContactsPage.cpp | 16 +- .../hw/CardputerDisplay.cpp | 23 +- 27 files changed, 1950 insertions(+), 346 deletions(-) create mode 100644 test/test_cardputer_delivery.py create mode 100644 test/test_cardputer_gps.py create mode 100644 test/test_cardputer_messaging.py create mode 100644 test/test_cardputer_ui_lifecycle.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 863a73641..c3ece928f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -400,7 +400,11 @@ jobs: python3 -B test/test_cardputer_board_policy.py -v python3 -B test/test_cardputer_layout.py -v python3 -B test/test_cardputer_ui.py -v + 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_delivery.py -v + python3 -B test/test_cardputer_gps.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 bbd3fab02..fc2c33de5 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1717,9 +1717,12 @@ bool MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe const bool notification_managed = false; #endif -#ifdef DISPLAY_CLASS +#if defined(DISPLAY_CLASS) || CARDPUTER_ADV_UI // we only want to show text messages on display, not cli data bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN; +#if CARDPUTER_ADV_UI + if (should_display) onUiContactMessage(from, text); +#endif if (should_display && _ui) { _ui->newMsg(path_len, from.name, text, offline_queue_len, -1, nullptr, queued ? offline_queue_len - 1 : -1); @@ -2340,6 +2343,9 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo const bool queued = addToOfflineQueue(out_frame, i); if (!queued) return false; +#if CARDPUTER_ADV_UI + onUiChannelMessage(secret, text); +#endif #if COMPANION_FEATURE_NOTIFICATIONS const bool notification_managed = notify && _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis()); if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen()); @@ -2354,7 +2360,7 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo frame[0] = PUSH_CODE_MSG_WAITING; _serial->writeFrame(frame, 1); } else { -#ifdef DISPLAY_CLASS +#if defined(DISPLAY_CLASS) || CARDPUTER_ADV_UI if (notify && _ui && !notification_managed) _ui->notify(UIEventType::channelMessage); #endif } @@ -2369,7 +2375,7 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo path_len == 0xFF ? "DIRECT" : "FLOOD", text); } #endif -#ifdef DISPLAY_CLASS +#if defined(DISPLAY_CLASS) || CARDPUTER_ADV_UI // Get the channel name from the channel index const char *channel_name = "Unknown"; ChannelDetails channel_details; @@ -9544,6 +9550,23 @@ void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size, (unsigned)_prefs.rx_ps_level); } +#if CARDPUTER_ADV_UI +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); +#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); +} +#endif + #if COMPANION_FEATURE_TEXT_TERMINAL Stream& MyMesh::terminalOutput() { if (isTerminalWaitingForInput()) return mesh::usbTerminalPort(false); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 8b315eac8..04f8ef6a9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -389,6 +389,12 @@ public: #endif protected: +#if CARDPUTER_ADV_UI + // 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*) {} +#endif #if COMPANION_FEATURE_TEMP_RADIO bool isTempRadioActive() const override { return _temp_radio_applied && _temp_radio_revert_at != 0 @@ -609,6 +615,12 @@ public: // Runtime copies use the wire PSK; storage/app keep the original alias PSK. bool getChannel(int channel_idx, ChannelDetails& channel); bool setChannel(int channel_idx, const ChannelDetails& channel); +#if CARDPUTER_ADV_UI + uint16_t getPrimaryRadioPreamble() const { return _radio_profiles.primaryPreamble(); } + bool queueUiContactWrite(const ContactInfo& contact) { return scheduleContactWrite(contact); } + bool sendUiChannelMessage(int channel_idx, uint32_t timestamp, + const char* text, size_t length); +#endif bool savePrefs() { const uint8_t previous_tail_flags[] = { _prefs.usb_debug_enabled, _prefs.usb_logging_always}; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 28be7269c..94483e6a4 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3991,6 +3991,8 @@ void setup() { the_mesh.begin( #ifdef DISPLAY_CLASS disp != NULL + #elif CARDPUTER_ADV_UI + cardputer_disp != NULL #else false #endif diff --git a/src/helpers/ui/DisplayBuildFlags.h b/src/helpers/ui/DisplayBuildFlags.h index da28e78a1..129786e54 100644 --- a/src/helpers/ui/DisplayBuildFlags.h +++ b/src/helpers/ui/DisplayBuildFlags.h @@ -24,8 +24,9 @@ #define MESHCORE_DISPLAY_TOKEN_JOIN(prefix, name) \ MESHCORE_DISPLAY_TOKEN_JOIN_INNER(prefix, name) -#if defined(DISPLAY_CLASS) \ - && !MESHCORE_DISPLAY_TOKEN_JOIN(MESHCORE_NULL_DISPLAY_CLASS_, DISPLAY_CLASS) +#if (defined(DISPLAY_CLASS) \ + && !MESHCORE_DISPLAY_TOKEN_JOIN(MESHCORE_NULL_DISPLAY_CLASS_, DISPLAY_CLASS)) \ + || CARDPUTER_ADV_UI #define MESHCORE_HAS_REAL_DISPLAY 1 #endif @@ -35,8 +36,9 @@ #define MESHCORE_HAS_EINK_DISPLAY 1 #endif -#if defined(DISPLAY_CLASS) \ - && MESHCORE_DISPLAY_TOKEN_JOIN(MESHCORE_COLOR_DISPLAY_CLASS_, DISPLAY_CLASS) +#if (defined(DISPLAY_CLASS) \ + && MESHCORE_DISPLAY_TOKEN_JOIN(MESHCORE_COLOR_DISPLAY_CLASS_, DISPLAY_CLASS)) \ + || CARDPUTER_ADV_UI #define MESHCORE_HAS_COLOR_DISPLAY 1 #endif diff --git a/src/helpers/ui/DisplayPowerSettings.cpp b/src/helpers/ui/DisplayPowerSettings.cpp index e3efcbfbf..bbdac4a4f 100644 --- a/src/helpers/ui/DisplayPowerSettings.cpp +++ b/src/helpers/ui/DisplayPowerSettings.cpp @@ -106,6 +106,11 @@ bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_ DisplayPowerPrefs prefs; prefs.wake_on_boot = wake_on_boot; if (pairing_supported) prefs.battery.mode = prefs.usb.mode = DisplayMode::ButtonPairing; +#if CARDPUTER_ADV_UI + // Preserve the keyboard UI's timed message wake on a fresh installation. + // A saved profile below remains authoritative, including explicit off. + prefs.battery.mode = prefs.usb.mode = DisplayMode::Automatic; +#endif displayPowerPrefs() = prefs; #ifdef MESHCORE_HAS_REAL_DISPLAY if (!fs) return false; diff --git a/test/test_cardputer_delivery.py b/test/test_cardputer_delivery.py new file mode 100644 index 000000000..3e572d95d --- /dev/null +++ b/test/test_cardputer_delivery.py @@ -0,0 +1,293 @@ +"""Execute shared Companion delivery and keyboard transport routing.""" + +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "examples/companion_radio/MyMesh.cpp" + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +using std::isfinite; +#define MESH_ENABLE_ONE_KEY_DM 1 +#define ONE_KEY_DM_SHARED_OFFLINE_QUEUE 0 +#define COMPANION_FEATURE_NOTIFICATIONS 0 +#define COMPANION_FEATURE_TEXT_TERMINAL 0 +#define MAX_FRAME_SIZE 176 +#define MAX_GROUP_CHANNELS 40 +#define MAX_TEXT_LEN 160 +#define PRV_KEY_SIZE 64 +#define PUB_KEY_SIZE 32 +#define ONE_KEY_DM_ID_SIZE 8 +#define TXT_TYPE_PLAIN 0 +#define TXT_TYPE_CLI_DATA 1 +#define TXT_TYPE_SIGNED_PLAIN 2 +#define RESP_CODE_CONTACT_MSG_RECV_V3 0x93 +#define RESP_CODE_CONTACT_MSG_RECV 0x82 +#define RESP_CODE_CHANNEL_MSG_RECV_V3 0x90 +#define RESP_CODE_CHANNEL_MSG_RECV 0x83 +#define PUSH_CODE_MSG_WAITING 0x80 +static unsigned long millis() { return 1234; } +enum class UIEventType { contactMessage, channelMessage }; +namespace mesh { +struct Packet { + uint8_t path_len = 0; + float getSNR() const { return 2.0f; } + bool isRouteFlood() const { return true; } +}; +struct GroupChannel { uint8_t secret[32]{}; uint8_t tx_radio = 0; }; +} +struct ContactInfo { struct { uint8_t pub_key[32]{}; } id; char name[32] = "Alice"; }; +struct ChannelDetails { char name[32]{}; mesh::GroupChannel channel; }; +struct Serial { + bool connected = false; + bool isConnected() const { return connected; } + bool isReplyRouteAvailable(Serial*) { return true; } + void writeFrame(const uint8_t*, int) {} + void writeFrameToRoute(Serial*, const uint8_t*, int) {} +}; +struct UI { + int received = 0, sounds = 0; + void setMessageNotificationOverride(bool, bool = false) {} + void newMsg(uint8_t, const char*, const char*, int, int, const char*, int) { ++received; } + void notify(UIEventType) { ++sounds; } +}; +struct MyMesh { + Serial serial; + UI ui; + Serial* _serial = &serial; + UI* _ui = &ui; + int app_target_ver = 3, offline_queue_len = 0; + uint8_t out_frame[MAX_FRAME_SIZE]{}; + bool accept = true, delivered = false; + int contact_history = 0, channel_history = 0; + Serial* private_key_backup_route = nullptr; + unsigned long private_key_backup_deadline = 0; + uint8_t private_key_backup_sender[6]{}; + char private_key_backup_nonce[17]{}; + struct Held { uint32_t timestamp; char text[160]; uint8_t id[8], sender_key[32]; } held_dms[1]{}; + uint8_t held_dm_count = 0; + struct { char node_name[32] = "Cardputer"; } _prefs; + ChannelDetails configured; + bool radio2 = true, mt_available = true, transport_accept = true; + int meshtext_sends = 0, meshcore_sends = 0; + uint8_t sent_key = 0, sent_radio = 0; + bool getChannel(int index, ChannelDetails& out) { + if (index != 9 || !configured.name[0]) return false; + out = configured; return true; + } + bool isAliasRadio2Defined() const { return radio2; } + bool isMeshtextChannel(const ChannelDetails& c) const { + return mt_available && parseCompanionChannelAlias(c.name).kind == CompanionChannelKind::Meshtext; + } + bool sendMeshtextText(const ChannelDetails& c, const char*, size_t) { + ++meshtext_sends; sent_key = c.channel.secret[0]; return transport_accept; + } + bool sendGroupMessage(uint32_t, mesh::GroupChannel& c, const char*, const char*, size_t) { + ++meshcore_sends; sent_key = c.secret[0]; sent_radio = c.tx_radio; return transport_accept; + } + 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 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; } + int getOfflineQueueCapacity() const { return 256; } + void markConnectionActive(const ContactInfo&) {} + void scheduleContactWrite(const ContactInfo&) {} + void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*); + bool queueMessage(const ContactInfo&, uint8_t, mesh::Packet*, uint32_t, + const uint8_t*, int, const char*, bool = false, uint32_t = 0); + bool queueChannelText(uint8_t, const uint8_t[32], float, uint8_t, uint32_t, + const char*, bool, bool = true); + bool sendUiChannelMessage(int, uint32_t, const char*, size_t); +}; +@METHODS@ +int main() { + MyMesh m; + ContactInfo from; + mesh::Packet packet; + m.onMessageRecv(from, &packet, 100, "hello"); + assert(m.offline_queue_len == 1); +#if CARDPUTER_ADV_UI + assert(m.contact_history == 1 && m.ui.received == 1 && m.ui.sounds == 1); +#else + assert(m.contact_history == 0 && m.ui.received == 0 && m.ui.sounds == 0); +#endif + // Intro/held-message duplicate suppression must happen before local history. + const int history_before_duplicate = m.contact_history; + const int ui_before_duplicate = m.ui.received; + m.delivered = true; + m.onMessageRecv(from, &packet, 100, "hello"); + assert(m.offline_queue_len == 1); + assert(m.contact_history == history_before_duplicate && m.ui.received == ui_before_duplicate); + m.delivered = false; + m.accept = false; + int prior = m.contact_history; + assert(!m.queueMessage(from, TXT_TYPE_SIGNED_PLAIN, &packet, 101, nullptr, 0, "room")); + assert(m.contact_history == prior); // Server must retry an unretained room post. + m.accept = true; + assert(m.queueMessage(from, TXT_TYPE_SIGNED_PLAIN, &packet, 101, nullptr, 0, "room")); +#if CARDPUTER_ADV_UI + assert(m.contact_history == prior + 1); +#endif + prior = m.contact_history; + assert(!m.queueMessage(from, TXT_TYPE_CLI_DATA, &packet, 101, nullptr, 0, "KEYBACKUP secret")); + assert(m.contact_history == prior); // No private export retained locally. + assert(m.queueMessage(from, TXT_TYPE_CLI_DATA, &packet, 101, nullptr, 0, "normal admin reply")); + assert(m.contact_history == prior); // Admin replies are not chat history. + uint8_t key[32] = {0x42}; + m.accept = false; + assert(!m.queueChannelText(9, key, 1, 0, 101, "channel", false)); + assert(m.channel_history == 0); + m.accept = true; + assert(m.queueChannelText(9, key, 1, 0, 101, "channel", false)); +#if CARDPUTER_ADV_UI + assert(m.channel_history == 1); + // Meshtext enters this same queue once after its transport-level dedup. + assert(m.queueChannelText(9, key, 1, 0, 101, "Meshtext", false)); + assert(m.channel_history == 2); + strcpy(m.configured.name, "#mt-longfast"); + m.configured.channel.secret[0] = 71; + m.configured.channel.tx_radio = 3; // Native MeshCore TX disabled for this alias. +#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); + m.transport_accept = false; + assert(!m.sendUiChannelMessage(9, 101, "text", 4)); + assert(m.meshcore_sends == 0); // Rejection never leaks onto the other protocol. + m.transport_accept = true; +#else + assert(!m.sendUiChannelMessage(9, 101, "text", 4)); + assert(m.meshcore_sends == 0); +#endif + m.mt_available = false; + assert(!m.sendUiChannelMessage(9, 101, "text", 4)); + strcpy(m.configured.name, "#r2-testing"); + m.configured.channel.secret[0] = 72; + m.configured.channel.tx_radio = 1; + assert(m.sendUiChannelMessage(9, 101, "text", 4)); + assert(m.sent_key == 72 && m.sent_radio == 1); + // Resolve the current slot every time; never transmit using a stale snapshot. + m.configured.channel.secret[0] = 73; + assert(m.sendUiChannelMessage(9, 101, "text", 4)); + assert(m.sent_key == 73); + int sends = m.meshcore_sends; + assert(!m.sendUiChannelMessage(1, 101, "text", 4)); + assert(!m.sendUiChannelMessage(9, 101, nullptr, 4)); + assert(!m.sendUiChannelMessage(9, 101, "", 0)); + assert(!m.sendUiChannelMessage(9, 101, "text", 3)); + std::string oversized(160, 'x'); + assert(!m.sendUiChannelMessage(9, 101, oversized.c_str(), oversized.size())); + assert(m.meshcore_sends == sends); +#else + assert(m.channel_history == 0 && m.ui.received == 0); +#endif +} +''' + + +class CardputerDeliveryTests(unittest.TestCase): + def run_cpp(self, cardputer=True, meshtext=True): + source = SOURCE.read_text() + signatures = [ + "void MyMesh::onMessageRecv(", + "bool MyMesh::queueMessage(", + "bool MyMesh::queueChannelText(", + ] + if cardputer: + signatures.append("bool MyMesh::sendUiChannelMessage(") + code = HARNESS.replace("@METHODS@", "\n".join(body(source, sig) for sig in signatures)) + 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)}", + "-I", str(ROOT / "src"), "-x", "c++", "-", "-o", str(binary), + ], input=code, text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_cardputer_shared_delivery_and_meshtext(self): + self.run_cpp() + + def test_missing_meshtext_transport_cannot_leak_native_packets(self): + self.run_cpp(meshtext=False) + + def test_headless_delivery_keeps_its_existing_behavior(self): + self.run_cpp(cardputer=False) + + 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([ + os.environ.get("CXX", "c++"), "-E", "-P", "-x", "c++", "-", + "-DCARDPUTER_ADV_UI=1", "-DCOMPANION_FEATURE_TEXT_TERMINAL=0", + ], input=source, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("bool MyMesh::sendUiChannelMessage(", result.stdout) + + def test_cardputer_color_traits_without_standard_ui(self): + for enabled in (0, 1): + code = '#include \n' + code += '#ifdef MESHCORE_HAS_COLOR_DISPLAY\nconstexpr bool color=true;\n#else\nconstexpr bool color=false;\n#endif\n' + code += '#ifdef MESHCORE_HAS_REAL_DISPLAY\nconstexpr bool real=true;\n#else\nconstexpr bool real=false;\n#endif\n' + code += f'static_assert(color == {enabled}); static_assert(real == {enabled});\nint main() {{}}\n' + with tempfile.TemporaryDirectory(prefix="meshcore-cardputer-traits-") as temp: + result = subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", f"-DCARDPUTER_ADV_UI={enabled}", + "-I", str(ROOT / "src"), "-x", "c++", "-", "-o", str(Path(temp) / "traits"), + ], input=code, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_fresh_message_wake_and_saved_display_choices(self): + from test_display_night_mode import compile_run + compile_run(r''' +#include +#include +#include +using namespace mesh::ui; +int main() { + FakeFS fs; + char reply[160]; + assert(!loadDisplayPowerSettings(&fs, true, true)); + assert(displayPowerPrefs().battery.mode == DisplayMode::Automatic); + assert(displayPowerPrefs().usb.mode == DisplayMode::Automatic); + assert(displayNightMode() == NightAuto && displayNightDelayMinutes() == 60); + assert(handleDisplayPowerCommand("set display.mode off", reply, sizeof(reply))); + assert(!strcmp(reply, "OK")); + assert(handleDisplayPowerCommand("set display.usb.mode button", reply, sizeof(reply))); + assert(!strcmp(reply, "OK")); + assert(handleDisplayPowerCommand("set display.timeout 47", reply, sizeof(reply))); + assert(!strcmp(reply, "OK")); + assert(handleDisplayPowerCommand("set display.night on", reply, sizeof(reply))); + assert(!strcmp(reply, "OK")); + assert(loadDisplayPowerSettings(&fs, true, true)); + assert(displayPowerPrefs().battery.mode == DisplayMode::Off); + assert(displayPowerPrefs().usb.mode == DisplayMode::Button); + assert(displayPowerPrefs().battery.seconds == 47); + assert(displayNightMode() == NightOn && displayNightActive()); +} +''', files=("src/helpers/ui/DisplayPowerSettings.cpp", "src/helpers/ui/DisplayNightMode.cpp"), + defines=("-DCARDPUTER_ADV_UI=1",)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_gps.py b/test/test_cardputer_gps.py new file mode 100644 index 000000000..1eff7b9ef --- /dev/null +++ b/test/test_cardputer_gps.py @@ -0,0 +1,218 @@ +"""Execute Cardputer GPS policy with real SensorManager and NMEA provider code.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_gps_upstream_adaptations import ARDUINO, MESH, RTC, CAYENNE + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/m5stack_cardputer_adv" +ARDUINO = ARDUINO.replace("#include ", "#include \n#include \n#include \n#include ") +ARDUINO = ARDUINO.replace(" virtual size_t write(uint8_t){return 1;}", r''' + virtual size_t write(uint8_t){return 1;} + size_t print(const char* text){size_t n=0;while(*text)n+=write(uint8_t(*text++));return n;} + size_t print(char c){return write(uint8_t(c));} +''') + r''' +class HardwareSerial:public Stream { +public: + std::deque incoming; + std::string output; + int rx=-1,tx=-1;unsigned baud=0,starts=0,ends=0; + void setPins(int input,int out){rx=input;tx=out;} + void begin(unsigned rate){baud=rate;++starts;} + void end(){++ends;} + int available()override{return int(incoming.size());} + int read()override{if(incoming.empty())return -1;char c=incoming.front();incoming.pop_front();return uint8_t(c);} + size_t write(uint8_t c)override{output+=char(c);return 1;} + void feed(const std::string& data){for(char c:data)incoming.push_back(c);} +}; +inline HardwareSerial Serial1; +''' +HARNESS = r''' +#include +#include +#include +#include +#include +#include "CardputerSensorManager.h" +#include +void LocationProvider::sendSentence(const char*){} +struct Provider:LocationProvider { + bool fix=false;long lat=47610000,lon=-122330000,alt=125000; + unsigned polls=0,starts=0,stops=0,clears=0; + long getLatitude()override{return lat;} + long getLongitude()override{return lon;} + long getAltitude()override{return alt;} + long satellitesCount()override{return fix?8:0;} + bool isValid()override{return fix&&lat>=-90000000&&lat<=90000000&&lon>=-180000000&&lon<=180000000;} + long getTimestamp()override{return 0;} + void reset()override{} + void begin()override{++starts;} + void stop()override{++stops;} + void loop()override{++polls;} + // This is what the real pinless MicroNMEA provider reports even in standby. + bool isEnabled()override{return true;} + bool clearPositionFix()override{++clears;fix=false;return true;} + void sendSentence(const char* sentence)override{MicroNMEA::sendSentence(Serial1,sentence);} + void synced(){markTimeSyncApplied();stopTimeSync();} +}; +static unsigned occurrences(const std::string& haystack,const char* needle) { + unsigned count=0;size_t at=0; + while((at=haystack.find(needle,at))!=std::string::npos){++count;at+=std::strlen(needle);} + return count; +} +static void reset(uint32_t time=100) {now_ms=time;delay_count=0;Serial1=HardwareSerial();} +static void setup(CardputerSensorManager& manager,Provider& provider) { + provider.setPowerSavingProfile(600,1800);assert(manager.begin()); + assert(Serial1.rx==15&&Serial1.tx==13&&Serial1.baud==115200); + assert(manager.isGPSDetected()&&manager.gpsUsesSerialUart(1)&&!manager.gpsUsesSerialUart(2)); + assert(!manager.isGpsPowered()&&provider.isEnabled()); + assert(Serial1.output=="$PCAS12,65535*1E\r\n"); + assert(!std::strcmp(manager.getSettingValue(0),"0")); + assert(manager.begin()&&Serial1.starts==1&&delay_count==0); +} +static void bootAndManualOff() { + reset();Serial1.feed("startup bytes");Provider provider;CardputerSensorManager manager(provider); + setup(manager,provider);manager.setPowerSavingEnabled(true); + for(unsigned tick=1;tick<=20;++tick) { + now_ms=100+tick*3600000U;provider.syncTime();manager.loop(); + assert(!manager.isGpsPowered()&&provider.polls==0); + } + assert(occurrences(Serial1.output,"$PCAS10")==0); + assert(occurrences(Serial1.output,"$PCAS12")>=20&&delay_count==0); +} +static void cycleAndWrap() { + for(uint32_t start:{100U,UINT32_MAX-599000U,UINT32_MAX-599999U}) { + reset(start);Provider p;CardputerSensorManager m(p);setup(m,p); + m.setPowerSavingEnabled(true);assert(m.setSettingValue("gps","1")); + uint32_t off=p.getNextGPSOff();assert(off==(uint32_t(start+600000U)?uint32_t(start+600000U):1U)); + assert(m.isGpsPowered()&&p.waitingTimeSync()&&!std::strcmp(m.getSettingValue(0),"1")); + now_ms=off-1;m.loop();assert(m.isGpsPowered()); + now_ms=off;m.loop();assert(!m.isGpsPowered()); + assert(!std::strcmp(m.getSettingValue(0),"1")); + uint32_t on=p.getNextGPSOn();assert(on==uint32_t(off+1800000U)); + now_ms=on-1;m.loop();assert(!m.isGpsPowered()); + now_ms=on;m.loop();assert(m.isGpsPowered()&&p.clears==2); + assert(uint32_t(p.getNextGPSOff())==uint32_t(now_ms+600000U)); + assert(delay_count==0&&occurrences(Serial1.output,"$PCAS10,0*1C\r\n")==2); + p.synced();m.loop();assert(!m.isGpsPowered()); // common early sleep after clock acquisition + } +} +static void toggles() { + reset();Provider p;CardputerSensorManager m(p);setup(m,p); + assert(m.setSettingValue("gps","1")&&m.isGpsPowered()); + m.setPowerSavingEnabled(true);const auto off=p.getNextGPSOff(); + ++now_ms;m.setPowerSavingEnabled(true);assert(p.getNextGPSOff()==off); + now_ms=uint32_t(off);m.loop();assert(!m.isGpsPowered()); + m.setPowerSavingEnabled(false);assert(m.isGpsPowered()&&!p.getGPSPowerSaving()); + assert(p.getNextGPSOn()==0&&p.getNextGPSOff()==0); + m.setPowerSavingEnabled(true);assert(p.getNextGPSOff()!=0); + assert(m.setSettingValue("gps","0")&&!m.isGpsPowered()); + assert(!p.getGPSPowerSaving()&&!p.waitingTimeSync()&&p.getNextGPSOn()==0&&p.getNextGPSOff()==0); + for(bool save:{false,true,false,true}) {m.setPowerSavingEnabled(save);m.loop();assert(!m.isGpsPowered());} + assert(m.setSettingValue("gps","1")&&m.isGpsPowered()); + m.shutdownGpsTransportForDeepSleep();assert(!m.isGpsPowered()&&!m.isGPSDetected()&&Serial1.ends==1); + m.loop();assert(!m.isGpsPowered()); +} +static void intervalAndValidity() { + reset();Provider p;CardputerSensorManager m(p);setup(m,p); + assert(m.setSettingValue("gps","1"));assert(m.setSettingValue("gps_interval","2")); + p.fix=true;m.loop();assert(m.node_lat==47.61&&m.node_lon==-122.33&&m.node_altitude==125); + p.lat=48000000;now_ms+=1999;m.loop();assert(m.node_lat==47.61); + ++now_ms;m.loop();assert(m.node_lat==48); + p.lat=91000000;now_ms+=2000;m.loop();assert(m.node_lat==48); + assert(m.setSettingValue("gps_interval","86400"));p.lat=49000000;m.loop();assert(m.node_lat==49); + assert(m.setSettingValue("gps_interval","1"));p.lat=50000000;m.loop();assert(m.node_lat==50); + for(const char* bad:{"","-1","86401","1x"})assert(!m.setSettingValue("gps_interval",bad)); + assert(!m.setSettingValue(nullptr,"1")&&!m.setSettingValue("gps",nullptr)); + assert(!m.setSettingValue("unknown","1")); +} +static void telemetry() { + reset();Provider p;CardputerSensorManager m(p);setup(m,p);CayenneLPP data(64); + assert(m.querySensors(TELEM_PERM_BASE,data)&&data.gps_count==0&&!m.isGpsPowered()); + const auto commands=Serial1.output;double lat=0,lon=0; + m.setTelemetryLocationAccessAvailable(false);m.loop();assert(Serial1.output==commands); + assert(!m.getCachedGpsPosition(lat,lon)); + assert(m.querySensors(TELEM_PERM_LOCATION,data)&&data.gps_count==0&&m.isGpsPowered()); + assert(!std::strcmp(m.getSettingValue(0),"0")); // telemetry did not change saved intent + p.fix=true;m.loop();now_ms+=31000;m.loop(); + assert(m.getCachedGpsPosition(lat,lon)); + assert(m.querySensors(TELEM_PERM_LOCATION,data)&&data.gps_count==1); + assert(m.setSettingValue("gps","0")&&!m.isGpsPowered()); + const auto polls=p.polls;now_ms+=1800000;m.loop();assert(!m.isGpsPowered()&&p.polls==polls); + data.reset();const auto before=Serial1.output; + assert(m.querySensors(TELEM_PERM_BASE,data)&&data.gps_count==1&&Serial1.output==before); + assert(m.querySensors(TELEM_PERM_LOCATION,data)&&data.gps_count==2); // cached, authorized, explicit hold + assert(m.isGpsPowered());m.setSettingValue("gps","0"); +} +static std::string sentence(const char* text) { + HardwareSerial output;MicroNMEA::sendSentence(output,text);return output.output; +} +static void actualNmeaAndClock() { + reset(10000);mesh::RTCClock clock;MicroNMEALocationProvider p(Serial1,&clock); + CardputerSensorManager m(p);p.setPowerSavingProfile(600,1800);assert(m.begin()); + assert(m.setSettingValue("gps","1")&&p.waitingTimeSync()); + for(unsigned second=0;second<4;++second) { + char rmc[160];std::snprintf(rmc,sizeof(rmc), + "$GPRMC,1200%02u.00,A,4736.600,N,12219.800,W,0.0,0.0,281026,,,A",second); + Serial1.feed(sentence("$GPGGA,120000.00,4736.600,N,12219.800,W,1,08,0.9,125.0,M,0.0,M,,")); + Serial1.feed(sentence(rmc));now_ms+=1100; + // A real foreground loop drains its bounded 96-byte NMEA batches often. + // Keep one timestamp per emitted second and never bypass that byte limit. + for(unsigned poll=0;poll<4&&Serial1.available();++poll)m.loop(); + assert(Serial1.available()==0); + } + assert(p.satellitesCount()==8&&p.isValid()); + assert(m.node_lat>47.60&&m.node_lat<47.62&&m.node_lon< -122.32&&m.node_lon> -122.34); + assert(p.getTimeSyncGeneration()>0&&p.getLastValidTimeSync()==clock.getCurrentTime()); + const double retained=m.node_lat; + Serial1.feed(sentence("$GPGGA,120005.00,,,,,0,00,99.9,,,,,,"));now_ms+=1100;m.loop(); + assert(!p.isValid()&&p.satellitesCount()==0&&m.node_lat==retained); + m.setSettingValue("gps","0");const auto generation=p.getTimeSyncGeneration(); + Serial1.feed(sentence("$GPRMC,120010.00,A,4736.600,N,12219.800,W,0.0,0.0,281026,,,A")); + now_ms+=1100;m.loop();assert(p.getTimeSyncGeneration()==generation&&!m.isGpsPowered()); +} +int main(int argc,char** argv) { + assert(argc==2);const std::string scenario=argv[1]; + if(scenario=="boot")bootAndManualOff();else if(scenario=="cycle")cycleAndWrap(); + else if(scenario=="toggles")toggles();else if(scenario=="interval")intervalAndValidity(); + else if(scenario=="telemetry")telemetry();else if(scenario=="nmea")actualNmeaAndClock();else assert(false); +} +''' + + +class CardputerGpsTests(unittest.TestCase): + def test_actual_manager_policy_cache_nmea_and_clock(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a native C++ compiler is required") + nmea = next((ROOT / ".pio/libdeps").glob("*/MicroNMEA/src/MicroNMEA.cpp"), None) + self.assertIsNotNone(nmea, "pinned MicroNMEA installed by native tests is required") + with tempfile.TemporaryDirectory(prefix="cardputer-gps-") as directory: + work = Path(directory) + for name, text in {"Arduino.h": ARDUINO, "Mesh.h": MESH, "RTClib.h": RTC, + "CayenneLPP.h": CAYENNE, "Wire.h": "#pragma once\nstruct TwoWire {};\n", + "main.cpp": HARNESS}.items(): + (work / name).write_text(text, encoding="ascii") + binary = work / ("gps.exe" if os.name == "nt" else "gps") + 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", + "-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), + str(ROOT / "src/helpers/SensorManager.cpp"), + str(VARIANT / "CardputerSensorManager.cpp"), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + for scenario in ("boot", "cycle", "toggles", "interval", "telemetry", "nmea"): + with self.subTest(scenario=scenario): + run = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_mesh_storage.py b/test/test_cardputer_mesh_storage.py index 59b7a7f8c..5dbc6daae 100644 --- a/test/test_cardputer_mesh_storage.py +++ b/test/test_cardputer_mesh_storage.py @@ -23,6 +23,8 @@ FILESYSTEM_STUB = r''' #include #include #include +#include +#define ESP32_PLATFORM 1 #define PUB_KEY_SIZE 32 #define FILE_READ "r" #define FILE_WRITE "w" @@ -31,24 +33,31 @@ namespace mesh { struct RTCClock {}; } struct Entry { std::string bytes; bool no_progress = false; + bool corrupt_read = false; unsigned reads = 0; }; struct File { std::shared_ptr entry; size_t position = 0; + size_t max_write = std::numeric_limits::max(); explicit operator bool() const { return bool(entry); } size_t size() const { return entry ? entry->bytes.size() : 0; } size_t available() const { return size() - position; } + bool isDirectory() const { return false; } + bool setBufferSize(size_t) { return true; } + void flush() {} size_t read(uint8_t* dest, size_t count) { if (!entry) return 0; if (++entry->reads > 100) throw std::runtime_error("read made no progress"); if (entry->no_progress) return 0; count = std::min(count, available()); memcpy(dest, entry->bytes.data() + position, count); + if (entry->corrupt_read && count) dest[0] ^= 1; position += count; return count; } size_t write(const uint8_t* src, size_t count) { + count = std::min(count, max_write); entry->bytes.append(reinterpret_cast(src), count); return count; } @@ -60,17 +69,34 @@ struct FILESYSTEM { std::map> files; unsigned opens = 0; bool fail_open = false; + bool fail_stat = false; + size_t max_write = std::numeric_limits::max(); + unsigned rename_calls = 0, fail_rename = 0; + bool corrupt_public_read = false; bool exists(const char* path) const { return files.count(path) != 0; } bool mkdir(const char*) { return true; } File open(const char* path, const char* mode, bool = false) { ++opens; if (fail_open) return {}; + if (*mode == 'r' && !files.count(path)) return {}; auto& entry = files[path]; if (!entry) entry = std::make_shared(); if (*mode == 'w') entry->bytes.clear(); - return File{entry}; + return File{entry, 0, max_write}; + } + bool remove(const char* path) { return files.erase(path) != 0; } + bool rename(const char* from, const char* to) { + if (++rename_calls == fail_rename || !files.count(from) || files.count(to)) return false; + files[to] = files.at(from); + files.erase(from); + if (corrupt_public_read && std::string(to) == "/meshcore_custom/prefs") { + files[to]->corrupt_read = true; + corrupt_public_read = false; + } + return true; } }; +static FILESYSTEM* stat_fs = nullptr; struct DataStore { DataStore(FILESYSTEM&, mesh::RTCClock&) {} }; ''' @@ -80,6 +106,7 @@ STORAGE_CHECKS = r''' int main() { mesh::RTCClock clock; FILESYSTEM primary, sd; + stat_fs = &primary; CardputerDataStore store(primary, clock); uint8_t key[PUB_KEY_SIZE] = {}; ChatHistory history; @@ -92,7 +119,7 @@ int main() { CustomNodePrefs prefs{1.1f, 127, 2}; store.beginCustom(&sd); - store.saveCustomPrefs(prefs); + assert(store.saveCustomPrefs(prefs)); assert(primary.files.count(CUSTOM_PREFS_FILE) == 1 && sd.files.empty()); CustomNodePrefs restored{}; store.loadCustomPrefs(restored); @@ -145,6 +172,7 @@ MESH_HARNESS = r''' #include #include #define MAX_HASH_SIZE 8 +#define MAX_MESSAGE_LENGTH 150 #define PAYLOAD_TYPE_GRP_TXT 5 namespace mesh { struct GroupChannel { uint8_t secret[32] = {}; }; @@ -154,6 +182,7 @@ struct Packet { void calculatePacketHash(uint8_t* hash) const { memset(hash, value, MAX_HASH_SIZE); } }; } +struct ChannelDetails { mesh::GroupChannel channel; char name[32] = "channel"; }; struct Store { unsigned writes = 0; void storeMessage(const uint8_t*, const char*, bool, bool) { ++writes; } @@ -166,7 +195,12 @@ struct MyMesh { if (!admit || !pkt) { delete pkt; return false; } return true; } - bool sendGroupMessage(uint32_t, mesh::GroupChannel&, const char*, const char*, int) { return admit; } + bool getChannel(int index, ChannelDetails& channel) { + assert(index == 7); + channel.channel.secret[31] = 99; + return true; + } + bool sendUiChannelMessage(int index, uint32_t, const char*, size_t) { assert(index == 7); return admit; } void loop() { ++base_loops; } bool millisHasNowPassed(unsigned long at) const { return int32_t(now - at) > 0; } }; @@ -178,7 +212,7 @@ struct CardputerMesh : MyMesh { unsigned long resend_timeout = 0; unsigned resends = 0; bool sendFloodScoped(const mesh::GroupChannel&, mesh::Packet*, uint32_t); - bool sendGroupMessage(uint32_t, mesh::GroupChannel&, const char*, const char*, int); + bool sendChannelMessage(int, uint32_t, const char*, size_t); void checkResend() { ++resends; } void loop(); }; @@ -189,7 +223,7 @@ int main() { node.admit = false; assert(!node.sendFloodScoped(channel, new mesh::Packet{PAYLOAD_TYPE_GRP_TXT, 42}, 0)); assert(node.last_message_hash[0] == 0 && node.last_message_heard_repeats == 9); - assert(!node.sendGroupMessage(0, channel, "name", "text", 4) && node.store.writes == 0); + assert(!node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 0); node.admit = true; auto* group = new mesh::Packet{PAYLOAD_TYPE_GRP_TXT, 77}; assert(node.sendFloodScoped(channel, group, 0)); @@ -201,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.sendGroupMessage(0, channel, "name", "text", 4) && node.store.writes == 1); + assert(node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 1); node.resend_timeout = 10; node.now = 9; node.loop(); @@ -309,6 +343,7 @@ class CardputerMeshStorageTests(unittest.TestCase): with tempfile.TemporaryDirectory() as temporary: work = Path(temporary) for name, content in (headers or {}).items(): + (work / name).parent.mkdir(parents=True, exist_ok=True) (work / name).write_text(content, encoding="ascii") program = work / "test.cpp" program.write_text(source, encoding="ascii") @@ -334,18 +369,82 @@ class CardputerMeshStorageTests(unittest.TestCase): def mesh_source(self): source = (VARIANT / "companion_radio/CardputerMesh.cpp").read_text() methods = "\n".join(body(source, signature) for signature in ( - "bool CardputerMesh::sendFloodScoped(", "bool CardputerMesh::sendGroupMessage(", + "bool CardputerMesh::sendFloodScoped(", "bool CardputerMesh::sendChannelMessage(", "void CardputerMesh::loop()")) return MESH_HARNESS.replace("@METHODS@", methods) def test_history_storage_and_faults(self): self.run_cpp(self.storage_source(), headers=self.storage_headers()) + def test_custom_preferences_verified_rollback_and_recovery(self): + checks = r''' +#include +#include +int main() { + mesh::RTCClock clock; + const CustomNodePrefs previous{1.0f, 128, 1}, candidate{1.2f, 240, 2}; + for (unsigned fault = 0; fault < 6; ++fault) { + FILESYSTEM fs; stat_fs = &fs; + CardputerDataStore store(fs, clock); + assert(store.saveCustomPrefs(previous)); + fs.rename_calls = 0; + if (fault == 0) fs.max_write = 3; + if (fault == 1) fs.fail_open = true; + if (fault == 2) fs.fail_rename = 1; + if (fault == 3) fs.fail_rename = 2; + if (fault == 4) fs.corrupt_public_read = true; + if (fault == 5) fs.fail_stat = true; + assert(!store.saveCustomPrefs(candidate)); + fs.max_write = 100; fs.fail_open = false; fs.fail_rename = 0; fs.fail_stat = false; + CustomNodePrefs restored{}; + CardputerDataStore rebooted(fs, clock); + assert(rebooted.loadCustomPrefs(restored)); + assert(!memcmp(&restored, &previous, sizeof(previous))); + assert(!fs.exists(CUSTOM_PREFS_FILE ".tmp")); + } + FILESYSTEM fs; stat_fs = &fs; + CardputerDataStore store(fs, clock); + assert(store.saveCustomPrefs(previous)); + assert(fs.rename(CUSTOM_PREFS_FILE, CUSTOM_PREFS_FILE ".bak")); + auto temp = fs.open(CUSTOM_PREFS_FILE ".tmp", FILE_WRITE); + temp.write(reinterpret_cast(&candidate), sizeof(candidate)); temp.close(); + CustomNodePrefs restored{}; + assert(store.loadCustomPrefs(restored)); + assert(!memcmp(&restored, &previous, sizeof(previous))); + assert(fs.exists(CUSTOM_PREFS_FILE) && !fs.exists(CUSTOM_PREFS_FILE ".bak")); + for (CustomNodePrefs invalid : {CustomNodePrefs{0.49f, 1, 1}, CustomNodePrefs{2.01f, 1, 1}, + CustomNodePrefs{1.0f, 1, 3}, CustomNodePrefs{std::numeric_limits::quiet_NaN(), 1, 1}}) { + assert(!store.saveCustomPrefs(invalid)); + } + fs.files.at(CUSTOM_PREFS_FILE)->bytes.resize(3); + restored = candidate; + assert(!store.loadCustomPrefs(restored)); + assert(!memcmp(&restored, &candidate, sizeof(candidate))); + assert(!store.saveCustomPrefs(previous)); +} +''' + self.run_cpp((VARIANT / "CardputerDataStore.cpp").read_text() + checks, + headers=self.storage_headers()) + def storage_headers(self): # Production includes SD.h for PlatformIO's variant-local dependency # discovery. The filesystem stub supplies every API this source uses; # no concrete SD driver is needed by the native ownership/fault tests. - return {"DataStore.h": FILESYSTEM_STUB, "SD.h": "#pragma once\n"} + return {"DataStore.h": FILESYSTEM_STUB, "SD.h": "#pragma once\n", + "helpers/ContactFileTransaction.h": (ROOT / "src/helpers/ContactFileTransaction.h").read_text(), + "helpers/IdentityStore.h": "#pragma once\n", + "helpers/PersistentStoreFormat.h": (ROOT / "src/helpers/PersistentStoreFormat.h").read_text(), + "sys/stat.h": r''' +#pragma once +#include +struct stat {}; +inline int stat(const char* path, struct stat*) { + if (!stat_fs || stat_fs->fail_stat) { errno = EIO; return -1; } + if (stat_fs->exists(path + 7)) return 0; + errno = ENOENT; + return -1; +} +'''} def test_history_source_bound_negative_control(self): source = self.storage_source().replace( diff --git a/test/test_cardputer_messaging.py b/test/test_cardputer_messaging.py new file mode 100644 index 000000000..36dddc20b --- /dev/null +++ b/test/test_cardputer_messaging.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Execute Cardputer admission, retry, accepted delivery and selection methods.""" +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 "globals.h" +#define PUB_KEY_SIZE 32 +#define OUT_PATH_UNKNOWN 255 +#define MSG_SEND_FAILED 0 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +enum MessageSendState { MESSAGE_SENDING, MESSAGE_DELIVERED, MESSAGE_FAILED }; +struct ContactInfo { + struct { uint8_t pub_key[32] = {}; } id; + uint8_t out_path_len = 0; +}; +namespace mesh { struct GroupChannel { uint8_t secret[32] = {}; }; } +struct ChannelDetails { mesh::GroupChannel channel; char name[32] = "channel"; }; +struct CustomNodePrefs { float battery_correction; uint8_t brightness, text_level; }; +@LAST_MESSAGE@ +struct Store { + bool save_ok = true; + unsigned writes = 0; + uint8_t key[32] = {}; + std::string text; + void storeMessage(const uint8_t* pubkey, const char* value, bool, bool) { + ++writes; memcpy(key, pubkey, 32); text = value; + } + bool saveCustomPrefs(const CustomNodePrefs&) { return save_ok; } + void loadCustomPrefs(CustomNodePrefs&) {} +}; +struct UI { + std::vector events; + unsigned contacts = 0, 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); + } +}; +struct MyMesh { + bool admit = true, present = true, persist = true, standard_save = true; + uint32_t now = 100, timeout = 25000; + 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*, + 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; + } + ContactInfo* lookupContactByPubKey(const uint8_t* key, size_t count) { + assert(count == 32); + return present && !memcmp(key, current.id.pub_key, 32) ? ¤t : nullptr; + } + bool queueUiContactWrite(const ContactInfo&) { ++path_saves; return persist; } + unsigned long futureMillis(uint32_t delay) const { return now + delay; } + bool savePrefs() { ++standard_saves; return standard_save; } +}; +struct CardputerMesh : MyMesh { + Store store; + Store* _store = &store; + UI ui; + UI* _ui = &ui; + LastSentMessage last_msg; + unsigned long resend_timeout = 0; + CustomNodePrefs _custom_prefs{1.0f, 128, 1}, _saved_custom_prefs = _custom_prefs; + bool isMessageSending() const { return last_msg.state == MESSAGE_SENDING; } + int sendDirectMessage(const ContactInfo&, uint32_t, const char*); + void checkResend(); + void cancelResending(); + void onUiContactMessage(const ContactInfo&, const char*); + void onUiChannelMessage(const uint8_t[32], const char*); + bool savePrefs(); + bool saveCustomPrefs(); + void beginCustom(); +}; +@METHODS@ +int main() { + CardputerMesh rejected; + rejected.admit = false; + assert(rejected.sendDirectMessage(rejected.current, 4, "test") == MSG_SEND_FAILED); + assert(!rejected.isMessageSending() && rejected.resend_timeout == 0); + assert(rejected.store.writes == 0 && rejected.ui.events.empty()); + + CardputerMesh node; + node.current.id.pub_key[31] = 42; + 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.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; + node.now = 30000; node.timeout = 32000; + 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); + node.admit = false; + node.checkResend(); + assert(!node.isMessageSending() && node.resend_timeout == 0 && node.last_msg.expected_ack == 0); + assert(node.ui.events.back() == MESSAGE_FAILED && node.store.writes == 1); + + CardputerMesh removed; + assert(removed.sendDirectMessage(removed.current, 1, "test") != MSG_SEND_FAILED); + removed.present = false; removed.checkResend(); + assert(removed.sends == 1 && !removed.isMessageSending() && removed.resend_timeout == 0); + + CardputerMesh fallback; + fallback.current.out_path_len = 4; fallback.timeout = 10; + assert(fallback.sendDirectMessage(fallback.current, 1, "test") != MSG_SEND_FAILED); + assert(fallback.resend_timeout == fallback.now + DIRECT_SEND_ROUTE_RESEND_RELAY); + fallback.checkResend(); fallback.checkResend(); fallback.checkResend(); + assert(fallback.sent_path == OUT_PATH_UNKNOWN && fallback.path_saves == 1); + assert(fallback.resend_timeout == fallback.now + DIRECT_SEND_FLOOD_RESEND_RELAY); + fallback.checkResend(); fallback.checkResend(); fallback.checkResend(); + assert(!fallback.isMessageSending() && fallback.sends == 6); + + CardputerMesh blocked; + blocked.current.out_path_len = 4; + assert(blocked.sendDirectMessage(blocked.current, 1, "test") != MSG_SEND_FAILED); + blocked.last_msg.attempt = DIRECT_SEND_ROUTE_ATTEMPTS; + blocked.persist = false; blocked.checkResend(); + assert(blocked.current.out_path_len == 4 && !blocked.isMessageSending() && blocked.sends == 1); + + CardputerMesh custom; + custom._custom_prefs.brightness = 200; + assert(custom.saveCustomPrefs() && custom.standard_saves == 0); + custom._custom_prefs.brightness = 250; custom.store.save_ok = false; + assert(!custom.saveCustomPrefs() && custom._custom_prefs.brightness == 200); + assert(custom.savePrefs() && custom.standard_saves == 1 && custom._custom_prefs.brightness == 200); + + CardputerMesh accepted; + 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"); + assert(accepted.ui.channels == 1 && accepted.store.writes == 2 && accepted.store.key[31] == 88); + assert(accepted.ui.key[31] == 88); +} +''' + +SELECTION = r''' +#include +#include +#include +#include +#include +#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 Mesh { + std::vector contacts; + ChannelDetails channel; + bool channel_present = true; + ContactInfo* lookupPersistentContactByPubKey(const uint8_t* key, size_t size) { + assert(size == 32); + for (auto& contact : contacts) if (!memcmp(key, contact.id.pub_key, 32)) return &contact; + return nullptr; + } + bool getChannel(int index, ChannelDetails& value) { + assert(index == 4); + if (!channel_present) return false; + value = channel; return true; + } +} the_mesh_cp; +struct History { unsigned count = 1; void clear() { count = 0; } }; +struct ChatPage { + int current_contact_idx = 2, current_channel_idx = -1; + ContactInfo current_contact; + ChannelDetails current_channel; + History chat_history; + int chat_history_offset = 2; + std::string last_sent_message = "previous"; + void refreshSelectedContact(); + bool refreshSelectedChannel(); +}; +@METHODS@ +int main() { + ContactInfo alice, bob, carol; + alice.id.pub_key[31] = 1; bob.id.pub_key[31] = 2; carol.id.pub_key[31] = 3; + bob.marker = 42; carol.marker = 99; + the_mesh_cp.contacts = {alice, bob, carol}; + ChatPage page; page.current_contact = bob; + the_mesh_cp.contacts.erase(the_mesh_cp.contacts.begin()); + page.refreshSelectedContact(); + assert(page.current_contact.id.pub_key[31] == 2 && page.current_contact.marker == 42); + the_mesh_cp.contacts.erase(the_mesh_cp.contacts.begin()); + 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; + strcpy(the_mesh_cp.channel.name, "renamed"); + assert(page.refreshSelectedChannel() && !strcmp(page.current_channel.name, "renamed")); + 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; + the_mesh_cp.channel.name[0] = 0; + assert(!page.refreshSelectedChannel() && page.current_channel_idx == -1); +} +''' + + +class CardputerMessagingTests(unittest.TestCase): + def run_cpp(self, source): + storage_tests.CardputerMeshStorageTests.run_cpp(self, source) + + def test_admission_retry_accepted_delivery_and_preference_rollback(self): + source = (VARIANT / "companion_radio/CardputerMesh.cpp").read_text() + methods = "\n".join(body(source, signature) for signature in ( + "int CardputerMesh::sendDirectMessage(", "void CardputerMesh::checkResend()", + "void CardputerMesh::cancelResending()", "void CardputerMesh::onUiContactMessage(", + "void CardputerMesh::onUiChannelMessage(", "bool CardputerMesh::saveCustomPrefs()", + "bool CardputerMesh::savePrefs()", "void CardputerMesh::beginCustom()")) + last_message = body((VARIANT / "types.h").read_text(), "struct LastSentMessage {") + ";" + self.run_cpp(HARNESS.replace("@METHODS@", methods).replace("@LAST_MESSAGE@", last_message)) + + def test_selection_survives_contact_compaction_and_invalidates_replaced_keys(self): + source = (VARIANT / "companion_radio/ui-cardputer/screen/main/ChatPage.cpp").read_text() + methods = "\n".join(body(source, signature) for signature in ( + "void ChatPage::refreshSelectedContact()", "bool ChatPage::refreshSelectedChannel()")) + self.run_cpp(SELECTION.replace("@METHODS@", methods)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cardputer_ui.py b/test/test_cardputer_ui.py index 3c3fab72a..1e3800a9d 100644 --- a/test/test_cardputer_ui.py +++ b/test/test_cardputer_ui.py @@ -87,6 +87,7 @@ struct Task { bool getGPSState() const { return true; } bool isBluetoothEnabled() const { return true; } bool powerSaveEnabled() const { return true; } + void showAlert(const char*,int) {} }; struct Prefs { char node_name[32]="node",default_scope_name[31]=""; @@ -98,9 +99,33 @@ struct Prefs { struct CustomPrefs { float battery_correction=1; }; struct ChannelDetails { char name[32]={}; }; struct Mesh { - unsigned saves=0; + unsigned saves=0,applies=0; + Prefs* prefs=nullptr; + CustomPrefs* custom=nullptr; + CustomPrefs committed; + bool fail=false; + std::string command; ChannelDetails channels[MAX_GROUP_CHANNELS]; - void savePrefs() { ++saves; } + bool savePrefs() { if(fail) return false; ++saves; return true; } + bool saveCustomPrefs() { + if(fail) { *custom=committed; return false; } + committed=*custom; ++saves; return true; + } + uint16_t getPrimaryRadioPreamble() const { return 96; } + bool handleLocalCommand(const char* text,char* reply) { + command=text; + if(fail) { strcpy(reply,"Error: save failed"); return true; } + if(command.rfind("set radio ",0)==0) { + float f,bw; unsigned sf,cr,pre; + assert(sscanf(text+10,"%f,%f,%u,%u,%u",&f,&bw,&sf,&cr,&pre)==5 && pre==96); + prefs->freq=f; prefs->bw=bw; prefs->sf=sf; prefs->cr=cr; ++applies; + } else if(command.rfind("set tx ",0)==0) { + prefs->tx_power_dbm=std::stoi(command.substr(7)); ++applies; + } else if(command.rfind("set name ",0)==0) { + snprintf(prefs->node_name,sizeof(prefs->node_name),"%s",text+9); + } else { assert(false); } + ++saves; strcpy(reply,"OK"); return true; + } bool getChannel(int i,ChannelDetails& result) { if(i<0 || i>=MAX_GROUP_CHANNELS) return false; result=channels[i]; return true; @@ -150,17 +175,22 @@ struct SettingsScreen { String edit_buffer; uint8_t edit_u8=0; int menu_index=RadioFreq; - bool restart_required=false,is_editing=true; + char edit_error[80]="Invalid value"; + bool is_editing=true; void renderItem(CardputerDisplay&,SettingsItem,int,int); bool commitItemEdit(SettingsItem); void handleEditInput(SettingsItem,Keyboard::Event&); bool inputParsePositiveFloat(float&); bool validRadioParams(float,float,uint8_t,uint8_t) const; + bool commitCommand(const char*); + bool commitRadio(float,float,uint8_t,uint8_t); int render(CardputerDisplay&); }; ''' for signature in ( "void SettingsScreen::renderItem(", + "bool SettingsScreen::commitCommand(", + "bool SettingsScreen::commitRadio(", "bool SettingsScreen::commitItemEdit(", "void SettingsScreen::handleEditInput(", "bool SettingsScreen::inputParsePositiveFloat(", @@ -172,6 +202,9 @@ struct SettingsScreen { struct CardputerUITask { CardputerDisplay* _display=nullptr; Board* _board; + bool allow_shutdown=true; + bool prepareForShutdown() { return allow_shutdown; } + void showAlert(const char*,int) {} void shutdown(bool restart=false); }; ''' @@ -199,6 +232,7 @@ struct ChannelsPage { int main() { Prefs prefs; CustomPrefs custom; Task task; CardputerDisplay display; SettingsScreen s{&prefs,&custom,&task}; + the_mesh_cp.prefs=&prefs; the_mesh_cp.custom=&custom; Keyboard::Event digit{Keyboard::KEY_1,"9"}; for(int i=0;i<4096;++i) s.handleEditInput(SettingsScreen::RadioFreq,digit); assert(s.edit_buffer.length()==s.MAX_NUMERIC_EDIT_BYTES); @@ -213,11 +247,11 @@ int main() { for(const char* bad:{"0","149.9","960.1","42949672960"}) { s.edit_buffer=bad; assert(!s.commitItemEdit(SettingsScreen::RadioFreq)); - assert(prefs.freq==910.525f && the_mesh_cp.saves==0 && !s.restart_required); + assert(prefs.freq==910.525f && the_mesh_cp.saves==0); } s.edit_buffer="915.25"; assert(s.commitItemEdit(SettingsScreen::RadioFreq)); - assert(prefs.freq==915.25f && the_mesh_cp.saves==1 && s.restart_required); + assert(prefs.freq==915.25f && the_mesh_cp.saves==1 && the_mesh_cp.applies==1); for(const char* bad:{"0","123.45","812.5"}) { s.edit_buffer=bad; assert(!s.commitItemEdit(SettingsScreen::RadioBw)); @@ -229,10 +263,35 @@ int main() { assert(!s.commitItemEdit(SettingsScreen::RadioCr)); s.edit_u8=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"); + the_mesh_cp.fail=true; + s.edit_buffer="916"; assert(!s.commitItemEdit(SettingsScreen::RadioFreq)); + assert(prefs.freq==915.25f && strstr(s.edit_error,"save failed")); + s.edit_buffer="renamed"; assert(!s.commitItemEdit(SettingsScreen::PublicInfoName)); + assert(!strcmp(prefs.node_name,"node")); + strcpy(prefs.default_scope_name,"original"); memset(prefs.default_scope_key,0xA5,sizeof(prefs.default_scope_key)); + s.edit_buffer="replacement"; assert(!s.commitItemEdit(SettingsScreen::MeshDefaultScope)); + assert(!strcmp(prefs.default_scope_name,"original") && prefs.default_scope_key[0]==0xA5); + s.edit_u8=3; assert(!s.commitItemEdit(SettingsScreen::MeshPathSize)); + assert(prefs.path_hash_mode==1); + s.edit_buffer="4400"; assert(!s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection)); + assert(custom.battery_correction==1.0f); + the_mesh_cp.fail=false; + s.edit_buffer="node renamed"; assert(s.commitItemEdit(SettingsScreen::PublicInfoName)); + assert(!strcmp(prefs.node_name,"node renamed")); + for(const char* bad:{"0","1","9999"}) { + s.edit_buffer=bad; assert(!s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection)); + assert(custom.battery_correction==1.0f); + } + s.edit_buffer="4400"; assert(s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection)); + assert(custom.battery_correction==1.1f); // 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); ui.shutdown(true); assert(task.board.boots==1); + ui.allow_shutdown=false; ui.shutdown(); ui.shutdown(true); + assert(task.board.off==1 && task.board.boots==1 && radio_driver.off==1); // Channel list indices must map back to actual sparse channel slots. MainScreen main_screen; ChannelsPage list{&main_screen}; for(int i:{2,7,39}) strcpy(the_mesh_cp.channels[i].name,"channel"); diff --git a/test/test_cardputer_ui_lifecycle.py b/test/test_cardputer_ui_lifecycle.py new file mode 100644 index 000000000..2c7be7c9d --- /dev/null +++ b/test/test_cardputer_ui_lifecycle.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Run actual Cardputer controls, drawing and shared display policy on the host.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_cardputer_ui import function + +ROOT = Path(__file__).resolve().parents[1] +VARIANT = ROOT / "variants/m5stack_cardputer_adv" + + +class CardputerLifecycleTests(unittest.TestCase): + def test_actual_controls_notifications_power_and_night_drawing(self): + task = (VARIANT / "companion_radio/ui-cardputer/CardputerUITask.cpp").read_text(encoding="ascii") + display = (VARIANT / "hw/CardputerDisplay.cpp").read_text(encoding="ascii") + abstract = (ROOT / "examples/companion_radio/AbstractUITask.h").read_text(encoding="ascii") + code = r''' +#include +#include +#include +#include +#include +#include +#include "helpers/ui/ColorTheme.h" +#include "helpers/CompanionNotificationPolicy.h" +#include "helpers/ui/BluetoothPairingUiPolicy.h" +uint32_t fake_now=0; +unsigned long millis() { return fake_now; } +bool night=false; +namespace mesh { namespace ui { bool displayNightActive() { return night; } } } +constexpr uint16_t TFT_BLACK=0,TFT_WHITE=0xFFFF,TFT_DARKGRAY=0x7BEF; +struct LCDMock { + std::vector colors; + std::vector text; + uint16_t current=0; + unsigned brightness=0,text_level=0,prints=0; + void setBaseColor(uint16_t c) { colors.push_back(c); } + bool begin() { return true; } + void setRotation(int) {} + void setColorDepth(int) {} + void setBrightness(uint8_t b) { brightness=b; } + void setTextColor(uint16_t c) { colors.push_back(c); } + void setTextColor(uint16_t c,uint16_t b) { colors.push_back(c); colors.push_back(b); } + template void setEmojiCallback(F) {} + void setColor(uint16_t c) { current=c; colors.push_back(c); } + void clear(uint16_t c) { colors.push_back(c); } + void startWrite() {} + void endWrite() {} + void wakeup() {} + void sleep() {} + int textWidth(const char* s) { return strlen(s)*8; } + int fontHeight() { return 16; } + void drawRect(int,int,int,int) { colors.push_back(current); } + void drawRect(int,int,int,int,uint16_t c) { colors.push_back(c); } + void fillRect(int,int,int,int) { colors.push_back(current); } + void drawBitmap(int,int,const uint8_t*,int,int,uint16_t c) { colors.push_back(c); } + void setCursor(int,int) {} + void print(const char* value) { ++prints; colors.push_back(current); text.emplace_back(value); } +}; +namespace lgfx { using LGFXBase=LCDMock; } +struct CardputerDisplay: DisplayDriver { + enum { TEXT_LEVEL_COUNT=3 }; + enum PaletteColor:uint16_t { P_BLACK=0,P_WHITE=0xFFFF,P_RED=0xF800,P_GREEN=0x07E0, + P_BLUE=0x001F,P_ORANGE=0xFD20,P_YELLOW=0xFFE0,P_GRAY=0x8410 }; + bool _isOn=false; + uint16_t _lastColor=0; + uint8_t _textLevel=1; + LCDMock LCD; + CardputerDisplay():DisplayDriver(240,135) {} + bool begin(); + bool isOn() override { return _isOn; } + void turnOn() override; + void turnOff() override; + void clear() override; + void startFrame(ColorVal bkg=UIColor::window_bkg) override; + void endFrame() override; + void setTextSize(int) override {} + void setColor(ColorVal) override; + void setCursor(int,int) override; + void print(const char*) override; + void fillRect(int,int,int,int) override; + void drawRect(int,int,int,int) override; + void drawXbm(int,int,const uint8_t*,int,int) override; + uint16_t getTextWidth(const char*) override; + void setTextLevel(uint8_t level) { _textLevel=level; LCD.text_level=level; } + uint8_t getTextLevel() const { return _textLevel; } + void setBrightness(uint8_t level) { LCD.setBrightness(level); } +}; +''' + color_start = display.index("ColorVal UIColor::window_bkg") + color_end = display.index("\n\nint32_t emoji_draw_callback", color_start) + code += display[color_start:color_end] + "\n" + code += function(display, "int32_t emoji_draw_callback(") + "\n" + for signature in ( + "bool CardputerDisplay::begin(", "void CardputerDisplay::turnOn(", + "void CardputerDisplay::turnOff(", "void CardputerDisplay::clear(", + "void CardputerDisplay::startFrame(", "void CardputerDisplay::endFrame(", + "void CardputerDisplay::setColor(", "void CardputerDisplay::setCursor(", + "void CardputerDisplay::print(", "void CardputerDisplay::fillRect(", + "void CardputerDisplay::drawRect(", "void CardputerDisplay::drawXbm(", + "uint16_t CardputerDisplay::getTextWidth(", + ): + code += function(display, signature) + "\n" + code += r''' +struct Speaker { + unsigned tones=0,sleeps=0; + void queueTone(int,int,float=0.5f) { ++tones; } + void sleep() { ++sleeps; } +}; +struct Board { + bool usb=false; + Speaker speaker; + bool isExternalPowered() { return usb; } + bool isUsbHostConnected() { return usb; } + Speaker* getSpeaker() { return &speaker; } +}; +struct Interfaces { + bool pending=false,enabled=true,ble=false; + bool takePairingRequest() { bool result=pending; pending=false; return result; } +}; +namespace Keyboard { +enum Key { KEY_ESC,KEY_RETURN,KEY_A }; +struct Event { Key key; }; +} +struct Prefs { uint8_t gps_enabled=1,buzzer_quiet=0,powersaving_enabled=0; }; +struct CustomPrefs { uint8_t text_level=1,brightness=96; }; +struct Mesh { + Prefs* prefs=nullptr; + CustomPrefs* custom=nullptr; + CustomPrefs committed; + bool fail=false; + unsigned saves=0; + uint32_t getBLEPin() const { return 246810; } + bool savePrefs() { if(fail) return false; ++saves; return true; } + bool saveCustomPrefs() { + if(fail) { *custom=committed; return false; } + committed=*custom; ++saves; return true; + } + bool setGpsEnabled(bool on) { + if(fail) return false; + prefs->gps_enabled=on; ++saves; return true; + } + bool handleLocalCommand(const char* command,char* reply) { + if(fail) { strcpy(reply,"Error: save failed"); return true; } + assert(!strcmp(command,"powersaving on") || !strcmp(command,"powersaving off")); + prefs->powersaving_enabled=!strcmp(command,"powersaving on"); ++saves; + strcpy(reply,"OK - powersaving"); return true; + } +} the_mesh_cp; +struct CardputerScreen {}; +struct MainScreen: CardputerScreen { + enum { FIRST,CHAT }; + int page=FIRST; + int getCurrentPage() { return page; } +}; +struct NewMessageScreen: CardputerScreen { + unsigned messages=0; + void newMessage(const char*,const char*) { ++messages; } +}; +enum class UIEventType { none,channelMessage,contactMessage,ack }; +enum class SoundType { Keyboard,NewMessage,DiscoveryResult,MessageAck }; +struct CardputerUITask { + CardputerDisplay* _display=nullptr; + Board* _board=nullptr; + Interfaces* _interfaceManager=nullptr; + Prefs* _node_prefs=nullptr; + CustomPrefs* _custom_prefs=nullptr; + CardputerScreen *main_screen=nullptr,*new_message_screen=nullptr,*current_screen=nullptr; + bool connected=false,_message_notification_override=false,_message_notification_screen_override=false; + uint8_t _notification_outputs=31; + int unsynced_msg_count=0; + char alert_text[80]={}; + unsigned long alert_expiry=0,next_refresh=0; + uint32_t pairing_prompt_until=0; + bool hasConnection() const { return connected || _interfaceManager->ble; } + bool hasBluetoothConnection() const { return _interfaceManager->ble; } + bool isBluetoothEnabled() const { return _interfaceManager->enabled; } + void scheduleRefresh(unsigned ms=0) { next_refresh=millis()+ms; } + void setCurrScreen(CardputerScreen* screen) { current_screen=screen; scheduleRefresh(); } + bool shouldWakeDisplayForMessage() const; + void showAlert(const char*,int); + void serviceDisplayPower(); + void servicePairingState(); + bool isPairingPromptActive() const; + void renderPairingPrompt(); + bool handlePairingInput(const Keyboard::Event&); + bool turnDisplayOn(); + bool getGPSState(); + bool toggleGPS(); + bool toggleBuzzer(); + bool togglePowerSave(); + void cycleBrightness(); + void cycleTextSize(); + void playSound(SoundType); + void notify(UIEventType); + uint8_t notificationCapabilities() const; + void notificationScreen(int8_t); + void newMsg(uint8_t,const char*,const char*,int,int=-1,const char*=nullptr,int=-1); +}; +'''.replace("const char*=", "const char* =") + code += "bool CardputerUITask::" + function(abstract, "bool shouldWakeDisplayForMessage() const").removeprefix("bool ") + "\n" + for signature in ( + "void CardputerUITask::showAlert(", "void CardputerUITask::serviceDisplayPower(", + "void CardputerUITask::servicePairingState(", "bool CardputerUITask::isPairingPromptActive(", + "void CardputerUITask::renderPairingPrompt(", "bool CardputerUITask::handlePairingInput(", + "bool CardputerUITask::turnDisplayOn(", "bool CardputerUITask::getGPSState(", + "bool CardputerUITask::toggleGPS(", "bool CardputerUITask::toggleBuzzer(", + "bool CardputerUITask::togglePowerSave(", "void CardputerUITask::cycleBrightness(", + "void CardputerUITask::cycleTextSize(", "void CardputerUITask::playSound(", + "void CardputerUITask::notify(", "uint8_t CardputerUITask::notificationCapabilities(", + "void CardputerUITask::notificationScreen(", "void CardputerUITask::newMsg(", + ): + code += function(task, signature) + "\n" + code += r''' +int main() { + CardputerDisplay display; + // Every drawing boundary, including implicit glyph and emoji colors, follows night mode. + night=true; assert(display.begin()); + display.startFrame(0x1234); + const uint8_t bitmap[]={0xFF}; + for(uint16_t color:{uint16_t(0),uint16_t(0xFFFF),uint16_t(0x07E0),uint16_t(0x1234)}) { + display.setColor(color); display.print("text"); display.fillRect(0,0,1,1); + display.drawRect(0,0,1,1); display.drawXbm(0,0,bitmap,1,1); + } + emoji_draw_callback(&display.LCD,0,0,0,16); display.clear(); display.endFrame(); + for(auto color:display.LCD.colors) assert(color==0 || color==0xF800); + night=false; display.setColor(CardputerDisplay::P_GREEN); + assert(display._lastColor==CardputerDisplay::P_GREEN); + display.setColor(CardputerDisplay::P_WHITE); assert(display._lastColor==0xFFFF); + + Board board; Interfaces interfaces; Prefs prefs; CustomPrefs custom; + MainScreen main; NewMessageScreen messages; + CardputerUITask ui; + ui._display=&display; ui._board=&board; ui._interfaceManager=&interfaces; + ui._node_prefs=&prefs; ui._custom_prefs=&custom; + ui.main_screen=&main; ui.current_screen=&main; ui.new_message_screen=&messages; + the_mesh_cp.prefs=&prefs; the_mesh_cp.custom=&custom; + auto& power=mesh::ui::displayPowerPrefs(); + power.battery.mode=mesh::ui::DisplayMode::Automatic; power.battery.seconds=27; + power.usb.mode=mesh::ui::DisplayMode::Off; + ui.serviceDisplayPower(); assert(display.isOn()); + fake_now=5000; ui.serviceDisplayPower(); assert(!display.isOn()); + ui.newMsg(0,"peer","message",1); assert(display.isOn() && messages.messages==1); + fake_now=31999; ui.serviceDisplayPower(); assert(display.isOn()); + fake_now=32000; ui.serviceDisplayPower(); assert(!display.isOn()); + // The first waking key is consumed; subsequent input extends the saved timeout. + assert(ui.turnDisplayOn()); assert(display.isOn()); assert(!ui.turnDisplayOn()); + fake_now=59000; ui.serviceDisplayPower(); assert(!display.isOn()); + ui.connected=true; ui.current_screen=&main; + ui.newMsg(0,"peer","online",2); assert(!display.isOn() && messages.messages==1 && ui.current_screen==&main); + ui.connected=false; + ui._notification_outputs=mesh::notify::Sound; + ui.newMsg(0,"peer","screen muted",3); assert(!display.isOn() && messages.messages==1); + ui._notification_outputs=31; ui._message_notification_screen_override=true; + ui.newMsg(0,"peer","managed screen",4); assert(!display.isOn() && messages.messages==1); + ui._message_notification_screen_override=false; + power.battery.mode=mesh::ui::DisplayMode::Button; + ui.serviceDisplayPower(); ui.newMsg(0,"peer","button mode",5); assert(!display.isOn()); + assert(ui.turnDisplayOn() && display.isOn()); + board.usb=true; ui.serviceDisplayPower(); assert(!display.isOn()); + ui.newMsg(0,"peer","USB off",6); assert(!display.isOn()); + ui.notificationScreen(1); assert(display.isOn()); + ui.notificationScreen(-1); ui.serviceDisplayPower(); assert(!display.isOn()); + assert(ui.notificationCapabilities()==mesh::notify::Screen); + unsigned tones=board.speaker.tones; + ui._notification_outputs=0; ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones); + ui._notification_outputs=mesh::notify::Sound; ui._message_notification_override=true; + ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones); + ui._message_notification_override=false; prefs.buzzer_quiet=1; + ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones); + prefs.buzzer_quiet=0; ui.notify(UIEventType::contactMessage); assert(board.speaker.tones==tones+1); + + the_mesh_cp.fail=true; + assert(!ui.toggleGPS() && prefs.gps_enabled==1); + assert(!ui.toggleBuzzer() && prefs.buzzer_quiet==0); + assert(!ui.togglePowerSave() && prefs.powersaving_enabled==0); + display.setTextLevel(1); display.setBrightness(96); + ui.cycleTextSize(); assert(custom.text_level==1 && display.getTextLevel()==1); + ui.cycleBrightness(); assert(custom.brightness==96 && display.LCD.brightness==96); + the_mesh_cp.fail=false; + assert(ui.toggleGPS() && prefs.gps_enabled==0); + assert(ui.toggleBuzzer() && prefs.buzzer_quiet==1); + assert(ui.togglePowerSave() && prefs.powersaving_enabled==1); + ui.cycleTextSize(); assert(custom.text_level==2 && display.getTextLevel()==2); + ui.cycleBrightness(); assert(custom.brightness==160 && display.LCD.brightness==160); + // Real pairing events wake pairing-only policy, without replacing the chat/draft owner. + power.usb.mode=mesh::ui::DisplayMode::Pairing; + ui.current_screen=&main; + ui.servicePairingState(); assert(!ui.isPairingPromptActive() && !display.isOn()); + interfaces.pending=true; ui.servicePairingState(); + assert(!interfaces.pending && ui.isPairingPromptActive() && display.isOn()); + assert(ui.current_screen==&main && ui.pairing_prompt_until==fake_now+120000); + display.LCD.text.clear(); ui.renderPairingPrompt(); + unsigned pin_count=0; + for(const auto& text:display.LCD.text) if(text=="246810") ++pin_count; + assert(pin_count==1); + Keyboard::Event enter{Keyboard::KEY_RETURN}; + assert(ui.handlePairingInput(enter) && ui.current_screen==&main && ui.isPairingPromptActive()); + ui.newMsg(0,"peer","during pairing",8); assert(ui.current_screen==&main); + fake_now=ui.pairing_prompt_until-1; ui.servicePairingState(); assert(ui.isPairingPromptActive()); + ++fake_now; ui.servicePairingState(); assert(!ui.isPairingPromptActive() && !display.isOn()); + assert(ui.current_screen==&main); + + // A saved Off profile stays dark, even when the phone requests a PIN. + power.usb.mode=mesh::ui::DisplayMode::Off; + interfaces.pending=true; ui.servicePairingState(); + assert(ui.isPairingPromptActive() && !display.isOn()); + // BLE completion, unlike an unrelated USB client, ends the prompt immediately. + interfaces.ble=true; ui.servicePairingState(); + assert(!ui.isPairingPromptActive() && ui.pairing_prompt_until==0); + interfaces.ble=false; power.usb.mode=mesh::ui::DisplayMode::ButtonPairing; + interfaces.pending=true; ui.servicePairingState(); assert(display.isOn()); + interfaces.enabled=false; ui.servicePairingState(); + assert(!ui.isPairingPromptActive() && !display.isOn()); + interfaces.enabled=true; + // ESC dismisses the overlay and wakes the retained screen under button-pairing policy. + interfaces.pending=true; ui.servicePairingState(); + Keyboard::Event escape{Keyboard::KEY_ESC}; + assert(ui.handlePairingInput(escape) && !ui.isPairingPromptActive()); + assert(ui.current_screen==&main && display.isOn()); + assert(!ui.handlePairingInput(enter)); + // Expiry remains bounded across the 32-bit millis rollover. + fake_now=0xFFFF0000U; interfaces.pending=true; ui.servicePairingState(); + assert(ui.isPairingPromptActive()); + fake_now+=119999U; ui.servicePairingState(); assert(ui.isPairingPromptActive()); + ++fake_now; ui.servicePairingState(); assert(!ui.isPairingPromptActive()); + // Failed display initialization leaves every display control and notification safe. + ui._display=nullptr; + auto saved=the_mesh_cp.saves; + ui.cycleTextSize(); ui.cycleBrightness(); ui.serviceDisplayPower(); ui.notificationScreen(1); + assert(!ui.turnDisplayOn() && ui.notificationCapabilities()==0 && the_mesh_cp.saves==saved); + interfaces.pending=true; ui.servicePairingState(); + assert(!ui.isPairingPromptActive() && !interfaces.pending); + ui.newMsg(0,"peer","headless",7); + 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("renderPairingPrompt();\n _display->endFrame();", task) + with tempfile.TemporaryDirectory(prefix="cardputer-lifecycle-") as tmp: + work = Path(tmp) + (work / "Arduino.h").write_text("#pragma once\nunsigned long millis();\n", encoding="ascii") + source, binary = work / "test.cpp", work / "test" + source.write_text(code, encoding="ascii") + compiled = subprocess.run([ + "g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-misleading-indentation", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie", + "-DCARDPUTER_ADV_UI=1", "-I", str(work), "-I", str(ROOT / "src"), + str(source), str(ROOT / "src/helpers/ui/DisplayDriver.cpp"), "-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 7b32714ff..d68c8e309 100644 --- a/variants/m5stack_cardputer_adv/CardputerDataStore.cpp +++ b/variants/m5stack_cardputer_adv/CardputerDataStore.cpp @@ -1,5 +1,9 @@ #include "CardputerDataStore.h" #include // Keep optional framework-SD discovery local to this variant. +#include +#include +#include +#include #include "globals.h" @@ -24,30 +28,97 @@ void CardputerDataStore::beginCustom(FILESYSTEM *history_fs) { _history_fs->mkdir(HISTORY_DIR_DIRECT); } -void CardputerDataStore::loadCustomPrefs(CustomNodePrefs &prefs) { - if (!_fs->exists(CUSTOM_PREFS_FILE)) { - return; - } - - File file = _fs->open(CUSTOM_PREFS_FILE, FILE_READ); - - // Read file only if it exactly matches struct size, otherwise keep default values - if (file.size() == sizeof(prefs)) { - file.read((uint8_t *)&prefs, sizeof(prefs)); - } - - file.close(); +static bool validCustomPrefs(const CustomNodePrefs &prefs) { + return isfinite(prefs.battery_correction) && prefs.battery_correction >= 0.5f + && prefs.battery_correction <= 2.0f && prefs.text_level < 3; } -void CardputerDataStore::saveCustomPrefs(const CustomNodePrefs &prefs) { - File file = _fs->open(CUSTOM_PREFS_FILE, FILE_WRITE); +static bool customPrefsPresence(FILESYSTEM *fs, const char *path, bool &present) { + (void)fs; // Custom preferences always use the default internal SPIFFS mount. + char vfs_path[96]; + const int length = snprintf(vfs_path, sizeof(vfs_path), "/spiffs%s", path); + if (length < 0 || static_cast(length) >= sizeof(vfs_path)) return false; + struct stat info; + const int result = ::stat(vfs_path, &info); + if (result != 0 && errno != ENOENT) return false; + present = result == 0; + return true; +} - if (!file) { - return; - } - - file.write((uint8_t *)&prefs, sizeof(prefs)); +static bool readCustomPrefs(FILESYSTEM *fs, const char *path, CustomNodePrefs &prefs) { + File file = fs->open(path, FILE_READ); + if (!file) return false; + CustomNodePrefs candidate; + const bool valid = !file.isDirectory() && file.size() == sizeof(candidate) + && file.read(reinterpret_cast(&candidate), sizeof(candidate)) == sizeof(candidate) + && validCustomPrefs(candidate); file.close(); + if (valid) prefs = candidate; + return valid; +} + +bool CardputerDataStore::loadCustomPrefs(CustomNodePrefs &prefs) { + _custom_prefs_write_blocked = false; + bool present = false, backup_present = false; + if (!customPrefsPresence(_fs, CUSTOM_PREFS_FILE, present) + || !customPrefsPresence(_fs, CUSTOM_PREFS_FILE ".bak", backup_present)) { + _custom_prefs_write_blocked = true; + return false; + } + if (present && readCustomPrefs(_fs, CUSTOM_PREFS_FILE, prefs)) return true; + CustomNodePrefs backup; + if (backup_present && readCustomPrefs(_fs, CUSTOM_PREFS_FILE ".bak", backup)) { + prefs = backup; + if (present && !_fs->remove(CUSTOM_PREFS_FILE)) { + _custom_prefs_write_blocked = true; + return true; + } + _fs->rename(CUSTOM_PREFS_FILE ".bak", CUSTOM_PREFS_FILE); + CustomNodePrefs verified; + _custom_prefs_write_blocked = !readCustomPrefs(_fs, CUSTOM_PREFS_FILE, verified) + || memcmp(&verified, &backup, sizeof(backup)) != 0; + return true; + } + // An unreadable existing image is not permission to replace its only copy. + _custom_prefs_write_blocked = present || backup_present; + return false; +} + +bool CardputerDataStore::saveCustomPrefs(const CustomNodePrefs &prefs) { + if (_custom_prefs_write_blocked || !validCustomPrefs(prefs)) return false; + mesh::ContactFileTransaction transaction(_fs, CUSTOM_PREFS_FILE, customPrefsPresence); + bool valid = transaction && transaction.write(reinterpret_cast(&prefs), sizeof(prefs)) == sizeof(prefs); + mesh::ContactFileTransaction::CommitProgress progress; + do { + progress = transaction.serviceCommit(valid, 1, true); + } while (progress == mesh::ContactFileTransaction::CommitProgress::Pending); + if (progress != mesh::ContactFileTransaction::CommitProgress::Succeeded) { + _custom_prefs_write_blocked = transaction.outcome() == mesh::ContactFileTransaction::Outcome::Indeterminate; + return false; + } + CustomNodePrefs verified; + if (readCustomPrefs(_fs, CUSTOM_PREFS_FILE, verified) && memcmp(&verified, &prefs, sizeof(prefs)) == 0) { + _fs->remove(CUSTOM_PREFS_FILE ".bak"); + return true; + } + // Keep the old verified image until the public candidate has been read back. + CustomNodePrefs backup; + bool backup_present = false; + if (!customPrefsPresence(_fs, CUSTOM_PREFS_FILE ".bak", backup_present) + || (backup_present && !readCustomPrefs(_fs, CUSTOM_PREFS_FILE ".bak", backup)) + || !_fs->remove(CUSTOM_PREFS_FILE)) { + _custom_prefs_write_blocked = true; + return false; + } + if (backup_present) { + _fs->rename(CUSTOM_PREFS_FILE ".bak", CUSTOM_PREFS_FILE); + _custom_prefs_write_blocked = !readCustomPrefs(_fs, CUSTOM_PREFS_FILE, verified) + || memcmp(&verified, &backup, sizeof(backup)) != 0; + } else { + bool present = false; + _custom_prefs_write_blocked = !customPrefsPresence(_fs, CUSTOM_PREFS_FILE, present) || present; + } + return false; } void CardputerDataStore::storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const char *text, bool is_sent, diff --git a/variants/m5stack_cardputer_adv/CardputerDataStore.h b/variants/m5stack_cardputer_adv/CardputerDataStore.h index a747d5b85..b9f8552ea 100644 --- a/variants/m5stack_cardputer_adv/CardputerDataStore.h +++ b/variants/m5stack_cardputer_adv/CardputerDataStore.h @@ -26,6 +26,7 @@ class CardputerDataStore : public DataStore { private: FILESYSTEM *_fs; FILESYSTEM *_history_fs = nullptr; + bool _custom_prefs_write_blocked = false; public: CardputerDataStore(FILESYSTEM &fs, mesh::RTCClock &clock) : DataStore(fs, clock), _fs(&fs) {} @@ -33,8 +34,8 @@ public: // Call after the SD card is mounted (or not); nullptr disables history. void beginCustom(FILESYSTEM *history_fs); bool hasHistory() const { return _history_fs != nullptr; } - void loadCustomPrefs(CustomNodePrefs &prefs); - void saveCustomPrefs(const CustomNodePrefs &prefs); + bool loadCustomPrefs(CustomNodePrefs &prefs); + bool saveCustomPrefs(const CustomNodePrefs &prefs); 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 379079f54..4372c57aa 100644 --- a/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp +++ b/variants/m5stack_cardputer_adv/CardputerSensorManager.cpp @@ -1,110 +1,156 @@ #include "CardputerSensorManager.h" +namespace { +const uint32_t STANDBY_RENEW_MS = 60UL * 60UL * 1000UL; +bool due(uint32_t now, uint32_t deadline) { + return static_cast(now - deadline) >= 0; +} +} + +void CardputerSensorManager::armGpsPowerSavingCycle() { + if (!powersaving_enabled || !_location->getGPSPowerSaving()) return; + _location->syncTimeForPowerSavingCycle(); + _location->setNextGPSOn(0); + _location->setNextSleep(); + // The provider uses zero as "no deadline", including at millis rollover. + if (static_cast(_location->getNextGPSOff()) == 0) _location->setNextGPSOff(1); +} + void CardputerSensorManager::start_gps() { - if (!gps_active) { - gps_standby(false); - delay(1000); - gps_active = true; - } + if (!gps_serial_open || gps_active) return; + gps_standby(false); + gps_active = true; + // Do not turn a retained pre-sleep fix into a new telemetry observation. + // Receiver startup is asynchronous; the normal bounded parser polls it. + _location->clearPositionFix(); _location->begin(); + next_gps_update = millis(); + if (!_location->getGPSPowerSaving()) _location->syncTimeForPowerSavingCycle(); + armGpsPowerSavingCycle(); } void CardputerSensorManager::stop_gps() { - if (gps_active) { - gps_standby(true); - gps_active = false; - } + if (!gps_active) return; + gps_standby(true); + gps_active = false; + _location->stopTimeSync(); + _location->setNextGPSOff(0); + if (powersaving_enabled && _location->getGPSPowerSaving() + && isGpsTelemetryUserEnabled()) _location->setNextWake(); + else _location->setNextGPSOn(0); _location->stop(); } void CardputerSensorManager::gps_standby(bool active) { - if (active) { - // Cap Lora-1262 does not have GPS switch pin - // Instead we send a command to the GPS module to put it into standby for maximum possible value - // (65535 seconds) - Serial1.print("$PCAS12,65535*1E\r\n"); - } else { - // Hot reboot to exit standby mode - Serial1.print("$PCAS10,0*1C\r\n"); - } + // CASIC protocol sections CAS10/CAS12: hot start and standby for at most + // 65535 seconds. The provider's MicroNMEA sender generates the checksum. + _location->sendSentence(active ? "$PCAS12,65535" : "$PCAS10,0"); + standby_renew_at = static_cast(millis()) + STANDBY_RENEW_MS; } bool CardputerSensorManager::begin() { + if (gps_serial_open) return true; + // Existing board macros name the cap's RX/TX nets; setPins takes MCU RX/TX. Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); Serial1.begin(GPS_BAUD_RATE); - - delay(1000); - - // Check data available to determine if GPS active - if (Serial1.available() > 0) { - start_gps(); - } - + gps_serial_open = true; + gps_active = false; + _location->setGPSPowerSaving(false); + _location->stopTimeSync(); + _location->stop(); + // 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); return true; } bool CardputerSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) { - if (requester_permissions & TELEM_PERM_LOCATION) { // does requester have permission? - telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude); - } + // Shared cache, permissions, bounded acquisition and manual-off cancellation. + queryGpsTelemetry(requester_permissions, telemetry); return true; } void CardputerSensorManager::loop() { - static long next_gps_update = 0; - static long next_gps_force_shutdown = 0; - - // periodically send standby command because standby has maximum of 18 hours - if (millis() > next_gps_force_shutdown) { - if (!gps_active) { - gps_standby(true); + if (!gps_serial_open) return; + const uint32_t now = millis(); + loopGpsTelemetry(now); + if (powersaving_enabled && isGpsTelemetryUserEnabled() + && !isTrackerGpsModeEnabled() && _location->getGPSPowerSaving()) { + const uint32_t next_off = _location->getNextGPSOff(); + const uint32_t next_on = _location->getNextGPSOn(); + if (gps_active && !gpsTelemetryReceiverRequired(now) + && ((next_off != 0 && due(now, next_off)) || !_location->waitingTimeSync())) { + stop_gps(); + } else if (!gps_active && ((next_on != 0 && due(now, next_on)) + || _location->waitingTimeSync())) { + start_gps(); } - next_gps_force_shutdown = millis() + 1000 * 60 * 60; // every hour } - 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; } - _location->loop(); - - if (millis() > next_gps_update) { + if (due(now, next_gps_update)) { if (_location->isValid()) { - node_lat = ((double)_location->getLatitude()) / 1000000.; - node_lon = ((double)_location->getLongitude()) / 1000000.; - node_altitude = ((double)_location->getAltitude()) / 1000.0; - MESH_DEBUG_PRINTLN("VALID location: lat %f lon %f", node_lat, node_lon); - } else { - MESH_DEBUG_PRINTLN("INVALID location, waiting for fix"); + node_lat = static_cast(_location->getLatitude()) / 1000000.0; + node_lon = static_cast(_location->getLongitude()) / 1000000.0; + node_altitude = static_cast(_location->getAltitude()) / 1000.0; + processGpsTelemetryFix(node_lat, node_lon, node_altitude, now); } - MESH_DEBUG_PRINTLN("GPS satellites: %d", _location->satellitesCount()); - next_gps_update = millis() + 1000; + next_gps_update = now + getGpsUpdateIntervalMillis(); } } -int CardputerSensorManager::getNumSettings() const { - return 1; -} +int CardputerSensorManager::getNumSettings() const { return 1; } const char *CardputerSensorManager::getSettingName(int i) const { - return i == 0 ? "gps" : NULL; + return i == 0 ? "gps" : nullptr; } const char *CardputerSensorManager::getSettingValue(int i) const { - if (i == 0) { - return gps_active ? "1" : "0"; - } - return NULL; + return i == 0 ? (isGpsTelemetryUserEnabled() ? "1" : "0") : nullptr; } bool CardputerSensorManager::setSettingValue(const char *name, const char *value) { + if (!name || !value) return false; if (strcmp(name, "gps") == 0) { - if (strcmp(value, "0") == 0) { - stop_gps(); - } else { - start_gps(); + const bool enabled = strcmp(value, "0") != 0; + const bool was_active = gps_active; + _location->setGPSPowerSaving(enabled && powersaving_enabled); + setGpsTelemetryUserEnabled(enabled); + if (enabled && powersaving_enabled && was_active) armGpsPowerSavingCycle(); + if (!enabled) { + _location->stopTimeSync(); + _location->setNextGPSOn(0); + _location->setNextGPSOff(0); } return true; } - return false; // not supported + const bool accepted = SensorManager::setSettingValue(name, value); + if (accepted && strcmp(name, "gps_interval") == 0) next_gps_update = millis(); + return accepted; +} + +void CardputerSensorManager::setPowerSavingEnabled(bool enabled) { + if (powersaving_enabled == enabled) return; + powersaving_enabled = enabled; + if (isTrackerGpsModeEnabled()) { + _location->setGPSPowerSaving(false); + return; + } + const bool user_enabled = isGpsTelemetryUserEnabled(); + _location->setGPSPowerSaving(enabled && user_enabled); + if (!user_enabled) return; + if (!gps_active) start_gps(); + else if (enabled) armGpsPowerSavingCycle(); +} + +void CardputerSensorManager::shutdownGpsTransportForDeepSleep() { + if (!gps_serial_open) return; + stop_gps(); + Serial1.end(); + gps_serial_open = false; } diff --git a/variants/m5stack_cardputer_adv/CardputerSensorManager.h b/variants/m5stack_cardputer_adv/CardputerSensorManager.h index a47d4f0d4..37f821296 100644 --- a/variants/m5stack_cardputer_adv/CardputerSensorManager.h +++ b/variants/m5stack_cardputer_adv/CardputerSensorManager.h @@ -6,7 +6,17 @@ class CardputerSensorManager : public SensorManager { private: bool gps_active = false; + bool gps_serial_open = false; + uint32_t next_gps_update = 0; + uint32_t standby_renew_at = 0; LocationProvider *_location; + void armGpsPowerSavingCycle(); + +protected: + bool telemetryGpsDetected() const override { return gps_serial_open; } + bool telemetryGpsActive() const override { return gps_active; } + void telemetryGpsStart() override { start_gps(); } + void telemetryGpsStop() override { stop_gps(); } public: CardputerSensorManager(LocationProvider &location) : _location(&location) {} @@ -17,6 +27,13 @@ public: const char *getSettingName(int i) const override; 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; } + // 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; } + void shutdownGpsTransportForDeepSleep() override; LocationProvider *getLocationProvider() override { return _location; } void start_gps(); void stop_gps(); diff --git a/variants/m5stack_cardputer_adv/README.md b/variants/m5stack_cardputer_adv/README.md index e33ee8853..c00e10bcf 100644 --- a/variants/m5stack_cardputer_adv/README.md +++ b/variants/m5stack_cardputer_adv/README.md @@ -69,6 +69,31 @@ Settings -> Text size cycles Normal (12 px), Large (16 px, default) and Extra large (19 px). The larger fonts are generated from DejaVu Sans with `tools/gen_vlw_font.py`. +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 +correction are saved separately from the standard node preferences. + +Screen wake and timeout follow the shared `display.mode` and `display.timeout` +settings for battery and USB power. Fresh installations use `automatic` mode +with a 15-second timeout; saved modes and timeouts are retained. +A key press wakes the screen for the selected timeout; the wake-up +press is consumed. The `automatic` mode also permits incoming messages to wake +the screen while no client is connected. Notification screen overrides and +muted notification outputs are respected. `set display.night auto|on|off` uses +the shared red-on-black palette without changing the saved daytime colors. +Automatic night mode uses GPS or recent direct-neighbor locations, starting +one hour after sunset by default. `set display.night.delay ` changes +that delay. + +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. + ## Third-party notices The keyboard/display UI is adapted from MultiMote's MIT-licensed project; @@ -83,6 +108,10 @@ neither dependency is bundled here. - Power save uses the companion's own light-sleep loop. It only sleeps while the screen is off and no USB or BLE client is connected. +- With GPS and power save enabled, the receiver sleeps after clock acquisition + 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. - 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/companion_radio/CardputerMesh.cpp b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp index 7bff4c4c3..d4b263c07 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.cpp @@ -59,17 +59,14 @@ mesh::DispatcherAction CardputerMesh::onRecvPacket(mesh::Packet *pkt) { return action; } -void CardputerMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, - uint32_t timestamp, const char *text) { - MyMesh::onChannelMessageRecv(channel, pkt, timestamp, text); - _store->storeMessage(channel.secret, text, false, true); +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); } -void CardputerMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, - const char *text) { - MyMesh::onMessageRecv(from, pkt, sender_timestamp, text); - +void CardputerMesh::onUiContactMessage(const ContactInfo &from, const char *text) { _store->storeMessage(from.id.pub_key, text, false, false); _ui->onContactMessageRecv(from, text); } @@ -92,19 +89,27 @@ bool CardputerMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Pac return sent; } -bool CardputerMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, const char *sender_name, - const char *text, int text_len) { - bool ok = MyMesh::sendGroupMessage(timestamp, channel, sender_name, text, text_len); - if (ok) { - _store->storeMessage(channel.secret, text, true, true); - } - return ok; +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; } int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text) { - uint32_t est_timeout; + if (isMessageSending() || !text || !text[0] + || strnlen(text, MAX_MESSAGE_LENGTH + 1) > MAX_MESSAGE_LENGTH) return MSG_SEND_FAILED; + uint32_t est_timeout = 0, expected_ack = 0; + const int rc = MyMesh::sendMessage(recipient, timestamp, 0, text, expected_ack, est_timeout); + if (rc == MSG_SEND_FAILED) return rc; bool flood = (recipient.out_path_len == OUT_PATH_UNKNOWN); last_msg.timestamp = timestamp; + last_msg.expected_ack = expected_ack; last_msg.recipient = recipient; strncpy(last_msg.text, text, sizeof(last_msg.text)); last_msg.text[sizeof(last_msg.text) - 1] = 0; @@ -113,15 +118,9 @@ int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t time last_msg.attempt = 1; last_msg.total_attempts = DIRECT_SEND_FLOOD_ATTEMPTS + (!flood ? DIRECT_SEND_ROUTE_ATTEMPTS : 0); + 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); - - if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - resend_timeout = futureMillis(DIRECT_SEND_FLOOD_RESEND_RELAY); - } else { - resend_timeout = futureMillis(DIRECT_SEND_ROUTE_RESEND_RELAY); - } - - int rc = MyMesh::sendMessage(recipient, timestamp, 0, text, last_msg.expected_ack, est_timeout); _store->storeMessage(recipient.id.pub_key, text, true, false); return rc; } @@ -152,17 +151,20 @@ bool CardputerMesh::processAck(const uint8_t *data, ContactInfo *&peer) { void CardputerMesh::beginCustom() { _store->loadCustomPrefs(_custom_prefs); + _saved_custom_prefs = _custom_prefs; } -void CardputerMesh::savePrefs() { - MyMesh::savePrefs(); - _store->saveCustomPrefs(_custom_prefs); +bool CardputerMesh::savePrefs() { + return MyMesh::savePrefs(); } -void CardputerMesh::setPowerSaving(bool enabled) { - getNodePrefs()->powersaving_enabled = enabled ? 1 : 0; - savePrefs(); - sensors.setPowerSavingEnabled(enabled); +bool CardputerMesh::saveCustomPrefs() { + if (!_store->saveCustomPrefs(_custom_prefs)) { + _custom_prefs = _saved_custom_prefs; + return false; + } + _saved_custom_prefs = _custom_prefs; + return true; } bool CardputerMesh::sendPing(ContactInfo &contact) { @@ -241,33 +243,44 @@ void CardputerMesh::checkResend() { return; } + ContactInfo *ref = lookupContactByPubKey(last_msg.recipient.id.pub_key, PUB_KEY_SIZE); + if (!ref || last_msg.attempt >= last_msg.total_attempts) { + cancelResending(); + return; + } + if (last_msg.need_direct && last_msg.attempt >= DIRECT_SEND_ROUTE_ATTEMPTS) { last_msg.need_direct = false; MESH_DEBUG_PRINTLN("Resetting path"); - ContactInfo *ref = the_mesh_cp.lookupContactByPubKey(last_msg.recipient.id.pub_key, CONTACT_LOOKUP_BYTES); - if (ref) { - ref->out_path_len = OUT_PATH_UNKNOWN; + const uint8_t previous_path_len = ref->out_path_len; + ref->out_path_len = OUT_PATH_UNKNOWN; + if (!queueUiContactWrite(*ref)) { + ref->out_path_len = previous_path_len; + cancelResending(); + return; } - last_msg.recipient.out_path_len = OUT_PATH_UNKNOWN; } + last_msg.recipient = *ref; + if (!last_msg.need_direct) last_msg.recipient.out_path_len = OUT_PATH_UNKNOWN; if (last_msg.attempt < last_msg.total_attempts) { - uint32_t est_timeout; - - _ui->onMessageSendAttempt(last_msg.attempt + 1, last_msg.total_attempts, last_msg.state); - - if (last_msg.recipient.out_path_len == OUT_PATH_UNKNOWN) { - resend_timeout = futureMillis(DIRECT_SEND_FLOOD_RESEND_RELAY); - } else { - resend_timeout = futureMillis(DIRECT_SEND_ROUTE_RESEND_RELAY); - } + uint32_t est_timeout = 0, expected_ack = 0; // expected_ack is derived from the attempt number, so it must be refreshed on every resend - MyMesh::sendMessage(last_msg.recipient, last_msg.timestamp, last_msg.attempt, last_msg.text, - last_msg.expected_ack, est_timeout); + const int rc = MyMesh::sendMessage(last_msg.recipient, last_msg.timestamp, last_msg.attempt, last_msg.text, + expected_ack, est_timeout); + if (rc == MSG_SEND_FAILED) { + cancelResending(); + return; + } + last_msg.expected_ack = expected_ack; + const uint32_t minimum_timeout = last_msg.recipient.out_path_len == OUT_PATH_UNKNOWN + ? DIRECT_SEND_FLOOD_RESEND_RELAY : DIRECT_SEND_ROUTE_RESEND_RELAY; + resend_timeout = futureMillis(est_timeout > minimum_timeout ? est_timeout : minimum_timeout); last_msg.attempt++; + _ui->onMessageSendAttempt(last_msg.attempt, last_msg.total_attempts, last_msg.state); } else { last_msg.state = MESSAGE_FAILED; _ui->onMessageSendAttempt(last_msg.attempt + 1, last_msg.total_attempts, last_msg.state); @@ -286,6 +299,7 @@ void CardputerMesh::loop() { void CardputerMesh::cancelResending() { resend_timeout = 0; + last_msg.expected_ack = 0; if (last_msg.state == MESSAGE_SENDING) { last_msg.state = MESSAGE_FAILED; _ui->onMessageSendAttempt(last_msg.total_attempts, last_msg.total_attempts, last_msg.state); diff --git a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h index cc4608ee2..68230788e 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h +++ b/variants/m5stack_cardputer_adv/companion_radio/CardputerMesh.h @@ -18,6 +18,7 @@ class CardputerMesh : public MyMesh { uint8_t last_message_hash[MAX_HASH_SIZE] = { 0 }; uint16_t last_message_heard_repeats = 0; CustomNodePrefs _custom_prefs{ .battery_correction = 1.0f, .brightness = 128, .text_level = 1 }; + CustomNodePrefs _saved_custom_prefs = _custom_prefs; LastSentMessage last_msg; unsigned long resend_timeout = 0; @@ -30,16 +31,12 @@ 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 onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, - const char *text) override; - void onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, - const char *text) override; + void onUiChannelMessage(const uint8_t secret[32], const char *text) override; + void onUiContactMessage(const ContactInfo &from, const char *text) override; bool sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis = 0) override; using MyMesh::sendFloodScoped; - // not virtual; using function hiding - bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, const char *sender_name, - const char *text, int text_len); + bool sendChannelMessage(int channel_idx, uint32_t timestamp, const char *text, size_t length); int sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text); @@ -53,9 +50,8 @@ public: // Loads Cardputer UI prefs; call after MyMesh::begin(). void beginCustom(); - void savePrefs(); - // Same effect as the CLI's "powersaving on|off". - void setPowerSaving(bool enabled); + bool savePrefs(); + bool saveCustomPrefs(); bool sendPing(ContactInfo &contact); bool sendAdvert(bool flood); bool sendRepeatersDiscover(); 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 eb7d50592..2fcbea56b 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp @@ -8,12 +8,13 @@ #include "target.h" #include "helpers.h" #include "CompanionBluetooth.h" +#include +#include void CardputerUITask::begin(DisplayDriver *display, SensorManager *sensors, CompanionNodePrefs *node_prefs, CustomNodePrefs *custom_prefs) { _display = static_cast(display); _sensors = sensors; - extendAutoOff(); _node_prefs = node_prefs; _custom_prefs = custom_prefs; @@ -21,7 +22,7 @@ void CardputerUITask::begin(DisplayDriver *display, SensorManager *sensors, Comp if (_display != NULL) { _display->setBrightness(_custom_prefs->brightness); _display->setTextLevel(_custom_prefs->text_level); - _display->turnOn(); + serviceDisplayPower(); } _top_btn.begin(); @@ -51,7 +52,8 @@ bool CardputerUITask::isAlertActive() { } void CardputerUITask::notify(UIEventType t) { - if (t == UIEventType::channelMessage || t == UIEventType::contactMessage) { + if ((_notification_outputs & mesh::notify::Sound) && !_message_notification_override + && (t == UIEventType::channelMessage || t == UIEventType::contactMessage)) { playSound(SoundType::NewMessage); } } @@ -85,24 +87,89 @@ void CardputerUITask::msgRead(int msgcount) { void CardputerUITask::newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount, int channel_idx, const char *channel_name, int queue_index) { unsynced_msg_count = msgcount; + const bool on_first_page = current_screen == main_screen + && static_cast(main_screen)->getCurrentPage() == MainScreen::FIRST; + const bool display_off = _display && !_display->isOn(); - bool onFirstPage = - (current_screen == main_screen && ((MainScreen *)main_screen)->getCurrentPage() == MainScreen::FIRST); - - bool display_off = (_display != NULL && !_display->isOn()); - - if (display_off || current_screen == new_message_screen || onFirstPage) { - ((NewMessageScreen *)new_message_screen)->newMessage(from_name, text); + // Keep a connected client's screen stable; managed notifications own wake. + const bool show_preview = (_notification_outputs & mesh::notify::Screen) + && !_message_notification_screen_override && !hasConnection() && !isPairingPromptActive(); + if (show_preview && (display_off || current_screen == new_message_screen || on_first_page)) { + static_cast(new_message_screen)->newMessage(from_name, text); setCurrScreen(new_message_screen); } + if (_display && shouldWakeDisplayForMessage()) { + _display->wake(mesh::ui::DisplayWake::Message); + } + scheduleRefresh(); +} - if (_display != NULL) { - if (!_display->isOn() && !hasConnection()) { - _display->turnOn(); - } - extendAutoOff(); +uint8_t CardputerUITask::notificationCapabilities() const { + // The codec's fixed tones do not implement custom RTTTL notification rules. + return _display ? mesh::notify::Screen : 0; +} + +void CardputerUITask::notificationScreen(int8_t mode) { + if (!_display) return; + _display->notificationPower(mode); + scheduleRefresh(); +} + +void CardputerUITask::serviceDisplayPower() { + if (!_display) return; + if (_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), + hasConnection(), isPairingPromptActive())) { + scheduleRefresh(); + if (!_display->isOn()) _board->getSpeaker()->sleep(); + } +} + +bool CardputerUITask::isPairingPromptActive() const { + return _display && mesh::ui::shouldDisplayBluetoothPairingPin( + isBluetoothEnabled(), hasBluetoothConnection(), the_mesh_cp.getBLEPin()) + && mesh::ui::isBluetoothPairingPromptActive(isBluetoothEnabled(), hasBluetoothConnection(), + pairing_prompt_until, static_cast(millis())); +} + +void CardputerUITask::servicePairingState() { + const uint32_t now = static_cast(millis()); + if (_interfaceManager->takePairingRequest() && _display + && mesh::ui::shouldDisplayBluetoothPairingPin( + isBluetoothEnabled(), hasBluetoothConnection(), the_mesh_cp.getBLEPin())) { + pairing_prompt_until = now + 120000U; + if (pairing_prompt_until == 0) pairing_prompt_until = 1; scheduleRefresh(); } + if (pairing_prompt_until != 0 && !isPairingPromptActive()) { + pairing_prompt_until = 0; + scheduleRefresh(); + } + serviceDisplayPower(); +} + +bool CardputerUITask::handlePairingInput(const Keyboard::Event &event) { + if (!isPairingPromptActive()) return false; + if (event.key == Keyboard::KEY_ESC) { + pairing_prompt_until = 0; + serviceDisplayPower(); + turnDisplayOn(); + } + return true; +} + +void CardputerUITask::renderPairingPrompt() { + if (!_display || !_display->isOn() || !isPairingPromptActive()) return; + char pin[16]; + snprintf(pin, sizeof(pin), "%06u", static_cast(the_mesh_cp.getBLEPin())); + _display->setColor(CardputerDisplay::P_BLACK); + _display->fillRect(0, 0, _display->width(), _display->height()); + _display->setColor(CardputerDisplay::P_WHITE); + _display->setTextSize(1); + _display->drawTextCentered(_display->width() / 2, 22, "Bluetooth pairing PIN"); + _display->setTextSize(2); + _display->drawTextCentered(_display->width() / 2, 52, pin); + _display->setTextSize(1); + _display->drawTextCentered(_display->width() / 2, 94, "Enter PIN on phone"); } void CardputerUITask::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { @@ -114,8 +181,6 @@ void CardputerUITask::onContactMessageRecv(const ContactInfo &contact, const cha } void CardputerUITask::onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state) { - extendAutoOff(); - if (current_screen == main_screen) { static_cast(main_screen)->onMessageSendAttempt(attempt, total, state); } @@ -148,6 +213,10 @@ void CardputerUITask::setCurrScreen(CardputerScreen *c) { hardware-agnostic pre-shutdown activity should be done here */ void CardputerUITask::shutdown(bool restart) { + if (!prepareForShutdown()) { + showAlert("Shutdown blocked: update in progress", 2000); + return; + } if (restart) { _board->reboot(); } else { @@ -158,13 +227,14 @@ void CardputerUITask::shutdown(bool restart) { } void CardputerUITask::loop() { + servicePairingState(); _board->getSpeaker()->processQueue(); CardputerKeyboard *kb = _board->getKeyboard(); const Keyboard::Modifiers latched = kb->getModifiers(); auto event = kb->poll(); - if (event.changed && event.down) { + if (event.changed && event.down && !handlePairingInput(event)) { if (turnDisplayOn()) { kb->setModifiers(latched); // a wake-up press must not flip Shift/Fn } else { @@ -175,7 +245,7 @@ void CardputerUITask::loop() { } } - if (_top_btn.check() == BUTTON_EVENT_CLICK) { + if (_top_btn.check() == BUTTON_EVENT_CLICK && !isPairingPromptActive()) { turnDisplayOn(); sendFloodAdvert(); } @@ -197,14 +267,9 @@ void CardputerUITask::loop() { } else { scheduleRefresh(delay_millis); } + renderPairingPrompt(); _display->endFrame(); } -#if AUTO_OFF_MILLIS > 0 - if (millis() > auto_off_time) { - _display->turnOff(); - _board->getSpeaker()->sleep(); - } -#endif } #ifdef AUTO_SHUTDOWN_MILLIVOLTS @@ -228,53 +293,35 @@ void CardputerUITask::loop() { } bool CardputerUITask::turnDisplayOn() { - if (_display != NULL) { - extendAutoOff(); - scheduleRefresh(); - - if (!_display->isOn()) { - _display->turnOn(); - return true; - } - } - return false; + if (!_display) return false; + const bool was_off = !_display->isOn(); + _display->wake(mesh::ui::DisplayWake::Button); + scheduleRefresh(); + return was_off; } bool CardputerUITask::getGPSState() { - if (_sensors != NULL) { - int num = _sensors->getNumSettings(); - for (int i = 0; i < num; i++) { - if (strcmp(_sensors->getSettingName(i), "gps") == 0) { - return !strcmp(_sensors->getSettingValue(i), "1"); - } - } - } - return false; + return _node_prefs && _node_prefs->gps_enabled != 0; } -void CardputerUITask::toggleGPS() { - if (_sensors != NULL) { - int num = _sensors->getNumSettings(); - for (int i = 0; i < num; i++) { - if (strcmp(_sensors->getSettingName(i), "gps") == 0) { - if (strcmp(_sensors->getSettingValue(i), "1") == 0) { - _sensors->setSettingValue("gps", "0"); - _node_prefs->gps_enabled = 0; - } else { - _sensors->setSettingValue("gps", "1"); - _node_prefs->gps_enabled = 1; - } - the_mesh_cp.savePrefs(); - scheduleRefresh(); - break; - } - } +bool CardputerUITask::toggleGPS() { + if (!the_mesh_cp.setGpsEnabled(!getGPSState())) { + showAlert("GPS setting could not be saved", 2000); + return false; } + scheduleRefresh(); + return true; } -void CardputerUITask::toggleBuzzer() { - _node_prefs->buzzer_quiet = !_node_prefs->buzzer_quiet; - the_mesh_cp.savePrefs(); +bool CardputerUITask::toggleBuzzer() { + const uint8_t previous = _node_prefs->buzzer_quiet; + _node_prefs->buzzer_quiet = !previous; + if (!the_mesh_cp.savePrefs()) { + _node_prefs->buzzer_quiet = previous; + showAlert("Sound setting could not be saved", 2000); + return false; + } + return true; } void CardputerUITask::renderAlert() { @@ -344,10 +391,14 @@ void CardputerUITask::toggleBluetooth() { } void CardputerUITask::cycleTextSize() { - uint8_t next = (_display->getTextLevel() + 1) % CardputerDisplay::TEXT_LEVEL_COUNT; - _display->setTextLevel(next); - _custom_prefs->text_level = next; - the_mesh_cp.savePrefs(); + if (!_display) return; + _custom_prefs->text_level = (_display->getTextLevel() + 1) % CardputerDisplay::TEXT_LEVEL_COUNT; + if (!the_mesh_cp.saveCustomPrefs()) { + showAlert("Text size could not be saved", 2000); + return; + } + _display->setTextLevel(_custom_prefs->text_level); + scheduleRefresh(); } void CardputerUITask::sendFloodAdvert() { @@ -355,6 +406,7 @@ void CardputerUITask::sendFloodAdvert() { } void CardputerUITask::cycleBrightness() { + if (!_display) return; static const uint8_t levels[] = { 32, 96, 160, 255 }; uint8_t next = levels[0]; for (uint8_t level : levels) { @@ -364,8 +416,11 @@ void CardputerUITask::cycleBrightness() { } } _custom_prefs->brightness = next; + if (!the_mesh_cp.saveCustomPrefs()) { + showAlert("Brightness could not be saved", 2000); + return; + } _display->setBrightness(next); - the_mesh_cp.savePrefs(); char buf[24]; snprintf(buf, sizeof(buf), "Brightness %d%%", next * 100 / 255); showAlert(buf, 1500); @@ -384,19 +439,16 @@ bool CardputerUITask::handleShortcut(const Keyboard::Event &e) { sendFloodAdvert(); return true; case Keyboard::KEY_G: // GPS - toggleGPS(); - showAlert(getGPSState() ? "GPS ON" : "GPS OFF", 1500); + if (toggleGPS()) showAlert(getGPSState() ? "GPS ON" : "GPS OFF", 1500); return true; case Keyboard::KEY_B: // Bluetooth toggleBluetooth(); return true; case Keyboard::KEY_M: // Mute - toggleBuzzer(); - showAlert(isBuzzerQuiet() ? "Sound OFF" : "Sound ON", 1500); + if (toggleBuzzer()) showAlert(isBuzzerQuiet() ? "Sound OFF" : "Sound ON", 1500); return true; case Keyboard::KEY_P: // Power save - togglePowerSave(); - showAlert(powerSaveEnabled() ? "Power save ON" : "Power save OFF", 1500); + if (togglePowerSave()) showAlert(powerSaveEnabled() ? "Power save ON" : "Power save OFF", 1500); return true; case Keyboard::KEY_L: // Light cycleBrightness(); @@ -443,6 +495,12 @@ bool CardputerUITask::handleShortcut(const Keyboard::Event &e) { } } -void CardputerUITask::togglePowerSave() { - the_mesh_cp.setPowerSaving(!_node_prefs->powersaving_enabled); +bool CardputerUITask::togglePowerSave() { + char reply[160] = {}; + const char *command = _node_prefs->powersaving_enabled ? "powersaving off" : "powersaving on"; + if (!the_mesh_cp.handleLocalCommand(command, reply) || strncmp(reply, "OK", 2) != 0) { + showAlert(reply[0] ? reply : "Power save setting failed", 2000); + return false; + } + return true; } 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 b6c04f503..711a762c4 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h @@ -23,13 +23,12 @@ class CardputerUITask : public AbstractUITask { CompanionNodePrefs *_node_prefs; CustomNodePrefs *_custom_prefs; unsigned long next_refresh = 0; - unsigned long auto_off_time; - bool sleep_enabled = false; char alert_text[80]; unsigned long alert_expiry = 0; int unsynced_msg_count = 0; unsigned long ui_started_at = 0; unsigned long next_batt_chck = 0; + uint32_t pairing_prompt_until = 0; CardputerScreen *splash_screen; CardputerScreen *main_screen; @@ -46,6 +45,9 @@ class CardputerUITask : public AbstractUITask { void renderAlert(); void sendFloodAdvert(); void cycleBrightness(); + void serviceDisplayPower(); + void renderPairingPrompt(); + bool handlePairingInput(const Keyboard::Event &event); // Returns false if display is already on bool turnDisplayOn(); @@ -72,13 +74,12 @@ public: inline bool isBuzzerQuiet() { return _node_prefs->buzzer_quiet; } inline const char* getNodeName() { return _node_prefs->node_name; } inline uint16_t getBattMilliVoltsCorrected() { return (float)getBattMilliVolts() * _custom_prefs->battery_correction; } - void toggleBuzzer(); + bool toggleBuzzer(); void toggleBluetooth(); void cycleTextSize(); - void togglePowerSave(); + bool togglePowerSave(); bool getGPSState(); - void toggleGPS(); - inline unsigned long getAutoOffTime() const { return auto_off_time; } + bool toggleGPS(); inline bool powerSaveEnabled() const { return _node_prefs->powersaving_enabled; } // Light sleep stops the backlight PWM and I2S audio; keep the CPU awake // while the screen is on or a tone is queued. @@ -95,10 +96,13 @@ public: void discoverRecv(const mesh::Identity &id, float snr); void messageRepeatsRecv(uint16_t count); void notify(UIEventType t = UIEventType::none) override; + uint8_t notificationCapabilities() const override; + void notificationScreen(int8_t mode) override; + void servicePairingState() override; + bool isPairingPromptActive() const override; void playSound(SoundType t); void loop() override; void shutdown(bool restart = false); CardputerAdvBoard *getBoard() { return _board; } - inline void extendAutoOff() { auto_off_time = millis() + AUTO_OFF_MILLIS; } inline void scheduleRefresh(unsigned long ms = 0) { next_refresh = millis() + ms; } }; 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 39111e75b..17246b783 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 @@ -179,103 +179,102 @@ void SettingsScreen::cancelItemEdit(SettingsItem item) { is_editing = false; } +bool SettingsScreen::commitCommand(const char *command) { + char reply[160] = {}; + if (the_mesh_cp.handleLocalCommand(command, reply) && strncmp(reply, "OK", 2) == 0) { + if (strstr(reply, "pending")) _task->showAlert(reply, 2000); + return true; + } + snprintf(edit_error, sizeof(edit_error), "%.*s", static_cast(sizeof(edit_error) - 1), + reply[0] ? reply : "Setting could not be saved"); + return false; +} + +bool SettingsScreen::commitRadio(float freq, float bw, uint8_t sf, uint8_t cr) { + if (!validRadioParams(freq, bw, sf, cr)) return false; + char command[112]; + snprintf(command, sizeof(command), "set radio %.6f,%.6f,%u,%u,%u", freq, bw, sf, cr, + the_mesh_cp.getPrimaryRadioPreamble()); + if (!commitCommand(command)) return false; + _task->showAlert("Radio saved; applies when idle", 2000); + return true; +} + bool SettingsScreen::commitItemEdit(SettingsItem item) { - + snprintf(edit_error, sizeof(edit_error), "Invalid value"); switch (item) { - case SettingsItem::PublicInfoName: - strncpy(_node_prefs->node_name, edit_buffer.c_str(), sizeof(_node_prefs->node_name) - 1); - _node_prefs->node_name[sizeof(_node_prefs->node_name) - 1] = '\0'; - the_mesh_cp.savePrefs(); - return true; + case SettingsItem::PublicInfoName: { + char command[sizeof(_node_prefs->node_name) + 10]; + if (edit_buffer.length() >= sizeof(_node_prefs->node_name)) return false; + snprintf(command, sizeof(command), "set name %s", edit_buffer.c_str()); + return commitCommand(command); + } - case SettingsItem::MeshDefaultScope: + case SettingsItem::MeshDefaultScope: { + char previous_name[sizeof(_node_prefs->default_scope_name)]; + uint8_t previous_key[sizeof(_node_prefs->default_scope_key)]; + memcpy(previous_name, _node_prefs->default_scope_name, sizeof(previous_name)); + memcpy(previous_key, _node_prefs->default_scope_key, sizeof(previous_key)); memset(_node_prefs->default_scope_name, 0, sizeof(_node_prefs->default_scope_name)); memset(_node_prefs->default_scope_key, 0, sizeof(_node_prefs->default_scope_key)); - if (edit_buffer.length() > 0) { - strncpy(_node_prefs->default_scope_name, edit_buffer.c_str(), - sizeof(_node_prefs->default_scope_name) - 1); - _node_prefs->default_scope_name[sizeof(_node_prefs->default_scope_name) - 1] = '\0'; - + strncpy(_node_prefs->default_scope_name, edit_buffer.c_str(), sizeof(_node_prefs->default_scope_name) - 1); const uint8_t prefix[] = "#"; - mesh::Utils::sha256(_node_prefs->default_scope_key, sizeof(_node_prefs->default_scope_key), prefix, 1, reinterpret_cast(_node_prefs->default_scope_name), - edit_buffer.length()); + strlen(_node_prefs->default_scope_name)); } - - the_mesh_cp.savePrefs(); - return true; + if (the_mesh_cp.savePrefs()) return true; + memcpy(_node_prefs->default_scope_name, previous_name, sizeof(previous_name)); + memcpy(_node_prefs->default_scope_key, previous_key, sizeof(previous_key)); + snprintf(edit_error, sizeof(edit_error), "Scope could not be saved"); + return false; + } case SettingsItem::RadioSf: - if (!validRadioParams(_node_prefs->freq, _node_prefs->bw, edit_u8, _node_prefs->cr)) return false; - _node_prefs->sf = edit_u8; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - + return commitRadio(_node_prefs->freq, _node_prefs->bw, edit_u8, _node_prefs->cr); case SettingsItem::RadioCr: - if (!validRadioParams(_node_prefs->freq, _node_prefs->bw, _node_prefs->sf, edit_u8)) return false; - _node_prefs->cr = edit_u8; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - - case SettingsItem::RadioPwr: + 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; - _node_prefs->tx_power_dbm = edit_u8; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - - case SettingsItem::MeshPathSize: + char command[24]; + snprintf(command, sizeof(command), "set tx %d", static_cast(edit_u8)); + return commitCommand(command); + } + case SettingsItem::MeshPathSize: { + if (edit_u8 < 1 || edit_u8 > 3) return false; + const uint8_t previous = _node_prefs->path_hash_mode; _node_prefs->path_hash_mode = edit_u8 - 1; - the_mesh_cp.savePrefs(); - return true; - + if (the_mesh_cp.savePrefs()) return true; + _node_prefs->path_hash_mode = previous; + snprintf(edit_error, sizeof(edit_error), "Path size could not be saved"); + return false; + } case SettingsItem::RadioFreq: { float value; - if (inputParsePositiveFloat(value) - && validRadioParams(value, _node_prefs->bw, _node_prefs->sf, _node_prefs->cr)) { - _node_prefs->freq = value; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - } - break; + return inputParsePositiveFloat(value) + && commitRadio(value, _node_prefs->bw, _node_prefs->sf, _node_prefs->cr); } - case SettingsItem::RadioBw: { float value; - if (inputParsePositiveFloat(value) - && validRadioParams(_node_prefs->freq, value, _node_prefs->sf, _node_prefs->cr)) { - _node_prefs->bw = value; - the_mesh_cp.savePrefs(); - restart_required = true; - return true; - } - break; + return inputParsePositiveFloat(value) + && commitRadio(_node_prefs->freq, value, _node_prefs->sf, _node_prefs->cr); + } + case SettingsItem::DeviceBatteryCorrection: { + if (!edit_buffer.length()) return false; + const uint16_t real = edit_buffer.toInt(); + const uint16_t adc = _task->getBoard()->getBattMilliVolts(); + if (!real || !adc) return false; + const float correction = static_cast(real) / adc; + if (!isfinite(correction) || correction < 0.5f || correction > 2.0f) return false; + _custom_prefs->battery_correction = correction; + if (the_mesh_cp.saveCustomPrefs()) return true; + snprintf(edit_error, sizeof(edit_error), "Battery correction could not be saved"); + return false; } - - case SettingsItem::DeviceBatteryCorrection: - - if (edit_buffer.length() > 0) { - uint16_t real = edit_buffer.toInt(); - uint16_t adc = _task->getBoard()->getBattMilliVolts(); - if (adc != 0) { - _custom_prefs->battery_correction = (float)real / adc; - the_mesh_cp.savePrefs(); - return true; - } - } - - break; - default: - break; + return false; } - - return false; } void SettingsScreen::handleEditInput(SettingsItem item, Keyboard::Event &e) { @@ -388,13 +387,8 @@ int SettingsScreen::render(CardputerDisplay &display) { const int item_x = 5 + display.getTextWidth(">") + 6; display.setTextSize(1); - if (restart_required) { - display.setColor(CardputerDisplay::P_RED); - display.drawTextCentered(display.width() / 2, title_y, "Settings | Restart"); - } else { - display.setColor(CardputerDisplay::P_GREEN); - display.drawTextCentered(display.width() / 2, title_y, "Settings"); - } + display.setColor(CardputerDisplay::P_GREEN); + display.drawTextCentered(display.width() / 2, title_y, "Settings"); int real_idx = 0; int list_page = menu_index / rows; @@ -471,7 +465,7 @@ bool SettingsScreen::handleInput(Keyboard::Event &e) { if (is_editing) { if (e.key == Keyboard::KEY_RETURN) { if (!commitItemEdit(static_cast(menu_index))) { - _task->showAlert("Invalid value", 1000); + _task->showAlert(edit_error, 2000); } else { is_editing = false; } 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 bffd5cf42..4b41b7e8a 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 @@ -32,7 +32,7 @@ class SettingsScreen : public CardputerScreen { int menu_index = 0; bool is_editing = false; - bool restart_required = false; + char edit_error[80] = "Invalid value"; String edit_buffer; uint8_t edit_u8; static constexpr size_t MAX_NUMERIC_EDIT_BYTES = 31; @@ -44,6 +44,8 @@ class SettingsScreen : public CardputerScreen { void handleEditInput(SettingsItem item, Keyboard::Event &e); bool inputParsePositiveFloat(float &val); bool validRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) const; + bool commitCommand(const char *command); + bool commitRadio(float freq, float bw, uint8_t sf, uint8_t cr); public: SettingsScreen(CardputerUITask *task, mesh::RTCClock *rtc, CompanionNodePrefs *node_prefs, 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 bc9d34247..e4c9ea46e 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 @@ -3,6 +3,8 @@ #include "helpers.h" int ChatPage::render(CardputerDisplay &lcd) { + refreshSelectedContact(); + refreshSelectedChannel(); char buf[64]; const int row = display.getRowHeight(); const int line_h = display.getFontLineHeight(); // advance used when print() wraps @@ -101,7 +103,9 @@ void ChatPage::sendChatMessage() { return; } - uint32_t ts = the_mesh_cp.getRTCClock()->getCurrentTime(); + refreshSelectedContact(); + refreshSelectedChannel(); + uint32_t ts = the_mesh_cp.getRTCClock()->getCurrentTimeUnique(); if (current_contact_idx != -1 && current_contact.type == ADV_TYPE_CHAT) { // direct msg int result = the_mesh_cp.sendDirectMessage(current_contact, ts, chat_text_box.c_str()); @@ -114,14 +118,16 @@ void ChatPage::sendChatMessage() { last_sent_message = chat_text_box; chat_text_box.clear(); chat_history_offset = 0; + } else { + _p->getUiTask()->showAlert("Unable to send message", 2000); } return; } if (current_channel_idx != -1) { // channel msg - bool ok = the_mesh_cp.sendGroupMessage(ts, current_channel.channel, _p->getUiTask()->getNodeName(), - chat_text_box.c_str(), chat_text_box.length()); + 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; @@ -132,6 +138,8 @@ void ChatPage::sendChatMessage() { chat_text_box.clear(); chat_history_offset = 0; _p->getUiTask()->showAlert("Waiting for repeats...", 2000); + } else { + _p->getUiTask()->showAlert("Unable to send message", 2000); } return; } @@ -181,7 +189,7 @@ bool ChatPage::handleInput(Keyboard::Event &e) { } if (e.key == Keyboard::ARROW_UP) { - if (chat_history_offset < chat_history.count() - 1) { + if (chat_history.count() > 0 && chat_history_offset < (int)chat_history.count() - 1) { chat_history_offset++; } return true; @@ -212,6 +220,7 @@ void ChatPage::selectContact(const ContactInfo &contact, int idx) { current_channel_idx = -1; the_mesh_cp.loadMessageHistory(contact.id.pub_key, false, chat_history); chat_history_offset = 0; + last_sent_message = ""; const HistoryMessage *msg; for (size_t i = chat_history.count(); i > 0; i--) { @@ -228,6 +237,7 @@ void ChatPage::selectChannel(const ChannelDetails &channel, int idx) { current_contact_idx = -1; the_mesh_cp.loadMessageHistory(channel.channel.secret, true, chat_history); chat_history_offset = 0; + last_sent_message = ""; const HistoryMessage *msg; for (size_t i = chat_history.count(); i > 0; i--) { @@ -241,7 +251,7 @@ void ChatPage::selectChannel(const ChannelDetails &channel, int idx) { bool ChatPage::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) { if (current_channel_idx != -1 && - memcmp(channel.secret, current_channel.channel.secret, CONTACT_LOOKUP_BYTES) == 0) { + memcmp(channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) == 0) { HistoryMessage *msg = chat_history.push_ref(); msg->out = false; snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text); @@ -253,7 +263,7 @@ bool ChatPage::onChannelMessageRecv(const mesh::GroupChannel &channel, const cha bool ChatPage::onContactMessageRecv(const ContactInfo &contact, const char *text) { if (current_contact_idx != -1 && - memcmp(contact.id.pub_key, current_contact.id.pub_key, CONTACT_LOOKUP_BYTES) == 0) { + memcmp(contact.id.pub_key, current_contact.id.pub_key, PUB_KEY_SIZE) == 0) { HistoryMessage *msg = chat_history.push_ref(); msg->out = false; snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text); @@ -265,9 +275,31 @@ bool ChatPage::onContactMessageRecv(const ContactInfo &contact, const char *text void ChatPage::refreshSelectedContact() { if (current_contact_idx != -1) { - ContactInfo c; - if (the_mesh_cp.getContactByIdx(current_contact_idx, c)) { - current_contact = c; + ContactInfo *contact = the_mesh_cp.lookupPersistentContactByPubKey(current_contact.id.pub_key, PUB_KEY_SIZE); + if (contact) { + current_contact = *contact; + } else { + current_contact_idx = -1; + current_contact = ContactInfo{}; + chat_history.clear(); + chat_history_offset = 0; + last_sent_message = ""; } } } + +bool ChatPage::refreshSelectedChannel() { + if (current_channel_idx == -1) return false; + ChannelDetails channel; + if (!the_mesh_cp.getChannel(current_channel_idx, channel) || !channel.name[0] + || memcmp(channel.channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) != 0) { + current_channel_idx = -1; + current_channel = ChannelDetails{}; + chat_history.clear(); + chat_history_offset = 0; + last_sent_message = ""; + return false; + } + current_channel = channel; + return true; +} 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 a6d42765a..431561bf5 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 @@ -20,6 +20,7 @@ private: int chat_history_offset = 0; // from bottom void sendChatMessage(); + bool refreshSelectedChannel(); public: ChatPage(MainScreen *parent) : _p(parent) { chat_text_box.reserve(MAX_MESSAGE_LENGTH); }; 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 57c3b19d5..0f04a55ad 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 @@ -167,10 +167,18 @@ bool ContactsPage::handleInput(Keyboard::Event &e) { if (the_mesh_cp.getContactByIdx(real_idx, c)) { if (c.type == ADV_TYPE_CHAT) { // getContactByIdx does not return a reference - ContactInfo *ref = the_mesh_cp.lookupContactByPubKey(c.id.pub_key, CONTACT_LOOKUP_BYTES); - ref->out_path_len = OUT_PATH_UNKNOWN; - _p->getUiTask()->showAlert("Path cleared", 1000); - _p->refreshSelectedContact(); + ContactInfo *ref = the_mesh_cp.lookupPersistentContactByPubKey(c.id.pub_key, PUB_KEY_SIZE); + if (ref) { + const uint8_t previous_path_len = ref->out_path_len; + ref->out_path_len = OUT_PATH_UNKNOWN; + if (the_mesh_cp.queueUiContactWrite(*ref)) { + _p->getUiTask()->showAlert("Path cleared", 1000); + _p->refreshSelectedContact(); + } else { + ref->out_path_len = previous_path_len; + _p->getUiTask()->showAlert("Unable to save path", 2000); + } + } } } } diff --git a/variants/m5stack_cardputer_adv/hw/CardputerDisplay.cpp b/variants/m5stack_cardputer_adv/hw/CardputerDisplay.cpp index 1fb92b145..4d5a7ac68 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerDisplay.cpp +++ b/variants/m5stack_cardputer_adv/hw/CardputerDisplay.cpp @@ -6,13 +6,22 @@ #include "globals.h" #include +#include -ColorVal UIColor::window_bkg = CardputerDisplay::PaletteColor::P_BLACK; +ColorVal UIColor::window_bkg = CardputerDisplay::P_BLACK; +ColorVal UIColor::title_bkg = CardputerDisplay::P_BLACK; +ColorVal UIColor::title_txt = CardputerDisplay::P_WHITE; +ColorVal UIColor::primary_txt = CardputerDisplay::P_WHITE; +ColorVal UIColor::secondary_txt = CardputerDisplay::P_GRAY; +ColorVal UIColor::warning_txt = CardputerDisplay::P_ORANGE; +ColorVal UIColor::popup_bkg = CardputerDisplay::P_BLACK; +ColorVal UIColor::popup_txt = CardputerDisplay::P_WHITE; +ColorVal UIColor::corp_blue = CardputerDisplay::P_BLUE; int32_t emoji_draw_callback(lgfx::LGFXBase *gfx, int32_t x, int32_t y, uint32_t code, int32_t font_height) { int w = gfx->textWidth("O"); int h = gfx->fontHeight(); - gfx->drawRect(x, y + (h - w - w / 2), w, w, TFT_DARKGRAY); + gfx->drawRect(x, y + (h - w - w / 2), w, w, mesh::ui::color_theme::render(TFT_DARKGRAY)); return w; } @@ -99,7 +108,8 @@ bool CardputerDisplay::begin() { LCD.setColorDepth(16); // lower SPI bandwidth LCD.setBrightness(128); - LCD.setTextColor(TFT_WHITE, TFT_BLACK); + LCD.setTextColor(mesh::ui::color_theme::render(P_WHITE), + mesh::ui::color_theme::background(P_BLACK)); LCD.setEmojiCallback(emoji_draw_callback); setTextLevel(_textLevel); return true; @@ -116,12 +126,13 @@ void CardputerDisplay::turnOff() { } void CardputerDisplay::clear() { - LCD.clear(); + LCD.clear(mesh::ui::color_theme::background(P_BLACK)); } void CardputerDisplay::startFrame(ColorVal bkg) { LCD.startWrite(); - LCD.clear(P_BLACK); + LCD.clear(mesh::ui::color_theme::background(bkg)); + setColor(P_WHITE); } void CardputerDisplay::endFrame() { @@ -180,7 +191,7 @@ void CardputerDisplay::setTextSize(int sz) { } void CardputerDisplay::setColor(ColorVal c) { - _lastColor = c; + _lastColor = mesh::ui::color_theme::render(c); LCD.setColor(_lastColor); LCD.setTextColor(_lastColor); }