diff --git a/native/lib/ctthw/modem/at_commands.h b/native/lib/ctthw/modem/at_commands.h index 790ac83..34cc9d0 100644 --- a/native/lib/ctthw/modem/at_commands.h +++ b/native/lib/ctthw/modem/at_commands.h @@ -10,22 +10,27 @@ // log/dry-run text that merely *mentions* a command is not a command and stays in // the driver.) // +// Layout mirrors ownership rather than enforcing it (the transport is stringly +// typed, so real "which command is valid for this modem" lives in the driver that +// references it — see Modem::iccidQueryCmd()): +// at:: — 3GPP-standard commands used by EVERY family. +// at::quectel:: — Quectel vendor extensions (+Q…). +// at::telit:: — Telit vendor extensions (#…). +// A vendor command showing up in the wrong driver is then a visible red flag. +// // Static commands are `constexpr` constants; the one parameterized command // (CGDCONT define) is a small builder so its wire format is also defined once. -// Grouped by function. Naming: k. +// Naming: k. namespace ctthw { namespace at { -// --- General / identity --- -inline constexpr const char *kCgmi = "AT+CGMI"; // manufacturer (modem-family detect) -inline constexpr const char *kCimi = "AT+CIMI"; // IMSI (bare digits, no prefix) -inline constexpr const char *kQccid = "AT+QCCID"; // ICCID (Quectel) - -// --- PDP / attach context (Quectel) --- +// --- Common: 3GPP-standard, used by every modem family --- +inline constexpr const char *kCgmi = "AT+CGMI"; // manufacturer (modem-family detect) +inline constexpr const char *kCimi = "AT+CIMI"; // IMSI (bare digits, no prefix) inline constexpr const char *kCgdcontQuery = "AT+CGDCONT?"; // list defined contexts -inline constexpr const char *kCfunOff = "AT+CFUN=0"; // radio off (detach before CGDCONT rewrite) -inline constexpr const char *kCfunOn = "AT+CFUN=1"; // radio on (re-attach) +inline constexpr const char *kCfunOff = "AT+CFUN=0"; // radio off (detach before CGDCONT rewrite) +inline constexpr const char *kCfunOn = "AT+CFUN=1"; // radio on (re-attach) // Define PDP context : AT+CGDCONT=,"","". Parameterized, // so the wire format is built in exactly one place. @@ -35,12 +40,20 @@ inline std::string cgdcontDefine(int cid, const std::string &pdp_type, "\""; } -// --- USB composition / ECM (Telit) --- +// --- Quectel vendor extensions (+Q…) --- +namespace quectel { +inline constexpr const char *kCcid = "AT+QCCID"; // ICCID ("+QCCID: ") +} // namespace quectel + +// --- Telit vendor extensions (#…) --- +namespace telit { +inline constexpr const char *kCcid = "AT#CCID"; // ICCID ("#CCID: ") inline constexpr const char *kUsbcfgQuery = "AT#USBCFG?"; // current USB composition inline constexpr const char *kUsbcfgEcm = "AT#USBCFG=1"; // switch to ECM composition inline constexpr const char *kReboot = "AT#REBOOT"; // reboot the modem inline constexpr const char *kEcmQuery = "AT#ECM?"; // ECM bind state inline constexpr const char *kEcmBind = "AT#ECM=1,0"; // bind ECM to PDP context 1 +} // namespace telit } // namespace at } // namespace ctthw diff --git a/native/lib/ctthw/modem/modem.cpp b/native/lib/ctthw/modem/modem.cpp index c2c9e3e..aaece1c 100644 --- a/native/lib/ctthw/modem/modem.cpp +++ b/native/lib/ctthw/modem/modem.cpp @@ -4,6 +4,10 @@ #include #include +#include +#include +#include + #include "modem/at_commands.h" #include "modem/quectel_ec25.h" #include "modem/telit_le910q1.h" @@ -12,6 +16,253 @@ namespace ctthw { Modem::~Modem() = default; +// ---- Pure carrier/APN helpers -------------------------------------------------- + +std::string Modem::parseImsi(const std::string &cimiResp) { + // AT+CIMI answers with the bare IMSI, no prefix, e.g. + // "\r\n240080008862744\r\n\r\nOK\r\n" + // Take the first run of >= 14 digits so a command echo or status token can't be + // mistaken for it (an IMSI is 14-15 digits). + size_t i = 0; + while (i < cimiResp.size()) { + if (!std::isdigit(static_cast(cimiResp[i]))) { + ++i; + continue; + } + size_t j = i; + while (j < cimiResp.size() && + std::isdigit(static_cast(cimiResp[j]))) + ++j; + if (j - i >= 14) + return cimiResp.substr(i, j - i); + i = j; + } + return ""; +} + +std::string Modem::parseIccid(const std::string &ccidResp) { + // Prefix-agnostic: the ICCID is the first run of >= 18 digits (an ICCID is 18-20 + // digits). Parses both "+QCCID: " (Quectel) and "#CCID: " (Telit); + // a command echo or status token is too short to be mistaken for it. + size_t i = 0; + while (i < ccidResp.size()) { + if (!std::isdigit(static_cast(ccidResp[i]))) { + ++i; + continue; + } + size_t j = i; + while (j < ccidResp.size() && + std::isdigit(static_cast(ccidResp[j]))) + ++j; + if (j - i >= 18) + return ccidResp.substr(i, j - i); + i = j; + } + return ""; +} + +std::string Modem::parseCgdcontApn(const std::string &resp, int cid) { + // Line form: +CGDCONT: ,"","","",... + // -> quoted-field index 0 = PDP_type, index 1 = APN. + std::string needle = "+CGDCONT: " + std::to_string(cid) + ","; + auto p = resp.find(needle); + if (p == std::string::npos) + return ""; + size_t end = resp.find('\n', p); + std::string line = + resp.substr(p, end == std::string::npos ? std::string::npos : end - p); + int q = 0; + size_t i = 0; + while (i < line.size()) { + if (line[i] == '"') { + size_t j = line.find('"', i + 1); + if (j == std::string::npos) + break; + if (q == 1) + return line.substr(i + 1, j - i - 1); // the APN + ++q; + i = j + 1; + } else { + ++i; + } + } + return ""; +} + +bool Modem::isTelenorImsi(const std::string &imsi) { + // Telenor Connexion's home PLMN (MCC 240 = Sweden, MNC 08). MCC 240 uses + // 2-digit MNCs, so a 5-digit MCC+MNC prefix is unambiguous. + // + // Add PLMNs here as carriers are onboarded. Do NOT widen the ICCID + // country-code rule instead — the ICCID range is the issuer's, not the + // subscription's, and conflating the two is the original defect. + static const char *const kTelenorPlmns[] = {"24008"}; + for (const char *plmn : kTelenorPlmns) { + const std::string prefix(plmn); + if (imsi.size() >= prefix.size() && + imsi.compare(0, prefix.size(), prefix) == 0) + return true; + } + return false; +} + +bool Modem::isTelenorIccid(const std::string &iccid) { + // Match the Telenor *issuer prefix*, not the 2-digit ICCID country code — + // matching only cc "46" missed Telenor's US-numbered 8901 SIMs and mis-mapped + // them to `super` (F5C51E6B6AFA, 3GPP cause 33). Telenor ships two ICCID + // families: Swedish "8946…" and US "8901240080…" (8901 + the embedded Telenor + // PLMN 24008). + // + // Do NOT widen this to bare "8901": that is a broad US range Kore/Twilio also + // issue from — validated 2026-07-30 against the full fleet, 964 Kore SIMs are + // "890126…"/"890124011…"/"890124020…", and 0 Kore ICCIDs start "890124008", + // so the 9-digit "890124008" prefix is Telenor-exclusive. This is only the + // fallback anyway; the IMSI PLMN (24008) is the reliable primary. Add issuer + // prefixes here as carriers are onboarded. + static const char *const kTelenorIccidPrefixes[] = {"8946", "890124008"}; + for (const char *pfx : kTelenorIccidPrefixes) { + const std::string prefix(pfx); + if (iccid.size() >= prefix.size() && + iccid.compare(0, prefix.size(), prefix) == 0) + return true; + } + return false; +} + +std::string Modem::apnForImsi(const std::string &imsi) { + if (imsi.size() < 5) + return ""; + return isTelenorImsi(imsi) ? kApnTelenor : ""; +} + +std::string Modem::apnForIccid(const std::string &iccid) { + if (iccid.size() < 4) + return ""; + return isTelenorIccid(iccid) ? kApnTelenor : kApnDefault; +} + +std::string Modem::chooseApn(const std::string &imsi, const std::string &iccid) { + const std::string byImsi = apnForImsi(imsi); + if (!byImsi.empty()) + return byImsi; + return apnForIccid(iccid); +} + +// ---- Shared side-effecting provisioning ---------------------------------------- + +void Modem::publishApn(const std::string &apn) const { + ::mkdir("/run/ctt", 0755); // ignore EEXIST; relevant only for the default path + int fd = ::open(apn_file_.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { + std::fprintf(stderr, "ctt-modem-provision: could not write %s (non-fatal)\n", + apn_file_.c_str()); + return; + } + std::string line = apn + "\n"; + if (::write(fd, line.data(), line.size()) < 0) + std::fprintf(stderr, "ctt-modem-provision: short write to %s (non-fatal)\n", + apn_file_.c_str()); + ::close(fd); +} + +ProvisionResult Modem::provisionAttachApn(bool dry_run) { + // IMSI first (names the subscription's home network, which decides the APN), + // ICCID second (names only the issuer's numbering range). See chooseApn(). The + // ICCID query is family-specific (Quectel AT+QCCID / Telit AT#CCID). + const std::string imsi = parseImsi(at_.cmd(at::kCimi, 3000)); + const std::string iccid = parseIccid(at_.cmd(iccidQueryCmd(), 3000)); + const std::string apn = chooseApn(imsi, iccid); + if (apn.empty()) { + std::fprintf(stderr, + "ctt-modem-provision: %s — no usable IMSI or ICCID (IMSI '%s', " + "ICCID '%s') — leaving APN untouched\n", + name(), imsi.c_str(), iccid.c_str()); + return ProvisionResult::Done; // fail open — never guess an APN + } + const std::string plmn = imsi.size() >= 5 ? imsi.substr(0, 5) : "(none)"; + const std::string cc = iccid.size() >= 4 ? iccid.substr(2, 2) : "(none)"; + const char *source = apnForImsi(imsi).empty() ? "ICCID cc" : "IMSI PLMN"; + std::fprintf(stderr, + "ctt-modem-provision: %s — IMSI PLMN %s / ICCID cc %s -> APN '%s' " + "(by %s)\n", + name(), plmn.c_str(), cc.c_str(), apn.c_str(), source); + + std::string cg = at_.cmd(at::kCgdcontQuery, 3000); + if (cg.find("+CGDCONT:") == std::string::npos) { + std::fprintf(stderr, + "ctt-modem-provision: %s — no +CGDCONT response ('%s') — leaving " + "attach context untouched\n", + name(), flattenReply(cg).c_str()); + return ProvisionResult::Done; // fail open + } + // Rewrite only a non-empty WRONG CID1 APN — that divergence (attach APN != dial + // APN, or a stale APN a recycled modem carried in from a prior deployment) is + // what strands the bearer (3GPP cause 55 / 33). Two cases are left untouched (no + // radio bounce), and both still publish the dial APN so the NM side is set: + // - already the desired APN, or + // - BLANK: a blank CID1 attaches on the network-default APN (bench-verified: + // connects fine). The proven failure mode is a non-empty WRONG APN, not a + // blank one, so we don't churn the working fleet of blank-CID1 stations. + std::string current = parseCgdcontApn(cg, 1); + std::fprintf(stderr, + "ctt-modem-provision: %s — CGDCONT CID1 APN '%s' (want '%s')\n", + name(), current.empty() ? "(blank)" : current.c_str(), apn.c_str()); + + if (current == apn) { + std::fprintf(stderr, + "ctt-modem-provision: %s — attach APN already correct; no NV " + "write\n", + name()); + publishApn(apn); // still publish so the NM side has the source of truth + return ProvisionResult::Done; + } + if (current.empty()) { + std::fprintf(stderr, + "ctt-modem-provision: %s — CGDCONT CID1 blank (network-default " + "APN); leaving attach context, publishing dial APN '%s'\n", + name(), apn.c_str()); + publishApn(apn); + return ProvisionResult::Done; + } + + if (dry_run) { + std::fprintf(stderr, + "ctt-modem-provision: %s — --dry-run — would write CFUN=0 / " + "CGDCONT=1,\"%s\",\"%s\" / CFUN=1, then publish %s\n", + name(), kPdpType, apn.c_str(), apn_file_.c_str()); + return ProvisionResult::Done; + } + + // A CGDCONT change is rejected on an already-activated context (Quectel AT + // Commands Manual V2.0 §10.2: "not allowed to change the definition of an + // already activated context" — the same holds for the Telit ECM context), so + // detach first; the change then takes effect at the next attach. Run + // Before=ModemManager, this radio bounce is uncontended. + std::fprintf(stderr, + "ctt-modem-provision: %s — setting attach APN (CFUN=0 / CGDCONT " + "CID1 / CFUN=1)\n", + name()); + at_.cmd(at::kCfunOff, 5000); + std::string w = at_.cmd(at::cgdcontDefine(1, kPdpType, apn), 5000); + if (w.find("OK") == std::string::npos) { + std::fprintf(stderr, + "ctt-modem-provision: %s — CGDCONT write not confirmed ('%s'); " + "re-attaching and leaving APN as-is\n", + name(), flattenReply(w).c_str()); + at_.cmd(at::kCfunOn, 5000); // restore the radio even on failure + return ProvisionResult::Done; // fail open + } + at_.cmd(at::kCfunOn, 5000); + std::fprintf(stderr, + "ctt-modem-provision: %s — attach APN set to '%s'; radio " + "re-attaching\n", + name(), apn.c_str()); + publishApn(apn); + return ProvisionResult::Done; +} + +// ---- Family dispatch ----------------------------------------------------------- + namespace { std::string toLower(std::string s) { for (char &c : s) diff --git a/native/lib/ctthw/modem/modem.h b/native/lib/ctthw/modem/modem.h index eccad67..66651fd 100644 --- a/native/lib/ctthw/modem/modem.h +++ b/native/lib/ctthw/modem/modem.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include "modem/at_port.h" @@ -15,11 +16,13 @@ enum class ProvisionResult { // again on the fresh port to finish — so it completes in ONE boot. }; -// Modem — abstract cellular modem driver. One subclass per family; each owns its -// own provisioning and shares only the AT transport: +// Modem — abstract cellular modem driver. One subclass per family; each owns the +// family-specific data-path work and shares the carrier→APN logic and the attach- +// context heal: // -// TelitLE910Q1 — the CDC-ECM data path (AT#USBCFG / AT#ECM). -// QuectelEC25 — the LTE attach APN (AT+CGDCONT CID1), matched to the SIM. +// TelitLE910Q1 — the CDC-ECM data path (AT#USBCFG / AT#ECM), THEN the shared +// attach-APN heal (a recycled Telit can carry a stale CGDCONT). +// QuectelEC25 — nothing but the shared attach-APN heal (AT+CGDCONT CID1). // // Provisioning is idempotent and fail-open: provision() reads state first and only // writes when needed, reporting progress on stderr; it never exits the process @@ -27,7 +30,22 @@ enum class ProvisionResult { // against real hardware (AtPort) or a scripted fake in tests. class Modem { public: - explicit Modem(AtTransport &at) : at_(at) {} + // --- Per-carrier APN (shared by all families) --- + // The chosen APN is published to /run/ctt/modem-apn — the single source + // provision-modem-apn.sh reads to set the NM dial APN, so the attach APN (written + // to the modem's CGDCONT CID1) and the dial APN stay identical by construction. + static constexpr const char *kDefaultApnFile = "/run/ctt/modem-apn"; + static constexpr const char *kApnTelenor = "internet.cxn"; // Telenor Connexion + static constexpr const char *kApnDefault = "super"; // else (Twilio/Kore Super SIM) + // PDP type for the attach context. "IP" (IPv4) per the documented practice + // (Quectel AT Manual V2.0 §10.2 lists IP/IPV6/IPV4V6; every practitioner guide + // uses IP for the data context) — and these M2M SIMs are IPv4-only, with the NM + // profile already forcing v4 via ipv6.method=disabled. Avoids an IPv6 PDN attempt + // the network refuses. Matches the CID1 PDP type observed on both families. + static constexpr const char *kPdpType = "IP"; + + explicit Modem(AtTransport &at, std::string apn_file = kDefaultApnFile) + : at_(at), apn_file_(std::move(apn_file)) {} virtual ~Modem(); Modem(const Modem &) = delete; Modem &operator=(const Modem &) = delete; @@ -41,8 +59,61 @@ class Modem { // re-enumeration (see ProvisionResult); Done otherwise. virtual ProvisionResult provision(bool dry_run) = 0; + // --- Pure carrier/APN helpers (no I/O; unit-tested against real fixtures) --- + + // Digits of an "AT+CIMI" reply — the SIM IMSI. CIMI answers with the bare IMSI + // (no prefix), so this takes the first run of >= 14 digits. + static std::string parseImsi(const std::string &cimiResp); + // The SIM ICCID from an ICCID query reply — the first run of >= 18 digits + // (an ICCID is 18-20 digits). Prefix-agnostic, so it parses both the Quectel + // "+QCCID: " and the Telit "#CCID: " forms. + static std::string parseIccid(const std::string &ccidResp); + // APN for the CGDCONT context at in an "AT+CGDCONT?" reply (the 2nd quoted + // field on that line), or "" if the context is not defined. + static std::string parseCgdcontApn(const std::string &resp, int cid); + // True if the IMSI's MCC+MNC is a known Telenor (Connexion) home PLMN. + static bool isTelenorImsi(const std::string &imsi); + // True if the ICCID starts with a known Telenor *issuer* prefix (8946 or + // 890124008). Prefix, not the 2-digit country code — see the .cpp for why bare + // "8901" is unsafe (collides with 964 Kore SIMs; fleet-validated 2026-07-30). + static bool isTelenorIccid(const std::string &iccid); + // Carrier APN from the IMSI's home PLMN, or "" when the PLMN is not recognized + // (so the caller can fall back to the ICCID rule). Never guesses a default. + static std::string apnForImsi(const std::string &imsi); + // Carrier APN from the ICCID issuer prefix (isTelenorIccid); "" if too short. + static std::string apnForIccid(const std::string &iccid); + // The APN to use: IMSI first, ICCID second, "" if neither is usable. + // + // The IMSI is authoritative because it names the *subscription's* home network, + // which is what determines the APN the carrier will accept. The ICCID only names + // the issuer's numbering range: Telenor ships SIMs in an 8901 (US-numbered) range + // whose ICCID country code reads "01", so the ICCID-only rule selected `super` on + // a Telenor subscription and the network refused the bearer with 3GPP cause 33 + // (option-unsubscribed). Kept as a fallback for modems/SIMs that won't report an + // IMSI. See investigations/ (V2 station F5C51E6B6AFA, 2026-07-29). + static std::string chooseApn(const std::string &imsi, const std::string &iccid); + protected: + // The AT command that reads the SIM ICCID (family-specific: Quectel AT+QCCID, + // Telit AT#CCID; both replies are parsed by the generic parseIccid()). + virtual const char *iccidQueryCmd() const = 0; + + // Shared attach-context provisioning — the cause-55 / recycled-context heal. + // Reads the SIM (IMSI + ICCID), chooses the APN, and rewrites CGDCONT CID1 iff + // it carries a *non-empty WRONG* APN (detach with CFUN=0, redefine, CFUN=1 so it + // takes at the next attach); a blank CID1 (network-default) or an already-correct + // one is left untouched. Always publishes the dial APN. Used by BOTH families so + // a recycled modem's stale attach context is corrected whether it's a Quectel + // (QMI) or a Telit (ECM) — bench-proven on a Telit 2026-07-31. Never reboots -> + // always returns Done. + ProvisionResult provisionAttachApn(bool dry_run); + + // Publish the chosen APN for provision-modem-apn.sh. Best-effort (a failure is + // logged, never fatal — the shell script falls back to its own mmcli mapping). + void publishApn(const std::string &apn) const; + AtTransport &at_; + std::string apn_file_; }; // Identify the attached modem via AT+CGMI and return the matching driver. A diff --git a/native/lib/ctthw/modem/quectel_ec25.cpp b/native/lib/ctthw/modem/quectel_ec25.cpp index 2942a2c..d929f73 100644 --- a/native/lib/ctthw/modem/quectel_ec25.cpp +++ b/native/lib/ctthw/modem/quectel_ec25.cpp @@ -1,243 +1,11 @@ #include "modem/quectel_ec25.h" -#include "modem/at_commands.h" - -#include -#include - -#include -#include -#include - namespace ctthw { -std::string QuectelEC25::parseIccid(const std::string &qccidResp) { - auto p = qccidResp.find("+QCCID:"); - if (p == std::string::npos) - return ""; - size_t i = p + 7; - while (i < qccidResp.size() && - !std::isdigit(static_cast(qccidResp[i]))) - ++i; - std::string out; - while (i < qccidResp.size() && - std::isdigit(static_cast(qccidResp[i]))) - out += qccidResp[i++]; - return out; -} - -std::string QuectelEC25::parseCgdcontApn(const std::string &resp, int cid) { - // Line form: +CGDCONT: ,"","","",... - // -> quoted-field index 0 = PDP_type, index 1 = APN. - std::string needle = "+CGDCONT: " + std::to_string(cid) + ","; - auto p = resp.find(needle); - if (p == std::string::npos) - return ""; - size_t end = resp.find('\n', p); - std::string line = - resp.substr(p, end == std::string::npos ? std::string::npos : end - p); - int q = 0; - size_t i = 0; - while (i < line.size()) { - if (line[i] == '"') { - size_t j = line.find('"', i + 1); - if (j == std::string::npos) - break; - if (q == 1) - return line.substr(i + 1, j - i - 1); // the APN - ++q; - i = j + 1; - } else { - ++i; - } - } - return ""; -} - -std::string QuectelEC25::parseImsi(const std::string &cimiResp) { - // AT+CIMI answers with the bare IMSI, no "+CIMI:" prefix, e.g. - // "\r\n240080008862744\r\n\r\nOK\r\n" - // Take the first run of >= 14 digits so a command echo or status token can't be - // mistaken for it (an IMSI is 14-15 digits). - size_t i = 0; - while (i < cimiResp.size()) { - if (!std::isdigit(static_cast(cimiResp[i]))) { - ++i; - continue; - } - size_t j = i; - while (j < cimiResp.size() && - std::isdigit(static_cast(cimiResp[j]))) - ++j; - if (j - i >= 14) - return cimiResp.substr(i, j - i); - i = j; - } - return ""; -} - -bool QuectelEC25::isTelenorImsi(const std::string &imsi) { - // Telenor Connexion's home PLMN (MCC 240 = Sweden, MNC 08). MCC 240 uses - // 2-digit MNCs, so a 5-digit MCC+MNC prefix is unambiguous. - // - // Add PLMNs here as carriers are onboarded. Do NOT widen the ICCID - // country-code rule instead — the ICCID range is the issuer's, not the - // subscription's, and conflating the two is the original defect. - static const char *const kTelenorPlmns[] = {"24008"}; - for (const char *plmn : kTelenorPlmns) { - const std::string prefix(plmn); - if (imsi.size() >= prefix.size() && - imsi.compare(0, prefix.size(), prefix) == 0) - return true; - } - return false; -} - -std::string QuectelEC25::apnForImsi(const std::string &imsi) { - if (imsi.size() < 5) - return ""; - return isTelenorImsi(imsi) ? kApnTelenor : ""; -} - -bool QuectelEC25::isTelenorIccid(const std::string &iccid) { - // Match the Telenor *issuer prefix*, not the 2-digit ICCID country code — - // matching only cc "46" missed Telenor's US-numbered 8901 SIMs and mis-mapped - // them to `super` (F5C51E6B6AFA, 3GPP cause 33). Telenor ships two ICCID - // families: Swedish "8946…" and US "8901240080…" (8901 + the embedded Telenor - // PLMN 24008). - // - // Do NOT widen this to bare "8901": that is a broad US range Kore/Twilio also - // issue from — validated 2026-07-30 against the full fleet, 964 Kore SIMs are - // "890126…"/"890124011…"/"890124020…", and 0 Kore ICCIDs start "890124008", - // so the 9-digit "890124008" prefix is Telenor-exclusive. This is only the - // fallback anyway; the IMSI PLMN (24008) is the reliable primary. Add issuer - // prefixes here as carriers are onboarded. - static const char *const kTelenorIccidPrefixes[] = {"8946", "890124008"}; - for (const char *pfx : kTelenorIccidPrefixes) { - const std::string prefix(pfx); - if (iccid.size() >= prefix.size() && - iccid.compare(0, prefix.size(), prefix) == 0) - return true; - } - return false; -} - -std::string QuectelEC25::apnForIccid(const std::string &iccid) { - if (iccid.size() < 4) - return ""; - return isTelenorIccid(iccid) ? kApnTelenor : kApnDefault; -} - -std::string QuectelEC25::chooseApn(const std::string &imsi, - const std::string &iccid) { - const std::string byImsi = apnForImsi(imsi); - if (!byImsi.empty()) - return byImsi; - return apnForIccid(iccid); -} - -void QuectelEC25::publishApn(const std::string &apn) const { - ::mkdir("/run/ctt", 0755); // ignore EEXIST; relevant only for the default path - int fd = ::open(apn_file_.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); - if (fd < 0) { - std::fprintf(stderr, "ctt-modem-provision: could not write %s (non-fatal)\n", - apn_file_.c_str()); - return; - } - std::string line = apn + "\n"; - if (::write(fd, line.data(), line.size()) < 0) - std::fprintf(stderr, "ctt-modem-provision: short write to %s (non-fatal)\n", - apn_file_.c_str()); - ::close(fd); -} - ProvisionResult QuectelEC25::provision(bool dry_run) { - // IMSI first (names the subscription's home network, which decides the APN), - // ICCID second (names only the issuer's numbering range). See chooseApn(). - const std::string imsi = parseImsi(at_.cmd(at::kCimi, 3000)); - const std::string iccid = parseIccid(at_.cmd(at::kQccid, 3000)); - const std::string apn = chooseApn(imsi, iccid); - if (apn.empty()) { - std::fprintf(stderr, - "ctt-modem-provision: Quectel — no usable IMSI or ICCID (IMSI " - "'%s', ICCID '%s') — leaving APN untouched\n", - imsi.c_str(), iccid.c_str()); - return ProvisionResult::Done; // fail open — never guess an APN - } - const std::string plmn = imsi.size() >= 5 ? imsi.substr(0, 5) : "(none)"; - const std::string cc = iccid.size() >= 4 ? iccid.substr(2, 2) : "(none)"; - const char *source = apnForImsi(imsi).empty() ? "ICCID cc" : "IMSI PLMN"; - std::fprintf(stderr, - "ctt-modem-provision: Quectel — IMSI PLMN %s / ICCID cc %s -> APN " - "'%s' (by %s)\n", - plmn.c_str(), cc.c_str(), apn.c_str(), source); - - std::string cg = at_.cmd(at::kCgdcontQuery, 3000); - if (cg.find("+CGDCONT:") == std::string::npos) { - std::fprintf(stderr, - "ctt-modem-provision: no +CGDCONT response ('%s') — leaving " - "modem untouched\n", - flattenReply(cg).c_str()); - return ProvisionResult::Done; // fail open - } - // Rewrite only a non-empty WRONG CID1 APN — that divergence (attach APN != - // dial APN) is what triggers cause-55. Two cases are left untouched (no radio - // bounce), and both still publish the dial APN so the NM side is set: - // - already the desired APN, or - // - BLANK: a blank CID1 attaches on the network-default APN (bench-verified: - // connects fine). The proven failure mode is a non-empty WRONG APN, not a - // blank one, so we don't churn the working fleet of blank-CID1 stations. - std::string current = parseCgdcontApn(cg, 1); - std::fprintf(stderr, - "ctt-modem-provision: Quectel — CGDCONT CID1 APN '%s' (want '%s')\n", - current.empty() ? "(blank)" : current.c_str(), apn.c_str()); - - if (current == apn) { - std::fprintf(stderr, "ctt-modem-provision: Quectel — attach APN already " - "correct; no NV write\n"); - publishApn(apn); // still publish so the NM side has the source of truth - return ProvisionResult::Done; - } - if (current.empty()) { - std::fprintf(stderr, "ctt-modem-provision: Quectel — CGDCONT CID1 blank " - "(network-default APN); leaving attach context, " - "publishing dial APN '%s'\n", - apn.c_str()); - publishApn(apn); - return ProvisionResult::Done; - } - - if (dry_run) { - std::fprintf(stderr, - "ctt-modem-provision: --dry-run — would write CFUN=0 / " - "CGDCONT=1,\"%s\",\"%s\" / CFUN=1, then publish %s\n", - kPdpType, apn.c_str(), apn_file_.c_str()); - return ProvisionResult::Done; - } - - // A CGDCONT change is rejected on an already-activated context (AT Commands - // Manual V2.0 §10.2: "not allowed to change the definition of an already - // activated context"), so detach first; the change then takes effect at the - // next attach. Run Before=ModemManager, this radio bounce is uncontended. - std::fprintf(stderr, "ctt-modem-provision: Quectel — setting attach APN " - "(CFUN=0 / CGDCONT CID1 / CFUN=1)\n"); - at_.cmd(at::kCfunOff, 5000); - std::string w = at_.cmd(at::cgdcontDefine(1, kPdpType, apn), 5000); - if (w.find("OK") == std::string::npos) { - std::fprintf(stderr, - "ctt-modem-provision: Quectel — CGDCONT write not confirmed " - "('%s'); re-attaching and leaving APN as-is\n", - flattenReply(w).c_str()); - at_.cmd(at::kCfunOn, 5000); // restore the radio even on failure - return ProvisionResult::Done; // fail open - } - at_.cmd(at::kCfunOn, 5000); - std::fprintf(stderr, - "ctt-modem-provision: Quectel — attach APN set to '%s'; radio " - "re-attaching (MM brings the wwan0 bearer up)\n", - apn.c_str()); - publishApn(apn); - return ProvisionResult::Done; + // The Quectel is QMI-managed and needs no ECM/USB-composition work — its entire + // job is the shared attach-APN heal (CGDCONT CID1 matched to the SIM). + return provisionAttachApn(dry_run); } } // namespace ctthw diff --git a/native/lib/ctthw/modem/quectel_ec25.h b/native/lib/ctthw/modem/quectel_ec25.h index cdbae9e..f42c3e0 100644 --- a/native/lib/ctthw/modem/quectel_ec25.h +++ b/native/lib/ctthw/modem/quectel_ec25.h @@ -1,7 +1,6 @@ #pragma once -#include - +#include "modem/at_commands.h" #include "modem/modem.h" namespace ctthw { @@ -10,77 +9,22 @@ namespace ctthw { // diverge from the NetworkManager dial APN (the 3GPP cause-55 trap that stranded // the Belgium station). The Quectel is QMI-managed (ModemManager drives it over // cdc-wdm0/wwan0; its tty ports are ID_MM_DEVICE_IGNORE=1, so the AT port is ours), -// and — unlike the Telit — it needs no ECM/USB-composition work at all. -// -// It identifies the SIM's carrier and, if CGDCONT CID1 carries a *non-empty wrong* -// APN, sets it and bounces the radio (CFUN=0/1) so the change takes effect at the -// next attach. Carrier identity comes from the **IMSI** (AT+CIMI) first, falling -// back to the ICCID country code (AT+QCCID) — see chooseApn(). A blank CID1 is -// left alone (it attaches on the network-default APN — bench-verified to connect; -// the failure mode is a non-empty wrong APN, not a blank one), so the working -// fleet is not churned. The chosen APN is written to /run/ctt/modem-apn — the -// single source provision-modem-apn.sh reads to set the NM dial APN, so attach and -// dial stay identical by construction. +// and — unlike the Telit — it needs no ECM/USB-composition work at all, so its +// whole provision() is the shared attach-APN heal (Modem::provisionAttachApn): +// identify the SIM's carrier, and if CGDCONT CID1 carries a non-empty wrong APN, +// set it and bounce the radio so the change takes at the next attach. // // Reference: reference/chipsets/modem/quectel-ec25-af/ (KB). Idempotent + fail-open. class QuectelEC25 : public Modem { public: - static constexpr const char *kDefaultApnFile = "/run/ctt/modem-apn"; - // Per-carrier APN. Selected from the IMSI's home PLMN when it is recognized, - // else from the ICCID country code (ICCID[2:4]) — see chooseApn(). - static constexpr const char *kApnTelenor = "internet.cxn"; // Telenor Connexion - static constexpr const char *kApnDefault = "super"; // else (Super SIM) - // PDP type for the attach context. "IP" (IPv4) per the documented practice - // (AT Commands Manual V2.0 §10.2 lists IP/IPV6/IPV4V6; every practitioner guide - // uses IP for the data context) — and these M2M SIMs are IPv4-only, with the NM - // profile already forcing v4 via ipv6.method=disabled. Avoids an IPv6 PDN attempt - // the network refuses. - static constexpr const char *kPdpType = "IP"; - - explicit QuectelEC25(AtTransport &at, std::string apn_file = kDefaultApnFile) - : Modem(at), apn_file_(std::move(apn_file)) {} + using Modem::Modem; const char *name() const override { return "Quectel EC25"; } ProvisionResult provision(bool dry_run) override; - // --- Pure helpers (no I/O; unit-tested against real fixtures) --- - - // Digits following "+QCCID:" — the SIM ICCID. - static std::string parseIccid(const std::string &qccidResp); - // Digits of an "AT+CIMI" reply — the SIM IMSI. CIMI answers with the bare IMSI - // (no "+CIMI:" prefix), so this takes the first run of >= 14 digits. - static std::string parseImsi(const std::string &cimiResp); - // APN for the CGDCONT context at in an "AT+CGDCONT?" reply (the 2nd quoted - // field on that line), or "" if the context is not defined. - static std::string parseCgdcontApn(const std::string &resp, int cid); - // True if the IMSI's MCC+MNC is a known Telenor (Connexion) home PLMN. - static bool isTelenorImsi(const std::string &imsi); - // True if the ICCID starts with a known Telenor *issuer* prefix (8946 or - // 890124008). Prefix, not the 2-digit country code — see the .cpp for why bare - // "8901" is unsafe (collides with 964 Kore SIMs; fleet-validated 2026-07-30). - static bool isTelenorIccid(const std::string &iccid); - // Carrier APN from the IMSI's home PLMN, or "" when the PLMN is not recognized - // (so the caller can fall back to the ICCID rule). Never guesses a default. - static std::string apnForImsi(const std::string &imsi); - // Carrier APN from the ICCID issuer prefix (isTelenorIccid); "" if too short. - static std::string apnForIccid(const std::string &iccid); - // The APN to use: IMSI first, ICCID second, "" if neither is usable. - // - // The IMSI is authoritative because it names the *subscription's* home network, - // which is what determines the APN the carrier will accept. The ICCID only names - // the issuer's numbering range: Telenor ships SIMs in an 8901 (US-numbered) range - // whose ICCID country code reads "01", so the ICCID-only rule selected `super` on - // a Telenor subscription and the network refused the bearer with 3GPP cause 33 - // (option-unsubscribed). Kept as a fallback for modems/SIMs that won't report an - // IMSI. See investigations/ (V2 station F5C51E6B6AFA, 2026-07-29). - static std::string chooseApn(const std::string &imsi, const std::string &iccid); - -private: - // Publish the chosen APN for provision-modem-apn.sh. Best-effort (a failure is - // logged, never fatal — the shell script falls back to its own mmcli mapping). - void publishApn(const std::string &apn) const; - - std::string apn_file_; +protected: + // The Quectel reports its ICCID via AT+QCCID. + const char *iccidQueryCmd() const override { return at::quectel::kCcid; } }; } // namespace ctthw diff --git a/native/lib/ctthw/modem/telit_le910q1.cpp b/native/lib/ctthw/modem/telit_le910q1.cpp index d581eba..eb400de 100644 --- a/native/lib/ctthw/modem/telit_le910q1.cpp +++ b/native/lib/ctthw/modem/telit_le910q1.cpp @@ -36,7 +36,7 @@ bool TelitLE910Q1::parseEcmBound(const std::string &resp) { ProvisionResult TelitLE910Q1::provision(bool dry_run) { // Stage 1: ensure the ECM USB composition (AT#USBCFG=1). - std::string u = at_.cmd(at::kUsbcfgQuery, 3000); + std::string u = at_.cmd(at::telit::kUsbcfgQuery, 3000); int mode = parseUsbcfg(u); if (mode < 0) { std::fprintf(stderr, @@ -50,10 +50,10 @@ ProvisionResult TelitLE910Q1::provision(bool dry_run) { flattenReply(u).c_str()); if (dry_run) { std::fprintf(stderr, "ctt-modem-provision: --dry-run — would write " - "AT#USBCFG=1 then AT#REBOOT (then bind ECM)\n"); + "AT#USBCFG=1 then AT#REBOOT (then bind ECM + set APN)\n"); return ProvisionResult::Done; // dry-run never reboots } - std::string w = at_.cmd(at::kUsbcfgEcm, 5000); + std::string w = at_.cmd(at::telit::kUsbcfgEcm, 5000); if (w.find("OK") == std::string::npos) { std::fprintf(stderr, "ctt-modem-provision: USBCFG write not confirmed ('%s') — " @@ -63,16 +63,16 @@ ProvisionResult TelitLE910Q1::provision(bool dry_run) { } std::fprintf(stderr, "ctt-modem-provision: switching to ECM composition; " "rebooting modem (AT#REBOOT)\n"); - at_.cmd(at::kReboot, 5000); + at_.cmd(at::telit::kReboot, 5000); std::fprintf(stderr, "ctt-modem-provision: modem rebooting into ECM " - "(1bc7:7021); will bind once it re-enumerates\n"); + "(1bc7:7021); will bind + set APN once it re-enumerates\n"); // The executable reopens the re-enumerated AT port and runs us again -> Stage 2, // so the bind lands in THIS boot (before ModemManager) rather than the next one. return ProvisionResult::RebootedRetry; } // Stage 2: ensure the ECM session is bound (AT#ECM=1,0). - std::string e = at_.cmd(at::kEcmQuery, 3000); + std::string e = at_.cmd(at::telit::kEcmQuery, 3000); if (e.find("#ECM:") == std::string::npos) { std::fprintf(stderr, "ctt-modem-provision: no #ECM response ('%s') — leaving modem " @@ -87,26 +87,29 @@ ProvisionResult TelitLE910Q1::provision(bool dry_run) { bound ? "bound (provisioned)" : "UNBOUND (needs binding)", flattenReply(e).c_str()); - if (bound) - return ProvisionResult::Done; // happy path: read-only, never touch a healthy modem's NV - - if (dry_run) { - std::fprintf(stderr, "ctt-modem-provision: --dry-run — would write " - "AT#ECM=1,0\n"); - return ProvisionResult::Done; + if (!bound) { + if (dry_run) { + std::fprintf(stderr, "ctt-modem-provision: --dry-run — would write " + "AT#ECM=1,0\n"); + // fall through to the (also dry-run) attach-APN stage + } else { + std::fprintf(stderr, "ctt-modem-provision: binding ECM to PDP context 1 " + "(AT#ECM=1,0)\n"); + std::string w = at_.cmd(at::telit::kEcmBind, 5000); + if (w.find("OK") == std::string::npos) { + std::fprintf(stderr, + "ctt-modem-provision: ECM bind not confirmed ('%s')\n", + flattenReply(w).c_str()); + return ProvisionResult::Done; // fail open — can't bind, don't touch the APN + } + std::fprintf(stderr, "ctt-modem-provision: ECM bound\n"); + } } - std::fprintf(stderr, "ctt-modem-provision: binding ECM to PDP context 1 " - "(AT#ECM=1,0)\n"); - std::string w = at_.cmd(at::kEcmBind, 5000); - if (w.find("OK") == std::string::npos) { - std::fprintf(stderr, "ctt-modem-provision: ECM bind not confirmed ('%s')\n", - flattenReply(w).c_str()); - return ProvisionResult::Done; // fail open - } - std::fprintf(stderr, "ctt-modem-provision: ECM bound (mdm0 will carry data " - "once ModemManager/NM bring it up)\n"); - return ProvisionResult::Done; + // Stage 3: heal the attach context (CGDCONT CID1) to the SIM's APN — shared with + // the Quectel. The ECM PDN dials CID1, so an ECM-bound-but-wrong-APN Telit (a + // recycled modem carrying a stale context) stays dead until this rewrites it. + return provisionAttachApn(dry_run); } } // namespace ctthw diff --git a/native/lib/ctthw/modem/telit_le910q1.h b/native/lib/ctthw/modem/telit_le910q1.h index a43b69d..d8ea4c2 100644 --- a/native/lib/ctthw/modem/telit_le910q1.h +++ b/native/lib/ctthw/modem/telit_le910q1.h @@ -2,18 +2,25 @@ #include +#include "modem/at_commands.h" #include "modem/modem.h" namespace ctthw { -// TelitLE910Q1 — provisions the Telit CDC-ECM data path. Two durable NV settings -// make the mdm0 (cdc_ether NAT) interface work: +// TelitLE910Q1 — provisions the Telit CDC-ECM data path. Three durable NV settings +// make the mdm0 (cdc_ether NAT) interface carry data: // 1. USB composition = ECM: AT#USBCFG must read 1 (ECM, 1bc7:7021), not 0 (RNDIS, // 1bc7:7020). Switching it re-enumerates the modem (a reboot), so we do it // first and return RebootedRetry; the executable waits for the modem to come // back as ECM, reopens the port, and re-runs — so the bind (2) completes in // the SAME boot rather than waiting for the next one. // 2. ECM session bound to a PDP context: AT#ECM must read "x,1" (bound), not "x,0". +// 3. The attach context (CGDCONT CID1) matches the SIM. The ECM PDN dials CID1, +// so a recycled Telit carrying a stale APN from a prior deployment (e.g. +// internet.cxn baked in, now with a Kore SIM) binds ECM to the wrong APN and +// the PDN stays dead through every reboot (bench-proven 2026-07-31). After the +// ECM stages this runs the shared Modem::provisionAttachApn heal — the mirror +// of the Quectel attach-context fix. // // Reference: reference/chipsets/modem/telit-le910q1/ (KB). Idempotent + fail-open. class TelitLE910Q1 : public Modem { @@ -29,6 +36,10 @@ class TelitLE910Q1 : public Modem { static int parseUsbcfg(const std::string &resp); // "#ECM: ," -> bound iff the second field (PDP context id) is non-zero. static bool parseEcmBound(const std::string &resp); + +protected: + // The Telit reports its ICCID via AT#CCID. + const char *iccidQueryCmd() const override { return at::telit::kCcid; } }; } // namespace ctthw diff --git a/native/src/ctt-modem-provision/VERSION b/native/src/ctt-modem-provision/VERSION index 9fc80f9..1c09c74 100644 --- a/native/src/ctt-modem-provision/VERSION +++ b/native/src/ctt-modem-provision/VERSION @@ -1 +1 @@ -0.3.2 \ No newline at end of file +0.3.3 diff --git a/native/test/test-modem-provision.cpp b/native/test/test-modem-provision.cpp index 4d4de7d..4065e4e 100644 --- a/native/test/test-modem-provision.cpp +++ b/native/test/test-modem-provision.cpp @@ -68,6 +68,11 @@ void test_telit_parsers() { CHECK(TelitLE910Q1::parseEcmBound("\r\n#ECM: 0,1\r\n\r\nOK\r\n") == true); CHECK(TelitLE910Q1::parseEcmBound("\r\n#ECM: 0,0\r\n\r\nOK\r\n") == false); CHECK(TelitLE910Q1::parseEcmBound("\r\nERROR\r\n") == false); + // ICCID: the shared parser reads the Telit "#CCID: " form too (not just + // the Quectel "+QCCID:"), via the inherited generic digit-run scan. + CHECK(TelitLE910Q1::parseIccid("\r\n#CCID: 89883070000067330512\r\n\r\nOK\r\n") == + "89883070000067330512"); + CHECK(TelitLE910Q1::parseIccid("\r\nERROR\r\n").empty()); } // ---- Telit provision: one-boot ECM convergence --------------------------------- @@ -114,6 +119,91 @@ void test_telit_provision() { } } +// ---- Telit provision: recycled/stale attach context heal ----------------------- +// The bug this fix exists for: a Telit is perfectly ECM-bound, but CGDCONT CID1 +// carries a WRONG APN it kept in NV from a prior deployment. The ECM PDN dials CID1, +// so the modem enumerates, mdm0 gets its lease, and the WAN stays dead through every +// reboot. Bench-proven on 10.1.94.239 (2026-07-31): CID1="internet.cxn" + Kore Super +// SIM -> 100% loss; the 2.3.2 provisioner reported "provisioned" and never touched +// CGDCONT. Stage 3 now mirrors the Quectel heal. +void test_telit_provision_recycled_context() { + using ctthw::ProvisionResult; + + // (a) Recycled Telenor context, now a Kore Super SIM: heal CID1 -> super. + { + const std::string apnFile = "/tmp/ctt-test-telit-recycled-super"; + std::remove(apnFile.c_str()); + ScriptedAt at; + at.replies["AT#USBCFG?"] = "\r\n#USBCFG: 1\r\n\r\nOK\r\n"; + at.replies["AT#ECM?"] = "\r\n#ECM: 0,1\r\n\r\nOK\r\n"; // already bound + at.replies["AT#CCID"] = "\r\n#CCID: 89883070000067330512\r\n\r\nOK\r\n"; // Kore Super + at.replies["AT+CGDCONT?"] = // stale Telenor APN baked in from a prior deployment + "\r\n+CGDCONT: 1,\"IP\",\"internet.cxn\",\"0.0.0.0\",0,0\r\n\r\nOK\r\n"; + TelitLE910Q1 t(at, apnFile); + CHECK(t.provision(/*dry_run=*/false) == ProvisionResult::Done); + CHECK(!at.issued("AT#ECM=1,0")); // already bound: no rebind + CHECK(at.issued("AT+CFUN=0")); // detach… + CHECK(at.issued("AT+CGDCONT=1,\"IP\",\"super\"")); // …rewrite CID1… + CHECK(at.issued("AT+CFUN=1")); // …re-attach + CHECK(readFile(apnFile) == "super"); // and publish for NM + std::remove(apnFile.c_str()); + } + + // (b) Recycled the other way: Kore context, now a US Telenor SIM (IMSI decides) -> + // heal CID1 -> internet.cxn. + { + const std::string apnFile = "/tmp/ctt-test-telit-recycled-cxn"; + std::remove(apnFile.c_str()); + ScriptedAt at; + at.replies["AT#USBCFG?"] = "\r\n#USBCFG: 1\r\n\r\nOK\r\n"; + at.replies["AT#ECM?"] = "\r\n#ECM: 0,1\r\n\r\nOK\r\n"; + at.replies["AT+CIMI"] = "\r\n240080008862744\r\n\r\nOK\r\n"; // Telenor PLMN + at.replies["AT#CCID"] = "\r\n#CCID: 89012400800088627441\r\n\r\nOK\r\n"; // US Telenor + at.replies["AT+CGDCONT?"] = + "\r\n+CGDCONT: 1,\"IP\",\"super\",\"0.0.0.0\",0,0\r\n\r\nOK\r\n"; // stale Kore + TelitLE910Q1 t(at, apnFile); + t.provision(/*dry_run=*/false); + CHECK(at.issued("AT+CGDCONT=1,\"IP\",\"internet.cxn\"")); + CHECK(readFile(apnFile) == "internet.cxn"); + std::remove(apnFile.c_str()); + } + + // (c) Correct context already: no radio bounce, still publishes the dial APN. + { + const std::string apnFile = "/tmp/ctt-test-telit-ok"; + std::remove(apnFile.c_str()); + ScriptedAt at; + at.replies["AT#USBCFG?"] = "\r\n#USBCFG: 1\r\n\r\nOK\r\n"; + at.replies["AT#ECM?"] = "\r\n#ECM: 0,1\r\n\r\nOK\r\n"; + at.replies["AT#CCID"] = "\r\n#CCID: 89883070000067330512\r\n\r\nOK\r\n"; // Kore Super + at.replies["AT+CGDCONT?"] = + "\r\n+CGDCONT: 1,\"IP\",\"super\",\"0.0.0.0\",0,0\r\n\r\nOK\r\n"; // already right + TelitLE910Q1 t(at, apnFile); + t.provision(/*dry_run=*/false); + CHECK(!at.issued("AT+CFUN=0")); // no bounce + CHECK(readFile(apnFile) == "super"); + std::remove(apnFile.c_str()); + } + + // (d) dry-run on a wrong context: reports intent, writes nothing. + { + const std::string apnFile = "/tmp/ctt-test-telit-dry"; + std::remove(apnFile.c_str()); + ScriptedAt at; + at.replies["AT#USBCFG?"] = "\r\n#USBCFG: 1\r\n\r\nOK\r\n"; + at.replies["AT#ECM?"] = "\r\n#ECM: 0,1\r\n\r\nOK\r\n"; + at.replies["AT#CCID"] = "\r\n#CCID: 89883070000067330512\r\n\r\nOK\r\n"; + at.replies["AT+CGDCONT?"] = + "\r\n+CGDCONT: 1,\"IP\",\"internet.cxn\",\"0.0.0.0\",0,0\r\n\r\nOK\r\n"; + TelitLE910Q1 t(at, apnFile); + t.provision(/*dry_run=*/true); + CHECK(!at.issued("AT+CFUN=0")); + CHECK(!at.issued("AT+CGDCONT=1,\"IP\",\"super\"")); + CHECK(std::ifstream(apnFile).good() == false); + std::remove(apnFile.c_str()); + } +} + // ---- Quectel parsers / mapping -------------------------------------------------- void test_quectel_parsers() { // ICCID: digits after "+QCCID:". cc = digits [2:4]; "46" -> Telenor. @@ -328,6 +418,7 @@ void test_quectel_provision_dryrun() { int main() { test_telit_parsers(); test_telit_provision(); + test_telit_provision_recycled_context(); test_quectel_parsers(); test_dispatch(); test_quectel_provision_divergence();