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); }