From aea5247156a1c27437896d2ccf97a483e4b08cf6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Oct 2026 10:35:45 +0200 Subject: [PATCH 01/37] confd: unlock USB ports before setting up interfaces Since be8fe3566 the hardware handler runs after the interfaces, so USB ports are unlocked only after the interfaces have been set up. A netdev behind a USB port, e.g. a WiFi radio or a modem, is then missing when its interface is set up. Run the hardware handler before the interfaces again, and move only the hostapd handling, which binds to the WiFi netdevs, to after them. Signed-off-by: Joachim Wiberg --- src/confd/src/core.c | 12 ++++-- src/confd/src/core.h | 1 + src/confd/src/hardware.c | 84 +++++++++++++++++++++++++--------------- 3 files changed, 62 insertions(+), 35 deletions(-) diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 2aa0b3b75..ee694aabe 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -734,14 +734,18 @@ static int change_cb(sr_session_ctx_t *session, uint32_t sub_id, const char *mod } } + /* ietf-hardware, before the interfaces: netdevs behind a USB port + * appear only once the port is unlocked. */ + if ((rc = hardware_change(session, config, diff, event, confd))) + goto free_diff; + /* ietf-interfaces */ if ((rc = interfaces_change(session, config, diff, event, confd))) goto free_diff; - /* ietf-hardware, after the interfaces: hostapd binds to WiFi - * netdevs, so it must be (re)started only once the interface - * pipeline has created or recreated them. */ - if ((rc = hardware_change(session, config, diff, event, confd))) + /* ietf-hardware WiFi, after the interfaces: hostapd binds to the + * netdevs the interfaces have just created or recreated. */ + if ((rc = hardware_wifi_change(session, config, diff, event, confd))) goto free_diff; /* infix-dhcp-client*/ diff --git a/src/confd/src/core.h b/src/confd/src/core.h index 7cafc5550..a628a54e9 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -298,6 +298,7 @@ int services_change(sr_session_ctx_t *session, struct lyd_node *config, struct l int hardware_candidate_init(struct confd *confd); int hardware_rpc_init(struct confd *confd); int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); +int hardware_wifi_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); /* keystore.c */ int keystore_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd); diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index b3eec62a3..68f94c5d2 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -1394,7 +1394,6 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l int country_changed = 0; int rc = SR_ERR_OK; int gps_changed = 0; - int wifi_changed = 0; if (!lydx_find_xpathf(diff, XPATH_BASE_)) return SR_ERR_OK; @@ -1459,40 +1458,12 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l struct lyd_node *interfaces_config; struct lyd_node *cwifi_radio; int wifi_iface_count = 0; - char src[40], dst[40]; - char **ap_list = NULL; - int ap_interfaces = 0; - int i; switch (event) { case SR_EV_ABORT: continue; case SR_EV_CHANGE: break; - case SR_EV_DONE: - /* The changed interface need not be an AP, a - * wds-link or station on the radio also lands - * here, so judge by the APs left in config. */ - interfaces_config = lydx_get_descendant(config, "interfaces", "interface", NULL); - wifi_find_radio_aps(interfaces_config, name, &ap_list, &ap_interfaces); - for (i = 0; i < ap_interfaces; i++) - free(ap_list[i]); - free(ap_list); - - if (ap_interfaces) { - snprintf(src, sizeof(src), HOSTAPD_CONF_NEXT, name); - snprintf(dst, sizeof(dst), HOSTAPD_CONF, name); - - if (fexistf(HOSTAPD_CONF_NEXT, name)) - (void)rename(src, dst); - } else { - erasef(HOSTAPD_CONF, name); - erasef(HOSTAPD_CONF_NEXT, name); - } - /* All radios share one hostapd process; the service is - * (re)generated after the component loop below. */ - wifi_changed = 1; - continue; default: continue; @@ -1588,6 +1559,57 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l } } +err: + + return rc; +} + +int hardware_wifi_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, + sr_event_t event, struct confd *confd) +{ + struct lyd_node *difs, *dif, *cif; + int wifi_changed = 0; + int rc = SR_ERR_OK; + + if (event != SR_EV_DONE || !lydx_find_xpathf(diff, XPATH_BASE_)) + return SR_ERR_OK; + + if (lydx_get_xpathf(diff, XPATH_BASE_ "/infix-hardware:wifi/country-code")) + difs = lydx_get_descendant(config, "hardware", "component", NULL); + else + difs = lydx_get_descendant(diff, "hardware", "component", NULL); + + LYX_LIST_FOR_EACH(difs, dif, "component") { + const char *name = lydx_get_cattr(dif, "name"); + char src[40], dst[40], **ap_list = NULL; + int ap_interfaces = 0, i; + + cif = lydx_get_xpathf(config, "/hardware/component[name='%s']", name); + if (!cif || strcmp(lydx_get_cattr(cif, "class"), "infix-hardware:wifi")) + continue; + + /* The changed interface need not be an AP, a wds-link or + * station on the radio also lands here, so judge by the + * APs left in config. */ + wifi_find_radio_aps(lydx_get_descendant(config, "interfaces", "interface", NULL), + name, &ap_list, &ap_interfaces); + for (i = 0; i < ap_interfaces; i++) + free(ap_list[i]); + free(ap_list); + + if (ap_interfaces) { + snprintf(src, sizeof(src), HOSTAPD_CONF_NEXT, name); + snprintf(dst, sizeof(dst), HOSTAPD_CONF, name); + + if (fexistf(HOSTAPD_CONF_NEXT, name)) + (void)rename(src, dst); + } else { + erasef(HOSTAPD_CONF, name); + erasef(HOSTAPD_CONF_NEXT, name); + } + wifi_changed = 1; + } + /* * All AP radios run in a single hostapd process so that * cross-radio directives (e.g. no_probe_resp_if_seen_on for band @@ -1596,7 +1618,7 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l * /etc/hostapd-.conf and (re)start it. A restart re-reads * all radio configs, so band edits and radio add/remove both apply. */ - if (wifi_changed && event == SR_EV_DONE) { + if (wifi_changed) { struct lyd_node *cifs; glob_t gl = { 0 }; size_t i; @@ -1651,7 +1673,7 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l } globfree(&gl); } -err: + return rc; } From 1f3958263e0a462d9bc39a863e798a8745524ce4 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Oct 2026 10:36:54 +0200 Subject: [PATCH 02/37] confd: lock a USB port when its component is removed Removing a USB component from the configuration left its port unlocked. The component loop skips a component that is no longer in the configuration, so the code locking the port of a deleted component was never reached. Lock the port when its component is gone from the configuration. The USB test removed the components only after locking them, remove them while unlocked instead. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 2 + src/confd/src/hardware.c | 20 +++---- test/case/hardware/usb/test.adoc | 4 +- test/case/hardware/usb/test.py | 95 ++++++++++---------------------- 4 files changed, 42 insertions(+), 79 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 10721b249..3f8202e5f 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -106,6 +106,8 @@ All notable changes to the project are documented in this file. WPA3 clients - Changing the MAC address of a WiFi access point could leave the access points on that radio down until the WiFi service was restarted +- Removing a USB port component from the configuration left the port + unlocked, now the port is locked [pppoe]: https://www.kernelkit.org/infix/latest/pppoe/ [wds]: https://www.kernelkit.org/infix/latest/wifi/#wds-backhaul-and-repeaters diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index 68f94c5d2..d939b63e6 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -1416,18 +1416,24 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l difs = lydx_get_descendant(diff, "hardware", "component", NULL); LYX_LIST_FOR_EACH(difs, dif, "component") { - enum lydx_op op; struct lyd_node *state, *cif; const char *admin_state; const char *class, *name; - op = lydx_get_op(dif); name = lydx_get_cattr(dif, "name"); /* Get the current config node for this component */ cif = lydx_get_xpathf(config, "/hardware/component[name='%s']", name); - if (!cif) + if (!cif) { + /* Component deleted, lock its USB port */ + class = lydx_get_cattr(dif, "class"); + if (event == SR_EV_DONE && class && !strcmp(class, "infix-hardware:usb") && + usb_authorize(confd->root, name, 0)) { + rc = SR_ERR_INTERNAL; + goto err; + } continue; + } class = lydx_get_cattr(cif, "class"); if (country_changed && strcmp(class, "infix-hardware:wifi") && @@ -1439,14 +1445,6 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l if (event != SR_EV_DONE) continue; - if (op == LYDX_OP_DELETE) { - /* Handle USB deletion */ - if (usb_authorize(confd->root, name, 0)) { - rc = SR_ERR_INTERNAL; - goto err; - } - continue; - } state = lydx_get_child(cif, "state"); admin_state = lydx_get_cattr(state, "admin-state"); if (usb_authorize(confd->root, name, !strcmp(admin_state, "unlocked"))) { diff --git a/test/case/hardware/usb/test.adoc b/test/case/hardware/usb/test.adoc index 5ce510abf..199af5d59 100644 --- a/test/case/hardware/usb/test.adoc +++ b/test/case/hardware/usb/test.adoc @@ -9,7 +9,7 @@ and verifies whether the USB ports are correctly _locked_ (restricted from use) and _unlocked_ (available for use) when they should. It also verifies this behavior during reboot. This test does not involve the actual use of the USB port; it only ensures the configured state is consistent with the -hardware state. +hardware state. Removing the configuration of a port locks it. If this pass you can be certain that the configuration of the USB port is handled correctly. @@ -25,6 +25,8 @@ image::topology.svg[USB Configuration topology, align=center, scaledwidth=75%] . Verify that all USB ports are unlocked . Lock all USB ports . Verify that all USB ports are locked +. Unlock all USB ports again +. Verify that all USB ports are unlocked . Remove all hardware configuration . Verify that all USB ports are locked . Unlock USB ports diff --git a/test/case/hardware/usb/test.py b/test/case/hardware/usb/test.py index fba4755c0..7b6413d77 100755 --- a/test/case/hardware/usb/test.py +++ b/test/case/hardware/usb/test.py @@ -6,18 +6,29 @@ use) and _unlocked_ (available for use) when they should. It also verifies this behavior during reboot. This test does not involve the actual use of the USB port; it only ensures the configured state is consistent with the -hardware state. +hardware state. Removing the configuration of a port locks it. If this pass you can be certain that the configuration of the USB port is handled correctly. """ import infamy -import copy import infamy.usb as usb -import time from infamy.util import until, wait_boot + +def set_usb_state(target, ports, state): + target.put_config_dicts({"ietf-hardware": {"hardware": {"component": [ + {"name": port, "class": "infix-hardware:usb", + "state": {"admin-state": state}} for port in ports + ]}}}) + + +def verify_usb_state(target, ports, state): + for port in ports: + until(lambda: usb.get_usb_state(target, port) == state) + + with infamy.Test() as test: with test.step("Set up topology and attach to target DUT"): env = infamy.Env() @@ -28,84 +39,35 @@ test.skip() with test.step("Unlock all USB ports"): - components = [] - for port in available: - component = { - "name": port, - "class": "infix-hardware:usb", - "state": { - "admin-state": "unlocked" - } - } - components.append(component) - - target.put_config_dicts({ - "ietf-hardware": { - "hardware": { - "component": components - } - } - }) + set_usb_state(target, available, "unlocked") with test.step("Verify that all USB ports are unlocked"): - for port in available: - until(lambda: usb.get_usb_state(target, port) == "unlocked") + verify_usb_state(target, available, "unlocked") with test.step("Lock all USB ports"): - components = [] - for port in available: - component = { - "class": "infix-hardware:usb", - "name": port, - "state": { - "admin-state": "locked" - } - } - components.append(component) - - target.put_config_dicts({ - "ietf-hardware": { - "hardware": { - "component": components - } - } - }) + set_usb_state(target, available, "locked") with test.step("Verify that all USB ports are locked"): - for port in available: - until(lambda: usb.get_usb_state(target, port) == "locked") + verify_usb_state(target, available, "locked") + + with test.step("Unlock all USB ports again"): + set_usb_state(target, available, "unlocked") + + with test.step("Verify that all USB ports are unlocked"): + verify_usb_state(target, available, "unlocked") with test.step("Remove all hardware configuration"): for port in available: target.delete_xpath(f"/ietf-hardware:hardware/component[name='{port}']") with test.step("Verify that all USB ports are locked"): - for port in available: - until(lambda: usb.get_usb_state(target, port) == "locked") + verify_usb_state(target, available, "locked") with test.step("Unlock USB ports"): - components = [] - for port in available: - component = { - "name": port, - "class": "infix-hardware:usb", - "state": { - "admin-state": "unlocked" - } - } - components.append(component) - - target.put_config_dicts({ - "ietf-hardware": { - "hardware": { - "component": components - } - } - }) + set_usb_state(target, available, "unlocked") with test.step("Verify that all USB ports are unlocked"): - for port in available: - until(lambda: usb.get_usb_state(target, port) == "unlocked") + verify_usb_state(target, available, "unlocked") with test.step("Save the configuration to startup configuration and reboot"): target.startup_override() @@ -116,7 +78,6 @@ target = env.attach("target", "mgmt", test_reset=False) with test.step("Verify USB port remain unlocked after reboot"): - for port in available: - until(lambda: usb.get_usb_state(target, port) == "unlocked") + verify_usb_state(target, available, "unlocked") test.succeed() From 33bb41e4c5c2c96f031b04a247fcf8d5e0019e2e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Oct 2026 09:07:29 +0200 Subject: [PATCH 03/37] confd: allow more characters in DHCP client option values A startup-config with DHCP enabled fails validation on boards with parentheses in their model name, e.g. Banana Pi BPI-R4: Unsatisfied pattern - "Banana Pi BPI-R4 (1x SFP+, 1x 2.5GbE)" does not conform to "[a-zA-Z0-9 _.:/@=,+-]+". The DHCP client copies the model name to its vendor-class option, and since c2bc873e7 option values are limited to a small character set, so the system rejects a value it wrote itself and the device boots into failure-config. Only the characters that can break out of the quoting on the udhcpc command line need to be rejected, issue #1657: quotes, '$', '\', '`', and control characters. Allow anything else, Unicode included, and size the buffers holding the value for 255 UTF-8 characters. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 5 +++++ doc/limitations.md | 2 +- src/confd/src/dhcp-client.c | 2 +- src/confd/src/dhcp-common.c | 2 +- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-dhcp-client.yang | 9 ++++++++- ...2026-09-28.yang => infix-dhcp-client@2026-10-18.yang} | 0 7 files changed, 17 insertions(+), 5 deletions(-) rename src/confd/yang/confd/{infix-dhcp-client@2026-09-28.yang => infix-dhcp-client@2026-10-18.yang} (100%) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 3f8202e5f..5b7695186 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -77,6 +77,11 @@ All notable changes to the project are documented in this file. ### Fixes +- Fix `startup-config` failing validation on boards with parentheses in + their model name, e.g., Banana Pi BPI-R4. The DHCP client copies the + model name to its `vendor-class` option, which v26.09.0 limited to a + small character set. Option values may now contain any character + except control characters, quotes, `$`, `` ` ``, and `\` - WebUI: uploading a software bundle failed on slow storage - WebUI: the Overview page showed the hostname template, e.g. `rpi-%m`, and never showed the boot partition diff --git a/doc/limitations.md b/doc/limitations.md index d5374fde0..cd49acd7e 100644 --- a/doc/limitations.md +++ b/doc/limitations.md @@ -17,7 +17,7 @@ settings below before upgrading from an older release. | Interface `name` | Letters, digits, `_`, `.`, `:`, `+`, `-`, must start with a letter, digit, or `_` | | | Hardware component `name` | As interface name, `@` also allowed | | | Keystore asymmetric key and certificate `name` | As interface name, `@` also allowed | | -| DHCP client option `value` | Letters, digits, space, `_`, `.`, `:`, `/`, `@`, `=`, `,`, `+`, `-` | 1..255 | +| DHCP client option `value` | Anything except control characters, `"`, `'`, `$`, `` ` ``, and `\` | 1..255 | | DHCP server static-host `hostname` | Letters, digits, `_`, `.`, `-` | 1..255 | | DHCP server static-host `client-id` | Letters, digits, `_`, `.`, `:`, `+`, `-` | 1..255 | | Syslog `property-filter` value | Letters, digits, space, `_`, `.`, `:`, `/`, `@`, `=`, `,`, `-`, and `+ * ? \| ^ $ ( ) [ ] { }` | | diff --git a/src/confd/src/dhcp-client.c b/src/confd/src/dhcp-client.c index 8d1f1ffab..09e9ebfdc 100644 --- a/src/confd/src/dhcp-client.c +++ b/src/confd/src/dhcp-client.c @@ -101,7 +101,7 @@ static void add(const char *ifname, struct lyd_node *cfg) char *cid = NULL, *options = NULL; int ena = 0; const char *vendor_class; - char vendor[272] = { 0 }; + char vendor[4 * 255 + 8] = { 0 }; /* 255 UTF-8 characters */ char do_arp[20] = { 0 }; bool arping; FILE *fp; diff --git a/src/confd/src/dhcp-common.c b/src/confd/src/dhcp-common.c index 3a96a9e07..0dd1f2422 100644 --- a/src/confd/src/dhcp-common.c +++ b/src/confd/src/dhcp-common.c @@ -160,7 +160,7 @@ char *dhcp_compose_options(struct lyd_node *cfg, const char *ifname, char **opti struct lyd_node *id, const char *val, const char *hex, char *(*ip_cache_cb)(const char *, char *, size_t)) { - char opt[300]; + char opt[4 * 255 + 32]; /* 255 UTF-8 characters */ if (!dhcp_compose_option(cfg, ifname, id, val, hex, opt, sizeof(opt), ip_cache_cb)) return *options; diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 69dd496a8..d6f0fbbc7 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -35,7 +35,7 @@ MODULES=( "ieee802-dot1ab-lldp@2022-03-15.yang" "infix-lldp@2025-05-05.yang" "infix-dhcp-common@2025-12-21.yang" - "infix-dhcp-client@2026-09-28.yang" + "infix-dhcp-client@2026-10-18.yang" "infix-dhcpv6-client@2025-11-09.yang" "infix-dhcp-server@2026-09-24.yang" "infix-firewall@2026-07-02.yang" diff --git a/src/confd/yang/confd/infix-dhcp-client.yang b/src/confd/yang/confd/infix-dhcp-client.yang index 1751eb9bc..76b0b2960 100644 --- a/src/confd/yang/confd/infix-dhcp-client.yang +++ b/src/confd/yang/confd/infix-dhcp-client.yang @@ -16,6 +16,13 @@ module infix-dhcp-client { contact "kernelkit@googlegroups.com"; description "This module implements a DHCPv4 client"; + revision 2026-10-18 { + description "Allow any character in option values except quotes, + dollar, backslash, backtick, and control characters, e.g., + a board model with parentheses in vendor-class."; + reference "internal"; + } + revision 2026-09-28 { description "Constrain the character set and length of option values."; reference "internal"; @@ -137,7 +144,7 @@ module infix-dhcp-client { octet, so a value is at most 255 bytes."; type string { length "1..255"; - pattern '[a-zA-Z0-9 _.:/@=,+-]+'; + pattern '[^"' + "'" + '$\\`\p{Cc}]+'; } must "../id != 'hostname' or re-match(., '[a-zA-Z0-9\\-_]{1,64}')"; } diff --git a/src/confd/yang/confd/infix-dhcp-client@2026-09-28.yang b/src/confd/yang/confd/infix-dhcp-client@2026-10-18.yang similarity index 100% rename from src/confd/yang/confd/infix-dhcp-client@2026-09-28.yang rename to src/confd/yang/confd/infix-dhcp-client@2026-10-18.yang From fdfaa705286d810c98dd6aa10e58232b8b1a139c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Oct 2026 09:19:59 +0200 Subject: [PATCH 04/37] confd: allow any character but control characters in syslog patterns Since 1b8a917be the syslog pattern-match and property-filter value only allow a small set of characters, so a startup-config with, e.g., a '\' in a regular expression fails validation after upgrade: pattern-match "error\s+\d+" The values are written into quoted fields in syslog.conf, where syslogd drops a '\' in front of any character, so only an unescaped '"' or a line break can break out of the field. Escape '\' and '"' when writing them and allow anything but control characters. As a bonus, a '\' now reaches the regular expression as written. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 4 ++++ doc/limitations.md | 2 +- src/confd/src/core.c | 18 ++++++++++++++++++ src/confd/src/core.h | 1 + src/confd/src/syslog.c | 8 ++++++-- src/confd/yang/confd.inc | 2 +- ....yang => infix-dhcp-server@2026-10-18.yang} | 0 src/confd/yang/confd/infix-syslog.yang | 12 +++++++++--- ...09-24.yang => infix-syslog@2026-10-18.yang} | 0 9 files changed, 40 insertions(+), 7 deletions(-) rename src/confd/yang/confd/{infix-dhcp-server@2026-09-24.yang => infix-dhcp-server@2026-10-18.yang} (100%) rename src/confd/yang/confd/{infix-syslog@2026-09-24.yang => infix-syslog@2026-10-18.yang} (100%) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 5b7695186..be8be7924 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -82,6 +82,10 @@ All notable changes to the project are documented in this file. model name to its `vendor-class` option, which v26.09.0 limited to a small character set. Option values may now contain any character except control characters, quotes, `$`, `` ` ``, and `\` +- Fix syslog `pattern-match` and `property-filter` values with characters + outside the small set allowed since v26.09.0, e.g., `\` in a regular + expression, failing validation. Any character except control characters + is allowed, and `\` and `"` now reach syslogd as written - WebUI: uploading a software bundle failed on slow storage - WebUI: the Overview page showed the hostname template, e.g. `rpi-%m`, and never showed the boot partition diff --git a/doc/limitations.md b/doc/limitations.md index cd49acd7e..9bc13ab11 100644 --- a/doc/limitations.md +++ b/doc/limitations.md @@ -20,7 +20,7 @@ settings below before upgrading from an older release. | DHCP client option `value` | Anything except control characters, `"`, `'`, `$`, `` ` ``, and `\` | 1..255 | | DHCP server static-host `hostname` | Letters, digits, `_`, `.`, `-` | 1..255 | | DHCP server static-host `client-id` | Letters, digits, `_`, `.`, `:`, `+`, `-` | 1..255 | -| Syslog `property-filter` value | Letters, digits, space, `_`, `.`, `:`, `/`, `@`, `=`, `,`, `-`, and `+ * ? \| ^ $ ( ) [ ] { }` | | +| Syslog `property-filter` value | Anything except control characters | | | Syslog file and remote `pattern-match` | As `property-filter` value | | | Wi-Fi `mesh-id` | Anything except control characters, `"`, and `\` | | | Wi-Fi `nas-identifier` | Letters, digits, `_`, `.`, `:`, `+`, `-` | | diff --git a/src/confd/src/core.c b/src/confd/src/core.c index ee694aabe..fcdc7e4ff 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -18,6 +18,24 @@ struct confd confd; * only. Use these for files read by a daemon running as another user, * or for the few files that must stay world readable. */ +/* + * Write str to fp in double quotes, with '"' and '\' escaped, for + * config files that unescape quoted values, e.g. syslog.conf, dnsmasq. + */ +int fputq(const char *str, FILE *fp) +{ + if (fputc('"', fp) == EOF) + return EOF; + for (; *str; str++) { + if ((*str == '"' || *str == '\\') && fputc('\\', fp) == EOF) + return EOF; + if (fputc(*str, fp) == EOF) + return EOF; + } + + return fputc('"', fp) == EOF ? EOF : 0; +} + FILE *fopenp(const char *path, mode_t mode, const char *group) { gid_t gid = 0; diff --git a/src/confd/src/core.h b/src/confd/src/core.h index a628a54e9..cec5f040f 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -225,6 +225,7 @@ int finit_deletef(const char *fmt, ...) __attribute__((format(printf, 1, 2))); int finit_reloadf(const char *fmt, ...) __attribute__((format(printf, 1, 2))); const char *rpc_user(sr_session_ctx_t *session, const char **via); +int fputq(const char *str, FILE *fp); FILE *fopenp(const char *path, mode_t mode, const char *group); FILE *fopenfp(mode_t mode, const char *group, const char *fmt, ...) __attribute__((format(printf, 3, 4))); diff --git a/src/confd/src/syslog.c b/src/confd/src/syslog.c index c954ed6af..f964ed0d0 100644 --- a/src/confd/src/syslog.c +++ b/src/confd/src/syslog.c @@ -143,7 +143,9 @@ static size_t selector(sr_session_ctx_t *session, struct action *act) strlcat(op_prefix, operator, sizeof(op_prefix)); /* Property-based filter: :property, [!][icase_]operator, "value" */ - fprintf(act->fp, ":%s, %s, \"%s\"\n", property, op_prefix, value); + fprintf(act->fp, ":%s, %s, ", property, op_prefix); + fputq(value, act->fp); + fputc('\n', act->fp); has_pattern = true; } @@ -158,7 +160,9 @@ static size_t selector(sr_session_ctx_t *session, struct action *act) pattern = srx_get_str(session, "%s/pattern-match", act->xpath); if (pattern) { /* Property-based filter: :msg, ereregex, "pattern" */ - fprintf(act->fp, ":msg, ereregex, \"%s\"\n", pattern); + fputs(":msg, ereregex, ", act->fp); + fputq(pattern, act->fp); + fputc('\n', act->fp); has_pattern = true; free(pattern); } diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index d6f0fbbc7..8a4344f66 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -24,7 +24,7 @@ MODULES=( # NOTE: ietf-tls-client must be version matched with ietf-tls-server, used by netopeer2! # "ietf-tls-client@2023-12-28.yang" "ietf-syslog@2024-03-21.yang -e file-action -e file-limit-size -e remote-action -e select-adv-compare -e select-match" - "infix-syslog@2026-09-24.yang" + "infix-syslog@2026-10-18.yang" "iana-hardware@2018-03-13.yang" "ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor" "infix-hardware@2026-10-06.yang" diff --git a/src/confd/yang/confd/infix-dhcp-server@2026-09-24.yang b/src/confd/yang/confd/infix-dhcp-server@2026-10-18.yang similarity index 100% rename from src/confd/yang/confd/infix-dhcp-server@2026-09-24.yang rename to src/confd/yang/confd/infix-dhcp-server@2026-10-18.yang diff --git a/src/confd/yang/confd/infix-syslog.yang b/src/confd/yang/confd/infix-syslog.yang index 37bd6e908..4b0c9c546 100644 --- a/src/confd/yang/confd/infix-syslog.yang +++ b/src/confd/yang/confd/infix-syslog.yang @@ -20,6 +20,12 @@ module infix-syslog { contact "kernelkit@googlegroups.com"; description "Infix augments and deviations to ietf-syslog, draft 32."; + revision 2026-10-18 { + description "Allow any character except control characters in the + property-filter value and pattern-match."; + reference "internal"; + } + revision 2026-09-24 { description "Constrain the character set of the property-filter value and the select pattern-match."; @@ -258,7 +264,7 @@ module infix-syslog { leaf value { type string { - pattern '[a-zA-Z0-9 _.:+*?|^$()\[\]{}/@=,-]+'; + pattern '[^\p{Cc}]+'; } mandatory true; description "The value to compare against."; @@ -321,7 +327,7 @@ module infix-syslog { syntax remains available."; deviate replace { type string { - pattern '[a-zA-Z0-9 _.:+*?|^$()\[\]{}/@=,-]+'; + pattern '[^\p{Cc}]+'; } } } @@ -331,7 +337,7 @@ module infix-syslog { syntax remains available."; deviate replace { type string { - pattern '[a-zA-Z0-9 _.:+*?|^$()\[\]{}/@=,-]+'; + pattern '[^\p{Cc}]+'; } } } diff --git a/src/confd/yang/confd/infix-syslog@2026-09-24.yang b/src/confd/yang/confd/infix-syslog@2026-10-18.yang similarity index 100% rename from src/confd/yang/confd/infix-syslog@2026-09-24.yang rename to src/confd/yang/confd/infix-syslog@2026-10-18.yang From dea3d77657f971e397a44db3a084fbf8dcac49d1 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Oct 2026 09:20:00 +0200 Subject: [PATCH 05/37] confd: allow any character but control characters in DHCP client-id Since a55ebf1fb the DHCP server static-host client-id string only allows letters, digits, and '_.:+-', so a startup-config with, e.g., a space in it fails validation after upgrade. The value is written into the comma-separated dnsmasq dhcp-host line. Quote it, with '\' and '"' escaped, so dnsmasq takes commas and other separators in it literally, and allow anything but control characters. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 3 +++ doc/limitations.md | 2 +- src/confd/src/dhcp-server.c | 20 +++++++++++++------- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-dhcp-server.yang | 8 +++++++- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index be8be7924..bc98625b7 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -86,6 +86,9 @@ All notable changes to the project are documented in this file. outside the small set allowed since v26.09.0, e.g., `\` in a regular expression, failing validation. Any character except control characters is allowed, and `\` and `"` now reach syslogd as written +- Fix DHCP server static-host `client-id` strings with characters outside + the small set allowed since v26.09.0, e.g., a space, failing validation. + Any character except control characters is allowed - WebUI: uploading a software bundle failed on slow storage - WebUI: the Overview page showed the hostname template, e.g. `rpi-%m`, and never showed the boot partition diff --git a/doc/limitations.md b/doc/limitations.md index 9bc13ab11..a7628ee19 100644 --- a/doc/limitations.md +++ b/doc/limitations.md @@ -19,7 +19,7 @@ settings below before upgrading from an older release. | Keystore asymmetric key and certificate `name` | As interface name, `@` also allowed | | | DHCP client option `value` | Anything except control characters, `"`, `'`, `$`, `` ` ``, and `\` | 1..255 | | DHCP server static-host `hostname` | Letters, digits, `_`, `.`, `-` | 1..255 | -| DHCP server static-host `client-id` | Letters, digits, `_`, `.`, `:`, `+`, `-` | 1..255 | +| DHCP server static-host `client-id` | Anything except control characters | 1..255 | | Syslog `property-filter` value | Anything except control characters | | | Syslog file and remote `pattern-match` | As `property-filter` value | | | Wi-Fi `mesh-id` | Anything except control characters, `"`, and `\` | | diff --git a/src/confd/src/dhcp-server.c b/src/confd/src/dhcp-server.c index 7da94ca9d..2127acb16 100644 --- a/src/confd/src/dhcp-server.c +++ b/src/confd/src/dhcp-server.c @@ -142,7 +142,7 @@ static void configure_boot(FILE *fp, struct lyd_node *cfg, const char *tag) fputc('\n', fp); } -static const char *host_match(struct lyd_node *match, const char **id) +static const char *host_match(struct lyd_node *match, const char **id, int *quote) { struct { const char *key; @@ -171,8 +171,10 @@ static const char *host_match(struct lyd_node *match, const char **id) /* The client-id setting is has a qualifier */ sub = lydx_get_child(node, "str"); - if (sub) + if (sub) { + *quote = 1; return lyd_get_value(sub); + } sub = lydx_get_child(node, "hex"); if (sub) return lyd_get_value(sub); @@ -187,8 +189,9 @@ static int configure_host(FILE *fp, struct lyd_node *host, const char *subnet) const char *ip = lydx_get_cattr(host, "address"); const char *tag = host_tag(NULL, ip); const char *match, *id, *name; + int quote = 0; - match = host_match(lydx_get_child(host, "match"), &id); + match = host_match(lydx_get_child(host, "match"), &id, "e); if (!match) return -1; @@ -204,10 +207,13 @@ static int configure_host(FILE *fp, struct lyd_node *host, const char *subnet) * also allow options from subnet */ fprintf(fp, "\n# Host %s specific options\n", ip); - fprintf(fp, "dhcp-host=%s%s,set:%s,set:%s,%s,%s%s%s\n", - id ? id : "", match, tag, subnet, ip, - name ?: "", name ? "," : "", - lydx_get_cattr(host, "lease-time")); + fprintf(fp, "dhcp-host=%s", id ? id : ""); + if (quote) + fputq(match, fp); + else + fputs(match, fp); + fprintf(fp, ",set:%s,set:%s,%s,%s%s%s\n", tag, subnet, ip, + name ?: "", name ? "," : "", lydx_get_cattr(host, "lease-time")); return 0; } diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 8a4344f66..16e3f13e9 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -37,7 +37,7 @@ MODULES=( "infix-dhcp-common@2025-12-21.yang" "infix-dhcp-client@2026-10-18.yang" "infix-dhcpv6-client@2025-11-09.yang" - "infix-dhcp-server@2026-09-24.yang" + "infix-dhcp-server@2026-10-18.yang" "infix-firewall@2026-07-02.yang" "infix-firewall-services@2025-04-26.yang" "infix-firewall-icmp-types@2025-04-26.yang" diff --git a/src/confd/yang/confd/infix-dhcp-server.yang b/src/confd/yang/confd/infix-dhcp-server.yang index 072ff3c5a..b1637fa6f 100644 --- a/src/confd/yang/confd/infix-dhcp-server.yang +++ b/src/confd/yang/confd/infix-dhcp-server.yang @@ -20,6 +20,12 @@ module infix-dhcp-server { contact "kernelkit@googlegroups.com"; description "This module implements a DHCPv4 server"; + revision 2026-10-18 { + description "Allow any character except control characters in the + static-host match client-id string."; + reference "internal"; + } + revision 2026-09-24 { description "Constrain the character set and length of static-host match hostname and client-id string."; @@ -316,7 +322,7 @@ module infix-dhcp-server { bytes."; type string { length "1..255"; - pattern '[a-zA-Z0-9_.:+-]+'; + pattern '[^\p{Cc}]+'; } } } From a99f29e9aeb9c8b04b38feecd5628f7e087b29aa Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Oct 2026 09:34:10 +0200 Subject: [PATCH 06/37] confd: migrate ietf-netconf-server also in 1.10 configurations A startup-config written by a build of main between the confd 1.10 bump, 2beb143f0, and the move of NETCONF to the SSH daemon, 80716e702, fails to load after upgrade: libyang[0]: No module named "ietf-netconf-server" in the context. Such a configuration is already version 1.10, so the 1.10 migration that removes ietf-netconf-server never runs for it. Run the migration again for 1.11. It now leaves a configuration without ietf-netconf-server as is, otherwise a migrated configuration would get NETCONF disabled. Signed-off-by: Joachim Wiberg --- .../migrate/1.10/20-netconf-ssh-subsystem.sh | 15 ++++++++++----- .../migrate/1.11/20-netconf-ssh-subsystem.sh | 1 + src/confd/share/migrate/1.11/Makefile.am | 3 +++ 3 files changed, 14 insertions(+), 5 deletions(-) create mode 120000 src/confd/share/migrate/1.11/20-netconf-ssh-subsystem.sh diff --git a/src/confd/share/migrate/1.10/20-netconf-ssh-subsystem.sh b/src/confd/share/migrate/1.10/20-netconf-ssh-subsystem.sh index 13cb4b61c..1a3d9b963 100755 --- a/src/confd/share/migrate/1.10/20-netconf-ssh-subsystem.sh +++ b/src/confd/share/migrate/1.10/20-netconf-ssh-subsystem.sh @@ -1,17 +1,22 @@ #!/bin/sh # NETCONF is served by the SSH daemon as a subsystem and ietf-netconf-server # is gone. NETCONF is on by default, so only a configuration without a -# NETCONF endpoint needs ssh/netconf/enabled set to false. +# NETCONF endpoint needs ssh/netconf/enabled set to false. Also run for +# 1.11, a configuration without ietf-netconf-server is left as is. file=$1 temp=${file}.tmp jq ' -(.["ietf-netconf-server:netconf-server"]?.listen?.endpoints?.endpoint // [] | length > 0) as $netconf | -del(.["ietf-netconf-server:netconf-server"]) | -if $netconf then +if has("ietf-netconf-server:netconf-server") | not then . else - .["infix-services:ssh"].netconf.enabled = false + (.["ietf-netconf-server:netconf-server"]?.listen?.endpoints?.endpoint // [] | length > 0) as $netconf | + del(.["ietf-netconf-server:netconf-server"]) | + if $netconf then + . + else + .["infix-services:ssh"].netconf.enabled = false + end end ' "$file" > "$temp" && mv "$temp" "$file" diff --git a/src/confd/share/migrate/1.11/20-netconf-ssh-subsystem.sh b/src/confd/share/migrate/1.11/20-netconf-ssh-subsystem.sh new file mode 120000 index 000000000..aac2a9c18 --- /dev/null +++ b/src/confd/share/migrate/1.11/20-netconf-ssh-subsystem.sh @@ -0,0 +1 @@ +../1.10/20-netconf-ssh-subsystem.sh \ No newline at end of file diff --git a/src/confd/share/migrate/1.11/Makefile.am b/src/confd/share/migrate/1.11/Makefile.am index f1a52eed3..ff62c2391 100644 --- a/src/confd/share/migrate/1.11/Makefile.am +++ b/src/confd/share/migrate/1.11/Makefile.am @@ -1,2 +1,5 @@ migratedir = $(pkgdatadir)/migrate/1.11 dist_migrate_DATA = 10-wifi-country-code.sh +if NETCONF_SUBSYSTEM +dist_migrate_DATA += 20-netconf-ssh-subsystem.sh +endif From 59c3f2bc3c15e1a58a37cc76f9472782cf053fd5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Oct 2026 11:37:25 +0200 Subject: [PATCH 07/37] patches/linux: keep the MT7988 PCIe port clocks enabled No PCIe card is detected on the Banana Pi BPI-R4, every port reports: mtk-pcie-gen3 11300000.pcie: PCIe link down, current LTSSM state: detect.quiet (0x0) The clocks feeding the PCIe ports have no consumer in the device tree, so the kernel gates them before the PCIe driver module loads. Mark them critical. See issue #1714 for the proper fix. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 2 + ...988-keep-the-PCIe-port-clocks-enable.patch | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 patches/linux/6.18.57/0103-clk-mediatek-mt7988-keep-the-PCIe-port-clocks-enable.patch diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index bc98625b7..54daa27c2 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -120,6 +120,8 @@ All notable changes to the project are documented in this file. points on that radio down until the WiFi service was restarted - Removing a USB port component from the configuration left the port unlocked, now the port is locked +- Banana Pi BPI-R4: no PCIe card was detected, e.g., an NVMe disk or a + WiFi card [pppoe]: https://www.kernelkit.org/infix/latest/pppoe/ [wds]: https://www.kernelkit.org/infix/latest/wifi/#wds-backhaul-and-repeaters diff --git a/patches/linux/6.18.57/0103-clk-mediatek-mt7988-keep-the-PCIe-port-clocks-enable.patch b/patches/linux/6.18.57/0103-clk-mediatek-mt7988-keep-the-PCIe-port-clocks-enable.patch new file mode 100644 index 000000000..5fae580a6 --- /dev/null +++ b/patches/linux/6.18.57/0103-clk-mediatek-mt7988-keep-the-PCIe-port-clocks-enable.patch @@ -0,0 +1,58 @@ +From 773e6267865af44796580064e32dfddfced46deb Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Sun, 18 Oct 2026 11:31:40 +0200 +Subject: [PATCH 103/103] clk: mediatek: mt7988: keep the PCIe port clocks + enabled + +The PCIe links on the Banana Pi BPI-R4 never come up when the PCIe +driver is built as a module: + + mtk-pcie-gen3 11300000.pcie: PCIe link down, current LTSSM state: detect.quiet (0x0) + +The 26 MHz pextp_p0_sel to pextp_p3_sel clocks feeding the PCIe ports +are enabled by the bootloader but have no consumer in the device tree, +so clk_disable_unused() gates them before the PCIe driver probes. A +modular driver loses them on every boot, a built-in one when its probe +is deferred past clk_disable_unused(). + +Mark them CLK_IS_CRITICAL, as the MediaTek SDK does. + +The proper fix is to list these clocks in the PCIe nodes of +mt7988a.dtsi, so the PCIe driver enables them itself. That also needs a +change to the mediatek-pcie-gen3 binding, which allows only four clocks +for mediatek,mt7986-pcie. + +Link: https://github.com/openwrt/openwrt/issues/25706 +Signed-off-by: Joachim Wiberg +--- + drivers/clk/mediatek/clk-mt7988-topckgen.c | 16 ++++++++-------- + 1 file changed, 8 insertions(+), 8 deletions(-) + +diff --git a/drivers/clk/mediatek/clk-mt7988-topckgen.c b/drivers/clk/mediatek/clk-mt7988-topckgen.c +index 50e02cc7a214..f9ec3e5be945 100644 +--- a/drivers/clk/mediatek/clk-mt7988-topckgen.c ++++ b/drivers/clk/mediatek/clk-mt7988-topckgen.c +@@ -225,15 +225,15 @@ static const struct mtk_mux top_muxes[] = { + 0x0d0, 0x0d4, 0x0d8, 0, 2, 7, 0x1C4, 21, CLK_IS_CRITICAL), + MUX_GATE_CLR_SET_UPD_FLAGS(CLK_TOP_INFRA_F26M_SEL, "csw_infra_f26m_sel", sspxtp_parents, + 0x0d0, 0x0d4, 0x0d8, 8, 1, 15, 0x1C4, 22, CLK_IS_CRITICAL), +- MUX_GATE_CLR_SET_UPD(CLK_TOP_PEXTP_P0_SEL, "pextp_p0_sel", sspxtp_parents, 0x0d0, 0x0d4, +- 0x0d8, 16, 1, 23, 0x1C4, 23), +- MUX_GATE_CLR_SET_UPD(CLK_TOP_PEXTP_P1_SEL, "pextp_p1_sel", sspxtp_parents, 0x0d0, 0x0d4, +- 0x0d8, 24, 1, 31, 0x1C4, 24), ++ MUX_GATE_CLR_SET_UPD_FLAGS(CLK_TOP_PEXTP_P0_SEL, "pextp_p0_sel", sspxtp_parents, ++ 0x0d0, 0x0d4, 0x0d8, 16, 1, 23, 0x1C4, 23, CLK_IS_CRITICAL), ++ MUX_GATE_CLR_SET_UPD_FLAGS(CLK_TOP_PEXTP_P1_SEL, "pextp_p1_sel", sspxtp_parents, ++ 0x0d0, 0x0d4, 0x0d8, 24, 1, 31, 0x1C4, 24, CLK_IS_CRITICAL), + /* CLK_CFG_14 */ +- MUX_GATE_CLR_SET_UPD(CLK_TOP_PEXTP_P2_SEL, "pextp_p2_sel", sspxtp_parents, 0x0e0, 0x0e4, +- 0x0e8, 0, 1, 7, 0x1C4, 25), +- MUX_GATE_CLR_SET_UPD(CLK_TOP_PEXTP_P3_SEL, "pextp_p3_sel", sspxtp_parents, 0x0e0, 0x0e4, +- 0x0e8, 8, 1, 15, 0x1C4, 26), ++ MUX_GATE_CLR_SET_UPD_FLAGS(CLK_TOP_PEXTP_P2_SEL, "pextp_p2_sel", sspxtp_parents, ++ 0x0e0, 0x0e4, 0x0e8, 0, 1, 7, 0x1C4, 25, CLK_IS_CRITICAL), ++ MUX_GATE_CLR_SET_UPD_FLAGS(CLK_TOP_PEXTP_P3_SEL, "pextp_p3_sel", sspxtp_parents, ++ 0x0e0, 0x0e4, 0x0e8, 8, 1, 15, 0x1C4, 26, CLK_IS_CRITICAL), + MUX_GATE_CLR_SET_UPD(CLK_TOP_DA_XTP_GLB_P0_SEL, "da_xtp_glb_p0_sel", da_xtp_glb_p0_parents, + 0x0e0, 0x0e4, 0x0e8, 16, 1, 23, 0x1C4, 27), + MUX_GATE_CLR_SET_UPD(CLK_TOP_DA_XTP_GLB_P1_SEL, "da_xtp_glb_p1_sel", da_xtp_glb_p0_parents, From 915ef1f8dd40c8a2d4bb23ce415718e5d03133c4 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Oct 2026 14:11:43 +0200 Subject: [PATCH 08/37] board/aarch64: bpi-r4: fix the build instructions The README builds the bootloaders and the system with targets that do not exist: make x-bpi-r4-emmc-boot make: *** No rule to make target 'x-bpi-r4-emmc-boot'. Stop. Build each in its own output directory, and point mkimage.sh at them. Signed-off-by: Joachim Wiberg --- board/aarch64/bananapi-bpi-r4/README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/board/aarch64/bananapi-bpi-r4/README.md b/board/aarch64/bananapi-bpi-r4/README.md index d9cb6eda7..46841d6c8 100644 --- a/board/aarch64/bananapi-bpi-r4/README.md +++ b/board/aarch64/bananapi-bpi-r4/README.md @@ -332,19 +332,22 @@ matching device tree: ```bash # Build bootloaders for SD and eMMC -make x-bpi-r4-sd-boot -make x-bpi-r4-emmc-boot +make O=x-bpi-r4-sd-boot bpi_r4_sd_boot_defconfig && make O=x-bpi-r4-sd-boot +make O=x-bpi-r4-emmc-boot bpi_r4_emmc_boot_defconfig && make O=x-bpi-r4-emmc-boot # Build main system -make aarch64 +make O=x-aarch64 aarch64_defconfig && make O=x-aarch64 # Create SD card image -./utils/mkimage.sh -od bananapi-bpi-r4 +./utils/mkimage.sh -b x-bpi-r4-sd-boot -r x-aarch64 bananapi-bpi-r4 # Create eMMC image -./utils/mkimage.sh -odt emmc bananapi-bpi-r4 +./utils/mkimage.sh -b x-bpi-r4-emmc-boot -r x-aarch64 -t emmc bananapi-bpi-r4 ``` +With `-d` instead of `-b`, `mkimage.sh` downloads the bootloader from +the latest release. + [0]: https://www.kernelkit.org/posts/flashing-sdcard/ [1]: https://www.kernelkit.org/infix/latest/ [2]: https://github.com/kernelkit/infix/releases/download/latest-boot/infix-bpi-r4-sdcard.img From 05060674cff251ff6a352bf8c23151bc203445b6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 26 Apr 2026 21:22:40 +0200 Subject: [PATCH 09/37] board: add USB2 component to bpi-r4 factory-config The M.2 LTE slot uses the USB2 bus. Add it as an unlocked component so the modem hardware is accessible and visible to users in the config. Signed-off-by: Joachim Wiberg --- .../product/bananapi,bpi-r4-2g5/etc/factory-config.cfg | 7 +++++++ .../share/product/bananapi,bpi-r4/etc/factory-config.cfg | 7 +++++++ 2 files changed, 14 insertions(+) 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 5aa69df31..7b9c7b54f 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" + } } ] }, 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 9e643858c..3a6bf5d1a 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" + } } ] }, From 9fc2461d9bb6a86780c80d06f94c6e885f411927 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 7 Jun 2026 20:45:37 +0200 Subject: [PATCH 10/37] board: some sim card help on the bpi-r4 Signed-off-by: Joachim Wiberg --- board/aarch64/bananapi-bpi-r4/README.md | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/board/aarch64/bananapi-bpi-r4/README.md b/board/aarch64/bananapi-bpi-r4/README.md index 46841d6c8..6f2b5f4b0 100644 --- a/board/aarch64/bananapi-bpi-r4/README.md +++ b/board/aarch64/bananapi-bpi-r4/README.md @@ -87,6 +87,45 @@ Infix ships with the following factory defaults. > are bridged into the LAN as access points. WiFi is not included with the board > and is not part of the factory default configuration. +## SIM Card Installation + +The BPI-R4 has three SIM holders, one per PCIe slot: + +| Slot | PCIe socket | Typical use | +|----------|-------------|--------------------------| +| **SIM1** | M.2 Key-B | 4G/5G WWAN modem | +| **SIM2** | mPCIe #1 | Cellular or other modem | +| **SIM3** | mPCIe #2 | Cellular or other modem | + +All three take **nano-SIM (4FF)** cards. + +> [!NOTE] +> The official Banana Pi pages show an older board revision with a sliding +> "sled" holder. Newer boards ship with a fixed friction-fit holder instead: +> there is no lid to lift and no spring eject. The [closest matching photo on +> bananapi.org][15] shows the current mechanism. + +### Inserting a SIM + +1. Orient the card with the gold contacts facing the PCB (printed + side up) and the cut corner pointing toward the M.2/mPCIe socket, + which on this board is also toward the outer edge. The silkscreen + next to each holder shows the same outline. +2. Push the SIM straight in until it is fully seated. There is no + click and the holder is not spring-loaded, so press firmly until + the contacts engage and the rear edge of the card sits flush with + the holder. +3. A SIM that still protrudes is not fully inserted; push again. + +### Removing a SIM + +The friction grip on these holders is tight. Gently pry the card out +with a fingernail or a thin plastic spudger. Avoid metal tweezers +near the contacts. Removing the front panel of the metal case is +absolutely necessary to access the SIM holder. + +For software configuration once the modem is recognised, see the +[Cellular Modem User's Guide][16]. ## Getting Started @@ -361,3 +400,5 @@ the latest release. [12]: https://github.com/frank-w/u-boot/releases [13]: https://github.com/frank-w/u-boot/releases/download/CI-BUILD-2026-01-bpi-2026.01-2026-01-15_2013/bpi-r4_spim-nand_bl2.img [14]: https://github.com/frank-w/u-boot/releases/download/CI-BUILD-2026-01-bpi-2026.01-2026-01-15_2013/bpi-r4_spim-nand_fip.bin +[15]: https://docs.banana-pi.org/bpi-r4/bpi-r4_4g5g_2.jpg +[16]: https://www.kernelkit.org/infix/latest/modem/ From abf02b930813d132c9b8da2dd9f56c8c696075c1 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 7 Jun 2026 14:00:25 +0200 Subject: [PATCH 11/37] confd: fix debug mode regression In the last major refactor of confd to a stand-alone daemon, new command line options were added, one of which -v . Unfortunately opening up for a few corner cases where the debug flag or the log_level was not set properly. This fixes that and also adds a CONFD_ARGS for /etc/default/confd, which is customary for all daemons. Signed-off-by: Joachim Wiberg --- board/common/rootfs/etc/default/confd | 4 ++++ package/confd/confd.conf | 14 ++++++-------- src/confd/src/main.c | 1 + 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/board/common/rootfs/etc/default/confd b/board/common/rootfs/etc/default/confd index 7919fb441..3352c8086 100644 --- a/board/common/rootfs/etc/default/confd +++ b/board/common/rootfs/etc/default/confd @@ -1 +1,5 @@ CONFD_TIMEOUT=60 +#DEBUG=1 + +# -v debug +CONFD_ARGS="" diff --git a/package/confd/confd.conf b/package/confd/confd.conf index cc8d05720..1737f2941 100644 --- a/package/confd/confd.conf +++ b/package/confd/confd.conf @@ -1,11 +1,9 @@ -#set DEBUG=1 - # Single daemon handles gen-config, datastore init, config load, and plugins -# log:prio:daemon.err -service log:console env:/etc/default/confd \ +service log:console env:/etc/default/confd \ [S12345] confd -f -v warning \ - -F /etc/factory-config.cfg \ - -S /cfg/startup-config.cfg \ - -E /etc/failure-config.cfg \ - -t $CONFD_TIMEOUT \ + -F /etc/factory-config.cfg \ + -S /cfg/startup-config.cfg \ + -E /etc/failure-config.cfg \ + -t $CONFD_TIMEOUT \ + $CONFD_ARGS \ -- Configuration daemon diff --git a/src/confd/src/main.c b/src/confd/src/main.c index 6e24690c4..a8df87ecd 100644 --- a/src/confd/src/main.c +++ b/src/confd/src/main.c @@ -820,6 +820,7 @@ int main(int argc, char **argv) if (getenv("DEBUG")) { log_opts |= LOG_PERROR; + log_level = LOG_DEBUG; debug = 1; } openlog("confd", log_opts, LOG_DAEMON); From 20f45a5f6d8ae0894cbc299d07af0993e5984964 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 26 Apr 2026 21:22:33 +0200 Subject: [PATCH 12/37] Add cellular modem support via modemd daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates the modem management subsystem, donated by Avvero Pty Ltd, adapted to work on any platform without proprietary hardware (simctrl, modules.json, /sys/class/sim/). Components: - package/feature-modem: Buildroot feature package pulling in modemd, ModemManager, libmbim, libqmi, and USB serial/MBIM/QMI kernel drivers - package/modemd: Buildroot package for the modemd daemon and helpers - src/modemd: modem management daemon — detects modems via ModemManager, connects bearers, configures IP addresses, handles SMS and location - src/confd/src/modem.c: confd plugin — enables/disables modemd and modem-manager via sysrepo, forwards RPCs (restart/reset/send-sms) - src/confd/src/interfaces.{c,h}: IFT_MODEM type for wwan* interfaces; confd manages address/route assignment, modem daemon owns link state - src/confd/yang/confd/infix-modem.yang: YANG model for modem config and operational state (bearers, APN, bands, modes, location, SMS) - src/statd/python/yanger/infix_modem.py: operational state collector - patches/modem-manager: name multiplexed links after the data port, and trust the ID_MM_PORT_TYPE_MBIM udev tag (fixes Dell DW5811e / Sierra EM7455 on cold boot), from the kkit-1.22.0 branch of our fork - patches/libqmi: skip the sysfs path check in download mode, from the kkit-1.34.0 branch of our fork Platform portability fixes for boards without simctrl hardware: - modem-info: fall back to /run/modems.json when /run/modules.json is absent; discover wwan interfaces via sysfs scan; provide synthetic SIM entry when /sys/class/sim/ is not present; fix dbg() to respect flag - modemd: make --set-allowed-modes=any best-effort (some modems only support one mode combination and reject any change); skip mode set when current mode already matches requested mode - sim-setup: guard simctrl open, return None when modules.json absent to skip SIM slot switching entirely on non-simctrl hardware Signed-off-by: Joachim Wiberg --- configs/aarch64_defconfig | 1 + package/Config.in | 2 + package/feature-modem/Config.in | 19 + package/feature-modem/feature-modem.mk | 25 + package/modemd/Config.in | 6 + package/modemd/modemd.hash | 2 + package/modemd/modemd.mk | 44 + .../etc/finit.d/available/modem-manager.conf | 1 + ...ate-don-t-match-sysfs-path-in-downlo.patch | 41 + ...multiplexed-links-after-the-data-por.patch | 39 + ...st-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch | 44 + src/confd/src/Makefile.am | 1 + src/confd/src/core.c | 4 +- src/confd/src/core.h | 18 + src/confd/src/interfaces.c | 19 +- src/confd/src/interfaces.h | 5 + src/confd/src/modem.c | 330 ++++ src/confd/yang/confd.inc | 1 + src/confd/yang/confd/infix-modem.yang | 1089 +++++++++++ .../yang/confd/infix-modem@2024-03-15.yang | 1 + src/modemd/77-mm-dell-port-types.rules | 39 + src/modemd/LICENSE | 27 + src/modemd/finit.conf | 1 + src/modemd/modem-carrier | 314 ++++ src/modemd/modem-command.c | 617 +++++++ src/modemd/modem-info | 432 +++++ src/modemd/modem-power | 86 + src/modemd/modem-rpc | 43 + src/modemd/modem-scan-networks | 79 + src/modemd/modem-sms | 136 ++ src/modemd/modem-udev | 294 +++ src/modemd/modem-update-firmware | 389 ++++ src/modemd/modem-ussd | 72 + src/modemd/modemd | 1613 +++++++++++++++++ src/modemd/modemd.modules-load | 2 + src/modemd/modemd.rules | 8 + src/modemd/qmi-wwan-ids.rules | 7 + src/modemd/sim-setup | 240 +++ src/statd/python/yanger/__main__.py | 3 + src/statd/python/yanger/infix_modem.py | 12 + 40 files changed, 6102 insertions(+), 4 deletions(-) create mode 100644 package/feature-modem/Config.in create mode 100644 package/feature-modem/feature-modem.mk create mode 100644 package/modemd/Config.in create mode 100644 package/modemd/modemd.hash create mode 100644 package/modemd/modemd.mk create mode 100644 package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf create mode 100644 patches/libqmi/1.34.0/0001-qmi-firmware-update-don-t-match-sysfs-path-in-downlo.patch create mode 100644 patches/modem-manager/1.22.0/0001-bearer-qmi-name-multiplexed-links-after-the-data-por.patch create mode 100644 patches/modem-manager/1.22.0/0002-port-probe-trust-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch create mode 100644 src/confd/src/modem.c create mode 100644 src/confd/yang/confd/infix-modem.yang create mode 120000 src/confd/yang/confd/infix-modem@2024-03-15.yang create mode 100644 src/modemd/77-mm-dell-port-types.rules create mode 100644 src/modemd/LICENSE create mode 100644 src/modemd/finit.conf create mode 100755 src/modemd/modem-carrier create mode 100644 src/modemd/modem-command.c create mode 100755 src/modemd/modem-info create mode 100755 src/modemd/modem-power create mode 100755 src/modemd/modem-rpc create mode 100755 src/modemd/modem-scan-networks create mode 100755 src/modemd/modem-sms create mode 100755 src/modemd/modem-udev create mode 100755 src/modemd/modem-update-firmware create mode 100755 src/modemd/modem-ussd create mode 100755 src/modemd/modemd create mode 100644 src/modemd/modemd.modules-load create mode 100644 src/modemd/modemd.rules create mode 100644 src/modemd/qmi-wwan-ids.rules create mode 100755 src/modemd/sim-setup create mode 100644 src/statd/python/yanger/infix_modem.py diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index 4680acd20..29d283015 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -153,6 +153,7 @@ IX_HOME="https://github.com/kernelkit/infix/" IX_DOC="https://www.kernelkit.org/infix/" IX_SUPPORT="mailto:kernelkit@googlegroups.com" BR2_PACKAGE_FEATURE_GPS=y +BR2_PACKAGE_FEATURE_MODEM=y BR2_PACKAGE_FEATURE_WIFI=y BR2_PACKAGE_FEATURE_WIFI_MEDIATEK=y BR2_PACKAGE_FEATURE_WIFI_QUALCOMM=y diff --git a/package/Config.in b/package/Config.in index 88810954c..d75c10698 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" @@ -32,6 +33,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/libsrx/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/lowdown/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/mbedtls-atf/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/mcd/Config.in" +source "$BR2_EXTERNAL_INFIX_PATH/package/modemd/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/mdns-alias/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/netbrowse/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/onieprom/Config.in" diff --git a/package/feature-modem/Config.in b/package/feature-modem/Config.in new file mode 100644 index 000000000..df69bad31 --- /dev/null +++ b/package/feature-modem/Config.in @@ -0,0 +1,19 @@ +config BR2_PACKAGE_FEATURE_MODEM + bool "Feature Modem" + select BR2_PACKAGE_MODEMD + select BR2_PACKAGE_MODEM_MANAGER + select BR2_PACKAGE_MODEM_MANAGER_LIBMBIM + select BR2_PACKAGE_MODEM_MANAGER_LIBQMI + select BR2_PACKAGE_MODEM_MANAGER_LIBQRTR + help + Enables cellular modem support via ModemManager and the + modemd management daemon. Includes drivers for common + USB option modems and QMI/MBIM-based devices. + +config BR2_PACKAGE_FEATURE_MODEM_QUALCOMM + bool "Qualcomm-based modems (QMI/QRTR/MHI)" + depends on BR2_PACKAGE_FEATURE_MODEM + help + Adds kernel support for Qualcomm-based cellular modems that use + the MHI bus and QRTR IPC router (e.g. Sierra Wireless EM7xxx, + Quectel EM/RMxxx, Telit LN9xx). diff --git a/package/feature-modem/feature-modem.mk b/package/feature-modem/feature-modem.mk new file mode 100644 index 000000000..d5a7827ba --- /dev/null +++ b/package/feature-modem/feature-modem.mk @@ -0,0 +1,25 @@ +################################################################################ +# +# Cellular modem support +# +################################################################################ + +FEATURE_MODEM_PACKAGE_VERSION = 1.0 +FEATURE_MODEM_PACKAGE_LICENSE = MIT + +define FEATURE_MODEM_LINUX_CONFIG_FIXUPS + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL_WWAN) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL_OPTION) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_WDM) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_QMI_WWAN) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_CDC_MBIM) + + $(if $(filter y,$(BR2_PACKAGE_FEATURE_MODEM_QUALCOMM)), + $(call KCONFIG_SET_OPT,CONFIG_QRTR,m) + $(call KCONFIG_SET_OPT,CONFIG_MHI_BUS,m) + $(call KCONFIG_ENABLE_OPT,CONFIG_QRTR_MHI) + ) +endef + +$(eval $(generic-package)) diff --git a/package/modemd/Config.in b/package/modemd/Config.in new file mode 100644 index 000000000..0b8ddafe0 --- /dev/null +++ b/package/modemd/Config.in @@ -0,0 +1,6 @@ +config BR2_PACKAGE_MODEMD + bool "modemd" + select BR2_PACKAGE_MODEM_MANAGER + select BR2_PACKAGE_PYTHON3 + help + Daemon which manages modems. diff --git a/package/modemd/modemd.hash b/package/modemd/modemd.hash new file mode 100644 index 000000000..db4831e36 --- /dev/null +++ b/package/modemd/modemd.hash @@ -0,0 +1,2 @@ +# Locally calculated +sha256 25b33026a661c4c550374cfcba6890a4363bf19db0c1c31a6e65b5edd113ecf0 LICENSE diff --git a/package/modemd/modemd.mk b/package/modemd/modemd.mk new file mode 100644 index 000000000..8b84dfacd --- /dev/null +++ b/package/modemd/modemd.mk @@ -0,0 +1,44 @@ +################################################################################ +# +# modemd +# +################################################################################ + +MODEMD_VERSION = 1.0 +MODEMD_SITE_METHOD = local +MODEMD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/modemd +MODEMD_LICENSE = BSD-3-Clause +MODEMD_LICENSE_FILES = LICENSE +MODEMD_REDISTRIBUTE = NO +MODEMD_DEPENDENCIES = modem-manager jansson python3 + +define MODEMD_BUILD_CMDS + $(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \ + $(MODEMD_DIR)/modem-command.c -o $(MODEMD_DIR)/modem-command -ljansson +endef + +define MODEMD_INSTALL_TARGET_CMDS + mkdir -p $(TARGET_DIR)/usr/libexec/modemd + mkdir -p $(TARGET_DIR)/lib/udev/rules.d + mkdir -p $(FINIT_D)/available/ + mkdir -p $(TARGET_DIR)/sbin + $(INSTALL) -D -m 0644 $(MODEMD_DIR)/finit.conf $(FINIT_D)/available/modemd.conf + install -m 644 $(MODEMD_DIR)/modemd.rules $(TARGET_DIR)/lib/udev/rules.d/90-modemd.rules + install -m 644 $(MODEMD_DIR)/qmi-wwan-ids.rules $(TARGET_DIR)/lib/udev/rules.d/91-qmi-wwan-ids.rules + install -m 644 $(MODEMD_DIR)/77-mm-dell-port-types.rules $(TARGET_DIR)/etc/udev/rules.d/77-mm-dell-port-types.rules + install -D -m 644 $(MODEMD_DIR)/modemd.modules-load $(TARGET_DIR)/etc/modules-load.d/modemd.conf + install -m 755 $(MODEMD_DIR)/modem-udev $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modemd $(TARGET_DIR)/sbin/modemd + install -m 755 $(MODEMD_DIR)/modem-command $(TARGET_DIR)/sbin/modem-command + install -m 755 $(MODEMD_DIR)/modem-info $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-rpc $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-sms $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-scan-networks $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-update-firmware $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-ussd $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-carrier $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/modem-power $(TARGET_DIR)/usr/libexec/modemd/ + install -m 755 $(MODEMD_DIR)/sim-setup $(TARGET_DIR)/usr/libexec/modemd/ +endef + +$(eval $(generic-package)) diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf new file mode 100644 index 000000000..8c060e2df --- /dev/null +++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf @@ -0,0 +1 @@ +service [2345789] ModemManager -- ModemManager daemon diff --git a/patches/libqmi/1.34.0/0001-qmi-firmware-update-don-t-match-sysfs-path-in-downlo.patch b/patches/libqmi/1.34.0/0001-qmi-firmware-update-don-t-match-sysfs-path-in-downlo.patch new file mode 100644 index 000000000..6d4585d3e --- /dev/null +++ b/patches/libqmi/1.34.0/0001-qmi-firmware-update-don-t-match-sysfs-path-in-downlo.patch @@ -0,0 +1,41 @@ +From 98c48c5867b3d52542988876f7e81f10d343b50b Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Sat, 26 Sep 2026 11:30:04 +0200 +Subject: [PATCH] qmi-firmware-update: don't match sysfs path in download mode +Organization: Wires + +After a reset into download mode the modem may re-enumerate under +another root hub or host controller, e.g. usb4 -> usb3. It then no +longer matches the sysfs path recorded before the reset, so +qmi-firmware-update never finds it. + +Skip the sysfs path check when looking for a device in download mode. +With more than one modem in download mode at the same time, the first +one found is used. + +Originally from the modem support donated by Avvero Pty Ltd. + +Signed-off-by: Joachim Wiberg +--- + src/qmi-firmware-update/qfu-helpers-udev.c | 5 ++++- + 1 file changed, 4 insertions(+), 1 deletion(-) + +diff --git a/src/qmi-firmware-update/qfu-helpers-udev.c b/src/qmi-firmware-update/qfu-helpers-udev.c +index bda91061..cd25e0bc 100644 +--- a/src/qmi-firmware-update/qfu-helpers-udev.c ++++ b/src/qmi-firmware-update/qfu-helpers-udev.c +@@ -364,7 +364,10 @@ device_matches (GUdevDevice *device, + if (!device_sysfs_path) + goto out; + +- if (g_strcmp0 (device_sysfs_path, sysfs_path) != 0) ++ /* In download mode the device may re-enumerate on another root hub, ++ * e.g. usb4 -> usb3, so its sysfs path no longer matches */ ++ if (mode != QFU_HELPERS_DEVICE_MODE_DOWNLOAD && ++ g_strcmp0 (device_sysfs_path, sysfs_path) != 0) + goto out; + + if (device_mode != mode) +-- +2.43.0 + diff --git a/patches/modem-manager/1.22.0/0001-bearer-qmi-name-multiplexed-links-after-the-data-por.patch b/patches/modem-manager/1.22.0/0001-bearer-qmi-name-multiplexed-links-after-the-data-por.patch new file mode 100644 index 000000000..ee9a2f3ac --- /dev/null +++ b/patches/modem-manager/1.22.0/0001-bearer-qmi-name-multiplexed-links-after-the-data-por.patch @@ -0,0 +1,39 @@ +From 50b47c45e3979745adf84d92000c955b8d0631c9 Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Sat, 26 Sep 2026 11:13:00 +0200 +Subject: [PATCH 1/2] bearer-qmi: name multiplexed links after the data port +Organization: Wires + +Multiplexed QMI links are named qmapmux., where N is the D-Bus +id of the modem object. A new id is assigned each time the modem is +re-probed, e.g., after a reset or replug, so the link name changes and +tells nothing about which interface it belongs to. + +Use the data port name as prefix instead, so links on wwan0 become +wwan0.1, wwan0.2, ... + +Originally from the modem support donated by Avvero Pty Ltd. + +Signed-off-by: Joachim Wiberg +--- + src/mm-bearer-qmi.c | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/src/mm-bearer-qmi.c b/src/mm-bearer-qmi.c +index 54f2e934..2f27b074 100644 +--- a/src/mm-bearer-qmi.c ++++ b/src/mm-bearer-qmi.c +@@ -2493,8 +2493,8 @@ load_settings_from_bearer (MMBearerQmi *self, + return FALSE; + } + +- /* 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))); ++ /* Name links after the data port, e.g. wwan0.1, not qmapmux0.1 */ ++ ctx->link_prefix_hint = g_strdup_printf ("%s.", mm_port_get_device (ctx->data)); + } + + /* If profile id is given, we'll launch the connection specifying the profile id in use +-- +2.43.0 + diff --git a/patches/modem-manager/1.22.0/0002-port-probe-trust-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch b/patches/modem-manager/1.22.0/0002-port-probe-trust-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch new file mode 100644 index 000000000..1347a00a9 --- /dev/null +++ b/patches/modem-manager/1.22.0/0002-port-probe-trust-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch @@ -0,0 +1,44 @@ +From 3d9719aa8b78ab4dc6a1a9767f8050bfda320d36 Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Sat, 26 Sep 2026 11:13:00 +0200 +Subject: [PATCH 2/2] port-probe: trust the ID_MM_PORT_TYPE_MBIM udev tag +Organization: Wires + +When a udev rule explicitly sets ID_MM_PORT_TYPE_MBIM=1 on a port, +treat it as definitive and skip the active MBIM probe. This mirrors +how ID_MM_PORT_TYPE_GPS is handled. Ports that are only guessed to be +MBIM, from their driver or wwan port type, are still probed. + +The active MBIM probe opens the WDM device and sends an MBIM OPEN +message. Some modems, e.g., Dell DW5811e (Sierra Wireless EM7455 in +USB composition 8), keep sending QMI/QMUX framed notifications on the +MBIM control channel before ever answering MBIM OPEN, so the probe +times out after ~50 s. mm_port_probe_list_has_mbim_port() then +returns FALSE, the Dell plugin creates a non-MBIM modem object, and +the usbmisc port ends up as "unhandled port type". + +A udev rule that sets the tag already matches on vendor, product and +interface, so the active probe adds no information for such a port. + +Signed-off-by: Joachim Wiberg +--- + src/mm-port-probe.c | 3 +++ + 1 file changed, 3 insertions(+) + +diff --git a/src/mm-port-probe.c b/src/mm-port-probe.c +index 85f30e60..d3be5ed4 100644 +--- a/src/mm-port-probe.c ++++ b/src/mm-port-probe.c +@@ -1529,6 +1529,9 @@ 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"); ++ /* An explicit udev tag is definitive, skip the MBIM probe too */ ++ if (mm_kernel_device_get_property_as_boolean (self->priv->port, ID_MM_PORT_TYPE_MBIM)) ++ 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); +-- +2.43.0 + diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index b47ec290a..9f3e16743 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -51,6 +51,7 @@ confd_plugin_la_SOURCES = \ if-wifi.c \ if-wireguard.c \ if-ppp.c \ + modem.c \ keystore.c \ system.c \ support.c \ diff --git a/src/confd/src/core.c b/src/confd/src/core.c index fcdc7e4ff..583153b50 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -1089,7 +1089,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 cec5f040f..24bbc2296 100644 --- a/src/confd/src/core.h +++ b/src/confd/src/core.h @@ -137,6 +137,10 @@ typedef enum { if ((rc = register_rpc_tree(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 */ @@ -213,6 +217,17 @@ static inline int register_rpc_tree(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); @@ -325,4 +340,7 @@ int snmp_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_n static inline int snmp_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd) { return 0; } #endif +/* modem.c */ +int modem_init(struct confd *confd); + #endif /* CONFD_CORE_H_ */ diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c index f84ec4edf..b1e940db5 100644 --- a/src/confd/src/interfaces.c +++ b/src/confd/src/interfaces.c @@ -104,6 +104,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)) @@ -420,6 +422,8 @@ static int netdag_gen_afspec_add(sr_session_ctx_t *session, struct dagger *net, return vlan_gen(NULL, cif, ip); case IFT_VXLAN: return vxlan_gen(NULL, cif, ip); + case IFT_MODEM: + return modem_gen(NULL, cif, net); case IFT_WIFI: return wifi_validate_secret(session, cif) ? : wifi_add_iface(cif, net); @@ -463,6 +467,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: @@ -503,6 +508,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_PPPOE: @@ -590,6 +597,9 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif, case IFT_VETH: veth_gen_del(dif, ip); break; + case IFT_MODEM: + modem_gen_del(dif, net); + break; case IFT_WIFI: wifi_del_iface(dif, net); break; @@ -637,7 +647,8 @@ static int veth_gen_host(struct dagger *net, struct lyd_node *dif, struct lyd_no static sr_error_t netdag_gen_iface_timeout(struct dagger *net, const char *ifname, const char *iftype) { - if (!strcmp(iftype, "infix-if-type:ethernet")) { + if (!strcmp(iftype, "infix-if-type:ethernet") || + !strcmp(iftype, "infix-if-type:modem")) { FILE *wait; wait = dagger_fopen_net_init(net, ifname, NETDAG_INIT_TIMEOUT, "wait-interface.sh"); @@ -771,8 +782,9 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net attr = lydx_get_cattr(cif, "description"); fprintf(ip, "link set alias \"%s\" dev %s\n", attr ?: "", ifname); - /* Bring interface back up, if enabled, unless pppd or hostapd owns it */ - if (lydx_is_enabled(cif, "enabled") && !pppd_owned && !wds) + /* Bring interface back up, if enabled, unless pppd, hostapd or modemd owns it */ + if (lydx_is_enabled(cif, "enabled") && !pppd_owned && !wds && + strcmp(iftype, "infix-if-type:modem")) fprintf(ip, "link set dev %s up state up\n", ifname); err = err ? : netdag_gen_sysctl(net, cif, dif); @@ -805,6 +817,7 @@ static int netdag_init_iface(struct lyd_node *cif) return veth_add_deps(cif); case IFT_WIFI: return wifi_add_deps(cif); + case IFT_MODEM: case IFT_DUMMY: case IFT_ETH: case IFT_GRE: diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h index fc8b0f4df..13db8e3d9 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") \ @@ -180,6 +181,10 @@ int ppp_gen(sr_session_ctx_t *session, struct lyd_node *dif, struct lyd_node *ci int ppp_del(struct lyd_node *dif, struct dagger *net); int ppp_add_deps(struct lyd_node *cif); +/* modem.c */ +int modem_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net); +int modem_gen_del(struct lyd_node *dif, struct dagger *net); + /* infix-if-wireguard */ int wireguard_validate_peers(sr_session_ctx_t *session, struct lyd_node *cif); int wireguard_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip, struct dagger *net); diff --git a/src/confd/src/modem.c b/src/confd/src/modem.c new file mode 100644 index 000000000..a7d83ddd2 --- /dev/null +++ b/src/confd/src/modem.c @@ -0,0 +1,330 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "core.h" + +#define MAX_MODEMS 8 +#define RUN_DIR "/run/modemd" +#define SOCK RUN_DIR "/modemd.sock" + +#define MODULE "infix-modem" +#define ROOT_XPATH "/infix-modem:" +#define CFG_XPATH ROOT_XPATH "modems" + + +static int xpath_get_index(const char *xpath) +{ + regmatch_t pmatch[2]; + regex_t regex; + char buf[32]; + int ret, len, i; + int index = -1; + + if (regcomp(®ex, "index='([0-9]+)'", REG_EXTENDED)) + return -1; + + ret = regexec(®ex, xpath, 2, pmatch, 0); + if (!ret) { + len = (pmatch[1].rm_eo - pmatch[1].rm_so); + if (len < (int)(sizeof(buf)-1)) { + for (i = 0; i < len; i++) + buf[i] = xpath[pmatch[1].rm_so + i]; + + buf[i] = '\0'; + index = (int) strtoul(buf, NULL, 10); + } + } + regfree(®ex); + + return index; +} + +static int node_index(struct lyd_node *node) +{ + const char *s; + + s = lydx_get_cattr(node, "index"); + if (!s || !s[0]) + return -1; + + return (int) strtoul(s, NULL, 10); +} + +static int disable(void) +{ + NOTE("Disabling modemd"); + + /* disable modem-manager */ + systemf("initctl -bfqn stop modem-manager"); + systemf("initctl -bfqn disable modem-manager"); + + /* disable modemd */ + systemf("initctl -bnq stop modemd"); + systemf("initctl -bnq disable modemd"); + + return SR_ERR_OK; +} + +static int enable(void) +{ + int enabled, reload = 0; + + NOTE("Enabling modemd"); + + /* enable modem-manager */ + enabled = !systemf("initctl -bfq status modem-manager"); + if (!enabled) { + systemf("initctl -bfqn enable modem-manager"); + reload = 1; + } + /* enable modemd */ + enabled = !systemf("initctl -bfq status modemd"); + if (!enabled) { + systemf("initctl -bfqn enable modemd"); + reload = 1; + } + /* reload if required */ + if (reload) + systemf("initctl -b reload"); + + /* restart modem-manager */ + systemf("initctl -bfqn restart modem-manager"); + + /* restart modemd */ + systemf("initctl -bfqn restart modemd"); + + return SR_ERR_OK; +} + +static int genconf(sr_data_t *cfg, struct lyd_node *diff) +{ + struct lyd_node *node, *tree; + uint8_t enabled[MAX_MODEMS]; + int index; + + memset(enabled, 0, sizeof(enabled)); + + tree = lydx_get_descendant(cfg->tree, "modems", "modem", NULL); + LYX_LIST_FOR_EACH(tree, node, "modem") { + index = node_index(node); + if (index < MAX_MODEMS) + enabled[index] = lydx_get_bool(node, "enabled") ? 1 : 0; + } + + tree = lydx_get_descendant(diff, "modems", "modem", NULL); + LYX_LIST_FOR_EACH(tree, node, "modem") { + index = node_index(node); + if (index < MAX_MODEMS && lydx_get_op(node) == LYDX_OP_DELETE) + enabled[index] = 0; + } + + for (index = 0; index < MAX_MODEMS; index++) { + if (enabled[index]) { + if (enable() == SR_ERR_OK) { + return SR_ERR_OK; + } else { + ERROR("Cannot enable modem%d", index); + break; + } + } + } + + return disable(); +} + +static int infix_modem_change(sr_session_ctx_t *session, uint32_t sub_id, const char *module, + const char *xpath, sr_event_t event, unsigned request_id, void *confd) +{ + sr_data_t *cfg = NULL; + struct lyd_node *diff = NULL; + sr_error_t err; + + if (event != SR_EV_DONE) { + return SR_ERR_OK; + } + err = sr_get_data(session, CFG_XPATH "//.", 0, 0, 0, &cfg); + if (err) { + ERROR("Can't get data"); + goto out; + } + err = srx_get_diff(session, &diff); + if (err) { + ERROR("Can't get diff"); + goto out; + } + err = genconf(cfg, diff); + if (err) { + ERROR("Can't gen conf"); + goto out; + } +out: + if (diff) lyd_free_tree(diff); + if (cfg) sr_release_data(cfg); + + return err; +} + +static int infix_modem_rpcsend(char *msg, int len) +{ + struct sockaddr_un addr; + struct timeval tv; + fd_set wfds; + int sock, ret = -1; + + sock = socket(AF_UNIX, SOCK_STREAM, 0); + if (sock < 0) + return -1; + + addr.sun_family = AF_UNIX; + strcpy(addr.sun_path, SOCK); + + if (connect(sock, &addr, sizeof(addr)) == 0) { + tv.tv_sec = 5; + tv.tv_usec = 0; + FD_ZERO(&wfds); + FD_SET(sock, &wfds); + + if (select(sock + 1, NULL, &wfds, NULL, &tv) > 0) { + if (write(sock, msg, len) == len) { + ret = 0; + } + } + } + close(sock); + + return ret; +} + +static int infix_modem_rpc(const char *xpath, const char *rpc, const char *data) +{ + char msg[1024]; + int len; + + NOTE("Sending rpc %s to modemd", rpc); + + len = snprintf(msg, sizeof(msg), + "{ \"rpc\" : \"%s\", \"data\" : %s }", + rpc, data ? data : "null"); + + if (infix_modem_rpcsend(msg, len) < 0) { + ERROR("Unable to send rpc"); + return SR_ERR_INTERNAL; + } + + return SR_ERR_OK; +} + +static int infix_modem_sendsms(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) +{ + char data[1024]; + + if (input_cnt < 3) { + ERROR("Not enough input parameters"); + return SR_ERR_SYS; + } + + snprintf(data, sizeof(data)-1, + "{ \"index\" : %s, \"number\" : \"%s\", \"text\" : \"%s\" }", + input[0].data.string_val, + input[1].data.string_val, + input[2].data.string_val); + + return infix_modem_rpc(xpath, "send-sms", data); +} + +static int infix_modem_restart(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) +{ + char data[1024]; + + if (input_cnt < 1) { + ERROR("Not enough input parameters"); + return SR_ERR_SYS; + } + + snprintf(data, sizeof(data)-1, + "{ \"index\" : %s }", input[0].data.string_val); + + return infix_modem_rpc(xpath, "restart", data); +} + +static int infix_modem_reset(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) +{ + char data[1024]; + + if (input_cnt < 1) { + ERROR("Not enough input parameters"); + return SR_ERR_SYS; + } + + snprintf(data, sizeof(data)-1, + "{ \"index\" : %s }", input[0].data.string_val); + + return infix_modem_rpc(xpath, "reset", data); +} + +static void infix_modem_notif (sr_session_ctx_t *session, uint32_t sub_id, + const sr_ev_notif_type_t notif_type, const char *xpath, + const sr_val_t *values, const size_t values_cnt, + struct timespec *timestamp, void *confd) +{ + int index; + + index = xpath_get_index(xpath); + if (index < 0) { + ERROR("No index"); + return; + } + if (values_cnt < 1) { + ERROR("No values"); + return; + } + + NOTE("Notification from modem%d: %s", + index, values[0].data.string_val); +} + +int modem_init(struct confd *confd) +{ + int rc; + + REGISTER_CHANGE(confd->session, MODULE, CFG_XPATH, 0, infix_modem_change, confd, &confd->sub); + REGISTER_NOTIF(confd->session, MODULE, CFG_XPATH "/modem/status-update", infix_modem_notif, confd, &confd->sub); + REGISTER_RPC(confd->session, ROOT_XPATH "restart", infix_modem_restart, NULL, &confd->sub); + REGISTER_RPC(confd->session, ROOT_XPATH "reset", infix_modem_reset, NULL, &confd->sub); + REGISTER_RPC(confd->session, ROOT_XPATH "send-sms", infix_modem_sendsms, NULL, &confd->sub); + + return SR_ERR_OK; +fail: + ERROR("init failed: %s", sr_strerror(rc)); + return rc; +} + +int modem_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net) +{ + return 0; +} + +int modem_gen_del(struct lyd_node *dif, struct dagger *net) +{ + return 0; +} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 16e3f13e9..ff5b8b7ff 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -59,4 +59,5 @@ MODULES=( "infix-ptp@2026-04-07.yang" "ietf-schedule@2026-03-10.yang -e icalendar-recurrence" "infix-schedule@2026-09-07.yang" + "infix-modem@2024-03-15.yang" ) diff --git a/src/confd/yang/confd/infix-modem.yang b/src/confd/yang/confd/infix-modem.yang new file mode 100644 index 000000000..f0a52fe68 --- /dev/null +++ b/src/confd/yang/confd/infix-modem.yang @@ -0,0 +1,1089 @@ +/* + * Infix Modem YANG module + */ + +module infix-modem { + yang-version 1.1; + namespace "urn:infix:params:xml:ns:yang:infix-modem"; + prefix "infix-modem"; + + import ietf-inet-types { + prefix "inet"; + } + import ietf-interfaces { + prefix "if"; + } + import ietf-yang-types { + prefix yang; + } + import ietf-netconf-acm { + prefix nacm; + } + + organization "KernelKit"; + contact "kernelkit@googlegroups.com"; + description "YANG data model for modems."; + + revision 2024-03-15 { + description "Initial revision"; + reference "internal"; + } + + container modems { + description "Configuration of modems."; + + list modem { + key "index"; + unique sim; + description "List of modems"; + + leaf index { + type uint8; + description "Index of the modem."; + } + leaf enabled { + type boolean; + description "Enable or disable modem."; + default false; + } + leaf path { + type string; + description "Path to modem."; + config false; + } + leaf sim { + type int8; + description "Index of SIM card."; + } + leaf pin { + type string; + description "PIN code to unlock SIM card."; + nacm:default-deny-all; + } + leaf puk { + type string; + description "PUK code to unlock SIM card."; + nacm:default-deny-all; + } + + leaf preferred-mode { + type access-mode; + description "Preferred mode."; + } + list allowed-mode { + key "mode"; + description "List of allowed modes."; + + leaf mode { + type access-mode; + description "Allowed mode."; + } + } + leaf carrier { + type string; + description "Network carrier profile."; + } + list band { + key "band"; + description "List of enabled bands."; + + leaf band { + type band; + description "Enabled band."; + } + } + list bearer { + key "index"; + description "List of bearers."; + + leaf index { + type uint8; + description "Index of the bearer."; + } + leaf apn-type { + type bearer-apn-type; + description "APN type."; + } + leaf apn { + type string; + description "Access Point Name (APN)."; + } + leaf username { + type string; + description "User name (if any) required by the network."; + default ""; + } + leaf password { + type string; + description "Password (if any) required by the network."; + default ""; + nacm:default-deny-all; + } + leaf allow-roaming { + type boolean; + description "Flag to tell whether connection is allowed during roaming."; + default false; + } + leaf ip-type { + type ip-family; + description "IP addressing type."; + default ipv4v6; + } + leaf default-route { + type boolean; + description "Set default route for connection"; + default false; + } + leaf firewall-enabled { + type boolean; + description "Enable firewall for connection."; + default false; + } + leaf dns-enabled { + type boolean; + description "Enable DNS resolver for connection."; + default true; + } + } + container location { + leaf enabled { + type boolean; + description "Enable location gathering."; + default false; + } + + list source { + key "source"; + description "List of location sources."; + + leaf source { + type location-source; + description "Source used for location gathering."; + } + } + } + + container info { + description "Modem information."; + config false; + + leaf manufacturer { + type string; + description "Manufacturer of the modem."; + } + leaf model { + type string; + description "Model name of the modem."; + } + leaf hardware-revision { + type string; + description "Hardware revision of the modem."; + } + leaf firmware-version { + type string; + description "Firmware version of the modem."; + } + leaf-list supported-carrier { + type string; + description "List of supported carriers."; + } + leaf serial-number { + type string; + description "Serial number of the modem (IMEI)."; + } + leaf-list phone-number { + type string; + description "List of own phone numbers."; + } + leaf imsi { + type string; + description "International Mobile Subscriber Identity"; + } + leaf iccid { + type string; + description "ICCID of the SIM card."; + } + } + + container status { + description "Modem Status Information."; + config false; + + leaf sim-active { + type int8; + description "Index of active SIM card."; + } + leaf sim-present { + type boolean; + description "True if SIM card is present"; + } + leaf state { + type string; + description "Modem state."; + } + leaf state-failed-reason { + type string; + description "Reason of failed modem state."; + } + leaf selected-carrier { + type string; + description "Selected carrier."; + } + leaf signal-quality { + type uint8; + description "Signal strength in percent (0-100)."; + } + leaf signal-rssi { + type string; + description "RSSI (Received Signal Strength Indication) in dBm."; + } + leaf signal-rsrp { + type string; + description "RSRP (Reference Signal Received Power) in dBm."; + } + leaf signal-rsrq { + type string; + description "RSRQ (Reference Signal Received Quality) in dB."; + } + leaf signal-rscp { + type string; + description "RSCP (Received Signal Code Power) in dBm."; + } + leaf signal-snr { + type string; + description "SNR (Signal Noise Ratio) in dB."; + } + leaf signal-sinr { + type string; + description "SINR (Signal Interference Noise Ratio) ) in dB."; + } + + container cellular { + description "Cellular network information."; + + leaf registration-state { + description "Modem registration state."; + type registration-state; + } + leaf operator-name { + type string; + description "Name of the cellular network operator."; + } + leaf operator-id { + type string; + description "Identifier of the cellular network operator (MCC/MNC)."; + } + leaf network-type { + type string; + description "Type of cellular network (e.g. LTE)"; + } + leaf packet-service-state { + description "Modem packet service state."; + type string; + } + } + + list bearer { + description "Bearer information."; + key "path"; + + leaf path { + type string; + description "Path to bearer."; + } + leaf interface { + type if:interface-ref; + description "Network interface name for the bearer."; + } + leaf connected { + type boolean; + description "Indicates whether bearer is connected."; + } + leaf connection-failed-reason { + type string; + description "Reason of failed connection of bearer."; + } + leaf ipv4-address { + type inet:ipv4-address; + description "The IPv4 address of the bearer connection."; + } + leaf ipv4-prefix { + type uint16; + description "The IPv4 prefix of the bearer connection."; + } + leaf ipv6-address { + type inet:ipv6-address; + description "The IPv6 address of the bearer connection."; + } + leaf ipv6-prefix { + type uint16; + description "The IPv6 prefix of the bearer connection."; + } + leaf in-bytes { + type yang:counter64; + description "The number of received bytes of the bearer connection."; + } + leaf out-bytes { + type yang:counter64; + description "The number of sent bytes of the bearer connection."; + } + leaf total-in-bytes { + type yang:counter64; + description "Total number of received bytes of all connections."; + } + leaf total-out-bytes { + type yang:counter64; + description "Total number of sent bytes of all connections."; + } + leaf total-duration { + type string; + description "Total duration of all connections in seconds."; + } + } + + container location { + description "Location information."; + + leaf latitude { + type string; + description "Latitude in decimal degrees."; + } + leaf longitude { + type string; + description "Longitude in decimal degrees."; + } + leaf altitude { + type string; + description "Altitude above sea level in meters."; + } + leaf mcc { + type string; + description "Mobile Country Code of the operator."; + } + leaf mnc { + type string; + description "Mobile Network Code of the operator."; + } + leaf lac { + type string; + description "Location Area Code of the operator."; + } + leaf cid { + type string; + description "Cell Identifier of the operator."; + } + leaf tac { + type string; + description "Tracking Area Code of the operator."; + } + } + } + + notification status-update { + description "Notification for modem status updates."; + leaf desc { + type string; + description "Description of the status update."; + mandatory true; + } + } + } + } + + rpc restart { + description "Restart modem."; + input { + leaf index { + type string; + description "Modem index."; + } + } + } + rpc reset { + description "Reset the modem to factory defaults."; + input { + leaf index { + type string; + description "Modem index."; + } + } + } + rpc send-sms { + description "Send an SMS message."; + input { + leaf index { + type string; + description "Modem index."; + } + leaf phone-number { + type string; + description "Recipient's phone number."; + } + leaf message-text { + type string; + description "Text of the SMS message."; + } + } + } + + typedef bearer-apn-type { + description "APN type for bearer."; + + type enumeration { + enum initial { + description "APN used for the initial attach procedure."; + } + enum default { + description "Default connection APN providing access to the Internet."; + } + enum ims { + description "APN providing access to IMS services."; + } + enum mms { + description "APN providing access to MMS services."; + } + enum management { + description "APN providing access to over-the-air device management procedures."; + } + enum voice { + description "APN providing access to voice-over-IP services."; + } + enum emergency { + description "APN providing access to emergency services."; + } + enum private { + description "APN providing access to private networks."; + } + enum purchase { + description "APN providing access to over-the-air activation sites."; + } + enum video_share { + description "APN providing access to video sharing service."; + } + enum local { + description "APN providing access to a local connection with the device."; + } + enum app { + description "APN providing access to certain applications allowed by mobile operators."; + } + enum xcap { + description "APN providing access to XCAP provisioning on IMS services."; + } + enum tethering { + description "APN providing access to mobile hotspot tethering."; + } + } + } + + typedef ip-family { + description "IP address family."; + + type enumeration { + enum ipv4 { + description "IPv4 address family."; + } + enum ipv6 { + description "IPv6 address family."; + } + enum ipv4v6 { + description "IPv4 or IPv6 address family."; + } + } + } + + typedef access-mode { + type enumeration { + enum cs { + description "Circuit-switched technologies (e.g. CSD, GSM)."; + } + enum 2g { + description "2G technologies (e.g. GPRS, EDGE)."; + } + enum 3g { + description "3G technologies (e.g. UMTS, HSxPA)."; + } + enum 4g { + description "4G technologies (e.g. LTE)."; + } + enum 5g { + description "5G technologies (e.g. 5GNR)."; + } + enum any { + description "Any technologies."; + } + } + } + + typedef registration-state { + type enumeration { + enum idle { + description "Not registered, not searching for new operator to register."; + } + enum home { + description "Registered on home network."; + } + enum searching { + description "Not registered, searching for new operator to register with."; + } + enum denied { + description "Registration denied."; + } + enum roaming { + description "Registered on a roaming network."; + } + enum unknown { + description "Unknown registration state."; + } + } + } + + typedef band { + type enumeration { + enum any { + description "Any band."; + } + enum egsm { + description "GSM/GPRS/EDGE 900 MHz."; + } + enum dcs { + description "GSM/GPRS/EDGE 1800 MHz."; + } + enum pcs { + description "GSM/GPRS/EDGE 1900 MHz."; + } + enum g850 { + description "GSM/GPRS/EDGE 850 MHz."; + } + enum g450 { + description "GSM/GPRS/EDGE 450 MHz."; + } + enum g480 { + description "GSM/GPRS/EDGE 480 MHz."; + } + enum g750 { + description "GSM/GPRS/EDGE 750 MHz."; + } + enum g380 { + description "GSM/GPRS/EDGE 380 MHz."; + } + enum g410 { + description "GSM/GPRS/EDGE 410 MHz."; + } + enum g710 { + description "GSM/GPRS/EDGE 710 MHz."; + } + enum g810 { + description "GSM/GPRS/EDGE 810 MHz."; + } + enum utran-1 { + description "UMTS 2100 MHz (IMT, UTRAN band 1)."; + } + enum utran-2 { + description "UMTS 1900 MHz (PCS A-F, UTRAN band 2)."; + } + enum utran-3 { + description "UMTS 1800 MHz (DCS, UTRAN band 3)."; + } + enum utran-4 { + description "UMTS 1700 MHz (AWS A-F, UTRAN band 4)."; + } + enum utran-5 { + description "UMTS 850 MHz (CLR, UTRAN band 5)."; + } + enum utran-6 { + description "UMTS 800 MHz (UTRAN band 6)."; + } + enum utran-7 { + description "UMTS 2600 MHz (IMT-E, UTRAN band 7)."; + } + enum utran-8 { + description "UMTS 900 MHz (E-GSM, UTRAN band 8)."; + } + enum utran-9 { + description "UMTS 1700 MHz (UTRAN band 9)."; + } + enum utran-10 { + description "UMTS 1700 MHz (EAWS A-G, UTRAN band 10)."; + } + enum utran-11 { + description "UMTS 1500 MHz (LPDC, UTRAN band 11)."; + } + enum utran-12 { + description "UMTS 700 MHz (LSMH A/B/C, UTRAN band 12)."; + } + enum utran-13 { + description "UMTS 700 MHz (USMH C, UTRAN band 13)."; + } + enum utran-14 { + description "UMTS 700 MHz (USMH D, UTRAN band 14)."; + } + enum utran-19 { + description "UMTS 800 MHz (UTRAN band 19)."; + } + enum utran-20 { + description "UMTS 800 MHz (EUDD, UTRAN band 20)."; + } + enum utran-21 { + description "UMTS 1500 MHz (UPDC, UTRAN band 21)."; + } + enum utran-22 { + description "UMTS 3500 MHz (UTRAN band 22)."; + } + enum utran-25 { + description "UMTS 1900 MHz (EPCS A-G, UTRAN band 25)."; + } + enum utran-26 { + description "UMTS 850 MHz (ECLR, UTRAN band 26)."; + } + enum utran-32 { + description "UMTS 1500 MHz (L-band, UTRAN band 32)."; + } + enum eutran-1 { + description "E-UTRAN band 1."; + } + enum eutran-2 { + description "E-UTRAN band 2."; + } + enum eutran-3 { + description "E-UTRAN band 3."; + } + enum eutran-4 { + description "E-UTRAN band 4."; + } + enum eutran-5 { + description "E-UTRAN band 5."; + } + enum eutran-6 { + description "E-UTRAN band 6."; + } + enum eutran-7 { + description "E-UTRAN band 7."; + } + enum eutran-8 { + description "E-UTRAN band 8."; + } + enum eutran-9 { + description "E-UTRAN band 9."; + } + enum eutran-10 { + description "E-UTRAN band 10."; + } + enum eutran-11 { + description "E-UTRAN band 11."; + } + enum eutran-12 { + description "E-UTRAN band 12."; + } + enum eutran-13 { + description "E-UTRAN band 13."; + } + enum eutran-14 { + description "E-UTRAN band 14."; + } + enum eutran-17 { + description "E-UTRAN band 17."; + } + enum eutran-18 { + description "E-UTRAN band 18."; + } + enum eutran-19 { + description "E-UTRAN band 19."; + } + enum eutran-20 { + description "E-UTRAN band 20."; + } + enum eutran-21 { + description "E-UTRAN band 21."; + } + enum eutran-22 { + description "E-UTRAN band 22."; + } + enum eutran-23 { + description "E-UTRAN band 23."; + } + enum eutran-24 { + description "E-UTRAN band 24."; + } + enum eutran-25 { + description "E-UTRAN band 25."; + } + enum eutran-26 { + description "E-UTRAN band 26."; + } + enum eutran-27 { + description "E-UTRAN band 27."; + } + enum eutran-28 { + description "E-UTRAN band 28."; + } + enum eutran-29 { + description "E-UTRAN band 29."; + } + enum eutran-30 { + description "E-UTRAN band 30."; + } + enum eutran-31 { + description "E-UTRAN band 31."; + } + enum eutran-32 { + description "E-UTRAN band 32."; + } + enum eutran-33 { + description "E-UTRAN band 33."; + } + enum eutran-34 { + description "E-UTRAN band 34."; + } + enum eutran-35 { + description "E-UTRAN band 35."; + } + enum eutran-36 { + description "E-UTRAN band 36."; + } + enum eutran-37 { + description "E-UTRAN band 37."; + } + enum eutran-38 { + description "E-UTRAN band 38."; + } + enum eutran-39 { + description "E-UTRAN band 39."; + } + enum eutran-40 { + description "E-UTRAN band 40."; + } + enum eutran-41 { + description "E-UTRAN band 41."; + } + enum eutran-42 { + description "E-UTRAN band 42."; + } + enum eutran-43 { + description "E-UTRAN band 43."; + } + enum eutran-44 { + description "E-UTRAN band 44."; + } + enum eutran-45 { + description "E-UTRAN band 45."; + } + enum eutran-46 { + description "E-UTRAN band 46."; + } + enum eutran-47 { + description "E-UTRAN band 47."; + } + enum eutran-48 { + description "E-UTRAN band 48."; + } + enum eutran-49 { + description "E-UTRAN band 49."; + } + enum eutran-50 { + description "E-UTRAN band 50."; + } + enum eutran-51 { + description "E-UTRAN band 51."; + } + enum eutran-52 { + description "E-UTRAN band 52."; + } + enum eutran-53 { + description "E-UTRAN band 53."; + } + enum eutran-54 { + description "E-UTRAN band 54."; + } + enum eutran-55 { + description "E-UTRAN band 55."; + } + enum eutran-56 { + description "E-UTRAN band 56."; + } + enum eutran-57 { + description "E-UTRAN band 57."; + } + enum eutran-58 { + description "E-UTRAN band 58."; + } + enum eutran-59 { + description "E-UTRAN band 59."; + } + enum eutran-60 { + description "E-UTRAN band 60."; + } + enum eutran-61 { + description "E-UTRAN band 61."; + } + enum eutran-62 { + description "E-UTRAN band 62."; + } + enum eutran-63 { + description "E-UTRAN band 63."; + } + enum eutran-64 { + description "E-UTRAN band 64."; + } + enum eutran-65 { + description "E-UTRAN band 65."; + } + enum eutran-66 { + description "E-UTRAN band 66."; + } + enum eutran-67 { + description "E-UTRAN band 67."; + } + enum eutran-68 { + description "E-UTRAN band 68."; + } + enum eutran-69 { + description "E-UTRAN band 69."; + } + enum eutran-70 { + description "E-UTRAN band 70."; + } + enum eutran-71 { + description "E-UTRAN band 71."; + } + enum eutran-85 { + description "E-UTRAN band 85."; + } + enum cdma-bc0 { + description "CDMA Band Class 0 (US Cellular 850MHz)."; + } + enum cdma-bc1 { + description "CDMA Band Class 1 (US PCS 1900MHz)."; + } + enum cdma-bc2 { + description "CDMA Band Class 2 (UK TACS 900MHz)."; + } + enum cdma-bc3 { + description "CDMA Band Class 3 (Japanese TACS)."; + } + enum cdma-bc4 { + description "CDMA Band Class 4 (Korean PCS)."; + } + enum cdma-bc5 { + description "CDMA Band Class 5 (NMT 450MHz)."; + } + enum cdma-bc6 { + description "CDMA Band Class 6 (IMT2000 2100MHz)."; + } + enum cdma-bc7 { + description "CDMA Band Class 7 (Cellular 700MHz)."; + } + enum cdma-bc8 { + description "CDMA Band Class 8 (1800MHz)."; + } + enum cdma-bc9 { + description "CDMA Band Class 9 (900MHz)."; + } + enum cdma-bc10 { + description "CDMA Band Class 10 (US Secondary 800)."; + } + enum cdma-bc11 { + description "CDMA Band Class 11 (European PAMR 400MHz)."; + } + enum cdma-bc12 { + description "CDMA Band Class 12 (PAMR 800MHz)."; + } + enum cdma-bc13 { + description "CDMA Band Class 13 (IMT2000 2500MHz Expansion)."; + } + enum cdma-bc14 { + description "CDMA Band Class 14 (More US PCS 1900MHz)."; + } + enum cdma-bc15 { + description "CDMA Band Class 15 (AWS 1700MHz)."; + } + enum cdma-bc16 { + description "CDMA Band Class 16 (US 2500MHz)."; + } + enum cdma-bc17 { + description "CDMA Band Class 17 (US 2500MHz Forward Link Only)."; + } + enum cdma-bc18 { + description "CDMA Band Class 18 (US 700MHz Public Safety)."; + } + enum cdma-bc19 { + description "CDMA Band Class 19 (US Lower 700MHz)."; + } + enum ngran-1 { + description "NGRAN band 1."; + } + enum ngran-2 { + description "NGRAN band 2."; + } + enum ngran-3 { + description "NGRAN band 3."; + } + enum ngran-5 { + description "NGRAN band 5."; + } + enum ngran-7 { + description "NGRAN band 7."; + } + enum ngran-8 { + description "NGRAN band 8."; + } + enum ngran-12 { + description "NGRAN band 12."; + } + enum ngran-13 { + description "NGRAN band 13."; + } + enum ngran-14 { + description "NGRAN band 14."; + } + enum ngran-18 { + description "NGRAN band 18."; + } + enum ngran-20 { + description "NGRAN band 20."; + } + enum ngran-25 { + description "NGRAN band 25."; + } + enum ngran-26 { + description "NGRAN band 26."; + } + enum ngran-28 { + description "NGRAN band 28."; + } + enum ngran-29 { + description "NGRAN band 29."; + } + enum ngran-30 { + description "NGRAN band 30."; + } + enum ngran-34 { + description "NGRAN band 34."; + } + enum ngran-38 { + description "NGRAN band 38."; + } + enum ngran-39 { + description "NGRAN band 39."; + } + enum ngran-40 { + description "NGRAN band 40."; + } + enum ngran-41 { + description "NGRAN band 41."; + } + enum ngran-48 { + description "NGRAN band 48."; + } + enum ngran-50 { + description "NGRAN band 50."; + } + enum ngran-51 { + description "NGRAN band 51."; + } + enum ngran-53 { + description "NGRAN band 53."; + } + enum ngran-65 { + description "NGRAN band 65."; + } + enum ngran-66 { + description "NGRAN band 66."; + } + enum ngran-70 { + description "NGRAN band 70."; + } + enum ngran-71 { + description "NGRAN band 71."; + } + enum ngran-74 { + description "NGRAN band 74."; + } + enum ngran-75 { + description "NGRAN band 75."; + } + enum ngran-76 { + description "NGRAN band 76."; + } + enum ngran-77 { + description "NGRAN band 77."; + } + enum ngran-78 { + description "NGRAN band 78."; + } + enum ngran-79 { + description "NGRAN band 79."; + } + enum ngran-80 { + description "NGRAN band 80."; + } + enum ngran-81 { + description "NGRAN band 81."; + } + enum ngran-82 { + description "NGRAN band 82."; + } + enum ngran-83 { + description "NGRAN band 83."; + } + enum ngran-84 { + description "NGRAN band 84."; + } + enum ngran-86 { + description "NGRAN band 86."; + } + enum ngran-89 { + description "NGRAN band 89."; + } + enum ngran-90 { + description "NGRAN band 90."; + } + enum ngran-91 { + description "NGRAN band 91."; + } + enum ngran-92 { + description "NGRAN band 92."; + } + enum ngran-93 { + description "NGRAN band 93."; + } + enum ngran-94 { + description "NGRAN band 94."; + } + enum ngran-95 { + description "NGRAN band 95."; + } + enum ngran-257 { + description "NGRAN band 257."; + } + enum ngran-258 { + description "NGRAN band 258."; + } + enum ngran-260 { + description "NGRAN band 260."; + } + enum ngran-261 { + description "NGRAN band 261."; + } + } + } + + typedef location-source { + type enumeration { + enum gps { + description "GPS location source."; + } + enum agps-msa { + description "MSA A-GPS location source."; + } + enum agps-msb { + description "MSB A-GPS location source."; + } + enum 3gpp { + description "3GPP location source."; + } + enum cdma { + description "CDMA location source."; + } + } + } +} diff --git a/src/confd/yang/confd/infix-modem@2024-03-15.yang b/src/confd/yang/confd/infix-modem@2024-03-15.yang new file mode 120000 index 000000000..467b07a47 --- /dev/null +++ b/src/confd/yang/confd/infix-modem@2024-03-15.yang @@ -0,0 +1 @@ +infix-modem.yang \ No newline at end of file diff --git a/src/modemd/77-mm-dell-port-types.rules b/src/modemd/77-mm-dell-port-types.rules new file mode 100644 index 000000000..376b75f35 --- /dev/null +++ b/src/modemd/77-mm-dell-port-types.rules @@ -0,0 +1,39 @@ +# do not edit this file, it will be overwritten on update + +ACTION!="add|change|move|bind", GOTO="mm_dell_port_types_end" + +SUBSYSTEMS=="usb", ATTRS{idVendor}=="413c", GOTO="mm_dell_vendorcheck" +GOTO="mm_dell_port_types_end" + +LABEL="mm_dell_vendorcheck" +SUBSYSTEMS=="usb", ATTRS{bInterfaceNumber}=="?*", ENV{.MM_USBIFNUM}="$attr{bInterfaceNumber}" + +# Dell DW5821e (default 0x81d7, with esim support 0x81e0) +# if 02: primary port +# if 03: secondary port +# if 04: raw NMEA port +# if 05: diag/qcdm port +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="02", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="03", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_SECONDARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="04", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_GPS}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="05", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_QCDM}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="02", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="03", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_SECONDARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="04", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_GPS}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="05", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_QCDM}="1" + +# Dell DW5820e +# if 02: AT port +# if 04: debug port (ignore) +# if 06: AT port +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d9", ENV{.MM_USBIFNUM}=="04", ENV{ID_MM_PORT_IGNORE}="1" + +# Dell DW5811e (Sierra Wireless EM7455 rebadge) +# USB composition 8: MBIM-only (bInterfaceClass=02 bInterfaceSubClass=0e) +# Bound by cdc_mbim driver; no AT ports in this composition. +# if 0c: MBIM control port +# if 0d: data port (handled by cdc_mbim / cdc_ncm driver) +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81b6", ENV{.MM_USBIFNUM}=="0c", SUBSYSTEM=="usbmisc", ENV{ID_MM_PORT_TYPE_MBIM}="1" + +GOTO="mm_dell_port_types_end" +LABEL="mm_dell_port_types_end" diff --git a/src/modemd/LICENSE b/src/modemd/LICENSE new file mode 100644 index 000000000..3db075fa2 --- /dev/null +++ b/src/modemd/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2024 Avvero Pty Ltd +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of copyright holders nor the names of + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/modemd/finit.conf b/src/modemd/finit.conf new file mode 100644 index 000000000..090a51913 --- /dev/null +++ b/src/modemd/finit.conf @@ -0,0 +1 @@ +service [2345] name:modemd /sbin/modemd -- Modem daemon diff --git a/src/modemd/modem-carrier b/src/modemd/modem-carrier new file mode 100755 index 000000000..d5cc85072 --- /dev/null +++ b/src/modemd/modem-carrier @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 + +import subprocess +import argparse +import json +import syslog +import sys +import os +import re + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def log(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def read_file(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +# Telit LE910 +le910_carriers = [ + {"num": 0, "name": "AT&T"}, + {"num": 1, "name": "Verizon"}, + {"num": 2, "name": "T-Mobile"}, + {"num": 3, "name": "Bell"}, + {"num": 4, "name": "Telus"}, + {"num": 10, "name": "NTT-Docomo"}, + {"num": 11, "name": "Telstra"}, + {"num": 12, "name": "KDDI"}, + {"num": 13, "name": "Softbank"}, + {"num": 14, "name": "Vodafone New Zealand"}, + {"num": 15, "name": "Spark New Zealand"}, + {"num": 20, "name": "China Mobile"}, + {"num": 21, "name": "China Unicom"}, + {"num": 22, "name": "China Telecom"}, + {"num": 30, "name": "Sprint"}, + {"num": 31, "name": "SouthernLINC"}, + {"num": 40, "name": "Global"}, + {"num": 101, "name": "T-Mobile Germany"}, + {"num": 102, "name": "AT&T Mexico"}, + {"num": 103, "name": "Orange-WW"}, + {"num": 104, "name": "Southern Linc USA"}, + {"num": 105, "name": "Vodafone DE"} +] + + +def le910_carrier_bynum(num): + for carrier in le910_carriers: + if carrier["num"] == num: + return carrier + return None + + +def le910_carrier_byname(name): + for carrier in le910_carriers: + if carrier["name"] == name: + return carrier + return None + + +def le910_is_supported(variant): + if variant == "NS": + return True + elif variant == "AP": + return True + elif variant == "NF" or variant == "NFD": + return True + elif variant == "CN": + return True + elif variant == "APX": + return True + elif variant == "WWX" or variant == "WWXD": + return True + else: + return False + + +def le910_carrier_default(variant): + if variant == "NF" or variant == "WWX" or variant == "WWXD": + num = 0 + elif variant == "AP" or variant == "APX": + num = 10 + elif variant == "CN": + num = 20 + elif variant == "NS": + num = 30 + elif variant == "EU" or variant == "EUX": + num = 40 + else: + return None + + return le910_carrier_bynum(num) + + +def le910_command(index, command): + cmd = ['/sbin/modem-command', + '--index', str(index), + '--mode', '115200 8N1', + '--secondary', + '--timeout', '5', + command] + + for attempt in range(1, 3): + output = runcmd(cmd) + if output: + return output + + err("Command '%s' failed" % " ".join(cmd)) + return None + + +def le910_list_carriers(index): + output = le910_command(index, 'AT#FWSWITCH=?') + if output is None: + return None + + r = re.search(r"#FWSWITCH: \(([^\)]*)\)", output) + if not r: + err("Unable to list carriers on modem%d" % index) + return None + + rlist = r.group(1) + nums = [] + for rl in rlist.split(","): + r = re.search(r"(\d+)-(\d+)", rl) + if r: + start = int(r.group(1)) + end = int(r.group(2)) + for i in range(start, end + 1): + nums.append(i) + else: + nums.append(int(rl)) + + carriers = [] + for num in nums: + carrier = le910_carrier_bynum(num) + if carrier: + carriers.append(carrier) + else: + err("Unknown carrier %d" % num) + + return carriers + + +def le910_set_carrier(index, carrier): + output = le910_command(index, 'AT#FWSWITCH=%d' % carrier["num"]) + if output is None: + return False + else: + return True + + +def le910_get_carrier(index): + output = le910_command(index, 'AT#FWSWITCH?') + if output is None: + return -1 + + r = re.search(r"#FWSWITCH: (\d+)", output) + if not r: + err("Unable to query carrier on modem%d" % index) + return -1 + num = int(r.group(1)) + + return le910_carrier_bynum(num) + + +def runcheck(manf, model): + if manf == "Telit" and model[:5] == "LE910": + return le910_is_supported(model.split("-")[1]) + else: + return False + + +def runset(index, manf, model, name): + if not runcheck(manf, model): + fatal("Unsupported modem") + + if name == "default": + carrier = le910_carrier_default(model.split("-")[1]) + else: + carrier = le910_carrier_byname(name) + if not carrier: + fatal("Invalid carrier '%s'" % name) + + current = le910_get_carrier(index) + if not current: + fatal("Cannot get current carrier") + + if current["name"] == carrier["name"]: + log("Carrier '%s' already set on modem%d" % (carrier["name"], index)) + print(json.dumps({"action": "none"})) + sys.exit(0) + + log("Setting carrier '%s' on modem%d" % (carrier["name"], index)) + if not le910_set_carrier(index, carrier): + fatal("Unable to set carrier on modem%d" % index) + + print(json.dumps({"action": "reset"})) + sys.exit(0) + + +def runget(index, manf, model): + if not runcheck(manf, model): + fatal("Unsupported modem") + + carrier = le910_get_carrier(index) + if not carrier: + fatal("Unable to get carrier") + + print(json.dumps(carrier)) + sys.exit(0) + + +def runlist(index, manf, model): + if not runcheck(manf, model): + print(json.dumps([])) + sys.exit(0) + + carriers = le910_list_carriers(index) + if not carriers: + fatal("No carriers") + + names = [] + for carrier in sorted(carriers, key=lambda c: c["name"]): + names.append(carrier["name"]) + + print(json.dumps(names)) + sys.exit(0) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-carrier') + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-m", "--manf", default=0, help="Modem manufacturer") + parser.add_argument("-M", "--model", default=0, help="Modem model") + parser.add_argument("-s", "--set", help="Set carrier") + parser.add_argument("-g", "--get", help="Get carrier", + action="store_true") + parser.add_argument("-l", "--list", help="List supported carriers", + action="store_true") + parser.add_argument("-c", "--check", help="Check support for carriers", + action="store_true") + args = parser.parse_args() + + if not args.manf: + fatal("No manufacturer given") + else: + manf = args.manf + + if not args.model: + fatal("No model given") + else: + model = args.model + + if args.index: + index = int(args.index) + else: + index = 0 + + if args.check: + if not runcheck(manf, model): + sys.exit(1) + elif args.list: + runlist(index, manf, model) + elif args.set: + runset(index, manf, model, args.set) + elif args.get: + runget(index, manf, model) + + sys.exit(0) diff --git a/src/modemd/modem-command.c b/src/modemd/modem-command.c new file mode 100644 index 000000000..96a2f7aa1 --- /dev/null +++ b/src/modemd/modem-command.c @@ -0,0 +1,617 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int verbose = 0; + +#define MODEMS_LOCK "/var/lock/modems.lock" +#define MODEMS_INFO "/run/modems.json" +#define TIMEOUT 3 + +#define DBG(fmt, a...) print(LOG_DEBUG, fmt, ##a) +#define SAY(fmt, a...) print(LOG_INFO, fmt, ##a) +#define ERR(fmt, a...) print(LOG_ERR, fmt, ##a) +#define FATAL(fmt, a...) print(LOG_ALERT, fmt, ##a) + +static void print (int level, const char *fmt, ...) +{ + char msg[1024]; + va_list ap; + + if (!fmt) return; + va_start(ap, fmt); + vsnprintf(msg, sizeof(msg) - 1, fmt, ap); + va_end(ap); + + switch (level) { + case LOG_ERR: + fprintf(stderr, "ERROR: %s\n", msg); + break; + case LOG_ALERT: + fprintf(stderr, "ERROR: %s\n", msg); + unlink(MODEMS_LOCK); + exit(1); + break; + case LOG_DEBUG: + if (verbose) { + printf("DEBUG: %s\n", msg); + } + break; + default: + printf("%s\n", msg); + break; + } +} + +static void printser (int dir, char *ser) +{ + char quoted[1024]; + char *p; + int len = 0; + + for (p = ser; *p; p++) { + if (*p == '\r') { + len += snprintf(quoted + len, sizeof(quoted) - len, ""); + } else if (*p == '\n') { + len += snprintf(quoted + len, sizeof(quoted) - len, ""); + } else { + len += snprintf(quoted + len, sizeof(quoted) - len, "%c", *p); + } + } + if (dir == 0) { + DBG("Received: '%s'", quoted); + } else { + DBG("Sending '%s'", quoted); + } +} + + +struct ttymode { + int set; + int speed; + int parity; + int bits; + int stopbits; +}; + +static int parse_mode (const char *str, struct ttymode *mode) +{ + char buf[256]; + char *t1, *t2; + + /* parse string */ + if (strlen(str) >= sizeof(buf)) { + return -1; + } + strncpy(buf, str, sizeof(buf)); + t1 = strtok(buf, " "); + if (!t1) { + ERR("Invalid format"); + return -1; + } + t2 = strtok(NULL, " "); + if (!t2 || strlen(t2) != 3) { + ERR("Invalid format"); + return -1; + } + + /* parse speed */ + switch (atoi(t1)) { + case 9600: + mode->speed = B9600; + break; + case 19200: + mode->speed = B19200; + break; + case 38400: + mode->speed = B38400; + break; + case 57600: + mode->speed = B57600; + break; + case 115200: + mode->speed = B115200; + break; + default: + ERR("Invalid baudrate"); + return -1; + } + + /* parse bits */ + switch (t2[0]) { + case '5': + mode->bits = CS5; + break; + case '6': + mode->bits = CS6; + break; + case '7': + mode->bits = CS7; + break; + case '8': + mode->bits = CS8; + break; + default: + ERR("Invalid bits"); + return -1; + } + + /* parse parity */ + switch (t2[1]) { + case 'N': + mode->parity = 0; + break; + case 'E': + mode->parity = PARENB; + break; + case 'O': + mode->parity = PARENB | PARODD; + break; + default: + ERR("Invalid parity"); + return -1; + } + + /* parse stopbits */ + switch (t2[2]) { + case '1': + mode->stopbits = 0; + break; + case '2': + mode->stopbits = CSTOPB; + break; + default: + ERR("Invalid stop bits %c"); + return -1; + } + + return 0; +} + + +static int setup (const char *tty, int timeout, struct ttymode *mode) +{ + struct termios stbuf; + struct stat st; + char dev[32]; + int i, fd; + + if (strncmp(tty, "/dev", 4) == 0) { + strncpy(dev, tty, sizeof(dev)); + } else { + snprintf(dev, sizeof(dev), "/dev/%s", tty); + } + + /* wait for device */ + for (i = 0; i < timeout; i++) { + if (lstat(dev, &st) == 0) break; + sleep(1); + } + if (i >= timeout) { + ERR("Timeout waiting for %s", dev); + return -1; + } + + DBG("Opening %s", tty); + fd = open(dev, O_RDWR | O_EXCL | O_NONBLOCK | O_NOCTTY); + if (fd < 0) { + ERR("Unable to open '%s'", dev); + return -1; + } + + if (!mode->set) return fd; + + memset(&stbuf, 0, sizeof (struct termios)); + if (tcgetattr(fd, &stbuf) != 0) { + ERR("Unable to get serial attributes"); + goto error; + } + /* default settings */ + stbuf.c_cflag &= ~(CBAUD | CSIZE | CSTOPB | PARENB | PARODD | CRTSCTS); + stbuf.c_iflag &= ~(IGNCR | ICRNL | IUCLC | INPCK | IXON | IXOFF | IXANY ); + stbuf.c_oflag &= ~(OPOST | OLCUC | OCRNL | ONLCR | ONLRET); + stbuf.c_lflag &= ~(ICANON | ECHO | ECHOE | ECHONL); + stbuf.c_cc[VMIN] = 1; + stbuf.c_cc[VTIME] = 0; + stbuf.c_cc[VEOF] = 1; + + /* ignore parity */ + stbuf.c_iflag |= IGNPAR; + + /* configure serial attributes */ + stbuf.c_cflag |= (mode->bits | mode->parity | mode->stopbits | CLOCAL | CREAD); + + /* disable XON/XOFF flow control */ + stbuf.c_iflag &= ~(IXON | IXOFF | IXANY); + + /* disable RTS/CTS flow control */ + stbuf.c_cflag &= ~(CRTSCTS); + + if (cfsetispeed(&stbuf, mode->speed) != 0 || + cfsetospeed(&stbuf, mode->speed) != 0) { + ERR("Unable to set port speed"); + goto error; + } + + if (tcsetattr(fd, TCSANOW, &stbuf) != 0) { + ERR("Unable to set serial attributes"); + goto error; + } + + return fd; +error: + close(fd); + return -1; +} + +static int serial_write (int fd, int timeout, char *buf, int len) +{ + struct timeval tv; + fd_set fds; + + FD_ZERO(&fds); + FD_SET(fd, &fds); + tv.tv_sec = timeout; + tv.tv_usec = 0; + + if (select(fd + 1, NULL, &fds, NULL, &tv) <= 0) { + DBG("Cannot write to serial device"); + return -1; + } + alarm(timeout); + tcflush(fd, TCIOFLUSH); + + printser(1, buf); + if (write(fd, buf, len) != len) { + return -1; + } + tcdrain(fd); + alarm(0); + + return 0; +} + +static int serial_read (int fd, int timeout, char *buf, int size) +{ + struct timeval tv; + fd_set fds; + int rc, len = 0; + + FD_ZERO(&fds); + FD_SET(fd, &fds); + tv.tv_sec = timeout; + tv.tv_usec = 0; + memset(buf, 0, size); + + rc = select(fd + 1, &fds, NULL, NULL, &tv); + if (rc <= 0) { + DBG("Cannot read from serial device (%d)", rc); + return -1; + } + do { + errno = 0; + alarm(timeout); + rc = read(fd, buf+len, size-len-1); + alarm(0); + + if (rc > 0) { + len += rc; + } else { + if (rc < 0 && errno == -EAGAIN) { + continue; + } else { + break; + } + } + } while (1); + + if (len > 0) buf[len] = '\0'; + + printser(0, buf); + + return len; +} + +static int execute (int fd, int timeout, const char *cmd, const char *expect) +{ + char buf[1024]; + time_t start; + long elapsed; + int len, ret = -1; + + if (cmd) { + start = time(NULL); + len = snprintf(buf, sizeof(buf), "%s\r\n", cmd); + if (serial_write(fd, timeout, buf, len) < 0) { + DBG("Cannot write to serial device"); + return -1; + } + elapsed = time(NULL) - start; + timeout -= elapsed; + } + + DBG("Waiting for response"); + + while (timeout > 0) { + start = time(NULL); + len = serial_read(fd, timeout, buf, sizeof(buf)); + if (len > 0) { + buf[len] = '\0'; + SAY("%s", buf); + + if (strstr(buf, expect)) { + ret = 0; + break; + } + } + elapsed = time(NULL) - start; + timeout -= elapsed; + } + return ret; +} + +static int probe (int fd) +{ + char buf[256]; + int i, len; + + DBG("Probing serial port"); + + /* try 3 times */ + for (i = 3; i > 0; i--) { + len = snprintf(buf, sizeof(buf), "AT\r\n"); + if (serial_write(fd, 1, buf, len) < 0) { + DBG("Cannot write to tty"); + continue; + } + len = serial_read(fd, 1, buf, sizeof(buf)); + if (len <= 0) { + DBG("Cannot read from tty"); + continue; + } + if (strstr(buf, "OK")) { + return 0; + } + } + + DBG("Probing failed"); + return -1; +} + +static int modemidx (const char *devpath) +{ + json_t *json, *modules, *module, *mod, *paths, *obj; + const char *p; + int i, j, index = -1; + + json = json_load_file("/run/modules.json", 0, NULL); + if (!json) { + return -1; + } + modules = json_object_get(json, "modules"); + if (!modules || !json_is_array(modules)) { + goto out; + } + for (i = 0; i < json_array_size(modules); i++) { + module = json_array_get(modules, i); + if (!module) continue; + + obj = json_object_get(module, "type"); + if (!obj || !json_is_string(obj)) continue; + p = json_string_value(obj); + if (strcmp(p, "modem") != 0) continue; + + obj = json_object_get(module, "index"); + if (!obj || !json_is_integer(obj)) continue; + index = json_integer_value(obj); + + paths = json_object_get(module, "paths"); + if (!paths || !json_is_array(paths)) continue; + + for (j = 0; j < json_array_size(paths); j++) { + obj = json_array_get(paths, j); + if (!obj) continue; + p = json_string_value(obj); + + if (strstr(devpath, p)) { + /* found */ + goto out; + } + } + } + index = -1; +out: + json_decref(json); + return index; +} + +static char *modemdev (int index, int port) +{ + json_t *json, *modems, *modem, *ports, *obj; + char *dev = NULL; + int i, p, fd; + + fd = open(MODEMS_LOCK, + O_WRONLY | O_CREAT | O_EXCL, + S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH); + if (fd < 0) { + ERR("Cannot open lock"); + return NULL; + } + if (flock(fd, LOCK_EX) < 0) { + ERR("Cannot to lock"); + close(fd); + return NULL; + } + + json = json_load_file(MODEMS_INFO, 0, NULL); + if (!json) { + goto out; + } + modems = json_object_get(json, "modems"); + if (!modems || !json_is_array(modems)) { + goto out; + } + for (i = 0; i < json_array_size(modems); i++) { + modem = json_array_get(modems, i); + if (!modem) + continue; + + obj = json_object_get(modem, "devpath"); + if (!obj || !json_is_string(obj)) + continue; + + if (modemidx(json_string_value(obj)) != index) + continue; + + ports = json_object_get(modem, "atports"); + if (!ports || !json_is_array(ports) || json_array_size(ports) == 0) + goto out; + + if (json_array_size(ports) >= port) + port = 0; + + obj = json_array_get(ports, port); + if (!obj || !json_is_string(obj)) + goto out; + + /* found */ + dev = strdup(json_string_value(obj)); + goto out; + } + dev = NULL; + +out: + if (json) json_decref(json); + + unlink(MODEMS_LOCK); + flock(fd, LOCK_UN); + close(fd); + + return dev; +} + +static struct option options[] = { + { "index", required_argument, 0, 'i' }, + { "device", required_argument, 0, 'd' }, + { "mode", required_argument, 0, 'm' }, + { "command", required_argument, 0, 'c' }, + { "expect", required_argument, 0, 'e' }, + { "timeout", required_argument, 0, 't' }, + { "primary", no_argument, 0, 'P' }, + { "secondary", no_argument, 0, 'S' }, + { "verbose", no_argument, 0, 'v' }, + { "help", no_argument, 0, 'h' }, + { 0, 0, 0, 0 } +}; + +static void usage (void) +{ + fprintf(stderr, "Usage: modem-command [OPTIONS] [command]\n\n"); + fprintf(stderr, " --index Index of modem\n"); + fprintf(stderr, " --device TTY device of modem\n"); + fprintf(stderr, " --mode Serial mode for TTY (e.g. '115200 8N1')\n"); + fprintf(stderr, " --expect String to expect\n"); + fprintf(stderr, " --timeout Timeout for operation\n"); + fprintf(stderr, " --primary Use primary atport\n"); + fprintf(stderr, " --secondary Use secondary atport\n"); + fprintf(stderr, " --verbose Be verbose\n"); + fprintf(stderr, " --help print help\n\n"); + exit(1); +} + +int main (int argc, char *argv[]) +{ + const char *cmd = NULL, *expect = "OK"; + char *dev = NULL; + struct ttymode mode; + int timeout = TIMEOUT; + int index = 0, port = 0; + int opt, c, fd; + int ret = 1; + + memset(&mode, 0, sizeof(struct ttymode)); + + while (1) { + c = getopt_long(argc, argv, "i:d:m:e:t:PSvh", options, &opt); + if (c < 0) break; + + switch (c) { + case 'i': + index = atoi(optarg); + break; + case 'd': + dev = strdup(optarg); + break; + case 'm': + if (parse_mode(optarg, &mode) < 0) { + FATAL("Invalid serial mode specified"); + } + mode.set = 1; + break; + case 'c': + cmd = optarg; + break; + case 'e': + expect = optarg; + break; + case 't': + timeout = atoi(optarg); + break; + case 'P': + port = 0; + break; + case 'S': + port = 1; + break; + case 'v': + verbose = 1; + break; + default: + usage(); + break; + } + } + + if (optind < argc) { + cmd = argv[optind]; + if (strlen(cmd) == 0) + FATAL("Invalid command"); + } + dev = modemdev(index, port); + if (!dev || strlen(dev) == 0) { + FATAL("No device found"); + } + fd = setup(dev, timeout, &mode); + if (fd < 0) { + FATAL("Unable to setup device"); + } + + if (probe(fd) < 0) { + ERR("Unable to probe device"); + goto abort; + } + if (execute(fd, timeout, cmd, expect) < 0) { + ERR("Command failed"); + goto abort; + } + ret = 0; + +abort: + close(fd); + if (dev) free(dev); + return ret; +} diff --git a/src/modemd/modem-info b/src/modemd/modem-info new file mode 100755 index 000000000..2f1684b26 --- /dev/null +++ b/src/modemd/modem-info @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 + +import subprocess +import argparse +import syslog +import json +import sys +import os +import re + +debug = False + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def dbg(msg): + global debug + if debug: + syslog.syslog(syslog.LOG_INFO, msg) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def vbool(val): + if val == "yes" or val == "true": + return True + else: + return False + + +def vnum(val): + if val != "--": + return int(val) + else: + return 0 + + +def vnumstr(val): + if val != "" and val != "--": + return val + else: + return "0" + + +def vstr(val): + if val != "--": + return val + else: + return "" + + +def venum(val, enums, default): + enval = vstr(val) + for en in enums: + if enval == en: + return en + return default + + +def vip4(val): + val = vstr(val) + if val == "": + return "0.0.0.0" + else: + return val + + +def vip6(val): + val = vstr(val) + if val == "": + return "::" + else: + return val + + +def fread(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +def freadj(path): + output = fread(path) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + dbg("Unable to parse json output") + return None + finally: + return data + + +def get_manufacturer(index, gen): + path = "/run/modemd/modem%d/manufacturer" % index + manf = fread(path) + if manf: + return manf + + manf = vstr(gen.get("manufacturer", "")) + return manf + + +def get_model(index, gen): + path = "/run/modemd/modem%d/model" % index + model = fread(path) + if model: + return model + + model = vstr(gen.get("model", "")) + return model + + +def get_supported_carriers(index, manf, model): + path = "/run/modemd/modem%d/supported-carriers" % index + output = freadj(path) + if output: + return output + else: + return [] + + +def get_current_carrier(index, manf, model): + path = "/run/modemd/modem%d/current-carrier" % index + output = freadj(path) + if output: + return output + else: + return "default" + + +def device_devpath(devpath): + path = devpath + while path != "/sys": + path = os.path.realpath("%s/.." % path) + subdir = os.path.basename(path) + r = re.search(r"usb\d", subdir) + if r: + return path + return None + + +def modem_get_module(devpath): + if not os.path.exists("/run/modules.json"): + if os.path.exists("/run/modems.json"): + with open("/run/modems.json", "r") as fd: + data = json.load(fd) + for index, modem in enumerate(data.get("modems", [])): + if devpath in modem["devpath"]: + return {"index": index, "slot": index, "paths": [modem["devpath"]], "type": "modem"} + return None + + with open("/run/modules.json", "r") as fd: + data = json.load(fd) + + for module in data["modules"]: + if module["type"] != "modem": + continue + for path in module["paths"]: + if path in devpath: + return module + return None + + +def modem_get_sim(module): + path = "/sys/class/sim" + for index in range(0, 5): + name = "sim%d" % index + if not os.path.exists("%s/%s" % (path, name)): + continue + + slot = int(fread("%s/%s/slot" % (path, name))) + if slot != module["slot"]: + continue + + present = int(fread("%s/%s/present" % (path, name))) + + return { + "index": index, + "name": name, + "slot": slot, + "present": bool(present) + } + + return None + + +def sysfs_interfaces(devpath): + ifaces = [] + try: + for iface in os.listdir("/sys/class/net"): + p = os.path.realpath("/sys/class/net/%s" % iface) + if p.startswith(devpath): + ifaces.append(iface) + except OSError: + pass + return ifaces + + +def print_modem(index): + with open("/run/modems.json", "r") as fd: + data = json.load(fd) + + for modem in data["modems"]: + module = modem_get_module(modem["devpath"]) + if module["index"] == index: + modem["slot"] = module["slot"] + modem["sim"] = modem_get_sim(module) or {"index": 0, "name": "sim0", "slot": 0, "present": True} + if not modem.get("interfaces"): + modem["interfaces"] = sysfs_interfaces(modem["devpath"]) + print(json.dumps(modem)) + sys.exit(0) + + print(json.dumps({})) + sys.exit(0) + + +def print_all(): + modems = [] + + output = runcmdj(['mmcli', '-J', '-L']) + if not output: + dbg("mmcli command failed") + print(json.dumps(modems)) + sys.exit(0) + + for mpath in output['modem-list']: + output = runcmdj(['mmcli', '-J', '-m', mpath]) + if not output: + dbg("no output for %s" % mpath) + continue + modem = output.get("modem") + if modem is None: + dbg("no modem for %s" % mpath) + continue + gen = modem.get("generic") + if gen is None: + dbg("no generic for %s" % mpath) + continue + gpp = modem.get("3gpp") + if gpp is None: + dbg("no 3gpp for %s" % mpath) + continue + + devpath = device_devpath(gen["device"]) + if devpath is None: + dbg("no devpath for %s" % mpath) + continue + + module = modem_get_module(devpath) + if module is None: + dbg("no modem module for %s" % devpath) + continue + + index = module["index"] + + manf = get_manufacturer(index, gen) + if manf is None: + dbg("no manufacturer for modem%d" % index) + continue + + model = get_model(index, gen) + if model is None: + dbg("no model for modem%d" % index) + continue + + modem = { + "index": index, + "path": mpath + } + + modem["info"] = { + "manufacturer": manf, + "model": model, + "supported-carrier": get_supported_carriers(index, manf, model), + "hardware-revision": vstr(gen["hardware-revision"]), + "firmware-version": vstr(gen["revision"]), + "serial-number": vstr(gen["equipment-identifier"]), + "phone-number": gen.get("own-numbers", []), + } + + modem["status"] = { + "state": vstr(gen["state"]), + "selected-carrier": get_current_carrier(index, manf, model), + "signal-quality": vnum(gen["signal-quality"]["value"]) + } + + if gen["state"] == "failed": + reason = vstr(gen["state-failed-reason"]) + if len(reason) > 0: + modem["status"]["state-failed-reason"] = reason + + sim = modem_get_sim(module) + if sim is not None: + modem["status"]["sim-active"] = sim["index"] + modem["status"]["sim-present"] = sim["present"] + + refresh = 0 + output = runcmdj(['mmcli', '-J', '-m', mpath, '--signal-get']) + if output: + refresh = int(output["modem"]["signal"]["refresh"]["rate"]) + if refresh == 0: + # enable extended signal info + runcmd(['mmcli', '-J', '-m', mpath, '--signal-setup=5']) + else: + signals = ["rssi", "rsrp", "rsrq", "rscp", "snr", "sinr"] + found = False + for tech in output["modem"]["signal"].keys(): + for sig in signals: + v = output["modem"]["signal"][tech].get(sig, "--") + if v != "--": + modem["status"]["signal-%s" % sig] = v + found = True + if found: + break + + modem["status"]["cellular"] = { + "registration-state": + venum(gpp["registration-state"], + ["idle", "home", "searching", "denied", "roaming"], + "unknown"), + "operator-name": vstr(gpp["operator-name"]), + "operator-id": vstr(gpp["operator-code"]), + "network-type": ",".join(vstr(gen["access-technologies"])).upper(), + "service-state": vstr(gpp["packet-service-state"]) + } + + bearers = [] + for bpath in gen["bearers"]: + output = runcmdj(['mmcli', '-J', '-m', mpath, '-b', bpath]) + if not output: + continue + + b = output["bearer"] + + bearer = { + "path": vstr(bpath), + "connected": vbool(b["status"]["connected"]), + "ipv4-address": vip4(b["ipv4-config"]["address"]), + "ipv4-prefix": vnum(b["ipv4-config"]["prefix"]), + "ipv6-address": vip6(b["ipv6-config"]["address"]), + "ipv6-prefix": vnum(b["ipv6-config"]["prefix"]), + "in-bytes": vnumstr(b["stats"]["bytes-rx"]), + "out-bytes": vnumstr(b["stats"]["bytes-tx"]), + "total-in-bytes": vnumstr(b["stats"]["total-bytes-rx"]), + "total-out-bytes": vnumstr(b["stats"]["total-bytes-tx"]), + "total-duration": vnumstr(b["stats"]["total-duration"]) + } + if b["status"]["connected"] != "yes": + reason = vstr(b["status"]["connection-error"]["message"]) + if len(reason) > 0: + bearer["connection-failed-reason"] = reason + + iface = vstr(b["status"]["interface"]) + if len(iface) > 0: + bearer["interface"] = iface + + bearers.append(bearer) + + modem["status"]["bearer"] = bearers + + output = runcmdj(['mmcli', '-J', '-m', mpath, '-i', gen["sim"]]) + if output: + modem["info"]["imsi"] = output["sim"]["properties"]["imsi"] + modem["info"]["iccid"] = output["sim"]["properties"]["iccid"] + + output = runcmdj(['mmcli', '-J', '-m', mpath, '--location-get']) + if output: + loc = output["modem"]["location"] + modem["status"]["location"] = { + "latitude": vstr(loc["gps"]["latitude"]), + "longitude": vstr(loc["gps"]["longitude"]), + "altitude": vstr(loc["gps"]["altitude"]), + "cid": vstr(loc["3gpp"]["cid"]), + "lac": vstr(loc["3gpp"]["lac"]), + "mcc": vstr(loc["3gpp"]["mcc"]), + "mnc": vstr(loc["3gpp"]["mnc"]), + "tac": vstr(loc["3gpp"]["tac"]) + } + + modems.append(modem) + + modems = sorted(modems, key=lambda m: m['index']) + data = json.dumps(modems) + + print(data) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-info') + parser.add_argument("-i", "--index", default=0, help="Modem index") + args = parser.parse_args() + + with open("/proc/cmdline", 'r') as fd: + cmdline = fd.read() + if "debug" in cmdline: + debug = True + + if args.index: + print_modem(int(args.index)) + else: + print_all() diff --git a/src/modemd/modem-power b/src/modemd/modem-power new file mode 100755 index 000000000..9bcb67a0e --- /dev/null +++ b/src/modemd/modem-power @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +import subprocess +import argparse +import json +import time +import syslog +import sys + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def log(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def reset(index, info): + log("Power-cycling modem %d" % index) + + with open("%s/authorized" % info["devpath"], "w") as fd: + fd.write("0\n") + time.sleep(0.5) + + path = "/sys/class/pcie/slot%d/pwrctl" % info["slot"] + with open(path, "w") as fd: + fd.write("cycle\n") + + return True + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-power') + parser.add_argument("-i", "--index", default=0, help="Modem index") + args = parser.parse_args() + + if args.index: + index = int(args.index) + else: + index = 0 + + info = runcmdj(['/usr/libexec/modemd/modem-info', + '-i', str(index)]) + if info is None: + fatal("Unable to obtain modem info") + + if not reset(index, info): + fatal("Unable to power-cycle modem") + + sys.exit(0) diff --git a/src/modemd/modem-rpc b/src/modemd/modem-rpc new file mode 100755 index 000000000..5bc7ecd7d --- /dev/null +++ b/src/modemd/modem-rpc @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sys +import os + + +def sendrpc(index, rpc): + path = "/run/modemd/modem%d/rpc" % index + if os.path.exists(path): + sys.stderr.write("Error: another rpc is running\n") + return + + with open(path, "w") as fd: + json.dump(rpc, fd) + + if not os.system(['sysrepocfg', '-f', 'json', '-R', path]): + sys.stderr.write("Error: unable to restart\n") + + os.unlink(path) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-rpc') + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-r", "--rpc", default=0, help="RPC command") + args = parser.parse_args() + + if args.index: + index = int(args.index) + else: + index = 0 + + if not args.rpc: + sys.stderr.write("Error: no rpc command\n") + sys.exit(1) + + print("Sending '%s' rpc to modem%d" % (args.rpc, index)) + + rpc = {"infix-modem:%s" % args.rpc: {"index": str(index)}} + + sendrpc(index, rpc) diff --git a/src/modemd/modem-scan-networks b/src/modemd/modem-scan-networks new file mode 100755 index 000000000..288e7fa60 --- /dev/null +++ b/src/modemd/modem-scan-networks @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +import argparse +import subprocess +import json +import sys + +index = 0 + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def scan(mpath): + output = runcmdj(['mmcli', '-J', '-m', mpath, + '--3gpp-scan', '--timeout', '60']) + if not output: + return False + + networks = [] + for entry in output["modem"]["3gpp"]["scan-networks"]: + net = {} + for attr in entry.split(", "): + (key, value) = attr.split(": ") + net[key] = value + + if "operator-name" in net: + networks.append(net) + + if len(networks) == 0: + print("No networks found") + return True + + for net in networks: + print("Network '%s' %s (%s, %s)" % ( + net.get("operator-name"), + net.get("operator-code"), + net.get("access-technologies"), + net.get("availability"))) + return True + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-scan-networks') + parser.add_argument("-i", "--index", default=0, help="Modem index") + args = parser.parse_args() + if args.index: + index = int(args.index) + + for m in runcmdj(['/usr/libexec/modemd/modem-info']): + if m["index"] == index: + print("Scanning networks on modem%d, please stand by ..." % index) + if not scan(m["path"]): + print("Operation failed.", file=sys.stderr) diff --git a/src/modemd/modem-sms b/src/modemd/modem-sms new file mode 100755 index 000000000..7bcb797ce --- /dev/null +++ b/src/modemd/modem-sms @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 + +from datetime import datetime +import argparse +import locale +import subprocess +import json +import glob +import re +import sys +import os + +LOCALEPATH = "/usr/lib/locale" + + +def run_cmd(cmd): + try: + res = subprocess.run(cmd, capture_output=True, text=True) + return True if res.returncode == 0 else False + except subprocess.CalledProcessError: + return False + + +def send(index, number, text): + if index < 0: + # use first modem by default + index = 0 + + rpc = { + "infix-modem:send-sms": { + "index": str(index), + "phone-number": number, + "message-text": text + } + } + print("Sending SMS to modem%d" % index) + + path = "/run/modemd/modem%d/rpc" % index + if os.path.exists(path): + sys.stderr.write("Error: another rpc is running\n") + return + + with open(path, "w") as fd: + json.dump(rpc, fd) + + if not run_cmd(['sysrepocfg', '-f', 'json', '-R', path]): + sys.stderr.write("Error: Unable to send SMS\n") + + os.unlink(path) + + +def listsms(): + smslist = [] + files = glob.glob('/var/sms/*') + for f in files: + with open(f, "r") as fd: + sms = json.load(fd) + sms["path"] = f + + t = sms["payload"]["properties"]["timestamp"] + t = re.sub("[+-][\\d:]+$", "", t) + sms["time"] = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S") + + smslist.append(sms) + + return sorted(smslist, key=lambda sms: sms["time"]) + + +def delete(index=-1): + count = 0 + for sms in listsms(): + if index > -1 and sms["modem"] == index: + os.remove(sms["path"]) + count += 1 + print("Deleted %d SMS files" % count) + + +def show(index=-1): + for sms in listsms(): + if index > -1 and sms["modem"] != index: + continue + + content = sms["payload"]["content"] + prop = sms["payload"]["properties"] + + print("--- %s ---\n" % os.path.basename(sms["path"])) + print("From: %s" % content["number"]) + print("SMSC: %s" % prop["smsc"]) + print("Modem: %d" % sms["modem"]) + print("Date: %s" % prop["timestamp"]) + print("State: %s" % prop["state"]) + print("\n%s\n" % content["text"]) + + +def listlocale(): + locales = [] + for f in os.listdir(LOCALEPATH): + if os.path.isdir(os.path.join(LOCALEPATH, f)): + locales.append(f) + return ", ".join(locales) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-sms') + parser.add_argument("-d", action="store_true", help="Delete SMS") + parser.add_argument("-s", action="store_true", help="Send SMS") + + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-n", "--number", default=None, help="Phone number") + parser.add_argument("-t", "--text", default=None, help="Message text") + + parser.add_argument("-l", "--locale", default=None, help="Set locale") + args = parser.parse_args() + + index = -1 + if args.index: + index = int(args.index) + + if args.locale: + try: + locale.setlocale(locale.LC_ALL, args.locale) + except locale.Error: + print("Error: Invalid locale (available: %s)" % listlocale()) + sys.exit(1) + + if args.s: + if not args.number: + sys.stderr.write("Error: need number\n") + elif not args.text: + sys.stderr.write("Error: need text\n") + else: + send(index, args.number, args.text) + elif args.d: + delete(index) + else: + show(index) diff --git a/src/modemd/modem-udev b/src/modemd/modem-udev new file mode 100755 index 000000000..ba4ebfbaf --- /dev/null +++ b/src/modemd/modem-udev @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 + +import shutil +import errno +import time +import json +import fcntl +import sys +import re +import os + +MODEMS = "/run/modems.json" +LOCK = "/var/lock/modems.lock" +LOGFILE = "/run/modemd/modem-udev.log" +debug = False + + +def log(msg): + ts = '{:010.3f}'.format(time.monotonic()) + with open(LOGFILE, 'a') as fd: + fd.write("[%s] modem-udev (pid %d) %s\n" % (ts, os.getpid(), msg)) + + +def dbg(msg): + global debug + if debug: + log(msg) + + +def info(msg): + log(msg) + + +def err(msg): + log("Error: %s" % msg) + + +def lock(): + start = time.time() + while True: + elapsed = time.time() - start + if elapsed > 10: + err("Timeout waiting for lock") + break + try: + fd = open(LOCK, "a") + fcntl.flock(fd.fileno(), fcntl.LOCK_EX) + dbg("Acquired lock") + return fd + except IOError as e: + dbg("Unable to lock (%d)" % e.errno) + if e.errno != errno.EAGAIN: + err("Unable to lock") + return None + else: + time.sleep(1) + + return None + + +def unlock(fd): + try: + fcntl.flock(fd.fileno(), fcntl.LOCK_UN) + fd.close() + dbg("Released lock") + return True + except IOError as e: + err("Unlock failed with %d" % e.errno) + return False + + +def mkdir(path): + if os.path.isdir(path): + return True + try: + os.mkdir(path, mode=0o755) + shutil.chown(path, user="root", group="wheel") + except OSError: + err("Unable to mkdir %s" % path) + return False + finally: + return True + + +def read_modem_data(): + data = {} + if os.path.exists(MODEMS): + with open(MODEMS, "r") as fd: + data = json.load(fd) + return data + + +def write_modem_data(data): + with open(MODEMS + ".bak", 'w') as fd: + json.dump(data, fd) + + os.rename(MODEMS + ".bak", MODEMS) + return True + + +def get_vendor(devpath): + path = "%s/idVendor" % devpath + if os.path.exists(path): + with open(path, "r") as fd: + return fd.readline().strip() + else: + return None + + +def get_product(devpath): + path = "%s/idProduct" % devpath + if os.path.exists(path): + with open(path, "r") as fd: + return fd.readline().strip() + else: + return None + + +def update(d): + modem = None + devpath = d.get("devpath") + + data = read_modem_data() + if "modems" not in data: + data = {"modems": []} + else: + for m in data["modems"]: + if devpath == m.get("devpath"): + data["modems"].remove(m) + modem = m + break + + if not modem: + modem = {"devpath": devpath} + + vendor = get_vendor(devpath) + if vendor: + modem["vendor"] = vendor + + product = get_product(devpath) + if product: + modem["product"] = product + + atport = d.get("atport") + atports = modem.get("atports", []) + if atport and atport not in atports: + ptype = d.get("ptype", "default") + if ptype == "primary": + atports.insert(0, atport) + else: + atports.append(atport) + modem["atports"] = atports + + qmiport = d.get("qmiport") + qmiports = modem.get("qmiports", []) + if qmiport and qmiport not in qmiports: + qmiports.append(qmiport) + modem["qmiports"] = qmiports + + iface = d.get("iface") + ifaces = modem.get("interfaces", []) + if iface and iface not in ifaces: + ifaces.append(iface) + modem["interfaces"] = ifaces + + data["modems"].append(modem) + write_modem_data(data) + + info("Updated modem %s" % devpath) + + +def add_tty(devpath): + devpath = os.path.realpath("/sys/%s/../../../../" % devpath) + + info("Adding tty device %s" % devpath) + + atport = os.getenv("DEVNAME") + if not atport: + err("No DEVNAME") + return False + + if os.getenv("ID_MM_PORT_TYPE_AT_PRIMARY"): + ptype = "primary" + elif os.getenv("ID_MM_PORT_TYPE_AT_SECONDARY"): + ptype = "secondary" + else: + ptype = "default" + + info("Adding %s atport %s for %s" % (ptype, atport, devpath)) + + update({"devpath": devpath, "atport": atport, "ptype": ptype}) + + +def add_net(devpath): + devpath = os.path.realpath("/sys/%s/../../../" % devpath) + + info("Adding net device %s" % devpath) + + iface = os.getenv("INTERFACE", None) + if not iface: + err("No INTERFACE") + return False + + r = re.search(r"wwan(\d+)\.(\d+)", iface) + if r and r.group(2): + info("Skipping interface %s" % iface) + return False + + info("Adding interface %s for %s" % (iface, devpath)) + update({"devpath": devpath, "iface": iface}) + + try: + with open("/sys/class/net/%s/qmi/raw_ip" % iface, "w") as fd: + fd.write("Y\n") + except (IOError, OSError): + err("Unable to set raw-ip") + return False + + info("Interface %s has been set to rawip" % iface) + + +def add_usbmisc(devpath): + devpath = os.path.realpath("/sys/%s/../../../" % devpath) + + info("Adding usbmisc device %s" % devpath) + + qmiport = os.getenv("DEVNAME", None) + if not qmiport: + err("No DEVNAME") + return False + + info("Adding qmiport %s for %s" % (qmiport, devpath)) + update({"devpath": devpath, "qmiport": qmiport}) + + +def apply(): + subsystem = os.getenv("SUBSYSTEM") + if not subsystem: + err("No SUBSYSTEM") + return False + + devpath = os.getenv("DEVPATH") + if not devpath: + err("No DEVPATH") + return False + + if "usb" not in devpath: + info("Skipping %s" % devpath) + return False + + if subsystem == "net": + add_net(devpath) + elif subsystem == "tty": + add_tty(devpath) + elif subsystem == "usbmisc": + add_usbmisc(devpath) + else: + err("Subsystem %s unhandled" % subsystem) + return False + + return True + + +if __name__ == "__main__": + with open("/proc/cmdline", 'r') as fd: + cmdline = fd.read() + if "debug" in cmdline: + debug = True + + mkdir("/run/modemd") + dbg("started") + + if debug: + for i, j in os.environ.items(): + dbg("ENV: %s=%s" % (i, j)) + else: + if os.path.exists(LOGFILE): + os.remove(LOGFILE) + + fd = lock() + if fd is None: + sys.exit(2) + + if apply(): + rc = 0 + else: + err("Unable to add modem device") + rc = 1 + + unlock(fd) + + dbg("ended") + + sys.exit(rc) diff --git a/src/modemd/modem-update-firmware b/src/modemd/modem-update-firmware new file mode 100755 index 000000000..b8675108e --- /dev/null +++ b/src/modemd/modem-update-firmware @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 + +import subprocess +import argparse +import urllib.parse +import hashlib +import syslog +import json +import time +import os +import sys + +tempdir = "/tmp/modem-fwupdate" +debug = False +index = 0 + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def dbg(msg): + global debug + if debug: + syslog.syslog(syslog.LOG_INFO, msg) + + +def info(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + rmdir(tempdir) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + err("Command failed") + dbg(res.stderr) + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def exists(path): + try: + os.stat(path) + except OSError: + return False + return True + + +def rm(path): + if os.path.exists(path): + os.remove(path) + + +def rmdir(path): + if os.path.isdir(path): + for f in os.listdir(path): + p = os.path.join(path, f) + if os.path.isdir(p): + rmdir(p) + else: + os.remove(p) + + +def mkdir(path): + if os.path.isdir(path): + return True + try: + os.mkdir(path, mode=0o755) + except OSError: + fatal("Unable to mkdir %s" % path) + + +def initctl(cmd, svc): + output = runcmdj(['initctl', '-j', 'status']) + for ctl in output: + if ctl["identity"] == svc: + return runcmd(['initctl', cmd, svc]) + return False + + +def get_path(index): + for modem in runcmdj(['/usr/libexec/modemd/modem-info']): + if modem["index"] == index: + return modem["path"] + + +def get_device_info(index): + path = get_path(index) + if path is None: + return None + + output = runcmdj(['mmcli', '-J', '-m', path]) + if not output: + return None + + gen = output["modem"]["generic"] + info = { + "index": index, + "vendor": gen["manufacturer"], + "model": gen["model"], + "state": gen["state"], + "qmidev": [], + "atdev": [] + } + for port in gen["ports"]: + if "(qmi)" in port: + info["qmidev"].append(port.split()[0]) + elif "(at)" in port: + info["atdev"].append(port.split()[0]) + + return info + + +def wait_for_device(dev, appear): + if appear: + mode = "appear" + else: + mode = "disappear" + + info("Waiting for modem%d to %s" % (dev["index"], mode)) + + path = "/dev/%s" % dev["qmidev"][0] + timeout = 30 + while timeout > 0: + if mode == "appear": + if exists(path): + return True + elif mode == "disappear": + if not exists(path): + return True + time.sleep(1) + timeout -= 1 + return False + + +def qmiupdate(dev): + if len(dev["qmidev"]) == 0: + err("No qmi device found") + return False + + qmidev = "/dev/%s" % dev["qmidev"][0] + cwefile = None + nvufile = None + + for f in os.scandir(tempdir): + if f.name.endswith('cwe'): + cwefile = "%s/%s" % (tempdir, f.name) + if f.name.endswith('nvu'): + nvufile = "%s/%s" % (tempdir, f.name) + + if not cwefile: + err("No CWE file found") + return False + if not nvufile: + err("No NVU file found") + return False + + info("Resetting modem") + if not runcmd(['qmicli', '-d', qmidev, + '--dms-set-operating-mode=offline']): + err("Unable to set modem offline") + return False + if not runcmd(['qmicli', '-d', qmidev, + '--dms-set-operating-mode=reset']): + err("Unable to reset modem") + return False + + if not wait_for_device(dev, 0): + err("Device has not disappeared") + return False + if not wait_for_device(dev, 1): + err("Device has not appeared") + return False + + info("Running qmi-firmware-update, please stand by...") + + cmd = ['qmi-firmware-update', '-v', '--override-download', + '-w', qmidev, '-u', cwefile, nvufile] + return runcmd(cmd) + + +def firmware_update_sierra(dev, url, checksum): + if not download(url, checksum): + err("Unable to prepare firmware") + return False + + info("Stopping modem services") + initctl("stop", "modemd") + initctl("stop", "modem-manager") + + time.sleep(3) + ret = qmiupdate(dev) + + info("Starting modem services") + initctl("restart", "modem-manager") + initctl("restart", "modemd") + + return ret + + +def atcmd(dev, command, expect=None, timeout=None): + device = "/dev/%s" % dev["atdev"][0] + + cmd = ['modem-command', '-d', device, command] + if expect: + cmd += ['-e', expect] + if timeout: + cmd += ['-t', str(timeout)] + + return runcmd(cmd) + + +def firmware_update_telit(dev, url, checksum): + if 'LN920' not in dev["model"]: + err("Not supported") + return False + + if len(dev["atdev"]) == 0: + err("No AT device found") + return False + + if dev["state"] != "connected": + err("Modem is not connected") + return False + + u = urllib.parse.urlparse(url) + if not u: + err("Unable to parse URL") + return False + if u.scheme != "ftp": + err("Not an FTP URL") + return False + + hostname = "" + username = "" + password = "" + port = 21 + + if u.hostname: + hostname = u.hostname + if u.username: + username = u.username + if u.password: + password = u.password + if u.port: + port = int(u.port) + if u.path: + path = u.path + + if hostname == "": + err("No hostname in URL") + return False + if path == "": + err("No path in URL") + return False + + parms = (hostname, port, path, username, password) + cmd = 'AT#FTPGETOTAENH=%s,%d,%s,%s,%s' % parms + exp = '#DREL' + if not atcmd(dev, cmd, exp, 60): + err("Unable to download firmware") + return False + + if not atcmd(dev, 'AT#OTAUP=2'): + err("Unable to update firmware") + return False + + if not atcmd(dev, 'AT#ENHRST=1,3'): + err("Unable to reboot modem") + return False + + time.sleep(5) + info("Restarting modem services") + initctl("restart", "modem-manager") + initctl("restart", "modemd") + return True + + +def verify(path, checksum): + if not checksum or checksum == "any": + return True + + sha256 = hashlib.sha256() + with open(path, 'rb') as f: + while True: + data = f.read(1024) + if not data: + break + sha256.update(data) + + calculated = sha256.hexdigest() + + if checksum != calculated: + err("Checksum mismatch (%s vs. %s)" % (calculated, checksum)) + return False + else: + info("Verified checksum") + return True + + +def download(url, checksum): + path = "%s/firmware.zip" % tempdir + + info("Downloading %s" % url) + + if not runcmd(['curl', '-s', '-L', url, '-o', path]): + err("Unable to download firmware") + return False + + if not verify(path, checksum): + err("Checksum verification failed") + return False + + if not runcmd(['unzip', '-d', tempdir, path]): + err("Unable to unpack firmware") + return False + + return True + + +if __name__ == "__main__": + syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) + + parser = argparse.ArgumentParser(prog='modem-firmware-update') + parser.add_argument("-i", "--index", default=None, help="Modem index") + parser.add_argument("-u", "--url", default=None, help="URL to firmware") + parser.add_argument("-c", "--checksum", default=None, + help="SHA256 checksum of firmware") + parser.add_argument('-d', action='store_true') + + args = parser.parse_args() + if args.d: + debug = True + if args.index: + index = int(args.index) + if not args.url: + fatal("No firmware URL specified") + + rmdir(tempdir) + mkdir(tempdir) + + dev = get_device_info(index) + if dev is None: + fatal("No device found for modem%d" % index) + + info("Starting firmware update for %s" % dev["model"]) + + if "Sierra" in dev["vendor"]: + ret = firmware_update_sierra(dev, args.url, args.checksum) + elif "Telit" in dev["vendor"]: + ret = firmware_update_telit(dev, args.url, args.checksum) + else: + fatal("Unsupported vendor") + + if ret: + info("Firmware update succeeded") + else: + fatal("Firmware update failed") + + rmdir(tempdir) diff --git a/src/modemd/modem-ussd b/src/modemd/modem-ussd new file mode 100755 index 000000000..e1b1e10ce --- /dev/null +++ b/src/modemd/modem-ussd @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +import argparse +import subprocess +import sys + +verbose = False + + +def runcmd(cmd): + if verbose: + print("Running: %s" % " ".join(cmd)) + + ret = None + try: + res = subprocess.run(cmd, capture_output=True, text=True) + if verbose: + print(res.stdout) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def ussd(index, code): + cmd = ['/sbin/modem-command', + '--index', str(index), + '--expect', '+CUSD', + '--timeout', '60', + 'AT+CUSD=1,"%s",15' % code] + + if verbose: + cmd.append('--verbose') + + output = runcmd(cmd) + if output is None: + sys.stderr.write("ERROR: Command failed\n") + return False + + resp = output.split('"') + if len(resp) == 3: + print(resp[1]) + + return True + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog='modem-ussd') + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-c", "--code", default=None, help="USSD codec") + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + if args.verbose: + verbose = True + + index = 0 + if args.index: + index = int(args.index) + + if not args.code: + sys.stderr.write("Error: need USSD code\n") + else: + if ussd(index, args.code): + sys.exit(0) + + sys.exit(1) diff --git a/src/modemd/modemd b/src/modemd/modemd new file mode 100755 index 000000000..43699fed1 --- /dev/null +++ b/src/modemd/modemd @@ -0,0 +1,1613 @@ +#!/usr/bin/env python3 + +import threading +import subprocess +import argparse +import hashlib +import signal +import syslog +import socket +import shutil +import select +import json +import enum +import time +import os +import sys + +rundir = "/run/modemd" +smsdir = "/var/sms" +debug = False +threads = [] + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + +class Trigger(enum.Enum): + POWER = 1 + RESET = 2 + RESTART = 3 + + +class State(enum.Enum): + FAILED = -1 + DOWN = 0 + ENABLED = 1 + CONNECTING = 2 + CONNECTED = 3 + UP = 4 + + +def info(msg): + syslog.syslog(syslog.LOG_INFO, " %s" % msg) + + +def dbg(msg): + global debug + if debug: + syslog.syslog(syslog.LOG_INFO, " %s" % msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, " %s" % msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, " %s" % msg) + sys.exit(1) + + +def opener(path, flags): + ret = os.open(path, flags, 0o664) + shutil.chown(path, user="root", group="wheel") + return ret + + +def rmf(path): + if os.path.exists(path): + os.unlink(path) + + +def rmrf(path): + for f in os.listdir(path): + p = os.path.join(path, f) + if os.path.isdir(p): + rmrf(p) + else: + os.remove(p) + + +def mkdir(path): + if os.path.isdir(path): + return True + try: + os.mkdir(path, mode=0o755) + shutil.chown(path, user="root", group="wheel") + except OSError: + fatal("Unable to mkdir %s" % path) + + +def fread(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +def freadj(path): + output = fread(path) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + dbg("Unable to parse json output") + return None + finally: + return data + + +def fwrite(path, cont): + with open(path, "w", opener=opener) as fd: + if not fd.write(cont): + return False + return True + + +def fwritej(path, cont): + if fwrite(path, json.dumps(cont)): + return True + else: + return False + + +def runcmdp(cmd1, cmd2, check=True): + dbg("Running: %s | %s" % (" ".join(cmd1), " ".join(cmd2))) + ret = None + try: + res1 = subprocess.run(cmd1, check=check, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + res2 = subprocess.run(cmd2, input=res1.stdout, check=check, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if res1.returncode == 0 and res2.returncode == 0: + if res2.stdout: + ret = res2.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + dbg("Piped command failed") + dbg(res1.stderr) + dbg(res2.stderr) + return None + finally: + return ret + + +def runcmd(cmd, check=True): + dbg("Running: %s" % " ".join(cmd)) + ret = None + try: + res = subprocess.run(cmd, check=check, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError as e: + dbg("Command %s failed" % cmd[0]) + dbg(e) + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + dbg("Unable to parse json output") + return None + finally: + return data + + +def list_sms(modem): + smslist = [] + + output = None + if modem: + output = runcmdj(["mmcli", "-J", "-m", modem, + "--messaging-list-sms"]) + if not output: + return smslist + + for path in output["modem.messaging.sms"]: + output = runcmdj(["mmcli", "-J", "-s", path]) + if not output: + continue + props = output["sms"]["properties"] + if props["pdu-type"] == "submit": + if props["state"] == "--": + smslist.append({"path": path, + "type": "notsent"}) + elif props["state"] == "sent": + smslist.append({"path": path, + "type": "sent"}) + elif props["pdu-type"] == "deliver": + if props["state"] == "received": + smslist.append({"path": path, + "type": "received", + "payload": output["sms"]}) + + return smslist + + +class RpcThread(threading.Thread): + path = None + server = None + + def __init__(self): + global rundir + super().__init__(daemon=True, name="rpc") + self.exited = False + self.stopevent = threading.Event() + self.sock = rundir + "/modemd.sock" + + def stop(self): + self.stopevent.set() + + def stopped(self): + return self.stopevent.is_set() + + def sendsms(self, path, data): + info("Sending sms to %s" % data["number"]) + if not path: + err("No modem path yet") + return False + + opts = [] + opts.append('number="%s"' % data["number"]) + opts.append('text="%s"' % data["text"]) + + if not runcmd(["mmcli", "-m", path, + "--messaging-create-sms=%s" % ",".join(opts)]): + err("Unable to create sms") + return False + + return True + + def rpc(self, rpc, data): + global threads + + info("Got rpc %s for modem%d" % (rpc, data["index"])) + for th in threads: + if th.name.startswith("modem") and th.index == data["index"]: + if rpc == "restart": + th.trigger = Trigger.RESTART + th.interrupt = True + elif rpc == "reset": + th.trigger = Trigger.RESET + th.interrupt = True + elif rpc == "send-sms": + self.sendsms(th.path, data) + return True + + def handle(self, msg): + if "rpc" not in msg: + err("No rpc in msg") + return False + elif "data" not in msg: + err("No data in msg") + return False + elif "index" not in msg["data"]: + err("No index") + return False + + if msg["rpc"] == "send-sms": + if "number" not in msg["data"]: + err("No sms number") + return False + if "text" not in msg["data"]: + err("No sms text") + return False + + return self.rpc(msg["rpc"], msg["data"]) + + def process(self): + conn, addr = self.server.accept() + try: + while not self.stopped(): + msg = conn.recv(1024) + if msg: + self.handle(json.loads(msg.decode())) + else: + break + finally: + conn.close() + return True + + def run(self): + rmf(self.sock) + self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server.bind(self.sock) + self.server.listen(1) + + while not self.stopped(): + if not self.process(): + return False + + self.server.close() + rmf(self.sock) + + dbg("Exiting rpc") + self.exited = True + return True + + +class SimThread(threading.Thread): + def __init__(self): + super().__init__(daemon=True, name="sim") + self.exited = False + self.stopevent = threading.Event() + + def stop(self): + self.stopevent.set() + + def stopped(self): + return self.stopevent.is_set() + + def changed(self, index): + global threads + + path = "/sys/class/sim/sim%d/present" % index + present = int(fread(path)) + if present is None: + return + if present == 0: + info("sim%d disappeared" % index) + return + + info("sim%d appeared" % index) + for th in threads: + if th.name.startswith("modem") and th.sim["index"] == index: + th.trigger = Trigger.POWER + th.interrupt = True + + def run(self): + fd = os.open("/dev/simctrl", os.O_RDONLY) + while not self.stopped(): + res, *_ = select.select([fd], [], [], 10.0) + if fd in res: + index = int.from_bytes(os.read(fd, 1)) + self.changed(index) + + dbg("Exiting sim") + fd.close() + self.exited = True + return True + + +class ModemThread(threading.Thread): + index = None + cfg = None + + def __init__(self, cfg): + global rundir + + self.index = cfg["index"] + super().__init__(daemon=True, name="modem%d" % self.index) + self.stopevent = threading.Event() + self.exited = False + + self.cfg = cfg + self.prefix = "[modem%d] " % self.index + self.rundir = "%s/modem%d" % (rundir, self.index) + self.statedir = "%s/state" % self.rundir + self.locdir = "%s/location" % self.rundir + + mkdir(self.rundir) + mkdir(self.statedir) + mkdir(self.locdir) + + self.init() + + def stop(self): + self.stopevent.set() + + def stopped(self): + return self.stopevent.is_set() + + def dbg(self, msg): + dbg("%s%s" % (self.prefix, msg)) + + def info(self, msg): + info("%s%s" % (self.prefix, msg)) + + def err(self, msg): + err("%s%s" % (self.prefix, msg)) + + def fatal(self, msg): + fatal("%s%s" % (self.prefix, msg)) + + def init(self): + self.timeout = 1 + self.path = None + self.interfaces = [] + self.ifaces = [] + self.status = {} + self.state = None + self.trigger = None + self.iface = None + self.sim = None + self.failtime = None + self.detected = False + self.timer1 = 0 + self.timer1 = 0 + self.timer2 = 0 + self.updfail = 0 + self.connfail = 0 + self.location = {"enabled": False} + + self.set_state(State.DOWN) + self.configure() + + def configure(self): + self.bearers = { + "list": [], + "initial": None, + "active": [] + } + bearers = self.cfg.get("bearer") + if not bearers: + self.err("No bearers configured") + return False + + default = None + count = 0 + for bearer in sorted(bearers, key=lambda b: b["index"]): + bearer["pid"] = "--" + + apn = bearer.get("apn") + if not apn: + self.err("No APN configured") + return False + + apntype = bearer.get("apn-type") + if apntype == "initial": + if not self.bearers["initial"]: + self.bearers["initial"] = bearer + elif apntype == "default" and not default: + default = bearer + count += 1 + else: + self.bearers["list"].append(bearer) + count += 1 + + if default: + self.bearers["list"].insert(0, default) + + # at least one bearer is mandatory + if len(self.bearers["list"]) == 0: + self.err("No bearers evaluated") + return False + + # check for duplicate APNs + seen = [] + for bearer in self.bearers["list"]: + if bearer["apn"] not in seen: + seen.append(bearer["apn"]) + else: + self.err("Duplicate APN '%s'" % bearer["apn"]) + return False + + if not self.bearers["initial"]: + self.bearers["initial"] = self.bearers["list"][0] + + # check location + enabled = False + sources = [] + loc = self.cfg.get("location") + if loc and loc.get("enabled"): + enabled = True + v = loc.get("source") + if v: + sources = [s.get("source") for s in v] + + self.location = { + "enabled": enabled, + "sources": sources, + "state": "down"} + + return True + + def find_bearer(self, apn): + for bearer in self.bearers["active"]: + if bearer["apn"] == apn: + return bearer + return None + + def restart(self): + self.info("Restarting") + self.notif("RESTART") + self.pulldown() + + def power(self): + self.info("Power-cycling") + self.notif("POWER") + + self.pulldown() + + if runcmd(['/usr/libexec/modemd/modem-power', + '-i', str(self.index)]): + self.info("Waiting 10s") + time.sleep(10) + else: + self.err("Unable to power-cycle") + + self.init() + + def reset(self): + self.info("Resetting") + self.notif("RESET") + + self.pulldown() + + wait = 0 + if runcmd(["mmcli", "-m", self.path, + "--reset"]): + self.info("Performed a normal reset") + wait = 5 + elif runcmd(["mmcli", "-m", self.path, + "--factory-reset=000000"]): + self.info("Performed a factory reset") + wait = 10 + else: + self.err("Unable to reset") + + if wait > 0: + self.info("Waiting %ds" % wait) + time.sleep(wait) + + self.init() + + def notif(self, msg): + data = { + "infix-modem:modems": { + "modem": [{ + "index": self.index, + "status-update": {"desc": msg} + }] + } + } + note = "%s/notif" % self.rundir + fwritej(note, data) + if runcmd(["sysrepocfg", "-f", "json", "-N", note]): + self.dbg("Sent notification: %s" % msg) + rmf(note) + + def get_bearers(self): + bearers = [] + if not self.update(): + return bearers + for path in self.status.get("bearers", []): + output = runcmdj(["mmcli", "-J", "-m", self.path, "-b", path]) + if output and "bearer" in output: + bearers.append(output["bearer"]) + return bearers + + def get_profiles(self): + profiles = [] + output = runcmdj(["mmcli", "-J", "-m", self.path, + "--3gpp-profile-manager-list"]) + if not output: + return profiles + for entry in output["modem"]["3gpp"]["profile-manager"]["list"]: + profile = {} + for attr in entry.split(", "): + (key, value) = attr.split(": ") + profile[key] = value + profiles.append(profile) + return profiles + + def get_conn_failure(self, path): + for bearer in self.get_bearers(): + if bearer["dbus-path"] == path: + return bearer["status"]["connection-error"]["message"] + return "unknown" + + def update(self): + if not self.path: + return False + output = runcmdj(["mmcli", "-J", "-m", self.path]) + if output: + output = output.get("modem", {}) + output = output.get("generic", None) + if output: + self.status = output + self.updfail = 0 + return True + + self.updfail += 1 + if self.updfail > 5: + self.err("Reinit after too many failures") + time.sleep(10) + self.init() + + return False + + def iplink(self, iface, action): + dbg("Setting link of %s to %s" % (iface, action)) + if not runcmd(["ip", "link", "set", "dev", iface, action]): + self.err("Unable to set link of %s to %s" % (iface, action)) + return False + if not runcmd(["ip", "link", "set", "dev", iface, "state", action]): + self.err("Unable to set link state of %s to %s" % (iface, action)) + return False + return True + + def resolvconf(self, iface, action): + enabled = iface["bearer"].get("dns-enabled", False) + if enabled is False: + return True + + self.info("About to %s nameservers on %s" % (action, iface["name"])) + + runcmd(["resolvconf", "-d", iface["name"]], check=False) + if action != "add": + return True + + count = 0 + data = "" + for dns in (iface["ipv4"]["dns"] + iface["ipv6"]["dns"]): + self.dbg("Adding nameserver %s" % dns) + data += "nameserver %s\n" % dns + count += 1 + + if count > 0: + path = "%s/resolv.conf" % self.rundir + fwrite(path, data) + if not runcmdp(["cat", path], ["resolvconf", "-a", iface["name"]]): + self.err("Unable to add nameservers on %s" % iface["name"]) + return False + + return True + + def ipgwroute(self, iface, action): + output = runcmdj(["ip", "-j", "route", + "show", "dev", iface["name"]]) + for rt in output: + if "scope" in rt and rt["scope"] == "link": + if action == "add": + return True + else: + runcmd(["ip", "route", + "del", rt["dst"], "dev", iface["name"]]) + + if action != "add": + return True + + if iface["ipv4"]["gateway"] != "--": + gw = iface["ipv4"]["gateway"] + elif iface["ipv6"]["gateway"] != "--": + gw = iface["ipv6"]["gateway"] + else: + self.err("No gateway address found") + return False + + cmd = ["ip", "route", "add", gw, "dev", iface["name"]] + if not runcmd(cmd): + self.err("Unable to add gateway route") + return False + + return True + + def ipdefroute(self, iface, action): + self.info("About to %s default route on %s" % (action, iface["name"])) + + output = runcmdj(["ip", "-j", "route", "show", "default"]) + if output: + for route in output: + if not runcmd(["ip", "route", "del", "default", + "dev", route["dev"]]): + self.err("Can't delete default route on %s" % route["dev"]) + + if not self.ipgwroute(iface, action): + return False + + if action != "add": + return True + + cmd = ["ip", "route", "add", "default", "dev", iface["name"]] + if iface["ipv4"]["gateway"] != "--": + cmd += ["via", iface["ipv4"]["gateway"]] + elif iface["ipv6"]["gateway"] != "--": + cmd += ["via", iface["ipv6"]["gateway"]] + + if not runcmd(cmd): + self.err("Unable to add default route on %s" % iface["name"]) + time.sleep(30) + return False + else: + return True + + def ifupdown(self, iface, action): + enabled = iface["bearer"].get("firewall-enabled", False) + if enabled is True: + self.info("Setting %s firewall on %s" % (action, self.iface)) + if not runcmd(["/etc/firewall/default", action, self.iface]): + return False + + path = "/var/ifupdown/%s" % self.iface + if os.path.isfile(path) or os.access(path, os.X_OK): + self.info("Running %s %s" % (path, action)) + if not runcmd([path, action]): + return False + + return True + + def linkdown(self, iface): + self.resolvconf(iface, "delete") + self.ipdefroute(iface, "delete") + self.ifupdown(iface, "down") + + output = runcmdj(["ip", "-j", "addr", "show", iface["name"]]) + for entry in output: + for addr in entry["addr_info"]: + runcmd(["ip", "addr", "del", + addr["local"] + "/" + str(addr["prefixlen"]), + "dev", iface["name"]]) + + self.iplink(iface["name"], "down") + + for i in range(len(self.ifaces)): + if self.ifaces[i]["name"] == iface["name"]: + del self.ifaces[i] + + return True + + def linkup(self, iface): + self.iplink(iface["name"], "up") + + addrs = [] + for ip in (iface["ipv4"], iface["ipv6"]): + if ip["address"] != "--" and ip["prefix"] != "--": + addrs.append("%s/%s" % (ip["address"], ip["prefix"])) + + if len(addrs) == 0: + self.err("No addresses yet") + return False + + for addr in addrs: + self.info("Setting address %s on %s" % (addr, iface["name"])) + if not runcmd(["ip", "addr", "change", + addr, "dev", iface["name"]]): + self.err("Unable to set address for %s" % iface["name"]) + return False + + if iface["bearer"].get("default-route"): + if not self.ipdefroute(iface, "add"): + return False + + if not self.ifupdown(iface, "up"): + return False + + if not self.resolvconf(iface, "add"): + return False + + self.ifaces.append(iface) + return True + + def poweron(self): + if self.status.get("power-state", "") == "off": + self.info("Powering on") + if not runcmd(["mmcli", "-m", self.path, "--set-power-state-on"]): + self.err("Unable to power on") + return False + return True + + def disable(self): + self.info("Disabling") + if not runcmd(["mmcli", "-m", self.path, "--disable"]): + self.err("Unable to disable") + return False + else: + return True + + def enable(self): + state = self.status.get("state", "unknown") + self.info("Enabling (state '%s')" % state) + + if not self.path: + return False + if state == "unknown" or state == "disabled": + if not runcmd(["mmcli", "-m", self.path, "--enable"]): + self.err("Unable to enable") + return False + return True + + def unlock(self): + cmd = ["mmcli", "-m", self.path, "--sim=%s" % + self.status.get("sim", "0")] + + if self.status.get("unlock-required", "") == "sim-pin": + self.info("Unlocking with PIN") + pin = self.cfg.get("pin") + if not pin: + self.err("No PIN defined") + return False + cmd.append("--pin=%s" % pin) + elif self.status.get("unlock-required", "") == "sim-puk": + self.info("Unlocking with PUK") + puk = self.cfg.get("puk") + if not puk: + self.err("No PUK defined") + return False + cmd.append("--puk=%s" % puk) + else: + self.err("Unsupported lock") + return False + + self.disable() + if not runcmd(cmd): + self.err("Unable to unlock") + return False + else: + return True + + def setcarrier(self): + path = "%s/carrier-support" % self.rundir + if os.path.exists(path): + supported = int(fread(path)) + else: + if runcmd(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--check"]): + supported = 1 + else: + supported = 0 + + fwrite(path, "%d" % supported) + + if supported == 0: + self.info("Modem does not support setting a carrier") + return True + + path = "%s/supported-carriers" % self.rundir + if os.path.exists(path): + carriers = freadj(path) + else: + self.info("Getting supported carriers for %s %s" % + (self.manf, self.model)) + carriers = runcmdj(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--index", str(self.index), + "--list"]) + if carriers: + fwritej(path, carriers) + + if not carriers: + self.err("Unable to list supported carriers") + return False + + path = "%s/current-carrier" % self.rundir + if os.path.exists(path): + current = freadj(path) + else: + output = runcmdj(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--index", str(self.index), + "--get"]) + if output and isinstance(output, dict): + current = output.get("name") + if current: + fwritej(path, current) + + if not current: + self.err("Unable to get current carrier") + return False + + self.info("Current carrier is '%s'" % current) + + carrier = self.cfg.get("carrier", "default") + if carrier == current: + return True + + self.info("Setting carrier '%s' for modem%d" % + (carrier, self.index)) + + output = runcmdj(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--index", str(self.index), + "--set", carrier]) + if not output: + self.err("Unable to set carrier '%s' on modem%d" % + (carrier, self.index)) + return False + + fwritej(path, carrier) + + if output.get("action", "") == "reset": + self.reset() + return False + else: + return True + + def initialize(self): + self.pulldown() + self.update() + self.poweron() + + if not self.setcarrier(): + return False + + if self.status.get("state", "") == "locked": + if not self.unlock(): + return False + + return self.enable() + + def get_profile_args(self, bearer): + args = [ + "profile-id=%s" % bearer["pid"], + "profile-name=profile%s" % bearer["pid"] + ] + v = bearer.get("apn-type") + if v: + args.append("apn-type=%s" % v) + v = bearer.get("apn") + if v: + args.append("apn=%s" % v) + v = bearer.get("ip-type") + if v: + args.append("ip-type=%s" % v) + v = bearer.get("username") + if v: + args.append("user=%s" % v) + v = bearer.get("password") + if v: + args.append("password=%s" % v) + + return ",".join(args) + + def get_bearer_args(self, bearer): + args = [] + pid = bearer.get("pid") + if pid and pid == "!-": + args.append("profile-id=%s" % pid) + else: + v = bearer.get("apn") + if v: + args.append("apn=%s" % v) + v = bearer.get("ip-type") + if v: + args.append("ip-type=%s" % v) + v = bearer.get("username") + if v: + args.append("user=%s" % v) + v = bearer.get("password") + if v: + args.append("password=%s" % v) + + v = bearer.get("allow-roaming") + if v: + args.append("allow-roaming=true") + else: + args.append("allow-roaming=false") + + return ",".join(args) + + def prepare_profile_bearers(self, bearers): + self.info("Preparing profile bearers") + + # wipe auxiliary profiles + for p in self.get_profiles(): + if p["profile-id"] != "1": + self.dbg("Deleting profile %s" % p["profile-id"]) + if not runcmd(["mmcli", "-m", self.path, + "--3gpp-profile-manager-delete=%s" % + p["profile-id"]]): + self.err("Unable to delete profile") + return False + + # create profiles and bearers + pid = 1 + for bearer in bearers: + bearer["pid"] = str(pid) + pid += 1 + + if pid > len(self.interfaces): + self.err("Max. number of bearers reached") + break + if bearer["pid"] != "1": + if not runcmd(["mmcli", "-m", self.path, + "--3gpp-profile-manager-set="]): + self.err("Unable to create profile") + return False + + args = self.get_profile_args(bearer) + if not runcmd(["mmcli", "-m", self.path, + "--3gpp-profile-manager-set=%s" % args]): + self.err("Unable to configure profile") + return False + + args = self.get_bearer_args(bearer) + if not runcmd(["mmcli", "-m", self.path, + "--create-bearer=%s" % args]): + self.err("Unable to create bearer") + return False + + self.info("Created profile bearer %s for '%s'" % + (bearer["pid"], bearer["apn"])) + self.bearers["active"].append(bearer) + return True + + def prepare_multiplex_bearers(self, bearers): + self.info("Preparing multiplex bearers") + + for bearer in bearers: + args = self.get_bearer_args(bearer) + if not runcmd(["mmcli", "-m", self.path, + "--create-bearer=multiplex=required,%s" % args]): + self.err("Unable to create bearer") + return False + self.info("Created multiplex bearer for '%s'" % bearer["apn"]) + self.bearers["active"].append(bearer) + return True + + def prepare_default_bearer(self, bearer): + if not runcmd(["mmcli", "-m", self.path, + "--create-bearer=%s" % + self.get_bearer_args(bearer)]): + self.err("Unable to create bearer") + return False + + self.info("Created default bearer '%s'" % bearer["apn"]) + self.bearers["active"].append(bearer) + return True + + def prepare_bearers(self): + self.info("Preparing bearers") + + # wipe existing bearers + for bearer in self.get_bearers(): + self.dbg("Deleting bearer %s" % bearer["dbus-path"]) + if not runcmd(["mmcli", "-m", self.path, + "--delete-bearer=%s" % bearer["dbus-path"]]): + self.err("Unable to delete bearer") + return False + + # configure initial bearer (used to attach to network) + initial = self.bearers.get("initial") + if initial: + self.info("Setting initial bearer '%s'" % initial["apn"]) + if not runcmd(["mmcli", "-m", self.path, + "--3gpp-set-initial-eps-bearer-settings=%s" % + self.get_bearer_args(initial)]): + self.err("Unable to set initial bearer") + + # configure dialup bearers + count = len(self.bearers["list"]) + self.info("Configuring %d bearers" % count) + + if len(self.bearers["list"]) == 1: + return self.prepare_default_bearer(self.bearers["list"][0]) + elif len(self.interfaces) == 1: + # use multiplex beares in case of 1 interface + return self.prepare_multiplex_bearers(self.bearers["list"]) + else: + # use profile beaeres in case of multiple interfaces + return self.prepare_profile_bearers(self.bearers["list"]) + + def prepare_bands(self): + self.info("Preparing bands") + + v = self.cfg.get("band", []) + bands = [k.get("band", "any") for k in v] + if len(bands) == 0 or "any" in bands: + return True + + if not runcmd(["mmcli", "-m", self.path, + "--set-current-bands=%s" % "|".join(bands)]): + self.err("Unable to set band") + return False + return True + + def prepare_modes(self): + self.info("Preparing modes") + + pref = self.cfg.get("preferred-mode") + if not pref or pref == "any": + pref = "none" + + allow = [] + v = self.cfg.get("allowed-mode", []) + allow = [k.get("mode", "any") for k in v] + if "any" in allow: + allow = [] + + if pref == "none" and len(allow) == 0: + runcmd(["mmcli", "-m", self.path, "--set-allowed-modes=any"], check=False) + return True + + mode = "allowed: %s; preferred: %s" % (", ".join(allow), pref) + supported = False + for m in self.status.get("supported-modes", []): + if m == mode: + supported = True + break + if not supported: + self.err("Mode '%s' is not supported" % mode) + return False + + current = self.status.get("current-modes", "") + if current == mode: + self.info("Mode '%s' already set" % mode) + return True + + self.info("Setting mode '%s'" % mode) + + cmd = ["mmcli", "-m", self.path] + if len(allow) > 0: + cmd.append("--set-allowed-modes=%s" % "|".join(allow)) + if pref != "none": + cmd.append("--set-preferred-mode=%s" % pref) + return runcmd(cmd) + + def prepare_location(self): + self.info("Preparing location") + + rmrf(self.locdir) + if not self.location["enabled"]: + fwrite("%s/disabled" % self.locdir, "1") + return True + + for source in ["gps", "agps-msa", "agps-msb", "3gpp", "cdma"]: + if self.location["enabled"] and source in self.location["sources"]: + self.info("Enabling location source '%s'" % source) + action = "enable" + else: + self.dbg("Disabling location source '%s'" % source) + action = "disable" + + args = [] + if source == "gps": + args = ["--location-%s-gps-nmea" % action, + "--location-%s-gps-raw" % action] + elif source == "agps-msa": + args = ["--location-%s-agps-msa" % action] + elif source == "agps-msb": + args = ["--location-%s-agps-msb" % action] + elif source == "3gpp": + args = ["--location-%s-3gpp" % action] + elif source == "cdma": + args = ["--location-%s-cdma-bs" % action] + else: + continue + + if not runcmd(["mmcli", "-m", self.path] + args): + self.err("Unable to %s location source" % action) + if self.location["enabled"]: + fwrite("%s/failed" % self.locdir, "1") + self.location["state"] = "failed" + return False + return True + + def prepare(self): + if not self.detected: + return False + + self.connfail = 0 + + if not self.prepare_bands(): + self.err("Unable to prepare bands") + return False + if not self.prepare_modes(): + self.err("Unable to prepare mode") + return False + if not self.prepare_bearers(): + self.err("Unable to prepare bearers") + return False + if not self.prepare_location(): + self.err("Unable to prepare location") + return False + + self.info("Waiting for network") + return True + + def connect(self): + for bearer in self.status.get("bearers", []): + self.info("Connecting %s" % bearer) + if not runcmd(["mmcli", "-m", self.path, + "--connect", + "--bearer=%s" % bearer + ]): + self.connfail += 1 + self.err("Unable to connect (%d failures)" % self.connfail) + self.err("Reason: %s" % self.get_conn_failure(bearer)) + return False + return True + + def pullup(self): + count = 0 + for bearer in self.get_bearers(): + iface = { + "name": bearer["status"]["interface"], + "connected": bearer["status"]["connected"], + "ipv4": bearer["ipv4-config"], + "ipv6": bearer["ipv6-config"], + "bearer": self.find_bearer(bearer["properties"]["apn"]) + } + if iface["bearer"] is None: + continue + if iface["name"] == "--": + continue + if iface["connected"] != "yes": + continue + if iface["ipv4"]["address"] == "--": + if iface["ipv6"]["address"] == "--": + continue + if not self.linkup(iface): + self.linkdown(iface) + return False + else: + count += 1 + + if count == len(self.bearers["list"]): + self.info("All bearers are up") + return True + else: + return False + + def pulldown(self): + for bearer in self.get_bearers(): + iface = { + "name": bearer["status"]["interface"], + "bearer": self.find_bearer(bearer["properties"]["apn"]) + } + + if bearer["status"]["connected"] == "yes": + self.info("Disconnecting %s" % bearer["dbus-path"]) + runcmd(["mmcli", "-m", self.path, + "--disconnect", "--bearer=%s" % bearer["dbus-path"]]) + for iface in self.ifaces: + self.linkdown(iface) + + self.set_state(State.DOWN) + + def set_state(self, state): + if state == self.state: + return False + + if state == State.UP: + self.notif(state.name) + elif state == State.DOWN and self.state == State.UP: + self.notif(state.name) + elif state == State.FAILED: + self.notif(state.name) + + rmrf(self.statedir) + fwrite("%s/%s" % (self.statedir, state.name.lower()), "1") + self.state = state + return True + + def check_state(self): + self.dbg("State %s (status %s)" % + (self.state.name, self.status.get("state"))) + + if self.detected and self.status.get("state", "") == "failed": + if self.set_state(State.FAILED): + self.err("In failed state because %s" % + self.status.get("state-failed-reason", "")) + self.failtime = time.time() + + # state failed + if self.state == State.FAILED: + elapsed = time.time() - self.failtime + if elapsed > 30: + self.err("Resetting after being 30s in state failed") + self.reset() + + # state down + elif self.state == State.DOWN: + if self.status.get("state", "") == "disabled": + self.enable() + self.timeout = 3 + elif self.status.get("state", "") != "initializing": + if self.prepare(): + self.set_state(State.ENABLED) + + # state enabled + elif self.state == State.ENABLED: + if self.status.get("state", "") == "registered": + if self.state != "connecting": + if self.connect(): + self.set_state(State.CONNECTING) + elif self.connfail > 5: + self.err("Resetting after 5 connection attempts") + self.reset() + + # state connecting + elif self.state == State.CONNECTING: + if self.status.get("state", "") == "connected": + self.set_state(State.CONNECTED) + + # state connected + elif self.state == State.CONNECTED: + if self.state != State.UP: + if self.pullup(): + # all bearers are up + self.set_state(State.UP) + + # state up + elif self.state == State.UP: + self.timeout = 10 + if self.status.get("state", "") != "connected": + self.pulldown() + + + def check_trigger(self): + if self.trigger is not None: + if self.trigger == Trigger.RESTART: + self.restart() + elif self.trigger == Trigger.RESET: + self.reset() + elif self.trigger == Trigger.POWER: + self.power() + + self.trigger = None + + def check_location(self): + if not self.location["enabled"]: + return + + output = runcmdj(["mmcli", "-J", "-m", self.path, + "--location-get"]) + if output: + lat = output["modem"]["location"]["gps"]["latitude"] + lon = output["modem"]["location"]["gps"]["longitude"] + else: + lat = "--" + lon = "--" + + if lat != "--" and lon != "--": + if self.location["state"] != "up": + self.info("Retrieved GPS location [%s, %s]" % (lat, lon)) + rmf("%s/down" % self.locdir) + fwrite("%s/up" % self.locdir, "1") + self.location["state"] = "up" + else: + rmf("%s/up" % self.locdir) + fwrite("%s/down" % self.locdir, "1") + self.location["state"] = "down" + + def save_sms(self, sms): + global smsdir + + payload = sms["payload"] + + string = "%s,%s,%s" % (payload["properties"]["timestamp"], + payload["content"]["number"], + payload["content"]["text"]) + m = hashlib.sha256() + m.update(string.encode()) + path = os.path.join(smsdir, m.hexdigest()) + + if not os.path.exists(path): + self.dbg("Saving SMS to %s" % sms["path"]) + data = {"modem": self.index, "payload": payload} + fwritej(path, data) + self.notif("SMS-RECEIVED %s" % os.path.basename(path)) + + storage = payload["properties"]["storage"].upper() + if storage != "--": + if not runcmd(["mmcli", "-m", self.path, + "--messaging-delete-sms=%s" % sms["path"]]): + self.err("Unable to delete SMS from %s storage" % storage) + else: + self.info("Deleted SMS from %s storage" % storage) + + def check_sms(self): + if self.state.value < State.ENABLED.value: + return + state = self.status.get("state", "") + if state != "registered" and state != "connected": + return + + for sms in list_sms(self.path): + if sms["type"] == "notsent": + self.dbg("Sending sms %s" % sms) + runcmd(["mmcli", "-m", self.path, "-s", sms["path"], "--send"]) + + elif sms["type"] == "sent": + self.notif("SMS-SENT") + runcmd(["mmcli", "-m", self.path, + "--messaging-delete-sms=%s" % sms["path"]]) + + elif sms["type"] == "received": + self.save_sms(sms) + + def check(self): + timeout = 10 if self.state == State.UP else 3 + elapsed = time.time() - self.timer1 + if elapsed > timeout: + self.check_trigger() + if self.update(): + self.check_state() + self.timer1 = time.time() + + elapsed = time.time() - self.timer2 + if elapsed > 30: + self.check_location() + self.check_sms() + self.timer2 = time.time() + + def lookup(self): + if self.iface and self.sim: + return True + + info = runcmdj(['/usr/libexec/modemd/modem-info', + '-i', str(self.index)]) + if not info: + self.err("No modem info") + return False + + ifaces = info.get("interfaces", []) + if len(info.get("interfaces", [])) == 0: + self.err("No modem interface") + return False + else: + self.iface = ifaces[0] + + self.sim = info.get("sim", None) + if not self.sim: + self.err("No modem sim") + return False + + if self.sim["index"] < 0: + self.err("Invalid sim index") + return False + + return True + + def query(self): + if self.path: + return True + + modem = None + output = runcmdj(['/usr/libexec/modemd/modem-info']) + if output: + for m in output: + if m.get("index", -1) == self.index: + modem = m + break + if modem is None: + self.err("Modem not present") + return False + + info = modem.get("info") + if not info: + self.err("Unable to obtain modem info") + return False + + path = "%s/manufacturer" % self.rundir + self.manf = info.get("manufacturer", "") + self.dbg("Manufacturer is '%s'" % self.manf) + fwrite(path, self.manf) + + path = "%s/model" % self.rundir + self.model = info.get("model", "") + self.dbg("Model is '%s'" % self.model) + fwrite(path, self.model) + + self.path = modem.get("path") + self.dbg("Got path %s" % self.path) + return True + + def detect(self): + self.info("Detecting") + + path = "%s/detected" % self.rundir + if os.path.exists(path): + os.remove(path) + + while not self.stopped(): + if self.lookup() and self.query(): + fwrite(path, "1") + self.info("Detected %s %s" % (self.manf, self.model)) + return True + else: + time.sleep(1) + + def run(self): + while not self.stopped(): + self.dbg("state %s, status '%s' (detected %s)" % + (self.state.name, + self.status.get("state", "unknown"), + str(self.detected))) + + if not self.detected: + if self.detect() and self.initialize(): + self.detected = True + else: + time.sleep(3) + + if self.detected: + self.check() + + time.sleep(1) + + self.pulldown() + + rmrf(self.statedir) + rmrf(self.locdir) + rmrf(self.rundir) + + self.dbg("Exiting") + self.exited = True + return True + + +def sighandler(signum, frame): + global threads + + for th in threads: + th.stop() + + # wait for modem threads + timeout = 0 + while timeout < 3: + exited = True + for th in threads: + if th.name.startswith("modem") and not th.exited: + exited = False + if exited: + break + time.sleep(1) + timeout += 1 + + if signum == 15: + sys.exit(0) + else: + fatal("Exiting on signal %d" % signum) + + +if __name__ == "__main__": + syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) + + if os.geteuid() != 0: + fatal("Must be root") + + parser = argparse.ArgumentParser(prog="modemd") + parser.add_argument("-d", action="store_true") + args = parser.parse_args() + + if args.d: + debug = True + else: + with open("/proc/cmdline", 'r') as fd: + cmdline = fd.read() + if "debug" in cmdline: + debug = True + + if debug: + runcmd(["mmcli", "-G", "debug"]) + + mkdir(rundir) + mkdir(smsdir) + signal.signal(signal.SIGINT, sighandler) + signal.signal(signal.SIGTERM, sighandler) + + try: + th = RpcThread() + th.start() + except Exception as e: + if debug: + print(e) + fatal("Rpc thread caught an exception") + + try: + th = SimThread() + th.start() + except Exception as e: + if debug: + print(e) + fatal("SIM thread caught an exception") + + config = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "infix-modem"]) + if not config or "infix-modem:modems" not in config: + fatal("Unable to read config") + + if not runcmd(['/usr/libexec/modemd/sim-setup']): + fatal("Unable to setup SIMs") + + for cfg in config["infix-modem:modems"]["modem"]: + if not cfg["enabled"] or cfg["index"] < 0: + continue + + th = None + try: + th = ModemThread(cfg) + th.start() + except Exception as e: + if debug: + print(e) + fatal("Modem %d thread caught an exception" % cfg["index"]) + if th: + threads.append(th) + + if len(threads) == 0: + fatal("No modem threads are running") + + [th.join() for th in threads] + + fatal("Abnormal exit") diff --git a/src/modemd/modemd.modules-load b/src/modemd/modemd.modules-load new file mode 100644 index 000000000..454279039 --- /dev/null +++ b/src/modemd/modemd.modules-load @@ -0,0 +1,2 @@ +# Placeholder — add module names here if any modem drivers need explicit +# loading (e.g. when device IDs are not yet upstream in the driver table). diff --git a/src/modemd/modemd.rules b/src/modemd/modemd.rules new file mode 100644 index 000000000..4725c0570 --- /dev/null +++ b/src/modemd/modemd.rules @@ -0,0 +1,8 @@ +ACTION!="add", GOTO="modemd_end" + +SUBSYSTEM=="net", ENV{INTERFACE}=="wwan[0-9]*", RUN+="/usr/libexec/modemd/modem-udev" +SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}=="1", RUN+="/usr/libexec/modemd/modem-udev" +SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_SECONDARY}=="1", RUN+="/usr/libexec/modemd/modem-udev" +SUBSYSTEM=="usbmisc", KERNEL=="cdc-wdm[0-9]*",, RUN+="/usr/libexec/modemd/modem-udev" + +LABEL="modemd_end" diff --git a/src/modemd/qmi-wwan-ids.rules b/src/modemd/qmi-wwan-ids.rules new file mode 100644 index 000000000..902f8d8f8 --- /dev/null +++ b/src/modemd/qmi-wwan-ids.rules @@ -0,0 +1,7 @@ +# Bind USB modem devices whose IDs are not yet upstream in qmi_wwan. +# Required until the IDs are accepted into the kernel driver. +ACTION!="add", GOTO="qmi_wwan_ids_end" +SUBSYSTEM!="usb", GOTO="qmi_wwan_ids_end" +ENV{DEVTYPE}!="usb_device", GOTO="qmi_wwan_ids_end" + +LABEL="qmi_wwan_ids_end" diff --git a/src/modemd/sim-setup b/src/modemd/sim-setup new file mode 100755 index 000000000..be400f036 --- /dev/null +++ b/src/modemd/sim-setup @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 + +import subprocess +import json +import fcntl +import struct +import time +import syslog +import sys +import os + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + +def log(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def fread(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +def freadj(path): + output = fread(path) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + err("Unable to parse json output") + return None + finally: + return data + + +def find_modem(setup, index): + for modem in setup["modems"]: + if modem["index"] == index: + return modem + return None + + +def find_sim(setup, index): + for sim in setup["sims"]: + if sim["index"] == index: + return sim + return None + + +def pwrctl(setup, state): + for modem in setup["modems"]: + path = "/sys/class/pcie/slot%d/pwrctl" % modem["slot"] + if not os.path.exists(path): + continue + with open(path, "w") as fd: + fd.write("%s\n" % state) + + +def sim_ctrl(cmd, slots): + req = struct.pack('2i', *slots) + + if not os.path.exists("/dev/simctrl"): + return None + + try: + fd = os.open("/dev/simctrl", os.O_RDWR) + except OSError: + fatal("Cannot open sim ctrl") + + try: + resp = fcntl.ioctl(fd, cmd, req) + except OSError: + fatal("Cannot run sim ctrl ioctl") + + if not resp: + return None + else: + return struct.unpack('2i', resp) + + +def sim_get_slots(): + # NM_SIMIOC_SLOT_GET is 0x80085300 (see linux/nm-sim.h) + return sim_ctrl(0x80085300, (0, 0)) + + +def sim_set_slots(slots): + # NM_SIMIOC_SLOT_SET is 0x40085301 (see linux/nm-sim.h) + return sim_ctrl(0x40085301, slots) + + +def slot_setup(setup): + slots = [] + for sim in setup["sims"]: + log("Connecting sim%d to slot %d" % (sim["index"], sim["newslot"])) + slots.append(sim["newslot"]) + + if sim_set_slots(tuple(slots)) is None: + return False + else: + return True + + +def change_setup(setup): + log("Changing SIM setup") + + runcmd(["initctl", "stop", "modem-manager"]) + time.sleep(1) + + pwrctl(setup, "down") + time.sleep(1) + + if slot_setup(setup) is False: + err("Unable to set up sim slots") + + pwrctl(setup, "up") + + time.sleep(1) + runcmd(["initctl", "restart", "modem-manager"]) + + return True + + +def swapslot(slot): + if slot == 0: + return 1 + elif slot == 1: + return 0 + else: + return -1 + + +def sim_setup(setup): + changed = False + + config = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "infix-modem"]) + if not config or "infix-modem:modems" not in config: + err("Cannot read config") + return False + + for cfg in config["infix-modem:modems"]["modem"]: + if not cfg.get("enabled", False) or cfg.get("index", -1) < 0: + continue + + modem = find_modem(setup, cfg["index"]) + sim = find_sim(setup, cfg.get("sim", -1)) + if modem and sim: + sim["newslot"] = modem["slot"] + if sim["newslot"] != sim["slot"]: + changed = True + + if not changed: + log("SIMs already set up") + return True + + if len(setup["modems"]) == 2 and len(setup["sims"]) == 2: + for i in range(0, 2): + o = 1 if i == 0 else 0 + if setup["sims"][i]["newslot"] == -1: + log("Swapping slot of sim%d" % setup["sims"][i]["index"]) + setup["sims"][i]["newslot"] = swapslot(setup["sims"][o]["newslot"]) + + return change_setup(setup) + + +def read_setup(): + setup = { + "modems": [], + "sims": [] + } + + modules = freadj("/run/modules.json") + if not modules: + log("No modules.json found, skipping SIM setup") + return None + + for module in modules.get("modules", []): + if module["slot"] > -1 and module["type"] == "modem": + setup["modems"].append({"index": module["index"], + "slot": module["slot"]}) + + slots = sim_get_slots() + if slots: + for index in range(0, len(slots)): + setup["sims"].append({"index": index, + "slot": slots[index], + "newslot": -1}) + + return setup + + +if __name__ == "__main__": + setup = read_setup() + if setup is None: + sys.exit(0) + + if sim_setup(setup) is False: + fatal("Unable set up SIMs") + + sys.exit(0) diff --git a/src/statd/python/yanger/__main__.py b/src/statd/python/yanger/__main__.py index 3347a7f0e..8476b1d56 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -129,6 +129,9 @@ def main(): elif model == 'ieee1588-ptp-tt': from . import ieee1588_ptp yang_data = ieee1588_ptp.operational() + elif model == 'infix-modem': + from . import infix_modem + yang_data = infix_modem.operational() else: common.LOG.warning("Unsupported model %s", model) sys.exit(1) diff --git a/src/statd/python/yanger/infix_modem.py b/src/statd/python/yanger/infix_modem.py new file mode 100644 index 000000000..a2b0f6d4f --- /dev/null +++ b/src/statd/python/yanger/infix_modem.py @@ -0,0 +1,12 @@ +from .common import LOG +from .host import HOST + + +def operational(): + modems = HOST.run_json(['/usr/libexec/modemd/modem-info'], []) + + return { + "infix-modem:modems": { + "modem": [m for m in modems] + } + } From 9dbced1157cd88027c2686001392b854b61852ee Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 26 Sep 2026 08:47:14 +0200 Subject: [PATCH 13/37] statd: fix dns-resolver and user operational data Two bugs found while testing modem support on hardware: ietf_system.py: "interface": null written to DNS server entries when no interface is associated, causing LY_EVALID in the ietf-system schema validator. Only include the key when the interface name is non-empty. ietf_system.py: empty list inserts (user [], server [], options [], search []) overwrote configured data in the operational store instead of leaving it intact. Guard each insert so operational data only shadows configured data when there is something to report. Signed-off-by: Joachim Wiberg --- src/statd/python/yanger/ietf_system.py | 21 ++++++++++++--------- test/case/statd/system/ietf-system.json | 4 +--- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index c94de06c3..95577de18 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 = [] @@ -392,7 +394,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/test/case/statd/system/ietf-system.json b/test/case/statd/system/ietf-system.json index e9f63c938..a9c4cf320 100644 --- a/test/case/statd/system/ietf-system.json +++ b/test/case/statd/system/ietf-system.json @@ -113,15 +113,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", From 3357d631a222dc6bf82ba87f1ca940b4a055e6e6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 11 May 2026 09:05:47 +0200 Subject: [PATCH 14/37] test: skip defconfig backup files at find time, not mid-loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defconfig.sh used $# (find result, incl. *~ / *.bak) for the TAP total but then 'continue'd past backups without decrementing — so a stray bpi_r4_*_defconfig~ in the tree made TAP see "1..23" followed by only 21 results, and the harness treated the missing 2 as failures. Filter the patterns at find instead; $# now matches what the loop iterates over Signed-off-by: Joachim Wiberg --- test/case/repo/defconfig.sh | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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 From 22b4308d7d27adc18da85b2d3d02cecd1711cf87 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 30 Sep 2026 16:50:12 +0200 Subject: [PATCH 15/37] modem: migrate from infix-modem to ietf-hardware/ietf-interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial modem import used a standalone infix-modem YANG module with an integer-indexed list under /infix-modem:modems carrying a bearer config mixed in with hardware config. Infix has adopted the ietf-hardware model to allow for a clearer separation of concerns, this commit aims to improve on that situation by refactoring the modem model to use the same architectural pattern already used for WiFi radios, GPS receivers, and USB ports: Hardware: /ietf-hardware:hardware/component[class=infix-hardware:modem] /ietf-hardware:hardware/component[class=infix-hardware:sim] Interface: /ietf-interfaces:interfaces/interface[type=infix-if-type:modem] Configuration split: modem component — admin-state (enable/disable), radio access mode, frequency bands, location services, probe-timeout sim component — PIN, PUK, carrier profile wwan interface — APN, IP type, roaming, route-preference, auth This also enables the modem to appear in 'show hardware' alongside other hardware components, and to be managed over NETCONF/RESTCONF using the same ietf-hardware RPCs used for USB and WiFi. YANG changes: - infix-modem.yang removed (1089 lines, standalone module) - infix-hardware.yang: new modem and sim containers, modem-state and sim-state operational state, typedefs for bands/modes/location, notification status-update; actions restart/reset/send-sms are augmented directly on the component list entry (not inside container modem) to work around sysrepo NP-container parent validation — when checking a nested action's parent, sysrepo restricts its operational data fetch to the container path, excluding the sibling 'class' leaf needed to evaluate the container's 'when' condition, so the container is never instantiated and the parent check fails - infix-if-modem.yang: new submodule (same pattern as wifi/wireguard) adds the wwan bearer container to ietf-interfaces - infix-if-type.yang: new modem identity for wwan interface type confd: - if-modem.c: new file replaces the stub modem_gen() in modem.c; generates dagger init scripts with probe-timeout wait loop and unconditional dummy wwan creation so downstream IP config succeeds when a USB modem is slow to enumerate at boot - hardware.c: start/stop/enable/disable modemd (and modem-manager) in response to admin-state changes on the modem hardware component; use finit_enable/disable consistently - hardware_cand_infer_class(): auto-set class for modemN/simN names - modem.c: stripped to RPC/notification forwarding only; the old genconf() / enable() / disable() logic is gone — hardware.c owns the lifecycle now - interfaces.c: remove wwan from wait-interface timeout (the modem interface appearance is managed by modemd, not confd's init scripts) modemd: - load_config() replaces the infix-modem sysrepocfg call; reads from ietf-hardware (modem/sim config), ietf-interfaces (bearer config), and ietf-keystore (credentials) — three standard modules instead of one proprietary one; modems absent from sysrepo config but present in system.json are still started (physical detection wins) - Default route written to /etc/net.d/.conf (picked up by netd/staticd/zebra) instead of a direct 'ip route add'; routes are now visible to FRR for redistribution and metric-based failover - Addresses tagged with proto wwan (rt_addrprotos entry 7) for precise flush on disconnect without touching other address sources - _derive_gateway() handles point-to-point bearers where modem reports gateway as "--"; derives peer from link-scope subnet route - SimThread exits cleanly on platforms without /dev/simctrl - sim-setup, modem-info, modem-udev, modem-rpc, modem-sms all ported to the ietf-hardware/ietf-interfaces namespace statd: - infix_modem.py now emits modem-state and sim-state into ietf-hardware component entries (modem0, sim0, ...) instead of a separate tree; sim-state reports physical slot, lock state, and SIM operator name - show hardware: Cellular Modems and SIM Cards tables added; sensor table width now adapts to the widest section in the output - 00-probe: USB modem detection writes to system.json["modem"], same source as wifi-radios, feeding gen-hardware which emits the ietf-hardware component entries at factory config generation time CLI: - 'show modem [ref]': without ref shows Cellular Modems and SIM Cards tables (same data subset as 'show hardware'); with ref shows a full per-modem dump (hardware info, status, cellular, SIM, bearers, GPS) - 'modem restart ': disconnect and reconnect all bearers without a full hardware reset - 'modem reset ': factory-reset the modem firmware - 'modem sms ': send an SMS via the modem (uses the signalling plane; no active data bearer required) - MODEMS PTYPE provides tab-completion reading modem names from /run/system.json doc: - Add initial User Guide for modems - Update ChangeLog with modem news Signed-off-by: Joachim Wiberg --- .../common/rootfs/etc/iproute2/rt_addrprotos | 1 + .../rootfs/usr/libexec/infix/init.d/00-probe | 68 + doc/ChangeLog.md | 5 + doc/README.md | 1 + doc/modem.md | 437 +++++++ mkdocs.yml | 1 + package/confd/confd.mk | 7 + package/feature-modem/feature-modem.mk | 1 + src/bin/show/__init__.py | 28 + src/confd/bin/gen-hardware | 26 + src/confd/src/Makefile.am | 1 + src/confd/src/hardware.c | 53 +- src/confd/src/if-modem.c | 112 ++ src/confd/src/interfaces.c | 7 +- src/confd/src/interfaces.h | 6 +- src/confd/src/modem.c | 276 ++--- src/confd/yang/confd.inc | 7 +- src/confd/yang/confd/infix-hardware.yang | 726 ++++++++++- ...06.yang => infix-hardware@2026-10-11.yang} | 0 src/confd/yang/confd/infix-if-modem.yang | 307 +++++ .../yang/confd/infix-if-modem@2026-10-11.yang | 1 + src/confd/yang/confd/infix-if-type.yang | 19 + ...-03.yang => infix-if-type@2026-10-11.yang} | 0 src/confd/yang/confd/infix-interfaces.yang | 6 + ....yang => infix-interfaces@2026-10-11.yang} | 0 src/confd/yang/confd/infix-modem.yang | 1089 ----------------- .../yang/confd/infix-modem@2024-03-15.yang | 1 - src/confd/yang/modem.inc | 5 + src/klish-plugin-infix/xml/infix.xml | 42 + src/modemd/modem-info | 187 ++- src/modemd/modem-rpc | 9 +- src/modemd/modem-sms | 16 +- src/modemd/modem-udev | 48 +- src/modemd/modemd | 294 +++-- src/modemd/sim-setup | 37 +- src/statd/python/cli_pretty/cli_pretty.py | 311 ++++- src/statd/python/yanger/__main__.py | 3 - src/statd/python/yanger/ietf_hardware.py | 9 +- src/statd/python/yanger/infix_modem.py | 84 +- 39 files changed, 2745 insertions(+), 1486 deletions(-) create mode 100644 doc/modem.md create mode 100644 src/confd/src/if-modem.c rename src/confd/yang/confd/{infix-hardware@2026-10-06.yang => infix-hardware@2026-10-11.yang} (100%) create mode 100644 src/confd/yang/confd/infix-if-modem.yang create mode 120000 src/confd/yang/confd/infix-if-modem@2026-10-11.yang rename src/confd/yang/confd/{infix-if-type@2026-10-03.yang => infix-if-type@2026-10-11.yang} (100%) rename src/confd/yang/confd/{infix-interfaces@2026-10-03.yang => infix-interfaces@2026-10-11.yang} (100%) delete mode 100644 src/confd/yang/confd/infix-modem.yang delete mode 120000 src/confd/yang/confd/infix-modem@2024-03-15.yang create mode 100644 src/confd/yang/modem.inc diff --git a/board/common/rootfs/etc/iproute2/rt_addrprotos b/board/common/rootfs/etc/iproute2/rt_addrprotos index f31bdca27..3de5ebd2f 100644 --- a/board/common/rootfs/etc/iproute2/rt_addrprotos +++ b/board/common/rootfs/etc/iproute2/rt_addrprotos @@ -5,3 +5,4 @@ 4 static 5 dhcp 6 random +7 wwan diff --git a/board/common/rootfs/usr/libexec/infix/init.d/00-probe b/board/common/rootfs/usr/libexec/infix/init.d/00-probe index e2054cefb..456910392 100755 --- a/board/common/rootfs/usr/libexec/infix/init.d/00-probe +++ b/board/common/rootfs/usr/libexec/infix/init.d/00-probe @@ -620,6 +620,73 @@ def probe_ptp_capabilities(out): out.setdefault("interfaces", {}).update(ifaces) +def _usb_root_from_sysfs(dev_path): + p = dev_path + while p not in ("/", "/sys"): + parent = os.path.realpath(os.path.join(p, "..")) + basename = os.path.basename(parent) + if basename.startswith("usb") and basename[3:].isdigit(): + return parent + p = parent + return None + + +def _read_usb_vid_pid(dev_path): + p = dev_path + while p not in ("/", "/sys"): + try: + vid = open(os.path.join(p, "idVendor")).read().strip() + pid = open(os.path.join(p, "idProduct")).read().strip() + return vid, pid + except OSError: + pass + p = os.path.dirname(p) + return "", "" + + +def probe_modems(out): + """devpath is the USB bus root; ModemManager matches by path prefix. + seen_roots deduplicates cdc-wdm and ttyUSB ports of the same physical modem.""" + seen_roots = set() + modems = [] + idx = 0 + + def add_modem(dev_path): + nonlocal idx + usb_root = _usb_root_from_sysfs(dev_path) + if usb_root is None or usb_root in seen_roots: + return + seen_roots.add(usb_root) + vid, pid = _read_usb_vid_pid(dev_path) + modems.append({ + "index": idx, + "name": "modem%d" % idx, + "devpath": usb_root, + "vid": vid, + "pid": pid, + }) + idx += 1 + + for cls_dir, prefix in (("/sys/class/usbmisc", "cdc-wdm"), + ("/sys/class/tty", "ttyUSB")): + try: + entries = os.listdir(cls_dir) + except OSError: + continue + for entry in entries: + if not entry.startswith(prefix): + continue + try: + dev_path = os.path.realpath( + os.path.join(cls_dir, entry, "device")) + add_modem(dev_path) + except OSError: + pass + + if modems: + out["modem"] = modems + + def main(): out = { "vendor": None, @@ -647,6 +714,7 @@ def main(): probe_wifi_radios(out) probe_ptp_capabilities(out) + probe_modems(out) if not out["factory-password-hash"]: sys.stdout.write("\n\n\033[31mCRITICAL BOOTSTRAP ERROR\n" + diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 54daa27c2..d576a075c 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -74,6 +74,10 @@ All notable changes to the project are documented in this file. - Configurable OSPF auto-cost reference bandwidth, for OSPFv2 and OSPFv3, issue #1576. The default, 100000 Mbit/s, is the value used before, so link costs do not change on upgrade. The CLI shows it in `show ip ospf` +- Add cellular modem (WWAN) support for USB-attached MBIM/QMI modems, + including USB dongles, mPCIe cards, and M.2 Key-B modules. Multiple + APNs per modem, SIM PIN configuration, and NMEA routing from modem to + GPS subsystem. See the [Modem User's Guide][modem] for details ### Fixes @@ -125,6 +129,7 @@ All notable changes to the project are documented in this file. [pppoe]: https://www.kernelkit.org/infix/latest/pppoe/ [wds]: https://www.kernelkit.org/infix/latest/wifi/#wds-backhaul-and-repeaters +[modem]: https://www.kernelkit.org/infix/latest/modem/ [v26.09.0][] - 2026-09-30 ------------------------- diff --git a/doc/README.md b/doc/README.md index d034a1bb1..b16afd8f9 100644 --- a/doc/README.md +++ b/doc/README.md @@ -27,6 +27,7 @@ regression test system solely relies on NETCONF and RESTCONF. - [DHCP Server](dhcp.md) - [TFTP Server](tftp.md) - [SNMP](snmp.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..3b8418a1f --- /dev/null +++ b/doc/modem.md @@ -0,0 +1,437 @@ +# Cellular Modem (WWAN) + +Cellular modems are supported when they expose a QMI or MBIM control +interface over USB. Form factor does not matter: +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 for the physical modem +- A `sim0` hardware component for the SIM card slot +- A `wwan0` network interface that references both and carries the + bearer (APN) configuration + +## Architecture + +Cellular modem support is built in three layers: + +1. **Modem hardware component (modem0)**: the physical modem + - Configured via `ietf-hardware` with class `infix-hardware:modem` + - `admin-state` controls whether the cellular subsystem is 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)**: the SIM card slot + - Configured via `ietf-hardware` 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` 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. Same idea as multi-SSID on a WiFi radio: one +hardware modem, several independent data connections. + +Configure `wwan0` and `wwan1` both pointing to `modem0`: + +``` +edit interface wwan0 wwan +set modem modem0 +set bearer apn internet + +edit interface wwan1 wwan +set modem modem0 +set 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 the hardware is inserted. + +## Supported Modems + +Modems exposing a CDC-WDM control interface over USB are supported, +regardless of physical form factor. Two control protocols are +recognised: + +- **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 (USB dongles, mPCIe cards, M.2 Key-B) use USB on +the connector even when the slot also carries PCIe lanes. To the +system they are all USB modems. PCIe-only modems (some 5G NR modules) +are not supported, since the cellular subsystem 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    MANUFACTURER  MODEL   STATE       NETWORK  SIGNAL
+modem0  Quectel       EM06-E  registered  lte      good
+──────────────────────────────────────────────────────────────
+SIM Cards
+NAME  SLOT  STATE     OPERATOR
+sim0  1     unlocked  Tele2
+
+ +The `STATE`, `SIM STATE`, and `SIGNAL` columns are color-coded so the +healthy / attention / problem cases are visible at a glance: green for +`registered`, `connected`, `unlocked`, `excellent`, `good`; yellow for +transient or attention states like `enabling`, `pin-required`, `poor`; +red for `failed`, `not-inserted`, `bad`. + +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`). +The `admin-state` must be set explicitly. There is no implicit default, +and without `unlocked` the cellular subsystem stays inactive: + +
admin@example:/> configure
+admin@example:/config/> edit hardware component modem0
+admin@example:/config/hardware/component/modem0/> set state admin-state unlocked
+admin@example:/config/hardware/component/modem0/> end
+admin@example:/config/hardware/> edit component sim0
+admin@example:/config/hardware/component/sim0/> set state admin-state unlocked
+admin@example:/config/hardware/component/sim0/> leave
+admin@example:/>
+
+ +To take the modem offline cleanly without removing the configuration, +set `admin-state locked`. All related 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/component/modem0/> set state admin-state locked
+admin@example:/config/hardware/component/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 the system starts applying +configuration. The `probe-timeout` leaf inside the `modem` hardware +configuration container controls how long the system 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/component/modem0/modem/> leave
+
+ +With `probe-timeout` at its default of 30, the system waits up to 30 +seconds for the wwan interface to appear before proceeding. For most +modems it is 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. + +> [!NOTE] +> Some Quectel modules (e.g. EM05) re-enumerate the USB device several +> times during a cold-boot firmware-init sequence that takes around 50 +> to 60 seconds. Bump `probe-timeout` to 90 for those, otherwise the +> dummy placeholder fires before the modem settles and the real +> interface ends up with a different name (`wwan1` instead of `wwan0`). + +### 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 interface wwan0
+admin@example:/config/interface/wwan0/> set wwan modem modem0
+admin@example:/config/interface/wwan0/> set wwan sim sim0
+admin@example:/config/interface/wwan0/> set wwan bearer apn internet
+admin@example:/config/interface/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. The default of 200 + places cellular behind wired Ethernet (distance 5) and WiFi, so + cellular acts as a failover when the wired link is up +- `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. + +Create the keystore entry for the APN password: + +
admin@example:/> configure
+admin@example:/config/> edit keystore symmetric-key apn-pass
+admin@example:/config/keystore/…/apn-pass/> set key-format passphrase-key-format
+admin@example:/config/keystore/…/apn-pass/> change cleartext-symmetric-key
+Passphrase: ************
+Retype passphrase: ************
+admin@example:/config/keystore/…/apn-pass/> leave
+
+ +Then point the bearer's authentication at it: + +
admin@example:/> configure
+admin@example:/config/> edit interface wwan0 wwan bearer
+admin@example:/config/interface/wwan0/wwan/bearer/> set authentication username myuser
+admin@example:/config/interface/wwan0/wwan/bearer/> set authentication password apn-pass
+admin@example:/config/interface/wwan0/wwan/bearer/> leave
+
+ +Setting any leaf inside `authentication` creates the container, which +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/interface/wwan0/wwan/bearer/> set authentication 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/component/sim0/> set sim pin 1234
+admin@example:/config/hardware/component/sim0/> leave
+
+ +### 6. Verify Connectivity + +Once connected, the `wwan0` interface receives an IP address from the +carrier and the default route is installed automatically: + +
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
+MODEM: modem0                                                 
+──────────────────────────────────────────────────────────────
+Hardware Information
+  Manufacturer        : Quectel
+  Model               : EM06-E
+  Firmware Version    : EM06ELAR04A07M4G
+  Serial Number       : 352753090141905
+  IMSI                : 240021234567890
+  ICCID               : 8946020000001234567
+──────────────────────────────────────────────────────────────
+Status
+  State               : connected
+  Power State         : on
+  Signal              : Quality: 72% good  RSSI: -59 dBm  RSRP: -95 dBm  RSRQ: -11.0 dB
+──────────────────────────────────────────────────────────────
+Cellular
+  Registration        : home
+  Service State       : attached
+  Operator            : Tele2
+  Operator ID         : 23002
+  Network Type        : lte
+──────────────────────────────────────────────────────────────
+SIM Card
+  Name                : sim0
+  Slot                : 1
+  Lock State          : unlocked
+  Operator            : Tele2
+
+ +When the modem is in trouble, the same view shows why. A +`State: failed` line is followed by `Failed Reason` (e.g. +`sim-missing`), and the `SIM Card` section's `Lock State` reads +`not-inserted` or `pin-required` to match. + +## Cellular Failover + +The default `route-preference` (200) is deliberately higher than the +distances used by wired Ethernet (udhcpc default: 5) and WiFi. When a +wired link is up 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 prefer cellular over WiFi, set `route-preference` explicitly: + +
admin@example:/config/> edit interface wwan0 wwan bearer
+admin@example:/config/interface/wwan0/wwan/bearer/> set route-preference 100
+admin@example:/config/interface/wwan0/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 interface wwan0 wwan bearer
+admin@example:/config/interface/wwan0/wwan/bearer/> set roaming true
+admin@example:/config/interface/wwan0/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 it if the modem is +in a bad state that a bearer restart cannot fix: + +
admin@example:/> modem reset modem0
+
+ +> [!NOTE] +> Not every modem accepts a software reset. Quectel EM05, for +> example, refuses programmatic reset requests. The only way to +> recover from a hung firmware on these is a physical power cycle. +> The system logs the refusal once and stops retrying. + +### 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 the router"
+
+ +> [!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 `fixed-dialing` appears in the +> `Locks` line of `show modem modem0`. + +## 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` +- If the State is `failed`, look at the `Failed Reason` line that + immediately follows: + - `sim-missing`: the SIM Card section will also show + `Lock State: not-inserted`. Power off the device, insert the + SIM, power back on. A warm reboot is not enough; on most M.2 + slots the SIM tray stays powered through reboot, so the modem + keeps the original not-inserted reading + - `unlock-required`: the SIM Card section will show + `Lock State: pin-required`. Configure the PIN with + `set hardware component sim0 sim pin ` + - `sim-wrong` / `sim-error`: the SIM is not compatible or is + damaged. Try a different SIM +- Verify the APN is correct for your operator +- Check system logs with `show log` for modem-related messages + +**`wwan0` interface is a dummy (no data flows, no carrier address)** + +- The modem was not enumerated by the kernel before the system + applied the configuration +- Create the modem hardware configuration container (see Step 2), + which enables the default 30-second probe-timeout so the system + 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 b6e965375..599a17f65 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Overview: vpn.md - WireGuard: vpn-wireguard.md - Wireless LAN (WiFi): wifi.md + - Cellular Modem (wwan): modem.md - Services: - Device Discovery: discovery.md - DHCP Server: dhcp.md diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 05b545aa0..d7b5c5ad1 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -180,6 +180,12 @@ define CONFD_INSTALL_YANG_MODULES_SNMP $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/snmp.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 @@ -219,6 +225,7 @@ 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_WEBUI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_SNMP +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_MODEM CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_IN_ROMFS CONFD_TARGET_FINALIZE_HOOKS += CONFD_CLEANUP diff --git a/package/feature-modem/feature-modem.mk b/package/feature-modem/feature-modem.mk index d5a7827ba..52c9a66ec 100644 --- a/package/feature-modem/feature-modem.mk +++ b/package/feature-modem/feature-modem.mk @@ -14,6 +14,7 @@ define FEATURE_MODEM_LINUX_CONFIG_FIXUPS $(call KCONFIG_ENABLE_OPT,CONFIG_USB_WDM) $(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_QMI_WWAN) $(call KCONFIG_ENABLE_OPT,CONFIG_USB_CDC_MBIM) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_CDC_MBIM) $(if $(filter y,$(BR2_PACKAGE_FEATURE_MODEM_QUALCOMM)), $(call KCONFIG_SET_OPT,CONFIG_QRTR,m) diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index 46eac8f2f..1405221c1 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -145,6 +145,33 @@ def channel_survey(radio: str, passive: bool) -> None: sys.stdout.flush() +def modem(args: List[str]) -> None: + ref = args[0] if args else None + + if ref: + try: + result = subprocess.run(["/usr/libexec/modemd/modem-info"], + capture_output=True, text=True, check=True) + data = json.loads(result.stdout) if result.stdout.strip() else [] + except (subprocess.CalledProcessError, json.JSONDecodeError): + data = [] + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + cli_pretty({"modem-list": data}, "show-modem-detail", ref) + else: + data = get_json("/ietf-hardware:hardware") + if not data: + print("No modem data available.") + return + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + cli_pretty(data, "show-modem") + + def ntp(args: List[str]) -> None: # Create argument parser for ntp subcommands parser = argparse.ArgumentParser(prog='show ntp', add_help=False) @@ -847,6 +874,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 3870a7302..aacbff002 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() @@ -60,6 +65,20 @@ gen_radio() EOF } +gen_modem() +{ + modem="$1" + cat <root, name, !strcmp(admin_state, "unlocked"))) { + if (usb_authorize(confd->root, name, + admin_state && !strcmp(admin_state, "unlocked"))) { 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 (admin_state && !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 **wifi_iface_list = NULL; struct lyd_node *interfaces_config; 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 b1e940db5..e476928b2 100644 --- a/src/confd/src/interfaces.c +++ b/src/confd/src/interfaces.c @@ -423,7 +423,7 @@ static int netdag_gen_afspec_add(sr_session_ctx_t *session, struct dagger *net, case IFT_VXLAN: return vxlan_gen(NULL, cif, ip); case IFT_MODEM: - return modem_gen(NULL, cif, net); + return modem_add_iface(cif, net); case IFT_WIFI: return wifi_validate_secret(session, cif) ? : wifi_add_iface(cif, net); @@ -598,7 +598,7 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif, veth_gen_del(dif, ip); break; case IFT_MODEM: - modem_gen_del(dif, net); + modem_del_iface(dif, net); break; case IFT_WIFI: wifi_del_iface(dif, net); @@ -647,8 +647,7 @@ static int veth_gen_host(struct dagger *net, struct lyd_node *dif, struct lyd_no static sr_error_t netdag_gen_iface_timeout(struct dagger *net, const char *ifname, const char *iftype) { - if (!strcmp(iftype, "infix-if-type:ethernet") || - !strcmp(iftype, "infix-if-type:modem")) { + if (!strcmp(iftype, "infix-if-type:ethernet")) { FILE *wait; wait = dagger_fopen_net_init(net, ifname, NETDAG_INIT_TIMEOUT, "wait-interface.sh"); diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h index 13db8e3d9..2fb290d7e 100644 --- a/src/confd/src/interfaces.h +++ b/src/confd/src/interfaces.h @@ -181,9 +181,9 @@ int ppp_gen(sr_session_ctx_t *session, struct lyd_node *dif, struct lyd_node *ci int ppp_del(struct lyd_node *dif, struct dagger *net); int ppp_add_deps(struct lyd_node *cif); -/* modem.c */ -int modem_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net); -int modem_gen_del(struct lyd_node *dif, struct dagger *net); +/* if-modem.c */ +int modem_add_iface(struct lyd_node *cif, struct dagger *net); +int modem_del_iface(struct lyd_node *dif, struct dagger *net); /* infix-if-wireguard */ int wireguard_validate_peers(sr_session_ctx_t *session, struct lyd_node *cif); diff --git a/src/confd/src/modem.c b/src/confd/src/modem.c index a7d83ddd2..e86357fbb 100644 --- a/src/confd/src/modem.c +++ b/src/confd/src/modem.c @@ -1,11 +1,5 @@ /* SPDX-License-Identifier: BSD-3-Clause */ -#include -#include -#include -#include -#include -#include #include #include #include @@ -17,35 +11,33 @@ #include "core.h" -#define MAX_MODEMS 8 -#define RUN_DIR "/run/modemd" -#define SOCK RUN_DIR "/modemd.sock" +#define RUN_DIR "/run/modemd" +#define SOCK RUN_DIR "/modemd.sock" -#define MODULE "infix-modem" -#define ROOT_XPATH "/infix-modem:" -#define CFG_XPATH ROOT_XPATH "modems" +#define HW_BASE "ietf-hardware" +#define HW_MODULE "infix-hardware" +#define HW_COMP "/ietf-hardware:hardware/component" +#define IH_MODEM HW_COMP "/infix-hardware:modem" +#define IH_COMP_ACTION HW_COMP "/infix-hardware:" -static int xpath_get_index(const char *xpath) +static int component_index(const char *xpath) { regmatch_t pmatch[2]; regex_t regex; - char buf[32]; - int ret, len, i; + char name[32]; int index = -1; + int len; - if (regcomp(®ex, "index='([0-9]+)'", REG_EXTENDED)) + if (regcomp(®ex, "name='([^']*)'", REG_EXTENDED)) return -1; - ret = regexec(®ex, xpath, 2, pmatch, 0); - if (!ret) { - len = (pmatch[1].rm_eo - pmatch[1].rm_so); - if (len < (int)(sizeof(buf)-1)) { - for (i = 0; i < len; i++) - buf[i] = xpath[pmatch[1].rm_so + i]; - - buf[i] = '\0'; - index = (int) strtoul(buf, NULL, 10); + if (regexec(®ex, xpath, 2, pmatch, 0) == 0) { + len = pmatch[1].rm_eo - pmatch[1].rm_so; + if (len < (int)(sizeof(name) - 1)) { + memcpy(name, xpath + pmatch[1].rm_so, len); + name[len] = '\0'; + sscanf(name, "modem%d", &index); } } regfree(®ex); @@ -53,131 +45,6 @@ static int xpath_get_index(const char *xpath) return index; } -static int node_index(struct lyd_node *node) -{ - const char *s; - - s = lydx_get_cattr(node, "index"); - if (!s || !s[0]) - return -1; - - return (int) strtoul(s, NULL, 10); -} - -static int disable(void) -{ - NOTE("Disabling modemd"); - - /* disable modem-manager */ - systemf("initctl -bfqn stop modem-manager"); - systemf("initctl -bfqn disable modem-manager"); - - /* disable modemd */ - systemf("initctl -bnq stop modemd"); - systemf("initctl -bnq disable modemd"); - - return SR_ERR_OK; -} - -static int enable(void) -{ - int enabled, reload = 0; - - NOTE("Enabling modemd"); - - /* enable modem-manager */ - enabled = !systemf("initctl -bfq status modem-manager"); - if (!enabled) { - systemf("initctl -bfqn enable modem-manager"); - reload = 1; - } - /* enable modemd */ - enabled = !systemf("initctl -bfq status modemd"); - if (!enabled) { - systemf("initctl -bfqn enable modemd"); - reload = 1; - } - /* reload if required */ - if (reload) - systemf("initctl -b reload"); - - /* restart modem-manager */ - systemf("initctl -bfqn restart modem-manager"); - - /* restart modemd */ - systemf("initctl -bfqn restart modemd"); - - return SR_ERR_OK; -} - -static int genconf(sr_data_t *cfg, struct lyd_node *diff) -{ - struct lyd_node *node, *tree; - uint8_t enabled[MAX_MODEMS]; - int index; - - memset(enabled, 0, sizeof(enabled)); - - tree = lydx_get_descendant(cfg->tree, "modems", "modem", NULL); - LYX_LIST_FOR_EACH(tree, node, "modem") { - index = node_index(node); - if (index < MAX_MODEMS) - enabled[index] = lydx_get_bool(node, "enabled") ? 1 : 0; - } - - tree = lydx_get_descendant(diff, "modems", "modem", NULL); - LYX_LIST_FOR_EACH(tree, node, "modem") { - index = node_index(node); - if (index < MAX_MODEMS && lydx_get_op(node) == LYDX_OP_DELETE) - enabled[index] = 0; - } - - for (index = 0; index < MAX_MODEMS; index++) { - if (enabled[index]) { - if (enable() == SR_ERR_OK) { - return SR_ERR_OK; - } else { - ERROR("Cannot enable modem%d", index); - break; - } - } - } - - return disable(); -} - -static int infix_modem_change(sr_session_ctx_t *session, uint32_t sub_id, const char *module, - const char *xpath, sr_event_t event, unsigned request_id, void *confd) -{ - sr_data_t *cfg = NULL; - struct lyd_node *diff = NULL; - sr_error_t err; - - if (event != SR_EV_DONE) { - return SR_ERR_OK; - } - err = sr_get_data(session, CFG_XPATH "//.", 0, 0, 0, &cfg); - if (err) { - ERROR("Can't get data"); - goto out; - } - err = srx_get_diff(session, &diff); - if (err) { - ERROR("Can't get diff"); - goto out; - } - err = genconf(cfg, diff); - if (err) { - ERROR("Can't gen conf"); - goto out; - } -out: - if (diff) lyd_free_tree(diff); - if (cfg) sr_release_data(cfg); - - return err; -} - static int infix_modem_rpcsend(char *msg, int len) { struct sockaddr_un addr; @@ -199,9 +66,8 @@ static int infix_modem_rpcsend(char *msg, int len) FD_SET(sock, &wfds); if (select(sock + 1, NULL, &wfds, NULL, &tv) > 0) { - if (write(sock, msg, len) == len) { + if (write(sock, msg, len) == len) ret = 0; - } } } close(sock); @@ -209,7 +75,7 @@ static int infix_modem_rpcsend(char *msg, int len) return ret; } -static int infix_modem_rpc(const char *xpath, const char *rpc, const char *data) +static int infix_modem_rpc(const char *rpc, const char *data) { char msg[1024]; int len; @@ -233,98 +99,108 @@ static int infix_modem_sendsms(sr_session_ctx_t *session, uint32_t sub_id, const unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) { char data[1024]; + int index; - if (input_cnt < 3) { - ERROR("Not enough input parameters"); + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return SR_ERR_INVAL_ARG; + } + if (input_cnt < 2) { + ERROR("send-sms: not enough input parameters"); return SR_ERR_SYS; } - snprintf(data, sizeof(data)-1, - "{ \"index\" : %s, \"number\" : \"%s\", \"text\" : \"%s\" }", + snprintf(data, sizeof(data) - 1, + "{ \"index\" : %d, \"number\" : \"%s\", \"text\" : \"%s\" }", + index, input[0].data.string_val, - input[1].data.string_val, - input[2].data.string_val); + input[1].data.string_val); - return infix_modem_rpc(xpath, "send-sms", data); + return infix_modem_rpc("send-sms", data); } static int infix_modem_restart(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, const sr_val_t *input, const size_t input_cnt, sr_event_t event, unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) { - char data[1024]; + char data[64]; + int index; - if (input_cnt < 1) { - ERROR("Not enough input parameters"); - return SR_ERR_SYS; + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return SR_ERR_INVAL_ARG; } - snprintf(data, sizeof(data)-1, - "{ \"index\" : %s }", input[0].data.string_val); - - return infix_modem_rpc(xpath, "restart", data); + snprintf(data, sizeof(data), "{ \"index\" : %d }", index); + return infix_modem_rpc("restart", data); } static int infix_modem_reset(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, const sr_val_t *input, const size_t input_cnt, sr_event_t event, unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) { - char data[1024]; + char data[64]; + int index; - if (input_cnt < 1) { - ERROR("Not enough input parameters"); - return SR_ERR_SYS; + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return SR_ERR_INVAL_ARG; } - snprintf(data, sizeof(data)-1, - "{ \"index\" : %s }", input[0].data.string_val); - - return infix_modem_rpc(xpath, "reset", data); + snprintf(data, sizeof(data), "{ \"index\" : %d }", index); + return infix_modem_rpc("reset", data); } -static void infix_modem_notif (sr_session_ctx_t *session, uint32_t sub_id, - const sr_ev_notif_type_t notif_type, const char *xpath, - const sr_val_t *values, const size_t values_cnt, - struct timespec *timestamp, void *confd) +static void infix_modem_notif(sr_session_ctx_t *session, uint32_t sub_id, + const sr_ev_notif_type_t notif_type, const char *xpath, + const sr_val_t *values, const size_t values_cnt, + struct timespec *timestamp, void *confd) { int index; - index = xpath_get_index(xpath); + index = component_index(xpath); if (index < 0) { - ERROR("No index"); + ERROR("Cannot parse modem index from xpath: %s", xpath); return; } if (values_cnt < 1) { - ERROR("No values"); + ERROR("No values in status-update notification"); return; } - NOTE("Notification from modem%d: %s", - index, values[0].data.string_val); + NOTE("Notification from modem%d: %s", index, values[0].data.string_val); } int modem_init(struct confd *confd) { + const struct lys_module *mod; + sr_conn_ctx_t *conn; + const struct ly_ctx *ly_ctx; int rc; - REGISTER_CHANGE(confd->session, MODULE, CFG_XPATH, 0, infix_modem_change, confd, &confd->sub); - REGISTER_NOTIF(confd->session, MODULE, CFG_XPATH "/modem/status-update", infix_modem_notif, confd, &confd->sub); - REGISTER_RPC(confd->session, ROOT_XPATH "restart", infix_modem_restart, NULL, &confd->sub); - REGISTER_RPC(confd->session, ROOT_XPATH "reset", infix_modem_reset, NULL, &confd->sub); - REGISTER_RPC(confd->session, ROOT_XPATH "send-sms", infix_modem_sendsms, NULL, &confd->sub); + conn = sr_session_get_connection(confd->session); + ly_ctx = sr_acquire_context(conn); + mod = ly_ctx_get_module_implemented(ly_ctx, HW_MODULE); + sr_release_context(conn); + + if (!mod || lys_feature_value(mod, "modem") != LY_SUCCESS) + return SR_ERR_OK; + + REGISTER_NOTIF(confd->session, HW_BASE, + IH_MODEM "/status-update", + infix_modem_notif, confd, &confd->sub); + REGISTER_RPC(confd->session, IH_COMP_ACTION "restart", + infix_modem_restart, NULL, &confd->sub); + REGISTER_RPC(confd->session, IH_COMP_ACTION "reset", + infix_modem_reset, NULL, &confd->sub); + REGISTER_RPC(confd->session, IH_COMP_ACTION "send-sms", + infix_modem_sendsms, NULL, &confd->sub); return SR_ERR_OK; fail: ERROR("init failed: %s", sr_strerror(rc)); return rc; } - -int modem_gen(struct lyd_node *dif, struct lyd_node *cif, struct dagger *net) -{ - return 0; -} - -int modem_gen_del(struct lyd_node *dif, struct dagger *net) -{ - return 0; -} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index ff5b8b7ff..491248f75 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -27,10 +27,10 @@ MODULES=( "infix-syslog@2026-10-18.yang" "iana-hardware@2018-03-13.yang" "ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor" - "infix-hardware@2026-10-06.yang" + "infix-hardware@2026-10-11.yang" "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2026-04-28.yang" - "infix-if-type@2026-10-03.yang" + "infix-if-type@2026-10-11.yang" "infix-routing@2026-10-07.yang" "ieee802-dot1ab-lldp@2022-03-15.yang" "infix-lldp@2025-05-05.yang" @@ -48,7 +48,7 @@ MODULES=( "ieee802-ethernet-phy-type@2025-09-10.yang" "infix-ethernet-interface@2026-05-21.yang" "infix-factory-default@2023-06-28.yang" - "infix-interfaces@2026-10-03.yang -e vlan-filtering" + "infix-interfaces@2026-10-11.yang -e vlan-filtering" "ietf-crypto-types -e cleartext-symmetric-keys" "infix-crypto-types@2026-02-14.yang" "ietf-keystore -e symmetric-keys" @@ -59,5 +59,4 @@ MODULES=( "infix-ptp@2026-04-07.yang" "ietf-schedule@2026-03-10.yang -e icalendar-recurrence" "infix-schedule@2026-09-07.yang" - "infix-modem@2024-03-15.yang" ) diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 44a52c98a..328c25579 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,14 @@ module infix-hardware { contact "kernelkit@googlegroups.com"; description "Vital Product Data augmentation of ieee-hardware and deviations."; + revision 2026-10-11 { + description "Add cellular modem and SIM hardware components: class + identities, modem and sim configuration, modem-state and + sim-state operational data, restart/reset/send-sms actions + and status-update notification."; + reference "internal"; + } + revision 2026-10-06 { description "Move the WiFi country code from each radio to the box-wide hardware/wifi container, the regulatory domain is one setting."; @@ -86,6 +96,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; @@ -161,6 +175,303 @@ module infix-hardware { - 160 MHz: Requires VHT capable hardware (5/6 GHz only)"; } + /* + * 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."; + } + + typedef modem-state { + type enumeration { + enum failed { description "Unrecoverable error; see state-failed-reason for the cause."; } + enum unknown { description "State unknown; modem has not yet reported in."; } + enum initializing { description "Modem is initialising hardware."; } + enum locked { description "Modem is locked (SIM lock or otherwise) and cannot be used."; } + enum disabled { description "Modem is administratively disabled (low power)."; } + enum disabling { description "Modem is transitioning to the disabled state."; } + enum enabling { description "Modem is transitioning to the enabled state."; } + enum enabled { description "Modem is enabled but not yet registered."; } + enum searching { description "Searching for a cellular network."; } + enum registered { description "Registered on a cellular network."; } + enum disconnecting { description "Tearing down an active data connection."; } + enum connecting { description "Bringing up a data connection."; } + enum connected { description "Data connection established."; } + } + description + "Cellular modem state, mirroring ModemManager's MMModemState + enumeration. Read-only operational data."; + } + + typedef modem-state-failed-reason { + type enumeration { + enum none { description "No failure recorded."; } + enum unknown { description "Failure reason is unknown."; } + enum sim-missing { description "No SIM card is inserted."; } + enum sim-error { description "The SIM card reports an error."; } + enum sim-wrong { description "The SIM card is not accepted by the modem (PLMN mismatch, etc.)."; } + enum sim-busy { description "The SIM card is busy."; } + enum unlock-required { description "PIN or PUK unlock is required."; } + enum modem-busy { description "The modem is busy and could not complete the operation."; } + enum modem-disabled { description "The modem is administratively disabled."; } + enum mode-not-supported { description "The configured radio access mode is not supported."; } + enum esim-without-profiles { description "An eSIM is present but has no profiles."; } + } + description + "Reason for a modem state of 'failed', mirroring + ModemManager's MMModemStateFailedReason enumeration."; + } + + typedef modem-power-state { + type enumeration { + enum unknown { description "Power state cannot be reported."; } + enum off { description "Modem is fully powered down."; } + enum low { description "Modem is in a low-power state; radio idle."; } + enum on { description "Modem is fully powered."; } + } + description + "Modem power state, mirroring ModemManager's MMModemPowerState + enumeration."; + } + + typedef modem-access-tech { + type enumeration { + enum unknown { description "Access technology unknown or unreported."; } + enum pots { description "Plain old telephone service."; } + enum gsm { description "2G GSM."; } + enum gsm-compact { description "2G GSM compact."; } + enum gprs { description "2.5G GPRS."; } + enum edge { description "2.75G EDGE."; } + enum umts { description "3G UMTS."; } + enum hsdpa { description "3.5G HSDPA."; } + enum hsupa { description "3.5G HSUPA."; } + enum hspa { description "3.5G HSPA."; } + enum hspa-plus { description "3.75G HSPA+."; } + enum 1xrtt { description "CDMA2000 1xRTT."; } + enum evdo0 { description "CDMA2000 EV-DO rev. 0."; } + enum evdoa { description "CDMA2000 EV-DO rev. A."; } + enum evdob { description "CDMA2000 EV-DO rev. B."; } + enum lte { description "4G LTE."; } + enum 5gnr { description "5G New Radio."; } + } + description + "Cellular radio access technology currently in use, mirroring + ModemManager's MMModemAccessTechnology bitmask (Infix exposes + it as a single best-match enum, not a leaf-list)."; + } + + typedef modem-3gpp-facility { + type enumeration { + enum sim { description "SIM PIN."; } + enum fixed-dialing { description "Fixed dialling numbers (FDN)."; } + enum phone-sim { description "Phone SIM lock."; } + enum phone-first-sim { description "First-inserted-SIM lock."; } + enum phone-network { description "Network personalisation."; } + enum phone-network-subset { description "Network-subset personalisation."; } + enum phone-service-provider { description "Service-provider personalisation."; } + enum phone-corporate { description "Corporate personalisation."; } + enum phone-first-sim-network { description "First-SIM network personalisation."; } + } + description + "3GPP facility that may be locked or active on the modem, + mirroring ModemManager's MMModem3gppFacility enumeration."; + } + + typedef modem-packet-service-state { + type enumeration { + enum unknown { description "Packet service state cannot be determined."; } + enum attached { description "Packet service is attached; data sessions can be brought up."; } + enum detached { description "Packet service is detached; data sessions are unavailable."; } + } + description + "3GPP packet-service attach state. Independent of the + circuit-switched registration-state — a modem may be + registered but packet-detached, meaning voice/SMS work but + data does not."; + } + /* * Hardware class identities */ @@ -195,6 +506,26 @@ module infix-hardware { device-tree unit addresses such as 'sfp@9'."; } + 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'" { @@ -664,6 +995,354 @@ 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 modem-state; + description "Cellular modem state (see modem-state typedef)."; + } + + leaf state-failed-reason { + type modem-state-failed-reason; + description + "Detailed reason for the failed state. Only meaningful when + 'state' is 'failed'; absent or 'none' otherwise."; + } + + leaf power-state { + type modem-power-state; + description "Modem RF power state."; + } + + leaf-list enabled-locks { + type modem-3gpp-facility; + description + "3GPP facility locks currently enabled on the modem (e.g. + 'fixed-dialing' for FDN-restricted SIMs that whitelist + outbound calls / SMS)."; + } + + leaf hardware-revision { + type string; + description "Hardware revision string reported by the modem."; + } + + leaf-list phone-number { + type string; + description + "MSISDN(s) associated with the active SIM, as reported by + the modem. Typically empty unless provisioned in the SIM."; + } + + leaf selected-carrier { + type string; + description + "Operator carrier profile currently selected by the modem + firmware (e.g. 'default', 'att', 'verizon')."; + } + + leaf-list supported-carrier { + type string; + description + "Carrier profiles supported by this modem firmware. Used + to validate the SIM-component 'carrier' configuration."; + } + + leaf signal-quality { + type uint8; + units "percent"; + description "Signal quality as a percentage (0-100)."; + } + + leaf signal-quality-recent { + type boolean; + description + "TRUE if the most recent signal-quality measurement is fresh. + FALSE indicates a cached value that may no longer reflect + current conditions."; + } + + leaf signal-rssi { + type int16; + units "dBm"; + description "Received Signal Strength Indicator."; + } + + leaf signal-rsrp { + type int16; + units "dBm"; + description "LTE Reference Signal Received Power."; + } + + leaf signal-rsrq { + type decimal64 { fraction-digits 1; } + units "dB"; + description "LTE Reference Signal Received Quality."; + } + + leaf signal-sinr { + type decimal64 { fraction-digits 1; } + units "dB"; + description "Signal-to-Interference-plus-Noise Ratio."; + } + + 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 service-state { + type modem-packet-service-state; + description + "3GPP packet-service attach state. A modem can be + registered but not packet-attached, in which case + data connections cannot be established."; + } + + leaf operator-name { + type string; + description "Name of the registered cellular operator."; + } + + leaf operator-id { + type string { + pattern '[0-9]{5,6}'; + } + description + "Operator PLMN code: MCC (3 digits) concatenated with + MNC (2 or 3 digits)."; + } + + leaf network-type { + type modem-access-tech; + description "Active cellular radio access technology."; + } + } + } + + /* + * 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 unknown { description "Modem reports a SIM but it cannot be read (busy, transient error)."; } + 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') */ @@ -814,4 +1493,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-10-06.yang b/src/confd/yang/confd/infix-hardware@2026-10-11.yang similarity index 100% rename from src/confd/yang/confd/infix-hardware@2026-10-06.yang rename to src/confd/yang/confd/infix-hardware@2026-10-11.yang 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..6540135c5 --- /dev/null +++ b/src/confd/yang/confd/infix-if-modem.yang @@ -0,0 +1,307 @@ +submodule infix-if-modem { + yang-version 1.1; + belongs-to infix-interfaces { + prefix infix-if; + } + + import ietf-interfaces { + prefix if; + } + import ietf-inet-types { + prefix inet; + } + import ietf-yang-types { + prefix yang; + } + 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-10-11 { + 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."; + } + } + } + + container bearer-state { + config false; + description + "Operational state of the modem's data bearer, mirroring the + layout of modem-state / sim-state under ietf-hardware. + + Populated by statd/yanger from modemd's modem-info JSON. + When the bearer is disconnected, addresses and counters may + be absent; 'connected' and 'connection-failed-reason' tell + the rest of the story."; + + leaf path { + type string; + description + "ModemManager D-Bus object path of the underlying bearer + (e.g. '/org/freedesktop/ModemManager1/Bearer/0')."; + } + + leaf connected { + type boolean; + description "TRUE when the bearer has an active data session."; + } + + leaf connection-failed-reason { + type string; + description + "Free-form failure reason reported by ModemManager when + a recent connect attempt failed. Absent on success."; + } + + leaf interface { + type string; + description + "Kernel network interface carrying the bearer's data + (e.g. 'wwan0'). Same as the parent interface name in + single-bearer configurations."; + } + + leaf ipv4-address { + type inet:ipv4-address; + description "IPv4 address assigned by the cellular network."; + } + + leaf ipv4-prefix-length { + type uint8 { range "0..32"; } + description "Prefix length for the assigned IPv4 address."; + } + + leaf ipv6-address { + type inet:ipv6-address; + description "IPv6 address assigned by the cellular network."; + } + + leaf ipv6-prefix-length { + type uint8 { range "0..128"; } + description "Prefix length for the assigned IPv6 address."; + } + + leaf in-bytes { + type yang:counter64; + units "bytes"; + description "Bytes received over the bearer in the current session."; + } + + leaf out-bytes { + type yang:counter64; + units "bytes"; + description "Bytes transmitted over the bearer in the current session."; + } + + leaf total-in-bytes { + type yang:counter64; + units "bytes"; + description + "Total bytes received over the bearer across all sessions + since the modem was attached."; + } + + leaf total-out-bytes { + type yang:counter64; + units "bytes"; + description + "Total bytes transmitted over the bearer across all sessions + since the modem was attached."; + } + + leaf total-duration { + type uint64; + units "seconds"; + description + "Cumulative connected time across all sessions since the + modem was attached."; + } + } + } + } +} diff --git a/src/confd/yang/confd/infix-if-modem@2026-10-11.yang b/src/confd/yang/confd/infix-if-modem@2026-10-11.yang new file mode 120000 index 000000000..3d99e0bdd --- /dev/null +++ b/src/confd/yang/confd/infix-if-modem@2026-10-11.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 edf4faf1a..417ebab93 100644 --- a/src/confd/yang/confd/infix-if-type.yang +++ b/src/confd/yang/confd/infix-if-type.yang @@ -11,6 +11,11 @@ module infix-if-type { contact "kernelkit@googlegroups.com"; description "Infix extensions to IANA interfaces types"; + revision 2026-10-11 { + description "Add interface type modem for cellular wwan interfaces."; + reference "internal"; + } + revision 2026-10-03 { description "Add interface type pppoe."; reference "internal"; @@ -52,6 +57,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."; } @@ -136,6 +145,16 @@ module infix-if-type { base ianaift:ppp; description "PPP over Ethernet (PPPoE) client session."; } + + 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-10-03.yang b/src/confd/yang/confd/infix-if-type@2026-10-11.yang similarity index 100% rename from src/confd/yang/confd/infix-if-type@2026-10-03.yang rename to src/confd/yang/confd/infix-if-type@2026-10-11.yang diff --git a/src/confd/yang/confd/infix-interfaces.yang b/src/confd/yang/confd/infix-interfaces.yang index 2e51ba0ea..24bb4a635 100644 --- a/src/confd/yang/confd/infix-interfaces.yang +++ b/src/confd/yang/confd/infix-interfaces.yang @@ -37,11 +37,17 @@ module infix-interfaces { include infix-if-wireguard; include infix-if-ppp; include infix-if-ptp; + include infix-if-modem; organization "KernelKit"; contact "kernelkit@googlegroups.com"; description "Linux bridge and lag extensions for ietf-interfaces."; + revision 2026-10-11 { + description "Include infix-if-modem submodule for cellular wwan interfaces."; + reference "internal"; + } + revision 2026-10-03 { description "Add PPPoE client interfaces."; reference "internal"; diff --git a/src/confd/yang/confd/infix-interfaces@2026-10-03.yang b/src/confd/yang/confd/infix-interfaces@2026-10-11.yang similarity index 100% rename from src/confd/yang/confd/infix-interfaces@2026-10-03.yang rename to src/confd/yang/confd/infix-interfaces@2026-10-11.yang diff --git a/src/confd/yang/confd/infix-modem.yang b/src/confd/yang/confd/infix-modem.yang deleted file mode 100644 index f0a52fe68..000000000 --- a/src/confd/yang/confd/infix-modem.yang +++ /dev/null @@ -1,1089 +0,0 @@ -/* - * Infix Modem YANG module - */ - -module infix-modem { - yang-version 1.1; - namespace "urn:infix:params:xml:ns:yang:infix-modem"; - prefix "infix-modem"; - - import ietf-inet-types { - prefix "inet"; - } - import ietf-interfaces { - prefix "if"; - } - import ietf-yang-types { - prefix yang; - } - import ietf-netconf-acm { - prefix nacm; - } - - organization "KernelKit"; - contact "kernelkit@googlegroups.com"; - description "YANG data model for modems."; - - revision 2024-03-15 { - description "Initial revision"; - reference "internal"; - } - - container modems { - description "Configuration of modems."; - - list modem { - key "index"; - unique sim; - description "List of modems"; - - leaf index { - type uint8; - description "Index of the modem."; - } - leaf enabled { - type boolean; - description "Enable or disable modem."; - default false; - } - leaf path { - type string; - description "Path to modem."; - config false; - } - leaf sim { - type int8; - description "Index of SIM card."; - } - leaf pin { - type string; - description "PIN code to unlock SIM card."; - nacm:default-deny-all; - } - leaf puk { - type string; - description "PUK code to unlock SIM card."; - nacm:default-deny-all; - } - - leaf preferred-mode { - type access-mode; - description "Preferred mode."; - } - list allowed-mode { - key "mode"; - description "List of allowed modes."; - - leaf mode { - type access-mode; - description "Allowed mode."; - } - } - leaf carrier { - type string; - description "Network carrier profile."; - } - list band { - key "band"; - description "List of enabled bands."; - - leaf band { - type band; - description "Enabled band."; - } - } - list bearer { - key "index"; - description "List of bearers."; - - leaf index { - type uint8; - description "Index of the bearer."; - } - leaf apn-type { - type bearer-apn-type; - description "APN type."; - } - leaf apn { - type string; - description "Access Point Name (APN)."; - } - leaf username { - type string; - description "User name (if any) required by the network."; - default ""; - } - leaf password { - type string; - description "Password (if any) required by the network."; - default ""; - nacm:default-deny-all; - } - leaf allow-roaming { - type boolean; - description "Flag to tell whether connection is allowed during roaming."; - default false; - } - leaf ip-type { - type ip-family; - description "IP addressing type."; - default ipv4v6; - } - leaf default-route { - type boolean; - description "Set default route for connection"; - default false; - } - leaf firewall-enabled { - type boolean; - description "Enable firewall for connection."; - default false; - } - leaf dns-enabled { - type boolean; - description "Enable DNS resolver for connection."; - default true; - } - } - container location { - leaf enabled { - type boolean; - description "Enable location gathering."; - default false; - } - - list source { - key "source"; - description "List of location sources."; - - leaf source { - type location-source; - description "Source used for location gathering."; - } - } - } - - container info { - description "Modem information."; - config false; - - leaf manufacturer { - type string; - description "Manufacturer of the modem."; - } - leaf model { - type string; - description "Model name of the modem."; - } - leaf hardware-revision { - type string; - description "Hardware revision of the modem."; - } - leaf firmware-version { - type string; - description "Firmware version of the modem."; - } - leaf-list supported-carrier { - type string; - description "List of supported carriers."; - } - leaf serial-number { - type string; - description "Serial number of the modem (IMEI)."; - } - leaf-list phone-number { - type string; - description "List of own phone numbers."; - } - leaf imsi { - type string; - description "International Mobile Subscriber Identity"; - } - leaf iccid { - type string; - description "ICCID of the SIM card."; - } - } - - container status { - description "Modem Status Information."; - config false; - - leaf sim-active { - type int8; - description "Index of active SIM card."; - } - leaf sim-present { - type boolean; - description "True if SIM card is present"; - } - leaf state { - type string; - description "Modem state."; - } - leaf state-failed-reason { - type string; - description "Reason of failed modem state."; - } - leaf selected-carrier { - type string; - description "Selected carrier."; - } - leaf signal-quality { - type uint8; - description "Signal strength in percent (0-100)."; - } - leaf signal-rssi { - type string; - description "RSSI (Received Signal Strength Indication) in dBm."; - } - leaf signal-rsrp { - type string; - description "RSRP (Reference Signal Received Power) in dBm."; - } - leaf signal-rsrq { - type string; - description "RSRQ (Reference Signal Received Quality) in dB."; - } - leaf signal-rscp { - type string; - description "RSCP (Received Signal Code Power) in dBm."; - } - leaf signal-snr { - type string; - description "SNR (Signal Noise Ratio) in dB."; - } - leaf signal-sinr { - type string; - description "SINR (Signal Interference Noise Ratio) ) in dB."; - } - - container cellular { - description "Cellular network information."; - - leaf registration-state { - description "Modem registration state."; - type registration-state; - } - leaf operator-name { - type string; - description "Name of the cellular network operator."; - } - leaf operator-id { - type string; - description "Identifier of the cellular network operator (MCC/MNC)."; - } - leaf network-type { - type string; - description "Type of cellular network (e.g. LTE)"; - } - leaf packet-service-state { - description "Modem packet service state."; - type string; - } - } - - list bearer { - description "Bearer information."; - key "path"; - - leaf path { - type string; - description "Path to bearer."; - } - leaf interface { - type if:interface-ref; - description "Network interface name for the bearer."; - } - leaf connected { - type boolean; - description "Indicates whether bearer is connected."; - } - leaf connection-failed-reason { - type string; - description "Reason of failed connection of bearer."; - } - leaf ipv4-address { - type inet:ipv4-address; - description "The IPv4 address of the bearer connection."; - } - leaf ipv4-prefix { - type uint16; - description "The IPv4 prefix of the bearer connection."; - } - leaf ipv6-address { - type inet:ipv6-address; - description "The IPv6 address of the bearer connection."; - } - leaf ipv6-prefix { - type uint16; - description "The IPv6 prefix of the bearer connection."; - } - leaf in-bytes { - type yang:counter64; - description "The number of received bytes of the bearer connection."; - } - leaf out-bytes { - type yang:counter64; - description "The number of sent bytes of the bearer connection."; - } - leaf total-in-bytes { - type yang:counter64; - description "Total number of received bytes of all connections."; - } - leaf total-out-bytes { - type yang:counter64; - description "Total number of sent bytes of all connections."; - } - leaf total-duration { - type string; - description "Total duration of all connections in seconds."; - } - } - - container location { - description "Location information."; - - leaf latitude { - type string; - description "Latitude in decimal degrees."; - } - leaf longitude { - type string; - description "Longitude in decimal degrees."; - } - leaf altitude { - type string; - description "Altitude above sea level in meters."; - } - leaf mcc { - type string; - description "Mobile Country Code of the operator."; - } - leaf mnc { - type string; - description "Mobile Network Code of the operator."; - } - leaf lac { - type string; - description "Location Area Code of the operator."; - } - leaf cid { - type string; - description "Cell Identifier of the operator."; - } - leaf tac { - type string; - description "Tracking Area Code of the operator."; - } - } - } - - notification status-update { - description "Notification for modem status updates."; - leaf desc { - type string; - description "Description of the status update."; - mandatory true; - } - } - } - } - - rpc restart { - description "Restart modem."; - input { - leaf index { - type string; - description "Modem index."; - } - } - } - rpc reset { - description "Reset the modem to factory defaults."; - input { - leaf index { - type string; - description "Modem index."; - } - } - } - rpc send-sms { - description "Send an SMS message."; - input { - leaf index { - type string; - description "Modem index."; - } - leaf phone-number { - type string; - description "Recipient's phone number."; - } - leaf message-text { - type string; - description "Text of the SMS message."; - } - } - } - - typedef bearer-apn-type { - description "APN type for bearer."; - - type enumeration { - enum initial { - description "APN used for the initial attach procedure."; - } - enum default { - description "Default connection APN providing access to the Internet."; - } - enum ims { - description "APN providing access to IMS services."; - } - enum mms { - description "APN providing access to MMS services."; - } - enum management { - description "APN providing access to over-the-air device management procedures."; - } - enum voice { - description "APN providing access to voice-over-IP services."; - } - enum emergency { - description "APN providing access to emergency services."; - } - enum private { - description "APN providing access to private networks."; - } - enum purchase { - description "APN providing access to over-the-air activation sites."; - } - enum video_share { - description "APN providing access to video sharing service."; - } - enum local { - description "APN providing access to a local connection with the device."; - } - enum app { - description "APN providing access to certain applications allowed by mobile operators."; - } - enum xcap { - description "APN providing access to XCAP provisioning on IMS services."; - } - enum tethering { - description "APN providing access to mobile hotspot tethering."; - } - } - } - - typedef ip-family { - description "IP address family."; - - type enumeration { - enum ipv4 { - description "IPv4 address family."; - } - enum ipv6 { - description "IPv6 address family."; - } - enum ipv4v6 { - description "IPv4 or IPv6 address family."; - } - } - } - - typedef access-mode { - type enumeration { - enum cs { - description "Circuit-switched technologies (e.g. CSD, GSM)."; - } - enum 2g { - description "2G technologies (e.g. GPRS, EDGE)."; - } - enum 3g { - description "3G technologies (e.g. UMTS, HSxPA)."; - } - enum 4g { - description "4G technologies (e.g. LTE)."; - } - enum 5g { - description "5G technologies (e.g. 5GNR)."; - } - enum any { - description "Any technologies."; - } - } - } - - typedef registration-state { - type enumeration { - enum idle { - description "Not registered, not searching for new operator to register."; - } - enum home { - description "Registered on home network."; - } - enum searching { - description "Not registered, searching for new operator to register with."; - } - enum denied { - description "Registration denied."; - } - enum roaming { - description "Registered on a roaming network."; - } - enum unknown { - description "Unknown registration state."; - } - } - } - - typedef band { - type enumeration { - enum any { - description "Any band."; - } - enum egsm { - description "GSM/GPRS/EDGE 900 MHz."; - } - enum dcs { - description "GSM/GPRS/EDGE 1800 MHz."; - } - enum pcs { - description "GSM/GPRS/EDGE 1900 MHz."; - } - enum g850 { - description "GSM/GPRS/EDGE 850 MHz."; - } - enum g450 { - description "GSM/GPRS/EDGE 450 MHz."; - } - enum g480 { - description "GSM/GPRS/EDGE 480 MHz."; - } - enum g750 { - description "GSM/GPRS/EDGE 750 MHz."; - } - enum g380 { - description "GSM/GPRS/EDGE 380 MHz."; - } - enum g410 { - description "GSM/GPRS/EDGE 410 MHz."; - } - enum g710 { - description "GSM/GPRS/EDGE 710 MHz."; - } - enum g810 { - description "GSM/GPRS/EDGE 810 MHz."; - } - enum utran-1 { - description "UMTS 2100 MHz (IMT, UTRAN band 1)."; - } - enum utran-2 { - description "UMTS 1900 MHz (PCS A-F, UTRAN band 2)."; - } - enum utran-3 { - description "UMTS 1800 MHz (DCS, UTRAN band 3)."; - } - enum utran-4 { - description "UMTS 1700 MHz (AWS A-F, UTRAN band 4)."; - } - enum utran-5 { - description "UMTS 850 MHz (CLR, UTRAN band 5)."; - } - enum utran-6 { - description "UMTS 800 MHz (UTRAN band 6)."; - } - enum utran-7 { - description "UMTS 2600 MHz (IMT-E, UTRAN band 7)."; - } - enum utran-8 { - description "UMTS 900 MHz (E-GSM, UTRAN band 8)."; - } - enum utran-9 { - description "UMTS 1700 MHz (UTRAN band 9)."; - } - enum utran-10 { - description "UMTS 1700 MHz (EAWS A-G, UTRAN band 10)."; - } - enum utran-11 { - description "UMTS 1500 MHz (LPDC, UTRAN band 11)."; - } - enum utran-12 { - description "UMTS 700 MHz (LSMH A/B/C, UTRAN band 12)."; - } - enum utran-13 { - description "UMTS 700 MHz (USMH C, UTRAN band 13)."; - } - enum utran-14 { - description "UMTS 700 MHz (USMH D, UTRAN band 14)."; - } - enum utran-19 { - description "UMTS 800 MHz (UTRAN band 19)."; - } - enum utran-20 { - description "UMTS 800 MHz (EUDD, UTRAN band 20)."; - } - enum utran-21 { - description "UMTS 1500 MHz (UPDC, UTRAN band 21)."; - } - enum utran-22 { - description "UMTS 3500 MHz (UTRAN band 22)."; - } - enum utran-25 { - description "UMTS 1900 MHz (EPCS A-G, UTRAN band 25)."; - } - enum utran-26 { - description "UMTS 850 MHz (ECLR, UTRAN band 26)."; - } - enum utran-32 { - description "UMTS 1500 MHz (L-band, UTRAN band 32)."; - } - enum eutran-1 { - description "E-UTRAN band 1."; - } - enum eutran-2 { - description "E-UTRAN band 2."; - } - enum eutran-3 { - description "E-UTRAN band 3."; - } - enum eutran-4 { - description "E-UTRAN band 4."; - } - enum eutran-5 { - description "E-UTRAN band 5."; - } - enum eutran-6 { - description "E-UTRAN band 6."; - } - enum eutran-7 { - description "E-UTRAN band 7."; - } - enum eutran-8 { - description "E-UTRAN band 8."; - } - enum eutran-9 { - description "E-UTRAN band 9."; - } - enum eutran-10 { - description "E-UTRAN band 10."; - } - enum eutran-11 { - description "E-UTRAN band 11."; - } - enum eutran-12 { - description "E-UTRAN band 12."; - } - enum eutran-13 { - description "E-UTRAN band 13."; - } - enum eutran-14 { - description "E-UTRAN band 14."; - } - enum eutran-17 { - description "E-UTRAN band 17."; - } - enum eutran-18 { - description "E-UTRAN band 18."; - } - enum eutran-19 { - description "E-UTRAN band 19."; - } - enum eutran-20 { - description "E-UTRAN band 20."; - } - enum eutran-21 { - description "E-UTRAN band 21."; - } - enum eutran-22 { - description "E-UTRAN band 22."; - } - enum eutran-23 { - description "E-UTRAN band 23."; - } - enum eutran-24 { - description "E-UTRAN band 24."; - } - enum eutran-25 { - description "E-UTRAN band 25."; - } - enum eutran-26 { - description "E-UTRAN band 26."; - } - enum eutran-27 { - description "E-UTRAN band 27."; - } - enum eutran-28 { - description "E-UTRAN band 28."; - } - enum eutran-29 { - description "E-UTRAN band 29."; - } - enum eutran-30 { - description "E-UTRAN band 30."; - } - enum eutran-31 { - description "E-UTRAN band 31."; - } - enum eutran-32 { - description "E-UTRAN band 32."; - } - enum eutran-33 { - description "E-UTRAN band 33."; - } - enum eutran-34 { - description "E-UTRAN band 34."; - } - enum eutran-35 { - description "E-UTRAN band 35."; - } - enum eutran-36 { - description "E-UTRAN band 36."; - } - enum eutran-37 { - description "E-UTRAN band 37."; - } - enum eutran-38 { - description "E-UTRAN band 38."; - } - enum eutran-39 { - description "E-UTRAN band 39."; - } - enum eutran-40 { - description "E-UTRAN band 40."; - } - enum eutran-41 { - description "E-UTRAN band 41."; - } - enum eutran-42 { - description "E-UTRAN band 42."; - } - enum eutran-43 { - description "E-UTRAN band 43."; - } - enum eutran-44 { - description "E-UTRAN band 44."; - } - enum eutran-45 { - description "E-UTRAN band 45."; - } - enum eutran-46 { - description "E-UTRAN band 46."; - } - enum eutran-47 { - description "E-UTRAN band 47."; - } - enum eutran-48 { - description "E-UTRAN band 48."; - } - enum eutran-49 { - description "E-UTRAN band 49."; - } - enum eutran-50 { - description "E-UTRAN band 50."; - } - enum eutran-51 { - description "E-UTRAN band 51."; - } - enum eutran-52 { - description "E-UTRAN band 52."; - } - enum eutran-53 { - description "E-UTRAN band 53."; - } - enum eutran-54 { - description "E-UTRAN band 54."; - } - enum eutran-55 { - description "E-UTRAN band 55."; - } - enum eutran-56 { - description "E-UTRAN band 56."; - } - enum eutran-57 { - description "E-UTRAN band 57."; - } - enum eutran-58 { - description "E-UTRAN band 58."; - } - enum eutran-59 { - description "E-UTRAN band 59."; - } - enum eutran-60 { - description "E-UTRAN band 60."; - } - enum eutran-61 { - description "E-UTRAN band 61."; - } - enum eutran-62 { - description "E-UTRAN band 62."; - } - enum eutran-63 { - description "E-UTRAN band 63."; - } - enum eutran-64 { - description "E-UTRAN band 64."; - } - enum eutran-65 { - description "E-UTRAN band 65."; - } - enum eutran-66 { - description "E-UTRAN band 66."; - } - enum eutran-67 { - description "E-UTRAN band 67."; - } - enum eutran-68 { - description "E-UTRAN band 68."; - } - enum eutran-69 { - description "E-UTRAN band 69."; - } - enum eutran-70 { - description "E-UTRAN band 70."; - } - enum eutran-71 { - description "E-UTRAN band 71."; - } - enum eutran-85 { - description "E-UTRAN band 85."; - } - enum cdma-bc0 { - description "CDMA Band Class 0 (US Cellular 850MHz)."; - } - enum cdma-bc1 { - description "CDMA Band Class 1 (US PCS 1900MHz)."; - } - enum cdma-bc2 { - description "CDMA Band Class 2 (UK TACS 900MHz)."; - } - enum cdma-bc3 { - description "CDMA Band Class 3 (Japanese TACS)."; - } - enum cdma-bc4 { - description "CDMA Band Class 4 (Korean PCS)."; - } - enum cdma-bc5 { - description "CDMA Band Class 5 (NMT 450MHz)."; - } - enum cdma-bc6 { - description "CDMA Band Class 6 (IMT2000 2100MHz)."; - } - enum cdma-bc7 { - description "CDMA Band Class 7 (Cellular 700MHz)."; - } - enum cdma-bc8 { - description "CDMA Band Class 8 (1800MHz)."; - } - enum cdma-bc9 { - description "CDMA Band Class 9 (900MHz)."; - } - enum cdma-bc10 { - description "CDMA Band Class 10 (US Secondary 800)."; - } - enum cdma-bc11 { - description "CDMA Band Class 11 (European PAMR 400MHz)."; - } - enum cdma-bc12 { - description "CDMA Band Class 12 (PAMR 800MHz)."; - } - enum cdma-bc13 { - description "CDMA Band Class 13 (IMT2000 2500MHz Expansion)."; - } - enum cdma-bc14 { - description "CDMA Band Class 14 (More US PCS 1900MHz)."; - } - enum cdma-bc15 { - description "CDMA Band Class 15 (AWS 1700MHz)."; - } - enum cdma-bc16 { - description "CDMA Band Class 16 (US 2500MHz)."; - } - enum cdma-bc17 { - description "CDMA Band Class 17 (US 2500MHz Forward Link Only)."; - } - enum cdma-bc18 { - description "CDMA Band Class 18 (US 700MHz Public Safety)."; - } - enum cdma-bc19 { - description "CDMA Band Class 19 (US Lower 700MHz)."; - } - enum ngran-1 { - description "NGRAN band 1."; - } - enum ngran-2 { - description "NGRAN band 2."; - } - enum ngran-3 { - description "NGRAN band 3."; - } - enum ngran-5 { - description "NGRAN band 5."; - } - enum ngran-7 { - description "NGRAN band 7."; - } - enum ngran-8 { - description "NGRAN band 8."; - } - enum ngran-12 { - description "NGRAN band 12."; - } - enum ngran-13 { - description "NGRAN band 13."; - } - enum ngran-14 { - description "NGRAN band 14."; - } - enum ngran-18 { - description "NGRAN band 18."; - } - enum ngran-20 { - description "NGRAN band 20."; - } - enum ngran-25 { - description "NGRAN band 25."; - } - enum ngran-26 { - description "NGRAN band 26."; - } - enum ngran-28 { - description "NGRAN band 28."; - } - enum ngran-29 { - description "NGRAN band 29."; - } - enum ngran-30 { - description "NGRAN band 30."; - } - enum ngran-34 { - description "NGRAN band 34."; - } - enum ngran-38 { - description "NGRAN band 38."; - } - enum ngran-39 { - description "NGRAN band 39."; - } - enum ngran-40 { - description "NGRAN band 40."; - } - enum ngran-41 { - description "NGRAN band 41."; - } - enum ngran-48 { - description "NGRAN band 48."; - } - enum ngran-50 { - description "NGRAN band 50."; - } - enum ngran-51 { - description "NGRAN band 51."; - } - enum ngran-53 { - description "NGRAN band 53."; - } - enum ngran-65 { - description "NGRAN band 65."; - } - enum ngran-66 { - description "NGRAN band 66."; - } - enum ngran-70 { - description "NGRAN band 70."; - } - enum ngran-71 { - description "NGRAN band 71."; - } - enum ngran-74 { - description "NGRAN band 74."; - } - enum ngran-75 { - description "NGRAN band 75."; - } - enum ngran-76 { - description "NGRAN band 76."; - } - enum ngran-77 { - description "NGRAN band 77."; - } - enum ngran-78 { - description "NGRAN band 78."; - } - enum ngran-79 { - description "NGRAN band 79."; - } - enum ngran-80 { - description "NGRAN band 80."; - } - enum ngran-81 { - description "NGRAN band 81."; - } - enum ngran-82 { - description "NGRAN band 82."; - } - enum ngran-83 { - description "NGRAN band 83."; - } - enum ngran-84 { - description "NGRAN band 84."; - } - enum ngran-86 { - description "NGRAN band 86."; - } - enum ngran-89 { - description "NGRAN band 89."; - } - enum ngran-90 { - description "NGRAN band 90."; - } - enum ngran-91 { - description "NGRAN band 91."; - } - enum ngran-92 { - description "NGRAN band 92."; - } - enum ngran-93 { - description "NGRAN band 93."; - } - enum ngran-94 { - description "NGRAN band 94."; - } - enum ngran-95 { - description "NGRAN band 95."; - } - enum ngran-257 { - description "NGRAN band 257."; - } - enum ngran-258 { - description "NGRAN band 258."; - } - enum ngran-260 { - description "NGRAN band 260."; - } - enum ngran-261 { - description "NGRAN band 261."; - } - } - } - - typedef location-source { - type enumeration { - enum gps { - description "GPS location source."; - } - enum agps-msa { - description "MSA A-GPS location source."; - } - enum agps-msb { - description "MSB A-GPS location source."; - } - enum 3gpp { - description "3GPP location source."; - } - enum cdma { - description "CDMA location source."; - } - } - } -} diff --git a/src/confd/yang/confd/infix-modem@2024-03-15.yang b/src/confd/yang/confd/infix-modem@2024-03-15.yang deleted file mode 120000 index 467b07a47..000000000 --- a/src/confd/yang/confd/infix-modem@2024-03-15.yang +++ /dev/null @@ -1 +0,0 @@ -infix-modem.yang \ No newline at end of file diff --git a/src/confd/yang/modem.inc b/src/confd/yang/modem.inc new file mode 100644 index 000000000..6a9a87380 --- /dev/null +++ b/src/confd/yang/modem.inc @@ -0,0 +1,5 @@ +MODULES=( + "infix-hardware -e modem" + "infix-if-type -e modem" + "infix-interfaces -e modem" +) diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index bcca3206e..1c8cfd47a 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -129,6 +129,15 @@ + + + + jq -r '.modem[]? | "modem" + (.index|tostring)' /run/system.json 2>/dev/null + + + + + @@ -455,6 +464,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" + + + + @@ -713,6 +746,15 @@ echo "Public: $pub" + + + + + + show modem "$KLISH_PARAM_ref" |pager + + + diff --git a/src/modemd/modem-info b/src/modemd/modem-info index 2f1684b26..65b713948 100755 --- a/src/modemd/modem-info +++ b/src/modemd/modem-info @@ -159,6 +159,20 @@ def get_current_carrier(index, manf, model): return "default" +def _sim_lock_state(unlock_required): + mapping = { + "none": "unlocked", + "sim-pin": "pin-required", + "sim-pin2": "pin-required", + "sim-puk": "puk-required", + "sim-puk2": "puk-required", + } + # Default to "unlocked": this function is only called when the SIM query + # succeeded, so the card is present. "--" means the modem doesn't report + # lock state but the SIM is accessible. + return mapping.get(unlock_required, "unlocked") + + def device_devpath(devpath): path = devpath while path != "/sys": @@ -171,24 +185,37 @@ def device_devpath(devpath): def modem_get_module(devpath): - if not os.path.exists("/run/modules.json"): - if os.path.exists("/run/modems.json"): - with open("/run/modems.json", "r") as fd: - data = json.load(fd) - for index, modem in enumerate(data.get("modems", [])): - if devpath in modem["devpath"]: - return {"index": index, "slot": index, "paths": [modem["devpath"]], "type": "modem"} + # Prefer system.json (new canonical source written by 00-probe) + if os.path.exists("/run/system.json"): + with open("/run/system.json", "r") as fd: + data = json.load(fd) + for modem in data.get("modem", []): + if devpath in modem.get("devpath", ""): + idx = modem.get("index", 0) + return {"index": idx, "slot": idx, + "paths": [modem["devpath"]], "type": "modem"} + + # Fallback: Minex modules.json (hardware with slot/SIM multiplexer) + if os.path.exists("/run/modules.json"): + with open("/run/modules.json", "r") as fd: + data = json.load(fd) + for module in data.get("modules", []): + if module["type"] != "modem": + continue + for path in module.get("paths", []): + if path in devpath: + return module return None - with open("/run/modules.json", "r") as fd: - data = json.load(fd) + # Fallback: legacy modems.json written by modem-udev + if os.path.exists("/run/modems.json"): + with open("/run/modems.json", "r") as fd: + data = json.load(fd) + for index, modem in enumerate(data.get("modems", [])): + if devpath in modem.get("devpath", ""): + return {"index": index, "slot": index, + "paths": [modem["devpath"]], "type": "modem"} - for module in data["modules"]: - if module["type"] != "modem": - continue - for path in module["paths"]: - if path in devpath: - return module return None @@ -228,18 +255,35 @@ def sysfs_interfaces(devpath): def print_modem(index): - with open("/run/modems.json", "r") as fd: - data = json.load(fd) - - for modem in data["modems"]: - module = modem_get_module(modem["devpath"]) - if module["index"] == index: - modem["slot"] = module["slot"] - modem["sim"] = modem_get_sim(module) or {"index": 0, "name": "sim0", "slot": 0, "present": True} - if not modem.get("interfaces"): - modem["interfaces"] = sysfs_interfaces(modem["devpath"]) - print(json.dumps(modem)) - sys.exit(0) + # Try system.json first (canonical source written by 00-probe) + if os.path.exists("/run/system.json"): + with open("/run/system.json", "r") as fd: + data = json.load(fd) + for modem in data.get("modem", []): + if modem.get("index") == index: + module = {"index": index, "slot": index, + "paths": [modem["devpath"]], "type": "modem"} + modem["sim"] = modem_get_sim(module) or \ + {"index": 0, "name": "sim0", "slot": 0, "present": True} + if not modem.get("interfaces"): + modem["interfaces"] = sysfs_interfaces(modem["devpath"]) + print(json.dumps(modem)) + sys.exit(0) + + # Fallback: legacy modems.json written by modem-udev + if os.path.exists("/run/modems.json"): + with open("/run/modems.json", "r") as fd: + data = json.load(fd) + for modem in data.get("modems", []): + module = modem_get_module(modem["devpath"]) + if module and module["index"] == index: + modem["slot"] = module["slot"] + modem["sim"] = modem_get_sim(module) or \ + {"index": 0, "name": "sim0", "slot": 0, "present": True} + if not modem.get("interfaces"): + modem["interfaces"] = sysfs_interfaces(modem["devpath"]) + print(json.dumps(modem)) + sys.exit(0) print(json.dumps({})) sys.exit(0) @@ -309,14 +353,23 @@ def print_all(): "phone-number": gen.get("own-numbers", []), } + sig_q = gen.get("signal-quality") or {} modem["status"] = { "state": vstr(gen["state"]), "selected-carrier": get_current_carrier(index, manf, model), - "signal-quality": vnum(gen["signal-quality"]["value"]) + "signal-quality": vnum(sig_q.get("value", "0")), + "signal-quality-recent": vbool(sig_q.get("recent", "no")), + "power-state": vstr(gen.get("power-state", "--")) or "unknown", } + locks = gpp.get("enabled-locks") or [] + if isinstance(locks, list): + modem["status"]["enabled-locks"] = [ + lk for lk in locks if lk and lk != "--" and lk != "none" + ] + if gen["state"] == "failed": - reason = vstr(gen["state-failed-reason"]) + reason = vstr(gen.get("state-failed-reason", "--")) if len(reason) > 0: modem["status"]["state-failed-reason"] = reason @@ -324,6 +377,17 @@ def print_all(): if sim is not None: modem["status"]["sim-active"] = sim["index"] modem["status"]["sim-present"] = sim["present"] + sim_name = sim["name"] + sim_slot = sim["slot"] + else: + sim_name = "sim0" + slot_raw = gen.get("primary-sim-slot", 1) + sim_slot = int(slot_raw) if str(slot_raw).isdigit() else 1 + + # rssi / rsrp are int16 dBm in YANG; rsrq / sinr are decimal64 dB. + # mmcli reports them as floating-point strings; normalise here. + int_signals = {"rssi", "rsrp"} + dec_signals = {"rsrq", "sinr"} refresh = 0 output = runcmdj(['mmcli', '-J', '-m', mpath, '--signal-get']) @@ -336,14 +400,33 @@ def print_all(): signals = ["rssi", "rsrp", "rsrq", "rscp", "snr", "sinr"] found = False for tech in output["modem"]["signal"].keys(): + if tech == "refresh": + continue for sig in signals: v = output["modem"]["signal"][tech].get(sig, "--") - if v != "--": - modem["status"]["signal-%s" % sig] = v + if v == "--": + continue + try: + if sig in int_signals: + modem["status"]["signal-%s" % sig] = int(round(float(v))) + elif sig in dec_signals: + modem["status"]["signal-%s" % sig] = "%.1f" % float(v) + else: + modem["status"]["signal-%s" % sig] = v found = True + except (TypeError, ValueError): + continue if found: break + access_techs = gen.get("access-technologies") or [] + if isinstance(access_techs, str): + access_techs = [access_techs] + access_techs = [t for t in access_techs if t and t != "--"] + # mmcli reports the active access-technology set lowest-to-highest; + # pick the most advanced for the YANG single-valued enum. + network_type = access_techs[-1].lower() if access_techs else "unknown" + modem["status"]["cellular"] = { "registration-state": venum(gpp["registration-state"], @@ -351,8 +434,10 @@ def print_all(): "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"]) + "network-type": network_type, + "service-state": + venum(gpp.get("packet-service-state", "--"), + ["attached", "detached"], "unknown"), } bearers = [] @@ -389,10 +474,40 @@ def print_all(): modem["status"]["bearer"] = bearers - output = runcmdj(['mmcli', '-J', '-m', mpath, '-i', gen["sim"]]) + sim_path = vstr(gen.get("sim", "--")) + output = None + if sim_path: + output = runcmdj(['mmcli', '-J', '-m', mpath, '-i', sim_path]) + if output: - modem["info"]["imsi"] = output["sim"]["properties"]["imsi"] - modem["info"]["iccid"] = output["sim"]["properties"]["iccid"] + sim_props = output["sim"]["properties"] + modem["info"]["imsi"] = vstr(sim_props.get("imsi", "--")) + modem["info"]["iccid"] = vstr(sim_props.get("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", "--")), + } + else: + # mmcli SIM query failed (or there's no SIM path at all). Emit + # sim-state anyway so a 'simN' component appears in the YANG + # tree — Andrew's report: a missing SIM should be visible from + # 'show modem', not silent. Derive the state from modem-level + # signals: an empty 'sim' path or 'sim-missing' failure reason + # both mean the slot is empty. Otherwise we genuinely don't + # know (modem busy / SIM read error / etc.) — report 'unknown'. + if (not sim_path or + vstr(gen.get("state-failed-reason", "--")) == "sim-missing"): + sim_state_value = "not-inserted" + else: + sim_state_value = "unknown" + modem["sim-state"] = { + "name": sim_name, + "slot": sim_slot, + "state": sim_state_value, + "operator-name": "", + } output = runcmdj(['mmcli', '-J', '-m', mpath, '--location-get']) if output: diff --git a/src/modemd/modem-rpc b/src/modemd/modem-rpc index 5bc7ecd7d..ff9f86eb1 100755 --- a/src/modemd/modem-rpc +++ b/src/modemd/modem-rpc @@ -38,6 +38,13 @@ if __name__ == "__main__": print("Sending '%s' rpc to modem%d" % (args.rpc, index)) - rpc = {"infix-modem:%s" % args.rpc: {"index": str(index)}} + rpc = { + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % index, + "infix-hardware:modem": {args.rpc: {}} + }] + } + } sendrpc(index, rpc) diff --git a/src/modemd/modem-sms b/src/modemd/modem-sms index 7bcb797ce..cf6c8b424 100755 --- a/src/modemd/modem-sms +++ b/src/modemd/modem-sms @@ -27,11 +27,17 @@ def send(index, number, text): index = 0 rpc = { - "infix-modem:send-sms": { - "index": str(index), - "phone-number": number, - "message-text": text - } + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % index, + "infix-hardware:modem": { + "send-sms": { + "phone-number": number, + "message-text": text + } + } + }] + } } print("Sending SMS to modem%d" % index) diff --git a/src/modemd/modem-udev b/src/modemd/modem-udev index ba4ebfbaf..64d6b1e23 100755 --- a/src/modemd/modem-udev +++ b/src/modemd/modem-udev @@ -10,6 +10,7 @@ import re import os MODEMS = "/run/modems.json" +SYSTEM = "/run/system.json" LOCK = "/var/lock/modems.lock" LOGFILE = "/run/modemd/modem-udev.log" debug = False @@ -98,22 +99,42 @@ def write_modem_data(data): return True -def get_vendor(devpath): - path = "%s/idVendor" % devpath - if os.path.exists(path): - with open(path, "r") as fd: +def _sysfs_attr(devpath, name): + try: + with open("%s/%s" % (devpath, name), "r") as fd: return fd.readline().strip() - else: + except OSError: return None -def get_product(devpath): - path = "%s/idProduct" % devpath - if os.path.exists(path): - with open(path, "r") as fd: - return fd.readline().strip() +def update_system_json(modems_list): + data = {} + try: + with open(SYSTEM, "r") as fd: + data = json.load(fd) + except (IOError, json.JSONDecodeError): + pass + + entries = [] + for i, m in enumerate(modems_list): + entries.append({ + "index": i, + "name": "modem%d" % i, + "devpath": m.get("devpath", ""), + "vid": m.get("vendor", ""), + "pid": m.get("product", ""), + }) + + if not entries: + data.pop("modem", None) else: - return None + data["modem"] = entries + + tmp = SYSTEM + ".tmp" + with open(tmp, "w") as fd: + json.dump(data, fd, indent=2) + os.rename(tmp, SYSTEM) + info("Updated system.json with %d modem(s)" % len(entries)) def update(d): @@ -133,11 +154,11 @@ def update(d): if not modem: modem = {"devpath": devpath} - vendor = get_vendor(devpath) + vendor = _sysfs_attr(devpath, "idVendor") if vendor: modem["vendor"] = vendor - product = get_product(devpath) + product = _sysfs_attr(devpath, "idProduct") if product: modem["product"] = product @@ -165,6 +186,7 @@ def update(d): data["modems"].append(modem) write_modem_data(data) + update_system_json(data["modems"]) info("Updated modem %s" % devpath) diff --git a/src/modemd/modemd b/src/modemd/modemd index 43699fed1..50f595b02 100755 --- a/src/modemd/modemd +++ b/src/modemd/modemd @@ -14,6 +14,7 @@ import enum import time import os import sys +import ipaddress rundir = "/run/modemd" smsdir = "/var/sms" @@ -63,8 +64,10 @@ def opener(path, flags): def rmf(path): - if os.path.exists(path): + try: os.unlink(path) + except FileNotFoundError: + pass def rmrf(path): @@ -87,12 +90,12 @@ def mkdir(path): def fread(path): - if os.path.exists(path): + try: with open(path, "r") as fd: - output = str(fd.read()) - if output: - return output.strip() - return None + output = fd.read() + return output.strip() if output else None + except OSError: + return None def freadj(path): @@ -207,6 +210,92 @@ def list_sms(modem): return smslist +def load_config(): + hw = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-hardware"]) + ifaces = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-interfaces"]) + + ks = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-keystore"]) + keys = {} + if ks: + for key in ks.get("ietf-keystore:keystore", {}) \ + .get("symmetric-keys", {}) \ + .get("symmetric-key", []): + keys[key["name"]] = key.get("cleartext-symmetric-key", "") + + # Cellular modems appear seconds after boot; use physical detection as the + # authoritative list rather than sysrepo which may not have them yet. + sysj = freadj("/run/system.json") or {} + physical = sysj.get("modem", []) + if not physical: + mj = freadj("/run/modems.json") or {} + for i, m in enumerate(mj.get("modems", [])): + physical.append({"index": i, "name": "modem%d" % i, + "devpath": m.get("devpath", "")}) + + hw_comps, sims = {}, {} + for comp in (hw or {}).get("ietf-hardware:hardware", {}).get("component", []): + cls = comp.get("class", "") + if cls == "infix-hardware:modem": + hw_comps[comp["name"]] = comp + elif cls == "infix-hardware:sim": + sims[comp["name"]] = comp.get("infix-hardware:sim", {}) + + modems = {} + for phys in physical: + name = phys.get("name", "modem%d" % phys.get("index", 0)) + index = phys.get("index", 0) + + comp = hw_comps.get(name, {}) + admin = comp.get("state", {}).get("admin-state", "unlocked") + if admin == "locked": + continue + + ih = comp.get("infix-hardware:modem", {}) + modems[name] = { + "index": index, + "preferred-mode": ih.get("preferred-mode"), + "allowed-mode": ih.get("allowed-mode", []), + "band": ih.get("band", []), + "location": ih.get("location"), + "bearer": [], + } + + for iface in (ifaces or {}).get("ietf-interfaces:interfaces", {}).get("interface", []): + if iface.get("type", "") != "infix-if-type:modem": + continue + wwan = iface.get("infix-interfaces:wwan", {}) + modem_ref = wwan.get("modem") + if not modem_ref or modem_ref not in modems: + continue + + sim_ref = wwan.get("sim") + if sim_ref and sim_ref in sims and "pin" not in modems[modem_ref]: + sim = sims[sim_ref] + modems[modem_ref].update({ + "pin": sim.get("pin"), + "puk": sim.get("puk"), + "carrier": sim.get("carrier", "default"), + }) + + bearer_cfg = wwan.get("bearer", {}) + auth = bearer_cfg.get("authentication") + bearer = { + "index": len(modems[modem_ref]["bearer"]), + "name": iface["name"], + "apn": bearer_cfg.get("apn", ""), + "ip-type": bearer_cfg.get("ip-type", "ipv4v6"), + "roaming": bearer_cfg.get("roaming", False), + "route-preference": bearer_cfg.get("route-preference", 200), + } + if auth: + bearer["username"] = auth.get("username", "") + bearer["password"] = keys.get(auth.get("password", ""), "") + + modems[modem_ref]["bearer"].append(bearer) + + return list(modems.values()) + + class RpcThread(threading.Thread): path = None server = None @@ -340,6 +429,10 @@ class SimThread(threading.Thread): th.interrupt = True def run(self): + if not os.path.exists("/dev/simctrl"): + dbg("No /dev/simctrl, SIM slot monitoring not available on this platform") + self.exited = True + return fd = os.open("/dev/simctrl", os.O_RDONLY) while not self.stopped(): res, *_ = select.select([fd], [], [], 10.0) @@ -428,8 +521,6 @@ class ModemThread(threading.Thread): self.err("No bearers configured") return False - default = None - count = 0 for bearer in sorted(bearers, key=lambda b: b["index"]): bearer["pid"] = "--" @@ -438,19 +529,7 @@ class ModemThread(threading.Thread): self.err("No APN configured") return False - apntype = bearer.get("apn-type") - if apntype == "initial": - if not self.bearers["initial"]: - self.bearers["initial"] = bearer - elif apntype == "default" and not default: - default = bearer - count += 1 - else: - self.bearers["list"].append(bearer) - count += 1 - - if default: - self.bearers["list"].insert(0, default) + self.bearers["list"].append(bearer) # at least one bearer is mandatory if len(self.bearers["list"]) == 0: @@ -475,9 +554,7 @@ class ModemThread(threading.Thread): loc = self.cfg.get("location") if loc and loc.get("enabled"): enabled = True - v = loc.get("source") - if v: - sources = [s.get("source") for s in v] + sources = loc.get("source", []) self.location = { "enabled": enabled, @@ -538,10 +615,12 @@ class ModemThread(threading.Thread): def notif(self, msg): data = { - "infix-modem:modems": { - "modem": [{ - "index": self.index, - "status-update": {"desc": msg} + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % self.index, + "infix-hardware:modem": { + "status-update": {"desc": msg} + } }] } } @@ -612,10 +691,6 @@ class ModemThread(threading.Thread): return True def resolvconf(self, iface, action): - enabled = iface["bearer"].get("dns-enabled", False) - if enabled is False: - return True - self.info("About to %s nameservers on %s" % (action, iface["name"])) runcmd(["resolvconf", "-d", iface["name"]], check=False) @@ -638,71 +713,78 @@ class ModemThread(threading.Thread): return True - def ipgwroute(self, iface, action): - output = runcmdj(["ip", "-j", "route", - "show", "dev", iface["name"]]) - for rt in output: - if "scope" in rt and rt["scope"] == "link": - if action == "add": - return True - else: - runcmd(["ip", "route", - "del", rt["dst"], "dev", iface["name"]]) - - if action != "add": - return True - - if iface["ipv4"]["gateway"] != "--": - gw = iface["ipv4"]["gateway"] - elif iface["ipv6"]["gateway"] != "--": - gw = iface["ipv6"]["gateway"] - else: - self.err("No gateway address found") - return False - - cmd = ["ip", "route", "add", gw, "dev", iface["name"]] - if not runcmd(cmd): - self.err("Unable to add gateway route") - return False - - return True + def _derive_gateway(self, ifname): + """Derive the peer gateway from the link-scope subnet route on ifname. + + Point-to-point bearers (MBIM/QMI) often report gateway as '--' even + though the kernel installs a link-scope subnet route (e.g. /30). The + peer — and therefore the nexthop for the default route — is the only + other host in that subnet. + """ + output = runcmdj(["ip", "-j", "route", "show", + "dev", ifname, "scope", "link"]) + for rt in (output or []): + dst = rt.get("dst", "") + src = rt.get("prefsrc", "") + if not dst or not src or "/" not in dst: + continue + try: + net = ipaddress.ip_network(dst, strict=False) + our = ipaddress.ip_address(src) + peers = [str(h) for h in net.hosts() if h != our] + if peers: + return peers[0] + except ValueError: + pass + return None def ipdefroute(self, iface, action): self.info("About to %s default route on %s" % (action, iface["name"])) - output = runcmdj(["ip", "-j", "route", "show", "default"]) - if output: - for route in output: - if not runcmd(["ip", "route", "del", "default", - "dev", route["dev"]]): - self.err("Can't delete default route on %s" % route["dev"]) - - if not self.ipgwroute(iface, action): - return False + conf = "/etc/net.d/%s.conf" % iface["name"] if action != "add": + rmf(conf) return True - cmd = ["ip", "route", "add", "default", "dev", iface["name"]] if iface["ipv4"]["gateway"] != "--": - cmd += ["via", iface["ipv4"]["gateway"]] + gw = iface["ipv4"]["gateway"] elif iface["ipv6"]["gateway"] != "--": - cmd += ["via", iface["ipv6"]["gateway"]] + gw = iface["ipv6"]["gateway"] + else: + # Gateway not reported by modem (common for point-to-point bearers). + # Derive it from the link-scope subnet route on the interface: the + # peer is the only other host in the subnet. + gw = self._derive_gateway(iface["name"]) + if not gw: + self.err("No gateway address found") + return False - if not runcmd(cmd): - self.err("Unable to add default route on %s" % iface["name"]) - time.sleep(30) - return False + metric = iface["bearer"].get("route-preference", 200) + + # Write route to /etc/net.d/ for netd/staticd/zebra to install. + # This keeps the route visible to FRR for redistribution and allows + # metric-based failover behind wired/WiFi routes (which use lower + # metric values from udhcpc, typically 5). + content = "# Generated by modemd -- do not edit\n\n" + content += "route {\n" + if ":" in gw: + content += ' prefix = "::/0"\n' else: + content += ' prefix = "0.0.0.0/0"\n' + content += ' nexthop = "%s"\n' % gw + content += ' distance = %d\n' % metric + content += ' tag = 100\n' + content += '}\n' + + if fread(conf) == content.strip(): return True + next_conf = conf + "+" + fwrite(next_conf, content) + os.rename(next_conf, conf) + return True def ifupdown(self, iface, action): - enabled = iface["bearer"].get("firewall-enabled", False) - if enabled is True: - self.info("Setting %s firewall on %s" % (action, self.iface)) - if not runcmd(["/etc/firewall/default", action, self.iface]): - return False - path = "/var/ifupdown/%s" % self.iface if os.path.isfile(path) or os.access(path, os.X_OK): self.info("Running %s %s" % (path, action)) @@ -716,12 +798,10 @@ class ModemThread(threading.Thread): self.ipdefroute(iface, "delete") self.ifupdown(iface, "down") - output = runcmdj(["ip", "-j", "addr", "show", iface["name"]]) - for entry in output: - for addr in entry["addr_info"]: - runcmd(["ip", "addr", "del", - addr["local"] + "/" + str(addr["prefixlen"]), - "dev", iface["name"]]) + # Flush only wwan-protocol-tagged addresses; leaves any static or + # dhcp-protocol addresses intact in case the interface is shared. + runcmd(["ip", "addr", "flush", "dev", iface["name"], + "proto", "wwan"], check=False) self.iplink(iface["name"], "down") @@ -743,16 +823,21 @@ class ModemThread(threading.Thread): self.err("No addresses yet") return False + # Flush any stale wwan-protocol addresses before adding new ones. + # The 'proto wwan' tag (rt_addrprotos entry 7) allows precise cleanup + # on disconnect without touching addresses added by other means. + runcmd(["ip", "addr", "flush", "dev", iface["name"], + "proto", "wwan"], check=False) + for addr in addrs: self.info("Setting address %s on %s" % (addr, iface["name"])) - if not runcmd(["ip", "addr", "change", - addr, "dev", iface["name"]]): + if not runcmd(["ip", "addr", "replace", addr, + "dev", iface["name"], "proto", "wwan"]): self.err("Unable to set address for %s" % iface["name"]) return False - if iface["bearer"].get("default-route"): - if not self.ipdefroute(iface, "add"): - return False + if not self.ipdefroute(iface, "add"): + return False if not self.ifupdown(iface, "up"): return False @@ -921,9 +1006,6 @@ class ModemThread(threading.Thread): "profile-id=%s" % bearer["pid"], "profile-name=profile%s" % bearer["pid"] ] - v = bearer.get("apn-type") - if v: - args.append("apn-type=%s" % v) v = bearer.get("apn") if v: args.append("apn=%s" % v) @@ -958,7 +1040,7 @@ class ModemThread(threading.Thread): if v: args.append("password=%s" % v) - v = bearer.get("allow-roaming") + v = bearer.get("roaming") if v: args.append("allow-roaming=true") else: @@ -1071,8 +1153,7 @@ class ModemThread(threading.Thread): def prepare_bands(self): self.info("Preparing bands") - v = self.cfg.get("band", []) - bands = [k.get("band", "any") for k in v] + bands = self.cfg.get("band", []) if len(bands) == 0 or "any" in bands: return True @@ -1089,9 +1170,7 @@ class ModemThread(threading.Thread): if not pref or pref == "any": pref = "none" - allow = [] - v = self.cfg.get("allowed-mode", []) - allow = [k.get("mode", "any") for k in v] + allow = self.cfg.get("allowed-mode", []) if "any" in allow: allow = [] @@ -1583,17 +1662,14 @@ if __name__ == "__main__": print(e) fatal("SIM thread caught an exception") - config = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "infix-modem"]) - if not config or "infix-modem:modems" not in config: - fatal("Unable to read config") + modems = load_config() + if not modems: + fatal("No modems configured or enabled") if not runcmd(['/usr/libexec/modemd/sim-setup']): fatal("Unable to setup SIMs") - for cfg in config["infix-modem:modems"]["modem"]: - if not cfg["enabled"] or cfg["index"] < 0: - continue - + for cfg in modems: th = None try: th = ModemThread(cfg) diff --git a/src/modemd/sim-setup b/src/modemd/sim-setup index be400f036..a19b4a51d 100755 --- a/src/modemd/sim-setup +++ b/src/modemd/sim-setup @@ -173,17 +173,42 @@ def swapslot(slot): def sim_setup(setup): changed = False - config = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "infix-modem"]) - if not config or "infix-modem:modems" not in config: + hw = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-hardware"]) + ifaces = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-interfaces"]) + if not hw or not ifaces: err("Cannot read config") return False - for cfg in config["infix-modem:modems"]["modem"]: - if not cfg.get("enabled", False) or cfg.get("index", -1) < 0: + modem_set = set() + for comp in hw.get("ietf-hardware:hardware", {}).get("component", []): + if comp.get("class", "") == "infix-hardware:modem" \ + and comp.get("state", {}).get("admin-state", "unlocked") == "unlocked": + name = comp["name"] + try: + modem_set.add(int(name[5:])) + except (ValueError, IndexError): + pass + + sim_for_modem = {} + for iface in ifaces.get("ietf-interfaces:interfaces", {}).get("interface", []): + if iface.get("type", "") != "infix-if-type:modem": continue + wwan = iface.get("infix-interfaces:wwan", {}) + modem_ref = wwan.get("modem", "") + sim_ref = wwan.get("sim", "") + if not modem_ref.startswith("modem") or not sim_ref.startswith("sim"): + continue + try: + midx = int(modem_ref[5:]) + sidx = int(sim_ref[3:]) + except (ValueError, IndexError): + continue + if midx in modem_set: + sim_for_modem[midx] = sidx - modem = find_modem(setup, cfg["index"]) - sim = find_sim(setup, cfg.get("sim", -1)) + for modem_idx, sim_idx in sim_for_modem.items(): + modem = find_modem(setup, modem_idx) + sim = find_sim(setup, sim_idx) if modem and sim: sim["newslot"] = modem["slot"] if sim["newslot"] != sim["slot"]: diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 919e27630..f0db96781 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -579,6 +579,59 @@ def signal_to_status(signal): return status +def signal_quality_to_status(percent): + """Modem signal-quality (0-100%) → colored status word. + + Thresholds chosen so the output vocabulary matches signal_to_status + above: a roughly linear -110..-50 dBm modem-quality mapping puts + 80% near -62 dBm (excellent), 60% near -74 dBm (good), 40% near -86 + dBm (poor). Below that is "bad". + """ + if percent is None: + return "" + try: + percent = float(percent) + except (TypeError, ValueError): + return "" + if percent >= 80: + return Decore.bright_green("excellent") + if percent >= 60: + return Decore.green("good") + if percent >= 40: + return Decore.yellow("poor") + return Decore.red("bad") + + +def modem_state_to_status(state): + """Color the modem state word: connected/registered green, failed + red, transient states yellow, everything else plain.""" + if not state: + return "" + if state in ("connected", "registered"): + return Decore.green(state) + if state == "failed": + return Decore.red(state) + if state in ("connecting", "enabling", "disabling", "disconnecting", + "enabled", "searching", "initializing", "locked"): + return Decore.yellow(state) + return state + + +def sim_lock_to_status(state): + """Color the SIM lock-state word.""" + if not state: + return "" + if state == "unlocked": + return Decore.green(state) + if state == "not-inserted": + return Decore.yellow(state) + if state == "permanently-blocked": + return Decore.red(state) + if state in ("pin-required", "puk-required"): + return Decore.yellow(state) + return state + + def datetime_now(): if UNIT_TEST: return datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) @@ -893,8 +946,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 +970,7 @@ def print(self, indent=0): # Truncate over-long names so they never spill into the VALUE column # (e.g. unmapped switch-PHY hwmon names derived from the DT path). - field = PadSensor.name - len(indent_str) + field = nw - len(indent_str) if len(display_name) >= field: display_name = display_name[:field - 2] + "…" row = f"{indent_str}{display_name:<{field}}" @@ -2368,6 +2422,222 @@ def sensor_sort_key(component): return natural_key(component.get("name", "")) +def _build_modem_table(modems): + """SimpleTable with one row per modem hardware component. Shared + between 'show modem' and the modem section of 'show hardware'.""" + table = SimpleTable([ + Column('NAME'), + Column('MANUFACTURER', flexible=True), + Column('MODEL', flexible=True), + Column('STATE'), + Column('NETWORK'), + Column('SIGNAL'), + ]) + 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 = modem_state_to_status(ms.get("state", "unknown")) + network = cell.get("network-type", "") + signal = signal_quality_to_status(ms.get("signal-quality")) + table.row(name, mfg, model, state, network, signal) + return table + + +def _build_sim_table(sims): + """SimpleTable with one row per SIM hardware component. Shared + between 'show modem' and the SIM section of 'show hardware'.""" + 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 = sim_lock_to_status(ss.get("state", "")) + operator = ss.get("operator-name", "") + table.row(name, slot, state, operator) + return table + + +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 = _build_modem_table(modems) if modems else None + sim_table = _build_sim_table(sims) if sims else None + + width = max(modem_table.width() if modem_table else 0, + sim_table.width() if sim_table else 0, + 62) + + if modem_table: + Decore.title("Cellular Modems", width) + modem_table.adjust_padding(width) + modem_table.print() + + if sim_table: + Decore.title("SIM Cards", width) + sim_table.adjust_padding(width) + sim_table.print() + + +def _modem_signal_str(status): + parts = [] + sq = status.get("signal-quality") + if sq is not None: + parts.append(f"Quality: {sq}%") + for key, label in (("signal-rssi", "RSSI"), ("signal-rsrp", "RSRP"), + ("signal-rsrq", "RSRQ"), ("signal-sinr", "SINR")): + if key in status: + parts.append(f"{label}: {status[key]}") + return " ".join(parts) + + +def show_modem_detail(json, ref): + modems = json.get("modem-list", []) + try: + idx = int(ref.removeprefix("modem")) + except ValueError: + idx = -1 + + modem = next((m for m in modems if m.get("index") == idx), None) + if modem is None: + print(f"Modem '{ref}' not found.") + return + + info = modem.get("info", {}) + status = modem.get("status", {}) + cell = status.get("cellular", {}) + sim_state = modem.get("sim-state", {}) + bearers = status.get("bearer", []) + location = status.get("location", {}) + + width = 62 + print(Decore.invert(f"{'MODEM: ' + ref:<{width}}")) + + Decore.title("Hardware Information", width) + for label, val in ( + ("Manufacturer", info.get("manufacturer", "")), + ("Model", info.get("model", "")), + ("Hardware Revision", info.get("hardware-revision", "")), + ("Firmware Version", info.get("firmware-version", "")), + ("Serial Number", info.get("serial-number", "")), + ("IMSI", info.get("imsi", "")), + ("ICCID", info.get("iccid", "")), + ): + if val: + print(f" {label:<20}: {val}") + phone = info.get("phone-number", []) + if phone: + print(f" {'Phone Number':<20}: {', '.join(phone)}") + carriers = info.get("supported-carrier", []) + if carriers: + print(f" {'Supported Carriers':<20}: {', '.join(carriers)}") + selected = status.get("selected-carrier", "") + if selected: + print(f" {'Selected Carrier':<20}: {selected}") + + Decore.title("Status", width) + state = status.get("state", "unknown") + print(f" {'State':<20}: {state}") + if state == "failed": + reason = status.get("state-failed-reason", "") + if reason: + print(f" {'Failed Reason':<20}: {reason}") + sig_str = _modem_signal_str(status) + if sig_str: + print(f" {'Signal':<20}: {sig_str}") + + if cell: + Decore.title("Cellular", width) + for label, key in ( + ("Registration", "registration-state"), + ("Operator", "operator-name"), + ("Operator ID", "operator-id"), + ("Network Type", "network-type"), + ("Service State", "service-state"), + ): + val = cell.get(key, "") + if val: + print(f" {label:<20}: {val}") + + if sim_state: + Decore.title("SIM Card", width) + for label, key in ( + ("Name", "name"), + ("Slot", "slot"), + ("Lock State", "state"), + ("Operator", "operator-name"), + ): + val = sim_state.get(key) + if val: + print(f" {label:<20}: {val}") + + if bearers: + Decore.title("Bearers", width) + for b in bearers: + iface = b.get("interface", "") + hdr = iface if iface else b.get("path", "") + connected = b.get("connected", False) + conn_str = "connected" if connected else "disconnected" + print(f" {hdr} ({conn_str})") + if not connected: + reason = b.get("connection-failed-reason", "") + if reason: + print(f" {'Error':<18}: {reason}") + v4 = b.get("ipv4-address") + v4pfx = b.get("ipv4-prefix", 0) + if v4 and v4pfx: + print(f" {'IPv4':<18}: {v4}/{v4pfx}") + v6 = b.get("ipv6-address") + v6pfx = b.get("ipv6-prefix", 0) + if v6 and v6pfx: + print(f" {'IPv6':<18}: {v6}/{v6pfx}") + rx = b.get("in-bytes", 0) + tx = b.get("out-bytes", 0) + if rx or tx: + print(f" {'Traffic':<18}: RX {rx} B TX {tx} B") + total_rx = b.get("total-in-bytes", 0) + total_tx = b.get("total-out-bytes", 0) + duration = b.get("total-duration", 0) + if total_rx or total_tx: + print(f" {'Total Traffic':<18}: RX {total_rx} B TX {total_tx} B ({duration} s)") + + if location: + lat = location.get("latitude", "") + lon = location.get("longitude", "") + alt = location.get("altitude", "") + cid = location.get("cid", "") + lac = location.get("lac", "") + mcc = location.get("mcc", "") + mnc = location.get("mnc", "") + if lat or cid: + Decore.title("Location", width) + if lat and lon: + pos = f"{lat}, {lon}" + if alt: + pos += f" Alt: {alt} m" + print(f" {'GPS':<20}: {pos}") + if cid: + cell_id = f"CID {cid}" + if lac: + cell_id += f" LAC {lac}" + if mcc and mnc: + cell_id += f" MCC {mcc} MNC {mnc}" + print(f" {'Cell ID':<20}: {cell_id}") + + def show_hardware(json): if not json.get("ietf-hardware:hardware"): print("Error, top level \"ietf-hardware:component\" missing") @@ -2380,8 +2650,18 @@ 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 = _build_modem_table(modems) if modems else None + sim_table = _build_sim_table(sims) if sims else None - 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}}")) @@ -2510,11 +2790,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)) @@ -2539,7 +2829,7 @@ def show_hardware(json): for child in sorted(children[parent_name], key=sensor_sort_key): sensor = Sensor(child) - sensor.print(indent=1) + sensor.print(indent=1, name_width=name_width) # Display standalone sensors (no parent), naturally sorted so port # temperatures read e1, e2, ... e28 rather than e1, e10, e11, ... @@ -2548,7 +2838,7 @@ def show_hardware(json): print() # Add blank line between modules and standalone for component in sorted(standalone, key=sensor_sort_key): sensor = Sensor(component) - sensor.print() + sensor.print(name_width=name_width) def resolve_container_network(network, all_ifaces): @@ -6318,6 +6608,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') @@ -6401,6 +6694,10 @@ def main(): show_container_detail(json_data, args.name) elif args.command == "show-hardware": show_hardware(json_data) + elif args.command == "show-modem": + show_modem(json_data) + elif args.command == "show-modem-detail": + show_modem_detail(json_data, args.name) elif args.command == "show-interfaces": show_interfaces(json_data, args.name) elif args.command == "show-lldp": diff --git a/src/statd/python/yanger/__main__.py b/src/statd/python/yanger/__main__.py index 8476b1d56..3347a7f0e 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -129,9 +129,6 @@ def main(): elif model == 'ieee1588-ptp-tt': from . import ieee1588_ptp yang_data = ieee1588_ptp.operational() - elif model == 'infix-modem': - from . import infix_modem - yang_data = infix_modem.operational() else: common.LOG.warning("Unsupported model %s", model) sys.exit(1) diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index b34be178f..c73f89847 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -924,6 +924,12 @@ def unique_names(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", {}) # Thermal zones first: the kernel mirrors each one as an hwmon @@ -941,7 +947,8 @@ def operational(): cpu_component(sensors) + sensors + wifi_radio_components() + - gps_receiver_components() + gps_receiver_components() + + modem_components() ), }, } diff --git a/src/statd/python/yanger/infix_modem.py b/src/statd/python/yanger/infix_modem.py index a2b0f6d4f..be5f5a85f 100644 --- a/src/statd/python/yanger/infix_modem.py +++ b/src/statd/python/yanger/infix_modem.py @@ -2,11 +2,91 @@ from .host import HOST +def _modem_to_hw_state(modem): + info = modem.get("info", {}) + status = modem.get("status", {}) + cellular = status.get("cellular", {}) + + state = {} + if info.get("manufacturer"): + state["manufacturer"] = info["manufacturer"] + if info.get("model"): + state["model"] = info["model"] + if info.get("firmware-version"): + state["firmware-version"] = info["firmware-version"] + if info.get("serial-number"): + state["serial-number"] = info["serial-number"] + if info.get("imsi"): + state["imsi"] = info["imsi"] + if info.get("iccid"): + state["iccid"] = info["iccid"] + if status.get("state"): + state["state"] = status["state"] + if "signal-quality" in status: + state["signal-quality"] = status["signal-quality"] + for sig in ("signal-rssi", "signal-rsrp", "signal-rsrq", "signal-sinr"): + if sig in status: + state[sig] = status[sig] + + if cellular: + cell = {} + if cellular.get("registration-state"): + cell["registration-state"] = cellular["registration-state"] + if cellular.get("operator-name"): + cell["operator-name"] = cellular["operator-name"] + if cellular.get("operator-id"): + cell["operator-id"] = cellular["operator-id"] + if cellular.get("network-type"): + cell["network-type"] = cellular["network-type"] + if cell: + state["cellular"] = cell + + return state + + +def _sim_to_hw_state(sim_raw): + state = {} + slot = sim_raw.get("slot") + if isinstance(slot, int): + state["slot"] = slot + if sim_raw.get("state"): + state["state"] = sim_raw["state"] + if sim_raw.get("operator-name"): + state["operator-name"] = sim_raw["operator-name"] + return state + + def operational(): modems = HOST.run_json(['/usr/libexec/modemd/modem-info'], []) + hw_components = [] + for modem in modems: + idx = modem.get("index", 0) + name = "modem%d" % idx + hw_state = _modem_to_hw_state(modem) + if hw_state: + hw_components.append({ + "name": name, + "class": "infix-hardware:modem", + "infix-hardware:modem-state": hw_state + }) + + sim_raw = modem.get("sim-state") + if sim_raw: + sim_name = sim_raw.get("name", "sim%d" % idx) + sim_hw_state = _sim_to_hw_state(sim_raw) + if sim_hw_state: + hw_components.append({ + "name": sim_name, + "class": "infix-hardware:sim", + "infix-hardware:sim-state": sim_hw_state + }) + + if not hw_components: + return {} + return { - "infix-modem:modems": { - "modem": [m for m in modems] + "ietf-hardware:hardware": { + "component": hw_components } } From ce1c42b5fcc51f9051ef484f8ba9431a84685fb3 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 10 May 2026 06:18:42 +0200 Subject: [PATCH 16/37] modemd: package Python scripts for compiled deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python scripts without a .py extension are never cached as bytecode by CPython — every invocation re-parses the source. Packaging as a wheel pre-compiles all modules to .pyc and installs thin import stubs as the executables, matching the approach used by bin/show and statd. Restructure the modemd Python scripts into a proper Python package (src/modemd/modemd/): each script becomes a module with a main() entry point. pyproject.toml declares 11 entry points; the Buildroot MODEMD_BUILD_PYTHON hook builds a wheel via PEP 517, then pyinstaller.py installs compiled stubs to /usr/libexec/modemd/ and pre-compiled .pyc modules to site-packages. /sbin/modemd is now a symlink to the compiled stub. Signed-off-by: Joachim Wiberg --- package/modemd/modemd.mk | 35 ++++++++++++------- src/modemd/{modemd => modemd/__init__.py} | 9 +++-- .../{modem-carrier => modemd/carrier.py} | 8 +++-- src/modemd/{modem-info => modemd/info.py} | 8 +++-- src/modemd/{modem-power => modemd/power.py} | 8 +++-- src/modemd/{modem-rpc => modemd/rpc.py} | 8 +++-- .../scan_networks.py} | 9 +++-- src/modemd/{sim-setup => modemd/sim_setup.py} | 8 +++-- src/modemd/{modem-sms => modemd/sms.py} | 8 +++-- src/modemd/{modem-udev => modemd/udev.py} | 9 +++-- .../update_firmware.py} | 9 +++-- src/modemd/{modem-ussd => modemd/ussd.py} | 9 +++-- src/modemd/pyproject.toml | 28 +++++++++++++++ 13 files changed, 111 insertions(+), 45 deletions(-) rename src/modemd/{modemd => modemd/__init__.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-carrier => modemd/carrier.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-info => modemd/info.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-power => modemd/power.py} (98%) mode change 100755 => 100644 rename src/modemd/{modem-rpc => modemd/rpc.py} (97%) mode change 100755 => 100644 rename src/modemd/{modem-scan-networks => modemd/scan_networks.py} (97%) mode change 100755 => 100644 rename src/modemd/{sim-setup => modemd/sim_setup.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-sms => modemd/sms.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-udev => modemd/udev.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-update-firmware => modemd/update_firmware.py} (99%) mode change 100755 => 100644 rename src/modemd/{modem-ussd => modemd/ussd.py} (97%) mode change 100755 => 100644 create mode 100644 src/modemd/pyproject.toml diff --git a/package/modemd/modemd.mk b/package/modemd/modemd.mk index 8b84dfacd..afe36c13e 100644 --- a/package/modemd/modemd.mk +++ b/package/modemd/modemd.mk @@ -10,13 +10,34 @@ MODEMD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/modemd MODEMD_LICENSE = BSD-3-Clause MODEMD_LICENSE_FILES = LICENSE MODEMD_REDISTRIBUTE = NO -MODEMD_DEPENDENCIES = modem-manager jansson python3 +MODEMD_DEPENDENCIES = modem-manager jansson python3 \ + host-python3 host-python-pypa-build host-python-installer \ + host-python-poetry-core define MODEMD_BUILD_CMDS $(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \ $(MODEMD_DIR)/modem-command.c -o $(MODEMD_DIR)/modem-command -ljansson endef +define MODEMD_BUILD_PYTHON + cd $(MODEMD_SITE) && \ + $(PKG_PYTHON_PEP517_ENV) $(HOST_DIR)/bin/python3 $(PKG_PYTHON_PEP517_BUILD_CMD) -o $(@D)/dist + mkdir -p $(TARGET_DIR)/usr/libexec/modemd + rm -f $(TARGET_DIR)/usr/libexec/modemd/modemd \ + $(TARGET_DIR)/usr/libexec/modemd/modem-* \ + $(TARGET_DIR)/usr/libexec/modemd/sim-setup + cd $(@D) && \ + $(HOST_DIR)/bin/python3 $(TOPDIR)/support/scripts/pyinstaller.py \ + dist/*.whl \ + --interpreter=/usr/bin/python3 \ + --script-kind=posix \ + --purelib=$(TARGET_DIR)/usr/lib/python$(PYTHON3_VERSION_MAJOR)/site-packages \ + --headers=$(TARGET_DIR)/usr/include/python$(PYTHON3_VERSION_MAJOR) \ + --scripts=$(TARGET_DIR)/usr/libexec/modemd \ + --data=$(TARGET_DIR) +endef +MODEMD_POST_INSTALL_TARGET_HOOKS += MODEMD_BUILD_PYTHON + define MODEMD_INSTALL_TARGET_CMDS mkdir -p $(TARGET_DIR)/usr/libexec/modemd mkdir -p $(TARGET_DIR)/lib/udev/rules.d @@ -27,18 +48,8 @@ define MODEMD_INSTALL_TARGET_CMDS install -m 644 $(MODEMD_DIR)/qmi-wwan-ids.rules $(TARGET_DIR)/lib/udev/rules.d/91-qmi-wwan-ids.rules install -m 644 $(MODEMD_DIR)/77-mm-dell-port-types.rules $(TARGET_DIR)/etc/udev/rules.d/77-mm-dell-port-types.rules install -D -m 644 $(MODEMD_DIR)/modemd.modules-load $(TARGET_DIR)/etc/modules-load.d/modemd.conf - install -m 755 $(MODEMD_DIR)/modem-udev $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modemd $(TARGET_DIR)/sbin/modemd install -m 755 $(MODEMD_DIR)/modem-command $(TARGET_DIR)/sbin/modem-command - install -m 755 $(MODEMD_DIR)/modem-info $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-rpc $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-sms $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-scan-networks $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-update-firmware $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-ussd $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-carrier $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/modem-power $(TARGET_DIR)/usr/libexec/modemd/ - install -m 755 $(MODEMD_DIR)/sim-setup $(TARGET_DIR)/usr/libexec/modemd/ + ln -sf /usr/libexec/modemd/modemd $(TARGET_DIR)/sbin/modemd endef $(eval $(generic-package)) diff --git a/src/modemd/modemd b/src/modemd/modemd/__init__.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modemd rename to src/modemd/modemd/__init__.py index 50f595b02..41f0732ab --- a/src/modemd/modemd +++ b/src/modemd/modemd/__init__.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import threading import subprocess import argparse @@ -1620,7 +1618,8 @@ def sighandler(signum, frame): fatal("Exiting on signal %d" % signum) -if __name__ == "__main__": +def main(): + global debug syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) if os.geteuid() != 0: @@ -1687,3 +1686,7 @@ def sighandler(signum, frame): [th.join() for th in threads] fatal("Abnormal exit") + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-carrier b/src/modemd/modemd/carrier.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-carrier rename to src/modemd/modemd/carrier.py index d5cc85072..d966978c9 --- a/src/modemd/modem-carrier +++ b/src/modemd/modemd/carrier.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import argparse import json @@ -272,7 +270,7 @@ def runlist(index, manf, model): sys.exit(0) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-carrier') parser.add_argument("-i", "--index", default=0, help="Modem index") parser.add_argument("-m", "--manf", default=0, help="Modem manufacturer") @@ -312,3 +310,7 @@ def runlist(index, manf, model): runget(index, manf, model) sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-info b/src/modemd/modemd/info.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-info rename to src/modemd/modemd/info.py index 65b713948..6de9b9584 --- a/src/modemd/modem-info +++ b/src/modemd/modemd/info.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import argparse import syslog @@ -531,7 +529,7 @@ def print_all(): print(data) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-info') parser.add_argument("-i", "--index", default=0, help="Modem index") args = parser.parse_args() @@ -545,3 +543,7 @@ def print_all(): print_modem(int(args.index)) else: print_all() + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-power b/src/modemd/modemd/power.py old mode 100755 new mode 100644 similarity index 98% rename from src/modemd/modem-power rename to src/modemd/modemd/power.py index 9bcb67a0e..3cc3e22e5 --- a/src/modemd/modem-power +++ b/src/modemd/modemd/power.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import argparse import json @@ -65,7 +63,7 @@ def reset(index, info): return True -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-power') parser.add_argument("-i", "--index", default=0, help="Modem index") args = parser.parse_args() @@ -84,3 +82,7 @@ def reset(index, info): fatal("Unable to power-cycle modem") sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-rpc b/src/modemd/modemd/rpc.py old mode 100755 new mode 100644 similarity index 97% rename from src/modemd/modem-rpc rename to src/modemd/modemd/rpc.py index ff9f86eb1..58707a9c1 --- a/src/modemd/modem-rpc +++ b/src/modemd/modemd/rpc.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import argparse import json import sys @@ -21,7 +19,7 @@ def sendrpc(index, rpc): os.unlink(path) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-rpc') parser.add_argument("-i", "--index", default=0, help="Modem index") parser.add_argument("-r", "--rpc", default=0, help="RPC command") @@ -48,3 +46,7 @@ def sendrpc(index, rpc): } sendrpc(index, rpc) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-scan-networks b/src/modemd/modemd/scan_networks.py old mode 100755 new mode 100644 similarity index 97% rename from src/modemd/modem-scan-networks rename to src/modemd/modemd/scan_networks.py index 288e7fa60..377e8826c --- a/src/modemd/modem-scan-networks +++ b/src/modemd/modemd/scan_networks.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import argparse import subprocess import json @@ -65,7 +63,8 @@ def scan(mpath): return True -if __name__ == "__main__": +def main(): + global index parser = argparse.ArgumentParser(prog='modem-scan-networks') parser.add_argument("-i", "--index", default=0, help="Modem index") args = parser.parse_args() @@ -77,3 +76,7 @@ def scan(mpath): print("Scanning networks on modem%d, please stand by ..." % index) if not scan(m["path"]): print("Operation failed.", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/sim-setup b/src/modemd/modemd/sim_setup.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/sim-setup rename to src/modemd/modemd/sim_setup.py index a19b4a51d..edf86e464 --- a/src/modemd/sim-setup +++ b/src/modemd/modemd/sim_setup.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import json import fcntl @@ -254,7 +252,7 @@ def read_setup(): return setup -if __name__ == "__main__": +def main(): setup = read_setup() if setup is None: sys.exit(0) @@ -263,3 +261,7 @@ def read_setup(): fatal("Unable set up SIMs") sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-sms b/src/modemd/modemd/sms.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-sms rename to src/modemd/modemd/sms.py index cf6c8b424..5735abada --- a/src/modemd/modem-sms +++ b/src/modemd/modemd/sms.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - from datetime import datetime import argparse import locale @@ -106,7 +104,7 @@ def listlocale(): return ", ".join(locales) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser(prog='modem-sms') parser.add_argument("-d", action="store_true", help="Delete SMS") parser.add_argument("-s", action="store_true", help="Send SMS") @@ -140,3 +138,7 @@ def listlocale(): delete(index) else: show(index) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-udev b/src/modemd/modemd/udev.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-udev rename to src/modemd/modemd/udev.py index 64d6b1e23..aaf6d3c29 --- a/src/modemd/modem-udev +++ b/src/modemd/modemd/udev.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import shutil import errno import time @@ -283,7 +281,8 @@ def apply(): return True -if __name__ == "__main__": +def main(): + global debug with open("/proc/cmdline", 'r') as fd: cmdline = fd.read() if "debug" in cmdline: @@ -314,3 +313,7 @@ def apply(): dbg("ended") sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-update-firmware b/src/modemd/modemd/update_firmware.py old mode 100755 new mode 100644 similarity index 99% rename from src/modemd/modem-update-firmware rename to src/modemd/modemd/update_firmware.py index b8675108e..d7eb46c42 --- a/src/modemd/modem-update-firmware +++ b/src/modemd/modemd/update_firmware.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import subprocess import argparse import urllib.parse @@ -347,7 +345,8 @@ def download(url, checksum): return True -if __name__ == "__main__": +def main(): + global debug, index syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) parser = argparse.ArgumentParser(prog='modem-firmware-update') @@ -387,3 +386,7 @@ def download(url, checksum): fatal("Firmware update failed") rmdir(tempdir) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modem-ussd b/src/modemd/modemd/ussd.py old mode 100755 new mode 100644 similarity index 97% rename from src/modemd/modem-ussd rename to src/modemd/modemd/ussd.py index e1b1e10ce..2940f96e8 --- a/src/modemd/modem-ussd +++ b/src/modemd/modemd/ussd.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - import argparse import subprocess import sys @@ -49,7 +47,8 @@ def ussd(index, code): return True -if __name__ == "__main__": +def main(): + global verbose parser = argparse.ArgumentParser(prog='modem-ussd') parser.add_argument("-i", "--index", default=0, help="Modem index") parser.add_argument("-c", "--code", default=None, help="USSD codec") @@ -70,3 +69,7 @@ def ussd(index, code): sys.exit(0) sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/pyproject.toml b/src/modemd/pyproject.toml new file mode 100644 index 000000000..3e221d54c --- /dev/null +++ b/src/modemd/pyproject.toml @@ -0,0 +1,28 @@ +[tool.poetry] +name = "modemd" +version = "1.0" +description = "Modem daemon and utilities" +license = "BSD-3-Clause" +packages = [ + { include = "modemd" } +] +authors = [ + "KernelKit developers" +] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +modemd = "modemd:main" +modem-info = "modemd.info:main" +modem-carrier = "modemd.carrier:main" +modem-rpc = "modemd.rpc:main" +modem-sms = "modemd.sms:main" +modem-scan-networks = "modemd.scan_networks:main" +modem-update-firmware = "modemd.update_firmware:main" +modem-ussd = "modemd.ussd:main" +modem-power = "modemd.power:main" +modem-udev = "modemd.udev:main" +sim-setup = "modemd.sim_setup:main" From b6f27b544bf04d9a8eeef66d7738308305938af2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 10 May 2026 07:48:08 +0200 Subject: [PATCH 17/37] modemd: add SIGHUP handler for config reload On SIGHUP, modemd stops all ModemThreads, re-runs sim-setup, then restarts the modem threads from fresh sysrepo config. The pidfile is touched with os.utime() once the reload is complete, satisfying Finit's default pidfile-based notify so confd can signal a reload and wait for it to finish before proceeding. This allows operator and APN changes to take effect without a full daemon restart. Also extract start_modem_threads() to remove the duplicated thread startup loop shared between initial startup and reload, and align sighandler() to use isinstance(th, ModemThread) consistently with the rest of the thread-management code. Signed-off-by: Joachim Wiberg --- src/modemd/modemd/__init__.py | 65 +++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py index 41f0732ab..a9a1bf7ac 100644 --- a/src/modemd/modemd/__init__.py +++ b/src/modemd/modemd/__init__.py @@ -14,10 +14,12 @@ import sys import ipaddress -rundir = "/run/modemd" -smsdir = "/var/sms" -debug = False +rundir = "/run/modemd" +smsdir = "/var/sms" +pidfile = "/run/modemd.pid" +debug = False threads = [] +reload_event = threading.Event() syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) @@ -1600,12 +1602,11 @@ def sighandler(signum, frame): for th in threads: th.stop() - # wait for modem threads timeout = 0 while timeout < 3: exited = True for th in threads: - if th.name.startswith("modem") and not th.exited: + if isinstance(th, ModemThread) and not th.exited: exited = False if exited: break @@ -1618,6 +1619,22 @@ def sighandler(signum, frame): fatal("Exiting on signal %d" % signum) +def start_modem_threads(cfgs): + started = [] + for cfg in cfgs: + try: + th = ModemThread(cfg) + th.start() + started.append(th) + except Exception as e: + err("Failed to start modem%d thread: %s" % (cfg["index"], e)) + return started + + +def sighup_handler(signum, frame): + reload_event.set() + + def main(): global debug syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) @@ -1642,8 +1659,10 @@ def main(): mkdir(rundir) mkdir(smsdir) + fwrite(pidfile, str(os.getpid()) + "\n") signal.signal(signal.SIGINT, sighandler) signal.signal(signal.SIGTERM, sighandler) + signal.signal(signal.SIGHUP, sighup_handler) try: th = RpcThread() @@ -1668,24 +1687,32 @@ def main(): if not runcmd(['/usr/libexec/modemd/sim-setup']): fatal("Unable to setup SIMs") - for cfg in modems: - th = None - try: - th = ModemThread(cfg) - th.start() - except Exception as e: - if debug: - print(e) - fatal("Modem %d thread caught an exception" % cfg["index"]) - if th: - threads.append(th) - + threads.extend(start_modem_threads(modems)) if len(threads) == 0: fatal("No modem threads are running") - [th.join() for th in threads] + while True: + if not reload_event.wait(timeout=60): + continue + reload_event.clear() + + info("Reloading configuration") + modem_threads = [th for th in threads if isinstance(th, ModemThread)] + for th in modem_threads: + th.stop() + for th in modem_threads: + th.join(timeout=5) + if th.is_alive(): + err("modem%d thread did not stop, orphaning" % th.index) + threads[:] = [th for th in threads if th not in modem_threads] + + runcmd(['/usr/libexec/modemd/sim-setup']) + + new_modems = load_config() + threads.extend(start_modem_threads(new_modems)) - fatal("Abnormal exit") + os.utime(pidfile, None) + info("Reload complete") if __name__ == "__main__": From b0f2aa0b70d285fc2a8463273c31b62076200060 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 26 Sep 2026 08:54:08 +0200 Subject: [PATCH 18/37] modem: expose location and last-change in operational state Cellular modem location data (GPS coordinates, 3GPP cell info) was CLI-only: 'show modem modem0' shelled out to modem-info, which queried mmcli on demand and rendered straight to the terminal. NETCONF and RESTCONF clients had no view into modem location, and there was no way to tell how stale any modem-state field was. Move both into the YANG operational tree so they are available on every northbound interface, not just the CLI. YANG (infix-hardware.yang, revision 2026-10-11): - modem-state/last-change (yang:date-and-time): freshness indicator for the modem-state subtree, bumped on each modemd poll cycle - modem-state/location-state container: source (gps|3gpp|...), latitude/longitude/altitude (decimal64 6/6/1 fraction-digits), cell-id/lac/tac/mcc/mnc, and its own last-change Runtime path: - modemd's check_location() now writes /run/modemd/modemN/location/data.json with the full location data plus an RFC3339 last-change. Atomic via tmp+rename. Walks the mmcli output once instead of twice. - check() touches /run/modemd/modemN/state.json with a fresh last-change after each successful state poll. - yanger/infix_modem.py reads both files via HOST.read_json and folds them into the modem-state operational subtree. It also populates hardware-revision, selected-carrier, phone-number and supported-carrier, which the model already had but nothing filled. - yanger/ietf_interfaces/modem.py (new): wwan interfaces are typed infix-if-type:modem and report infix-interfaces:wwan/bearer-state from modemd's bearer data. Disconnected bearers are matched to wwanN by modem index, which only holds for one bearer per modem. - cli_pretty show_modem_detail rewritten to consume the operational tree: modem-state from ietf-hardware, bearers from the wwan interfaces. New 'Last Update' lines for both top-level state and location. - bin/show/modem() consolidated: both 'show modem' and 'show modem REF' read the operational tree, no more direct modem-info subprocess call. Cleanup along the way: - Removed the legacy /run/modemd/modemN/location/{up,down,disabled, failed} marker file writes, they had no readers anywhere in the tree. In-memory self.location['state'] is kept purely as the edge-trigger for the 'Retrieved GPS location' log line. - Aligned now_rfc3339() output with yanger.common.YangDate (+00:00 form, not Z) so consumers see one consistent yang:date-and-time format regardless of producer. - Fixed multi-modem SIM scoping in show_modem_detail: now derives sim from modem instead of picking the first SIM globally. Signed-off-by: Joachim Wiberg --- src/bin/show/__init__.py | 35 ++- src/confd/yang/confd/infix-hardware.yang | 75 +++++- src/modemd/modemd/__init__.py | 121 ++++++++-- src/statd/python/cli_pretty/cli_pretty.py | 216 +++++++++++------- .../python/yanger/ietf_interfaces/link.py | 12 + .../python/yanger/ietf_interfaces/modem.py | 103 +++++++++ src/statd/python/yanger/infix_modem.py | 200 +++++++++++++--- 7 files changed, 616 insertions(+), 146 deletions(-) create mode 100644 src/statd/python/yanger/ietf_interfaces/modem.py diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index 1405221c1..5eeb4e5e0 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -148,27 +148,24 @@ def channel_survey(radio: str, passive: bool) -> None: def modem(args: List[str]) -> None: ref = args[0] if args else None + data = get_json("/ietf-hardware:hardware") + if not data: + print("No modem data available.") + return + + # Merge bearer-state from wwan interfaces (lives under ietf-interfaces). + # Quiet on miss — a system without wwan interfaces still shows modems. + iface_data = get_json("/ietf-interfaces:interfaces", quiet=True) + if iface_data: + data.update(iface_data) + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + if ref: - try: - result = subprocess.run(["/usr/libexec/modemd/modem-info"], - capture_output=True, text=True, check=True) - data = json.loads(result.stdout) if result.stdout.strip() else [] - except (subprocess.CalledProcessError, json.JSONDecodeError): - data = [] - - if RAW_OUTPUT: - print(json.dumps(data, indent=2)) - return - cli_pretty({"modem-list": data}, "show-modem-detail", ref) + cli_pretty(data, "show-modem-detail", ref) else: - data = get_json("/ietf-hardware:hardware") - if not data: - print("No modem data available.") - return - - if RAW_OUTPUT: - print(json.dumps(data, indent=2)) - return cli_pretty(data, "show-modem") diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 328c25579..8ba300ede 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -26,8 +26,9 @@ module infix-hardware { revision 2026-10-11 { description "Add cellular modem and SIM hardware components: class identities, modem and sim configuration, modem-state and - sim-state operational data, restart/reset/send-sms actions - and status-update notification."; + sim-state operational data including location and + last-change, restart/reset/send-sms actions and + status-update notification."; reference "internal"; } @@ -1256,6 +1257,76 @@ module infix-hardware { description "Active cellular radio access technology."; } } + + container location-state { + description + "Location data observed by the modem. Coordinates come from the + modem's GPS subsystem (or A-GPS); cell-id/lac/tac/mcc/mnc come + from 3GPP signalling. Source indicates which mechanism produced + the current fix. Empty when no source is enabled or no fix is + available."; + + leaf source { + type modem-location-source; + description "Source that produced the current fix."; + } + + leaf latitude { + type decimal64 { fraction-digits 6; } + units "degrees"; + description "Latitude in decimal degrees, negative = south."; + } + + leaf longitude { + type decimal64 { fraction-digits 6; } + units "degrees"; + description "Longitude in decimal degrees, negative = west."; + } + + leaf altitude { + type decimal64 { fraction-digits 1; } + units "meters"; + description "Altitude above mean sea level."; + } + + leaf cell-id { + type uint32; + description "Serving 3GPP cell identifier."; + } + + leaf lac { + type uint16; + description "Location Area Code (2G/3G)."; + } + + leaf tac { + type uint16; + description "Tracking Area Code (LTE/5G)."; + } + + leaf mcc { + type string { pattern '[0-9]{3}'; } + description "Mobile Country Code."; + } + + leaf mnc { + type string { pattern '[0-9]{2,3}'; } + description "Mobile Network Code."; + } + + leaf last-change { + type yang:date-and-time; + description "Timestamp when location-state was last refreshed."; + } + } + + leaf last-change { + type yang:date-and-time; + description + "Timestamp of the most recent change to any leaf in modem-state. + Consumers can use this to gauge data freshness independent of + individual leaf timestamps."; + } } /* diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py index a9a1bf7ac..a6eec56ab 100644 --- a/src/modemd/modemd/__init__.py +++ b/src/modemd/modemd/__init__.py @@ -1,6 +1,7 @@ import threading import subprocess import argparse +import datetime import hashlib import signal import syslog @@ -125,6 +126,32 @@ def fwritej(path, cont): return False +def now_rfc3339(): + """Return current UTC time as a yang:date-and-time string. + + Format matches yanger.common.YangDate (+00:00 offset, not Z) so that + consumers see identical timestamp formatting regardless of producer. + """ + return datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds") + + +def fwritej_atomic(path, cont): + """Write JSON to path via tmp+rename so readers never see a partial file.""" + tmp = path + ".tmp" + try: + with open(tmp, "w", opener=opener) as fd: + fd.write(json.dumps(cont)) + os.rename(tmp, path) + return True + except OSError: + try: + os.remove(tmp) + except OSError: + pass + return False + + def runcmdp(cmd1, cmd2, check=True): dbg("Running: %s | %s" % (" ".join(cmd1), " ".join(cmd2))) ret = None @@ -500,6 +527,11 @@ def init(self): self.sim = None self.failtime = None self.detected = False + # Fingerprint of the last state we wrote to state.json — used to + # detect transitions and only bump state-last-changed when admin/ + # oper/alarm state actually changes (RFC 4268 semantics). + self.std_fp = None + self.std_last_changed = None self.timer1 = 0 self.timer1 = 0 self.timer2 = 0 @@ -1207,7 +1239,6 @@ def prepare_location(self): rmrf(self.locdir) if not self.location["enabled"]: - fwrite("%s/disabled" % self.locdir, "1") return True for source in ["gps", "agps-msa", "agps-msb", "3gpp", "cdma"]: @@ -1236,7 +1267,6 @@ def prepare_location(self): if not runcmd(["mmcli", "-m", self.path] + args): self.err("Unable to %s location source" % action) if self.location["enabled"]: - fwrite("%s/failed" % self.locdir, "1") self.location["state"] = "failed" return False return True @@ -1411,22 +1441,49 @@ def check_location(self): output = runcmdj(["mmcli", "-J", "-m", self.path, "--location-get"]) - if output: - lat = output["modem"]["location"]["gps"]["latitude"] - lon = output["modem"]["location"]["gps"]["longitude"] - else: - lat = "--" - lon = "--" + loc = (output or {}).get("modem", {}).get("location", {}) + gps = loc.get("gps", {}) + gpp = loc.get("3gpp", {}) + + def _pick(d, key): + v = d.get(key) + return v if v not in (None, "--", "") else None + + data = {"last-change": now_rfc3339()} + + lat = _pick(gps, "latitude") + lon = _pick(gps, "longitude") + alt = _pick(gps, "altitude") + if lat is not None and lon is not None: + data["source"] = "gps" + data["latitude"] = float(lat) + data["longitude"] = float(lon) + if alt is not None: + data["altitude"] = float(alt) + + for src_key, dst_key in (("cid", "cell-id"), ("lac", "lac"), + ("tac", "tac"), ("mcc", "mcc"), + ("mnc", "mnc")): + v = _pick(gpp, src_key) + if v is None: + continue + if dst_key in ("cell-id", "lac", "tac"): + try: + data[dst_key] = int(v, 0) if isinstance(v, str) else int(v) + except (TypeError, ValueError): + continue + else: + data[dst_key] = str(v) + data.setdefault("source", "3gpp") + + fwritej_atomic("%s/data.json" % self.locdir, data) - if lat != "--" and lon != "--": + if "latitude" in data: if self.location["state"] != "up": - self.info("Retrieved GPS location [%s, %s]" % (lat, lon)) - rmf("%s/down" % self.locdir) - fwrite("%s/up" % self.locdir, "1") + self.info("Retrieved GPS location [%s, %s]" + % (data["latitude"], data["longitude"])) self.location["state"] = "up" else: - rmf("%s/up" % self.locdir) - fwrite("%s/down" % self.locdir, "1") self.location["state"] = "down" def save_sms(self, sms): @@ -1475,6 +1532,41 @@ def check_sms(self): elif sms["type"] == "received": self.save_sms(sms) + def touch_state(self): + """Refresh state.json with current poll time and transition timestamp. + + Two timestamps with different semantics live in state.json: + + last-change — when modemd last polled (freshness + indicator for modem-state). + state-last-changed — when admin/oper/alarm state last + actually changed (RFC 4268 / standard + ietf-hardware state container). + + The transition fingerprint is the modem state, state-failed-reason, + and whether signal-quality is currently below the weak-signal + threshold (latter drives the alarm-state 'warning' bit in yanger). + """ + now = now_rfc3339() + + mm_state = self.status.get("state") or "unknown" + failed_reason = self.status.get("state-failed-reason", "none") + sq = self.status.get("signal-quality") + try: + weak = isinstance(sq, (int, float)) and sq < 20 + except TypeError: + weak = False + fp = (mm_state, failed_reason, weak) + + if fp != self.std_fp: + self.std_fp = fp + self.std_last_changed = now + + data = {"last-change": now} + if self.std_last_changed: + data["state-last-changed"] = self.std_last_changed + fwritej_atomic("%s/state.json" % self.rundir, data) + def check(self): timeout = 10 if self.state == State.UP else 3 elapsed = time.time() - self.timer1 @@ -1483,6 +1575,7 @@ def check(self): if self.update(): self.check_state() self.timer1 = time.time() + self.touch_state() elapsed = time.time() - self.timer2 if elapsed > 30: diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index f0db96781..10be3e761 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2492,150 +2492,204 @@ def show_modem(json): sim_table.print() -def _modem_signal_str(status): +def _modem_signal_str(ms): parts = [] - sq = status.get("signal-quality") + sq = ms.get("signal-quality") if sq is not None: - parts.append(f"Quality: {sq}%") - for key, label in (("signal-rssi", "RSSI"), ("signal-rsrp", "RSRP"), - ("signal-rsrq", "RSRQ"), ("signal-sinr", "SINR")): - if key in status: - parts.append(f"{label}: {status[key]}") + recency = "" if ms.get("signal-quality-recent", True) else " (stale)" + word = signal_quality_to_status(sq) + head = f"Quality: {sq}% {word}" if word else f"Quality: {sq}%" + parts.append(f"{head}{recency}") + for key, label, unit in ( + ("signal-rssi", "RSSI", "dBm"), + ("signal-rsrp", "RSRP", "dBm"), + ("signal-rsrq", "RSRQ", "dB"), + ("signal-sinr", "SINR", "dB")): + if key in ms: + parts.append(f"{label}: {ms[key]} {unit}") return " ".join(parts) -def show_modem_detail(json, ref): - modems = json.get("modem-list", []) - try: - idx = int(ref.removeprefix("modem")) - except ValueError: - idx = -1 +def _bearers_for_modem(json, modem_name): + """Return [(ifname, bearer_state, wwan_dict), ...] for wwan interfaces + that reference 'modem_name', preserving sysrepo list order.""" + interfaces = get_json_data([], json, "ietf-interfaces:interfaces", + "interface") + out = [] + for iface in interfaces: + if iface.get("type") != "infix-if-type:modem": + continue + wwan = iface.get("infix-interfaces:wwan") or {} + if wwan.get("modem") != modem_name: + continue + bearer_state = wwan.get("bearer-state") or {} + out.append((iface.get("name", "?"), bearer_state, wwan)) + return out + - modem = next((m for m in modems if m.get("index") == idx), None) +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 - info = modem.get("info", {}) - status = modem.get("status", {}) - cell = status.get("cellular", {}) - sim_state = modem.get("sim-state", {}) - bearers = status.get("bearer", []) - location = status.get("location", {}) + ms = modem.get("infix-hardware:modem-state", {}) + cell = ms.get("cellular", {}) + location = ms.get("location-state", {}) + + sim_name = ref.replace("modem", "sim", 1) + sim = next((c for c in components + if c.get("class") == "infix-hardware:sim" + and c.get("name") == sim_name), None) + sim_state = sim.get("infix-hardware:sim-state", {}) if sim else {} + + bearers = _bearers_for_modem(json, ref) width = 62 print(Decore.invert(f"{'MODEM: ' + ref:<{width}}")) Decore.title("Hardware Information", width) for label, val in ( - ("Manufacturer", info.get("manufacturer", "")), - ("Model", info.get("model", "")), - ("Hardware Revision", info.get("hardware-revision", "")), - ("Firmware Version", info.get("firmware-version", "")), - ("Serial Number", info.get("serial-number", "")), - ("IMSI", info.get("imsi", "")), - ("ICCID", info.get("iccid", "")), + ("Manufacturer", ms.get("manufacturer", "")), + ("Model", ms.get("model", "")), + ("Hardware Revision", ms.get("hardware-revision", "")), + ("Firmware Version", ms.get("firmware-version", "")), + ("Serial Number", ms.get("serial-number", "")), + ("IMSI", ms.get("imsi", "")), + ("ICCID", ms.get("iccid", "")), ): if val: print(f" {label:<20}: {val}") - phone = info.get("phone-number", []) + phone = ms.get("phone-number", []) if phone: print(f" {'Phone Number':<20}: {', '.join(phone)}") - carriers = info.get("supported-carrier", []) + carriers = ms.get("supported-carrier", []) if carriers: print(f" {'Supported Carriers':<20}: {', '.join(carriers)}") - selected = status.get("selected-carrier", "") + selected = ms.get("selected-carrier", "") if selected: print(f" {'Selected Carrier':<20}: {selected}") Decore.title("Status", width) - state = status.get("state", "unknown") - print(f" {'State':<20}: {state}") + state = ms.get("state", "unknown") + print(f" {'State':<20}: {modem_state_to_status(state)}") if state == "failed": - reason = status.get("state-failed-reason", "") - if reason: - print(f" {'Failed Reason':<20}: {reason}") - sig_str = _modem_signal_str(status) + reason = ms.get("state-failed-reason") + if reason and reason != "none": + print(f" {'Failed Reason':<20}: {Decore.red(reason)}") + power = ms.get("power-state") + if power and power != "unknown": + print(f" {'Power State':<20}: {power}") + locks = ms.get("enabled-locks") or [] + if locks: + print(f" {'Locks':<20}: {', '.join(locks)}") + 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"), + ("Service State", "service-state"), ("Operator", "operator-name"), ("Operator ID", "operator-id"), ("Network Type", "network-type"), - ("Service State", "service-state"), ): val = cell.get(key, "") if val: print(f" {label:<20}: {val}") - if sim_state: - Decore.title("SIM Card", width) - for label, key in ( - ("Name", "name"), - ("Slot", "slot"), - ("Lock State", "state"), - ("Operator", "operator-name"), - ): - val = sim_state.get(key) - if val: - print(f" {label:<20}: {val}") + # SIM Card section is now always rendered (sim-state is always emitted + # by yanger). When the slot is empty, sim_state.state == 'not-inserted' + # tells Andrew's "where's my missing SIM" story directly. + Decore.title("SIM Card", width) + print(f" {'Name':<20}: {sim.get('name', '') if sim else sim_name}") + slot_val = sim_state.get("slot") + if slot_val is not None: + print(f" {'Slot':<20}: {slot_val}") + lock_state = sim_state.get("state") + if lock_state: + print(f" {'Lock State':<20}: {sim_lock_to_status(lock_state)}") + sim_op = sim_state.get("operator-name") + if sim_op: + print(f" {'Operator':<20}: {sim_op}") if bearers: Decore.title("Bearers", width) - for b in bearers: - iface = b.get("interface", "") - hdr = iface if iface else b.get("path", "") - connected = b.get("connected", False) - conn_str = "connected" if connected else "disconnected" - print(f" {hdr} ({conn_str})") + for ifname, bstate, _wwan in bearers: + connected = bstate.get("connected", False) + print(f" {ifname} ({'connected' if connected else 'disconnected'})") if not connected: - reason = b.get("connection-failed-reason", "") + reason = bstate.get("connection-failed-reason", "") if reason: print(f" {'Error':<18}: {reason}") - v4 = b.get("ipv4-address") - v4pfx = b.get("ipv4-prefix", 0) + v4 = bstate.get("ipv4-address") + v4pfx = bstate.get("ipv4-prefix-length", 0) if v4 and v4pfx: print(f" {'IPv4':<18}: {v4}/{v4pfx}") - v6 = b.get("ipv6-address") - v6pfx = b.get("ipv6-prefix", 0) + v6 = bstate.get("ipv6-address") + v6pfx = bstate.get("ipv6-prefix-length", 0) if v6 and v6pfx: print(f" {'IPv6':<18}: {v6}/{v6pfx}") - rx = b.get("in-bytes", 0) - tx = b.get("out-bytes", 0) - if rx or tx: - print(f" {'Traffic':<18}: RX {rx} B TX {tx} B") - total_rx = b.get("total-in-bytes", 0) - total_tx = b.get("total-out-bytes", 0) - duration = b.get("total-duration", 0) - if total_rx or total_tx: - print(f" {'Total Traffic':<18}: RX {total_rx} B TX {total_tx} B ({duration} s)") + rx = bstate.get("in-bytes", 0) + tx = bstate.get("out-bytes", 0) + try: + rxi = int(rx) + txi = int(tx) + if rxi or txi: + print(f" {'Traffic':<18}: RX {rxi} B TX {txi} B") + except (TypeError, ValueError): + pass + total_rx = bstate.get("total-in-bytes", 0) + total_tx = bstate.get("total-out-bytes", 0) + duration = bstate.get("total-duration", 0) + try: + trxi = int(total_rx) + ttxi = int(total_tx) + duri = int(duration) + if trxi or ttxi: + print(f" {'Total Traffic':<18}: " + f"RX {trxi} B TX {ttxi} B ({duri} s)") + except (TypeError, ValueError): + pass if location: - lat = location.get("latitude", "") - lon = location.get("longitude", "") - alt = location.get("altitude", "") - cid = location.get("cid", "") - lac = location.get("lac", "") - mcc = location.get("mcc", "") - mnc = location.get("mnc", "") - if lat or cid: - Decore.title("Location", width) - if lat and lon: + Decore.title("Location", width) + src = location.get("source") + if src: + print(f" {'Source':<20}: {src}") + lat = location.get("latitude") + lon = location.get("longitude") + alt = location.get("altitude") + if lat is not None and lon is not None: pos = f"{lat}, {lon}" - if alt: + if alt is not None: pos += f" Alt: {alt} m" print(f" {'GPS':<20}: {pos}") - if cid: + cid = location.get("cell-id") + if cid is not None: cell_id = f"CID {cid}" - if lac: + lac = location.get("lac") + tac = location.get("tac") + if lac is not None: cell_id += f" LAC {lac}" + if tac is not None: + cell_id += f" TAC {tac}" + mcc = location.get("mcc") + mnc = location.get("mnc") if mcc and mnc: cell_id += f" MCC {mcc} MNC {mnc}" print(f" {'Cell ID':<20}: {cell_id}") + loc_change = location.get("last-change") + if loc_change: + print(f" {'Last Update':<20}: {loc_change}") def show_hardware(json): diff --git a/src/statd/python/yanger/ietf_interfaces/link.py b/src/statd/python/yanger/ietf_interfaces/link.py index 9d1635fef..7dee49b0e 100644 --- a/src/statd/python/yanger/ietf_interfaces/link.py +++ b/src/statd/python/yanger/ietf_interfaces/link.py @@ -1,3 +1,5 @@ +import re + from ..host import HOST from . import common @@ -5,6 +7,7 @@ from . import ethernet from . import ip from . import lag +from . import modem from . import tun from . import veth from . import vlan @@ -12,6 +15,9 @@ from . import wireguard +_WWAN_RE = re.compile(r"^wwan\d+$") + + def statistics(iplink): statistics = {} @@ -54,6 +60,9 @@ def visible(name): def iplink2yang_type(iplink): ifname=iplink["ifname"] + if _WWAN_RE.match(ifname): + return "infix-if-type:modem" + match iplink["link_type"]: case "loopback": return "infix-if-type:loopback" @@ -223,6 +232,9 @@ def interface(iplink, ipaddr, systemjson=None): case "infix-if-type:wireguard": if wg := wireguard.wireguard(iplink): interface["infix-interfaces:wireguard"] = wg + case "infix-if-type:modem": + if wwan := modem.wwan(iplink["ifname"]): + interface["infix-interfaces:wwan"] = wwan match iplink2yang_lower(iplink): case "infix-interfaces:bridge-port": diff --git a/src/statd/python/yanger/ietf_interfaces/modem.py b/src/statd/python/yanger/ietf_interfaces/modem.py new file mode 100644 index 000000000..fc6fe9c63 --- /dev/null +++ b/src/statd/python/yanger/ietf_interfaces/modem.py @@ -0,0 +1,103 @@ +""" +Modem (wwan) interface operational state. + +Populates `infix-interfaces:wwan/bearer-state` from modemd's modem-info +JSON output. Yanger calls wwan(ifname) from link.interface() when an +interface is classified as 'infix-if-type:modem'. + +The mapping from wwan interface to bearer: + + - modem-info reports a list of bearers per modem. Each bearer dict + has an 'interface' field set to the kernel device name (e.g. + 'wwan0') when the bearer is connected; absent when disconnected. + - For a connected bearer we match by 'interface' directly. + - For disconnected bearers we fall back to modem index: bearers on + 'modem{N}' default to 'wwan{N}' when no better hint is available. + +That matches modemd's own naming convention for the single-bearer +case, which is the only case the codebase exercises today. Multi- +bearer setups will need a richer cross-reference (e.g. APN matching +against sysrepo config) — a follow-on once multi-bearer is in +serious use. +""" + +from ..host import HOST + + +# modem-info bearer key -> bearer-state YANG leaf. Keys absent from +# the source dict are simply not emitted. +_BEARER_KEY_MAP = { + "path": "path", + "connected": "connected", + "connection-failed-reason": "connection-failed-reason", + "interface": "interface", + "ipv4-address": "ipv4-address", + "ipv4-prefix": "ipv4-prefix-length", + "ipv6-address": "ipv6-address", + "ipv6-prefix": "ipv6-prefix-length", + "in-bytes": "in-bytes", + "out-bytes": "out-bytes", + "total-in-bytes": "total-in-bytes", + "total-out-bytes": "total-out-bytes", + "total-duration": "total-duration", +} + + +def _bearer_state(bearer): + state = {} + for src, dst in _BEARER_KEY_MAP.items(): + if src not in bearer: + continue + val = bearer[src] + if val in (None, "", "--"): + continue + if dst in ("ipv4-prefix-length", "ipv6-prefix-length"): + try: + val = int(val) + except (TypeError, ValueError): + continue + if val == 0: + continue + state[dst] = val + return state or None + + +def _find_bearer(modems, ifname): + """Return the bearer dict matching ifname, or None. + + Prefers an exact 'interface' match (connected case), falls back to + the bearer list of modem{N} when ifname is wwan{N}. + """ + for modem in modems: + bearers = (modem.get("status") or {}).get("bearer") or [] + for b in bearers: + if b.get("interface") == ifname: + return b + + # No connected match — fall back to wwan{N} → modem{N}. + if ifname.startswith("wwan"): + try: + idx = int(ifname[4:]) + except ValueError: + return None + for modem in modems: + if modem.get("index") == idx: + bearers = (modem.get("status") or {}).get("bearer") or [] + if bearers: + return bearers[0] + return None + + +def wwan(ifname): + modems = HOST.run_json(['/usr/libexec/modemd/modem-info'], []) + if not modems: + return None + + bearer = _find_bearer(modems, ifname) + if not bearer: + return None + + state = _bearer_state(bearer) + if not state: + return None + return {"bearer-state": state} diff --git a/src/statd/python/yanger/infix_modem.py b/src/statd/python/yanger/infix_modem.py index be5f5a85f..227b2c7f8 100644 --- a/src/statd/python/yanger/infix_modem.py +++ b/src/statd/python/yanger/infix_modem.py @@ -2,42 +2,146 @@ from .host import HOST +RUNDIR = "/run/modemd" + + +# YANG modem-state.state values that map to ietf-hardware oper-state +# 'enabled' (resource is partially or fully operable). Everything in +# the "failed" / "disabled" buckets maps to oper-state 'disabled'; +# everything else is reported as 'unknown'. +_MODEM_OPER_ENABLED = { + "enabling", "enabled", + "searching", "registered", + "connecting", "connected", + "disconnecting", +} +_MODEM_OPER_DISABLED = { + "failed", "disabled", "disabling", "locked", +} + +# state-failed-reason values that warrant an alarm-state 'major' bit +# (i.e. the operator can resolve them by intervention — insert SIM, +# enter PIN, …). +_SIM_CLASS_FAILED_REASONS = { + "sim-missing", "sim-error", "sim-wrong", "sim-busy", + "unlock-required", +} + +# A 'warning' alarm-state bit is raised when registered but signal +# quality drops below this percentage. Recoverable, not load-bearing. +_WEAK_SIGNAL_THRESHOLD = 20 + + +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 _last_change(idx): + """modem-state/last-change — freshness, written by modemd each poll.""" + data = HOST.read_json("%s/modem%d/state.json" % (RUNDIR, idx), default={}) + return data.get("last-change") if data else None + + +def _state_last_changed(idx): + """component/state/state-last-changed — RFC 4268, transition-driven. + + Written by modemd when admin/oper/alarm state actually changes. + """ + data = HOST.read_json("%s/modem%d/state.json" % (RUNDIR, idx), default={}) + return data.get("state-last-changed") if data else None + + +def _modem_oper_state(modem_state): + if not modem_state: + return "unknown" + if modem_state in _MODEM_OPER_ENABLED: + return "enabled" + if modem_state in _MODEM_OPER_DISABLED: + return "disabled" + return "unknown" + + +def _modem_alarm_state(modem_state, failed_reason, signal_quality): + bits = [] + if modem_state == "failed" and failed_reason in _SIM_CLASS_FAILED_REASONS: + bits.append("major") + elif modem_state == "failed": + bits.append("minor") + elif modem_state in ("registered", "connected") \ + and isinstance(signal_quality, int) \ + and signal_quality < _WEAK_SIGNAL_THRESHOLD: + bits.append("warning") + return " ".join(bits) if bits else None + + +def _sim_oper_state(sim_state): + if sim_state == "not-inserted": + return "disabled" + if sim_state in ("unlocked", "pin-required", "puk-required", + "permanently-blocked"): + return "enabled" + return "unknown" + + 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"] + + # Hardware information + for src_key in ("manufacturer", "model", "hardware-revision", + "firmware-version", "serial-number", "imsi", "iccid", + "selected-carrier"): + if info.get(src_key): + state[src_key] = info[src_key] + elif status.get(src_key): + state[src_key] = status[src_key] + + for leaflist_key in ("phone-number", "supported-carrier"): + values = info.get(leaflist_key) or [] + values = [v for v in values if v] + if values: + state[leaflist_key] = values + + # Status if status.get("state"): state["state"] = status["state"] + if status.get("state-failed-reason"): + state["state-failed-reason"] = status["state-failed-reason"] + if status.get("power-state"): + state["power-state"] = status["power-state"] + locks = status.get("enabled-locks") or [] + if locks: + state["enabled-locks"] = locks + if "signal-quality" in status: state["signal-quality"] = status["signal-quality"] + if "signal-quality-recent" in status: + state["signal-quality-recent"] = status["signal-quality-recent"] for sig in ("signal-rssi", "signal-rsrp", "signal-rsrq", "signal-sinr"): if sig in status: state[sig] = status[sig] + # Cellular 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"] + for key in ("registration-state", "service-state", + "operator-name", "operator-id", "network-type"): + v = cellular.get(key) + if v: + cell[key] = v if cell: state["cellular"] = cell @@ -63,24 +167,60 @@ def operational(): for modem in modems: idx = modem.get("index", 0) name = "modem%d" % idx + status = modem.get("status", {}) + modem_state = status.get("state") + failed_reason = status.get("state-failed-reason") + signal_quality = status.get("signal-quality") + hw_state = _modem_to_hw_state(modem) + + location_state = _location_state(idx) + if location_state: + hw_state["location-state"] = location_state + + last_change = _last_change(idx) + if last_change: + hw_state["last-change"] = last_change + + component = { + "name": name, + "class": "infix-hardware:modem", + } + + # Standard ietf-hardware state container — drives 'show hardware' + # and gives generic NETCONF clients a meaningful view of the + # modem without needing modem-specific YANG awareness. + std_state = { + "oper-state": _modem_oper_state(modem_state), + } + alarm = _modem_alarm_state(modem_state, failed_reason, signal_quality) + if alarm: + std_state["alarm-state"] = alarm + slc = _state_last_changed(idx) + if slc: + std_state["state-last-changed"] = slc + component["state"] = std_state + if hw_state: - hw_components.append({ - "name": name, - "class": "infix-hardware:modem", - "infix-hardware:modem-state": hw_state - }) + component["infix-hardware:modem-state"] = hw_state + hw_components.append(component) 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) + sim_component = { + "name": sim_name, + "class": "infix-hardware:sim", + "state": { + "oper-state": _sim_oper_state(sim_raw.get("state")), + }, + } + if slc: + sim_component["state"]["state-last-changed"] = slc if sim_hw_state: - hw_components.append({ - "name": sim_name, - "class": "infix-hardware:sim", - "infix-hardware:sim-state": sim_hw_state - }) + sim_component["infix-hardware:sim-state"] = sim_hw_state + hw_components.append(sim_component) if not hw_components: return {} From da4d86482266fe4d5b359a5d9a2e182014491036 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 10 May 2026 11:32:13 +0200 Subject: [PATCH 19/37] modem: route NMEA from cellular modems to gpsd Cellular modems (Quectel EM05/EM06/EM12, Sierra EM7565, ...) expose GPS data as NMEA on a dedicated USB serial interface. Until now we relied on ModemManager's --location-enable-gps-nmea, which makes MM hold the NMEA port and parse the stream itself. Useful for mmcli, but a dead end for the rest of the system: modem GPS was visible only via 'show modem' and could not act as an NTP fallback time source on devices without dedicated GPS hardware. Reroute the NMEA stream into the existing gpsd and chronyd pipeline so cellular modems behave like any other NMEA-over-USB GPS dongle. No changes to gpsd, chronyd, or the ietf-hardware:gps component; the user activates it the same way as for a dedicated receiver, by adding a 'gps' hardware component that references /dev/gpsN. udev (src/modemd/77-mm-modem-gps.rules): - For known vendor:product:interface tuples, set ID_MM_PORT_IGNORE so ModemManager keeps off the NMEA port, and add SYMLINK+="gps%n" so gpsd's existing udev hook picks up the device automatically. - Interface-number match uses ENV{ID_USB_INTERFACE_NUM} rather than ATTRS{bInterfaceNumber}: udev requires all ATTRS{} matches in a rule to share one parent, but idVendor/idProduct live on the USB device while bInterfaceNumber lives on the USB interface. ENV{} matching has no parent constraint and udev already populates ID_USB_INTERFACE_NUM from bInterfaceNumber. - Initial entries: Quectel EM05 (2c7c:0125), EM06-E (2c7c:0306), EM12-G (2c7c:0512), Sierra Wireless EM7565 (1199:9091). Easy to extend. modemd (modemd/__init__.py): - GPS_AT_COMMANDS table maps manufacturer -> (enable, disable) AT command pair. prepare_location() issues the vendor AT command via 'mmcli --command=AT+QGPS=1' (or AT+CGPS=1 for Sierra) instead of --location-enable-gps-{nmea,raw}. Vendor lookup is substring-based ('Quectel' matches both 'Quectel' and 'Quectel Incorporated') because ModemManager normalises differently across firmware revisions. For unrecognised vendors the high-level MM path is still used as a fallback, so nothing regresses for hardware not yet in the table. - The AT path runs based on the udev/vendor table, not on MM's --location-status capabilities. Setting ID_MM_PORT_IGNORE on the NMEA port removes 'gps' from MM's capability list even though the GPS hardware is still there, so a capability gate would mistakenly skip GPS for the very modems we're targeting. - _location_capabilities() filters MM-managed sources so a single unsupported flag (e.g. CDMA on an LTE-only modem) doesn't fail the whole batched call. Sources the modem doesn't support are silently skipped; a warning is logged only if the user's YANG config explicitly lists one. - Subprocess fan-out collapsed: agps-msa, agps-msb, 3gpp and cdma flags share a single batched mmcli invocation. prepare_location() drops from 5 subprocess calls to 1 (unknown vendor) or 2 (known vendor, AT + batched MM). - Stringly-typed if/elif source dispatch replaced with a dict-driven table (LOCATION_MM_FLAGS). - Two helpers eliminate the ['mmcli', '-m', self.path, ...] boilerplate at ~30 ModemThread call sites: self._mmcli(*args, check=True) # runcmd wrapper self._mmclij(*args) # runcmdj wrapper build (package/feature-modem/Config.in): - Select BR2_PACKAGE_MODEM_MANAGER_ATVIADBUS so ModemManager accepts raw AT commands over D-Bus without being started in --debug mode. Required by the AT path above; without it 'mmcli --command' is rejected with MM_CORE_ERROR_UNAUTHORIZED. doc (doc/modem.md): - New Location and GPS section: enabling location, the source options, and the antenna bias caveat. Signed-off-by: Joachim Wiberg --- doc/modem.md | 53 ++++++++ package/feature-modem/Config.in | 6 + package/modemd/modemd.mk | 1 + src/modemd/77-mm-modem-gps.rules | 41 ++++++ src/modemd/modemd/__init__.py | 219 ++++++++++++++++++++----------- 5 files changed, 241 insertions(+), 79 deletions(-) create mode 100644 src/modemd/77-mm-modem-gps.rules diff --git a/doc/modem.md b/doc/modem.md index 3b8418a1f..7aeac6c86 100644 --- a/doc/modem.md +++ b/doc/modem.md @@ -353,6 +353,59 @@ admin@example:/config/interface/wwan0/wwan/bearer/> leave > Enabling roaming may incur significant charges depending on your > mobile subscription. Check with your operator before enabling. +## Location and GPS + +Cellular modems with a GNSS chip can report their location into the +YANG operational tree, and feed the system GPS subsystem so the modem +shows up alongside any USB GPS receivers in `show hardware` and is +available as an NTP reference clock. See the +[Hardware User's Guide][hardware-gps] for the system-wide view of +GPS/GNSS receivers, and the [NTP User's Guide][ntp-gps] for using a +GPS source as a time reference. + +Location reporting is off by default. Enable it under the modem +hardware container: + +
admin@example:/config/> edit hardware component modem0 modem
+admin@example:/config/hardware/component/modem0/modem/> set location enabled
+admin@example:/config/hardware/component/modem0/modem/> set location source gps
+admin@example:/config/hardware/component/modem0/modem/> leave
+
+ +The `source` leaf-list takes one or more of: + +- `gps`: on-modem GNSS receiver. The modem joins the system GPS + subsystem and is reported alongside any USB GPS dongles in + `show hardware` +- `3gpp`: cell-tower based location reported by the network as + MCC/MNC/LAC/TAC/cell-id. No external antenna required, but + positional accuracy is coarse +- `agps-msa`, `agps-msb`: assisted-GPS variants that use the cellular + network to download almanac and ephemeris data, speeding up the + first fix + +When `gps` is enabled and the modem has a fix, `show modem modem0` +gains a Location section with current latitude, longitude, and +altitude. + +> [!NOTE] +> Not every modem has GNSS hardware. Many LTE-only modules only +> support `3gpp` location. If `show modem modem0` does not gain a +> Location section after several minutes outdoors with `gps` +> enabled, the firmware does not expose GNSS regardless of the YANG +> configuration. + +> [!IMPORTANT] +> Most cellular modems' GNSS antenna input is passive RF only. The +> host board, or an inline bias-T, must supply DC power on the coax +> to drive an active antenna's LNA. Check the carrier-board README +> for the GPS antenna wiring on your specific hardware. Cold-start +> time to first fix is typically several minutes with a clear view +> of the sky. + +[hardware-gps]: https://www.kernelkit.org/infix/latest/hardware/#gpsgnss-receivers +[ntp-gps]: https://www.kernelkit.org/infix/latest/ntp/#gps-reference-clock + ## Management Commands ### Restart Bearer diff --git a/package/feature-modem/Config.in b/package/feature-modem/Config.in index df69bad31..1e51a85b5 100644 --- a/package/feature-modem/Config.in +++ b/package/feature-modem/Config.in @@ -2,6 +2,7 @@ config BR2_PACKAGE_FEATURE_MODEM bool "Feature Modem" select BR2_PACKAGE_MODEMD select BR2_PACKAGE_MODEM_MANAGER + select BR2_PACKAGE_MODEM_MANAGER_ATVIADBUS select BR2_PACKAGE_MODEM_MANAGER_LIBMBIM select BR2_PACKAGE_MODEM_MANAGER_LIBQMI select BR2_PACKAGE_MODEM_MANAGER_LIBQRTR @@ -10,6 +11,11 @@ config BR2_PACKAGE_FEATURE_MODEM modemd management daemon. Includes drivers for common USB option modems and QMI/MBIM-based devices. + ATVIADBUS allows raw AT commands over D-Bus (used by modemd to + enable GPS NMEA output via vendor-specific AT commands such as + AT+QGPS=1) without needing to start ModemManager in --debug + mode. + config BR2_PACKAGE_FEATURE_MODEM_QUALCOMM bool "Qualcomm-based modems (QMI/QRTR/MHI)" depends on BR2_PACKAGE_FEATURE_MODEM diff --git a/package/modemd/modemd.mk b/package/modemd/modemd.mk index afe36c13e..5cde00e70 100644 --- a/package/modemd/modemd.mk +++ b/package/modemd/modemd.mk @@ -47,6 +47,7 @@ define MODEMD_INSTALL_TARGET_CMDS install -m 644 $(MODEMD_DIR)/modemd.rules $(TARGET_DIR)/lib/udev/rules.d/90-modemd.rules install -m 644 $(MODEMD_DIR)/qmi-wwan-ids.rules $(TARGET_DIR)/lib/udev/rules.d/91-qmi-wwan-ids.rules install -m 644 $(MODEMD_DIR)/77-mm-dell-port-types.rules $(TARGET_DIR)/etc/udev/rules.d/77-mm-dell-port-types.rules + install -m 644 $(MODEMD_DIR)/77-mm-modem-gps.rules $(TARGET_DIR)/etc/udev/rules.d/77-mm-modem-gps.rules install -D -m 644 $(MODEMD_DIR)/modemd.modules-load $(TARGET_DIR)/etc/modules-load.d/modemd.conf install -m 755 $(MODEMD_DIR)/modem-command $(TARGET_DIR)/sbin/modem-command ln -sf /usr/libexec/modemd/modemd $(TARGET_DIR)/sbin/modemd diff --git a/src/modemd/77-mm-modem-gps.rules b/src/modemd/77-mm-modem-gps.rules new file mode 100644 index 000000000..995e3b126 --- /dev/null +++ b/src/modemd/77-mm-modem-gps.rules @@ -0,0 +1,41 @@ +# Cellular modem NMEA port routing +# +# For modems whose GPS NMEA stream is available on a dedicated USB +# interface, expose that TTY as /dev/gpsN so the existing gpsd → chronyd +# pipeline can consume it like any other NMEA USB GPS receiver. Setting +# ID_MM_PORT_IGNORE=1 keeps ModemManager from claiming the NMEA port for +# itself; modemd issues the vendor-specific AT command to enable GPS +# output (see prepare_location() in modemd). +# +# When adding a new modem entry, find the NMEA interface number with +# 'mmcli -m N' or 'lsusb -v' and add a rule below. + +ACTION!="add|change|move|bind", GOTO="modem_gps_end" + +# Note: bInterfaceNumber lives on the USB interface device while +# idVendor/idProduct live on the USB device — udev's ATTRS{} requires +# all matches in one rule to share a parent, so we match the interface +# number via ENV{ID_USB_INTERFACE_NUM}, which udev populates from +# bInterfaceNumber and which has no same-parent constraint. + +# Quectel EM05 — NMEA on interface 1 +SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0125", \ + ENV{ID_USB_INTERFACE_NUM}=="01", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +# Quectel EM06-E / EM06-A — NMEA on interface 1 +SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0306", \ + ENV{ID_USB_INTERFACE_NUM}=="01", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +# Quectel EM12-G — NMEA on interface 1 +SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0512", \ + ENV{ID_USB_INTERFACE_NUM}=="01", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +# Sierra Wireless EM7565 — NMEA on interface 2 +SUBSYSTEM=="tty", ATTRS{idVendor}=="1199", ATTRS{idProduct}=="9091", \ + ENV{ID_USB_INTERFACE_NUM}=="02", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +LABEL="modem_gps_end" diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py index a6eec56ab..b2ed353ec 100644 --- a/src/modemd/modemd/__init__.py +++ b/src/modemd/modemd/__init__.py @@ -22,6 +22,27 @@ threads = [] reload_event = threading.Event() +# Vendor-specific AT commands to enable / disable raw NMEA output on the +# modem's dedicated GPS port. Used when the udev rules in +# 77-mm-modem-gps.rules have set ID_MM_PORT_IGNORE on that port — gpsd +# then reads NMEA directly from /dev/gpsN. For unrecognised vendors, +# prepare_location() falls back to ModemManager's high-level options +# (which keeps MM in charge of the NMEA port). Keys match the +# manufacturer string ModemManager reports verbatim. +GPS_AT_COMMANDS = { + "Quectel": ("AT+QGPS=1", "AT+QGPS=0"), + "Sierra Wireless": ("AT+CGPS=1", "AT+CGPS=0"), +} + +# ModemManager --location-{enable,disable}-* flag suffixes per source. +# 'gps' is handled separately because known vendors take the raw AT path. +LOCATION_MM_FLAGS = { + "agps-msa": ("agps-msa",), + "agps-msb": ("agps-msb",), + "3gpp": ("3gpp",), + "cdma": ("cdma-bs",), +} + syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) class Trigger(enum.Enum): @@ -515,6 +536,12 @@ def err(self, msg): def fatal(self, msg): fatal("%s%s" % (self.prefix, msg)) + def _mmcli(self, *args, check=True): + return runcmd(["mmcli", "-m", self.path, *args], check=check) + + def _mmclij(self, *args): + return runcmdj(["mmcli", "-J", "-m", self.path, *args]) + def init(self): self.timeout = 1 self.path = None @@ -628,12 +655,10 @@ def reset(self): self.pulldown() wait = 0 - if runcmd(["mmcli", "-m", self.path, - "--reset"]): + if self._mmcli("--reset"): self.info("Performed a normal reset") wait = 5 - elif runcmd(["mmcli", "-m", self.path, - "--factory-reset=000000"]): + elif self._mmcli("--factory-reset=000000"): self.info("Performed a factory reset") wait = 10 else: @@ -667,15 +692,14 @@ def get_bearers(self): if not self.update(): return bearers for path in self.status.get("bearers", []): - output = runcmdj(["mmcli", "-J", "-m", self.path, "-b", path]) + output = self._mmclij("-b", path) if output and "bearer" in output: bearers.append(output["bearer"]) return bearers def get_profiles(self): profiles = [] - output = runcmdj(["mmcli", "-J", "-m", self.path, - "--3gpp-profile-manager-list"]) + output = self._mmclij("--3gpp-profile-manager-list") if not output: return profiles for entry in output["modem"]["3gpp"]["profile-manager"]["list"]: @@ -695,7 +719,7 @@ def get_conn_failure(self, path): def update(self): if not self.path: return False - output = runcmdj(["mmcli", "-J", "-m", self.path]) + output = self._mmclij() if output: output = output.get("modem", {}) output = output.get("generic", None) @@ -883,14 +907,14 @@ def linkup(self, iface): def poweron(self): if self.status.get("power-state", "") == "off": self.info("Powering on") - if not runcmd(["mmcli", "-m", self.path, "--set-power-state-on"]): + if not self._mmcli("--set-power-state-on"): self.err("Unable to power on") return False return True def disable(self): self.info("Disabling") - if not runcmd(["mmcli", "-m", self.path, "--disable"]): + if not self._mmcli("--disable"): self.err("Unable to disable") return False else: @@ -903,14 +927,13 @@ def enable(self): if not self.path: return False if state == "unknown" or state == "disabled": - if not runcmd(["mmcli", "-m", self.path, "--enable"]): + if not self._mmcli("--enable"): self.err("Unable to enable") return False return True def unlock(self): - cmd = ["mmcli", "-m", self.path, "--sim=%s" % - self.status.get("sim", "0")] + args = ["--sim=%s" % self.status.get("sim", "0")] if self.status.get("unlock-required", "") == "sim-pin": self.info("Unlocking with PIN") @@ -918,20 +941,20 @@ def unlock(self): if not pin: self.err("No PIN defined") return False - cmd.append("--pin=%s" % pin) + args.append("--pin=%s" % pin) elif self.status.get("unlock-required", "") == "sim-puk": self.info("Unlocking with PUK") puk = self.cfg.get("puk") if not puk: self.err("No PUK defined") return False - cmd.append("--puk=%s" % puk) + args.append("--puk=%s" % puk) else: self.err("Unsupported lock") return False self.disable() - if not runcmd(cmd): + if not self._mmcli(*args): self.err("Unable to unlock") return False else: @@ -1087,9 +1110,8 @@ def prepare_profile_bearers(self, bearers): for p in self.get_profiles(): if p["profile-id"] != "1": self.dbg("Deleting profile %s" % p["profile-id"]) - if not runcmd(["mmcli", "-m", self.path, - "--3gpp-profile-manager-delete=%s" % - p["profile-id"]]): + if not self._mmcli("--3gpp-profile-manager-delete=%s" % + p["profile-id"]): self.err("Unable to delete profile") return False @@ -1103,20 +1125,17 @@ def prepare_profile_bearers(self, bearers): self.err("Max. number of bearers reached") break if bearer["pid"] != "1": - if not runcmd(["mmcli", "-m", self.path, - "--3gpp-profile-manager-set="]): + if not self._mmcli("--3gpp-profile-manager-set="): self.err("Unable to create profile") return False args = self.get_profile_args(bearer) - if not runcmd(["mmcli", "-m", self.path, - "--3gpp-profile-manager-set=%s" % args]): + if not self._mmcli("--3gpp-profile-manager-set=%s" % args): self.err("Unable to configure profile") return False args = self.get_bearer_args(bearer) - if not runcmd(["mmcli", "-m", self.path, - "--create-bearer=%s" % args]): + if not self._mmcli("--create-bearer=%s" % args): self.err("Unable to create bearer") return False @@ -1130,8 +1149,7 @@ def prepare_multiplex_bearers(self, bearers): for bearer in bearers: args = self.get_bearer_args(bearer) - if not runcmd(["mmcli", "-m", self.path, - "--create-bearer=multiplex=required,%s" % args]): + if not self._mmcli("--create-bearer=multiplex=required,%s" % args): self.err("Unable to create bearer") return False self.info("Created multiplex bearer for '%s'" % bearer["apn"]) @@ -1139,9 +1157,7 @@ def prepare_multiplex_bearers(self, bearers): return True def prepare_default_bearer(self, bearer): - if not runcmd(["mmcli", "-m", self.path, - "--create-bearer=%s" % - self.get_bearer_args(bearer)]): + if not self._mmcli("--create-bearer=%s" % self.get_bearer_args(bearer)): self.err("Unable to create bearer") return False @@ -1155,8 +1171,7 @@ def prepare_bearers(self): # wipe existing bearers for bearer in self.get_bearers(): self.dbg("Deleting bearer %s" % bearer["dbus-path"]) - if not runcmd(["mmcli", "-m", self.path, - "--delete-bearer=%s" % bearer["dbus-path"]]): + if not self._mmcli("--delete-bearer=%s" % bearer["dbus-path"]): self.err("Unable to delete bearer") return False @@ -1164,9 +1179,8 @@ def prepare_bearers(self): initial = self.bearers.get("initial") if initial: self.info("Setting initial bearer '%s'" % initial["apn"]) - if not runcmd(["mmcli", "-m", self.path, - "--3gpp-set-initial-eps-bearer-settings=%s" % - self.get_bearer_args(initial)]): + if not self._mmcli("--3gpp-set-initial-eps-bearer-settings=%s" % + self.get_bearer_args(initial)): self.err("Unable to set initial bearer") # configure dialup bearers @@ -1189,8 +1203,7 @@ def prepare_bands(self): if len(bands) == 0 or "any" in bands: return True - if not runcmd(["mmcli", "-m", self.path, - "--set-current-bands=%s" % "|".join(bands)]): + if not self._mmcli("--set-current-bands=%s" % "|".join(bands)): self.err("Unable to set band") return False return True @@ -1207,7 +1220,7 @@ def prepare_modes(self): allow = [] if pref == "none" and len(allow) == 0: - runcmd(["mmcli", "-m", self.path, "--set-allowed-modes=any"], check=False) + self._mmcli("--set-allowed-modes=any", check=False) return True mode = "allowed: %s; preferred: %s" % (", ".join(allow), pref) @@ -1227,12 +1240,47 @@ def prepare_modes(self): self.info("Setting mode '%s'" % mode) - cmd = ["mmcli", "-m", self.path] + args = [] if len(allow) > 0: - cmd.append("--set-allowed-modes=%s" % "|".join(allow)) + args.append("--set-allowed-modes=%s" % "|".join(allow)) if pref != "none": - cmd.append("--set-preferred-mode=%s" % pref) - return runcmd(cmd) + args.append("--set-preferred-mode=%s" % pref) + return self._mmcli(*args) + + def _gps_at_command(self, enable): + """Vendor-specific AT command to toggle GPS NMEA, or None. + + Match by substring so 'Quectel' picks up 'Quectel Incorporated' too — + ModemManager normalises differently across firmware revisions. + """ + for vendor, cmds in GPS_AT_COMMANDS.items(): + if vendor in self.manf: + return cmds[0] if enable else cmds[1] + return None + + def _location_capabilities(self): + """YANG source names this modem actually supports. + + Returns None if the capability query itself fails. Otherwise a + set drawn from {gps, agps-msa, agps-msb, 3gpp, cdma}. Asking + ModemManager to enable an unsupported source fails the whole + batched mmcli call, so prepare_location() filters against this. + """ + output = self._mmclij("--location-status") + if not output: + return None + caps = output.get("modem", {}).get("location", {}).get("capabilities", "") + if isinstance(caps, str): + caps = [c.strip() for c in caps.split(",") if c.strip()] + mapping = { + "3gpp-lac-ci": "3gpp", + "gps-raw": "gps", + "gps-nmea": "gps", + "agps-msa": "agps-msa", + "agps-msb": "agps-msb", + "cdma-bs": "cdma", + } + return {mapping[c] for c in caps if c in mapping} def prepare_location(self): self.info("Preparing location") @@ -1241,34 +1289,53 @@ def prepare_location(self): if not self.location["enabled"]: return True - for source in ["gps", "agps-msa", "agps-msb", "3gpp", "cdma"]: - if self.location["enabled"] and source in self.location["sources"]: + sources = self.location["sources"] + + # GPS via vendor AT command (known vendor) or via MM (fallback). + # The AT path doesn't consult MM's --location-status, since + # marking the NMEA port ID_MM_PORT_IGNORE removes 'gps' from MM's + # capability list even though the hardware is still there. + at = self._gps_at_command("gps" in sources) + if at is not None: + if not self._mmcli("--command=%s" % at): + self.err("Unable to send AT command '%s'" % at) + self.location["state"] = "failed" + return False + gps_flags = () + else: + gps_flags = ("gps-nmea", "gps-raw") + + # Capability filter for the remaining (MM-managed) sources so + # CDMA on an LTE-only modem doesn't fail the whole batched call. + caps = self._location_capabilities() + if caps is None: + self.err("Unable to query modem location capabilities") + self.location["state"] = "failed" + return False + + flag_map = {**LOCATION_MM_FLAGS, "gps": gps_flags} + args = [] + for source, flags in flag_map.items(): + if source == "gps" and at is not None: + continue # AT command already handled GPS + if source not in caps: + if source in sources: + self.err("Modem does not support location source" + " '%s', skipping" % source) + continue + enabled = source in sources + if enabled: self.info("Enabling location source '%s'" % source) - action = "enable" else: self.dbg("Disabling location source '%s'" % source) - action = "disable" - - args = [] - if source == "gps": - args = ["--location-%s-gps-nmea" % action, - "--location-%s-gps-raw" % action] - elif source == "agps-msa": - args = ["--location-%s-agps-msa" % action] - elif source == "agps-msb": - args = ["--location-%s-agps-msb" % action] - elif source == "3gpp": - args = ["--location-%s-3gpp" % action] - elif source == "cdma": - args = ["--location-%s-cdma-bs" % action] - else: - continue + action = "enable" if enabled else "disable" + for flag in flags: + args.append("--location-%s-%s" % (action, flag)) - if not runcmd(["mmcli", "-m", self.path] + args): - self.err("Unable to %s location source" % action) - if self.location["enabled"]: - self.location["state"] = "failed" - return False + if args and not self._mmcli(*args): + self.err("Unable to configure location sources") + self.location["state"] = "failed" + return False return True def prepare(self): @@ -1296,10 +1363,7 @@ def prepare(self): def connect(self): for bearer in self.status.get("bearers", []): self.info("Connecting %s" % bearer) - if not runcmd(["mmcli", "-m", self.path, - "--connect", - "--bearer=%s" % bearer - ]): + if not self._mmcli("--connect", "--bearer=%s" % bearer): self.connfail += 1 self.err("Unable to connect (%d failures)" % self.connfail) self.err("Reason: %s" % self.get_conn_failure(bearer)) @@ -1346,8 +1410,8 @@ def pulldown(self): if bearer["status"]["connected"] == "yes": self.info("Disconnecting %s" % bearer["dbus-path"]) - runcmd(["mmcli", "-m", self.path, - "--disconnect", "--bearer=%s" % bearer["dbus-path"]]) + self._mmcli("--disconnect", + "--bearer=%s" % bearer["dbus-path"]) for iface in self.ifaces: self.linkdown(iface) @@ -1439,8 +1503,7 @@ def check_location(self): if not self.location["enabled"]: return - output = runcmdj(["mmcli", "-J", "-m", self.path, - "--location-get"]) + output = self._mmclij("--location-get") loc = (output or {}).get("modem", {}).get("location", {}) gps = loc.get("gps", {}) gpp = loc.get("3gpp", {}) @@ -1506,8 +1569,7 @@ def save_sms(self, sms): storage = payload["properties"]["storage"].upper() if storage != "--": - if not runcmd(["mmcli", "-m", self.path, - "--messaging-delete-sms=%s" % sms["path"]]): + if not self._mmcli("--messaging-delete-sms=%s" % sms["path"]): self.err("Unable to delete SMS from %s storage" % storage) else: self.info("Deleted SMS from %s storage" % storage) @@ -1522,12 +1584,11 @@ def check_sms(self): for sms in list_sms(self.path): if sms["type"] == "notsent": self.dbg("Sending sms %s" % sms) - runcmd(["mmcli", "-m", self.path, "-s", sms["path"], "--send"]) + self._mmcli("-s", sms["path"], "--send") elif sms["type"] == "sent": self.notif("SMS-SENT") - runcmd(["mmcli", "-m", self.path, - "--messaging-delete-sms=%s" % sms["path"]]) + self._mmcli("--messaging-delete-sms=%s" % sms["path"]) elif sms["type"] == "received": self.save_sms(sms) From 992d530964ca2d6625681991bbf92998afeffbff Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 7 Jun 2026 18:13:41 +0200 Subject: [PATCH 20/37] modemd: stop retrying mmcli --reset when the modem rejects it Some cellular modems (notably Quectel EM05) don't expose a RESET capability over MBIM/QMI. Both 'mmcli --reset' and 'mmcli --factory-reset' return Unsupported immediately, the AT command is never forwarded to the firmware, and there's nothing modemd can do over the management bus to recover. Until now modemd's failed-state retry kept calling --reset every 30 s for the rest of uptime, producing pure log noise: modemd: [modem0] Resetting after being 30s in state failed modemd: [modem0] Resetting ModemManager: [modem0] failed requesting modem reset: Unsupported ModemManager: [modem0] failed requesting modem factory reset: Unsupported modemd: [modem0] Unable to reset ... and the same five lines again 30 s later, indefinitely. Mitigate this by caching the Unsupported state per modem per session. Signed-off-by: Joachim Wiberg --- src/modemd/modemd/__init__.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py index b2ed353ec..b8826342f 100644 --- a/src/modemd/modemd/__init__.py +++ b/src/modemd/modemd/__init__.py @@ -516,6 +516,13 @@ def __init__(self, cfg): mkdir(self.statedir) mkdir(self.locdir) + # Some modems (e.g. Quectel EM05) reject both --reset and + # --factory-reset. Cache the first such observation so we don't + # retry every 30 s for the rest of the thread's lifetime. Recovery + # from a hardware-stuck state on these modems requires a physical + # power cycle anyway — see MODEM.md. + self._reset_unsupported = False + self.init() def stop(self): @@ -649,6 +656,12 @@ def power(self): self.init() def reset(self): + if self._reset_unsupported: + # Cached from a previous attempt: skip silently. The state + # machine will keep polling; once the user power-cycles the + # hardware modemd will re-detect via init(). + return + self.info("Resetting") self.notif("RESET") @@ -662,7 +675,10 @@ def reset(self): self.info("Performed a factory reset") wait = 10 else: - self.err("Unable to reset") + self.err("Modem rejects --reset and --factory-reset; " + "recovery requires a physical power cycle. " + "Suppressing further reset attempts.") + self._reset_unsupported = True if wait > 0: self.info("Waiting %ds" % wait) @@ -1446,7 +1462,7 @@ def check_state(self): # state failed if self.state == State.FAILED: elapsed = time.time() - self.failtime - if elapsed > 30: + if elapsed > 30 and not self._reset_unsupported: self.err("Resetting after being 30s in state failed") self.reset() @@ -1465,7 +1481,7 @@ def check_state(self): if self.state != "connecting": if self.connect(): self.set_state(State.CONNECTING) - elif self.connfail > 5: + elif self.connfail > 5 and not self._reset_unsupported: self.err("Resetting after 5 connection attempts") self.reset() From 551f64a3676ac3f5418f274ac811576bfea17efd Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 7 Jun 2026 18:36:50 +0200 Subject: [PATCH 21/37] modemd: handle preferred-mode set without allowed-mode Setting just preferred-mode in YANG (no allowed-mode leaf-list) built an invalid mode string with an empty allowed: Mode 'allowed: ; preferred: 4g' is not supported The empty allowed: can never match anything in the modem's supported-modes list, so the user got a cryptic 'is not supported' even when their preferred mode was, in principle, supported. When only preferred-mode is configured, walk the modem's supported-modes list for an entry that already has that preferred, and reuse its allowed set. If no supported entry has that preferred mode, fail with a message that lists every combination the modem does support, easier to diagnose than the silent 'empty allowed' case. Signed-off-by: Joachim Wiberg --- src/modemd/modemd/__init__.py | 78 ++++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py index b8826342f..fe301622c 100644 --- a/src/modemd/modemd/__init__.py +++ b/src/modemd/modemd/__init__.py @@ -523,6 +523,12 @@ def __init__(self, cfg): # power cycle anyway — see MODEM.md. self._reset_unsupported = False + # Signature of the last (allow, pref, supported-modes) tuple that + # prepare_modes rejected. Repeats are silent until the user changes + # the config (SIGHUP recycles the thread, clearing this) or the + # modem reports a different supported-modes set. + self._modes_failed_sig = None + self.init() def stop(self): @@ -1213,20 +1219,17 @@ def prepare_bearers(self): 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 + self.info("Preparing bands") 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" @@ -1235,33 +1238,75 @@ def prepare_modes(self): if "any" in allow: allow = [] + supported_modes = self.status.get("supported-modes", []) + + # Same (config, modem-supports) inputs we already rejected — stay + # silent so the 3 s prepare-retry loop doesn't spam syslog with + # the same error. Cleared on success below, on SIGHUP (thread + # rebuild), or whenever the modem reports a different supported + # set. frozenset so leaf-list reorderings don't defeat the cache. + sig = (frozenset(allow), pref, frozenset(supported_modes)) + if self._modes_failed_sig == sig: + return False + + self.info("Preparing modes") + if pref == "none" and len(allow) == 0: + # User didn't pin anything; let the modem choose. self._mmcli("--set-allowed-modes=any", check=False) + self._modes_failed_sig = None return True + # If only preferred-mode is set (no allowed-mode), derive allowed + # from supported combinations that have this preferred. Skipping + # this would build an invalid 'allowed: ; preferred: X' string + # that never matches. Pick the widest supported allow-set so the + # user gets maximum fallback (e.g. 2g/3g/4g over 4g-only when both + # have 'preferred: 4g'). + if len(allow) == 0: + best = None + for m in supported_modes: + if ("preferred: %s" % pref) not in m: + continue + head = m.split("allowed: ", 1) + if len(head) != 2: + continue + allowed_part = head[1].split(";", 1)[0].strip() + candidate = [t.strip() for t in allowed_part.split(",") + if t.strip()] + if best is None or len(candidate) > len(best): + best = candidate + if not best: + supported = "; ".join(supported_modes) or "(none reported)" + self.err("Modem does not support preferred mode '%s'. " + "Supported: %s" % (pref, supported)) + self._modes_failed_sig = sig + return False + allow = best + 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) + if mode not in supported_modes: + supported = "; ".join(supported_modes) or "(none reported)" + self.err("Mode '%s' is not supported. Supported: %s" % + (mode, supported)) + self._modes_failed_sig = sig return False current = self.status.get("current-modes", "") if current == mode: self.info("Mode '%s' already set" % mode) + self._modes_failed_sig = None return True self.info("Setting mode '%s'" % mode) - args = [] - if len(allow) > 0: - args.append("--set-allowed-modes=%s" % "|".join(allow)) + args = ["--set-allowed-modes=%s" % "|".join(allow)] if pref != "none": args.append("--set-preferred-mode=%s" % pref) - return self._mmcli(*args) + ok = self._mmcli(*args) + if ok: + self._modes_failed_sig = None + return ok def _gps_at_command(self, enable): """Vendor-specific AT command to toggle GPS NMEA, or None. @@ -1363,8 +1408,9 @@ def prepare(self): if not self.prepare_bands(): self.err("Unable to prepare bands") return False + # prepare_modes logs a specific error on first failure and goes + # quiet on cached repeats; don't add a generic line on top. if not self.prepare_modes(): - self.err("Unable to prepare mode") return False if not self.prepare_bearers(): self.err("Unable to prepare bearers") From 5f4ba6e05fd07e89c6b83812c8a32ca27818924f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 29 Jul 2026 09:39:13 +0200 Subject: [PATCH 22/37] modem: anchor hardware components to USB ports Nothing ties the modem0 component to a physical device: the kernel names wwan interfaces first-come-first-served and ModemManager numbers modems by discovery order. With two modems, or one slow to boot, the configuration can end up applied to the wrong interface. Anchor the component to its USB port using the standard ietf-hardware uri leaf-list, previously deviated out: uri "usb:///sys/bus/usb/devices/3-1.2.1" The generated factory config anchors detected modems automatically. On systems with a device-specific factory config the user finds the URI with the new 'show hardware modem0' detail view, which also lists probed but unconfigured modems, and pastes it into the component configuration. An anchored component is authoritative: the data interface is named after the configured interface as it appears, so enumeration order never decides names, and ModemManager only gets the devices confd has tagged for it. When every unlocked modem is anchored, ModemManager runs with an allowlist-only filter policy and never probes unconfigured devices. Components without a uri keep the old discovery-based behavior. Modem services are now driven from the aggregate state of all modem components instead of the last one changed. The probe-timeout contract is unchanged: hardware must be present within the timeout, otherwise the dummy placeholder holds the name and a reboot is required, same recovery model as WiFi radios. Limitations: - anchored components must be named modemN - the uri leaf-list becomes writable on all component classes; schemes other than usb: are accepted and ignored - the URI is validated at apply time, not by the YANG model The same anchoring pattern fits USB GPS receivers, and PCIe wwan-core modems can later reuse the anchor with true link creation on top. Signed-off-by: Joachim Wiberg --- .../rootfs/usr/libexec/infix/init.d/00-probe | 51 ++- doc/modem.md | 45 ++- .../etc/finit.d/available/modem-manager.conf | 2 +- src/bin/show/__init__.py | 9 +- src/confd/bin/gen-hardware | 15 +- src/confd/src/core.c | 3 +- src/confd/src/hardware.c | 352 +++++++++++++++--- src/confd/src/if-modem.c | 9 +- src/confd/yang/confd/infix-hardware.yang | 31 +- src/klish-plugin-infix/xml/infix.xml | 13 +- src/modemd/modemd/__init__.py | 99 ++++- src/modemd/modemd/info.py | 71 +++- src/modemd/modemd/udev.py | 36 +- src/statd/python/cli_pretty/cli_pretty.py | 50 +++ src/statd/python/yanger/infix_modem.py | 57 +++ 15 files changed, 707 insertions(+), 136 deletions(-) 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 456910392..230e1f4b3 100755 --- a/board/common/rootfs/usr/libexec/infix/init.d/00-probe +++ b/board/common/rootfs/usr/libexec/infix/init.d/00-probe @@ -620,48 +620,45 @@ def probe_ptp_capabilities(out): out.setdefault("interfaces", {}).update(ifaces) -def _usb_root_from_sysfs(dev_path): +def _usb_device_from_sysfs(dev_path): + """Deepest ancestor that is a USB device directory (has idVendor), + i.e. the port-level node like .../3-1.2/3-1.2.1 — not the bus root. + Port-level paths keep two modems on the same bus apart and match + the /sys/bus/usb/devices/ shortcut users can inspect.""" 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 + if os.path.isfile(os.path.join(p, "idVendor")): + return p p = os.path.dirname(p) - return "", "" + return None 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() + """One entry per physical modem; devpath is the USB device (port) + directory, deduplicating the cdc-wdm and ttyUSB ports it exposes. + The uri is the copy-paste form users put in the hardware component + configuration to anchor a component to this port.""" + seen = 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: + usb_dev = _usb_device_from_sysfs(dev_path) + if usb_dev is None or usb_dev in seen: return - seen_roots.add(usb_root) - vid, pid = _read_usb_vid_pid(dev_path) + seen.add(usb_dev) + try: + vid = open(os.path.join(usb_dev, "idVendor")).read().strip() + pid = open(os.path.join(usb_dev, "idProduct")).read().strip() + except OSError: + vid, pid = "", "" + port = os.path.basename(usb_dev) modems.append({ "index": idx, "name": "modem%d" % idx, - "devpath": usb_root, + "devpath": usb_dev, + "uri": "usb:///sys/bus/usb/devices/%s" % port, "vid": vid, "pid": pid, }) diff --git a/doc/modem.md b/doc/modem.md index 7aeac6c86..3696e018b 100644 --- a/doc/modem.md +++ b/doc/modem.md @@ -138,13 +138,36 @@ 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`). -The `admin-state` must be set explicitly. There is no implicit default, +Otherwise, add them manually. Start by finding the modem's USB +attachment point. Detected modems are always visible in the hardware +detail view, even before they are configured: + +
admin@example:/> show hardware modem0
+COMPONENT: modem0                                             
+  Name                : modem0
+  Class               : modem
+  USB ID              : 2c7c:0125
+  URI                 : usb:///sys/bus/usb/devices/3-1.2.1
+  Oper State          : unknown
+
+See 'show modem modem0' for cellular status.
+
+ +Copy the `URI` value into the component configuration. It anchors the +component to the physical USB port, so the data interface always gets +the name you configured, no matter in which order the hardware +enumerates. Anchoring also acts as a lock: once every modem component +is anchored, a modem plugged into any other port is detected and shown, +but stays unmanaged until it too is configured. + +The class is inferred from the component name (`modemN` → +`infix-hardware:modem`, `simN` → `infix-hardware:sim`). The +`admin-state` must be set explicitly. There is no implicit default, and without `unlocked` the cellular subsystem stays inactive:
admin@example:/> configure
 admin@example:/config/> edit hardware component modem0
+admin@example:/config/hardware/component/modem0/> set uri usb:///sys/bus/usb/devices/3-1.2.1
 admin@example:/config/hardware/component/modem0/> set state admin-state unlocked
 admin@example:/config/hardware/component/modem0/> end
 admin@example:/config/hardware/> edit component sim0
@@ -180,16 +203,16 @@ admin@example:/config/hardware/component/modem0/modem/> leave
 With `probe-timeout` at its default of 30, the system waits up to 30
 seconds for the wwan interface to appear before proceeding.  For most
 modems it is 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
+within the timeout, a dummy placeholder interface is created so the
+rest of the configuration can be applied, and a reboot is required
+once the hardware is available, same as for WiFi radios.  Set
 `probe-timeout 0` to disable waiting entirely.
 
 > [!NOTE]
 > Some Quectel modules (e.g. EM05) re-enumerate the USB device several
 > times during a cold-boot firmware-init sequence that takes around 50
-> to 60 seconds.  Bump `probe-timeout` to 90 for those, otherwise the
-> dummy placeholder fires before the modem settles and the real
-> interface ends up with a different name (`wwan1` instead of `wwan0`).
+> to 60 seconds.  Bump `probe-timeout` to 90 for those, so the
+> interface is in place before the configuration is applied.
 
 ### 3. Configure the Bearer (APN)
 
@@ -478,10 +501,8 @@ is required; the modem only needs to be registered on the network:
 
 - The modem was not enumerated by the kernel before the system
   applied the configuration
-- Create the modem hardware configuration container (see Step 2),
-  which enables the default 30-second probe-timeout so the system
-  waits for the wwan interface before falling back to a dummy
-  placeholder
+- Increase `probe-timeout` (see Slow USB Modems above) to cover the
+  modem's boot time, then reboot with the modem in place
 
 **High latency or poor signal**
 
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
index 8c060e2df..2d1128fde 100644
--- 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
@@ -1 +1 @@
-service [2345789] ModemManager -- ModemManager daemon
+service [2345789] env:-/etc/default/modem-manager ModemManager $MODEM_MANAGER_OPTS -- ModemManager daemon
diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py
index 5eeb4e5e0..9c3eb272d 100755
--- a/src/bin/show/__init__.py
+++ b/src/bin/show/__init__.py
@@ -95,10 +95,15 @@ def hardware(args: List[str]) -> None:
 
     if args:
         hw = data.get("ietf-hardware:hardware", {})
-        hw["component"] = [c for c in hw.get("component", []) if c.get("name") == args[0]]
-        if not hw["component"]:
+        comps = [c for c in hw.get("component", []) if c.get("name") == args[0]]
+        if not comps:
             print(f"No hardware component named {args[0]}.")
             return
+        # A modem gets the detail view, with the URI to anchor it by
+        if not RAW_OUTPUT and comps[0].get("class") == "infix-hardware:modem":
+            cli_pretty(data, "show-hardware-detail", args[0])
+            return
+        hw["component"] = comps
 
     if RAW_OUTPUT:
         print(json.dumps(data, indent=2))
diff --git a/src/confd/bin/gen-hardware b/src/confd/bin/gen-hardware
index aacbff002..bcab908c6 100755
--- a/src/confd/bin/gen-hardware
+++ b/src/confd/bin/gen-hardware
@@ -68,10 +68,23 @@ EOF
 gen_modem()
 {
     modem="$1"
+
+    # Anchor the component to the probed USB port so the system can
+    # bind this component name to the physical device deterministically.
+    uri=$(jq -r --arg m "$modem" \
+	  '.["modem"][] | select(.name == $m) | .uri // empty' \
+	  /run/system.json 2>/dev/null)
+
+    uri_line=""
+    if [ -n "$uri" ]; then
+	uri_line="
+    \"uri\": [ \"$uri\" ],"
+    fi
+
     cat < "3-1.2.1".  Other URI
+ * schemes (CLEI URNs, ...) are ignored.  Returns NULL when the
+ * component carries no usb: URI.
+ */
+static const char *modem_usb_port(struct lyd_node *cif, char *buf, size_t len)
+{
+	struct lyd_node *node;
+
+	LYX_LIST_FOR_EACH(lyd_child(cif), node, "uri") {
+		const char *uri = lyd_get_value(node);
+		char tmp[256];
+		char *port;
+		size_t n;
+
+		if (!uri || strncmp(uri, "usb:", 4))
+			continue;
+
+		strlcpy(tmp, uri, sizeof(tmp));
+		n = strlen(tmp);
+		while (n && tmp[n - 1] == '/')
+			tmp[--n] = 0;
+
+		port = strrchr(tmp, '/');
+		port = port ? port + 1 : tmp + strlen("usb:");
+		if (!port[0] || strspn(port, "0123456789.-") != strlen(port)) {
+			ERROR("Malformed usb URI '%s', expected .../", uri);
+			continue;
+		}
+
+		strlcpy(buf, port, len);
+		return buf;
+	}
+
+	return NULL;
+}
+
+/*
+ * True when the diff touches a wwan interface: the generated udev
+ * rule embeds the bound interface name, so it must be regenerated on
+ * interface changes too, not only on hardware-component changes.
+ */
+static int modem_wwan_diff(struct lyd_node *diff)
+{
+	struct lyd_node *ifaces, *iface;
+
+	ifaces = lydx_get_descendant(diff, "interfaces", "interface", NULL);
+	LYX_LIST_FOR_EACH(ifaces, iface, "interface") {
+		if (lydx_get_child(iface, "wwan"))
+			return 1;
+	}
+
+	return 0;
+}
+
+/*
+ * First interface bound to this modem component via the wwan/modem
+ * leafref, i.e. the name the kernel netdev should carry.
+ */
+static const char *modem_iface_name(struct lyd_node *config, const char *modem)
+{
+	struct lyd_node *ifaces, *iface;
+
+	ifaces = lydx_get_descendant(config, "interfaces", "interface", NULL);
+	LYX_LIST_FOR_EACH(ifaces, iface, "interface") {
+		struct lyd_node *wwan = lydx_get_child(iface, "wwan");
+		const char *ref = wwan ? lydx_get_cattr(wwan, "modem") : NULL;
+
+		if (ref && !strcmp(ref, modem))
+			return lydx_get_cattr(iface, "name");
+	}
+
+	return NULL;
+}
+
+/*
+ * First KERNEL=="" token from a previously generated rules file,
+ * so ports of removed components can be re-triggered to drop their
+ * stale ID_MM_DEVICE_PROCESS tag from the udev database.
+ */
+static int modem_rule_port(const char *path, char *buf, size_t len)
+{
+	char line[256];
+	FILE *fp;
+
+	fp = fopen(path, "r");
+	if (!fp)
+		return -1;
+
+	while (fgets(line, sizeof(line), fp)) {
+		char *ptr = strstr(line, "KERNEL==\"");
+		char *end;
+
+		if (!ptr)
+			continue;
+		ptr += strlen("KERNEL==\"");
+		end = strchr(ptr, '"');
+		if (!end)
+			continue;
+		*end = 0;
+		strlcpy(buf, ptr, len);
+		fclose(fp);
+		return 0;
+	}
+
+	fclose(fp);
+	return -1;
+}
+
+/*
+ * udevadm trigger treats a path argument as parent-match: the device
+ * and all its children (net, tty, usbmisc) are re-triggered in one
+ * call.  It bails without triggering anything if the path cannot be
+ * opened, hence the existence guard for absent hardware.
+ */
+static void modem_trigger_port(const char *port)
+{
+	if (!fexistf("/sys/bus/usb/devices/%s", port))
+		return;
+
+	systemf("udevadm trigger -c add /sys/bus/usb/devices/%s 2>/dev/null", port);
+}
+
+static int modem_port_find(char (*ports)[64], int num, const char *port)
+{
+	int p;
+
+	for (p = 0; p < num; p++) {
+		if (!strcmp(ports[p], port))
+			return p;
+	}
+
+	return -1;
+}
+
+/*
+ * Regenerate udev rules and the ModemManager environment from the full
+ * configuration, then hand tagged devices over to ModemManager.
+ *
+ * Components anchored with a usb: URI get a rules file that (a) tags
+ * the USB device so ModemManager may probe it and (b) names the data
+ * netdev, at add time, after the interface configured for the modem.
+ * A netdev that appears after config apply (dummy placeholder in the
+ * way, or brought up already) keeps its kernel name until reboot,
+ * same recovery model as WiFi radios.  When every unlocked modem is
+ * anchored, ModemManager is switched to ALLOWLIST-ONLY filter policy
+ * so it never touches unconfigured devices.  Unanchored (legacy)
+ * configurations keep ModemManager's default probe behavior.
+ */
+static int modem_gen_rules(struct lyd_node *config)
+{
+	char trig[MODEM_MAX * 2][64];
+	char used[MODEM_MAX][64];
+	int num_trig = 0, num_used = 0;
+	int unlocked = 0, unanchored = 0;
+	struct lyd_node *cifs, *comp;
+	glob_t gl = { 0 };
+	size_t i;
+	int p;
+	FILE *fp;
+
+	/* Stale rules from removed/renamed components: collect their
+	 * ports for re-trigger, then drop the files. */
+	if (glob(MODEM_RULES_GLOB, 0, NULL, &gl) == 0) {
+		for (i = 0; i < gl.gl_pathc; i++) {
+			if (num_trig < MODEM_MAX &&
+			    !modem_rule_port(gl.gl_pathv[i], trig[num_trig],
+					     sizeof(trig[0])))
+				num_trig++;
+			unlink(gl.gl_pathv[i]);
+		}
+	}
+	globfree(&gl);
+
+	cifs = lydx_get_descendant(config, "hardware", "component", NULL);
+	LYX_LIST_FOR_EACH(cifs, comp, "component") {
+		const char *cls = lydx_get_cattr(comp, "class");
+		const char *name, *admin_state, *ifname;
+		struct lyd_node *state;
+		char port[64];
+
+		if (!cls || strcmp(cls, "infix-hardware:modem"))
+			continue;
+
+		state = lydx_get_child(comp, "state");
+		admin_state = lydx_get_cattr(state, "admin-state");
+		if (!admin_state || strcmp(admin_state, "unlocked"))
+			continue;
+
+		name = lydx_get_cattr(comp, "name");
+
+		if (!modem_usb_port(comp, port, sizeof(port))) {
+			unlocked++;
+			unanchored++;
+			continue;
+		}
+
+		/* modemd derives its state paths from the modemN name */
+		if (fnmatch("modem+([0-9])", name, FNM_EXTMATCH)) {
+			ERROR("Modem %s: anchored components must be named modemN, ignoring", name);
+			continue;
+		}
+
+		if (modem_port_find(used, num_used, port) >= 0) {
+			ERROR("Modem %s: USB port %s already claimed, ignoring", name, port);
+			continue;
+		}
+		if (num_used < MODEM_MAX)
+			strlcpy(used[num_used++], port, sizeof(used[0]));
+		if (num_trig < MODEM_MAX * 2 &&
+		    modem_port_find(trig, num_trig, port) < 0)
+			strlcpy(trig[num_trig++], port, sizeof(trig[0]));
+
+		unlocked++;
+
+		if (!fexistf("/sys/bus/usb/devices/%s", port))
+			NOTE("Modem %s: no device at USB port %s (yet)", name, port);
+
+		fp = fopenf("w", MODEM_RULES, name);
+		if (!fp) {
+			ERRNO("Failed creating udev rules for %s", name);
+			continue;
+		}
+
+		fprintf(fp, "# Generated by confd, do not edit.\n");
+		fprintf(fp, "SUBSYSTEM==\"usb\", KERNEL==\"%s\", ENV{ID_MM_DEVICE_PROCESS}=\"1\"\n",
+			port);
+
+		/* Name via a variable: eudev's rule generator renames any new
+		 * interface holding a literal NAME= value, our placeholder too */
+		ifname = modem_iface_name(config, name);
+		if (ifname)
+			fprintf(fp, "ACTION==\"add\", SUBSYSTEM==\"net\", KERNELS==\"%s\", "
+				"ENV{INFIX_MODEM_IFNAME}=\"%s\", NAME=\"$env{INFIX_MODEM_IFNAME}\"\n",
+				port, ifname);
+		fclose(fp);
+	}
+
+	if (writesf(unlocked && !unanchored
+		    ? "# Generated by confd, do not edit.\n"
+		      "MODEM_MANAGER_OPTS=\"--filter-policy=allowlist-only\""
+		    : "# Generated by confd, do not edit.\n"
+		      "MODEM_MANAGER_OPTS=\"\"",
+		    "w", MODEM_MANAGER_ENV))
+		ERRNO("Failed writing " MODEM_MANAGER_ENV);
+
+	systemf("udevadm control --reload");
+
+	/* Re-run rules for current and removed ports alike: tags must be
+	 * in the udev db before ModemManager starts, and stale tags must
+	 * be dropped before it does. */
+	for (p = 0; p < num_trig; p++)
+		modem_trigger_port(trig[p]);
+
+	return unlocked;
+}
+
 int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff,
 		    sr_event_t event, struct confd *confd)
 {
@@ -1404,9 +1666,17 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
 	int country_changed = 0;
 	int rc = SR_ERR_OK;
 	int gps_changed = 0;
+	int modem_changed = 0;
+	int modem_rules_only = 0;
 
-	if (!lydx_find_xpathf(diff, XPATH_BASE_))
-		return SR_ERR_OK;
+	if (!lydx_find_xpathf(diff, XPATH_BASE_)) {
+		/* The generated udev rule embeds the bound interface
+		 * name; regenerate rules (but leave services alone) when
+		 * a wwan interface changes. */
+		if (!modem_wwan_diff(diff))
+			return SR_ERR_OK;
+		modem_rules_only = 1;
+	}
 
 	/*
 	 * The country code is one setting for every radio.  Apply the
@@ -1435,13 +1705,17 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
 		/* Get the current config node for this component */
 		cif = lydx_get_xpathf(config, "/hardware/component[name='%s']", name);
 		if (!cif) {
-			/* Component deleted, lock its USB port */
+			/* Component deleted: lock its USB port, modem services
+			 * and udev rules are regenerated from the remaining
+			 * config below. */
 			class = lydx_get_cattr(dif, "class");
 			if (event == SR_EV_DONE && class && !strcmp(class, "infix-hardware:usb") &&
 			    usb_authorize(confd->root, name, 0)) {
 				rc = SR_ERR_INTERNAL;
 				goto err;
 			}
+			if (class && !strcmp(class, "infix-hardware:modem"))
+				modem_changed = 1;
 			continue;
 		}
 
@@ -1463,45 +1737,9 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
 				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 (admin_state && !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");
-			}
+			/* Handled from full config after the loop; the
+			 * services and udev rules span all modems. */
+			modem_changed = 1;
 		} else if (!strcmp(class, "infix-hardware:wifi")) {
 			struct lyd_node **wifi_iface_list = NULL;
 			struct lyd_node *interfaces_config;
@@ -1608,6 +1846,35 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
 		}
 	}
 
+	/*
+	 * Modem components: regenerate udev anchor rules and the
+	 * ModemManager environment from the full config, then drive the
+	 * services.  modemd owns the bearer lifecycle and cleanly
+	 * disconnects before ModemManager goes down, hence both services
+	 * are driven together, from the aggregate state of all modems.
+	 * On wwan-interface-only changes the rules are refreshed but the
+	 * services are left alone; modemd is signaled by the interface
+	 * dagger script.
+	 */
+	if ((modem_changed || modem_rules_only) && event == SR_EV_DONE) {
+		int unlocked = modem_gen_rules(config);
+
+		if (modem_changed) {
+			if (unlocked > 0) {
+				NOTE("Modem(s) enabled, starting modemd");
+				finit_enable("modem-manager");
+				finit_enable("modemd");
+				systemf("initctl -bfqn restart modem-manager");
+				systemf("initctl -bfqn restart modemd");
+			} else {
+				NOTE("No enabled modems, disabling modemd");
+				systemf("initctl -bfqn stop modemd");
+				finit_disable("modemd");
+				systemf("initctl -bfqn stop modem-manager");
+				finit_disable("modem-manager");
+			}
+		}
+	}
 err:
 
 	return rc;
@@ -1723,7 +1990,6 @@ int hardware_wifi_change(sr_session_ctx_t *session, struct lyd_node *config, str
 		globfree(&gl);
 	}
 
-
 	return rc;
 }
 
diff --git a/src/confd/src/if-modem.c b/src/confd/src/if-modem.c
index 2c5a419f7..43822996b 100644
--- a/src/confd/src/if-modem.c
+++ b/src/confd/src/if-modem.c
@@ -22,6 +22,13 @@
  *   withdraws the default route.  Then delete the interface — either the real
  *   wwan one or the dummy placeholder.
  *
+ * For modem components anchored to a USB port (component uri leaf-list,
+ * see hardware.c), a generated udev rule names the netdev after the
+ * configured interface as it appears, so enumeration order never
+ * decides interface names.  A modem that shows up only after the
+ * probe-timeout expired is out of luck for this boot: the dummy holds
+ * the name until reboot, same recovery model as WiFi.
+ *
  * 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.
@@ -74,7 +81,7 @@ int modem_add_iface(struct lyd_node *cif, struct dagger *net)
 	}
 
 	/* If interface doesn't exist, create a dummy placeholder so downstream IP config works.
-	 * modemd will reconfigure the real interface once the modem attaches. */
+	 * Recovery when the modem shows up later is a reboot, see the file header. */
 	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);
diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang
index 8ba300ede..430cc4ce1 100644
--- a/src/confd/yang/confd/infix-hardware.yang
+++ b/src/confd/yang/confd/infix-hardware.yang
@@ -541,9 +541,20 @@ module infix-hardware {
   deviation "/iehw:hardware/iehw:component/iehw:alias" {
     deviate not-supported;
   }
-  deviation "/iehw:hardware/iehw:component/iehw:uri" {
-    deviate not-supported;
-  }
+
+  /*
+   * NOTE: the standard component uri leaf-list (RFC 8348,
+   * entPhysicalUris) is deliberately NOT deviated out.  Infix uses
+   * it to anchor pluggable components to a physical attachment
+   * point, e.g.
+   *
+   *   uri "usb:///sys/bus/usb/devices/3-1.2.1"
+   *
+   * binds the component to that USB port.  Other URI schemes (CLEI
+   * URNs, ...) may coexist in the leaf-list and are ignored by the
+   * system.  Values are validated at apply time, not by the model.
+   */
+
   deviation "/iehw:hardware/iehw:component/iehw:asset-id" {
     deviate not-supported;
   }
@@ -1116,6 +1127,20 @@ module infix-hardware {
         description "IMEI serial number.";
       }
 
+      leaf usb-vendor-id {
+        type string {
+          pattern '[0-9a-fA-F]{4}';
+        }
+        description "USB vendor ID of the modem device, as probed.";
+      }
+
+      leaf usb-product-id {
+        type string {
+          pattern '[0-9a-fA-F]{4}';
+        }
+        description "USB product ID of the modem device, as probed.";
+      }
+
       leaf imsi {
         type string;
         description "IMSI from the active SIM card.";
diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml
index 1c8cfd47a..c3455287d 100644
--- a/src/klish-plugin-infix/xml/infix.xml
+++ b/src/klish-plugin-infix/xml/infix.xml
@@ -122,17 +122,20 @@
   
 
 
-
+
   
-    
+    
+      jq -r '.modem[]? | "modem" + (.index|tostring)' /run/system.json 2>/dev/null
+    
   
   
 
 
-
+
   
     
-      jq -r '.modem[]? | "modem" + (.index|tostring)' /run/system.json 2>/dev/null
+      ls /sys/class/ieee80211/ 2>/dev/null
+      jq -r '(.["usb-ports"][]?.name), (.modem[]?.name)' /run/system.json 2>/dev/null
     
   
   
@@ -725,7 +728,7 @@ echo "Public:  $pub"
   
 
   
-    
+    
       
         
           
diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py
index fe301622c..a31b81215 100644
--- a/src/modemd/modemd/__init__.py
+++ b/src/modemd/modemd/__init__.py
@@ -288,19 +288,23 @@ def load_config():
         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
+    def uri_port(comp):
+        """USB port token from the component's uri leaf-list, or None.
+        Same parse as confd: trailing slashes trimmed, token restricted
+        to the sysfs port alphabet.  Other URI schemes are ignored."""
+        for u in comp.get("uri", []):
+            if not u.startswith("usb:"):
+                continue
+            port = os.path.basename(u.rstrip("/"))
+            if port and set(port) <= set("0123456789.-"):
+                return port
+            err("Malformed usb URI '%s', expected .../" % u)
+        return None
 
-        ih = comp.get("infix-hardware:modem", {})
-        modems[name] = {
+    def modem_cfg(index, ih, port):
+        return {
             "index": index,
+            "usb-port": port,
             "preferred-mode": ih.get("preferred-mode"),
             "allowed-mode": ih.get("allowed-mode", []),
             "band": ih.get("band", []),
@@ -308,6 +312,53 @@ def load_config():
             "bearer": [],
         }
 
+    modems = {}
+    claimed = set()
+    ports = {name: uri_port(comp) for name, comp in hw_comps.items()}
+    anchored = any(ports.values())
+
+    # Components anchored to a USB port are authoritative: the thread is
+    # started from config alone and binds to the device at that port,
+    # whether it has enumerated yet or not.
+    for name in sorted(hw_comps):
+        comp = hw_comps[name]
+        port = ports[name]
+        if not port:
+            continue
+        if port in claimed:
+            err("Modem %s: USB port %s already claimed by another "
+                "component, skipping" % (name, port))
+            continue
+        claimed.add(port)
+        if comp.get("state", {}).get("admin-state", "unlocked") == "locked":
+            continue
+        if not name.startswith("modem") or not name[5:].isdigit():
+            err("Cannot manage anchored modem component '%s', "
+                "must be named modemN" % name)
+            continue
+        modems[name] = modem_cfg(int(name[5:]),
+                                 comp.get("infix-hardware:modem", {}), port)
+
+    # Unanchored: legacy positional behavior, joined by component name.
+    # Once any component is anchored the config is authoritative for the
+    # whole system: unconfigured devices are no longer auto-managed.
+    for phys in physical:
+        name = phys.get("name", "modem%d" % phys.get("index", 0))
+        port = os.path.basename(phys.get("devpath", ""))
+        if name in modems or (port and port in claimed):
+            continue
+
+        comp = hw_comps.get(name, {})
+        if ports.get(name):
+            continue
+        if anchored and not comp:
+            continue
+        if comp.get("state", {}).get("admin-state", "unlocked") == "locked":
+            continue
+
+        modems[name] = modem_cfg(phys.get("index", 0),
+                                 comp.get("infix-hardware:modem", {}), None)
+
     for iface in (ifaces or {}).get("ietf-interfaces:interfaces", {}).get("interface", []):
         if iface.get("type", "") != "infix-if-type:modem":
             continue
@@ -507,6 +558,7 @@ def __init__(self, cfg):
         self.exited = False
 
         self.cfg = cfg
+        self.port = cfg.get("usb-port")
         self.prefix = "[modem%d] " % self.index
         self.rundir = "%s/modem%d" % (rundir, self.index)
         self.statedir = "%s/state" % self.rundir
@@ -532,6 +584,9 @@ def __init__(self, cfg):
         self.init()
 
     def stop(self):
+        # Retract the component -> port binding so modem-info does not
+        # keep reporting this modem under a stale component identity.
+        rmf("%s/port" % self.rundir)
         self.stopevent.set()
 
     def stopped(self):
@@ -1710,8 +1765,12 @@ def lookup(self):
         if self.iface and self.sim:
             return True
 
-        info = runcmdj(['/usr/libexec/modemd/modem-info',
-                        '-i', str(self.index)])
+        if self.port:
+            cmd = ['/usr/libexec/modemd/modem-info',
+                   '-i', str(self.index), '-p', self.port]
+        else:
+            cmd = ['/usr/libexec/modemd/modem-info', '-i', str(self.index)]
+        info = runcmdj(cmd)
         if not info:
             self.err("No modem info")
             return False
@@ -1742,7 +1801,13 @@ def query(self):
         output = runcmdj(['/usr/libexec/modemd/modem-info'])
         if output:
             for m in output:
-                if m.get("index", -1) == self.index:
+                # Anchored: match by the USB port from the component's
+                # configured uri; never grab a modem at another port.
+                if self.port:
+                    if os.path.basename(m.get("devpath", "")) == self.port:
+                        modem = m
+                        break
+                elif m.get("index", -1) == self.index:
                     modem = m
                     break
         if modem is None:
@@ -1764,6 +1829,12 @@ def query(self):
         self.dbg("Model is '%s'" % self.model)
         fwrite(path, self.model)
 
+        # Publish the component -> USB port binding; modem-info uses it
+        # to report this modem under the configured component name
+        # rather than the probe enumeration order.
+        if self.port:
+            fwrite("%s/port" % self.rundir, self.port)
+
         self.path = modem.get("path")
         self.dbg("Got path %s" % self.path)
         return True
diff --git a/src/modemd/modemd/info.py b/src/modemd/modemd/info.py
index 6de9b9584..40485a842 100644
--- a/src/modemd/modemd/info.py
+++ b/src/modemd/modemd/info.py
@@ -1,6 +1,7 @@
 import subprocess
 import argparse
 import syslog
+import glob
 import json
 import sys
 import os
@@ -172,23 +173,40 @@ def _sim_lock_state(unlock_required):
 
 
 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:
+    """USB device (port) directory of the modem, e.g. .../3-1.2/3-1.2.1.
+    The deepest ancestor with an idVendor attribute; its basename is the
+    system-unique port token used for matching against system.json and
+    against configured component URIs."""
+    path = os.path.realpath(devpath)
+    while path not in ("/", "/sys"):
+        if os.path.isfile("%s/idVendor" % path):
             return path
+        path = os.path.dirname(path)
     return None
 
 
 def modem_get_module(devpath):
+    port = os.path.basename(devpath) if devpath else ""
+
+    # Anchored modems: the modem daemon publishes its component ->
+    # USB port binding, authoritative over probe enumeration order.
+    if port:
+        for pfile in glob.glob("/run/modemd/modem*/port"):
+            if fread(pfile) != port:
+                continue
+            try:
+                idx = int(os.path.basename(os.path.dirname(pfile))[5:])
+            except ValueError:
+                continue
+            return {"index": idx, "slot": idx,
+                    "paths": [devpath], "type": "modem"}
+
     # Prefer system.json (new canonical source written by 00-probe)
     if os.path.exists("/run/system.json"):
         with open("/run/system.json", "r") as fd:
             data = json.load(fd)
         for modem in data.get("modem", []):
-            if devpath in modem.get("devpath", ""):
+            if port and port == os.path.basename(modem.get("devpath", "")):
                 idx = modem.get("index", 0)
                 return {"index": idx, "slot": idx,
                         "paths": [modem["devpath"]], "type": "modem"}
@@ -245,20 +263,25 @@ def sysfs_interfaces(devpath):
     try:
         for iface in os.listdir("/sys/class/net"):
             p = os.path.realpath("/sys/class/net/%s" % iface)
-            if p.startswith(devpath):
+            if p.startswith(devpath + "/"):
                 ifaces.append(iface)
     except OSError:
         pass
     return ifaces
 
 
-def print_modem(index):
+def print_modem(index, port=None):
+    def match(modem):
+        if port:
+            return port == os.path.basename(modem.get("devpath", ""))
+        return modem.get("index") == 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:
+            if match(modem):
                 module = {"index": index, "slot": index,
                           "paths": [modem["devpath"]], "type": "modem"}
                 modem["sim"] = modem_get_sim(module) or \
@@ -274,14 +297,17 @@ def print_modem(index):
             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)
+            if not module:
+                continue
+            if not (match(modem) if port else module["index"] == index):
+                continue
+            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)
@@ -338,7 +364,8 @@ def print_all():
 
         modem = {
             "index": index,
-            "path": mpath
+            "path": mpath,
+            "devpath": devpath,
         }
 
         modem["info"] = {
@@ -532,6 +559,8 @@ def print_all():
 def main():
     parser = argparse.ArgumentParser(prog='modem-info')
     parser.add_argument("-i", "--index", default=0, help="Modem index")
+    parser.add_argument("-p", "--port", default=None,
+                        help="USB port token, e.g. 3-1.2.1")
     args = parser.parse_args()
 
     with open("/proc/cmdline", 'r') as fd:
@@ -539,7 +568,9 @@ def main():
         if "debug" in cmdline:
             debug = True
 
-    if args.index:
+    if args.port:
+        print_modem(int(args.index) if args.index else 0, port=args.port)
+    elif args.index:
         print_modem(int(args.index))
     else:
         print_all()
diff --git a/src/modemd/modemd/udev.py b/src/modemd/modemd/udev.py
index aaf6d3c29..c7bd8024c 100644
--- a/src/modemd/modemd/udev.py
+++ b/src/modemd/modemd/udev.py
@@ -7,6 +7,8 @@
 import re
 import os
 
+from .info import device_devpath
+
 MODEMS = "/run/modems.json"
 SYSTEM = "/run/system.json"
 LOCK = "/var/lock/modems.lock"
@@ -115,13 +117,18 @@ def update_system_json(modems_list):
 
     entries = []
     for i, m in enumerate(modems_list):
-        entries.append({
+        devpath = m.get("devpath", "")
+        port = os.path.basename(devpath) if devpath else ""
+        entry = {
             "index": i,
             "name": "modem%d" % i,
-            "devpath": m.get("devpath", ""),
+            "devpath": devpath,
             "vid": m.get("vendor", ""),
             "pid": m.get("product", ""),
-        })
+        }
+        if port:
+            entry["uri"] = "usb:///sys/bus/usb/devices/%s" % port
+        entries.append(entry)
 
     if not entries:
         data.pop("modem", None)
@@ -189,8 +196,19 @@ def update(d):
     info("Updated modem %s" % devpath)
 
 
+def usb_device_dir(devpath):
+    """USB device (port) directory for a udev $DEVPATH; shares the
+    idVendor walk with modem-info so all producers agree on the port
+    token regardless of how many sysfs levels the triggering device
+    sits below the USB device."""
+    return device_devpath("/sys/%s" % devpath)
+
+
 def add_tty(devpath):
-    devpath = os.path.realpath("/sys/%s/../../../../" % devpath)
+    devpath = usb_device_dir(devpath)
+    if not devpath:
+        err("No USB device for tty")
+        return False
 
     info("Adding tty device %s" % devpath)
 
@@ -212,7 +230,10 @@ def add_tty(devpath):
 
 
 def add_net(devpath):
-    devpath = os.path.realpath("/sys/%s/../../../" % devpath)
+    devpath = usb_device_dir(devpath)
+    if not devpath:
+        err("No USB device for net interface")
+        return False
 
     info("Adding net device %s" % devpath)
 
@@ -240,7 +261,10 @@ def add_net(devpath):
 
 
 def add_usbmisc(devpath):
-    devpath = os.path.realpath("/sys/%s/../../../" % devpath)
+    devpath = usb_device_dir(devpath)
+    if not devpath:
+        err("No USB device for usbmisc")
+        return False
 
     info("Adding usbmisc device %s" % devpath)
 
diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py
index 10be3e761..a6fa6ca26 100755
--- a/src/statd/python/cli_pretty/cli_pretty.py
+++ b/src/statd/python/cli_pretty/cli_pretty.py
@@ -2527,6 +2527,13 @@ def _bearers_for_modem(json, modem_name):
     return out
 
 
+def _usb_id(ms):
+    """VID:PID string from modem-state, or '' when not probed."""
+    vid = ms.get("usb-vendor-id", "")
+    pid = ms.get("usb-product-id", "")
+    return f"{vid}:{pid}" if vid and pid else ""
+
+
 def show_modem_detail(json, ref):
     components = get_json_data([], json, "ietf-hardware:hardware", "component")
     modem = next((c for c in components
@@ -2558,6 +2565,8 @@ def show_modem_detail(json, ref):
         ("Hardware Revision",  ms.get("hardware-revision", "")),
         ("Firmware Version",   ms.get("firmware-version", "")),
         ("Serial Number",      ms.get("serial-number", "")),
+        ("USB ID",             _usb_id(ms)),
+        ("URI",                ", ".join(modem.get("uri", []))),
         ("IMSI",               ms.get("imsi", "")),
         ("ICCID",              ms.get("iccid", "")),
     ):
@@ -2895,6 +2904,42 @@ def show_hardware(json):
                 sensor.print(name_width=name_width)
 
 
+def show_hardware_detail(json, ref):
+    """Per-component detail view: identification and state for any
+    hardware component.  The URI line is copy-paste ready for anchoring
+    the component in the configuration."""
+    components = get_json_data([], json, "ietf-hardware:hardware", "component")
+    comp = next((c for c in components if c.get("name") == ref), None)
+    if comp is None:
+        print(f"Hardware component '{ref}' not found.")
+        return
+
+    width = 62
+    print(Decore.invert(f"{'COMPONENT: ' + ref:<{width}}"))
+
+    cls = comp.get("class", "")
+    state = comp.get("state", {})
+    ms = comp.get("infix-hardware:modem-state", {})
+
+    for label, val in (
+        ("Name",         comp.get("name", "")),
+        ("Class",        cls.split(":")[-1]),
+        ("Model",        comp.get("model-name", "") or ms.get("model", "")),
+        ("Manufacturer", comp.get("mfg-name", "") or ms.get("manufacturer", "")),
+        ("USB ID",       _usb_id(ms)),
+        ("URI",          ", ".join(comp.get("uri", []))),
+        ("Admin State",  state.get("admin-state", "")),
+        ("Oper State",   state.get("oper-state", "")),
+        ("Alarm State",  state.get("alarm-state", "")),
+    ):
+        if val:
+            print(f"  {label:<20}: {val}")
+
+    if cls == "infix-hardware:modem":
+        print()
+        print(f"See 'show modem {ref}' for cellular status.")
+
+
 def resolve_container_network(network, all_ifaces):
     """Resolve container network to bridge names or network type.
 
@@ -6662,6 +6707,9 @@ def main():
               .add_argument('name', help='Container name')
 
     subparsers.add_parser('show-hardware', help='Show USB ports')
+    subparsers.add_parser('show-hardware-detail',
+                          help='Show detailed hardware component info') \
+              .add_argument('name', help='Component name (e.g. modem0)')
     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)')
@@ -6748,6 +6796,8 @@ def main():
         show_container_detail(json_data, args.name)
     elif args.command == "show-hardware":
         show_hardware(json_data)
+    elif args.command == "show-hardware-detail":
+        show_hardware_detail(json_data, args.name)
     elif args.command == "show-modem":
         show_modem(json_data)
     elif args.command == "show-modem-detail":
diff --git a/src/statd/python/yanger/infix_modem.py b/src/statd/python/yanger/infix_modem.py
index 227b2c7f8..035ae43df 100644
--- a/src/statd/python/yanger/infix_modem.py
+++ b/src/statd/python/yanger/infix_modem.py
@@ -1,3 +1,5 @@
+import os
+
 from .common import LOG
 from .host import HOST
 
@@ -160,8 +162,29 @@ def _sim_to_hw_state(sim_raw):
     return state
 
 
+def _probed_modems():
+    """Probed modem entries from system.json, keyed by USB port token.
+    Carries the attachment uri and VID:PID for each physical modem,
+    whether managed by the modem daemon or not."""
+    probed = {}
+    for entry in HOST.read_json("/run/system.json", {}).get("modem", []):
+        port = os.path.basename(entry.get("devpath", ""))
+        if port:
+            probed[port] = entry
+    return probed
+
+
+def _usb_ids(phys):
+    """usb-vendor-id/usb-product-id leaves from a probed entry."""
+    return {leaf: phys[key]
+            for key, leaf in (("vid", "usb-vendor-id"),
+                              ("pid", "usb-product-id"))
+            if phys.get(key)}
+
+
 def operational():
     modems = HOST.run_json(['/usr/libexec/modemd/modem-info'], [])
+    probed = _probed_modems()
 
     hw_components = []
     for modem in modems:
@@ -187,6 +210,13 @@ def operational():
             "class": "infix-hardware:modem",
         }
 
+        port = os.path.basename(modem.get("devpath", ""))
+        phys = probed.pop(port, None) if port else None
+        if phys:
+            if phys.get("uri"):
+                component["uri"] = [phys["uri"]]
+            hw_state.update(_usb_ids(phys))
+
         # Standard ietf-hardware state container — drives 'show hardware'
         # and gives generic NETCONF clients a meaningful view of the
         # modem without needing modem-specific YANG awareness.
@@ -222,6 +252,33 @@ def operational():
                 sim_component["infix-hardware:sim-state"] = sim_hw_state
             hw_components.append(sim_component)
 
+    # Probed modems not managed (yet): the modem daemon may be stopped
+    # or the device unconfigured.  Surface them with their attachment
+    # uri so 'show hardware modemN' tells the user what to configure.
+    used = {c["name"] for c in hw_components}
+    for phys in probed.values():
+        hw_state = _usb_ids(phys)
+
+        name = phys.get("name", "modem%d" % phys.get("index", 0))
+        idx = phys.get("index", 0)
+        while name in used:
+            idx += 1
+            name = "modem%d" % idx
+        used.add(name)
+
+        component = {
+            "name": name,
+            "class": "infix-hardware:modem",
+            "state": {
+                "oper-state": "unknown",
+            },
+        }
+        if phys.get("uri"):
+            component["uri"] = [phys["uri"]]
+        if hw_state:
+            component["infix-hardware:modem-state"] = hw_state
+        hw_components.append(component)
+
     if not hw_components:
         return {}
 

From 71e7fd177b592bc6d7dd25a0f25322c9eb6af60a Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sat, 26 Sep 2026 12:47:23 +0200
Subject: [PATCH 23/37] modemd: don't let location setup block the data
 connection

On a Quectel EM05 the bearer is created but never connected, modemd
keeps preparing the modem every few seconds:

    Unable to send AT command 'AT+QGPS=1'
    Unable to prepare location

GNSS stays on in the modem across modemd restarts and reboots, and
AT+QGPS=1 on a running session fails with CME ERROR 504.  A failed
location setup failed prepare(), so modemd never got as far as
connecting the bearer.

Query the GPS state first and only send the AT command when it
differs.  Location is optional, so a failure no longer fails prepare()
or skips the other location sources.  The setup is instead retried
from check_location() until it succeeds.

Signed-off-by: Joachim Wiberg 
---
 src/modemd/modemd/__init__.py | 66 ++++++++++++++++++++++-------------
 1 file changed, 41 insertions(+), 25 deletions(-)

diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py
index a31b81215..4b912a9fe 100644
--- a/src/modemd/modemd/__init__.py
+++ b/src/modemd/modemd/__init__.py
@@ -8,6 +8,7 @@
 import socket
 import shutil
 import select
+import re
 import json
 import enum
 import time
@@ -27,11 +28,14 @@
 # 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.
+# (which keeps MM in charge of the NMEA port).  Keys are matched as
+# substrings of the manufacturer string ModemManager reports.  The
+# query, when known, avoids an error from a modem that is already in
+# the requested state, e.g. Quectel CME ERROR 504 on AT+QGPS=1.
 GPS_AT_COMMANDS = {
-    "Quectel":         ("AT+QGPS=1", "AT+QGPS=0"),
-    "Sierra Wireless": ("AT+CGPS=1", "AT+CGPS=0"),
+    #                   query        enable       disable
+    "Quectel":         ("AT+QGPS?", "AT+QGPS=1", "AT+QGPS=0"),
+    "Sierra Wireless": (None,       "AT+CGPS=1", "AT+CGPS=0"),
 }
 
 # ModemManager --location-{enable,disable}-* flag suffixes per source.
@@ -1363,17 +1367,31 @@ def prepare_modes(self):
             self._modes_failed_sig = None
         return ok
 
-    def _gps_at_command(self, enable):
-        """Vendor-specific AT command to toggle GPS NMEA, or None.
+    def _gps_at_commands(self):
+        """Vendor-specific (query, enable, disable) AT commands, or None.
 
-        Match by substring so 'Quectel' picks up 'Quectel Incorporated' too —
+        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 cmds
         return None
 
+    def _gps_at_toggle(self, cmds, enable):
+        """Set GPS on/off with the vendor AT commands, skip if already set."""
+        query, on, off = cmds
+        if query:
+            output = self._mmcli("--command=%s" % query)
+            match = re.search(r":\s*(\d)", output if isinstance(output, str) else "")
+            if match and (match.group(1) != "0") == enable:
+                return True
+        at = on if enable else off
+        if not self._mmcli("--command=%s" % at):
+            self.err("Unable to send AT command '%s'" % at)
+            return False
+        return True
+
     def _location_capabilities(self):
         """YANG source names this modem actually supports.
 
@@ -1402,6 +1420,7 @@ def prepare_location(self):
         self.info("Preparing location")
 
         rmrf(self.locdir)
+        self.location["state"] = None
         if not self.location["enabled"]:
             return True
 
@@ -1411,15 +1430,13 @@ def prepare_location(self):
         # 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")
+        ok = True
+        flag_map = dict(LOCATION_MM_FLAGS)
+        cmds = self._gps_at_commands()
+        if cmds is None:
+            flag_map["gps"] = ("gps-nmea", "gps-raw")
+        elif not self._gps_at_toggle(cmds, "gps" in sources):
+            ok = False
 
         # Capability filter for the remaining (MM-managed) sources so
         # CDMA on an LTE-only modem doesn't fail the whole batched call.
@@ -1429,11 +1446,8 @@ def prepare_location(self):
             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"
@@ -1450,9 +1464,10 @@ def prepare_location(self):
 
         if args and not self._mmcli(*args):
             self.err("Unable to configure location sources")
+            ok = False
+        if not ok:
             self.location["state"] = "failed"
-            return False
-        return True
+        return ok
 
     def prepare(self):
         if not self.detected:
@@ -1470,9 +1485,8 @@ def prepare(self):
         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
+        # Optional, must not block the data connection, see check_location()
+        self.prepare_location()
 
         self.info("Waiting for network")
         return True
@@ -1619,6 +1633,8 @@ def check_trigger(self):
     def check_location(self):
         if not self.location["enabled"]:
             return
+        if self.location["state"] == "failed" and not self.prepare_location():
+            return
 
         output = self._mmclij("--location-get")
         loc = (output or {}).get("modem", {}).get("location", {})

From d4ee545ec27a3f1f89b8d458826e5267286cb1d8 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Mon, 12 Oct 2026 06:58:38 +0200
Subject: [PATCH 24/37] modemd: bring up bearers that use PPP or DHCP

modemd only handled bearers where ModemManager hands out the address.
Bearers with IP method ppp or dhcp were skipped, the address being
"--", so these modems connected but never got an IP link:

 - AT-only modems, e.g. on cdc_acm, carry data as PPP on the tty
   ModemManager dialed on, and have no netdev at all.  lookup() also
   refused them, "No modem interface", and 00-probe only looked for
   cdc-wdm and ttyUSB ports.
 - Modems with an Ethernet-style data port get their address by DHCP.

For PPP, run pppd on the tty with the link named after the configured
interface, replacing confd's placeholder.  The route and DNS servers go
through the same ip-up and ip-down scripts as PPPoE, with the bearer's
route-preference.  For DHCP, run udhcpc on the data port with a script
that records the lease, then apply it like an address from
ModemManager.  The kernel needs PPP_ASYNC for PPP on a tty.

00-probe now also records ttyACM ports, but only interfaces that speak
AT commands, the same rule ModemManager uses, so GPS receivers and
serial consoles on cdc_acm are not taken for modems.

Signed-off-by: Joachim Wiberg 
---
 .../rootfs/usr/libexec/infix/init.d/00-probe  | 24 ++++-
 doc/modem.md                                  | 11 ++-
 package/feature-modem/feature-modem.mk        |  2 +
 src/modemd/modemd/__init__.py                 | 87 ++++++++++++++++---
 src/modemd/modemd/dhcp.py                     | 60 +++++++++++++
 src/modemd/modemd/ppp.py                      | 49 +++++++++++
 src/modemd/pyproject.toml                     |  1 +
 7 files changed, 220 insertions(+), 14 deletions(-)
 create mode 100644 src/modemd/modemd/dhcp.py
 create mode 100644 src/modemd/modemd/ppp.py

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 230e1f4b3..b10557e3e 100755
--- a/board/common/rootfs/usr/libexec/infix/init.d/00-probe
+++ b/board/common/rootfs/usr/libexec/infix/init.d/00-probe
@@ -633,6 +633,22 @@ def _usb_device_from_sysfs(dev_path):
     return None
 
 
+def _acm_is_modem(dev_path):
+    """A cdc_acm port is a modem only if its interface speaks AT commands,
+    class 2/2 protocol 1-6, same rule as ModemManager.  Keeps GPS
+    receivers and serial consoles on cdc_acm out of the modem list."""
+    def attr(name):
+        with open(os.path.join(dev_path, name)) as f:
+            return int(f.read(), 16)
+
+    try:
+        cls, sub = attr("bInterfaceClass"), attr("bInterfaceSubClass")
+        proto = attr("bInterfaceProtocol")
+    except (OSError, ValueError):
+        return False
+    return cls == 2 and sub == 2 and 1 <= proto <= 6
+
+
 def probe_modems(out):
     """One entry per physical modem; devpath is the USB device (port)
     directory, deduplicating the cdc-wdm and ttyUSB ports it exposes.
@@ -664,18 +680,20 @@ def probe_modems(out):
         })
         idx += 1
 
-    for cls_dir, prefix in (("/sys/class/usbmisc", "cdc-wdm"),
-                             ("/sys/class/tty", "ttyUSB")):
+    for cls_dir, prefixes in (("/sys/class/usbmisc", ("cdc-wdm",)),
+                              ("/sys/class/tty", ("ttyUSB", "ttyACM"))):
         try:
             entries = os.listdir(cls_dir)
         except OSError:
             continue
         for entry in entries:
-            if not entry.startswith(prefix):
+            if not entry.startswith(prefixes):
                 continue
             try:
                 dev_path = os.path.realpath(
                     os.path.join(cls_dir, entry, "device"))
+                if entry.startswith("ttyACM") and not _acm_is_modem(dev_path):
+                    continue
                 add_modem(dev_path)
             except OSError:
                 pass
diff --git a/doc/modem.md b/doc/modem.md
index 3696e018b..7fe192be5 100644
--- a/doc/modem.md
+++ b/doc/modem.md
@@ -1,7 +1,7 @@
 # Cellular Modem (WWAN)
 
 Cellular modems are supported when they expose a QMI or MBIM control
-interface over USB.  Form factor does not matter:
+interface, or only AT commands, over USB.  Form factor does not matter:
 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.
@@ -92,6 +92,15 @@ recognised:
 - **QMI**: Qualcomm MSM Interface (e.g. Sierra Wireless EM7xxx,
   Quectel EM/RMxxx)
 
+Modems with only an AT command port, e.g. a USB serial port handled by
+the `cdc_acm` driver (`ttyACM0`), are supported too.  They have no
+network interface of their own, so the data connection runs as PPP
+over the AT port, and the wwan interface is the PPP link.
+
+> [!NOTE]
+> AT-only modems have only been tested with the virtual modem of the
+> test system, not with modem hardware.
+
 Most 4G/LTE modules (USB dongles, mPCIe cards, M.2 Key-B) use USB on
 the connector even when the slot also carries PCIe lanes.  To the
 system they are all USB modems.  PCIe-only modems (some 5G NR modules)
diff --git a/package/feature-modem/feature-modem.mk b/package/feature-modem/feature-modem.mk
index 52c9a66ec..01c23b11d 100644
--- a/package/feature-modem/feature-modem.mk
+++ b/package/feature-modem/feature-modem.mk
@@ -15,6 +15,8 @@ define FEATURE_MODEM_LINUX_CONFIG_FIXUPS
 	$(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)
+	# AT-only modems carry data as PPP on a tty
+	$(call KCONFIG_SET_OPT,CONFIG_PPP_ASYNC,m)
 
 	$(if $(filter y,$(BR2_PACKAGE_FEATURE_MODEM_QUALCOMM)),
 		$(call KCONFIG_SET_OPT,CONFIG_QRTR,m)
diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py
index 4b912a9fe..058e470b5 100644
--- a/src/modemd/modemd/__init__.py
+++ b/src/modemd/modemd/__init__.py
@@ -231,6 +231,22 @@ def runcmdj(cmd):
         return data
 
 
+def stopproc(proc):
+    """Stop a child process, kill it if it does not stop in time."""
+    if not proc:
+        return
+    proc.terminate()
+    try:
+        proc.wait(timeout=5)
+    except subprocess.TimeoutExpired:
+        proc.kill()
+
+
+# After the helpers above, which they use
+from . import dhcp  # noqa: E402
+from . import ppp   # noqa: E402
+
+
 def list_sms(modem):
     smslist = []
 
@@ -619,10 +635,11 @@ def init(self):
         self.path = None
         self.interfaces = []
         self.ifaces = []
+        self.dhcp = {}
+        self.ppp = {}
         self.status = {}
         self.state = None
         self.trigger = None
-        self.iface = None
         self.sim = None
         self.failtime = None
         self.detected = False
@@ -922,7 +939,7 @@ def ipdefroute(self, iface, action):
         return True
 
     def ifupdown(self, iface, action):
-        path = "/var/ifupdown/%s" % self.iface
+        path = "/var/ifupdown/%s" % iface["name"]
         if os.path.isfile(path) or os.access(path, os.X_OK):
             self.info("Running %s %s" % (path, action))
             if not runcmd([path, action]):
@@ -1501,6 +1518,46 @@ def connect(self):
                 return False
         return True
 
+    def dhcp_lease(self, iface):
+        """Start the DHCP client on the bearer's interface and fill in
+        IPv4 from the lease.  False until there is a lease."""
+        name = iface["name"]
+        if name not in self.dhcp:
+            self.info("Starting DHCP client on %s" % name)
+            self.dhcp[name] = dhcp.start(name)
+        lease = dhcp.lease(name)
+        if not lease:
+            return False
+        iface["ipv4"] = {**iface["ipv4"], **lease}
+        return True
+
+    def ppp_up(self, iface):
+        """For a bearer with IP method ppp, run pppd on the tty ModemManager
+        dialed on.  The link takes the name of the configured interface,
+        replacing confd's placeholder.  True once the link is up."""
+        name = iface["bearer"]["name"]
+        if name not in self.ppp:
+            if ppp.is_placeholder(name):
+                runcmd(["ip", "link", "del", "dev", name], check=False)
+            self.info("Starting PPP on %s as %s" % (iface["name"], name))
+            self.ppp[name] = ppp.start(iface["name"], name, iface["bearer"])
+        return ppp.is_up(name)
+
+    def dhcp_renew(self):
+        """Apply a DHCP lease that changed while the bearer is up."""
+        for iface in list(self.ifaces):
+            if iface["ipv4"].get("method") != "dhcp":
+                continue
+            lease = dhcp.lease(iface["name"])
+            ipv4 = {**iface["ipv4"], **(lease or {})}
+            if ipv4 == iface["ipv4"]:
+                continue
+            self.info("DHCP lease changed on %s" % iface["name"])
+            self.ifaces.remove(iface)
+            iface = dict(iface, ipv4=ipv4)
+            if not self.linkup(iface):
+                self.linkdown(iface)
+
     def pullup(self):
         count = 0
         for bearer in self.get_bearers():
@@ -1517,6 +1574,13 @@ def pullup(self):
                 continue
             if iface["connected"] != "yes":
                 continue
+            method = iface["ipv4"].get("method")
+            if method == "ppp":
+                if self.ppp_up(iface):
+                    count += 1
+                continue
+            if method == "dhcp" and not self.dhcp_lease(iface):
+                continue
             if iface["ipv4"]["address"] == "--":
                 if iface["ipv6"]["address"] == "--":
                     continue
@@ -1545,6 +1609,11 @@ def pulldown(self):
                             "--bearer=%s" % bearer["dbus-path"])
         for iface in self.ifaces:
             self.linkdown(iface)
+        # DHCP clients and pppd, also those still waiting for an address
+        for name in list(self.dhcp):
+            dhcp.stop(self.dhcp.pop(name), name)
+        for name in list(self.ppp):
+            ppp.stop(self.ppp.pop(name), name)
 
         self.set_state(State.DOWN)
 
@@ -1617,6 +1686,11 @@ def check_state(self):
             self.timeout = 10
             if self.status.get("state", "") != "connected":
                 self.pulldown()
+            elif any(p.poll() is not None for p in self.ppp.values()):
+                self.err("PPP link lost")
+                self.pulldown()
+            else:
+                self.dhcp_renew()
 
 
     def check_trigger(self):
@@ -1778,7 +1852,7 @@ def check(self):
             self.timer2 = time.time()
 
     def lookup(self):
-        if self.iface and self.sim:
+        if self.sim:
             return True
 
         if self.port:
@@ -1791,13 +1865,6 @@ def lookup(self):
             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")
diff --git a/src/modemd/modemd/dhcp.py b/src/modemd/modemd/dhcp.py
new file mode 100644
index 000000000..b9ef69ac9
--- /dev/null
+++ b/src/modemd/modemd/dhcp.py
@@ -0,0 +1,60 @@
+"""DHCP for bearers where ModemManager reports IP method 'dhcp'.
+
+Modems with an Ethernet-style data port (ECM/NCM) get their address by
+DHCP on that port.  modemd runs udhcpc on it, with this module's main()
+as the udhcpc script.  The script only records the lease, modemd applies
+it like an address handed out by ModemManager.
+"""
+import ipaddress
+import os
+import subprocess
+import sys
+
+from . import freadj, fwritej_atomic, rmf, stopproc
+
+LEASEDIR = "/run/modemd/dhcp"
+SCRIPT = "/usr/libexec/modemd/modem-dhcp"
+
+
+def _path(ifname):
+    return "%s/%s.json" % (LEASEDIR, ifname)
+
+
+def start(ifname):
+    """Start udhcpc on ifname, returns the process."""
+    rmf(_path(ifname))
+    return subprocess.Popen(["udhcpc", "-f", "-S", "-t", "3", "-T", "5",
+                             "-A", "10", "-i", ifname, "-s", SCRIPT])
+
+
+def stop(proc, ifname):
+    """Stop the udhcpc process for ifname and forget its lease."""
+    stopproc(proc)
+    rmf(_path(ifname))
+
+
+def lease(ifname):
+    """Current lease for ifname, in ModemManager ipv4-config terms, or None."""
+    return freadj(_path(ifname))
+
+
+def main():
+    """udhcpc script: record the lease on bound/renew, drop it otherwise."""
+    action = sys.argv[1] if len(sys.argv) > 1 else ""
+    ifname = os.environ.get("interface", "")
+    if not ifname:
+        sys.exit(1)
+
+    if action not in ("bound", "renew"):
+        rmf(_path(ifname))
+        return
+
+    mask = os.environ.get("subnet", "255.255.255.255")
+    routers = os.environ.get("router", "").split()
+    os.makedirs(LEASEDIR, exist_ok=True)
+    fwritej_atomic(_path(ifname), {
+        "address": os.environ["ip"],
+        "prefix": str(ipaddress.IPv4Network("0.0.0.0/%s" % mask).prefixlen),
+        "gateway": routers[0] if routers else "--",
+        "dns": os.environ.get("dns", "").split(),
+    })
diff --git a/src/modemd/modemd/ppp.py b/src/modemd/modemd/ppp.py
new file mode 100644
index 000000000..16b856128
--- /dev/null
+++ b/src/modemd/modemd/ppp.py
@@ -0,0 +1,49 @@
+"""PPP for bearers where ModemManager reports IP method 'ppp'.
+
+AT-only modems carry data as PPP over the same tty ModemManager dialed
+on.  modemd runs pppd on it, naming the link after the configured wwan
+interface.  Route and DNS servers are handled by the system's ip-up and
+ip-down scripts, shared with PPPoE, which read the env file written here.
+"""
+import subprocess
+
+from . import fwrite, rmf, runcmdj, stopproc
+
+ENVFILE = "/etc/default/pppd-%s"
+
+
+def start(tty, ifname, bearer):
+    """Start pppd on tty, the link is named ifname.  Returns the process."""
+    fwrite(ENVFILE % ifname,
+           "# Generated by modemd, read by /etc/ppp/ip-up\n"
+           "DEFAULT_ROUTE=1\n"
+           "ROUTE_PREFERENCE=%s\n" % bearer.get("route-preference", 200))
+
+    cmd = ["pppd", "/dev/%s" % tty, "nodetach", "ifname", ifname,
+           "noauth", "noipdefault", "nodefaultroute", "usepeerdns",
+           "lcp-echo-interval", "10", "lcp-echo-failure", "3"]
+    cmd.append("noipv6" if bearer.get("ip-type") == "ipv4" else "+ipv6")
+    if bearer.get("username"):
+        cmd += ["user", bearer["username"],
+                "password", bearer.get("password", ""), "hide-password"]
+
+    return subprocess.Popen(cmd)
+
+
+def stop(proc, ifname):
+    """Stop pppd, its ip-down script drops the route and DNS servers."""
+    stopproc(proc)
+    rmf(ENVFILE % ifname)
+
+
+def is_up(ifname):
+    """True once the PPP link has an IPv4 address."""
+    links = runcmdj(["ip", "-j", "-4", "addr", "show", "dev", ifname]) or []
+    return any(link.get("addr_info") for link in links)
+
+
+def is_placeholder(ifname):
+    """True if ifname is confd's dummy placeholder for a missing modem."""
+    links = runcmdj(["ip", "-j", "-d", "link", "show", "dev", ifname]) or []
+    return any(link.get("linkinfo", {}).get("info_kind") == "dummy"
+               for link in links)
diff --git a/src/modemd/pyproject.toml b/src/modemd/pyproject.toml
index 3e221d54c..90a50e016 100644
--- a/src/modemd/pyproject.toml
+++ b/src/modemd/pyproject.toml
@@ -18,6 +18,7 @@ build-backend = "poetry.core.masonry.api"
 modemd = "modemd:main"
 modem-info = "modemd.info:main"
 modem-carrier = "modemd.carrier:main"
+modem-dhcp = "modemd.dhcp:main"
 modem-rpc = "modemd.rpc:main"
 modem-sms = "modemd.sms:main"
 modem-scan-networks = "modemd.scan_networks:main"

From 5d2e019b1d5b93d188f32fb43513f6436d9519ee Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sat, 17 Oct 2026 17:58:51 +0200
Subject: [PATCH 25/37] modemd: don't refresh modem status twice per check

While a bearer comes up, each check queries ModemManager for the modem
status twice: check() refreshes it, then pullup() does it again through
get_bearers().

pullup() is only called from check_state(), right after the refresh, so
let it skip get_bearers()'s own.

Signed-off-by: Joachim Wiberg 
---
 src/modemd/modemd/__init__.py | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py
index 058e470b5..660b2ce6e 100644
--- a/src/modemd/modemd/__init__.py
+++ b/src/modemd/modemd/__init__.py
@@ -785,9 +785,11 @@ def notif(self, msg):
             self.dbg("Sent notification: %s" % msg)
         rmf(note)
 
-    def get_bearers(self):
+    def get_bearers(self, refresh=True):
+        """Bearer details, refreshing modem status first unless the caller
+        just did."""
         bearers = []
-        if not self.update():
+        if refresh and not self.update():
             return bearers
         for path in self.status.get("bearers", []):
             output = self._mmclij("-b", path)
@@ -1560,7 +1562,7 @@ def dhcp_renew(self):
 
     def pullup(self):
         count = 0
-        for bearer in self.get_bearers():
+        for bearer in self.get_bearers(refresh=False):
             iface = {
                 "name": bearer["status"]["interface"],
                 "connected": bearer["status"]["connected"],

From b2056500f1d38c9b12da496a338d0c6b1aff958e Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Mon, 12 Oct 2026 06:58:14 +0200
Subject: [PATCH 26/37] patches/modem-manager: backport single-AT port probing
 fix

A modem with a single AT port and a vendor ID no plugin claims is never
picked up:

    task 0,ttyACM0: deferring support check until result suggested
    task 0,ttyACM0: not supported by any plugin

The nokia plugin only sets ALLOWED_SINGLE_AT, which port probing does
not count as wanting AT probes, so it defers the port until another
port of the device suggests a plugin.  With one port, nothing does,
and the generic plugin is never tried.

Backport the upstream fix, ModemManager issue #822, from the
kkit-1.22.0 branch of our fork.

Signed-off-by: Joachim Wiberg 
---
 ...multiplexed-links-after-the-data-por.patch |  2 +-
 ...st-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch |  2 +-
 ...M_PLUGIN_ALLOWED_SINGLE_AT-in-port-p.patch | 31 +++++++++++++++++++
 3 files changed, 33 insertions(+), 2 deletions(-)
 create mode 100644 patches/modem-manager/1.22.0/0003-plugin-include-MM_PLUGIN_ALLOWED_SINGLE_AT-in-port-p.patch

diff --git a/patches/modem-manager/1.22.0/0001-bearer-qmi-name-multiplexed-links-after-the-data-por.patch b/patches/modem-manager/1.22.0/0001-bearer-qmi-name-multiplexed-links-after-the-data-por.patch
index ee9a2f3ac..212bcdb6a 100644
--- a/patches/modem-manager/1.22.0/0001-bearer-qmi-name-multiplexed-links-after-the-data-por.patch
+++ b/patches/modem-manager/1.22.0/0001-bearer-qmi-name-multiplexed-links-after-the-data-por.patch
@@ -1,7 +1,7 @@
 From 50b47c45e3979745adf84d92000c955b8d0631c9 Mon Sep 17 00:00:00 2001
 From: Joachim Wiberg 
 Date: Sat, 26 Sep 2026 11:13:00 +0200
-Subject: [PATCH 1/2] bearer-qmi: name multiplexed links after the data port
+Subject: [PATCH 1/3] bearer-qmi: name multiplexed links after the data port
 Organization: Wires
 
 Multiplexed QMI links are named qmapmux., where N is the D-Bus
diff --git a/patches/modem-manager/1.22.0/0002-port-probe-trust-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch b/patches/modem-manager/1.22.0/0002-port-probe-trust-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch
index 1347a00a9..d8fadadcc 100644
--- a/patches/modem-manager/1.22.0/0002-port-probe-trust-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch
+++ b/patches/modem-manager/1.22.0/0002-port-probe-trust-the-ID_MM_PORT_TYPE_MBIM-udev-tag.patch
@@ -1,7 +1,7 @@
 From 3d9719aa8b78ab4dc6a1a9767f8050bfda320d36 Mon Sep 17 00:00:00 2001
 From: Joachim Wiberg 
 Date: Sat, 26 Sep 2026 11:13:00 +0200
-Subject: [PATCH 2/2] port-probe: trust the ID_MM_PORT_TYPE_MBIM udev tag
+Subject: [PATCH 2/3] port-probe: trust the ID_MM_PORT_TYPE_MBIM udev tag
 Organization: Wires
 
 When a udev rule explicitly sets ID_MM_PORT_TYPE_MBIM=1 on a port,
diff --git a/patches/modem-manager/1.22.0/0003-plugin-include-MM_PLUGIN_ALLOWED_SINGLE_AT-in-port-p.patch b/patches/modem-manager/1.22.0/0003-plugin-include-MM_PLUGIN_ALLOWED_SINGLE_AT-in-port-p.patch
new file mode 100644
index 000000000..d1e280756
--- /dev/null
+++ b/patches/modem-manager/1.22.0/0003-plugin-include-MM_PLUGIN_ALLOWED_SINGLE_AT-in-port-p.patch
@@ -0,0 +1,31 @@
+From db4f2fc81021169bac74f1b3730bb075befe3f08 Mon Sep 17 00:00:00 2001
+From: Garfield Watkins 
+Date: Wed, 14 Feb 2024 18:36:20 +0200
+Subject: [PATCH 3/3] plugin: include MM_PLUGIN_ALLOWED_SINGLE_AT in port
+ probing
+Organization: Wires
+
+Fixes https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/822
+
+(cherry picked from commit 4d360e3b2220ad73e693842e0f6e5b654b85138d)
+Signed-off-by: Joachim Wiberg 
+---
+ src/mm-plugin.c | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/src/mm-plugin.c b/src/mm-plugin.c
+index 19cb5d72..1e721e54 100644
+--- a/src/mm-plugin.c
++++ b/src/mm-plugin.c
+@@ -816,7 +816,7 @@ mm_plugin_supports_port (MMPlugin            *self,
+ 
+     /* Build mask of flags based on plugin */
+     plugin_expected_flags = MM_PORT_PROBE_NONE;
+-    if (self->priv->at)
++    if (self->priv->at || self->priv->single_at)
+         plugin_expected_flags |= MM_PORT_PROBE_AT;
+     if (self->priv->qcdm || self->priv->qcdm_required)
+         plugin_expected_flags |= MM_PORT_PROBE_QCDM;
+-- 
+2.43.0
+

From 7b33aea7dcc082245ad428b3b0c61b4659d1694d Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sun, 11 Oct 2026 16:22:11 +0200
Subject: [PATCH 27/37] board: enable cellular modem support in the x86_64
 image

The modem YANG models and services are only installed with the modem
feature, so the x86_64 image used for the test system has no way to
test the modem configuration.

Signed-off-by: Joachim Wiberg 
---
 configs/x86_64_defconfig | 1 +
 1 file changed, 1 insertion(+)

diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig
index 16a8ebf1a..e9ac7d5c5 100644
--- a/configs/x86_64_defconfig
+++ b/configs/x86_64_defconfig
@@ -147,6 +147,7 @@ IX_HOME="https://github.com/kernelkit/infix/"
 IX_DOC="https://www.kernelkit.org/infix/"
 IX_SUPPORT="mailto:kernelkit@googlegroups.com"
 BR2_PACKAGE_FEATURE_GPS=y
+BR2_PACKAGE_FEATURE_MODEM=y
 BR2_PACKAGE_FEATURE_WIFI=y
 BR2_PACKAGE_FEATURE_WIFI_HWSIM=y
 BR2_PACKAGE_FEATURE_WIFI_MEDIATEK=y

From 2d405b531b154f57617a75adadb1ebb3bfbab710 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Mon, 12 Oct 2026 06:58:45 +0200
Subject: [PATCH 28/37] modem: add a virtual modem for testing

There is no way to test modem support without modem hardware, neither
in QEMU nor in CI.

Add FEATURE_MODEM_VIRTUAL, enabled in the x86_64 image: a USB gadget
with one ACM function on dummy_hcd, which loops it back to the host
side of the same kernel.  The host side is an AT modem on ttyACM,
probed by ModemManager and anchored by confd like a real one.  The
gadget side, ttyGS0, is served by an AT command emulator, and a data
call is answered by a pppd in its own network namespace.

The modem is created at boot by 00-modemsim, only on nodes the topology
gives a modem through the opt/modem fw_cfg.  Its bus is authorized by
00-modemsim and not listed as a USB port by 00-probe, it is not a port
anyone plugs into.

Signed-off-by: Joachim Wiberg 
---
 .../rootfs/usr/libexec/infix/init.d/00-probe  |   3 +
 configs/x86_64_defconfig                      |   1 +
 package/feature-modem/00-modemsim             |  69 +++++
 package/feature-modem/Config.in               |  13 +
 package/feature-modem/feature-modem.mk        |  21 ++
 package/feature-modem/modemsim                | 237 ++++++++++++++++++
 package/feature-modem/modemsim.conf           |   5 +
 7 files changed, 349 insertions(+)
 create mode 100755 package/feature-modem/00-modemsim
 create mode 100755 package/feature-modem/modemsim
 create mode 100644 package/feature-modem/modemsim.conf

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 b10557e3e..4e16360f2 100755
--- a/board/common/rootfs/usr/libexec/infix/init.d/00-probe
+++ b/board/common/rootfs/usr/libexec/infix/init.d/00-probe
@@ -452,6 +452,9 @@ def generic_usb_ports(out):
         device_path = os.path.join(usb_base, entry)
         if not os.path.exists(os.path.join(device_path, "authorized")):
             continue
+        # The virtual modem's bus (00-modemsim) is not a port
+        if "/dummy_hcd." in os.path.realpath(device_path):
+            continue
         num = entry.replace("usb", "")
         ports.append({"num": num, "path": device_path})
 
diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig
index e9ac7d5c5..520f85e45 100644
--- a/configs/x86_64_defconfig
+++ b/configs/x86_64_defconfig
@@ -148,6 +148,7 @@ IX_DOC="https://www.kernelkit.org/infix/"
 IX_SUPPORT="mailto:kernelkit@googlegroups.com"
 BR2_PACKAGE_FEATURE_GPS=y
 BR2_PACKAGE_FEATURE_MODEM=y
+BR2_PACKAGE_FEATURE_MODEM_VIRTUAL=y
 BR2_PACKAGE_FEATURE_WIFI=y
 BR2_PACKAGE_FEATURE_WIFI_HWSIM=y
 BR2_PACKAGE_FEATURE_WIFI_MEDIATEK=y
diff --git a/package/feature-modem/00-modemsim b/package/feature-modem/00-modemsim
new file mode 100755
index 000000000..bd2d3668b
--- /dev/null
+++ b/package/feature-modem/00-modemsim
@@ -0,0 +1,69 @@
+#!/bin/sh
+# Create a virtual USB modem for testing: a configfs gadget with one ACM
+# function on dummy_hcd, which loops it back to the host side of the
+# same kernel.  The host side is a cdc_acm /dev/ttyACMn, probed by
+# ModemManager like any AT modem, the gadget side is /dev/ttyGS0, served
+# by the modemsim AT emulator.  pppd for the network side of a data call
+# runs in the modemsim netns.
+#
+# Runs before 00-probe, so the system probe records the modem.  Only for
+# nodes the topology gives a modem: qeneth writes 1 to the opt/modem
+# fw_cfg, 0 or no file means no modem.  On success it sets the
+#  condition for the emulator service.  Installed only with
+# BR2_PACKAGE_FEATURE_MODEM_VIRTUAL.
+
+fwcfg=/sys/firmware/qemu_fw_cfg/by_name/opt/modem/raw
+gadget=/sys/kernel/config/usb_gadget/modemsim
+
+[ "$(cat "$fwcfg" 2>/dev/null)" -gt 0 ] 2>/dev/null || exit 0
+
+log()
+{
+	logger -p user.err -t 00-modemsim "$*"
+}
+
+modprobe dummy_hcd || exit 0
+mountpoint -q /sys/kernel/config || mount -t configfs none /sys/kernel/config
+
+mkdir -p $gadget
+echo 0x1d6b > $gadget/idVendor		# Linux Foundation
+echo 0x0104 > $gadget/idProduct		# Multifunction Composite Gadget
+mkdir -p $gadget/strings/0x409
+echo "Virtual"       > $gadget/strings/0x409/manufacturer
+echo "modemsim"      > $gadget/strings/0x409/product
+echo "000000000001"  > $gadget/strings/0x409/serialnumber
+
+mkdir -p $gadget/functions/acm.at $gadget/configs/c.1
+ln -sf $gadget/functions/acm.at $gadget/configs/c.1/
+
+for udc in /sys/class/udc/dummy_udc*; do break; done
+if [ ! -e "$udc" ]; then
+	log "no dummy UDC, cannot create virtual modem"
+	exit 1
+fi
+
+# USB devices need authorizing, see usbcore.authorized_default.  This
+# bus is not listed as a port by 00-probe, so confd never touches it.
+for bus in /sys/devices/platform/dummy_hcd.*/usb*; do
+	echo 1 > "$bus/authorized_default"
+done
+
+basename "$udc" > $gadget/UDC
+
+ip netns add modemsim 2>/dev/null
+
+# The host side enumerates asynchronously, wait (bounded ~10s) for it
+i=0
+while [ $i -lt 100 ]; do
+	for tty in /sys/devices/platform/dummy_hcd.*/usb*/*/*/tty/ttyACM*; do
+		if [ -e "$tty" ]; then
+			initctl -bq cond set modemsim
+			exit 0
+		fi
+	done
+	i=$((i + 1))
+	sleep 0.1
+done
+
+log "timed out waiting for the virtual modem's ttyACM"
+exit 1
diff --git a/package/feature-modem/Config.in b/package/feature-modem/Config.in
index 1e51a85b5..ad2621441 100644
--- a/package/feature-modem/Config.in
+++ b/package/feature-modem/Config.in
@@ -23,3 +23,16 @@ config BR2_PACKAGE_FEATURE_MODEM_QUALCOMM
 	  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).
+
+config BR2_PACKAGE_FEATURE_MODEM_VIRTUAL
+	bool "Virtual modem (dummy_hcd + AT emulator)"
+	depends on BR2_PACKAGE_FEATURE_MODEM
+	help
+	  A virtual USB modem for the QEMU test target, to exercise the
+	  modem support without modem hardware.  A USB gadget with one ACM
+	  function on dummy_hcd shows up as an AT modem on ttyACM, served by
+	  an AT command emulator; data calls run as PPP to a pppd in its own
+	  network namespace.  Created at boot only on nodes the topology
+	  gives a modem, see the opt/modem fw_cfg.
+
+	  Leave disabled on real hardware; it only provides a simulated modem.
diff --git a/package/feature-modem/feature-modem.mk b/package/feature-modem/feature-modem.mk
index 01c23b11d..382bb4e7a 100644
--- a/package/feature-modem/feature-modem.mk
+++ b/package/feature-modem/feature-modem.mk
@@ -23,6 +23,27 @@ define FEATURE_MODEM_LINUX_CONFIG_FIXUPS
 		$(call KCONFIG_SET_OPT,CONFIG_MHI_BUS,m)
 		$(call KCONFIG_ENABLE_OPT,CONFIG_QRTR_MHI)
 	)
+
+	# Virtual modem, an ACM gadget looped back by dummy_hcd, which is a
+	# module so nodes without a modem have no extra USB bus
+	$(if $(filter y,$(BR2_PACKAGE_FEATURE_MODEM_VIRTUAL)),
+		$(call KCONFIG_ENABLE_OPT,CONFIG_USB_GADGET)
+		$(call KCONFIG_SET_OPT,CONFIG_USB_DUMMY_HCD,m)
+		$(call KCONFIG_ENABLE_OPT,CONFIG_CONFIGFS_FS)
+		$(call KCONFIG_ENABLE_OPT,CONFIG_USB_CONFIGFS)
+		$(call KCONFIG_ENABLE_OPT,CONFIG_USB_CONFIGFS_ACM)
+		$(call KCONFIG_ENABLE_OPT,CONFIG_USB_ACM)
+	)
+endef
+
+ifeq ($(BR2_PACKAGE_FEATURE_MODEM_VIRTUAL),y)
+define FEATURE_MODEM_INSTALL_VIRTUAL
+	$(INSTALL) -D -m 0755 $(FEATURE_MODEM_PKGDIR)/00-modemsim $(TARGET_DIR)/usr/libexec/infix/init.d/00-modemsim
+	$(INSTALL) -D -m 0755 $(FEATURE_MODEM_PKGDIR)/modemsim $(TARGET_DIR)/usr/libexec/infix/modemsim
+	$(INSTALL) -D -m 0644 $(FEATURE_MODEM_PKGDIR)/modemsim.conf $(TARGET_DIR)/etc/finit.d/available/modemsim.conf
+	ln -sf ../available/modemsim.conf $(TARGET_DIR)/etc/finit.d/enabled/modemsim.conf
 endef
+FEATURE_MODEM_POST_INSTALL_TARGET_HOOKS += FEATURE_MODEM_INSTALL_VIRTUAL
+endif
 
 $(eval $(generic-package))
diff --git a/package/feature-modem/modemsim b/package/feature-modem/modemsim
new file mode 100755
index 000000000..2cb96c182
--- /dev/null
+++ b/package/feature-modem/modemsim
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+"""AT command emulator for the virtual modem, see 00-modemsim.
+
+Just enough of 3GPP TS 27.007 for ModemManager's generic plugin to probe,
+enable, register and dial.  Table driven: each entry is a regex on the
+command (without the AT prefix) and a handler returning response lines.
+Unknown commands answer ERROR and are logged.
+
+On dial the emulator answers CONNECT and hands the tty to pppd, which
+plays the network side from the modemsim netns.  When the PPP session
+ends, the emulator takes the tty back and answers AT commands again.
+"""
+import os
+import re
+import subprocess
+import sys
+import syslog
+import termios
+import time
+import tty
+
+TTY = "/dev/ttyGS0"
+NETNS = "modemsim"
+
+MANUFACTURER = "Virtual"
+MODEL = "modemsim"
+REVISION = "modemsim 1.0"
+IMEI = "356938035643809"
+# The 3GPP test network, MCC 001 MNC 01
+IMSI = "001010000000001"
+ICCID = "89001010000000000017"
+OPERATOR = "Virtual"
+MCCMNC = "00101"
+
+
+class Modem:
+    def __init__(self):
+        self.echo = True
+        self.cmee = 0
+        self.cfun = 1
+        self.creg = {"CREG": 0, "CGREG": 0, "CEREG": 0}
+        self.contexts = {}           # cid -> (pdp_type, apn)
+        self.active = set()          # active cids
+        self.dialed = False
+
+    # Each handler gets the regex match and returns the response lines,
+    # without the final result code.
+
+    def ident(self, m):
+        return [{"MI": MANUFACTURER, "MM": MODEL, "MR": REVISION,
+                 "SN": IMEI}[m.group(1).upper()]]
+
+    def ati(self, m):
+        return [MANUFACTURER, MODEL, "Revision: " + REVISION]
+
+    def gcap(self, m):
+        return ["+GCAP: +CGSM"]
+
+    def cpin(self, m):
+        return ["+CPIN: READY"]
+
+    def cimi(self, m):
+        return [IMSI]
+
+    def ccid(self, m):
+        return ["+CCID: " + ICCID]
+
+    def crsm_iccid(self, m):
+        # EF_ICCID is BCD with swapped nibbles, 90 00 is success
+        swapped = "".join(ICCID[i + 1] + ICCID[i] for i in range(0, 20, 2))
+        return ['+CRSM: 144,0,"%s"' % swapped]
+
+    def cfun_get(self, m):
+        return ["+CFUN: %d" % self.cfun]
+
+    def cfun_set(self, m):
+        self.cfun = int(m.group(1))
+        return []
+
+    def cmee_set(self, m):
+        self.cmee = int(m.group(1))
+        return []
+
+    def reg_set(self, m):
+        self.creg[m.group(1)] = int(m.group(2))
+        return []
+
+    def reg_get(self, m):
+        name = m.group(1)
+        mode = self.creg[name]
+        stat = 1 if self.cfun == 1 else 0      # home / not registered
+        if mode == 2 and stat == 1:
+            act = 7 if name == "CEREG" else 2
+            return ['+%s: %d,%d,"0001","00000A1B",%d' % (name, mode, stat, act)]
+        return ["+%s: %d,%d" % (name, mode, stat)]
+
+    def cops_get(self, m):
+        if self.cfun != 1:
+            return ["+COPS: 0"]
+        return ['+COPS: 0,0,"%s",7' % OPERATOR]
+
+    def cops_test(self, m):
+        return ['+COPS: (2,"%s","%s","%s",7),,(0-4),(0-2)'
+                % (OPERATOR, OPERATOR, MCCMNC)]
+
+    def csq(self, m):
+        return ["+CSQ: 20,99"]                # about -73 dBm
+
+    def cgdcont_test(self, m):
+        return ['+CGDCONT: (1-4),"IP",,,(0-2),(0-4)',
+                '+CGDCONT: (1-4),"IPV6",,,(0-2),(0-4)',
+                '+CGDCONT: (1-4),"IPV4V6",,,(0-2),(0-4)']
+
+    def cgdcont_get(self, m):
+        return ['+CGDCONT: %d,"%s","%s","0.0.0.0",0,0' % (cid, t, apn)
+                for cid, (t, apn) in sorted(self.contexts.items())]
+
+    def cgdcont_set(self, m):
+        self.contexts[int(m.group(1))] = (m.group(2), m.group(3))
+        return []
+
+    def cgact_get(self, m):
+        return ["+CGACT: %d,%d" % (cid, cid in self.active)
+                for cid in sorted(self.contexts)]
+
+    def cgact_set(self, m):
+        cid = int(m.group(2))
+        (self.active.add if m.group(1) == "1" else self.active.discard)(cid)
+        return []
+
+    def dial(self, m):
+        self.active.add(int(m.group(1) or 1))
+        self.dialed = True
+        return []
+
+    def ok(self, m):
+        return []
+
+    def set_echo(self, m):
+        self.echo = m.group(1) == "1"
+        return []
+
+    TABLE = [(re.compile(pattern, re.IGNORECASE), name) for pattern, name in [
+        (r"$",                            "ok"),         # bare AT
+        (r"E([01])$",                     "set_echo"),
+        (r"(?:V1|Q0|&F|Z|&C1|&D2|H0?|X\d|\+CTZU=\d|\+IFC=.*|\+CSCS=.*|\+CMGF=.*|\+CNMI=.*"
+         r"|\+CGEREP=.*|\+CMER=.*|\+CLIP=.*|\+CCWA=.*|\+CPMS=.*)$", "ok"),
+        (r"\+C?G(MI|MM|MR|SN)$",           "ident"),
+        (r"I$",                           "ati"),
+        (r"\+GCAP$",                      "gcap"),
+        (r"\+CPIN\?$",                    "cpin"),
+        (r"\+CIMI$",                      "cimi"),
+        (r"\+CCID$",                      "ccid"),
+        (r"\+CRSM=176,12258,0,0,10$",     "crsm_iccid"),
+        (r"\+CFUN\?$",                    "cfun_get"),
+        (r"\+CFUN=(\d)(?:,\d)?$",         "cfun_set"),
+        (r"\+CMEE=(\d)$",                 "cmee_set"),
+        (r"\+(CREG|CGREG|CEREG)=(\d)$",   "reg_set"),
+        (r"\+(CREG|CGREG|CEREG)\?$",      "reg_get"),
+        (r"\+COPS\?$",                    "cops_get"),
+        (r"\+COPS=\?$",                   "cops_test"),
+        (r"\+COPS=.*$",                   "ok"),
+        (r"\+CSQ$",                       "csq"),
+        (r"\+CGDCONT=\?$",                "cgdcont_test"),
+        (r"\+CGDCONT\?$",                 "cgdcont_get"),
+        (r'\+CGDCONT=(\d+),"([^"]*)","([^"]*)".*$', "cgdcont_set"),
+        (r"\+CGACT\?$",                   "cgact_get"),
+        (r"\+CGACT=([01]),(\d+)$",        "cgact_set"),
+        (r"D\*99(?:\*\*\*(\d+))?#$",      "dial"),
+    ]]
+
+    def handle(self, line):
+        if line[:2].upper() != "AT":
+            return []
+        cmd = line[2:]
+        for pattern, name in self.TABLE:
+            m = pattern.match(cmd)
+            if m:
+                resp = getattr(self, name)(m)
+                return ["CONNECT"] if self.dialed else resp + ["OK"]
+        syslog.syslog(syslog.LOG_NOTICE, "unhandled: AT%s" % cmd)
+        return ["+CME ERROR: 4" if self.cmee else "ERROR"]
+
+
+def pppd(path):
+    """pppd for the network side of a call on path."""
+    return ["ip", "netns", "exec", NETNS, "pppd", path, "nodetach",
+            "noauth", "local", "nocrtscts", "noipv6", "10.66.0.1:10.66.0.2",
+            "ms-dns", "10.66.0.1", "lcp-echo-interval", "10",
+            "lcp-echo-failure", "3"]
+
+
+def serve(path, modem):
+    """Answer AT commands on path until a dial, True, or a hangup, False."""
+    fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
+    tty.setraw(fd)
+    termios.tcflush(fd, termios.TCIOFLUSH)
+
+    buf = b""
+    try:
+        while not modem.dialed:
+            data = os.read(fd, 256)
+            if not data:
+                return False
+            if modem.echo:
+                os.write(fd, data)
+            buf += data
+            while b"\r" in buf and not modem.dialed:
+                raw, buf = buf.split(b"\r", 1)
+                line = raw.strip(b"\n").decode(errors="replace").strip()
+                if not line:
+                    continue
+                for resp in modem.handle(line):
+                    os.write(fd, b"\r\n" + resp.encode() + b"\r\n")
+    finally:
+        os.close(fd)
+    return True
+
+
+def main():
+    syslog.openlog("modemsim", syslog.LOG_PID, syslog.LOG_DAEMON)
+    path = sys.argv[1] if len(sys.argv) > 1 else TTY
+
+    modem = Modem()
+    while True:
+        if not serve(path, modem):
+            time.sleep(1)       # host side closed the port, reopen
+            continue
+        syslog.syslog(syslog.LOG_NOTICE, "dialed, starting PPP")
+        subprocess.run(pppd(path), check=False)
+        syslog.syslog(syslog.LOG_NOTICE, "PPP session ended")
+        modem.dialed = False
+        modem.active.clear()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/feature-modem/modemsim.conf b/package/feature-modem/modemsim.conf
new file mode 100644
index 000000000..67e7fad07
--- /dev/null
+++ b/package/feature-modem/modemsim.conf
@@ -0,0 +1,5 @@
+# Virtual modem AT emulator, QEMU test target only.  Installed with
+# BR2_PACKAGE_FEATURE_MODEM_VIRTUAL, started by 00-modemsim setting the
+#  condition once the virtual modem exists.
+service [2345]  name:modemsim \
+	/usr/libexec/infix/modemsim /dev/ttyGS0 -- Virtual modem (AT emulator)

From a8eb65ba17906e8507b7b6e68a18c89edf792c78 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sun, 11 Oct 2026 16:22:10 +0200
Subject: [PATCH 29/37] test/infamy: get_iface() returns None for a missing
 interface

get_iface() is documented to return None when the interface does not
exist, but get_data() returns None for an empty reply, so it crashed
instead:

    AttributeError: 'NoneType' object has no attribute 'get'

Return None when there is no data.

Signed-off-by: Joachim Wiberg 
---
 test/infamy/transport.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/test/infamy/transport.py b/test/infamy/transport.py
index d8d36a2d1..09b98d464 100644
--- a/test/infamy/transport.py
+++ b/test/infamy/transport.py
@@ -93,6 +93,8 @@ def __getitem__(self, key):
     def get_iface(self, name):
         """Fetch target dict for iface and extract param from JSON"""
         content = self.get_data(infamy.iface.get_xpath(name))
+        if not content:
+            return None
         interfaces = content.get("interfaces", {}).get("interface", {})
 
         # KeyedList does not support `.get()`

From 7cd869e33ffb415c6bfaa93e576ee068a4707b5d Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sun, 11 Oct 2026 16:22:12 +0200
Subject: [PATCH 30/37] test: verify cellular modem basic configuration

New test suite for cellular modems.  The first test runs without modem
hardware: it configures a modem and a SIM component and a wwan
interface on top of them, verifies the placeholder interface, that a
wwan interface referencing a SIM as its modem is rejected, and that the
placeholder is removed with the interface.

Signed-off-by: Joachim Wiberg 
---
 test/case/all.yaml                 |  3 +
 test/case/modem/Readme.adoc        |  8 +++
 test/case/modem/all.yaml           |  3 +
 test/case/modem/basic/Readme.adoc  |  1 +
 test/case/modem/basic/test.adoc    | 39 +++++++++++++
 test/case/modem/basic/test.py      | 94 ++++++++++++++++++++++++++++++
 test/case/modem/basic/topology.dot |  1 +
 test/case/modem/basic/topology.svg | 33 +++++++++++
 8 files changed, 182 insertions(+)
 create mode 100644 test/case/modem/Readme.adoc
 create mode 100644 test/case/modem/all.yaml
 create mode 120000 test/case/modem/basic/Readme.adoc
 create mode 100644 test/case/modem/basic/test.adoc
 create mode 100755 test/case/modem/basic/test.py
 create mode 120000 test/case/modem/basic/topology.dot
 create mode 100644 test/case/modem/basic/topology.svg

diff --git a/test/case/all.yaml b/test/case/all.yaml
index 8389277a6..d2ae879de 100644
--- a/test/case/all.yaml
+++ b/test/case/all.yaml
@@ -42,6 +42,9 @@
 - name: "Hardware"
   suite: hardware/all.yaml
 
+- name: "Cellular Modem"
+  suite: modem/all.yaml
+
 - name: "Services"
   suite: services/all.yaml
 
diff --git a/test/case/modem/Readme.adoc b/test/case/modem/Readme.adoc
new file mode 100644
index 000000000..a5e5bfd1d
--- /dev/null
+++ b/test/case/modem/Readme.adoc
@@ -0,0 +1,8 @@
+:testgroup:
+== Cellular Modem
+
+Tests verifying cellular modem (WWAN) configuration:
+
+ - Modem and SIM hardware components, wwan interface and data bearer
+
+include::basic/Readme.adoc[]
diff --git a/test/case/modem/all.yaml b/test/case/modem/all.yaml
new file mode 100644
index 000000000..87e38be48
--- /dev/null
+++ b/test/case/modem/all.yaml
@@ -0,0 +1,3 @@
+---
+- name: Cellular Modem Basic Configuration
+  case: basic/test.py
diff --git a/test/case/modem/basic/Readme.adoc b/test/case/modem/basic/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/modem/basic/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/modem/basic/test.adoc b/test/case/modem/basic/test.adoc
new file mode 100644
index 000000000..e5a7ef7ae
--- /dev/null
+++ b/test/case/modem/basic/test.adoc
@@ -0,0 +1,39 @@
+=== Cellular Modem Basic Configuration
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/modem/basic]
+
+==== Description
+
+Verify that a cellular modem can be configured without any modem
+hardware present: a modem and a SIM hardware component, and a wwan
+interface with a data bearer referencing them.
+
+With no modem attached, the wwan interface is created as a placeholder
+once the modem's probe-timeout has expired, so that IP configuration
+on top of it can proceed.  The placeholder is removed again when the
+interface is removed from the configuration.
+
+The test also verifies that a wwan interface cannot reference a hardware
+component of the wrong class, e.g., a SIM card in place of a modem, by
+sending the same interface twice: first with the SIM as its modem, which
+must be rejected, then with the modem, which must be accepted.
+
+This test does not exercise a modem, only the configuration model and
+how it is applied.
+
+==== Topology
+
+image::topology.svg[Cellular Modem Basic Configuration topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUT
+. Configure modem and SIM hardware components
+. Configure wwan0 with a data bearer on modem0 and sim0
+. Verify wwan0 exists as a modem interface
+. Verify a wwan interface cannot use a SIM as modem
+. Verify the same wwan interface is accepted on modem0
+. Remove wwan0 and wwan1, verify the interfaces are gone
+. Remove modem and SIM hardware components
+
+
diff --git a/test/case/modem/basic/test.py b/test/case/modem/basic/test.py
new file mode 100755
index 000000000..b0ea82126
--- /dev/null
+++ b/test/case/modem/basic/test.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""Cellular modem basic configuration
+
+Verify that a cellular modem can be configured without any modem
+hardware present: a modem and a SIM hardware component, and a wwan
+interface with a data bearer referencing them.
+
+With no modem attached, the wwan interface is created as a placeholder
+once the modem's probe-timeout has expired, so that IP configuration
+on top of it can proceed.  The placeholder is removed again when the
+interface is removed from the configuration.
+
+The test also verifies that a wwan interface cannot reference a hardware
+component of the wrong class, e.g., a SIM card in place of a modem, by
+sending the same interface twice: first with the SIM as its modem, which
+must be rejected, then with the modem, which must be accepted.
+
+This test does not exercise a modem, only the configuration model and
+how it is applied.
+"""
+import infamy
+import infamy.iface as iface
+from infamy.util import until
+
+
+def wwan_iface(name, modem, sim):
+    return {"ietf-interfaces": {"interfaces": {"interface": [{
+        "name": name,
+        "type": "infix-if-type:modem",
+        "infix-interfaces:wwan": {
+            "modem": modem,
+            "sim": sim,
+            "bearer": {"apn": "internet"},
+        },
+    }]}}}
+
+
+with infamy.Test() as test:
+    with test.step("Set up topology and attach to target DUT"):
+        env = infamy.Env()
+        target = env.attach("target", "mgmt")
+
+        if not target.has_feature("infix-hardware", "modem"):
+            print("DUT does not advertise the 'modem' feature, skipping")
+            test.skip()
+
+    with test.step("Configure modem and SIM hardware components"):
+        target.put_config_dicts({"ietf-hardware": {"hardware": {"component": [
+            {
+                "name": "modem0",
+                "class": "infix-hardware:modem",
+                "state": {"admin-state": "unlocked"},
+                "infix-hardware:modem": {"probe-timeout": 1},
+            },
+            {
+                "name": "sim0",
+                "class": "infix-hardware:sim",
+                "state": {"admin-state": "unlocked"},
+            },
+        ]}}})
+
+    with test.step("Configure wwan0 with a data bearer on modem0 and sim0"):
+        target.put_config_dicts(wwan_iface("wwan0", "modem0", "sim0"))
+
+    with test.step("Verify wwan0 exists as a modem interface"):
+        ifc = until(lambda: target.get_iface("wwan0"), attempts=30)
+        assert ifc["type"] == "infix-if-type:modem", \
+            f"unexpected type {ifc['type']}"
+
+    with test.step("Verify a wwan interface cannot use a SIM as modem"):
+        try:
+            target.put_config_dicts(wwan_iface("wwan1", "sim0", "sim0"),
+                                    retries=1)
+        except Exception as err:
+            print(f"Rejected: {err}")
+        else:
+            test.fail("wwan1 with a SIM as modem was accepted")
+
+    with test.step("Verify the same wwan interface is accepted on modem0"):
+        target.put_config_dicts(wwan_iface("wwan1", "modem0", "sim0"))
+
+    with test.step("Remove wwan0 and wwan1, verify the interfaces are gone"):
+        target.delete_xpaths([iface.get_xpath("wwan0"),
+                              iface.get_xpath("wwan1")])
+        until(lambda: target.get_iface("wwan0") is None
+              and target.get_iface("wwan1") is None, attempts=30)
+
+    with test.step("Remove modem and SIM hardware components"):
+        target.delete_xpaths([
+            "/ietf-hardware:hardware/component[name='modem0']",
+            "/ietf-hardware:hardware/component[name='sim0']",
+        ])
+
+    test.succeed()
diff --git a/test/case/modem/basic/topology.dot b/test/case/modem/basic/topology.dot
new file mode 120000
index 000000000..02b788692
--- /dev/null
+++ b/test/case/modem/basic/topology.dot
@@ -0,0 +1 @@
+../../../infamy/topologies/1x1.dot
\ No newline at end of file
diff --git a/test/case/modem/basic/topology.svg b/test/case/modem/basic/topology.svg
new file mode 100644
index 000000000..6fc6f47a8
--- /dev/null
+++ b/test/case/modem/basic/topology.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+1x1
+
+
+
+host
+
+host
+
+mgmt
+
+
+
+target
+
+mgmt
+
+target
+
+
+
+host:mgmt--target:mgmt
+
+
+
+

From 4136db4dc2b358017019c39300e94c59637cd202 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Mon, 12 Oct 2026 06:58:51 +0200
Subject: [PATCH 31/37] test: verify a modem data connection

New test, modem/connect, on the DUT with the virtual modem: anchor the
detected modem to its USB port, configure its SIM and a wwan interface,
then verify that the modem registers, that wwan0 gets its address and
default route from the network, and that locking the modem takes the
data connection down.

The qn_modem node attribute in the physical topology gives a node the
virtual modem, through the opt/modem fw_cfg.  dut1 has one in both the
dual and quad topologies, and provides "modem" for tests to require.

Signed-off-by: Joachim Wiberg 
---
 test/case/modem/Readme.adoc                 |   3 +
 test/case/modem/all.yaml                    |   3 +
 test/case/modem/connect/Readme.adoc         |   1 +
 test/case/modem/connect/test.adoc           |  38 ++++++
 test/case/modem/connect/test.py             | 133 ++++++++++++++++++++
 test/case/modem/connect/topology.dot        |  23 ++++
 test/case/modem/connect/topology.svg        |  33 +++++
 test/templates/inc/infix-fwcfg.mustache     |   1 +
 test/templates/inc/infix-modem.mustache     |   3 +
 test/templates/infix-bios-x86_64.mustache   |   1 +
 test/templates/infix-kernel-x86_64.mustache |   1 +
 test/templates/infix-x86_64.mustache        |   1 +
 test/virt/dual/topology.dot.in              |   5 +-
 test/virt/quad/topology.dot.in              |   3 +-
 14 files changed, 246 insertions(+), 3 deletions(-)
 create mode 120000 test/case/modem/connect/Readme.adoc
 create mode 100644 test/case/modem/connect/test.adoc
 create mode 100755 test/case/modem/connect/test.py
 create mode 100644 test/case/modem/connect/topology.dot
 create mode 100644 test/case/modem/connect/topology.svg
 create mode 100644 test/templates/inc/infix-modem.mustache

diff --git a/test/case/modem/Readme.adoc b/test/case/modem/Readme.adoc
index a5e5bfd1d..e5b5d1cca 100644
--- a/test/case/modem/Readme.adoc
+++ b/test/case/modem/Readme.adoc
@@ -4,5 +4,8 @@
 Tests verifying cellular modem (WWAN) configuration:
 
  - Modem and SIM hardware components, wwan interface and data bearer
+ - Data connection over a virtual AT modem, from detection to an IP link
 
 include::basic/Readme.adoc[]
+
+include::connect/Readme.adoc[]
diff --git a/test/case/modem/all.yaml b/test/case/modem/all.yaml
index 87e38be48..444e4c6f6 100644
--- a/test/case/modem/all.yaml
+++ b/test/case/modem/all.yaml
@@ -1,3 +1,6 @@
 ---
 - name: Cellular Modem Basic Configuration
   case: basic/test.py
+
+- name: Cellular Modem Data Connection
+  case: connect/test.py
diff --git a/test/case/modem/connect/Readme.adoc b/test/case/modem/connect/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/modem/connect/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/modem/connect/test.adoc b/test/case/modem/connect/test.adoc
new file mode 100644
index 000000000..103e24139
--- /dev/null
+++ b/test/case/modem/connect/test.adoc
@@ -0,0 +1,38 @@
+=== Cellular Modem Data Connection
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/modem/connect]
+
+==== Description
+
+Verify a data connection over a modem, from detection to an IP link.  On
+the QEMU test target the modem is virtual: a USB modem looped back in the
+DUT's own kernel, answering AT commands like an AT-only cellular modem,
+with a PPP peer playing the network.
+
+The modem is anchored to the USB port it was detected on, then its SIM
+and a wwan interface with a data bearer are configured.  The modem must
+register on the network, and the wwan interface come up with the address
+and default route from the network.  Locking the modem must take the
+data connection down again.
+
+The nameservers from the network are added next to those from other
+interfaces, which must remain.
+
+==== Topology
+
+image::topology.svg[Cellular Modem Data Connection topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUT
+. Find the detected modem and its USB port
+. Add a nameserver from another interface
+. Configure modem0 anchored to its port, sim0 and wwan0
+. Verify modem0 registers on the network with sim0 unlocked
+. Verify wwan0 is up with the address from the network
+. Verify the default route via wwan0
+. Verify the nameservers from wwan0 and the other interface
+. Lock modem0 and verify the data connection goes down
+. Remove the nameserver from the other interface
+
+
diff --git a/test/case/modem/connect/test.py b/test/case/modem/connect/test.py
new file mode 100755
index 000000000..781095227
--- /dev/null
+++ b/test/case/modem/connect/test.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""Cellular modem data connection
+
+Verify a data connection over a modem, from detection to an IP link.  On
+the QEMU test target the modem is virtual: a USB modem looped back in the
+DUT's own kernel, answering AT commands like an AT-only cellular modem,
+with a PPP peer playing the network.
+
+The modem is anchored to the USB port it was detected on, then its SIM
+and a wwan interface with a data bearer are configured.  The modem must
+register on the network, and the wwan interface come up with the address
+and default route from the network.  Locking the modem must take the
+data connection down again.
+
+The nameservers from the network are added next to those from other
+interfaces, which must remain.
+"""
+import infamy
+import infamy.route as route
+from infamy.util import until
+
+ADDRESS = "10.66.0.2"       # handed out by the virtual modem's network
+MODEM_DNS = "10.66.0.1"     # ditto
+OTHER_DNS = "192.0.2.53"
+OTHER_IF = "test0"
+
+
+def components(target):
+    """Hardware components in operational, by name."""
+    data = target.get_data("/ietf-hardware:hardware") or {}
+    return {c.get("name"): c for c in
+            data.get("hardware", {}).get("component", [])}
+
+
+def state(comp, key):
+    """Augmented state container, with or without module prefix."""
+    comp = comp or {}
+    return comp.get(key, comp.get("infix-hardware:" + key, {}))
+
+
+def registered(target):
+    comps = components(target)
+    return state(comps.get("modem0"), "modem-state").get("state") in \
+        ("registered", "connecting", "connected") and \
+        state(comps.get("sim0"), "sim-state").get("state") == "unlocked"
+
+
+def has_address(target):
+    ifc = target.get_iface("wwan0")
+    if not ifc:
+        return False
+    ipv4 = ifc.get("ipv4") or ifc.get("ietf-ip:ipv4") or {}
+    return any(a.get("ip") == ADDRESS for a in ipv4.get("address", []))
+
+
+def nameservers(tgtssh):
+    out = tgtssh.runsh("sudo resolvconf -l").stdout
+    return [line.split()[1] for line in out.splitlines()
+            if line.startswith("nameserver")]
+
+
+def modem(state):
+    return {
+        "name": "modem0",
+        "class": "infix-hardware:modem",
+        "uri": [uri],
+        "state": {"admin-state": state},
+    }
+
+
+with infamy.Test() as test:
+    with test.step("Set up topology and attach to target DUT"):
+        env = infamy.Env()
+        target = env.attach("target", "mgmt")
+        tgtssh = env.attach("target", "mgmt", "ssh")
+
+        if not target.has_feature("infix-hardware", "modem"):
+            print("DUT does not advertise the 'modem' feature, skipping")
+            test.skip()
+
+    with test.step("Find the detected modem and its USB port"):
+        detected = until(lambda: components(target).get("modem0"), attempts=30)
+        uri = next(iter(detected["uri"]))
+        print(f"modem0 detected at {uri}")
+
+    with test.step("Add a nameserver from another interface"):
+        tgtssh.runsh(f"echo 'nameserver {OTHER_DNS}' | sudo resolvconf -a {OTHER_IF}")
+
+    with test.step("Configure modem0 anchored to its port, sim0 and wwan0"):
+        target.put_config_dicts({
+            "ietf-hardware": {"hardware": {"component": [
+                modem("unlocked"),
+                {
+                    "name": "sim0",
+                    "class": "infix-hardware:sim",
+                    "state": {"admin-state": "unlocked"},
+                },
+            ]}},
+            "ietf-interfaces": {"interfaces": {"interface": [{
+                "name": "wwan0",
+                "type": "infix-if-type:modem",
+                "infix-interfaces:wwan": {
+                    "modem": "modem0",
+                    "sim": "sim0",
+                    "bearer": {"apn": "internet", "ip-type": "ipv4"},
+                },
+            }]}},
+        })
+
+    with test.step("Verify modem0 registers on the network with sim0 unlocked"):
+        until(lambda: registered(target), attempts=90)
+
+    with test.step("Verify wwan0 is up with the address from the network"):
+        until(lambda: has_address(target), attempts=60)
+
+    with test.step("Verify the default route via wwan0"):
+        until(lambda: route.ipv4_route_exist(target, "0.0.0.0/0", pref=200),
+              attempts=30)
+
+    with test.step("Verify the nameservers from wwan0 and the other interface"):
+        until(lambda: MODEM_DNS in nameservers(tgtssh), attempts=30)
+        if OTHER_DNS not in nameservers(tgtssh):
+            test.fail(f"nameserver {OTHER_DNS} from {OTHER_IF} is gone")
+
+    with test.step("Lock modem0 and verify the data connection goes down"):
+        target.put_config_dicts({"ietf-hardware": {"hardware": {
+            "component": [modem("locked")]}}})
+        until(lambda: not has_address(target), attempts=30)
+
+    with test.step("Remove the nameserver from the other interface"):
+        tgtssh.runsh(f"sudo resolvconf -d {OTHER_IF}")
+
+    test.succeed()
diff --git a/test/case/modem/connect/topology.dot b/test/case/modem/connect/topology.dot
new file mode 100644
index 000000000..08934b784
--- /dev/null
+++ b/test/case/modem/connect/topology.dot
@@ -0,0 +1,23 @@
+graph "1x1" {
+	layout="neato";
+	overlap="false";
+	esep="+80";
+
+        node [shape=record, fontname="DejaVu Sans Mono, Book"];
+	edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
+
+	host [
+	    label="host | {  mgmt }",
+	    pos="0,12!",
+	    requires="controller",
+	];
+
+        target [
+	    label="{  mgmt } | target",
+	    pos="10,12!",
+
+	    requires="infix modem",
+	];
+
+	host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"]
+}
diff --git a/test/case/modem/connect/topology.svg b/test/case/modem/connect/topology.svg
new file mode 100644
index 000000000..6fc6f47a8
--- /dev/null
+++ b/test/case/modem/connect/topology.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+1x1
+
+
+
+host
+
+host
+
+mgmt
+
+
+
+target
+
+mgmt
+
+target
+
+
+
+host:mgmt--target:mgmt
+
+
+
+
diff --git a/test/templates/inc/infix-fwcfg.mustache b/test/templates/inc/infix-fwcfg.mustache
index 914b87eab..2689a41ef 100644
--- a/test/templates/inc/infix-fwcfg.mustache
+++ b/test/templates/inc/infix-fwcfg.mustache
@@ -1,2 +1,3 @@
   -fw_cfg name=opt/mactab,file=$imgdir/{{name}}.mactab \
   -fw_cfg name=opt/wifi,file=$imgdir/{{name}}.wifi \
+  -fw_cfg name=opt/modem,file=$imgdir/{{name}}.modem \
diff --git a/test/templates/inc/infix-modem.mustache b/test/templates/inc/infix-modem.mustache
new file mode 100644
index 000000000..dcc7fa52c
--- /dev/null
+++ b/test/templates/inc/infix-modem.mustache
@@ -0,0 +1,3 @@
+# Virtual modem for this node, 1 or 0, from the qn_modem node attribute of
+# the physical topology.  Read at boot by 00-modemsim (fw_cfg opt/modem).
+echo "{{#qn_modem}}{{qn_modem}}{{/qn_modem}}{{^qn_modem}}0{{/qn_modem}}" > $imgdir/{{name}}.modem
diff --git a/test/templates/infix-bios-x86_64.mustache b/test/templates/infix-bios-x86_64.mustache
index eddecec07..2fe4ffa51 100644
--- a/test/templates/infix-bios-x86_64.mustache
+++ b/test/templates/infix-bios-x86_64.mustache
@@ -14,6 +14,7 @@ qemu-img create -f qcow2 -o backing_file=$origimg -F qcow2 {{name}}.qcow2
 {{> inc/infix-mactab}}
 
 {{> inc/infix-wifi}}
+{{> inc/infix-modem}}
 
 {{#qn_gps}}
 mkfifo /tmp/{{name}}-gps.in /tmp/{{name}}-gps.out 2>/dev/null
diff --git a/test/templates/infix-kernel-x86_64.mustache b/test/templates/infix-kernel-x86_64.mustache
index bd468ac00..d5e814b02 100644
--- a/test/templates/infix-kernel-x86_64.mustache
+++ b/test/templates/infix-kernel-x86_64.mustache
@@ -16,6 +16,7 @@ unsquashfs -n -f -d $imgdir $img boot/bzImage >/dev/null
 {{> inc/infix-mactab}}
 
 {{> inc/infix-wifi}}
+{{> inc/infix-modem}}
 
 exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \
   -m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}384M{{/qn_mem}} \
diff --git a/test/templates/infix-x86_64.mustache b/test/templates/infix-x86_64.mustache
index a7bdc0d54..a8d8673cd 100644
--- a/test/templates/infix-x86_64.mustache
+++ b/test/templates/infix-x86_64.mustache
@@ -16,6 +16,7 @@ unsquashfs -n -f -d $imgdir $img boot/bzImage >/dev/null
 {{> inc/infix-mactab}}
 
 {{> inc/infix-wifi}}
+{{> inc/infix-modem}}
 
 exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \
   -m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}384M{{/qn_mem}} \
diff --git a/test/virt/dual/topology.dot.in b/test/virt/dual/topology.dot.in
index 70dbd7419..328ca6924 100644
--- a/test/virt/dual/topology.dot.in
+++ b/test/virt/dual/topology.dot.in
@@ -24,11 +24,12 @@ graph "dual" {
         dut1 [
 	    label="{  e1 |  e2 |  e3 } | dut1 | {  e4 |  e5 |  e6 }",
 	    pos="10,18!",
-	    provides="infix watchdog",
+	    provides="infix watchdog modem",
 	    expected_boot="primary",
 	    qn_console=9001,
 	    qn_mem="384M",
-	    qn_usb="dut1.usb"
+	    qn_usb="dut1.usb",
+	    qn_modem="1"
 	];
         dut2 [
 	    label="{  e1 |  e2 |  e3 } | dut2 | {  e4 |  e5 |  e6 }",
diff --git a/test/virt/quad/topology.dot.in b/test/virt/quad/topology.dot.in
index f42e9c22c..9cb97f5a0 100644
--- a/test/virt/quad/topology.dot.in
+++ b/test/virt/quad/topology.dot.in
@@ -23,11 +23,12 @@ graph "quad" {
         dut1 [
 	    label="{  e1 |  e2 |  e3 |  e4 } | dut1 | {  e5 |  e6 |  e7 |  e8 } | {  radio0 |  radio1 |  radio2 |  radio3 }",
 	    pos="10,30!",
-	    provides="infix watchdog gps",
+	    provides="infix watchdog gps modem",
 	    expected_boot="primary",
 	    qn_console=9001,
 	    qn_mem="384M",
 	    qn_usb="dut1.usb",
+	    qn_modem="1",
 	    qn_gps="true"
 	    // NB: dut1 has two extra radios, radio2 and radio3, wired to the
 	    // dedicated cell2 (below).  With both on one cell this DUT runs a

From 85466b79aa07a4c608585552fc16c9bde6067024 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sat, 17 Oct 2026 18:18:08 +0200
Subject: [PATCH 32/37] configs: make cellular modem support opt-in

Modem support pulls ModemManager, libqmi, libmbim, and modemd into the
build, and there is no modem hardware in any test system yet.  Keep it
out of the default builds for now.

Drop it from the aarch64 and x86_64 defconfigs and add a modem snippet
instead, which also enables the virtual modem for the QEMU test target:

    make x86_64_defconfig apply-modem

The modem tests skip on builds without it.

Signed-off-by: Joachim Wiberg 
---
 configs/aarch64_defconfig   |  1 -
 configs/snippets/modem.conf |  5 +++++
 configs/x86_64_defconfig    |  2 --
 doc/ChangeLog.md            |  3 ++-
 doc/modem.md                | 12 ++++++++++++
 5 files changed, 19 insertions(+), 4 deletions(-)
 create mode 100644 configs/snippets/modem.conf

diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig
index 29d283015..4680acd20 100644
--- a/configs/aarch64_defconfig
+++ b/configs/aarch64_defconfig
@@ -153,7 +153,6 @@ IX_HOME="https://github.com/kernelkit/infix/"
 IX_DOC="https://www.kernelkit.org/infix/"
 IX_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/configs/snippets/modem.conf b/configs/snippets/modem.conf
new file mode 100644
index 000000000..bd2c71d3b
--- /dev/null
+++ b/configs/snippets/modem.conf
@@ -0,0 +1,5 @@
+# Cellular modem support, opt-in since it pulls in ModemManager, libqmi,
+# libmbim, and modemd.  The virtual modem is for the QEMU test target,
+# it does nothing on hardware.
+BR2_PACKAGE_FEATURE_MODEM=y
+BR2_PACKAGE_FEATURE_MODEM_VIRTUAL=y
diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig
index 520f85e45..16a8ebf1a 100644
--- a/configs/x86_64_defconfig
+++ b/configs/x86_64_defconfig
@@ -147,8 +147,6 @@ IX_HOME="https://github.com/kernelkit/infix/"
 IX_DOC="https://www.kernelkit.org/infix/"
 IX_SUPPORT="mailto:kernelkit@googlegroups.com"
 BR2_PACKAGE_FEATURE_GPS=y
-BR2_PACKAGE_FEATURE_MODEM=y
-BR2_PACKAGE_FEATURE_MODEM_VIRTUAL=y
 BR2_PACKAGE_FEATURE_WIFI=y
 BR2_PACKAGE_FEATURE_WIFI_HWSIM=y
 BR2_PACKAGE_FEATURE_WIFI_MEDIATEK=y
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index d576a075c..a015f1010 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -77,7 +77,8 @@ All notable changes to the project are documented in this file.
 - Add cellular modem (WWAN) support for USB-attached MBIM/QMI modems,
   including USB dongles, mPCIe cards, and M.2 Key-B modules.  Multiple
   APNs per modem, SIM PIN configuration, and NMEA routing from modem to
-  GPS subsystem.  See the [Modem User's Guide][modem] for details
+  GPS subsystem.  Not part of the default builds, enable it with the
+  `modem` configuration snippet, see the [Modem User's Guide][modem]
 
 ### Fixes
 
diff --git a/doc/modem.md b/doc/modem.md
index 7fe192be5..bb5d5a35c 100644
--- a/doc/modem.md
+++ b/doc/modem.md
@@ -13,6 +13,18 @@ Setup involves three configuration items:
 - A `wwan0` network interface that references both and carries the
   bearer (APN) configuration
 
+> [!NOTE]
+> Modem support is not part of the default builds.  Enable it with the
+> `modem` configuration snippet when building, e.g., for 64-bit Arm:
+>
+> ```
+> make aarch64_defconfig apply-modem
+> make
+> ```
+>
+> Use the defconfig for your architecture, e.g., `riscv64_defconfig` or
+> `x86_64_defconfig`, the snippet works with any of them.
+
 ## Architecture
 
 Cellular modem support is built in three layers:

From a416d98ba5e124ff1736722f86bbe6ccf6242802 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sat, 17 Oct 2026 18:33:18 +0200
Subject: [PATCH 33/37] .github: test the weekly build, then build and test
 with all snippets

The weekly build was meant to catch flaky tests, but it never ran any.
Opt-in features, e.g. modem support, are not in the defconfigs, so CI
never builds or tests them either.

Test the weekly release build on x86_64.  After it is tested and
published, build x86_64 and aarch64 with all configuration snippets
applied, like 'make dev', and test that on x86_64 too.  The dev build
runs after the release build and its tests, the two must not run at
the same time.

'make dev' applies the snippets and then builds, so CI cannot use it to
configure a build it runs itself.  Split out the configuration part as
'make dev-config', 'make dev' works as before.  build.yml and test.yml
get a 'config' input for extra make goals after the defconfig.  A build
or test with another name than the default gets the name in its
artifact names, the dev build runs in the same workflow as the release
build and artifact names must be unique within a run.

Signed-off-by: Joachim Wiberg 
---
 .github/workflows/build.yml  |  9 +++++++--
 .github/workflows/test.yml   | 15 +++++++++-----
 .github/workflows/weekly.yml | 39 ++++++++++++++++++++++++++++++++++++
 Makefile                     |  8 +++++---
 4 files changed, 61 insertions(+), 10 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 86bb4c23e..1ac505ed4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -50,6 +50,11 @@ on:
         type: string
         default: ''
         description: 'Optional script to run after checkout (for spin customization)'
+      config:
+        required: false
+        type: string
+        default: ''
+        description: 'Extra make goals after the defconfig, e.g. dev-config or apply-modem'
     secrets:
       CHECKOUT_TOKEN:
         required: false
@@ -129,7 +134,7 @@ jobs:
 
       - name: Configure ${{ env.TARGET }}
         run: |
-          make ${{ env.TARGET }}_defconfig apply-mirror
+          make ${{ env.TARGET }}_defconfig apply-mirror ${{ inputs.config }}
 
       - name: Cleanup stale containers and ports
         run: |
@@ -184,4 +189,4 @@ jobs:
       - uses: actions/upload-artifact@v7
         with:
           path: output/${{ steps.vars.outputs.tgz }}
-          name: artifact-${{ env.TARGET }}
+          name: artifact-${{ env.NAME == 'infix' && env.TARGET || format('{0}-{1}', env.NAME, env.TARGET) }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index be21f3725..8ab4162e4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -48,6 +48,11 @@ on:
         type: string
         default: ''
         description: 'Release version (e.g. v26.04.0), sets INFIX_RELEASE so make test finds versioned artifacts'
+      config:
+        required: false
+        type: string
+        default: ''
+        description: 'Extra make goals after the defconfig, e.g. dev-config or apply-modem'
     secrets:
       CHECKOUT_TOKEN:
         required: false
@@ -96,12 +101,12 @@ jobs:
 
       - name: Configure ${{ env.TARGET }}
         run: |
-          make ${{ env.TARGET }}_defconfig
+          make ${{ env.TARGET }}_defconfig ${{ inputs.config }}
 
+      # Same name as the build.yml upload, only the image tarball is needed
       - uses: actions/download-artifact@v8
         with:
-          pattern: "artifact-*"
-          merge-multiple: true
+          name: artifact-${{ (inputs.name || 'infix') == 'infix' && env.TARGET || format('{0}-{1}', inputs.name, env.TARGET) }}
 
       - name: Restore ${{ env.TARGET }} output/
         run: |
@@ -141,7 +146,7 @@ jobs:
       - name: Upload Test Report as Artifact
         uses: actions/upload-artifact@v7
         with:
-          name: test-report
+          name: ${{ inputs.name && inputs.name != 'infix' && format('test-report-{0}', inputs.name) || 'test-report' }}
           path: output/images/test-report.pdf
 
       - name: Generate XPath Coverage Report for ${{ env.TARGET }}
@@ -153,5 +158,5 @@ jobs:
         if: always()
         uses: actions/upload-artifact@v7
         with:
-          name: xpath-coverage-report
+          name: ${{ inputs.name && inputs.name != 'infix' && format('xpath-coverage-report-{0}', inputs.name) || 'xpath-coverage-report' }}
           path: output/images/xpath-coverage-report.pdf
diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml
index 158718254..6d35d72fc 100644
--- a/.github/workflows/weekly.yml
+++ b/.github/workflows/weekly.yml
@@ -1,5 +1,10 @@
 # Weekly release build to catch flaky tests and verify clean builds.
 # Runs without caches (ccache) to ensure reproducibility. See issue #1003.
+#
+# After the release build is tested and published, a build with all
+# configuration snippets applied, like 'make dev', covers the opt-in
+# features that are not in the defconfigs.  It waits for the release
+# build and its tests, the two must not run at the same time.
 name: Weekly Build
 
 on:
@@ -15,6 +20,14 @@ jobs:
       version: "latest"
       use_cache: false
 
+  test:
+    needs: build
+    uses: ./.github/workflows/test.yml
+    with:
+      name: "infix"
+      target: "x86_64"
+      release: "latest"
+
   publish:
     name: Publish Weekly Build
     needs: build
@@ -55,3 +68,29 @@ jobs:
 
           Built from commit: \`${{ github.sha }}\`
           EOF
+
+  build-dev-x86_64:
+    needs: [test, publish]
+    if: ${{ !cancelled() && github.repository == 'kernelkit/infix' }}
+    uses: ./.github/workflows/build.yml
+    with:
+      name: "infix-dev"
+      target: "x86_64"
+      config: "dev-config"
+
+  build-dev-aarch64:
+    needs: [test, publish]
+    if: ${{ !cancelled() && github.repository == 'kernelkit/infix' }}
+    uses: ./.github/workflows/build.yml
+    with:
+      name: "infix-dev"
+      target: "aarch64"
+      config: "dev-config"
+
+  test-dev:
+    needs: build-dev-x86_64
+    uses: ./.github/workflows/test.yml
+    with:
+      name: "infix-dev"
+      target: "x86_64"
+      config: "dev-config"
diff --git a/Makefile b/Makefile
index a801128a8..5d4349185 100644
--- a/Makefile
+++ b/Makefile
@@ -41,13 +41,15 @@ list-snippets:
 	@echo "Available snippets (use 'make apply-'):"
 	@ls $(SNIPPETS_DIR)/*.conf 2>/dev/null | sed 's|.*/||; s|\.conf$$||; s|^|  |'
 
-dev: | $(config)
+dev: dev-config
+	@+$(call bmake,all)
+
+dev-config: | $(config)
 	@for s in $(SNIPPETS_DIR)/*.conf; do \
 		KCONFIG_CONFIG=$(config) $(MERGE_CONFIG) -m $(config) $$s; \
 		echo "Applied snippet: $$s"; \
 	done
 	@+$(call bmake,olddefconfig)
-	@+$(call bmake,all)
 
 migrate-configs:
 	@$(CURDIR)/utils/migrate-configs.sh $(subst :, ,$(BR2_EXTERNAL))
@@ -70,4 +72,4 @@ test:
 buildroot/Makefile:
 	@git submodule update --init
 
-.PHONY: all check coverity dep test cyclonedx list-snippets dev migrate-configs
+.PHONY: all check coverity dep test cyclonedx list-snippets dev dev-config migrate-configs

From e04134a97773ebec2def50050758712d89a8dd41 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sun, 18 Oct 2026 13:51:02 +0200
Subject: [PATCH 34/37] patches/linux: add a force_dbdc option for MT7615D WiFi
 cards

An MT7615D card with an unprogrammed EEPROM, e.g., the BPI-MT7615,
comes up with one radio for either 2.4 or 5 GHz instead of one radio per
band.  The driver cannot tell an MT7615D from a single radio MT7615N, so
add a force_dbdc option to the mt7615_common module to run both bands.

Signed-off-by: Joachim Wiberg 
---
 doc/ChangeLog.md                              |  2 +
 ...15-add-a-force_dbdc-module-parameter.patch | 48 +++++++++++++++++++
 2 files changed, 50 insertions(+)
 create mode 100644 patches/linux/6.18.57/0104-wifi-mt76-mt7615-add-a-force_dbdc-module-parameter.patch

diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index a015f1010..306ae698d 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -79,6 +79,8 @@ All notable changes to the project are documented in this file.
   APNs per modem, SIM PIN configuration, and NMEA routing from modem to
   GPS subsystem.  Not part of the default builds, enable it with the
   `modem` configuration snippet, see the [Modem User's Guide][modem]
+- An MT7615D WiFi card with an unprogrammed EEPROM can run both bands at
+  once with the `force_dbdc` option of the `mt7615_common` kernel module
 
 ### Fixes
 
diff --git a/patches/linux/6.18.57/0104-wifi-mt76-mt7615-add-a-force_dbdc-module-parameter.patch b/patches/linux/6.18.57/0104-wifi-mt76-mt7615-add-a-force_dbdc-module-parameter.patch
new file mode 100644
index 000000000..a8d4e820a
--- /dev/null
+++ b/patches/linux/6.18.57/0104-wifi-mt76-mt7615-add-a-force_dbdc-module-parameter.patch
@@ -0,0 +1,48 @@
+From 75d4190394037af38822d7d0cf388d8690f33ef2 Mon Sep 17 00:00:00 2001
+From: Joachim Wiberg 
+Date: Sun, 18 Oct 2026 13:47:49 +0200
+Subject: [PATCH 104/104] wifi: mt76: mt7615: add a force_dbdc module parameter
+
+An MT7615D card with an unprogrammed EEPROM selects no band, so the
+driver registers one radio that runs on either 2.4 or 5 GHz instead of
+one radio per band:
+
+  $ ls /sys/class/ieee80211/
+  phy0
+
+The driver cannot tell an MT7615D from an MT7615N, which has only one
+radio.  Add a force_dbdc module parameter to run both bands concurrently
+when the EEPROM selects no band.
+
+Signed-off-by: Joachim Wiberg 
+---
+ drivers/net/wireless/mediatek/mt76/mt7615/eeprom.c | 9 +++++++++
+ 1 file changed, 9 insertions(+)
+
+diff --git a/drivers/net/wireless/mediatek/mt76/mt7615/eeprom.c b/drivers/net/wireless/mediatek/mt76/mt7615/eeprom.c
+index d4bc7e11e772..638578e07329 100644
+--- a/drivers/net/wireless/mediatek/mt76/mt7615/eeprom.c
++++ b/drivers/net/wireless/mediatek/mt76/mt7615/eeprom.c
+@@ -98,6 +98,10 @@ static int mt7615_check_eeprom(struct mt76_dev *dev)
+ 	}
+ }
+ 
++static bool force_dbdc;
++module_param(force_dbdc, bool, 0444);
++MODULE_PARM_DESC(force_dbdc, "Use both bands concurrently (DBDC) when the EEPROM does not select a band");
++
+ static void
+ mt7615_eeprom_parse_hw_band_cap(struct mt7615_dev *dev)
+ {
+@@ -136,6 +140,11 @@ mt7615_eeprom_parse_hw_band_cap(struct mt7615_dev *dev)
+ 		break;
+ 	default:
+ 		dev->mphy.cap.has_2ghz = true;
++		if (force_dbdc) {
++			dev_info(dev->mt76.dev, "no band selected in EEPROM, forcing DBDC\n");
++			dev->dbdc_support = true;
++			break;
++		}
+ 		dev->mphy.cap.has_5ghz = true;
+ 		break;
+ 	}

From c85a37293f60cec2e2f1b1f3b1d2e243af59cab8 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sun, 18 Oct 2026 13:51:02 +0200
Subject: [PATCH 35/37] utils: add a script to fix up MT7615 EEPROM images

A WiFi card with an unprogrammed EEPROM gets a random MAC address and,
for an MT7615D, a single radio.  The script shows or changes the MAC
address and band selection in an image dumped from such a card, so the
image can be handed to the driver instead.

Signed-off-by: Joachim Wiberg 
---
 utils/mt7615-eeprom.sh | 113 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 113 insertions(+)
 create mode 100755 utils/mt7615-eeprom.sh

diff --git a/utils/mt7615-eeprom.sh b/utils/mt7615-eeprom.sh
new file mode 100755
index 000000000..b47c0bebf
--- /dev/null
+++ b/utils/mt7615-eeprom.sh
@@ -0,0 +1,113 @@
+#!/bin/sh
+# Show or fix up the EEPROM image of an MT7615 WiFi card
+set -e
+
+size=19880			# EEPROM buffer of the mt7615 driver
+
+usage()
+{
+	cat < mt7615.bin
+
+Without options, show the chip ID, MAC address, and band selection of
+FILE.  With options, change FILE in place, setting also the chip ID the
+driver checks for.
+
+Options:
+  -b BAND  dual  One radio, 2.4 or 5 GHz
+           2g    One radio, 2.4 GHz only
+           5g    One radio, 5 GHz only
+           dbdc  Two radios, 2.4 and 5 GHz at the same time, MT7615D only
+  -m MAC   MAC address of the first radio, the second radio derives its own
+  -h       This help text
+EOF
+}
+
+die()
+{
+	echo "${0##*/}: $*" >&2
+	exit 1
+}
+
+peek()
+{
+	od -An -tu1 -j "$1" -N 1 "$file" | tr -d ' '
+}
+
+poke()
+{
+	off=$1
+	shift
+	for val; do
+		# shellcheck disable=SC2059
+		printf "\\$(printf %03o "$val")" | dd of="$file" bs=1 seek="$off" conv=notrunc 2>/dev/null
+		off=$((off + 1))
+	done
+}
+
+show()
+{
+	chip=$(od -An -tx1 -N 2 "$file" | awk '{ print $2 $1 }')
+	mac=$(od -An -tx1 -j 4 -N 6 "$file" | sed 's/^ *//; s/ /:/g')
+	case $((($(peek 62) >> 4) & 3)) in
+		0) band="dual" ;;
+		1) band="5g" ;;
+		2) band="2g" ;;
+		3) band="dbdc" ;;
+	esac
+
+	echo "chip-id : 0x$chip"
+	echo "mac     : $mac"
+	echo "band    : $band"
+}
+
+while getopts "b:hm:" opt; do
+	case $opt in
+		b) band=$OPTARG ;;
+		m) mac=$OPTARG ;;
+		h) usage; exit 0 ;;
+		*) usage >&2; exit 1 ;;
+	esac
+done
+shift $((OPTIND - 1))
+
+file=$1
+[ -n "$file" ] || { usage >&2; exit 1; }
+[ -f "$file" ] || die "$file: no such file"
+[ "$(wc -c < "$file")" -ge $size ] || die "$file: too small, the driver needs $size bytes"
+
+if [ -n "$band" ]; then
+	case $band in
+		dual) sel=0 ;;
+		5g)   sel=1 ;;
+		2g)   sel=2 ;;
+		dbdc) sel=3 ;;
+		*)    die "invalid band: $band" ;;
+	esac
+fi
+
+if [ -n "$mac" ]; then
+	echo "$mac" | grep -Eqi '^([0-9a-f]{2}:){5}[0-9a-f]{2}$' || die "invalid MAC address: $mac"
+	[ $((0x${mac%%:*} & 1)) -eq 0 ] || die "invalid MAC address: $mac is multicast"
+fi
+
+if [ -n "$band" ] || [ -n "$mac" ]; then
+	poke 0 0x15 0x76
+	[ -z "$sel" ] || poke 62 $((($(peek 62) & ~0x30) | (sel << 4)))
+	if [ -n "$mac" ]; then
+		oifs=$IFS
+		IFS=:
+		# shellcheck disable=SC2086
+		set -- $mac
+		IFS=$oifs
+		poke 4 "0x$1" "0x$2" "0x$3" "0x$4" "0x$5" "0x$6"
+	fi
+fi
+
+show

From 716372d14e9ab0c2b2d8eb3cae0037be007d4448 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sun, 18 Oct 2026 13:51:03 +0200
Subject: [PATCH 36/37] board/aarch64: bpi-r4: load a WiFi card EEPROM from the
 aux partition

A WiFi card with an unprogrammed EEPROM, e.g., the BPI-MT7615, gets a
random MAC address and, for an MT7615D, a single radio.

Let the boot loader copy /mnt/aux/wifi-eeprom.bin to reserved memory,
handed to the driver of the card in mPCIe slot CN14 as its EEPROM.  The
region is zeroed when there is no file, which the driver rejects for the
card's own EEPROM.

Signed-off-by: Joachim Wiberg 
---
 board/aarch64/bananapi-bpi-r4/README.md       | 42 +++++++++++++++++++
 .../dts/infix/bananapi,bpi-r4.dtso            | 40 ++++++++++++++++++
 .../bananapi-bpi-r4/uboot/mt7988-env.dtsi     |  2 +
 board/common/uboot/scripts/ixpreboot.sh       |  7 ++++
 doc/ChangeLog.md                              |  3 ++
 5 files changed, 94 insertions(+)

diff --git a/board/aarch64/bananapi-bpi-r4/README.md b/board/aarch64/bananapi-bpi-r4/README.md
index 6f2b5f4b0..4e32f8be1 100644
--- a/board/aarch64/bananapi-bpi-r4/README.md
+++ b/board/aarch64/bananapi-bpi-r4/README.md
@@ -87,6 +87,48 @@ Infix ships with the following factory defaults.
 > are bridged into the LAN as access points.  WiFi is not included with the board
 > and is not part of the factory default configuration.
 
+### WiFi Cards Without an EEPROM
+
+Some mPCIe WiFi cards, e.g., the BPI-MT7615, ship with an unprogrammed
+EEPROM.  The card then comes up with a random MAC address and, for an
+MT7615D, with one radio instead of one per band:
+
+```
+mt7615e 0001:01:00.0: Invalid MAC address, using random address 06:0e:56:91:b5:19
+```
+
+Such a card can be given an EEPROM image as `/mnt/aux/wifi-eeprom.bin`,
+which the boot loader hands to the WiFi driver.  This requires:
+
+- The card in mPCIe slot CN14, the slot next to SIM3
+- A boot loader from v26.10.0 or later
+- An image of at most 20 KiB
+
+For an MT7615 card, start from the EEPROM the driver uses today:
+
+```
+admin@bpi:~$ sudo cat /sys/kernel/debug/ieee80211/radio0/mt76/eeprom > wifi-eeprom.bin
+```
+
+On a PC, set a MAC address and, for an MT7615D, both bands with the
+`utils/mt7615-eeprom.sh` script from the source tree:
+
+```
+$ utils/mt7615-eeprom.sh -b dbdc -m 02:00:00:76:15:00 wifi-eeprom.bin
+chip-id : 0x7615
+mac     : 02:00:00:76:15:00
+band    : dbdc
+```
+
+Copy the image back to the board as `/mnt/aux/wifi-eeprom.bin` and
+reboot.  Removing the file returns the card to its own EEPROM at the
+next boot.  The image holds no more calibration data than the card
+has without it, so range and throughput can be lower than with a
+factory programmed EEPROM.
+
+On other boards, an MT7615D gets both radios with the `force_dbdc`
+option of the `mt7615_common` kernel module.
+
 ## SIM Card Installation
 
 The BPI-R4 has three SIM holders, one per PCIe slot:
diff --git a/board/aarch64/bananapi-bpi-r4/dts/infix/bananapi,bpi-r4.dtso b/board/aarch64/bananapi-bpi-r4/dts/infix/bananapi,bpi-r4.dtso
index 7ea10d03b..57d349464 100644
--- a/board/aarch64/bananapi-bpi-r4/dts/infix/bananapi,bpi-r4.dtso
+++ b/board/aarch64/bananapi-bpi-r4/dts/infix/bananapi,bpi-r4.dtso
@@ -26,3 +26,43 @@
 		};
 	};
 };
+
+/*
+ * EEPROM for a WiFi card in mPCIe CN14, for cards shipped without one.
+ * The boot loader copies /mnt/aux/wifi-eeprom.bin here, or zeroes the
+ * region, which the driver rejects for the card's own EEPROM.
+ */
+&{/reserved-memory} {
+	wifi-eeprom@43e00000 {
+		compatible = "nvmem-rmem";
+		reg = <0 0x43e00000 0 0x5000>;
+		no-map;
+
+		nvmem-layout {
+			compatible = "fixed-layout";
+			#address-cells = <1>;
+			#size-cells = <1>;
+
+			wifi_eeprom: eeprom@0 {
+				reg = <0x0 0x5000>;
+			};
+		};
+	};
+};
+
+&pcie1 {
+	pcie@0,0 {
+		reg = <0x0000 0 0 0 0>;
+		#address-cells = <3>;
+		#size-cells = <2>;
+		device_type = "pci";
+		ranges;
+
+		wifi@0,0 {
+			compatible = "mediatek,mt76";
+			reg = <0x0000 0 0 0 0>;
+			nvmem-cells = <&wifi_eeprom>;
+			nvmem-cell-names = "eeprom";
+		};
+	};
+};
diff --git a/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi b/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi
index b3ffc7c44..31b4fade5 100644
--- a/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi
+++ b/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi
@@ -4,6 +4,8 @@
                        bootcmd = "run ixboot";
                        boot_targets = "mmc0";
                        ethprime = "eth0";
+                       wifi_eeprom_addr_r = "0x43e00000";
+                       wifi_eeprom_size = "0x5000";
                        fdt_addr_r = "0x43f00000";
                        kernel_addr_r = "0x44000000";
                        fdtoverlay_addr_r = "0x47f00000";
diff --git a/board/common/uboot/scripts/ixpreboot.sh b/board/common/uboot/scripts/ixpreboot.sh
index 4d0147dc8..2cb878640 100644
--- a/board/common/uboot/scripts/ixpreboot.sh
+++ b/board/common/uboot/scripts/ixpreboot.sh
@@ -44,6 +44,13 @@ for tgt in "${boot_targets}"; do
 	    fi
 	fi
 
+	if test -n "${wifi_eeprom_addr_r}"; then
+	    mw.b ${wifi_eeprom_addr_r} 0 ${wifi_eeprom_size}
+	    if test -e ${devtype} ${devnum}:${auxpart} /wifi-eeprom.bin; then
+		load ${devtype} ${devnum}:${auxpart} ${wifi_eeprom_addr_r} /wifi-eeprom.bin
+	    fi
+	fi
+
 	if test -n "${DEBUG}"; then
 	    setenv bootargs_log "debug"
 	else
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 306ae698d..303567813 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -81,6 +81,9 @@ All notable changes to the project are documented in this file.
   `modem` configuration snippet, see the [Modem User's Guide][modem]
 - An MT7615D WiFi card with an unprogrammed EEPROM can run both bands at
   once with the `force_dbdc` option of the `mt7615_common` kernel module
+- Banana Pi BPI-R4: a WiFi card with an unprogrammed EEPROM, e.g., the
+  BPI-MT7615, can be given an EEPROM image on the aux partition, see the
+  board documentation
 
 ### Fixes
 

From 0852e0f52d21a30453cf4bb99427275ea39587a2 Mon Sep 17 00:00:00 2001
From: Joachim Wiberg 
Date: Sun, 18 Oct 2026 15:17:43 +0200
Subject: [PATCH 37/37] board/aarch64: bpi-r4: permanent MAC addresses from a
 VPD on aux

The BPI-R4 has no VPD EEPROM, so every port gets a random address on
each boot, and the hostname, derived from the base address, changes
with it.

Let the boot loader copy /mnt/aux/vpd.bin to reserved memory, like on
the BPI-R3, and give each port a fixed offset from its base MAC address.
U-Boot writes its random ethaddr into the nodes behind the ethernet
aliases, which takes precedence, and an overlay cannot delete them in
U-Boot, so point the aliases at nothing.

Signed-off-by: Joachim Wiberg 
---
 board/aarch64/bananapi-bpi-r4/README.md       | 37 ++++++++++
 .../dts/infix/bananapi,bpi-r4.dtso            | 70 +++++++++++++++++++
 .../bananapi-bpi-r4/uboot/mt7988-env.dtsi     |  1 +
 doc/ChangeLog.md                              |  2 +
 doc/vpd.md                                    |  4 +-
 5 files changed, 112 insertions(+), 2 deletions(-)

diff --git a/board/aarch64/bananapi-bpi-r4/README.md b/board/aarch64/bananapi-bpi-r4/README.md
index 4e32f8be1..ee532dae9 100644
--- a/board/aarch64/bananapi-bpi-r4/README.md
+++ b/board/aarch64/bananapi-bpi-r4/README.md
@@ -298,6 +298,43 @@ mmc partconf 0 1 1 0
 
 Your BPI-R4 should now boot Infix from internal eMMC storage.
 
+## Giving the Board a Permanent MAC Address
+
+The BPI-R4 has no EEPROM with a factory-programmed MAC address.  Out of
+the box every Ethernet port therefore gets a random address on each
+boot, and the hostname, which is derived from the base address, changes
+with it.
+
+Store a product record as `/mnt/aux/vpd.bin`, from the shell, and the
+board keeps that identity across reboots and upgrades.  This requires a
+boot loader from v26.10.0 or later:
+
+```
+echo '{"mac-address":"02:00:00:ba:04:00","serial-number":"banana4","vendor":"SinoVoip Co., Ltd.","product-name":"BPI-R4"}' \
+  | onieprom -e > /mnt/aux/vpd.bin
+```
+
+Give each board its own base address and serial number, then reboot.
+The base address becomes the hostname suffix, each wired port gets a
+fixed offset from the base, and the WiFi interfaces get addresses
+derived from it.  With the record above the board is named
+`bpi-ba-04-00`, `lan1` is `02:00:00:ba:04:03`, `sfp1` is
+`02:00:00:ba:04:07` and `wifi0` is `06:00:00:ba:04:00`.
+
+| Offset | BPI-R4           | BPI-R4-2g5 and BPI-R4P |
+|--------|------------------|------------------------|
+| +1     | CPU switch port  | CPU switch port        |
+| +2     | `wan`            | `lan0`                 |
+| +3..+5 | `lan1`..`lan3`   | `lan1`..`lan3`         |
+| +6     | `sfp2`           | `wan`                  |
+| +7     | `sfp1`           | `sfp1`                 |
+
+A locally administered address like `02:xx:xx:xx:xx:xx` is fine for a
+lab.  For boards that will share a network with other equipment, use a
+range you own.  Remove the file to return to random addresses.  See
+[Vital Product Data](../../../doc/vpd.md#vpd-file-on-the-aux-partition)
+for the record format and what else it can carry.
+
 ## Troubleshooting
 
 ### Board won't boot
diff --git a/board/aarch64/bananapi-bpi-r4/dts/infix/bananapi,bpi-r4.dtso b/board/aarch64/bananapi-bpi-r4/dts/infix/bananapi,bpi-r4.dtso
index 57d349464..19da1a1d1 100644
--- a/board/aarch64/bananapi-bpi-r4/dts/infix/bananapi,bpi-r4.dtso
+++ b/board/aarch64/bananapi-bpi-r4/dts/infix/bananapi,bpi-r4.dtso
@@ -15,18 +15,88 @@
 &{/} {
 	aliases {
 		serial0 = "/soc/serial@11000000";
+		/*
+		 * U-Boot writes its random ethaddr into the nodes behind the
+		 * ethernet aliases, which would take precedence over the VPD
+		 * addresses below.  An overlay cannot delete properties in
+		 * U-Boot, so point the aliases at nothing instead.
+		 */
+		ethernet0 = "/no-ethaddr";
+		ethernet1 = "/no-ethaddr";
+		ethernet2 = "/no-ethaddr";
 	};
 
 	chosen {
 		infix {
 			/* Default admin user password: 'admin' */
 			factory-password-hash = "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A";
+			vpds = <&vpd>;
 			usb-ports = <&ssusb1>;
 			usb-port-names = "USB";
 		};
 	};
 };
 
+/*
+ * Product VPD, an ONIE TLV that the boot loader copies from the aux
+ * partition into reserved memory.  The region is zeroed when there is
+ * no VPD, which leaves the layout without cells.  Ports reference the
+ * base MAC address with an index, <&base_mac N>, and get base + N as
+ * their permanent address.
+ */
+&{/reserved-memory} {
+	vpd: vpd@43e10000 {
+		compatible = "nvmem-rmem";
+		reg = <0 0x43e10000 0 0x10000>;
+		no-map;
+		infix,board = "product";
+
+		nvmem-layout {
+			compatible = "onie,tlv-layout";
+
+			base_mac: mac-address {
+				#nvmem-cell-cells = <1>;
+			};
+		};
+	};
+};
+
+/* Port addresses: VPD base MAC + 1 .. + 7 */
+&gmac0 {
+	nvmem-cells = <&base_mac 1>;
+	nvmem-cell-names = "mac-address";
+};
+
+&gsw_port0 {
+	nvmem-cells = <&base_mac 2>;
+	nvmem-cell-names = "mac-address";
+};
+
+&gsw_port1 {
+	nvmem-cells = <&base_mac 3>;
+	nvmem-cell-names = "mac-address";
+};
+
+&gsw_port2 {
+	nvmem-cells = <&base_mac 4>;
+	nvmem-cell-names = "mac-address";
+};
+
+&gsw_port3 {
+	nvmem-cells = <&base_mac 5>;
+	nvmem-cell-names = "mac-address";
+};
+
+&gmac1 {
+	nvmem-cells = <&base_mac 6>;
+	nvmem-cell-names = "mac-address";
+};
+
+&gmac2 {
+	nvmem-cells = <&base_mac 7>;
+	nvmem-cell-names = "mac-address";
+};
+
 /*
  * EEPROM for a WiFi card in mPCIe CN14, for cards shipped without one.
  * The boot loader copies /mnt/aux/wifi-eeprom.bin here, or zeroes the
diff --git a/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi b/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi
index 31b4fade5..31a4448af 100644
--- a/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi
+++ b/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi
@@ -4,6 +4,7 @@
                        bootcmd = "run ixboot";
                        boot_targets = "mmc0";
                        ethprime = "eth0";
+                       vpd_addr_r = "0x43e10000";
                        wifi_eeprom_addr_r = "0x43e00000";
                        wifi_eeprom_size = "0x5000";
                        fdt_addr_r = "0x43f00000";
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 303567813..55d7ca5c8 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -84,6 +84,8 @@ All notable changes to the project are documented in this file.
 - Banana Pi BPI-R4: a WiFi card with an unprogrammed EEPROM, e.g., the
   BPI-MT7615, can be given an EEPROM image on the aux partition, see the
   board documentation
+- Banana Pi BPI-R4: ports get permanent addresses derived from the chassis
+  MAC of a `/mnt/aux/vpd.bin`, see [VPD documentation](vpd.md#vpd-file-on-the-aux-partition)
 
 ### Fixes
 
diff --git a/doc/vpd.md b/doc/vpd.md
index 9e49dfcd5..577a0a150 100644
--- a/doc/vpd.md
+++ b/doc/vpd.md
@@ -79,8 +79,8 @@ fixed offset given by the device tree, and WiFi interfaces derive
 theirs from the base MAC as described in
 [WiFi](wifi.md#multi-ssid-configuration).
 
-This is currently supported on the Banana Pi BPI-R3 family and the
-Acer Connect Vero W6m.
+This is currently supported on the Banana Pi BPI-R3 family, the Banana
+Pi BPI-R4 family, and the Acer Connect Vero W6m.
 
 ## Product VPD