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)