Files
infix/src/statd/python/yanger/ietf_interfaces/modem.py
T
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

104 lines
3.4 KiB
Python

"""
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}