#!/usr/bin/env python3
"""van-modem-usb-kick — recover the EC25 modem when it's missing after boot.

Seen on 2026-08-04, right after moving the modem onto the new powered UGreen
hub: the 5GHz AP (rtw89, on the hub's outer USB chip) and the hub's built-in
Ethernet (AX88179, on its inner cascaded USB2.1 hub chip) both enumerate
cleanly at boot, but the EC25 modem — plugged into a port on that same inner
hub chip — sometimes never does, even though it has power (LED on). dmesg
shows no connect attempt at all for the modem's port during boot (contrast
the Ethernet's clean enumeration, or the AP's one-shot retry after a
descriptor-read error) — the port just never signalled a connect in time, and
a hub only rescans a port on an actual connect/disconnect edge, so it stays
invisible until something produces one. Physically unplugging/replugging
either the whole hub or just the modem fixes it immediately, because both
produce that edge. This script produces the same edge in software: unbind
then rebind the inner hub chip's kernel driver, which forces it to redo
enumeration of everything on it (the modem, and briefly the Starlink
Ethernet — the AP is on the outer chip and is undisturbed).

The inner hub is found by walking up from STARLINK_IFACE's sysfs device (it's
the Ethernet's parent hub) rather than hardcoding a bus/port path, since that
identifies "the hub carrying the modem" without assuming a specific topology.

Bounded to a few minutes after boot, then exits — this is a boot-race
backstop, not a permanent watcher (a modem physically removed later should
just stay gone, not get fought).

Usage: van-modem-usb-kick [modem-usb-vendor] [starlink-iface]
Defaults to 2c7c (Quectel) and enx6c1ff7d210a5 — see deploy.conf.
"""

import sys
import time
from pathlib import Path

MODEM_VENDOR = sys.argv[1] if len(sys.argv) > 1 else "2c7c"
STARLINK_IFACE = sys.argv[2] if len(sys.argv) > 2 else "enx6c1ff7d210a5"
INTERVAL = 20            # seconds between checks
BUDGET_S = 6 * INTERVAL  # ~2 minutes of retries after boot, then give up quietly
USB_DEVICES = Path("/sys/bus/usb/devices")
HUB_DRIVER = Path("/sys/bus/usb/drivers/hub")


def log(msg, level="info"):
    # systemd journal severity prefixes (sd-daemon), same convention as van-ap-watchdog.
    pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
    print(pri + msg, flush=True)


def modem_present(vendor):
    for f in USB_DEVICES.glob("*/idVendor"):
        try:
            if f.read_text().strip() == vendor:
                return True
        except OSError:
            continue
    return False


def inner_hub_name(starlink_iface):
    """sysfs driver-name (e.g. '2-1.4') of the USB hub carrying starlink_iface —
    that's the same hub chip the modem's port is on. None if the Ethernet
    itself hasn't enumerated (nothing to walk up from)."""
    dev = Path(f"/sys/class/net/{starlink_iface}/device")
    if not dev.exists():
        return None
    # dev resolves to .../<hub>/<hub>.<port>/<hub>.<port>:<config>.<iface>
    # (e.g. .../2-1.4/2-1.4.2/2-1.4.2:2.0) — two levels up is the hub itself.
    hub = dev.resolve().parent.parent
    if not (hub / "bDeviceClass").exists():
        return None
    try:
        if hub.joinpath("bDeviceClass").read_text().strip() != "09":  # 09 = hub
            return None
    except OSError:
        return None
    return hub.name


def kick(hub_name):
    log(f"modem (vendor {MODEM_VENDOR}) absent — rebinding hub {hub_name} to force "
        f"re-enumeration (same effect as unplug/replug; briefly bounces {STARLINK_IFACE})",
        "warn")
    try:
        (HUB_DRIVER / "unbind").write_text(hub_name)
        time.sleep(2)
        (HUB_DRIVER / "bind").write_text(hub_name)
    except OSError as e:
        log(f"hub rebind failed: {e}", "warn")


def main():
    log(f"van-modem-usb-kick up: watching for USB vendor {MODEM_VENDOR} for up to "
        f"{BUDGET_S}s after boot")
    elapsed = 0
    kicked = False
    while elapsed < BUDGET_S:
        if modem_present(MODEM_VENDOR):
            log("modem present — exiting" + (" (recovered)" if kicked else ""))
            return
        if not kicked:
            hub_name = inner_hub_name(STARLINK_IFACE)
            if hub_name:
                kick(hub_name)
                kicked = True
            # else: Ethernet itself isn't up yet — give the system more time
            # before assuming anything is wrong.
        time.sleep(INTERVAL)
        elapsed += INTERVAL
    if not modem_present(MODEM_VENDOR):
        log(f"modem still absent after {BUDGET_S}s — giving up "
            f"(leaving it for manual unplug/replug from here on)", "warn")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(0)
