From 600483c35b3528ef37c306c85c5cf10718f89e25 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 10 May 2026 11:32:13 +0200 Subject: [PATCH] modem: route NMEA from cellular modems to gpsd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cellular modems (Quectel EM05/EM06/EM12, Sierra EM7565, ...) expose GPS data as NMEA on a dedicated USB serial interface. Until now we relied on ModemManager's --location-enable-gps-nmea, which makes MM hold the NMEA port and parse the stream itself — useful for mmcli but a dead end for the rest of the system. The result was that modem GPS was visible only via 'show modem' and could not act as an NTP fallback time source on devices without dedicated GPS hardware. This commit reroutes the NMEA stream into the existing gpsd → chronyd pipeline so cellular modems behave just like any other NMEA-over-USB GPS dongle. No changes to gpsd, chronyd, or the ietf-hardware:gps component — the user activates it the same way as for a dedicated receiver, by adding a 'gps' hardware component that references /dev/gpsN. This is Phase 2 of the modem GPS / location integration plan (.notes/modem-gps-plan.md). udev (src/modemd/77-mm-modem-gps.rules): - For known vendor:product:interface tuples, set ID_MM_PORT_IGNORE so ModemManager keeps off the NMEA port, and add SYMLINK+="gps%n" so gpsd's existing udev hook picks up the device automatically. - Interface-number match uses ENV{ID_USB_INTERFACE_NUM} rather than ATTRS{bInterfaceNumber}: udev requires all ATTRS{} matches in a rule to share one parent, but idVendor/idProduct live on the USB device while bInterfaceNumber lives on the USB interface. ENV{} matching has no parent constraint and udev already populates ID_USB_INTERFACE_NUM from bInterfaceNumber. - Initial entries: Quectel EM05 (2c7c:0125), EM06-E (2c7c:0306), EM12-G (2c7c:0512), Sierra Wireless EM7565 (1199:9091). Easy to extend. modemd (modemd/__init__.py): - GPS_AT_COMMANDS table maps manufacturer -> (enable, disable) AT command pair. prepare_location() issues the vendor AT command via 'mmcli --command=AT+QGPS=1' (or AT+CGPS=1 for Sierra) instead of --location-enable-gps-{nmea,raw}. Vendor lookup is substring-based ('Quectel' matches both 'Quectel' and 'Quectel Incorporated') because ModemManager normalises differently across firmware revisions. For unrecognised vendors the high-level MM path is still used as a fallback, so nothing regresses for hardware not yet in the table. - The AT path runs based on the udev/vendor table, not on MM's --location-status capabilities. Setting ID_MM_PORT_IGNORE on the NMEA port removes 'gps' from MM's capability list even though the GPS hardware is still there, so a capability gate would mistakenly skip GPS for the very modems we're targeting. - _location_capabilities() filters MM-managed sources so a single unsupported flag (e.g. CDMA on an LTE-only modem) doesn't fail the whole batched call. Sources the modem doesn't support are silently skipped; a warning is logged only if the user's YANG config explicitly lists one. - Subprocess fan-out collapsed: agps-msa, agps-msb, 3gpp and cdma flags share a single batched mmcli invocation. prepare_location() drops from 5 subprocess calls to 1 (unknown vendor) or 2 (known vendor, AT + batched MM). - Stringly-typed if/elif source dispatch replaced with a dict-driven table (LOCATION_MM_FLAGS). - Two helpers eliminate the ['mmcli', '-m', self.path, ...] boilerplate at ~30 ModemThread call sites: self._mmcli(*args, check=True) # runcmd wrapper self._mmclij(*args) # runcmdj wrapper build (package/feature-modem/Config.in): - Select BR2_PACKAGE_MODEM_MANAGER_ATVIADBUS so ModemManager accepts raw AT commands over D-Bus without being started in --debug mode. Required by the AT path above; without it 'mmcli --command' is rejected with MM_CORE_ERROR_UNAUTHORIZED. Signed-off-by: Joachim Wiberg --- package/feature-modem/Config.in | 6 + package/modemd/modemd.mk | 1 + src/modemd/77-mm-modem-gps.rules | 41 ++++++ src/modemd/modemd/__init__.py | 219 ++++++++++++++++++++----------- 4 files changed, 188 insertions(+), 79 deletions(-) create mode 100644 src/modemd/77-mm-modem-gps.rules diff --git a/package/feature-modem/Config.in b/package/feature-modem/Config.in index ed888950..f198b888 100644 --- a/package/feature-modem/Config.in +++ b/package/feature-modem/Config.in @@ -2,6 +2,7 @@ config BR2_PACKAGE_FEATURE_MODEM bool "Feature Modem" select BR2_PACKAGE_MODEMD select BR2_PACKAGE_MODEM_MANAGER + select BR2_PACKAGE_MODEM_MANAGER_ATVIADBUS select BR2_PACKAGE_MODEM_MANAGER_LIBMBIM select BR2_PACKAGE_MODEM_MANAGER_LIBQMI select BR2_PACKAGE_MODEM_MANAGER_LIBQRTR @@ -10,6 +11,11 @@ config BR2_PACKAGE_FEATURE_MODEM the modemd management daemon. Includes drivers for common USB option modems and QMI/MBIM-based devices. + ATVIADBUS allows raw AT commands over D-Bus (used by modemd to + enable GPS NMEA output via vendor-specific AT commands such as + AT+QGPS=1) without needing to start ModemManager in --debug + mode. + config BR2_PACKAGE_FEATURE_MODEM_QUALCOMM bool "Qualcomm-based modems (QMI/QRTR/MHI)" depends on BR2_PACKAGE_FEATURE_MODEM diff --git a/package/modemd/modemd.mk b/package/modemd/modemd.mk index afe36c13..5cde00e7 100644 --- a/package/modemd/modemd.mk +++ b/package/modemd/modemd.mk @@ -47,6 +47,7 @@ define MODEMD_INSTALL_TARGET_CMDS install -m 644 $(MODEMD_DIR)/modemd.rules $(TARGET_DIR)/lib/udev/rules.d/90-modemd.rules install -m 644 $(MODEMD_DIR)/qmi-wwan-ids.rules $(TARGET_DIR)/lib/udev/rules.d/91-qmi-wwan-ids.rules install -m 644 $(MODEMD_DIR)/77-mm-dell-port-types.rules $(TARGET_DIR)/etc/udev/rules.d/77-mm-dell-port-types.rules + install -m 644 $(MODEMD_DIR)/77-mm-modem-gps.rules $(TARGET_DIR)/etc/udev/rules.d/77-mm-modem-gps.rules install -D -m 644 $(MODEMD_DIR)/modemd.modules-load $(TARGET_DIR)/etc/modules-load.d/modemd.conf install -m 755 $(MODEMD_DIR)/modem-command $(TARGET_DIR)/sbin/modem-command ln -sf /usr/libexec/modemd/modemd $(TARGET_DIR)/sbin/modemd diff --git a/src/modemd/77-mm-modem-gps.rules b/src/modemd/77-mm-modem-gps.rules new file mode 100644 index 00000000..995e3b12 --- /dev/null +++ b/src/modemd/77-mm-modem-gps.rules @@ -0,0 +1,41 @@ +# Cellular modem NMEA port routing +# +# For modems whose GPS NMEA stream is available on a dedicated USB +# interface, expose that TTY as /dev/gpsN so the existing gpsd → chronyd +# pipeline can consume it like any other NMEA USB GPS receiver. Setting +# ID_MM_PORT_IGNORE=1 keeps ModemManager from claiming the NMEA port for +# itself; modemd issues the vendor-specific AT command to enable GPS +# output (see prepare_location() in modemd). +# +# When adding a new modem entry, find the NMEA interface number with +# 'mmcli -m N' or 'lsusb -v' and add a rule below. + +ACTION!="add|change|move|bind", GOTO="modem_gps_end" + +# Note: bInterfaceNumber lives on the USB interface device while +# idVendor/idProduct live on the USB device — udev's ATTRS{} requires +# all matches in one rule to share a parent, so we match the interface +# number via ENV{ID_USB_INTERFACE_NUM}, which udev populates from +# bInterfaceNumber and which has no same-parent constraint. + +# Quectel EM05 — NMEA on interface 1 +SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0125", \ + ENV{ID_USB_INTERFACE_NUM}=="01", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +# Quectel EM06-E / EM06-A — NMEA on interface 1 +SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0306", \ + ENV{ID_USB_INTERFACE_NUM}=="01", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +# Quectel EM12-G — NMEA on interface 1 +SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0512", \ + ENV{ID_USB_INTERFACE_NUM}=="01", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +# Sierra Wireless EM7565 — NMEA on interface 2 +SUBSYSTEM=="tty", ATTRS{idVendor}=="1199", ATTRS{idProduct}=="9091", \ + ENV{ID_USB_INTERFACE_NUM}=="02", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +LABEL="modem_gps_end" diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py index 359d571f..f528cd79 100644 --- a/src/modemd/modemd/__init__.py +++ b/src/modemd/modemd/__init__.py @@ -22,6 +22,27 @@ debug = False threads = [] reload_event = threading.Event() +# Vendor-specific AT commands to enable / disable raw NMEA output on the +# modem's dedicated GPS port. Used when the udev rules in +# 77-mm-modem-gps.rules have set ID_MM_PORT_IGNORE on that port — gpsd +# then reads NMEA directly from /dev/gpsN. For unrecognised vendors, +# prepare_location() falls back to ModemManager's high-level options +# (which keeps MM in charge of the NMEA port). Keys match the +# manufacturer string ModemManager reports verbatim. +GPS_AT_COMMANDS = { + "Quectel": ("AT+QGPS=1", "AT+QGPS=0"), + "Sierra Wireless": ("AT+CGPS=1", "AT+CGPS=0"), +} + +# ModemManager --location-{enable,disable}-* flag suffixes per source. +# 'gps' is handled separately because known vendors take the raw AT path. +LOCATION_MM_FLAGS = { + "agps-msa": ("agps-msa",), + "agps-msb": ("agps-msb",), + "3gpp": ("3gpp",), + "cdma": ("cdma-bs",), +} + syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) class Trigger(enum.Enum): @@ -515,6 +536,12 @@ class ModemThread(threading.Thread): def fatal(self, msg): fatal("%s%s" % (self.prefix, msg)) + def _mmcli(self, *args, check=True): + return runcmd(["mmcli", "-m", self.path, *args], check=check) + + def _mmclij(self, *args): + return runcmdj(["mmcli", "-J", "-m", self.path, *args]) + def init(self): self.timeout = 1 self.path = None @@ -628,12 +655,10 @@ class ModemThread(threading.Thread): self.pulldown() wait = 0 - if runcmd(["mmcli", "-m", self.path, - "--reset"]): + if self._mmcli("--reset"): self.info("Performed a normal reset") wait = 5 - elif runcmd(["mmcli", "-m", self.path, - "--factory-reset=000000"]): + elif self._mmcli("--factory-reset=000000"): self.info("Performed a factory reset") wait = 10 else: @@ -667,15 +692,14 @@ class ModemThread(threading.Thread): if not self.update(): return bearers for path in self.status.get("bearers", []): - output = runcmdj(["mmcli", "-J", "-m", self.path, "-b", path]) + output = self._mmclij("-b", path) if output and "bearer" in output: bearers.append(output["bearer"]) return bearers def get_profiles(self): profiles = [] - output = runcmdj(["mmcli", "-J", "-m", self.path, - "--3gpp-profile-manager-list"]) + output = self._mmclij("--3gpp-profile-manager-list") if not output: return profiles for entry in output["modem"]["3gpp"]["profile-manager"]["list"]: @@ -695,7 +719,7 @@ class ModemThread(threading.Thread): def update(self): if not self.path: return False - output = runcmdj(["mmcli", "-J", "-m", self.path]) + output = self._mmclij() if output: output = output.get("modem", {}) output = output.get("generic", None) @@ -888,14 +912,14 @@ class ModemThread(threading.Thread): def poweron(self): if self.status.get("power-state", "") == "off": self.info("Powering on") - if not runcmd(["mmcli", "-m", self.path, "--set-power-state-on"]): + if not self._mmcli("--set-power-state-on"): self.err("Unable to power on") return False return True def disable(self): self.info("Disabling") - if not runcmd(["mmcli", "-m", self.path, "--disable"]): + if not self._mmcli("--disable"): self.err("Unable to disable") return False else: @@ -908,14 +932,13 @@ class ModemThread(threading.Thread): if not self.path: return False if state == "unknown" or state == "disabled": - if not runcmd(["mmcli", "-m", self.path, "--enable"]): + if not self._mmcli("--enable"): self.err("Unable to enable") return False return True def unlock(self): - cmd = ["mmcli", "-m", self.path, "--sim=%s" % - self.status.get("sim", "0")] + args = ["--sim=%s" % self.status.get("sim", "0")] if self.status.get("unlock-required", "") == "sim-pin": self.info("Unlocking with PIN") @@ -923,20 +946,20 @@ class ModemThread(threading.Thread): if not pin: self.err("No PIN defined") return False - cmd.append("--pin=%s" % pin) + args.append("--pin=%s" % pin) elif self.status.get("unlock-required", "") == "sim-puk": self.info("Unlocking with PUK") puk = self.cfg.get("puk") if not puk: self.err("No PUK defined") return False - cmd.append("--puk=%s" % puk) + args.append("--puk=%s" % puk) else: self.err("Unsupported lock") return False self.disable() - if not runcmd(cmd): + if not self._mmcli(*args): self.err("Unable to unlock") return False else: @@ -1092,9 +1115,8 @@ class ModemThread(threading.Thread): for p in self.get_profiles(): if p["profile-id"] != "1": self.dbg("Deleting profile %s" % p["profile-id"]) - if not runcmd(["mmcli", "-m", self.path, - "--3gpp-profile-manager-delete=%s" % - p["profile-id"]]): + if not self._mmcli("--3gpp-profile-manager-delete=%s" % + p["profile-id"]): self.err("Unable to delete profile") return False @@ -1108,20 +1130,17 @@ class ModemThread(threading.Thread): self.err("Max. number of bearers reached") break if bearer["pid"] != "1": - if not runcmd(["mmcli", "-m", self.path, - "--3gpp-profile-manager-set="]): + if not self._mmcli("--3gpp-profile-manager-set="): self.err("Unable to create profile") return False args = self.get_profile_args(bearer) - if not runcmd(["mmcli", "-m", self.path, - "--3gpp-profile-manager-set=%s" % args]): + if not self._mmcli("--3gpp-profile-manager-set=%s" % args): self.err("Unable to configure profile") return False args = self.get_bearer_args(bearer) - if not runcmd(["mmcli", "-m", self.path, - "--create-bearer=%s" % args]): + if not self._mmcli("--create-bearer=%s" % args): self.err("Unable to create bearer") return False @@ -1135,8 +1154,7 @@ class ModemThread(threading.Thread): for bearer in bearers: args = self.get_bearer_args(bearer) - if not runcmd(["mmcli", "-m", self.path, - "--create-bearer=multiplex=required,%s" % args]): + if not self._mmcli("--create-bearer=multiplex=required,%s" % args): self.err("Unable to create bearer") return False self.info("Created multiplex bearer for '%s'" % bearer["apn"]) @@ -1144,9 +1162,7 @@ class ModemThread(threading.Thread): return True def prepare_default_bearer(self, bearer): - if not runcmd(["mmcli", "-m", self.path, - "--create-bearer=%s" % - self.get_bearer_args(bearer)]): + if not self._mmcli("--create-bearer=%s" % self.get_bearer_args(bearer)): self.err("Unable to create bearer") return False @@ -1160,8 +1176,7 @@ class ModemThread(threading.Thread): # wipe existing bearers for bearer in self.get_bearers(): self.dbg("Deleting bearer %s" % bearer["dbus-path"]) - if not runcmd(["mmcli", "-m", self.path, - "--delete-bearer=%s" % bearer["dbus-path"]]): + if not self._mmcli("--delete-bearer=%s" % bearer["dbus-path"]): self.err("Unable to delete bearer") return False @@ -1169,9 +1184,8 @@ class ModemThread(threading.Thread): initial = self.bearers.get("initial") if initial: self.info("Setting initial bearer '%s'" % initial["apn"]) - if not runcmd(["mmcli", "-m", self.path, - "--3gpp-set-initial-eps-bearer-settings=%s" % - self.get_bearer_args(initial)]): + if not self._mmcli("--3gpp-set-initial-eps-bearer-settings=%s" % + self.get_bearer_args(initial)): self.err("Unable to set initial bearer") # configure dialup bearers @@ -1194,8 +1208,7 @@ class ModemThread(threading.Thread): if len(bands) == 0 or "any" in bands: return True - if not runcmd(["mmcli", "-m", self.path, - "--set-current-bands=%s" % "|".join(bands)]): + if not self._mmcli("--set-current-bands=%s" % "|".join(bands)): self.err("Unable to set band") return False return True @@ -1212,7 +1225,7 @@ class ModemThread(threading.Thread): allow = [] if pref == "none" and len(allow) == 0: - runcmd(["mmcli", "-m", self.path, "--set-allowed-modes=any"], check=False) + self._mmcli("--set-allowed-modes=any", check=False) return True mode = "allowed: %s; preferred: %s" % (", ".join(allow), pref) @@ -1232,12 +1245,47 @@ class ModemThread(threading.Thread): self.info("Setting mode '%s'" % mode) - cmd = ["mmcli", "-m", self.path] + args = [] if len(allow) > 0: - cmd.append("--set-allowed-modes=%s" % "|".join(allow)) + args.append("--set-allowed-modes=%s" % "|".join(allow)) if pref != "none": - cmd.append("--set-preferred-mode=%s" % pref) - return runcmd(cmd) + args.append("--set-preferred-mode=%s" % pref) + return self._mmcli(*args) + + def _gps_at_command(self, enable): + """Vendor-specific AT command to toggle GPS NMEA, or None. + + Match by substring so 'Quectel' picks up 'Quectel Incorporated' too — + ModemManager normalises differently across firmware revisions. + """ + for vendor, cmds in GPS_AT_COMMANDS.items(): + if vendor in self.manf: + return cmds[0] if enable else cmds[1] + return None + + def _location_capabilities(self): + """YANG source names this modem actually supports. + + Returns None if the capability query itself fails. Otherwise a + set drawn from {gps, agps-msa, agps-msb, 3gpp, cdma}. Asking + ModemManager to enable an unsupported source fails the whole + batched mmcli call, so prepare_location() filters against this. + """ + output = self._mmclij("--location-status") + if not output: + return None + caps = output.get("modem", {}).get("location", {}).get("capabilities", "") + if isinstance(caps, str): + caps = [c.strip() for c in caps.split(",") if c.strip()] + mapping = { + "3gpp-lac-ci": "3gpp", + "gps-raw": "gps", + "gps-nmea": "gps", + "agps-msa": "agps-msa", + "agps-msb": "agps-msb", + "cdma-bs": "cdma", + } + return {mapping[c] for c in caps if c in mapping} def prepare_location(self): self.info("Preparing location") @@ -1246,34 +1294,53 @@ class ModemThread(threading.Thread): if not self.location["enabled"]: return True - for source in ["gps", "agps-msa", "agps-msb", "3gpp", "cdma"]: - if self.location["enabled"] and source in self.location["sources"]: + sources = self.location["sources"] + + # GPS via vendor AT command (known vendor) or via MM (fallback). + # The AT path doesn't consult MM's --location-status, since + # marking the NMEA port ID_MM_PORT_IGNORE removes 'gps' from MM's + # capability list even though the hardware is still there. + at = self._gps_at_command("gps" in sources) + if at is not None: + if not self._mmcli("--command=%s" % at): + self.err("Unable to send AT command '%s'" % at) + self.location["state"] = "failed" + return False + gps_flags = () + else: + gps_flags = ("gps-nmea", "gps-raw") + + # Capability filter for the remaining (MM-managed) sources so + # CDMA on an LTE-only modem doesn't fail the whole batched call. + caps = self._location_capabilities() + if caps is None: + self.err("Unable to query modem location capabilities") + self.location["state"] = "failed" + return False + + flag_map = {**LOCATION_MM_FLAGS, "gps": gps_flags} + args = [] + for source, flags in flag_map.items(): + if source == "gps" and at is not None: + continue # AT command already handled GPS + if source not in caps: + if source in sources: + self.err("Modem does not support location source" + " '%s', skipping" % source) + continue + enabled = source in sources + if enabled: self.info("Enabling location source '%s'" % source) - action = "enable" else: self.dbg("Disabling location source '%s'" % source) - action = "disable" + action = "enable" if enabled else "disable" + for flag in flags: + args.append("--location-%s-%s" % (action, flag)) - args = [] - if source == "gps": - args = ["--location-%s-gps-nmea" % action, - "--location-%s-gps-raw" % action] - elif source == "agps-msa": - args = ["--location-%s-agps-msa" % action] - elif source == "agps-msb": - args = ["--location-%s-agps-msb" % action] - elif source == "3gpp": - args = ["--location-%s-3gpp" % action] - elif source == "cdma": - args = ["--location-%s-cdma-bs" % action] - else: - continue - - if not runcmd(["mmcli", "-m", self.path] + args): - self.err("Unable to %s location source" % action) - if self.location["enabled"]: - self.location["state"] = "failed" - return False + if args and not self._mmcli(*args): + self.err("Unable to configure location sources") + self.location["state"] = "failed" + return False return True def prepare(self): @@ -1301,10 +1368,7 @@ class ModemThread(threading.Thread): def connect(self): for bearer in self.status.get("bearers", []): self.info("Connecting %s" % bearer) - if not runcmd(["mmcli", "-m", self.path, - "--connect", - "--bearer=%s" % bearer - ]): + if not self._mmcli("--connect", "--bearer=%s" % bearer): self.connfail += 1 self.err("Unable to connect (%d failures)" % self.connfail) self.err("Reason: %s" % self.get_conn_failure(bearer)) @@ -1351,8 +1415,8 @@ class ModemThread(threading.Thread): if bearer["status"]["connected"] == "yes": self.info("Disconnecting %s" % bearer["dbus-path"]) - runcmd(["mmcli", "-m", self.path, - "--disconnect", "--bearer=%s" % bearer["dbus-path"]]) + self._mmcli("--disconnect", + "--bearer=%s" % bearer["dbus-path"]) for iface in self.ifaces: self.linkdown(iface) @@ -1444,8 +1508,7 @@ class ModemThread(threading.Thread): if not self.location["enabled"]: return - output = runcmdj(["mmcli", "-J", "-m", self.path, - "--location-get"]) + output = self._mmclij("--location-get") loc = (output or {}).get("modem", {}).get("location", {}) gps = loc.get("gps", {}) gpp = loc.get("3gpp", {}) @@ -1511,8 +1574,7 @@ class ModemThread(threading.Thread): storage = payload["properties"]["storage"].upper() if storage != "--": - if not runcmd(["mmcli", "-m", self.path, - "--messaging-delete-sms=%s" % sms["path"]]): + if not self._mmcli("--messaging-delete-sms=%s" % sms["path"]): self.err("Unable to delete SMS from %s storage" % storage) else: self.info("Deleted SMS from %s storage" % storage) @@ -1527,12 +1589,11 @@ class ModemThread(threading.Thread): for sms in list_sms(self.path): if sms["type"] == "notsent": self.dbg("Sending sms %s" % sms) - runcmd(["mmcli", "-m", self.path, "-s", sms["path"], "--send"]) + self._mmcli("-s", sms["path"], "--send") elif sms["type"] == "sent": self.notif("SMS-SENT") - runcmd(["mmcli", "-m", self.path, - "--messaging-delete-sms=%s" % sms["path"]]) + self._mmcli("--messaging-delete-sms=%s" % sms["path"]) elif sms["type"] == "received": self.save_sms(sms)