diff --git a/examples/companion_radio/ui-new/ApnDatabase.h b/examples/companion_radio/ui-new/ApnDatabase.h new file mode 100644 index 00000000..e3c3d3a5 --- /dev/null +++ b/examples/companion_radio/ui-new/ApnDatabase.h @@ -0,0 +1,372 @@ +#pragma once + +// ============================================================================= +// ApnDatabase.h - Embedded APN Lookup Table +// +// Maps MCC/MNC (Mobile Country Code / Mobile Network Code) to default APN +// settings for common carriers worldwide. Compiled directly into flash (~3KB) +// so users never need to manually install a lookup file. +// +// The modem queries IMSI via AT+CIMI to extract MCC (3 digits) + MNC (2-3 +// digits), then looks up the APN here. If not found, falls back to the +// modem's existing PDP context (AT+CGDCONT?) or user-configured APN. +// +// To add a carrier: append to APN_DATABASE[] with the MCC+MNC as a single +// integer. MNC can be 2 or 3 digits: +// MCC=310, MNC=260 → mccmnc = 310260 +// MCC=505, MNC=01 → mccmnc = 50501 +// +// Guard: HAS_4G_MODEM +// ============================================================================= + +#ifdef HAS_4G_MODEM + +#ifndef APN_DATABASE_H +#define APN_DATABASE_H + +struct ApnEntry { + uint32_t mccmnc; // MCC+MNC as integer (e.g. 310260 for T-Mobile US) + const char* apn; // APN string + const char* carrier; // Human-readable carrier name (for debug/display) +}; + +// --------------------------------------------------------------------------- +// APN Database — sorted by MCC for binary search potential (not required) +// +// Sources: carrier documentation, GSMA databases, community wikis. +// This covers ~120 major carriers across key regions. Users with less +// common carriers can set APN manually in Settings. +// --------------------------------------------------------------------------- + +static const ApnEntry APN_DATABASE[] = { + // ========================================================================= + // Australia (MCC 505) + // ========================================================================= + { 50501, "telstra.internet", "Telstra" }, + { 50502, "yesinternet", "Optus" }, + { 50503, "vfinternet.au", "Vodafone AU" }, + { 50506, "3netaccess", "Three AU" }, + { 50507, "telstra.internet", "Vodafone AU (MVNO)" }, // Many MVNOs on Telstra + { 50510, "telstra.internet", "Norfolk Tel" }, + { 50512, "3netaccess", "Amaysim" }, // Optus MVNO + { 50514, "yesinternet", "Aussie Broadband" }, // Optus MVNO + { 50590, "yesinternet", "Optus MVNO" }, + + // ========================================================================= + // New Zealand (MCC 530) + // ========================================================================= + { 53001, "internet", "Vodafone NZ" }, + { 53005, "internet", "Spark NZ" }, + { 53024, "internet", "2degrees" }, + + // ========================================================================= + // United States (MCC 310, 311, 312, 313, 316) + // ========================================================================= + { 310012, "fast.t-mobile.com", "Verizon (old)" }, + { 310026, "fast.t-mobile.com", "T-Mobile US" }, + { 310030, "fast.t-mobile.com", "T-Mobile US" }, + { 310032, "fast.t-mobile.com", "T-Mobile US" }, + { 310060, "fast.t-mobile.com", "T-Mobile US" }, + { 310160, "fast.t-mobile.com", "T-Mobile US" }, + { 310200, "fast.t-mobile.com", "T-Mobile US" }, + { 310210, "fast.t-mobile.com", "T-Mobile US" }, + { 310220, "fast.t-mobile.com", "T-Mobile US" }, + { 310230, "fast.t-mobile.com", "T-Mobile US" }, + { 310240, "fast.t-mobile.com", "T-Mobile US" }, + { 310250, "fast.t-mobile.com", "T-Mobile US" }, + { 310260, "fast.t-mobile.com", "T-Mobile US" }, + { 310270, "fast.t-mobile.com", "T-Mobile US" }, + { 310310, "fast.t-mobile.com", "T-Mobile US" }, + { 310490, "fast.t-mobile.com", "T-Mobile US" }, + { 310530, "fast.t-mobile.com", "T-Mobile US" }, + { 310580, "fast.t-mobile.com", "T-Mobile US" }, + { 310660, "fast.t-mobile.com", "T-Mobile US" }, + { 310800, "fast.t-mobile.com", "T-Mobile US" }, + { 311480, "vzwinternet", "Verizon" }, + { 311481, "vzwinternet", "Verizon" }, + { 311482, "vzwinternet", "Verizon" }, + { 311483, "vzwinternet", "Verizon" }, + { 311484, "vzwinternet", "Verizon" }, + { 311489, "vzwinternet", "Verizon" }, + { 310410, "fast.t-mobile.com", "AT&T (migrated)" }, + { 310120, "att.mvno", "AT&T (Sprint)" }, + { 312530, "iot.1nce.net", "1NCE IoT" }, + { 310120, "tfdata", "Tracfone" }, + + // ========================================================================= + // Canada (MCC 302) + // ========================================================================= + { 30220, "internet.com", "Rogers" }, + { 30221, "internet.com", "Rogers" }, + { 30237, "internet.com", "Rogers" }, + { 30272, "internet.com", "Rogers" }, + { 30234, "sp.telus.com", "Telus" }, + { 30286, "sp.telus.com", "Telus" }, + { 30236, "sp.telus.com", "Telus" }, + { 30261, "sp.bell.ca", "Bell" }, + { 30263, "sp.bell.ca", "Bell" }, + { 30267, "sp.bell.ca", "Bell" }, + { 30268, "fido-core-appl1.apn", "Fido" }, + { 30278, "internet.com", "SaskTel" }, + { 30266, "sp.mb.com", "MTS" }, + + // ========================================================================= + // United Kingdom (MCC 234, 235) + // ========================================================================= + { 23410, "o2-internet", "O2 UK" }, + { 23415, "three.co.uk", "Vodafone UK" }, + { 23420, "three.co.uk", "Three UK" }, + { 23430, "everywhere", "EE" }, + { 23431, "everywhere", "EE" }, + { 23432, "everywhere", "EE" }, + { 23433, "everywhere", "EE" }, + { 23450, "data.lycamobile.co.uk","Lycamobile UK" }, + { 23486, "three.co.uk", "Three UK" }, + + // ========================================================================= + // Germany (MCC 262) + // ========================================================================= + { 26201, "internet.t-mobile", "Telekom DE" }, + { 26202, "web.vodafone.de", "Vodafone DE" }, + { 26203, "internet", "O2 DE" }, + { 26207, "internet", "O2 DE" }, + + // ========================================================================= + // France (MCC 208) + // ========================================================================= + { 20801, "orange", "Orange FR" }, + { 20810, "sl2sfr", "SFR" }, + { 20815, "free", "Free Mobile" }, + { 20820, "ofnew.fr", "Bouygues" }, + + // ========================================================================= + // Italy (MCC 222) + // ========================================================================= + { 22201, "mobile.vodafone.it", "TIM" }, + { 22210, "mobile.vodafone.it", "Vodafone IT" }, + { 22250, "internet.it", "Iliad IT" }, + { 22288, "internet.wind", "WindTre" }, + { 22299, "internet.wind", "WindTre" }, + + // ========================================================================= + // Spain (MCC 214) + // ========================================================================= + { 21401, "internet", "Vodafone ES" }, + { 21403, "internet", "Orange ES" }, + { 21404, "internet", "Yoigo" }, + { 21407, "internet", "Movistar" }, + + // ========================================================================= + // Netherlands (MCC 204) + // ========================================================================= + { 20404, "internet", "Vodafone NL" }, + { 20408, "internet", "KPN" }, + { 20412, "internet", "Telfort" }, + { 20416, "internet", "T-Mobile NL" }, + { 20420, "internet", "T-Mobile NL" }, + + // ========================================================================= + // Sweden (MCC 240) + // ========================================================================= + { 24001, "internet.telia.se", "Telia SE" }, + { 24002, "tre.se", "Three SE" }, + { 24007, "internet.telenor.se", "Telenor SE" }, + + // ========================================================================= + // Norway (MCC 242) + // ========================================================================= + { 24201, "internet.telenor.no", "Telenor NO" }, + { 24202, "internet.netcom.no", "Telia NO" }, + + // ========================================================================= + // Denmark (MCC 238) + // ========================================================================= + { 23801, "internet", "TDC" }, + { 23802, "internet", "Telenor DK" }, + { 23806, "internet", "Three DK" }, + { 23820, "internet", "Telia DK" }, + + // ========================================================================= + // Switzerland (MCC 228) + // ========================================================================= + { 22801, "gprs.swisscom.ch", "Swisscom" }, + { 22802, "internet", "Sunrise" }, + { 22803, "internet", "Salt" }, + + // ========================================================================= + // Austria (MCC 232) + // ========================================================================= + { 23201, "a1.net", "A1" }, + { 23203, "web.one.at", "Three AT" }, + { 23205, "web", "T-Mobile AT" }, + + // ========================================================================= + // Japan (MCC 440, 441) + // ========================================================================= + { 44010, "spmode.ne.jp", "NTT Docomo" }, + { 44020, "plus.4g", "SoftBank" }, + { 44051, "au.au-net.ne.jp", "KDDI au" }, + + // ========================================================================= + // South Korea (MCC 450) + // ========================================================================= + { 45005, "lte.sktelecom.com", "SK Telecom" }, + { 45006, "lte.ktfwing.com", "KT" }, + { 45008, "lte.lguplus.co.kr", "LG U+" }, + + // ========================================================================= + // India (MCC 404, 405) + // ========================================================================= + { 40445, "airtelgprs.com", "Airtel" }, + { 40410, "airtelgprs.com", "Airtel" }, + { 40411, "www", "Vodafone IN (Vi)" }, + { 40413, "www", "Vodafone IN (Vi)" }, + { 40486, "www", "Vodafone IN (Vi)" }, + { 40553, "jionet", "Jio" }, + { 40554, "jionet", "Jio" }, + { 40512, "bsnlnet", "BSNL" }, + + // ========================================================================= + // Singapore (MCC 525) + // ========================================================================= + { 52501, "internet", "Singtel" }, + { 52503, "internet", "M1" }, + { 52505, "internet", "StarHub" }, + + // ========================================================================= + // Hong Kong (MCC 454) + // ========================================================================= + { 45400, "internet", "CSL" }, + { 45406, "internet", "SmarTone" }, + { 45412, "internet", "CMHK" }, + + // ========================================================================= + // Brazil (MCC 724) + // ========================================================================= + { 72405, "claro.com.br", "Claro BR" }, + { 72406, "wap.oi.com.br", "Vivo" }, + { 72410, "wap.oi.com.br", "Vivo" }, + { 72411, "wap.oi.com.br", "Vivo" }, + { 72415, "internet.tim.br", "TIM BR" }, + { 72431, "gprs.oi.com.br", "Oi" }, + + // ========================================================================= + // Mexico (MCC 334) + // ========================================================================= + { 33402, "internet.itelcel.com","Telcel" }, + { 33403, "internet.movistar.mx","Movistar MX" }, + { 33404, "internet.att.net.mx", "AT&T MX" }, + + // ========================================================================= + // South Africa (MCC 655) + // ========================================================================= + { 65501, "internet", "Vodacom" }, + { 65502, "internet", "Telkom ZA" }, + { 65507, "internet", "Cell C" }, + { 65510, "internet", "MTN ZA" }, + + // ========================================================================= + // Philippines (MCC 515) + // ========================================================================= + { 51502, "internet.globe.com.ph","Globe" }, + { 51503, "internet", "Smart" }, + { 51505, "internet", "Sun Cellular" }, + + // ========================================================================= + // Thailand (MCC 520) + // ========================================================================= + { 52001, "internet", "AIS" }, + { 52004, "internet", "TrueMove" }, + { 52005, "internet", "dtac" }, + + // ========================================================================= + // Indonesia (MCC 510) + // ========================================================================= + { 51001, "internet", "Telkomsel" }, + { 51010, "internet", "Telkomsel" }, + { 51011, "3gprs", "XL Axiata" }, + { 51028, "3gprs", "XL Axiata (Axis)" }, + + // ========================================================================= + // Malaysia (MCC 502) + // ========================================================================= + { 50212, "celcom3g", "Celcom" }, + { 50213, "celcom3g", "Celcom" }, + { 50216, "internet", "Digi" }, + { 50219, "celcom3g", "Celcom" }, + + // ========================================================================= + // Czech Republic (MCC 230) + // ========================================================================= + { 23001, "internet.t-mobile.cz","T-Mobile CZ" }, + { 23002, "internet", "O2 CZ" }, + { 23003, "internet.vodafone.cz","Vodafone CZ" }, + + // ========================================================================= + // Poland (MCC 260) + // ========================================================================= + { 26001, "internet", "Plus PL" }, + { 26002, "internet", "T-Mobile PL" }, + { 26003, "internet", "Orange PL" }, + { 26006, "internet", "Play" }, + + // ========================================================================= + // Portugal (MCC 268) + // ========================================================================= + { 26801, "internet", "Vodafone PT" }, + { 26803, "internet", "NOS" }, + { 26806, "internet", "MEO" }, + + // ========================================================================= + // Ireland (MCC 272) + // ========================================================================= + { 27201, "internet", "Vodafone IE" }, + { 27202, "open.internet", "Three IE" }, + { 27205, "three.ie", "Three IE" }, + + // ========================================================================= + // IoT / Global SIMs + // ========================================================================= + { 901028, "iot.1nce.net", "1NCE (IoT)" }, + { 90143, "hologram", "Hologram" }, +}; + +#define APN_DATABASE_SIZE (sizeof(APN_DATABASE) / sizeof(APN_DATABASE[0])) + +// --------------------------------------------------------------------------- +// Lookup function — returns nullptr if not found +// --------------------------------------------------------------------------- + +inline const ApnEntry* apnLookup(uint32_t mccmnc) { + for (int i = 0; i < (int)APN_DATABASE_SIZE; i++) { + if (APN_DATABASE[i].mccmnc == mccmnc) { + return &APN_DATABASE[i]; + } + } + return nullptr; +} + +// Parse IMSI string into MCC+MNC. Tries 3-digit MNC first (6-digit mccmnc), +// falls back to 2-digit MNC (5-digit mccmnc) if not found. +inline const ApnEntry* apnLookupFromIMSI(const char* imsi) { + if (!imsi || strlen(imsi) < 5) return nullptr; + + // Extract MCC (always 3 digits) + uint32_t mcc = (imsi[0] - '0') * 100 + (imsi[1] - '0') * 10 + (imsi[2] - '0'); + + // Try 3-digit MNC first (more specific) + if (strlen(imsi) >= 6) { + uint32_t mnc3 = (imsi[3] - '0') * 100 + (imsi[4] - '0') * 10 + (imsi[5] - '0'); + uint32_t mccmnc6 = mcc * 1000 + mnc3; + const ApnEntry* entry = apnLookup(mccmnc6); + if (entry) return entry; + } + + // Fall back to 2-digit MNC + uint32_t mnc2 = (imsi[3] - '0') * 10 + (imsi[4] - '0'); + uint32_t mccmnc5 = mcc * 100 + mnc2; + return apnLookup(mccmnc5); +} + +#endif // APN_DATABASE_H +#endif // HAS_4G_MODEM \ No newline at end of file diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index 56368a07..a031e0c4 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -14,7 +14,7 @@ // Maximum messages to store in history #define CHANNEL_MSG_HISTORY_SIZE 300 #define CHANNEL_MSG_TEXT_LEN 160 -#define MSG_PATH_MAX 20 // Max repeater hops stored per message +#define MSG_PATH_MAX 8 // Max repeater hops stored per message #ifndef MAX_GROUP_CHANNELS #define MAX_GROUP_CHANNELS 20 @@ -24,7 +24,7 @@ // On-disk format for message persistence (SD card) // --------------------------------------------------------------------------- #define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store -#define MSG_FILE_VERSION 3 +#define MSG_FILE_VERSION 2 #define MSG_FILE_PATH "/meshcore/messages.bin" struct __attribute__((packed)) MsgFileHeader { @@ -44,7 +44,7 @@ struct __attribute__((packed)) MsgFileRecord { uint8_t reserved; uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key) char text[CHANNEL_MSG_TEXT_LEN]; - // 188 bytes total + // 176 bytes total }; class UITask; // Forward declaration @@ -74,17 +74,23 @@ private: uint8_t _viewChannelIdx; // Which channel we're currently viewing bool _sdReady; // SD card is available for persistence bool _showPathOverlay; // Show path detail overlay for last received msg - int _pathOverlayScroll; // Scroll offset for hop list in path overlay + + // Per-channel unread message counts (standalone mode) + // Index 0..MAX_GROUP_CHANNELS-1 for channel messages + // Index MAX_GROUP_CHANNELS for DMs (channel_idx == 0xFF) + int _unread[MAX_GROUP_CHANNELS + 1]; public: ChannelScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0), - _msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false), _pathOverlayScroll(0) { + _msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false) { // Initialize all messages as invalid for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) { _messages[i].valid = false; memset(_messages[i].path, 0, MSG_PATH_MAX); } + // Initialize unread counts + memset(_unread, 0, sizeof(_unread)); } void setSDReady(bool ready) { _sdReady = ready; } @@ -119,7 +125,15 @@ public: // Reset scroll to show newest message _scrollPos = 0; _showPathOverlay = false; // Dismiss overlay on new message - _pathOverlayScroll = 0; + + // Track unread count for this channel (only for received messages, not sent) + // path_len == 0 means locally sent + if (path_len != 0) { + int unreadSlot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx; + if (unreadSlot >= 0 && unreadSlot <= MAX_GROUP_CHANNELS) { + _unread[unreadSlot]++; + } + } // Persist to SD card saveToSD(); @@ -139,9 +153,42 @@ public: int getMessageCount() const { return _msgCount; } uint8_t getViewChannelIdx() const { return _viewChannelIdx; } - void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; _showPathOverlay = false; _pathOverlayScroll = 0; } + void setViewChannelIdx(uint8_t idx) { + _viewChannelIdx = idx; + _scrollPos = 0; + _showPathOverlay = false; + markChannelRead(idx); + } bool isShowingPathOverlay() const { return _showPathOverlay; } + // --- Unread message tracking (standalone mode) --- + + // Mark all messages for a channel as read + void markChannelRead(uint8_t channel_idx) { + int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx; + if (slot >= 0 && slot <= MAX_GROUP_CHANNELS) { + _unread[slot] = 0; + } + } + + // Get unread count for a specific channel + int getUnreadForChannel(uint8_t channel_idx) const { + int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx; + if (slot >= 0 && slot <= MAX_GROUP_CHANNELS) { + return _unread[slot]; + } + return 0; + } + + // Get total unread across all channels + int getTotalUnread() const { + int total = 0; + for (int i = 0; i <= MAX_GROUP_CHANNELS; i++) { + total += _unread[i]; + } + return total; + } + // Find the newest RECEIVED message for the current channel // (path_len != 0 means received, path_len 0 = locally sent) ChannelMessage* getNewestReceivedMsg() { @@ -162,7 +209,7 @@ public: // ----------------------------------------------------------------------- // Save the entire message buffer to SD card. - // File: /meshcore/messages.bin (~56 KB for 300 messages) + // File: /meshcore/messages.bin (~50 KB for 300 messages) void saveToSD() { #if defined(HAS_SDCARD) && defined(ESP32) if (!_sdReady) return; @@ -362,25 +409,12 @@ public: } y += lineH + 2; - // Show each hop resolved against contacts (scrollable) + // Show each hop resolved against contacts if (plen > 0 && plen != 0xFF) { int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX; - int footerHeight = 14; - int scrollBarW = 4; - int maxY = display.height() - footerHeight; + int maxY = display.height() - 26; - // Calculate how many hops fit in the visible area - int hopsAreaTop = y; - int visibleHops = (maxY - y) / lineH; - if (visibleHops < 1) visibleHops = 1; - - // Clamp scroll position - int maxScroll = displayHops > visibleHops ? displayHops - visibleHops : 0; - if (_pathOverlayScroll > maxScroll) _pathOverlayScroll = maxScroll; - - int startHop = _pathOverlayScroll; - - for (int h = startHop; h < displayHops && y + lineH <= maxY; h++) { + for (int h = 0; h < displayHops && y + lineH <= maxY; h++) { uint8_t hopHash = msg->path[h]; display.setCursor(0, y); display.setColor(DisplayDriver::LIGHT); @@ -423,24 +457,6 @@ public: } y += lineH; } - - // --- Scroll bar for hop list --- - if (displayHops > visibleHops) { - int sbX = display.width() - scrollBarW; - int sbTop = hopsAreaTop; - int sbHeight = maxY - hopsAreaTop; - - // Draw track outline - display.setColor(DisplayDriver::LIGHT); - display.drawRect(sbX, sbTop, scrollBarW, sbHeight); - - // Draw proportional thumb - int thumbH = (visibleHops * sbHeight) / displayHops; - if (thumbH < 4) thumbH = 4; - int thumbY = sbTop + (_pathOverlayScroll * (sbHeight - thumbH)) / maxScroll; - for (int ty = thumbY + 1; ty < thumbY + thumbH - 1; ty++) - display.drawRect(sbX + 1, ty, scrollBarW - 2, 1); - } } } @@ -450,7 +466,7 @@ public: display.drawRect(0, footerY - 2, display.width(), 1); display.setCursor(0, footerY); display.setColor(DisplayDriver::YELLOW); - display.print("Q:Back W/S:Scroll"); + display.print("Q:Back"); #if AUTO_OFF_MILLIS == 0 return 5000; @@ -709,18 +725,6 @@ public: _showPathOverlay = false; return true; } - // W - scroll up in hop list - if (c == 'w' || c == 'W' || c == 0xF2) { - if (_pathOverlayScroll > 0) { - _pathOverlayScroll--; - return true; - } - } - // S - scroll down in hop list - if (c == 's' || c == 'S' || c == 0xF1) { - _pathOverlayScroll++; // Clamped during render - return true; - } return true; // Consume all keys while overlay is up } @@ -730,7 +734,6 @@ public: if (c == 'v' || c == 'V') { if (getNewestReceivedMsg() != nullptr) { _showPathOverlay = true; - _pathOverlayScroll = 0; return true; } return false; // No received messages to show @@ -767,6 +770,7 @@ public: } } _scrollPos = 0; + markChannelRead(_viewChannelIdx); return true; } @@ -780,6 +784,7 @@ public: _viewChannelIdx = 0; } _scrollPos = 0; + markChannelRead(_viewChannelIdx); return true; } diff --git a/examples/companion_radio/ui-new/ModemManager.cpp b/examples/companion_radio/ui-new/ModemManager.cpp index a7971b2b..62e60423 100644 --- a/examples/companion_radio/ui-new/ModemManager.cpp +++ b/examples/companion_radio/ui-new/ModemManager.cpp @@ -17,6 +17,10 @@ ModemManager modemManager; #define AT_BUF_SIZE 512 static char _atBuf[AT_BUF_SIZE]; +// Config file paths +#define MODEM_CONFIG_FILE "/sms/modem.cfg" +#define APN_CONFIG_FILE "/sms/apn.cfg" + // --------------------------------------------------------------------------- // Public API - SMS (unchanged) // --------------------------------------------------------------------------- @@ -30,6 +34,10 @@ void ModemManager::begin() { _callPhone[0] = '\0'; _callStartTime = 0; _urcPos = 0; + _imei[0] = '\0'; + _imsi[0] = '\0'; + _apn[0] = '\0'; + strcpy(_apnSource, "none"); // Create FreeRTOS primitives _sendQueue = xQueueCreate(MODEM_SEND_QUEUE_SIZE, sizeof(SMSOutgoing)); @@ -192,8 +200,6 @@ const char* ModemManager::stateToString(ModemState s) { // Persistent modem enable/disable config // --------------------------------------------------------------------------- -#define MODEM_CONFIG_FILE "/sms/modem.cfg" - bool ModemManager::loadEnabledConfig() { File f = SD.open(MODEM_CONFIG_FILE, FILE_READ); if (!f) { @@ -217,6 +223,112 @@ void ModemManager::saveEnabledConfig(bool enabled) { } } +// --------------------------------------------------------------------------- +// APN Configuration +// --------------------------------------------------------------------------- + +void ModemManager::setAPN(const char* apn) { + strncpy(_apn, apn, sizeof(_apn) - 1); + _apn[sizeof(_apn) - 1] = '\0'; + strcpy(_apnSource, "user"); + saveAPNConfig(apn); + MESH_DEBUG_PRINTLN("[Modem] APN set by user: %s", _apn); +} + +bool ModemManager::loadAPNConfig(char* apnOut, int maxLen) { + File f = SD.open(APN_CONFIG_FILE, FILE_READ); + if (!f) { return false; } + String line = f.readStringUntil('\n'); + f.close(); + line.trim(); + if (line.length() == 0) return false; + strncpy(apnOut, line.c_str(), maxLen - 1); + apnOut[maxLen - 1] = '\0'; + return true; +} + +void ModemManager::saveAPNConfig(const char* apn) { + if (!SD.exists("/sms")) SD.mkdir("/sms"); + File f = SD.open(APN_CONFIG_FILE, FILE_WRITE); + if (f) { + f.println(apn); + f.close(); + Serial.printf("[Modem] APN config saved: %s\n", apn); + } +} + +// --------------------------------------------------------------------------- +// APN Resolution — called during init after network registration +// +// Priority: +// 1. User-configured APN (from /sms/apn.cfg) +// 2. Network-provisioned APN (AT+CGDCONT? — modem already has one) +// 3. Auto-detected from IMSI via embedded ApnDatabase +// 4. Blank (some carriers work with empty APN) +// --------------------------------------------------------------------------- + +void ModemManager::resolveAPN() { + // 1. Check for user-configured APN on SD card + char userApn[64]; + if (loadAPNConfig(userApn, sizeof(userApn))) { + strncpy(_apn, userApn, sizeof(_apn) - 1); + strcpy(_apnSource, "user"); + MESH_DEBUG_PRINTLN("[Modem] APN from user config: %s", _apn); + + // Apply to modem + char cmd[80]; + snprintf(cmd, sizeof(cmd), "AT+CGDCONT=1,\"IP\",\"%s\"", _apn); + sendAT(cmd, "OK", 3000); + return; + } + + // 2. Check if modem already has a network-provisioned APN + if (sendAT("AT+CGDCONT?", "OK", 3000)) { + // Response: +CGDCONT: 1,"IP","telstra.internet",,0,0 + char* p = strstr(_atBuf, "+CGDCONT:"); + if (p) { + char* q1 = strchr(p, '"'); // first quote (before IP) + if (q1) q1 = strchr(q1 + 1, '"'); // close quote of IP + if (q1) q1 = strchr(q1 + 1, '"'); // open quote of APN + if (q1) { + q1++; + char* q2 = strchr(q1, '"'); + if (q2 && q2 > q1) { + int len = q2 - q1; + if (len > 0 && len < (int)sizeof(_apn)) { + memcpy(_apn, q1, len); + _apn[len] = '\0'; + strcpy(_apnSource, "network"); + MESH_DEBUG_PRINTLN("[Modem] APN from network/modem: %s", _apn); + return; + } + } + } + } + } + + // 3. Auto-detect from IMSI using embedded database + if (_imsi[0]) { + const ApnEntry* entry = apnLookupFromIMSI(_imsi); + if (entry) { + strncpy(_apn, entry->apn, sizeof(_apn) - 1); + strcpy(_apnSource, "auto"); + MESH_DEBUG_PRINTLN("[Modem] APN auto-detected: %s (%s)", _apn, entry->carrier); + + // Apply to modem + char cmd[80]; + snprintf(cmd, sizeof(cmd), "AT+CGDCONT=1,\"IP\",\"%s\"", _apn); + sendAT(cmd, "OK", 3000); + return; + } + } + + // 4. No APN found — leave blank + _apn[0] = '\0'; + strcpy(_apnSource, "none"); + MESH_DEBUG_PRINTLN("[Modem] APN: none detected (IMSI=%s)", _imsi[0] ? _imsi : "unknown"); +} + // --------------------------------------------------------------------------- // URC (Unsolicited Result Code) Handling // --------------------------------------------------------------------------- @@ -537,6 +649,28 @@ restart: // Disable echo sendAT("ATE0", "OK"); + // --- Query device identity --- + // IMEI (International Mobile Equipment Identity) + if (sendAT("AT+GSN", "OK", 3000)) { + // Response is just the IMEI number on its own line + char* p = _atBuf; + while (*p && !isdigit(*p)) p++; // skip to first digit + int i = 0; + while (isdigit(p[i]) && i < 19) { _imei[i] = p[i]; i++; } + _imei[i] = '\0'; + MESH_DEBUG_PRINTLN("[Modem] IMEI: %s", _imei); + } + + // IMSI (International Mobile Subscriber Identity) — for APN auto-detection + if (sendAT("AT+CIMI", "OK", 3000)) { + char* p = _atBuf; + while (*p && !isdigit(*p)) p++; + int i = 0; + while (isdigit(p[i]) && i < 19) { _imsi[i] = p[i]; i++; } + _imsi[i] = '\0'; + MESH_DEBUG_PRINTLN("[Modem] IMSI: %s", _imsi); + } + // Set SMS text mode sendAT("AT+CMGF=1", "OK"); @@ -589,6 +723,10 @@ restart: } // Query operator name + // AT+COPS=3,0 sets the format to "long alphanumeric" so AT+COPS? + // returns "Optus" instead of "50502" + sendAT("AT+COPS=3,0", "OK", 2000); + if (sendAT("AT+COPS?", "OK", 5000)) { char* p = strchr(_atBuf, '"'); if (p) { @@ -604,9 +742,28 @@ restart: } } + // If operator is still numeric (all digits), look up friendly name from IMSI + if (_operator[0] && isdigit(_operator[0])) { + bool allDigits = true; + for (int i = 0; _operator[i]; i++) { + if (!isdigit(_operator[i])) { allDigits = false; break; } + } + if (allDigits && _imsi[0]) { + const ApnEntry* entry = apnLookupFromIMSI(_imsi); + if (entry && entry->carrier) { + strncpy(_operator, entry->carrier, sizeof(_operator) - 1); + _operator[sizeof(_operator) - 1] = '\0'; + MESH_DEBUG_PRINTLN("[Modem] operator (from IMSI lookup): %s", _operator); + } + } + } + // Initial signal query pollCSQ(); + // Resolve APN (user config → network provisioned → IMSI auto-detect) + resolveAPN(); + // Sync ESP32 system clock from modem network time bool clockSet = false; for (int attempt = 0; attempt < 5 && !clockSet; attempt++) { @@ -653,7 +810,8 @@ restart: sendAT("AT+CMGD=1,4", "OK", 5000); _state = ModemState::READY; - MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s)", _csq, _operator); + MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s, APN=%s [%s], IMEI=%s)", + _csq, _operator, _apn[0] ? _apn : "(none)", _apnSource, _imei); // ---- Phase 4: Main loop ---- unsigned long lastCSQPoll = 0; diff --git a/examples/companion_radio/ui-new/ModemManager.h b/examples/companion_radio/ui-new/ModemManager.h index 9cef7d4a..785202da 100644 --- a/examples/companion_radio/ui-new/ModemManager.h +++ b/examples/companion_radio/ui-new/ModemManager.h @@ -22,6 +22,7 @@ #include #include #include "variant.h" +#include "ApnDatabase.h" // --------------------------------------------------------------------------- // Modem pins (from variant.h, always defined for reference) @@ -158,6 +159,20 @@ public: const char* getCallPhone() const { return _callPhone; } uint32_t getCallStartTime() const { return _callStartTime; } + // --- Device info (populated during init) --- + const char* getIMEI() const { return _imei; } + const char* getIMSI() const { return _imsi; } + const char* getAPN() const { return _apn; } + const char* getAPNSource() const { return _apnSource; } // "auto", "network", "user", "none" + + // --- APN configuration --- + // Set APN manually (overrides auto-detection). Persists to SD. + void setAPN(const char* apn); + // Load user-configured APN from SD card. Returns true if found. + static bool loadAPNConfig(char* apnOut, int maxLen); + // Save user-configured APN to SD card. + static void saveAPNConfig(const char* apn); + // Pause/resume polling — used by web reader to avoid Core 0 contention // during WiFi TLS handshakes. While paused, the task skips AT commands // (SMS poll, CSQ poll) but still drains URCs and handles call commands @@ -178,6 +193,12 @@ private: volatile bool _paused = false; // Suppresses AT polling when true char _operator[24] = {0}; + // Device identity (populated during Phase 2 init) + char _imei[20] = {0}; // IMEI from AT+GSN + char _imsi[20] = {0}; // IMSI from AT+CIMI (for APN lookup) + char _apn[64] = {0}; // Active APN + char _apnSource[8] = {0}; // "auto", "network", "user", "none" + // Call state (written by modem task, read by main loop) char _callPhone[SMS_PHONE_LEN] = {0}; // Current call number volatile uint32_t _callStartTime = 0; // millis() when call connected @@ -211,6 +232,9 @@ private: void drainURCs(); // Read available UART data, process complete lines void processURCLine(const char* line); // Handle a single URC line + // APN resolution (called from modem task during init) + void resolveAPN(); // Auto-detect APN from network/IMSI/user config + // Call control (called from modem task) bool doDialCall(const char* phone); bool doAnswerCall(); diff --git a/examples/companion_radio/ui-new/Settingsscreen.h b/examples/companion_radio/ui-new/Settingsscreen.h index 1ce8d032..1e98a3b7 100644 --- a/examples/companion_radio/ui-new/Settingsscreen.h +++ b/examples/companion_radio/ui-new/Settingsscreen.h @@ -69,6 +69,11 @@ enum SettingsRowType : uint8_t { ROW_INFO_HEADER, // "--- Info ---" separator ROW_PUB_KEY, // Public key display ROW_FIRMWARE, // Firmware version + #ifdef HAS_4G_MODEM + ROW_IMEI, // IMEI display (read-only) + ROW_OPERATOR_INFO, // Carrier/operator display (read-only) + ROW_APN, // APN setting (editable) + #endif }; // --------------------------------------------------------------------------- @@ -83,7 +88,11 @@ enum EditMode : uint8_t { }; // Max rows in the settings list +#ifdef HAS_4G_MODEM +#define SETTINGS_MAX_ROWS 46 // Extra rows for IMEI, Carrier, APN +#else #define SETTINGS_MAX_ROWS 40 +#endif #define SETTINGS_TEXT_BUF 33 // 32 chars + null class SettingsScreen : public UIScreen { @@ -160,6 +169,12 @@ private: addRow(ROW_PUB_KEY); addRow(ROW_FIRMWARE); + #ifdef HAS_4G_MODEM + addRow(ROW_IMEI); + addRow(ROW_OPERATOR_INFO); + addRow(ROW_APN); + #endif + // Clamp cursor if (_cursor >= _numRows) _cursor = _numRows - 1; if (_cursor < 0) _cursor = 0; @@ -177,7 +192,11 @@ private: bool isSelectable(int idx) const { if (idx < 0 || idx >= _numRows) return false; SettingsRowType t = _rows[idx].type; - return t != ROW_CH_HEADER && t != ROW_INFO_HEADER; + return t != ROW_CH_HEADER && t != ROW_INFO_HEADER + #ifdef HAS_4G_MODEM + && t != ROW_IMEI && t != ROW_OPERATOR_INFO + #endif + ; } void skipNonSelectable(int dir) { @@ -548,7 +567,7 @@ public: // Show first 8 bytes of pub key as hex (16 chars) char hexBuf[17]; mesh::Utils::toHex(hexBuf, the_mesh.self_id.pub_key, 8); - snprintf(tmp, sizeof(tmp), "ID: %s", hexBuf); + snprintf(tmp, sizeof(tmp), "Node ID: %s", hexBuf); display.print(tmp); break; } @@ -557,6 +576,53 @@ public: snprintf(tmp, sizeof(tmp), "FW: %s", FIRMWARE_VERSION); display.print(tmp); break; + + #ifdef HAS_4G_MODEM + case ROW_IMEI: { + const char* imei = modemManager.getIMEI(); + snprintf(tmp, sizeof(tmp), "IMEI: %s", imei[0] ? imei : "(unavailable)"); + display.print(tmp); + break; + } + + case ROW_OPERATOR_INFO: { + const char* op = modemManager.getOperator(); + int bars = modemManager.getSignalBars(); + if (op[0]) { + // Show carrier name with signal bar count + snprintf(tmp, sizeof(tmp), "Carrier: %s (%d/5)", op, bars); + } else { + snprintf(tmp, sizeof(tmp), "Carrier: (searching)"); + } + display.print(tmp); + break; + } + + case ROW_APN: { + if (editing && _editMode == EDIT_TEXT) { + snprintf(tmp, sizeof(tmp), "APN: %s_", _editBuf); + } else { + const char* apn = modemManager.getAPN(); + const char* src = modemManager.getAPNSource(); + if (apn[0]) { + // Truncate APN to fit: "APN: " (5) + apn (max 28) + " [x]" (4) = ~37 chars + char apnShort[29]; + strncpy(apnShort, apn, 28); + apnShort[28] = '\0'; + // Abbreviate source: auto→A, network→N, user→U, none→? + char srcChar = '?'; + if (strcmp(src, "auto") == 0) srcChar = 'A'; + else if (strcmp(src, "network") == 0) srcChar = 'N'; + else if (strcmp(src, "user") == 0) srcChar = 'U'; + snprintf(tmp, sizeof(tmp), "APN: %s [%c]", apnShort, srcChar); + } else { + snprintf(tmp, sizeof(tmp), "APN: (none)"); + } + } + display.print(tmp); + break; + } + #endif } y += lineHeight; @@ -673,6 +739,20 @@ public: } _editMode = EDIT_NONE; } + #ifdef HAS_4G_MODEM + else if (type == ROW_APN) { + // Save the edited APN (even if empty — clears user override) + if (_editPos > 0) { + modemManager.setAPN(_editBuf); + Serial.printf("Settings: APN set to '%s'\n", _editBuf); + } else { + // Empty APN: remove user override, revert to auto-detection + ModemManager::saveAPNConfig(""); + Serial.println("Settings: APN cleared (will auto-detect on next boot)"); + } + _editMode = EDIT_NONE; + } + #endif return true; } if (c == 'q' || c == 'Q' || c == 27) { @@ -876,6 +956,12 @@ public: Serial.println("Settings: 4G modem DISABLED (shutdown)"); } break; + case ROW_APN: { + // Start text editing with current APN as initial value + const char* currentApn = modemManager.getAPN(); + startEditText(currentApn); + break; + } #endif case ROW_ADD_CHANNEL: startEditText(""); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8902126b..69de49d3 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -297,7 +297,7 @@ public: int y = 20; display.setColor(DisplayDriver::YELLOW); display.setTextSize(2); - sprintf(tmp, "MSG: %d", _task->getMsgCount()); + sprintf(tmp, "MSG: %d", _task->getUnreadMsgCount()); display.drawTextCentered(display.width() / 2, y, tmp); y += 18; @@ -985,6 +985,13 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i // Add to channel history screen with channel index and path data ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path); + // If user is currently viewing this channel, mark it as read immediately + // (they can see the message arrive in real-time) + if (isOnChannelScreen() && + ((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) { + ((ChannelScreen *) channel_screen)->markChannelRead(channel_idx); + } + #if defined(LilyGo_TDeck_Pro) // T-Deck Pro: Don't interrupt user with popup - just show brief notification // Messages are stored in channel history, accessible via 'M' key @@ -1365,6 +1372,10 @@ bool UITask::isEditingHomeScreen() const { void UITask::gotoChannelScreen() { ((ChannelScreen *) channel_screen)->resetScroll(); + // Mark the currently viewed channel as read + ((ChannelScreen *) channel_screen)->markChannelRead( + ((ChannelScreen *) channel_screen)->getViewChannelIdx() + ); setCurrScreen(channel_screen); if (_display != NULL && !_display->isOn()) { _display->turnOn(); @@ -1462,6 +1473,10 @@ uint8_t UITask::getChannelScreenViewIdx() const { return ((ChannelScreen *) channel_screen)->getViewChannelIdx(); } +int UITask::getUnreadMsgCount() const { + return ((ChannelScreen *) channel_screen)->getTotalUnread(); +} + void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) { // Format the message as "Sender: message" char formattedMsg[CHANNEL_MSG_TEXT_LEN]; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 5ac35fcb..ac3743be 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -115,6 +115,7 @@ public: void showAlert(const char* text, int duration_millis) override; void forceRefresh() override { _next_refresh = 100; } int getMsgCount() const { return _msgcount; } + int getUnreadMsgCount() const; // Per-channel unread tracking (standalone) bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; bool isOnChannelScreen() const { return curr == channel_screen; } diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index d455a123..2a4913e9 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -80,7 +80,7 @@ build_flags = -D PIN_DISPLAY_BL=45 -D PIN_USER_BTN=0 -D CST328_PIN_RST=38 - -D FIRMWARE_VERSION='"Meck v0.9.4A"' + -D FIRMWARE_VERSION='"Meck v0.9.3A"' -D ARDUINO_LOOP_STACK_SIZE=32768 build_src_filter = ${esp32_base.build_src_filter} +<../variants/LilyGo_TDeck_Pro> @@ -127,8 +127,8 @@ extends = LilyGo_TDeck_Pro build_flags = ${LilyGo_TDeck_Pro.build_flags} -I examples/companion_radio/ui-new - -D MAX_CONTACTS=400 - -D MAX_GROUP_CHANNELS=20 + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 -D MECK_AUDIO_VARIANT build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} @@ -166,14 +166,17 @@ lib_deps = ${LilyGo_TDeck_Pro.lib_deps} densaugeo/base64 @ ~1.4.0 - ; 4G standalone (4G modem hardware, no BLE — maximum battery + modem features) +; 4G standalone (4G modem hardware, no BLE — maximum battery + cellular features) +; No BLE_PIN_CODE: BLE never initializes, saving ~30KB heap + radio power. +; MECK_WEB_READER enabled: works better without BLE (no teardown dance needed, +; more free heap from boot). WiFi-first with cellular PPP fallback (future). [env:meck_4g_standalone] extends = LilyGo_TDeck_Pro build_flags = ${LilyGo_TDeck_Pro.build_flags} -I examples/companion_radio/ui-new - -D MAX_CONTACTS=400 - -D MAX_GROUP_CHANNELS=20 + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1