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