#!/usr/bin/env bash
# EV Infrastructure Device Control — system, network, wifi, hotspot, display, antenna management
#
# All commands output JSON to stdout for device-admin parsing.
# Designed to run as evinfra user (sudo NOPASSWD available).
#
# Usage: evctl <category> <command> [args...]
#
# Categories:
#   system   — reboot, restart-services, timezone, NTP, time
#   wifi     — connect, scan, active
#   hotspot  — activate, deactivate, status, get-config, set-config
#   network  — set-static-ip, set-dhcp, test-connection, status, whitelist-ips, whitelist-macs
#   display  — get-config, set-config
#   antenna  — get-config, set-config (CM5 WiFi/BT antenna switch)
#   support  — package (binary tar.xz; the only non-JSON command)

set -uo pipefail

DATA_DIR="/data"
CONFIG_DIR="${DATA_DIR}/config"
MACHINE_ID=$(get-machine-id 2>/dev/null || echo "unknown")

# Absolute path to the suid sudo wrapper. Resolving "sudo" via PATH races
# against suid-sgid-wrappers.service on early boot — if the wrappers
# directory hasn't been populated yet, /run/wrappers/bin/sudo doesn't
# exist and PATH falls through to /run/current-system/sw/bin/sudo, which
# is the non-setuid copy. That copy exits "must be owned by uid 0 and
# have the setuid bit set" the moment it's invoked, and every evctl
# command that needs root (reboot, restart-services, timezone, NTP,
# iptables, …) silently fails because the calling admin endpoint sees
# nothing on stdout/stderr — a backgrounded `&` swallows the error.
# EVI_SUDO lets the bats suite substitute a stub; production always resolves
# to the suid wrapper above.
SUDO="${EVI_SUDO:-/run/wrappers/bin/sudo}"

# ── JSON helpers ───────────────────────────────────────────────────────────

json_ok() {
  local data="${1:-null}"
  echo "{\"ok\":true,\"data\":${data}}"
}

json_error() {
  local msg="$1"
  echo "{\"ok\":false,\"error\":$(json_string "$msg")}"
}

json_string() {
  # Escape a string for JSON
  printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'
}

json_kv() {
  # Build a JSON object from key=value pairs
  python3 -c "
import json, sys
obj = {}
for arg in sys.argv[1:]:
    k, _, v = arg.partition('=')
    # Try to parse as JSON value (number, bool, null)
    try:
        obj[k] = json.loads(v)
    except (json.JSONDecodeError, ValueError):
        obj[k] = v
print(json.dumps(obj))
" "$@"
}

# ── Input validation ──────────────────────────────────────────────────────

validate_timezone() {
  timedatectl list-timezones 2>/dev/null | grep -qx "$1"
}

validate_ip() {
  echo "$1" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}(/[0-9]{1,2})?$'
}

validate_mac() {
  echo "$1" | grep -qiE '^([0-9a-f]{2}:){5}[0-9a-f]{2}$'
}

# ── system ─────────────────────────────────────────────────────────────────

cmd_system() {
  case "${1:-help}" in
    reboot)
      # systemctl reboot returns within milliseconds (it just queues the
      # reboot with PID 1); no `&` needed. Running it synchronously means
      # we surface any sudo failure to the admin endpoint instead of
      # swallowing it — that's exactly the bug that made the UI claim
      # "Rebooting..." while nothing happened.
      local reboot_err
      if ! reboot_err=$($SUDO systemctl reboot 2>&1); then
        json_error "Reboot failed: $reboot_err"
        return 1
      fi
      json_ok '"Rebooting..."'
      ;;

    restart-services)
      $SUDO systemctl restart firmware admin kiosk 2>&1 || true
      json_ok '"Services restarted"'
      ;;

    reload-auto-update)
      # Re-read /data/config/auto_update.json and restart the systemd timer
      # so a new schedule_hour/schedule_minute takes effect. Used by the
      # admin server's auto-update settings endpoint instead of calling
      # `sudo systemctl restart auto-update.timer` directly.
      $SUDO systemctl restart auto-update.timer 2>&1 || {
        json_error "Failed to restart auto-update.timer"
        return 1
      }
      json_ok '"auto-update timer reloaded"'
      ;;

    stop-service|start-service|restart-service)
      # Sanctioned wrapper for stopping/starting individual services.
      # The admin server uses these for transactional config imports
      # (stop firmware → extract tar → start firmware) instead of
      # invoking systemctl directly.
      #
      # systemctl stop/start are SYNCHRONOUS — they block until the unit
      # transitions to its target state. We rely on that for the import
      # flow: once `stop-service` returns ok, firmware's file handles on
      # /data/firmware/firmware/{config.json,database} are released and
      # tar can safely extract over them.
      local action="${1%-service}"   # stop-service → stop, etc.
      local svc="${2:-}"
      case "$svc" in
        firmware|admin|kiosk|nginx|hotspot|hotspot-autostart|avahi-hostname|wireguard) ;;
        *) json_error "Service not allowlisted: $svc"; return 1 ;;
      esac
      local err
      if ! err=$($SUDO systemctl "$action" "$svc" 2>&1); then
        # Previously this path silently emitted json_ok regardless of
        # systemctl's exit code, so the admin handler couldn't tell that
        # the firmware was still running when it began extracting.
        json_error "systemctl $action $svc failed: $err"
        return 1
      fi
      json_ok "$(json_kv "service=$svc" "action=$action")"
      ;;

    factory-reset)
      local tasks=()

      # Factory reset is intentionally NON-destructive to device IDENTITY and
      # PROVISIONING. It must NOT touch /data/cert (mTLS/device identity) or
      # /data/config (machine_*, WireGuard keys + assigned address, whitelists,
      # NTP, display, timezone, …): wiping those deprovisions the station from
      # the VPN/cloud and bricks remote management (see EC0940, 2026-06-22,
      # where a reset-style wipe of the WG keys took the unit off the VPN with
      # no remote way back). The ONLY /data/config exception is WiFi
      # credentials (/data/config/wifi), cleared so the unit can move site.

      # 1. Clear WiFi credentials COMPLETELY. Two stores must both be cleared,
      #    or WiFi silently comes back:
      #      (a) the live NetworkManager connection profiles, and
      #      (b) the provisioned seed at /data/config/wifi/{psk,ssid} that
      #          wifi-setup.service replays into a new NM profile every boot.
      while IFS=: read -r type name; do
        if [ "$type" = "802-11-wireless" ] && [ -n "$name" ]; then
          nmcli connection delete "$name" 2>/dev/null || true
        fi
      done < <(nmcli -t -f TYPE,NAME connection show 2>/dev/null)
      rm -f "${CONFIG_DIR}/wifi/psk" "${CONFIG_DIR}/wifi/ssid"
      tasks+=("wifi_cleared=true")

      # 2. Reset hotspot to default credentials (a NetworkManager profile, not
      #    under /data/cert or /data/config).
      nmcli connection delete station-hotspot 2>/dev/null || true
      tasks+=("hotspot_reset=true")

      # NOTE: admin-account password reset is intentionally NOT done here.
      # It is handled by the device-admin endpoint (/api/system/factory-reset)
      # before this runs, because it requires argon2 + the auth DB and must
      # reset ONLY non-developer/non-service passwords (deleting auth.sqlite
      # would also wipe the developer + service accounts, which must survive).

      # PRESERVED (must survive factory reset — identity, provisioning, config):
      # - /data/cert/**                         (device + mTLS certificates = identity)
      # - /data/config/wireguard/**             (VPN private/public key + assigned address)
      # - /data/config/{ip-whitelist,mac-whitelist,ntp.conf,display.conf,timezone}
      # - /data/config/{machine_model,machine_platform,machine_version,update_channel}
      # - /data/firmware/firmware/**            (hardware config + charging records)
      # - /data/firmware/device-admin/{appearance,uploads,ads}
      # Only /data/config/wifi is wiped (step 1).

      # Restart services to pick up changes
      $SUDO systemctl restart firmware admin kiosk 2>&1 || true
      tasks+=("services_restarted=true")

      json_ok "$(json_kv "${tasks[@]}")"
      ;;

    get-timezone)
      local tz
      tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo "unknown")
      json_ok "$(json_kv "timezone=$tz")"
      ;;

    set-timezone)
      local tz="${2:-}"
      if [ -z "$tz" ]; then
        json_error "Missing timezone argument"
        return 1
      fi
      if ! validate_timezone "$tz"; then
        json_error "Invalid timezone: $tz"
        return 1
      fi
      $SUDO timedatectl set-timezone "$tz" 2>&1
      # Persist across reboots. NixOS activation re-creates /etc/localtime
      # from the build-time deviceConfig.timezone on every boot, which
      # would silently revert this change. timezone-persist.service reads
      # this file early in stage 2 and re-applies it via timedatectl.
      $SUDO install -d -m 0755 -o 1000 -g 1000 "${CONFIG_DIR}" 2>/dev/null || true
      printf '%s\n' "$tz" | $SUDO tee "${CONFIG_DIR}/timezone" >/dev/null
      json_ok "$(json_kv "timezone=$tz")"
      ;;

    get-time-auto)
      local ntp
      ntp=$(timedatectl show -p NTP --value 2>/dev/null || echo "unknown")
      local server=""
      if [ -f "${CONFIG_DIR}/ntp.conf" ]; then
        server=$(cat "${CONFIG_DIR}/ntp.conf" 2>/dev/null || echo "")
      fi
      json_ok "$(json_kv "auto=$ntp" "ntp_server=${server:-pool.ntp.org}")"
      ;;

    set-ntp-server)
      local server="${2:-}"
      if [ -z "$server" ]; then
        json_error "Missing NTP server URL"
        return 1
      fi
      mkdir -p "${CONFIG_DIR}"
      echo "$server" > "${CONFIG_DIR}/ntp.conf"

      # Configure systemd-timesyncd with custom server
      $SUDO mkdir -p /etc/systemd/timesyncd.conf.d
      cat <<EOF | $SUDO tee /etc/systemd/timesyncd.conf.d/custom.conf >/dev/null
[Time]
NTP=$server
FallbackNTP=pool.ntp.org
EOF
      $SUDO systemctl restart systemd-timesyncd 2>&1
      $SUDO timedatectl set-ntp true 2>&1
      # Automatic mode is the default; clear any persisted manual-mode marker
      # so ntp-mode-persist.service doesn't re-disable NTP on next boot.
      rm -f "${CONFIG_DIR}/ntp-disabled"
      json_ok "$(json_kv "ntp_server=$server")"
      ;;

    default-ntp-server)
      rm -f "${CONFIG_DIR}/ntp.conf"
      rm -f "${CONFIG_DIR}/ntp-disabled"
      $SUDO rm -f /etc/systemd/timesyncd.conf.d/custom.conf
      $SUDO systemctl restart systemd-timesyncd 2>&1
      $SUDO timedatectl set-ntp true 2>&1
      json_ok "$(json_kv "ntp_server=pool.ntp.org")"
      ;;

    set-time-manual)
      local time_str="${2:-}"
      if [ -z "$time_str" ]; then
        json_error "Missing time argument (YYYY-MM-DD HH:MM:SS)"
        return 1
      fi
      $SUDO timedatectl set-ntp false 2>&1
      if $SUDO timedatectl set-time "$time_str" 2>&1; then
        # Persist manual mode so it survives reboot — NixOS re-enables
        # systemd-timesyncd by default, which would otherwise flip NTP back on
        # and silently override the operator's manual clock. ntp-mode-persist.
        # service re-applies `set-ntp false` on boot when this marker exists.
        mkdir -p "${CONFIG_DIR}"
        touch "${CONFIG_DIR}/ntp-disabled"
        json_ok "$(json_kv "time=$time_str" "auto=false")"
      else
        json_error "Failed to set time: $time_str"
        return 1
      fi
      ;;

    status)
      # Live hardware/runtime snapshot — RAM, swap, CPU, GPU, uptime.
      # Used by the troubleshooting page; values intentionally raw (kB,
      # seconds, °C) so the UI can format and threshold them.

      local mem_total mem_avail mem_used
      mem_total=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
      mem_avail=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
      mem_used=$((mem_total - mem_avail))

      local swap_total swap_free swap_used
      swap_total=$(awk '/^SwapTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
      swap_free=$(awk '/^SwapFree:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
      swap_used=$((swap_total - swap_free))

      # CPU usage: sample /proc/stat twice with a small delta and compute
      # 1 - idle/total. 200 ms is short enough to keep the call snappy.
      local cpu_pct=0 cpu_cores
      cpu_cores=$(nproc 2>/dev/null || echo 1)
      local s1 s2 t1 t2 i1 i2 td id
      s1=$(awk '/^cpu / {print ($2+$3+$4+$5+$6+$7+$8)" "$5}' /proc/stat 2>/dev/null)
      sleep 0.2
      s2=$(awk '/^cpu / {print ($2+$3+$4+$5+$6+$7+$8)" "$5}' /proc/stat 2>/dev/null)
      t1=${s1% *}; i1=${s1#* }
      t2=${s2% *}; i2=${s2#* }
      td=$((t2 - t1)); id=$((i2 - i1))
      if [ "$td" -gt 0 ]; then
        cpu_pct=$(( (td - id) * 100 / td ))
      fi

      # CPU temperature. The naive "read /sys/class/thermal/thermal_zone0"
      # path is unreliable: on the RPi the zone *index* of the SoC varies
      # between kernels (vc4 ordering, USB-C thermal, fan controller all
      # claim early indices on some images), and on non-Pi hardware
      # zone0 is often an ACPI controller that reports a placeholder
      # temperature (~20 °C). Walk every zone, pick the first one whose
      # `type` looks like a CPU/SoC sensor, and fall back to vcgencmd
      # measure_temp on the Pi (which reports the SoC temperature —
      # essentially the CPU on a Pi where CPU and GPU share the SoC).
      local cpu_temp_c="null"
      local zone zone_type
      for zone in /sys/class/thermal/thermal_zone*; do
        [ -r "$zone/temp" ] || continue
        zone_type=$(cat "$zone/type" 2>/dev/null || echo "")
        case "$zone_type" in
          cpu-thermal|cpu_thermal|soc-thermal|soc_thermal|x86_pkg_temp|coretemp|k10temp)
            cpu_temp_c=$(awk '{ printf "%.1f", $1/1000 }' "$zone/temp" 2>/dev/null)
            [ -z "$cpu_temp_c" ] && cpu_temp_c="null"
            break
            ;;
        esac
      done
      if [ "$cpu_temp_c" = "null" ] && command -v vcgencmd >/dev/null 2>&1; then
        # RPi fallback: vcgencmd measure_temp returns the SoC die temp,
        # which on a Pi is the CPU temperature for all practical purposes.
        local vt
        vt=$(vcgencmd measure_temp 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)
        [ -n "$vt" ] && cpu_temp_c="$vt"
      fi
      if [ "$cpu_temp_c" = "null" ] && [ -r /sys/class/thermal/thermal_zone0/temp ]; then
        # Last-resort fallback to the historical zone0 read so the field
        # is never simply empty on hardware we haven't enumerated above.
        cpu_temp_c=$(awk '{ printf "%.1f", $1/1000 }' /sys/class/thermal/thermal_zone0/temp 2>/dev/null)
        [ -z "$cpu_temp_c" ] && cpu_temp_c="null"
      fi

      # GPU info — Raspberry Pi videocore. Skipped silently on other HW.
      local gpu_temp_c="null" gpu_freq_hz="null" throttled="null"
      if command -v vcgencmd >/dev/null 2>&1; then
        local gt
        gt=$(vcgencmd measure_temp 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)
        [ -n "$gt" ] && gpu_temp_c="$gt"

        local gf
        gf=$(vcgencmd measure_clock core 2>/dev/null | cut -d= -f2)
        if [ -n "$gf" ] && echo "$gf" | grep -qE '^[0-9]+$'; then
          gpu_freq_hz="$gf"
        fi

        local th
        th=$(vcgencmd get_throttled 2>/dev/null | cut -d= -f2)
        [ -n "$th" ] && throttled="\"$th\""
      fi

      local uptime_seconds
      uptime_seconds=$(awk '{ printf "%d", $1 }' /proc/uptime 2>/dev/null || echo 0)

      local data
      data=$(python3 - <<EOF
import json
# Aliases so bare 'null' interpolated from bash becomes JSON null.
null = None
print(json.dumps({
  "ram": {"used_kb": $mem_used, "total_kb": $mem_total, "available_kb": $mem_avail},
  "swap": {"used_kb": $swap_used, "total_kb": $swap_total},
  "cpu": {"usage_percent": $cpu_pct, "cores": $cpu_cores, "temp_c": $cpu_temp_c},
  "gpu": {"temp_c": $gpu_temp_c, "freq_hz": $gpu_freq_hz, "throttled": $throttled},
  "uptime_seconds": $uptime_seconds
}))
EOF
)
      json_ok "$data"
      ;;

    help|*)
      cat <<HELP
evctl system <command>

Commands:
  reboot                          Reboot the device
  restart-services                Restart firmware, admin, and kiosk services
  stop-service <name>             Stop a single service (allowlisted)
  start-service <name>            Start a single service (allowlisted)
  restart-service <name>          Restart a single service (allowlisted)
  reload-auto-update              Restart auto-update.timer to pick up new schedule
  status                          Live hardware snapshot: RAM, swap, CPU, GPU, uptime (JSON)
  get-timezone                    Get current timezone
  set-timezone <tz>               Set timezone (e.g. Europe/Prague)
  get-time-auto                   Get NTP auto-sync status and server
  set-ntp-server <url>            Set custom NTP server
  default-ntp-server              Reset to default NTP server (pool.ntp.org)
  set-time-manual <datetime>      Disable NTP and set time manually (YYYY-MM-DD HH:MM:SS)
  factory-reset                   Clear WiFi + admin/hotspot creds (preserves identity, certs, VPN keys, and device config)
HELP
      ;;
  esac
}

# ── wifi ───────────────────────────────────────────────────────────────────

cmd_wifi() {
  case "${1:-help}" in
    connect)
      local ssid="${2:-}"
      local psk="${3:-}"
      if [ -z "$ssid" ]; then
        json_error "Missing SSID"
        return 1
      fi
      local output
      if [ -n "$psk" ]; then
        output=$(nmcli device wifi connect "$ssid" password "$psk" 2>&1)
      else
        output=$(nmcli device wifi connect "$ssid" 2>&1)
      fi
      if [ $? -eq 0 ]; then
        json_ok "$(json_kv "ssid=$ssid" "connected=true")"
      else
        json_error "WiFi connection failed: $output"
        return 1
      fi
      ;;

    scan)
      # Force rescan then list
      nmcli device wifi rescan 2>/dev/null || true
      sleep 1
      local networks
      networks=$(nmcli -t -f SSID,SIGNAL,SECURITY device wifi list 2>/dev/null | sort -t: -k2 -rn | head -30)
      python3 -c "
import json, sys
lines = sys.stdin.read().strip().split('\n')
result = []
seen = set()
for line in lines:
    parts = line.split(':')
    if len(parts) >= 3 and parts[0] and parts[0] not in seen:
        seen.add(parts[0])
        result.append({'ssid': parts[0], 'signal': int(parts[1]) if parts[1].isdigit() else 0, 'security': parts[2]})
print(json.dumps({'ok': True, 'data': {'networks': result}}))
" <<< "$networks"
      ;;

    active)
      local ssid
      ssid=$(nmcli -t -f TYPE,NAME connection show --active 2>/dev/null | grep '^802-11-wireless:' | cut -d: -f2 | head -1)
      if [ -n "$ssid" ]; then
        json_ok "$(json_kv "ssid=$ssid" "connected=true")"
      else
        json_ok "$(json_kv "ssid=" "connected=false")"
      fi
      ;;

    forget)
      # Remove all user-configured WiFi connections (not the pre-configured one)
      local removed=0
      while IFS=: read -r type name; do
        if [ "$type" = "802-11-wireless" ] && [ -n "$name" ]; then
          nmcli connection delete "$name" 2>/dev/null && removed=$((removed + 1))
        fi
      done < <(nmcli -t -f TYPE,NAME connection show 2>/dev/null)
      json_ok "$(json_kv "removed=$removed")"
      ;;

    help|*)
      cat <<HELP
evctl wifi <command>

Commands:
  connect <ssid> [psk]    Connect to WiFi network (persisted across reboot)
  scan                    Scan for available WiFi networks
  active                  Get currently connected WiFi network
  forget                  Remove all saved WiFi connections
HELP
      ;;
  esac
}

# ── hotspot ────────────────────────────────────────────────────────────────

HOTSPOT_CON="station-hotspot"
HOTSPOT_SSID="station-${MACHINE_ID}"

ensure_hotspot_connection() {
  if ! nmcli connection show "$HOTSPOT_CON" >/dev/null 2>&1; then
    nmcli connection add type wifi ifname wlan0 con-name "$HOTSPOT_CON" \
      autoconnect no ssid "$HOTSPOT_SSID" \
      -- wifi-sec.key-mgmt wpa-psk wifi-sec.psk "iont.tech" wifi-sec.proto rsn wifi-sec.pairwise ccmp wifi-sec.group ccmp \
      ipv4.method shared ipv4.addresses "192.168.4.1/24" \
      wifi.mode ap 2>/dev/null
  fi
}

cmd_hotspot() {
  case "${1:-help}" in
    activate)
      ensure_hotspot_connection
      # Single WiFi radio: bringing the AP up on wlan0 makes NetworkManager
      # deactivate whatever WiFi *client* is currently on that radio. We do
      # NOT touch the client profile's autoconnect (and never use
      # `nmcli device disconnect`, which would persistently block it), so the
      # disconnect is purely TEMPORARY: the client reconnects on its own when
      # the hotspot is deactivated (see below) or after a reboot — the hotspot
      # profile is autoconnect=no so it never auto-activates at boot, while the
      # client stays autoconnect=yes. No "WiFi is the only uplink" guard: any
      # WiFi uplink dropped here is restored the moment the hotspot goes down.
      local output
      output=$(nmcli connection up "$HOTSPOT_CON" 2>&1)
      if [ $? -eq 0 ]; then
        json_ok "$(json_kv "status=active" "ssid=$HOTSPOT_SSID")"
      else
        json_error "Failed to activate hotspot: $output"
        return 1
      fi
      ;;

    deactivate)
      local output rc
      output=$(nmcli connection down "$HOTSPOT_CON" 2>&1); rc=$?
      # Radio is free again — bring the WiFi CLIENT back explicitly. We must NOT
      # use `nmcli device connect wlan0`: it re-selects the "best" connection for
      # the device and readily re-activates the AP profile (it does not honour
      # the AP's autoconnect=no) — so the hotspot came straight back up while
      # deactivate still reported success. Instead bring up the highest-priority
      # autoconnect WiFi connection that ISN'T the hotspot; NM autoconnect (the
      # client stayed autoconnect=yes) is the backstop.
      local wifi_client
      wifi_client=$(nmcli -t -f NAME,TYPE,AUTOCONNECT connection show 2>/dev/null \
        | awk -F: -v ap="$HOTSPOT_CON" '$2=="802-11-wireless" && $3=="yes" && $1!=ap {print $1; exit}')
      [ -n "$wifi_client" ] && nmcli connection up "$wifi_client" >/dev/null 2>&1 || true
      if [ $rc -eq 0 ]; then
        json_ok "$(json_kv "status=inactive")"
      else
        json_error "Failed to deactivate hotspot: $output"
        return 1
      fi
      ;;

    status)
      # `active` is a boolean for the admin UI's toggle (which previously
      # read `newVal.active` and saw `undefined` here, leaving the button
      # stuck on "Activate" even after a successful activation). `status`
      # is kept as a string for back-compat with anything else.
      if nmcli -t -f NAME connection show --active 2>/dev/null | grep -qx "$HOTSPOT_CON"; then
        json_ok "$(json_kv "active=true" "status=active" "ssid=$HOTSPOT_SSID")"
      else
        json_ok "$(json_kv "active=false" "status=inactive" "ssid=$HOTSPOT_SSID")"
      fi
      ;;

    get-config)
      ensure_hotspot_connection
      local ssid password
      ssid=$(nmcli -t -f 802-11-wireless.ssid connection show "$HOTSPOT_CON" 2>/dev/null | cut -d: -f2)
      password=$(nmcli -s -t -f 802-11-wireless-security.psk connection show "$HOTSPOT_CON" 2>/dev/null | cut -d: -f2)
      json_ok "$(json_kv "ssid=${ssid:-$HOTSPOT_SSID}" "password=${password:-}")"
      ;;

    set-config)
      local password="${2:-}"
      if [ -z "$password" ]; then
        json_error "Missing password"
        return 1
      fi
      if [ ${#password} -lt 8 ]; then
        json_error "Password must be at least 8 characters"
        return 1
      fi
      ensure_hotspot_connection
      nmcli connection modify "$HOTSPOT_CON" wifi-sec.psk "$password" 2>&1
      json_ok "$(json_kv "password_set=true")"
      ;;

    help|*)
      cat <<HELP
evctl hotspot <command>

Commands:
  activate                Activate hotspot (SSID: station-<machine_id>)
  deactivate              Deactivate hotspot
  status                  Get hotspot status (active/inactive)
  get-config              Get hotspot SSID and password
  set-config <password>   Set hotspot password (min 8 chars)
HELP
      ;;
  esac
}

# ── network ────────────────────────────────────────────────────────────────

get_active_connection() {
  # First real (non-loopback, non-WireGuard) active connection. Excluding wg0
  # matters: it's "connected" too, and picking it would point IP changes at the
  # VPN interface instead of the LAN.
  nmcli -t -f NAME,DEVICE,TYPE connection show --active 2>/dev/null \
    | grep -viE "^[^:]*:lo:|:(loopback|wireguard)$" | head -1 | cut -d: -f1
}

# Resolve the NetworkManager connection NAME bound to a given device
# (e.g. end0, wlan0). Falls back to get_active_connection when no interface is
# supplied or no match is found.
connection_for_iface() {
  local iface="${1:-}"
  if [ -n "$iface" ]; then
    local con
    con=$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null \
      | awk -F: -v d="$iface" '$2==d{print $1; exit}')
    if [ -n "$con" ]; then echo "$con"; return 0; fi
  fi
  get_active_connection
}

# Convert a dotted netmask (255.255.255.0) to a CIDR prefix (24). Echoes the
# input unchanged if it already looks like a prefix.
netmask_to_prefix() {
  local m="${1:-}"
  case "$m" in
    ''|*[!0-9.]*) echo "${m:-24}"; return ;;  # already a prefix or empty
  esac
  case "$m" in
    *.*.*.*)
      local p=0 oct
      for oct in ${m//./ }; do
        while [ "$oct" -gt 0 ]; do p=$((p + (oct & 1))); oct=$((oct >> 1)); done
      done
      echo "$p" ;;
    *) echo "$m" ;;
  esac
}

cmd_network() {
  case "${1:-help}" in
    set-static-ip)
      # set-static-ip <ip/mask> [gateway] [dns] [iface]
      # <ip/mask> may be "192.168.1.50/24" or "192.168.1.50/255.255.255.0";
      # a dotted mask is normalised to a prefix.
      local ip="${2:-}"
      local gateway="${3:-}"
      local dns="${4:-1.1.1.1}"
      local iface="${5:-}"
      if [ -z "$ip" ]; then
        json_error "Missing IP address (e.g. 192.168.1.100/24)"
        return 1
      fi
      local addr_ip="${ip%%/*}" addr_mask="${ip#*/}"
      [ "$addr_mask" = "$ip" ] && addr_mask="24"   # no slash supplied
      if ! validate_ip "$addr_ip"; then
        json_error "Invalid IP format"
        return 1
      fi
      local prefix; prefix=$(netmask_to_prefix "$addr_mask")
      local cidr="${addr_ip}/${prefix}"
      local con
      con=$(connection_for_iface "$iface")
      if [ -z "$con" ]; then
        json_error "No matching network connection${iface:+ for $iface}"
        return 1
      fi
      nmcli connection modify "$con" ipv4.method manual ipv4.addresses "$cidr" \
        ${gateway:+ipv4.gateway "$gateway"} ipv4.dns "$dns" 2>&1
      nmcli connection up "$con" 2>&1
      json_ok "$(json_kv "connection=$con" "ip=$cidr" "gateway=${gateway:-auto}" "dns=$dns" "method=static")"
      ;;

    set-dhcp)
      # set-dhcp [iface]
      local iface="${2:-}"
      local con
      con=$(connection_for_iface "$iface")
      if [ -z "$con" ]; then
        json_error "No matching network connection${iface:+ for $iface}"
        return 1
      fi
      nmcli connection modify "$con" ipv4.method auto ipv4.addresses "" ipv4.gateway "" ipv4.dns "" 2>&1
      nmcli connection up "$con" 2>&1
      json_ok "$(json_kv "connection=$con" "method=auto")"
      ;;

    get-ip)
      # get-ip [iface] — real ipv4 config of the (interface's) active connection.
      local iface="${2:-}"
      local con
      con=$(connection_for_iface "$iface")
      if [ -z "$con" ]; then
        json_error "No matching network connection${iface:+ for $iface}"
        return 1
      fi
      local method addrs gw dnsv
      method=$(nmcli -t -g ipv4.method connection show "$con" 2>/dev/null)
      addrs=$(nmcli -t -g ipv4.addresses connection show "$con" 2>/dev/null | head -1)
      gw=$(nmcli -t -g ipv4.gateway connection show "$con" 2>/dev/null)
      dnsv=$(nmcli -t -g ipv4.dns connection show "$con" 2>/dev/null)
      local auto="no"; [ "$method" = "auto" ] && auto="yes"
      json_ok "$(json_kv "connection=$con" "method=${method:-auto}" "use_dhcp=$auto" "address=$addrs" "gateway=$gw" "dns=$dnsv")"
      ;;

    ping)
      # Ping a single host. Used by the admin server's network/ping
      # endpoint instead of execFile('ping', ...) directly so the admin
      # never invokes system tools outside this wrapper.
      local host="${2:-8.8.8.8}"
      local count="${3:-4}"
      # Validate host (hostname or IPv4) — same regex as the admin endpoint.
      if ! echo "$host" | grep -qE '^[a-zA-Z0-9.-]+$'; then
        json_error "Invalid host"
        return 1
      fi
      if ! echo "$count" | grep -qE '^[1-9][0-9]?$'; then
        json_error "Invalid count (must be 1..99)"
        return 1
      fi
      local output
      output=$(ping -c "$count" -W 2 "$host" 2>&1)
      local rc=$?
      # Extract avg rtt from "rtt min/avg/max/mdev = 12.345/15.678/..."
      local time_ms=""
      time_ms=$(printf '%s' "$output" | awk -F'/' '/^rtt/ { printf "%.0f", $5 }')
      if [ "$rc" -eq 0 ]; then
        json_ok "$(json_kv "host=$host" "time_ms=${time_ms:-0}" "reachable=true")"
      else
        json_error "Ping failed: $output"
        return 1
      fi
      ;;

    test-connection)
      local results=()
      local ok=true

      if ping -c1 -W3 1.1.1.1 >/dev/null 2>&1; then
        results+=("cloudflare=reachable")
      else
        results+=("cloudflare=unreachable")
        ok=false
      fi

      if ping -c1 -W3 8.8.8.8 >/dev/null 2>&1; then
        results+=("google=reachable")
      else
        results+=("google=unreachable")
        ok=false
      fi

      # DNS test
      if host -W3 google.com >/dev/null 2>&1; then
        results+=("dns=working")
      else
        results+=("dns=failing")
        ok=false
      fi

      results+=("internet=$ok")
      json_ok "$(json_kv "${results[@]}")"
      ;;

    status)
      # Per-interface WiFi / ethernet state for the admin /system/network UI.
      # Source of truth is nmcli, falling back to /sys/class/net for MAC and
      # `ip -o -4 addr show` for the v4 address. The hotspot connection is
      # excluded from "wifi connected" because it's an AP-mode profile, not
      # a client connection.
      local eth_iface="" eth_state="" eth_con=""
      local wifi_iface="" wifi_state="" wifi_con=""
      while IFS=: read -r dev type state con; do
        case "$type" in
          ethernet)
            if [ -z "$eth_iface" ]; then
              eth_iface="$dev"; eth_state="$state"; eth_con="$con"
            fi
            ;;
          wifi)
            if [ "$con" != "$HOTSPOT_CON" ] && [ -z "$wifi_iface" ]; then
              wifi_iface="$dev"; wifi_state="$state"; wifi_con="$con"
            fi
            ;;
        esac
      done < <(nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status 2>/dev/null)

      # Fall back to sysfs for the wifi interface name if nmcli didn't report
      # it (e.g. NetworkManager not yet up). MAC + presence still useful.
      if [ -z "$wifi_iface" ] && [ -d /sys/class/net/wlan0 ]; then
        wifi_iface="wlan0"; wifi_state="disconnected"
      fi

      local wifi_connected=false wifi_ssid="" wifi_signal=0 wifi_ip="" wifi_mac=""
      if [ -n "$wifi_iface" ]; then
        wifi_mac=$(cat "/sys/class/net/$wifi_iface/address" 2>/dev/null || echo "")
        if [ "$wifi_state" = "connected" ]; then
          wifi_connected=true
          wifi_ssid="$wifi_con"
          wifi_signal=$(nmcli -t -f IN-USE,SIGNAL dev wifi 2>/dev/null | awk -F: '$1=="*"{print $2; exit}')
          [ -z "$wifi_signal" ] && wifi_signal=0
          wifi_ip=$(ip -o -4 addr show "$wifi_iface" 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
        fi
      fi

      local eth_present=false eth_connected=false eth_ip="" eth_mac=""
      if [ -n "$eth_iface" ]; then
        eth_present=true
        eth_mac=$(cat "/sys/class/net/$eth_iface/address" 2>/dev/null || echo "")
        if [ "$eth_state" = "connected" ]; then
          eth_connected=true
          eth_ip=$(ip -o -4 addr show "$eth_iface" 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
        fi
      fi

      # Legacy back-compat fields (keep so older callers don't break).
      local connectivity ip
      connectivity=$(nmcli networking connectivity check 2>/dev/null || echo "unknown")
      ip="$wifi_ip"; [ -z "$ip" ] && ip="$eth_ip"
      local con
      con=$(get_active_connection)

      python3 - <<EOF
import json
print(json.dumps({
  "ok": True,
  "data": {
    "wifi": {
      "connected": ${wifi_connected^},
      "ssid": $(json_string "$wifi_ssid"),
      "signal": ${wifi_signal:-0},
      "ip": $(json_string "$wifi_ip"),
      "mac": $(json_string "$wifi_mac"),
    },
    "ethernet": {
      "present": ${eth_present^},
      "connected": ${eth_connected^},
      "ip": $(json_string "$eth_ip"),
      "mac": $(json_string "$eth_mac"),
    },
    "connectivity": $(json_string "$connectivity"),
    "ip": $(json_string "${ip:-}"),
    "connection": $(json_string "${con:-}"),
  },
}))
EOF
      ;;

    whitelist-ips)
      local ips="${2:-}"
      local whitelist_file="${CONFIG_DIR}/ip-whitelist"

      if [ -z "$ips" ]; then
        # Read current whitelist
        if [ -f "$whitelist_file" ]; then
          local current
          current=$(cat "$whitelist_file")
          json_ok "$(json_kv "ips=$current")"
        else
          json_ok "$(json_kv "ips=")"
        fi
        return 0
      fi

      if [ "$ips" = "clear" ]; then
        rm -f "$whitelist_file"
        # Remove iptables rules
        $SUDO iptables -D INPUT -p tcp --dport 80 -j ev-whitelist 2>/dev/null || true
        $SUDO iptables -D INPUT -p tcp --dport 443 -j ev-whitelist 2>/dev/null || true
        $SUDO iptables -F ev-whitelist 2>/dev/null || true
        $SUDO iptables -X ev-whitelist 2>/dev/null || true
        json_ok "$(json_kv "ips=" "cleared=true")"
        return 0
      fi

      mkdir -p "${CONFIG_DIR}"
      echo "$ips" > "$whitelist_file"

      # Apply iptables rules
      $SUDO iptables -F ev-whitelist 2>/dev/null || $SUDO iptables -N ev-whitelist 2>/dev/null
      IFS=',' read -ra IP_ARRAY <<< "$ips"
      for ip in "${IP_ARRAY[@]}"; do
        ip=$(echo "$ip" | tr -d ' ')
        if validate_ip "$ip"; then
          $SUDO iptables -A ev-whitelist -s "$ip" -j ACCEPT
        fi
      done
      $SUDO iptables -A ev-whitelist -j DROP

      # Hook into INPUT chain for ports 80 and 443
      $SUDO iptables -D INPUT -p tcp --dport 80 -j ev-whitelist 2>/dev/null || true
      $SUDO iptables -D INPUT -p tcp --dport 443 -j ev-whitelist 2>/dev/null || true
      $SUDO iptables -I INPUT -p tcp --dport 80 -j ev-whitelist
      $SUDO iptables -I INPUT -p tcp --dport 443 -j ev-whitelist

      json_ok "$(json_kv "ips=$ips" "applied=true")"
      ;;

    whitelist-macs)
      local macs="${2:-}"
      local whitelist_file="${CONFIG_DIR}/mac-whitelist"

      if [ -z "$macs" ]; then
        if [ -f "$whitelist_file" ]; then
          json_ok "$(json_kv "macs=$(cat "$whitelist_file")")"
        else
          json_ok "$(json_kv "macs=")"
        fi
        return 0
      fi

      if [ "$macs" = "clear" ]; then
        rm -f "$whitelist_file"
        json_ok "$(json_kv "macs=" "cleared=true")"
        return 0
      fi

      mkdir -p "${CONFIG_DIR}"
      echo "$macs" > "$whitelist_file"
      json_ok "$(json_kv "macs=$macs" "applied=true")"
      ;;

    help|*)
      cat <<HELP
evctl network <command>

Commands:
  set-static-ip <ip/mask> [gateway] [dns]   Set static IP
  set-dhcp                                   Switch to DHCP
  ping <host> [count]                        Ping a host (default count=4)
  test-connection                            Test internet connectivity
  status                                     Get network status
  whitelist-ips [ip1,ip2,...]                Set/get IP whitelist for admin access
  whitelist-macs [mac1,mac2,...]             Set/get MAC whitelist for hotspot
HELP
      ;;
  esac
}

# ── display ────────────────────────────────────────────────────────────────

# EVI_DISPLAY_CONF lets tests (and, in principle, a recovery shell) point
# this at a scratch file instead of the real /data/config/display.conf.
DISPLAY_CONF="${EVI_DISPLAY_CONF:-${CONFIG_DIR}/display.conf}"
IONT_ETC="${IONT_ETC:-/etc/iont}"

# Standard fallback resolutions used when /sys/class/drm enumeration finds
# nothing usable (e.g. HDMI unplugged, vc4-kms not loaded yet, container
# without /sys access). Curated for kiosk panels we actually ship plus the
# common HDMI desktop sizes operators recognise. Order = preferred order
# in the admin dropdown.
DISPLAY_FALLBACK_MODES='[
  [1920, 1080],
  [1280, 720],
  [1024, 768],
  [800, 600],
  [800, 480],
  [640, 480]
]'

# Normalize a rotation arg to a degree string (0/90/180/270). Echoes the
# normalized value on success or empty on failure. Used by set-config.
_display_normalize_rotation() {
  case "${1:-}" in
    0|normal)     echo 0   ;;
    90|right)     echo 90  ;;
    180|inverted) echo 180 ;;
    270|left)     echo 270 ;;
    *) return 1 ;;
  esac
}

# Read one KEY=VALUE field from DISPLAY_CONF. Echoes empty if missing.
_display_conf_get() {
  local key="$1"
  [ -f "$DISPLAY_CONF" ] || return 0
  sed -n "s/^${key}=//p" "$DISPLAY_CONF" 2>/dev/null || true
}

# Atomically rewrite DISPLAY_CONF with the supplied env-style lines (one
# per line on stdin). Caller is responsible for stripping blanks.
_display_conf_write() {
  mkdir -p "${CONFIG_DIR}"
  local tmp="${DISPLAY_CONF}.tmp.$$"
  cat > "$tmp"
  mv "$tmp" "$DISPLAY_CONF"
}

# Upsert one KEY=VALUE line into DISPLAY_CONF, preserving every other line
# (sed replace-in-place if the key already exists, append otherwise). Used
# by `display set-config DISPLAY=...`/`OUTPUT=...`/`ROTATION=...` so setting
# one field never clobbers the others.
_display_conf_upsert() { # _display_conf_upsert KEY VALUE
  local key="$1" value="$2"
  mkdir -p "$(dirname "$DISPLAY_CONF")"
  touch "$DISPLAY_CONF"
  if grep -q "^${key}=" "$DISPLAY_CONF" 2>/dev/null; then
    sed -i "s|^${key}=.*|${key}=${value}|" "$DISPLAY_CONF"
  else
    printf '%s=%s\n' "$key" "$value" >> "$DISPLAY_CONF"
  fi
}

# Drop a KEY=... line entirely (used to clear a resolution pin back to
# "use native mode").
_display_conf_unset() { # _display_conf_unset KEY
  [ -f "$DISPLAY_CONF" ] || return 0
  sed -i "/^${1}=/d" "$DISPLAY_CONF"
}

# Best-effort path to this checkout's repo root, derived from evctl's own
# location (scripts/evctl -> one level up). Works when evctl runs from a
# source checkout (dev shell, CI, bats tests). On a real device evctl is
# packaged standalone (see modules/system.nix's writeScriptBin) with no
# accompanying repo tree, so callers must tolerate this being unavailable.
_evctl_repo_root() {
  local self_dir
  self_dir=$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd) || return 1
  (cd "${self_dir}/.." 2>/dev/null && pwd)
}

# Directory of shipped display profiles (config/displays/*.txt). Honors
# $EVI_DISPLAYS_DIR for tests; otherwise derived from the repo root above.
_display_profiles_dir() {
  if [ -n "${EVI_DISPLAYS_DIR:-}" ]; then
    printf '%s' "$EVI_DISPLAYS_DIR"
    return 0
  fi
  local root
  root=$(_evctl_repo_root) || return 1
  printf '%s' "${root}/config/displays"
}

# Validate a DISPLAY profile name: "none" (headless) is always valid;
# otherwise it must match a basename shipped under config/displays/*.txt.
# Falls back to a hardcoded mirror of that directory when it can't be
# found on disk (e.g. evctl running from its packaged, repo-less location
# on a real device) — keep this list in sync with config/displays/ (see
# the configurable-display plan's Global Constraints).
_display_valid_profile() {
  local want="$1" dir
  [ "$want" = "none" ] && return 0
  dir=$(_display_profiles_dir) || dir=""
  if [ -n "$dir" ] && [ -d "$dir" ]; then
    [ -e "${dir}/${want}.txt" ]
    return
  fi
  # MUST match `ls config/displays/*.txt` basenames exactly — update this
  # line whenever a profile is added/removed/renamed there.
  case "$want" in
    hdmi-auto|waveshare-5inch-hdmi|waveshare-5inch-hdmi-v2|waveshare-5inch-hdmi-h|waveshare-7inch-hdmi-h) return 0 ;;
    *) return 1 ;;
  esac
}

# Validate an OUTPUT (HDMI connector) selection.
_display_valid_output() {
  case "$1" in
    hdmi0|hdmi1|auto) return 0 ;;
    *) return 1 ;;
  esac
}

# Resolve the active display profile or output. `resolve-display` (packaged
# in modules/system.nix's environment.systemPackages from the SAME
# modules/scripts/resolve-display.sh source — the design's single source of
# truth for this precedence) is what runs on a real device; prefer it. The
# block below is ONLY a fallback for contexts where that package isn't on
# PATH — a standalone copy of this script, or these bats tests — and must
# mirror resolve-display.sh's precedence exactly: DISPLAY_CONF -> IONT_ETC
# default -> hardcoded fallback.
_display_resolve() { # _display_resolve profile|output
  if command -v resolve-display >/dev/null 2>&1; then
    DISPLAY_CONF="$DISPLAY_CONF" IONT_ETC="$IONT_ETC" resolve-display "$1"
    return
  fi

  local v=""
  case "$1" in
    profile)
      v=$(_display_conf_get DISPLAY | tail -1 | tr -d '[:space:]')
      [ -z "$v" ] && [ -r "${IONT_ETC}/machine_display" ] && v=$(tr -d '[:space:]' < "${IONT_ETC}/machine_display")
      [ -z "$v" ] && v=hdmi-auto
      ;;
    output)
      v=$(_display_conf_get OUTPUT | tail -1 | tr -d '[:space:]')
      [ -z "$v" ] && [ -r "${IONT_ETC}/machine_display_output" ] && v=$(tr -d '[:space:]' < "${IONT_ETC}/machine_display_output")
      [ -z "$v" ] && v=auto
      ;;
  esac
  printf '%s' "$v"
}

cmd_display() {
  case "${1:-help}" in
    get-config)
      local rotation_deg
      rotation_deg=$(_display_conf_get ROTATION)
      [ -z "$rotation_deg" ] && rotation_deg=0
      # Migrate legacy name-based values to degrees
      case "$rotation_deg" in
        normal) rotation_deg=0 ;;
        right)  rotation_deg=90 ;;
        inverted) rotation_deg=180 ;;
        left)   rotation_deg=270 ;;
      esac

      local res_x res_y
      res_x=$(_display_conf_get RESOLUTION_X)
      res_y=$(_display_conf_get RESOLUTION_Y)

      # When no resolution has been pinned, we still want the consumer
      # (admin UI) to know the current mode. Report 0 — the admin layer
      # already falls back to /sys/class/drm for the live read. Avoiding
      # an extra read here keeps `evctl display get-config` cheap (the
      # service start-up path calls it repeatedly).
      [ -z "$res_x" ] && res_x=0
      [ -z "$res_y" ] && res_y=0

      local display output
      display=$(_display_resolve profile)
      output=$(_display_resolve output)

      json_ok "$(json_kv \
        "rotation=${rotation_deg}" \
        "rotation_deg=${rotation_deg}" \
        "resolution_x_px=${res_x}" \
        "resolution_y_px=${res_y}" \
        "display=${display}" \
        "output=${output}")"
      ;;

    set-config)
      shift  # drop "set-config"

      # --dry-run: still write DISPLAY_CONF (so callers/tests can inspect
      # the result), but skip the apply step below (service restarts +
      # reboot scheduling). Accepted anywhere in the arg list.
      local dry_run=0
      local -a rest=()
      for a in "$@"; do
        case "$a" in
          --dry-run) dry_run=1 ;;
          *) rest+=("$a") ;;
        esac
      done
      set -- "${rest[@]}"

      # Two calling conventions for backwards-compat:
      #   evctl display set-config <rotation>
      #   evctl display set-config rotation_deg=90 resolution_x_px=1920 resolution_y_px=1080
      #   evctl display set-config DISPLAY=<profile> OUTPUT=<hdmi0|hdmi1|auto> [ROTATION=<deg>]
      # If the first remaining arg has no `=`, treat it as the positional
      # rotation (old API). Otherwise parse all args as key=value pairs.
      if [ "$#" -ge 1 ] && [ "${1#*=}" = "$1" ]; then
        set -- "ROTATION=$1" "${@:2}"
      fi

      local prev_res_x prev_res_y
      prev_res_x=$(_display_conf_get RESOLUTION_X)
      prev_res_y=$(_display_conf_get RESOLUTION_Y)

      # Pass 1: parse + validate every arg WITHOUT writing anything, so an
      # invalid arg anywhere in the list (e.g. a bogus DISPLAY) leaves
      # DISPLAY_CONF untouched instead of applying a partial update.
      local -a up_keys=() up_vals=()
      local -a unset_keys=()
      local display_changed=0 output_changed=0
      local res_x="" res_y=""
      while [ "$#" -gt 0 ]; do
        case "$1" in
          rotation=*|rotation_deg=*|ROTATION=*)
            local rotation
            if ! rotation=$(_display_normalize_rotation "${1#*=}"); then
              json_error "Invalid rotation (use 0/90/180/270 or normal/left/right/inverted)"
              return 1
            fi
            up_keys+=(ROTATION); up_vals+=("$rotation")
            ;;
          resolution_x=*|resolution_x_px=*|RESOLUTION_X=*)
            res_x="${1#*=}"
            if ! echo "$res_x" | grep -qE '^[0-9]+$'; then
              json_error "Invalid resolution_x: $res_x (must be a non-negative integer)"
              return 1
            fi
            ;;
          resolution_y=*|resolution_y_px=*|RESOLUTION_Y=*)
            res_y="${1#*=}"
            if ! echo "$res_y" | grep -qE '^[0-9]+$'; then
              json_error "Invalid resolution_y: $res_y (must be a non-negative integer)"
              return 1
            fi
            ;;
          DISPLAY=*)
            local disp="${1#*=}"
            if ! _display_valid_profile "$disp"; then
              json_error "Invalid DISPLAY profile: $disp"
              return 1
            fi
            [ "$disp" != "$(_display_resolve profile)" ] && display_changed=1
            up_keys+=(DISPLAY); up_vals+=("$disp")
            ;;
          OUTPUT=*)
            local out="${1#*=}"
            if ! _display_valid_output "$out"; then
              json_error "Invalid OUTPUT: $out (use hdmi0/hdmi1/auto)"
              return 1
            fi
            [ "$out" != "$(_display_resolve output)" ] && output_changed=1
            up_keys+=(OUTPUT); up_vals+=("$out")
            ;;
          *)
            json_error "Unknown set-config arg: $1"
            return 1
            ;;
        esac
        shift
      done

      # Resolution: only one of width/height supplied is an error; both
      # zero/empty means "clear pin, use native". Anything else must be
      # two non-negative integers. Only queue a write when the caller
      # actually touched resolution.
      if [ -n "$res_x$res_y" ]; then
        # One was set but the other wasn't on the CLI; inherit the missing
        # half from prior config so callers can update axes independently.
        [ -z "$res_x" ] && res_x="${prev_res_x:-0}"
        [ -z "$res_y" ] && res_y="${prev_res_y:-0}"
        if [ "$res_x" != "0" ] && [ "$res_y" != "0" ]; then
          up_keys+=(RESOLUTION_X); up_vals+=("$res_x")
          up_keys+=(RESOLUTION_Y); up_vals+=("$res_y")
        else
          # Both-zero means "clear the pin" — drop the keys entirely.
          unset_keys+=(RESOLUTION_X RESOLUTION_Y)
        fi
      fi

      # Pass 2: everything validated — now write.
      local i
      for ((i = 0; i < ${#up_keys[@]}; i++)); do
        _display_conf_upsert "${up_keys[$i]}" "${up_vals[$i]}"
      done
      for k in "${unset_keys[@]}"; do
        _display_conf_unset "$k"
      done

      # Persistence is the file we just wrote. display.service's own watch
      # loop re-reads /data/config/display.conf each tick and re-applies a
      # ROTATION/RESOLUTION change live via wlr-randr — no restart needed
      # for those. DISPLAY/OUTPUT are different: config.txt (panel timing)
      # is read by the Pi firmware only at boot, so those need the apply
      # step below.
      if [ "$dry_run" = 1 ]; then
        echo "[display] --dry-run: wrote ${up_keys[*]:-<nothing>} to $DISPLAY_CONF; skipping apply" >&2
      else
        echo "[display] set-config applying: ${up_keys[*]:-<nothing>}" >&2
        $SUDO systemctl restart firmware-config.service >&2 || \
          echo "[display] warning: firmware-config.service restart failed" >&2
        $SUDO systemctl try-restart display kiosk >&2 || \
          echo "[display] warning: display/kiosk try-restart failed" >&2
        if [ "$display_changed" = 1 ] || [ "$output_changed" = 1 ]; then
          echo "[display] DISPLAY/OUTPUT changed — scheduling reboot to apply boot-time config.txt" >&2
          $SUDO systemd-run --on-active=2 systemctl reboot >&2 || \
            echo "[display] warning: failed to schedule reboot" >&2
        fi
      fi

      local rotation_deg res_x_out res_y_out display_out output_out
      rotation_deg=$(_display_conf_get ROTATION); [ -z "$rotation_deg" ] && rotation_deg=0
      res_x_out=$(_display_conf_get RESOLUTION_X); [ -z "$res_x_out" ] && res_x_out=0
      res_y_out=$(_display_conf_get RESOLUTION_Y); [ -z "$res_y_out" ] && res_y_out=0
      display_out=$(_display_resolve profile)
      output_out=$(_display_resolve output)

      json_ok "$(json_kv \
        "rotation=$rotation_deg" \
        "rotation_deg=$rotation_deg" \
        "resolution_x_px=$res_x_out" \
        "resolution_y_px=$res_y_out" \
        "display=$display_out" \
        "output=$output_out" \
        "dry_run=$([ "$dry_run" = 1 ] && echo true || echo false)" \
        "applied=true")"
      ;;

    screenshot)
      # Capture the kiosk's display via grim (wlroots screenshot tool).
      # BINARY-on-stdout pattern: PNG to stdout, status/errors to stderr,
      # so callers (admin server, scripts) can pipe stdout directly into
      # an HTTP response or a file. The kiosk session runs as root with
      # XDG_RUNTIME_DIR=/run/user/0 (see modules/display.nix), so we need
      # to point grim at that runtime dir + find its wayland socket.
      #
      # When evctl is invoked as root (e.g. `sudo evctl display screenshot`
      # from the admin server, which itself runs as the unprivileged evinfra
      # user), the inherited XDG_RUNTIME_DIR may point at the *caller's* dir
      # (/run/user/1000) where no wayland socket exists — and which root would
      # otherwise scan fruitlessly. The compositor's socket is always under
      # /run/user/0, so force that when we're root.
      local runtime_dir="${XDG_RUNTIME_DIR:-/run/user/0}"
      if [ "$(id -u)" = "0" ]; then
        runtime_dir=/run/user/0
      fi
      local socket=""
      # Pick the first non-lock wayland socket in the runtime dir. Cage
      # creates exactly one; the loop is defensive against multi-session
      # setups we don't currently use.
      for sock in "$runtime_dir"/wayland-*; do
        case "$sock" in
          *.lock) continue ;;
        esac
        if [ -S "$sock" ]; then
          socket="$(basename "$sock")"
          break
        fi
      done
      if [ -z "$socket" ]; then
        echo "evctl: no wayland socket in $runtime_dir — is the display service running?" >&2
        return 1
      fi
      if ! command -v grim >/dev/null 2>&1; then
        echo "evctl: grim is not installed (add it to environment.systemPackages in modules/display.nix)" >&2
        return 1
      fi
      # `grim -` writes PNG to stdout. We must inherit the kiosk's
      # XDG_RUNTIME_DIR so the wayland socket is discoverable.
      XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY="$socket" grim -t png -
      ;;

    list-modes)
      # Enumerate available modes for the first connected DRM connector.
      # We deliberately stay off Wayland tooling (wlr-randr) here because
      # this command can be called before the kiosk session is running
      # (e.g. from a recovery shell, or before /run/user/0/wayland-* exists).
      # /sys/class/drm/<card>/modes is populated by the kernel as soon as
      # the connector reports EDID and is available at every boot stage.
      #
      # Output shape:
      #   { ok: true, data: { source: "drm"|"fallback", modes: [{width, height}, ...] } }
      python3 - "$DISPLAY_FALLBACK_MODES" <<'PY'
import json
import os
import re
import sys

FALLBACK = json.loads(sys.argv[1])

def enumerate_drm():
    base = '/sys/class/drm'
    try:
        entries = sorted(os.listdir(base))
    except FileNotFoundError:
        return []
    seen = set()
    out = []
    for entry in entries:
        # connector dirs look like "card1-HDMI-A-1"
        if '-' not in entry or not entry.startswith('card'):
            continue
        status_path = os.path.join(base, entry, 'status')
        modes_path = os.path.join(base, entry, 'modes')
        try:
            with open(status_path) as f:
                status = f.read().strip()
        except OSError:
            continue
        if status != 'connected':
            continue
        try:
            with open(modes_path) as f:
                modes_text = f.read()
        except OSError:
            continue
        for line in modes_text.splitlines():
            m = re.match(r'^\s*(\d+)x(\d+)', line.strip())
            if not m:
                continue
            w, h = int(m.group(1)), int(m.group(2))
            if (w, h) in seen:
                continue
            seen.add((w, h))
            out.append({"width": w, "height": h})
    return out

modes = enumerate_drm()
if modes:
    source = "drm"
else:
    source = "fallback"
    modes = [{"width": w, "height": h} for (w, h) in FALLBACK]

print(json.dumps({"ok": True,
                  "data": {"source": source, "modes": modes}}))
PY
      ;;

    help|*)
      cat <<HELP
evctl display <command>

Commands:
  get-config                            Get current rotation/resolution pin +
                                        resolved DISPLAY profile/OUTPUT.
  set-config [--dry-run] <args>         Set display configuration. Accepts:
      <rotation>                        (legacy) positional 0/90/180/270
      rotation_deg=<deg>|ROTATION=<deg> rotation in degrees (0/90/180/270)
      resolution_x_px=<W>|RESOLUTION_X=<W>  horizontal resolution in pixels
      resolution_y_px=<H>|RESOLUTION_Y=<H>  vertical resolution in pixels
                                        Use both 0 to clear the pin and let
                                        the compositor pick the native mode.
      DISPLAY=<profile>                display profile (config/displays/*
                                        basename) or "none" for headless
      OUTPUT=<hdmi0|hdmi1|auto>         forced HDMI output connector
                                        DISPLAY/OUTPUT changes restart
                                        firmware-config + display/kiosk and
                                        schedule a reboot to apply boot-time
                                        config.txt/cmdline (skipped with
                                        --dry-run).
  list-modes                            Enumerate supported resolutions from
                                        /sys/class/drm (fallback: curated
                                        list of standard sizes).
  screenshot                            Capture the kiosk display as PNG and
                                        write it to stdout (binary). Errors
                                        go to stderr.
HELP
      ;;
  esac
}

# ── antenna ────────────────────────────────────────────────────────────────
#
# WiFi/Bluetooth antenna selection (CM5 only). The CM5 module shares one RF
# path between WiFi and BT and switches it with two GPIO hogs declared in
# bcm2712-rpi-cm5-*.dtb: ant1 (on-module PCB antenna, the DTB default) and
# ant2 (external U.FL connector). `dtparam=ant2` in config.txt makes the Pi
# firmware flip the hogs before the kernel is handed the DTB, so the
# selection only takes effect on the next boot — the kernel claims the
# hogged lines and userspace cannot re-drive them.
#
# Selection lives in /data/config/hardware.conf (ANTENNA=internal|external),
# resolved by resolve-antenna; modules/platform/cm5.nix reads that same
# resolver from installBootLoader and firmware-config.service when it
# assembles config.txt.

HARDWARE_CONF="${EVI_HARDWARE_CONF:-${CONFIG_DIR}/hardware.conf}"
DEVICE_TREE="${EVI_DEVICE_TREE:-/proc/device-tree}"

# Does this board have an antenna switch at all? Ground truth is the live
# device tree rather than machine_platform: the CM5 DTBs declare ant1/ant2
# hogs, bcm2711-rpi-4-b.dtb declares none. A misfiled platform marker
# therefore can't make us write a dtparam the firmware would ignore.
_antenna_supported() {
  compgen -G "${DEVICE_TREE}"/*/gpio@*/ant2-hog >/dev/null 2>&1
}

# Which antenna the RUNNING kernel actually booted with. The firmware
# rewrites the hogs in place for dtparam=ant2 (ant2-hog gains output-high,
# ant1-hog gains output-low), so the property that is present tells us the
# applied state — which is what distinguishes "saved" from "in effect".
_antenna_active() {
  local hog
  for hog in "${DEVICE_TREE}"/*/gpio@*/ant2-hog; do
    [ -e "$hog/output-high" ] && { printf 'external'; return; }
  done
  printf 'internal'
}

# Configured (not necessarily active) selection. Prefers the packaged
# resolve-antenna — the single source of truth also used by cm5.nix — and
# falls back to an inline mirror for a repo-less/standalone evctl copy.
_antenna_configured() {
  if command -v resolve-antenna >/dev/null 2>&1; then
    HARDWARE_CONF="$HARDWARE_CONF" resolve-antenna
    return
  fi
  local v=""
  [ -r "$HARDWARE_CONF" ] && \
    v=$(sed -n 's/^ANTENNA=//p' "$HARDWARE_CONF" 2>/dev/null | tail -1 | tr -d '[:space:]')
  case "$v" in
    external) printf 'external' ;;
    *)        printf 'internal' ;;
  esac
}

# Upsert ANTENNA=<value>, preserving every other line in hardware.conf —
# the file is shared with any future hardware knob.
_antenna_conf_upsert() { # _antenna_conf_upsert VALUE
  mkdir -p "$(dirname "$HARDWARE_CONF")"
  touch "$HARDWARE_CONF"
  if grep -q '^ANTENNA=' "$HARDWARE_CONF" 2>/dev/null; then
    sed -i "s|^ANTENNA=.*|ANTENNA=$1|" "$HARDWARE_CONF"
  else
    printf 'ANTENNA=%s\n' "$1" >> "$HARDWARE_CONF"
  fi
}

_antenna_status_json() { # _antenna_status_json [extra json_kv args...]
  local configured active supported
  configured=$(_antenna_configured)
  active=$(_antenna_active)
  supported=$(_antenna_supported && echo true || echo false)
  json_ok "$(json_kv \
    "antenna=${configured}" \
    "active=${active}" \
    "supported=${supported}" \
    "reboot_required=$([ "$configured" != "$active" ] && echo true || echo false)" \
    "$@")"
}

cmd_antenna() {
  case "${1:-help}" in
    get-config)
      _antenna_status_json
      ;;

    set-config)
      shift

      # --dry-run: still write hardware.conf (so callers/tests can inspect
      # the result) but skip the apply step. Accepted anywhere in the args.
      local dry_run=0
      local -a rest=()
      for a in "$@"; do
        case "$a" in
          --dry-run) dry_run=1 ;;
          *) rest+=("$a") ;;
        esac
      done
      set -- "${rest[@]}"

      # Two calling conventions, matching `display set-config`:
      #   evctl antenna set-config external
      #   evctl antenna set-config ANTENNA=external
      if [ "$#" -ge 1 ] && [ "${1#*=}" = "$1" ]; then
        set -- "ANTENNA=$1" "${@:2}"
      fi

      local want=""
      while [ "$#" -gt 0 ]; do
        case "$1" in
          ANTENNA=*|antenna=*) want="${1#*=}" ;;
          *) json_error "Unknown set-config arg: $1"; return 1 ;;
        esac
        shift
      done

      if [ -z "$want" ]; then
        json_error "Empty antenna payload — expected ANTENNA=internal|external"
        return 1
      fi
      case "$want" in
        internal|external) ;;
        *) json_error "Invalid antenna '$want' (expected internal or external)"; return 1 ;;
      esac

      # Validate the board BEFORE writing: on a platform with no antenna
      # switch the dtparam would be silently ignored by the firmware, so a
      # stored selection would read back as applied when nothing changed.
      if ! _antenna_supported; then
        json_error "This platform has no WiFi antenna switch (CM5 only)"
        return 1
      fi

      _antenna_conf_upsert "$want"

      # config.txt is only read by the Pi firmware at boot, so there is no
      # live apply: restage it now (so the staged boot config matches the
      # selection even if the operator pulls power instead of rebooting),
      # then schedule the reboot that actually flips the switch.
      if [ "$dry_run" = 1 ]; then
        echo "[antenna] --dry-run: wrote ANTENNA=$want to $HARDWARE_CONF; skipping apply" >&2
      elif [ "$want" = "$(_antenna_active)" ]; then
        echo "[antenna] already running on $want — restaging config.txt, no reboot needed" >&2
        $SUDO systemctl restart firmware-config.service >&2 || \
          echo "[antenna] warning: firmware-config.service restart failed" >&2
      else
        echo "[antenna] set to $want — scheduling reboot to apply boot-time config.txt" >&2
        $SUDO systemctl restart firmware-config.service >&2 || \
          echo "[antenna] warning: firmware-config.service restart failed" >&2
        $SUDO systemd-run --on-active=2 systemctl reboot >&2 || \
          echo "[antenna] warning: failed to schedule reboot" >&2
      fi

      _antenna_status_json \
        "dry_run=$([ "$dry_run" = 1 ] && echo true || echo false)" \
        "applied=true"
      ;;

    help|*)
      cat <<'HELP'
evctl antenna — WiFi/Bluetooth antenna selection (CM5 only)

  get-config                     Report the configured antenna, the one the
                                 running kernel booted with, whether this
                                 board has a switch at all, and whether a
                                 reboot is still pending.
  set-config [--dry-run] <sel>   Select internal (on-module PCB antenna) or
                                 external (U.FL connector). Accepts either
                                 `external` or `ANTENNA=external`. Restages
                                 config.txt and schedules a reboot — the
                                 switch is driven by kernel-claimed GPIO
                                 hogs and cannot be flipped while running.
HELP
      ;;
  esac
}

# ── support ────────────────────────────────────────────────────────────────
#
# Diagnostic bundle for IONT support. Unlike every other evctl command, the
# `package` subcommand emits a BINARY tar.xz on stdout (or to a file path).
# Status messages go to stderr so callers can pipe stdout straight into a
# file or HTTP response.

cmd_support() {
  case "${1:-help}" in
    package)
      shift
      support_package "$@"
      ;;

    help|*)
      cat <<HELP
EV Infrastructure Support

Usage: evctl support <command> [args...]

Commands:
  package [path]   Generate diagnostic bundle (tar.xz). Writes to <path>
                   if given, otherwise streams to stdout. NOTE: stdout is
                   BINARY — refuses to run if stdout is a TTY.

Bundle contents:
  system-info.txt                            Machine identity, uptime, df, free, services
  manifest.txt                               Per-file source path / MISSING marker
  journal/all-24h.log                        journalctl --since "24 hours ago"
                                             (single dump — already a superset
                                              of any per-unit slice)
  firmware/config.json                       First found among:
                                               /data/firmware/firmware/config.json
                                               /data/firmware/config.json
  firmware/database                          Online sqlite3 .backup of:
                                               /data/firmware/firmware/database
                                               /data/firmware/database
                                               (.sqlite suffixes also probed)
  device-admin/kiosk-appearance.json         First found among:
  device-admin/developer-settings.json         /data/firmware/device-admin/<f>
                                               /data/config/device-admin/<f>

Subdirectories that have no source files on this device are NOT created
(the bundle won't ship empty `firmware/` or `device-admin/` directories).
HELP
      ;;
  esac
}

support_package() {
  local out_path="${1:-}"

  # Refuse to corrupt a terminal with binary output.
  if [ -z "$out_path" ] && [ -t 1 ]; then
    echo "evctl support package: refusing to stream tar.xz to a terminal — pipe to a file or pass <path>" >&2
    return 1
  fi

  local stage
  stage=$(mktemp -d -t evctl-support-XXXXXX) || {
    echo "evctl support package: mktemp failed" >&2
    return 1
  }
  # Cleanup on any exit from this function.
  trap "rm -rf '$stage'" RETURN

  local timestamp
  timestamp=$(date +%Y%m%d-%H%M%S)
  local bundle="support-${MACHINE_ID}-${timestamp}"
  local root="$stage/$bundle"
  mkdir -p "$root"

  echo "[support] collecting system info..." >&2
  {
    echo "=== Machine ID ==="
    echo "$MACHINE_ID"
    echo
    echo "=== Hostname ==="
    hostname 2>&1 || true
    echo
    echo "=== Date ==="
    date
    echo
    echo "=== Uptime ==="
    uptime 2>&1 || true
    echo
    echo "=== NixOS Version ==="
    cat /etc/os-release 2>/dev/null || true
    echo
    echo "=== Machine Version ==="
    cat /data/config/machine_version 2>/dev/null || true
    echo
    echo "=== Machine Model ==="
    cat /data/config/machine_model 2>/dev/null || true
    echo
    echo "=== Machine Platform ==="
    cat /data/config/machine_platform 2>/dev/null || true
    echo
    echo "=== Update Channel ==="
    cat /data/config/update_channel 2>/dev/null || true
    echo
    echo "=== Disk Usage ==="
    df -h 2>&1 || true
    echo
    echo "=== Memory ==="
    free -h 2>&1 || true
    echo
    echo "=== Network ==="
    ip addr 2>&1 || true
    echo
    echo "=== Services ==="
    systemctl status firmware admin kiosk deploy data-setup nginx-https hotspot wireguard --no-pager 2>&1 || true
  } > "$root/system-info.txt"

  echo "[support] collecting journal..." >&2
  mkdir -p "$root/journal"
  # Single 24h system journal dump. journalctl with no -u filter already
  # contains every unit's output, so per-unit slices were strict subsets
  # plus extra files; one dump captures the same information and any
  # cross-unit interactions visible only at the system level.
  journalctl --since "24 hours ago" --no-pager > "$root/journal/all-24h.log" 2>&1 || true

  # Track which expected files we found so we can (a) skip creating empty
  # directories and (b) leave a manifest in the bundle showing what came
  # from where — no more silent empty bundles when paths drift.
  local manifest="$root/manifest.txt"
  : > "$manifest"
  manifest_log() { printf '%s\n' "$1" >> "$manifest"; }

  echo "[support] collecting firmware config + database..." >&2
  # Firmware writes config.json and the SQLite charging-record database
  # inside its WorkingDirectory (services.nix sets it to
  # /data/firmware/firmware), so the canonical paths are below. We probe a
  # short list of candidates so a future layout change doesn't silently
  # ship an empty bundle. The DB filename has no extension — literally
  # "database" — see IONT_DATABASE_FILENAME in iont_database.hpp.
  local fw_dir_created=0
  local fw_config_src=""
  for cand in /data/firmware/firmware/config.json /data/firmware/config.json; do
    if [ -f "$cand" ]; then fw_config_src="$cand"; break; fi
  done
  if [ -n "$fw_config_src" ]; then
    mkdir -p "$root/firmware"; fw_dir_created=1
    cp "$fw_config_src" "$root/firmware/config.json" 2>/dev/null \
      && manifest_log "firmware/config.json   <- $fw_config_src" \
      || manifest_log "firmware/config.json   FAILED to copy from $fw_config_src"
  else
    manifest_log "firmware/config.json   MISSING (none of: /data/firmware/firmware/config.json /data/firmware/config.json)"
  fi

  local fw_db_src=""
  for cand in /data/firmware/firmware/database /data/firmware/database \
              /data/firmware/firmware/database.sqlite /data/firmware/database.sqlite; do
    if [ -f "$cand" ]; then fw_db_src="$cand"; break; fi
  done
  if [ -n "$fw_db_src" ]; then
    [ "$fw_dir_created" -eq 1 ] || { mkdir -p "$root/firmware"; fw_dir_created=1; }
    local copied=0
    if command -v sqlite3 >/dev/null 2>&1; then
      sqlite3 "$fw_db_src" ".backup '$root/firmware/database'" 2>/dev/null && copied=1
    fi
    if [ "$copied" -ne 1 ]; then
      cp "$fw_db_src" "$root/firmware/database" 2>/dev/null && copied=1
    fi
    if [ "$copied" -eq 1 ]; then
      manifest_log "firmware/database      <- $fw_db_src"
    else
      manifest_log "firmware/database      FAILED to snapshot from $fw_db_src"
    fi
  else
    manifest_log "firmware/database      MISSING (no candidate found under /data/firmware)"
  fi

  echo "[support] collecting device-admin config..." >&2
  local da_dir_created=0
  for f in kiosk-appearance.json developer-settings.json; do
    local da_src=""
    for cand in "/data/firmware/device-admin/$f" "/data/config/device-admin/$f"; do
      if [ -f "$cand" ]; then da_src="$cand"; break; fi
    done
    if [ -n "$da_src" ]; then
      [ "$da_dir_created" -eq 1 ] || { mkdir -p "$root/device-admin"; da_dir_created=1; }
      cp "$da_src" "$root/device-admin/$f" 2>/dev/null \
        && manifest_log "device-admin/$f   <- $da_src" \
        || manifest_log "device-admin/$f   FAILED to copy from $da_src"
    else
      manifest_log "device-admin/$f   MISSING"
    fi
  done

  echo "[support] compressing..." >&2
  if [ -n "$out_path" ]; then
    XZ_OPT="-T0" tar -cJf "$out_path" -C "$stage" "$bundle" >&2 || {
      echo "evctl support package: tar failed" >&2
      return 1
    }
    echo "[support] wrote $out_path" >&2
  else
    XZ_OPT="-T0" tar -cJf - -C "$stage" "$bundle" || {
      echo "evctl support package: tar failed" >&2
      return 1
    }
  fi
}

# ── gpio ──────────────────────────────────────────────────────────────────
#
# GPIO test mode control — wraps the json socket protocol so callers don't
# have to speak it directly. Daemon runs as systemd Type=notify service,
# listens on /run/iont/gpio-test.sock, and exposes {list,status,set,release,
# shutdown,subscribe_inputs} operations.
#
# State files at /run/iont/gpio-test.{lock,heartbeat,sock} — all tmpfs.

GPIO_LOCK=/run/iont/gpio-test.lock
GPIO_HEARTBEAT=/run/iont/gpio-test.heartbeat
GPIO_SOCK=/run/iont/gpio-test.sock

_gpio_test_active() {
  [ -f "$GPIO_LOCK" ]
}

_gpio_send() {
  # Send a JSON request to the daemon and emit its JSON response. Returns
  # non-zero if the socket isn't there or the daemon is unreachable.
  if [ ! -S "$GPIO_SOCK" ]; then
    return 1
  fi
  printf '%s\n' "$1" | ${PYTHON:-python3} -c '
import socket, sys
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect("'"$GPIO_SOCK"'")
sock.sendall(sys.stdin.buffer.read())
data = sock.recv(1 << 20).decode("utf-8").strip()
print(data)
'
}

cmd_gpio() {
  case "${1:-help}" in
    list)
      _gpio_send '{"op":"list"}'
      ;;
    status)
      if _gpio_test_active; then
        _gpio_send '{"op":"status"}'
      else
        json_ok "$(json_kv "test_mode=false")"
      fi
      ;;
    test)
      shift
      case "${1:-help}" in
        start)
          if _gpio_test_active; then
            json_ok "$(json_kv "already_active=true")"
            return 0
          fi
          local _stop_err
          if ! _stop_err=$($SUDO systemctl stop firmware.service 2>&1); then
            json_error "cannot stop firmware (permission?): $_stop_err"
            return 1
          fi
          local _fw_iters=0
          while systemctl is-active firmware.service >/dev/null 2>&1; do
            _fw_iters=$((_fw_iters + 1))
            if [ "$_fw_iters" -ge 120 ]; then
              json_error "firmware did not stop in 60s"
              $SUDO systemctl start firmware.service >/dev/null 2>&1 || true
              return 1
            fi
            sleep 0.5
          done
          local _daemon_err
          if ! _daemon_err=$($SUDO systemctl start gpio-test-daemon.service 2>&1); then
            json_error "cannot start gpio-test-daemon: $_daemon_err"
            $SUDO systemctl start firmware.service >/dev/null 2>&1 || true
            return 1
          fi
          # Wait for the socket to appear (daemon's Type=notify means
          # systemctl is-active goes 'active' only after READY=1).
          local _sock_iters=0
          for _ in $(seq 1 50); do
            [ -S "$GPIO_SOCK" ] && break
            _sock_iters=$((_sock_iters + 1))
            sleep 0.1
          done
          if [ ! -S "$GPIO_SOCK" ]; then
            json_error "gpio-test daemon did not come up"
            $SUDO systemctl stop gpio-test-daemon.service >/dev/null 2>&1 || true
            $SUDO systemctl start firmware.service >/dev/null 2>&1 || true
            return 1
          fi
          touch "$GPIO_HEARTBEAT"
          echo "$$" > "$GPIO_LOCK"
          json_ok "$(json_kv "test_mode=true")"
          ;;
        stop)
          if _gpio_test_active; then
            _gpio_send '{"op":"shutdown"}' >/dev/null 2>&1 || true
            for _ in $(seq 1 50); do
              [ -S "$GPIO_SOCK" ] || break
              sleep 0.1
            done
            $SUDO systemctl stop gpio-test-daemon.service >/dev/null 2>&1 || true
            rm -f "$GPIO_LOCK"
            local _start_err
            if ! _start_err=$($SUDO systemctl start firmware.service 2>&1); then
              json_error "test mode stopped but firmware restart failed: $_start_err"
              return 1
            fi
            json_ok "$(json_kv "test_mode=false")"
          else
            json_ok "$(json_kv "test_mode=false" "already_inactive=true")"
          fi
          ;;
        heartbeat)
          if ! _gpio_test_active; then
            json_error "test_mode_inactive"
            return 1
          fi
          touch "$GPIO_HEARTBEAT"
          json_ok "$(json_kv "ok=true")"
          ;;
        status)
          if _gpio_test_active; then
            local HB_AGE=$(($(date +%s) - $(stat -c %Y "$GPIO_HEARTBEAT" 2>/dev/null || echo 0)))
            json_ok "$(json_kv "test_mode=true" "heartbeat_age_s=$HB_AGE")"
          else
            json_ok "$(json_kv "test_mode=false")"
          fi
          ;;
        *)
          json_error "unknown test subcommand: ${1:-}"
          ;;
      esac
      ;;
    set)
      if ! _gpio_test_active; then
        json_error "test_mode_inactive"
        return 1
      fi
      # Args: <chip> <offset> <direction> <bias> <value>
      shift
      local req
      req=$(printf '{"op":"set","chip":"%s","offset":%s,"direction":"%s","bias":"%s","value":%s}' \
                    "$1" "$2" "$3" "$4" "$5")
      _gpio_send "$req"
      ;;
    release)
      if ! _gpio_test_active; then
        json_error "test_mode_inactive"
        return 1
      fi
      shift
      local req
      req=$(printf '{"op":"release","chip":"%s","offset":%s}' "$1" "$2")
      _gpio_send "$req"
      ;;
    watch)
      # Root because claimed lines' levels only come from debugfs.
      shift
      exec $SUDO -n /run/current-system/sw/bin/gpio-watch --json "$@"
      ;;
    help|*)
      cat <<HELP
Usage: evctl gpio <subcommand>
  list                                       — JSON enumeration of every GPIO line (does NOT require test mode)
  status                                     — JSON {test_mode, heartbeat_age_s, …}
  test start                                 — stops firmware + launches daemon
  test stop                                  — releases lines + restarts firmware
  test heartbeat                             — refreshes the watchdog timer
  set <chip> <offset> <dir> <bias> <value>   — claim/configure a line (test mode required)
  release <chip> <offset>                    — release a line (test mode required)
  watch [--interval S]                       — stream every line's level/mode/owner as NDJSON (no test mode needed)
HELP
      ;;
  esac
}

# shellcheck shell=bash
# NOTE: fragment injected into the Debian evctl (which has the shebang) at
# assemble time — not run standalone.
# cmd_update — legacy-updater control surface for migrated Debian devices.
# Injected into the Debian evctl at assemble time. Updates on legacy devices go
# through the existing iont.tech .tar.gz updater (NOT sysupgrade): this exposes
# a JSON control surface over it, matching evctl's other categories so the
# new device-admin can drive check/start/status.
#
# Legacy updater contract (factory/device-rpi-installation/*/updater):
#   trigger : systemctl start update-firmware@install.service  (→ main install)
#   latest  : curl https://cdn.iont.info/firmware/<rpi>/<channel>/latest
#   in-flight: /home/iont.tech/updater/is_updating == "true"
cmd_update() {
  local UPD_DIR=/home/iont.tech/updater
  local LEGACY_CFG=/home/iont.tech/config.json
  case "${1:-help}" in
    status)
      local updating=false ver rpi
      [ "$(cat "$UPD_DIR/is_updating" 2>/dev/null)" = "true" ] && updating=true
      ver=$(cat /data/config/machine_version 2>/dev/null || echo unknown)
      rpi=$(jq -r '.rpi // "unknown"' "$LEGACY_CFG" 2>/dev/null || echo unknown)
      json_ok "$(python3 -c 'import json,sys; print(json.dumps({"updating":sys.argv[1]=="true","version":sys.argv[2],"rpi":sys.argv[3],"mechanism":"legacy-updater"}))' "$updating" "$ver" "$rpi")"
      ;;
    check)
      local rpi channel latest cur
      channel="${2:-stable}"
      rpi=$(jq -r '.rpi // empty' "$LEGACY_CFG" 2>/dev/null)
      [ -n "$rpi" ] || { json_error "no .rpi in $LEGACY_CFG"; return; }
      cur=$(cat /data/config/machine_version 2>/dev/null || echo unknown)
      latest=$(curl -s --max-time 20 "https://cdn.iont.info/firmware/${rpi}/${channel}/latest" 2>/dev/null | tr -d '\r')
      [ -n "$latest" ] || { json_error "could not fetch latest for ${rpi}/${channel}"; return; }
      json_ok "$(python3 -c 'import json,sys; print(json.dumps({"current":sys.argv[1],"latest":sys.argv[2],"update_available":sys.argv[1] not in (sys.argv[2], sys.argv[2].lstrip("tv"))}))' "$cur" "$latest")"
      ;;
    start)
      # Kick the legacy updater (async oneshot). channel selection is encoded by
      # the version prefix on the CDN (v=stable, t=testing); `install` picks the
      # published `latest` for the device's rpi model.
      if [ "$(cat "$UPD_DIR/is_updating" 2>/dev/null)" = "true" ]; then
        json_error "an update is already in progress"; return
      fi
      # stable/latest on the legacy CDN still points at a 2.x package until the
      # rollout ends; installing that here would put the 2.x tree back over a
      # migrated station. Only accept a 3.x (or later) package.
      local rpi latest major
      rpi=$(jq -r '.rpi // empty' "$LEGACY_CFG" 2>/dev/null)
      [ -n "$rpi" ] || { json_error "no .rpi in $LEGACY_CFG"; return; }
      latest=$(curl -s --max-time 20 "https://cdn.iont.info/firmware/${rpi}/stable/latest" 2>/dev/null | tr -d '\r\n')
      major=${latest#[tv]}; major=${major%%.*}
      case "$major" in
        ''|*[!0-9]*) json_error "could not read stable/latest for ${rpi}"; return ;;
      esac
      if [ "$major" -lt 3 ]; then
        json_error "stable/latest for ${rpi} is ${latest} (a 2.x legacy package); refusing"; return
      fi
      if ! err=$($SUDO systemctl start --no-block update-firmware@install.service 2>&1); then
        # fall back to invoking the updater directly if the template unit is absent
        if [ -x "$UPD_DIR/main" ] || [ -x "$UPD_DIR/main.sh" ]; then
          $SUDO bash -c "'$UPD_DIR'/main install '' '' >/dev/null 2>&1 &" 2>/dev/null || true
          json_ok '"update started (direct)"'
        else
          json_error "could not start legacy updater: $err"
        fi
        return
      fi
      json_ok '"update started"'
      ;;
    help|*)
      cat <<'HELP'
evctl update — legacy .tar.gz updater control (migrated Debian devices)

  status   — is an update running + current version + rpi model
  check    — compare current vs CDN `latest` for this device
  start    — trigger the legacy updater (install newest published)
HELP
      ;;
  esac
}

# ── main ───────────────────────────────────────────────────────────────────

case "${1:-help}" in
  system)   shift; cmd_system "$@" ;;
  update)   shift; cmd_update "$@" ;;
  wifi)     shift; cmd_wifi "$@" ;;
  hotspot)  shift; cmd_hotspot "$@" ;;
  network)  shift; cmd_network "$@" ;;
  display)  shift; cmd_display "$@" ;;
  antenna)  shift; cmd_antenna "$@" ;;
  gpio)     shift; cmd_gpio "$@" ;;
  support)  shift; cmd_support "$@" ;;
  help|*)
    cat <<HELP
EV Infrastructure Device Control

Usage: evctl <category> <command> [args...]

Categories:
  system    Reboot, restart services, timezone, NTP, time
  wifi      WiFi connection, scanning
  hotspot   Access point management
  network   IP configuration, connectivity, whitelists
  display   Display rotation
  antenna   WiFi/BT antenna selection (CM5)
  gpio      GPIO test mode control
  support   Diagnostic bundle (tar.xz)

Run 'evctl <category> help' for category-specific commands.
HELP
    ;;
esac
