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
+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",