diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 86bb4c23e6..1ac505ed40 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 be21f3725a..8ab4162e4a 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 158718254f..6d35d72fc2 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 a801128a8d..5d43491859 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 diff --git a/board/aarch64/bananapi-bpi-r4/README.md b/board/aarch64/bananapi-bpi-r4/README.md index d9cb6eda7e..ee532dae9a 100644 --- a/board/aarch64/bananapi-bpi-r4/README.md +++ b/board/aarch64/bananapi-bpi-r4/README.md @@ -87,6 +87,87 @@ 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: + +| 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 @@ -217,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 @@ -332,19 +450,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 @@ -358,3 +479,5 @@ make aarch64 [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/ 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 7ea10d03ba..19da1a1d1a 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,14 +15,124 @@ &{/} { 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 + * 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/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 5aa69df31d..7b9c7b54f6 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 9e643858cf..3a6bf5d1a4 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" + } } ] }, diff --git a/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi b/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi index b3ffc7c440..31a4448af6 100644 --- a/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi +++ b/board/aarch64/bananapi-bpi-r4/uboot/mt7988-env.dtsi @@ -4,6 +4,9 @@ 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"; kernel_addr_r = "0x44000000"; fdtoverlay_addr_r = "0x47f00000"; diff --git a/board/common/rootfs/etc/default/confd b/board/common/rootfs/etc/default/confd index 7919fb441d..3352c80860 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/board/common/rootfs/etc/iproute2/rt_addrprotos b/board/common/rootfs/etc/iproute2/rt_addrprotos index f31bdca27e..3de5ebd2fd 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 e2054cefb8..4e16360f28 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}) @@ -620,6 +623,88 @@ def probe_ptp_capabilities(out): out.setdefault("interfaces", {}).update(ifaces) +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"): + if os.path.isfile(os.path.join(p, "idVendor")): + return p + p = os.path.dirname(p) + 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. + 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_dev = _usb_device_from_sysfs(dev_path) + if usb_dev is None or usb_dev in seen: + return + 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_dev, + "uri": "usb:///sys/bus/usb/devices/%s" % port, + "vid": vid, + "pid": pid, + }) + idx += 1 + + 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(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 + + if modems: + out["modem"] = modems + + def main(): out = { "vendor": None, @@ -647,6 +732,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/board/common/uboot/scripts/ixpreboot.sh b/board/common/uboot/scripts/ixpreboot.sh index 4d0147dc87..2cb878640b 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/configs/snippets/modem.conf b/configs/snippets/modem.conf new file mode 100644 index 0000000000..bd2c71d3bd --- /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/doc/ChangeLog.md b/doc/ChangeLog.md index 10721b2495..55d7ca5c8c 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -74,9 +74,33 @@ 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. 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 +- 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 +- 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 `\` +- 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 +- 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 @@ -106,9 +130,14 @@ 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 +- 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 +[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 d034a1bb1d..b16afd8f90 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/limitations.md b/doc/limitations.md index d5374fde02..a7628ee191 100644 --- a/doc/limitations.md +++ b/doc/limitations.md @@ -17,10 +17,10 @@ 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 `+ * ? \| ^ $ ( ) [ ] { }` | | +| 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 `\` | | | Wi-Fi `nas-identifier` | Letters, digits, `_`, `.`, `:`, `+`, `-` | | diff --git a/doc/modem.md b/doc/modem.md new file mode 100644 index 0000000000..bb5d5a35cc --- /dev/null +++ b/doc/modem.md @@ -0,0 +1,532 @@ +# Cellular Modem (WWAN) + +Cellular modems are supported when they expose a QMI or MBIM control +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. + +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 + +> [!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: + +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) + +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) +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. 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
+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 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, so the +> interface is in place before the configuration is applied. + +### 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. + +## 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 + +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 +- 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** + +- 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/doc/vpd.md b/doc/vpd.md index 9e49dfcd55..577a0a150b 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 diff --git a/mkdocs.yml b/mkdocs.yml index b6e9653752..599a17f65f 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/Config.in b/package/Config.in index 88810954c1..d75c106982 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/confd/confd.conf b/package/confd/confd.conf index cc8d057205..1737f29410 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/package/confd/confd.mk b/package/confd/confd.mk index 05b545aa03..d7b5c5ad1a 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/00-modemsim b/package/feature-modem/00-modemsim new file mode 100755 index 0000000000..bd2d3668b1 --- /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 new file mode 100644 index 0000000000..ad26214419 --- /dev/null +++ b/package/feature-modem/Config.in @@ -0,0 +1,38 @@ +config BR2_PACKAGE_FEATURE_MODEM + bool "Feature Modem" + select BR2_PACKAGE_MODEMD + select BR2_PACKAGE_MODEM_MANAGER + select BR2_PACKAGE_MODEM_MANAGER_ATVIADBUS + select BR2_PACKAGE_MODEM_MANAGER_LIBMBIM + select BR2_PACKAGE_MODEM_MANAGER_LIBQMI + select BR2_PACKAGE_MODEM_MANAGER_LIBQRTR + help + Enables cellular modem support via ModemManager and the + modemd management daemon. Includes drivers for common + USB option modems and QMI/MBIM-based devices. + + ATVIADBUS allows raw AT commands over D-Bus (used by modemd to + enable GPS NMEA output via vendor-specific AT commands such as + AT+QGPS=1) without needing to start ModemManager in --debug + mode. + +config BR2_PACKAGE_FEATURE_MODEM_QUALCOMM + bool "Qualcomm-based modems (QMI/QRTR/MHI)" + depends on BR2_PACKAGE_FEATURE_MODEM + help + Adds kernel support for Qualcomm-based cellular modems that use + the MHI bus and QRTR IPC router (e.g. Sierra Wireless EM7xxx, + Quectel EM/RMxxx, Telit LN9xx). + +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 new file mode 100644 index 0000000000..382bb4e7a6 --- /dev/null +++ b/package/feature-modem/feature-modem.mk @@ -0,0 +1,49 @@ +################################################################################ +# +# Cellular modem support +# +################################################################################ + +FEATURE_MODEM_PACKAGE_VERSION = 1.0 +FEATURE_MODEM_PACKAGE_LICENSE = MIT + +define FEATURE_MODEM_LINUX_CONFIG_FIXUPS + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL_WWAN) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL_OPTION) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_WDM) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_QMI_WWAN) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_CDC_MBIM) + $(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_CDC_MBIM) + # 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) + $(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 0000000000..2cb96c1825 --- /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 0000000000..67e7fad070 --- /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) diff --git a/package/modemd/Config.in b/package/modemd/Config.in new file mode 100644 index 0000000000..0b8ddafe04 --- /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 0000000000..db4831e367 --- /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 0000000000..5cde00e705 --- /dev/null +++ b/package/modemd/modemd.mk @@ -0,0 +1,56 @@ +################################################################################ +# +# modemd +# +################################################################################ + +MODEMD_VERSION = 1.0 +MODEMD_SITE_METHOD = local +MODEMD_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/modemd +MODEMD_LICENSE = BSD-3-Clause +MODEMD_LICENSE_FILES = LICENSE +MODEMD_REDISTRIBUTE = NO +MODEMD_DEPENDENCIES = modem-manager jansson python3 \ + host-python3 host-python-pypa-build host-python-installer \ + host-python-poetry-core + +define MODEMD_BUILD_CMDS + $(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \ + $(MODEMD_DIR)/modem-command.c -o $(MODEMD_DIR)/modem-command -ljansson +endef + +define MODEMD_BUILD_PYTHON + cd $(MODEMD_SITE) && \ + $(PKG_PYTHON_PEP517_ENV) $(HOST_DIR)/bin/python3 $(PKG_PYTHON_PEP517_BUILD_CMD) -o $(@D)/dist + mkdir -p $(TARGET_DIR)/usr/libexec/modemd + rm -f $(TARGET_DIR)/usr/libexec/modemd/modemd \ + $(TARGET_DIR)/usr/libexec/modemd/modem-* \ + $(TARGET_DIR)/usr/libexec/modemd/sim-setup + cd $(@D) && \ + $(HOST_DIR)/bin/python3 $(TOPDIR)/support/scripts/pyinstaller.py \ + dist/*.whl \ + --interpreter=/usr/bin/python3 \ + --script-kind=posix \ + --purelib=$(TARGET_DIR)/usr/lib/python$(PYTHON3_VERSION_MAJOR)/site-packages \ + --headers=$(TARGET_DIR)/usr/include/python$(PYTHON3_VERSION_MAJOR) \ + --scripts=$(TARGET_DIR)/usr/libexec/modemd \ + --data=$(TARGET_DIR) +endef +MODEMD_POST_INSTALL_TARGET_HOOKS += MODEMD_BUILD_PYTHON + +define MODEMD_INSTALL_TARGET_CMDS + mkdir -p $(TARGET_DIR)/usr/libexec/modemd + mkdir -p $(TARGET_DIR)/lib/udev/rules.d + mkdir -p $(FINIT_D)/available/ + mkdir -p $(TARGET_DIR)/sbin + $(INSTALL) -D -m 0644 $(MODEMD_DIR)/finit.conf $(FINIT_D)/available/modemd.conf + install -m 644 $(MODEMD_DIR)/modemd.rules $(TARGET_DIR)/lib/udev/rules.d/90-modemd.rules + install -m 644 $(MODEMD_DIR)/qmi-wwan-ids.rules $(TARGET_DIR)/lib/udev/rules.d/91-qmi-wwan-ids.rules + install -m 644 $(MODEMD_DIR)/77-mm-dell-port-types.rules $(TARGET_DIR)/etc/udev/rules.d/77-mm-dell-port-types.rules + install -m 644 $(MODEMD_DIR)/77-mm-modem-gps.rules $(TARGET_DIR)/etc/udev/rules.d/77-mm-modem-gps.rules + install -D -m 644 $(MODEMD_DIR)/modemd.modules-load $(TARGET_DIR)/etc/modules-load.d/modemd.conf + install -m 755 $(MODEMD_DIR)/modem-command $(TARGET_DIR)/sbin/modem-command + ln -sf /usr/libexec/modemd/modemd $(TARGET_DIR)/sbin/modemd +endef + +$(eval $(generic-package)) diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf new file mode 100644 index 0000000000..2d1128fde6 --- /dev/null +++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/modem-manager.conf @@ -0,0 +1 @@ +service [2345789] env:-/etc/default/modem-manager ModemManager $MODEM_MANAGER_OPTS -- 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 0000000000..6d4585d3e4 --- /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/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 0000000000..5fae580a69 --- /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, 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 0000000000..a8d4e820af --- /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; + } 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 0000000000..212bcdb6ab --- /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/3] 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 0000000000..d8fadadcc9 --- /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/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, +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/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 0000000000..d1e280756f --- /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 + diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index 46eac8f2fd..9c3eb272d8 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)) @@ -145,6 +150,30 @@ def channel_survey(radio: str, passive: bool) -> None: sys.stdout.flush() +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: + cli_pretty(data, "show-modem-detail", ref) + else: + cli_pretty(data, "show-modem") + + def ntp(args: List[str]) -> None: # Create argument parser for ntp subcommands parser = argparse.ArgumentParser(prog='show ntp', add_help=False) @@ -847,6 +876,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 3870a7302c..bcab908c6e 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,33 @@ gen_radio() 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 < 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 0000000000..aac2a9c18a --- /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 f1a52eed3c..ff62c23913 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 diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index b47ec290ae..9e4deb8fd8 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -51,6 +51,8 @@ confd_plugin_la_SOURCES = \ if-wifi.c \ if-wireguard.c \ if-ppp.c \ + if-modem.c \ + modem.c \ keystore.c \ system.c \ support.c \ diff --git a/src/confd/src/core.c b/src/confd/src/core.c index 2aa0b3b752..a5899107e3 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; @@ -734,14 +752,19 @@ 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, and a modem netdev gets + * its name from the udev rules written here. */ + 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*/ @@ -1067,7 +1090,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 7cafc55506..24bbc22964 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); @@ -225,6 +240,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))); @@ -298,6 +314,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); @@ -323,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/dhcp-client.c b/src/confd/src/dhcp-client.c index 8d1f1ffab0..09e9ebfdcd 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 3a96a9e07e..0dd1f24228 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/src/dhcp-server.c b/src/confd/src/dhcp-server.c index 7da94ca9d5..2127acb16e 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/src/hardware.c b/src/confd/src/hardware.c index b3eec62a30..f5d800f264 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -26,6 +26,10 @@ #define GPSD_CONF "/etc/finit.d/available/gpsd.conf" #define GPSD_CONF_NEXT GPSD_CONF"+" #define GPSD_MAX_DEVICES 4 +#define MODEM_RULES "/run/udev/rules.d/95-modem-%s.rules" +#define MODEM_RULES_GLOB "/run/udev/rules.d/95-modem-*.rules" +#define MODEM_MANAGER_ENV "/etc/default/modem-manager" +#define MODEM_MAX 16 static int dir_cb(const char *fpath, const struct stat *sb, int typeflag, struct FTW *ftwbuf) { @@ -176,6 +180,16 @@ static int hardware_cand_infer_class(json_t *root, sr_session_ctx_t *session, co err = srx_set_item(session, &inferred, 0, "%s/class", xpath); } + if (!fnmatch("modem+([0-9])", name, FNM_EXTMATCH)) { + inferred.data.string_val = "infix-hardware:modem"; + err = srx_set_item(session, &inferred, 0, "%s/class", xpath); + } + + if (!fnmatch("sim+([0-9])", name, FNM_EXTMATCH)) { + inferred.data.string_val = "infix-hardware:sim"; + err = srx_set_item(session, &inferred, 0, "%s/class", xpath); + } + out_free_name: free(name); out_free_xpath: @@ -1387,6 +1401,264 @@ static int hardware_cand(sr_session_ctx_t *session, uint32_t sub_id, const char return err; } +/* + * USB port token from a modem component's uri leaf-list, e.g. + * "usb:///sys/bus/usb/devices/3-1.2.1" -> "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) { @@ -1394,10 +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 wifi_changed = 0; - - if (!lydx_find_xpathf(diff, XPATH_BASE_)) - return SR_ERR_OK; + int modem_changed = 0; + int modem_rules_only = 0; + + 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 @@ -1417,18 +1696,28 @@ 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, 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; + } class = lydx_get_cattr(cif, "class"); if (country_changed && strcmp(class, "infix-hardware:wifi") && @@ -1440,59 +1729,28 @@ 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"))) { + 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")) { + /* 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; 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 +1846,86 @@ 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; +} + +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 +1934,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 +1989,6 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l } globfree(&gl); } -err: return rc; } diff --git a/src/confd/src/if-modem.c b/src/confd/src/if-modem.c new file mode 100644 index 0000000000..43822996b8 --- /dev/null +++ b/src/confd/src/if-modem.c @@ -0,0 +1,119 @@ +/* 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. + * + * 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. + * + * 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. + * 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); + 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 f84ec4edf9..e476928b2d 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_add_iface(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_del_iface(dif, net); + break; case IFT_WIFI: wifi_del_iface(dif, net); break; @@ -771,8 +781,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 +816,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 fc8b0f4df2..2fb290d7ed 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); +/* if-modem.c */ +int modem_add_iface(struct lyd_node *cif, struct dagger *net); +int modem_del_iface(struct lyd_node *dif, struct dagger *net); + /* infix-if-wireguard */ int wireguard_validate_peers(sr_session_ctx_t *session, struct lyd_node *cif); int wireguard_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip, struct dagger *net); diff --git a/src/confd/src/main.c b/src/confd/src/main.c index 6e24690c45..a8df87ecd6 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); diff --git a/src/confd/src/modem.c b/src/confd/src/modem.c new file mode 100644 index 0000000000..e86357fbb7 --- /dev/null +++ b/src/confd/src/modem.c @@ -0,0 +1,206 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include + +#include +#include +#include + +#include "core.h" + +#define RUN_DIR "/run/modemd" +#define SOCK RUN_DIR "/modemd.sock" + +#define HW_BASE "ietf-hardware" +#define HW_MODULE "infix-hardware" +#define HW_COMP "/ietf-hardware:hardware/component" +#define IH_MODEM HW_COMP "/infix-hardware:modem" +#define IH_COMP_ACTION HW_COMP "/infix-hardware:" + + +static int component_index(const char *xpath) +{ + regmatch_t pmatch[2]; + regex_t regex; + char name[32]; + int index = -1; + int len; + + if (regcomp(®ex, "name='([^']*)'", REG_EXTENDED)) + return -1; + + if (regexec(®ex, xpath, 2, pmatch, 0) == 0) { + len = pmatch[1].rm_eo - pmatch[1].rm_so; + if (len < (int)(sizeof(name) - 1)) { + memcpy(name, xpath + pmatch[1].rm_so, len); + name[len] = '\0'; + sscanf(name, "modem%d", &index); + } + } + regfree(®ex); + + return index; +} + +static int infix_modem_rpcsend(char *msg, int len) +{ + struct sockaddr_un addr; + struct timeval tv; + fd_set wfds; + int sock, ret = -1; + + sock = socket(AF_UNIX, SOCK_STREAM, 0); + if (sock < 0) + return -1; + + addr.sun_family = AF_UNIX; + strcpy(addr.sun_path, SOCK); + + if (connect(sock, &addr, sizeof(addr)) == 0) { + tv.tv_sec = 5; + tv.tv_usec = 0; + FD_ZERO(&wfds); + FD_SET(sock, &wfds); + + if (select(sock + 1, NULL, &wfds, NULL, &tv) > 0) { + if (write(sock, msg, len) == len) + ret = 0; + } + } + close(sock); + + return ret; +} + +static int infix_modem_rpc(const char *rpc, const char *data) +{ + char msg[1024]; + int len; + + NOTE("Sending rpc %s to modemd", rpc); + + len = snprintf(msg, sizeof(msg), + "{ \"rpc\" : \"%s\", \"data\" : %s }", + rpc, data ? data : "null"); + + if (infix_modem_rpcsend(msg, len) < 0) { + ERROR("Unable to send rpc"); + return SR_ERR_INTERNAL; + } + + return SR_ERR_OK; +} + +static int infix_modem_sendsms(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) +{ + char data[1024]; + int index; + + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return SR_ERR_INVAL_ARG; + } + if (input_cnt < 2) { + ERROR("send-sms: not enough input parameters"); + return SR_ERR_SYS; + } + + snprintf(data, sizeof(data) - 1, + "{ \"index\" : %d, \"number\" : \"%s\", \"text\" : \"%s\" }", + index, + input[0].data.string_val, + input[1].data.string_val); + + return infix_modem_rpc("send-sms", data); +} + +static int infix_modem_restart(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) +{ + char data[64]; + int index; + + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return SR_ERR_INVAL_ARG; + } + + snprintf(data, sizeof(data), "{ \"index\" : %d }", index); + return infix_modem_rpc("restart", data); +} + +static int infix_modem_reset(sr_session_ctx_t *session, uint32_t sub_id, const char *xpath, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, void *priv) +{ + char data[64]; + int index; + + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return SR_ERR_INVAL_ARG; + } + + snprintf(data, sizeof(data), "{ \"index\" : %d }", index); + return infix_modem_rpc("reset", data); +} + +static void infix_modem_notif(sr_session_ctx_t *session, uint32_t sub_id, + const sr_ev_notif_type_t notif_type, const char *xpath, + const sr_val_t *values, const size_t values_cnt, + struct timespec *timestamp, void *confd) +{ + int index; + + index = component_index(xpath); + if (index < 0) { + ERROR("Cannot parse modem index from xpath: %s", xpath); + return; + } + if (values_cnt < 1) { + ERROR("No values in status-update notification"); + return; + } + + NOTE("Notification from modem%d: %s", index, values[0].data.string_val); +} + +int modem_init(struct confd *confd) +{ + const struct lys_module *mod; + sr_conn_ctx_t *conn; + const struct ly_ctx *ly_ctx; + int rc; + + conn = sr_session_get_connection(confd->session); + ly_ctx = sr_acquire_context(conn); + mod = ly_ctx_get_module_implemented(ly_ctx, HW_MODULE); + sr_release_context(conn); + + if (!mod || lys_feature_value(mod, "modem") != LY_SUCCESS) + return SR_ERR_OK; + + REGISTER_NOTIF(confd->session, HW_BASE, + IH_MODEM "/status-update", + infix_modem_notif, confd, &confd->sub); + REGISTER_RPC(confd->session, IH_COMP_ACTION "restart", + infix_modem_restart, NULL, &confd->sub); + REGISTER_RPC(confd->session, IH_COMP_ACTION "reset", + infix_modem_reset, NULL, &confd->sub); + REGISTER_RPC(confd->session, IH_COMP_ACTION "send-sms", + infix_modem_sendsms, NULL, &confd->sub); + + return SR_ERR_OK; +fail: + ERROR("init failed: %s", sr_strerror(rc)); + return rc; +} diff --git a/src/confd/src/syslog.c b/src/confd/src/syslog.c index c954ed6afc..f964ed0d02 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 69dd496a8a..491248f759 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -24,20 +24,20 @@ 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" + "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" "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-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" @@ -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" diff --git a/src/confd/yang/confd/infix-dhcp-client.yang b/src/confd/yang/confd/infix-dhcp-client.yang index 1751eb9bc8..76b0b2960e 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 diff --git a/src/confd/yang/confd/infix-dhcp-server.yang b/src/confd/yang/confd/infix-dhcp-server.yang index 072ff3c5a6..b1637fa6fd 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}]+'; } } } 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-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 44a52c98a9..430cc4ce12 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,15 @@ 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 including location and + last-change, 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 +97,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 +176,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 +507,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'" { @@ -209,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; } @@ -664,6 +1007,438 @@ 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 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."; + } + + 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."; + } + } + + container location-state { + description + "Location data observed by the modem. Coordinates come from the + modem's GPS subsystem (or A-GPS); cell-id/lac/tac/mcc/mnc come + from 3GPP signalling. Source indicates which mechanism produced + the current fix. Empty when no source is enabled or no fix is + available."; + + leaf source { + type modem-location-source; + description "Source that produced the current fix."; + } + + leaf latitude { + type decimal64 { fraction-digits 6; } + units "degrees"; + description "Latitude in decimal degrees, negative = south."; + } + + leaf longitude { + type decimal64 { fraction-digits 6; } + units "degrees"; + description "Longitude in decimal degrees, negative = west."; + } + + leaf altitude { + type decimal64 { fraction-digits 1; } + units "meters"; + description "Altitude above mean sea level."; + } + + leaf cell-id { + type uint32; + description "Serving 3GPP cell identifier."; + } + + leaf lac { + type uint16; + description "Location Area Code (2G/3G)."; + } + + leaf tac { + type uint16; + description "Tracking Area Code (LTE/5G)."; + } + + leaf mcc { + type string { pattern '[0-9]{3}'; } + description "Mobile Country Code."; + } + + leaf mnc { + type string { pattern '[0-9]{2,3}'; } + description "Mobile Network Code."; + } + + leaf last-change { + type yang:date-and-time; + description "Timestamp when location-state was last refreshed."; + } + } + + leaf last-change { + type yang:date-and-time; + description + "Timestamp of the most recent change to any leaf in modem-state. + Consumers can use this to gauge data freshness independent of + individual leaf timestamps."; + } + } + + /* + * SIM card configuration (when class = 'ih:sim') + */ + + container sim { + if-feature modem; + when "derived-from-or-self(../iehw:class, 'ih:sim')"; + presence "SIM card configuration"; + description + "SIM card / subscription configuration. + + PIN and PUK are per-SIM credentials used to unlock the card. + carrier selects the operator profile that modemd applies to the + modem firmware when this SIM is active (band preferences, firmware + quirks). On hardware with a SIM multiplexer (/dev/simctrl) each + SIM slot gets its own component (sim0, sim1, …); on hardware without + a multiplexer a synthetic sim0 represents the single SIM present."; + + leaf pin { + type string; + nacm:default-deny-all; + description + "PIN code to unlock this SIM card. Leave unset if the SIM has + PIN protection disabled."; + } + + leaf puk { + type string; + nacm:default-deny-all; + description + "PUK (PIN Unblocking Key) to unblock a PIN-locked SIM. Only + needed after too many incorrect PIN attempts."; + } + + leaf carrier { + type string; + description + "Operator profile name applied to the modem firmware when this + SIM is active (e.g. 'default', 'att', 'verizon'). Profiles + are provided by the modem-carrier helper in modemd. Omit to + use the modem's built-in defaults."; + } + } + + /* + * SIM card operational state (when class = 'ih:sim') + * Populated by statd/infix_modem.py from modemd runtime data. + */ + + container sim-state { + if-feature modem; + when "derived-from-or-self(../iehw:class, 'ih:sim')"; + config false; + description + "SIM card operational state. + + Shows which physical slot the SIM occupies, its lock state, and + the home operator name stored on the card. Useful for remote + diagnostics (e.g. confirming which SIM slot is active)."; + + leaf slot { + type uint8; + description + "Physical SIM slot number (1-based). + For single-SIM modems this is always 1."; + } + + leaf state { + type enumeration { + enum 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 +1589,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 0000000000..6540135c50 --- /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 0000000000..3d99e0bdda --- /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 edf4faf1ab..417ebab931 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 2e51ba0eab..24bb4a6351 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-syslog.yang b/src/confd/yang/confd/infix-syslog.yang index 37bd6e908e..4b0c9c546b 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 diff --git a/src/confd/yang/modem.inc b/src/confd/yang/modem.inc new file mode 100644 index 0000000000..6a9a873800 --- /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 bcca3206e5..c3455287d0 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -122,9 +122,21 @@ - + - + + 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 + @@ -455,6 +467,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" + + + + @@ -692,7 +728,7 @@ echo "Public: $pub" - + @@ -713,6 +749,15 @@ echo "Public: $pub" + + + + + + show modem "$KLISH_PARAM_ref" |pager + + + diff --git a/src/modemd/77-mm-dell-port-types.rules b/src/modemd/77-mm-dell-port-types.rules new file mode 100644 index 0000000000..376b75f35d --- /dev/null +++ b/src/modemd/77-mm-dell-port-types.rules @@ -0,0 +1,39 @@ +# do not edit this file, it will be overwritten on update + +ACTION!="add|change|move|bind", GOTO="mm_dell_port_types_end" + +SUBSYSTEMS=="usb", ATTRS{idVendor}=="413c", GOTO="mm_dell_vendorcheck" +GOTO="mm_dell_port_types_end" + +LABEL="mm_dell_vendorcheck" +SUBSYSTEMS=="usb", ATTRS{bInterfaceNumber}=="?*", ENV{.MM_USBIFNUM}="$attr{bInterfaceNumber}" + +# Dell DW5821e (default 0x81d7, with esim support 0x81e0) +# if 02: primary port +# if 03: secondary port +# if 04: raw NMEA port +# if 05: diag/qcdm port +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="02", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="03", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_SECONDARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="04", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_GPS}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d7", ENV{.MM_USBIFNUM}=="05", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_QCDM}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="02", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="03", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_SECONDARY}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="04", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_GPS}="1" +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81e0", ENV{.MM_USBIFNUM}=="05", SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_QCDM}="1" + +# Dell DW5820e +# if 02: AT port +# if 04: debug port (ignore) +# if 06: AT port +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81d9", ENV{.MM_USBIFNUM}=="04", ENV{ID_MM_PORT_IGNORE}="1" + +# Dell DW5811e (Sierra Wireless EM7455 rebadge) +# USB composition 8: MBIM-only (bInterfaceClass=02 bInterfaceSubClass=0e) +# Bound by cdc_mbim driver; no AT ports in this composition. +# if 0c: MBIM control port +# if 0d: data port (handled by cdc_mbim / cdc_ncm driver) +ATTRS{idVendor}=="413c", ATTRS{idProduct}=="81b6", ENV{.MM_USBIFNUM}=="0c", SUBSYSTEM=="usbmisc", ENV{ID_MM_PORT_TYPE_MBIM}="1" + +GOTO="mm_dell_port_types_end" +LABEL="mm_dell_port_types_end" diff --git a/src/modemd/77-mm-modem-gps.rules b/src/modemd/77-mm-modem-gps.rules new file mode 100644 index 0000000000..995e3b126b --- /dev/null +++ b/src/modemd/77-mm-modem-gps.rules @@ -0,0 +1,41 @@ +# Cellular modem NMEA port routing +# +# For modems whose GPS NMEA stream is available on a dedicated USB +# interface, expose that TTY as /dev/gpsN so the existing gpsd → chronyd +# pipeline can consume it like any other NMEA USB GPS receiver. Setting +# ID_MM_PORT_IGNORE=1 keeps ModemManager from claiming the NMEA port for +# itself; modemd issues the vendor-specific AT command to enable GPS +# output (see prepare_location() in modemd). +# +# When adding a new modem entry, find the NMEA interface number with +# 'mmcli -m N' or 'lsusb -v' and add a rule below. + +ACTION!="add|change|move|bind", GOTO="modem_gps_end" + +# Note: bInterfaceNumber lives on the USB interface device while +# idVendor/idProduct live on the USB device — udev's ATTRS{} requires +# all matches in one rule to share a parent, so we match the interface +# number via ENV{ID_USB_INTERFACE_NUM}, which udev populates from +# bInterfaceNumber and which has no same-parent constraint. + +# Quectel EM05 — NMEA on interface 1 +SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0125", \ + ENV{ID_USB_INTERFACE_NUM}=="01", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +# Quectel EM06-E / EM06-A — NMEA on interface 1 +SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0306", \ + ENV{ID_USB_INTERFACE_NUM}=="01", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +# Quectel EM12-G — NMEA on interface 1 +SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0512", \ + ENV{ID_USB_INTERFACE_NUM}=="01", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +# Sierra Wireless EM7565 — NMEA on interface 2 +SUBSYSTEM=="tty", ATTRS{idVendor}=="1199", ATTRS{idProduct}=="9091", \ + ENV{ID_USB_INTERFACE_NUM}=="02", \ + ENV{ID_MM_PORT_IGNORE}="1", SYMLINK+="gps%n" + +LABEL="modem_gps_end" diff --git a/src/modemd/LICENSE b/src/modemd/LICENSE new file mode 100644 index 0000000000..3db075fa2c --- /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 0000000000..090a519135 --- /dev/null +++ b/src/modemd/finit.conf @@ -0,0 +1 @@ +service [2345] name:modemd /sbin/modemd -- Modem daemon diff --git a/src/modemd/modem-command.c b/src/modemd/modem-command.c new file mode 100644 index 0000000000..96a2f7aa15 --- /dev/null +++ b/src/modemd/modem-command.c @@ -0,0 +1,617 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int verbose = 0; + +#define MODEMS_LOCK "/var/lock/modems.lock" +#define MODEMS_INFO "/run/modems.json" +#define TIMEOUT 3 + +#define DBG(fmt, a...) print(LOG_DEBUG, fmt, ##a) +#define SAY(fmt, a...) print(LOG_INFO, fmt, ##a) +#define ERR(fmt, a...) print(LOG_ERR, fmt, ##a) +#define FATAL(fmt, a...) print(LOG_ALERT, fmt, ##a) + +static void print (int level, const char *fmt, ...) +{ + char msg[1024]; + va_list ap; + + if (!fmt) return; + va_start(ap, fmt); + vsnprintf(msg, sizeof(msg) - 1, fmt, ap); + va_end(ap); + + switch (level) { + case LOG_ERR: + fprintf(stderr, "ERROR: %s\n", msg); + break; + case LOG_ALERT: + fprintf(stderr, "ERROR: %s\n", msg); + unlink(MODEMS_LOCK); + exit(1); + break; + case LOG_DEBUG: + if (verbose) { + printf("DEBUG: %s\n", msg); + } + break; + default: + printf("%s\n", msg); + break; + } +} + +static void printser (int dir, char *ser) +{ + char quoted[1024]; + char *p; + int len = 0; + + for (p = ser; *p; p++) { + if (*p == '\r') { + len += snprintf(quoted + len, sizeof(quoted) - len, ""); + } else if (*p == '\n') { + len += snprintf(quoted + len, sizeof(quoted) - len, ""); + } else { + len += snprintf(quoted + len, sizeof(quoted) - len, "%c", *p); + } + } + if (dir == 0) { + DBG("Received: '%s'", quoted); + } else { + DBG("Sending '%s'", quoted); + } +} + + +struct ttymode { + int set; + int speed; + int parity; + int bits; + int stopbits; +}; + +static int parse_mode (const char *str, struct ttymode *mode) +{ + char buf[256]; + char *t1, *t2; + + /* parse string */ + if (strlen(str) >= sizeof(buf)) { + return -1; + } + strncpy(buf, str, sizeof(buf)); + t1 = strtok(buf, " "); + if (!t1) { + ERR("Invalid format"); + return -1; + } + t2 = strtok(NULL, " "); + if (!t2 || strlen(t2) != 3) { + ERR("Invalid format"); + return -1; + } + + /* parse speed */ + switch (atoi(t1)) { + case 9600: + mode->speed = B9600; + break; + case 19200: + mode->speed = B19200; + break; + case 38400: + mode->speed = B38400; + break; + case 57600: + mode->speed = B57600; + break; + case 115200: + mode->speed = B115200; + break; + default: + ERR("Invalid baudrate"); + return -1; + } + + /* parse bits */ + switch (t2[0]) { + case '5': + mode->bits = CS5; + break; + case '6': + mode->bits = CS6; + break; + case '7': + mode->bits = CS7; + break; + case '8': + mode->bits = CS8; + break; + default: + ERR("Invalid bits"); + return -1; + } + + /* parse parity */ + switch (t2[1]) { + case 'N': + mode->parity = 0; + break; + case 'E': + mode->parity = PARENB; + break; + case 'O': + mode->parity = PARENB | PARODD; + break; + default: + ERR("Invalid parity"); + return -1; + } + + /* parse stopbits */ + switch (t2[2]) { + case '1': + mode->stopbits = 0; + break; + case '2': + mode->stopbits = CSTOPB; + break; + default: + ERR("Invalid stop bits %c"); + return -1; + } + + return 0; +} + + +static int setup (const char *tty, int timeout, struct ttymode *mode) +{ + struct termios stbuf; + struct stat st; + char dev[32]; + int i, fd; + + if (strncmp(tty, "/dev", 4) == 0) { + strncpy(dev, tty, sizeof(dev)); + } else { + snprintf(dev, sizeof(dev), "/dev/%s", tty); + } + + /* wait for device */ + for (i = 0; i < timeout; i++) { + if (lstat(dev, &st) == 0) break; + sleep(1); + } + if (i >= timeout) { + ERR("Timeout waiting for %s", dev); + return -1; + } + + DBG("Opening %s", tty); + fd = open(dev, O_RDWR | O_EXCL | O_NONBLOCK | O_NOCTTY); + if (fd < 0) { + ERR("Unable to open '%s'", dev); + return -1; + } + + if (!mode->set) return fd; + + memset(&stbuf, 0, sizeof (struct termios)); + if (tcgetattr(fd, &stbuf) != 0) { + ERR("Unable to get serial attributes"); + goto error; + } + /* default settings */ + stbuf.c_cflag &= ~(CBAUD | CSIZE | CSTOPB | PARENB | PARODD | CRTSCTS); + stbuf.c_iflag &= ~(IGNCR | ICRNL | IUCLC | INPCK | IXON | IXOFF | IXANY ); + stbuf.c_oflag &= ~(OPOST | OLCUC | OCRNL | ONLCR | ONLRET); + stbuf.c_lflag &= ~(ICANON | ECHO | ECHOE | ECHONL); + stbuf.c_cc[VMIN] = 1; + stbuf.c_cc[VTIME] = 0; + stbuf.c_cc[VEOF] = 1; + + /* ignore parity */ + stbuf.c_iflag |= IGNPAR; + + /* configure serial attributes */ + stbuf.c_cflag |= (mode->bits | mode->parity | mode->stopbits | CLOCAL | CREAD); + + /* disable XON/XOFF flow control */ + stbuf.c_iflag &= ~(IXON | IXOFF | IXANY); + + /* disable RTS/CTS flow control */ + stbuf.c_cflag &= ~(CRTSCTS); + + if (cfsetispeed(&stbuf, mode->speed) != 0 || + cfsetospeed(&stbuf, mode->speed) != 0) { + ERR("Unable to set port speed"); + goto error; + } + + if (tcsetattr(fd, TCSANOW, &stbuf) != 0) { + ERR("Unable to set serial attributes"); + goto error; + } + + return fd; +error: + close(fd); + return -1; +} + +static int serial_write (int fd, int timeout, char *buf, int len) +{ + struct timeval tv; + fd_set fds; + + FD_ZERO(&fds); + FD_SET(fd, &fds); + tv.tv_sec = timeout; + tv.tv_usec = 0; + + if (select(fd + 1, NULL, &fds, NULL, &tv) <= 0) { + DBG("Cannot write to serial device"); + return -1; + } + alarm(timeout); + tcflush(fd, TCIOFLUSH); + + printser(1, buf); + if (write(fd, buf, len) != len) { + return -1; + } + tcdrain(fd); + alarm(0); + + return 0; +} + +static int serial_read (int fd, int timeout, char *buf, int size) +{ + struct timeval tv; + fd_set fds; + int rc, len = 0; + + FD_ZERO(&fds); + FD_SET(fd, &fds); + tv.tv_sec = timeout; + tv.tv_usec = 0; + memset(buf, 0, size); + + rc = select(fd + 1, &fds, NULL, NULL, &tv); + if (rc <= 0) { + DBG("Cannot read from serial device (%d)", rc); + return -1; + } + do { + errno = 0; + alarm(timeout); + rc = read(fd, buf+len, size-len-1); + alarm(0); + + if (rc > 0) { + len += rc; + } else { + if (rc < 0 && errno == -EAGAIN) { + continue; + } else { + break; + } + } + } while (1); + + if (len > 0) buf[len] = '\0'; + + printser(0, buf); + + return len; +} + +static int execute (int fd, int timeout, const char *cmd, const char *expect) +{ + char buf[1024]; + time_t start; + long elapsed; + int len, ret = -1; + + if (cmd) { + start = time(NULL); + len = snprintf(buf, sizeof(buf), "%s\r\n", cmd); + if (serial_write(fd, timeout, buf, len) < 0) { + DBG("Cannot write to serial device"); + return -1; + } + elapsed = time(NULL) - start; + timeout -= elapsed; + } + + DBG("Waiting for response"); + + while (timeout > 0) { + start = time(NULL); + len = serial_read(fd, timeout, buf, sizeof(buf)); + if (len > 0) { + buf[len] = '\0'; + SAY("%s", buf); + + if (strstr(buf, expect)) { + ret = 0; + break; + } + } + elapsed = time(NULL) - start; + timeout -= elapsed; + } + return ret; +} + +static int probe (int fd) +{ + char buf[256]; + int i, len; + + DBG("Probing serial port"); + + /* try 3 times */ + for (i = 3; i > 0; i--) { + len = snprintf(buf, sizeof(buf), "AT\r\n"); + if (serial_write(fd, 1, buf, len) < 0) { + DBG("Cannot write to tty"); + continue; + } + len = serial_read(fd, 1, buf, sizeof(buf)); + if (len <= 0) { + DBG("Cannot read from tty"); + continue; + } + if (strstr(buf, "OK")) { + return 0; + } + } + + DBG("Probing failed"); + return -1; +} + +static int modemidx (const char *devpath) +{ + json_t *json, *modules, *module, *mod, *paths, *obj; + const char *p; + int i, j, index = -1; + + json = json_load_file("/run/modules.json", 0, NULL); + if (!json) { + return -1; + } + modules = json_object_get(json, "modules"); + if (!modules || !json_is_array(modules)) { + goto out; + } + for (i = 0; i < json_array_size(modules); i++) { + module = json_array_get(modules, i); + if (!module) continue; + + obj = json_object_get(module, "type"); + if (!obj || !json_is_string(obj)) continue; + p = json_string_value(obj); + if (strcmp(p, "modem") != 0) continue; + + obj = json_object_get(module, "index"); + if (!obj || !json_is_integer(obj)) continue; + index = json_integer_value(obj); + + paths = json_object_get(module, "paths"); + if (!paths || !json_is_array(paths)) continue; + + for (j = 0; j < json_array_size(paths); j++) { + obj = json_array_get(paths, j); + if (!obj) continue; + p = json_string_value(obj); + + if (strstr(devpath, p)) { + /* found */ + goto out; + } + } + } + index = -1; +out: + json_decref(json); + return index; +} + +static char *modemdev (int index, int port) +{ + json_t *json, *modems, *modem, *ports, *obj; + char *dev = NULL; + int i, p, fd; + + fd = open(MODEMS_LOCK, + O_WRONLY | O_CREAT | O_EXCL, + S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH); + if (fd < 0) { + ERR("Cannot open lock"); + return NULL; + } + if (flock(fd, LOCK_EX) < 0) { + ERR("Cannot to lock"); + close(fd); + return NULL; + } + + json = json_load_file(MODEMS_INFO, 0, NULL); + if (!json) { + goto out; + } + modems = json_object_get(json, "modems"); + if (!modems || !json_is_array(modems)) { + goto out; + } + for (i = 0; i < json_array_size(modems); i++) { + modem = json_array_get(modems, i); + if (!modem) + continue; + + obj = json_object_get(modem, "devpath"); + if (!obj || !json_is_string(obj)) + continue; + + if (modemidx(json_string_value(obj)) != index) + continue; + + ports = json_object_get(modem, "atports"); + if (!ports || !json_is_array(ports) || json_array_size(ports) == 0) + goto out; + + if (json_array_size(ports) >= port) + port = 0; + + obj = json_array_get(ports, port); + if (!obj || !json_is_string(obj)) + goto out; + + /* found */ + dev = strdup(json_string_value(obj)); + goto out; + } + dev = NULL; + +out: + if (json) json_decref(json); + + unlink(MODEMS_LOCK); + flock(fd, LOCK_UN); + close(fd); + + return dev; +} + +static struct option options[] = { + { "index", required_argument, 0, 'i' }, + { "device", required_argument, 0, 'd' }, + { "mode", required_argument, 0, 'm' }, + { "command", required_argument, 0, 'c' }, + { "expect", required_argument, 0, 'e' }, + { "timeout", required_argument, 0, 't' }, + { "primary", no_argument, 0, 'P' }, + { "secondary", no_argument, 0, 'S' }, + { "verbose", no_argument, 0, 'v' }, + { "help", no_argument, 0, 'h' }, + { 0, 0, 0, 0 } +}; + +static void usage (void) +{ + fprintf(stderr, "Usage: modem-command [OPTIONS] [command]\n\n"); + fprintf(stderr, " --index Index of modem\n"); + fprintf(stderr, " --device TTY device of modem\n"); + fprintf(stderr, " --mode Serial mode for TTY (e.g. '115200 8N1')\n"); + fprintf(stderr, " --expect String to expect\n"); + fprintf(stderr, " --timeout Timeout for operation\n"); + fprintf(stderr, " --primary Use primary atport\n"); + fprintf(stderr, " --secondary Use secondary atport\n"); + fprintf(stderr, " --verbose Be verbose\n"); + fprintf(stderr, " --help print help\n\n"); + exit(1); +} + +int main (int argc, char *argv[]) +{ + const char *cmd = NULL, *expect = "OK"; + char *dev = NULL; + struct ttymode mode; + int timeout = TIMEOUT; + int index = 0, port = 0; + int opt, c, fd; + int ret = 1; + + memset(&mode, 0, sizeof(struct ttymode)); + + while (1) { + c = getopt_long(argc, argv, "i:d:m:e:t:PSvh", options, &opt); + if (c < 0) break; + + switch (c) { + case 'i': + index = atoi(optarg); + break; + case 'd': + dev = strdup(optarg); + break; + case 'm': + if (parse_mode(optarg, &mode) < 0) { + FATAL("Invalid serial mode specified"); + } + mode.set = 1; + break; + case 'c': + cmd = optarg; + break; + case 'e': + expect = optarg; + break; + case 't': + timeout = atoi(optarg); + break; + case 'P': + port = 0; + break; + case 'S': + port = 1; + break; + case 'v': + verbose = 1; + break; + default: + usage(); + break; + } + } + + if (optind < argc) { + cmd = argv[optind]; + if (strlen(cmd) == 0) + FATAL("Invalid command"); + } + dev = modemdev(index, port); + if (!dev || strlen(dev) == 0) { + FATAL("No device found"); + } + fd = setup(dev, timeout, &mode); + if (fd < 0) { + FATAL("Unable to setup device"); + } + + if (probe(fd) < 0) { + ERR("Unable to probe device"); + goto abort; + } + if (execute(fd, timeout, cmd, expect) < 0) { + ERR("Command failed"); + goto abort; + } + ret = 0; + +abort: + close(fd); + if (dev) free(dev); + return ret; +} diff --git a/src/modemd/modemd.modules-load b/src/modemd/modemd.modules-load new file mode 100644 index 0000000000..4542790393 --- /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 0000000000..4725c05700 --- /dev/null +++ b/src/modemd/modemd.rules @@ -0,0 +1,8 @@ +ACTION!="add", GOTO="modemd_end" + +SUBSYSTEM=="net", ENV{INTERFACE}=="wwan[0-9]*", RUN+="/usr/libexec/modemd/modem-udev" +SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_PRIMARY}=="1", RUN+="/usr/libexec/modemd/modem-udev" +SUBSYSTEM=="tty", ENV{ID_MM_PORT_TYPE_AT_SECONDARY}=="1", RUN+="/usr/libexec/modemd/modem-udev" +SUBSYSTEM=="usbmisc", KERNEL=="cdc-wdm[0-9]*",, RUN+="/usr/libexec/modemd/modem-udev" + +LABEL="modemd_end" diff --git a/src/modemd/modemd/__init__.py b/src/modemd/modemd/__init__.py new file mode 100644 index 0000000000..660b2ce6ee --- /dev/null +++ b/src/modemd/modemd/__init__.py @@ -0,0 +1,2091 @@ +import threading +import subprocess +import argparse +import datetime +import hashlib +import signal +import syslog +import socket +import shutil +import select +import re +import json +import enum +import time +import os +import sys +import ipaddress + +rundir = "/run/modemd" +smsdir = "/var/sms" +pidfile = "/run/modemd.pid" +debug = False +threads = [] +reload_event = threading.Event() + +# Vendor-specific AT commands to enable / disable raw NMEA output on the +# modem's dedicated GPS port. Used when the udev rules in +# 77-mm-modem-gps.rules have set ID_MM_PORT_IGNORE on that port — gpsd +# then reads NMEA directly from /dev/gpsN. For unrecognised vendors, +# prepare_location() falls back to ModemManager's high-level options +# (which keeps MM in charge of the NMEA port). Keys 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 = { + # 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. +# 'gps' is handled separately because known vendors take the raw AT path. +LOCATION_MM_FLAGS = { + "agps-msa": ("agps-msa",), + "agps-msb": ("agps-msb",), + "3gpp": ("3gpp",), + "cdma": ("cdma-bs",), +} + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + +class Trigger(enum.Enum): + POWER = 1 + RESET = 2 + RESTART = 3 + + +class State(enum.Enum): + FAILED = -1 + DOWN = 0 + ENABLED = 1 + CONNECTING = 2 + CONNECTED = 3 + UP = 4 + + +def info(msg): + syslog.syslog(syslog.LOG_INFO, " %s" % msg) + + +def dbg(msg): + global debug + if debug: + syslog.syslog(syslog.LOG_INFO, " %s" % msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, " %s" % msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, " %s" % msg) + sys.exit(1) + + +def opener(path, flags): + ret = os.open(path, flags, 0o664) + shutil.chown(path, user="root", group="wheel") + return ret + + +def rmf(path): + try: + os.unlink(path) + except FileNotFoundError: + pass + + +def rmrf(path): + for f in os.listdir(path): + p = os.path.join(path, f) + if os.path.isdir(p): + rmrf(p) + else: + os.remove(p) + + +def mkdir(path): + if os.path.isdir(path): + return True + try: + os.mkdir(path, mode=0o755) + shutil.chown(path, user="root", group="wheel") + except OSError: + fatal("Unable to mkdir %s" % path) + + +def fread(path): + try: + with open(path, "r") as fd: + output = fd.read() + return output.strip() if output else None + except OSError: + return None + + +def freadj(path): + output = fread(path) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + dbg("Unable to parse json output") + return None + finally: + return data + + +def fwrite(path, cont): + with open(path, "w", opener=opener) as fd: + if not fd.write(cont): + return False + return True + + +def fwritej(path, cont): + if fwrite(path, json.dumps(cont)): + return True + else: + return False + + +def now_rfc3339(): + """Return current UTC time as a yang:date-and-time string. + + Format matches yanger.common.YangDate (+00:00 offset, not Z) so that + consumers see identical timestamp formatting regardless of producer. + """ + return datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds") + + +def fwritej_atomic(path, cont): + """Write JSON to path via tmp+rename so readers never see a partial file.""" + tmp = path + ".tmp" + try: + with open(tmp, "w", opener=opener) as fd: + fd.write(json.dumps(cont)) + os.rename(tmp, path) + return True + except OSError: + try: + os.remove(tmp) + except OSError: + pass + return False + + +def runcmdp(cmd1, cmd2, check=True): + dbg("Running: %s | %s" % (" ".join(cmd1), " ".join(cmd2))) + ret = None + try: + res1 = subprocess.run(cmd1, check=check, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + res2 = subprocess.run(cmd2, input=res1.stdout, check=check, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if res1.returncode == 0 and res2.returncode == 0: + if res2.stdout: + ret = res2.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + dbg("Piped command failed") + dbg(res1.stderr) + dbg(res2.stderr) + return None + finally: + return ret + + +def runcmd(cmd, check=True): + dbg("Running: %s" % " ".join(cmd)) + ret = None + try: + res = subprocess.run(cmd, check=check, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError as e: + dbg("Command %s failed" % cmd[0]) + dbg(e) + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + dbg("Unable to parse json output") + return None + finally: + return data + + +def 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 = [] + + output = None + if modem: + output = runcmdj(["mmcli", "-J", "-m", modem, + "--messaging-list-sms"]) + if not output: + return smslist + + for path in output["modem.messaging.sms"]: + output = runcmdj(["mmcli", "-J", "-s", path]) + if not output: + continue + props = output["sms"]["properties"] + if props["pdu-type"] == "submit": + if props["state"] == "--": + smslist.append({"path": path, + "type": "notsent"}) + elif props["state"] == "sent": + smslist.append({"path": path, + "type": "sent"}) + elif props["pdu-type"] == "deliver": + if props["state"] == "received": + smslist.append({"path": path, + "type": "received", + "payload": output["sms"]}) + + return smslist + + +def load_config(): + hw = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-hardware"]) + ifaces = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-interfaces"]) + + ks = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-keystore"]) + keys = {} + if ks: + for key in ks.get("ietf-keystore:keystore", {}) \ + .get("symmetric-keys", {}) \ + .get("symmetric-key", []): + keys[key["name"]] = key.get("cleartext-symmetric-key", "") + + # Cellular modems appear seconds after boot; use physical detection as the + # authoritative list rather than sysrepo which may not have them yet. + sysj = freadj("/run/system.json") or {} + physical = sysj.get("modem", []) + if not physical: + mj = freadj("/run/modems.json") or {} + for i, m in enumerate(mj.get("modems", [])): + physical.append({"index": i, "name": "modem%d" % i, + "devpath": m.get("devpath", "")}) + + hw_comps, sims = {}, {} + for comp in (hw or {}).get("ietf-hardware:hardware", {}).get("component", []): + cls = comp.get("class", "") + if cls == "infix-hardware:modem": + hw_comps[comp["name"]] = comp + elif cls == "infix-hardware:sim": + sims[comp["name"]] = comp.get("infix-hardware:sim", {}) + + 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 + + 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", []), + "location": ih.get("location"), + "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 + wwan = iface.get("infix-interfaces:wwan", {}) + modem_ref = wwan.get("modem") + if not modem_ref or modem_ref not in modems: + continue + + sim_ref = wwan.get("sim") + if sim_ref and sim_ref in sims and "pin" not in modems[modem_ref]: + sim = sims[sim_ref] + modems[modem_ref].update({ + "pin": sim.get("pin"), + "puk": sim.get("puk"), + "carrier": sim.get("carrier", "default"), + }) + + bearer_cfg = wwan.get("bearer", {}) + auth = bearer_cfg.get("authentication") + bearer = { + "index": len(modems[modem_ref]["bearer"]), + "name": iface["name"], + "apn": bearer_cfg.get("apn", ""), + "ip-type": bearer_cfg.get("ip-type", "ipv4v6"), + "roaming": bearer_cfg.get("roaming", False), + "route-preference": bearer_cfg.get("route-preference", 200), + } + if auth: + bearer["username"] = auth.get("username", "") + bearer["password"] = keys.get(auth.get("password", ""), "") + + modems[modem_ref]["bearer"].append(bearer) + + return list(modems.values()) + + +class RpcThread(threading.Thread): + path = None + server = None + + def __init__(self): + global rundir + super().__init__(daemon=True, name="rpc") + self.exited = False + self.stopevent = threading.Event() + self.sock = rundir + "/modemd.sock" + + def stop(self): + self.stopevent.set() + + def stopped(self): + return self.stopevent.is_set() + + def sendsms(self, path, data): + info("Sending sms to %s" % data["number"]) + if not path: + err("No modem path yet") + return False + + opts = [] + opts.append('number="%s"' % data["number"]) + opts.append('text="%s"' % data["text"]) + + if not runcmd(["mmcli", "-m", path, + "--messaging-create-sms=%s" % ",".join(opts)]): + err("Unable to create sms") + return False + + return True + + def rpc(self, rpc, data): + global threads + + info("Got rpc %s for modem%d" % (rpc, data["index"])) + for th in threads: + if th.name.startswith("modem") and th.index == data["index"]: + if rpc == "restart": + th.trigger = Trigger.RESTART + th.interrupt = True + elif rpc == "reset": + th.trigger = Trigger.RESET + th.interrupt = True + elif rpc == "send-sms": + self.sendsms(th.path, data) + return True + + def handle(self, msg): + if "rpc" not in msg: + err("No rpc in msg") + return False + elif "data" not in msg: + err("No data in msg") + return False + elif "index" not in msg["data"]: + err("No index") + return False + + if msg["rpc"] == "send-sms": + if "number" not in msg["data"]: + err("No sms number") + return False + if "text" not in msg["data"]: + err("No sms text") + return False + + return self.rpc(msg["rpc"], msg["data"]) + + def process(self): + conn, addr = self.server.accept() + try: + while not self.stopped(): + msg = conn.recv(1024) + if msg: + self.handle(json.loads(msg.decode())) + else: + break + finally: + conn.close() + return True + + def run(self): + rmf(self.sock) + self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server.bind(self.sock) + self.server.listen(1) + + while not self.stopped(): + if not self.process(): + return False + + self.server.close() + rmf(self.sock) + + dbg("Exiting rpc") + self.exited = True + return True + + +class SimThread(threading.Thread): + def __init__(self): + super().__init__(daemon=True, name="sim") + self.exited = False + self.stopevent = threading.Event() + + def stop(self): + self.stopevent.set() + + def stopped(self): + return self.stopevent.is_set() + + def changed(self, index): + global threads + + path = "/sys/class/sim/sim%d/present" % index + present = int(fread(path)) + if present is None: + return + if present == 0: + info("sim%d disappeared" % index) + return + + info("sim%d appeared" % index) + for th in threads: + if th.name.startswith("modem") and th.sim["index"] == index: + th.trigger = Trigger.POWER + th.interrupt = True + + def run(self): + if not os.path.exists("/dev/simctrl"): + dbg("No /dev/simctrl, SIM slot monitoring not available on this platform") + self.exited = True + return + fd = os.open("/dev/simctrl", os.O_RDONLY) + while not self.stopped(): + res, *_ = select.select([fd], [], [], 10.0) + if fd in res: + index = int.from_bytes(os.read(fd, 1)) + self.changed(index) + + dbg("Exiting sim") + fd.close() + self.exited = True + return True + + +class ModemThread(threading.Thread): + index = None + cfg = None + + def __init__(self, cfg): + global rundir + + self.index = cfg["index"] + super().__init__(daemon=True, name="modem%d" % self.index) + self.stopevent = threading.Event() + self.exited = False + + self.cfg = cfg + self.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 + self.locdir = "%s/location" % self.rundir + + mkdir(self.rundir) + 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 + + # 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): + # 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): + return self.stopevent.is_set() + + def dbg(self, msg): + dbg("%s%s" % (self.prefix, msg)) + + def info(self, msg): + info("%s%s" % (self.prefix, msg)) + + def err(self, msg): + err("%s%s" % (self.prefix, msg)) + + def fatal(self, msg): + fatal("%s%s" % (self.prefix, msg)) + + def _mmcli(self, *args, check=True): + return runcmd(["mmcli", "-m", self.path, *args], check=check) + + def _mmclij(self, *args): + return runcmdj(["mmcli", "-J", "-m", self.path, *args]) + + def init(self): + self.timeout = 1 + self.path = None + self.interfaces = [] + self.ifaces = [] + self.dhcp = {} + self.ppp = {} + self.status = {} + self.state = None + self.trigger = None + 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 + self.updfail = 0 + self.connfail = 0 + self.location = {"enabled": False} + + self.set_state(State.DOWN) + self.configure() + + def configure(self): + self.bearers = { + "list": [], + "initial": None, + "active": [] + } + bearers = self.cfg.get("bearer") + if not bearers: + self.err("No bearers configured") + return False + + for bearer in sorted(bearers, key=lambda b: b["index"]): + bearer["pid"] = "--" + + apn = bearer.get("apn") + if not apn: + self.err("No APN configured") + return False + + self.bearers["list"].append(bearer) + + # at least one bearer is mandatory + if len(self.bearers["list"]) == 0: + self.err("No bearers evaluated") + return False + + # check for duplicate APNs + seen = [] + for bearer in self.bearers["list"]: + if bearer["apn"] not in seen: + seen.append(bearer["apn"]) + else: + self.err("Duplicate APN '%s'" % bearer["apn"]) + return False + + if not self.bearers["initial"]: + self.bearers["initial"] = self.bearers["list"][0] + + # check location + enabled = False + sources = [] + loc = self.cfg.get("location") + if loc and loc.get("enabled"): + enabled = True + sources = loc.get("source", []) + + self.location = { + "enabled": enabled, + "sources": sources, + "state": "down"} + + return True + + def find_bearer(self, apn): + for bearer in self.bearers["active"]: + if bearer["apn"] == apn: + return bearer + return None + + def restart(self): + self.info("Restarting") + self.notif("RESTART") + self.pulldown() + + def power(self): + self.info("Power-cycling") + self.notif("POWER") + + self.pulldown() + + if runcmd(['/usr/libexec/modemd/modem-power', + '-i', str(self.index)]): + self.info("Waiting 10s") + time.sleep(10) + else: + self.err("Unable to power-cycle") + + self.init() + + def reset(self): + 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") + + self.pulldown() + + wait = 0 + if self._mmcli("--reset"): + self.info("Performed a normal reset") + wait = 5 + elif self._mmcli("--factory-reset=000000"): + self.info("Performed a factory reset") + wait = 10 + else: + self.err("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) + time.sleep(wait) + + self.init() + + def notif(self, msg): + data = { + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % self.index, + "infix-hardware:modem": { + "status-update": {"desc": msg} + } + }] + } + } + note = "%s/notif" % self.rundir + fwritej(note, data) + if runcmd(["sysrepocfg", "-f", "json", "-N", note]): + self.dbg("Sent notification: %s" % msg) + rmf(note) + + def get_bearers(self, refresh=True): + """Bearer details, refreshing modem status first unless the caller + just did.""" + bearers = [] + if refresh and not self.update(): + return bearers + for path in self.status.get("bearers", []): + output = self._mmclij("-b", path) + if output and "bearer" in output: + bearers.append(output["bearer"]) + return bearers + + def get_profiles(self): + profiles = [] + output = self._mmclij("--3gpp-profile-manager-list") + if not output: + return profiles + for entry in output["modem"]["3gpp"]["profile-manager"]["list"]: + profile = {} + for attr in entry.split(", "): + (key, value) = attr.split(": ") + profile[key] = value + profiles.append(profile) + return profiles + + def get_conn_failure(self, path): + for bearer in self.get_bearers(): + if bearer["dbus-path"] == path: + return bearer["status"]["connection-error"]["message"] + return "unknown" + + def update(self): + if not self.path: + return False + output = self._mmclij() + if output: + output = output.get("modem", {}) + output = output.get("generic", None) + if output: + self.status = output + self.updfail = 0 + return True + + self.updfail += 1 + if self.updfail > 5: + self.err("Reinit after too many failures") + time.sleep(10) + self.init() + + return False + + def iplink(self, iface, action): + dbg("Setting link of %s to %s" % (iface, action)) + if not runcmd(["ip", "link", "set", "dev", iface, action]): + self.err("Unable to set link of %s to %s" % (iface, action)) + return False + if not runcmd(["ip", "link", "set", "dev", iface, "state", action]): + self.err("Unable to set link state of %s to %s" % (iface, action)) + return False + return True + + def resolvconf(self, iface, action): + self.info("About to %s nameservers on %s" % (action, iface["name"])) + + 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 _derive_gateway(self, ifname): + """Derive the peer gateway from the link-scope subnet route on ifname. + + Point-to-point bearers (MBIM/QMI) often report gateway as '--' even + though the kernel installs a link-scope subnet route (e.g. /30). The + peer — and therefore the nexthop for the default route — is the only + other host in that subnet. + """ + output = runcmdj(["ip", "-j", "route", "show", + "dev", ifname, "scope", "link"]) + for rt in (output or []): + dst = rt.get("dst", "") + src = rt.get("prefsrc", "") + if not dst or not src or "/" not in dst: + continue + try: + net = ipaddress.ip_network(dst, strict=False) + our = ipaddress.ip_address(src) + peers = [str(h) for h in net.hosts() if h != our] + if peers: + return peers[0] + except ValueError: + pass + return None + + def ipdefroute(self, iface, action): + self.info("About to %s default route on %s" % (action, iface["name"])) + + conf = "/etc/net.d/%s.conf" % iface["name"] + + if action != "add": + rmf(conf) + return True + + if iface["ipv4"]["gateway"] != "--": + gw = iface["ipv4"]["gateway"] + elif iface["ipv6"]["gateway"] != "--": + gw = iface["ipv6"]["gateway"] + else: + # Gateway not reported by modem (common for point-to-point bearers). + # Derive it from the link-scope subnet route on the interface: the + # peer is the only other host in the subnet. + gw = self._derive_gateway(iface["name"]) + if not gw: + self.err("No gateway address found") + return False + + metric = iface["bearer"].get("route-preference", 200) + + # Write route to /etc/net.d/ for netd/staticd/zebra to install. + # This keeps the route visible to FRR for redistribution and allows + # metric-based failover behind wired/WiFi routes (which use lower + # metric values from udhcpc, typically 5). + content = "# Generated by modemd -- do not edit\n\n" + content += "route {\n" + if ":" in gw: + content += ' prefix = "::/0"\n' + else: + content += ' prefix = "0.0.0.0/0"\n' + content += ' nexthop = "%s"\n' % gw + content += ' distance = %d\n' % metric + content += ' tag = 100\n' + content += '}\n' + + if fread(conf) == content.strip(): + return True + next_conf = conf + "+" + fwrite(next_conf, content) + os.rename(next_conf, conf) + return True + + def ifupdown(self, iface, action): + path = "/var/ifupdown/%s" % 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]): + return False + + return True + + def linkdown(self, iface): + self.resolvconf(iface, "delete") + self.ipdefroute(iface, "delete") + self.ifupdown(iface, "down") + + # Flush only wwan-protocol-tagged addresses; leaves any static or + # dhcp-protocol addresses intact in case the interface is shared. + runcmd(["ip", "addr", "flush", "dev", iface["name"], + "proto", "wwan"], check=False) + + self.iplink(iface["name"], "down") + + for i in range(len(self.ifaces)): + if self.ifaces[i]["name"] == iface["name"]: + del self.ifaces[i] + + return True + + def linkup(self, iface): + self.iplink(iface["name"], "up") + + addrs = [] + for ip in (iface["ipv4"], iface["ipv6"]): + if ip["address"] != "--" and ip["prefix"] != "--": + addrs.append("%s/%s" % (ip["address"], ip["prefix"])) + + if len(addrs) == 0: + self.err("No addresses yet") + return False + + # Flush any stale wwan-protocol addresses before adding new ones. + # The 'proto wwan' tag (rt_addrprotos entry 7) allows precise cleanup + # on disconnect without touching addresses added by other means. + runcmd(["ip", "addr", "flush", "dev", iface["name"], + "proto", "wwan"], check=False) + + for addr in addrs: + self.info("Setting address %s on %s" % (addr, iface["name"])) + if not runcmd(["ip", "addr", "replace", addr, + "dev", iface["name"], "proto", "wwan"]): + self.err("Unable to set address for %s" % iface["name"]) + return False + + if not self.ipdefroute(iface, "add"): + return False + + if not self.ifupdown(iface, "up"): + return False + + if not self.resolvconf(iface, "add"): + return False + + self.ifaces.append(iface) + return True + + def poweron(self): + if self.status.get("power-state", "") == "off": + self.info("Powering on") + if not self._mmcli("--set-power-state-on"): + self.err("Unable to power on") + return False + return True + + def disable(self): + self.info("Disabling") + if not self._mmcli("--disable"): + self.err("Unable to disable") + return False + else: + return True + + def enable(self): + state = self.status.get("state", "unknown") + self.info("Enabling (state '%s')" % state) + + if not self.path: + return False + if state == "unknown" or state == "disabled": + if not self._mmcli("--enable"): + self.err("Unable to enable") + return False + return True + + def unlock(self): + args = ["--sim=%s" % self.status.get("sim", "0")] + + if self.status.get("unlock-required", "") == "sim-pin": + self.info("Unlocking with PIN") + pin = self.cfg.get("pin") + if not pin: + self.err("No PIN defined") + return False + args.append("--pin=%s" % pin) + elif self.status.get("unlock-required", "") == "sim-puk": + self.info("Unlocking with PUK") + puk = self.cfg.get("puk") + if not puk: + self.err("No PUK defined") + return False + args.append("--puk=%s" % puk) + else: + self.err("Unsupported lock") + return False + + self.disable() + if not self._mmcli(*args): + self.err("Unable to unlock") + return False + else: + return True + + def setcarrier(self): + path = "%s/carrier-support" % self.rundir + if os.path.exists(path): + supported = int(fread(path)) + else: + if runcmd(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--check"]): + supported = 1 + else: + supported = 0 + + fwrite(path, "%d" % supported) + + if supported == 0: + self.info("Modem does not support setting a carrier") + return True + + path = "%s/supported-carriers" % self.rundir + if os.path.exists(path): + carriers = freadj(path) + else: + self.info("Getting supported carriers for %s %s" % + (self.manf, self.model)) + carriers = runcmdj(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--index", str(self.index), + "--list"]) + if carriers: + fwritej(path, carriers) + + if not carriers: + self.err("Unable to list supported carriers") + return False + + path = "%s/current-carrier" % self.rundir + if os.path.exists(path): + current = freadj(path) + else: + output = runcmdj(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--index", str(self.index), + "--get"]) + if output and isinstance(output, dict): + current = output.get("name") + if current: + fwritej(path, current) + + if not current: + self.err("Unable to get current carrier") + return False + + self.info("Current carrier is '%s'" % current) + + carrier = self.cfg.get("carrier", "default") + if carrier == current: + return True + + self.info("Setting carrier '%s' for modem%d" % + (carrier, self.index)) + + output = runcmdj(["/usr/libexec/modemd/modem-carrier", + "--manf", self.manf, + "--model", self.model, + "--index", str(self.index), + "--set", carrier]) + if not output: + self.err("Unable to set carrier '%s' on modem%d" % + (carrier, self.index)) + return False + + fwritej(path, carrier) + + if output.get("action", "") == "reset": + self.reset() + return False + else: + return True + + def initialize(self): + self.pulldown() + self.update() + self.poweron() + + if not self.setcarrier(): + return False + + if self.status.get("state", "") == "locked": + if not self.unlock(): + return False + + return self.enable() + + def get_profile_args(self, bearer): + args = [ + "profile-id=%s" % bearer["pid"], + "profile-name=profile%s" % bearer["pid"] + ] + v = bearer.get("apn") + if v: + args.append("apn=%s" % v) + v = bearer.get("ip-type") + if v: + args.append("ip-type=%s" % v) + v = bearer.get("username") + if v: + args.append("user=%s" % v) + v = bearer.get("password") + if v: + args.append("password=%s" % v) + + return ",".join(args) + + def get_bearer_args(self, bearer): + args = [] + pid = bearer.get("pid") + if pid and pid == "!-": + args.append("profile-id=%s" % pid) + else: + v = bearer.get("apn") + if v: + args.append("apn=%s" % v) + v = bearer.get("ip-type") + if v: + args.append("ip-type=%s" % v) + v = bearer.get("username") + if v: + args.append("user=%s" % v) + v = bearer.get("password") + if v: + args.append("password=%s" % v) + + v = bearer.get("roaming") + if v: + args.append("allow-roaming=true") + else: + args.append("allow-roaming=false") + + return ",".join(args) + + def prepare_profile_bearers(self, bearers): + self.info("Preparing profile bearers") + + # wipe auxiliary profiles + for p in self.get_profiles(): + if p["profile-id"] != "1": + self.dbg("Deleting profile %s" % p["profile-id"]) + if not self._mmcli("--3gpp-profile-manager-delete=%s" % + p["profile-id"]): + self.err("Unable to delete profile") + return False + + # create profiles and bearers + pid = 1 + for bearer in bearers: + bearer["pid"] = str(pid) + pid += 1 + + if pid > len(self.interfaces): + self.err("Max. number of bearers reached") + break + if bearer["pid"] != "1": + if not self._mmcli("--3gpp-profile-manager-set="): + self.err("Unable to create profile") + return False + + args = self.get_profile_args(bearer) + if not self._mmcli("--3gpp-profile-manager-set=%s" % args): + self.err("Unable to configure profile") + return False + + args = self.get_bearer_args(bearer) + if not self._mmcli("--create-bearer=%s" % args): + self.err("Unable to create bearer") + return False + + self.info("Created profile bearer %s for '%s'" % + (bearer["pid"], bearer["apn"])) + self.bearers["active"].append(bearer) + return True + + def prepare_multiplex_bearers(self, bearers): + self.info("Preparing multiplex bearers") + + for bearer in bearers: + args = self.get_bearer_args(bearer) + if not self._mmcli("--create-bearer=multiplex=required,%s" % args): + self.err("Unable to create bearer") + return False + self.info("Created multiplex bearer for '%s'" % bearer["apn"]) + self.bearers["active"].append(bearer) + return True + + def prepare_default_bearer(self, bearer): + if not self._mmcli("--create-bearer=%s" % self.get_bearer_args(bearer)): + self.err("Unable to create bearer") + return False + + self.info("Created default bearer '%s'" % bearer["apn"]) + self.bearers["active"].append(bearer) + return True + + def prepare_bearers(self): + self.info("Preparing bearers") + + # wipe existing bearers + for bearer in self.get_bearers(): + self.dbg("Deleting bearer %s" % bearer["dbus-path"]) + if not self._mmcli("--delete-bearer=%s" % bearer["dbus-path"]): + self.err("Unable to delete bearer") + return False + + # configure initial bearer (used to attach to network) + initial = self.bearers.get("initial") + if initial: + self.info("Setting initial bearer '%s'" % initial["apn"]) + if not self._mmcli("--3gpp-set-initial-eps-bearer-settings=%s" % + self.get_bearer_args(initial)): + self.err("Unable to set initial bearer") + + # configure dialup bearers + count = len(self.bearers["list"]) + self.info("Configuring %d bearers" % count) + + if len(self.bearers["list"]) == 1: + return self.prepare_default_bearer(self.bearers["list"][0]) + elif len(self.interfaces) == 1: + # use multiplex beares in case of 1 interface + return self.prepare_multiplex_bearers(self.bearers["list"]) + else: + # use profile beaeres in case of multiple interfaces + return self.prepare_profile_bearers(self.bearers["list"]) + + def prepare_bands(self): + 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): + pref = self.cfg.get("preferred-mode") + if not pref or pref == "any": + pref = "none" + + allow = self.cfg.get("allowed-mode", []) + if "any" in allow: + allow = [] + + 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) + 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 = ["--set-allowed-modes=%s" % "|".join(allow)] + if pref != "none": + args.append("--set-preferred-mode=%s" % pref) + ok = self._mmcli(*args) + if ok: + self._modes_failed_sig = None + return ok + + def _gps_at_commands(self): + """Vendor-specific (query, enable, disable) AT commands, 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 + 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. + + Returns None if the capability query itself fails. Otherwise a + set drawn from {gps, agps-msa, agps-msb, 3gpp, cdma}. Asking + ModemManager to enable an unsupported source fails the whole + batched mmcli call, so prepare_location() filters against this. + """ + output = self._mmclij("--location-status") + if not output: + return None + caps = output.get("modem", {}).get("location", {}).get("capabilities", "") + if isinstance(caps, str): + caps = [c.strip() for c in caps.split(",") if c.strip()] + mapping = { + "3gpp-lac-ci": "3gpp", + "gps-raw": "gps", + "gps-nmea": "gps", + "agps-msa": "agps-msa", + "agps-msb": "agps-msb", + "cdma-bs": "cdma", + } + return {mapping[c] for c in caps if c in mapping} + + def prepare_location(self): + self.info("Preparing location") + + rmrf(self.locdir) + self.location["state"] = None + if not self.location["enabled"]: + return True + + sources = self.location["sources"] + + # GPS via vendor AT command (known vendor) or via MM (fallback). + # The AT path doesn't consult MM's --location-status, since + # marking the NMEA port ID_MM_PORT_IGNORE removes 'gps' from MM's + # capability list even though the hardware is still there. + 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. + caps = self._location_capabilities() + if caps is None: + self.err("Unable to query modem location capabilities") + self.location["state"] = "failed" + return False + + args = [] + for source, flags in flag_map.items(): + if source not in caps: + if source in sources: + self.err("Modem does not support location source" + " '%s', skipping" % source) + continue + enabled = source in sources + if enabled: + self.info("Enabling location source '%s'" % source) + else: + self.dbg("Disabling location source '%s'" % source) + action = "enable" if enabled else "disable" + for flag in flags: + args.append("--location-%s-%s" % (action, flag)) + + if args and not self._mmcli(*args): + self.err("Unable to configure location sources") + ok = False + if not ok: + self.location["state"] = "failed" + return ok + + 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 + # 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(): + return False + if not self.prepare_bearers(): + self.err("Unable to prepare bearers") + return False + # Optional, must not block the data connection, see check_location() + self.prepare_location() + + self.info("Waiting for network") + return True + + def connect(self): + for bearer in self.status.get("bearers", []): + self.info("Connecting %s" % bearer) + if not self._mmcli("--connect", "--bearer=%s" % bearer): + self.connfail += 1 + self.err("Unable to connect (%d failures)" % self.connfail) + self.err("Reason: %s" % self.get_conn_failure(bearer)) + return False + return True + + def 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(refresh=False): + 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 + 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 + if not self.linkup(iface): + self.linkdown(iface) + return False + else: + count += 1 + + if count == len(self.bearers["list"]): + self.info("All bearers are up") + return True + else: + return False + + def pulldown(self): + for bearer in self.get_bearers(): + iface = { + "name": bearer["status"]["interface"], + "bearer": self.find_bearer(bearer["properties"]["apn"]) + } + + if bearer["status"]["connected"] == "yes": + self.info("Disconnecting %s" % bearer["dbus-path"]) + self._mmcli("--disconnect", + "--bearer=%s" % bearer["dbus-path"]) + for iface in self.ifaces: + self.linkdown(iface) + # 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) + + 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 and not self._reset_unsupported: + 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 and not self._reset_unsupported: + 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() + 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): + 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 + if self.location["state"] == "failed" and not self.prepare_location(): + return + + output = self._mmclij("--location-get") + loc = (output or {}).get("modem", {}).get("location", {}) + gps = loc.get("gps", {}) + gpp = loc.get("3gpp", {}) + + def _pick(d, key): + v = d.get(key) + return v if v not in (None, "--", "") else None + + data = {"last-change": now_rfc3339()} + + lat = _pick(gps, "latitude") + lon = _pick(gps, "longitude") + alt = _pick(gps, "altitude") + if lat is not None and lon is not None: + data["source"] = "gps" + data["latitude"] = float(lat) + data["longitude"] = float(lon) + if alt is not None: + data["altitude"] = float(alt) + + for src_key, dst_key in (("cid", "cell-id"), ("lac", "lac"), + ("tac", "tac"), ("mcc", "mcc"), + ("mnc", "mnc")): + v = _pick(gpp, src_key) + if v is None: + continue + if dst_key in ("cell-id", "lac", "tac"): + try: + data[dst_key] = int(v, 0) if isinstance(v, str) else int(v) + except (TypeError, ValueError): + continue + else: + data[dst_key] = str(v) + data.setdefault("source", "3gpp") + + fwritej_atomic("%s/data.json" % self.locdir, data) + + if "latitude" in data: + if self.location["state"] != "up": + self.info("Retrieved GPS location [%s, %s]" + % (data["latitude"], data["longitude"])) + self.location["state"] = "up" + else: + self.location["state"] = "down" + + def save_sms(self, sms): + global smsdir + + payload = sms["payload"] + + string = "%s,%s,%s" % (payload["properties"]["timestamp"], + payload["content"]["number"], + payload["content"]["text"]) + m = hashlib.sha256() + m.update(string.encode()) + path = os.path.join(smsdir, m.hexdigest()) + + if not os.path.exists(path): + self.dbg("Saving SMS to %s" % sms["path"]) + data = {"modem": self.index, "payload": payload} + fwritej(path, data) + self.notif("SMS-RECEIVED %s" % os.path.basename(path)) + + storage = payload["properties"]["storage"].upper() + if storage != "--": + if not self._mmcli("--messaging-delete-sms=%s" % sms["path"]): + self.err("Unable to delete SMS from %s storage" % storage) + else: + self.info("Deleted SMS from %s storage" % storage) + + def check_sms(self): + if self.state.value < State.ENABLED.value: + return + state = self.status.get("state", "") + if state != "registered" and state != "connected": + return + + for sms in list_sms(self.path): + if sms["type"] == "notsent": + self.dbg("Sending sms %s" % sms) + self._mmcli("-s", sms["path"], "--send") + + elif sms["type"] == "sent": + self.notif("SMS-SENT") + self._mmcli("--messaging-delete-sms=%s" % sms["path"]) + + elif sms["type"] == "received": + self.save_sms(sms) + + def touch_state(self): + """Refresh state.json with 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 + if elapsed > timeout: + self.check_trigger() + if self.update(): + self.check_state() + self.timer1 = time.time() + self.touch_state() + + elapsed = time.time() - self.timer2 + if elapsed > 30: + self.check_location() + self.check_sms() + self.timer2 = time.time() + + def lookup(self): + if self.sim: + return True + + 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 + + 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: + # 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: + 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) + + # 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 + + def detect(self): + self.info("Detecting") + + path = "%s/detected" % self.rundir + if os.path.exists(path): + os.remove(path) + + while not self.stopped(): + if self.lookup() and self.query(): + fwrite(path, "1") + self.info("Detected %s %s" % (self.manf, self.model)) + return True + else: + time.sleep(1) + + def run(self): + while not self.stopped(): + self.dbg("state %s, status '%s' (detected %s)" % + (self.state.name, + self.status.get("state", "unknown"), + str(self.detected))) + + if not self.detected: + if self.detect() and self.initialize(): + self.detected = True + else: + time.sleep(3) + + if self.detected: + self.check() + + time.sleep(1) + + self.pulldown() + + rmrf(self.statedir) + rmrf(self.locdir) + rmrf(self.rundir) + + self.dbg("Exiting") + self.exited = True + return True + + +def sighandler(signum, frame): + global threads + + for th in threads: + th.stop() + + timeout = 0 + while timeout < 3: + exited = True + for th in threads: + if isinstance(th, ModemThread) and not th.exited: + exited = False + if exited: + break + time.sleep(1) + timeout += 1 + + if signum == 15: + sys.exit(0) + else: + fatal("Exiting on signal %d" % signum) + + +def start_modem_threads(cfgs): + started = [] + for cfg in cfgs: + try: + th = ModemThread(cfg) + th.start() + started.append(th) + except Exception as e: + err("Failed to start modem%d thread: %s" % (cfg["index"], e)) + return started + + +def sighup_handler(signum, frame): + reload_event.set() + + +def main(): + global debug + syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) + + if os.geteuid() != 0: + fatal("Must be root") + + parser = argparse.ArgumentParser(prog="modemd") + parser.add_argument("-d", action="store_true") + args = parser.parse_args() + + if args.d: + debug = True + else: + with open("/proc/cmdline", 'r') as fd: + cmdline = fd.read() + if "debug" in cmdline: + debug = True + + if debug: + runcmd(["mmcli", "-G", "debug"]) + + mkdir(rundir) + mkdir(smsdir) + fwrite(pidfile, str(os.getpid()) + "\n") + signal.signal(signal.SIGINT, sighandler) + signal.signal(signal.SIGTERM, sighandler) + signal.signal(signal.SIGHUP, sighup_handler) + + try: + th = RpcThread() + th.start() + except Exception as e: + if debug: + print(e) + fatal("Rpc thread caught an exception") + + try: + th = SimThread() + th.start() + except Exception as e: + if debug: + print(e) + fatal("SIM thread caught an exception") + + modems = load_config() + if not modems: + fatal("No modems configured or enabled") + + if not runcmd(['/usr/libexec/modemd/sim-setup']): + fatal("Unable to setup SIMs") + + threads.extend(start_modem_threads(modems)) + if len(threads) == 0: + fatal("No modem threads are running") + + while True: + if not reload_event.wait(timeout=60): + continue + reload_event.clear() + + info("Reloading configuration") + modem_threads = [th for th in threads if isinstance(th, ModemThread)] + for th in modem_threads: + th.stop() + for th in modem_threads: + th.join(timeout=5) + if th.is_alive(): + err("modem%d thread did not stop, orphaning" % th.index) + threads[:] = [th for th in threads if th not in modem_threads] + + runcmd(['/usr/libexec/modemd/sim-setup']) + + new_modems = load_config() + threads.extend(start_modem_threads(new_modems)) + + os.utime(pidfile, None) + info("Reload complete") + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/carrier.py b/src/modemd/modemd/carrier.py new file mode 100644 index 0000000000..d966978c9a --- /dev/null +++ b/src/modemd/modemd/carrier.py @@ -0,0 +1,316 @@ +import subprocess +import argparse +import json +import syslog +import sys +import os +import re + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def log(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def read_file(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +# Telit LE910 +le910_carriers = [ + {"num": 0, "name": "AT&T"}, + {"num": 1, "name": "Verizon"}, + {"num": 2, "name": "T-Mobile"}, + {"num": 3, "name": "Bell"}, + {"num": 4, "name": "Telus"}, + {"num": 10, "name": "NTT-Docomo"}, + {"num": 11, "name": "Telstra"}, + {"num": 12, "name": "KDDI"}, + {"num": 13, "name": "Softbank"}, + {"num": 14, "name": "Vodafone New Zealand"}, + {"num": 15, "name": "Spark New Zealand"}, + {"num": 20, "name": "China Mobile"}, + {"num": 21, "name": "China Unicom"}, + {"num": 22, "name": "China Telecom"}, + {"num": 30, "name": "Sprint"}, + {"num": 31, "name": "SouthernLINC"}, + {"num": 40, "name": "Global"}, + {"num": 101, "name": "T-Mobile Germany"}, + {"num": 102, "name": "AT&T Mexico"}, + {"num": 103, "name": "Orange-WW"}, + {"num": 104, "name": "Southern Linc USA"}, + {"num": 105, "name": "Vodafone DE"} +] + + +def le910_carrier_bynum(num): + for carrier in le910_carriers: + if carrier["num"] == num: + return carrier + return None + + +def le910_carrier_byname(name): + for carrier in le910_carriers: + if carrier["name"] == name: + return carrier + return None + + +def le910_is_supported(variant): + if variant == "NS": + return True + elif variant == "AP": + return True + elif variant == "NF" or variant == "NFD": + return True + elif variant == "CN": + return True + elif variant == "APX": + return True + elif variant == "WWX" or variant == "WWXD": + return True + else: + return False + + +def le910_carrier_default(variant): + if variant == "NF" or variant == "WWX" or variant == "WWXD": + num = 0 + elif variant == "AP" or variant == "APX": + num = 10 + elif variant == "CN": + num = 20 + elif variant == "NS": + num = 30 + elif variant == "EU" or variant == "EUX": + num = 40 + else: + return None + + return le910_carrier_bynum(num) + + +def le910_command(index, command): + cmd = ['/sbin/modem-command', + '--index', str(index), + '--mode', '115200 8N1', + '--secondary', + '--timeout', '5', + command] + + for attempt in range(1, 3): + output = runcmd(cmd) + if output: + return output + + err("Command '%s' failed" % " ".join(cmd)) + return None + + +def le910_list_carriers(index): + output = le910_command(index, 'AT#FWSWITCH=?') + if output is None: + return None + + r = re.search(r"#FWSWITCH: \(([^\)]*)\)", output) + if not r: + err("Unable to list carriers on modem%d" % index) + return None + + rlist = r.group(1) + nums = [] + for rl in rlist.split(","): + r = re.search(r"(\d+)-(\d+)", rl) + if r: + start = int(r.group(1)) + end = int(r.group(2)) + for i in range(start, end + 1): + nums.append(i) + else: + nums.append(int(rl)) + + carriers = [] + for num in nums: + carrier = le910_carrier_bynum(num) + if carrier: + carriers.append(carrier) + else: + err("Unknown carrier %d" % num) + + return carriers + + +def le910_set_carrier(index, carrier): + output = le910_command(index, 'AT#FWSWITCH=%d' % carrier["num"]) + if output is None: + return False + else: + return True + + +def le910_get_carrier(index): + output = le910_command(index, 'AT#FWSWITCH?') + if output is None: + return -1 + + r = re.search(r"#FWSWITCH: (\d+)", output) + if not r: + err("Unable to query carrier on modem%d" % index) + return -1 + num = int(r.group(1)) + + return le910_carrier_bynum(num) + + +def runcheck(manf, model): + if manf == "Telit" and model[:5] == "LE910": + return le910_is_supported(model.split("-")[1]) + else: + return False + + +def runset(index, manf, model, name): + if not runcheck(manf, model): + fatal("Unsupported modem") + + if name == "default": + carrier = le910_carrier_default(model.split("-")[1]) + else: + carrier = le910_carrier_byname(name) + if not carrier: + fatal("Invalid carrier '%s'" % name) + + current = le910_get_carrier(index) + if not current: + fatal("Cannot get current carrier") + + if current["name"] == carrier["name"]: + log("Carrier '%s' already set on modem%d" % (carrier["name"], index)) + print(json.dumps({"action": "none"})) + sys.exit(0) + + log("Setting carrier '%s' on modem%d" % (carrier["name"], index)) + if not le910_set_carrier(index, carrier): + fatal("Unable to set carrier on modem%d" % index) + + print(json.dumps({"action": "reset"})) + sys.exit(0) + + +def runget(index, manf, model): + if not runcheck(manf, model): + fatal("Unsupported modem") + + carrier = le910_get_carrier(index) + if not carrier: + fatal("Unable to get carrier") + + print(json.dumps(carrier)) + sys.exit(0) + + +def runlist(index, manf, model): + if not runcheck(manf, model): + print(json.dumps([])) + sys.exit(0) + + carriers = le910_list_carriers(index) + if not carriers: + fatal("No carriers") + + names = [] + for carrier in sorted(carriers, key=lambda c: c["name"]): + names.append(carrier["name"]) + + print(json.dumps(names)) + sys.exit(0) + + +def main(): + parser = argparse.ArgumentParser(prog='modem-carrier') + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-m", "--manf", default=0, help="Modem manufacturer") + parser.add_argument("-M", "--model", default=0, help="Modem model") + parser.add_argument("-s", "--set", help="Set carrier") + parser.add_argument("-g", "--get", help="Get carrier", + action="store_true") + parser.add_argument("-l", "--list", help="List supported carriers", + action="store_true") + parser.add_argument("-c", "--check", help="Check support for carriers", + action="store_true") + args = parser.parse_args() + + if not args.manf: + fatal("No manufacturer given") + else: + manf = args.manf + + if not args.model: + fatal("No model given") + else: + model = args.model + + if args.index: + index = int(args.index) + else: + index = 0 + + if args.check: + if not runcheck(manf, model): + sys.exit(1) + elif args.list: + runlist(index, manf, model) + elif args.set: + runset(index, manf, model, args.set) + elif args.get: + runget(index, manf, model) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/dhcp.py b/src/modemd/modemd/dhcp.py new file mode 100644 index 0000000000..b9ef69ac9c --- /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/info.py b/src/modemd/modemd/info.py new file mode 100644 index 0000000000..40485a8420 --- /dev/null +++ b/src/modemd/modemd/info.py @@ -0,0 +1,580 @@ +import subprocess +import argparse +import syslog +import glob +import json +import sys +import os +import re + +debug = False + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def dbg(msg): + global debug + if debug: + syslog.syslog(syslog.LOG_INFO, msg) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def vbool(val): + if val == "yes" or val == "true": + return True + else: + return False + + +def vnum(val): + if val != "--": + return int(val) + else: + return 0 + + +def vnumstr(val): + if val != "" and val != "--": + return val + else: + return "0" + + +def vstr(val): + if val != "--": + return val + else: + return "" + + +def venum(val, enums, default): + enval = vstr(val) + for en in enums: + if enval == en: + return en + return default + + +def vip4(val): + val = vstr(val) + if val == "": + return "0.0.0.0" + else: + return val + + +def vip6(val): + val = vstr(val) + if val == "": + return "::" + else: + return val + + +def fread(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +def freadj(path): + output = fread(path) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + dbg("Unable to parse json output") + return None + finally: + return data + + +def get_manufacturer(index, gen): + path = "/run/modemd/modem%d/manufacturer" % index + manf = fread(path) + if manf: + return manf + + manf = vstr(gen.get("manufacturer", "")) + return manf + + +def get_model(index, gen): + path = "/run/modemd/modem%d/model" % index + model = fread(path) + if model: + return model + + model = vstr(gen.get("model", "")) + return model + + +def get_supported_carriers(index, manf, model): + path = "/run/modemd/modem%d/supported-carriers" % index + output = freadj(path) + if output: + return output + else: + return [] + + +def get_current_carrier(index, manf, model): + path = "/run/modemd/modem%d/current-carrier" % index + output = freadj(path) + if output: + return output + else: + return "default" + + +def _sim_lock_state(unlock_required): + mapping = { + "none": "unlocked", + "sim-pin": "pin-required", + "sim-pin2": "pin-required", + "sim-puk": "puk-required", + "sim-puk2": "puk-required", + } + # Default to "unlocked": this function is only called when the SIM query + # succeeded, so the card is present. "--" means the modem doesn't report + # lock state but the SIM is accessible. + return mapping.get(unlock_required, "unlocked") + + +def device_devpath(devpath): + """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 port and port == os.path.basename(modem.get("devpath", "")): + idx = modem.get("index", 0) + return {"index": idx, "slot": idx, + "paths": [modem["devpath"]], "type": "modem"} + + # Fallback: Minex modules.json (hardware with slot/SIM multiplexer) + if os.path.exists("/run/modules.json"): + with open("/run/modules.json", "r") as fd: + data = json.load(fd) + for module in data.get("modules", []): + if module["type"] != "modem": + continue + for path in module.get("paths", []): + if path in devpath: + return module + return None + + # Fallback: legacy modems.json written by modem-udev + if os.path.exists("/run/modems.json"): + with open("/run/modems.json", "r") as fd: + data = json.load(fd) + for index, modem in enumerate(data.get("modems", [])): + if devpath in modem.get("devpath", ""): + return {"index": index, "slot": index, + "paths": [modem["devpath"]], "type": "modem"} + + return None + + +def modem_get_sim(module): + path = "/sys/class/sim" + for index in range(0, 5): + name = "sim%d" % index + if not os.path.exists("%s/%s" % (path, name)): + continue + + slot = int(fread("%s/%s/slot" % (path, name))) + if slot != module["slot"]: + continue + + present = int(fread("%s/%s/present" % (path, name))) + + return { + "index": index, + "name": name, + "slot": slot, + "present": bool(present) + } + + return None + + +def sysfs_interfaces(devpath): + ifaces = [] + try: + for iface in os.listdir("/sys/class/net"): + p = os.path.realpath("/sys/class/net/%s" % iface) + if p.startswith(devpath + "/"): + ifaces.append(iface) + except OSError: + pass + return ifaces + + +def print_modem(index, 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 match(modem): + 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 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) + + +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, + "devpath": devpath, + } + + 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", []), + } + + sig_q = gen.get("signal-quality") or {} + modem["status"] = { + "state": vstr(gen["state"]), + "selected-carrier": get_current_carrier(index, manf, model), + "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.get("state-failed-reason", "--")) + if len(reason) > 0: + modem["status"]["state-failed-reason"] = reason + + sim = modem_get_sim(module) + if sim is not None: + modem["status"]["sim-active"] = sim["index"] + modem["status"]["sim-present"] = sim["present"] + sim_name = sim["name"] + sim_slot = sim["slot"] + else: + sim_name = "sim0" + slot_raw = gen.get("primary-sim-slot", 1) + sim_slot = int(slot_raw) if str(slot_raw).isdigit() else 1 + + # 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']) + 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(): + if tech == "refresh": + continue + for sig in signals: + v = output["modem"]["signal"][tech].get(sig, "--") + 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"], + ["idle", "home", "searching", "denied", "roaming"], + "unknown"), + "operator-name": vstr(gpp["operator-name"]), + "operator-id": vstr(gpp["operator-code"]), + "network-type": network_type, + "service-state": + venum(gpp.get("packet-service-state", "--"), + ["attached", "detached"], "unknown"), + } + + 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 + + sim_path = vstr(gen.get("sim", "--")) + output = None + if sim_path: + output = runcmdj(['mmcli', '-J', '-m', mpath, '-i', sim_path]) + + if output: + 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: + loc = output["modem"]["location"] + modem["status"]["location"] = { + "latitude": vstr(loc["gps"]["latitude"]), + "longitude": vstr(loc["gps"]["longitude"]), + "altitude": vstr(loc["gps"]["altitude"]), + "cid": vstr(loc["3gpp"]["cid"]), + "lac": vstr(loc["3gpp"]["lac"]), + "mcc": vstr(loc["3gpp"]["mcc"]), + "mnc": vstr(loc["3gpp"]["mnc"]), + "tac": vstr(loc["3gpp"]["tac"]) + } + + modems.append(modem) + + modems = sorted(modems, key=lambda m: m['index']) + data = json.dumps(modems) + + print(data) + + +def main(): + parser = argparse.ArgumentParser(prog='modem-info') + parser.add_argument("-i", "--index", default=0, help="Modem index") + 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: + cmdline = fd.read() + if "debug" in cmdline: + debug = True + + 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() + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/power.py b/src/modemd/modemd/power.py new file mode 100644 index 0000000000..3cc3e22e56 --- /dev/null +++ b/src/modemd/modemd/power.py @@ -0,0 +1,88 @@ +import subprocess +import argparse +import json +import time +import syslog +import sys + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def log(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def reset(index, info): + log("Power-cycling modem %d" % index) + + with open("%s/authorized" % info["devpath"], "w") as fd: + fd.write("0\n") + time.sleep(0.5) + + path = "/sys/class/pcie/slot%d/pwrctl" % info["slot"] + with open(path, "w") as fd: + fd.write("cycle\n") + + return True + + +def main(): + parser = argparse.ArgumentParser(prog='modem-power') + parser.add_argument("-i", "--index", default=0, help="Modem index") + args = parser.parse_args() + + if args.index: + index = int(args.index) + else: + index = 0 + + info = runcmdj(['/usr/libexec/modemd/modem-info', + '-i', str(index)]) + if info is None: + fatal("Unable to obtain modem info") + + if not reset(index, info): + fatal("Unable to power-cycle modem") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/ppp.py b/src/modemd/modemd/ppp.py new file mode 100644 index 0000000000..16b856128c --- /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/modemd/rpc.py b/src/modemd/modemd/rpc.py new file mode 100644 index 0000000000..58707a9c1f --- /dev/null +++ b/src/modemd/modemd/rpc.py @@ -0,0 +1,52 @@ +import argparse +import json +import sys +import os + + +def sendrpc(index, rpc): + path = "/run/modemd/modem%d/rpc" % index + if os.path.exists(path): + sys.stderr.write("Error: another rpc is running\n") + return + + with open(path, "w") as fd: + json.dump(rpc, fd) + + if not os.system(['sysrepocfg', '-f', 'json', '-R', path]): + sys.stderr.write("Error: unable to restart\n") + + os.unlink(path) + + +def main(): + parser = argparse.ArgumentParser(prog='modem-rpc') + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-r", "--rpc", default=0, help="RPC command") + args = parser.parse_args() + + if args.index: + index = int(args.index) + else: + index = 0 + + if not args.rpc: + sys.stderr.write("Error: no rpc command\n") + sys.exit(1) + + print("Sending '%s' rpc to modem%d" % (args.rpc, index)) + + rpc = { + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % index, + "infix-hardware:modem": {args.rpc: {}} + }] + } + } + + sendrpc(index, rpc) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/scan_networks.py b/src/modemd/modemd/scan_networks.py new file mode 100644 index 0000000000..377e8826c4 --- /dev/null +++ b/src/modemd/modemd/scan_networks.py @@ -0,0 +1,82 @@ +import argparse +import subprocess +import json +import sys + +index = 0 + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def scan(mpath): + output = runcmdj(['mmcli', '-J', '-m', mpath, + '--3gpp-scan', '--timeout', '60']) + if not output: + return False + + networks = [] + for entry in output["modem"]["3gpp"]["scan-networks"]: + net = {} + for attr in entry.split(", "): + (key, value) = attr.split(": ") + net[key] = value + + if "operator-name" in net: + networks.append(net) + + if len(networks) == 0: + print("No networks found") + return True + + for net in networks: + print("Network '%s' %s (%s, %s)" % ( + net.get("operator-name"), + net.get("operator-code"), + net.get("access-technologies"), + net.get("availability"))) + return True + + +def main(): + global index + parser = argparse.ArgumentParser(prog='modem-scan-networks') + parser.add_argument("-i", "--index", default=0, help="Modem index") + args = parser.parse_args() + if args.index: + index = int(args.index) + + for m in runcmdj(['/usr/libexec/modemd/modem-info']): + if m["index"] == index: + print("Scanning networks on modem%d, please stand by ..." % index) + if not scan(m["path"]): + print("Operation failed.", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/sim_setup.py b/src/modemd/modemd/sim_setup.py new file mode 100644 index 0000000000..edf86e464f --- /dev/null +++ b/src/modemd/modemd/sim_setup.py @@ -0,0 +1,267 @@ +import subprocess +import json +import fcntl +import struct +import time +import syslog +import sys +import os + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + +def log(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def fread(path): + if os.path.exists(path): + with open(path, "r") as fd: + output = str(fd.read()) + if output: + return output.strip() + return None + + +def freadj(path): + output = fread(path) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + err("Unable to parse json output") + return None + finally: + return data + + +def find_modem(setup, index): + for modem in setup["modems"]: + if modem["index"] == index: + return modem + return None + + +def find_sim(setup, index): + for sim in setup["sims"]: + if sim["index"] == index: + return sim + return None + + +def pwrctl(setup, state): + for modem in setup["modems"]: + path = "/sys/class/pcie/slot%d/pwrctl" % modem["slot"] + if not os.path.exists(path): + continue + with open(path, "w") as fd: + fd.write("%s\n" % state) + + +def sim_ctrl(cmd, slots): + req = struct.pack('2i', *slots) + + if not os.path.exists("/dev/simctrl"): + return None + + try: + fd = os.open("/dev/simctrl", os.O_RDWR) + except OSError: + fatal("Cannot open sim ctrl") + + try: + resp = fcntl.ioctl(fd, cmd, req) + except OSError: + fatal("Cannot run sim ctrl ioctl") + + if not resp: + return None + else: + return struct.unpack('2i', resp) + + +def sim_get_slots(): + # NM_SIMIOC_SLOT_GET is 0x80085300 (see linux/nm-sim.h) + return sim_ctrl(0x80085300, (0, 0)) + + +def sim_set_slots(slots): + # NM_SIMIOC_SLOT_SET is 0x40085301 (see linux/nm-sim.h) + return sim_ctrl(0x40085301, slots) + + +def slot_setup(setup): + slots = [] + for sim in setup["sims"]: + log("Connecting sim%d to slot %d" % (sim["index"], sim["newslot"])) + slots.append(sim["newslot"]) + + if sim_set_slots(tuple(slots)) is None: + return False + else: + return True + + +def change_setup(setup): + log("Changing SIM setup") + + runcmd(["initctl", "stop", "modem-manager"]) + time.sleep(1) + + pwrctl(setup, "down") + time.sleep(1) + + if slot_setup(setup) is False: + err("Unable to set up sim slots") + + pwrctl(setup, "up") + + time.sleep(1) + runcmd(["initctl", "restart", "modem-manager"]) + + return True + + +def swapslot(slot): + if slot == 0: + return 1 + elif slot == 1: + return 0 + else: + return -1 + + +def sim_setup(setup): + changed = False + + hw = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-hardware"]) + ifaces = runcmdj(["sysrepocfg", "-f", "json", "-X", "-m", "ietf-interfaces"]) + if not hw or not ifaces: + err("Cannot read config") + return False + + modem_set = set() + for comp in hw.get("ietf-hardware:hardware", {}).get("component", []): + if comp.get("class", "") == "infix-hardware:modem" \ + and comp.get("state", {}).get("admin-state", "unlocked") == "unlocked": + name = comp["name"] + try: + modem_set.add(int(name[5:])) + except (ValueError, IndexError): + pass + + sim_for_modem = {} + for iface in ifaces.get("ietf-interfaces:interfaces", {}).get("interface", []): + if iface.get("type", "") != "infix-if-type:modem": + continue + wwan = iface.get("infix-interfaces:wwan", {}) + modem_ref = wwan.get("modem", "") + sim_ref = wwan.get("sim", "") + if not modem_ref.startswith("modem") or not sim_ref.startswith("sim"): + continue + try: + midx = int(modem_ref[5:]) + sidx = int(sim_ref[3:]) + except (ValueError, IndexError): + continue + if midx in modem_set: + sim_for_modem[midx] = sidx + + for modem_idx, sim_idx in sim_for_modem.items(): + modem = find_modem(setup, modem_idx) + sim = find_sim(setup, sim_idx) + if modem and sim: + sim["newslot"] = modem["slot"] + if sim["newslot"] != sim["slot"]: + changed = True + + if not changed: + log("SIMs already set up") + return True + + if len(setup["modems"]) == 2 and len(setup["sims"]) == 2: + for i in range(0, 2): + o = 1 if i == 0 else 0 + if setup["sims"][i]["newslot"] == -1: + log("Swapping slot of sim%d" % setup["sims"][i]["index"]) + setup["sims"][i]["newslot"] = swapslot(setup["sims"][o]["newslot"]) + + return change_setup(setup) + + +def read_setup(): + setup = { + "modems": [], + "sims": [] + } + + modules = freadj("/run/modules.json") + if not modules: + log("No modules.json found, skipping SIM setup") + return None + + for module in modules.get("modules", []): + if module["slot"] > -1 and module["type"] == "modem": + setup["modems"].append({"index": module["index"], + "slot": module["slot"]}) + + slots = sim_get_slots() + if slots: + for index in range(0, len(slots)): + setup["sims"].append({"index": index, + "slot": slots[index], + "newslot": -1}) + + return setup + + +def main(): + setup = read_setup() + if setup is None: + sys.exit(0) + + if sim_setup(setup) is False: + fatal("Unable set up SIMs") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/sms.py b/src/modemd/modemd/sms.py new file mode 100644 index 0000000000..5735abadab --- /dev/null +++ b/src/modemd/modemd/sms.py @@ -0,0 +1,144 @@ +from datetime import datetime +import argparse +import locale +import subprocess +import json +import glob +import re +import sys +import os + +LOCALEPATH = "/usr/lib/locale" + + +def run_cmd(cmd): + try: + res = subprocess.run(cmd, capture_output=True, text=True) + return True if res.returncode == 0 else False + except subprocess.CalledProcessError: + return False + + +def send(index, number, text): + if index < 0: + # use first modem by default + index = 0 + + rpc = { + "ietf-hardware:hardware": { + "component": [{ + "name": "modem%d" % index, + "infix-hardware:modem": { + "send-sms": { + "phone-number": number, + "message-text": text + } + } + }] + } + } + print("Sending SMS to modem%d" % index) + + path = "/run/modemd/modem%d/rpc" % index + if os.path.exists(path): + sys.stderr.write("Error: another rpc is running\n") + return + + with open(path, "w") as fd: + json.dump(rpc, fd) + + if not run_cmd(['sysrepocfg', '-f', 'json', '-R', path]): + sys.stderr.write("Error: Unable to send SMS\n") + + os.unlink(path) + + +def listsms(): + smslist = [] + files = glob.glob('/var/sms/*') + for f in files: + with open(f, "r") as fd: + sms = json.load(fd) + sms["path"] = f + + t = sms["payload"]["properties"]["timestamp"] + t = re.sub("[+-][\\d:]+$", "", t) + sms["time"] = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S") + + smslist.append(sms) + + return sorted(smslist, key=lambda sms: sms["time"]) + + +def delete(index=-1): + count = 0 + for sms in listsms(): + if index > -1 and sms["modem"] == index: + os.remove(sms["path"]) + count += 1 + print("Deleted %d SMS files" % count) + + +def show(index=-1): + for sms in listsms(): + if index > -1 and sms["modem"] != index: + continue + + content = sms["payload"]["content"] + prop = sms["payload"]["properties"] + + print("--- %s ---\n" % os.path.basename(sms["path"])) + print("From: %s" % content["number"]) + print("SMSC: %s" % prop["smsc"]) + print("Modem: %d" % sms["modem"]) + print("Date: %s" % prop["timestamp"]) + print("State: %s" % prop["state"]) + print("\n%s\n" % content["text"]) + + +def listlocale(): + locales = [] + for f in os.listdir(LOCALEPATH): + if os.path.isdir(os.path.join(LOCALEPATH, f)): + locales.append(f) + return ", ".join(locales) + + +def main(): + parser = argparse.ArgumentParser(prog='modem-sms') + parser.add_argument("-d", action="store_true", help="Delete SMS") + parser.add_argument("-s", action="store_true", help="Send SMS") + + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-n", "--number", default=None, help="Phone number") + parser.add_argument("-t", "--text", default=None, help="Message text") + + parser.add_argument("-l", "--locale", default=None, help="Set locale") + args = parser.parse_args() + + index = -1 + if args.index: + index = int(args.index) + + if args.locale: + try: + locale.setlocale(locale.LC_ALL, args.locale) + except locale.Error: + print("Error: Invalid locale (available: %s)" % listlocale()) + sys.exit(1) + + if args.s: + if not args.number: + sys.stderr.write("Error: need number\n") + elif not args.text: + sys.stderr.write("Error: need text\n") + else: + send(index, args.number, args.text) + elif args.d: + delete(index) + else: + show(index) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/udev.py b/src/modemd/modemd/udev.py new file mode 100644 index 0000000000..c7bd8024c7 --- /dev/null +++ b/src/modemd/modemd/udev.py @@ -0,0 +1,343 @@ +import shutil +import errno +import time +import json +import fcntl +import sys +import re +import os + +from .info import device_devpath + +MODEMS = "/run/modems.json" +SYSTEM = "/run/system.json" +LOCK = "/var/lock/modems.lock" +LOGFILE = "/run/modemd/modem-udev.log" +debug = False + + +def log(msg): + ts = '{:010.3f}'.format(time.monotonic()) + with open(LOGFILE, 'a') as fd: + fd.write("[%s] modem-udev (pid %d) %s\n" % (ts, os.getpid(), msg)) + + +def dbg(msg): + global debug + if debug: + log(msg) + + +def info(msg): + log(msg) + + +def err(msg): + log("Error: %s" % msg) + + +def lock(): + start = time.time() + while True: + elapsed = time.time() - start + if elapsed > 10: + err("Timeout waiting for lock") + break + try: + fd = open(LOCK, "a") + fcntl.flock(fd.fileno(), fcntl.LOCK_EX) + dbg("Acquired lock") + return fd + except IOError as e: + dbg("Unable to lock (%d)" % e.errno) + if e.errno != errno.EAGAIN: + err("Unable to lock") + return None + else: + time.sleep(1) + + return None + + +def unlock(fd): + try: + fcntl.flock(fd.fileno(), fcntl.LOCK_UN) + fd.close() + dbg("Released lock") + return True + except IOError as e: + err("Unlock failed with %d" % e.errno) + return False + + +def mkdir(path): + if os.path.isdir(path): + return True + try: + os.mkdir(path, mode=0o755) + shutil.chown(path, user="root", group="wheel") + except OSError: + err("Unable to mkdir %s" % path) + return False + finally: + return True + + +def read_modem_data(): + data = {} + if os.path.exists(MODEMS): + with open(MODEMS, "r") as fd: + data = json.load(fd) + return data + + +def write_modem_data(data): + with open(MODEMS + ".bak", 'w') as fd: + json.dump(data, fd) + + os.rename(MODEMS + ".bak", MODEMS) + return True + + +def _sysfs_attr(devpath, name): + try: + with open("%s/%s" % (devpath, name), "r") as fd: + return fd.readline().strip() + except OSError: + return None + + +def update_system_json(modems_list): + data = {} + try: + with open(SYSTEM, "r") as fd: + data = json.load(fd) + except (IOError, json.JSONDecodeError): + pass + + entries = [] + for i, m in enumerate(modems_list): + devpath = m.get("devpath", "") + port = os.path.basename(devpath) if devpath else "" + entry = { + "index": i, + "name": "modem%d" % i, + "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) + else: + data["modem"] = entries + + tmp = SYSTEM + ".tmp" + with open(tmp, "w") as fd: + json.dump(data, fd, indent=2) + os.rename(tmp, SYSTEM) + info("Updated system.json with %d modem(s)" % len(entries)) + + +def update(d): + modem = None + devpath = d.get("devpath") + + data = read_modem_data() + if "modems" not in data: + data = {"modems": []} + else: + for m in data["modems"]: + if devpath == m.get("devpath"): + data["modems"].remove(m) + modem = m + break + + if not modem: + modem = {"devpath": devpath} + + vendor = _sysfs_attr(devpath, "idVendor") + if vendor: + modem["vendor"] = vendor + + product = _sysfs_attr(devpath, "idProduct") + if product: + modem["product"] = product + + atport = d.get("atport") + atports = modem.get("atports", []) + if atport and atport not in atports: + ptype = d.get("ptype", "default") + if ptype == "primary": + atports.insert(0, atport) + else: + atports.append(atport) + modem["atports"] = atports + + qmiport = d.get("qmiport") + qmiports = modem.get("qmiports", []) + if qmiport and qmiport not in qmiports: + qmiports.append(qmiport) + modem["qmiports"] = qmiports + + iface = d.get("iface") + ifaces = modem.get("interfaces", []) + if iface and iface not in ifaces: + ifaces.append(iface) + modem["interfaces"] = ifaces + + data["modems"].append(modem) + write_modem_data(data) + update_system_json(data["modems"]) + + info("Updated modem %s" % devpath) + + +def 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 = usb_device_dir(devpath) + if not devpath: + err("No USB device for tty") + return False + + 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 = usb_device_dir(devpath) + if not devpath: + err("No USB device for net interface") + return False + + 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 = usb_device_dir(devpath) + if not devpath: + err("No USB device for usbmisc") + return False + + info("Adding usbmisc device %s" % devpath) + + qmiport = os.getenv("DEVNAME", None) + if not qmiport: + err("No DEVNAME") + return False + + info("Adding qmiport %s for %s" % (qmiport, devpath)) + update({"devpath": devpath, "qmiport": qmiport}) + + +def apply(): + subsystem = os.getenv("SUBSYSTEM") + if not subsystem: + err("No SUBSYSTEM") + return False + + devpath = os.getenv("DEVPATH") + if not devpath: + err("No DEVPATH") + return False + + if "usb" not in devpath: + info("Skipping %s" % devpath) + return False + + if subsystem == "net": + add_net(devpath) + elif subsystem == "tty": + add_tty(devpath) + elif subsystem == "usbmisc": + add_usbmisc(devpath) + else: + err("Subsystem %s unhandled" % subsystem) + return False + + return True + + +def main(): + global debug + with open("/proc/cmdline", 'r') as fd: + cmdline = fd.read() + if "debug" in cmdline: + debug = True + + mkdir("/run/modemd") + dbg("started") + + if debug: + for i, j in os.environ.items(): + dbg("ENV: %s=%s" % (i, j)) + else: + if os.path.exists(LOGFILE): + os.remove(LOGFILE) + + fd = lock() + if fd is None: + sys.exit(2) + + if apply(): + rc = 0 + else: + err("Unable to add modem device") + rc = 1 + + unlock(fd) + + dbg("ended") + + sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/update_firmware.py b/src/modemd/modemd/update_firmware.py new file mode 100644 index 0000000000..d7eb46c426 --- /dev/null +++ b/src/modemd/modemd/update_firmware.py @@ -0,0 +1,392 @@ +import subprocess +import argparse +import urllib.parse +import hashlib +import syslog +import json +import time +import os +import sys + +tempdir = "/tmp/modem-fwupdate" +debug = False +index = 0 + +syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG) + + +def dbg(msg): + global debug + if debug: + syslog.syslog(syslog.LOG_INFO, msg) + + +def info(msg): + syslog.syslog(syslog.LOG_INFO, msg) + + +def err(msg): + syslog.syslog(syslog.LOG_ERR, msg) + + +def fatal(msg): + syslog.syslog(syslog.LOG_ALERT, msg) + rmdir(tempdir) + sys.exit(1) + + +def runcmd(cmd): + ret = None + try: + res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + err("Command failed") + dbg(res.stderr) + return None + finally: + return ret + + +def runcmdj(cmd): + output = runcmd(cmd) + if not output: + return None + try: + data = json.loads(output) + except json.JSONDecodeError: + return None + finally: + return data + + +def exists(path): + try: + os.stat(path) + except OSError: + return False + return True + + +def rm(path): + if os.path.exists(path): + os.remove(path) + + +def rmdir(path): + if os.path.isdir(path): + for f in os.listdir(path): + p = os.path.join(path, f) + if os.path.isdir(p): + rmdir(p) + else: + os.remove(p) + + +def mkdir(path): + if os.path.isdir(path): + return True + try: + os.mkdir(path, mode=0o755) + except OSError: + fatal("Unable to mkdir %s" % path) + + +def initctl(cmd, svc): + output = runcmdj(['initctl', '-j', 'status']) + for ctl in output: + if ctl["identity"] == svc: + return runcmd(['initctl', cmd, svc]) + return False + + +def get_path(index): + for modem in runcmdj(['/usr/libexec/modemd/modem-info']): + if modem["index"] == index: + return modem["path"] + + +def get_device_info(index): + path = get_path(index) + if path is None: + return None + + output = runcmdj(['mmcli', '-J', '-m', path]) + if not output: + return None + + gen = output["modem"]["generic"] + info = { + "index": index, + "vendor": gen["manufacturer"], + "model": gen["model"], + "state": gen["state"], + "qmidev": [], + "atdev": [] + } + for port in gen["ports"]: + if "(qmi)" in port: + info["qmidev"].append(port.split()[0]) + elif "(at)" in port: + info["atdev"].append(port.split()[0]) + + return info + + +def wait_for_device(dev, appear): + if appear: + mode = "appear" + else: + mode = "disappear" + + info("Waiting for modem%d to %s" % (dev["index"], mode)) + + path = "/dev/%s" % dev["qmidev"][0] + timeout = 30 + while timeout > 0: + if mode == "appear": + if exists(path): + return True + elif mode == "disappear": + if not exists(path): + return True + time.sleep(1) + timeout -= 1 + return False + + +def qmiupdate(dev): + if len(dev["qmidev"]) == 0: + err("No qmi device found") + return False + + qmidev = "/dev/%s" % dev["qmidev"][0] + cwefile = None + nvufile = None + + for f in os.scandir(tempdir): + if f.name.endswith('cwe'): + cwefile = "%s/%s" % (tempdir, f.name) + if f.name.endswith('nvu'): + nvufile = "%s/%s" % (tempdir, f.name) + + if not cwefile: + err("No CWE file found") + return False + if not nvufile: + err("No NVU file found") + return False + + info("Resetting modem") + if not runcmd(['qmicli', '-d', qmidev, + '--dms-set-operating-mode=offline']): + err("Unable to set modem offline") + return False + if not runcmd(['qmicli', '-d', qmidev, + '--dms-set-operating-mode=reset']): + err("Unable to reset modem") + return False + + if not wait_for_device(dev, 0): + err("Device has not disappeared") + return False + if not wait_for_device(dev, 1): + err("Device has not appeared") + return False + + info("Running qmi-firmware-update, please stand by...") + + cmd = ['qmi-firmware-update', '-v', '--override-download', + '-w', qmidev, '-u', cwefile, nvufile] + return runcmd(cmd) + + +def firmware_update_sierra(dev, url, checksum): + if not download(url, checksum): + err("Unable to prepare firmware") + return False + + info("Stopping modem services") + initctl("stop", "modemd") + initctl("stop", "modem-manager") + + time.sleep(3) + ret = qmiupdate(dev) + + info("Starting modem services") + initctl("restart", "modem-manager") + initctl("restart", "modemd") + + return ret + + +def atcmd(dev, command, expect=None, timeout=None): + device = "/dev/%s" % dev["atdev"][0] + + cmd = ['modem-command', '-d', device, command] + if expect: + cmd += ['-e', expect] + if timeout: + cmd += ['-t', str(timeout)] + + return runcmd(cmd) + + +def firmware_update_telit(dev, url, checksum): + if 'LN920' not in dev["model"]: + err("Not supported") + return False + + if len(dev["atdev"]) == 0: + err("No AT device found") + return False + + if dev["state"] != "connected": + err("Modem is not connected") + return False + + u = urllib.parse.urlparse(url) + if not u: + err("Unable to parse URL") + return False + if u.scheme != "ftp": + err("Not an FTP URL") + return False + + hostname = "" + username = "" + password = "" + port = 21 + + if u.hostname: + hostname = u.hostname + if u.username: + username = u.username + if u.password: + password = u.password + if u.port: + port = int(u.port) + if u.path: + path = u.path + + if hostname == "": + err("No hostname in URL") + return False + if path == "": + err("No path in URL") + return False + + parms = (hostname, port, path, username, password) + cmd = 'AT#FTPGETOTAENH=%s,%d,%s,%s,%s' % parms + exp = '#DREL' + if not atcmd(dev, cmd, exp, 60): + err("Unable to download firmware") + return False + + if not atcmd(dev, 'AT#OTAUP=2'): + err("Unable to update firmware") + return False + + if not atcmd(dev, 'AT#ENHRST=1,3'): + err("Unable to reboot modem") + return False + + time.sleep(5) + info("Restarting modem services") + initctl("restart", "modem-manager") + initctl("restart", "modemd") + return True + + +def verify(path, checksum): + if not checksum or checksum == "any": + return True + + sha256 = hashlib.sha256() + with open(path, 'rb') as f: + while True: + data = f.read(1024) + if not data: + break + sha256.update(data) + + calculated = sha256.hexdigest() + + if checksum != calculated: + err("Checksum mismatch (%s vs. %s)" % (calculated, checksum)) + return False + else: + info("Verified checksum") + return True + + +def download(url, checksum): + path = "%s/firmware.zip" % tempdir + + info("Downloading %s" % url) + + if not runcmd(['curl', '-s', '-L', url, '-o', path]): + err("Unable to download firmware") + return False + + if not verify(path, checksum): + err("Checksum verification failed") + return False + + if not runcmd(['unzip', '-d', tempdir, path]): + err("Unable to unpack firmware") + return False + + return True + + +def main(): + global debug, index + syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON) + + parser = argparse.ArgumentParser(prog='modem-firmware-update') + parser.add_argument("-i", "--index", default=None, help="Modem index") + parser.add_argument("-u", "--url", default=None, help="URL to firmware") + parser.add_argument("-c", "--checksum", default=None, + help="SHA256 checksum of firmware") + parser.add_argument('-d', action='store_true') + + args = parser.parse_args() + if args.d: + debug = True + if args.index: + index = int(args.index) + if not args.url: + fatal("No firmware URL specified") + + rmdir(tempdir) + mkdir(tempdir) + + dev = get_device_info(index) + if dev is None: + fatal("No device found for modem%d" % index) + + info("Starting firmware update for %s" % dev["model"]) + + if "Sierra" in dev["vendor"]: + ret = firmware_update_sierra(dev, args.url, args.checksum) + elif "Telit" in dev["vendor"]: + ret = firmware_update_telit(dev, args.url, args.checksum) + else: + fatal("Unsupported vendor") + + if ret: + info("Firmware update succeeded") + else: + fatal("Firmware update failed") + + rmdir(tempdir) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/modemd/ussd.py b/src/modemd/modemd/ussd.py new file mode 100644 index 0000000000..2940f96e8b --- /dev/null +++ b/src/modemd/modemd/ussd.py @@ -0,0 +1,75 @@ +import argparse +import subprocess +import sys + +verbose = False + + +def runcmd(cmd): + if verbose: + print("Running: %s" % " ".join(cmd)) + + ret = None + try: + res = subprocess.run(cmd, capture_output=True, text=True) + if verbose: + print(res.stdout) + if res.returncode == 0: + if res.stdout: + ret = res.stdout.strip() + else: + ret = True + except subprocess.CalledProcessError: + return None + finally: + return ret + + +def ussd(index, code): + cmd = ['/sbin/modem-command', + '--index', str(index), + '--expect', '+CUSD', + '--timeout', '60', + 'AT+CUSD=1,"%s",15' % code] + + if verbose: + cmd.append('--verbose') + + output = runcmd(cmd) + if output is None: + sys.stderr.write("ERROR: Command failed\n") + return False + + resp = output.split('"') + if len(resp) == 3: + print(resp[1]) + + return True + + +def main(): + global verbose + parser = argparse.ArgumentParser(prog='modem-ussd') + parser.add_argument("-i", "--index", default=0, help="Modem index") + parser.add_argument("-c", "--code", default=None, help="USSD codec") + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + if args.verbose: + verbose = True + + index = 0 + if args.index: + index = int(args.index) + + if not args.code: + sys.stderr.write("Error: need USSD code\n") + else: + if ussd(index, args.code): + sys.exit(0) + + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/modemd/pyproject.toml b/src/modemd/pyproject.toml new file mode 100644 index 0000000000..90a50e016d --- /dev/null +++ b/src/modemd/pyproject.toml @@ -0,0 +1,29 @@ +[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-dhcp = "modemd.dhcp:main" +modem-rpc = "modemd.rpc:main" +modem-sms = "modemd.sms:main" +modem-scan-networks = "modemd.scan_networks:main" +modem-update-firmware = "modemd.update_firmware:main" +modem-ussd = "modemd.ussd:main" +modem-power = "modemd.power:main" +modem-udev = "modemd.udev:main" +sim-setup = "modemd.sim_setup:main" diff --git a/src/modemd/qmi-wwan-ids.rules b/src/modemd/qmi-wwan-ids.rules new file mode 100644 index 0000000000..902f8d8f8f --- /dev/null +++ b/src/modemd/qmi-wwan-ids.rules @@ -0,0 +1,7 @@ +# Bind USB modem devices whose IDs are not yet upstream in qmi_wwan. +# Required until the IDs are accepted into the kernel driver. +ACTION!="add", GOTO="qmi_wwan_ids_end" +SUBSYSTEM!="usb", GOTO="qmi_wwan_ids_end" +ENV{DEVTYPE}!="usb_device", GOTO="qmi_wwan_ids_end" + +LABEL="qmi_wwan_ids_end" diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 919e27630d..a6fa6ca26c 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,285 @@ 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(ms): + parts = [] + sq = ms.get("signal-quality") + if sq is not None: + 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 _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 + + +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 + if c.get("class") == "infix-hardware:modem" + and c.get("name") == ref), None) + if modem is None: + print(f"Modem '{ref}' not found.") + return + + ms = modem.get("infix-hardware:modem-state", {}) + cell = ms.get("cellular", {}) + location = ms.get("location-state", {}) + + sim_name = ref.replace("modem", "sim", 1) + sim = next((c for c in components + if c.get("class") == "infix-hardware:sim" + and c.get("name") == sim_name), None) + sim_state = sim.get("infix-hardware:sim-state", {}) if sim else {} + + 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", 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", "")), + ("USB ID", _usb_id(ms)), + ("URI", ", ".join(modem.get("uri", []))), + ("IMSI", ms.get("imsi", "")), + ("ICCID", ms.get("iccid", "")), + ): + if val: + print(f" {label:<20}: {val}") + phone = ms.get("phone-number", []) + if phone: + print(f" {'Phone Number':<20}: {', '.join(phone)}") + carriers = ms.get("supported-carrier", []) + if carriers: + print(f" {'Supported Carriers':<20}: {', '.join(carriers)}") + selected = ms.get("selected-carrier", "") + if selected: + print(f" {'Selected Carrier':<20}: {selected}") + + Decore.title("Status", width) + state = ms.get("state", "unknown") + print(f" {'State':<20}: {modem_state_to_status(state)}") + if state == "failed": + 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"), + ): + val = cell.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 ifname, bstate, _wwan in bearers: + connected = bstate.get("connected", False) + print(f" {ifname} ({'connected' if connected else 'disconnected'})") + if not connected: + reason = bstate.get("connection-failed-reason", "") + if reason: + print(f" {'Error':<18}: {reason}") + v4 = bstate.get("ipv4-address") + v4pfx = bstate.get("ipv4-prefix-length", 0) + if v4 and v4pfx: + print(f" {'IPv4':<18}: {v4}/{v4pfx}") + v6 = bstate.get("ipv6-address") + v6pfx = bstate.get("ipv6-prefix-length", 0) + if v6 and v6pfx: + print(f" {'IPv6':<18}: {v6}/{v6pfx}") + 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: + Decore.title("Location", width) + src = location.get("source") + if src: + print(f" {'Source':<20}: {src}") + lat = location.get("latitude") + lon = location.get("longitude") + alt = location.get("altitude") + if lat is not None and lon is not None: + pos = f"{lat}, {lon}" + if alt is not None: + pos += f" Alt: {alt} m" + print(f" {'GPS':<20}: {pos}") + cid = location.get("cell-id") + if cid is not None: + cell_id = f"CID {cid}" + lac = location.get("lac") + tac = location.get("tac") + if lac is not None: + cell_id += f" LAC {lac}" + if tac is not None: + cell_id += f" TAC {tac}" + mcc = location.get("mcc") + mnc = location.get("mnc") + if mcc and mnc: + cell_id += f" MCC {mcc} MNC {mnc}" + print(f" {'Cell ID':<20}: {cell_id}") + loc_change = location.get("last-change") + if loc_change: + print(f" {'Last Update':<20}: {loc_change}") + + def show_hardware(json): if not json.get("ietf-hardware:hardware"): print("Error, top level \"ietf-hardware:component\" missing") @@ -2380,8 +2713,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"] - width = max(PadSensor.table_width(), 62) + # 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(), + 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 +2853,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 +2892,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 +2901,43 @@ 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 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): @@ -6318,6 +6707,12 @@ 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)') subparsers.add_parser('show-interfaces', help='Show interfaces') \ .add_argument('-n', '--name', help='Interface name') @@ -6401,6 +6796,12 @@ 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": + show_modem_detail(json_data, args.name) elif args.command == "show-interfaces": show_interfaces(json_data, args.name) elif args.command == "show-lldp": diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index b34be178f2..c73f898478 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/ietf_interfaces/link.py b/src/statd/python/yanger/ietf_interfaces/link.py index 9d1635fef0..7dee49b0e1 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 0000000000..fc6fe9c63c --- /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/ietf_system.py b/src/statd/python/yanger/ietf_system.py index c94de06c38..95577de181 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/src/statd/python/yanger/infix_modem.py b/src/statd/python/yanger/infix_modem.py new file mode 100644 index 0000000000..035ae43dfd --- /dev/null +++ b/src/statd/python/yanger/infix_modem.py @@ -0,0 +1,289 @@ +import os + +from .common import LOG +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 = {} + + # 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 = {} + 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 + + 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 _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: + 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", + } + + 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. + 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: + 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: + 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 {} + + return { + "ietf-hardware:hardware": { + "component": hw_components + } + } diff --git a/test/case/all.yaml b/test/case/all.yaml index 8389277a61..d2ae879deb 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/hardware/usb/test.adoc b/test/case/hardware/usb/test.adoc index 5ce510abfc..199af5d596 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 fba4755c08..7b6413d77c 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() diff --git a/test/case/modem/Readme.adoc b/test/case/modem/Readme.adoc new file mode 100644 index 0000000000..e5b5d1cca8 --- /dev/null +++ b/test/case/modem/Readme.adoc @@ -0,0 +1,11 @@ +:testgroup: +== Cellular Modem + +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 new file mode 100644 index 0000000000..444e4c6f63 --- /dev/null +++ b/test/case/modem/all.yaml @@ -0,0 +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/basic/Readme.adoc b/test/case/modem/basic/Readme.adoc new file mode 120000 index 0000000000..ae32c84120 --- /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 0000000000..e5a7ef7aee --- /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 0000000000..b0ea821265 --- /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 0000000000..02b788692c --- /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 0000000000..6fc6f47a8e --- /dev/null +++ b/test/case/modem/basic/topology.svg @@ -0,0 +1,33 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +target + +mgmt + +target + + + +host:mgmt--target:mgmt + + + + diff --git a/test/case/modem/connect/Readme.adoc b/test/case/modem/connect/Readme.adoc new file mode 120000 index 0000000000..ae32c84120 --- /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 0000000000..103e241397 --- /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 0000000000..7810952275 --- /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 0000000000..08934b784c --- /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 0000000000..6fc6f47a8e --- /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/case/repo/defconfig.sh b/test/case/repo/defconfig.sh index b3acbea169..2699f9cd19 100755 --- a/test/case/repo/defconfig.sh +++ b/test/case/repo/defconfig.sh @@ -31,12 +31,6 @@ check() echo "1..$total" for defconfig in "$@"; do - # Skip UNIX backup files - case "$defconfig" in - *~|*.bak|'#'*'#'|.#*) - continue - ;; - esac base=$(basename "$defconfig") if whitelist "$base"; then echo "ok $num - $base is exempted # skip" @@ -50,6 +44,9 @@ check() } # shellcheck disable=SC2046 -check $(find "$CONFIGS" -maxdepth 1 -type f | LC_ALL=C sort) || exit 1 +check $(find "$CONFIGS" -maxdepth 1 -type f \ + ! -name '*~' ! -name '*.bak' \ + ! -name '#*#' ! -name '.#*' \ + | LC_ALL=C sort) || exit 1 exit 0 diff --git a/test/case/statd/system/ietf-system.json b/test/case/statd/system/ietf-system.json index e9f63c9387..a9c4cf3207 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", diff --git a/test/infamy/transport.py b/test/infamy/transport.py index d8d36a2d18..09b98d4648 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()` diff --git a/test/templates/inc/infix-fwcfg.mustache b/test/templates/inc/infix-fwcfg.mustache index 914b87eabf..2689a41ef9 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 0000000000..dcc7fa52c2 --- /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 eddecec07c..2fe4ffa512 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 bd468ac009..d5e814b028 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 a7bdc0d541..a8d8673cda 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 70dbd74196..328ca69242 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 f42e9c22cd..9cb97f5a08 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 diff --git a/utils/mt7615-eeprom.sh b/utils/mt7615-eeprom.sh new file mode 100755 index 0000000000..b47c0bebff --- /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