modem: route NMEA from cellular modems to gpsd

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 <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-07 20:51:03 +02:00
parent 7b7baa0730
commit 600483c35b
4 changed files with 188 additions and 79 deletions
+6
View File
@@ -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
+1
View File
@@ -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
+41
View File
@@ -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"
+140 -79
View File
@@ -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)