Compare commits

...
13 Commits
Author SHA1 Message Date
Joachim Wiberg 009b31d465 modemd: handle preferred-mode set without allowed-mode
Setting just preferred-mode in YANG (no allowed-mode leaf-list) built an
invalid mode string with an empty allowed:

  Mode 'allowed: ; preferred: 4g' is not supported

The empty allowed: can never match anything in the modem's supported-modes
list, so the user got a cryptic 'is not supported' even when their preferred
mode was, in principle, supported.

When only preferred-mode is configured, walk the modem's supported-modes list
for an entry that already has that preferred, and reuse its allowed set.  If
no supported entry has that preferred mode, fail with a message that lists
every combination the modem does support, easier to diagnose than the silent
'empty allowed' case.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:51:04 +02:00
Joachim Wiberg 36ef190c3d modemd: stop retrying mmcli --reset when the modem rejects it
Some cellular modems (notably Quectel EM05) don't expose a RESET capability
over MBIM/QMI.  Both 'mmcli --reset' and 'mmcli --factory-reset' return
Unsupported immediately, the AT command is never forwarded to the firmware,
and there's nothing modemd can do over the management bus to recover.  Until
now modemd's failed-state retry kept calling --reset every 30 s for the rest
of uptime, producing pure log noise:

    modemd: <error> [modem0] Resetting after being 30s in state failed
    modemd: <info>  [modem0] Resetting
    ModemManager: <wrn> [modem0] failed requesting modem reset: Unsupported
    ModemManager: <wrn> [modem0] failed requesting modem factory reset: Unsupported
    modemd: <error> [modem0] Unable to reset

... and the same five lines again 30 s later, indefinitely.

Mitigate this by caching the Unsupported state per modem per session.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:51:03 +02:00
Joachim Wiberg 600483c35b modem: route NMEA from cellular modems to gpsd
Cellular modems (Quectel EM05/EM06/EM12, Sierra EM7565, ...) expose
GPS data as NMEA on a dedicated USB serial interface.  Until now we
relied on ModemManager's --location-enable-gps-nmea, which makes MM
hold the NMEA port and parse the stream itself — useful for mmcli
but a dead end for the rest of the system.  The result was that
modem GPS was visible only via 'show modem' and could not act as an
NTP fallback time source on devices without dedicated GPS hardware.

This commit reroutes the NMEA stream into the existing
gpsd → chronyd pipeline so cellular modems behave just like any
other NMEA-over-USB GPS dongle.  No changes to gpsd, chronyd, or
the ietf-hardware:gps component — the user activates it the same
way as for a dedicated receiver, by adding a 'gps' hardware
component that references /dev/gpsN.

This is Phase 2 of the modem GPS / location integration plan
(.notes/modem-gps-plan.md).

udev (src/modemd/77-mm-modem-gps.rules):
  - For known vendor:product:interface tuples, set ID_MM_PORT_IGNORE
    so ModemManager keeps off the NMEA port, and add
    SYMLINK+="gps%n" so gpsd's existing udev hook picks up the
    device automatically.
  - Interface-number match uses ENV{ID_USB_INTERFACE_NUM} rather
    than ATTRS{bInterfaceNumber}: udev requires all ATTRS{} matches
    in a rule to share one parent, but idVendor/idProduct live on
    the USB device while bInterfaceNumber lives on the USB
    interface.  ENV{} matching has no parent constraint and udev
    already populates ID_USB_INTERFACE_NUM from bInterfaceNumber.
  - Initial entries: Quectel EM05 (2c7c:0125), EM06-E (2c7c:0306),
    EM12-G (2c7c:0512), Sierra Wireless EM7565 (1199:9091).
    Easy to extend.

modemd (modemd/__init__.py):
  - GPS_AT_COMMANDS table maps manufacturer -> (enable, disable) AT
    command pair.  prepare_location() issues the vendor AT command
    via 'mmcli --command=AT+QGPS=1' (or AT+CGPS=1 for Sierra)
    instead of --location-enable-gps-{nmea,raw}.  Vendor lookup is
    substring-based ('Quectel' matches both 'Quectel' and 'Quectel
    Incorporated') because ModemManager normalises differently
    across firmware revisions.  For unrecognised vendors the
    high-level MM path is still used as a fallback, so nothing
    regresses for hardware not yet in the table.
  - The AT path runs based on the udev/vendor table, not on MM's
    --location-status capabilities.  Setting ID_MM_PORT_IGNORE on
    the NMEA port removes 'gps' from MM's capability list even
    though the GPS hardware is still there, so a capability gate
    would mistakenly skip GPS for the very modems we're targeting.
  - _location_capabilities() filters MM-managed sources so a single
    unsupported flag (e.g. CDMA on an LTE-only modem) doesn't fail
    the whole batched call.  Sources the modem doesn't support are
    silently skipped; a warning is logged only if the user's YANG
    config explicitly lists one.
  - Subprocess fan-out collapsed: agps-msa, agps-msb, 3gpp and cdma
    flags share a single batched mmcli invocation.
    prepare_location() drops from 5 subprocess calls to 1 (unknown
    vendor) or 2 (known vendor, AT + batched MM).
  - Stringly-typed if/elif source dispatch replaced with a
    dict-driven table (LOCATION_MM_FLAGS).
  - Two helpers eliminate the ['mmcli', '-m', self.path, ...]
    boilerplate at ~30 ModemThread call sites:
      self._mmcli(*args, check=True)   # runcmd wrapper
      self._mmclij(*args)              # runcmdj wrapper

build (package/feature-modem/Config.in):
  - Select BR2_PACKAGE_MODEM_MANAGER_ATVIADBUS so ModemManager
    accepts raw AT commands over D-Bus without being started in
    --debug mode.  Required by the AT path above; without it
    'mmcli --command' is rejected with MM_CORE_ERROR_UNAUTHORIZED.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:51:03 +02:00
Joachim Wiberg 7b7baa0730 modem: expose location and last-change in operational state
Cellular modem location data (GPS coordinates, 3GPP cell info) was
previously CLI-only — 'show modem modem0' shelled out to modem-info
which queried mmcli on demand and rendered straight to terminal.
NETCONF/RESTCONF clients had no view into modem location, and there
was no way to tell how stale any modem-state field was.

This commit moves both into the YANG operational tree so they are
accessible through every northbound interface, not just the CLI.  It
is Phase 1 of the modem GPS / location integration plan; Phase 2 will
route the modem's NMEA TTY into the existing gpsd → chronyd pipeline
so cellular modems can also serve as an NTP fallback time source.

YANG (infix-hardware.yang, revision 2026-05-10):
  - modem-state/last-change (yang:date-and-time): freshness indicator
    for the modem-state subtree, bumped on each modemd poll cycle
  - modem-state/location-state container: source (gps|3gpp|...),
    latitude/longitude/altitude (decimal64 6/6/1 fraction-digits),
    cell-id/lac/tac/mcc/mnc, and its own last-change

Runtime path:
  - modemd's check_location() now writes
    /run/modemd/modemN/location/data.json with the full location data
    plus an RFC3339 last-change.  Atomic via tmp+rename.  Walks the
    mmcli output once instead of twice.
  - check() touches /run/modemd/modemN/state.json with a fresh
    last-change after each successful state poll.
  - yanger/infix_modem.py reads both files via HOST.read_json and
    folds them into the modem-state operational subtree.
  - cli_pretty show_modem_detail rewritten to consume the operational
    tree (same path as 'show modem' overview), with new 'Last Update'
    lines for both top-level state and location.
  - bin/show/modem() consolidated: both 'show modem' and
    'show modem REF' use get_json('/ietf-hardware:hardware') — no
    more direct modem-info subprocess call.

Cleanup along the way:
  - Removed the legacy /run/modemd/modemN/location/{up,down,disabled,
    failed} marker file writes — they had no readers anywhere in the
    tree.  In-memory self.location['state'] is kept purely as the
    edge-trigger for the 'Retrieved GPS location' log line.
  - Aligned now_rfc3339() output with yanger.common.YangDate (+00:00
    form, not Z) so consumers see one consistent yang:date-and-time
    format regardless of producer.
  - Fixed multi-modem SIM scoping in show_modem_detail: now derives
    sim<N> from modem<N> instead of picking the first SIM globally.

The bearer / hardware-revision / phone-number / supported-carrier
sections of 'show modem REF' are deliberately omitted for now —
those fields are not yet in the YANG tree.  Bearer details in
particular likely belong on the wwan interface
(ietf-interfaces:interfaces-state) rather than on the modem hardware
component, and can be addressed in a follow-up.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:51:02 +02:00
Joachim Wiberg f41898d325 modemd: add SIGHUP handler for config reload
On SIGHUP, modemd stops all ModemThreads, re-runs sim-setup, then
restarts the modem threads from fresh sysrepo config.  The pidfile is
touched with os.utime() once the reload is complete, satisfying Finit's
default pidfile-based notify so confd can signal a reload and wait for
it to finish before proceeding.

This allows operator and APN changes to take effect without a full
daemon restart.

Also extract start_modem_threads() to remove the duplicated thread
startup loop shared between initial startup and reload, and align
sighandler() to use isinstance(th, ModemThread) consistently with the
rest of the thread-management code.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:51:02 +02:00
Joachim Wiberg 1168c6c8e2 modemd: package Python scripts for compiled deployment
Python scripts without a .py extension are never cached as bytecode by
CPython — every invocation re-parses the source.  Packaging as a wheel
pre-compiles all modules to .pyc and installs thin import stubs as the
executables, matching the approach used by bin/show and statd.

Restructure the modemd Python scripts into a proper Python package
(src/modemd/modemd/): each script becomes a module with a main() entry
point.  pyproject.toml declares 11 entry points; the Buildroot
MODEMD_BUILD_PYTHON hook builds a wheel via PEP 517, then pyinstaller.py
installs compiled stubs to /usr/libexec/modemd/ and pre-compiled .pyc
modules to site-packages.

/sbin/modemd is now a symlink to the compiled stub.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:51:01 +02:00
Joachim Wiberg f78f161dbe modem: migrate from infix-modem to ietf-hardware/ietf-interfaces
The initial modem import used a standalone infix-modem YANG module with
an integer-indexed list under /infix-modem:modems carrying a bearer
config mixed in with hardware config.  Infix has adopted the ietf-hardware
model to allow for a clearer separation of concerns, this commit aims to
improve on that situation by refactoring the modem model to use the same
architectural pattern already used for WiFi radios, GPS receivers, and
USB ports:

Hardware:
    /ietf-hardware:hardware/component[class=infix-hardware:modem]
    /ietf-hardware:hardware/component[class=infix-hardware:sim]
Interface:
    /ietf-interfaces:interfaces/interface[type=infix-if-type:modem]

Configuration split:
  modem component — admin-state (enable/disable), radio access mode,
                    frequency bands, location services, probe-timeout
  sim component   — PIN, PUK, carrier profile
  wwan interface  — APN, IP type, roaming, route-preference, auth

This also enables the modem to appear in 'show hardware' alongside
other hardware components, and to be managed over NETCONF/RESTCONF
using the same ietf-hardware RPCs used for USB and WiFi.

YANG changes:
  - infix-modem.yang removed (1089 lines, standalone module)
  - infix-hardware.yang: new modem and sim containers, modem-state and
    sim-state operational state, typedefs for bands/modes/location,
    notification status-update; actions restart/reset/send-sms are
    augmented directly on the component list entry (not inside container
    modem) to work around sysrepo NP-container parent validation — when
    checking a nested action's parent, sysrepo restricts its operational
    data fetch to the container path, excluding the sibling 'class' leaf
    needed to evaluate the container's 'when' condition, so the container
    is never instantiated and the parent check fails
  - infix-if-modem.yang: new submodule (same pattern as wifi/wireguard)
    adds the wwan bearer container to ietf-interfaces
  - infix-if-type.yang: new modem identity for wwan interface type
  - confd version bumped to 1.9

confd:
  - if-modem.c: new file replaces the stub modem_gen() in modem.c;
    generates dagger init scripts with probe-timeout wait loop and
    unconditional dummy wwan creation so downstream IP config succeeds
    when a USB modem is slow to enumerate at boot
  - hardware.c: start/stop/enable/disable modemd (and modem-manager)
    in response to admin-state changes on the modem hardware component;
    use finit_enable/disable consistently
  - hardware_cand_infer_class(): auto-set class for modemN/simN names
  - modem.c: stripped to RPC/notification forwarding only; the old
    genconf() / enable() / disable() logic is gone — hardware.c owns
    the lifecycle now
  - interfaces.c: remove wwan from wait-interface timeout (the modem
    interface appearance is managed by modemd, not confd's init scripts)
  - migrate/1.9: script fully migrates /infix-modem:modems to the new
    split model; each modem[N] becomes a modemN hardware component and
    simN sim component; each bearer becomes a wwanN interface (bearers
    numbered globally across modems); APN, IP type, roaming, preferred-
    mode, bands, location, PIN/PUK/carrier are all carried over; plain-
    text bearer credentials are moved to a named keystore symmetric-key
    (base64-encoded) referenced from bearer/authentication/password;
    apn-type, firewall-enabled, dns-enabled and default-route are
    dropped (no equivalent in the new model)

modemd:
  - load_config() replaces the infix-modem sysrepocfg call; reads from
    ietf-hardware (modem/sim config), ietf-interfaces (bearer config),
    and ietf-keystore (credentials) — three standard modules instead of
    one proprietary one; modems absent from sysrepo config but present
    in system.json are still started (physical detection wins)
  - Default route written to /etc/net.d/<iface>.conf (picked up by
    netd/staticd/zebra) instead of a direct 'ip route add'; routes are
    now visible to FRR for redistribution and metric-based failover
  - Addresses tagged with proto wwan (rt_addrprotos entry 7) for
    precise flush on disconnect without touching other address sources
  - _derive_gateway() handles point-to-point bearers where modem
    reports gateway as "--"; derives peer from link-scope subnet route
  - SimThread exits cleanly on platforms without /dev/simctrl
  - sim-setup, modem-info, modem-udev, modem-rpc, modem-sms all ported
    to the ietf-hardware/ietf-interfaces namespace

statd:
  - infix_modem.py now emits modem-state and sim-state into ietf-hardware
    component entries (modem0, sim0, ...) instead of a separate tree;
    sim-state reports physical slot, lock state, and SIM operator name
  - ietf_hardware.py: hardware component names deduplicated by rename
    (cpu → cpu, cpu1, ...) to prevent LY_EVALID when hwmon and thermal
    sysfs both normalise to the same sensor name
  - show hardware: Cellular Modems and SIM Cards tables added; sensor
    table width now adapts to the widest section in the output
  - 00-probe: USB modem detection writes to system.json["modem"], same
    source as wifi-radios, feeding gen-hardware which emits the
    ietf-hardware component entries at factory config generation time

CLI:
  - 'show modem [ref]': without ref shows Cellular Modems and SIM Cards
    tables (same data subset as 'show hardware'); with ref shows a full
    per-modem dump (hardware info, status, cellular, SIM, bearers, GPS)
  - 'modem restart <ref>': disconnect and reconnect all bearers without
    a full hardware reset
  - 'modem reset <ref>': factory-reset the modem firmware
  - 'modem sms <ref> <number> <message>': send an SMS via the modem
    (uses the signalling plane; no active data bearer required)
  - MODEMS PTYPE provides tab-completion reading modem names from
    /run/system.json

doc:
  - Add initial User Guide for modems
  - Update ChangeLog with modem news
  - Use absolute URLs in ChangeLog.  Relative links only work for in-tree
    references, not in release notes

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:50:58 +02:00
Joachim Wiberg f0dfc6a8de test: skip defconfig backup files at find time, not mid-loop
defconfig.sh used $# (find result, incl. *~ / *.bak) for the TAP total
but then 'continue'd past backups without decrementing — so a stray
bpi_r4_*_defconfig~ in the tree made TAP see "1..23" followed by only 21
results, and the harness treated the missing 2 as failures.  Filter the
patterns at find instead; $# now matches what the loop iterates over

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:50:29 +02:00
Joachim Wiberg ffe5ed1e32 statd, show: fix several operational data reporting bugs
Three bugs found while testing modem support on hardware:

ietf_system.py: "interface": null written to DNS server entries when
  no interface is associated, causing LY_EVALID in the ietf-system
  schema validator.  Only include the key when the interface name is
  non-empty.

ietf_system.py: empty list inserts (user [], server [], options [],
  search []) overwrote configured data in the operational store instead
  of leaving it intact.  Guard each insert so operational data only
  shadows configured data when there is something to report.

show system: CPU temperature reported only the first sensor named
  exactly "cpu", "soc", or "core".  Platforms with multiple thermal
  zones (e.g. BPI-R4 exposes cpu and cpu1) always showed only one
  reading.  Collect all sensors whose name matches a CPU/SoC prefix
  and report the maximum.

statd.c: yanger parse errors logged without model name or libyang
  error string, making failures hard to diagnose.  Include both.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:50:29 +02:00
Joachim Wiberg 314b0b425f Add cellular modem support via modemd daemon
Integrates the modem management subsystem, donated by Avvero Pty Ltd,
adapted to work on any platform without proprietary hardware (simctrl,
modules.json, /sys/class/sim/).

Components:
- package/feature-modem: Buildroot feature package pulling in modemd,
  ModemManager, libmbim, libqmi, and USB serial/MBIM/QMI kernel drivers
- package/modemd: Buildroot package for the modemd daemon and helpers
- src/modemd: modem management daemon — detects modems via ModemManager,
  connects bearers, configures IP addresses, handles SMS and location
- src/confd/src/modem.c: confd plugin — enables/disables modemd and
  modem-manager via sysrepo, forwards RPCs (restart/reset/send-sms)
- src/confd/src/interfaces.{c,h}: IFT_MODEM type for wwan* interfaces;
  confd manages address/route assignment, modem daemon owns link state
- src/confd/yang/confd/infix-modem.yang: YANG model for modem config and
  operational state (bearers, APN, bands, modes, location, SMS)
- src/statd/python/yanger/infix_modem.py: operational state collector
- patches/modem-manager: upstream patches for udev install path,
  multiplexed interface naming, and MBIM port trust from udev hint
  (the last fixes Dell DW5811e / Sierra EM7455 on cold boot)
- patches/libqmi: ignore sysfs in download mode

Platform portability fixes for boards without simctrl hardware:
- modem-info: fall back to /run/modems.json when /run/modules.json is
  absent; discover wwan interfaces via sysfs scan; provide synthetic SIM
  entry when /sys/class/sim/ is not present; fix dbg() to respect flag
- modemd: make --set-allowed-modes=any best-effort (some modems only
  support one mode combination and reject any change); skip mode set
  when current mode already matches requested mode
- sim-setup: guard simctrl open, return None when modules.json absent
  to skip SIM slot switching entirely on non-simctrl hardware

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:50:28 +02:00
Joachim Wiberg 985b694e51 confd: fix debug mode regression
In the last major refactor of confd to a stand-alone daemon, new command
line options were added, one of which -v <LEVEL>.  Unfortunately opening
up for a few corner cases where the debug flag or the log_level was not
set properly.

This fixes that and also adds a CONFD_ARGS for /etc/default/confd, which
is customary for all daemons.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:50:28 +02:00
Joachim Wiberg 060e0ea284 board: some sim card help on the bpi-r4
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:50:28 +02:00
Joachim Wiberg 43bf6358a0 board: add USB2 component to bpi-r4 factory-config
The M.2 LTE slot uses the USB2 bus. Add it as an unlocked component so
the modem hardware is accessible and visible to users in the config.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:50:27 +02:00
78 changed files with 8343 additions and 69 deletions
+42
View File
@@ -87,6 +87,45 @@ Infix ships with the following factory defaults.
> are bridged into the LAN as access points. WiFi is not included with the board
> and is not part of the factory default configuration.
## SIM Card Installation
The BPI-R4 has three SIM holders, one per PCIe slot:
| Slot | PCIe socket | Typical use |
|----------|-------------|--------------------------|
| **SIM1** | M.2 Key-B | 4G/5G WWAN modem |
| **SIM2** | mPCIe #1 | Cellular or other modem |
| **SIM3** | mPCIe #2 | Cellular or other modem |
All three take **nano-SIM (4FF)** cards.
> [!NOTE]
> The official Banana Pi pages show an older board revision with a sliding
> "sled" holder. Newer boards ship with a fixed friction-fit holder instead:
> there is no lid to lift and no spring eject. The [closest matching photo on
> bananapi.org][15] shows the current mechanism.
### Inserting a SIM
1. Orient the card with the gold contacts facing the PCB (printed
side up) and the cut corner pointing toward the M.2/mPCIe socket,
which on this board is also toward the outer edge. The silkscreen
next to each holder shows the same outline.
2. Push the SIM straight in until it is fully seated. There is no
click and the holder is not spring-loaded, so press firmly until
the contacts engage and the rear edge of the card sits flush with
the holder.
3. A SIM that still protrudes is not fully inserted; push again.
### Removing a SIM
The friction grip on these holders is tight. Gently pry the card out
with a fingernail or a thin plastic spudger. Avoid metal tweezers
near the contacts. Removing the front panel of the metal case is
absolutely necessary to access the SIM holder.
For software configuration once the modem is recognised, see the
[Cellular Modem User's Guide][16].
## Getting Started
@@ -358,3 +397,6 @@ 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/
@@ -10,6 +10,13 @@
"state": {
"admin-state": "unlocked"
}
},
{
"name": "USB2",
"class": "infix-hardware:usb",
"state": {
"admin-state": "unlocked"
}
}
]
},
@@ -366,7 +373,7 @@
]
},
"infix-meta:meta": {
"version": "1.7"
"version": "1.8"
},
"infix-services:mdns": {
"enabled": true
@@ -10,6 +10,13 @@
"state": {
"admin-state": "unlocked"
}
},
{
"name": "USB2",
"class": "infix-hardware:usb",
"state": {
"admin-state": "unlocked"
}
}
]
},
@@ -358,7 +365,7 @@
]
},
"infix-meta:meta": {
"version": "1.7"
"version": "1.8"
},
"infix-services:mdns": {
"enabled": true
+4
View File
@@ -1 +1,5 @@
CONFD_TIMEOUT=60
#DEBUG=1
# -v debug
CONFD_ARGS=""
@@ -5,3 +5,4 @@
4 static
5 dhcp
6 random
7 wwan
@@ -620,6 +620,73 @@ def probe_ptp_capabilities(out):
out.setdefault("interfaces", {}).update(ifaces)
def _usb_root_from_sysfs(dev_path):
p = dev_path
while p not in ("/", "/sys"):
parent = os.path.realpath(os.path.join(p, ".."))
basename = os.path.basename(parent)
if basename.startswith("usb") and basename[3:].isdigit():
return parent
p = parent
return None
def _read_usb_vid_pid(dev_path):
p = dev_path
while p not in ("/", "/sys"):
try:
vid = open(os.path.join(p, "idVendor")).read().strip()
pid = open(os.path.join(p, "idProduct")).read().strip()
return vid, pid
except OSError:
pass
p = os.path.dirname(p)
return "", ""
def probe_modems(out):
"""devpath is the USB bus root; ModemManager matches by path prefix.
seen_roots deduplicates cdc-wdm and ttyUSB ports of the same physical modem."""
seen_roots = set()
modems = []
idx = 0
def add_modem(dev_path):
nonlocal idx
usb_root = _usb_root_from_sysfs(dev_path)
if usb_root is None or usb_root in seen_roots:
return
seen_roots.add(usb_root)
vid, pid = _read_usb_vid_pid(dev_path)
modems.append({
"index": idx,
"name": "modem%d" % idx,
"devpath": usb_root,
"vid": vid,
"pid": pid,
})
idx += 1
for cls_dir, prefix in (("/sys/class/usbmisc", "cdc-wdm"),
("/sys/class/tty", "ttyUSB")):
try:
entries = os.listdir(cls_dir)
except OSError:
continue
for entry in entries:
if not entry.startswith(prefix):
continue
try:
dev_path = os.path.realpath(
os.path.join(cls_dir, entry, "device"))
add_modem(dev_path)
except OSError:
pass
if modems:
out["modem"] = modems
def main():
out = {
"vendor": None,
@@ -647,6 +714,7 @@ def main():
probe_wifi_radios(out)
probe_ptp_capabilities(out)
probe_modems(out)
if not out["factory-password-hash"]:
sys.stdout.write("\n\n\033[31mCRITICAL BOOTSTRAP ERROR\n" +
+1
View File
@@ -149,6 +149,7 @@ INFIX_HOME="https://github.com/kernelkit/infix/"
INFIX_DOC="https://www.kernelkit.org/infix/"
INFIX_SUPPORT="mailto:kernelkit@googlegroups.com"
BR2_PACKAGE_FEATURE_GPS=y
BR2_PACKAGE_FEATURE_MODEM=y
BR2_PACKAGE_FEATURE_WIFI=y
BR2_PACKAGE_FEATURE_WIFI_MEDIATEK=y
BR2_PACKAGE_FEATURE_WIFI_QUALCOMM=y
+9 -4
View File
@@ -10,13 +10,17 @@ All notable changes to the project are documented in this file.
- Add Wi-Fi roaming for fast, seamless handoff between access points that
share an SSID: 802.11k, 802.11v and 802.11r (over-the-air FT). See the
[Wi-Fi][wifi] guide for details
[Wi-Fi User's Guide][wifi] for details
- Add Wi-Fi 802.11s mesh support, letting access points form a wireless
backhaul between each other without cabling
- Add band steering for dual-band access points, nudging dual-band
clients onto the faster 5/6 GHz band
- Add `legacy-rates` option to re-enable 802.11b rates on 2.4 GHz for
old IoT devices (disabled by default)
- 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
GPS to gpsd. See the [Modem User's Guide][modem] for details
### Fixes
@@ -24,7 +28,8 @@ All notable changes to the project are documented in this file.
sysctls. You must now enable IP forwarding explicitly on the interfaces
that should route traffic; enabling NAT alone is no longer enough
[wifi]: wifi.md
[wifi]: https://www.kernelkit.org/infix/latest/wifi/
[modem]: https://www.kernelkit.org/infix/latest/modem/
[v26.05.0][] - 2026-05-29
-------------------------
@@ -83,9 +88,9 @@ All notable changes to the project are documented in this file.
show`, which were not expanded, so `show interface` and other operational
reads failed. Ranges are now expanded and listed correctly
[ethernet]: ethernet.md#restricting-advertised-link-modes
[ethernet]: https://www.kernelkit.org/infix/latest/ethernet/#restricting-advertised-link-modes
[BPI-R3]: https://docs.banana-pi.org/en/BPI-R3/BananaPi_BPI-R3
[AcerConnectVero]: ../board/aarch64/acer-connect-vero-w6m/
[AcerConnectVero]: https://github.com/kernelkit/infix/tree/main/board/aarch64/acer-connect-vero-w6m/
[v26.04.0][] - 2026-04-30
-------------------------
+1
View File
@@ -23,6 +23,7 @@ regression test system solely relies on NETCONF and RESTCONF.
- [Network Configuration](networking.md)
- [Wi-Fi](wifi.md)
- [DHCP Server](dhcp.md)
- [Cellular Modem (WWAN)](modem.md)
- [Syslog Support](syslog.md)
- **Infix In-Depth**
- [Boot Procedure](boot.md)
+438
View File
@@ -0,0 +1,438 @@
# Cellular Modem (WWAN)
Infix supports cellular modem connectivity via modems that expose a
QMI or MBIM control interface over USB. Form factor does not matter:
USB dongles, mPCIe cards, and M.2 Key-B modules all work as long as
the modem chipset uses USB on the connector (the typical case for
4G/LTE modems). See *Supported Modems* below for the exceptions.
Setup involves three configuration items:
- A `modem0` hardware component for the physical modem
- A `sim0` hardware component for the SIM card slot
- A `wwan0` network interface that references both and carries the
bearer (APN) configuration
## Architecture
Infix uses a three-layer architecture for cellular modem support:
1. **Modem hardware component (modem0)**: the physical modem
- Configured via `ietf-hardware` with class `infix-hardware:modem`
- `admin-state` controls whether ModemManager and modemd are active
- Holds physical-layer config: allowed bands, preferred mode, location
- Auto-discovered into factory-default config when the modem is
present at first boot
2. **SIM hardware component (sim0)**: 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 protocols are handled by
ModemManager:
- **MBIM**: Mobile Broadband Interface Model (e.g. Sierra Wireless,
Quectel EM06/EM12)
- **QMI**: Qualcomm MSM Interface (e.g. Sierra Wireless EM7xxx,
Quectel EM/RMxxx)
Most 4G/LTE modules (USB dongles, mPCIe cards, M.2 Key-B) use USB on
the connector even when the slot also carries PCIe lanes. From Infix's
view they are all USB modems. PCIe-only modems (some 5G NR modules)
are not supported, since the modemd / ModemManager pipeline assumes a
USB-attached control interface.
## Step-by-step Setup
### 1. Hardware Detection
At first boot, USB modems detected by the kernel are written to
`/run/system.json` and added as hardware components in the
factory-default configuration. Verify the modem appears:
<pre class="cli"><code>admin@example:/> <b>show modem</b>
──────────────────────────────────────────────────────────────
<span class="title">Cellular Modems</span>
NAME MANUFACTURER MODEL STATE NETWORK SIGNAL
modem0 Quectel EM06-E registered lte good
──────────────────────────────────────────────────────────────
<span class="title">SIM Cards</span>
NAME SLOT STATE OPERATOR
sim0 1 unlocked Tele2
</code></pre>
The `STATE`, `SIM STATE`, and `SIGNAL` columns are color-coded so the
healthy / attention / problem cases are visible at a glance: green for
`registered`, `connected`, `unlocked`, `excellent`, `good`; yellow for
transient or attention states like `enabling`, `pin-required`, `poor`;
red for `failed`, `not-inserted`, `bad`.
If `modem0` does not appear:
- Verify the kernel sees the modem (`dmesg | grep -i mbim` or
`dmesg | grep -i qmi`). Without a kernel driver no further setup
is possible.
- On boards with a custom factory configuration, or after replacing
hardware on a running system, the components may need to be added
manually. See Step 2.
### 2. Hardware Components
If `show modem` already lists `modem0` and `sim0`, the components were
auto-discovered into the factory-default configuration and you can skip
ahead to Step 3.
Otherwise, add them manually. The class is inferred from the component
name (`modemN``infix-hardware:modem`, `simN``infix-hardware:sim`).
The `admin-state` must be set explicitly. There is no implicit default,
and without `unlocked` confd will not start ModemManager or modemd:
<pre class="cli"><code>admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit hardware component modem0</b>
admin@example:/config/hardware/component/modem0/> <b>set state admin-state unlocked</b>
admin@example:/config/hardware/component/modem0/> <b>end</b>
admin@example:/config/hardware/> <b>edit component sim0</b>
admin@example:/config/hardware/component/sim0/> <b>set state admin-state unlocked</b>
admin@example:/config/hardware/component/sim0/> <b>leave</b>
admin@example:/>
</code></pre>
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:
<pre class="cli"><code>admin@example:/config/> <b>edit hardware component modem0</b>
admin@example:/config/hardware/component/modem0/> <b>set state admin-state locked</b>
admin@example:/config/hardware/component/modem0/> <b>leave</b>
</code></pre>
#### Slow USB Modems
USB modems can be slow to enumerate at boot. The kernel wwan interface
may not appear until several seconds after confd starts applying
configuration. The `probe-timeout` leaf inside the `modem` hardware
configuration container controls how long confd waits. It defaults to
30 seconds when the container is present.
To enable the timeout, create the modem configuration container (this
also lets you configure bands, preferred mode, etc.):
<pre class="cli"><code>admin@example:/config/> <b>edit hardware component modem0 modem</b>
admin@example:/config/hardware/component/modem0/modem/> <b>leave</b>
</code></pre>
With `probe-timeout` at its default of 30, confd waits up to 30 seconds
for the wwan interface to appear before proceeding. For most modems it
is ready in 2-5 seconds. If the modem has not appeared within the
timeout, a dummy placeholder interface is created and a reboot is
required for the real interface to take over. Set `probe-timeout 0`
to disable waiting entirely.
> [!NOTE]
> Some Quectel modules (e.g. EM05) re-enumerate the USB device several
> times during a cold-boot firmware-init sequence that takes around 50
> to 60 seconds. Bump `probe-timeout` to 90 for those, otherwise the
> dummy placeholder fires before the modem settles and the real
> interface ends up with a different name (`wwan1` instead of `wwan0`).
### 3. Configure the Bearer (APN)
Bearer configuration lives on the `wwan0` interface. Reference the
modem hardware component and set the APN:
<pre class="cli"><code>admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit interface wwan0</b>
admin@example:/config/interface/wwan0/> <b>set wwan modem modem0</b>
admin@example:/config/interface/wwan0/> <b>set wwan sim sim0</b>
admin@example:/config/interface/wwan0/> <b>set wwan bearer apn internet</b>
admin@example:/config/interface/wwan0/> <b>leave</b>
</code></pre>
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:
<pre class="cli"><code>admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit keystore symmetric-key apn-pass</b>
admin@example:/config/keystore/…/apn-pass/> <b>set key-format passphrase-key-format</b>
admin@example:/config/keystore/…/apn-pass/> <b>change cleartext-symmetric-key</b>
Passphrase: ************
Retype passphrase: ************
admin@example:/config/keystore/…/apn-pass/> <b>leave</b>
</code></pre>
Then point the bearer's authentication at it:
<pre class="cli"><code>admin@example:/> <b>configure</b>
admin@example:/config/> <b>edit interface wwan0 wwan bearer</b>
admin@example:/config/interface/wwan0/wwan/bearer/> <b>set authentication username myuser</b>
admin@example:/config/interface/wwan0/wwan/bearer/> <b>set authentication password apn-pass</b>
admin@example:/config/interface/wwan0/wwan/bearer/> <b>leave</b>
</code></pre>
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:
<pre class="cli"><code>admin@example:/config/interface/wwan0/wwan/bearer/> <b>set authentication type pap</b>
</code></pre>
### 5. Configure SIM PIN
If the SIM requires a PIN to unlock, configure it on the SIM hardware
component:
<pre class="cli"><code>admin@example:/config/> <b>edit hardware component sim0</b>
admin@example:/config/hardware/component/sim0/> <b>set sim pin 1234</b>
admin@example:/config/hardware/component/sim0/> <b>leave</b>
</code></pre>
### 6. Verify Connectivity
Once connected, the `wwan0` interface receives an IP address from the
carrier and modemd installs the default route:
<pre class="cli"><code>admin@example:/> <b>show interface wwan0</b>
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
</code></pre>
Check the full modem state including signal quality and registration:
<pre class="cli"><code>admin@example:/> <b>show modem modem0</b>
<span class="header">MODEM: modem0 </span>
──────────────────────────────────────────────────────────────
<span class="title">Hardware Information</span>
Manufacturer : Quectel
Model : EM06-E
Firmware Version : EM06ELAR04A07M4G
Serial Number : 352753090141905
IMSI : 240021234567890
ICCID : 8946020000001234567
──────────────────────────────────────────────────────────────
<span class="title">Status</span>
State : connected
Power State : on
Signal : Quality: 72% good RSSI: -59 dBm RSRP: -95 dBm RSRQ: -11.0 dB
──────────────────────────────────────────────────────────────
<span class="title">Cellular</span>
Registration : home
Service State : attached
Operator : Tele2
Operator ID : 23002
Network Type : lte
──────────────────────────────────────────────────────────────
<span class="title">SIM Card</span>
Name : sim0
Slot : 1
Lock State : unlocked
Operator : Tele2
</code></pre>
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:
<pre class="cli"><code>admin@example:/config/> <b>edit interface wwan0 wwan bearer</b>
admin@example:/config/interface/wwan0/wwan/bearer/> <b>set route-preference 100</b>
admin@example:/config/interface/wwan0/wwan/bearer/> <b>leave</b>
</code></pre>
Lower `route-preference` = higher priority.
## Roaming
Data roaming is disabled by default. To allow the modem to connect
when on a foreign (roaming) network:
<pre class="cli"><code>admin@example:/config/> <b>edit interface wwan0 wwan bearer</b>
admin@example:/config/interface/wwan0/wwan/bearer/> <b>set roaming true</b>
admin@example:/config/interface/wwan0/wwan/bearer/> <b>leave</b>
</code></pre>
> [!IMPORTANT]
> Enabling roaming may incur significant charges depending on your
> mobile subscription. Check with your operator before enabling.
## Management Commands
### Restart Bearer
Disconnect and reconnect all bearers without resetting the modem
hardware. Use this after changing APN or authentication settings:
<pre class="cli"><code>admin@example:/> <b>modem restart modem0</b>
</code></pre>
### 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:
<pre class="cli"><code>admin@example:/> <b>modem reset modem0</b>
</code></pre>
> [!NOTE]
> Not all modules accept `--reset` from ModemManager. Quectel EM05,
> for example, rejects both `--reset` and `--factory-reset`. The only
> way to recover from a hung firmware on these is a physical power
> cycle. When the modem reports the rejection, modemd logs it 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:
<pre class="cli"><code>admin@example:/> <b>modem sms modem0 +46701234567 "Hello from Infix"</b>
</code></pre>
> [!NOTE]
> Some SIM cards have Fixed Dialing Number (FDN) enabled, which
> restricts outgoing SMS and calls to a pre-configured whitelist. If
> `modem sms` fails, check whether FDN is active with `mmcli -m 0` and
> look for `enabled locks: fixed-dialing` in the output.
## Troubleshooting
**Modem not detected (`show modem` shows no modem entry)**
- Verify the modem is connected and recognized by the kernel: check
`dmesg` for `cdc_mbim` or `qmi_wwan` driver messages
- Confirm `/sys/class/usbmisc/` contains a `cdc-wdm*` entry
- The modem must be present at boot; hotplug after boot is not
supported
**`wwan0` shows as `down` or has no IP address**
- Check `show modem modem0`. The State should show `registered` or
`connected`, not `failed`
- 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 <code>`
- `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 modemd or ModemManager
messages
**`wwan0` interface is a dummy (no data flows, no carrier address)**
- The modem was not enumerated by the kernel before confd applied
config
- Create the modem hardware configuration container (see Step 2),
which enables the default 30-second probe-timeout so confd waits for
the wwan interface before falling back to a dummy placeholder
**High latency or poor signal**
- Use `show modem modem0` to check signal quality and RSRP
- Signal below -110 dBm RSRP typically indicates poor coverage
- Consider repositioning the antenna or the device
+1
View File
@@ -44,6 +44,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
+2
View File
@@ -2,6 +2,7 @@ menu "Packages"
comment "Hardware Support"
source "$BR2_EXTERNAL_INFIX_PATH/package/feature-gps/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/feature-modem/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/feature-wifi/Config.in"
comment "Software Packages"
@@ -30,6 +31,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/landing/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/libsrx/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/lowdown/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/mcd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/modemd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/mdns-alias/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/netbrowse/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/onieprom/Config.in"
+6 -8
View File
@@ -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] <usr/ixinit> 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
+7
View File
@@ -91,6 +91,12 @@ define CONFD_INSTALL_YANG_MODULES_GPS
$(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/gps.inc
endef
endif
ifeq ($(BR2_PACKAGE_FEATURE_MODEM),y)
define CONFD_INSTALL_YANG_MODULES_MODEM
$(COMMON_SYSREPO_ENV) \
$(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/modem.inc
endef
endif
# PER_PACKAGE_DIR
# Since the last package in the dependency chain that runs sysrepoctl is confd, we need to
@@ -121,6 +127,7 @@ CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES
CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_CONTAINERS
CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WIFI
CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_GPS
CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_MODEM
CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_IN_ROMFS
CONFD_TARGET_FINALIZE_HOOKS += CONFD_CLEANUP
+25
View File
@@ -0,0 +1,25 @@
config BR2_PACKAGE_FEATURE_MODEM
bool "Feature Modem"
select BR2_PACKAGE_MODEMD
select BR2_PACKAGE_MODEM_MANAGER
select BR2_PACKAGE_MODEM_MANAGER_ATVIADBUS
select BR2_PACKAGE_MODEM_MANAGER_LIBMBIM
select BR2_PACKAGE_MODEM_MANAGER_LIBQMI
select BR2_PACKAGE_MODEM_MANAGER_LIBQRTR
help
Enables cellular modem support in Infix via ModemManager and
the modemd management daemon. Includes drivers for common
USB option modems and QMI/MBIM-based devices.
ATVIADBUS allows raw AT commands over D-Bus (used by modemd to
enable GPS NMEA output via vendor-specific AT commands such as
AT+QGPS=1) without needing to start ModemManager in --debug
mode.
config BR2_PACKAGE_FEATURE_MODEM_QUALCOMM
bool "Qualcomm-based modems (QMI/QRTR/MHI)"
depends on BR2_PACKAGE_FEATURE_MODEM
help
Adds kernel support for Qualcomm-based cellular modems that use
the MHI bus and QRTR IPC router (e.g. Sierra Wireless EM7xxx,
Quectel EM/RMxxx, Telit LN9xx).
+26
View File
@@ -0,0 +1,26 @@
################################################################################
#
# Cellular modem support
#
################################################################################
FEATURE_MODEM_PACKAGE_VERSION = 1.0
FEATURE_MODEM_PACKAGE_LICENSE = MIT
define FEATURE_MODEM_LINUX_CONFIG_FIXUPS
$(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL)
$(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL_WWAN)
$(call KCONFIG_ENABLE_OPT,CONFIG_USB_SERIAL_OPTION)
$(call KCONFIG_ENABLE_OPT,CONFIG_USB_WDM)
$(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_QMI_WWAN)
$(call KCONFIG_ENABLE_OPT,CONFIG_USB_CDC_MBIM)
$(call KCONFIG_ENABLE_OPT,CONFIG_USB_NET_CDC_MBIM)
$(if $(filter y,$(BR2_PACKAGE_FEATURE_MODEM_QUALCOMM)),
$(call KCONFIG_SET_OPT,CONFIG_QRTR,m)
$(call KCONFIG_SET_OPT,CONFIG_MHI_BUS,m)
$(call KCONFIG_ENABLE_OPT,CONFIG_QRTR_MHI)
)
endef
$(eval $(generic-package))
+6
View File
@@ -0,0 +1,6 @@
config BR2_PACKAGE_MODEMD
bool "modemd"
select BR2_PACKAGE_MODEM_MANAGER
select BR2_PACKAGE_PYTHON3
help
Daemon which manages modems.
+2
View File
@@ -0,0 +1,2 @@
# Locally calculated
sha256 25b33026a661c4c550374cfcba6890a4363bf19db0c1c31a6e65b5edd113ecf0 LICENSE
+56
View File
@@ -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))
@@ -0,0 +1 @@
service [2345789] ModemManager -- ModemManager daemon
@@ -0,0 +1,21 @@
diff --git a/src/qmi-firmware-update/qfu-helpers-udev.c b/src/qmi-firmware-update/qfu-helpers-udev.c
index bda9106..40d648f 100644
--- a/src/qmi-firmware-update/qfu-helpers-udev.c
+++ b/src/qmi-firmware-update/qfu-helpers-udev.c
@@ -364,8 +364,14 @@ device_matches (GUdevDevice *device,
if (!device_sysfs_path)
goto out;
- if (g_strcmp0 (device_sysfs_path, sysfs_path) != 0)
- goto out;
+ /*
+ * don't compare sysfs path for download mode as it
+ * changes from USB4 to USB3 and thus may use a different host controller
+ */
+ if (mode != QFU_HELPERS_DEVICE_MODE_DOWNLOAD) {
+ if (g_strcmp0 (device_sysfs_path, sysfs_path) != 0)
+ goto out;
+ }
if (device_mode != mode)
return NULL;
+13
View File
@@ -0,0 +1,13 @@
diff --git a/meson.build b/meson.build
index 8cddf77..e97f862 100644
--- a/meson.build
+++ b/meson.build
@@ -188,7 +188,7 @@ endif
config_h.set('WITH_UDEV', enable_udev)
# udev base directory (required to install rules even when udev support is disabled)
-udev_udevdir = get_option('udevdir')
+udev_udevdir = '/lib/udev'
if udev_udevdir == ''
assert(enable_udev, 'udevdir must be explicitly given if udev support is disabled')
udev_udevdir = dependency('udev').get_pkgconfig_variable('udevdir')
@@ -0,0 +1,17 @@
diff --git a/src/mm-bearer-qmi.c b/src/mm-bearer-qmi.c
index 54f2e93..3c4df7b 100644
--- a/src/mm-bearer-qmi.c
+++ b/src/mm-bearer-qmi.c
@@ -2494,7 +2494,11 @@ load_settings_from_bearer (MMBearerQmi *self,
}
/* The link prefix hint given must be modem-specific */
- ctx->link_prefix_hint = g_strdup_printf ("qmapmux%u.", mm_base_modem_get_dbus_id (MM_BASE_MODEM (modem)));
+ const gchar *name = mm_port_get_device (MM_PORT (ctx->data));
+ if (name)
+ ctx->link_prefix_hint = g_strdup_printf ("%s.", name);
+ else
+ ctx->link_prefix_hint = g_strdup_printf ("qmapmux%u.", mm_base_modem_get_dbus_id (MM_BASE_MODEM (modem)));
}
/* If profile id is given, we'll launch the connection specifying the profile id in use
@@ -0,0 +1,29 @@
When a udev rule explicitly sets ID_MM_PORT_TYPE_MBIM=1 for a port,
treat it as definitive and skip the active MBIM open/probe. This
mirrors the way ID_MM_PORT_TYPE_GPS is handled (set via is_gps, a
direct result field rather than a "maybe" hint).
The active MBIM probe attempts to open the WDM device and send an
MBIM OPEN message. Some modems (e.g. Dell DW5811e / Sierra Wireless
EM7455 in USB composition 8) persistently send QMI/QMUX-framed
notifications on the MBIM control channel before ever responding to
MBIM OPEN, causing the probe to time out (~50 s). The probe failure
causes mm_port_probe_list_has_mbim_port() to return FALSE, so the
Dell plugin creates a non-MBIM modem object that cannot handle the
usbmisc/MBIM port, resulting in "unhandled port type".
Since the udev rule already encodes the vendor/product/interface
specificity required to identify the port, the active probe adds no
information in this case.
diff --git a/src/mm-port-probe.c b/src/mm-port-probe.c
--- a/src/mm-port-probe.c
+++ b/src/mm-port-probe.c
@@ -1529,6 +1529,7 @@ mm_port_probe_run (MMPortProbe *self,
/* If this is a port flagged as being a MBIM port, don't do any other probing */
if (self->priv->maybe_mbim) {
mm_obj_dbg (self, "no AT/QCDM/QMI probing in possible MBIM port");
+ mm_port_probe_set_result_mbim (self, TRUE);
mm_port_probe_set_result_at (self, FALSE);
mm_port_probe_set_result_qcdm (self, FALSE);
mm_port_probe_set_result_qmi (self, FALSE);
+32 -8
View File
@@ -81,6 +81,30 @@ def hardware(args: List[str]) -> None:
cli_pretty(data, "show-hardware")
def modem(args: List[str]) -> None:
ref = args[0] if args else None
data = get_json("/ietf-hardware:hardware")
if not data:
print("No modem data available.")
return
# 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)
@@ -558,8 +582,9 @@ def system(args: List[str]) -> None:
runtime = {}
# Extract CPU temperature and fan speed from hardware components
cpu_temp = None
cpu_temps = []
fan_rpm = None
_cpu_prefixes = ("cpu", "soc", "core")
if hardware_data and "ietf-hardware:hardware" in hardware_data:
components = hardware_data.get("ietf-hardware:hardware", {}).get("component", [])
for component in components:
@@ -570,18 +595,16 @@ def system(args: List[str]) -> None:
name = component.get("name", "")
value_type = sensor_data.get("value-type")
# Only capture CPU/SoC temperature (ignore phy, sfp, etc.)
# Different platforms use different names: cpu, soc, core, etc.
if value_type == "celsius" and name in ("cpu", "soc", "core") and cpu_temp is None:
temp_millidegrees = sensor_data.get("value", 0)
cpu_temp = temp_millidegrees / 1000.0
# Capture all CPU/SoC temperatures (handles cpu, cpu1, soc, core0, etc.)
if value_type == "celsius" and any(name == p or name.startswith(p) for p in _cpu_prefixes):
cpu_temps.append(sensor_data.get("value", 0) / 1000.0)
# Capture fan speed if available
elif value_type == "rpm" and fan_rpm is None:
fan_rpm = sensor_data.get("value", 0)
if cpu_temp is not None:
runtime["cpu_temp"] = cpu_temp
if cpu_temps:
runtime["cpu_temp"] = max(cpu_temps)
if fan_rpm is not None:
runtime["fan_rpm"] = fan_rpm
@@ -745,6 +768,7 @@ def execute_command(command: str, args: List[str]):
'keystore': keystore,
'lldp': lldp,
'mdns': mdns,
'modem': modem,
'nacm': nacm,
'ntp': ntp,
'ospf': ospf,
+26
View File
@@ -11,6 +11,11 @@ if jq -e '.["wifi-radios"]' /run/system.json > /dev/null 2>&1; then
else
wifi_radios=""
fi
if jq -e '.["modem"]' /run/system.json > /dev/null 2>&1; then
modem_names=$(jq -r '.["modem"][].name' /run/system.json)
else
modem_names=""
fi
gen_port()
@@ -61,6 +66,20 @@ gen_radio()
EOF
}
gen_modem()
{
modem="$1"
cat <<EOF
{
"name": "$modem",
"class": "infix-hardware:modem",
"state": {
"admin-state": "unlocked"
}
}
EOF
}
first=1
cat <<EOF
{
@@ -81,6 +100,13 @@ for radio in $wifi_radios; do
first=0;
gen_radio $radio
done
for modem in $modem_names; do
if [ $first -eq 0 ]; then
echo -n ','
fi
first=0;
gen_modem "$modem"
done
cat <<EOF
]}
}
+123
View File
@@ -0,0 +1,123 @@
#!/bin/sh
# Migrate infix-modem:modems config to ietf-hardware/ietf-interfaces.
#
# Old model (infix-modem:modems/modem[index]):
# enabled, sim, pin, puk, carrier, preferred-mode, allowed-mode[],
# band[], location{}, bearer[]{apn, ip-type, allow-roaming, username, password}
#
# New model:
# ietf-hardware:hardware/component[modemN] -- class infix-hardware:modem
# ietf-hardware:hardware/component[simN] -- class infix-hardware:sim
# ietf-interfaces:interfaces/interface[wwanN] -- type infix-if-type:modem
#
# Dropped (no equivalent in new model):
# bearer.apn-type, bearer.firewall-enabled, bearer.dns-enabled, bearer.default-route
#
# Credentials: plaintext password is moved to the keystore (base64-encoded)
# and referenced by name from bearer.authentication.password.
file=$1
temp=${file}.tmp
jq '
if ."infix-modem:modems" then
."infix-modem:modems".modem as $modems |
# Flat list of all bearers across all modems, in order, with a global wwan index.
([ $modems[] | . as $m | (.bearer // [])[] | {modem: $m, bearer: .} ]
| to_entries
| map(.value + {wwan_index: .key})
) as $bearer_list |
# Hardware components for each modem (modemN).
($modems | map(. as $m |
{
"name": ("modem" + ($m.index | tostring)),
"class": "infix-hardware:modem",
"state": {
"admin-state": (if ($m.enabled // false) then "unlocked" else "locked" end)
}
} + (
(if $m["preferred-mode"] then {"preferred-mode": $m["preferred-mode"]} else {} end) +
(if (($m["allowed-mode"] // []) | length) > 0
then {"allowed-mode": [$m["allowed-mode"][].mode]}
else {} end) +
(if (($m.band // []) | length) > 0
then {"band": [$m.band[].band]}
else {} end) +
(if $m.location then
{"location": (
{"enabled": ($m.location.enabled // false)} +
(if (($m.location.source // []) | length) > 0
then {"source": [$m.location.source[].source]}
else {} end)
)}
else {} end)
| if . == {} then {} else {"infix-hardware:modem": .} end)
)) as $modem_comps |
# Hardware components for each SIM (simN, keyed by modem.sim index).
($modems | map(. as $m |
select($m.sim != null) |
{
"name": ("sim" + ($m.sim | tostring)),
"class": "infix-hardware:sim"
} + (
(if $m.pin then {"pin": $m.pin} else {} end) +
(if $m.puk then {"puk": $m.puk} else {} end) +
(if $m.carrier then {"carrier": $m.carrier} else {} end)
| if . == {} then {} else {"infix-hardware:sim": .} end)
)) as $sim_comps |
# Keystore entries for bearers that have a non-empty password.
($bearer_list | map(
select((.bearer.password // "") != "") |
{
"name": ("apn-wwan" + (.wwan_index | tostring) + "-pass"),
"key-format": "infix-crypto-types:passphrase-key-format",
"cleartext-symmetric-key": ((.bearer.password // "") | @base64)
}
)) as $ks_entries |
# wwan interfaces, one per bearer.
($bearer_list | map(. as $entry |
($entry.modem) as $m |
($entry.bearer) as $b |
("wwan" + ($entry.wwan_index | tostring)) as $wname |
("apn-wwan" + ($entry.wwan_index | tostring) + "-pass") as $kname |
{
"name": $wname,
"type": "infix-if-type:modem",
"infix-interfaces:wwan": {
"modem": ("modem" + ($m.index | tostring)),
"sim": ("sim" + ($m.sim | tostring)),
"bearer": (
(if $b.apn then {"apn": $b.apn} else {} end) +
(if $b["ip-type"] then {"ip-type": $b["ip-type"]} else {} end) +
(if ($b["allow-roaming"] // false) then {"roaming": true} else {} end) +
(if (($b.username // "") != "") then
{"authentication": {"username": $b.username, "password": $kname}}
else {} end)
)
}
}
)) as $wwan_ifaces |
# Apply: delete old tree, inject new hardware components, keystore entries, interfaces.
del(."infix-modem:modems")
| .["ietf-hardware:hardware"]["component"] = (
(.["ietf-hardware:hardware"]?.component // []) + $modem_comps + $sim_comps)
| if ($ks_entries | length) > 0 then
.["ietf-keystore:keystore"]["symmetric-keys"]["symmetric-key"] = (
(.["ietf-keystore:keystore"]?."symmetric-keys"?."symmetric-key" // []) + $ks_entries)
else . end
| .["ietf-interfaces:interfaces"]["interface"] = (
(.["ietf-interfaces:interfaces"]?.interface // []) + $wwan_ifaces)
else
.
end
' "$file" > "$temp" && mv "$temp" "$file"
+3 -1
View File
@@ -1,2 +1,4 @@
migratedir = $(pkgdatadir)/migrate/1.9
dist_migrate_DATA = 10-ethernet-advertise.sh
dist_migrate_DATA = \
10-ethernet-advertise.sh \
10-remove-infix-modem.sh
+2
View File
@@ -48,6 +48,8 @@ confd_plugin_la_SOURCES = \
if-vxlan.c \
if-wifi.c \
if-wireguard.c \
if-modem.c \
modem.c \
keystore.c \
system.c \
ntp.c \
+3 -1
View File
@@ -858,7 +858,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:
+17
View File
@@ -133,6 +133,10 @@ typedef enum {
if ((rc = register_rpc(s, x, c, a, u))) \
goto fail
#define REGISTER_NOTIF(s,m,x,c,a,u) \
if ((rc = register_notif(s, m, x, c, a, u))) \
goto fail
struct confd {
sr_session_ctx_t *session; /* running datastore */
sr_session_ctx_t *startup; /* startup datastore */
@@ -192,6 +196,17 @@ static inline int register_rpc(sr_session_ctx_t *session, const char *xpath,
return rc;
}
static inline int register_notif(sr_session_ctx_t *session, const char *module, const char *xpath,
sr_event_notif_cb cb, void *arg, sr_subscription_ctx_t **sub)
{
int rc = sr_notif_subscribe(session, module, xpath,
NULL, NULL, /* forever */
cb, arg, SR_SUBSCR_NO_THREAD, sub);
if (rc)
ERROR("failed subscribing to %s notification: %s", xpath, sr_strerror(rc));
return rc;
}
/* core.c */
int finit_enable(const char *svc);
@@ -274,4 +289,6 @@ int ntp_candidate_init(struct confd *confd);
/* ptp.c */
int ptp_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd);
int modem_init(struct confd *confd);
#endif /* CONFD_CORE_H_ */
+52 -1
View File
@@ -174,6 +174,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:
@@ -1231,10 +1241,51 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l
}
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")) {
/*
* Modem hardware component: drive modemd enable/disable based on
* admin-state. modemd in turn drives ModemManager.
*
* On DELETE or admin-state=locked: stop and disable both modemd and
* modem-manager. On admin-state=unlocked: enable and (re)start both.
*
* Why modemd controls ModemManager (not hardware.c directly): modemd
* handles the full bearer lifecycle (connect, disconnect, retry) and
* polls operational state for statd. Stopping it cleanly disconnects
* the bearer before taking ModemManager down.
*/
if (event != SR_EV_DONE)
continue;
if (op == LYDX_OP_DELETE) {
NOTE("Modem %s removed, disabling modemd", name);
systemf("initctl -bfqn stop modemd");
finit_disable("modemd");
systemf("initctl -bfqn stop modem-manager");
finit_disable("modem-manager");
continue;
}
state = lydx_get_child(cif, "state");
admin_state = lydx_get_cattr(state, "admin-state");
if (admin_state && !strcmp(admin_state, "unlocked")) {
NOTE("Modem %s enabled, starting modemd", name);
finit_enable("modem-manager");
finit_enable("modemd");
systemf("initctl -bfqn restart modem-manager");
systemf("initctl -bfqn restart modemd");
} else {
NOTE("Modem %s locked, disabling modemd", name);
systemf("initctl -bfqn stop modemd");
finit_disable("modemd");
systemf("initctl -bfqn stop modem-manager");
finit_disable("modem-manager");
}
} else if (!strcmp(class, "infix-hardware:wifi")) {
struct lyd_node *interfaces_config, *interfaces_diff;
struct lyd_node **wifi_iface_list = NULL;
+112
View File
@@ -0,0 +1,112 @@
/* SPDX-License-Identifier: BSD-3-Clause */
#include <srx/lyx.h>
#include <srx/srx_val.h>
#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/<ifname>.conf route file so that netd/FRR
* withdraws the default route. Then delete the interface — either the real
* wwan one or the dummy placeholder.
*
* Address management is handled by modemd (analogous to the udhcpc script for
* DHCP). modemd uses 'ip addr add ... proto wwan' so that protocol-tagged flush
* on disconnect is precise and does not touch addresses added by other means.
*
* Routes are written by modemd to /etc/net.d/<ifname>.conf, picked up by netd
* via inotify, and installed by staticd/zebra. This keeps all routes visible to
* FRR and enables proper metric-based failover behind wired/WiFi interfaces.
*/
static int modem_get_probe_timeout(sr_session_ctx_t *session, const char *modem)
{
char *str;
int timeout = 0;
str = srx_get_str(session,
"/ietf-hardware:hardware/component[name='%s']/infix-hardware:modem/probe-timeout",
modem);
if (str) {
timeout = atoi(str);
free(str);
}
return timeout;
}
int modem_add_iface(struct lyd_node *cif, struct dagger *net)
{
const char *ifname = lydx_get_cattr(cif, "name");
struct lyd_node *wwan = lydx_get_child(cif, "wwan");
const char *modem_hw = wwan ? lydx_get_cattr(wwan, "modem") : NULL;
int probe_timeout = 0;
FILE *sh;
if (modem_hw)
probe_timeout = modem_get_probe_timeout(net->session, modem_hw);
sh = dagger_fopen_net_init(net, ifname, NETDAG_INIT_PRE, "modem-iface.sh");
if (!sh) {
ERRNO("Failed to open dagger file for modem interface creation");
return SR_ERR_INTERNAL;
}
/* Wait for wwan interface if probe-timeout is set (slow USB modems) */
if (probe_timeout > 0) {
fprintf(sh, "timeout=%d\n", probe_timeout);
fprintf(sh, "while [ $timeout -gt 0 ]; do\n");
fprintf(sh, " ip link show %s >/dev/null 2>&1 && break\n", ifname);
fprintf(sh, " sleep 1\n");
fprintf(sh, " timeout=$((timeout - 1))\n");
fprintf(sh, "done\n");
}
/* If interface doesn't exist, create a dummy placeholder so downstream IP config works.
* modemd will reconfigure the real interface once the modem attaches. */
fprintf(sh, "if ! ip link show %s >/dev/null 2>&1; then\n", ifname);
fprintf(sh, " logger -t modem \"%s: interface not yet available, creating dummy placeholder\"\n", ifname);
fprintf(sh, " ip link add %s type dummy\n", ifname);
fprintf(sh, "fi\n\n");
/* Signal modemd to pick up any updated bearer config. */
fprintf(sh, "initctl -bfq touch modemd 2>/dev/null || true\n");
fclose(sh);
return SR_ERR_OK;
}
int modem_del_iface(struct lyd_node *dif, struct dagger *net)
{
const char *ifname = lydx_get_cattr(dif, "name");
FILE *sh;
sh = dagger_fopen_net_exit(net, ifname, NETDAG_EXIT_POST, "modem-iface.sh");
if (!sh) {
ERRNO("Failed to open dagger file for modem interface deletion");
return SR_ERR_INTERNAL;
}
fprintf(sh, "# Generated by Infix confd - Modem Interface Cleanup\n\n");
/* Remove route config so netd/FRR withdraws the default route */
fprintf(sh, "rm -f /etc/net.d/%s.conf\n", ifname);
/* Bring down and remove — works for both real wwan and dummy placeholder */
fprintf(sh, "ip link set %s down 2>/dev/null || true\n", ifname);
fprintf(sh, "ip link del %s 2>/dev/null || true\n", ifname);
fclose(sh);
return SR_ERR_OK;
}
+14 -2
View File
@@ -80,6 +80,8 @@ static int ifchange_cand_infer_type(sr_session_ctx_t *session, const char *path)
if (!fnmatch("wifi+([0-9])*", ifname, FNM_EXTMATCH))
inferred.data.string_val = "infix-if-type:wifi";
else if (!fnmatch("wwan+([0-9-])", ifname, FNM_EXTMATCH))
inferred.data.string_val = "infix-if-type:modem";
else if (iface_is_phys(ifname))
inferred.data.string_val = "infix-if-type:ethernet";
else if (!fnmatch("br+([0-9])", ifname, FNM_EXTMATCH))
@@ -390,6 +392,8 @@ static int netdag_gen_afspec_add(sr_session_ctx_t *session, struct dagger *net,
return vlan_gen(NULL, cif, ip);
case IFT_VXLAN:
return vxlan_gen(NULL, cif, ip);
case IFT_MODEM:
return modem_add_iface(cif, net);
case IFT_WIFI:
return wifi_validate_secret(session, cif)
? : wifi_add_iface(cif, net);
@@ -434,6 +438,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:
@@ -474,6 +479,8 @@ static bool netdag_must_del(struct lyd_node *dif, struct lyd_node *cif)
return lydx_get_descendant(lyd_child(dif), "veth", NULL);
case IFT_VXLAN:
return lydx_get_descendant(lyd_child(dif), "vxlan", NULL);
case IFT_MODEM:
return false;
case IFT_WIREGUARD:
return lydx_get_descendant(lyd_child(dif), "wireguard", NULL);
case IFT_UNKNOWN:
@@ -551,6 +558,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;
@@ -693,8 +703,9 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net
fprintf(ip, "link set alias \"%s\" dev %s\n", attr ?: "", ifname);
/* Bring interface back up, if enabled */
if (lydx_is_enabled(cif, "enabled"))
fprintf(ip, "link set dev %s up state up\n", ifname);
if (strcmp(iftype, "infix-if-type:modem") != 0)
if (lydx_is_enabled(cif, "enabled"))
fprintf(ip, "link set dev %s up state up\n", ifname);
err = err ? : netdag_gen_sysctl(net, cif, dif);
@@ -724,6 +735,7 @@ static int netdag_init_iface(struct lyd_node *cif)
return vlan_add_deps(cif);
case IFT_VETH:
return veth_add_deps(cif);
case IFT_MODEM:
case IFT_WIFI:
case IFT_DUMMY:
case IFT_ETH:
+5
View File
@@ -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") \
@@ -167,6 +168,10 @@ int ifchange_cand_infer_dhcp(sr_session_ctx_t *session, const char *path);
/* if-vxlan.c */
int vxlan_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip);
/* if-modem.c */
int modem_add_iface(struct lyd_node *cif, struct dagger *net);
int modem_del_iface(struct lyd_node *dif, struct dagger *net);
/* infix-if-wireguard */
int wireguard_validate_peers(sr_session_ctx_t *session, struct lyd_node *cif);
int wireguard_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip, struct dagger *net);
+4 -2
View File
@@ -656,9 +656,10 @@ int main(int argc, char **argv)
log_level = LOG_WARNING;
else if (!strcmp(optarg, "info"))
log_level = LOG_NOTICE;
else if (!strcmp(optarg, "debug"))
else if (!strcmp(optarg, "debug")) {
log_level = LOG_DEBUG;
else {
debug = 1;
} else {
fprintf(stderr, "confd error: Invalid verbosity \"%s\"\n", optarg);
return EXIT_FAILURE;
}
@@ -696,6 +697,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);
+206
View File
@@ -0,0 +1,206 @@
/* SPDX-License-Identifier: BSD-3-Clause */
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <regex.h>
#include <srx/common.h>
#include <srx/lyx.h>
#include <srx/srx_val.h>
#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(&regex, "name='([^']*)'", REG_EXTENDED))
return -1;
if (regexec(&regex, 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(&regex);
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;
}
+2 -2
View File
@@ -27,10 +27,10 @@ MODULES=(
"infix-syslog@2025-11-17.yang"
"iana-hardware@2018-03-13.yang"
"ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor"
"infix-hardware@2026-03-01.yang"
"infix-hardware@2026-05-10.yang"
"ieee802-dot1q-types@2022-10-29.yang"
"infix-ip@2026-04-28.yang"
"infix-if-type@2026-01-07.yang"
"infix-if-type@2026-04-27.yang"
"infix-routing@2026-03-11.yang"
"ieee802-dot1ab-lldp@2022-03-15.yang"
"infix-lldp@2025-05-05.yang"
+812 -1
View File
@@ -12,7 +12,9 @@ module infix-hardware {
import ietf-yang-types {
prefix yang;
}
import ietf-netconf-acm {
prefix nacm;
}
import infix-wifi-country-codes {
prefix iwcc;
}
@@ -21,6 +23,31 @@ module infix-hardware {
contact "kernelkit@googlegroups.com";
description "Vital Product Data augmentation of ieee-hardware and deviations.";
revision 2026-05-10 {
description "Add modem-state/last-change and modem-state/location-state operational
nodes so cellular modem location and state freshness are accessible over
NETCONF/RESTCONF, not only via the CLI.";
reference "internal";
}
revision 2026-05-08 {
description "Move modem actions (restart/reset/send-sms) from container modem to a
direct augment on component with when/if-feature guard, fixing sysrepo
NP-container parent validation for nested YANG actions.";
reference "internal";
}
revision 2026-05-03 {
description "Add cellular modem and SIM hardware config containers.
Add modem typedefs (modem-access-mode, modem-band, modem-location-source).
Add actions restart/reset/send-sms and status-update notification on modem.
Add sim container with pin, puk, carrier.
Add sim-state operational container with slot, lock state, operator-name.";
reference "internal";
}
revision 2026-04-27 {
description "Add cellular modem and SIM hardware class identities.
Add modem-state operational augment for statd.";
reference "internal";
}
revision 2026-03-01 {
description "Add channel-width leaf for WiFi radio container.";
reference "internal";
@@ -58,6 +85,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;
@@ -133,6 +164,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
*/
@@ -157,6 +485,26 @@ module infix-hardware {
description "GPS/GNSS receiver for time synchronization";
}
identity modem {
if-feature modem;
base iahw:hardware-class;
description "Cellular modem hardware component.
One modem0 component per physical modem module.
Auto-discovered by 00-probe from /sys/class/usbmisc/cdc-wdm*
and written to /run/system.json['modem']; gen-hardware emits
the corresponding ietf-hardware component entries.";
}
identity sim {
if-feature modem;
base iahw:hardware-class;
description "SIM/UICC card hardware component.
One sim0 component per SIM slot on hardware that exposes
SIM multiplexing via /sys/class/sim/ (e.g. Minex modules).
On hardware without a SIM multiplexer, a synthetic sim0 is
inferred from the modem's active SIM.";
}
deviation "/iehw:hardware/iehw:component/iehw:state/iehw:admin-state" {
deviate add {
must ". = 'locked' or . = 'unlocked'" {
@@ -604,6 +952,424 @@ module infix-hardware {
}
}
/*
* Modem hardware configuration (when class = 'ih:modem')
*/
container modem {
if-feature modem;
when "derived-from-or-self(../iehw:class, 'ih:modem')";
presence "Modem hardware configuration";
description
"Cellular modem hardware configuration.
Controls radio access technology, frequency bands, and location
services. The modem is enabled or disabled via the hardware
component admin-state (unlocked = enabled, locked = disabled).
Actions restart and reset operate on the modem hardware directly.
send-sms uses the modem's SMS capability independent of any data
bearer or APN — SMS travels over the signalling plane, not the
data bearer.";
leaf preferred-mode {
type modem-access-mode;
description
"Preferred radio access technology. The modem will attempt to
use this technology first; falls back to allowed modes if not
available. Omit (or set to 'any') for automatic selection.";
}
leaf-list allowed-mode {
type modem-access-mode;
description "Restrict the modem to these radio access technologies.
Empty list means all technologies are allowed.";
}
leaf-list band {
type modem-band;
description
"Restrict the modem to these frequency bands. An empty list
(or a list containing only 'any') means all bands supported
by the hardware are allowed.";
}
container location {
description "Modem location/GPS subsystem configuration.";
leaf enabled {
type boolean;
default false;
description "Enable location gathering via the modem.";
}
leaf-list source {
type modem-location-source;
description "Location sources to enable.";
}
}
leaf probe-timeout {
type uint8;
default 30;
description
"Seconds to wait for the wwan interface to appear at boot.
USB modems may be slow to initialize due to firmware loading
and USB enumeration. The default of 30 seconds covers most
USB modems; the interface typically appears within 2-5 seconds.
If the interface is not detected within the timeout, a dummy
placeholder is created so downstream configuration succeeds.
modemd will reconfigure the real interface when it attaches.
Set to 0 to disable waiting and create the dummy immediately.";
}
notification status-update {
description "Emitted by modemd when the modem state changes.";
leaf desc {
type string;
mandatory true;
description "Human-readable description of the state change.";
}
}
}
/*
* Modem operational state (when class = 'ih:modem')
* Populated by statd/infix_modem.py from /run/modemd/modemN/ runtime files.
*/
container modem-state {
if-feature modem;
when "derived-from-or-self(../iehw:class, 'ih:modem')";
config false;
description
"Cellular modem operational state.
Written by statd/infix_modem.py which reads modemd runtime files
from /run/modemd/modem0/. The split is intentional: modemd drives
ModemManager and writes runtime files; statd reads them on demand
and populates sysrepo operational nodes. modemd never touches
sysrepo operational nodes directly.";
leaf manufacturer {
type string;
description "Modem manufacturer (e.g. 'Quectel', 'Sierra Wireless').";
}
leaf model {
type string;
description "Modem model (e.g. 'EM05', 'EM7455').";
}
leaf firmware-version {
type string;
description "Firmware revision reported by the modem.";
}
leaf serial-number {
type string;
description "IMEI serial number.";
}
leaf imsi {
type string;
description "IMSI from the active SIM card.";
}
leaf iccid {
type string;
description "ICCID from the active SIM card.";
}
leaf state {
type modem-state;
description "Cellular modem state (see modem-state typedef).";
}
leaf state-failed-reason {
type modem-state-failed-reason;
description
"Detailed reason for the failed state. Only meaningful when
'state' is 'failed'; absent or 'none' otherwise.";
}
leaf power-state {
type modem-power-state;
description "Modem RF power state.";
}
leaf-list enabled-locks {
type modem-3gpp-facility;
description
"3GPP facility locks currently enabled on the modem (e.g.
'fixed-dialing' for FDN-restricted SIMs that whitelist
outbound calls / SMS).";
}
leaf hardware-revision {
type string;
description "Hardware revision string reported by the modem.";
}
leaf-list phone-number {
type string;
description
"MSISDN(s) associated with the active SIM, as reported by
the modem. Typically empty unless provisioned in the SIM.";
}
leaf selected-carrier {
type string;
description
"Operator carrier profile currently selected by the modem
firmware (e.g. 'default', 'att', 'verizon').";
}
leaf-list supported-carrier {
type string;
description
"Carrier profiles supported by this modem firmware. Used
to validate the SIM-component 'carrier' configuration.";
}
leaf signal-quality {
type uint8;
units "percent";
description "Signal quality as a percentage (0-100).";
}
leaf signal-quality-recent {
type boolean;
description
"TRUE if the most recent signal-quality measurement is fresh.
FALSE indicates a cached value that may no longer reflect
current conditions.";
}
leaf signal-rssi {
type int16;
units "dBm";
description "Received Signal Strength Indicator.";
}
leaf signal-rsrp {
type int16;
units "dBm";
description "LTE Reference Signal Received Power.";
}
leaf signal-rsrq {
type decimal64 { fraction-digits 1; }
units "dB";
description "LTE Reference Signal Received Quality.";
}
leaf signal-sinr {
type decimal64 { fraction-digits 1; }
units "dB";
description "Signal-to-Interference-plus-Noise Ratio.";
}
container cellular {
description "Cellular network information.";
leaf registration-state {
type enumeration {
enum idle { description "Not registered, not searching."; }
enum home { description "Registered on home network."; }
enum searching { description "Searching for a network."; }
enum denied { description "Registration denied."; }
enum roaming { description "Registered on a roaming network."; }
enum unknown { description "Registration state unknown."; }
}
description "3GPP registration state.";
}
leaf service-state {
type modem-packet-service-state;
description
"3GPP packet-service attach state. A modem can be
registered but not packet-attached, in which case
data connections cannot be established.";
}
leaf operator-name {
type string;
description "Name of the registered cellular operator.";
}
leaf operator-id {
type string {
pattern '[0-9]{5,6}';
}
description
"Operator PLMN code: MCC (3 digits) concatenated with
MNC (2 or 3 digits).";
}
leaf network-type {
type modem-access-tech;
description "Active cellular radio access technology.";
}
}
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')
*/
@@ -726,4 +1492,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).";
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
infix-hardware.yang
+1
View File
@@ -0,0 +1 @@
infix-hardware.yang
+315
View File
@@ -0,0 +1,315 @@
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/<ifname>.conf and installed
by staticd/zebra through FRR. The default route is always installed when
the bearer is connected; route-preference controls administrative distance
so that cellular acts as a natural failover path behind wired and WiFi.";
revision 2026-05-03 {
description
"Redesign: rename container modem->wwan, device->modem, active-sim->sim.
Rebuild bearer: replace metric+default-route with route-preference (always
on), replace auth-type/username/password with authentication presence
container referencing keystore, drop dns-enabled/firewall-enabled/apn-type.";
reference "internal";
}
revision 2026-04-27 {
description "Initial revision.";
reference "internal";
}
feature modem {
description "Cellular modem support is an optional build-time feature in Infix.";
}
augment "/if:interfaces/if:interface" {
when "derived-from-or-self(if:type, 'infixift:modem')" {
description "Applies only to interfaces of type 'modem'.";
}
container wwan {
if-feature modem;
description
"Cellular modem (wwan) interface configuration.
References a modem hardware component for physical layer config
and a SIM hardware component for subscription credentials.
The bearer sub-container configures the data connection (APN, auth,
IP type, routing preference).";
leaf modem {
type leafref {
path "/iehw:hardware/iehw:component/iehw:name";
}
must "derived-from-or-self(/iehw:hardware/iehw:component[iehw:name=current()]/iehw:class, 'ih:modem')" {
error-message "Referenced hardware component must be a cellular modem (class 'ih:modem').";
}
description
"Reference to the modem hardware component (e.g. modem0).
The hardware component carries physical-layer configuration
(bands, modes, location) and is auto-discovered at boot.
Its admin-state controls whether the modem is powered
(unlocked = enabled, locked = disabled).";
}
leaf sim {
type leafref {
path "/iehw:hardware/iehw:component/iehw:name";
}
must "derived-from-or-self(/iehw:hardware/iehw:component[iehw:name=current()]/iehw:class, 'ih:sim')" {
error-message "Referenced hardware component must be a SIM card (class 'ih:sim').";
}
description
"Reference to the SIM hardware component (e.g. sim0).
The SIM component carries PIN/PUK credentials and the carrier
profile. On hardware with a SIM multiplexer, changing this
reference switches the active SIM slot.";
}
container bearer {
description
"Data bearer (connection) configuration.
These settings are passed to ModemManager by modemd to establish
the cellular data connection. The APN is mandatory; authentication
is only needed when the operator requires it.
When the bearer connects, modemd writes a default route to
/etc/net.d/<ifname>.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/<ifname>.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.";
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
infix-if-modem.yang
+17
View File
@@ -11,6 +11,10 @@ module infix-if-type {
contact "kernelkit@googlegroups.com";
description "Infix extensions to IANA interfaces types";
revision 2026-04-27 {
description "Add interface type modem for cellular wwan interfaces.";
reference "internal";
}
revision 2026-01-07 {
description "Add interface type wifi and wireguard";
reference "internal";
@@ -47,6 +51,10 @@ module infix-if-type {
* Features
*/
feature modem {
description "Cellular modem support is an optional build-time feature in Infix.";
}
feature wifi {
description "WiFi support is an optional build-time feature in Infix.";
}
@@ -121,6 +129,15 @@ module infix-if-type {
base ianaift:l2vlan;
description "Layer 2 Virtual LAN using 802.1Q.";
}
identity modem {
if-feature modem;
base infix-interface-type;
description "Cellular modem (wwan) network interface.
Created by the kernel (cdc_mbim, qmi_wwan) when a USB modem
attaches. Interface names follow the kernel wwan subsystem
naming convention: wwan[0-9]+.";
}
identity wifi {
if-feature wifi;
base infix-interface-type;
+1
View File
@@ -0,0 +1 @@
infix-if-type.yang
@@ -36,6 +36,7 @@ module infix-interfaces {
include infix-if-wifi;
include infix-if-wireguard;
include infix-if-ptp;
include infix-if-modem;
organization "KernelKit";
contact "kernelkit@googlegroups.com";
@@ -60,6 +61,11 @@ module infix-interfaces {
reference "internal";
}
revision 2026-04-27 {
description "Include infix-if-modem submodule for cellular wwan interfaces.";
reference "internal";
}
revision 2026-04-09 {
description "Add ptp-capabilities submodule for per-interface PTP timestamping info.";
reference "internal";
@@ -0,0 +1 @@
infix-interfaces.yang
+5
View File
@@ -0,0 +1,5 @@
MODULES=(
"infix-hardware -e modem"
"infix-if-type -e modem"
"infix-interfaces -e modem"
)
+42
View File
@@ -113,6 +113,15 @@
<ACTION sym="STRING"/>
</PTYPE>
<PTYPE name="MODEMS">
<COMPL>
<ACTION sym="script">
jq -r '.modem[]? | "modem" + (.index|tostring)' /run/system.json 2>/dev/null
</ACTION>
</COMPL>
<ACTION sym="STRING"/>
</PTYPE>
<PTYPE name="FIREWALL_ZONES">
<COMPL>
<ACTION sym="firewall_zones@infix"/>
@@ -335,6 +344,30 @@ echo "Public: $pub"
</COMMAND>
</COMMAND>
<COMMAND name="modem" help="Cellular modem management" mode="switch">
<COMMAND name="restart" help="Restart modem connection (disconnect and reconnect bearers)">
<PARAM name="ref" ptype="/MODEMS" help="Modem name (e.g. modem0)"/>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
rpc "/ietf-hardware:hardware/component[name='$KLISH_PARAM_ref']/infix-hardware:restart"
</ACTION>
</COMMAND>
<COMMAND name="reset" help="Factory-reset modem firmware to default state">
<PARAM name="ref" ptype="/MODEMS" help="Modem name (e.g. modem0)"/>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
rpc "/ietf-hardware:hardware/component[name='$KLISH_PARAM_ref']/infix-hardware:reset"
</ACTION>
</COMMAND>
<COMMAND name="sms" help="Send an SMS message via the modem">
<PARAM name="ref" ptype="/MODEMS" help="Modem name (e.g. modem0)"/>
<PARAM name="number" ptype="/STRING" help="Recipient phone number (e.g. +46701234567)"/>
<PARAM name="message" ptype="/STRING" help="SMS message text (max 160 characters)"/>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
rpc "/ietf-hardware:hardware/component[name='$KLISH_PARAM_ref']/infix-hardware:send-sms" \
phone-number "$KLISH_PARAM_number" message-text "$KLISH_PARAM_message"
</ACTION>
</COMMAND>
</COMMAND>
<COMMAND name="show" help="Show system status and configuration files" mode="switch">
<COMMAND name="bridge" help="Show bridge (ports/fdb/mdb/vlans)">
@@ -573,6 +606,15 @@ echo "Public: $pub"
</ACTION>
</COMMAND>
<COMMAND name="modem" help="Show cellular modem status">
<SWITCH name="optional" min="0" max="1">
<PARAM name="ref" ptype="/MODEMS" help="Modem name (e.g. modem0)" />
</SWITCH>
<ACTION sym="script" in="tty" out="tty" interrupt="true">
show modem "$KLISH_PARAM_ref" |pager
</ACTION>
</COMMAND>
<!-- https://www.cisco.com/c/en/us/td/docs/wireless/access_point/mob_exp/83/cmd-ref/me_cr_book/me_ports_and_interfaces_cli.html -->
<COMMAND name="interface" help="Show interface info, default an overview of all">
<SWITCH name="optional" min="0" max="1">
+39
View File
@@ -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"
+41
View File
@@ -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"
+27
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
service [2345] name:modemd /sbin/modemd -- Modem daemon
+617
View File
@@ -0,0 +1,617 @@
/* SPDX-License-Identifier: BSD-3-Clause */
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <unistd.h>
#include <termios.h>
#include <getopt.h>
#include <fcntl.h>
#include <syslog.h>
#include <sys/stat.h>
#include <sys/file.h>
#include <time.h>
#include <errno.h>
#include <jansson.h>
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, "<CR>");
} else if (*p == '\n') {
len += snprintf(quoted + len, sizeof(quoted) - len, "<NL>");
} 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 <idx> Index of modem\n");
fprintf(stderr, " --device <tty> TTY device of modem\n");
fprintf(stderr, " --mode <mode> Serial mode for TTY (e.g. '115200 8N1')\n");
fprintf(stderr, " --expect <str> String to expect\n");
fprintf(stderr, " --timeout <to> 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;
}
+2
View File
@@ -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).
+8
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+316
View File
@@ -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()
+549
View File
@@ -0,0 +1,549 @@
import subprocess
import argparse
import syslog
import json
import sys
import os
import re
debug = False
syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_SYSLOG)
def dbg(msg):
global debug
if debug:
syslog.syslog(syslog.LOG_INFO, msg)
def runcmd(cmd):
ret = None
try:
res = subprocess.run(cmd, check=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
if res.returncode == 0:
if res.stdout:
ret = res.stdout.strip()
else:
ret = True
except subprocess.CalledProcessError:
return None
finally:
return ret
def runcmdj(cmd):
output = runcmd(cmd)
if not output:
return None
try:
data = json.loads(output)
except json.JSONDecodeError:
return None
finally:
return data
def vbool(val):
if val == "yes" or val == "true":
return True
else:
return False
def vnum(val):
if val != "--":
return int(val)
else:
return 0
def vnumstr(val):
if val != "" and val != "--":
return val
else:
return "0"
def vstr(val):
if val != "--":
return val
else:
return ""
def venum(val, enums, default):
enval = vstr(val)
for en in enums:
if enval == en:
return en
return default
def vip4(val):
val = vstr(val)
if val == "":
return "0.0.0.0"
else:
return val
def vip6(val):
val = vstr(val)
if val == "":
return "::"
else:
return val
def fread(path):
if os.path.exists(path):
with open(path, "r") as fd:
output = str(fd.read())
if output:
return output.strip()
return None
def freadj(path):
output = fread(path)
if not output:
return None
try:
data = json.loads(output)
except json.JSONDecodeError:
dbg("Unable to parse json output")
return None
finally:
return data
def get_manufacturer(index, gen):
path = "/run/modemd/modem%d/manufacturer" % index
manf = fread(path)
if manf:
return manf
manf = vstr(gen.get("manufacturer", ""))
return manf
def get_model(index, gen):
path = "/run/modemd/modem%d/model" % index
model = fread(path)
if model:
return model
model = vstr(gen.get("model", ""))
return model
def get_supported_carriers(index, manf, model):
path = "/run/modemd/modem%d/supported-carriers" % index
output = freadj(path)
if output:
return output
else:
return []
def get_current_carrier(index, manf, model):
path = "/run/modemd/modem%d/current-carrier" % index
output = freadj(path)
if output:
return output
else:
return "default"
def _sim_lock_state(unlock_required):
mapping = {
"none": "unlocked",
"sim-pin": "pin-required",
"sim-pin2": "pin-required",
"sim-puk": "puk-required",
"sim-puk2": "puk-required",
}
# Default to "unlocked": this function is only called when the SIM query
# succeeded, so the card is present. "--" means the modem doesn't report
# lock state but the SIM is accessible.
return mapping.get(unlock_required, "unlocked")
def device_devpath(devpath):
path = devpath
while path != "/sys":
path = os.path.realpath("%s/.." % path)
subdir = os.path.basename(path)
r = re.search(r"usb\d", subdir)
if r:
return path
return None
def modem_get_module(devpath):
# Prefer system.json (new canonical source written by 00-probe)
if os.path.exists("/run/system.json"):
with open("/run/system.json", "r") as fd:
data = json.load(fd)
for modem in data.get("modem", []):
if devpath in modem.get("devpath", ""):
idx = modem.get("index", 0)
return {"index": idx, "slot": idx,
"paths": [modem["devpath"]], "type": "modem"}
# Fallback: Minex modules.json (hardware with slot/SIM multiplexer)
if os.path.exists("/run/modules.json"):
with open("/run/modules.json", "r") as fd:
data = json.load(fd)
for module in data.get("modules", []):
if module["type"] != "modem":
continue
for path in module.get("paths", []):
if path in devpath:
return module
return None
# Fallback: legacy modems.json written by modem-udev
if os.path.exists("/run/modems.json"):
with open("/run/modems.json", "r") as fd:
data = json.load(fd)
for index, modem in enumerate(data.get("modems", [])):
if devpath in modem.get("devpath", ""):
return {"index": index, "slot": index,
"paths": [modem["devpath"]], "type": "modem"}
return None
def modem_get_sim(module):
path = "/sys/class/sim"
for index in range(0, 5):
name = "sim%d" % index
if not os.path.exists("%s/%s" % (path, name)):
continue
slot = int(fread("%s/%s/slot" % (path, name)))
if slot != module["slot"]:
continue
present = int(fread("%s/%s/present" % (path, name)))
return {
"index": index,
"name": name,
"slot": slot,
"present": bool(present)
}
return None
def sysfs_interfaces(devpath):
ifaces = []
try:
for iface in os.listdir("/sys/class/net"):
p = os.path.realpath("/sys/class/net/%s" % iface)
if p.startswith(devpath):
ifaces.append(iface)
except OSError:
pass
return ifaces
def print_modem(index):
# Try system.json first (canonical source written by 00-probe)
if os.path.exists("/run/system.json"):
with open("/run/system.json", "r") as fd:
data = json.load(fd)
for modem in data.get("modem", []):
if modem.get("index") == index:
module = {"index": index, "slot": index,
"paths": [modem["devpath"]], "type": "modem"}
modem["sim"] = modem_get_sim(module) or \
{"index": 0, "name": "sim0", "slot": 0, "present": True}
if not modem.get("interfaces"):
modem["interfaces"] = sysfs_interfaces(modem["devpath"])
print(json.dumps(modem))
sys.exit(0)
# Fallback: legacy modems.json written by modem-udev
if os.path.exists("/run/modems.json"):
with open("/run/modems.json", "r") as fd:
data = json.load(fd)
for modem in data.get("modems", []):
module = modem_get_module(modem["devpath"])
if module and module["index"] == index:
modem["slot"] = module["slot"]
modem["sim"] = modem_get_sim(module) or \
{"index": 0, "name": "sim0", "slot": 0, "present": True}
if not modem.get("interfaces"):
modem["interfaces"] = sysfs_interfaces(modem["devpath"])
print(json.dumps(modem))
sys.exit(0)
print(json.dumps({}))
sys.exit(0)
def print_all():
modems = []
output = runcmdj(['mmcli', '-J', '-L'])
if not output:
dbg("mmcli command failed")
print(json.dumps(modems))
sys.exit(0)
for mpath in output['modem-list']:
output = runcmdj(['mmcli', '-J', '-m', mpath])
if not output:
dbg("no output for %s" % mpath)
continue
modem = output.get("modem")
if modem is None:
dbg("no modem for %s" % mpath)
continue
gen = modem.get("generic")
if gen is None:
dbg("no generic for %s" % mpath)
continue
gpp = modem.get("3gpp")
if gpp is None:
dbg("no 3gpp for %s" % mpath)
continue
devpath = device_devpath(gen["device"])
if devpath is None:
dbg("no devpath for %s" % mpath)
continue
module = modem_get_module(devpath)
if module is None:
dbg("no modem module for %s" % devpath)
continue
index = module["index"]
manf = get_manufacturer(index, gen)
if manf is None:
dbg("no manufacturer for modem%d" % index)
continue
model = get_model(index, gen)
if model is None:
dbg("no model for modem%d" % index)
continue
modem = {
"index": index,
"path": mpath
}
modem["info"] = {
"manufacturer": manf,
"model": model,
"supported-carrier": get_supported_carriers(index, manf, model),
"hardware-revision": vstr(gen["hardware-revision"]),
"firmware-version": vstr(gen["revision"]),
"serial-number": vstr(gen["equipment-identifier"]),
"phone-number": gen.get("own-numbers", []),
}
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")
args = parser.parse_args()
with open("/proc/cmdline", 'r') as fd:
cmdline = fd.read()
if "debug" in cmdline:
debug = True
if args.index:
print_modem(int(args.index))
else:
print_all()
if __name__ == "__main__":
main()
+88
View File
@@ -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()
+52
View File
@@ -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()
+82
View File
@@ -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()
+267
View File
@@ -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()
+144
View File
@@ -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()
+319
View File
@@ -0,0 +1,319 @@
import shutil
import errno
import time
import json
import fcntl
import sys
import re
import os
MODEMS = "/run/modems.json"
SYSTEM = "/run/system.json"
LOCK = "/var/lock/modems.lock"
LOGFILE = "/run/modemd/modem-udev.log"
debug = False
def log(msg):
ts = '{:010.3f}'.format(time.monotonic())
with open(LOGFILE, 'a') as fd:
fd.write("[%s] modem-udev (pid %d) %s\n" % (ts, os.getpid(), msg))
def dbg(msg):
global debug
if debug:
log(msg)
def info(msg):
log(msg)
def err(msg):
log("Error: %s" % msg)
def lock():
start = time.time()
while True:
elapsed = time.time() - start
if elapsed > 10:
err("Timeout waiting for lock")
break
try:
fd = open(LOCK, "a")
fcntl.flock(fd.fileno(), fcntl.LOCK_EX)
dbg("Acquired lock")
return fd
except IOError as e:
dbg("Unable to lock (%d)" % e.errno)
if e.errno != errno.EAGAIN:
err("Unable to lock")
return None
else:
time.sleep(1)
return None
def unlock(fd):
try:
fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
fd.close()
dbg("Released lock")
return True
except IOError as e:
err("Unlock failed with %d" % e.errno)
return False
def mkdir(path):
if os.path.isdir(path):
return True
try:
os.mkdir(path, mode=0o755)
shutil.chown(path, user="root", group="wheel")
except OSError:
err("Unable to mkdir %s" % path)
return False
finally:
return True
def read_modem_data():
data = {}
if os.path.exists(MODEMS):
with open(MODEMS, "r") as fd:
data = json.load(fd)
return data
def write_modem_data(data):
with open(MODEMS + ".bak", 'w') as fd:
json.dump(data, fd)
os.rename(MODEMS + ".bak", MODEMS)
return True
def _sysfs_attr(devpath, name):
try:
with open("%s/%s" % (devpath, name), "r") as fd:
return fd.readline().strip()
except OSError:
return None
def update_system_json(modems_list):
data = {}
try:
with open(SYSTEM, "r") as fd:
data = json.load(fd)
except (IOError, json.JSONDecodeError):
pass
entries = []
for i, m in enumerate(modems_list):
entries.append({
"index": i,
"name": "modem%d" % i,
"devpath": m.get("devpath", ""),
"vid": m.get("vendor", ""),
"pid": m.get("product", ""),
})
if not entries:
data.pop("modem", None)
else:
data["modem"] = entries
tmp = SYSTEM + ".tmp"
with open(tmp, "w") as fd:
json.dump(data, fd, indent=2)
os.rename(tmp, SYSTEM)
info("Updated system.json with %d modem(s)" % len(entries))
def update(d):
modem = None
devpath = d.get("devpath")
data = read_modem_data()
if "modems" not in data:
data = {"modems": []}
else:
for m in data["modems"]:
if devpath == m.get("devpath"):
data["modems"].remove(m)
modem = m
break
if not modem:
modem = {"devpath": devpath}
vendor = _sysfs_attr(devpath, "idVendor")
if vendor:
modem["vendor"] = vendor
product = _sysfs_attr(devpath, "idProduct")
if product:
modem["product"] = product
atport = d.get("atport")
atports = modem.get("atports", [])
if atport and atport not in atports:
ptype = d.get("ptype", "default")
if ptype == "primary":
atports.insert(0, atport)
else:
atports.append(atport)
modem["atports"] = atports
qmiport = d.get("qmiport")
qmiports = modem.get("qmiports", [])
if qmiport and qmiport not in qmiports:
qmiports.append(qmiport)
modem["qmiports"] = qmiports
iface = d.get("iface")
ifaces = modem.get("interfaces", [])
if iface and iface not in ifaces:
ifaces.append(iface)
modem["interfaces"] = ifaces
data["modems"].append(modem)
write_modem_data(data)
update_system_json(data["modems"])
info("Updated modem %s" % devpath)
def add_tty(devpath):
devpath = os.path.realpath("/sys/%s/../../../../" % devpath)
info("Adding tty device %s" % devpath)
atport = os.getenv("DEVNAME")
if not atport:
err("No DEVNAME")
return False
if os.getenv("ID_MM_PORT_TYPE_AT_PRIMARY"):
ptype = "primary"
elif os.getenv("ID_MM_PORT_TYPE_AT_SECONDARY"):
ptype = "secondary"
else:
ptype = "default"
info("Adding %s atport %s for %s" % (ptype, atport, devpath))
update({"devpath": devpath, "atport": atport, "ptype": ptype})
def add_net(devpath):
devpath = os.path.realpath("/sys/%s/../../../" % devpath)
info("Adding net device %s" % devpath)
iface = os.getenv("INTERFACE", None)
if not iface:
err("No INTERFACE")
return False
r = re.search(r"wwan(\d+)\.(\d+)", iface)
if r and r.group(2):
info("Skipping interface %s" % iface)
return False
info("Adding interface %s for %s" % (iface, devpath))
update({"devpath": devpath, "iface": iface})
try:
with open("/sys/class/net/%s/qmi/raw_ip" % iface, "w") as fd:
fd.write("Y\n")
except (IOError, OSError):
err("Unable to set raw-ip")
return False
info("Interface %s has been set to rawip" % iface)
def add_usbmisc(devpath):
devpath = os.path.realpath("/sys/%s/../../../" % devpath)
info("Adding usbmisc device %s" % devpath)
qmiport = os.getenv("DEVNAME", None)
if not qmiport:
err("No DEVNAME")
return False
info("Adding qmiport %s for %s" % (qmiport, devpath))
update({"devpath": devpath, "qmiport": qmiport})
def apply():
subsystem = os.getenv("SUBSYSTEM")
if not subsystem:
err("No SUBSYSTEM")
return False
devpath = os.getenv("DEVPATH")
if not devpath:
err("No DEVPATH")
return False
if "usb" not in devpath:
info("Skipping %s" % devpath)
return False
if subsystem == "net":
add_net(devpath)
elif subsystem == "tty":
add_tty(devpath)
elif subsystem == "usbmisc":
add_usbmisc(devpath)
else:
err("Subsystem %s unhandled" % subsystem)
return False
return True
def main():
global debug
with open("/proc/cmdline", 'r') as fd:
cmdline = fd.read()
if "debug" in cmdline:
debug = True
mkdir("/run/modemd")
dbg("started")
if debug:
for i, j in os.environ.items():
dbg("ENV: %s=%s" % (i, j))
else:
if os.path.exists(LOGFILE):
os.remove(LOGFILE)
fd = lock()
if fd is None:
sys.exit(2)
if apply():
rc = 0
else:
err("Unable to add modem device")
rc = 1
unlock(fd)
dbg("ended")
sys.exit(rc)
if __name__ == "__main__":
main()
+392
View File
@@ -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()
+75
View File
@@ -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()
+28
View File
@@ -0,0 +1,28 @@
[tool.poetry]
name = "modemd"
version = "1.0"
description = "Infix modem daemon and utilities"
license = "BSD-3-Clause"
packages = [
{ include = "modemd" }
]
authors = [
"KernelKit developers"
]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
modemd = "modemd:main"
modem-info = "modemd.info:main"
modem-carrier = "modemd.carrier:main"
modem-rpc = "modemd.rpc:main"
modem-sms = "modemd.sms:main"
modem-scan-networks = "modemd.scan_networks:main"
modem-update-firmware = "modemd.update_firmware:main"
modem-ussd = "modemd.ussd:main"
modem-power = "modemd.power:main"
modem-udev = "modemd.udev:main"
sim-setup = "modemd.sim_setup:main"
+7
View File
@@ -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"
+358 -7
View File
@@ -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)
@@ -895,8 +948,9 @@ class Sensor:
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 @@ class Sensor:
# Standalone sensor without description: use name as-is
display_name = self.name
row = f"{indent_str}{display_name:<{PadSensor.name - len(indent_str)}}"
row = f"{indent_str}{display_name:<{nw - len(indent_str)}}"
# For colored value, pad manually to account for ANSI codes
value_str = self.get_formatted_value()
# Count visible characters (strip ANSI codes for length calculation)
@@ -2245,6 +2299,276 @@ def show_services(json):
service_table.print()
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 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", "")),
("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")
@@ -2257,8 +2581,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}}"))
@@ -2387,11 +2721,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))
@@ -2420,7 +2764,7 @@ def show_hardware(json):
if module_name in children:
for child in sorted(children[module_name], key=lambda c: c.get("name", "")):
sensor = Sensor(child)
sensor.print(indent=1)
sensor.print(indent=1, name_width=name_width)
# Display standalone sensors (no parent)
if standalone:
@@ -2428,7 +2772,7 @@ def show_hardware(json):
print() # Add blank line between modules and standalone
for component in sorted(standalone, key=lambda c: c.get("name", "")):
sensor = Sensor(component)
sensor.print()
sensor.print(name_width=name_width)
def resolve_container_network(network, all_ifaces):
@@ -5989,6 +6333,9 @@ def main():
.add_argument('name', help='Container name')
subparsers.add_parser('show-hardware', help='Show USB ports')
subparsers.add_parser('show-modem', help='Show cellular modem overview')
subparsers.add_parser('show-modem-detail', help='Show detailed modem info') \
.add_argument('name', help='Modem name (e.g. modem0)')
subparsers.add_parser('show-interfaces', help='Show interfaces') \
.add_argument('-n', '--name', help='Interface name')
@@ -6066,6 +6413,10 @@ def main():
show_container_detail(json_data, args.name)
elif args.command == "show-hardware":
show_hardware(json_data)
elif args.command == "show-modem":
show_modem(json_data)
elif args.command == "show-modem-detail":
show_modem_detail(json_data, args.name)
elif args.command == "show-interfaces":
show_interfaces(json_data, args.name)
elif args.command == "show-lldp":
+29 -9
View File
@@ -829,19 +829,39 @@ def gps_receiver_components():
return components
def modem_components():
from . import infix_modem
data = infix_modem.operational()
return data.get("ietf-hardware:hardware", {}).get("component", [])
def operational():
systemjson = HOST.read_json("/run/system.json", {})
all_components = (
motherboard_component(systemjson) +
vpd_components(systemjson) +
usb_port_components(systemjson) +
hwmon_sensor_components() +
thermal_sensor_components() +
wifi_radio_components() +
gps_receiver_components() +
modem_components()
)
name_count = {}
components = []
for c in all_components:
name = c.get("name")
count = name_count.get(name, 0)
name_count[name] = count + 1
if count > 0:
c = dict(c)
c["name"] = f"{name}{count}"
components.append(c)
return {
"ietf-hardware:hardware": {
"component":
motherboard_component(systemjson) +
vpd_components(systemjson) +
usb_port_components(systemjson) +
hwmon_sensor_components() +
thermal_sensor_components() +
wifi_radio_components() +
gps_receiver_components() +
[],
"component": components,
},
}
@@ -1,3 +1,5 @@
import re
from ..host import HOST
from . import common
@@ -5,6 +7,7 @@ from . import bridge
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 wifi
from . import wireguard
_WWAN_RE = re.compile(r"^wwan\d+$")
def statistics(iplink):
statistics = {}
@@ -29,6 +35,9 @@ def statistics(iplink):
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"
@@ -182,6 +191,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":
@@ -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}
+12 -9
View File
@@ -134,11 +134,10 @@ def add_dns(out):
try:
ipaddress.ip_address(ip)
servers.append({
"address": ip,
"origin": "dhcp",
"interface": iface
})
entry = {"address": ip, "origin": "dhcp"}
if iface:
entry["interface"] = iface
servers.append(entry)
except ValueError:
continue
@@ -146,9 +145,12 @@ def add_dns(out):
parts = line.split('#', 1)
search.extend(parts[0].split()[1:])
insert(out, "infix-system:dns-resolver", "options", options)
insert(out, "infix-system:dns-resolver", "server", servers)
insert(out, "infix-system:dns-resolver", "search", search)
if options:
insert(out, "infix-system:dns-resolver", "options", options)
if servers:
insert(out, "infix-system:dns-resolver", "server", servers)
if search:
insert(out, "infix-system:dns-resolver", "search", search)
def add_software_slots(out, data):
slots = []
@@ -357,7 +359,8 @@ def add_users(out):
users.append(user)
insert(out, "authentication", "user", users)
if users:
insert(out, "authentication", "user", users)
def add_clock(out):
clock = {}
+232
View File
@@ -0,0 +1,232 @@
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 operational():
modems = HOST.run_json(['/usr/libexec/modemd/modem-info'], [])
hw_components = []
for modem in modems:
idx = modem.get("index", 0)
name = "modem%d" % idx
status = modem.get("status", {})
modem_state = status.get("state")
failed_reason = status.get("state-failed-reason")
signal_quality = status.get("signal-quality")
hw_state = _modem_to_hw_state(modem)
location_state = _location_state(idx)
if location_state:
hw_state["location-state"] = location_state
last_change = _last_change(idx)
if last_change:
hw_state["last-change"] = last_change
component = {
"name": name,
"class": "infix-hardware:modem",
}
# Standard ietf-hardware state container — drives 'show hardware'
# and gives generic NETCONF clients a meaningful view of the
# modem without needing modem-specific YANG awareness.
std_state = {
"oper-state": _modem_oper_state(modem_state),
}
alarm = _modem_alarm_state(modem_state, failed_reason, signal_quality)
if alarm:
std_state["alarm-state"] = alarm
slc = _state_last_changed(idx)
if slc:
std_state["state-last-changed"] = slc
component["state"] = std_state
if hw_state:
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)
if not hw_components:
return {}
return {
"ietf-hardware:hardware": {
"component": hw_components
}
}
+3 -2
View File
@@ -112,7 +112,8 @@ static int ly_add_yanger_data(const struct ly_ctx *ctx, struct lyd_node **parent
err = lyd_parse_data_fd(ctx, fd, LYD_JSON, LYD_PARSE_ONLY, 0, parent);
if (err)
ERROR("Error, parsing yanger data (%d): %s", err, ly_errmsg(ctx));
ERROR("Error, parsing yanger data (%d) for model '%s': %s", err,
yanger_args[1] ?: "?", ly_errmsg(ctx));
fclose(stream);
/* Note: fclose() already closes the underlying fd from fdopen() */
@@ -227,7 +228,7 @@ static int sr_generic_cb(sr_session_ctx_t *session, uint32_t, const char *model,
err = ly_add_yanger_data(ctx, parent, yanger_args);
if (err)
ERROR("Error adding yanger data");
ERROR("Error adding yanger data for xpath: %s", xpath);
sr_release_context(con);
+4 -7
View File
@@ -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
+1 -3
View File
@@ -110,15 +110,13 @@
}
},
"infix-system:dns-resolver": {
"options": {},
"server": [
{
"address": "192.168.2.1",
"origin": "dhcp",
"interface": "e7"
}
],
"search": []
]
},
"clock": {
"boot-datetime": "2026-01-02T20:42:11+00:00",