From 1761c72c0c870f0626a2f5361287c69b50b8761d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 26 Apr 2026 21:22:40 +0200 Subject: [PATCH 1/8] board: add USB2 component to bpi-r4 factory-config The M.2 LTE slot uses the USB2 bus. Add it as an unlocked component so the modem hardware is accessible and visible to users in the config. Signed-off-by: Joachim Wiberg --- .../product/bananapi,bpi-r4-2g5/etc/factory-config.cfg | 9 ++++++++- .../share/product/bananapi,bpi-r4/etc/factory-config.cfg | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg index 5571d3200..0a153881f 100644 --- a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg @@ -10,6 +10,13 @@ "state": { "admin-state": "unlocked" } + }, + { + "name": "USB2", + "class": "infix-hardware:usb", + "state": { + "admin-state": "unlocked" + } } ] }, @@ -366,7 +373,7 @@ ] }, "infix-meta:meta": { - "version": "1.7" + "version": "1.8" }, "infix-services:mdns": { "enabled": true diff --git a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg index 13a22c9ba..15d6ec6be 100644 --- a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg @@ -10,6 +10,13 @@ "state": { "admin-state": "unlocked" } + }, + { + "name": "USB2", + "class": "infix-hardware:usb", + "state": { + "admin-state": "unlocked" + } } ] }, @@ -358,7 +365,7 @@ ] }, "infix-meta:meta": { - "version": "1.7" + "version": "1.8" }, "infix-services:mdns": { "enabled": true From 5a4ca3e3fbd62aa06a57e8da94bb6e08939ff5bb Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 26 Apr 2026 21:22:33 +0200 Subject: [PATCH 2/8] Add cellular modem support via modemd daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates the modem management subsystem, donated by Avvero Pty Ltd, adapted to work on any platform without proprietary hardware (simctrl, modules.json, /sys/class/sim/). Components: - package/feature-modem: Buildroot feature package pulling in modemd, ModemManager, libmbim, libqmi, and USB serial/MBIM/QMI kernel drivers - package/modemd: Buildroot package for the modemd daemon and helpers - src/modemd: modem management daemon — detects modems via ModemManager, connects bearers, configures IP addresses, handles SMS and location - src/confd/src/modem.c: confd plugin — enables/disables modemd and modem-manager via sysrepo, forwards RPCs (restart/reset/send-sms) - src/confd/src/interfaces.{c,h}: IFT_MODEM type for wwan* interfaces; confd manages address/route assignment, modem daemon owns link state - src/confd/yang/confd/infix-modem.yang: YANG model for modem config and operational state (bearers, APN, bands, modes, location, SMS) - src/statd/python/yanger/infix_modem.py: operational state collector - patches/modem-manager: upstream patches for udev install path, multiplexed interface naming, and MBIM port trust from udev hint (the last fixes Dell DW5811e / Sierra EM7455 on cold boot) - patches/libqmi: ignore sysfs in download mode Platform portability fixes for boards without simctrl hardware: - modem-info: fall back to /run/modems.json when /run/modules.json is absent; discover wwan interfaces via sysfs scan; provide synthetic SIM entry when /sys/class/sim/ is not present; fix dbg() to respect flag - modemd: make --set-allowed-modes=any best-effort (some modems only support one mode combination and reject any change); skip mode set when current mode already matches requested mode - sim-setup: guard simctrl open, return None when modules.json absent to skip SIM slot switching entirely on non-simctrl hardware Signed-off-by: Joachim Wiberg --- configs/aarch64_defconfig | 1 + package/Config.in | 2 + package/feature-modem/Config.in | 19 + package/feature-modem/feature-modem.mk | 25 + package/modemd/Config.in | 6 + package/modemd/modemd.hash | 2 + package/modemd/modemd.mk | 44 + .../etc/finit.d/available/modem-manager.conf | 1 + .../0001-Ignore-sysfs-for-download-mode.patch | 21 + patches/modem-manager/0000-udevdir.patch | 13 + .../0002-multiplex-interface-name.patch | 17 + ...trust-ID_MM_PORT_TYPE_MBIM-udev-hint.patch | 29 + src/confd/src/Makefile.am | 1 + src/confd/src/core.c | 4 +- src/confd/src/core.h | 17 + src/confd/src/interfaces.c | 19 +- src/confd/src/interfaces.h | 5 + src/confd/src/modem.c | 330 ++++ src/confd/yang/confd.inc | 1 + src/confd/yang/confd/infix-modem.yang | 1089 +++++++++++ .../yang/confd/infix-modem@2024-03-15.yang | 1 + src/modemd/77-mm-dell-port-types.rules | 39 + src/modemd/LICENSE | 27 + src/modemd/finit.conf | 1 + src/modemd/modem-carrier | 314 ++++ src/modemd/modem-command.c | 617 +++++++ src/modemd/modem-info | 432 +++++ src/modemd/modem-power | 86 + src/modemd/modem-rpc | 43 + src/modemd/modem-scan-networks | 79 + src/modemd/modem-sms | 136 ++ src/modemd/modem-udev | 294 +++ src/modemd/modem-update-firmware | 389 ++++ src/modemd/modem-ussd | 72 + src/modemd/modemd | 1618 +++++++++++++++++ src/modemd/modemd.modules-load | 2 + src/modemd/modemd.rules | 8 + src/modemd/qmi-wwan-ids.rules | 7 + src/modemd/sim-setup | 240 +++ src/statd/python/yanger/__main__.py | 3 + src/statd/python/yanger/infix_modem.py | 12 + 41 files changed, 6062 insertions(+), 4 deletions(-) create mode 100644 package/feature-modem/Config.in create mode 100644 package/feature-modem/feature-modem.mk create mode 100644 package/modemd/Config.in create mode 100644 package/modemd/modemd.hash create mode 100644 package/modemd/modemd.mk create mode 100644 package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf create mode 100644 patches/libqmi/0001-Ignore-sysfs-for-download-mode.patch create mode 100644 patches/modem-manager/0000-udevdir.patch create mode 100644 patches/modem-manager/0002-multiplex-interface-name.patch create mode 100644 patches/modem-manager/0003-port-probe-trust-ID_MM_PORT_TYPE_MBIM-udev-hint.patch create mode 100644 src/confd/src/modem.c create mode 100644 src/confd/yang/confd/infix-modem.yang create mode 120000 src/confd/yang/confd/infix-modem@2024-03-15.yang create mode 100644 src/modemd/77-mm-dell-port-types.rules create mode 100644 src/modemd/LICENSE create mode 100644 src/modemd/finit.conf create mode 100755 src/modemd/modem-carrier create mode 100644 src/modemd/modem-command.c create mode 100755 src/modemd/modem-info create mode 100755 src/modemd/modem-power create mode 100755 src/modemd/modem-rpc create mode 100755 src/modemd/modem-scan-networks create mode 100755 src/modemd/modem-sms create mode 100755 src/modemd/modem-udev create mode 100755 src/modemd/modem-update-firmware create mode 100755 src/modemd/modem-ussd create mode 100755 src/modemd/modemd create mode 100644 src/modemd/modemd.modules-load create mode 100644 src/modemd/modemd.rules create mode 100644 src/modemd/qmi-wwan-ids.rules create mode 100755 src/modemd/sim-setup create mode 100644 src/statd/python/yanger/infix_modem.py diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index ef2e9ba03..dec3e3929 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -148,6 +148,7 @@ INFIX_HOME="https://github.com/kernelkit/infix/" INFIX_DOC="https://www.kernelkit.org/infix/" INFIX_SUPPORT="mailto:kernelkit@googlegroups.com" BR2_PACKAGE_FEATURE_GPS=y +BR2_PACKAGE_FEATURE_MODEM=y BR2_PACKAGE_FEATURE_WIFI=y BR2_PACKAGE_FEATURE_WIFI_MEDIATEK=y BR2_PACKAGE_FEATURE_WIFI_QUALCOMM=y diff --git a/package/Config.in b/package/Config.in index 110d247b2..9b04f462b 100644 --- a/package/Config.in +++ b/package/Config.in @@ -2,6 +2,7 @@ menu "Packages" comment "Hardware Support" source "$BR2_EXTERNAL_INFIX_PATH/package/feature-gps/Config.in" +source "$BR2_EXTERNAL_INFIX_PATH/package/feature-modem/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/feature-wifi/Config.in" comment "Software Packages" @@ -30,6 +31,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/landing/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/libsrx/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/lowdown/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/mcd/Config.in" +source "$BR2_EXTERNAL_INFIX_PATH/package/modemd/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/mdns-alias/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/netbrowse/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/onieprom/Config.in" diff --git a/package/feature-modem/Config.in b/package/feature-modem/Config.in new file mode 100644 index 000000000..ed8889508 --- /dev/null +++ b/package/feature-modem/Config.in @@ -0,0 +1,19 @@ +config BR2_PACKAGE_FEATURE_MODEM + bool "Feature Modem" + select BR2_PACKAGE_MODEMD + select BR2_PACKAGE_MODEM_MANAGER + select BR2_PACKAGE_MODEM_MANAGER_LIBMBIM + select BR2_PACKAGE_MODEM_MANAGER_LIBQMI + select BR2_PACKAGE_MODEM_MANAGER_LIBQRTR + help + Enables cellular modem support in Infix via ModemManager and + the modemd management daemon. Includes drivers for common + USB option modems and QMI/MBIM-based devices. + +config BR2_PACKAGE_FEATURE_MODEM_QUALCOMM + bool "Qualcomm-based modems (QMI/QRTR/MHI)" + depends on BR2_PACKAGE_FEATURE_MODEM + help + Adds kernel support for Qualcomm-based cellular modems that use + the MHI bus and QRTR IPC router (e.g. Sierra Wireless EM7xxx, + Quectel EM/RMxxx, Telit LN9xx). diff --git a/package/feature-modem/feature-modem.mk b/package/feature-modem/feature-modem.mk new file mode 100644 index 000000000..d5a7827ba --- /dev/null +++ b/package/feature-modem/feature-modem.mk @@ -0,0 +1,25 @@ +################################################################################ +# +# Cellular modem support +# +################################################################################ + +FEATURE_MODEM_PACKAGE_VERSION = 1.0 +FEATURE_MODEM_PACKAGE_LICENSE = MIT + +define FEATURE_MODEM_LINUX_CONFIG_FIXUPS + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL_WWAN) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL_OPTION) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_WDM) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_QMI_WWAN) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_CDC_MBIM) + + $(if $(filter y,$(BR2_PACKAGE_FEATURE_MODEM_QUALCOMM)), + $(call KCONFIG_SET_OPT,CONFIG_QRTR,m) + $(call KCONFIG_SET_OPT,CONFIG_MHI_BUS,m) + $(call KCONFIG_ENABLE_OPT,CONFIG_QRTR_MHI) + ) +endef + +$(eval $(generic-package)) diff --git a/package/modemd/Config.in b/package/modemd/Config.in new file mode 100644 index 000000000..0b8ddafe0 --- /dev/null +++ b/package/modemd/Config.in @@ -0,0 +1,6 @@ +config BR2_PACKAGE_MODEMD + bool "modemd" + select BR2_PACKAGE_MODEM_MANAGER + select BR2_PACKAGE_PYTHON3 + help + Daemon which manages modems. diff --git a/package/modemd/modemd.hash b/package/modemd/modemd.hash new file mode 100644 index 000000000..db4831e36 --- /dev/null +++ b/package/modemd/modemd.hash @@ -0,0 +1,2 @@ +# Locally calculated +sha256 25b33026a661c4c550374cfcba6890a4363bf19db0c1c31a6e65b5edd113ecf0 LICENSE diff --git a/package/modemd/modemd.mk b/package/modemd/modemd.mk new file mode 100644 index 000000000..8b84dfacd --- /dev/null +++ b/package/modemd/modemd.mk @@ -0,0 +1,44 @@ +################################################################################ +# +# modemd +# +################################################################################ + +MODEMD_VERSION = 1.0 +MODEMD_SITE_METHOD = local +MODEMD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/modemd +MODEMD_LICENSE = BSD-3-Clause +MODEMD_LICENSE_FILES = LICENSE +MODEMD_REDISTRIBUTE = NO +MODEMD_DEPENDENCIES = modem-manager jansson python3 + +define MODEMD_BUILD_CMDS + $(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \ + $(MODEMD_DIR)/modem-command.c -o $(MODEMD_DIR)/modem-command -ljansson +endef + +define MODEMD_INSTALL_TARGET_CMDS + mkdir -p $(TARGET_DIR)/usr/libexec/modemd + mkdir -p $(TARGET_DIR)/lib/udev/rules.d + mkdir -p $(FINIT_D)/available/ + mkdir -p $(TARGET_DIR)/sbin + $(INSTALL) -D -m 0644 $(MODEMD_DIR)/finit.conf $(FINIT_D)/available/modemd.conf + 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 -D -m 644 $(MODEMD_DIR)/modemd.modules-load $(TARGET_DIR)/etc/modules-load.d/modemd.conf + install -m 755 $(MODEMD_DIR)/modem-udev $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modemd $(TARGET_DIR)/sbin/modemd + install -m 755 $(MODEMD_DIR)/modem-command $(TARGET_DIR)/sbin/modem-command + install -m 755 $(MODEMD_DIR)/modem-info $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-rpc $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-sms $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-scan-networks $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-update-firmware $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-ussd $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-carrier $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-power $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/sim-setup $(TARGET_DIR)/usr/libexec/modemd/ +endef + +$(eval $(generic-package)) diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf new file mode 100644 index 000000000..8c060e2df --- /dev/null +++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf @@ -0,0 +1 @@ +service [2345789] ModemManager -- ModemManager daemon diff --git a/patches/libqmi/0001-Ignore-sysfs-for-download-mode.patch b/patches/libqmi/0001-Ignore-sysfs-for-download-mode.patch new file mode 100644 index 000000000..31d4ee19a --- /dev/null +++ b/patches/libqmi/0001-Ignore-sysfs-for-download-mode.patch @@ -0,0 +1,21 @@ +diff --git a/src/qmi-firmware-update/qfu-helpers-udev.c b/src/qmi-firmware-update/qfu-helpers-udev.c +index bda9106..40d648f 100644 +--- a/src/qmi-firmware-update/qfu-helpers-udev.c ++++ b/src/qmi-firmware-update/qfu-helpers-udev.c +@@ -364,8 +364,14 @@ device_matches (GUdevDevice *device, + if (!device_sysfs_path) + goto out; + +- if (g_strcmp0 (device_sysfs_path, sysfs_path) != 0) +- goto out; ++ /* ++ * don't compare sysfs path for download mode as it ++ * changes from USB4 to USB3 and thus may use a different host controller ++ */ ++ if (mode != QFU_HELPERS_DEVICE_MODE_DOWNLOAD) { ++ if (g_strcmp0 (device_sysfs_path, sysfs_path) != 0) ++ goto out; ++ } + + if (device_mode != mode) + return NULL; diff --git a/patches/modem-manager/0000-udevdir.patch b/patches/modem-manager/0000-udevdir.patch new file mode 100644 index 000000000..139d208e2 --- /dev/null +++ b/patches/modem-manager/0000-udevdir.patch @@ -0,0 +1,13 @@ +diff --git a/meson.build b/meson.build +index 8cddf77..e97f862 100644 +--- a/meson.build ++++ b/meson.build +@@ -188,7 +188,7 @@ endif + config_h.set('WITH_UDEV', enable_udev) + + # udev base directory (required to install rules even when udev support is disabled) +-udev_udevdir = get_option('udevdir') ++udev_udevdir = '/lib/udev' + if udev_udevdir == '' + assert(enable_udev, 'udevdir must be explicitly given if udev support is disabled') + udev_udevdir = dependency('udev').get_pkgconfig_variable('udevdir') diff --git a/patches/modem-manager/0002-multiplex-interface-name.patch b/patches/modem-manager/0002-multiplex-interface-name.patch new file mode 100644 index 000000000..2a4ad9765 --- /dev/null +++ b/patches/modem-manager/0002-multiplex-interface-name.patch @@ -0,0 +1,17 @@ +diff --git a/src/mm-bearer-qmi.c b/src/mm-bearer-qmi.c +index 54f2e93..3c4df7b 100644 +--- a/src/mm-bearer-qmi.c ++++ b/src/mm-bearer-qmi.c +@@ -2494,7 +2494,11 @@ load_settings_from_bearer (MMBearerQmi *self, + } + + /* The link prefix hint given must be modem-specific */ +- ctx->link_prefix_hint = g_strdup_printf ("qmapmux%u.", mm_base_modem_get_dbus_id (MM_BASE_MODEM (modem))); ++ const gchar *name = mm_port_get_device (MM_PORT (ctx->data)); ++ if (name) ++ ctx->link_prefix_hint = g_strdup_printf ("%s.", name); ++ else ++ ctx->link_prefix_hint = g_strdup_printf ("qmapmux%u.", mm_base_modem_get_dbus_id (MM_BASE_MODEM (modem))); + } + + /* If profile id is given, we'll launch the connection specifying the profile id in use diff --git a/patches/modem-manager/0003-port-probe-trust-ID_MM_PORT_TYPE_MBIM-udev-hint.patch b/patches/modem-manager/0003-port-probe-trust-ID_MM_PORT_TYPE_MBIM-udev-hint.patch new file mode 100644 index 000000000..f9672e5ec --- /dev/null +++ b/patches/modem-manager/0003-port-probe-trust-ID_MM_PORT_TYPE_MBIM-udev-hint.patch @@ -0,0 +1,29 @@ +When a udev rule explicitly sets ID_MM_PORT_TYPE_MBIM=1 for a port, +treat it as definitive and skip the active MBIM open/probe. This +mirrors the way ID_MM_PORT_TYPE_GPS is handled (set via is_gps, a +direct result field rather than a "maybe" hint). + +The active MBIM probe attempts to open the WDM device and send an +MBIM OPEN message. Some modems (e.g. Dell DW5811e / Sierra Wireless +EM7455 in USB composition 8) persistently send QMI/QMUX-framed +notifications on the MBIM control channel before ever responding to +MBIM OPEN, causing the probe to time out (~50 s). The probe failure +causes mm_port_probe_list_has_mbim_port() to return FALSE, so the +Dell plugin creates a non-MBIM modem object that cannot handle the +usbmisc/MBIM port, resulting in "unhandled port type". + +Since the udev rule already encodes the vendor/product/interface +specificity required to identify the port, the active probe adds no +information in this case. + +diff --git a/src/mm-port-probe.c b/src/mm-port-probe.c +--- a/src/mm-port-probe.c ++++ b/src/mm-port-probe.c +@@ -1529,6 +1529,7 @@ mm_port_probe_run (MMPortProbe *self, + /* If this is a port flagged as being a MBIM port, don't do any other probing */ + if (self->priv->maybe_mbim) { + mm_obj_dbg (self, "no AT/QCDM/QMI probing in possible MBIM port"); ++ mm_port_probe_set_result_mbim (self, TRUE); + mm_port_probe_set_result_at (self, FALSE); + mm_port_probe_set_result_qcdm (self, FALSE); + mm_port_probe_set_result_qmi (self, FALSE); diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index 447117994..374004aad 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -46,6 +46,7 @@ confd_plugin_la_SOURCES = \ if-vxlan.c \ if-wifi.c \ if-wireguard.c \ + modem.c \ keystore.c \ system.c \ ntp.c \ diff --git a/src/confd/src/core.c b/src/confd/src/core.c index dfd0261d4..67aa17b65 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -764,7 +764,9 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv) rc = ntp_candidate_init(&confd); if (rc) goto err; - /* YOUR_INIT GOES HERE */ + rc = modem_init(&confd); + if (rc) + goto err; return SR_ERR_OK; err: diff --git a/src/confd/src/core.h b/src/confd/src/core.h index b56c8bf32..dadfddfca 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -133,6 +133,10 @@ typedef enum { if ((rc = register_rpc(s, x, c, a, u))) \ goto fail +#define REGISTER_NOTIF(s,m,x,c,a,u) \ + if ((rc = register_notif(s, m, x, c, a, u))) \ + goto fail + struct confd { sr_session_ctx_t *session; /* running datastore */ sr_session_ctx_t *startup; /* startup datastore */ @@ -192,6 +196,17 @@ static inline int register_rpc(sr_session_ctx_t *session, const char *xpath, return rc; } +static inline int register_notif(sr_session_ctx_t *session, const char *module, const char *xpath, + sr_event_notif_cb cb, void *arg, sr_subscription_ctx_t **sub) +{ + int rc = sr_notif_subscribe(session, module, xpath, + NULL, NULL, /* forever */ + cb, arg, SR_SUBSCR_NO_THREAD, sub); + if (rc) + ERROR("failed subscribing to %s notification: %s", xpath, sr_strerror(rc)); + return rc; +} + /* core.c */ int finit_enable(const char *svc); @@ -274,4 +289,6 @@ int ntp_candidate_init(struct confd *confd); /* ptp.c */ int ptp_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); +int modem_init(struct confd *confd); + #endif /* CONFD_CORE_H_ */ diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c index a255c20ea..03c5ebdc7 100644 --- a/src/confd/src/interfaces.c +++ b/src/confd/src/interfaces.c @@ -80,6 +80,8 @@ static int ifchange_cand_infer_type(sr_session_ctx_t *session, const char *path) if (!fnmatch("wifi+([0-9])*", ifname, FNM_EXTMATCH)) inferred.data.string_val = "infix-if-type:wifi"; + else if (!fnmatch("wwan+([0-9-])", ifname, FNM_EXTMATCH)) + inferred.data.string_val = "infix-if-type:modem"; else if (iface_is_phys(ifname)) inferred.data.string_val = "infix-if-type:ethernet"; else if (!fnmatch("br+([0-9])", ifname, FNM_EXTMATCH)) @@ -390,6 +392,8 @@ static int netdag_gen_afspec_add(sr_session_ctx_t *session, struct dagger *net, return vlan_gen(NULL, cif, ip); case IFT_VXLAN: return vxlan_gen(NULL, cif, ip); + case IFT_MODEM: + return modem_gen(NULL, cif, net); case IFT_WIFI: return wifi_validate_secret(session, cif) ? : wifi_add_iface(cif, net); @@ -432,6 +436,7 @@ static int netdag_gen_afspec_set(sr_session_ctx_t *session, struct dagger *net, case IFT_DUMMY: case IFT_GRE: case IFT_GRETAP: + case IFT_MODEM: case IFT_VETH: case IFT_VXLAN: case IFT_WIREGUARD: @@ -472,6 +477,8 @@ static bool netdag_must_del(struct lyd_node *dif, struct lyd_node *cif) return lydx_get_descendant(lyd_child(dif), "veth", NULL); case IFT_VXLAN: return lydx_get_descendant(lyd_child(dif), "vxlan", NULL); + case IFT_MODEM: + return false; case IFT_WIREGUARD: return lydx_get_descendant(lyd_child(dif), "wireguard", NULL); case IFT_UNKNOWN: @@ -549,6 +556,9 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif, case IFT_VETH: veth_gen_del(dif, ip); break; + case IFT_MODEM: + modem_gen_del(dif, net); + break; case IFT_WIFI: wifi_del_iface(dif, net); break; @@ -571,7 +581,8 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif, static sr_error_t netdag_gen_iface_timeout(struct dagger *net, const char *ifname, const char *iftype) { - if (!strcmp(iftype, "infix-if-type:ethernet")) { + if (!strcmp(iftype, "infix-if-type:ethernet") || + !strcmp(iftype, "infix-if-type:modem")) { FILE *wait; wait = dagger_fopen_net_init(net, ifname, NETDAG_INIT_TIMEOUT, "wait-interface.sh"); @@ -691,8 +702,9 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net fprintf(ip, "link set alias \"%s\" dev %s\n", attr ?: "", ifname); /* Bring interface back up, if enabled */ - if (lydx_is_enabled(cif, "enabled")) - fprintf(ip, "link set dev %s up state up\n", ifname); + if (strcmp(iftype, "infix-if-type:modem") != 0) + if (lydx_is_enabled(cif, "enabled")) + fprintf(ip, "link set dev %s up state up\n", ifname); err = err ? : netdag_gen_sysctl(net, cif, dif); @@ -722,6 +734,7 @@ static int netdag_init_iface(struct lyd_node *cif) return vlan_add_deps(cif); case IFT_VETH: return veth_add_deps(cif); + case IFT_MODEM: case IFT_WIFI: case IFT_DUMMY: case IFT_ETH: diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h index a427872f0..94551d6eb 100644 --- a/src/confd/src/interfaces.h +++ b/src/confd/src/interfaces.h @@ -29,6 +29,7 @@ _map(IFT_GRETAP, "infix-if-type:gretap") \ _map(IFT_LAG, "infix-if-type:lag") \ _map(IFT_LO, "infix-if-type:loopback") \ + _map(IFT_MODEM, "infix-if-type:modem") \ _map(IFT_VETH, "infix-if-type:veth") \ _map(IFT_VLAN, "infix-if-type:vlan") \ _map(IFT_VXLAN, "infix-if-type:vxlan") \ @@ -165,6 +166,10 @@ int ifchange_cand_infer_dhcp(sr_session_ctx_t *session, const char *path); /* if-vxlan.c */ int vxlan_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip); +/* modem.c */ +int modem_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net); +int modem_gen_del(struct lyd_node *dif, struct dagger *net); + /* infix-if-wireguard */ int wireguard_validate_peers(sr_session_ctx_t *session, struct lyd_node *cif); int wireguard_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip, struct dagger *net); diff --git a/src/confd/src/modem.c b/src/confd/src/modem.c new file mode 100644 index 000000000..a7d83ddd2 --- /dev/null +++ b/src/confd/src/modem.c @@ -0,0 +1,330 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "core.h" + +#define MAX_MODEMS 8 +#define RUN_DIR "/run/modemd" +#define SOCK RUN_DIR "/modemd.sock" + +#define MODULE "infix-modem" +#define ROOT_XPATH "/infix-modem:" +#define CFG_XPATH ROOT_XPATH "modems" + + +static int xpath_get_index(const char *xpath) +{ + regmatch_t pmatch[2]; + regex_t regex; + char buf[32]; + int ret, len, i; + int index = -1; + + if (regcomp(®ex, "index='([0-9]+)'", REG_EXTENDED)) + return -1; + + ret = regexec(®ex, xpath, 2, pmatch, 0); + if (!ret) { + len = (pmatch[1].rm_eo - pmatch[1].rm_so); + if (len < (int)(sizeof(buf)-1)) { + for (i = 0; i < len; i++) + buf[i] = xpath[pmatch[1].rm_so + i]; + + buf[i] = '\0'; + index = (int) strtoul(buf, NULL, 10); + } + } + regfree(®ex); + + return index; +} + +static int node_index(struct lyd_node *node) +{ + const char *s; + + s = lydx_get_cattr(node, "index"); + if (!s || !s[0]) + return -1; + + return (int) strtoul(s, NULL, 10); +} + +static int disable(void) +{ + NOTE("Disabling modemd"); + + /* disable modem-manager */ + systemf("initctl -bfqn stop modem-manager"); + systemf("initctl -bfqn disable modem-manager"); + + /* disable modemd */ + systemf("initctl -bnq stop modemd"); + systemf("initctl -bnq disable modemd"); + + return SR_ERR_OK; +} + +static int enable(void) +{ + int enabled, reload = 0; + + NOTE("Enabling modemd"); + + /* enable modem-manager */ + enabled = !systemf("initctl -bfq status modem-manager"); + if (!enabled) { + systemf("initctl -bfqn enable modem-manager"); + reload = 1; + } + /* enable modemd */ + enabled = !systemf("initctl -bfq status modemd"); + if (!enabled) { + systemf("initctl -bfqn enable modemd"); + reload = 1; + } + /* reload if required */ + if (reload) + systemf("initctl -b reload"); + + /* restart modem-manager */ + systemf("initctl -bfqn restart modem-manager"); + + /* restart modemd */ + systemf("initctl -bfqn restart modemd"); + + return SR_ERR_OK; +} + +static int genconf(sr_data_t *cfg, struct lyd_node *diff) +{ + struct lyd_node *node, *tree; + uint8_t enabled[MAX_MODEMS]; + int index; + + memset(enabled, 0, sizeof(enabled)); + + tree = lydx_get_descendant(cfg->tree, "modems", "modem", NULL); + LYX_LIST_FOR_EACH(tree, node, "modem") { + index = node_index(node); + if (index < MAX_MODEMS) + enabled[index] = lydx_get_bool(node, "enabled") ? 1 : 0; + } + + tree = lydx_get_descendant(diff, "modems", "modem", NULL); + LYX_LIST_FOR_EACH(tree, node, "modem") { + index = node_index(node); + if (index < MAX_MODEMS && lydx_get_op(node) == LYDX_OP_DELETE) + enabled[index] = 0; + } + + for (index = 0; index < MAX_MODEMS; index++) { + if (enabled[index]) { + if (enable() == SR_ERR_OK) { + return SR_ERR_OK; + } else { + ERROR("Cannot enable modem%d", index); + break; + } + } + } + + return disable(); +} + +static int infix_modem_change(sr_session_ctx_t *session, uint32_t sub_id, const char *module, + const char *xpath, sr_event_t event, unsigned request_id, void *confd) +{ + sr_data_t *cfg = NULL; + struct lyd_node *diff = NULL; + sr_error_t err; + + if (event != SR_EV_DONE) { + return SR_ERR_OK; + } + err = sr_get_data(session, CFG_XPATH "//.", 0, 0, 0, &cfg); + if (err) { + ERROR("Can't get data"); + goto out; + } + err = srx_get_diff(session, &diff); + if (err) { + ERROR("Can't get diff"); + goto out; + } + err = genconf(cfg, diff); + if (err) { + ERROR("Can't gen conf"); + goto out; + } +out: + if (diff) lyd_free_tree(diff); + if (cfg) sr_release_data(cfg); + + return err; +} + +static int infix_modem_rpcsend(char *msg, int len) +{ + struct sockaddr_un addr; + struct timeval tv; + fd_set wfds; + int sock, ret = -1; + + sock = socket(AF_UNIX, SOCK_STREAM, 0); + if (sock < 0) + return -1; + + addr.sun_family = AF_UNIX; + strcpy(addr.sun_path, SOCK); + + if (connect(sock, &addr, sizeof(addr)) == 0) { + tv.tv_sec = 5; + tv.tv_usec = 0; + FD_ZERO(&wfds); + FD_SET(sock, &wfds); + + if (select(sock + 1, NULL, &wfds, NULL, &tv) > 0) { + if (write(sock, msg, len) == len) { + ret = 0; + } + } + } + close(sock); + + return ret; +} + +static int infix_modem_rpc(const char *xpath, const char *rpc, const char *data) +{ + char msg[1024]; + int len; + + NOTE("Sending rpc %s to modemd", rpc); + + len = snprintf(msg, sizeof(msg), + "{ \"rpc\" : \"%s\", \"data\" : %s }", + rpc, data ? data : "null"); + + if (infix_modem_rpcsend(msg, len) < 0) { + ERROR("Unable to send rpc"); + return SR_ERR_INTERNAL; + } + + return SR_ERR_OK; +} + +static int infix_modem_sendsms(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) +{ + char data[1024]; + + if (input_cnt < 3) { + ERROR("Not enough input parameters"); + return SR_ERR_SYS; + } + + snprintf(data, sizeof(data)-1, + "{ \"index\" : %s, \"number\" : \"%s\", \"text\" : \"%s\" }", + input[0].data.string_val, + input[1].data.string_val, + input[2].data.string_val); + + return infix_modem_rpc(xpath, "send-sms", data); +} + +static int infix_modem_restart(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) +{ + char data[1024]; + + if (input_cnt < 1) { + ERROR("Not enough input parameters"); + return SR_ERR_SYS; + } + + snprintf(data, sizeof(data)-1, + "{ \"index\" : %s }", input[0].data.string_val); + + return infix_modem_rpc(xpath, "restart", data); +} + +static int infix_modem_reset(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) +{ + char data[1024]; + + if (input_cnt < 1) { + ERROR("Not enough input parameters"); + return SR_ERR_SYS; + } + + snprintf(data, sizeof(data)-1, + "{ \"index\" : %s }", input[0].data.string_val); + + return infix_modem_rpc(xpath, "reset", data); +} + +static void infix_modem_notif (sr_session_ctx_t *session, uint32_t sub_id, + const sr_ev_notif_type_t notif_type, const char *xpath, + const sr_val_t *values, const size_t values_cnt, + struct timespec *timestamp, void *confd) +{ + int index; + + index = xpath_get_index(xpath); + if (index < 0) { + ERROR("No index"); + return; + } + if (values_cnt < 1) { + ERROR("No values"); + return; + } + + NOTE("Notification from modem%d: %s", + index, values[0].data.string_val); +} + +int modem_init(struct confd *confd) +{ + int rc; + + REGISTER_CHANGE(confd->session, MODULE, CFG_XPATH, 0, infix_modem_change, confd, &confd->sub); + REGISTER_NOTIF(confd->session, MODULE, CFG_XPATH "/modem/status-update", infix_modem_notif, confd, &confd->sub); + REGISTER_RPC(confd->session, ROOT_XPATH "restart", infix_modem_restart, NULL, &confd->sub); + REGISTER_RPC(confd->session, ROOT_XPATH "reset", infix_modem_reset, NULL, &confd->sub); + REGISTER_RPC(confd->session, ROOT_XPATH "send-sms", infix_modem_sendsms, NULL, &confd->sub); + + return SR_ERR_OK; +fail: + ERROR("init failed: %s", sr_strerror(rc)); + return rc; +} + +int modem_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net) +{ + return 0; +} + +int modem_gen_del(struct lyd_node *dif, struct dagger *net) +{ + return 0; +} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 6a6505b0f..cd039c17f 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -56,4 +56,5 @@ MODULES=( "ieee1588-ptp-tt@2023-08-14.yang -e timestamp-correction" "ieee802-dot1as-gptp@2025-12-10.yang" "infix-ptp@2026-04-07.yang" + "infix-modem@2024-03-15.yang" ) diff --git a/src/confd/yang/confd/infix-modem.yang b/src/confd/yang/confd/infix-modem.yang new file mode 100644 index 000000000..f0a52fe68 --- /dev/null +++ b/src/confd/yang/confd/infix-modem.yang @@ -0,0 +1,1089 @@ +/* + * Infix Modem YANG module + */ + +module infix-modem { + yang-version 1.1; + namespace "urn:infix:params:xml:ns:yang:infix-modem"; + prefix "infix-modem"; + + import ietf-inet-types { + prefix "inet"; + } + import ietf-interfaces { + prefix "if"; + } + import ietf-yang-types { + prefix yang; + } + import ietf-netconf-acm { + prefix nacm; + } + + organization "KernelKit"; + contact "kernelkit@googlegroups.com"; + description "YANG data model for modems."; + + revision 2024-03-15 { + description "Initial revision"; + reference "internal"; + } + + container modems { + description "Configuration of modems."; + + list modem { + key "index"; + unique sim; + description "List of modems"; + + leaf index { + type uint8; + description "Index of the modem."; + } + leaf enabled { + type boolean; + description "Enable or disable modem."; + default false; + } + leaf path { + type string; + description "Path to modem."; + config false; + } + leaf sim { + type int8; + description "Index of SIM card."; + } + leaf pin { + type string; + description "PIN code to unlock SIM card."; + nacm:default-deny-all; + } + leaf puk { + type string; + description "PUK code to unlock SIM card."; + nacm:default-deny-all; + } + + leaf preferred-mode { + type access-mode; + description "Preferred mode."; + } + list allowed-mode { + key "mode"; + description "List of allowed modes."; + + leaf mode { + type access-mode; + description "Allowed mode."; + } + } + leaf carrier { + type string; + description "Network carrier profile."; + } + list band { + key "band"; + description "List of enabled bands."; + + leaf band { + type band; + description "Enabled band."; + } + } + list bearer { + key "index"; + description "List of bearers."; + + leaf index { + type uint8; + description "Index of the bearer."; + } + leaf apn-type { + type bearer-apn-type; + description "APN type."; + } + leaf apn { + type string; + description "Access Point Name (APN)."; + } + leaf username { + type string; + description "User name (if any) required by the network."; + default ""; + } + leaf password { + type string; + description "Password (if any) required by the network."; + default ""; + nacm:default-deny-all; + } + leaf allow-roaming { + type boolean; + description "Flag to tell whether connection is allowed during roaming."; + default false; + } + leaf ip-type { + type ip-family; + description "IP addressing type."; + default ipv4v6; + } + leaf default-route { + type boolean; + description "Set default route for connection"; + default false; + } + leaf firewall-enabled { + type boolean; + description "Enable firewall for connection."; + default false; + } + leaf dns-enabled { + type boolean; + description "Enable DNS resolver for connection."; + default true; + } + } + container location { + leaf enabled { + type boolean; + description "Enable location gathering."; + default false; + } + + list source { + key "source"; + description "List of location sources."; + + leaf source { + type location-source; + description "Source used for location gathering."; + } + } + } + + container info { + description "Modem information."; + config false; + + leaf manufacturer { + type string; + description "Manufacturer of the modem."; + } + leaf model { + type string; + description "Model name of the modem."; + } + leaf hardware-revision { + type string; + description "Hardware revision of the modem."; + } + leaf firmware-version { + type string; + description "Firmware version of the modem."; + } + leaf-list supported-carrier { + type string; + description "List of supported carriers."; + } + leaf serial-number { + type string; + description "Serial number of the modem (IMEI)."; + } + leaf-list phone-number { + type string; + description "List of own phone numbers."; + } + leaf imsi { + type string; + description "International Mobile Subscriber Identity"; + } + leaf iccid { + type string; + description "ICCID of the SIM card."; + } + } + + container status { + description "Modem Status Information."; + config false; + + leaf sim-active { + type int8; + description "Index of active SIM card."; + } + leaf sim-present { + type boolean; + description "True if SIM card is present"; + } + leaf state { + type string; + description "Modem state."; + } + leaf state-failed-reason { + type string; + description "Reason of failed modem state."; + } + leaf selected-carrier { + type string; + description "Selected carrier."; + } + leaf signal-quality { + type uint8; + description "Signal strength in percent (0-100)."; + } + leaf signal-rssi { + type string; + description "RSSI (Received Signal Strength Indication) in dBm."; + } + leaf signal-rsrp { + type string; + description "RSRP (Reference Signal Received Power) in dBm."; + } + leaf signal-rsrq { + type string; + description "RSRQ (Reference Signal Received Quality) in dB."; + } + leaf signal-rscp { + type string; + description "RSCP (Received Signal Code Power) in dBm."; + } + leaf signal-snr { + type string; + description "SNR (Signal Noise Ratio) in dB."; + } + leaf signal-sinr { + type string; + description "SINR (Signal Interference Noise Ratio) ) in dB."; + } + + container cellular { + description "Cellular network information."; + + leaf registration-state { + description "Modem registration state."; + type registration-state; + } + leaf operator-name { + type string; + description "Name of the cellular network operator."; + } + leaf operator-id { + type string; + description "Identifier of the cellular network operator (MCC/MNC)."; + } + leaf network-type { + type string; + description "Type of cellular network (e.g. LTE)"; + } + leaf packet-service-state { + description "Modem packet service state."; + type string; + } + } + + list bearer { + description "Bearer information."; + key "path"; + + leaf path { + type string; + description "Path to bearer."; + } + leaf interface { + type if:interface-ref; + description "Network interface name for the bearer."; + } + leaf connected { + type boolean; + description "Indicates whether bearer is connected."; + } + leaf connection-failed-reason { + type string; + description "Reason of failed connection of bearer."; + } + leaf ipv4-address { + type inet:ipv4-address; + description "The IPv4 address of the bearer connection."; + } + leaf ipv4-prefix { + type uint16; + description "The IPv4 prefix of the bearer connection."; + } + leaf ipv6-address { + type inet:ipv6-address; + description "The IPv6 address of the bearer connection."; + } + leaf ipv6-prefix { + type uint16; + description "The IPv6 prefix of the bearer connection."; + } + leaf in-bytes { + type yang:counter64; + description "The number of received bytes of the bearer connection."; + } + leaf out-bytes { + type yang:counter64; + description "The number of sent bytes of the bearer connection."; + } + leaf total-in-bytes { + type yang:counter64; + description "Total number of received bytes of all connections."; + } + leaf total-out-bytes { + type yang:counter64; + description "Total number of sent bytes of all connections."; + } + leaf total-duration { + type string; + description "Total duration of all connections in seconds."; + } + } + + container location { + description "Location information."; + + leaf latitude { + type string; + description "Latitude in decimal degrees."; + } + leaf longitude { + type string; + description "Longitude in decimal degrees."; + } + leaf altitude { + type string; + description "Altitude above sea level in meters."; + } + leaf mcc { + type string; + description "Mobile Country Code of the operator."; + } + leaf mnc { + type string; + description "Mobile Network Code of the operator."; + } + leaf lac { + type string; + description "Location Area Code of the operator."; + } + leaf cid { + type string; + description "Cell Identifier of the operator."; + } + leaf tac { + type string; + description "Tracking Area Code of the operator."; + } + } + } + + notification status-update { + description "Notification for modem status updates."; + leaf desc { + type string; + description "Description of the status update."; + mandatory true; + } + } + } + } + + rpc restart { + description "Restart modem."; + input { + leaf index { + type string; + description "Modem index."; + } + } + } + rpc reset { + description "Reset the modem to factory defaults."; + input { + leaf index { + type string; + description "Modem index."; + } + } + } + rpc send-sms { + description "Send an SMS message."; + input { + leaf index { + type string; + description "Modem index."; + } + leaf phone-number { + type string; + description "Recipient's phone number."; + } + leaf message-text { + type string; + description "Text of the SMS message."; + } + } + } + + typedef bearer-apn-type { + description "APN type for bearer."; + + type enumeration { + enum initial { + description "APN used for the initial attach procedure."; + } + enum default { + description "Default connection APN providing access to the Internet."; + } + enum ims { + description "APN providing access to IMS services."; + } + enum mms { + description "APN providing access to MMS services."; + } + enum management { + description "APN providing access to over-the-air device management procedures."; + } + enum voice { + description "APN providing access to voice-over-IP services."; + } + enum emergency { + description "APN providing access to emergency services."; + } + enum private { + description "APN providing access to private networks."; + } + enum purchase { + description "APN providing access to over-the-air activation sites."; + } + enum video_share { + description "APN providing access to video sharing service."; + } + enum local { + description "APN providing access to a local connection with the device."; + } + enum app { + description "APN providing access to certain applications allowed by mobile operators."; + } + enum xcap { + description "APN providing access to XCAP provisioning on IMS services."; + } + enum tethering { + description "APN providing access to mobile hotspot tethering."; + } + } + } + + typedef ip-family { + description "IP address family."; + + type enumeration { + enum ipv4 { + description "IPv4 address family."; + } + enum ipv6 { + description "IPv6 address family."; + } + enum ipv4v6 { + description "IPv4 or IPv6 address family."; + } + } + } + + typedef access-mode { + type enumeration { + enum cs { + description "Circuit-switched technologies (e.g. CSD, GSM)."; + } + enum 2g { + description "2G technologies (e.g. GPRS, EDGE)."; + } + enum 3g { + description "3G technologies (e.g. UMTS, HSxPA)."; + } + enum 4g { + description "4G technologies (e.g. LTE)."; + } + enum 5g { + description "5G technologies (e.g. 5GNR)."; + } + enum any { + description "Any technologies."; + } + } + } + + typedef registration-state { + type enumeration { + enum idle { + description "Not registered, not searching for new operator to register."; + } + enum home { + description "Registered on home network."; + } + enum searching { + description "Not registered, searching for new operator to register with."; + } + enum denied { + description "Registration denied."; + } + enum roaming { + description "Registered on a roaming network."; + } + enum unknown { + description "Unknown registration state."; + } + } + } + + typedef band { + type enumeration { + enum any { + description "Any band."; + } + enum egsm { + description "GSM/GPRS/EDGE 900 MHz."; + } + enum dcs { + description "GSM/GPRS/EDGE 1800 MHz."; + } + enum pcs { + description "GSM/GPRS/EDGE 1900 MHz."; + } + enum g850 { + description "GSM/GPRS/EDGE 850 MHz."; + } + enum g450 { + description "GSM/GPRS/EDGE 450 MHz."; + } + enum g480 { + description "GSM/GPRS/EDGE 480 MHz."; + } + enum g750 { + description "GSM/GPRS/EDGE 750 MHz."; + } + enum g380 { + description "GSM/GPRS/EDGE 380 MHz."; + } + enum g410 { + description "GSM/GPRS/EDGE 410 MHz."; + } + enum g710 { + description "GSM/GPRS/EDGE 710 MHz."; + } + enum g810 { + description "GSM/GPRS/EDGE 810 MHz."; + } + enum utran-1 { + description "UMTS 2100 MHz (IMT, UTRAN band 1)."; + } + enum utran-2 { + description "UMTS 1900 MHz (PCS A-F, UTRAN band 2)."; + } + enum utran-3 { + description "UMTS 1800 MHz (DCS, UTRAN band 3)."; + } + enum utran-4 { + description "UMTS 1700 MHz (AWS A-F, UTRAN band 4)."; + } + enum utran-5 { + description "UMTS 850 MHz (CLR, UTRAN band 5)."; + } + enum utran-6 { + description "UMTS 800 MHz (UTRAN band 6)."; + } + enum utran-7 { + description "UMTS 2600 MHz (IMT-E, UTRAN band 7)."; + } + enum utran-8 { + description "UMTS 900 MHz (E-GSM, UTRAN band 8)."; + } + enum utran-9 { + description "UMTS 1700 MHz (UTRAN band 9)."; + } + enum utran-10 { + description "UMTS 1700 MHz (EAWS A-G, UTRAN band 10)."; + } + enum utran-11 { + description "UMTS 1500 MHz (LPDC, UTRAN band 11)."; + } + enum utran-12 { + description "UMTS 700 MHz (LSMH A/B/C, UTRAN band 12)."; + } + enum utran-13 { + description "UMTS 700 MHz (USMH C, UTRAN band 13)."; + } + enum utran-14 { + description "UMTS 700 MHz (USMH D, UTRAN band 14)."; + } + enum utran-19 { + description "UMTS 800 MHz (UTRAN band 19)."; + } + enum utran-20 { + description "UMTS 800 MHz (EUDD, UTRAN band 20)."; + } + enum utran-21 { + description "UMTS 1500 MHz (UPDC, UTRAN band 21)."; + } + enum utran-22 { + description "UMTS 3500 MHz (UTRAN band 22)."; + } + enum utran-25 { + description "UMTS 1900 MHz (EPCS A-G, UTRAN band 25)."; + } + enum utran-26 { + description "UMTS 850 MHz (ECLR, UTRAN band 26)."; + } + enum utran-32 { + description "UMTS 1500 MHz (L-band, UTRAN band 32)."; + } + enum eutran-1 { + description "E-UTRAN band 1."; + } + enum eutran-2 { + description "E-UTRAN band 2."; + } + enum eutran-3 { + description "E-UTRAN band 3."; + } + enum eutran-4 { + description "E-UTRAN band 4."; + } + enum eutran-5 { + description "E-UTRAN band 5."; + } + enum eutran-6 { + description "E-UTRAN band 6."; + } + enum eutran-7 { + description "E-UTRAN band 7."; + } + enum eutran-8 { + description "E-UTRAN band 8."; + } + enum eutran-9 { + description "E-UTRAN band 9."; + } + enum eutran-10 { + description "E-UTRAN band 10."; + } + enum eutran-11 { + description "E-UTRAN band 11."; + } + enum eutran-12 { + description "E-UTRAN band 12."; + } + enum eutran-13 { + description "E-UTRAN band 13."; + } + enum eutran-14 { + description "E-UTRAN band 14."; + } + enum eutran-17 { + description "E-UTRAN band 17."; + } + enum eutran-18 { + description "E-UTRAN band 18."; + } + enum eutran-19 { + description "E-UTRAN band 19."; + } + enum eutran-20 { + description "E-UTRAN band 20."; + } + enum eutran-21 { + description "E-UTRAN band 21."; + } + enum eutran-22 { + description "E-UTRAN band 22."; + } + enum eutran-23 { + description "E-UTRAN band 23."; + } + enum eutran-24 { + description "E-UTRAN band 24."; + } + enum eutran-25 { + description "E-UTRAN band 25."; + } + enum eutran-26 { + description "E-UTRAN band 26."; + } + enum eutran-27 { + description "E-UTRAN band 27."; + } + enum eutran-28 { + description "E-UTRAN band 28."; + } + enum eutran-29 { + description "E-UTRAN band 29."; + } + enum eutran-30 { + description "E-UTRAN band 30."; + } + enum eutran-31 { + description "E-UTRAN band 31."; + } + enum eutran-32 { + description "E-UTRAN band 32."; + } + enum eutran-33 { + description "E-UTRAN band 33."; + } + enum eutran-34 { + description "E-UTRAN band 34."; + } + enum eutran-35 { + description "E-UTRAN band 35."; + } + enum eutran-36 { + description "E-UTRAN band 36."; + } + enum eutran-37 { + description "E-UTRAN band 37."; + } + enum eutran-38 { + description "E-UTRAN band 38."; + } + enum eutran-39 { + description "E-UTRAN band 39."; + } + enum eutran-40 { + description "E-UTRAN band 40."; + } + enum eutran-41 { + description "E-UTRAN band 41."; + } + enum eutran-42 { + description "E-UTRAN band 42."; + } + enum eutran-43 { + description "E-UTRAN band 43."; + } + enum eutran-44 { + description "E-UTRAN band 44."; + } + enum eutran-45 { + description "E-UTRAN band 45."; + } + enum eutran-46 { + description "E-UTRAN band 46."; + } + enum eutran-47 { + description "E-UTRAN band 47."; + } + enum eutran-48 { + description "E-UTRAN band 48."; + } + enum eutran-49 { + description "E-UTRAN band 49."; + } + enum eutran-50 { + description "E-UTRAN band 50."; + } + enum eutran-51 { + description "E-UTRAN band 51."; + } + enum eutran-52 { + description "E-UTRAN band 52."; + } + enum eutran-53 { + description "E-UTRAN band 53."; + } + enum eutran-54 { + description "E-UTRAN band 54."; + } + enum eutran-55 { + description "E-UTRAN band 55."; + } + enum eutran-56 { + description "E-UTRAN band 56."; + } + enum eutran-57 { + description "E-UTRAN band 57."; + } + enum eutran-58 { + description "E-UTRAN band 58."; + } + enum eutran-59 { + description "E-UTRAN band 59."; + } + enum eutran-60 { + description "E-UTRAN band 60."; + } + enum eutran-61 { + description "E-UTRAN band 61."; + } + enum eutran-62 { + description "E-UTRAN band 62."; + } + enum eutran-63 { + description "E-UTRAN band 63."; + } + enum eutran-64 { + description "E-UTRAN band 64."; + } + enum eutran-65 { + description "E-UTRAN band 65."; + } + enum eutran-66 { + description "E-UTRAN band 66."; + } + enum eutran-67 { + description "E-UTRAN band 67."; + } + enum eutran-68 { + description "E-UTRAN band 68."; + } + enum eutran-69 { + description "E-UTRAN band 69."; + } + enum eutran-70 { + description "E-UTRAN band 70."; + } + enum eutran-71 { + description "E-UTRAN band 71."; + } + enum eutran-85 { + description "E-UTRAN band 85."; + } + enum cdma-bc0 { + description "CDMA Band Class 0 (US Cellular 850MHz)."; + } + enum cdma-bc1 { + description "CDMA Band Class 1 (US PCS 1900MHz)."; + } + enum cdma-bc2 { + description "CDMA Band Class 2 (UK TACS 900MHz)."; + } + enum cdma-bc3 { + description "CDMA Band Class 3 (Japanese TACS)."; + } + enum cdma-bc4 { + description "CDMA Band Class 4 (Korean PCS)."; + } + enum cdma-bc5 { + description "CDMA Band Class 5 (NMT 450MHz)."; + } + enum cdma-bc6 { + description "CDMA Band Class 6 (IMT2000 2100MHz)."; + } + enum cdma-bc7 { + description "CDMA Band Class 7 (Cellular 700MHz)."; + } + enum cdma-bc8 { + description "CDMA Band Class 8 (1800MHz)."; + } + enum cdma-bc9 { + description "CDMA Band Class 9 (900MHz)."; + } + enum cdma-bc10 { + description "CDMA Band Class 10 (US Secondary 800)."; + } + enum cdma-bc11 { + description "CDMA Band Class 11 (European PAMR 400MHz)."; + } + enum cdma-bc12 { + description "CDMA Band Class 12 (PAMR 800MHz)."; + } + enum cdma-bc13 { + description "CDMA Band Class 13 (IMT2000 2500MHz Expansion)."; + } + enum cdma-bc14 { + description "CDMA Band Class 14 (More US PCS 1900MHz)."; + } + enum cdma-bc15 { + description "CDMA Band Class 15 (AWS 1700MHz)."; + } + enum cdma-bc16 { + description "CDMA Band Class 16 (US 2500MHz)."; + } + enum cdma-bc17 { + description "CDMA Band Class 17 (US 2500MHz Forward Link Only)."; + } + enum cdma-bc18 { + description "CDMA Band Class 18 (US 700MHz Public Safety)."; + } + enum cdma-bc19 { + description "CDMA Band Class 19 (US Lower 700MHz)."; + } + enum ngran-1 { + description "NGRAN band 1."; + } + enum ngran-2 { + description "NGRAN band 2."; + } + enum ngran-3 { + description "NGRAN band 3."; + } + enum ngran-5 { + description "NGRAN band 5."; + } + enum ngran-7 { + description "NGRAN band 7."; + } + enum ngran-8 { + description "NGRAN band 8."; + } + enum ngran-12 { + description "NGRAN band 12."; + } + enum ngran-13 { + description "NGRAN band 13."; + } + enum ngran-14 { + description "NGRAN band 14."; + } + enum ngran-18 { + description "NGRAN band 18."; + } + enum ngran-20 { + description "NGRAN band 20."; + } + enum ngran-25 { + description "NGRAN band 25."; + } + enum ngran-26 { + description "NGRAN band 26."; + } + enum ngran-28 { + description "NGRAN band 28."; + } + enum ngran-29 { + description "NGRAN band 29."; + } + enum ngran-30 { + description "NGRAN band 30."; + } + enum ngran-34 { + description "NGRAN band 34."; + } + enum ngran-38 { + description "NGRAN band 38."; + } + enum ngran-39 { + description "NGRAN band 39."; + } + enum ngran-40 { + description "NGRAN band 40."; + } + enum ngran-41 { + description "NGRAN band 41."; + } + enum ngran-48 { + description "NGRAN band 48."; + } + enum ngran-50 { + description "NGRAN band 50."; + } + enum ngran-51 { + description "NGRAN band 51."; + } + enum ngran-53 { + description "NGRAN band 53."; + } + enum ngran-65 { + description "NGRAN band 65."; + } + enum ngran-66 { + description "NGRAN band 66."; + } + enum ngran-70 { + description "NGRAN band 70."; + } + enum ngran-71 { + description "NGRAN band 71."; + } + enum ngran-74 { + description "NGRAN band 74."; + } + enum ngran-75 { + description "NGRAN band 75."; + } + enum ngran-76 { + description "NGRAN band 76."; + } + enum ngran-77 { + description "NGRAN band 77."; + } + enum ngran-78 { + description "NGRAN band 78."; + } + enum ngran-79 { + description "NGRAN band 79."; + } + enum ngran-80 { + description "NGRAN band 80."; + } + enum ngran-81 { + description "NGRAN band 81."; + } + enum ngran-82 { + description "NGRAN band 82."; + } + enum ngran-83 { + description "NGRAN band 83."; + } + enum ngran-84 { + description "NGRAN band 84."; + } + enum ngran-86 { + description "NGRAN band 86."; + } + enum ngran-89 { + description "NGRAN band 89."; + } + enum ngran-90 { + description "NGRAN band 90."; + } + enum ngran-91 { + description "NGRAN band 91."; + } + enum ngran-92 { + description "NGRAN band 92."; + } + enum ngran-93 { + description "NGRAN band 93."; + } + enum ngran-94 { + description "NGRAN band 94."; + } + enum ngran-95 { + description "NGRAN band 95."; + } + enum ngran-257 { + description "NGRAN band 257."; + } + enum ngran-258 { + description "NGRAN band 258."; + } + enum ngran-260 { + description "NGRAN band 260."; + } + enum ngran-261 { + description "NGRAN band 261."; + } + } + } + + typedef location-source { + type enumeration { + enum gps { + description "GPS location source."; + } + enum agps-msa { + description "MSA A-GPS location source."; + } + enum agps-msb { + description "MSB A-GPS location source."; + } + enum 3gpp { + description "3GPP location source."; + } + enum cdma { + description "CDMA location source."; + } + } + } +} diff --git a/src/confd/yang/confd/infix-modem@2024-03-15.yang b/src/confd/yang/confd/infix-modem@2024-03-15.yang new file mode 120000 index 000000000..467b07a47 --- /dev/null +++ b/src/confd/yang/confd/infix-modem@2024-03-15.yang @@ -0,0 +1 @@ +infix-modem.yang \ No newline at end of file diff --git a/src/modemd/77-mm-dell-port-types.rules b/src/modemd/77-mm-dell-port-types.rules new file mode 100644 index 000000000..376b75f35 --- /dev/null +++ b/src/modemd/77-mm-dell-port-types.rules @@ -0,0 +1,39 @@ +# do not edit this file, it will be overwritten on update + +ACTION!="add|change|move|bind", GOTO="mm_dell_port_types_end" + +SUBSYSTEMS=="usb", ATTRS{idVendor}=="413c", GOTO="mm_dell_vendorcheck" +GOTO="mm_dell_port_types_end" + +LABEL="mm_dell_vendorcheck" +SUBSYSTEMS=="usb", ATTRS{bInterfaceNumber}=="?*", ENV{.MM_USBIFNUM}="$attr{bInterfaceNumber}" + +# Dell DW5821e (default 0x81d7, with esim support 0x81e0) +# if 02: primary port +# if 03: secondary port +# if 04: raw NMEA port +# if 05: diag/qcdm port +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="02", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="03", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_SECONDARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="04", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_GPS}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="05", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_QCDM}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="02", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="03", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_SECONDARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="04", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_GPS}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="05", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_QCDM}="1" + +# Dell DW5820e +# if 02: AT port +# if 04: debug port (ignore) +# if 06: AT port +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d9", ENV{.MM_USBIFNUM}=="04", ENV{ID_MM_PORT_IGNORE}="1" + +# Dell DW5811e (Sierra Wireless EM7455 rebadge) +# USB composition 8: MBIM-only (bInterfaceClass=02 bInterfaceSubClass=0e) +# Bound by cdc_mbim driver; no AT ports in this composition. +# if 0c: MBIM control port +# if 0d: data port (handled by cdc_mbim / cdc_ncm driver) +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81b6", ENV{.MM_USBIFNUM}=="0c", SUBSYSTEM=="usbmisc", ENV{ID_MM_PORT_TYPE_MBIM}="1" + +GOTO="mm_dell_port_types_end" +LABEL="mm_dell_port_types_end" diff --git a/src/modemd/LICENSE b/src/modemd/LICENSE new file mode 100644 index 000000000..3db075fa2 --- /dev/null +++ b/src/modemd/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2024 Avvero Pty Ltd +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of copyright holders nor the names of + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/modemd/finit.conf b/src/modemd/finit.conf new file mode 100644 index 000000000..090a51913 --- /dev/null +++ b/src/modemd/finit.conf @@ -0,0 +1 @@ +service [2345] name:modemd /sbin/modemd -- Modem daemon diff --git a/src/modemd/modem-carrier b/src/modemd/modem-carrier new file mode 100755 index 000000000..d5cc85072 --- /dev/null +++ b/src/modemd/modem-carrier @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 + +import subprocess +import argparse +import json +import syslog +import sys +import os +import re + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def log(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def read_file(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +# Telit LE910 +le910_carriers = [ + {"num": 0, "name": "AT&T"}, + {"num": 1, "name": "Verizon"}, + {"num": 2, "name": "T-Mobile"}, + {"num": 3, "name": "Bell"}, + {"num": 4, "name": "Telus"}, + {"num": 10, "name": "NTT-Docomo"}, + {"num": 11, "name": "Telstra"}, + {"num": 12, "name": "KDDI"}, + {"num": 13, "name": "Softbank"}, + {"num": 14, "name": "Vodafone New Zealand"}, + {"num": 15, "name": "Spark New Zealand"}, + {"num": 20, "name": "China Mobile"}, + {"num": 21, "name": "China Unicom"}, + {"num": 22, "name": "China Telecom"}, + {"num": 30, "name": "Sprint"}, + {"num": 31, "name": "SouthernLINC"}, + {"num": 40, "name": "Global"}, + {"num": 101, "name": "T-Mobile Germany"}, + {"num": 102, "name": "AT&T Mexico"}, + {"num": 103, "name": "Orange-WW"}, + {"num": 104, "name": "Southern Linc USA"}, + {"num": 105, "name": "Vodafone DE"} +] + + +def le910_carrier_bynum(num): + for carrier in le910_carriers: + if carrier["num"] == num: + return carrier + return None + + +def le910_carrier_byname(name): + for carrier in le910_carriers: + if carrier["name"] == name: + return carrier + return None + + +def le910_is_supported(variant): + if variant == "NS": + return True + elif variant == "AP": + return True + elif variant == "NF" or variant == "NFD": + return True + elif variant == "CN": + return True + elif variant == "APX": + return True + elif variant == "WWX" or variant == "WWXD": + return True + else: + return False + + +def le910_carrier_default(variant): + if variant == "NF" or variant == "WWX" or variant == "WWXD": + num = 0 + elif variant == "AP" or variant == "APX": + num = 10 + elif variant == "CN": + num = 20 + elif variant == "NS": + num = 30 + elif variant == "EU" or variant == "EUX": + num = 40 + else: + return None + + return le910_carrier_bynum(num) + + +def le910_command(index, command): + cmd = ['/sbin/modem-command', + '--index', str(index), + '--mode', '115200 8N1', + '--secondary', + '--timeout', '5', + command] + + for attempt in range(1, 3): + output = runcmd(cmd) + if output: + return output + + err("Command '%s' failed" % " ".join(cmd)) + return None + + +def le910_list_carriers(index): + output = le910_command(index, 'AT#FWSWITCH=?') + if output is None: + return None + + r = re.search(r"#FWSWITCH: \(([^\)]*)\)", output) + if not r: + err("Unable to list carriers on modem%d" % index) + return None + + rlist = r.group(1) + nums = [] + for rl in rlist.split(","): + r = re.search(r"(\d+)-(\d+)", rl) + if r: + start = int(r.group(1)) + end = int(r.group(2)) + for i in range(start, end + 1): + nums.append(i) + else: + nums.append(int(rl)) + + carriers = [] + for num in nums: + carrier = le910_carrier_bynum(num) + if carrier: + carriers.append(carrier) + else: + err("Unknown carrier %d" % num) + + return carriers + + +def le910_set_carrier(index, carrier): + output = le910_command(index, 'AT#FWSWITCH=%d' % carrier["num"]) + if output is None: + return False + else: + return True + + +def le910_get_carrier(index): + output = le910_command(index, 'AT#FWSWITCH?') + if output is None: + return -1 + + r = re.search(r"#FWSWITCH: (\d+)", output) + if not r: + err("Unable to query carrier on modem%d" % index) + return -1 + num = int(r.group(1)) + + return le910_carrier_bynum(num) + + +def runcheck(manf, model): + if manf == "Telit" and model[:5] == "LE910": + return le910_is_supported(model.split("-")[1]) + else: + return False + + +def runset(index, manf, model, name): + if not runcheck(manf, model): + fatal("Unsupported modem") + + if name == "default": + carrier = le910_carrier_default(model.split("-")[1]) + else: + carrier = le910_carrier_byname(name) + if not carrier: + fatal("Invalid carrier '%s'" % name) + + current = le910_get_carrier(index) + if not current: + fatal("Cannot get current carrier") + + if current["name"] == carrier["name"]: + log("Carrier '%s' already set on modem%d" % (carrier["name"], index)) + print(json.dumps({"action": "none"})) + sys.exit(0) + + log("Setting carrier '%s' on modem%d" % (carrier["name"], index)) + if not le910_set_carrier(index, carrier): + fatal("Unable to set carrier on modem%d" % index) + + print(json.dumps({"action": "reset"})) + sys.exit(0) + + +def runget(index, manf, model): + if not runcheck(manf, model): + fatal("Unsupported modem") + + carrier = le910_get_carrier(index) + if not carrier: + fatal("Unable to get carrier") + + print(json.dumps(carrier)) + sys.exit(0) + + +def runlist(index, manf, model): + if not runcheck(manf, model): + print(json.dumps([])) + sys.exit(0) + + carriers = le910_list_carriers(index) + if not carriers: + fatal("No carriers") + + names = [] + for carrier in sorted(carriers, key=lambda c: c["name"]): + names.append(carrier["name"]) + + print(json.dumps(names)) + sys.exit(0) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-carrier') + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-m", "--manf", default=0, help="Modem manufacturer") + parser.add_argument("-M", "--model", default=0, help="Modem model") + parser.add_argument("-s", "--set", help="Set carrier") + parser.add_argument("-g", "--get", help="Get carrier", + action="store_true") + parser.add_argument("-l", "--list", help="List supported carriers", + action="store_true") + parser.add_argument("-c", "--check", help="Check support for carriers", + action="store_true") + args = parser.parse_args() + + if not args.manf: + fatal("No manufacturer given") + else: + manf = args.manf + + if not args.model: + fatal("No model given") + else: + model = args.model + + if args.index: + index = int(args.index) + else: + index = 0 + + if args.check: + if not runcheck(manf, model): + sys.exit(1) + elif args.list: + runlist(index, manf, model) + elif args.set: + runset(index, manf, model, args.set) + elif args.get: + runget(index, manf, model) + + sys.exit(0) diff --git a/src/modemd/modem-command.c b/src/modemd/modem-command.c new file mode 100644 index 000000000..96a2f7aa1 --- /dev/null +++ b/src/modemd/modem-command.c @@ -0,0 +1,617 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int verbose = 0; + +#define MODEMS_LOCK "/var/lock/modems.lock" +#define MODEMS_INFO "/run/modems.json" +#define TIMEOUT 3 + +#define DBG(fmt, a...) print(LOG_DEBUG, fmt, ##a) +#define SAY(fmt, a...) print(LOG_INFO, fmt, ##a) +#define ERR(fmt, a...) print(LOG_ERR, fmt, ##a) +#define FATAL(fmt, a...) print(LOG_ALERT, fmt, ##a) + +static void print (int level, const char *fmt, ...) +{ + char msg[1024]; + va_list ap; + + if (!fmt) return; + va_start(ap, fmt); + vsnprintf(msg, sizeof(msg) - 1, fmt, ap); + va_end(ap); + + switch (level) { + case LOG_ERR: + fprintf(stderr, "ERROR: %s\n", msg); + break; + case LOG_ALERT: + fprintf(stderr, "ERROR: %s\n", msg); + unlink(MODEMS_LOCK); + exit(1); + break; + case LOG_DEBUG: + if (verbose) { + printf("DEBUG: %s\n", msg); + } + break; + default: + printf("%s\n", msg); + break; + } +} + +static void printser (int dir, char *ser) +{ + char quoted[1024]; + char *p; + int len = 0; + + for (p = ser; *p; p++) { + if (*p == '\r') { + len += snprintf(quoted + len, sizeof(quoted) - len, ""); + } else if (*p == '\n') { + len += snprintf(quoted + len, sizeof(quoted) - len, ""); + } else { + len += snprintf(quoted + len, sizeof(quoted) - len, "%c", *p); + } + } + if (dir == 0) { + DBG("Received: '%s'", quoted); + } else { + DBG("Sending '%s'", quoted); + } +} + + +struct ttymode { + int set; + int speed; + int parity; + int bits; + int stopbits; +}; + +static int parse_mode (const char *str, struct ttymode *mode) +{ + char buf[256]; + char *t1, *t2; + + /* parse string */ + if (strlen(str) >= sizeof(buf)) { + return -1; + } + strncpy(buf, str, sizeof(buf)); + t1 = strtok(buf, " "); + if (!t1) { + ERR("Invalid format"); + return -1; + } + t2 = strtok(NULL, " "); + if (!t2 || strlen(t2) != 3) { + ERR("Invalid format"); + return -1; + } + + /* parse speed */ + switch (atoi(t1)) { + case 9600: + mode->speed = B9600; + break; + case 19200: + mode->speed = B19200; + break; + case 38400: + mode->speed = B38400; + break; + case 57600: + mode->speed = B57600; + break; + case 115200: + mode->speed = B115200; + break; + default: + ERR("Invalid baudrate"); + return -1; + } + + /* parse bits */ + switch (t2[0]) { + case '5': + mode->bits = CS5; + break; + case '6': + mode->bits = CS6; + break; + case '7': + mode->bits = CS7; + break; + case '8': + mode->bits = CS8; + break; + default: + ERR("Invalid bits"); + return -1; + } + + /* parse parity */ + switch (t2[1]) { + case 'N': + mode->parity = 0; + break; + case 'E': + mode->parity = PARENB; + break; + case 'O': + mode->parity = PARENB | PARODD; + break; + default: + ERR("Invalid parity"); + return -1; + } + + /* parse stopbits */ + switch (t2[2]) { + case '1': + mode->stopbits = 0; + break; + case '2': + mode->stopbits = CSTOPB; + break; + default: + ERR("Invalid stop bits %c"); + return -1; + } + + return 0; +} + + +static int setup (const char *tty, int timeout, struct ttymode *mode) +{ + struct termios stbuf; + struct stat st; + char dev[32]; + int i, fd; + + if (strncmp(tty, "/dev", 4) == 0) { + strncpy(dev, tty, sizeof(dev)); + } else { + snprintf(dev, sizeof(dev), "/dev/%s", tty); + } + + /* wait for device */ + for (i = 0; i < timeout; i++) { + if (lstat(dev, &st) == 0) break; + sleep(1); + } + if (i >= timeout) { + ERR("Timeout waiting for %s", dev); + return -1; + } + + DBG("Opening %s", tty); + fd = open(dev, O_RDWR | O_EXCL | O_NONBLOCK | O_NOCTTY); + if (fd < 0) { + ERR("Unable to open '%s'", dev); + return -1; + } + + if (!mode->set) return fd; + + memset(&stbuf, 0, sizeof (struct termios)); + if (tcgetattr(fd, &stbuf) != 0) { + ERR("Unable to get serial attributes"); + goto error; + } + /* default settings */ + stbuf.c_cflag &= ~(CBAUD | CSIZE | CSTOPB | PARENB | PARODD | CRTSCTS); + stbuf.c_iflag &= ~(IGNCR | ICRNL | IUCLC | INPCK | IXON | IXOFF | IXANY ); + stbuf.c_oflag &= ~(OPOST | OLCUC | OCRNL | ONLCR | ONLRET); + stbuf.c_lflag &= ~(ICANON | ECHO | ECHOE | ECHONL); + stbuf.c_cc[VMIN] = 1; + stbuf.c_cc[VTIME] = 0; + stbuf.c_cc[VEOF] = 1; + + /* ignore parity */ + stbuf.c_iflag |= IGNPAR; + + /* configure serial attributes */ + stbuf.c_cflag |= (mode->bits | mode->parity | mode->stopbits | CLOCAL | CREAD); + + /* disable XON/XOFF flow control */ + stbuf.c_iflag &= ~(IXON | IXOFF | IXANY); + + /* disable RTS/CTS flow control */ + stbuf.c_cflag &= ~(CRTSCTS); + + if (cfsetispeed(&stbuf, mode->speed) != 0 || + cfsetospeed(&stbuf, mode->speed) != 0) { + ERR("Unable to set port speed"); + goto error; + } + + if (tcsetattr(fd, TCSANOW, &stbuf) != 0) { + ERR("Unable to set serial attributes"); + goto error; + } + + return fd; +error: + close(fd); + return -1; +} + +static int serial_write (int fd, int timeout, char *buf, int len) +{ + struct timeval tv; + fd_set fds; + + FD_ZERO(&fds); + FD_SET(fd, &fds); + tv.tv_sec = timeout; + tv.tv_usec = 0; + + if (select(fd + 1, NULL, &fds, NULL, &tv) <= 0) { + DBG("Cannot write to serial device"); + return -1; + } + alarm(timeout); + tcflush(fd, TCIOFLUSH); + + printser(1, buf); + if (write(fd, buf, len) != len) { + return -1; + } + tcdrain(fd); + alarm(0); + + return 0; +} + +static int serial_read (int fd, int timeout, char *buf, int size) +{ + struct timeval tv; + fd_set fds; + int rc, len = 0; + + FD_ZERO(&fds); + FD_SET(fd, &fds); + tv.tv_sec = timeout; + tv.tv_usec = 0; + memset(buf, 0, size); + + rc = select(fd + 1, &fds, NULL, NULL, &tv); + if (rc <= 0) { + DBG("Cannot read from serial device (%d)", rc); + return -1; + } + do { + errno = 0; + alarm(timeout); + rc = read(fd, buf+len, size-len-1); + alarm(0); + + if (rc > 0) { + len += rc; + } else { + if (rc < 0 && errno == -EAGAIN) { + continue; + } else { + break; + } + } + } while (1); + + if (len > 0) buf[len] = '\0'; + + printser(0, buf); + + return len; +} + +static int execute (int fd, int timeout, const char *cmd, const char *expect) +{ + char buf[1024]; + time_t start; + long elapsed; + int len, ret = -1; + + if (cmd) { + start = time(NULL); + len = snprintf(buf, sizeof(buf), "%s\r\n", cmd); + if (serial_write(fd, timeout, buf, len) < 0) { + DBG("Cannot write to serial device"); + return -1; + } + elapsed = time(NULL) - start; + timeout -= elapsed; + } + + DBG("Waiting for response"); + + while (timeout > 0) { + start = time(NULL); + len = serial_read(fd, timeout, buf, sizeof(buf)); + if (len > 0) { + buf[len] = '\0'; + SAY("%s", buf); + + if (strstr(buf, expect)) { + ret = 0; + break; + } + } + elapsed = time(NULL) - start; + timeout -= elapsed; + } + return ret; +} + +static int probe (int fd) +{ + char buf[256]; + int i, len; + + DBG("Probing serial port"); + + /* try 3 times */ + for (i = 3; i > 0; i--) { + len = snprintf(buf, sizeof(buf), "AT\r\n"); + if (serial_write(fd, 1, buf, len) < 0) { + DBG("Cannot write to tty"); + continue; + } + len = serial_read(fd, 1, buf, sizeof(buf)); + if (len <= 0) { + DBG("Cannot read from tty"); + continue; + } + if (strstr(buf, "OK")) { + return 0; + } + } + + DBG("Probing failed"); + return -1; +} + +static int modemidx (const char *devpath) +{ + json_t *json, *modules, *module, *mod, *paths, *obj; + const char *p; + int i, j, index = -1; + + json = json_load_file("/run/modules.json", 0, NULL); + if (!json) { + return -1; + } + modules = json_object_get(json, "modules"); + if (!modules || !json_is_array(modules)) { + goto out; + } + for (i = 0; i < json_array_size(modules); i++) { + module = json_array_get(modules, i); + if (!module) continue; + + obj = json_object_get(module, "type"); + if (!obj || !json_is_string(obj)) continue; + p = json_string_value(obj); + if (strcmp(p, "modem") != 0) continue; + + obj = json_object_get(module, "index"); + if (!obj || !json_is_integer(obj)) continue; + index = json_integer_value(obj); + + paths = json_object_get(module, "paths"); + if (!paths || !json_is_array(paths)) continue; + + for (j = 0; j < json_array_size(paths); j++) { + obj = json_array_get(paths, j); + if (!obj) continue; + p = json_string_value(obj); + + if (strstr(devpath, p)) { + /* found */ + goto out; + } + } + } + index = -1; +out: + json_decref(json); + return index; +} + +static char *modemdev (int index, int port) +{ + json_t *json, *modems, *modem, *ports, *obj; + char *dev = NULL; + int i, p, fd; + + fd = open(MODEMS_LOCK, + O_WRONLY | O_CREAT | O_EXCL, + S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH); + if (fd < 0) { + ERR("Cannot open lock"); + return NULL; + } + if (flock(fd, LOCK_EX) < 0) { + ERR("Cannot to lock"); + close(fd); + return NULL; + } + + json = json_load_file(MODEMS_INFO, 0, NULL); + if (!json) { + goto out; + } + modems = json_object_get(json, "modems"); + if (!modems || !json_is_array(modems)) { + goto out; + } + for (i = 0; i < json_array_size(modems); i++) { + modem = json_array_get(modems, i); + if (!modem) + continue; + + obj = json_object_get(modem, "devpath"); + if (!obj || !json_is_string(obj)) + continue; + + if (modemidx(json_string_value(obj)) != index) + continue; + + ports = json_object_get(modem, "atports"); + if (!ports || !json_is_array(ports) || json_array_size(ports) == 0) + goto out; + + if (json_array_size(ports) >= port) + port = 0; + + obj = json_array_get(ports, port); + if (!obj || !json_is_string(obj)) + goto out; + + /* found */ + dev = strdup(json_string_value(obj)); + goto out; + } + dev = NULL; + +out: + if (json) json_decref(json); + + unlink(MODEMS_LOCK); + flock(fd, LOCK_UN); + close(fd); + + return dev; +} + +static struct option options[] = { + { "index", required_argument, 0, 'i' }, + { "device", required_argument, 0, 'd' }, + { "mode", required_argument, 0, 'm' }, + { "command", required_argument, 0, 'c' }, + { "expect", required_argument, 0, 'e' }, + { "timeout", required_argument, 0, 't' }, + { "primary", no_argument, 0, 'P' }, + { "secondary", no_argument, 0, 'S' }, + { "verbose", no_argument, 0, 'v' }, + { "help", no_argument, 0, 'h' }, + { 0, 0, 0, 0 } +}; + +static void usage (void) +{ + fprintf(stderr, "Usage: modem-command [OPTIONS] [command]\n\n"); + fprintf(stderr, " --index Index of modem\n"); + fprintf(stderr, " --device TTY device of modem\n"); + fprintf(stderr, " --mode Serial mode for TTY (e.g. '115200 8N1')\n"); + fprintf(stderr, " --expect String to expect\n"); + fprintf(stderr, " --timeout Timeout for operation\n"); + fprintf(stderr, " --primary Use primary atport\n"); + fprintf(stderr, " --secondary Use secondary atport\n"); + fprintf(stderr, " --verbose Be verbose\n"); + fprintf(stderr, " --help print help\n\n"); + exit(1); +} + +int main (int argc, char *argv[]) +{ + const char *cmd = NULL, *expect = "OK"; + char *dev = NULL; + struct ttymode mode; + int timeout = TIMEOUT; + int index = 0, port = 0; + int opt, c, fd; + int ret = 1; + + memset(&mode, 0, sizeof(struct ttymode)); + + while (1) { + c = getopt_long(argc, argv, "i:d:m:e:t:PSvh", options, &opt); + if (c < 0) break; + + switch (c) { + case 'i': + index = atoi(optarg); + break; + case 'd': + dev = strdup(optarg); + break; + case 'm': + if (parse_mode(optarg, &mode) < 0) { + FATAL("Invalid serial mode specified"); + } + mode.set = 1; + break; + case 'c': + cmd = optarg; + break; + case 'e': + expect = optarg; + break; + case 't': + timeout = atoi(optarg); + break; + case 'P': + port = 0; + break; + case 'S': + port = 1; + break; + case 'v': + verbose = 1; + break; + default: + usage(); + break; + } + } + + if (optind < argc) { + cmd = argv[optind]; + if (strlen(cmd) == 0) + FATAL("Invalid command"); + } + dev = modemdev(index, port); + if (!dev || strlen(dev) == 0) { + FATAL("No device found"); + } + fd = setup(dev, timeout, &mode); + if (fd < 0) { + FATAL("Unable to setup device"); + } + + if (probe(fd) < 0) { + ERR("Unable to probe device"); + goto abort; + } + if (execute(fd, timeout, cmd, expect) < 0) { + ERR("Command failed"); + goto abort; + } + ret = 0; + +abort: + close(fd); + if (dev) free(dev); + return ret; +} diff --git a/src/modemd/modem-info b/src/modemd/modem-info new file mode 100755 index 000000000..2f1684b26 --- /dev/null +++ b/src/modemd/modem-info @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 + +import subprocess +import argparse +import syslog +import json +import sys +import os +import re + +debug = False + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def dbg(msg): + global debug + if debug: + syslog.syslog(syslog.LOG_INFO, msg) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def vbool(val): + if val == "yes" or val == "true": + return True + else: + return False + + +def vnum(val): + if val != "--": + return int(val) + else: + return 0 + + +def vnumstr(val): + if val != "" and val != "--": + return val + else: + return "0" + + +def vstr(val): + if val != "--": + return val + else: + return "" + + +def venum(val, enums, default): + enval = vstr(val) + for en in enums: + if enval == en: + return en + return default + + +def vip4(val): + val = vstr(val) + if val == "": + return "0.0.0.0" + else: + return val + + +def vip6(val): + val = vstr(val) + if val == "": + return "::" + else: + return val + + +def fread(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +def freadj(path): + output = fread(path) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + dbg("Unable to parse json output") + return None + finally: + return data + + +def get_manufacturer(index, gen): + path = "/run/modemd/modem%d/manufacturer" % index + manf = fread(path) + if manf: + return manf + + manf = vstr(gen.get("manufacturer", "")) + return manf + + +def get_model(index, gen): + path = "/run/modemd/modem%d/model" % index + model = fread(path) + if model: + return model + + model = vstr(gen.get("model", "")) + return model + + +def get_supported_carriers(index, manf, model): + path = "/run/modemd/modem%d/supported-carriers" % index + output = freadj(path) + if output: + return output + else: + return [] + + +def get_current_carrier(index, manf, model): + path = "/run/modemd/modem%d/current-carrier" % index + output = freadj(path) + if output: + return output + else: + return "default" + + +def device_devpath(devpath): + path = devpath + while path != "/sys": + path = os.path.realpath("%s/.." % path) + subdir = os.path.basename(path) + r = re.search(r"usb\d", subdir) + if r: + return path + return None + + +def modem_get_module(devpath): + if not os.path.exists("/run/modules.json"): + if os.path.exists("/run/modems.json"): + with open("/run/modems.json", "r") as fd: + data = json.load(fd) + for index, modem in enumerate(data.get("modems", [])): + if devpath in modem["devpath"]: + return {"index": index, "slot": index, "paths": [modem["devpath"]], "type": "modem"} + return None + + with open("/run/modules.json", "r") as fd: + data = json.load(fd) + + for module in data["modules"]: + if module["type"] != "modem": + continue + for path in module["paths"]: + if path in devpath: + return module + return None + + +def modem_get_sim(module): + path = "/sys/class/sim" + for index in range(0, 5): + name = "sim%d" % index + if not os.path.exists("%s/%s" % (path, name)): + continue + + slot = int(fread("%s/%s/slot" % (path, name))) + if slot != module["slot"]: + continue + + present = int(fread("%s/%s/present" % (path, name))) + + return { + "index": index, + "name": name, + "slot": slot, + "present": bool(present) + } + + return None + + +def sysfs_interfaces(devpath): + ifaces = [] + try: + for iface in os.listdir("/sys/class/net"): + p = os.path.realpath("/sys/class/net/%s" % iface) + if p.startswith(devpath): + ifaces.append(iface) + except OSError: + pass + return ifaces + + +def print_modem(index): + with open("/run/modems.json", "r") as fd: + data = json.load(fd) + + for modem in data["modems"]: + module = modem_get_module(modem["devpath"]) + if module["index"] == index: + modem["slot"] = module["slot"] + modem["sim"] = modem_get_sim(module) or {"index": 0, "name": "sim0", "slot": 0, "present": True} + if not modem.get("interfaces"): + modem["interfaces"] = sysfs_interfaces(modem["devpath"]) + print(json.dumps(modem)) + sys.exit(0) + + print(json.dumps({})) + sys.exit(0) + + +def print_all(): + modems = [] + + output = runcmdj(['mmcli', '-J', '-L']) + if not output: + dbg("mmcli command failed") + print(json.dumps(modems)) + sys.exit(0) + + for mpath in output['modem-list']: + output = runcmdj(['mmcli', '-J', '-m', mpath]) + if not output: + dbg("no output for %s" % mpath) + continue + modem = output.get("modem") + if modem is None: + dbg("no modem for %s" % mpath) + continue + gen = modem.get("generic") + if gen is None: + dbg("no generic for %s" % mpath) + continue + gpp = modem.get("3gpp") + if gpp is None: + dbg("no 3gpp for %s" % mpath) + continue + + devpath = device_devpath(gen["device"]) + if devpath is None: + dbg("no devpath for %s" % mpath) + continue + + module = modem_get_module(devpath) + if module is None: + dbg("no modem module for %s" % devpath) + continue + + index = module["index"] + + manf = get_manufacturer(index, gen) + if manf is None: + dbg("no manufacturer for modem%d" % index) + continue + + model = get_model(index, gen) + if model is None: + dbg("no model for modem%d" % index) + continue + + modem = { + "index": index, + "path": mpath + } + + modem["info"] = { + "manufacturer": manf, + "model": model, + "supported-carrier": get_supported_carriers(index, manf, model), + "hardware-revision": vstr(gen["hardware-revision"]), + "firmware-version": vstr(gen["revision"]), + "serial-number": vstr(gen["equipment-identifier"]), + "phone-number": gen.get("own-numbers", []), + } + + modem["status"] = { + "state": vstr(gen["state"]), + "selected-carrier": get_current_carrier(index, manf, model), + "signal-quality": vnum(gen["signal-quality"]["value"]) + } + + if gen["state"] == "failed": + reason = vstr(gen["state-failed-reason"]) + if len(reason) > 0: + modem["status"]["state-failed-reason"] = reason + + sim = modem_get_sim(module) + if sim is not None: + modem["status"]["sim-active"] = sim["index"] + modem["status"]["sim-present"] = sim["present"] + + refresh = 0 + output = runcmdj(['mmcli', '-J', '-m', mpath, '--signal-get']) + if output: + refresh = int(output["modem"]["signal"]["refresh"]["rate"]) + if refresh == 0: + # enable extended signal info + runcmd(['mmcli', '-J', '-m', mpath, '--signal-setup=5']) + else: + signals = ["rssi", "rsrp", "rsrq", "rscp", "snr", "sinr"] + found = False + for tech in output["modem"]["signal"].keys(): + for sig in signals: + v = output["modem"]["signal"][tech].get(sig, "--") + if v != "--": + modem["status"]["signal-%s" % sig] = v + found = True + if found: + break + + modem["status"]["cellular"] = { + "registration-state": + venum(gpp["registration-state"], + ["idle", "home", "searching", "denied", "roaming"], + "unknown"), + "operator-name": vstr(gpp["operator-name"]), + "operator-id": vstr(gpp["operator-code"]), + "network-type": ",".join(vstr(gen["access-technologies"])).upper(), + "service-state": vstr(gpp["packet-service-state"]) + } + + bearers = [] + for bpath in gen["bearers"]: + output = runcmdj(['mmcli', '-J', '-m', mpath, '-b', bpath]) + if not output: + continue + + b = output["bearer"] + + bearer = { + "path": vstr(bpath), + "connected": vbool(b["status"]["connected"]), + "ipv4-address": vip4(b["ipv4-config"]["address"]), + "ipv4-prefix": vnum(b["ipv4-config"]["prefix"]), + "ipv6-address": vip6(b["ipv6-config"]["address"]), + "ipv6-prefix": vnum(b["ipv6-config"]["prefix"]), + "in-bytes": vnumstr(b["stats"]["bytes-rx"]), + "out-bytes": vnumstr(b["stats"]["bytes-tx"]), + "total-in-bytes": vnumstr(b["stats"]["total-bytes-rx"]), + "total-out-bytes": vnumstr(b["stats"]["total-bytes-tx"]), + "total-duration": vnumstr(b["stats"]["total-duration"]) + } + if b["status"]["connected"] != "yes": + reason = vstr(b["status"]["connection-error"]["message"]) + if len(reason) > 0: + bearer["connection-failed-reason"] = reason + + iface = vstr(b["status"]["interface"]) + if len(iface) > 0: + bearer["interface"] = iface + + bearers.append(bearer) + + modem["status"]["bearer"] = bearers + + output = runcmdj(['mmcli', '-J', '-m', mpath, '-i', gen["sim"]]) + if output: + modem["info"]["imsi"] = output["sim"]["properties"]["imsi"] + modem["info"]["iccid"] = output["sim"]["properties"]["iccid"] + + output = runcmdj(['mmcli', '-J', '-m', mpath, '--location-get']) + if output: + loc = output["modem"]["location"] + modem["status"]["location"] = { + "latitude": vstr(loc["gps"]["latitude"]), + "longitude": vstr(loc["gps"]["longitude"]), + "altitude": vstr(loc["gps"]["altitude"]), + "cid": vstr(loc["3gpp"]["cid"]), + "lac": vstr(loc["3gpp"]["lac"]), + "mcc": vstr(loc["3gpp"]["mcc"]), + "mnc": vstr(loc["3gpp"]["mnc"]), + "tac": vstr(loc["3gpp"]["tac"]) + } + + modems.append(modem) + + modems = sorted(modems, key=lambda m: m['index']) + data = json.dumps(modems) + + print(data) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-info') + parser.add_argument("-i", "--index", default=0, help="Modem index") + args = parser.parse_args() + + with open("/proc/cmdline", 'r') as fd: + cmdline = fd.read() + if "debug" in cmdline: + debug = True + + if args.index: + print_modem(int(args.index)) + else: + print_all() diff --git a/src/modemd/modem-power b/src/modemd/modem-power new file mode 100755 index 000000000..9bcb67a0e --- /dev/null +++ b/src/modemd/modem-power @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +import subprocess +import argparse +import json +import time +import syslog +import sys + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def log(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def reset(index, info): + log("Power-cycling modem %d" % index) + + with open("%s/authorized" % info["devpath"], "w") as fd: + fd.write("0\n") + time.sleep(0.5) + + path = "/sys/class/pcie/slot%d/pwrctl" % info["slot"] + with open(path, "w") as fd: + fd.write("cycle\n") + + return True + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-power') + parser.add_argument("-i", "--index", default=0, help="Modem index") + args = parser.parse_args() + + if args.index: + index = int(args.index) + else: + index = 0 + + info = runcmdj(['/usr/libexec/modemd/modem-info', + '-i', str(index)]) + if info is None: + fatal("Unable to obtain modem info") + + if not reset(index, info): + fatal("Unable to power-cycle modem") + + sys.exit(0) diff --git a/src/modemd/modem-rpc b/src/modemd/modem-rpc new file mode 100755 index 000000000..5bc7ecd7d --- /dev/null +++ b/src/modemd/modem-rpc @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sys +import os + + +def sendrpc(index, rpc): + path = "/run/modemd/modem%d/rpc" % index + if os.path.exists(path): + sys.stderr.write("Error: another rpc is running\n") + return + + with open(path, "w") as fd: + json.dump(rpc, fd) + + if not os.system(['sysrepocfg', '-f', 'json', '-R', path]): + sys.stderr.write("Error: unable to restart\n") + + os.unlink(path) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-rpc') + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-r", "--rpc", default=0, help="RPC command") + args = parser.parse_args() + + if args.index: + index = int(args.index) + else: + index = 0 + + if not args.rpc: + sys.stderr.write("Error: no rpc command\n") + sys.exit(1) + + print("Sending '%s' rpc to modem%d" % (args.rpc, index)) + + rpc = {"infix-modem:%s" % args.rpc: {"index": str(index)}} + + sendrpc(index, rpc) diff --git a/src/modemd/modem-scan-networks b/src/modemd/modem-scan-networks new file mode 100755 index 000000000..288e7fa60 --- /dev/null +++ b/src/modemd/modem-scan-networks @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +import argparse +import subprocess +import json +import sys + +index = 0 + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def scan(mpath): + output = runcmdj(['mmcli', '-J', '-m', mpath, + '--3gpp-scan', '--timeout', '60']) + if not output: + return False + + networks = [] + for entry in output["modem"]["3gpp"]["scan-networks"]: + net = {} + for attr in entry.split(", "): + (key, value) = attr.split(": ") + net[key] = value + + if "operator-name" in net: + networks.append(net) + + if len(networks) == 0: + print("No networks found") + return True + + for net in networks: + print("Network '%s' %s (%s, %s)" % ( + net.get("operator-name"), + net.get("operator-code"), + net.get("access-technologies"), + net.get("availability"))) + return True + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-scan-networks') + parser.add_argument("-i", "--index", default=0, help="Modem index") + args = parser.parse_args() + if args.index: + index = int(args.index) + + for m in runcmdj(['/usr/libexec/modemd/modem-info']): + if m["index"] == index: + print("Scanning networks on modem%d, please stand by ..." % index) + if not scan(m["path"]): + print("Operation failed.", file=sys.stderr) diff --git a/src/modemd/modem-sms b/src/modemd/modem-sms new file mode 100755 index 000000000..7bcb797ce --- /dev/null +++ b/src/modemd/modem-sms @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 + +from datetime import datetime +import argparse +import locale +import subprocess +import json +import glob +import re +import sys +import os + +LOCALEPATH = "/usr/lib/locale" + + +def run_cmd(cmd): + try: + res = subprocess.run(cmd, capture_output=True, text=True) + return True if res.returncode == 0 else False + except subprocess.CalledProcessError: + return False + + +def send(index, number, text): + if index < 0: + # use first modem by default + index = 0 + + rpc = { + "infix-modem:send-sms": { + "index": str(index), + "phone-number": number, + "message-text": text + } + } + print("Sending SMS to modem%d" % index) + + path = "/run/modemd/modem%d/rpc" % index + if os.path.exists(path): + sys.stderr.write("Error: another rpc is running\n") + return + + with open(path, "w") as fd: + json.dump(rpc, fd) + + if not run_cmd(['sysrepocfg', '-f', 'json', '-R', path]): + sys.stderr.write("Error: Unable to send SMS\n") + + os.unlink(path) + + +def listsms(): + smslist = [] + files = glob.glob('/var/sms/*') + for f in files: + with open(f, "r") as fd: + sms = json.load(fd) + sms["path"] = f + + t = sms["payload"]["properties"]["timestamp"] + t = re.sub("[+-][\\d:]+$", "", t) + sms["time"] = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S") + + smslist.append(sms) + + return sorted(smslist, key=lambda sms: sms["time"]) + + +def delete(index=-1): + count = 0 + for sms in listsms(): + if index > -1 and sms["modem"] == index: + os.remove(sms["path"]) + count += 1 + print("Deleted %d SMS files" % count) + + +def show(index=-1): + for sms in listsms(): + if index > -1 and sms["modem"] != index: + continue + + content = sms["payload"]["content"] + prop = sms["payload"]["properties"] + + print("--- %s ---\n" % os.path.basename(sms["path"])) + print("From: %s" % content["number"]) + print("SMSC: %s" % prop["smsc"]) + print("Modem: %d" % sms["modem"]) + print("Date: %s" % prop["timestamp"]) + print("State: %s" % prop["state"]) + print("\n%s\n" % content["text"]) + + +def listlocale(): + locales = [] + for f in os.listdir(LOCALEPATH): + if os.path.isdir(os.path.join(LOCALEPATH, f)): + locales.append(f) + return ", ".join(locales) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-sms') + parser.add_argument("-d", action="store_true", help="Delete SMS") + parser.add_argument("-s", action="store_true", help="Send SMS") + + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-n", "--number", default=None, help="Phone number") + parser.add_argument("-t", "--text", default=None, help="Message text") + + parser.add_argument("-l", "--locale", default=None, help="Set locale") + args = parser.parse_args() + + index = -1 + if args.index: + index = int(args.index) + + if args.locale: + try: + locale.setlocale(locale.LC_ALL, args.locale) + except locale.Error: + print("Error: Invalid locale (available: %s)" % listlocale()) + sys.exit(1) + + if args.s: + if not args.number: + sys.stderr.write("Error: need number\n") + elif not args.text: + sys.stderr.write("Error: need text\n") + else: + send(index, args.number, args.text) + elif args.d: + delete(index) + else: + show(index) diff --git a/src/modemd/modem-udev b/src/modemd/modem-udev new file mode 100755 index 000000000..ba4ebfbaf --- /dev/null +++ b/src/modemd/modem-udev @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 + +import shutil +import errno +import time +import json +import fcntl +import sys +import re +import os + +MODEMS = "/run/modems.json" +LOCK = "/var/lock/modems.lock" +LOGFILE = "/run/modemd/modem-udev.log" +debug = False + + +def log(msg): + ts = '{:010.3f}'.format(time.monotonic()) + with open(LOGFILE, 'a') as fd: + fd.write("[%s] modem-udev (pid %d) %s\n" % (ts, os.getpid(), msg)) + + +def dbg(msg): + global debug + if debug: + log(msg) + + +def info(msg): + log(msg) + + +def err(msg): + log("Error: %s" % msg) + + +def lock(): + start = time.time() + while True: + elapsed = time.time() - start + if elapsed > 10: + err("Timeout waiting for lock") + break + try: + fd = open(LOCK, "a") + fcntl.flock(fd.fileno(), fcntl.LOCK_EX) + dbg("Acquired lock") + return fd + except IOError as e: + dbg("Unable to lock (%d)" % e.errno) + if e.errno != errno.EAGAIN: + err("Unable to lock") + return None + else: + time.sleep(1) + + return None + + +def unlock(fd): + try: + fcntl.flock(fd.fileno(), fcntl.LOCK_UN) + fd.close() + dbg("Released lock") + return True + except IOError as e: + err("Unlock failed with %d" % e.errno) + return False + + +def mkdir(path): + if os.path.isdir(path): + return True + try: + os.mkdir(path, mode=0o755) + shutil.chown(path, user="root", group="wheel") + except OSError: + err("Unable to mkdir %s" % path) + return False + finally: + return True + + +def read_modem_data(): + data = {} + if os.path.exists(MODEMS): + with open(MODEMS, "r") as fd: + data = json.load(fd) + return data + + +def write_modem_data(data): + with open(MODEMS + ".bak", 'w') as fd: + json.dump(data, fd) + + os.rename(MODEMS + ".bak", MODEMS) + return True + + +def get_vendor(devpath): + path = "%s/idVendor" % devpath + if os.path.exists(path): + with open(path, "r") as fd: + return fd.readline().strip() + else: + return None + + +def get_product(devpath): + path = "%s/idProduct" % devpath + if os.path.exists(path): + with open(path, "r") as fd: + return fd.readline().strip() + else: + return None + + +def update(d): + modem = None + devpath = d.get("devpath") + + data = read_modem_data() + if "modems" not in data: + data = {"modems": []} + else: + for m in data["modems"]: + if devpath == m.get("devpath"): + data["modems"].remove(m) + modem = m + break + + if not modem: + modem = {"devpath": devpath} + + vendor = get_vendor(devpath) + if vendor: + modem["vendor"] = vendor + + product = get_product(devpath) + if product: + modem["product"] = product + + atport = d.get("atport") + atports = modem.get("atports", []) + if atport and atport not in atports: + ptype = d.get("ptype", "default") + if ptype == "primary": + atports.insert(0, atport) + else: + atports.append(atport) + modem["atports"] = atports + + qmiport = d.get("qmiport") + qmiports = modem.get("qmiports", []) + if qmiport and qmiport not in qmiports: + qmiports.append(qmiport) + modem["qmiports"] = qmiports + + iface = d.get("iface") + ifaces = modem.get("interfaces", []) + if iface and iface not in ifaces: + ifaces.append(iface) + modem["interfaces"] = ifaces + + data["modems"].append(modem) + write_modem_data(data) + + info("Updated modem %s" % devpath) + + +def add_tty(devpath): + devpath = os.path.realpath("/sys/%s/../../../../" % devpath) + + info("Adding tty device %s" % devpath) + + atport = os.getenv("DEVNAME") + if not atport: + err("No DEVNAME") + return False + + if os.getenv("ID_MM_PORT_TYPE_AT_PRIMARY"): + ptype = "primary" + elif os.getenv("ID_MM_PORT_TYPE_AT_SECONDARY"): + ptype = "secondary" + else: + ptype = "default" + + info("Adding %s atport %s for %s" % (ptype, atport, devpath)) + + update({"devpath": devpath, "atport": atport, "ptype": ptype}) + + +def add_net(devpath): + devpath = os.path.realpath("/sys/%s/../../../" % devpath) + + info("Adding net device %s" % devpath) + + iface = os.getenv("INTERFACE", None) + if not iface: + err("No INTERFACE") + return False + + r = re.search(r"wwan(\d+)\.(\d+)", iface) + if r and r.group(2): + info("Skipping interface %s" % iface) + return False + + info("Adding interface %s for %s" % (iface, devpath)) + update({"devpath": devpath, "iface": iface}) + + try: + with open("/sys/class/net/%s/qmi/raw_ip" % iface, "w") as fd: + fd.write("Y\n") + except (IOError, OSError): + err("Unable to set raw-ip") + return False + + info("Interface %s has been set to rawip" % iface) + + +def add_usbmisc(devpath): + devpath = os.path.realpath("/sys/%s/../../../" % devpath) + + info("Adding usbmisc device %s" % devpath) + + qmiport = os.getenv("DEVNAME", None) + if not qmiport: + err("No DEVNAME") + return False + + info("Adding qmiport %s for %s" % (qmiport, devpath)) + update({"devpath": devpath, "qmiport": qmiport}) + + +def apply(): + subsystem = os.getenv("SUBSYSTEM") + if not subsystem: + err("No SUBSYSTEM") + return False + + devpath = os.getenv("DEVPATH") + if not devpath: + err("No DEVPATH") + return False + + if "usb" not in devpath: + info("Skipping %s" % devpath) + return False + + if subsystem == "net": + add_net(devpath) + elif subsystem == "tty": + add_tty(devpath) + elif subsystem == "usbmisc": + add_usbmisc(devpath) + else: + err("Subsystem %s unhandled" % subsystem) + return False + + return True + + +if __name__ == "__main__": + with open("/proc/cmdline", 'r') as fd: + cmdline = fd.read() + if "debug" in cmdline: + debug = True + + mkdir("/run/modemd") + dbg("started") + + if debug: + for i, j in os.environ.items(): + dbg("ENV: %s=%s" % (i, j)) + else: + if os.path.exists(LOGFILE): + os.remove(LOGFILE) + + fd = lock() + if fd is None: + sys.exit(2) + + if apply(): + rc = 0 + else: + err("Unable to add modem device") + rc = 1 + + unlock(fd) + + dbg("ended") + + sys.exit(rc) diff --git a/src/modemd/modem-update-firmware b/src/modemd/modem-update-firmware new file mode 100755 index 000000000..b8675108e --- /dev/null +++ b/src/modemd/modem-update-firmware @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 + +import subprocess +import argparse +import urllib.parse +import hashlib +import syslog +import json +import time +import os +import sys + +tempdir = "/tmp/modem-fwupdate" +debug = False +index = 0 + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def dbg(msg): + global debug + if debug: + syslog.syslog(syslog.LOG_INFO, msg) + + +def info(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + rmdir(tempdir) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + err("Command failed") + dbg(res.stderr) + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def exists(path): + try: + os.stat(path) + except OSError: + return False + return True + + +def rm(path): + if os.path.exists(path): + os.remove(path) + + +def rmdir(path): + if os.path.isdir(path): + for f in os.listdir(path): + p = os.path.join(path, f) + if os.path.isdir(p): + rmdir(p) + else: + os.remove(p) + + +def mkdir(path): + if os.path.isdir(path): + return True + try: + os.mkdir(path, mode=0o755) + except OSError: + fatal("Unable to mkdir %s" % path) + + +def initctl(cmd, svc): + output = runcmdj(['initctl', '-j', 'status']) + for ctl in output: + if ctl["identity"] == svc: + return runcmd(['initctl', cmd, svc]) + return False + + +def get_path(index): + for modem in runcmdj(['/usr/libexec/modemd/modem-info']): + if modem["index"] == index: + return modem["path"] + + +def get_device_info(index): + path = get_path(index) + if path is None: + return None + + output = runcmdj(['mmcli', '-J', '-m', path]) + if not output: + return None + + gen = output["modem"]["generic"] + info = { + "index": index, + "vendor": gen["manufacturer"], + "model": gen["model"], + "state": gen["state"], + "qmidev": [], + "atdev": [] + } + for port in gen["ports"]: + if "(qmi)" in port: + info["qmidev"].append(port.split()[0]) + elif "(at)" in port: + info["atdev"].append(port.split()[0]) + + return info + + +def wait_for_device(dev, appear): + if appear: + mode = "appear" + else: + mode = "disappear" + + info("Waiting for modem%d to %s" % (dev["index"], mode)) + + path = "/dev/%s" % dev["qmidev"][0] + timeout = 30 + while timeout > 0: + if mode == "appear": + if exists(path): + return True + elif mode == "disappear": + if not exists(path): + return True + time.sleep(1) + timeout -= 1 + return False + + +def qmiupdate(dev): + if len(dev["qmidev"]) == 0: + err("No qmi device found") + return False + + qmidev = "/dev/%s" % dev["qmidev"][0] + cwefile = None + nvufile = None + + for f in os.scandir(tempdir): + if f.name.endswith('cwe'): + cwefile = "%s/%s" % (tempdir, f.name) + if f.name.endswith('nvu'): + nvufile = "%s/%s" % (tempdir, f.name) + + if not cwefile: + err("No CWE file found") + return False + if not nvufile: + err("No NVU file found") + return False + + info("Resetting modem") + if not runcmd(['qmicli', '-d', qmidev, + '--dms-set-operating-mode=offline']): + err("Unable to set modem offline") + return False + if not runcmd(['qmicli', '-d', qmidev, + '--dms-set-operating-mode=reset']): + err("Unable to reset modem") + return False + + if not wait_for_device(dev, 0): + err("Device has not disappeared") + return False + if not wait_for_device(dev, 1): + err("Device has not appeared") + return False + + info("Running qmi-firmware-update, please stand by...") + + cmd = ['qmi-firmware-update', '-v', '--override-download', + '-w', qmidev, '-u', cwefile, nvufile] + return runcmd(cmd) + + +def firmware_update_sierra(dev, url, checksum): + if not download(url, checksum): + err("Unable to prepare firmware") + return False + + info("Stopping modem services") + initctl("stop", "modemd") + initctl("stop", "modem-manager") + + time.sleep(3) + ret = qmiupdate(dev) + + info("Starting modem services") + initctl("restart", "modem-manager") + initctl("restart", "modemd") + + return ret + + +def atcmd(dev, command, expect=None, timeout=None): + device = "/dev/%s" % dev["atdev"][0] + + cmd = ['modem-command', '-d', device, command] + if expect: + cmd += ['-e', expect] + if timeout: + cmd += ['-t', str(timeout)] + + return runcmd(cmd) + + +def firmware_update_telit(dev, url, checksum): + if 'LN920' not in dev["model"]: + err("Not supported") + return False + + if len(dev["atdev"]) == 0: + err("No AT device found") + return False + + if dev["state"] != "connected": + err("Modem is not connected") + return False + + u = urllib.parse.urlparse(url) + if not u: + err("Unable to parse URL") + return False + if u.scheme != "ftp": + err("Not an FTP URL") + return False + + hostname = "" + username = "" + password = "" + port = 21 + + if u.hostname: + hostname = u.hostname + if u.username: + username = u.username + if u.password: + password = u.password + if u.port: + port = int(u.port) + if u.path: + path = u.path + + if hostname == "": + err("No hostname in URL") + return False + if path == "": + err("No path in URL") + return False + + parms = (hostname, port, path, username, password) + cmd = 'AT#FTPGETOTAENH=%s,%d,%s,%s,%s' % parms + exp = '#DREL' + if not atcmd(dev, cmd, exp, 60): + err("Unable to download firmware") + return False + + if not atcmd(dev, 'AT#OTAUP=2'): + err("Unable to update firmware") + return False + + if not atcmd(dev, 'AT#ENHRST=1,3'): + err("Unable to reboot modem") + return False + + time.sleep(5) + info("Restarting modem services") + initctl("restart", "modem-manager") + initctl("restart", "modemd") + return True + + +def verify(path, checksum): + if not checksum or checksum == "any": + return True + + sha256 = hashlib.sha256() + with open(path, 'rb') as f: + while True: + data = f.read(1024) + if not data: + break + sha256.update(data) + + calculated = sha256.hexdigest() + + if checksum != calculated: + err("Checksum mismatch (%s vs. %s)" % (calculated, checksum)) + return False + else: + info("Verified checksum") + return True + + +def download(url, checksum): + path = "%s/firmware.zip" % tempdir + + info("Downloading %s" % url) + + if not runcmd(['curl', '-s', '-L', url, '-o', path]): + err("Unable to download firmware") + return False + + if not verify(path, checksum): + err("Checksum verification failed") + return False + + if not runcmd(['unzip', '-d', tempdir, path]): + err("Unable to unpack firmware") + return False + + return True + + +if __name__ == "__main__": + syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) + + parser = argparse.ArgumentParser(prog='modem-firmware-update') + parser.add_argument("-i", "--index", default=None, help="Modem index") + parser.add_argument("-u", "--url", default=None, help="URL to firmware") + parser.add_argument("-c", "--checksum", default=None, + help="SHA256 checksum of firmware") + parser.add_argument('-d', action='store_true') + + args = parser.parse_args() + if args.d: + debug = True + if args.index: + index = int(args.index) + if not args.url: + fatal("No firmware URL specified") + + rmdir(tempdir) + mkdir(tempdir) + + dev = get_device_info(index) + if dev is None: + fatal("No device found for modem%d" % index) + + info("Starting firmware update for %s" % dev["model"]) + + if "Sierra" in dev["vendor"]: + ret = firmware_update_sierra(dev, args.url, args.checksum) + elif "Telit" in dev["vendor"]: + ret = firmware_update_telit(dev, args.url, args.checksum) + else: + fatal("Unsupported vendor") + + if ret: + info("Firmware update succeeded") + else: + fatal("Firmware update failed") + + rmdir(tempdir) diff --git a/src/modemd/modem-ussd b/src/modemd/modem-ussd new file mode 100755 index 000000000..e1b1e10ce --- /dev/null +++ b/src/modemd/modem-ussd @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +import argparse +import subprocess +import sys + +verbose = False + + +def runcmd(cmd): + if verbose: + print("Running: %s" % " ".join(cmd)) + + ret = None + try: + res = subprocess.run(cmd, capture_output=True, text=True) + if verbose: + print(res.stdout) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def ussd(index, code): + cmd = ['/sbin/modem-command', + '--index', str(index), + '--expect', '+CUSD', + '--timeout', '60', + 'AT+CUSD=1,"%s",15' % code] + + if verbose: + cmd.append('--verbose') + + output = runcmd(cmd) + if output is None: + sys.stderr.write("ERROR: Command failed\n") + return False + + resp = output.split('"') + if len(resp) == 3: + print(resp[1]) + + return True + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-ussd') + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-c", "--code", default=None, help="USSD codec") + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + if args.verbose: + verbose = True + + index = 0 + if args.index: + index = int(args.index) + + if not args.code: + sys.stderr.write("Error: need USSD code\n") + else: + if ussd(index, args.code): + sys.exit(0) + + sys.exit(1) diff --git a/src/modemd/modemd b/src/modemd/modemd new file mode 100755 index 000000000..695a41a06 --- /dev/null +++ b/src/modemd/modemd @@ -0,0 +1,1618 @@ +#!/usr/bin/env python3 + +import threading +import subprocess +import argparse +import hashlib +import signal +import syslog +import socket +import shutil +import select +import json +import enum +import time +import os +import sys + +rundir = "/run/modemd" +smsdir = "/var/sms" +debug = False +threads = [] + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + +class Trigger(enum.Enum): + POWER = 1 + RESET = 2 + RESTART = 3 + + +class State(enum.Enum): + FAILED = -1 + DOWN = 0 + ENABLED = 1 + CONNECTING = 2 + CONNECTED = 3 + UP = 4 + + +def info(msg): + syslog.syslog(syslog.LOG_INFO, " %s" % msg) + + +def dbg(msg): + global debug + if debug: + syslog.syslog(syslog.LOG_INFO, " %s" % msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, " %s" % msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, " %s" % msg) + sys.exit(1) + + +def opener(path, flags): + ret = os.open(path, flags, 0o664) + shutil.chown(path, user="root", group="wheel") + return ret + + +def rmf(path): + if os.path.exists(path): + os.unlink(path) + + +def rmrf(path): + for f in os.listdir(path): + p = os.path.join(path, f) + if os.path.isdir(p): + rmrf(p) + else: + os.remove(p) + + +def mkdir(path): + if os.path.isdir(path): + return True + try: + os.mkdir(path, mode=0o755) + shutil.chown(path, user="root", group="wheel") + except OSError: + fatal("Unable to mkdir %s" % path) + + +def fread(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +def freadj(path): + output = fread(path) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + dbg("Unable to parse json output") + return None + finally: + return data + + +def fwrite(path, cont): + with open(path, "w", opener=opener) as fd: + if not fd.write(cont): + return False + return True + + +def fwritej(path, cont): + if fwrite(path, json.dumps(cont)): + return True + else: + return False + + +def runcmdp(cmd1, cmd2, check=True): + dbg("Running: %s | %s" % (" ".join(cmd1), " ".join(cmd2))) + ret = None + try: + res1 = subprocess.run(cmd1, check=check, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + res2 = subprocess.run(cmd2, input=res1.stdout, check=check, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if res1.returncode == 0 and res2.returncode == 0: + if res2.stdout: + ret = res2.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + dbg("Piped command failed") + dbg(res1.stderr) + dbg(res2.stderr) + return None + finally: + return ret + + +def runcmd(cmd, check=True): + dbg("Running: %s" % " ".join(cmd)) + ret = None + try: + res = subprocess.run(cmd, check=check, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError as e: + dbg("Command %s failed" % cmd[0]) + dbg(e) + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + dbg("Unable to parse json output") + return None + finally: + return data + + +def list_sms(modem): + smslist = [] + + output = None + if modem: + output = runcmdj(["mmcli", "-J", "-m", modem, + "--messaging-list-sms"]) + if not output: + return smslist + + for path in output["modem.messaging.sms"]: + output = runcmdj(["mmcli", "-J", "-s", path]) + if not output: + continue + props = output["sms"]["properties"] + if props["pdu-type"] == "submit": + if props["state"] == "--": + smslist.append({"path": path, + "type": "notsent"}) + elif props["state"] == "sent": + smslist.append({"path": path, + "type": "sent"}) + elif props["pdu-type"] == "deliver": + if props["state"] == "received": + smslist.append({"path": path, + "type": "received", + "payload": output["sms"]}) + + return smslist + + +class RpcThread(threading.Thread): + path = None + server = None + + def __init__(self): + global rundir + super().__init__(daemon=True, name="rpc") + self.exited = False + self.stopevent = threading.Event() + self.sock = rundir + "/modemd.sock" + + def stop(self): + self.stopevent.set() + + def stopped(self): + return self.stopevent.is_set() + + def sendsms(self, path, data): + info("Sending sms to %s" % data["number"]) + if not path: + err("No modem path yet") + return False + + opts = [] + opts.append('number="%s"' % data["number"]) + opts.append('text="%s"' % data["text"]) + + if not runcmd(["mmcli", "-m", path, + "--messaging-create-sms=%s" % ",".join(opts)]): + err("Unable to create sms") + return False + + return True + + def rpc(self, rpc, data): + global threads + + info("Got rpc %s for modem%d" % (rpc, data["index"])) + for th in threads: + if th.name.startswith("modem") and th.index == data["index"]: + if rpc == "restart": + th.trigger = Trigger.RESTART + th.interrupt = True + elif rpc == "reset": + th.trigger = Trigger.RESET + th.interrupt = True + elif rpc == "send-sms": + self.sendsms(th.path, data) + return True + + def handle(self, msg): + if "rpc" not in msg: + err("No rpc in msg") + return False + elif "data" not in msg: + err("No data in msg") + return False + elif "index" not in msg["data"]: + err("No index") + return False + + if msg["rpc"] == "send-sms": + if "number" not in msg["data"]: + err("No sms number") + return False + if "text" not in msg["data"]: + err("No sms text") + return False + + return self.rpc(msg["rpc"], msg["data"]) + + def process(self): + conn, addr = self.server.accept() + try: + while not self.stopped(): + msg = conn.recv(1024) + if msg: + self.handle(json.loads(msg.decode())) + else: + break + finally: + conn.close() + return True + + def run(self): + rmf(self.sock) + self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server.bind(self.sock) + self.server.listen(1) + + while not self.stopped(): + if not self.process(): + return False + + self.server.close() + rmf(self.sock) + + dbg("Exiting rpc") + self.exited = True + return True + + +class SimThread(threading.Thread): + def __init__(self): + super().__init__(daemon=True, name="sim") + self.exited = False + self.stopevent = threading.Event() + + def stop(self): + self.stopevent.set() + + def stopped(self): + return self.stopevent.is_set() + + def changed(self, index): + global threads + + path = "/sys/class/sim/sim%d/present" % index + present = int(fread(path)) + if present is None: + return + if present == 0: + info("sim%d disappeared" % index) + return + + info("sim%d appeared" % index) + for th in threads: + if th.name.startswith("modem") and th.sim["index"] == index: + th.trigger = Trigger.POWER + th.interrupt = True + + def run(self): + fd = os.open("/dev/simctrl", os.O_RDONLY) + while not self.stopped(): + res, *_ = select.select([fd], [], [], 10.0) + if fd in res: + index = int.from_bytes(os.read(fd, 1)) + self.changed(index) + + dbg("Exiting sim") + fd.close() + self.exited = True + return True + + +class ModemThread(threading.Thread): + index = None + cfg = None + + def __init__(self, cfg): + global rundir + + self.index = cfg["index"] + super().__init__(daemon=True, name="modem%d" % self.index) + self.stopevent = threading.Event() + self.exited = False + + self.cfg = cfg + self.prefix = "[modem%d] " % self.index + self.rundir = "%s/modem%d" % (rundir, self.index) + self.statedir = "%s/state" % self.rundir + self.locdir = "%s/location" % self.rundir + + mkdir(self.rundir) + mkdir(self.statedir) + mkdir(self.locdir) + + self.init() + + def stop(self): + self.stopevent.set() + + def stopped(self): + return self.stopevent.is_set() + + def dbg(self, msg): + dbg("%s%s" % (self.prefix, msg)) + + def info(self, msg): + info("%s%s" % (self.prefix, msg)) + + def err(self, msg): + err("%s%s" % (self.prefix, msg)) + + def fatal(self, msg): + fatal("%s%s" % (self.prefix, msg)) + + def init(self): + self.timeout = 1 + self.path = None + self.interfaces = [] + self.ifaces = [] + self.status = {} + self.state = None + self.trigger = None + self.iface = None + self.sim = None + self.failtime = None + self.detected = False + self.timer1 = 0 + self.timer1 = 0 + self.timer2 = 0 + self.updfail = 0 + self.connfail = 0 + self.location = {"enabled": False} + + self.set_state(State.DOWN) + self.configure() + + def configure(self): + self.bearers = { + "list": [], + "initial": None, + "active": [] + } + bearers = self.cfg.get("bearer") + if not bearers: + self.err("No bearers configured") + return False + + default = None + count = 0 + for bearer in sorted(bearers, key=lambda b: b["index"]): + bearer["pid"] = "--" + + apn = bearer.get("apn") + if not apn: + self.err("No APN configured") + return False + + apntype = bearer.get("apn-type") + if apntype == "initial": + if not self.bearers["initial"]: + self.bearers["initial"] = bearer + elif apntype == "default" and not default: + default = bearer + count += 1 + else: + self.bearers["list"].append(bearer) + count += 1 + + if default: + self.bearers["list"].insert(0, default) + + # at least one bearer is mandatory + if len(self.bearers["list"]) == 0: + self.err("No bearers evaluated") + return False + + # check for duplicate APNs + seen = [] + for bearer in self.bearers["list"]: + if bearer["apn"] not in seen: + seen.append(bearer["apn"]) + else: + self.err("Duplicate APN '%s'" % bearer["apn"]) + return False + + if not self.bearers["initial"]: + self.bearers["initial"] = self.bearers["list"][0] + + # check location + enabled = False + sources = [] + loc = self.cfg.get("location") + if loc and loc.get("enabled"): + enabled = True + v = loc.get("source") + if v: + sources = [s.get("source") for s in v] + + self.location = { + "enabled": enabled, + "sources": sources, + "state": "down"} + + return True + + def find_bearer(self, apn): + for bearer in self.bearers["active"]: + if bearer["apn"] == apn: + return bearer + return None + + def restart(self): + self.info("Restarting") + self.notif("RESTART") + self.pulldown() + + def power(self): + self.info("Power-cycling") + self.notif("POWER") + + self.pulldown() + + if runcmd(['/usr/libexec/modemd/modem-power', + '-i', str(self.index)]): + self.info("Waiting 10s") + time.sleep(10) + else: + self.err("Unable to power-cycle") + + self.init() + + def reset(self): + self.info("Resetting") + self.notif("RESET") + + self.pulldown() + + wait = 0 + if runcmd(["mmcli", "-m", self.path, + "--reset"]): + self.info("Performed a normal reset") + wait = 5 + elif runcmd(["mmcli", "-m", self.path, + "--factory-reset=000000"]): + self.info("Performed a factory reset") + wait = 10 + else: + self.err("Unable to reset") + + if wait > 0: + self.info("Waiting %ds" % wait) + time.sleep(wait) + + self.init() + + def notif(self, msg): + data = { + "infix-modem:modems": { + "modem": [{ + "index": self.index, + "status-update": {"desc": msg} + }] + } + } + note = "%s/notif" % self.rundir + fwritej(note, data) + if runcmd(["sysrepocfg", "-f", "json", "-N", note]): + self.dbg("Sent notification: %s" % msg) + rmf(note) + + def get_bearers(self): + bearers = [] + if not self.update(): + return bearers + for path in self.status.get("bearers", []): + output = runcmdj(["mmcli", "-J", "-m", self.path, "-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"]) + if not output: + return profiles + for entry in output["modem"]["3gpp"]["profile-manager"]["list"]: + profile = {} + for attr in entry.split(", "): + (key, value) = attr.split(": ") + profile[key] = value + profiles.append(profile) + return profiles + + def get_conn_failure(self, path): + for bearer in self.get_bearers(): + if bearer["dbus-path"] == path: + return bearer["status"]["connection-error"]["message"] + return "unknown" + + def update(self): + if not self.path: + return False + output = runcmdj(["mmcli", "-J", "-m", self.path]) + if output: + output = output.get("modem", {}) + output = output.get("generic", None) + if output: + self.status = output + self.updfail = 0 + return True + + self.updfail += 1 + if self.updfail > 5: + self.err("Reinit after too many failures") + time.sleep(10) + self.init() + + return False + + def iplink(self, iface, action): + dbg("Setting link of %s to %s" % (iface, action)) + if not runcmd(["ip", "link", "set", "dev", iface, action]): + self.err("Unable to set link of %s to %s" % (iface, action)) + return False + if not runcmd(["ip", "link", "set", "dev", iface, "state", action]): + self.err("Unable to set link state of %s to %s" % (iface, action)) + return False + return True + + def resolvconf(self, iface, action): + enabled = iface["bearer"].get("dns-enabled", False) + if enabled is False: + return True + + self.info("About to %s nameservers on %s" % (action, iface["name"])) + + output = runcmd(["resolvconf", "-i"], check=False) + if output: + for ifc in output.split(" "): + self.dbg("Removing nameservers from %s" % ifc) + runcmd(["resolvconf", "-d", ifc]) + + if action != "add": + return True + + count = 0 + data = "" + for dns in (iface["ipv4"]["dns"] + iface["ipv6"]["dns"]): + self.dbg("Adding nameserver %s" % dns) + data += "nameserver %s\n" % dns + count += 1 + + if count > 0: + path = "%s/resolv.conf" % self.rundir + fwrite(path, data) + if not runcmdp(["cat", path], ["resolvconf", "-a", iface["name"]]): + self.err("Unable to add nameservers on %s" % iface["name"]) + return False + + return True + + def ipgwroute(self, iface, action): + output = runcmdj(["ip", "-j", "route", + "show", "dev", iface["name"]]) + for rt in output: + if "scope" in rt and rt["scope"] == "link": + if action == "add": + return True + else: + runcmd(["ip", "route", + "del", rt["dst"], "dev", iface["name"]]) + + if action != "add": + return True + + if iface["ipv4"]["gateway"] != "--": + gw = iface["ipv4"]["gateway"] + elif iface["ipv6"]["gateway"] != "--": + gw = iface["ipv6"]["gateway"] + else: + self.err("No gateway address found") + return False + + cmd = ["ip", "route", "add", gw, "dev", iface["name"]] + if not runcmd(cmd): + self.err("Unable to add gateway route") + return False + + return True + + def ipdefroute(self, iface, action): + self.info("About to %s default route on %s" % (action, iface["name"])) + + output = runcmdj(["ip", "-j", "route", "show", "default"]) + if output: + for route in output: + if not runcmd(["ip", "route", "del", "default", + "dev", route["dev"]]): + self.err("Can't delete default route on %s" % route["dev"]) + + if not self.ipgwroute(iface, action): + return False + + if action != "add": + return True + + cmd = ["ip", "route", "add", "default", "dev", iface["name"]] + if iface["ipv4"]["gateway"] != "--": + cmd += ["via", iface["ipv4"]["gateway"]] + elif iface["ipv6"]["gateway"] != "--": + cmd += ["via", iface["ipv6"]["gateway"]] + + if not runcmd(cmd): + self.err("Unable to add default route on %s" % iface["name"]) + time.sleep(30) + return False + else: + return True + + def ifupdown(self, iface, action): + enabled = iface["bearer"].get("firewall-enabled", False) + if enabled is True: + self.info("Setting %s firewall on %s" % (action, self.iface)) + if not runcmd(["/etc/firewall/default", action, self.iface]): + return False + + path = "/var/ifupdown/%s" % self.iface + if os.path.isfile(path) or os.access(path, os.X_OK): + self.info("Running %s %s" % (path, action)) + if not runcmd([path, action]): + return False + + return True + + def linkdown(self, iface): + self.resolvconf(iface, "delete") + self.ipdefroute(iface, "delete") + self.ifupdown(iface, "down") + + output = runcmdj(["ip", "-j", "addr", "show", iface["name"]]) + for entry in output: + for addr in entry["addr_info"]: + runcmd(["ip", "addr", "del", + addr["local"] + "/" + str(addr["prefixlen"]), + "dev", iface["name"]]) + + self.iplink(iface["name"], "down") + + for i in range(len(self.ifaces)): + if self.ifaces[i]["name"] == iface["name"]: + del self.ifaces[i] + + return True + + def linkup(self, iface): + self.iplink(iface["name"], "up") + + addrs = [] + for ip in (iface["ipv4"], iface["ipv6"]): + if ip["address"] != "--" and ip["prefix"] != "--": + addrs.append("%s/%s" % (ip["address"], ip["prefix"])) + + if len(addrs) == 0: + self.err("No addresses yet") + return False + + for addr in addrs: + self.info("Setting address %s on %s" % (addr, iface["name"])) + if not runcmd(["ip", "addr", "change", + addr, "dev", iface["name"]]): + self.err("Unable to set address for %s" % iface["name"]) + return False + + if iface["bearer"].get("default-route"): + if not self.ipdefroute(iface, "add"): + return False + + if not self.ifupdown(iface, "up"): + return False + + if not self.resolvconf(iface, "add"): + return False + + self.ifaces.append(iface) + return True + + 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"]): + self.err("Unable to power on") + return False + return True + + def disable(self): + self.info("Disabling") + if not runcmd(["mmcli", "-m", self.path, "--disable"]): + self.err("Unable to disable") + return False + else: + return True + + def enable(self): + state = self.status.get("state", "unknown") + self.info("Enabling (state '%s')" % state) + + if not self.path: + return False + if state == "unknown" or state == "disabled": + if not runcmd(["mmcli", "-m", self.path, "--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")] + + if self.status.get("unlock-required", "") == "sim-pin": + self.info("Unlocking with PIN") + pin = self.cfg.get("pin") + if not pin: + self.err("No PIN defined") + return False + cmd.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) + else: + self.err("Unsupported lock") + return False + + self.disable() + if not runcmd(cmd): + self.err("Unable to unlock") + return False + else: + return True + + def setcarrier(self): + path = "%s/carrier-support" % self.rundir + if os.path.exists(path): + supported = int(fread(path)) + else: + if runcmd(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--check"]): + supported = 1 + else: + supported = 0 + + fwrite(path, "%d" % supported) + + if supported == 0: + self.info("Modem does not support setting a carrier") + return True + + path = "%s/supported-carriers" % self.rundir + if os.path.exists(path): + carriers = freadj(path) + else: + self.info("Getting supported carriers for %s %s" % + (self.manf, self.model)) + carriers = runcmdj(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--index", str(self.index), + "--list"]) + if carriers: + fwritej(path, carriers) + + if not carriers: + self.err("Unable to list supported carriers") + return False + + path = "%s/current-carrier" % self.rundir + if os.path.exists(path): + current = freadj(path) + else: + output = runcmdj(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--index", str(self.index), + "--get"]) + if output and isinstance(output, dict): + current = output.get("name") + if current: + fwritej(path, current) + + if not current: + self.err("Unable to get current carrier") + return False + + self.info("Current carrier is '%s'" % current) + + carrier = self.cfg.get("carrier", "default") + if carrier == current: + return True + + self.info("Setting carrier '%s' for modem%d" % + (carrier, self.index)) + + output = runcmdj(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--index", str(self.index), + "--set", carrier]) + if not output: + self.err("Unable to set carrier '%s' on modem%d" % + (carrier, self.index)) + return False + + fwritej(path, carrier) + + if output.get("action", "") == "reset": + self.reset() + return False + else: + return True + + def initialize(self): + self.pulldown() + self.update() + self.poweron() + + if not self.setcarrier(): + return False + + if self.status.get("state", "") == "locked": + if not self.unlock(): + return False + + return self.enable() + + def get_profile_args(self, bearer): + args = [ + "profile-id=%s" % bearer["pid"], + "profile-name=profile%s" % bearer["pid"] + ] + v = bearer.get("apn-type") + if v: + args.append("apn-type=%s" % v) + v = bearer.get("apn") + if v: + args.append("apn=%s" % v) + v = bearer.get("ip-type") + if v: + args.append("ip-type=%s" % v) + v = bearer.get("username") + if v: + args.append("user=%s" % v) + v = bearer.get("password") + if v: + args.append("password=%s" % v) + + return ",".join(args) + + def get_bearer_args(self, bearer): + args = [] + pid = bearer.get("pid") + if pid and pid == "!-": + args.append("profile-id=%s" % pid) + else: + v = bearer.get("apn") + if v: + args.append("apn=%s" % v) + v = bearer.get("ip-type") + if v: + args.append("ip-type=%s" % v) + v = bearer.get("username") + if v: + args.append("user=%s" % v) + v = bearer.get("password") + if v: + args.append("password=%s" % v) + + v = bearer.get("allow-roaming") + if v: + args.append("allow-roaming=true") + else: + args.append("allow-roaming=false") + + return ",".join(args) + + def prepare_profile_bearers(self, bearers): + self.info("Preparing profile bearers") + + # wipe auxiliary profiles + 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"]]): + self.err("Unable to delete profile") + return False + + # create profiles and bearers + pid = 1 + for bearer in bearers: + bearer["pid"] = str(pid) + pid += 1 + + if pid > len(self.interfaces): + self.err("Max. number of bearers reached") + break + if bearer["pid"] != "1": + if not runcmd(["mmcli", "-m", self.path, + "--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]): + 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]): + self.err("Unable to create bearer") + return False + + self.info("Created profile bearer %s for '%s'" % + (bearer["pid"], bearer["apn"])) + self.bearers["active"].append(bearer) + return True + + def prepare_multiplex_bearers(self, bearers): + self.info("Preparing multiplex bearers") + + for bearer in bearers: + args = self.get_bearer_args(bearer) + if not runcmd(["mmcli", "-m", self.path, + "--create-bearer=multiplex=required,%s" % args]): + self.err("Unable to create bearer") + return False + self.info("Created multiplex bearer for '%s'" % bearer["apn"]) + self.bearers["active"].append(bearer) + return True + + def prepare_default_bearer(self, bearer): + if not runcmd(["mmcli", "-m", self.path, + "--create-bearer=%s" % + self.get_bearer_args(bearer)]): + self.err("Unable to create bearer") + return False + + self.info("Created default bearer '%s'" % bearer["apn"]) + self.bearers["active"].append(bearer) + return True + + def prepare_bearers(self): + self.info("Preparing bearers") + + # 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"]]): + self.err("Unable to delete bearer") + return False + + # configure initial bearer (used to attach to network) + 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)]): + self.err("Unable to set initial bearer") + + # configure dialup bearers + count = len(self.bearers["list"]) + self.info("Configuring %d bearers" % count) + + if len(self.bearers["list"]) == 1: + return self.prepare_default_bearer(self.bearers["list"][0]) + elif len(self.interfaces) == 1: + # use multiplex beares in case of 1 interface + return self.prepare_multiplex_bearers(self.bearers["list"]) + else: + # use profile beaeres in case of multiple interfaces + return self.prepare_profile_bearers(self.bearers["list"]) + + def prepare_bands(self): + self.info("Preparing bands") + + v = self.cfg.get("band", []) + bands = [k.get("band", "any") for k in v] + if len(bands) == 0 or "any" in bands: + return True + + if not runcmd(["mmcli", "-m", self.path, + "--set-current-bands=%s" % "|".join(bands)]): + self.err("Unable to set band") + return False + return True + + def prepare_modes(self): + self.info("Preparing modes") + + pref = self.cfg.get("preferred-mode") + if not pref or pref == "any": + pref = "none" + + allow = [] + v = self.cfg.get("allowed-mode", []) + allow = [k.get("mode", "any") for k in v] + if "any" in allow: + allow = [] + + if pref == "none" and len(allow) == 0: + runcmd(["mmcli", "-m", self.path, "--set-allowed-modes=any"], check=False) + return True + + mode = "allowed: %s; preferred: %s" % (", ".join(allow), pref) + supported = False + for m in self.status.get("supported-modes", []): + if m == mode: + supported = True + break + if not supported: + self.err("Mode '%s' is not supported" % mode) + return False + + current = self.status.get("current-modes", "") + if current == mode: + self.info("Mode '%s' already set" % mode) + return True + + self.info("Setting mode '%s'" % mode) + + cmd = ["mmcli", "-m", self.path] + if len(allow) > 0: + cmd.append("--set-allowed-modes=%s" % "|".join(allow)) + if pref != "none": + cmd.append("--set-preferred-mode=%s" % pref) + return runcmd(cmd) + + def prepare_location(self): + self.info("Preparing location") + + rmrf(self.locdir) + if not self.location["enabled"]: + fwrite("%s/disabled" % self.locdir, "1") + return True + + for source in ["gps", "agps-msa", "agps-msb", "3gpp", "cdma"]: + if self.location["enabled"] and source in self.location["sources"]: + self.info("Enabling location source '%s'" % source) + action = "enable" + else: + self.dbg("Disabling location source '%s'" % source) + action = "disable" + + 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"]: + fwrite("%s/failed" % self.locdir, "1") + self.location["state"] = "failed" + return False + return True + + def prepare(self): + if not self.detected: + return False + + self.connfail = 0 + + if not self.prepare_bands(): + self.err("Unable to prepare bands") + return False + if not self.prepare_modes(): + self.err("Unable to prepare mode") + return False + if not self.prepare_bearers(): + self.err("Unable to prepare bearers") + return False + if not self.prepare_location(): + self.err("Unable to prepare location") + return False + + self.info("Waiting for network") + return True + + 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 + ]): + self.connfail += 1 + self.err("Unable to connect (%d failures)" % self.connfail) + self.err("Reason: %s" % self.get_conn_failure(bearer)) + return False + return True + + def pullup(self): + count = 0 + for bearer in self.get_bearers(): + iface = { + "name": bearer["status"]["interface"], + "connected": bearer["status"]["connected"], + "ipv4": bearer["ipv4-config"], + "ipv6": bearer["ipv6-config"], + "bearer": self.find_bearer(bearer["properties"]["apn"]) + } + if iface["bearer"] is None: + continue + if iface["name"] == "--": + continue + if iface["connected"] != "yes": + continue + if iface["ipv4"]["address"] == "--": + if iface["ipv6"]["address"] == "--": + continue + if not self.linkup(iface): + self.linkdown(iface) + return False + else: + count += 1 + + if count == len(self.bearers["list"]): + self.info("All bearers are up") + return True + else: + return False + + def pulldown(self): + for bearer in self.get_bearers(): + iface = { + "name": bearer["status"]["interface"], + "bearer": self.find_bearer(bearer["properties"]["apn"]) + } + + if bearer["status"]["connected"] == "yes": + self.info("Disconnecting %s" % bearer["dbus-path"]) + runcmd(["mmcli", "-m", self.path, + "--disconnect", "--bearer=%s" % bearer["dbus-path"]]) + for iface in self.ifaces: + self.linkdown(iface) + + self.set_state(State.DOWN) + + def set_state(self, state): + if state == self.state: + return False + + if state == State.UP: + self.notif(state.name) + elif state == State.DOWN and self.state == State.UP: + self.notif(state.name) + elif state == State.FAILED: + self.notif(state.name) + + rmrf(self.statedir) + fwrite("%s/%s" % (self.statedir, state.name.lower()), "1") + self.state = state + return True + + def check_state(self): + self.dbg("State %s (status %s)" % + (self.state.name, self.status.get("state"))) + + if self.detected and self.status.get("state", "") == "failed": + if self.set_state(State.FAILED): + self.err("In failed state because %s" % + self.status.get("state-failed-reason", "")) + self.failtime = time.time() + + # state failed + if self.state == State.FAILED: + elapsed = time.time() - self.failtime + if elapsed > 30: + self.err("Resetting after being 30s in state failed") + self.reset() + + # state down + elif self.state == State.DOWN: + if self.status.get("state", "") == "disabled": + self.enable() + self.timeout = 3 + elif self.status.get("state", "") != "initializing": + if self.prepare(): + self.set_state(State.ENABLED) + + # state enabled + elif self.state == State.ENABLED: + if self.status.get("state", "") == "registered": + if self.state != "connecting": + if self.connect(): + self.set_state(State.CONNECTING) + elif self.connfail > 5: + self.err("Resetting after 5 connection attempts") + self.reset() + + # state connecting + elif self.state == State.CONNECTING: + if self.status.get("state", "") == "connected": + self.set_state(State.CONNECTED) + + # state connected + elif self.state == State.CONNECTED: + if self.state != State.UP: + if self.pullup(): + # all bearers are up + self.set_state(State.UP) + + # state up + elif self.state == State.UP: + self.timeout = 10 + if self.status.get("state", "") != "connected": + self.pulldown() + + + def check_trigger(self): + if self.trigger is not None: + if self.trigger == Trigger.RESTART: + self.restart() + elif self.trigger == Trigger.RESET: + self.reset() + elif self.trigger == Trigger.POWER: + self.power() + + self.trigger = None + + def check_location(self): + if not self.location["enabled"]: + return + + output = runcmdj(["mmcli", "-J", "-m", self.path, + "--location-get"]) + if output: + lat = output["modem"]["location"]["gps"]["latitude"] + lon = output["modem"]["location"]["gps"]["longitude"] + else: + lat = "--" + lon = "--" + + if lat != "--" and lon != "--": + if self.location["state"] != "up": + self.info("Retrieved GPS location [%s, %s]" % (lat, lon)) + rmf("%s/down" % self.locdir) + fwrite("%s/up" % self.locdir, "1") + self.location["state"] = "up" + else: + rmf("%s/up" % self.locdir) + fwrite("%s/down" % self.locdir, "1") + self.location["state"] = "down" + + def save_sms(self, sms): + global smsdir + + payload = sms["payload"] + + string = "%s,%s,%s" % (payload["properties"]["timestamp"], + payload["content"]["number"], + payload["content"]["text"]) + m = hashlib.sha256() + m.update(string.encode()) + path = os.path.join(smsdir, m.hexdigest()) + + if not os.path.exists(path): + self.dbg("Saving SMS to %s" % sms["path"]) + data = {"modem": self.index, "payload": payload} + fwritej(path, data) + self.notif("SMS-RECEIVED %s" % os.path.basename(path)) + + storage = payload["properties"]["storage"].upper() + if storage != "--": + if not runcmd(["mmcli", "-m", self.path, + "--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) + + def check_sms(self): + if self.state.value < State.ENABLED.value: + return + state = self.status.get("state", "") + if state != "registered" and state != "connected": + return + + 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"]) + + elif sms["type"] == "sent": + self.notif("SMS-SENT") + runcmd(["mmcli", "-m", self.path, + "--messaging-delete-sms=%s" % sms["path"]]) + + elif sms["type"] == "received": + self.save_sms(sms) + + def check(self): + timeout = 10 if self.state == State.UP else 3 + elapsed = time.time() - self.timer1 + if elapsed > timeout: + self.check_trigger() + if self.update(): + self.check_state() + self.timer1 = time.time() + + elapsed = time.time() - self.timer2 + if elapsed > 30: + self.check_location() + self.check_sms() + self.timer2 = time.time() + + def lookup(self): + if self.iface and self.sim: + return True + + info = runcmdj(['/usr/libexec/modemd/modem-info', + '-i', str(self.index)]) + if not info: + self.err("No modem info") + return False + + ifaces = info.get("interfaces", []) + if len(info.get("interfaces", [])) == 0: + self.err("No modem interface") + return False + else: + self.iface = ifaces[0] + + self.sim = info.get("sim", None) + if not self.sim: + self.err("No modem sim") + return False + + if self.sim["index"] < 0: + self.err("Invalid sim index") + return False + + return True + + def query(self): + if self.path: + return True + + modem = None + output = runcmdj(['/usr/libexec/modemd/modem-info']) + if output: + for m in output: + if m.get("index", -1) == self.index: + modem = m + break + if modem is None: + self.err("Modem not present") + return False + + info = modem.get("info") + if not info: + self.err("Unable to obtain modem info") + return False + + path = "%s/manufacturer" % self.rundir + self.manf = info.get("manufacturer", "") + self.dbg("Manufacturer is '%s'" % self.manf) + fwrite(path, self.manf) + + path = "%s/model" % self.rundir + self.model = info.get("model", "") + self.dbg("Model is '%s'" % self.model) + fwrite(path, self.model) + + self.path = modem.get("path") + self.dbg("Got path %s" % self.path) + return True + + def detect(self): + self.info("Detecting") + + path = "%s/detected" % self.rundir + if os.path.exists(path): + os.remove(path) + + while not self.stopped(): + if self.lookup() and self.query(): + fwrite(path, "1") + self.info("Detected %s %s" % (self.manf, self.model)) + return True + else: + time.sleep(1) + + def run(self): + while not self.stopped(): + self.dbg("state %s, status '%s' (detected %s)" % + (self.state.name, + self.status.get("state", "unknown"), + str(self.detected))) + + if not self.detected: + if self.detect() and self.initialize(): + self.detected = True + else: + time.sleep(3) + + if self.detected: + self.check() + + time.sleep(1) + + self.pulldown() + + rmrf(self.statedir) + rmrf(self.locdir) + rmrf(self.rundir) + + self.dbg("Exiting") + self.exited = True + return True + + +def sighandler(signum, frame): + global threads + + for th in threads: + th.stop() + + # wait for modem threads + timeout = 0 + while timeout < 3: + exited = True + for th in threads: + if th.name.startswith("modem") and not th.exited: + exited = False + if exited: + break + time.sleep(1) + timeout += 1 + + if signum == 15: + sys.exit(0) + else: + fatal("Exiting on signal %d" % signum) + + +if __name__ == "__main__": + syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) + + if os.geteuid() != 0: + fatal("Must be root") + + parser = argparse.ArgumentParser(prog="modemd") + parser.add_argument("-d", action="store_true") + args = parser.parse_args() + + if args.d: + debug = True + else: + with open("/proc/cmdline", 'r') as fd: + cmdline = fd.read() + if "debug" in cmdline: + debug = True + + if debug: + runcmd(["mmcli", "-G", "debug"]) + + mkdir(rundir) + mkdir(smsdir) + signal.signal(signal.SIGINT, sighandler) + signal.signal(signal.SIGTERM, sighandler) + + try: + th = RpcThread() + th.start() + except Exception as e: + if debug: + print(e) + fatal("Rpc thread caught an exception") + + try: + th = SimThread() + th.start() + except Exception as e: + if debug: + print(e) + fatal("SIM thread caught an exception") + + config = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "infix-modem"]) + if not config or "infix-modem:modems" not in config: + fatal("Unable to read config") + + if not runcmd(['/usr/libexec/modemd/sim-setup']): + fatal("Unable to setup SIMs") + + for cfg in config["infix-modem:modems"]["modem"]: + if not cfg["enabled"] or cfg["index"] < 0: + continue + + th = None + try: + th = ModemThread(cfg) + th.start() + except Exception as e: + if debug: + print(e) + fatal("Modem %d thread caught an exception" % cfg["index"]) + if th: + threads.append(th) + + if len(threads) == 0: + fatal("No modem threads are running") + + [th.join() for th in threads] + + fatal("Abnormal exit") diff --git a/src/modemd/modemd.modules-load b/src/modemd/modemd.modules-load new file mode 100644 index 000000000..454279039 --- /dev/null +++ b/src/modemd/modemd.modules-load @@ -0,0 +1,2 @@ +# Placeholder — add module names here if any modem drivers need explicit +# loading (e.g. when device IDs are not yet upstream in the driver table). diff --git a/src/modemd/modemd.rules b/src/modemd/modemd.rules new file mode 100644 index 000000000..4725c0570 --- /dev/null +++ b/src/modemd/modemd.rules @@ -0,0 +1,8 @@ +ACTION!="add", GOTO="modemd_end" + +SUBSYSTEM=="net", ENV{INTERFACE}=="wwan[0-9]*", RUN+="/usr/libexec/modemd/modem-udev" +SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}=="1", RUN+="/usr/libexec/modemd/modem-udev" +SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_SECONDARY}=="1", RUN+="/usr/libexec/modemd/modem-udev" +SUBSYSTEM=="usbmisc", KERNEL=="cdc-wdm[0-9]*",, RUN+="/usr/libexec/modemd/modem-udev" + +LABEL="modemd_end" diff --git a/src/modemd/qmi-wwan-ids.rules b/src/modemd/qmi-wwan-ids.rules new file mode 100644 index 000000000..902f8d8f8 --- /dev/null +++ b/src/modemd/qmi-wwan-ids.rules @@ -0,0 +1,7 @@ +# Bind USB modem devices whose IDs are not yet upstream in qmi_wwan. +# Required until the IDs are accepted into the kernel driver. +ACTION!="add", GOTO="qmi_wwan_ids_end" +SUBSYSTEM!="usb", GOTO="qmi_wwan_ids_end" +ENV{DEVTYPE}!="usb_device", GOTO="qmi_wwan_ids_end" + +LABEL="qmi_wwan_ids_end" diff --git a/src/modemd/sim-setup b/src/modemd/sim-setup new file mode 100755 index 000000000..be400f036 --- /dev/null +++ b/src/modemd/sim-setup @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 + +import subprocess +import json +import fcntl +import struct +import time +import syslog +import sys +import os + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + +def log(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def fread(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +def freadj(path): + output = fread(path) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + err("Unable to parse json output") + return None + finally: + return data + + +def find_modem(setup, index): + for modem in setup["modems"]: + if modem["index"] == index: + return modem + return None + + +def find_sim(setup, index): + for sim in setup["sims"]: + if sim["index"] == index: + return sim + return None + + +def pwrctl(setup, state): + for modem in setup["modems"]: + path = "/sys/class/pcie/slot%d/pwrctl" % modem["slot"] + if not os.path.exists(path): + continue + with open(path, "w") as fd: + fd.write("%s\n" % state) + + +def sim_ctrl(cmd, slots): + req = struct.pack('2i', *slots) + + if not os.path.exists("/dev/simctrl"): + return None + + try: + fd = os.open("/dev/simctrl", os.O_RDWR) + except OSError: + fatal("Cannot open sim ctrl") + + try: + resp = fcntl.ioctl(fd, cmd, req) + except OSError: + fatal("Cannot run sim ctrl ioctl") + + if not resp: + return None + else: + return struct.unpack('2i', resp) + + +def sim_get_slots(): + # NM_SIMIOC_SLOT_GET is 0x80085300 (see linux/nm-sim.h) + return sim_ctrl(0x80085300, (0, 0)) + + +def sim_set_slots(slots): + # NM_SIMIOC_SLOT_SET is 0x40085301 (see linux/nm-sim.h) + return sim_ctrl(0x40085301, slots) + + +def slot_setup(setup): + slots = [] + for sim in setup["sims"]: + log("Connecting sim%d to slot %d" % (sim["index"], sim["newslot"])) + slots.append(sim["newslot"]) + + if sim_set_slots(tuple(slots)) is None: + return False + else: + return True + + +def change_setup(setup): + log("Changing SIM setup") + + runcmd(["initctl", "stop", "modem-manager"]) + time.sleep(1) + + pwrctl(setup, "down") + time.sleep(1) + + if slot_setup(setup) is False: + err("Unable to set up sim slots") + + pwrctl(setup, "up") + + time.sleep(1) + runcmd(["initctl", "restart", "modem-manager"]) + + return True + + +def swapslot(slot): + if slot == 0: + return 1 + elif slot == 1: + return 0 + else: + return -1 + + +def sim_setup(setup): + changed = False + + config = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "infix-modem"]) + if not config or "infix-modem:modems" not in config: + err("Cannot read config") + return False + + for cfg in config["infix-modem:modems"]["modem"]: + if not cfg.get("enabled", False) or cfg.get("index", -1) < 0: + continue + + modem = find_modem(setup, cfg["index"]) + sim = find_sim(setup, cfg.get("sim", -1)) + if modem and sim: + sim["newslot"] = modem["slot"] + if sim["newslot"] != sim["slot"]: + changed = True + + if not changed: + log("SIMs already set up") + return True + + if len(setup["modems"]) == 2 and len(setup["sims"]) == 2: + for i in range(0, 2): + o = 1 if i == 0 else 0 + if setup["sims"][i]["newslot"] == -1: + log("Swapping slot of sim%d" % setup["sims"][i]["index"]) + setup["sims"][i]["newslot"] = swapslot(setup["sims"][o]["newslot"]) + + return change_setup(setup) + + +def read_setup(): + setup = { + "modems": [], + "sims": [] + } + + modules = freadj("/run/modules.json") + if not modules: + log("No modules.json found, skipping SIM setup") + return None + + for module in modules.get("modules", []): + if module["slot"] > -1 and module["type"] == "modem": + setup["modems"].append({"index": module["index"], + "slot": module["slot"]}) + + slots = sim_get_slots() + if slots: + for index in range(0, len(slots)): + setup["sims"].append({"index": index, + "slot": slots[index], + "newslot": -1}) + + return setup + + +if __name__ == "__main__": + setup = read_setup() + if setup is None: + sys.exit(0) + + if sim_setup(setup) is False: + fatal("Unable set up SIMs") + + sys.exit(0) diff --git a/src/statd/python/yanger/__main__.py b/src/statd/python/yanger/__main__.py index c88d4648f..92d8a1823 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -126,6 +126,9 @@ def main(): elif model == 'ieee1588-ptp-tt': from . import ieee1588_ptp yang_data = ieee1588_ptp.operational() + elif model == 'infix-modem': + from . import infix_modem + yang_data = infix_modem.operational() else: common.LOG.warning("Unsupported model %s", model) sys.exit(1) diff --git a/src/statd/python/yanger/infix_modem.py b/src/statd/python/yanger/infix_modem.py new file mode 100644 index 000000000..a2b0f6d4f --- /dev/null +++ b/src/statd/python/yanger/infix_modem.py @@ -0,0 +1,12 @@ +from .common import LOG +from .host import HOST + + +def operational(): + modems = HOST.run_json(['/usr/libexec/modemd/modem-info'], []) + + return { + "infix-modem:modems": { + "modem": [m for m in modems] + } + } From fd7d3bab94f08b3ed381ff5e985a50a62c62136a Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 3 May 2026 22:45:28 +0200 Subject: [PATCH 3/8] statd, show: fix several operational data reporting bugs Three bugs found while testing modem support on hardware: ietf_system.py: "interface": null written to DNS server entries when no interface is associated, causing LY_EVALID in the ietf-system schema validator. Only include the key when the interface name is non-empty. ietf_system.py: empty list inserts (user [], server [], options [], search []) overwrote configured data in the operational store instead of leaving it intact. Guard each insert so operational data only shadows configured data when there is something to report. show system: CPU temperature reported only the first sensor named exactly "cpu", "soc", or "core". Platforms with multiple thermal zones (e.g. BPI-R4 exposes cpu and cpu1) always showed only one reading. Collect all sensors whose name matches a CPU/SoC prefix and report the maximum. statd.c: yanger parse errors logged without model name or libyang error string, making failures hard to diagnose. Include both. Signed-off-by: Joachim Wiberg --- src/bin/show/__init__.py | 15 +++++++-------- src/statd/python/yanger/ietf_system.py | 21 ++++++++++++--------- src/statd/statd.c | 5 +++-- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index 266e642b8..f377e817d 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -552,8 +552,9 @@ def system(args: List[str]) -> None: runtime = {} # Extract CPU temperature and fan speed from hardware components - cpu_temp = None + cpu_temps = [] fan_rpm = None + _cpu_prefixes = ("cpu", "soc", "core") if hardware_data and "ietf-hardware:hardware" in hardware_data: components = hardware_data.get("ietf-hardware:hardware", {}).get("component", []) for component in components: @@ -564,18 +565,16 @@ def system(args: List[str]) -> None: name = component.get("name", "") value_type = sensor_data.get("value-type") - # Only capture CPU/SoC temperature (ignore phy, sfp, etc.) - # Different platforms use different names: cpu, soc, core, etc. - if value_type == "celsius" and name in ("cpu", "soc", "core") and cpu_temp is None: - temp_millidegrees = sensor_data.get("value", 0) - cpu_temp = temp_millidegrees / 1000.0 + # Capture all CPU/SoC temperatures (handles cpu, cpu1, soc, core0, etc.) + if value_type == "celsius" and any(name == p or name.startswith(p) for p in _cpu_prefixes): + cpu_temps.append(sensor_data.get("value", 0) / 1000.0) # Capture fan speed if available elif value_type == "rpm" and fan_rpm is None: fan_rpm = sensor_data.get("value", 0) - if cpu_temp is not None: - runtime["cpu_temp"] = cpu_temp + if cpu_temps: + runtime["cpu_temp"] = max(cpu_temps) if fan_rpm is not None: runtime["fan_rpm"] = fan_rpm diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index 60c7c6c01..f096c982b 100644 --- a/src/statd/python/yanger/ietf_system.py +++ b/src/statd/python/yanger/ietf_system.py @@ -134,11 +134,10 @@ def add_dns(out): try: ipaddress.ip_address(ip) - servers.append({ - "address": ip, - "origin": "dhcp", - "interface": iface - }) + entry = {"address": ip, "origin": "dhcp"} + if iface: + entry["interface"] = iface + servers.append(entry) except ValueError: continue @@ -146,9 +145,12 @@ def add_dns(out): parts = line.split('#', 1) search.extend(parts[0].split()[1:]) - insert(out, "infix-system:dns-resolver", "options", options) - insert(out, "infix-system:dns-resolver", "server", servers) - insert(out, "infix-system:dns-resolver", "search", search) + if options: + insert(out, "infix-system:dns-resolver", "options", options) + if servers: + insert(out, "infix-system:dns-resolver", "server", servers) + if search: + insert(out, "infix-system:dns-resolver", "search", search) def add_software_slots(out, data): slots = [] @@ -357,7 +359,8 @@ def add_users(out): users.append(user) - insert(out, "authentication", "user", users) + if users: + insert(out, "authentication", "user", users) def add_clock(out): clock = {} diff --git a/src/statd/statd.c b/src/statd/statd.c index dac055836..ad7c78c11 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -112,7 +112,8 @@ static int ly_add_yanger_data(const struct ly_ctx *ctx, struct lyd_node **parent err = lyd_parse_data_fd(ctx, fd, LYD_JSON, LYD_PARSE_ONLY, 0, parent); if (err) - ERROR("Error, parsing yanger data (%d): %s", err, ly_errmsg(ctx)); + ERROR("Error, parsing yanger data (%d) for model '%s': %s", err, + yanger_args[1] ?: "?", ly_errmsg(ctx)); fclose(stream); /* Note: fclose() already closes the underlying fd from fdopen() */ @@ -227,7 +228,7 @@ static int sr_generic_cb(sr_session_ctx_t *session, uint32_t, const char *model, err = ly_add_yanger_data(ctx, parent, yanger_args); if (err) - ERROR("Error adding yanger data"); + ERROR("Error adding yanger data for xpath: %s", xpath); sr_release_context(con); From abe9f6bf7b6524ce2dc6f10ed0ad962d1567c865 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 3 May 2026 22:46:41 +0200 Subject: [PATCH 4/8] modem: migrate from infix-modem to ietf-hardware/ietf-interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial modem import used a standalone infix-modem YANG module with an integer-indexed list under /infix-modem:modems carrying a bearer config mixed in with hardware config. Infix has adopted the ietf-hardware model to allow for a clearer separation of concerns, this commit aims to improve on that situation by refactoring the modem model to use the same architectural pattern already used for WiFi radios, GPS receivers, and USB ports: Hardware: /ietf-hardware:hardware/component[class=infix-hardware:modem] /ietf-hardware:hardware/component[class=infix-hardware:sim] Interface: /ietf-interfaces:interfaces/interface[type=infix-if-type:modem] Configuration split: modem component — admin-state (enable/disable), radio access mode, frequency bands, location services, probe-timeout sim component — PIN, PUK, carrier profile wwan interface — APN, IP type, roaming, route-preference, auth This also enables the modem to appear in 'show hardware' alongside other hardware components, and to be managed over NETCONF/RESTCONF using the same ietf-hardware RPCs used for USB and WiFi. YANG changes: - infix-modem.yang removed (1089 lines, standalone module) - infix-hardware.yang: new modem and sim containers, modem-state and sim-state operational state, typedefs for bands/modes/location, notification status-update; actions restart/reset/send-sms are augmented directly on the component list entry (not inside container modem) to work around sysrepo NP-container parent validation — when checking a nested action's parent, sysrepo restricts its operational data fetch to the container path, excluding the sibling 'class' leaf needed to evaluate the container's 'when' condition, so the container is never instantiated and the parent check fails - infix-if-modem.yang: new submodule (same pattern as wifi/wireguard) adds the wwan bearer container to ietf-interfaces - infix-if-type.yang: new modem identity for wwan interface type - confd version bumped to 1.9 confd: - if-modem.c: new file replaces the stub modem_gen() in modem.c; generates dagger init scripts with probe-timeout wait loop and unconditional dummy wwan creation so downstream IP config succeeds when a USB modem is slow to enumerate at boot - hardware.c: start/stop/enable/disable modemd (and modem-manager) in response to admin-state changes on the modem hardware component; use finit_enable/disable consistently - hardware_cand_infer_class(): auto-set class for modemN/simN names - modem.c: stripped to RPC/notification forwarding only; the old genconf() / enable() / disable() logic is gone — hardware.c owns the lifecycle now - interfaces.c: remove wwan from wait-interface timeout (the modem interface appearance is managed by modemd, not confd's init scripts) - migrate/1.9: script fully migrates /infix-modem:modems to the new split model; each modem[N] becomes a modemN hardware component and simN sim component; each bearer becomes a wwanN interface (bearers numbered globally across modems); APN, IP type, roaming, preferred- mode, bands, location, PIN/PUK/carrier are all carried over; plain- text bearer credentials are moved to a named keystore symmetric-key (base64-encoded) referenced from bearer/authentication/password; apn-type, firewall-enabled, dns-enabled and default-route are dropped (no equivalent in the new model) modemd: - load_config() replaces the infix-modem sysrepocfg call; reads from ietf-hardware (modem/sim config), ietf-interfaces (bearer config), and ietf-keystore (credentials) — three standard modules instead of one proprietary one; modems absent from sysrepo config but present in system.json are still started (physical detection wins) - Default route written to /etc/net.d/.conf (picked up by netd/staticd/zebra) instead of a direct 'ip route add'; routes are now visible to FRR for redistribution and metric-based failover - Addresses tagged with proto wwan (rt_addrprotos entry 7) for precise flush on disconnect without touching other address sources - _derive_gateway() handles point-to-point bearers where modem reports gateway as "--"; derives peer from link-scope subnet route - SimThread exits cleanly on platforms without /dev/simctrl - sim-setup, modem-info, modem-udev, modem-rpc, modem-sms all ported to the ietf-hardware/ietf-interfaces namespace statd: - infix_modem.py now emits modem-state and sim-state into ietf-hardware component entries (modem0, sim0, ...) instead of a separate tree; sim-state reports physical slot, lock state, and SIM operator name - ietf_hardware.py: hardware component names deduplicated by rename (cpu → cpu, cpu1, ...) to prevent LY_EVALID when hwmon and thermal sysfs both normalise to the same sensor name - show hardware: Cellular Modems and SIM Cards tables added; sensor table width now adapts to the widest section in the output - 00-probe: USB modem detection writes to system.json["modem"], same source as wifi-radios, feeding gen-hardware which emits the ietf-hardware component entries at factory config generation time CLI: - 'show modem [ref]': without ref shows Cellular Modems and SIM Cards tables (same data subset as 'show hardware'); with ref shows a full per-modem dump (hardware info, status, cellular, SIM, bearers, GPS) - 'modem restart ': disconnect and reconnect all bearers without a full hardware reset - 'modem reset ': factory-reset the modem firmware - 'modem sms ': send an SMS via the modem (uses the signalling plane; no active data bearer required) - MODEMS PTYPE provides tab-completion reading modem names from /run/system.json Signed-off-by: Joachim Wiberg --- .../common/rootfs/etc/iproute2/rt_addrprotos | 1 + .../rootfs/usr/libexec/infix/init.d/00-probe | 68 + doc/README.md | 1 + doc/modem.md | 376 ++++++ mkdocs.yml | 1 + package/confd/confd.mk | 7 + package/feature-modem/feature-modem.mk | 1 + src/bin/show/__init__.py | 28 + src/confd/bin/gen-hardware | 26 + src/confd/configure.ac | 3 +- .../migrate/1.9/10-remove-infix-modem.sh | 123 ++ src/confd/share/migrate/1.9/Makefile.am | 2 + src/confd/share/migrate/Makefile.am | 2 +- src/confd/src/Makefile.am | 1 + src/confd/src/hardware.c | 50 + src/confd/src/if-modem.c | 112 ++ src/confd/src/interfaces.c | 7 +- src/confd/src/interfaces.h | 6 +- src/confd/src/modem.c | 276 ++--- src/confd/yang/confd.inc | 5 +- src/confd/yang/confd/infix-hardware.yang | 557 ++++++++- .../yang/confd/infix-hardware@2026-04-27.yang | 1 + src/confd/yang/confd/infix-if-modem.yang | 214 ++++ .../yang/confd/infix-if-modem@2026-04-27.yang | 1 + src/confd/yang/confd/infix-if-type.yang | 17 + .../yang/confd/infix-if-type@2026-04-27.yang | 1 + src/confd/yang/confd/infix-interfaces.yang | 6 + .../confd/infix-interfaces@2026-04-27.yang | 1 + src/confd/yang/confd/infix-modem.yang | 1089 ----------------- .../yang/confd/infix-modem@2024-03-15.yang | 1 - src/confd/yang/modem.inc | 5 + src/klish-plugin-infix/xml/infix.xml | 42 + src/modemd/modem-info | 115 +- src/modemd/modem-rpc | 9 +- src/modemd/modem-sms | 16 +- src/modemd/modem-udev | 48 +- src/modemd/modemd | 294 +++-- src/modemd/sim-setup | 37 +- src/statd/python/cli_pretty/cli_pretty.py | 286 ++++- src/statd/python/yanger/__main__.py | 3 - src/statd/python/yanger/ietf_hardware.py | 38 +- src/statd/python/yanger/infix_modem.py | 84 +- 42 files changed, 2474 insertions(+), 1487 deletions(-) create mode 100644 doc/modem.md create mode 100755 src/confd/share/migrate/1.9/10-remove-infix-modem.sh create mode 100644 src/confd/share/migrate/1.9/Makefile.am create mode 100644 src/confd/src/if-modem.c create mode 120000 src/confd/yang/confd/infix-hardware@2026-04-27.yang create mode 100644 src/confd/yang/confd/infix-if-modem.yang create mode 120000 src/confd/yang/confd/infix-if-modem@2026-04-27.yang create mode 120000 src/confd/yang/confd/infix-if-type@2026-04-27.yang create mode 120000 src/confd/yang/confd/infix-interfaces@2026-04-27.yang delete mode 100644 src/confd/yang/confd/infix-modem.yang delete mode 120000 src/confd/yang/confd/infix-modem@2024-03-15.yang create mode 100644 src/confd/yang/modem.inc diff --git a/board/common/rootfs/etc/iproute2/rt_addrprotos b/board/common/rootfs/etc/iproute2/rt_addrprotos index f31bdca27..3de5ebd2f 100644 --- a/board/common/rootfs/etc/iproute2/rt_addrprotos +++ b/board/common/rootfs/etc/iproute2/rt_addrprotos @@ -5,3 +5,4 @@ 4 static 5 dhcp 6 random +7 wwan diff --git a/board/common/rootfs/usr/libexec/infix/init.d/00-probe b/board/common/rootfs/usr/libexec/infix/init.d/00-probe index e2054cefb..456910392 100755 --- a/board/common/rootfs/usr/libexec/infix/init.d/00-probe +++ b/board/common/rootfs/usr/libexec/infix/init.d/00-probe @@ -620,6 +620,73 @@ def probe_ptp_capabilities(out): out.setdefault("interfaces", {}).update(ifaces) +def _usb_root_from_sysfs(dev_path): + p = dev_path + while p not in ("/", "/sys"): + parent = os.path.realpath(os.path.join(p, "..")) + basename = os.path.basename(parent) + if basename.startswith("usb") and basename[3:].isdigit(): + return parent + p = parent + return None + + +def _read_usb_vid_pid(dev_path): + p = dev_path + while p not in ("/", "/sys"): + try: + vid = open(os.path.join(p, "idVendor")).read().strip() + pid = open(os.path.join(p, "idProduct")).read().strip() + return vid, pid + except OSError: + pass + p = os.path.dirname(p) + return "", "" + + +def probe_modems(out): + """devpath is the USB bus root; ModemManager matches by path prefix. + seen_roots deduplicates cdc-wdm and ttyUSB ports of the same physical modem.""" + seen_roots = set() + modems = [] + idx = 0 + + def add_modem(dev_path): + nonlocal idx + usb_root = _usb_root_from_sysfs(dev_path) + if usb_root is None or usb_root in seen_roots: + return + seen_roots.add(usb_root) + vid, pid = _read_usb_vid_pid(dev_path) + modems.append({ + "index": idx, + "name": "modem%d" % idx, + "devpath": usb_root, + "vid": vid, + "pid": pid, + }) + idx += 1 + + for cls_dir, prefix in (("/sys/class/usbmisc", "cdc-wdm"), + ("/sys/class/tty", "ttyUSB")): + try: + entries = os.listdir(cls_dir) + except OSError: + continue + for entry in entries: + if not entry.startswith(prefix): + continue + try: + dev_path = os.path.realpath( + os.path.join(cls_dir, entry, "device")) + add_modem(dev_path) + except OSError: + pass + + if modems: + out["modem"] = modems + + def main(): out = { "vendor": None, @@ -647,6 +714,7 @@ def main(): probe_wifi_radios(out) probe_ptp_capabilities(out) + probe_modems(out) if not out["factory-password-hash"]: sys.stdout.write("\n\n\033[31mCRITICAL BOOTSTRAP ERROR\n" + diff --git a/doc/README.md b/doc/README.md index 7ac9c004c..169e6a9fa 100644 --- a/doc/README.md +++ b/doc/README.md @@ -22,6 +22,7 @@ regression test system solely relies on NETCONF and RESTCONF. - [System Configuration](system.md) - [Network Configuration](networking.md) - [DHCP Server](dhcp.md) + - [Cellular Modem (WWAN)](modem.md) - [Syslog Support](syslog.md) - **Infix In-Depth** - [Boot Procedure](boot.md) diff --git a/doc/modem.md b/doc/modem.md new file mode 100644 index 000000000..6b13c773a --- /dev/null +++ b/doc/modem.md @@ -0,0 +1,376 @@ +# Cellular Modem (WWAN) + +Infix supports cellular modem connectivity via modems that expose a +QMI or MBIM control interface over USB. Form factor is not +significant — USB dongles, mPCIe cards, and M.2 Key-B modules all +work as long as the modem chipset uses USB on the connector (the +typical case for 4G/LTE modems). See *Supported Modems* below for +the exceptions. + +Setup involves three configuration items: + +- A `modem0` hardware component representing the physical modem +- A `sim0` hardware component representing the SIM card slot +- A `wwan0` network interface that references both and carries the + bearer (APN) configuration + +## Architecture + +Infix uses a three-layer architecture for cellular modem support: + +1. **Modem hardware component (modem0)**: Represents the physical modem + - Configured via `ietf-hardware` module with class `infix-hardware:modem` + - `admin-state` controls whether ModemManager and modemd are active + - Holds physical-layer config: allowed bands, preferred mode, location + - Auto-discovered into factory-default config when the modem is + present at first boot + +2. **SIM hardware component (sim0)**: Represents the SIM card slot + - Configured via `ietf-hardware` module with class `infix-hardware:sim` + - Holds PIN/PUK credentials and carrier profile + - Auto-discovered into factory-default config alongside the modem + +3. **Network interface (wwan0)**: Data bearer to the cellular network + - Configured via `ietf-interfaces` module with type `infix-if-type:modem` + - References a modem component and a SIM component + - Holds bearer config: APN, IP type, roaming, route preference, authentication + - Always added by the user; never auto-created + +## Naming Conventions + +| **Name Pattern** | **Type** | **Description** | +|------------------|----------------------|-------------------------------------------------| +| `modemN` | Modem hardware | Hardware component for the physical modem | +| `simN` | SIM hardware | Hardware component for the SIM card slot | +| `wwanN` | Modem interface | Network interface for cellular data | + +Where `N` is a number (0, 1, 2, ...). + +> [!TIP] +> Using these naming conventions simplifies configuration since type and class +> are automatically inferred. Creating a hardware component named `modem0` +> automatically sets its class to `infix-hardware:modem`, and creating an +> interface named `wwan0` automatically sets its type to `infix-if-type:modem`. +> +> **Note:** This inference only works via the CLI. When configuring over +> NETCONF or RESTCONF the class and type must be set explicitly. + +## Multi-Bearer (Multiple APNs) + +Multiple wwan interfaces can reference the same modem component, each +with a different APN. This is analogous to multi-SSID on a WiFi radio — +one hardware modem, multiple independent data connections. + +Configure `wwan0` and `wwan1` both pointing to `modem0`: + +``` +edit interfaces interface wwan0 wwan modem modem0 +edit interfaces interface wwan0 wwan bearer apn internet +edit interfaces interface wwan1 wwan modem modem0 +edit interfaces interface wwan1 wwan bearer apn corporate.vpn.apn +``` + +## Current Limitations + +- The modem must be present at boot — hot-plug is not supported +- If the modem is absent at boot (and no `probe-timeout` is set), a dummy + `wwan0` placeholder is created immediately so IP configuration can proceed; + a reboot is required once hardware is inserted + +## Supported Modems + +Modems exposing a CDC-WDM control interface over USB are supported, +regardless of physical form factor. Two protocols are handled by +ModemManager: + +- **MBIM** — Mobile Broadband Interface Model (e.g. Sierra Wireless, Quectel EM06/EM12) +- **QMI** — Qualcomm MSM Interface (e.g. Sierra Wireless EM7xxx, Quectel EM/RMxxx) + +Most 4G/LTE modules — whether USB dongles, mPCIe cards, or M.2 Key-B +modules — use USB on the connector even when the slot also carries +PCIe lanes; from Infix's view they are all USB modems. PCIe-only +modems (some 5G NR modules) are not currently supported, since the +modemd / ModemManager pipeline assumes a USB-attached control +interface. + +## Step-by-step Setup + +### 1 — Hardware Detection + +At first boot, USB modems detected by the kernel are written to +`/run/system.json` and added as hardware components in the factory- +default configuration. Verify the modem appears: + +
admin@example:/> show modem
+CELLULAR MODEMS                                               
+──────────────────────────────────────────────────────────────
+Cellular Modems                                               
+Name                : modem0
+Manufacturer        : Quectel
+Model               : EM06-E
+Firmware            : EM06ELAR04A07M4G
+State               : registered
+Signal              : 72%
+RSRP                : -95 dBm
+SINR                : 12.4 dB
+Operator            : Tele2 (23002)
+Network             : LTE
+
+ +If `modem0` does not appear: + +- Verify the kernel sees the modem (`dmesg | grep -i mbim` or + `dmesg | grep -i qmi`); without a kernel driver no further setup is + possible. +- On boards with a custom factory configuration, or after replacing + hardware on a running system, the components may need to be added + manually — see Step 2. + +### 2 — Hardware Components + +If `show modem` already lists `modem0` and `sim0`, the components were +auto-discovered into the factory-default configuration and you can skip +ahead to Step 3. + +Otherwise, add them manually. The class is inferred from the component +name (`modemN` → `infix-hardware:modem`, `simN` → `infix-hardware:sim`): + +
admin@example:/> configure
+admin@example:/config/> edit hardware component modem0
+admin@example:/config/hardware/…/modem0/> leave
+admin@example:/config/> edit hardware component sim0
+admin@example:/config/hardware/…/sim0/> leave
+admin@example:/config/> leave
+
+ +The component is created with `admin-state unlocked` by default, which +causes confd to start ModemManager and modemd. To take the modem +offline cleanly without removing the configuration, set `admin-state +locked` — both services are stopped and the bearer is torn down before +the modem goes offline: + +
admin@example:/config/> edit hardware component modem0
+admin@example:/config/hardware/…/modem0/> set state admin-state locked
+admin@example:/config/hardware/…/modem0/> leave
+
+ +#### Slow USB Modems + +USB modems can be slow to enumerate at boot — the kernel wwan interface +may not appear until several seconds after confd starts applying +configuration. The `probe-timeout` leaf inside the `modem` hardware +configuration container controls how long confd waits; it **defaults to +30 seconds** when the container is present. + +To enable the timeout, create the modem configuration container (this also +lets you configure bands, preferred mode, etc.): + +
admin@example:/config/> edit hardware component modem0 modem
+admin@example:/config/hardware/…/modem/> leave
+
+ +With `probe-timeout` at its default of 30, confd waits up to 30 seconds +for the wwan interface to appear before proceeding — for most modems it +will be ready in 2–5 seconds. If the modem has not appeared within the +timeout, a dummy placeholder interface is created and a reboot is required +for the real interface to take over. Set `probe-timeout 0` to disable +waiting entirely. + +### 3 — Configure the Bearer (APN) + +Bearer configuration lives on the `wwan0` interface. Reference the modem +hardware component and set the APN: + +
admin@example:/> configure
+admin@example:/config/> edit interfaces interface wwan0
+admin@example:/config/interfaces/…/wwan0/> set wwan modem modem0
+admin@example:/config/interfaces/…/wwan0/> set wwan sim sim0
+admin@example:/config/interfaces/…/wwan0/> set wwan bearer apn internet
+admin@example:/config/interfaces/…/wwan0/> leave
+
+ +The modem will connect automatically once the bearer is configured and +the hardware is unlocked. + +**Key bearer parameters:** + +- `apn`: Access Point Name — required, provided by your operator + (e.g. `internet`, `data.vodafone.com`, `web.tele2.se`) +- `route-preference`: Administrative distance for the default route (default: `200`). + Higher value = lower priority. Default 200 places cellular behind wired + Ethernet (distance 5) and WiFi automatically, making it a natural failover +- `roaming`: Allow data when roaming on a foreign network (default: `false`) +- `ip-type`: `ipv4`, `ipv6`, or `ipv4v6` dual-stack (default: `ipv4v6`) + +### 4 — Configure Authentication + +Most consumer APNs connect without credentials. If your operator requires +authentication, first store the password in the keystore, then reference +it from the bearer: + +
admin@example:/config/> edit keystore symmetric-keys symmetric-key apn-pass
+admin@example:/config/keystore/…/apn-pass/> set cleartext-symmetric-key mypassword
+admin@example:/config/keystore/…/apn-pass/> up 4
+admin@example:/config/> edit interfaces interface wwan0 wwan bearer
+admin@example:/config/interfaces/…/wwan/bearer/> edit authentication
+admin@example:/config/interfaces/…/wwan/bearer/authentication/> set username myuser
+admin@example:/config/interfaces/…/wwan/bearer/authentication/> set password apn-pass
+admin@example:/config/interfaces/…/wwan/bearer/authentication/> leave
+
+ +The `authentication` container is a presence container — creating it +enables authentication. The `password` leaf is a reference to a symmetric +key in the keystore, not the plaintext password itself. + +The authentication protocol defaults to `chap`. To use PAP instead: + +
admin@example:/config/interfaces/…/wwan/bearer/authentication/> set type pap
+
+ +### 5 — Configure SIM PIN + +If the SIM requires a PIN to unlock, configure it on the SIM hardware component: + +
admin@example:/config/> edit hardware component sim0
+admin@example:/config/hardware/…/sim0/> set sim pin 1234
+admin@example:/config/hardware/…/sim0/> leave
+
+ +### 6 — Verify Connectivity + +Once connected, the `wwan0` interface receives an IP address from the +carrier and modemd installs the default route: + +
admin@example:/> show interface wwan0
+name                : wwan0
+type                : modem
+index               : 5
+mtu                 : 1500
+operational status  : up
+ip forwarding       : enabled
+physical address    : 12:34:56:78:9a:bc
+ipv4 addresses      : 10.142.87.33/30 (wwan)
+ipv6 addresses      : 2001:db8:1:2::1/64 (wwan)
+in-octets           : 84213
+out-octets          : 31456
+
+ +Check the full modem state including signal quality and registration: + +
admin@example:/> show modem modem0
+name                : modem0
+class               : infix-hardware:modem
+admin-state         : unlocked
+oper-state          : enabled
+──────────────────────────────────────────────────────────────
+Manufacturer        : Quectel
+Model               : EM06-E
+Firmware            : EM06ELAR04A07M4G
+IMEI                : 352753090141905
+IMSI                : 240021234567890
+ICCID               : 8946020000001234567
+State               : connected
+Signal              : 72%
+RSRP                : -95 dBm
+RSRQ                : -11 dB
+SINR                : 12.4 dB
+Registration        : home
+Operator            : Tele2 (23002)
+Network             : LTE
+
+ +## Cellular Failover + +The default `route-preference` (200) is deliberately higher than distances +used by wired Ethernet (udhcpc default: 5) and WiFi. When wired +connectivity is available, it takes precedence automatically. Cellular +becomes the active path only if higher-priority routes are withdrawn. + +A default route via the bearer is always installed when the bearer +connects. To adjust the failover priority between two cellular modems, or +to make cellular preferred over WiFi, set `route-preference` explicitly: + +
admin@example:/config/> edit interfaces interface wwan0 wwan bearer
+admin@example:/config/interfaces/…/wwan/bearer/> set route-preference 100
+admin@example:/config/interfaces/…/wwan/bearer/> leave
+
+ +Lower `route-preference` = higher priority. + +## Roaming + +Data roaming is disabled by default. To allow the modem to connect when +on a foreign (roaming) network: + +
admin@example:/config/> edit interfaces interface wwan0 wwan bearer
+admin@example:/config/interfaces/…/wwan/bearer/> set roaming true
+admin@example:/config/interfaces/…/wwan/bearer/> leave
+
+ +> [!IMPORTANT] +> Enabling roaming may incur significant charges depending on your +> mobile subscription. Check with your operator before enabling. + +## Management Commands + +### Restart Bearer + +Disconnect and reconnect all bearers without resetting the modem hardware. +Use this after changing APN or authentication settings: + +
admin@example:/> modem restart modem0
+
+ +### Reset Modem + +Factory-reset the modem firmware. This clears all modem-internal settings +and takes longer than a restart. Only use this if the modem is in a bad +state that a bearer restart cannot fix: + +
admin@example:/> modem reset modem0
+
+ +### Send SMS + +Send an SMS message via the signalling plane. No active data bearer is +required — the modem only needs to be registered on the network: + +
admin@example:/> modem sms modem0 +46701234567 "Hello from Infix"
+
+ +> [!NOTE] +> Some SIM cards have Fixed Dialing Number (FDN) enabled, which restricts +> outgoing SMS and calls to a pre-configured whitelist. If `modem sms` +> fails, check whether FDN is active with `mmcli -m 0` and look for +> `enabled locks: fixed-dialing` in the output. + +## Troubleshooting + +**Modem not detected (`show modem` shows no modem entry)** + +- Verify the modem is connected and recognized by the kernel: check + `dmesg` for `cdc_mbim` or `qmi_wwan` driver messages +- Confirm `/sys/class/usbmisc/` contains a `cdc-wdm*` entry +- The modem must be present at boot — hotplug after boot is not supported + +**`wwan0` shows as `down` or has no IP address** + +- Check `show modem modem0` — the state should show + `registered` or `connected`, not `failed` +- Verify the APN is correct for your operator +- Check system logs with `show log` for modemd or ModemManager messages +- If the state shows `failed`, the modem may need a SIM card inserted or + a PIN unlocked (configure `hardware component sim0 sim pin`) + +**`wwan0` interface is a dummy (no data flows, no carrier address)** + +- The modem was not enumerated by the kernel before confd applied config +- Create the modem hardware configuration container (see Step 2) — this + enables the default 30-second probe-timeout so confd waits for the wwan + interface before falling back to a dummy placeholder + +**High latency or poor signal** + +- Use `show modem modem0` to check signal quality and RSRP +- Signal below -110 dBm RSRP typically indicates poor coverage +- Consider repositioning the antenna or the device diff --git a/mkdocs.yml b/mkdocs.yml index 9e92c10d6..570b1c31c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,7 @@ nav: - Overview: vpn.md - WireGuard: vpn-wireguard.md - Wireless LAN (WiFi): wifi.md + - Cellular Modem (wwan): modem.md - Services: - Device Discovery: discovery.md - DHCP Server: dhcp.md diff --git a/package/confd/confd.mk b/package/confd/confd.mk index ccb50d2b0..b4408c952 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -91,6 +91,12 @@ define CONFD_INSTALL_YANG_MODULES_GPS $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/gps.inc endef endif +ifeq ($(BR2_PACKAGE_FEATURE_MODEM),y) +define CONFD_INSTALL_YANG_MODULES_MODEM + $(COMMON_SYSREPO_ENV) \ + $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/modem.inc +endef +endif # PER_PACKAGE_DIR # Since the last package in the dependency chain that runs sysrepoctl is confd, we need to @@ -121,6 +127,7 @@ CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_CONTAINERS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WIFI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_GPS +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_MODEM CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_IN_ROMFS CONFD_TARGET_FINALIZE_HOOKS += CONFD_CLEANUP diff --git a/package/feature-modem/feature-modem.mk b/package/feature-modem/feature-modem.mk index d5a7827ba..52c9a66ec 100644 --- a/package/feature-modem/feature-modem.mk +++ b/package/feature-modem/feature-modem.mk @@ -14,6 +14,7 @@ define FEATURE_MODEM_LINUX_CONFIG_FIXUPS $(call KCONFIG_ENABLE_OPT,CONFIG_USB_WDM) $(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_QMI_WWAN) $(call KCONFIG_ENABLE_OPT,CONFIG_USB_CDC_MBIM) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_CDC_MBIM) $(if $(filter y,$(BR2_PACKAGE_FEATURE_MODEM_QUALCOMM)), $(call KCONFIG_SET_OPT,CONFIG_QRTR,m) diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index f377e817d..60efca2b1 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -75,6 +75,33 @@ def hardware(args: List[str]) -> None: cli_pretty(data, "show-hardware") +def modem(args: List[str]) -> None: + ref = args[0] if args else None + + if ref: + try: + result = subprocess.run(["/usr/libexec/modemd/modem-info"], + capture_output=True, text=True, check=True) + data = json.loads(result.stdout) if result.stdout.strip() else [] + except (subprocess.CalledProcessError, json.JSONDecodeError): + data = [] + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + cli_pretty({"modem-list": data}, "show-modem-detail", ref) + else: + data = get_json("/ietf-hardware:hardware") + if not data: + print("No modem data available.") + return + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + cli_pretty(data, "show-modem") + + def ntp(args: List[str]) -> None: # Create argument parser for ntp subcommands parser = argparse.ArgumentParser(prog='show ntp', add_help=False) @@ -738,6 +765,7 @@ def execute_command(command: str, args: List[str]): 'keystore': keystore, 'lldp': lldp, 'mdns': mdns, + 'modem': modem, 'nacm': nacm, 'ntp': ntp, 'ospf': ospf, diff --git a/src/confd/bin/gen-hardware b/src/confd/bin/gen-hardware index 8679bc730..c419500b1 100755 --- a/src/confd/bin/gen-hardware +++ b/src/confd/bin/gen-hardware @@ -11,6 +11,11 @@ if jq -e '.["wifi-radios"]' /run/system.json > /dev/null 2>&1; then else wifi_radios="" fi +if jq -e '.["modem"]' /run/system.json > /dev/null 2>&1; then + modem_names=$(jq -r '.["modem"][].name' /run/system.json) +else + modem_names="" +fi gen_port() @@ -61,6 +66,20 @@ gen_radio() EOF } +gen_modem() +{ + modem="$1" + cat < 0 + then {"allowed-mode": [$m["allowed-mode"][].mode]} + else {} end) + + (if (($m.band // []) | length) > 0 + then {"band": [$m.band[].band]} + else {} end) + + (if $m.location then + {"location": ( + {"enabled": ($m.location.enabled // false)} + + (if (($m.location.source // []) | length) > 0 + then {"source": [$m.location.source[].source]} + else {} end) + )} + else {} end) + | if . == {} then {} else {"infix-hardware:modem": .} end) + )) as $modem_comps | + + # Hardware components for each SIM (simN, keyed by modem.sim index). + ($modems | map(. as $m | + select($m.sim != null) | + { + "name": ("sim" + ($m.sim | tostring)), + "class": "infix-hardware:sim" + } + ( + (if $m.pin then {"pin": $m.pin} else {} end) + + (if $m.puk then {"puk": $m.puk} else {} end) + + (if $m.carrier then {"carrier": $m.carrier} else {} end) + | if . == {} then {} else {"infix-hardware:sim": .} end) + )) as $sim_comps | + + # Keystore entries for bearers that have a non-empty password. + ($bearer_list | map( + select((.bearer.password // "") != "") | + { + "name": ("apn-wwan" + (.wwan_index | tostring) + "-pass"), + "key-format": "infix-crypto-types:passphrase-key-format", + "cleartext-symmetric-key": ((.bearer.password // "") | @base64) + } + )) as $ks_entries | + + # wwan interfaces, one per bearer. + ($bearer_list | map(. as $entry | + ($entry.modem) as $m | + ($entry.bearer) as $b | + ("wwan" + ($entry.wwan_index | tostring)) as $wname | + ("apn-wwan" + ($entry.wwan_index | tostring) + "-pass") as $kname | + { + "name": $wname, + "type": "infix-if-type:modem", + "infix-interfaces:wwan": { + "modem": ("modem" + ($m.index | tostring)), + "sim": ("sim" + ($m.sim | tostring)), + "bearer": ( + (if $b.apn then {"apn": $b.apn} else {} end) + + (if $b["ip-type"] then {"ip-type": $b["ip-type"]} else {} end) + + (if ($b["allow-roaming"] // false) then {"roaming": true} else {} end) + + (if (($b.username // "") != "") then + {"authentication": {"username": $b.username, "password": $kname}} + else {} end) + ) + } + } + )) as $wwan_ifaces | + + # Apply: delete old tree, inject new hardware components, keystore entries, interfaces. + del(."infix-modem:modems") + + | .["ietf-hardware:hardware"]["component"] = ( + (.["ietf-hardware:hardware"]?.component // []) + $modem_comps + $sim_comps) + + | if ($ks_entries | length) > 0 then + .["ietf-keystore:keystore"]["symmetric-keys"]["symmetric-key"] = ( + (.["ietf-keystore:keystore"]?."symmetric-keys"?."symmetric-key" // []) + $ks_entries) + else . end + + | .["ietf-interfaces:interfaces"]["interface"] = ( + (.["ietf-interfaces:interfaces"]?.interface // []) + $wwan_ifaces) + +else + . +end +' "$file" > "$temp" && mv "$temp" "$file" diff --git a/src/confd/share/migrate/1.9/Makefile.am b/src/confd/share/migrate/1.9/Makefile.am new file mode 100644 index 000000000..7d1a54bef --- /dev/null +++ b/src/confd/share/migrate/1.9/Makefile.am @@ -0,0 +1,2 @@ +migratedir = $(pkgdatadir)/migrate/1.9 +dist_migrate_DATA = 10-remove-infix-modem.sh diff --git a/src/confd/share/migrate/Makefile.am b/src/confd/share/migrate/Makefile.am index 0a5c71ddd..2abea24e0 100644 --- a/src/confd/share/migrate/Makefile.am +++ b/src/confd/share/migrate/Makefile.am @@ -1,2 +1,2 @@ -SUBDIRS = 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 +SUBDIRS = 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 migratedir = $(pkgdatadir)/migrate diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index 374004aad..1d28c12e2 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -46,6 +46,7 @@ confd_plugin_la_SOURCES = \ if-vxlan.c \ if-wifi.c \ if-wireguard.c \ + if-modem.c \ modem.c \ keystore.c \ system.c \ diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index 5a5be02c0..e15e1ee39 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -170,6 +170,16 @@ static int hardware_cand_infer_class(json_t *root, sr_session_ctx_t *session, co err = srx_set_item(session, &inferred, 0, "%s/class", xpath); } + if (!fnmatch("modem+([0-9])", name, FNM_EXTMATCH)) { + inferred.data.string_val = "infix-hardware:modem"; + err = srx_set_item(session, &inferred, 0, "%s/class", xpath); + } + + if (!fnmatch("sim+([0-9])", name, FNM_EXTMATCH)) { + inferred.data.string_val = "infix-hardware:sim"; + err = srx_set_item(session, &inferred, 0, "%s/class", xpath); + } + out_free_name: free(name); out_free_xpath: @@ -649,6 +659,46 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l rc = SR_ERR_INTERNAL; goto err; } + } else if (!strcmp(class, "infix-hardware:modem")) { + /* + * Modem hardware component: drive modemd enable/disable based on + * admin-state. modemd in turn drives ModemManager. + * + * On DELETE or admin-state=locked: stop and disable both modemd and + * modem-manager. On admin-state=unlocked: enable and (re)start both. + * + * Why modemd controls ModemManager (not hardware.c directly): modemd + * handles the full bearer lifecycle (connect, disconnect, retry) and + * polls operational state for statd. Stopping it cleanly disconnects + * the bearer before taking ModemManager down. + */ + if (event != SR_EV_DONE) + continue; + + if (op == LYDX_OP_DELETE) { + NOTE("Modem %s removed, disabling modemd", name); + systemf("initctl -bfqn stop modemd"); + finit_disable("modemd"); + systemf("initctl -bfqn stop modem-manager"); + finit_disable("modem-manager"); + continue; + } + + state = lydx_get_child(cif, "state"); + admin_state = lydx_get_cattr(state, "admin-state"); + if (!strcmp(admin_state, "unlocked")) { + NOTE("Modem %s enabled, starting modemd", name); + finit_enable("modem-manager"); + finit_enable("modemd"); + systemf("initctl -bfqn restart modem-manager"); + systemf("initctl -bfqn restart modemd"); + } else { + NOTE("Modem %s locked, disabling modemd", name); + systemf("initctl -bfqn stop modemd"); + finit_disable("modemd"); + systemf("initctl -bfqn stop modem-manager"); + finit_disable("modem-manager"); + } } else if (!strcmp(class, "infix-hardware:wifi")) { struct lyd_node *interfaces_config, *interfaces_diff; struct lyd_node **wifi_iface_list = NULL; diff --git a/src/confd/src/if-modem.c b/src/confd/src/if-modem.c new file mode 100644 index 000000000..2c5a419f7 --- /dev/null +++ b/src/confd/src/if-modem.c @@ -0,0 +1,112 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include + +#include "core.h" +#include "dagger.h" +#include "interfaces.h" + +/* + * Modem interface lifecycle management + * + * The kernel (cdc_mbim, qmi_wwan driver) creates the wwan interface automatically + * when a USB modem attaches. confd does NOT need to run 'ip link add'. Instead: + * + * - On CREATE: generate a dagger init script that waits for the wwan interface to + * appear in sysfs. If hardware is absent (no modem detected), create a dummy + * placeholder so that downstream IP/address config works. Signal modemd to + * (re)connect with the latest bearer config. + * + * - On DELETE: remove the /etc/net.d/.conf route file so that netd/FRR + * withdraws the default route. Then delete the interface — either the real + * wwan one or the dummy placeholder. + * + * Address management is handled by modemd (analogous to the udhcpc script for + * DHCP). modemd uses 'ip addr add ... proto wwan' so that protocol-tagged flush + * on disconnect is precise and does not touch addresses added by other means. + * + * Routes are written by modemd to /etc/net.d/.conf, picked up by netd + * via inotify, and installed by staticd/zebra. This keeps all routes visible to + * FRR and enables proper metric-based failover behind wired/WiFi interfaces. + */ + +static int modem_get_probe_timeout(sr_session_ctx_t *session, const char *modem) +{ + char *str; + int timeout = 0; + + str = srx_get_str(session, + "/ietf-hardware:hardware/component[name='%s']/infix-hardware:modem/probe-timeout", + modem); + if (str) { + timeout = atoi(str); + free(str); + } + return timeout; +} + +int modem_add_iface(struct lyd_node *cif, struct dagger *net) +{ + const char *ifname = lydx_get_cattr(cif, "name"); + struct lyd_node *wwan = lydx_get_child(cif, "wwan"); + const char *modem_hw = wwan ? lydx_get_cattr(wwan, "modem") : NULL; + int probe_timeout = 0; + FILE *sh; + + if (modem_hw) + probe_timeout = modem_get_probe_timeout(net->session, modem_hw); + + sh = dagger_fopen_net_init(net, ifname, NETDAG_INIT_PRE, "modem-iface.sh"); + if (!sh) { + ERRNO("Failed to open dagger file for modem interface creation"); + return SR_ERR_INTERNAL; + } + + /* Wait for wwan interface if probe-timeout is set (slow USB modems) */ + if (probe_timeout > 0) { + fprintf(sh, "timeout=%d\n", probe_timeout); + fprintf(sh, "while [ $timeout -gt 0 ]; do\n"); + fprintf(sh, " ip link show %s >/dev/null 2>&1 && break\n", ifname); + fprintf(sh, " sleep 1\n"); + fprintf(sh, " timeout=$((timeout - 1))\n"); + fprintf(sh, "done\n"); + } + + /* If interface doesn't exist, create a dummy placeholder so downstream IP config works. + * modemd will reconfigure the real interface once the modem attaches. */ + fprintf(sh, "if ! ip link show %s >/dev/null 2>&1; then\n", ifname); + fprintf(sh, " logger -t modem \"%s: interface not yet available, creating dummy placeholder\"\n", ifname); + fprintf(sh, " ip link add %s type dummy\n", ifname); + fprintf(sh, "fi\n\n"); + + /* Signal modemd to pick up any updated bearer config. */ + fprintf(sh, "initctl -bfq touch modemd 2>/dev/null || true\n"); + + fclose(sh); + return SR_ERR_OK; +} + +int modem_del_iface(struct lyd_node *dif, struct dagger *net) +{ + const char *ifname = lydx_get_cattr(dif, "name"); + FILE *sh; + + sh = dagger_fopen_net_exit(net, ifname, NETDAG_EXIT_POST, "modem-iface.sh"); + if (!sh) { + ERRNO("Failed to open dagger file for modem interface deletion"); + return SR_ERR_INTERNAL; + } + + fprintf(sh, "# Generated by Infix confd - Modem Interface Cleanup\n\n"); + + /* Remove route config so netd/FRR withdraws the default route */ + fprintf(sh, "rm -f /etc/net.d/%s.conf\n", ifname); + + /* Bring down and remove — works for both real wwan and dummy placeholder */ + fprintf(sh, "ip link set %s down 2>/dev/null || true\n", ifname); + fprintf(sh, "ip link del %s 2>/dev/null || true\n", ifname); + + fclose(sh); + return SR_ERR_OK; +} diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c index 03c5ebdc7..b8b1a6585 100644 --- a/src/confd/src/interfaces.c +++ b/src/confd/src/interfaces.c @@ -393,7 +393,7 @@ static int netdag_gen_afspec_add(sr_session_ctx_t *session, struct dagger *net, case IFT_VXLAN: return vxlan_gen(NULL, cif, ip); case IFT_MODEM: - return modem_gen(NULL, cif, net); + return modem_add_iface(cif, net); case IFT_WIFI: return wifi_validate_secret(session, cif) ? : wifi_add_iface(cif, net); @@ -557,7 +557,7 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif, veth_gen_del(dif, ip); break; case IFT_MODEM: - modem_gen_del(dif, net); + modem_del_iface(dif, net); break; case IFT_WIFI: wifi_del_iface(dif, net); @@ -581,8 +581,7 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif, static sr_error_t netdag_gen_iface_timeout(struct dagger *net, const char *ifname, const char *iftype) { - if (!strcmp(iftype, "infix-if-type:ethernet") || - !strcmp(iftype, "infix-if-type:modem")) { + if (!strcmp(iftype, "infix-if-type:ethernet")) { FILE *wait; wait = dagger_fopen_net_init(net, ifname, NETDAG_INIT_TIMEOUT, "wait-interface.sh"); diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h index 94551d6eb..47351c166 100644 --- a/src/confd/src/interfaces.h +++ b/src/confd/src/interfaces.h @@ -166,9 +166,9 @@ int ifchange_cand_infer_dhcp(sr_session_ctx_t *session, const char *path); /* if-vxlan.c */ int vxlan_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip); -/* modem.c */ -int modem_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net); -int modem_gen_del(struct lyd_node *dif, struct dagger *net); +/* if-modem.c */ +int modem_add_iface(struct lyd_node *cif, struct dagger *net); +int modem_del_iface(struct lyd_node *dif, struct dagger *net); /* infix-if-wireguard */ int wireguard_validate_peers(sr_session_ctx_t *session, struct lyd_node *cif); diff --git a/src/confd/src/modem.c b/src/confd/src/modem.c index a7d83ddd2..e86357fbb 100644 --- a/src/confd/src/modem.c +++ b/src/confd/src/modem.c @@ -1,11 +1,5 @@ /* SPDX-License-Identifier: BSD-3-Clause */ -#include -#include -#include -#include -#include -#include #include #include #include @@ -17,35 +11,33 @@ #include "core.h" -#define MAX_MODEMS 8 -#define RUN_DIR "/run/modemd" -#define SOCK RUN_DIR "/modemd.sock" +#define RUN_DIR "/run/modemd" +#define SOCK RUN_DIR "/modemd.sock" -#define MODULE "infix-modem" -#define ROOT_XPATH "/infix-modem:" -#define CFG_XPATH ROOT_XPATH "modems" +#define HW_BASE "ietf-hardware" +#define HW_MODULE "infix-hardware" +#define HW_COMP "/ietf-hardware:hardware/component" +#define IH_MODEM HW_COMP "/infix-hardware:modem" +#define IH_COMP_ACTION HW_COMP "/infix-hardware:" -static int xpath_get_index(const char *xpath) +static int component_index(const char *xpath) { regmatch_t pmatch[2]; regex_t regex; - char buf[32]; - int ret, len, i; + char name[32]; int index = -1; + int len; - if (regcomp(®ex, "index='([0-9]+)'", REG_EXTENDED)) + if (regcomp(®ex, "name='([^']*)'", REG_EXTENDED)) return -1; - ret = regexec(®ex, xpath, 2, pmatch, 0); - if (!ret) { - len = (pmatch[1].rm_eo - pmatch[1].rm_so); - if (len < (int)(sizeof(buf)-1)) { - for (i = 0; i < len; i++) - buf[i] = xpath[pmatch[1].rm_so + i]; - - buf[i] = '\0'; - index = (int) strtoul(buf, NULL, 10); + if (regexec(®ex, xpath, 2, pmatch, 0) == 0) { + len = pmatch[1].rm_eo - pmatch[1].rm_so; + if (len < (int)(sizeof(name) - 1)) { + memcpy(name, xpath + pmatch[1].rm_so, len); + name[len] = '\0'; + sscanf(name, "modem%d", &index); } } regfree(®ex); @@ -53,131 +45,6 @@ static int xpath_get_index(const char *xpath) return index; } -static int node_index(struct lyd_node *node) -{ - const char *s; - - s = lydx_get_cattr(node, "index"); - if (!s || !s[0]) - return -1; - - return (int) strtoul(s, NULL, 10); -} - -static int disable(void) -{ - NOTE("Disabling modemd"); - - /* disable modem-manager */ - systemf("initctl -bfqn stop modem-manager"); - systemf("initctl -bfqn disable modem-manager"); - - /* disable modemd */ - systemf("initctl -bnq stop modemd"); - systemf("initctl -bnq disable modemd"); - - return SR_ERR_OK; -} - -static int enable(void) -{ - int enabled, reload = 0; - - NOTE("Enabling modemd"); - - /* enable modem-manager */ - enabled = !systemf("initctl -bfq status modem-manager"); - if (!enabled) { - systemf("initctl -bfqn enable modem-manager"); - reload = 1; - } - /* enable modemd */ - enabled = !systemf("initctl -bfq status modemd"); - if (!enabled) { - systemf("initctl -bfqn enable modemd"); - reload = 1; - } - /* reload if required */ - if (reload) - systemf("initctl -b reload"); - - /* restart modem-manager */ - systemf("initctl -bfqn restart modem-manager"); - - /* restart modemd */ - systemf("initctl -bfqn restart modemd"); - - return SR_ERR_OK; -} - -static int genconf(sr_data_t *cfg, struct lyd_node *diff) -{ - struct lyd_node *node, *tree; - uint8_t enabled[MAX_MODEMS]; - int index; - - memset(enabled, 0, sizeof(enabled)); - - tree = lydx_get_descendant(cfg->tree, "modems", "modem", NULL); - LYX_LIST_FOR_EACH(tree, node, "modem") { - index = node_index(node); - if (index < MAX_MODEMS) - enabled[index] = lydx_get_bool(node, "enabled") ? 1 : 0; - } - - tree = lydx_get_descendant(diff, "modems", "modem", NULL); - LYX_LIST_FOR_EACH(tree, node, "modem") { - index = node_index(node); - if (index < MAX_MODEMS && lydx_get_op(node) == LYDX_OP_DELETE) - enabled[index] = 0; - } - - for (index = 0; index < MAX_MODEMS; index++) { - if (enabled[index]) { - if (enable() == SR_ERR_OK) { - return SR_ERR_OK; - } else { - ERROR("Cannot enable modem%d", index); - break; - } - } - } - - return disable(); -} - -static int infix_modem_change(sr_session_ctx_t *session, uint32_t sub_id, const char *module, - const char *xpath, sr_event_t event, unsigned request_id, void *confd) -{ - sr_data_t *cfg = NULL; - struct lyd_node *diff = NULL; - sr_error_t err; - - if (event != SR_EV_DONE) { - return SR_ERR_OK; - } - err = sr_get_data(session, CFG_XPATH "//.", 0, 0, 0, &cfg); - if (err) { - ERROR("Can't get data"); - goto out; - } - err = srx_get_diff(session, &diff); - if (err) { - ERROR("Can't get diff"); - goto out; - } - err = genconf(cfg, diff); - if (err) { - ERROR("Can't gen conf"); - goto out; - } -out: - if (diff) lyd_free_tree(diff); - if (cfg) sr_release_data(cfg); - - return err; -} - static int infix_modem_rpcsend(char *msg, int len) { struct sockaddr_un addr; @@ -199,9 +66,8 @@ static int infix_modem_rpcsend(char *msg, int len) FD_SET(sock, &wfds); if (select(sock + 1, NULL, &wfds, NULL, &tv) > 0) { - if (write(sock, msg, len) == len) { + if (write(sock, msg, len) == len) ret = 0; - } } } close(sock); @@ -209,7 +75,7 @@ static int infix_modem_rpcsend(char *msg, int len) return ret; } -static int infix_modem_rpc(const char *xpath, const char *rpc, const char *data) +static int infix_modem_rpc(const char *rpc, const char *data) { char msg[1024]; int len; @@ -233,98 +99,108 @@ static int infix_modem_sendsms(sr_session_ctx_t *session, uint32_t sub_id, const unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) { char data[1024]; + int index; - if (input_cnt < 3) { - ERROR("Not enough input parameters"); + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return SR_ERR_INVAL_ARG; + } + if (input_cnt < 2) { + ERROR("send-sms: not enough input parameters"); return SR_ERR_SYS; } - snprintf(data, sizeof(data)-1, - "{ \"index\" : %s, \"number\" : \"%s\", \"text\" : \"%s\" }", + snprintf(data, sizeof(data) - 1, + "{ \"index\" : %d, \"number\" : \"%s\", \"text\" : \"%s\" }", + index, input[0].data.string_val, - input[1].data.string_val, - input[2].data.string_val); + input[1].data.string_val); - return infix_modem_rpc(xpath, "send-sms", data); + return infix_modem_rpc("send-sms", data); } static int infix_modem_restart(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, const sr_val_t *input, const size_t input_cnt, sr_event_t event, unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) { - char data[1024]; + char data[64]; + int index; - if (input_cnt < 1) { - ERROR("Not enough input parameters"); - return SR_ERR_SYS; + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return SR_ERR_INVAL_ARG; } - snprintf(data, sizeof(data)-1, - "{ \"index\" : %s }", input[0].data.string_val); - - return infix_modem_rpc(xpath, "restart", data); + snprintf(data, sizeof(data), "{ \"index\" : %d }", index); + return infix_modem_rpc("restart", data); } static int infix_modem_reset(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, const sr_val_t *input, const size_t input_cnt, sr_event_t event, unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) { - char data[1024]; + char data[64]; + int index; - if (input_cnt < 1) { - ERROR("Not enough input parameters"); - return SR_ERR_SYS; + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return SR_ERR_INVAL_ARG; } - snprintf(data, sizeof(data)-1, - "{ \"index\" : %s }", input[0].data.string_val); - - return infix_modem_rpc(xpath, "reset", data); + snprintf(data, sizeof(data), "{ \"index\" : %d }", index); + return infix_modem_rpc("reset", data); } -static void infix_modem_notif (sr_session_ctx_t *session, uint32_t sub_id, - const sr_ev_notif_type_t notif_type, const char *xpath, - const sr_val_t *values, const size_t values_cnt, - struct timespec *timestamp, void *confd) +static void infix_modem_notif(sr_session_ctx_t *session, uint32_t sub_id, + const sr_ev_notif_type_t notif_type, const char *xpath, + const sr_val_t *values, const size_t values_cnt, + struct timespec *timestamp, void *confd) { int index; - index = xpath_get_index(xpath); + index = component_index(xpath); if (index < 0) { - ERROR("No index"); + ERROR("Cannot parse modem index from xpath: %s", xpath); return; } if (values_cnt < 1) { - ERROR("No values"); + ERROR("No values in status-update notification"); return; } - NOTE("Notification from modem%d: %s", - index, values[0].data.string_val); + NOTE("Notification from modem%d: %s", index, values[0].data.string_val); } int modem_init(struct confd *confd) { + const struct lys_module *mod; + sr_conn_ctx_t *conn; + const struct ly_ctx *ly_ctx; int rc; - REGISTER_CHANGE(confd->session, MODULE, CFG_XPATH, 0, infix_modem_change, confd, &confd->sub); - REGISTER_NOTIF(confd->session, MODULE, CFG_XPATH "/modem/status-update", infix_modem_notif, confd, &confd->sub); - REGISTER_RPC(confd->session, ROOT_XPATH "restart", infix_modem_restart, NULL, &confd->sub); - REGISTER_RPC(confd->session, ROOT_XPATH "reset", infix_modem_reset, NULL, &confd->sub); - REGISTER_RPC(confd->session, ROOT_XPATH "send-sms", infix_modem_sendsms, NULL, &confd->sub); + conn = sr_session_get_connection(confd->session); + ly_ctx = sr_acquire_context(conn); + mod = ly_ctx_get_module_implemented(ly_ctx, HW_MODULE); + sr_release_context(conn); + + if (!mod || lys_feature_value(mod, "modem") != LY_SUCCESS) + return SR_ERR_OK; + + REGISTER_NOTIF(confd->session, HW_BASE, + IH_MODEM "/status-update", + infix_modem_notif, confd, &confd->sub); + REGISTER_RPC(confd->session, IH_COMP_ACTION "restart", + infix_modem_restart, NULL, &confd->sub); + REGISTER_RPC(confd->session, IH_COMP_ACTION "reset", + infix_modem_reset, NULL, &confd->sub); + REGISTER_RPC(confd->session, IH_COMP_ACTION "send-sms", + infix_modem_sendsms, NULL, &confd->sub); return SR_ERR_OK; fail: ERROR("init failed: %s", sr_strerror(rc)); return rc; } - -int modem_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net) -{ - return 0; -} - -int modem_gen_del(struct lyd_node *dif, struct dagger *net) -{ - return 0; -} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index cd039c17f..af752b54b 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -27,10 +27,10 @@ MODULES=( "infix-syslog@2025-11-17.yang" "iana-hardware@2018-03-13.yang" "ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor" - "infix-hardware@2026-02-08.yang" + "infix-hardware@2026-04-27.yang" "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2026-04-28.yang" - "infix-if-type@2026-01-07.yang" + "infix-if-type@2026-04-27.yang" "infix-routing@2026-03-11.yang" "ieee802-dot1ab-lldp@2022-03-15.yang" "infix-lldp@2025-05-05.yang" @@ -56,5 +56,4 @@ MODULES=( "ieee1588-ptp-tt@2023-08-14.yang -e timestamp-correction" "ieee802-dot1as-gptp@2025-12-10.yang" "infix-ptp@2026-04-07.yang" - "infix-modem@2024-03-15.yang" ) diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 6da99e9b9..752551802 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -12,7 +12,9 @@ module infix-hardware { import ietf-yang-types { prefix yang; } - + import ietf-netconf-acm { + prefix nacm; + } import infix-wifi-country-codes { prefix iwcc; } @@ -21,6 +23,25 @@ module infix-hardware { contact "kernelkit@googlegroups.com"; description "Vital Product Data augmentation of ieee-hardware and deviations."; + revision 2026-05-08 { + description "Move modem actions (restart/reset/send-sms) from container modem to a + direct augment on component with when/if-feature guard, fixing sysrepo + NP-container parent validation for nested YANG actions."; + reference "internal"; + } + revision 2026-05-03 { + description "Add cellular modem and SIM hardware config containers. + Add modem typedefs (modem-access-mode, modem-band, modem-location-source). + Add actions restart/reset/send-sms and status-update notification on modem. + Add sim container with pin, puk, carrier. + Add sim-state operational container with slot, lock state, operator-name."; + reference "internal"; + } + revision 2026-04-27 { + description "Add cellular modem and SIM hardware class identities. + Add modem-state operational augment for statd."; + reference "internal"; + } revision 2026-02-08 { description "Add GPS/GNSS receiver hardware class and container for time sync."; reference "internal"; @@ -53,6 +74,10 @@ module infix-hardware { description "GPS support is an optional build-time feature in Infix."; } + feature modem { + description "Cellular modem support is an optional build-time feature in Infix."; + } + typedef country-code { type string { length 2; @@ -98,6 +123,195 @@ module infix-hardware { description "WiFi frequency band selection."; } + /* + * Modem-specific typedefs + */ + + typedef modem-access-mode { + type enumeration { + enum cs { description "Circuit-switched (CSD, GSM)."; } + enum 2g { description "2G (GPRS, EDGE)."; } + enum 3g { description "3G (UMTS, HSxPA)."; } + enum 4g { description "4G (LTE)."; } + enum 5g { description "5G (NR)."; } + enum any { description "Any technology."; } + } + description "Cellular radio access technology."; + } + + typedef modem-band { + type enumeration { + // 2G GSM + enum egsm { description "GSM 900 MHz, Extended (880-960 MHz)."; } + enum dcs { description "GSM 1800 MHz, DCS (Digital Cellular System)."; } + enum pcs { description "GSM 1900 MHz, PCS (Personal Communications Service)."; } + enum g850 { description "GSM 850 MHz (Americas, Pacific)."; } + // 3G UMTS/WCDMA + enum utran-1 { description "UMTS Band I, 2100 MHz FDD (Europe, Asia, global)."; } + enum utran-2 { description "UMTS Band II, 1900 MHz FDD (Americas, PCS band)."; } + enum utran-3 { description "UMTS Band III, 1800 MHz FDD."; } + enum utran-4 { description "UMTS Band IV, 1700/2100 MHz FDD, AWS (Americas)."; } + enum utran-5 { description "UMTS Band V, 850 MHz FDD (Americas, Pacific)."; } + enum utran-6 { description "UMTS Band VI, 800 MHz FDD (Japan)."; } + enum utran-7 { description "UMTS Band VII, 2600 MHz FDD (Europe)."; } + enum utran-8 { description "UMTS Band VIII, 900 MHz FDD (Europe, Asia)."; } + enum utran-9 { description "UMTS Band IX, 1700 MHz FDD (Japan)."; } + enum utran-10 { description "UMTS Band X, 1700/2100 MHz FDD, AWS-II (Americas)."; } + enum utran-11 { description "UMTS Band XI, 1500 MHz FDD (Japan)."; } + enum utran-12 { description "UMTS Band XII, 700 MHz FDD (Americas, lower block)."; } + enum utran-13 { description "UMTS Band XIII, 700 MHz FDD (Americas, upper C block)."; } + enum utran-14 { description "UMTS Band XIV, 700 MHz FDD (Americas, upper D block)."; } + enum utran-19 { description "UMTS Band XIX, 800 MHz FDD (Japan)."; } + enum utran-20 { description "UMTS Band XX, 800 MHz FDD (Europe, digital dividend)."; } + enum utran-21 { description "UMTS Band XXI, 1500 MHz FDD (Japan)."; } + enum utran-22 { description "UMTS Band XXII, 3500 MHz FDD."; } + enum utran-25 { description "UMTS Band XXV, 1900 MHz FDD (Americas, PCS extended)."; } + enum utran-26 { description "UMTS Band XXVI, 850 MHz FDD (Americas, extended)."; } + enum utran-32 { description "UMTS Band XXXII, 1500 MHz SDL (supplemental downlink)."; } + // 4G LTE + enum eutran-1 { description "LTE Band 1, 2100 MHz FDD (Europe, Asia, global)."; } + enum eutran-2 { description "LTE Band 2, 1900 MHz FDD, PCS (Americas)."; } + enum eutran-3 { description "LTE Band 3, 1800 MHz FDD (global)."; } + enum eutran-4 { description "LTE Band 4, 1700/2100 MHz FDD, AWS (Americas)."; } + enum eutran-5 { description "LTE Band 5, 850 MHz FDD, CLR (Americas, Pacific)."; } + enum eutran-6 { description "LTE Band 6, 800 MHz FDD (Japan)."; } + enum eutran-7 { description "LTE Band 7, 2600 MHz FDD (Europe, global)."; } + enum eutran-8 { description "LTE Band 8, 900 MHz FDD (Europe, Asia)."; } + enum eutran-9 { description "LTE Band 9, 1800 MHz FDD (Japan)."; } + enum eutran-10 { description "LTE Band 10, 1700/2100 MHz FDD, AWS (Americas)."; } + enum eutran-11 { description "LTE Band 11, 1500 MHz FDD (Japan)."; } + enum eutran-12 { description "LTE Band 12, 700 MHz FDD (Americas, lower A/B/C)."; } + enum eutran-13 { description "LTE Band 13, 700 MHz FDD (Americas, upper C)."; } + enum eutran-14 { description "LTE Band 14, 700 MHz FDD (Americas, upper D, public safety)."; } + enum eutran-17 { description "LTE Band 17, 700 MHz FDD (Americas, lower B/C)."; } + enum eutran-18 { description "LTE Band 18, 800 MHz FDD (Japan)."; } + enum eutran-19 { description "LTE Band 19, 800 MHz FDD (Japan)."; } + enum eutran-20 { description "LTE Band 20, 800 MHz FDD (Europe, digital dividend)."; } + enum eutran-21 { description "LTE Band 21, 1500 MHz FDD (Japan)."; } + enum eutran-22 { description "LTE Band 22, 3500 MHz FDD."; } + enum eutran-23 { description "LTE Band 23, 2000 MHz FDD."; } + enum eutran-24 { description "LTE Band 24, 1600 MHz FDD (Americas, L-band)."; } + enum eutran-25 { description "LTE Band 25, 1900 MHz FDD, PCS extended (Americas)."; } + enum eutran-26 { description "LTE Band 26, 850 MHz FDD, CLR extended (Americas)."; } + enum eutran-27 { description "LTE Band 27, 800 MHz FDD, SMR (Americas)."; } + enum eutran-28 { description "LTE Band 28, 700 MHz FDD, APT (Asia-Pacific)."; } + enum eutran-29 { description "LTE Band 29, 700 MHz FDD, SDL (Americas, downlink only)."; } + enum eutran-30 { description "LTE Band 30, 2300 MHz FDD, WCS (Americas)."; } + enum eutran-31 { description "LTE Band 31, 450 MHz FDD."; } + enum eutran-32 { description "LTE Band 32, 1500 MHz FDD, SDL (Europe, downlink only)."; } + enum eutran-33 { description "LTE Band 33, 1900 MHz TDD."; } + enum eutran-34 { description "LTE Band 34, 2000 MHz TDD."; } + enum eutran-35 { description "LTE Band 35, 1900 MHz TDD."; } + enum eutran-36 { description "LTE Band 36, 1900 MHz TDD."; } + enum eutran-37 { description "LTE Band 37, 1910 MHz TDD."; } + enum eutran-38 { description "LTE Band 38, 2600 MHz TDD (China, Europe)."; } + enum eutran-39 { description "LTE Band 39, 1900 MHz TDD (China)."; } + enum eutran-40 { description "LTE Band 40, 2300 MHz TDD (India, Asia)."; } + enum eutran-41 { description "LTE Band 41, 2500 MHz TDD (China, Americas)."; } + enum eutran-42 { description "LTE Band 42, 3500 MHz TDD."; } + enum eutran-43 { description "LTE Band 43, 3700 MHz TDD."; } + enum eutran-44 { description "LTE Band 44, 700 MHz TDD, APT."; } + enum eutran-45 { description "LTE Band 45, 1500 MHz TDD (China)."; } + enum eutran-46 { description "LTE Band 46, 5200 MHz TDD, ULAA (unlicensed)."; } + enum eutran-47 { description "LTE Band 47, 5900 MHz TDD, V2X (vehicle-to-everything)."; } + enum eutran-48 { description "LTE Band 48, 3500 MHz TDD, CBRS (Americas, shared)."; } + enum eutran-49 { description "LTE Band 49, 3500 MHz TDD."; } + enum eutran-50 { description "LTE Band 50, 1500 MHz TDD."; } + enum eutran-51 { description "LTE Band 51, 1500 MHz TDD."; } + enum eutran-52 { description "LTE Band 52, 3300 MHz TDD."; } + enum eutran-53 { description "LTE Band 53, 2500 MHz TDD."; } + enum eutran-54 { description "LTE Band 54, 1600 MHz TDD."; } + enum eutran-55 { description "LTE Band 55, 1800 MHz TDD."; } + enum eutran-56 { description "LTE Band 56, 700 MHz TDD."; } + enum eutran-57 { description "LTE Band 57, 700 MHz TDD."; } + enum eutran-58 { description "LTE Band 58, 700 MHz TDD."; } + enum eutran-59 { description "LTE Band 59, 700 MHz TDD."; } + enum eutran-60 { description "LTE Band 60, 1500 MHz FDD."; } + enum eutran-61 { description "LTE Band 61, 1500 MHz FDD."; } + enum eutran-62 { description "LTE Band 62, 1500 MHz FDD."; } + enum eutran-63 { description "LTE Band 63, 1900 MHz FDD."; } + enum eutran-64 { description "LTE Band 64, 1900 MHz FDD."; } + enum eutran-65 { description "LTE Band 65, 2100 MHz FDD, extended (Europe)."; } + enum eutran-66 { description "LTE Band 66, 1700/2100 MHz FDD, AWS-3 (Americas)."; } + enum eutran-67 { description "LTE Band 67, 700 MHz FDD, SDL (Europe)."; } + enum eutran-68 { description "LTE Band 68, 700 MHz FDD (Middle East, Africa)."; } + enum eutran-69 { description "LTE Band 69, 2500 MHz FDD, SDL."; } + enum eutran-70 { description "LTE Band 70, 1700/2100 MHz FDD, AWS-4 (Americas)."; } + enum eutran-71 { description "LTE Band 71, 600 MHz FDD (Americas)."; } + enum eutran-85 { description "LTE Band 85, 700 MHz FDD, APT extended."; } + // 5G NR + enum ngran-1 { description "NR Band n1, 2100 MHz FDD (Europe, Asia)."; } + enum ngran-2 { description "NR Band n2, 1900 MHz FDD, PCS (Americas)."; } + enum ngran-3 { description "NR Band n3, 1800 MHz FDD (global)."; } + enum ngran-5 { description "NR Band n5, 850 MHz FDD (Americas, Pacific)."; } + enum ngran-7 { description "NR Band n7, 2600 MHz FDD (Europe)."; } + enum ngran-8 { description "NR Band n8, 900 MHz FDD (Europe, Asia)."; } + enum ngran-12 { description "NR Band n12, 700 MHz FDD (Americas)."; } + enum ngran-13 { description "NR Band n13, 700 MHz FDD (Americas)."; } + enum ngran-14 { description "NR Band n14, 700 MHz FDD (Americas, public safety)."; } + enum ngran-18 { description "NR Band n18, 850 MHz FDD (Japan)."; } + enum ngran-20 { description "NR Band n20, 800 MHz FDD (Europe, digital dividend)."; } + enum ngran-25 { description "NR Band n25, 1900 MHz FDD, PCS extended (Americas)."; } + enum ngran-26 { description "NR Band n26, 850 MHz FDD, CLR extended."; } + enum ngran-28 { description "NR Band n28, 700 MHz FDD, APT (Asia-Pacific)."; } + enum ngran-29 { description "NR Band n29, 700 MHz FDD, SDL (Americas)."; } + enum ngran-30 { description "NR Band n30, 2300 MHz FDD, WCS (Americas)."; } + enum ngran-34 { description "NR Band n34, 2000 MHz TDD."; } + enum ngran-38 { description "NR Band n38, 2600 MHz TDD."; } + enum ngran-39 { description "NR Band n39, 1900 MHz TDD (China)."; } + enum ngran-40 { description "NR Band n40, 2300 MHz TDD (India, Asia)."; } + enum ngran-41 { description "NR Band n41, 2500 MHz TDD (China, Americas)."; } + enum ngran-48 { description "NR Band n48, 3500 MHz TDD, CBRS (Americas, shared)."; } + enum ngran-50 { description "NR Band n50, 1500 MHz TDD."; } + enum ngran-51 { description "NR Band n51, 1500 MHz TDD."; } + enum ngran-53 { description "NR Band n53, 2500 MHz TDD."; } + enum ngran-65 { description "NR Band n65, 2100 MHz FDD, extended."; } + enum ngran-66 { description "NR Band n66, 1700/2100 MHz FDD, AWS-3."; } + enum ngran-70 { description "NR Band n70, 1700/2100 MHz FDD, AWS-4."; } + enum ngran-71 { description "NR Band n71, 600 MHz FDD (Americas)."; } + enum ngran-74 { description "NR Band n74, 1500 MHz FDD."; } + enum ngran-75 { description "NR Band n75, 1500 MHz SDL."; } + enum ngran-76 { description "NR Band n76, 1500 MHz SDL."; } + enum ngran-77 { description "NR Band n77, 3700 MHz TDD (Europe, Americas)."; } + enum ngran-78 { description "NR Band n78, 3500 MHz TDD (Europe, Asia, global)."; } + enum ngran-79 { description "NR Band n79, 4700 MHz TDD (Japan, China)."; } + enum ngran-80 { description "NR Band n80, 1800 MHz SUL (supplemental uplink)."; } + enum ngran-81 { description "NR Band n81, 900 MHz SUL (supplemental uplink)."; } + enum ngran-82 { description "NR Band n82, 800 MHz SUL (supplemental uplink)."; } + enum ngran-83 { description "NR Band n83, 700 MHz SUL, APT (supplemental uplink)."; } + enum ngran-84 { description "NR Band n84, 2100 MHz SUL (supplemental uplink)."; } + enum ngran-86 { description "NR Band n86, 1700 MHz SUL (supplemental uplink)."; } + enum ngran-89 { description "NR Band n89, 850 MHz SUL (supplemental uplink)."; } + enum ngran-90 { description "NR Band n90, 2500 MHz TDD."; } + enum ngran-91 { description "NR Band n91, 800+1800 MHz SUL (supplemental uplink)."; } + enum ngran-92 { description "NR Band n92, 800+2300 MHz SUL (supplemental uplink)."; } + enum ngran-93 { description "NR Band n93, 900+1800 MHz SUL (supplemental uplink)."; } + enum ngran-94 { description "NR Band n94, 900+2300 MHz SUL (supplemental uplink)."; } + enum ngran-95 { description "NR Band n95, 2100 MHz SUL (supplemental uplink)."; } + enum ngran-257 { description "NR Band n257, 28 GHz mmWave (Americas)."; } + enum ngran-258 { description "NR Band n258, 26 GHz mmWave (Europe, Asia)."; } + enum ngran-260 { description "NR Band n260, 39 GHz mmWave (Americas)."; } + enum ngran-261 { description "NR Band n261, 28 GHz mmWave (Americas)."; } + // Wildcard + enum any { description "All bands supported by the hardware."; } + } + description + "Cellular frequency band identifier using ModemManager's MMModemBand + enum value names (as used with 'mmcli --set-current-bands'). + An empty band list in the parent container is equivalent to 'any'."; + } + + typedef modem-location-source { + type enumeration { + enum gps { description "GPS location source."; } + enum agps-msa { description "MSA A-GPS location source."; } + enum agps-msb { description "MSB A-GPS location source."; } + enum 3gpp { description "3GPP location source."; } + enum cdma { description "CDMA location source."; } + } + description "Location source for the modem's location subsystem."; + } + /* * Hardware class identities */ @@ -122,6 +336,26 @@ module infix-hardware { description "GPS/GNSS receiver for time synchronization"; } + identity modem { + if-feature modem; + base iahw:hardware-class; + description "Cellular modem hardware component. + One modem0 component per physical modem module. + Auto-discovered by 00-probe from /sys/class/usbmisc/cdc-wdm* + and written to /run/system.json['modem']; gen-hardware emits + the corresponding ietf-hardware component entries."; + } + + identity sim { + if-feature modem; + base iahw:hardware-class; + description "SIM/UICC card hardware component. + One sim0 component per SIM slot on hardware that exposes + SIM multiplexing via /sys/class/sim/ (e.g. Minex modules). + On hardware without a SIM multiplexer, a synthetic sim0 is + inferred from the modem's active SIM."; + } + deviation "/iehw:hardware/iehw:component/iehw:state/iehw:admin-state" { deviate add { must ". = 'locked' or . = 'unlocked'" { @@ -539,6 +773,282 @@ module infix-hardware { } } + /* + * Modem hardware configuration (when class = 'ih:modem') + */ + + container modem { + if-feature modem; + when "derived-from-or-self(../iehw:class, 'ih:modem')"; + presence "Modem hardware configuration"; + description + "Cellular modem hardware configuration. + + Controls radio access technology, frequency bands, and location + services. The modem is enabled or disabled via the hardware + component admin-state (unlocked = enabled, locked = disabled). + + Actions restart and reset operate on the modem hardware directly. + send-sms uses the modem's SMS capability independent of any data + bearer or APN — SMS travels over the signalling plane, not the + data bearer."; + + leaf preferred-mode { + type modem-access-mode; + description + "Preferred radio access technology. The modem will attempt to + use this technology first; falls back to allowed modes if not + available. Omit (or set to 'any') for automatic selection."; + } + + leaf-list allowed-mode { + type modem-access-mode; + description "Restrict the modem to these radio access technologies. + Empty list means all technologies are allowed."; + } + + leaf-list band { + type modem-band; + description + "Restrict the modem to these frequency bands. An empty list + (or a list containing only 'any') means all bands supported + by the hardware are allowed."; + } + + container location { + description "Modem location/GPS subsystem configuration."; + leaf enabled { + type boolean; + default false; + description "Enable location gathering via the modem."; + } + leaf-list source { + type modem-location-source; + description "Location sources to enable."; + } + } + + leaf probe-timeout { + type uint8; + default 30; + description + "Seconds to wait for the wwan interface to appear at boot. + + USB modems may be slow to initialize due to firmware loading + and USB enumeration. The default of 30 seconds covers most + USB modems; the interface typically appears within 2-5 seconds. + + If the interface is not detected within the timeout, a dummy + placeholder is created so downstream configuration succeeds. + modemd will reconfigure the real interface when it attaches. + + Set to 0 to disable waiting and create the dummy immediately."; + } + + notification status-update { + description "Emitted by modemd when the modem state changes."; + leaf desc { + type string; + mandatory true; + description "Human-readable description of the state change."; + } + } + } + + /* + * Modem operational state (when class = 'ih:modem') + * Populated by statd/infix_modem.py from /run/modemd/modemN/ runtime files. + */ + + container modem-state { + if-feature modem; + when "derived-from-or-self(../iehw:class, 'ih:modem')"; + config false; + description + "Cellular modem operational state. + + Written by statd/infix_modem.py which reads modemd runtime files + from /run/modemd/modem0/. The split is intentional: modemd drives + ModemManager and writes runtime files; statd reads them on demand + and populates sysrepo operational nodes. modemd never touches + sysrepo operational nodes directly."; + + leaf manufacturer { + type string; + description "Modem manufacturer (e.g. 'Quectel', 'Sierra Wireless')."; + } + + leaf model { + type string; + description "Modem model (e.g. 'EM05', 'EM7455')."; + } + + leaf firmware-version { + type string; + description "Firmware revision reported by the modem."; + } + + leaf serial-number { + type string; + description "IMEI serial number."; + } + + leaf imsi { + type string; + description "IMSI from the active SIM card."; + } + + leaf iccid { + type string; + description "ICCID from the active SIM card."; + } + + leaf state { + type string; + description "ModemManager modem state (e.g. registered, connected, failed)."; + } + + leaf signal-quality { + type uint8; + description "Signal quality as a percentage (0-100)."; + } + + leaf signal-rssi { + type string; + description "RSSI in dBm."; + } + + leaf signal-rsrp { + type string; + description "RSRP (Reference Signal Received Power) in dBm."; + } + + leaf signal-rsrq { + type string; + description "RSRQ (Reference Signal Received Quality) in dB."; + } + + leaf signal-sinr { + type string; + description "SINR (Signal-to-Interference-Noise Ratio) in dB."; + } + + container cellular { + description "Cellular network information."; + + leaf registration-state { + type enumeration { + enum idle { description "Not registered, not searching."; } + enum home { description "Registered on home network."; } + enum searching { description "Searching for a network."; } + enum denied { description "Registration denied."; } + enum roaming { description "Registered on a roaming network."; } + enum unknown { description "Registration state unknown."; } + } + description "3GPP registration state."; + } + + leaf operator-name { + type string; + description "Name of the registered cellular operator."; + } + + leaf operator-id { + type string; + description "Operator MCC/MNC code."; + } + + leaf network-type { + type string; + description "Access technology in use (e.g. LTE, 5G NR)."; + } + } + } + + /* + * SIM card configuration (when class = 'ih:sim') + */ + + container sim { + if-feature modem; + when "derived-from-or-self(../iehw:class, 'ih:sim')"; + presence "SIM card configuration"; + description + "SIM card / subscription configuration. + + PIN and PUK are per-SIM credentials used to unlock the card. + carrier selects the operator profile that modemd applies to the + modem firmware when this SIM is active (band preferences, firmware + quirks). On hardware with a SIM multiplexer (/dev/simctrl) each + SIM slot gets its own component (sim0, sim1, …); on hardware without + a multiplexer a synthetic sim0 represents the single SIM present."; + + leaf pin { + type string; + nacm:default-deny-all; + description + "PIN code to unlock this SIM card. Leave unset if the SIM has + PIN protection disabled."; + } + + leaf puk { + type string; + nacm:default-deny-all; + description + "PUK (PIN Unblocking Key) to unblock a PIN-locked SIM. Only + needed after too many incorrect PIN attempts."; + } + + leaf carrier { + type string; + description + "Operator profile name applied to the modem firmware when this + SIM is active (e.g. 'default', 'att', 'verizon'). Profiles + are provided by the modem-carrier helper in modemd. Omit to + use the modem's built-in defaults."; + } + } + + /* + * SIM card operational state (when class = 'ih:sim') + * Populated by statd/infix_modem.py from modemd runtime data. + */ + + container sim-state { + if-feature modem; + when "derived-from-or-self(../iehw:class, 'ih:sim')"; + config false; + description + "SIM card operational state. + + Shows which physical slot the SIM occupies, its lock state, and + the home operator name stored on the card. Useful for remote + diagnostics (e.g. confirming which SIM slot is active)."; + + leaf slot { + type uint8; + description + "Physical SIM slot number (1-based). + For single-SIM modems this is always 1."; + } + + leaf state { + type enumeration { + enum not-inserted { description "No SIM card in this slot."; } + enum unlocked { description "SIM is present and accessible."; } + enum pin-required { description "SIM requires PIN to unlock."; } + enum puk-required { description "SIM PIN blocked; PUK needed to unblock."; } + enum permanently-blocked { description "SIM permanently blocked; cannot be used."; } + } + description "SIM card lock and presence state."; + } + + leaf operator-name { + type string; + description "Home operator name stored on the SIM card."; + } + } + /* * GPS/GNSS Receiver configuration (when class = 'ih:gps') */ @@ -661,4 +1171,49 @@ module infix-hardware { } } } + + /* + * Modem actions — augmented directly on component so sysrepo can + * resolve the parent node (list entry) without needing to instantiate + * the NP container modem, which requires the sibling 'class' leaf. + */ + augment "/iehw:hardware/iehw:component" { + if-feature modem; + when "derived-from-or-self(iehw:class, 'ih:modem')" { + description "Applies only to modem hardware components."; + } + + action restart { + description + "Restart the modem: disconnect all bearers and reconnect. + Use this to recover from a stuck connection without a full + hardware reset."; + } + + action reset { + description + "Factory-reset the modem firmware to its default state. + This clears all modem-internal profiles and settings. + Modem will re-initialise and reconnect automatically."; + } + + action send-sms { + description + "Send an SMS message via the modem. + SMS uses the signalling plane and does not require an active + data bearer; the modem only needs to be registered on a network."; + input { + leaf phone-number { + type string; + mandatory true; + description "Recipient phone number in international format (e.g. +46701234567)."; + } + leaf message-text { + type string; + mandatory true; + description "SMS message body (max 160 characters for single-part SMS)."; + } + } + } + } } diff --git a/src/confd/yang/confd/infix-hardware@2026-04-27.yang b/src/confd/yang/confd/infix-hardware@2026-04-27.yang new file mode 120000 index 000000000..154924a01 --- /dev/null +++ b/src/confd/yang/confd/infix-hardware@2026-04-27.yang @@ -0,0 +1 @@ +infix-hardware.yang \ No newline at end of file diff --git a/src/confd/yang/confd/infix-if-modem.yang b/src/confd/yang/confd/infix-if-modem.yang new file mode 100644 index 000000000..d2e187d20 --- /dev/null +++ b/src/confd/yang/confd/infix-if-modem.yang @@ -0,0 +1,214 @@ +submodule infix-if-modem { + yang-version 1.1; + belongs-to infix-interfaces { + prefix infix-if; + } + + import ietf-interfaces { + prefix if; + } + import ietf-keystore { + prefix ks; + } + import ietf-hardware { + prefix iehw; + } + import infix-hardware { + prefix ih; + } + import infix-if-type { + prefix infixift; + } + + organization "KernelKit"; + contact "kernelkit@googlegroups.com"; + description + "Cellular modem (wwan) interface configuration for ietf-interfaces. + + A modem interface (type 'infix-if-type:modem') is a network interface + created by the kernel when a cellular modem attaches. The kernel names + these wwan[0-9]+ via the wwan subsystem (cdc_mbim, qmi_wwan). + + Each wwan interface references: + - A hardware component of class 'ih:modem' (e.g. modem0) via 'modem'. + - A hardware component of class 'ih:sim' (e.g. sim0) via 'sim'. + + The modem component holds hardware-level config (bands, modes, location). + The SIM component holds credentials (PIN/PUK) and carrier profile. + This interface holds the bearer (connection) config: APN, credentials, + IP addressing type, and routing preference. + + Multi-bearer: configure multiple wwan interfaces referencing the same + modem component, each with a different APN. modemd manages them all in + one thread per hardware modem, analogous to multi-SSID on a WiFi radio. + + Routes are written by modemd to /etc/net.d/.conf and installed + by staticd/zebra through FRR. The default route is always installed when + the bearer is connected; route-preference controls administrative distance + so that cellular acts as a natural failover path behind wired and WiFi."; + + revision 2026-05-03 { + description + "Redesign: rename container modem->wwan, device->modem, active-sim->sim. + Rebuild bearer: replace metric+default-route with route-preference (always + on), replace auth-type/username/password with authentication presence + container referencing keystore, drop dns-enabled/firewall-enabled/apn-type."; + reference "internal"; + } + revision 2026-04-27 { + description "Initial revision."; + reference "internal"; + } + + feature modem { + description "Cellular modem support is an optional build-time feature in Infix."; + } + + augment "/if:interfaces/if:interface" { + when "derived-from-or-self(if:type, 'infixift:modem')" { + description "Applies only to interfaces of type 'modem'."; + } + container wwan { + if-feature modem; + + description + "Cellular modem (wwan) interface configuration. + + References a modem hardware component for physical layer config + and a SIM hardware component for subscription credentials. + The bearer sub-container configures the data connection (APN, auth, + IP type, routing preference)."; + + leaf modem { + type leafref { + path "/iehw:hardware/iehw:component/iehw:name"; + } + must "derived-from-or-self(/iehw:hardware/iehw:component[iehw:name=current()]/iehw:class, 'ih:modem')" { + error-message "Referenced hardware component must be a cellular modem (class 'ih:modem')."; + } + description + "Reference to the modem hardware component (e.g. modem0). + + The hardware component carries physical-layer configuration + (bands, modes, location) and is auto-discovered at boot. + Its admin-state controls whether the modem is powered + (unlocked = enabled, locked = disabled)."; + } + + leaf sim { + type leafref { + path "/iehw:hardware/iehw:component/iehw:name"; + } + must "derived-from-or-self(/iehw:hardware/iehw:component[iehw:name=current()]/iehw:class, 'ih:sim')" { + error-message "Referenced hardware component must be a SIM card (class 'ih:sim')."; + } + description + "Reference to the SIM hardware component (e.g. sim0). + + The SIM component carries PIN/PUK credentials and the carrier + profile. On hardware with a SIM multiplexer, changing this + reference switches the active SIM slot."; + } + + container bearer { + description + "Data bearer (connection) configuration. + + These settings are passed to ModemManager by modemd to establish + the cellular data connection. The APN is mandatory; authentication + is only needed when the operator requires it. + + When the bearer connects, modemd writes a default route to + /etc/net.d/.conf so that staticd/zebra installs it via FRR. + The route-preference leaf controls the administrative distance, + making cellular a natural failover behind lower-preference routes + such as wired Ethernet (udhcpc default 5) and WiFi."; + + leaf apn { + type string { + length "1..64"; + } + description "Access Point Name for the cellular data connection."; + } + + leaf ip-type { + type enumeration { + enum ipv4 { + description "Request IPv4 addressing only."; + } + enum ipv6 { + description "Request IPv6 addressing only."; + } + enum ipv4v6 { + description "Request dual-stack IPv4 and IPv6 addressing."; + } + } + default ipv4v6; + description + "IP addressing type to negotiate with the network during + bearer activation (3GPP PDN type). Use ipv4v6 unless the + operator or hardware requires a specific family."; + } + + leaf roaming { + type boolean; + default false; + description "Allow data connection when roaming on a foreign network."; + } + + leaf route-preference { + type uint32; + default 200; + description + "Administrative distance for the bearer default route installed + via /etc/net.d/.conf into staticd/zebra. + + Higher values mean lower priority. Default 200 places cellular + behind wired Ethernet (udhcpc typically uses 5) and WiFi, making + it a natural failover path. Adjust when multiple cellular modems + need relative priority ordering."; + } + + container authentication { + presence "APN authentication credentials"; + description + "Authentication credentials for the APN. + + If this container is absent, the bearer connects without + authentication (suitable for most consumer APNs). When present, + all three leaves — type, username, and password — are required."; + + leaf type { + type enumeration { + enum pap { + description "Password Authentication Protocol."; + } + enum chap { + description "Challenge Handshake Authentication Protocol (recommended)."; + } + } + default chap; + description "Authentication protocol required by the APN."; + } + + leaf username { + type string; + mandatory true; + description "Username for APN authentication."; + } + + leaf password { + type ks:central-symmetric-key-ref; + mandatory true; + description + "APN password, referenced from the system keystore. + + Store the password as a symmetric key in the keystore and + reference it here by key name. This avoids storing credentials + in plaintext in the running or startup configuration."; + } + } + } + } + } +} diff --git a/src/confd/yang/confd/infix-if-modem@2026-04-27.yang b/src/confd/yang/confd/infix-if-modem@2026-04-27.yang new file mode 120000 index 000000000..3d99e0bdd --- /dev/null +++ b/src/confd/yang/confd/infix-if-modem@2026-04-27.yang @@ -0,0 +1 @@ +infix-if-modem.yang \ No newline at end of file diff --git a/src/confd/yang/confd/infix-if-type.yang b/src/confd/yang/confd/infix-if-type.yang index 3674376a8..f57d90cd6 100644 --- a/src/confd/yang/confd/infix-if-type.yang +++ b/src/confd/yang/confd/infix-if-type.yang @@ -11,6 +11,10 @@ module infix-if-type { contact "kernelkit@googlegroups.com"; description "Infix extensions to IANA interfaces types"; + revision 2026-04-27 { + description "Add interface type modem for cellular wwan interfaces."; + reference "internal"; + } revision 2026-01-07 { description "Add interface type wifi and wireguard"; reference "internal"; @@ -47,6 +51,10 @@ module infix-if-type { * Features */ + feature modem { + description "Cellular modem support is an optional build-time feature in Infix."; + } + feature wifi { description "WiFi support is an optional build-time feature in Infix."; } @@ -121,6 +129,15 @@ module infix-if-type { base ianaift:l2vlan; description "Layer 2 Virtual LAN using 802.1Q."; } + identity modem { + if-feature modem; + base infix-interface-type; + description "Cellular modem (wwan) network interface. + Created by the kernel (cdc_mbim, qmi_wwan) when a USB modem + attaches. Interface names follow the kernel wwan subsystem + naming convention: wwan[0-9]+."; + } + identity wifi { if-feature wifi; base infix-interface-type; diff --git a/src/confd/yang/confd/infix-if-type@2026-04-27.yang b/src/confd/yang/confd/infix-if-type@2026-04-27.yang new file mode 120000 index 000000000..b0bd90183 --- /dev/null +++ b/src/confd/yang/confd/infix-if-type@2026-04-27.yang @@ -0,0 +1 @@ +infix-if-type.yang \ No newline at end of file diff --git a/src/confd/yang/confd/infix-interfaces.yang b/src/confd/yang/confd/infix-interfaces.yang index 03c9c8a61..b8c889d3f 100644 --- a/src/confd/yang/confd/infix-interfaces.yang +++ b/src/confd/yang/confd/infix-interfaces.yang @@ -36,6 +36,7 @@ module infix-interfaces { include infix-if-wifi; include infix-if-wireguard; include infix-if-ptp; + include infix-if-modem; organization "KernelKit"; contact "kernelkit@googlegroups.com"; @@ -46,6 +47,11 @@ module infix-interfaces { reference "internal"; } + revision 2026-04-27 { + description "Include infix-if-modem submodule for cellular wwan interfaces."; + reference "internal"; + } + revision 2026-04-09 { description "Add ptp-capabilities submodule for per-interface PTP timestamping info."; reference "internal"; diff --git a/src/confd/yang/confd/infix-interfaces@2026-04-27.yang b/src/confd/yang/confd/infix-interfaces@2026-04-27.yang new file mode 120000 index 000000000..9f14c9e11 --- /dev/null +++ b/src/confd/yang/confd/infix-interfaces@2026-04-27.yang @@ -0,0 +1 @@ +infix-interfaces.yang \ No newline at end of file diff --git a/src/confd/yang/confd/infix-modem.yang b/src/confd/yang/confd/infix-modem.yang deleted file mode 100644 index f0a52fe68..000000000 --- a/src/confd/yang/confd/infix-modem.yang +++ /dev/null @@ -1,1089 +0,0 @@ -/* - * Infix Modem YANG module - */ - -module infix-modem { - yang-version 1.1; - namespace "urn:infix:params:xml:ns:yang:infix-modem"; - prefix "infix-modem"; - - import ietf-inet-types { - prefix "inet"; - } - import ietf-interfaces { - prefix "if"; - } - import ietf-yang-types { - prefix yang; - } - import ietf-netconf-acm { - prefix nacm; - } - - organization "KernelKit"; - contact "kernelkit@googlegroups.com"; - description "YANG data model for modems."; - - revision 2024-03-15 { - description "Initial revision"; - reference "internal"; - } - - container modems { - description "Configuration of modems."; - - list modem { - key "index"; - unique sim; - description "List of modems"; - - leaf index { - type uint8; - description "Index of the modem."; - } - leaf enabled { - type boolean; - description "Enable or disable modem."; - default false; - } - leaf path { - type string; - description "Path to modem."; - config false; - } - leaf sim { - type int8; - description "Index of SIM card."; - } - leaf pin { - type string; - description "PIN code to unlock SIM card."; - nacm:default-deny-all; - } - leaf puk { - type string; - description "PUK code to unlock SIM card."; - nacm:default-deny-all; - } - - leaf preferred-mode { - type access-mode; - description "Preferred mode."; - } - list allowed-mode { - key "mode"; - description "List of allowed modes."; - - leaf mode { - type access-mode; - description "Allowed mode."; - } - } - leaf carrier { - type string; - description "Network carrier profile."; - } - list band { - key "band"; - description "List of enabled bands."; - - leaf band { - type band; - description "Enabled band."; - } - } - list bearer { - key "index"; - description "List of bearers."; - - leaf index { - type uint8; - description "Index of the bearer."; - } - leaf apn-type { - type bearer-apn-type; - description "APN type."; - } - leaf apn { - type string; - description "Access Point Name (APN)."; - } - leaf username { - type string; - description "User name (if any) required by the network."; - default ""; - } - leaf password { - type string; - description "Password (if any) required by the network."; - default ""; - nacm:default-deny-all; - } - leaf allow-roaming { - type boolean; - description "Flag to tell whether connection is allowed during roaming."; - default false; - } - leaf ip-type { - type ip-family; - description "IP addressing type."; - default ipv4v6; - } - leaf default-route { - type boolean; - description "Set default route for connection"; - default false; - } - leaf firewall-enabled { - type boolean; - description "Enable firewall for connection."; - default false; - } - leaf dns-enabled { - type boolean; - description "Enable DNS resolver for connection."; - default true; - } - } - container location { - leaf enabled { - type boolean; - description "Enable location gathering."; - default false; - } - - list source { - key "source"; - description "List of location sources."; - - leaf source { - type location-source; - description "Source used for location gathering."; - } - } - } - - container info { - description "Modem information."; - config false; - - leaf manufacturer { - type string; - description "Manufacturer of the modem."; - } - leaf model { - type string; - description "Model name of the modem."; - } - leaf hardware-revision { - type string; - description "Hardware revision of the modem."; - } - leaf firmware-version { - type string; - description "Firmware version of the modem."; - } - leaf-list supported-carrier { - type string; - description "List of supported carriers."; - } - leaf serial-number { - type string; - description "Serial number of the modem (IMEI)."; - } - leaf-list phone-number { - type string; - description "List of own phone numbers."; - } - leaf imsi { - type string; - description "International Mobile Subscriber Identity"; - } - leaf iccid { - type string; - description "ICCID of the SIM card."; - } - } - - container status { - description "Modem Status Information."; - config false; - - leaf sim-active { - type int8; - description "Index of active SIM card."; - } - leaf sim-present { - type boolean; - description "True if SIM card is present"; - } - leaf state { - type string; - description "Modem state."; - } - leaf state-failed-reason { - type string; - description "Reason of failed modem state."; - } - leaf selected-carrier { - type string; - description "Selected carrier."; - } - leaf signal-quality { - type uint8; - description "Signal strength in percent (0-100)."; - } - leaf signal-rssi { - type string; - description "RSSI (Received Signal Strength Indication) in dBm."; - } - leaf signal-rsrp { - type string; - description "RSRP (Reference Signal Received Power) in dBm."; - } - leaf signal-rsrq { - type string; - description "RSRQ (Reference Signal Received Quality) in dB."; - } - leaf signal-rscp { - type string; - description "RSCP (Received Signal Code Power) in dBm."; - } - leaf signal-snr { - type string; - description "SNR (Signal Noise Ratio) in dB."; - } - leaf signal-sinr { - type string; - description "SINR (Signal Interference Noise Ratio) ) in dB."; - } - - container cellular { - description "Cellular network information."; - - leaf registration-state { - description "Modem registration state."; - type registration-state; - } - leaf operator-name { - type string; - description "Name of the cellular network operator."; - } - leaf operator-id { - type string; - description "Identifier of the cellular network operator (MCC/MNC)."; - } - leaf network-type { - type string; - description "Type of cellular network (e.g. LTE)"; - } - leaf packet-service-state { - description "Modem packet service state."; - type string; - } - } - - list bearer { - description "Bearer information."; - key "path"; - - leaf path { - type string; - description "Path to bearer."; - } - leaf interface { - type if:interface-ref; - description "Network interface name for the bearer."; - } - leaf connected { - type boolean; - description "Indicates whether bearer is connected."; - } - leaf connection-failed-reason { - type string; - description "Reason of failed connection of bearer."; - } - leaf ipv4-address { - type inet:ipv4-address; - description "The IPv4 address of the bearer connection."; - } - leaf ipv4-prefix { - type uint16; - description "The IPv4 prefix of the bearer connection."; - } - leaf ipv6-address { - type inet:ipv6-address; - description "The IPv6 address of the bearer connection."; - } - leaf ipv6-prefix { - type uint16; - description "The IPv6 prefix of the bearer connection."; - } - leaf in-bytes { - type yang:counter64; - description "The number of received bytes of the bearer connection."; - } - leaf out-bytes { - type yang:counter64; - description "The number of sent bytes of the bearer connection."; - } - leaf total-in-bytes { - type yang:counter64; - description "Total number of received bytes of all connections."; - } - leaf total-out-bytes { - type yang:counter64; - description "Total number of sent bytes of all connections."; - } - leaf total-duration { - type string; - description "Total duration of all connections in seconds."; - } - } - - container location { - description "Location information."; - - leaf latitude { - type string; - description "Latitude in decimal degrees."; - } - leaf longitude { - type string; - description "Longitude in decimal degrees."; - } - leaf altitude { - type string; - description "Altitude above sea level in meters."; - } - leaf mcc { - type string; - description "Mobile Country Code of the operator."; - } - leaf mnc { - type string; - description "Mobile Network Code of the operator."; - } - leaf lac { - type string; - description "Location Area Code of the operator."; - } - leaf cid { - type string; - description "Cell Identifier of the operator."; - } - leaf tac { - type string; - description "Tracking Area Code of the operator."; - } - } - } - - notification status-update { - description "Notification for modem status updates."; - leaf desc { - type string; - description "Description of the status update."; - mandatory true; - } - } - } - } - - rpc restart { - description "Restart modem."; - input { - leaf index { - type string; - description "Modem index."; - } - } - } - rpc reset { - description "Reset the modem to factory defaults."; - input { - leaf index { - type string; - description "Modem index."; - } - } - } - rpc send-sms { - description "Send an SMS message."; - input { - leaf index { - type string; - description "Modem index."; - } - leaf phone-number { - type string; - description "Recipient's phone number."; - } - leaf message-text { - type string; - description "Text of the SMS message."; - } - } - } - - typedef bearer-apn-type { - description "APN type for bearer."; - - type enumeration { - enum initial { - description "APN used for the initial attach procedure."; - } - enum default { - description "Default connection APN providing access to the Internet."; - } - enum ims { - description "APN providing access to IMS services."; - } - enum mms { - description "APN providing access to MMS services."; - } - enum management { - description "APN providing access to over-the-air device management procedures."; - } - enum voice { - description "APN providing access to voice-over-IP services."; - } - enum emergency { - description "APN providing access to emergency services."; - } - enum private { - description "APN providing access to private networks."; - } - enum purchase { - description "APN providing access to over-the-air activation sites."; - } - enum video_share { - description "APN providing access to video sharing service."; - } - enum local { - description "APN providing access to a local connection with the device."; - } - enum app { - description "APN providing access to certain applications allowed by mobile operators."; - } - enum xcap { - description "APN providing access to XCAP provisioning on IMS services."; - } - enum tethering { - description "APN providing access to mobile hotspot tethering."; - } - } - } - - typedef ip-family { - description "IP address family."; - - type enumeration { - enum ipv4 { - description "IPv4 address family."; - } - enum ipv6 { - description "IPv6 address family."; - } - enum ipv4v6 { - description "IPv4 or IPv6 address family."; - } - } - } - - typedef access-mode { - type enumeration { - enum cs { - description "Circuit-switched technologies (e.g. CSD, GSM)."; - } - enum 2g { - description "2G technologies (e.g. GPRS, EDGE)."; - } - enum 3g { - description "3G technologies (e.g. UMTS, HSxPA)."; - } - enum 4g { - description "4G technologies (e.g. LTE)."; - } - enum 5g { - description "5G technologies (e.g. 5GNR)."; - } - enum any { - description "Any technologies."; - } - } - } - - typedef registration-state { - type enumeration { - enum idle { - description "Not registered, not searching for new operator to register."; - } - enum home { - description "Registered on home network."; - } - enum searching { - description "Not registered, searching for new operator to register with."; - } - enum denied { - description "Registration denied."; - } - enum roaming { - description "Registered on a roaming network."; - } - enum unknown { - description "Unknown registration state."; - } - } - } - - typedef band { - type enumeration { - enum any { - description "Any band."; - } - enum egsm { - description "GSM/GPRS/EDGE 900 MHz."; - } - enum dcs { - description "GSM/GPRS/EDGE 1800 MHz."; - } - enum pcs { - description "GSM/GPRS/EDGE 1900 MHz."; - } - enum g850 { - description "GSM/GPRS/EDGE 850 MHz."; - } - enum g450 { - description "GSM/GPRS/EDGE 450 MHz."; - } - enum g480 { - description "GSM/GPRS/EDGE 480 MHz."; - } - enum g750 { - description "GSM/GPRS/EDGE 750 MHz."; - } - enum g380 { - description "GSM/GPRS/EDGE 380 MHz."; - } - enum g410 { - description "GSM/GPRS/EDGE 410 MHz."; - } - enum g710 { - description "GSM/GPRS/EDGE 710 MHz."; - } - enum g810 { - description "GSM/GPRS/EDGE 810 MHz."; - } - enum utran-1 { - description "UMTS 2100 MHz (IMT, UTRAN band 1)."; - } - enum utran-2 { - description "UMTS 1900 MHz (PCS A-F, UTRAN band 2)."; - } - enum utran-3 { - description "UMTS 1800 MHz (DCS, UTRAN band 3)."; - } - enum utran-4 { - description "UMTS 1700 MHz (AWS A-F, UTRAN band 4)."; - } - enum utran-5 { - description "UMTS 850 MHz (CLR, UTRAN band 5)."; - } - enum utran-6 { - description "UMTS 800 MHz (UTRAN band 6)."; - } - enum utran-7 { - description "UMTS 2600 MHz (IMT-E, UTRAN band 7)."; - } - enum utran-8 { - description "UMTS 900 MHz (E-GSM, UTRAN band 8)."; - } - enum utran-9 { - description "UMTS 1700 MHz (UTRAN band 9)."; - } - enum utran-10 { - description "UMTS 1700 MHz (EAWS A-G, UTRAN band 10)."; - } - enum utran-11 { - description "UMTS 1500 MHz (LPDC, UTRAN band 11)."; - } - enum utran-12 { - description "UMTS 700 MHz (LSMH A/B/C, UTRAN band 12)."; - } - enum utran-13 { - description "UMTS 700 MHz (USMH C, UTRAN band 13)."; - } - enum utran-14 { - description "UMTS 700 MHz (USMH D, UTRAN band 14)."; - } - enum utran-19 { - description "UMTS 800 MHz (UTRAN band 19)."; - } - enum utran-20 { - description "UMTS 800 MHz (EUDD, UTRAN band 20)."; - } - enum utran-21 { - description "UMTS 1500 MHz (UPDC, UTRAN band 21)."; - } - enum utran-22 { - description "UMTS 3500 MHz (UTRAN band 22)."; - } - enum utran-25 { - description "UMTS 1900 MHz (EPCS A-G, UTRAN band 25)."; - } - enum utran-26 { - description "UMTS 850 MHz (ECLR, UTRAN band 26)."; - } - enum utran-32 { - description "UMTS 1500 MHz (L-band, UTRAN band 32)."; - } - enum eutran-1 { - description "E-UTRAN band 1."; - } - enum eutran-2 { - description "E-UTRAN band 2."; - } - enum eutran-3 { - description "E-UTRAN band 3."; - } - enum eutran-4 { - description "E-UTRAN band 4."; - } - enum eutran-5 { - description "E-UTRAN band 5."; - } - enum eutran-6 { - description "E-UTRAN band 6."; - } - enum eutran-7 { - description "E-UTRAN band 7."; - } - enum eutran-8 { - description "E-UTRAN band 8."; - } - enum eutran-9 { - description "E-UTRAN band 9."; - } - enum eutran-10 { - description "E-UTRAN band 10."; - } - enum eutran-11 { - description "E-UTRAN band 11."; - } - enum eutran-12 { - description "E-UTRAN band 12."; - } - enum eutran-13 { - description "E-UTRAN band 13."; - } - enum eutran-14 { - description "E-UTRAN band 14."; - } - enum eutran-17 { - description "E-UTRAN band 17."; - } - enum eutran-18 { - description "E-UTRAN band 18."; - } - enum eutran-19 { - description "E-UTRAN band 19."; - } - enum eutran-20 { - description "E-UTRAN band 20."; - } - enum eutran-21 { - description "E-UTRAN band 21."; - } - enum eutran-22 { - description "E-UTRAN band 22."; - } - enum eutran-23 { - description "E-UTRAN band 23."; - } - enum eutran-24 { - description "E-UTRAN band 24."; - } - enum eutran-25 { - description "E-UTRAN band 25."; - } - enum eutran-26 { - description "E-UTRAN band 26."; - } - enum eutran-27 { - description "E-UTRAN band 27."; - } - enum eutran-28 { - description "E-UTRAN band 28."; - } - enum eutran-29 { - description "E-UTRAN band 29."; - } - enum eutran-30 { - description "E-UTRAN band 30."; - } - enum eutran-31 { - description "E-UTRAN band 31."; - } - enum eutran-32 { - description "E-UTRAN band 32."; - } - enum eutran-33 { - description "E-UTRAN band 33."; - } - enum eutran-34 { - description "E-UTRAN band 34."; - } - enum eutran-35 { - description "E-UTRAN band 35."; - } - enum eutran-36 { - description "E-UTRAN band 36."; - } - enum eutran-37 { - description "E-UTRAN band 37."; - } - enum eutran-38 { - description "E-UTRAN band 38."; - } - enum eutran-39 { - description "E-UTRAN band 39."; - } - enum eutran-40 { - description "E-UTRAN band 40."; - } - enum eutran-41 { - description "E-UTRAN band 41."; - } - enum eutran-42 { - description "E-UTRAN band 42."; - } - enum eutran-43 { - description "E-UTRAN band 43."; - } - enum eutran-44 { - description "E-UTRAN band 44."; - } - enum eutran-45 { - description "E-UTRAN band 45."; - } - enum eutran-46 { - description "E-UTRAN band 46."; - } - enum eutran-47 { - description "E-UTRAN band 47."; - } - enum eutran-48 { - description "E-UTRAN band 48."; - } - enum eutran-49 { - description "E-UTRAN band 49."; - } - enum eutran-50 { - description "E-UTRAN band 50."; - } - enum eutran-51 { - description "E-UTRAN band 51."; - } - enum eutran-52 { - description "E-UTRAN band 52."; - } - enum eutran-53 { - description "E-UTRAN band 53."; - } - enum eutran-54 { - description "E-UTRAN band 54."; - } - enum eutran-55 { - description "E-UTRAN band 55."; - } - enum eutran-56 { - description "E-UTRAN band 56."; - } - enum eutran-57 { - description "E-UTRAN band 57."; - } - enum eutran-58 { - description "E-UTRAN band 58."; - } - enum eutran-59 { - description "E-UTRAN band 59."; - } - enum eutran-60 { - description "E-UTRAN band 60."; - } - enum eutran-61 { - description "E-UTRAN band 61."; - } - enum eutran-62 { - description "E-UTRAN band 62."; - } - enum eutran-63 { - description "E-UTRAN band 63."; - } - enum eutran-64 { - description "E-UTRAN band 64."; - } - enum eutran-65 { - description "E-UTRAN band 65."; - } - enum eutran-66 { - description "E-UTRAN band 66."; - } - enum eutran-67 { - description "E-UTRAN band 67."; - } - enum eutran-68 { - description "E-UTRAN band 68."; - } - enum eutran-69 { - description "E-UTRAN band 69."; - } - enum eutran-70 { - description "E-UTRAN band 70."; - } - enum eutran-71 { - description "E-UTRAN band 71."; - } - enum eutran-85 { - description "E-UTRAN band 85."; - } - enum cdma-bc0 { - description "CDMA Band Class 0 (US Cellular 850MHz)."; - } - enum cdma-bc1 { - description "CDMA Band Class 1 (US PCS 1900MHz)."; - } - enum cdma-bc2 { - description "CDMA Band Class 2 (UK TACS 900MHz)."; - } - enum cdma-bc3 { - description "CDMA Band Class 3 (Japanese TACS)."; - } - enum cdma-bc4 { - description "CDMA Band Class 4 (Korean PCS)."; - } - enum cdma-bc5 { - description "CDMA Band Class 5 (NMT 450MHz)."; - } - enum cdma-bc6 { - description "CDMA Band Class 6 (IMT2000 2100MHz)."; - } - enum cdma-bc7 { - description "CDMA Band Class 7 (Cellular 700MHz)."; - } - enum cdma-bc8 { - description "CDMA Band Class 8 (1800MHz)."; - } - enum cdma-bc9 { - description "CDMA Band Class 9 (900MHz)."; - } - enum cdma-bc10 { - description "CDMA Band Class 10 (US Secondary 800)."; - } - enum cdma-bc11 { - description "CDMA Band Class 11 (European PAMR 400MHz)."; - } - enum cdma-bc12 { - description "CDMA Band Class 12 (PAMR 800MHz)."; - } - enum cdma-bc13 { - description "CDMA Band Class 13 (IMT2000 2500MHz Expansion)."; - } - enum cdma-bc14 { - description "CDMA Band Class 14 (More US PCS 1900MHz)."; - } - enum cdma-bc15 { - description "CDMA Band Class 15 (AWS 1700MHz)."; - } - enum cdma-bc16 { - description "CDMA Band Class 16 (US 2500MHz)."; - } - enum cdma-bc17 { - description "CDMA Band Class 17 (US 2500MHz Forward Link Only)."; - } - enum cdma-bc18 { - description "CDMA Band Class 18 (US 700MHz Public Safety)."; - } - enum cdma-bc19 { - description "CDMA Band Class 19 (US Lower 700MHz)."; - } - enum ngran-1 { - description "NGRAN band 1."; - } - enum ngran-2 { - description "NGRAN band 2."; - } - enum ngran-3 { - description "NGRAN band 3."; - } - enum ngran-5 { - description "NGRAN band 5."; - } - enum ngran-7 { - description "NGRAN band 7."; - } - enum ngran-8 { - description "NGRAN band 8."; - } - enum ngran-12 { - description "NGRAN band 12."; - } - enum ngran-13 { - description "NGRAN band 13."; - } - enum ngran-14 { - description "NGRAN band 14."; - } - enum ngran-18 { - description "NGRAN band 18."; - } - enum ngran-20 { - description "NGRAN band 20."; - } - enum ngran-25 { - description "NGRAN band 25."; - } - enum ngran-26 { - description "NGRAN band 26."; - } - enum ngran-28 { - description "NGRAN band 28."; - } - enum ngran-29 { - description "NGRAN band 29."; - } - enum ngran-30 { - description "NGRAN band 30."; - } - enum ngran-34 { - description "NGRAN band 34."; - } - enum ngran-38 { - description "NGRAN band 38."; - } - enum ngran-39 { - description "NGRAN band 39."; - } - enum ngran-40 { - description "NGRAN band 40."; - } - enum ngran-41 { - description "NGRAN band 41."; - } - enum ngran-48 { - description "NGRAN band 48."; - } - enum ngran-50 { - description "NGRAN band 50."; - } - enum ngran-51 { - description "NGRAN band 51."; - } - enum ngran-53 { - description "NGRAN band 53."; - } - enum ngran-65 { - description "NGRAN band 65."; - } - enum ngran-66 { - description "NGRAN band 66."; - } - enum ngran-70 { - description "NGRAN band 70."; - } - enum ngran-71 { - description "NGRAN band 71."; - } - enum ngran-74 { - description "NGRAN band 74."; - } - enum ngran-75 { - description "NGRAN band 75."; - } - enum ngran-76 { - description "NGRAN band 76."; - } - enum ngran-77 { - description "NGRAN band 77."; - } - enum ngran-78 { - description "NGRAN band 78."; - } - enum ngran-79 { - description "NGRAN band 79."; - } - enum ngran-80 { - description "NGRAN band 80."; - } - enum ngran-81 { - description "NGRAN band 81."; - } - enum ngran-82 { - description "NGRAN band 82."; - } - enum ngran-83 { - description "NGRAN band 83."; - } - enum ngran-84 { - description "NGRAN band 84."; - } - enum ngran-86 { - description "NGRAN band 86."; - } - enum ngran-89 { - description "NGRAN band 89."; - } - enum ngran-90 { - description "NGRAN band 90."; - } - enum ngran-91 { - description "NGRAN band 91."; - } - enum ngran-92 { - description "NGRAN band 92."; - } - enum ngran-93 { - description "NGRAN band 93."; - } - enum ngran-94 { - description "NGRAN band 94."; - } - enum ngran-95 { - description "NGRAN band 95."; - } - enum ngran-257 { - description "NGRAN band 257."; - } - enum ngran-258 { - description "NGRAN band 258."; - } - enum ngran-260 { - description "NGRAN band 260."; - } - enum ngran-261 { - description "NGRAN band 261."; - } - } - } - - typedef location-source { - type enumeration { - enum gps { - description "GPS location source."; - } - enum agps-msa { - description "MSA A-GPS location source."; - } - enum agps-msb { - description "MSB A-GPS location source."; - } - enum 3gpp { - description "3GPP location source."; - } - enum cdma { - description "CDMA location source."; - } - } - } -} diff --git a/src/confd/yang/confd/infix-modem@2024-03-15.yang b/src/confd/yang/confd/infix-modem@2024-03-15.yang deleted file mode 120000 index 467b07a47..000000000 --- a/src/confd/yang/confd/infix-modem@2024-03-15.yang +++ /dev/null @@ -1 +0,0 @@ -infix-modem.yang \ No newline at end of file diff --git a/src/confd/yang/modem.inc b/src/confd/yang/modem.inc new file mode 100644 index 000000000..6a9a87380 --- /dev/null +++ b/src/confd/yang/modem.inc @@ -0,0 +1,5 @@ +MODULES=( + "infix-hardware -e modem" + "infix-if-type -e modem" + "infix-interfaces -e modem" +) diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 250cd8378..2e6a3f8ce 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -113,6 +113,15 @@ + + + + jq -r '.modem[]? | "modem" + (.index|tostring)' /run/system.json 2>/dev/null + + + + + @@ -335,6 +344,30 @@ echo "Public: $pub" + + + + + rpc "/ietf-hardware:hardware/component[name='$KLISH_PARAM_ref']/infix-hardware:restart" + + + + + + rpc "/ietf-hardware:hardware/component[name='$KLISH_PARAM_ref']/infix-hardware:reset" + + + + + + + + rpc "/ietf-hardware:hardware/component[name='$KLISH_PARAM_ref']/infix-hardware:send-sms" \ + phone-number "$KLISH_PARAM_number" message-text "$KLISH_PARAM_message" + + + + @@ -573,6 +606,15 @@ echo "Public: $pub" + + + + + + show modem "$KLISH_PARAM_ref" |pager + + + diff --git a/src/modemd/modem-info b/src/modemd/modem-info index 2f1684b26..c9e7653ac 100755 --- a/src/modemd/modem-info +++ b/src/modemd/modem-info @@ -159,6 +159,20 @@ def get_current_carrier(index, manf, model): return "default" +def _sim_lock_state(unlock_required): + mapping = { + "none": "unlocked", + "sim-pin": "pin-required", + "sim-pin2": "pin-required", + "sim-puk": "puk-required", + "sim-puk2": "puk-required", + } + # Default to "unlocked": this function is only called when the SIM query + # succeeded, so the card is present. "--" means the modem doesn't report + # lock state but the SIM is accessible. + return mapping.get(unlock_required, "unlocked") + + def device_devpath(devpath): path = devpath while path != "/sys": @@ -171,24 +185,37 @@ def device_devpath(devpath): def modem_get_module(devpath): - if not os.path.exists("/run/modules.json"): - if os.path.exists("/run/modems.json"): - with open("/run/modems.json", "r") as fd: - data = json.load(fd) - for index, modem in enumerate(data.get("modems", [])): - if devpath in modem["devpath"]: - return {"index": index, "slot": index, "paths": [modem["devpath"]], "type": "modem"} + # Prefer system.json (new canonical source written by 00-probe) + if os.path.exists("/run/system.json"): + with open("/run/system.json", "r") as fd: + data = json.load(fd) + for modem in data.get("modem", []): + if devpath in modem.get("devpath", ""): + idx = modem.get("index", 0) + return {"index": idx, "slot": idx, + "paths": [modem["devpath"]], "type": "modem"} + + # Fallback: Minex modules.json (hardware with slot/SIM multiplexer) + if os.path.exists("/run/modules.json"): + with open("/run/modules.json", "r") as fd: + data = json.load(fd) + for module in data.get("modules", []): + if module["type"] != "modem": + continue + for path in module.get("paths", []): + if path in devpath: + return module return None - with open("/run/modules.json", "r") as fd: - data = json.load(fd) + # Fallback: legacy modems.json written by modem-udev + if os.path.exists("/run/modems.json"): + with open("/run/modems.json", "r") as fd: + data = json.load(fd) + for index, modem in enumerate(data.get("modems", [])): + if devpath in modem.get("devpath", ""): + return {"index": index, "slot": index, + "paths": [modem["devpath"]], "type": "modem"} - for module in data["modules"]: - if module["type"] != "modem": - continue - for path in module["paths"]: - if path in devpath: - return module return None @@ -228,18 +255,35 @@ def sysfs_interfaces(devpath): def print_modem(index): - with open("/run/modems.json", "r") as fd: - data = json.load(fd) - - for modem in data["modems"]: - module = modem_get_module(modem["devpath"]) - if module["index"] == index: - modem["slot"] = module["slot"] - modem["sim"] = modem_get_sim(module) or {"index": 0, "name": "sim0", "slot": 0, "present": True} - if not modem.get("interfaces"): - modem["interfaces"] = sysfs_interfaces(modem["devpath"]) - print(json.dumps(modem)) - sys.exit(0) + # Try system.json first (canonical source written by 00-probe) + if os.path.exists("/run/system.json"): + with open("/run/system.json", "r") as fd: + data = json.load(fd) + for modem in data.get("modem", []): + if modem.get("index") == index: + module = {"index": index, "slot": index, + "paths": [modem["devpath"]], "type": "modem"} + modem["sim"] = modem_get_sim(module) or \ + {"index": 0, "name": "sim0", "slot": 0, "present": True} + if not modem.get("interfaces"): + modem["interfaces"] = sysfs_interfaces(modem["devpath"]) + print(json.dumps(modem)) + sys.exit(0) + + # Fallback: legacy modems.json written by modem-udev + if os.path.exists("/run/modems.json"): + with open("/run/modems.json", "r") as fd: + data = json.load(fd) + for modem in data.get("modems", []): + module = modem_get_module(modem["devpath"]) + if module and module["index"] == index: + modem["slot"] = module["slot"] + modem["sim"] = modem_get_sim(module) or \ + {"index": 0, "name": "sim0", "slot": 0, "present": True} + if not modem.get("interfaces"): + modem["interfaces"] = sysfs_interfaces(modem["devpath"]) + print(json.dumps(modem)) + sys.exit(0) print(json.dumps({})) sys.exit(0) @@ -324,6 +368,12 @@ def print_all(): if sim is not None: modem["status"]["sim-active"] = sim["index"] modem["status"]["sim-present"] = sim["present"] + sim_name = sim["name"] + sim_slot = sim["slot"] + else: + sim_name = "sim0" + slot_raw = gen.get("primary-sim-slot", 1) + sim_slot = int(slot_raw) if str(slot_raw).isdigit() else 1 refresh = 0 output = runcmdj(['mmcli', '-J', '-m', mpath, '--signal-get']) @@ -391,8 +441,15 @@ def print_all(): output = runcmdj(['mmcli', '-J', '-m', mpath, '-i', gen["sim"]]) if output: - modem["info"]["imsi"] = output["sim"]["properties"]["imsi"] - modem["info"]["iccid"] = output["sim"]["properties"]["iccid"] + sim_props = output["sim"]["properties"] + modem["info"]["imsi"] = sim_props["imsi"] + modem["info"]["iccid"] = sim_props["iccid"] + modem["sim-state"] = { + "name": sim_name, + "slot": sim_slot, + "state": _sim_lock_state(vstr(gen.get("unlock-required", "--"))), + "operator-name": vstr(sim_props.get("operator-name", "--")), + } output = runcmdj(['mmcli', '-J', '-m', mpath, '--location-get']) if output: diff --git a/src/modemd/modem-rpc b/src/modemd/modem-rpc index 5bc7ecd7d..ff9f86eb1 100755 --- a/src/modemd/modem-rpc +++ b/src/modemd/modem-rpc @@ -38,6 +38,13 @@ if __name__ == "__main__": print("Sending '%s' rpc to modem%d" % (args.rpc, index)) - rpc = {"infix-modem:%s" % args.rpc: {"index": str(index)}} + rpc = { + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % index, + "infix-hardware:modem": {args.rpc: {}} + }] + } + } sendrpc(index, rpc) diff --git a/src/modemd/modem-sms b/src/modemd/modem-sms index 7bcb797ce..cf6c8b424 100755 --- a/src/modemd/modem-sms +++ b/src/modemd/modem-sms @@ -27,11 +27,17 @@ def send(index, number, text): index = 0 rpc = { - "infix-modem:send-sms": { - "index": str(index), - "phone-number": number, - "message-text": text - } + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % index, + "infix-hardware:modem": { + "send-sms": { + "phone-number": number, + "message-text": text + } + } + }] + } } print("Sending SMS to modem%d" % index) diff --git a/src/modemd/modem-udev b/src/modemd/modem-udev index ba4ebfbaf..64d6b1e23 100755 --- a/src/modemd/modem-udev +++ b/src/modemd/modem-udev @@ -10,6 +10,7 @@ import re import os MODEMS = "/run/modems.json" +SYSTEM = "/run/system.json" LOCK = "/var/lock/modems.lock" LOGFILE = "/run/modemd/modem-udev.log" debug = False @@ -98,22 +99,42 @@ def write_modem_data(data): return True -def get_vendor(devpath): - path = "%s/idVendor" % devpath - if os.path.exists(path): - with open(path, "r") as fd: +def _sysfs_attr(devpath, name): + try: + with open("%s/%s" % (devpath, name), "r") as fd: return fd.readline().strip() - else: + except OSError: return None -def get_product(devpath): - path = "%s/idProduct" % devpath - if os.path.exists(path): - with open(path, "r") as fd: - return fd.readline().strip() +def update_system_json(modems_list): + data = {} + try: + with open(SYSTEM, "r") as fd: + data = json.load(fd) + except (IOError, json.JSONDecodeError): + pass + + entries = [] + for i, m in enumerate(modems_list): + entries.append({ + "index": i, + "name": "modem%d" % i, + "devpath": m.get("devpath", ""), + "vid": m.get("vendor", ""), + "pid": m.get("product", ""), + }) + + if not entries: + data.pop("modem", None) else: - return None + data["modem"] = entries + + tmp = SYSTEM + ".tmp" + with open(tmp, "w") as fd: + json.dump(data, fd, indent=2) + os.rename(tmp, SYSTEM) + info("Updated system.json with %d modem(s)" % len(entries)) def update(d): @@ -133,11 +154,11 @@ def update(d): if not modem: modem = {"devpath": devpath} - vendor = get_vendor(devpath) + vendor = _sysfs_attr(devpath, "idVendor") if vendor: modem["vendor"] = vendor - product = get_product(devpath) + product = _sysfs_attr(devpath, "idProduct") if product: modem["product"] = product @@ -165,6 +186,7 @@ def update(d): data["modems"].append(modem) write_modem_data(data) + update_system_json(data["modems"]) info("Updated modem %s" % devpath) diff --git a/src/modemd/modemd b/src/modemd/modemd index 695a41a06..fee12bec1 100755 --- a/src/modemd/modemd +++ b/src/modemd/modemd @@ -14,6 +14,7 @@ import enum import time import os import sys +import ipaddress rundir = "/run/modemd" smsdir = "/var/sms" @@ -63,8 +64,10 @@ def opener(path, flags): def rmf(path): - if os.path.exists(path): + try: os.unlink(path) + except FileNotFoundError: + pass def rmrf(path): @@ -87,12 +90,12 @@ def mkdir(path): def fread(path): - if os.path.exists(path): + try: with open(path, "r") as fd: - output = str(fd.read()) - if output: - return output.strip() - return None + output = fd.read() + return output.strip() if output else None + except OSError: + return None def freadj(path): @@ -207,6 +210,92 @@ def list_sms(modem): return smslist +def load_config(): + hw = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-hardware"]) + ifaces = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-interfaces"]) + + ks = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-keystore"]) + keys = {} + if ks: + for key in ks.get("ietf-keystore:keystore", {}) \ + .get("symmetric-keys", {}) \ + .get("symmetric-key", []): + keys[key["name"]] = key.get("cleartext-symmetric-key", "") + + # Cellular modems appear seconds after boot; use physical detection as the + # authoritative list rather than sysrepo which may not have them yet. + sysj = freadj("/run/system.json") or {} + physical = sysj.get("modem", []) + if not physical: + mj = freadj("/run/modems.json") or {} + for i, m in enumerate(mj.get("modems", [])): + physical.append({"index": i, "name": "modem%d" % i, + "devpath": m.get("devpath", "")}) + + hw_comps, sims = {}, {} + for comp in (hw or {}).get("ietf-hardware:hardware", {}).get("component", []): + cls = comp.get("class", "") + if cls == "infix-hardware:modem": + hw_comps[comp["name"]] = comp + elif cls == "infix-hardware:sim": + sims[comp["name"]] = comp.get("infix-hardware:sim", {}) + + modems = {} + for phys in physical: + name = phys.get("name", "modem%d" % phys.get("index", 0)) + index = phys.get("index", 0) + + comp = hw_comps.get(name, {}) + admin = comp.get("state", {}).get("admin-state", "unlocked") + if admin == "locked": + continue + + ih = comp.get("infix-hardware:modem", {}) + modems[name] = { + "index": index, + "preferred-mode": ih.get("preferred-mode"), + "allowed-mode": ih.get("allowed-mode", []), + "band": ih.get("band", []), + "location": ih.get("location"), + "bearer": [], + } + + for iface in (ifaces or {}).get("ietf-interfaces:interfaces", {}).get("interface", []): + if iface.get("type", "") != "infix-if-type:modem": + continue + wwan = iface.get("infix-interfaces:wwan", {}) + modem_ref = wwan.get("modem") + if not modem_ref or modem_ref not in modems: + continue + + sim_ref = wwan.get("sim") + if sim_ref and sim_ref in sims and "pin" not in modems[modem_ref]: + sim = sims[sim_ref] + modems[modem_ref].update({ + "pin": sim.get("pin"), + "puk": sim.get("puk"), + "carrier": sim.get("carrier", "default"), + }) + + bearer_cfg = wwan.get("bearer", {}) + auth = bearer_cfg.get("authentication") + bearer = { + "index": len(modems[modem_ref]["bearer"]), + "name": iface["name"], + "apn": bearer_cfg.get("apn", ""), + "ip-type": bearer_cfg.get("ip-type", "ipv4v6"), + "roaming": bearer_cfg.get("roaming", False), + "route-preference": bearer_cfg.get("route-preference", 200), + } + if auth: + bearer["username"] = auth.get("username", "") + bearer["password"] = keys.get(auth.get("password", ""), "") + + modems[modem_ref]["bearer"].append(bearer) + + return list(modems.values()) + + class RpcThread(threading.Thread): path = None server = None @@ -340,6 +429,10 @@ class SimThread(threading.Thread): th.interrupt = True def run(self): + if not os.path.exists("/dev/simctrl"): + dbg("No /dev/simctrl, SIM slot monitoring not available on this platform") + self.exited = True + return fd = os.open("/dev/simctrl", os.O_RDONLY) while not self.stopped(): res, *_ = select.select([fd], [], [], 10.0) @@ -428,8 +521,6 @@ class ModemThread(threading.Thread): self.err("No bearers configured") return False - default = None - count = 0 for bearer in sorted(bearers, key=lambda b: b["index"]): bearer["pid"] = "--" @@ -438,19 +529,7 @@ class ModemThread(threading.Thread): self.err("No APN configured") return False - apntype = bearer.get("apn-type") - if apntype == "initial": - if not self.bearers["initial"]: - self.bearers["initial"] = bearer - elif apntype == "default" and not default: - default = bearer - count += 1 - else: - self.bearers["list"].append(bearer) - count += 1 - - if default: - self.bearers["list"].insert(0, default) + self.bearers["list"].append(bearer) # at least one bearer is mandatory if len(self.bearers["list"]) == 0: @@ -475,9 +554,7 @@ class ModemThread(threading.Thread): loc = self.cfg.get("location") if loc and loc.get("enabled"): enabled = True - v = loc.get("source") - if v: - sources = [s.get("source") for s in v] + sources = loc.get("source", []) self.location = { "enabled": enabled, @@ -538,10 +615,12 @@ class ModemThread(threading.Thread): def notif(self, msg): data = { - "infix-modem:modems": { - "modem": [{ - "index": self.index, - "status-update": {"desc": msg} + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % self.index, + "infix-hardware:modem": { + "status-update": {"desc": msg} + } }] } } @@ -612,10 +691,6 @@ class ModemThread(threading.Thread): return True def resolvconf(self, iface, action): - enabled = iface["bearer"].get("dns-enabled", False) - if enabled is False: - return True - self.info("About to %s nameservers on %s" % (action, iface["name"])) output = runcmd(["resolvconf", "-i"], check=False) @@ -643,71 +718,78 @@ class ModemThread(threading.Thread): return True - def ipgwroute(self, iface, action): - output = runcmdj(["ip", "-j", "route", - "show", "dev", iface["name"]]) - for rt in output: - if "scope" in rt and rt["scope"] == "link": - if action == "add": - return True - else: - runcmd(["ip", "route", - "del", rt["dst"], "dev", iface["name"]]) - - if action != "add": - return True - - if iface["ipv4"]["gateway"] != "--": - gw = iface["ipv4"]["gateway"] - elif iface["ipv6"]["gateway"] != "--": - gw = iface["ipv6"]["gateway"] - else: - self.err("No gateway address found") - return False - - cmd = ["ip", "route", "add", gw, "dev", iface["name"]] - if not runcmd(cmd): - self.err("Unable to add gateway route") - return False - - return True + def _derive_gateway(self, ifname): + """Derive the peer gateway from the link-scope subnet route on ifname. + + Point-to-point bearers (MBIM/QMI) often report gateway as '--' even + though the kernel installs a link-scope subnet route (e.g. /30). The + peer — and therefore the nexthop for the default route — is the only + other host in that subnet. + """ + output = runcmdj(["ip", "-j", "route", "show", + "dev", ifname, "scope", "link"]) + for rt in (output or []): + dst = rt.get("dst", "") + src = rt.get("prefsrc", "") + if not dst or not src or "/" not in dst: + continue + try: + net = ipaddress.ip_network(dst, strict=False) + our = ipaddress.ip_address(src) + peers = [str(h) for h in net.hosts() if h != our] + if peers: + return peers[0] + except ValueError: + pass + return None def ipdefroute(self, iface, action): self.info("About to %s default route on %s" % (action, iface["name"])) - output = runcmdj(["ip", "-j", "route", "show", "default"]) - if output: - for route in output: - if not runcmd(["ip", "route", "del", "default", - "dev", route["dev"]]): - self.err("Can't delete default route on %s" % route["dev"]) - - if not self.ipgwroute(iface, action): - return False + conf = "/etc/net.d/%s.conf" % iface["name"] if action != "add": + rmf(conf) return True - cmd = ["ip", "route", "add", "default", "dev", iface["name"]] if iface["ipv4"]["gateway"] != "--": - cmd += ["via", iface["ipv4"]["gateway"]] + gw = iface["ipv4"]["gateway"] elif iface["ipv6"]["gateway"] != "--": - cmd += ["via", iface["ipv6"]["gateway"]] + gw = iface["ipv6"]["gateway"] + else: + # Gateway not reported by modem (common for point-to-point bearers). + # Derive it from the link-scope subnet route on the interface: the + # peer is the only other host in the subnet. + gw = self._derive_gateway(iface["name"]) + if not gw: + self.err("No gateway address found") + return False - if not runcmd(cmd): - self.err("Unable to add default route on %s" % iface["name"]) - time.sleep(30) - return False + metric = iface["bearer"].get("route-preference", 200) + + # Write route to /etc/net.d/ for netd/staticd/zebra to install. + # This keeps the route visible to FRR for redistribution and allows + # metric-based failover behind wired/WiFi routes (which use lower + # metric values from udhcpc, typically 5). + content = "# Generated by modemd -- do not edit\n\n" + content += "route {\n" + if ":" in gw: + content += ' prefix = "::/0"\n' else: + content += ' prefix = "0.0.0.0/0"\n' + content += ' nexthop = "%s"\n' % gw + content += ' distance = %d\n' % metric + content += ' tag = 100\n' + content += '}\n' + + if fread(conf) == content.strip(): return True + next_conf = conf + "+" + fwrite(next_conf, content) + os.rename(next_conf, conf) + return True def ifupdown(self, iface, action): - enabled = iface["bearer"].get("firewall-enabled", False) - if enabled is True: - self.info("Setting %s firewall on %s" % (action, self.iface)) - if not runcmd(["/etc/firewall/default", action, self.iface]): - return False - path = "/var/ifupdown/%s" % self.iface if os.path.isfile(path) or os.access(path, os.X_OK): self.info("Running %s %s" % (path, action)) @@ -721,12 +803,10 @@ class ModemThread(threading.Thread): self.ipdefroute(iface, "delete") self.ifupdown(iface, "down") - output = runcmdj(["ip", "-j", "addr", "show", iface["name"]]) - for entry in output: - for addr in entry["addr_info"]: - runcmd(["ip", "addr", "del", - addr["local"] + "/" + str(addr["prefixlen"]), - "dev", iface["name"]]) + # Flush only wwan-protocol-tagged addresses; leaves any static or + # dhcp-protocol addresses intact in case the interface is shared. + runcmd(["ip", "addr", "flush", "dev", iface["name"], + "proto", "wwan"], check=False) self.iplink(iface["name"], "down") @@ -748,16 +828,21 @@ class ModemThread(threading.Thread): self.err("No addresses yet") return False + # Flush any stale wwan-protocol addresses before adding new ones. + # The 'proto wwan' tag (rt_addrprotos entry 7) allows precise cleanup + # on disconnect without touching addresses added by other means. + runcmd(["ip", "addr", "flush", "dev", iface["name"], + "proto", "wwan"], check=False) + for addr in addrs: self.info("Setting address %s on %s" % (addr, iface["name"])) - if not runcmd(["ip", "addr", "change", - addr, "dev", iface["name"]]): + if not runcmd(["ip", "addr", "replace", addr, + "dev", iface["name"], "proto", "wwan"]): self.err("Unable to set address for %s" % iface["name"]) return False - if iface["bearer"].get("default-route"): - if not self.ipdefroute(iface, "add"): - return False + if not self.ipdefroute(iface, "add"): + return False if not self.ifupdown(iface, "up"): return False @@ -926,9 +1011,6 @@ class ModemThread(threading.Thread): "profile-id=%s" % bearer["pid"], "profile-name=profile%s" % bearer["pid"] ] - v = bearer.get("apn-type") - if v: - args.append("apn-type=%s" % v) v = bearer.get("apn") if v: args.append("apn=%s" % v) @@ -963,7 +1045,7 @@ class ModemThread(threading.Thread): if v: args.append("password=%s" % v) - v = bearer.get("allow-roaming") + v = bearer.get("roaming") if v: args.append("allow-roaming=true") else: @@ -1076,8 +1158,7 @@ class ModemThread(threading.Thread): def prepare_bands(self): self.info("Preparing bands") - v = self.cfg.get("band", []) - bands = [k.get("band", "any") for k in v] + bands = self.cfg.get("band", []) if len(bands) == 0 or "any" in bands: return True @@ -1094,9 +1175,7 @@ class ModemThread(threading.Thread): if not pref or pref == "any": pref = "none" - allow = [] - v = self.cfg.get("allowed-mode", []) - allow = [k.get("mode", "any") for k in v] + allow = self.cfg.get("allowed-mode", []) if "any" in allow: allow = [] @@ -1588,17 +1667,14 @@ if __name__ == "__main__": print(e) fatal("SIM thread caught an exception") - config = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "infix-modem"]) - if not config or "infix-modem:modems" not in config: - fatal("Unable to read config") + modems = load_config() + if not modems: + fatal("No modems configured or enabled") if not runcmd(['/usr/libexec/modemd/sim-setup']): fatal("Unable to setup SIMs") - for cfg in config["infix-modem:modems"]["modem"]: - if not cfg["enabled"] or cfg["index"] < 0: - continue - + for cfg in modems: th = None try: th = ModemThread(cfg) diff --git a/src/modemd/sim-setup b/src/modemd/sim-setup index be400f036..a19b4a51d 100755 --- a/src/modemd/sim-setup +++ b/src/modemd/sim-setup @@ -173,17 +173,42 @@ def swapslot(slot): def sim_setup(setup): changed = False - config = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "infix-modem"]) - if not config or "infix-modem:modems" not in config: + hw = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-hardware"]) + ifaces = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-interfaces"]) + if not hw or not ifaces: err("Cannot read config") return False - for cfg in config["infix-modem:modems"]["modem"]: - if not cfg.get("enabled", False) or cfg.get("index", -1) < 0: + modem_set = set() + for comp in hw.get("ietf-hardware:hardware", {}).get("component", []): + if comp.get("class", "") == "infix-hardware:modem" \ + and comp.get("state", {}).get("admin-state", "unlocked") == "unlocked": + name = comp["name"] + try: + modem_set.add(int(name[5:])) + except (ValueError, IndexError): + pass + + sim_for_modem = {} + for iface in ifaces.get("ietf-interfaces:interfaces", {}).get("interface", []): + if iface.get("type", "") != "infix-if-type:modem": continue + wwan = iface.get("infix-interfaces:wwan", {}) + modem_ref = wwan.get("modem", "") + sim_ref = wwan.get("sim", "") + if not modem_ref.startswith("modem") or not sim_ref.startswith("sim"): + continue + try: + midx = int(modem_ref[5:]) + sidx = int(sim_ref[3:]) + except (ValueError, IndexError): + continue + if midx in modem_set: + sim_for_modem[midx] = sidx - modem = find_modem(setup, cfg["index"]) - sim = find_sim(setup, cfg.get("sim", -1)) + for modem_idx, sim_idx in sim_for_modem.items(): + modem = find_modem(setup, modem_idx) + sim = find_sim(setup, sim_idx) if modem and sim: sim["newslot"] = modem["slot"] if sim["newslot"] != sim["slot"]: diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index bf77df3db..852f2935b 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -895,8 +895,9 @@ def get_formatted_value(self): else: return f"{self.value} {self.value_type}" - def print(self, indent=0): + def print(self, indent=0, name_width=None): import re + nw = name_width if name_width is not None else PadSensor.name # Add indentation for child sensors indent_str = " " * indent @@ -916,7 +917,7 @@ def print(self, indent=0): # Standalone sensor without description: use name as-is display_name = self.name - row = f"{indent_str}{display_name:<{PadSensor.name - len(indent_str)}}" + row = f"{indent_str}{display_name:<{nw - len(indent_str)}}" # For colored value, pad manually to account for ANSI codes value_str = self.get_formatted_value() # Count visible characters (strip ANSI codes for length calculation) @@ -2121,6 +2122,215 @@ def show_services(json): service_table.print() +def show_modem(json): + components = get_json_data([], json, "ietf-hardware:hardware", "component") + modems = [c for c in components if c.get("class") == "infix-hardware:modem"] + sims = [c for c in components if c.get("class") == "infix-hardware:sim"] + + if not modems and not sims: + print("No cellular modems found.") + return + + modem_table = None + if modems: + modem_table = SimpleTable([ + Column('NAME'), + Column('MANUFACTURER', flexible=True), + Column('MODEL', flexible=True), + Column('STATE'), + Column('NETWORK'), + Column('SIGNAL', 'right'), + ]) + for component in modems: + ms = component.get("infix-hardware:modem-state", {}) + cell = ms.get("cellular", {}) + name = component.get("name", "unknown") + mfg = ms.get("manufacturer", "") + model = ms.get("model", "") + state = ms.get("state", "unknown") + network = cell.get("network-type", "") + sig = ms.get("signal-quality") + signal = f"{sig}%" if sig is not None else "" + modem_table.row(name, mfg, model, state, network, signal) + + sim_table = None + if sims: + sim_table = SimpleTable([ + Column('NAME'), + Column('SLOT'), + Column('STATE'), + Column('OPERATOR', flexible=True), + ]) + for component in sims: + ss = component.get("infix-hardware:sim-state", {}) + name = component.get("name", "unknown") + slot = str(ss["slot"]) if ss.get("slot") is not None else "" + state = ss.get("state", "") + operator = ss.get("operator-name", "") + sim_table.row(name, slot, state, operator) + + width = max(modem_table.width() if modem_table else 0, + sim_table.width() if sim_table else 0, + 62) + print(Decore.invert(f"{'CELLULAR MODEMS':<{width}}")) + + if modem_table: + Decore.title("Cellular Modems", width) + modem_table.adjust_padding(width) + modem_table.print() + + if sim_table: + Decore.title("SIM Cards", width) + sim_table.adjust_padding(width) + sim_table.print() + + +def _modem_signal_str(status): + parts = [] + sq = status.get("signal-quality") + if sq is not None: + parts.append(f"Quality: {sq}%") + for key, label in (("signal-rssi", "RSSI"), ("signal-rsrp", "RSRP"), + ("signal-rsrq", "RSRQ"), ("signal-sinr", "SINR")): + if key in status: + parts.append(f"{label}: {status[key]}") + return " ".join(parts) + + +def show_modem_detail(json, ref): + modems = json.get("modem-list", []) + try: + idx = int(ref.removeprefix("modem")) + except ValueError: + idx = -1 + + modem = next((m for m in modems if m.get("index") == idx), None) + if modem is None: + print(f"Modem '{ref}' not found.") + return + + info = modem.get("info", {}) + status = modem.get("status", {}) + cell = status.get("cellular", {}) + sim_state = modem.get("sim-state", {}) + bearers = status.get("bearer", []) + location = status.get("location", {}) + + width = 62 + print(Decore.invert(f"{'MODEM: ' + ref:<{width}}")) + + Decore.title("Hardware Information", width) + for label, val in ( + ("Manufacturer", info.get("manufacturer", "")), + ("Model", info.get("model", "")), + ("Hardware Revision", info.get("hardware-revision", "")), + ("Firmware Version", info.get("firmware-version", "")), + ("Serial Number", info.get("serial-number", "")), + ("IMSI", info.get("imsi", "")), + ("ICCID", info.get("iccid", "")), + ): + if val: + print(f" {label:<20}: {val}") + phone = info.get("phone-number", []) + if phone: + print(f" {'Phone Number':<20}: {', '.join(phone)}") + carriers = info.get("supported-carrier", []) + if carriers: + print(f" {'Supported Carriers':<20}: {', '.join(carriers)}") + selected = status.get("selected-carrier", "") + if selected: + print(f" {'Selected Carrier':<20}: {selected}") + + Decore.title("Status", width) + state = status.get("state", "unknown") + print(f" {'State':<20}: {state}") + if state == "failed": + reason = status.get("state-failed-reason", "") + if reason: + print(f" {'Failed Reason':<20}: {reason}") + sig_str = _modem_signal_str(status) + if sig_str: + print(f" {'Signal':<20}: {sig_str}") + + if cell: + Decore.title("Cellular", width) + for label, key in ( + ("Registration", "registration-state"), + ("Operator", "operator-name"), + ("Operator ID", "operator-id"), + ("Network Type", "network-type"), + ("Service State", "service-state"), + ): + val = cell.get(key, "") + if val: + print(f" {label:<20}: {val}") + + if sim_state: + Decore.title("SIM Card", width) + for label, key in ( + ("Name", "name"), + ("Slot", "slot"), + ("Lock State", "state"), + ("Operator", "operator-name"), + ): + val = sim_state.get(key) + if val: + print(f" {label:<20}: {val}") + + if bearers: + Decore.title("Bearers", width) + for b in bearers: + iface = b.get("interface", "") + hdr = iface if iface else b.get("path", "") + connected = b.get("connected", False) + conn_str = "connected" if connected else "disconnected" + print(f" {hdr} ({conn_str})") + if not connected: + reason = b.get("connection-failed-reason", "") + if reason: + print(f" {'Error':<18}: {reason}") + v4 = b.get("ipv4-address") + v4pfx = b.get("ipv4-prefix", 0) + if v4 and v4pfx: + print(f" {'IPv4':<18}: {v4}/{v4pfx}") + v6 = b.get("ipv6-address") + v6pfx = b.get("ipv6-prefix", 0) + if v6 and v6pfx: + print(f" {'IPv6':<18}: {v6}/{v6pfx}") + rx = b.get("in-bytes", 0) + tx = b.get("out-bytes", 0) + if rx or tx: + print(f" {'Traffic':<18}: RX {rx} B TX {tx} B") + total_rx = b.get("total-in-bytes", 0) + total_tx = b.get("total-out-bytes", 0) + duration = b.get("total-duration", 0) + if total_rx or total_tx: + print(f" {'Total Traffic':<18}: RX {total_rx} B TX {total_tx} B ({duration} s)") + + if location: + lat = location.get("latitude", "") + lon = location.get("longitude", "") + alt = location.get("altitude", "") + cid = location.get("cid", "") + lac = location.get("lac", "") + mcc = location.get("mcc", "") + mnc = location.get("mnc", "") + if lat or cid: + Decore.title("Location", width) + if lat and lon: + pos = f"{lat}, {lon}" + if alt: + pos += f" Alt: {alt} m" + print(f" {'GPS':<20}: {pos}") + if cid: + cell_id = f"CID {cid}" + if lac: + cell_id += f" LAC {lac}" + if mcc and mnc: + cell_id += f" MCC {mcc} MNC {mnc}" + print(f" {'Cell ID':<20}: {cell_id}") + + def show_hardware(json): if not json.get("ietf-hardware:hardware"): print("Error, top level \"ietf-hardware:component\" missing") @@ -2133,8 +2343,53 @@ def show_hardware(json): sensors = [c for c in components if c.get("class") == "iana-hardware:sensor"] wifi_radios = [c for c in components if c.get("class") == "infix-hardware:wifi"] gps_receivers = [c for c in components if c.get("class") == "infix-hardware:gps"] + modems = [c for c in components if c.get("class") == "infix-hardware:modem"] + sims = [c for c in components if c.get("class") == "infix-hardware:sim"] + + # Pre-build modem and SIM tables to get natural widths before computing + # the global width used by all section separators. + modem_table = None + if modems: + modem_table = SimpleTable([ + Column('NAME'), + Column('MANUFACTURER', flexible=True), + Column('MODEL', flexible=True), + Column('STATE'), + Column('NETWORK'), + Column('SIGNAL', 'right'), + ]) + for component in modems: + ms = component.get("infix-hardware:modem-state", {}) + cell = ms.get("cellular", {}) + name = component.get("name", "unknown") + mfg = ms.get("manufacturer", "") + model = ms.get("model", "") + state = ms.get("state", "unknown") + network = cell.get("network-type", "") + sig = ms.get("signal-quality") + signal = f"{sig}%" if sig is not None else "" + modem_table.row(name, mfg, model, state, network, signal) + + sim_table = None + if sims: + sim_table = SimpleTable([ + Column('NAME'), + Column('SLOT'), + Column('STATE'), + Column('OPERATOR', flexible=True), + ]) + for component in sims: + ss = component.get("infix-hardware:sim-state", {}) + name = component.get("name", "unknown") + slot = str(ss["slot"]) if ss.get("slot") is not None else "" + state = ss.get("state", "") + operator = ss.get("operator-name", "") + sim_table.row(name, slot, state, operator) - width = max(PadSensor.table_width(), 62) + width = max(PadSensor.table_width(), + modem_table.width() if modem_table else 0, + sim_table.width() if sim_table else 0, + 62) # Display full-width inverted heading print(Decore.invert(f"{'HARDWARE COMPONENTS':<{width}}")) @@ -2263,11 +2518,21 @@ def show_hardware(json): usb_table.print() + if modem_table: + Decore.title("Cellular Modems", width) + modem_table.adjust_padding(width) + modem_table.print() + + if sim_table: + Decore.title("SIM Cards", width) + sim_table.adjust_padding(width) + sim_table.print() + if sensors: Decore.title("Sensors", width) - # Print header - hdr = (f"{'NAME':<{PadSensor.name}}" + name_width = PadSensor.name + max(0, width - PadSensor.table_width()) + hdr = (f"{'NAME':<{name_width}}" f"{'VALUE':<{PadSensor.value}}" f"{'STATUS':<{PadSensor.status}}") print(Decore.invert(hdr)) @@ -2296,7 +2561,7 @@ def show_hardware(json): if module_name in children: for child in sorted(children[module_name], key=lambda c: c.get("name", "")): sensor = Sensor(child) - sensor.print(indent=1) + sensor.print(indent=1, name_width=name_width) # Display standalone sensors (no parent) if standalone: @@ -2304,7 +2569,7 @@ def show_hardware(json): print() # Add blank line between modules and standalone for component in sorted(standalone, key=lambda c: c.get("name", "")): sensor = Sensor(component) - sensor.print() + sensor.print(name_width=name_width) def resolve_container_network(network, all_ifaces): @@ -5865,6 +6130,9 @@ def main(): .add_argument('name', help='Container name') subparsers.add_parser('show-hardware', help='Show USB ports') + subparsers.add_parser('show-modem', help='Show cellular modem overview') + subparsers.add_parser('show-modem-detail', help='Show detailed modem info') \ + .add_argument('name', help='Modem name (e.g. modem0)') subparsers.add_parser('show-interfaces', help='Show interfaces') \ .add_argument('-n', '--name', help='Interface name') @@ -5942,6 +6210,10 @@ def main(): show_container_detail(json_data, args.name) elif args.command == "show-hardware": show_hardware(json_data) + elif args.command == "show-modem": + show_modem(json_data) + elif args.command == "show-modem-detail": + show_modem_detail(json_data, args.name) elif args.command == "show-interfaces": show_interfaces(json_data, args.name) elif args.command == "show-lldp": diff --git a/src/statd/python/yanger/__main__.py b/src/statd/python/yanger/__main__.py index 92d8a1823..c88d4648f 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -126,9 +126,6 @@ def main(): elif model == 'ieee1588-ptp-tt': from . import ieee1588_ptp yang_data = ieee1588_ptp.operational() - elif model == 'infix-modem': - from . import infix_modem - yang_data = infix_modem.operational() else: common.LOG.warning("Unsupported model %s", model) sys.exit(1) diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index 62e32cd73..337763b59 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -829,19 +829,39 @@ def gps_receiver_components(): return components +def modem_components(): + from . import infix_modem + data = infix_modem.operational() + return data.get("ietf-hardware:hardware", {}).get("component", []) + + def operational(): systemjson = HOST.read_json("/run/system.json", {}) + all_components = ( + motherboard_component(systemjson) + + vpd_components(systemjson) + + usb_port_components(systemjson) + + hwmon_sensor_components() + + thermal_sensor_components() + + wifi_radio_components() + + gps_receiver_components() + + modem_components() + ) + + name_count = {} + components = [] + for c in all_components: + name = c.get("name") + count = name_count.get(name, 0) + name_count[name] = count + 1 + if count > 0: + c = dict(c) + c["name"] = f"{name}{count}" + components.append(c) + return { "ietf-hardware:hardware": { - "component": - motherboard_component(systemjson) + - vpd_components(systemjson) + - usb_port_components(systemjson) + - hwmon_sensor_components() + - thermal_sensor_components() + - wifi_radio_components() + - gps_receiver_components() + - [], + "component": components, }, } diff --git a/src/statd/python/yanger/infix_modem.py b/src/statd/python/yanger/infix_modem.py index a2b0f6d4f..be5f5a85f 100644 --- a/src/statd/python/yanger/infix_modem.py +++ b/src/statd/python/yanger/infix_modem.py @@ -2,11 +2,91 @@ from .host import HOST +def _modem_to_hw_state(modem): + info = modem.get("info", {}) + status = modem.get("status", {}) + cellular = status.get("cellular", {}) + + state = {} + if info.get("manufacturer"): + state["manufacturer"] = info["manufacturer"] + if info.get("model"): + state["model"] = info["model"] + if info.get("firmware-version"): + state["firmware-version"] = info["firmware-version"] + if info.get("serial-number"): + state["serial-number"] = info["serial-number"] + if info.get("imsi"): + state["imsi"] = info["imsi"] + if info.get("iccid"): + state["iccid"] = info["iccid"] + if status.get("state"): + state["state"] = status["state"] + if "signal-quality" in status: + state["signal-quality"] = status["signal-quality"] + for sig in ("signal-rssi", "signal-rsrp", "signal-rsrq", "signal-sinr"): + if sig in status: + state[sig] = status[sig] + + if cellular: + cell = {} + if cellular.get("registration-state"): + cell["registration-state"] = cellular["registration-state"] + if cellular.get("operator-name"): + cell["operator-name"] = cellular["operator-name"] + if cellular.get("operator-id"): + cell["operator-id"] = cellular["operator-id"] + if cellular.get("network-type"): + cell["network-type"] = cellular["network-type"] + if cell: + state["cellular"] = cell + + return state + + +def _sim_to_hw_state(sim_raw): + state = {} + slot = sim_raw.get("slot") + if isinstance(slot, int): + state["slot"] = slot + if sim_raw.get("state"): + state["state"] = sim_raw["state"] + if sim_raw.get("operator-name"): + state["operator-name"] = sim_raw["operator-name"] + return state + + def operational(): modems = HOST.run_json(['/usr/libexec/modemd/modem-info'], []) + hw_components = [] + for modem in modems: + idx = modem.get("index", 0) + name = "modem%d" % idx + hw_state = _modem_to_hw_state(modem) + if hw_state: + hw_components.append({ + "name": name, + "class": "infix-hardware:modem", + "infix-hardware:modem-state": hw_state + }) + + sim_raw = modem.get("sim-state") + if sim_raw: + sim_name = sim_raw.get("name", "sim%d" % idx) + sim_hw_state = _sim_to_hw_state(sim_raw) + if sim_hw_state: + hw_components.append({ + "name": sim_name, + "class": "infix-hardware:sim", + "infix-hardware:sim-state": sim_hw_state + }) + + if not hw_components: + return {} + return { - "infix-modem:modems": { - "modem": [m for m in modems] + "ietf-hardware:hardware": { + "component": hw_components } } From 8eb01f9f8237b36246ac71094c229419abb253e0 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 10 May 2026 06:18:42 +0200 Subject: [PATCH 5/8] modemd: package Python scripts for compiled deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python scripts without a .py extension are never cached as bytecode by CPython — every invocation re-parses the source. Packaging as a wheel pre-compiles all modules to .pyc and installs thin import stubs as the executables, matching the approach used by bin/show and statd. Restructure the modemd Python scripts into a proper Python package (src/modemd/modemd/): each script becomes a module with a main() entry point. pyproject.toml declares 11 entry points; the Buildroot MODEMD_BUILD_PYTHON hook builds a wheel via PEP 517, then pyinstaller.py installs compiled stubs to /usr/libexec/modemd/ and pre-compiled .pyc modules to site-packages. /sbin/modemd is now a symlink to the compiled stub. Signed-off-by: Joachim Wiberg --- package/modemd/modemd.mk | 35 ++++++++++++------- src/modemd/{modemd => modemd/__init__.py} | 9 +++-- .../{modem-carrier => modemd/carrier.py} | 8 +++-- src/modemd/{modem-info => modemd/info.py} | 8 +++-- src/modemd/{modem-power => modemd/power.py} | 8 +++-- src/modemd/{modem-rpc => modemd/rpc.py} | 8 +++-- .../scan_networks.py} | 9 +++-- src/modemd/{sim-setup => modemd/sim_setup.py} | 8 +++-- src/modemd/{modem-sms => modemd/sms.py} | 8 +++-- src/modemd/{modem-udev => modemd/udev.py} | 9 +++-- .../update_firmware.py} | 9 +++-- src/modemd/{modem-ussd => modemd/ussd.py} | 9 +++-- src/modemd/pyproject.toml | 28 +++++++++++++++ 13 files changed, 111 insertions(+), 45 deletions(-) rename src/modemd/{modemd => modemd/__init__.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-carrier => modemd/carrier.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-info => modemd/info.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-power => modemd/power.py} (98%) mode change 100755 => 100644 rename src/modemd/{modem-rpc => modemd/rpc.py} (97%) mode change 100755 => 100644 rename src/modemd/{modem-scan-networks => modemd/scan_networks.py} (97%) mode change 100755 => 100644 rename src/modemd/{sim-setup => modemd/sim_setup.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-sms => modemd/sms.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-udev => modemd/udev.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-update-firmware => modemd/update_firmware.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-ussd => modemd/ussd.py} (97%) mode change 100755 => 100644 create mode 100644 src/modemd/pyproject.toml diff --git a/package/modemd/modemd.mk b/package/modemd/modemd.mk index 8b84dfacd..afe36c13e 100644 --- a/package/modemd/modemd.mk +++ b/package/modemd/modemd.mk @@ -10,13 +10,34 @@ MODEMD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/modemd MODEMD_LICENSE = BSD-3-Clause MODEMD_LICENSE_FILES = LICENSE MODEMD_REDISTRIBUTE = NO -MODEMD_DEPENDENCIES = modem-manager jansson python3 +MODEMD_DEPENDENCIES = modem-manager jansson python3 \ + host-python3 host-python-pypa-build host-python-installer \ + host-python-poetry-core define MODEMD_BUILD_CMDS $(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \ $(MODEMD_DIR)/modem-command.c -o $(MODEMD_DIR)/modem-command -ljansson endef +define MODEMD_BUILD_PYTHON + cd $(MODEMD_SITE) && \ + $(PKG_PYTHON_PEP517_ENV) $(HOST_DIR)/bin/python3 $(PKG_PYTHON_PEP517_BUILD_CMD) -o $(@D)/dist + mkdir -p $(TARGET_DIR)/usr/libexec/modemd + rm -f $(TARGET_DIR)/usr/libexec/modemd/modemd \ + $(TARGET_DIR)/usr/libexec/modemd/modem-* \ + $(TARGET_DIR)/usr/libexec/modemd/sim-setup + cd $(@D) && \ + $(HOST_DIR)/bin/python3 $(TOPDIR)/support/scripts/pyinstaller.py \ + dist/*.whl \ + --interpreter=/usr/bin/python3 \ + --script-kind=posix \ + --purelib=$(TARGET_DIR)/usr/lib/python$(PYTHON3_VERSION_MAJOR)/site-packages \ + --headers=$(TARGET_DIR)/usr/include/python$(PYTHON3_VERSION_MAJOR) \ + --scripts=$(TARGET_DIR)/usr/libexec/modemd \ + --data=$(TARGET_DIR) +endef +MODEMD_POST_INSTALL_TARGET_HOOKS += MODEMD_BUILD_PYTHON + define MODEMD_INSTALL_TARGET_CMDS mkdir -p $(TARGET_DIR)/usr/libexec/modemd mkdir -p $(TARGET_DIR)/lib/udev/rules.d @@ -27,18 +48,8 @@ define MODEMD_INSTALL_TARGET_CMDS 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 -D -m 644 $(MODEMD_DIR)/modemd.modules-load $(TARGET_DIR)/etc/modules-load.d/modemd.conf - install -m 755 $(MODEMD_DIR)/modem-udev $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modemd $(TARGET_DIR)/sbin/modemd install -m 755 $(MODEMD_DIR)/modem-command $(TARGET_DIR)/sbin/modem-command - install -m 755 $(MODEMD_DIR)/modem-info $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-rpc $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-sms $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-scan-networks $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-update-firmware $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-ussd $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-carrier $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-power $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/sim-setup $(TARGET_DIR)/usr/libexec/modemd/ + ln -sf /usr/libexec/modemd/modemd $(TARGET_DIR)/sbin/modemd endef $(eval $(generic-package)) diff --git a/src/modemd/modemd b/src/modemd/modemd/__init__.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modemd rename to src/modemd/modemd/__init__.py index fee12bec1..b34094536 --- a/src/modemd/modemd +++ b/src/modemd/modemd/__init__.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import threading import subprocess import argparse @@ -1625,7 +1623,8 @@ def sighandler(signum, frame): fatal("Exiting on signal %d" % signum) -if __name__ == "__main__": +def main(): + global debug syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) if os.geteuid() != 0: @@ -1692,3 +1691,7 @@ def sighandler(signum, frame): [th.join() for th in threads] fatal("Abnormal exit") + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-carrier b/src/modemd/modemd/carrier.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-carrier rename to src/modemd/modemd/carrier.py index d5cc85072..d966978c9 --- a/src/modemd/modem-carrier +++ b/src/modemd/modemd/carrier.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import argparse import json @@ -272,7 +270,7 @@ def runlist(index, manf, model): sys.exit(0) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-carrier') parser.add_argument("-i", "--index", default=0, help="Modem index") parser.add_argument("-m", "--manf", default=0, help="Modem manufacturer") @@ -312,3 +310,7 @@ def runlist(index, manf, model): runget(index, manf, model) sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-info b/src/modemd/modemd/info.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-info rename to src/modemd/modemd/info.py index c9e7653ac..389c48efd --- a/src/modemd/modem-info +++ b/src/modemd/modemd/info.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import argparse import syslog @@ -473,7 +471,7 @@ def print_all(): print(data) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-info') parser.add_argument("-i", "--index", default=0, help="Modem index") args = parser.parse_args() @@ -487,3 +485,7 @@ def print_all(): print_modem(int(args.index)) else: print_all() + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-power b/src/modemd/modemd/power.py old mode 100755 new mode 100644 similarity index 98% rename from src/modemd/modem-power rename to src/modemd/modemd/power.py index 9bcb67a0e..3cc3e22e5 --- a/src/modemd/modem-power +++ b/src/modemd/modemd/power.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import argparse import json @@ -65,7 +63,7 @@ def reset(index, info): return True -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-power') parser.add_argument("-i", "--index", default=0, help="Modem index") args = parser.parse_args() @@ -84,3 +82,7 @@ def reset(index, info): fatal("Unable to power-cycle modem") sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-rpc b/src/modemd/modemd/rpc.py old mode 100755 new mode 100644 similarity index 97% rename from src/modemd/modem-rpc rename to src/modemd/modemd/rpc.py index ff9f86eb1..58707a9c1 --- a/src/modemd/modem-rpc +++ b/src/modemd/modemd/rpc.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import argparse import json import sys @@ -21,7 +19,7 @@ def sendrpc(index, rpc): os.unlink(path) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-rpc') parser.add_argument("-i", "--index", default=0, help="Modem index") parser.add_argument("-r", "--rpc", default=0, help="RPC command") @@ -48,3 +46,7 @@ def sendrpc(index, rpc): } sendrpc(index, rpc) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-scan-networks b/src/modemd/modemd/scan_networks.py old mode 100755 new mode 100644 similarity index 97% rename from src/modemd/modem-scan-networks rename to src/modemd/modemd/scan_networks.py index 288e7fa60..377e8826c --- a/src/modemd/modem-scan-networks +++ b/src/modemd/modemd/scan_networks.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import argparse import subprocess import json @@ -65,7 +63,8 @@ def scan(mpath): return True -if __name__ == "__main__": +def main(): + global index parser = argparse.ArgumentParser(prog='modem-scan-networks') parser.add_argument("-i", "--index", default=0, help="Modem index") args = parser.parse_args() @@ -77,3 +76,7 @@ def scan(mpath): print("Scanning networks on modem%d, please stand by ..." % index) if not scan(m["path"]): print("Operation failed.", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/sim-setup b/src/modemd/modemd/sim_setup.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/sim-setup rename to src/modemd/modemd/sim_setup.py index a19b4a51d..edf86e464 --- a/src/modemd/sim-setup +++ b/src/modemd/modemd/sim_setup.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import json import fcntl @@ -254,7 +252,7 @@ def read_setup(): return setup -if __name__ == "__main__": +def main(): setup = read_setup() if setup is None: sys.exit(0) @@ -263,3 +261,7 @@ def read_setup(): fatal("Unable set up SIMs") sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-sms b/src/modemd/modemd/sms.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-sms rename to src/modemd/modemd/sms.py index cf6c8b424..5735abada --- a/src/modemd/modem-sms +++ b/src/modemd/modemd/sms.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - from datetime import datetime import argparse import locale @@ -106,7 +104,7 @@ def listlocale(): return ", ".join(locales) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-sms') parser.add_argument("-d", action="store_true", help="Delete SMS") parser.add_argument("-s", action="store_true", help="Send SMS") @@ -140,3 +138,7 @@ def listlocale(): delete(index) else: show(index) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-udev b/src/modemd/modemd/udev.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-udev rename to src/modemd/modemd/udev.py index 64d6b1e23..aaf6d3c29 --- a/src/modemd/modem-udev +++ b/src/modemd/modemd/udev.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import shutil import errno import time @@ -283,7 +281,8 @@ def apply(): return True -if __name__ == "__main__": +def main(): + global debug with open("/proc/cmdline", 'r') as fd: cmdline = fd.read() if "debug" in cmdline: @@ -314,3 +313,7 @@ def apply(): dbg("ended") sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-update-firmware b/src/modemd/modemd/update_firmware.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-update-firmware rename to src/modemd/modemd/update_firmware.py index b8675108e..d7eb46c42 --- a/src/modemd/modem-update-firmware +++ b/src/modemd/modemd/update_firmware.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import argparse import urllib.parse @@ -347,7 +345,8 @@ def download(url, checksum): return True -if __name__ == "__main__": +def main(): + global debug, index syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) parser = argparse.ArgumentParser(prog='modem-firmware-update') @@ -387,3 +386,7 @@ def download(url, checksum): fatal("Firmware update failed") rmdir(tempdir) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-ussd b/src/modemd/modemd/ussd.py old mode 100755 new mode 100644 similarity index 97% rename from src/modemd/modem-ussd rename to src/modemd/modemd/ussd.py index e1b1e10ce..2940f96e8 --- a/src/modemd/modem-ussd +++ b/src/modemd/modemd/ussd.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import argparse import subprocess import sys @@ -49,7 +47,8 @@ def ussd(index, code): return True -if __name__ == "__main__": +def main(): + global verbose parser = argparse.ArgumentParser(prog='modem-ussd') parser.add_argument("-i", "--index", default=0, help="Modem index") parser.add_argument("-c", "--code", default=None, help="USSD codec") @@ -70,3 +69,7 @@ def ussd(index, code): sys.exit(0) sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/pyproject.toml b/src/modemd/pyproject.toml new file mode 100644 index 000000000..553b97578 --- /dev/null +++ b/src/modemd/pyproject.toml @@ -0,0 +1,28 @@ +[tool.poetry] +name = "modemd" +version = "1.0" +description = "Infix modem daemon and utilities" +license = "BSD-3-Clause" +packages = [ + { include = "modemd" } +] +authors = [ + "KernelKit developers" +] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +modemd = "modemd:main" +modem-info = "modemd.info:main" +modem-carrier = "modemd.carrier:main" +modem-rpc = "modemd.rpc:main" +modem-sms = "modemd.sms:main" +modem-scan-networks = "modemd.scan_networks:main" +modem-update-firmware = "modemd.update_firmware:main" +modem-ussd = "modemd.ussd:main" +modem-power = "modemd.power:main" +modem-udev = "modemd.udev:main" +sim-setup = "modemd.sim_setup:main" From a9a50337ef0966775eb6359dcd2b15f418eaebcd Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 10 May 2026 07:48:08 +0200 Subject: [PATCH 6/8] modemd: add SIGHUP handler for config reload On SIGHUP, modemd stops all ModemThreads, re-runs sim-setup, then restarts the modem threads from fresh sysrepo config. The pidfile is touched with os.utime() once the reload is complete, satisfying Finit's default pidfile-based notify so confd can signal a reload and wait for it to finish before proceeding. This allows operator and APN changes to take effect without a full daemon restart. Also extract start_modem_threads() to remove the duplicated thread startup loop shared between initial startup and reload, and align sighandler() to use isinstance(th, ModemThread) consistently with the rest of the thread-management code. Signed-off-by: Joachim Wiberg --- src/modemd/modemd/__init__.py | 65 +++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py index b34094536..35287fe4d 100644 --- a/src/modemd/modemd/__init__.py +++ b/src/modemd/modemd/__init__.py @@ -14,10 +14,12 @@ import sys import ipaddress -rundir = "/run/modemd" -smsdir = "/var/sms" -debug = False +rundir = "/run/modemd" +smsdir = "/var/sms" +pidfile = "/run/modemd.pid" +debug = False threads = [] +reload_event = threading.Event() syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) @@ -1605,12 +1607,11 @@ def sighandler(signum, frame): for th in threads: th.stop() - # wait for modem threads timeout = 0 while timeout < 3: exited = True for th in threads: - if th.name.startswith("modem") and not th.exited: + if isinstance(th, ModemThread) and not th.exited: exited = False if exited: break @@ -1623,6 +1624,22 @@ def sighandler(signum, frame): fatal("Exiting on signal %d" % signum) +def start_modem_threads(cfgs): + started = [] + for cfg in cfgs: + try: + th = ModemThread(cfg) + th.start() + started.append(th) + except Exception as e: + err("Failed to start modem%d thread: %s" % (cfg["index"], e)) + return started + + +def sighup_handler(signum, frame): + reload_event.set() + + def main(): global debug syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) @@ -1647,8 +1664,10 @@ def main(): mkdir(rundir) mkdir(smsdir) + fwrite(pidfile, str(os.getpid()) + "\n") signal.signal(signal.SIGINT, sighandler) signal.signal(signal.SIGTERM, sighandler) + signal.signal(signal.SIGHUP, sighup_handler) try: th = RpcThread() @@ -1673,24 +1692,32 @@ def main(): if not runcmd(['/usr/libexec/modemd/sim-setup']): fatal("Unable to setup SIMs") - for cfg in modems: - th = None - try: - th = ModemThread(cfg) - th.start() - except Exception as e: - if debug: - print(e) - fatal("Modem %d thread caught an exception" % cfg["index"]) - if th: - threads.append(th) - + threads.extend(start_modem_threads(modems)) if len(threads) == 0: fatal("No modem threads are running") - [th.join() for th in threads] + while True: + if not reload_event.wait(timeout=60): + continue + reload_event.clear() + + info("Reloading configuration") + modem_threads = [th for th in threads if isinstance(th, ModemThread)] + for th in modem_threads: + th.stop() + for th in modem_threads: + th.join(timeout=5) + if th.is_alive(): + err("modem%d thread did not stop, orphaning" % th.index) + threads[:] = [th for th in threads if th not in modem_threads] + + runcmd(['/usr/libexec/modemd/sim-setup']) + + new_modems = load_config() + threads.extend(start_modem_threads(new_modems)) - fatal("Abnormal exit") + os.utime(pidfile, None) + info("Reload complete") if __name__ == "__main__": From 5bd58942d6cc126d7f86b466a5f93c188262970e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 10 May 2026 11:06:48 +0200 Subject: [PATCH 7/8] modem: expose location and last-change in operational state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cellular modem location data (GPS coordinates, 3GPP cell info) was previously CLI-only — 'show modem modem0' shelled out to modem-info which queried mmcli on demand and rendered straight to terminal. NETCONF/RESTCONF clients had no view into modem location, and there was no way to tell how stale any modem-state field was. This commit moves both into the YANG operational tree so they are accessible through every northbound interface, not just the CLI. It is Phase 1 of the modem GPS / location integration plan; Phase 2 will route the modem's NMEA TTY into the existing gpsd → chronyd pipeline so cellular modems can also serve as an NTP fallback time source. YANG (infix-hardware.yang, revision 2026-05-10): - modem-state/last-change (yang:date-and-time): freshness indicator for the modem-state subtree, bumped on each modemd poll cycle - modem-state/location-state container: source (gps|3gpp|...), latitude/longitude/altitude (decimal64 6/6/1 fraction-digits), cell-id/lac/tac/mcc/mnc, and its own last-change Runtime path: - modemd's check_location() now writes /run/modemd/modemN/location/data.json with the full location data plus an RFC3339 last-change. Atomic via tmp+rename. Walks the mmcli output once instead of twice. - check() touches /run/modemd/modemN/state.json with a fresh last-change after each successful state poll. - yanger/infix_modem.py reads both files via HOST.read_json and folds them into the modem-state operational subtree. - cli_pretty show_modem_detail rewritten to consume the operational tree (same path as 'show modem' overview), with new 'Last Update' lines for both top-level state and location. - bin/show/modem() consolidated: both 'show modem' and 'show modem REF' use get_json('/ietf-hardware:hardware') — no more direct modem-info subprocess call. Cleanup along the way: - Removed the legacy /run/modemd/modemN/location/{up,down,disabled, failed} marker file writes — they had no readers anywhere in the tree. In-memory self.location['state'] is kept purely as the edge-trigger for the 'Retrieved GPS location' log line. - Aligned now_rfc3339() output with yanger.common.YangDate (+00:00 form, not Z) so consumers see one consistent yang:date-and-time format regardless of producer. - Fixed multi-modem SIM scoping in show_modem_detail: now derives sim from modem instead of picking the first SIM globally. The bearer / hardware-revision / phone-number / supported-carrier sections of 'show modem REF' are deliberately omitted for now — those fields are not yet in the YANG tree. Bearer details in particular likely belong on the wwan interface (ietf-interfaces:interfaces-state) rather than on the modem hardware component, and can be addressed in a follow-up. Signed-off-by: Joachim Wiberg --- src/bin/show/__init__.py | 29 ++-- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-hardware.yang | 76 ++++++++++ .../yang/confd/infix-hardware@2026-05-10.yang | 1 + src/modemd/modemd/__init__.py | 86 +++++++++-- src/statd/python/cli_pretty/cli_pretty.py | 138 +++++++----------- src/statd/python/yanger/infix_modem.py | 31 ++++ 7 files changed, 243 insertions(+), 120 deletions(-) create mode 120000 src/confd/yang/confd/infix-hardware@2026-05-10.yang diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index 60efca2b1..598d6bfc5 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -78,27 +78,18 @@ def hardware(args: List[str]) -> None: def modem(args: List[str]) -> None: ref = args[0] if args else None + data = get_json("/ietf-hardware:hardware") + if not data: + print("No modem data available.") + return + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + if ref: - try: - result = subprocess.run(["/usr/libexec/modemd/modem-info"], - capture_output=True, text=True, check=True) - data = json.loads(result.stdout) if result.stdout.strip() else [] - except (subprocess.CalledProcessError, json.JSONDecodeError): - data = [] - - if RAW_OUTPUT: - print(json.dumps(data, indent=2)) - return - cli_pretty({"modem-list": data}, "show-modem-detail", ref) + cli_pretty(data, "show-modem-detail", ref) else: - data = get_json("/ietf-hardware:hardware") - if not data: - print("No modem data available.") - return - - if RAW_OUTPUT: - print(json.dumps(data, indent=2)) - return cli_pretty(data, "show-modem") diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index af752b54b..6c280e56b 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -27,7 +27,7 @@ MODULES=( "infix-syslog@2025-11-17.yang" "iana-hardware@2018-03-13.yang" "ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor" - "infix-hardware@2026-04-27.yang" + "infix-hardware@2026-05-10.yang" "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2026-04-28.yang" "infix-if-type@2026-04-27.yang" diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 752551802..482b213e1 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -23,6 +23,12 @@ module infix-hardware { contact "kernelkit@googlegroups.com"; description "Vital Product Data augmentation of ieee-hardware and deviations."; + revision 2026-05-10 { + description "Add modem-state/last-change and modem-state/location-state operational + nodes so cellular modem location and state freshness are accessible over + NETCONF/RESTCONF, not only via the CLI."; + reference "internal"; + } revision 2026-05-08 { description "Move modem actions (restart/reset/send-sms) from container modem to a direct augment on component with when/if-feature guard, fixing sysrepo @@ -963,6 +969,76 @@ module infix-hardware { description "Access technology in use (e.g. LTE, 5G NR)."; } } + + container location-state { + description + "Location data observed by the modem. Coordinates come from the + modem's GPS subsystem (or A-GPS); cell-id/lac/tac/mcc/mnc come + from 3GPP signalling. Source indicates which mechanism produced + the current fix. Empty when no source is enabled or no fix is + available."; + + leaf source { + type modem-location-source; + description "Source that produced the current fix."; + } + + leaf latitude { + type decimal64 { fraction-digits 6; } + units "degrees"; + description "Latitude in decimal degrees, negative = south."; + } + + leaf longitude { + type decimal64 { fraction-digits 6; } + units "degrees"; + description "Longitude in decimal degrees, negative = west."; + } + + leaf altitude { + type decimal64 { fraction-digits 1; } + units "meters"; + description "Altitude above mean sea level."; + } + + leaf cell-id { + type uint32; + description "Serving 3GPP cell identifier."; + } + + leaf lac { + type uint16; + description "Location Area Code (2G/3G)."; + } + + leaf tac { + type uint16; + description "Tracking Area Code (LTE/5G)."; + } + + leaf mcc { + type string { pattern '[0-9]{3}'; } + description "Mobile Country Code."; + } + + leaf mnc { + type string { pattern '[0-9]{2,3}'; } + description "Mobile Network Code."; + } + + leaf last-change { + type yang:date-and-time; + description "Timestamp when location-state was last refreshed."; + } + } + + leaf last-change { + type yang:date-and-time; + description + "Timestamp of the most recent change to any leaf in modem-state. + Consumers can use this to gauge data freshness independent of + individual leaf timestamps."; + } } /* diff --git a/src/confd/yang/confd/infix-hardware@2026-05-10.yang b/src/confd/yang/confd/infix-hardware@2026-05-10.yang new file mode 120000 index 000000000..154924a01 --- /dev/null +++ b/src/confd/yang/confd/infix-hardware@2026-05-10.yang @@ -0,0 +1 @@ +infix-hardware.yang \ No newline at end of file diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py index 35287fe4d..1674f27da 100644 --- a/src/modemd/modemd/__init__.py +++ b/src/modemd/modemd/__init__.py @@ -1,6 +1,7 @@ import threading import subprocess import argparse +import datetime import hashlib import signal import syslog @@ -125,6 +126,32 @@ def fwritej(path, cont): return False +def now_rfc3339(): + """Return current UTC time as a yang:date-and-time string. + + Format matches yanger.common.YangDate (+00:00 offset, not Z) so that + consumers see identical timestamp formatting regardless of producer. + """ + return datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds") + + +def fwritej_atomic(path, cont): + """Write JSON to path via tmp+rename so readers never see a partial file.""" + tmp = path + ".tmp" + try: + with open(tmp, "w", opener=opener) as fd: + fd.write(json.dumps(cont)) + os.rename(tmp, path) + return True + except OSError: + try: + os.remove(tmp) + except OSError: + pass + return False + + def runcmdp(cmd1, cmd2, check=True): dbg("Running: %s | %s" % (" ".join(cmd1), " ".join(cmd2))) ret = None @@ -1212,7 +1239,6 @@ def prepare_location(self): rmrf(self.locdir) if not self.location["enabled"]: - fwrite("%s/disabled" % self.locdir, "1") return True for source in ["gps", "agps-msa", "agps-msb", "3gpp", "cdma"]: @@ -1241,7 +1267,6 @@ def prepare_location(self): if not runcmd(["mmcli", "-m", self.path] + args): self.err("Unable to %s location source" % action) if self.location["enabled"]: - fwrite("%s/failed" % self.locdir, "1") self.location["state"] = "failed" return False return True @@ -1416,22 +1441,49 @@ def check_location(self): output = runcmdj(["mmcli", "-J", "-m", self.path, "--location-get"]) - if output: - lat = output["modem"]["location"]["gps"]["latitude"] - lon = output["modem"]["location"]["gps"]["longitude"] - else: - lat = "--" - lon = "--" + loc = (output or {}).get("modem", {}).get("location", {}) + gps = loc.get("gps", {}) + gpp = loc.get("3gpp", {}) + + def _pick(d, key): + v = d.get(key) + return v if v not in (None, "--", "") else None + + data = {"last-change": now_rfc3339()} + + lat = _pick(gps, "latitude") + lon = _pick(gps, "longitude") + alt = _pick(gps, "altitude") + if lat is not None and lon is not None: + data["source"] = "gps" + data["latitude"] = float(lat) + data["longitude"] = float(lon) + if alt is not None: + data["altitude"] = float(alt) + + for src_key, dst_key in (("cid", "cell-id"), ("lac", "lac"), + ("tac", "tac"), ("mcc", "mcc"), + ("mnc", "mnc")): + v = _pick(gpp, src_key) + if v is None: + continue + if dst_key in ("cell-id", "lac", "tac"): + try: + data[dst_key] = int(v, 0) if isinstance(v, str) else int(v) + except (TypeError, ValueError): + continue + else: + data[dst_key] = str(v) + data.setdefault("source", "3gpp") - if lat != "--" and lon != "--": + fwritej_atomic("%s/data.json" % self.locdir, data) + + if "latitude" in data: if self.location["state"] != "up": - self.info("Retrieved GPS location [%s, %s]" % (lat, lon)) - rmf("%s/down" % self.locdir) - fwrite("%s/up" % self.locdir, "1") + self.info("Retrieved GPS location [%s, %s]" + % (data["latitude"], data["longitude"])) self.location["state"] = "up" else: - rmf("%s/up" % self.locdir) - fwrite("%s/down" % self.locdir, "1") self.location["state"] = "down" def save_sms(self, sms): @@ -1480,6 +1532,11 @@ def check_sms(self): elif sms["type"] == "received": self.save_sms(sms) + def touch_state(self): + """Refresh state.json with the current poll time.""" + fwritej_atomic("%s/state.json" % self.rundir, + {"last-change": now_rfc3339()}) + def check(self): timeout = 10 if self.state == State.UP else 3 elapsed = time.time() - self.timer1 @@ -1488,6 +1545,7 @@ def check(self): if self.update(): self.check_state() self.timer1 = time.time() + self.touch_state() elapsed = time.time() - self.timer2 if elapsed > 30: diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 852f2935b..e0b73d32b 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2185,72 +2185,61 @@ def show_modem(json): sim_table.print() -def _modem_signal_str(status): +def _modem_signal_str(ms): parts = [] - sq = status.get("signal-quality") + sq = ms.get("signal-quality") if sq is not None: parts.append(f"Quality: {sq}%") for key, label in (("signal-rssi", "RSSI"), ("signal-rsrp", "RSRP"), ("signal-rsrq", "RSRQ"), ("signal-sinr", "SINR")): - if key in status: - parts.append(f"{label}: {status[key]}") + if key in ms: + parts.append(f"{label}: {ms[key]}") return " ".join(parts) def show_modem_detail(json, ref): - modems = json.get("modem-list", []) - try: - idx = int(ref.removeprefix("modem")) - except ValueError: - idx = -1 - - modem = next((m for m in modems if m.get("index") == idx), None) + components = get_json_data([], json, "ietf-hardware:hardware", "component") + modem = next((c for c in components + if c.get("class") == "infix-hardware:modem" + and c.get("name") == ref), None) if modem is None: print(f"Modem '{ref}' not found.") return - info = modem.get("info", {}) - status = modem.get("status", {}) - cell = status.get("cellular", {}) - sim_state = modem.get("sim-state", {}) - bearers = status.get("bearer", []) - location = status.get("location", {}) + ms = modem.get("infix-hardware:modem-state", {}) + cell = ms.get("cellular", {}) + location = ms.get("location-state", {}) + + sim_name = ref.replace("modem", "sim", 1) + sim = next((c for c in components + if c.get("class") == "infix-hardware:sim" + and c.get("name") == sim_name), None) + sim_state = sim.get("infix-hardware:sim-state", {}) if sim else {} width = 62 print(Decore.invert(f"{'MODEM: ' + ref:<{width}}")) Decore.title("Hardware Information", width) for label, val in ( - ("Manufacturer", info.get("manufacturer", "")), - ("Model", info.get("model", "")), - ("Hardware Revision", info.get("hardware-revision", "")), - ("Firmware Version", info.get("firmware-version", "")), - ("Serial Number", info.get("serial-number", "")), - ("IMSI", info.get("imsi", "")), - ("ICCID", info.get("iccid", "")), + ("Manufacturer", ms.get("manufacturer", "")), + ("Model", ms.get("model", "")), + ("Firmware Version", ms.get("firmware-version", "")), + ("Serial Number", ms.get("serial-number", "")), + ("IMSI", ms.get("imsi", "")), + ("ICCID", ms.get("iccid", "")), ): if val: print(f" {label:<20}: {val}") - phone = info.get("phone-number", []) - if phone: - print(f" {'Phone Number':<20}: {', '.join(phone)}") - carriers = info.get("supported-carrier", []) - if carriers: - print(f" {'Supported Carriers':<20}: {', '.join(carriers)}") - selected = status.get("selected-carrier", "") - if selected: - print(f" {'Selected Carrier':<20}: {selected}") Decore.title("Status", width) - state = status.get("state", "unknown") + state = ms.get("state", "unknown") print(f" {'State':<20}: {state}") - if state == "failed": - reason = status.get("state-failed-reason", "") - if reason: - print(f" {'Failed Reason':<20}: {reason}") - sig_str = _modem_signal_str(status) + sig_str = _modem_signal_str(ms) if sig_str: print(f" {'Signal':<20}: {sig_str}") + last_change = ms.get("last-change") + if last_change: + print(f" {'Last Update':<20}: {last_change}") if cell: Decore.title("Cellular", width) @@ -2259,76 +2248,53 @@ def show_modem_detail(json, ref): ("Operator", "operator-name"), ("Operator ID", "operator-id"), ("Network Type", "network-type"), - ("Service State", "service-state"), ): val = cell.get(key, "") if val: print(f" {label:<20}: {val}") - if sim_state: + if sim: Decore.title("SIM Card", width) + print(f" {'Name':<20}: {sim.get('name', '')}") for label, key in ( - ("Name", "name"), ("Slot", "slot"), ("Lock State", "state"), ("Operator", "operator-name"), ): val = sim_state.get(key) - if val: + if val is not None and val != "": print(f" {label:<20}: {val}") - if bearers: - Decore.title("Bearers", width) - for b in bearers: - iface = b.get("interface", "") - hdr = iface if iface else b.get("path", "") - connected = b.get("connected", False) - conn_str = "connected" if connected else "disconnected" - print(f" {hdr} ({conn_str})") - if not connected: - reason = b.get("connection-failed-reason", "") - if reason: - print(f" {'Error':<18}: {reason}") - v4 = b.get("ipv4-address") - v4pfx = b.get("ipv4-prefix", 0) - if v4 and v4pfx: - print(f" {'IPv4':<18}: {v4}/{v4pfx}") - v6 = b.get("ipv6-address") - v6pfx = b.get("ipv6-prefix", 0) - if v6 and v6pfx: - print(f" {'IPv6':<18}: {v6}/{v6pfx}") - rx = b.get("in-bytes", 0) - tx = b.get("out-bytes", 0) - if rx or tx: - print(f" {'Traffic':<18}: RX {rx} B TX {tx} B") - total_rx = b.get("total-in-bytes", 0) - total_tx = b.get("total-out-bytes", 0) - duration = b.get("total-duration", 0) - if total_rx or total_tx: - print(f" {'Total Traffic':<18}: RX {total_rx} B TX {total_tx} B ({duration} s)") - if location: - lat = location.get("latitude", "") - lon = location.get("longitude", "") - alt = location.get("altitude", "") - cid = location.get("cid", "") - lac = location.get("lac", "") - mcc = location.get("mcc", "") - mnc = location.get("mnc", "") - if lat or cid: - Decore.title("Location", width) - if lat and lon: + Decore.title("Location", width) + src = location.get("source") + if src: + print(f" {'Source':<20}: {src}") + lat = location.get("latitude") + lon = location.get("longitude") + alt = location.get("altitude") + if lat is not None and lon is not None: pos = f"{lat}, {lon}" - if alt: + if alt is not None: pos += f" Alt: {alt} m" print(f" {'GPS':<20}: {pos}") - if cid: + cid = location.get("cell-id") + if cid is not None: cell_id = f"CID {cid}" - if lac: + lac = location.get("lac") + tac = location.get("tac") + if lac is not None: cell_id += f" LAC {lac}" + if tac is not None: + cell_id += f" TAC {tac}" + mcc = location.get("mcc") + mnc = location.get("mnc") if mcc and mnc: cell_id += f" MCC {mcc} MNC {mnc}" print(f" {'Cell ID':<20}: {cell_id}") + loc_change = location.get("last-change") + if loc_change: + print(f" {'Last Update':<20}: {loc_change}") def show_hardware(json): diff --git a/src/statd/python/yanger/infix_modem.py b/src/statd/python/yanger/infix_modem.py index be5f5a85f..4067544a0 100644 --- a/src/statd/python/yanger/infix_modem.py +++ b/src/statd/python/yanger/infix_modem.py @@ -2,6 +2,28 @@ from .host import HOST +RUNDIR = "/run/modemd" + + +def _location_state(idx): + data = HOST.read_json("%s/modem%d/location/data.json" % (RUNDIR, idx), + default={}) + if not data: + return None + + state = {} + for key in ("source", "latitude", "longitude", "altitude", + "cell-id", "lac", "tac", "mcc", "mnc", "last-change"): + if key in data and data[key] is not None: + state[key] = data[key] + return state or None + + +def _state_last_change(idx): + data = HOST.read_json("%s/modem%d/state.json" % (RUNDIR, idx), default={}) + return data.get("last-change") if data else None + + def _modem_to_hw_state(modem): info = modem.get("info", {}) status = modem.get("status", {}) @@ -64,6 +86,15 @@ def operational(): idx = modem.get("index", 0) name = "modem%d" % idx hw_state = _modem_to_hw_state(modem) + + location_state = _location_state(idx) + if location_state: + hw_state["location-state"] = location_state + + last_change = _state_last_change(idx) + if last_change: + hw_state["last-change"] = last_change + if hw_state: hw_components.append({ "name": name, From 54fdddd096e662997ec1d32285916873993c3800 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 10 May 2026 11:32:13 +0200 Subject: [PATCH 8/8] 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 ed8889508..f198b8889 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 afe36c13e..5cde00e70 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 000000000..995e3b126 --- /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 1674f27da..55a5c9f4b 100644 --- a/src/modemd/modemd/__init__.py +++ b/src/modemd/modemd/__init__.py @@ -22,6 +22,27 @@ 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 @@ def err(self, msg): 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 @@ -623,12 +650,10 @@ def reset(self): 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: @@ -662,15 +687,14 @@ def get_bearers(self): 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"]: @@ -690,7 +714,7 @@ def get_conn_failure(self, path): 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) @@ -883,14 +907,14 @@ def linkup(self, iface): 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: @@ -903,14 +927,13 @@ def enable(self): 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") @@ -918,20 +941,20 @@ def unlock(self): 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: @@ -1087,9 +1110,8 @@ def prepare_profile_bearers(self, bearers): 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 @@ -1103,20 +1125,17 @@ def prepare_profile_bearers(self, bearers): 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 @@ -1130,8 +1149,7 @@ def prepare_multiplex_bearers(self, bearers): 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"]) @@ -1139,9 +1157,7 @@ def prepare_multiplex_bearers(self, bearers): 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 @@ -1155,8 +1171,7 @@ def prepare_bearers(self): # 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 @@ -1164,9 +1179,8 @@ def prepare_bearers(self): 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 @@ -1189,8 +1203,7 @@ def prepare_bands(self): 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 @@ -1207,7 +1220,7 @@ def prepare_modes(self): 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) @@ -1227,12 +1240,47 @@ def prepare_modes(self): 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") @@ -1241,34 +1289,53 @@ def prepare_location(self): 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" - - 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 + action = "enable" if enabled else "disable" + for flag in flags: + args.append("--location-%s-%s" % (action, flag)) - 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): @@ -1296,10 +1363,7 @@ def prepare(self): 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)) @@ -1346,8 +1410,8 @@ def pulldown(self): 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) @@ -1439,8 +1503,7 @@ def check_location(self): 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", {}) @@ -1506,8 +1569,7 @@ def save_sms(self, sms): 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) @@ -1522,12 +1584,11 @@ def check_sms(self): 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)