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 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/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/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/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/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/Config.in b/package/feature-modem/Config.in new file mode 100644 index 000000000..f198b8889 --- /dev/null +++ b/package/feature-modem/Config.in @@ -0,0 +1,25 @@ +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 + 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. + + 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 + 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..52c9a66ec --- /dev/null +++ b/package/feature-modem/feature-modem.mk @@ -0,0 +1,26 @@ +################################################################################ +# +# 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) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_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..5cde00e70 --- /dev/null +++ b/package/modemd/modemd.mk @@ -0,0 +1,56 @@ +################################################################################ +# +# 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 \ + 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 + 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 -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 +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/bin/show/__init__.py b/src/bin/show/__init__.py index 266e642b8..598d6bfc5 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -75,6 +75,24 @@ def hardware(args: List[str]) -> None: cli_pretty(data, "show-hardware") +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: + cli_pretty(data, "show-modem-detail", ref) + else: + 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) @@ -552,8 +570,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 +583,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 @@ -739,6 +756,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 447117994..1d28c12e2 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -46,6 +46,8 @@ confd_plugin_la_SOURCES = \ if-vxlan.c \ if-wifi.c \ if-wireguard.c \ + if-modem.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/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 a255c20ea..b8b1a6585 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_add_iface(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_del_iface(dif, net); + break; case IFT_WIFI: wifi_del_iface(dif, net); break; @@ -691,8 +701,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 +733,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..47351c166 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); +/* 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); 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..e86357fbb --- /dev/null +++ b/src/confd/src/modem.c @@ -0,0 +1,206 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include + +#include +#include +#include + +#include "core.h" + +#define RUN_DIR "/run/modemd" +#define SOCK RUN_DIR "/modemd.sock" + +#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 component_index(const char *xpath) +{ + regmatch_t pmatch[2]; + regex_t regex; + char name[32]; + int index = -1; + int len; + + if (regcomp(®ex, "name='([^']*)'", REG_EXTENDED)) + return -1; + + 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); + + return index; +} + +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 *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]; + int index; + + 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\" : %d, \"number\" : \"%s\", \"text\" : \"%s\" }", + index, + input[0].data.string_val, + input[1].data.string_val); + + 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[64]; + int index; + + 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), "{ \"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[64]; + int index; + + 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), "{ \"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) +{ + int index; + + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return; + } + if (values_cnt < 1) { + ERROR("No values in status-update notification"); + return; + } + + 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; + + 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; +} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 6a6505b0f..6c280e56b 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-05-10.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" diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 6da99e9b9..482b213e1 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,31 @@ 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 + 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 +80,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 +129,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 +342,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 +779,352 @@ 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)."; + } + } + + 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."; + } + } + + /* + * 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 +1247,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-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/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/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/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/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/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-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/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/modemd/__init__.py b/src/modemd/modemd/__init__.py new file mode 100644 index 000000000..55a5c9f4b --- /dev/null +++ b/src/modemd/modemd/__init__.py @@ -0,0 +1,1843 @@ +import threading +import subprocess +import argparse +import datetime +import hashlib +import signal +import syslog +import socket +import shutil +import select +import json +import enum +import time +import os +import sys +import ipaddress + +rundir = "/run/modemd" +smsdir = "/var/sms" +pidfile = "/run/modemd.pid" +debug = False +threads = [] +reload_event = threading.Event() + +# Vendor-specific AT commands to enable / disable raw NMEA output on the +# modem's dedicated GPS port. Used when the udev rules in +# 77-mm-modem-gps.rules have set ID_MM_PORT_IGNORE on that port — gpsd +# then reads NMEA directly from /dev/gpsN. For unrecognised vendors, +# prepare_location() falls back to ModemManager's high-level options +# (which keeps MM in charge of the NMEA port). Keys match the +# manufacturer string ModemManager reports verbatim. +GPS_AT_COMMANDS = { + "Quectel": ("AT+QGPS=1", "AT+QGPS=0"), + "Sierra Wireless": ("AT+CGPS=1", "AT+CGPS=0"), +} + +# ModemManager --location-{enable,disable}-* flag suffixes per source. +# 'gps' is handled separately because known vendors take the raw AT path. +LOCATION_MM_FLAGS = { + "agps-msa": ("agps-msa",), + "agps-msb": ("agps-msb",), + "3gpp": ("3gpp",), + "cdma": ("cdma-bs",), +} + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + +class Trigger(enum.Enum): + 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): + try: + os.unlink(path) + except FileNotFoundError: + pass + + +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): + try: + with open(path, "r") as fd: + output = fd.read() + return output.strip() if output else None + except OSError: + 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 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 + 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 + + +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 + + 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): + 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) + 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 _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 + 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 + + 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 + + self.bearers["list"].append(bearer) + + # 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 + sources = loc.get("source", []) + + 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 self._mmcli("--reset"): + self.info("Performed a normal reset") + wait = 5 + elif self._mmcli("--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 = { + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % self.index, + "infix-hardware:modem": { + "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 = self._mmclij("-b", path) + if output and "bearer" in output: + bearers.append(output["bearer"]) + return bearers + + def get_profiles(self): + profiles = [] + output = self._mmclij("--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 = self._mmclij() + 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): + 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 _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"])) + + conf = "/etc/net.d/%s.conf" % iface["name"] + + if action != "add": + rmf(conf) + return True + + if iface["ipv4"]["gateway"] != "--": + gw = iface["ipv4"]["gateway"] + elif 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 + + 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): + 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") + + # 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") + + 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 + + # 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", "replace", addr, + "dev", iface["name"], "proto", "wwan"]): + self.err("Unable to set address for %s" % iface["name"]) + return False + + 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 self._mmcli("--set-power-state-on"): + self.err("Unable to power on") + return False + return True + + def disable(self): + self.info("Disabling") + if not self._mmcli("--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 self._mmcli("--enable"): + self.err("Unable to enable") + return False + return True + + def unlock(self): + args = ["--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 + 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 + args.append("--puk=%s" % puk) + else: + self.err("Unsupported lock") + return False + + self.disable() + if not self._mmcli(*args): + 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") + 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("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 self._mmcli("--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 self._mmcli("--3gpp-profile-manager-set="): + self.err("Unable to create profile") + return False + + args = self.get_profile_args(bearer) + 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 self._mmcli("--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 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"]) + self.bearers["active"].append(bearer) + return True + + def prepare_default_bearer(self, bearer): + if not self._mmcli("--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 self._mmcli("--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 self._mmcli("--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") + + bands = self.cfg.get("band", []) + if len(bands) == 0 or "any" in bands: + return True + + if not self._mmcli("--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 = self.cfg.get("allowed-mode", []) + if "any" in allow: + allow = [] + + if pref == "none" and len(allow) == 0: + self._mmcli("--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) + + args = [] + if len(allow) > 0: + args.append("--set-allowed-modes=%s" % "|".join(allow)) + if pref != "none": + 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") + + rmrf(self.locdir) + if not self.location["enabled"]: + return True + + 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) + else: + self.dbg("Disabling location source '%s'" % source) + action = "enable" if enabled else "disable" + for flag in flags: + args.append("--location-%s-%s" % (action, flag)) + + 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): + 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 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)) + 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"]) + self._mmcli("--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 = self._mmclij("--location-get") + 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") + + fwritej_atomic("%s/data.json" % self.locdir, data) + + if "latitude" in data: + if self.location["state"] != "up": + self.info("Retrieved GPS location [%s, %s]" + % (data["latitude"], data["longitude"])) + self.location["state"] = "up" + else: + 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 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) + + 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) + self._mmcli("-s", sms["path"], "--send") + + elif sms["type"] == "sent": + self.notif("SMS-SENT") + self._mmcli("--messaging-delete-sms=%s" % sms["path"]) + + 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 + if elapsed > timeout: + self.check_trigger() + if self.update(): + self.check_state() + self.timer1 = time.time() + self.touch_state() + + 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() + + timeout = 0 + while timeout < 3: + exited = True + for th in threads: + if isinstance(th, ModemThread) 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) + + +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) + + 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) + 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() + 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") + + 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") + + threads.extend(start_modem_threads(modems)) + if len(threads) == 0: + fatal("No modem threads are running") + + 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)) + + os.utime(pidfile, None) + info("Reload complete") + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/carrier.py b/src/modemd/modemd/carrier.py new file mode 100644 index 000000000..d966978c9 --- /dev/null +++ b/src/modemd/modemd/carrier.py @@ -0,0 +1,316 @@ +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) + + +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") + 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) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/info.py b/src/modemd/modemd/info.py new file mode 100644 index 000000000..389c48efd --- /dev/null +++ b/src/modemd/modemd/info.py @@ -0,0 +1,491 @@ +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 _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": + 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): + # 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 + + # 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"} + + 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): + # 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) + + +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"] + 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']) + 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: + 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: + 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) + + +def 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() + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/power.py b/src/modemd/modemd/power.py new file mode 100644 index 000000000..3cc3e22e5 --- /dev/null +++ b/src/modemd/modemd/power.py @@ -0,0 +1,88 @@ +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 + + +def 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) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/rpc.py b/src/modemd/modemd/rpc.py new file mode 100644 index 000000000..58707a9c1 --- /dev/null +++ b/src/modemd/modemd/rpc.py @@ -0,0 +1,52 @@ +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) + + +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") + 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 = { + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % index, + "infix-hardware:modem": {args.rpc: {}} + }] + } + } + + sendrpc(index, rpc) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/scan_networks.py b/src/modemd/modemd/scan_networks.py new file mode 100644 index 000000000..377e8826c --- /dev/null +++ b/src/modemd/modemd/scan_networks.py @@ -0,0 +1,82 @@ +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 + + +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() + 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) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/sim_setup.py b/src/modemd/modemd/sim_setup.py new file mode 100644 index 000000000..edf86e464 --- /dev/null +++ b/src/modemd/modemd/sim_setup.py @@ -0,0 +1,267 @@ +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 + + 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 + + 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 + + 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"]: + 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 + + +def 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) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/sms.py b/src/modemd/modemd/sms.py new file mode 100644 index 000000000..5735abada --- /dev/null +++ b/src/modemd/modemd/sms.py @@ -0,0 +1,144 @@ +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 = { + "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) + + 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) + + +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") + + 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) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/udev.py b/src/modemd/modemd/udev.py new file mode 100644 index 000000000..aaf6d3c29 --- /dev/null +++ b/src/modemd/modemd/udev.py @@ -0,0 +1,319 @@ +import shutil +import errno +import time +import json +import fcntl +import sys +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 + + +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 _sysfs_attr(devpath, name): + try: + with open("%s/%s" % (devpath, name), "r") as fd: + return fd.readline().strip() + except OSError: + return None + + +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: + 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): + 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 = _sysfs_attr(devpath, "idVendor") + if vendor: + modem["vendor"] = vendor + + product = _sysfs_attr(devpath, "idProduct") + 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) + update_system_json(data["modems"]) + + 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 + + +def main(): + global debug + 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) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/update_firmware.py b/src/modemd/modemd/update_firmware.py new file mode 100644 index 000000000..d7eb46c42 --- /dev/null +++ b/src/modemd/modemd/update_firmware.py @@ -0,0 +1,392 @@ +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 + + +def main(): + global debug, index + 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) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/ussd.py b/src/modemd/modemd/ussd.py new file mode 100644 index 000000000..2940f96e8 --- /dev/null +++ b/src/modemd/modemd/ussd.py @@ -0,0 +1,75 @@ +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 + + +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") + 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) + + +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" 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/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index bf77df3db..e0b73d32b 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,181 @@ 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(ms): + parts = [] + 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 ms: + parts.append(f"{label}: {ms[key]}") + return " ".join(parts) + + +def show_modem_detail(json, ref): + 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 + + 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", 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}") + + Decore.title("Status", width) + state = ms.get("state", "unknown") + print(f" {'State':<20}: {state}") + 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) + for label, key in ( + ("Registration", "registration-state"), + ("Operator", "operator-name"), + ("Operator ID", "operator-id"), + ("Network Type", "network-type"), + ): + val = cell.get(key, "") + if val: + print(f" {label:<20}: {val}") + + if sim: + Decore.title("SIM Card", width) + print(f" {'Name':<20}: {sim.get('name', '')}") + for label, key in ( + ("Slot", "slot"), + ("Lock State", "state"), + ("Operator", "operator-name"), + ): + val = sim_state.get(key) + if val is not None and val != "": + print(f" {label:<20}: {val}") + + if location: + 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 is not None: + pos += f" Alt: {alt} m" + print(f" {'GPS':<20}: {pos}") + cid = location.get("cell-id") + if cid is not None: + cell_id = f"CID {cid}" + 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): if not json.get("ietf-hardware:hardware"): print("Error, top level \"ietf-hardware:component\" missing") @@ -2133,8 +2309,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 +2484,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 +2527,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 +2535,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 +6096,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 +6176,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/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/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/python/yanger/infix_modem.py b/src/statd/python/yanger/infix_modem.py new file mode 100644 index 000000000..4067544a0 --- /dev/null +++ b/src/statd/python/yanger/infix_modem.py @@ -0,0 +1,123 @@ +from .common import LOG +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", {}) + 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) + + 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, + "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 { + "ietf-hardware:hardware": { + "component": hw_components + } + } 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); diff --git a/test/case/repo/defconfig.sh b/test/case/repo/defconfig.sh index b3acbea16..2699f9cd1 100755 --- a/test/case/repo/defconfig.sh +++ b/test/case/repo/defconfig.sh @@ -31,12 +31,6 @@ check() echo "1..$total" for defconfig in "$@"; do - # Skip UNIX backup files - case "$defconfig" in - *~|*.bak|'#'*'#'|.#*) - continue - ;; - esac base=$(basename "$defconfig") if whitelist "$base"; then echo "ok $num - $base is exempted # skip" @@ -50,6 +44,9 @@ check() } # shellcheck disable=SC2046 -check $(find "$CONFIGS" -maxdepth 1 -type f | LC_ALL=C sort) || exit 1 +check $(find "$CONFIGS" -maxdepth 1 -type f \ + ! -name '*~' ! -name '*.bak' \ + ! -name '#*#' ! -name '.#*' \ + | LC_ALL=C sort) || exit 1 exit 0 diff --git a/test/case/statd/system/ietf-system.json b/test/case/statd/system/ietf-system.json index a8dde70bf..edd8c11ed 100644 --- a/test/case/statd/system/ietf-system.json +++ b/test/case/statd/system/ietf-system.json @@ -110,15 +110,13 @@ } }, "infix-system:dns-resolver": { - "options": {}, "server": [ { "address": "192.168.2.1", "origin": "dhcp", "interface": "e7" } - ], - "search": [] + ] }, "clock": { "boot-datetime": "2026-01-02T20:42:11+00:00",