Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions native/lib/ctthw/modem/at_commands.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<Area><Action>.
// Naming: k<Area><Action>.

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 <cid>: AT+CGDCONT=<cid>,"<pdp_type>","<apn>". Parameterized,
// so the wire format is built in exactly one place.
Expand All @@ -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: <iccid>")
} // namespace quectel

// --- Telit vendor extensions (#…) ---
namespace telit {
inline constexpr const char *kCcid = "AT#CCID"; // ICCID ("#CCID: <iccid>")
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
251 changes: 251 additions & 0 deletions native/lib/ctthw/modem/modem.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
#include <cstdio>
#include <string>

#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>

#include "modem/at_commands.h"
#include "modem/quectel_ec25.h"
#include "modem/telit_le910q1.h"
Expand All @@ -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<unsigned char>(cimiResp[i]))) {
++i;
continue;
}
size_t j = i;
while (j < cimiResp.size() &&
std::isdigit(static_cast<unsigned char>(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: <iccid>" (Quectel) and "#CCID: <iccid>" (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<unsigned char>(ccidResp[i]))) {
++i;
continue;
}
size_t j = i;
while (j < ccidResp.size() &&
std::isdigit(static_cast<unsigned char>(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: <cid>,"<PDP_type>","<APN>","<addr>",...
// -> 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)
Expand Down
Loading
Loading