#!/usr/bin/env bash
#=============================================================================
#  keel.sh — KeelOS marine bridge deployment                        [ BETA ]
#
#  One script. A few questions. Underway.
#
#  *** BETA NOTICE ***********************************************************
#  KeelOS is under active testing. THUS: this is Beta software. Expect rough
#  edges, keep backups, and do not make it the only instrumentation between
#  you and the water. Dry-run first: keel.sh --dry-run
#  ***************************************************************************
#
#  Turns the Linux you already run into a marine instrument bridge:
#  CAN bus, NMEA 2000, J1939, J1708 bridging, NMEA 0183, Signal K,
#  and a kiosk gauge dashboard — installing ONLY what your answers require.
#
#  Usage:
#    sudo ./keel.sh                    interactive wizard
#    sudo ./keel.sh --answers f.yml    unattended (fleet) install
#    sudo ./keel.sh --dry-run          show the plan, touch nothing
#    sudo ./keel.sh --uninstall        remove everything keel.sh added
#    ./keel.sh --detect                hardware probe report only (no root)
#    ./keel.sh --hat-guide             Waveshare / CAN HAT selection guide
#
#  Idempotent: re-run any time; it converges instead of clobbering.
#  Everything installed is recorded in $STATE_DIR/manifest for uninstall.
#
#  Version: 0.BETA — under active testing; verify against known-good instruments
#=============================================================================
set -Eeuo pipefail

readonly KEEL_VERSION="0.BETA-pi45.6-j1587-n2k-auditfix"
readonly KEEL_NODE_VERSION="22.23.2"  # Signal K requires Node >=22; Node 22 still supports ARMv7 + ARM64
readonly STATE_DIR="/var/lib/keelos"
readonly MANIFEST="${STATE_DIR}/manifest"
readonly ANSWER_CACHE="${STATE_DIR}/answers.yml"
readonly LOG_FILE="/var/log/keelos-install.log"

#--------------------------------------------------------------------------
# Colors (only when stdout is a terminal)
#--------------------------------------------------------------------------
if [[ -t 1 ]]; then
  C_RESET=$'\e[0m';  C_DIM=$'\e[2m'
  C_AMBER=$'\e[38;5;214m'; C_GREEN=$'\e[38;5;79m'
  C_RED=$'\e[38;5;203m';   C_BLUE=$'\e[38;5;110m'
  C_BOLD=$'\e[1m'
else
  C_RESET='' C_DIM='' C_AMBER='' C_GREEN='' C_RED='' C_BLUE='' C_BOLD=''
fi

say()  { printf '%s\n' "${1-}"; }
info() { say "${C_BLUE}::${C_RESET} ${1-}"; }
ok()   { say "${C_GREEN} +${C_RESET} ${1-}"; }
skip() { say "${C_DIM} -${C_RESET} ${C_DIM}${1-}${C_RESET}"; }
warn() { say "${C_AMBER} !${C_RESET} ${1-}"; }
die()  { say "${C_RED}xx${C_RESET} ${1-}" >&2; exit 1; }

log()  { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >> "$LOG_FILE" 2>/dev/null || true; }

on_err() {
  local line=$1
  say ""
  warn "keel.sh ran aground at line ${line}."
  warn "Log: ${LOG_FILE} — re-running is safe, the script converges."
}
trap 'on_err $LINENO' ERR

banner() {
cat <<'EOF'
     _  __         _  ___  ___
    | |/ /___  ___| |/ _ \/ __|      one script
    | ' </ -_)/ -_) | (_) \__ \      a few questions
    |_|\_\___|\___|_|\___/|___/      underway
EOF
say "    ${C_DIM}marine bridge deployment · v${KEEL_VERSION}${C_RESET} ${C_AMBER}${C_BOLD}[BETA]${C_RESET}"
say "    ${C_AMBER}Under testing — Beta software. Not for primary navigation.${C_RESET}"
say ""
}

#--------------------------------------------------------------------------
# Globals set by detection / wizard
#--------------------------------------------------------------------------
DRY_RUN=0
UNINSTALL=0
DETECT_ONLY=0
HAT_GUIDE_ONLY=0
ASSUME_YES=0
ANSWER_FILE=""

PKG_MGR=""            # apt | dnf | pacman
BOOT_CONFIG=""        # /boot/firmware/config.txt or /boot/config.txt
PI_MODEL=""           # human-readable, empty if not a Pi
PI_GEN=0               # 4 | 5 | 0 (other/unknown)
IS_PI=0
ARCH="$(uname -m)"
OS_PRETTY="Linux"
OS_CODENAME=""

A_HAT=""              # hardware profile: ws-rs485-12m | ws-rs485-8m | ws-2chfd | ws-2ch-plus | ws-2ch | pican-m | usb | none
A_PROTO=""            # derived compatibility value: n2k | j1939 | both | none (also accepts legacy answer files)
A_N2K=""              # y | n — expose a physical NMEA 2000 CAN network
A_J1939=""            # y | n — expose a physical SAE J1939 CAN network
A_CONVERT=""          # y | n — enable NMEA 2000 <-> J1939 semantic translator
A_SIGNALK=""          # y | n — install/run Signal K independently of conversion
A_J1708="n"           # y | n — SAE J1708/J1587 input translated to NMEA 2000
A_J1708_PORT="auto"   # auto | /dev/...
A_0183="n"            # y | n
A_KIOSK="n"           # y | n
A_J1939_BITRATE="250000" # 250000 (classic/common) | 500000 (common newer variant)
A_N2K_IFACE=""        # optional answer-file override / extra CAN adapter
A_J1939_IFACE=""      # optional answer-file override / extra CAN adapter
N2K_IFACE=""          # resolved physical or virtual SocketCAN endpoint
J1939_IFACE=""        # resolved physical or virtual SocketCAN endpoint
NEED_SIGNALK=0        # set by resolve() only when Signal K or kiosk is selected

# Current Raspberry Pi OS uses Chromium; retain a fallback for older images.
pick_chromium() {
  case "$PKG_MGR" in
    apt)
      local candidate
      candidate="$(apt-cache policy chromium 2>/dev/null | awk '/Candidate:/ {print $2; exit}')"
      if [[ -n $candidate && $candidate != "(none)" ]]; then
        echo chromium
      elif apt-cache show chromium-browser >/dev/null 2>&1; then
        echo chromium-browser
      else
        # Current Pi OS calls the package chromium; let apt produce a useful
        # error instead of silently choosing a package name that does not exist.
        echo chromium
      fi ;;
    *) echo chromium ;;
  esac
}

declare -a PKGS=()            # packages to install
declare -a OVERLAYS=()        # dtoverlay lines for boot config
declare -a UNITS=()           # systemd units to enable
declare -a CAN_IFACES=()      # physical SocketCAN interfaces exposed by selected hardware
declare -a CAN_SPECS=()       # iface:bitrate or iface:vcan, exact runtime roles

#--------------------------------------------------------------------------
# Argument parsing
#--------------------------------------------------------------------------
usage() { sed -n '2,23p' "$0" | sed 's/^#//;s/^ //'; exit 0; }

while [[ $# -gt 0 ]]; do
  case "$1" in
    --dry-run)    DRY_RUN=1 ;;
    --uninstall)  UNINSTALL=1 ;;
    --detect)     DETECT_ONLY=1 ;;
    --hat-guide)  HAT_GUIDE_ONLY=1 ;;
    --yes|-y)     ASSUME_YES=1 ;;
    --answers)    ANSWER_FILE="${2:?--answers needs a file}"; shift ;;
    --help|-h)    usage ;;
    *) die "Unknown flag: $1 (try --help)" ;;
  esac
  shift
done

need_root() {
  [[ $EUID -eq 0 ]] || die "This action needs root. Try: sudo $0 ${*:-}"
}

run() {  # run <cmd...>  — respects --dry-run, logs everything
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: $*${C_RESET}"
  else
    log "RUN $*"
    "$@"
  fi
}

record() {  # record <type> <value>  — manifest entry for uninstall
  (( DRY_RUN )) && return 0
  mkdir -p "$STATE_DIR"
  grep -qxF "$1|$2" "$MANIFEST" 2>/dev/null || printf '%s|%s\n' "$1" "$2" >> "$MANIFEST"
}

#--------------------------------------------------------------------------
# Detection — the boat tells us what it can
#--------------------------------------------------------------------------
detect_pkg_mgr() {
  if   command -v apt-get >/dev/null 2>&1; then PKG_MGR="apt"
  elif command -v dnf     >/dev/null 2>&1; then PKG_MGR="dnf"
  elif command -v pacman  >/dev/null 2>&1; then PKG_MGR="pacman"
  else die "No supported package manager found (need apt, dnf, or pacman)."
  fi
}

detect_platform() {
  if [[ -r /etc/os-release ]]; then
    # shellcheck disable=SC1091
    . /etc/os-release
    OS_PRETTY="${PRETTY_NAME:-${NAME:-Linux}}"
    OS_CODENAME="${VERSION_CODENAME:-}"
  fi

  if [[ -r /proc/device-tree/model ]]; then
    PI_MODEL="$(tr -d '\0' < /proc/device-tree/model)"
    if [[ $PI_MODEL == *"Raspberry Pi"* ]]; then
      IS_PI=1
      [[ $PI_MODEL == *"Raspberry Pi 4"* ]] && PI_GEN=4
      [[ $PI_MODEL == *"Raspberry Pi 5"* ]] && PI_GEN=5
    fi
  fi

  # Raspberry Pi OS Bookworm/Trixie use /boot/firmware/config.txt. Keep the
  # Bullseye path as a compatibility fallback for older Pi 4 deployments.
  if   [[ -f /boot/firmware/config.txt ]]; then BOOT_CONFIG="/boot/firmware/config.txt"
  elif [[ -f /boot/config.txt          ]]; then BOOT_CONFIG="/boot/config.txt"
  fi
}

probe_spi() {  # 0 if SPI device nodes present
  compgen -G "/dev/spidev*" >/dev/null 2>&1
}

probe_usb_can() {  # prints matching USB CAN interfaces, if any
  # gs_usb-class devices show up as canX with a gs_usb driver
  local d
  for d in /sys/class/net/can*; do
    [[ -e $d ]] || continue
    if readlink -f "$d/device/driver" 2>/dev/null | grep -q gs_usb; then
      basename "$d"
    fi
  done
}

probe_socketcan() {  # prints all CAN interfaces with their bound driver when known
  local d ifc drv
  for d in /sys/class/net/can*; do
    [[ -e $d ]] || continue
    ifc="$(basename "$d")"
    drv="$(basename "$(readlink -f "$d/device/driver" 2>/dev/null)" 2>/dev/null || true)"
    if [[ -n $drv && $drv != driver ]]; then printf '%s(%s)\n' "$ifc" "$drv"; else printf '%s\n' "$ifc"; fi
  done
}

probe_serial() {  # prints candidate NMEA 0183 / RS485 serial ports
  [[ -e /dev/serial0 ]] && printf "%s\n" /dev/serial0
  compgen -G "/dev/ttyUSB*" 2>/dev/null || true
  compgen -G "/dev/ttyACM*" 2>/dev/null || true
  compgen -G "/dev/ttyAMA*" 2>/dev/null || true
}

detect_report() {
  banner
  info "Hardware probe"
  say  "   Platform ....... ${PI_MODEL:-generic $(uname -m)}"
  if (( PI_GEN == 4 || PI_GEN == 5 )); then say "   Pi generation .. ${PI_GEN}"; else say "   Pi generation .. other/unknown"; fi
  say  "   OS ............. ${OS_PRETTY}${OS_CODENAME:+ (${OS_CODENAME})}"
  say  "   Architecture ... ${ARCH}"
  say  "   Kernel ......... $(uname -r)"
  say  "   Pkg manager .... ${PKG_MGR}"
  say  "   Boot config .... ${BOOT_CONFIG:-n/a (not a Pi-style boot)}"
  if probe_spi; then say "   SPI ............ present ($(compgen -G '/dev/spidev*' | tr '\n' ' '))"
  else               say "   SPI ............ not enabled (wizard can enable it)"; fi
  local allcan; allcan="$(probe_socketcan | tr '\n' ' ' || true)"
  say  "   SocketCAN ...... ${allcan:-none detected}"
  local usbcan; usbcan="$(probe_usb_can | tr '\n' ' ' || true)"
  say  "   USB CAN ........ ${usbcan:-none detected}"
  local serials; serials="$(probe_serial | tr '\n' ' ')"
  say  "   Serial ports ... ${serials:-none detected}"
  say ""
}

#--------------------------------------------------------------------------
# Wizard — the questions from the brochure, for real
#--------------------------------------------------------------------------
ask() {  # ask <var> <prompt> <default>
  local __var=$1 __prompt=$2 __def=${3-} __ans
  if (( ASSUME_YES )); then
    printf -v "$__var" '%s' "$__def"; return 0
  fi
  read -r -p "${C_GREEN}> ${C_RESET}${__prompt} " __ans || true
  printf -v "$__var" '%s' "${__ans:-$__def}"
}

load_answers() {  # minimal flat-YAML reader: "key: value" lines only
  local f=$1 k v
  [[ -r $f ]] || die "Answer file not readable: $f"
  while IFS=':' read -r k v; do
    k="${k//[[:space:]]/}"; v="${v//[[:space:]]/}"
    case "$k" in
      hat)    A_HAT="$v"   ;;
      proto)  A_PROTO="$v" ;; # legacy keel.sh answer files
      nmea2000) A_N2K="${v,,}" ;;
      j1939) A_J1939="${v,,}" ;;
      convert) A_CONVERT="${v,,}" ;;
      signalk) A_SIGNALK="${v,,}" ;;
      j1708)  A_J1708="${v,,}" ;;
      j1708_port) A_J1708_PORT="$v" ;;
      nmea0183) A_0183="${v,,}" ;;
      kiosk)  A_KIOSK="${v,,}" ;;
      j1939_bitrate) A_J1939_BITRATE="$v" ;;
      n2k_iface) A_N2K_IFACE="$v" ;;
      j1939_iface) A_J1939_IFACE="$v" ;;
      ''|'#'*) : ;;
    esac
  done < "$f"
  info "Answers loaded from ${f}"
}

waveshare_hat_guide() {
  say "${C_AMBER}${C_BOLD}Waveshare / CAN HAT selection guide${C_RESET}"
  say ""
  say "  1) Waveshare RS485 CAN HAT — current 12 MHz board"
  say "     One MCP2515 CAN channel + one RS485 UART channel. Look for a 12.000 MHz"
  say "     crystal/oscillator on the board. Current Waveshare instructions use GPIO25."
  say ""
  say "  2) Waveshare RS485 CAN HAT — legacy 8 MHz board"
  say "     Same product family, but older boards (Waveshare says purchases before"
  say "     Aug 2019 may be 8 MHz). Pick this only if the oscillator is marked 8 MHz."
  say ""
  say "  3) Waveshare 2-CH CAN FD HAT Rev2.1+ — MCP2518FD, factory Mode A"
  say "     Two CAN/CAN-FD channels. Rev2.1+ is printed on the rear. Factory Mode A"
  say "     uses CAN0=SPI0.0/GPIO25 and CAN1=SPI1.0/GPIO24. Set logic jumper to 3.3V."
  say "     Waveshare's product page lists Pi through 4B; Pi 5 is not explicitly listed."
  say ""
  say "  4) Waveshare 2-CH CAN HAT+ — dual MCP2515 (recommended Waveshare dual-CAN for Pi 5)"
  say "     The PCB says '2-CH CAN HAT+' and uses the HAT+ form factor/EEPROM. Waveshare"
  say "     explicitly lists Raspberry Pi 5 support. Default config uses SPI1 CS1/CS2."
  say ""
  say "  5) Waveshare 2-CH CAN HAT — older non-Plus dual MCP2515 board"
  say "     PCB name does NOT have '+'. Waveshare lists support through Pi 4B, not Pi 5."
  say "     Prefer option 4 on a Pi 5."
  say ""
  say "  6) PiCAN-M — MCP2515 + NMEA 0183 (16 MHz MCP2515 setup)"
  say "  7) USB SocketCAN adapter — gs_usb/candleLight-style adapter; no SPI overlay"
  say "  8) No CAN hardware — serial/NMEA 0183 only"
  say ""
  say "  Capability model:"
  say "     1-CAN boards: choose NMEA 2000 OR J1939 physically; optional translation uses vcan for the peer side."
  say "     2-CAN boards: NMEA 2000 and J1939 may run simultaneously on separate channels, with translation optional."
  say "     Signal K, J1708, NMEA 0183, and kiosk are independent selections."
  say ""
  say "${C_DIM}Tip: do not choose by connector shape alone. Read the exact PCB product name,"
  say "chip marking (MCP2515 vs MCP2518FD), and oscillator marking before continuing.${C_RESET}"
  say ""
}

hat_can_channels() {
  case "$A_HAT" in
    ws-rs485-12m|ws-rs485-8m|pican-m) printf '%s\n' 1 ;;
    ws-2chfd|ws-2ch-plus|ws-2ch)      printf '%s\n' 2 ;;
    usb)
      local n=0 d
      for d in /sys/class/net/can*; do [[ -e $d ]] && ((n+=1)); done
      (( n > 0 )) && printf '%s\n' "$n" || printf '%s\n' 1
      ;;
    none) printf '%s\n' 0 ;;
  esac
}

normalize_capabilities() {
  # Backward compatibility: old answer files used one proto: selector. Preserve
  # their prior behavior, including the virtual translated peer and Signal K.
  if [[ -n $A_PROTO && -z $A_N2K && -z $A_J1939 ]]; then
    case "$A_PROTO" in
      n2k)   A_N2K=y; A_J1939=n ;;
      j1939) A_N2K=n; A_J1939=y ;;
      both)  A_N2K=y; A_J1939=y ;;
      none)  A_N2K=n; A_J1939=n ;;
      *) die "Unknown legacy proto value '${A_PROTO}'." ;;
    esac
    [[ -z $A_CONVERT ]] && { [[ $A_PROTO == none ]] && A_CONVERT=n || A_CONVERT=y; }
    [[ -z $A_SIGNALK ]] && { [[ $A_PROTO == none ]] && A_SIGNALK=n || A_SIGNALK=y; }
  fi

  [[ -n $A_N2K ]]     || A_N2K=n
  [[ -n $A_J1939 ]]   || A_J1939=n
  [[ -n $A_CONVERT ]] || A_CONVERT=n
  [[ -n $A_SIGNALK ]] || A_SIGNALK=n

  case "${A_N2K}:${A_J1939}" in
    y:y) A_PROTO=both ;;
    y:n) A_PROTO=n2k ;;
    n:y) A_PROTO=j1939 ;;
    n:n) A_PROTO=none ;;
    *) die "nmea2000 and j1939 must each be y or n." ;;
  esac
}

validate_selection() {
  case "$A_HAT" in
    ws-rs485) A_HAT="ws-rs485-12m" ;; # backwards-compatible answer files
  esac

  case "$A_HAT" in
    ws-rs485-12m|ws-rs485-8m|ws-2chfd|ws-2ch-plus|ws-2ch|pican-m|usb|none) ;;
    *) die "Unknown hat value '${A_HAT}'. Run --hat-guide or use the interactive wizard." ;;
  esac

  normalize_capabilities
  case "${A_N2K,,}" in y|n) ;; *) die "nmea2000 must be y or n." ;; esac
  case "${A_J1939,,}" in y|n) ;; *) die "j1939 must be y or n." ;; esac
  case "${A_CONVERT,,}" in y|n) ;; *) die "convert must be y or n." ;; esac
  case "${A_SIGNALK,,}" in y|n) ;; *) die "signalk must be y or n." ;; esac
  case "${A_J1708,,}" in y|n) ;; *) die "j1708 must be y or n." ;; esac
  [[ $A_J1708_PORT == auto || $A_J1708_PORT =~ ^/dev/[a-zA-Z0-9_./:-]+$ ]] \
    || die "j1708_port must be auto or a /dev/... path."
  case "${A_0183,,}" in y|n) ;; *) die "nmea0183 must be y or n." ;; esac
  case "${A_KIOSK,,}" in y|n) ;; *) die "kiosk must be y or n." ;; esac
  case "$A_J1939_BITRATE" in 250000|500000) ;; *) die "j1939_bitrate must be 250000 or 500000." ;; esac
  [[ -z $A_N2K_IFACE || $A_N2K_IFACE =~ ^[a-zA-Z0-9_.-]+$ ]] || die "Invalid n2k_iface '${A_N2K_IFACE}' (':' is reserved internally)."
  [[ -z $A_J1939_IFACE || $A_J1939_IFACE =~ ^[a-zA-Z0-9_.-]+$ ]] || die "Invalid j1939_iface '${A_J1939_IFACE}' (':' is reserved internally)."
  [[ -z $A_N2K_IFACE || -z $A_J1939_IFACE || $A_N2K_IFACE != "$A_J1939_IFACE" ]] \
    || die "NMEA 2000 and J1939 must use different SocketCAN interfaces."

  local required_can=0 available_can
  [[ $A_N2K == y ]] && ((required_can+=1))
  [[ $A_J1939 == y ]] && ((required_can+=1))
  available_can="$(hat_can_channels)"

  if (( required_can > 0 )) && [[ $A_HAT == none ]]; then
    die "A physical CAN capability was selected but 'No CAN hardware' is configured."
  fi

  # Distinct explicit interface overrides mean the operator has supplied another
  # adapter in addition to the selected HAT, so do not incorrectly reject it.
  if (( required_can > available_can )); then
    if [[ $A_N2K == y && $A_J1939 == y && -n $A_N2K_IFACE && -n $A_J1939_IFACE && $A_N2K_IFACE != "$A_J1939_IFACE" ]]; then
      warn "Selected HAT has ${available_can} CAN channel(s), but explicit interfaces '${A_N2K_IFACE}' and '${A_J1939_IFACE}' request an additional adapter."
    else
      die "Selected hardware provides ${available_can} physical CAN channel(s), but ${required_can} were requested. Choose one CAN network, a dual-CAN Waveshare board, or add a second CAN adapter/interface override."
    fi
  fi

  if [[ $A_CONVERT == y && $A_N2K == n && $A_J1939 == n ]]; then
    die "Protocol conversion requires at least one NMEA 2000 or J1939 CAN capability."
  fi
  if [[ $A_KIOSK == y ]]; then
    A_SIGNALK=y
  fi
  if [[ $A_SIGNALK == y && $A_N2K == n && $A_CONVERT == n && $A_J1708 == n ]]; then
    warn "Signal K selected without NMEA 2000 or translation; it will start, but no KeelOS CAN provider will be preconfigured."
  fi

  if (( IS_PI )) && (( PI_GEN != 4 && PI_GEN != 5 )); then
    warn "This installer is hardened for Raspberry Pi 4/5; detected: ${PI_MODEL}."
  fi
  if (( PI_GEN == 5 )); then
    case "$A_HAT" in
      ws-2chfd)
        warn "Waveshare's 2-CH CAN FD HAT page does not explicitly list Pi 5 support."
        warn "The standard MCP251XFD overlays are available on modern Pi kernels, but this board/Pi 5 pairing is best-effort."
        ;;
      ws-2ch)
        warn "The older Waveshare 2-CH CAN HAT is documented through Pi 4B, not Pi 5."
        warn "For Pi 5, the Waveshare 2-CH CAN HAT+ is the documented choice."
        ;;
    esac
  fi
}

wizard() {
  local pick channels
  waveshare_hat_guide
  say "${C_AMBER}[1/8] Which hardware is physically installed?${C_RESET}"
  ask pick "[1-8]:" "1"
  case "$pick" in
    1) A_HAT="ws-rs485-12m" ;;
    2) A_HAT="ws-rs485-8m"  ;;
    3) A_HAT="ws-2chfd"     ;;
    4) A_HAT="ws-2ch-plus"  ;;
    5) A_HAT="ws-2ch"       ;;
    6) A_HAT="pican-m"      ;;
    7) A_HAT="usb"          ;;
    8) A_HAT="none"         ;;
    *) die "Pick 1-8 and re-run." ;;
  esac
  channels="$(hat_can_channels)"
  info "hardware profile exposes ${channels} CAN channel(s) before any external adapters"

  A_N2K=n; A_J1939=n; A_CONVERT=n; A_SIGNALK=n
  if [[ $A_HAT != none ]]; then
    say ""
    say "${C_AMBER}[2/8] Enable a physical NMEA 2000 network?${C_RESET}  [Y/n]"
    ask pick "[Y/n]:" "Y"; [[ ${pick,,} != n* ]] && A_N2K=y

    say ""
    say "${C_AMBER}[3/8] Enable a physical J1939 network?${C_RESET}  [y/N]"
    if [[ $channels -ge 2 || $A_N2K == n ]]; then
      ask pick "[y/N]:" "N"; [[ ${pick,,} == y* ]] && A_J1939=y
    else
      say "${C_DIM}   selected board has one CAN channel already assigned to NMEA 2000; skipping physical J1939.${C_RESET}"
    fi

    if [[ $A_J1939 == y ]]; then
      say ""
      say "${C_AMBER}J1939 physical bitrate?${C_RESET}  [1] 250 kbit/s / [2] 500 kbit/s"
      ask pick "[1/2]:" "1"
      [[ $pick == 2 ]] && A_J1939_BITRATE="500000" || A_J1939_BITRATE="250000"
    fi

    say ""
    say "${C_AMBER}[4/8] Enable NMEA 2000 <-> J1939 conversion?${C_RESET}  [Y/n]"
    say "${C_DIM}   With one physical CAN bus, the other side is created as a virtual CAN bus.${C_RESET}"
    ask pick "[Y/n]:" "Y"; [[ ${pick,,} != n* ]] && A_CONVERT=y
  else
    say ""
    say "${C_DIM}[2-4/8] CAN capabilities skipped — no CAN hardware selected.${C_RESET}"
  fi

  say ""
  say "${C_AMBER}[5/8] Legacy SAE J1708/J1587 -> NMEA 2000 conversion?${C_RESET}  [y/N]"
  say "${C_DIM}   Read-only on the 9600 bit/s J1708 bus; engine/transmission/fuel/electrical data and alarms become NMEA 2000.${C_RESET}"
  ask pick "[y/N]:" "N"; [[ ${pick,,} == y* ]] && A_J1708=y
  if [[ $A_J1708 == y ]]; then
    local jport foundports
    foundports="$(probe_serial | tr '\n' ' ' || true)"
    [[ -n $foundports ]] && info "candidate serial/RS-485 ports: ${foundports}"
    ask jport "J1708 port [auto or /dev/...]:" "auto"
    A_J1708_PORT="$jport"
  fi

  say ""
  say "${C_AMBER}[6/8] NMEA 0183 serial devices?${C_RESET}  [y/N]"
  ask pick "[y/N]:" "N"; [[ ${pick,,} == y* ]] && A_0183=y
  if [[ $A_0183 == y ]]; then
    local found; found="$(probe_serial | head -n1 || true)"
    [[ -n $found ]] && info "found ${found} (gpsd/Signal K can use it)"
  fi
  [[ $A_HAT == pican-m ]] && A_0183=y

  say ""
  say "${C_AMBER}[7/8] Install/run Signal K?${C_RESET}  [Y/n]"
  ask pick "[Y/n]:" "Y"; [[ ${pick,,} != n* ]] && A_SIGNALK=y

  say ""
  say "${C_AMBER}[8/8] Boot into kiosk gauge dashboard?${C_RESET}  [Y/n]"
  ask pick "[Y/n]:" "Y"; [[ ${pick,,} != n* ]] && A_KIOSK=y
  say ""
  validate_selection
}

save_answers() {
  (( DRY_RUN )) && return 0
  mkdir -p "$STATE_DIR"
  cat > "$ANSWER_CACHE" <<EOF
# keel.sh answers — hardware first, capabilities second
# re-run with: keel.sh --answers ${ANSWER_CACHE}
hat: ${A_HAT}
nmea2000: ${A_N2K}
j1939: ${A_J1939}
convert: ${A_CONVERT}
signalk: ${A_SIGNALK}
j1939_bitrate: ${A_J1939_BITRATE}
n2k_iface: ${A_N2K_IFACE}
j1939_iface: ${A_J1939_IFACE}
j1708: ${A_J1708}
j1708_port: ${A_J1708_PORT}
nmea0183: ${A_0183}
kiosk: ${A_KIOSK}
EOF
  ok "Answers cached at ${ANSWER_CACHE}"
}

#--------------------------------------------------------------------------
# Resolution — YOUR answers only
#--------------------------------------------------------------------------
resolve() {
  say "${C_DIM}Resolving selected hardware and capabilities:${C_RESET}"

  case "$A_HAT" in
    ws-rs485-12m)
      OVERLAYS+=("dtoverlay=mcp2515-can0,oscillator=12000000,interrupt=25,spimaxfrequency=2000000")
      OVERLAYS+=("enable_uart=1")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0) ;;
    ws-rs485-8m)
      OVERLAYS+=("dtoverlay=mcp2515-can0,oscillator=8000000,interrupt=25,spimaxfrequency=1000000")
      OVERLAYS+=("enable_uart=1")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0) ;;
    ws-2chfd)
      OVERLAYS+=("dtoverlay=spi1-3cs")
      OVERLAYS+=("dtoverlay=mcp251xfd,spi0-0,interrupt=25")
      OVERLAYS+=("dtoverlay=mcp251xfd,spi1-0,interrupt=24")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0 can1) ;;
    ws-2ch-plus)
      OVERLAYS+=("dtoverlay=i2c0")
      OVERLAYS+=("dtoverlay=spi1-3cs")
      OVERLAYS+=("dtoverlay=mcp2515,spi1-1,oscillator=16000000,interrupt=22")
      OVERLAYS+=("dtoverlay=mcp2515,spi1-2,oscillator=16000000,interrupt=13")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0 can1) ;;
    ws-2ch)
      OVERLAYS+=("dtoverlay=mcp2515-can1,oscillator=16000000,interrupt=25")
      OVERLAYS+=("dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=23")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0 can1) ;;
    pican-m)
      OVERLAYS+=("dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=25")
      PKGS+=(can-utils iproute2)
      CAN_IFACES+=(can0) ;;
    usb)
      PKGS+=(can-utils iproute2)
      local usb_found=()
      mapfile -t usb_found < <(probe_usb_can)
      if [[ ${#usb_found[@]} -gt 0 ]]; then
        CAN_IFACES+=("${usb_found[@]}")
      else
        # Before udev/driver enumeration (or in a fleet answer file), retain the
        # conventional names but tell the operator exactly what is assumed.
        CAN_IFACES+=(can0)
        [[ $A_N2K == y && $A_J1939 == y ]] && CAN_IFACES+=(can1)
        warn "No gs_usb SocketCAN interface is currently visible; assuming ${CAN_IFACES[*]}."
      fi ;;
    none) skip "skipped: physical CAN stack (not requested)" ;;
  esac

  local default0="${CAN_IFACES[0]:-can0}" default1="${CAN_IFACES[1]:-can1}"

  # Assign physical CAN roles independently. NMEA 2000 is always 250 kbit/s.
  if [[ $A_N2K == y ]]; then
    N2K_IFACE="${A_N2K_IFACE:-$default0}"
    CAN_SPECS+=("${N2K_IFACE}:250000")
  fi
  if [[ $A_J1939 == y ]]; then
    if [[ $A_N2K == y ]]; then
      J1939_IFACE="${A_J1939_IFACE:-$default1}"
    else
      J1939_IFACE="${A_J1939_IFACE:-$default0}"
    fi
    CAN_SPECS+=("${J1939_IFACE}:${A_J1939_BITRATE}")
  fi

  # Conversion is a capability, not a synonym for selecting both networks.
  # If only one physical side exists, create the opposite side as vcan so the
  # translated data can still feed local applications without joining buses.
  if [[ $A_CONVERT == y ]]; then
    PKGS+=(python3)
    if [[ -z $N2K_IFACE ]]; then N2K_IFACE="${A_N2K_IFACE:-vcan0}"; CAN_SPECS+=("${N2K_IFACE}:vcan"); fi
    if [[ -z $J1939_IFACE ]]; then J1939_IFACE="${A_J1939_IFACE:-vcan1}"; CAN_SPECS+=("${J1939_IFACE}:vcan"); fi
    [[ $N2K_IFACE != "$J1939_IFACE" ]] || die "Resolved NMEA 2000 and J1939 interfaces are identical (${N2K_IFACE})."
    UNITS+=(keelos-j1939.service)
  fi

  [[ ${#CAN_SPECS[@]} -gt 0 ]] && UNITS+=(keelos-can.service)

  if [[ $A_J1708 == y ]]; then
    PKGS+=(python3)
    if [[ $A_J1708_PORT == auto ]]; then
      case "$A_HAT" in
        ws-rs485-12m|ws-rs485-8m)
          if (( PI_GEN == 5 )); then
            # Pi 5 /dev/serial0 is the debug UART (ttyAMA10), not GPIO14/15.
            # The classic Waveshare RS485 CAN HAT is wired to GPIO14/15, so
            # explicitly enable UART0 there and use ttyAMA0.
            A_J1708_PORT="/dev/ttyAMA0"
            OVERLAYS+=("dtoverlay=uart0-pi5")
            ok "J1708: Pi 5 Waveshare RS-485 uses UART0 on GPIO14/15 (${A_J1708_PORT})"
          else
            A_J1708_PORT="/dev/serial0"
            ok "J1708: using the selected Waveshare RS485 CAN HAT's RS-485 UART (${A_J1708_PORT})"
          fi
          ;;
        *)
          warn "J1708 auto mode on this HAT looks only for USB/ACM RS-485 adapters; a bare Pi UART is TTL and will not be auto-selected."
          ;;
      esac
    fi
    # J1587 always translates into an NMEA 2000 SocketCAN endpoint. If no
    # physical NMEA 2000 network was selected, create a private vcan endpoint
    # unless the operator explicitly supplied an externally-managed interface.
    if [[ -z $N2K_IFACE ]]; then
      N2K_IFACE="${A_N2K_IFACE:-vcan0}"
      if [[ -z $A_N2K_IFACE ]]; then
        CAN_SPECS+=("${N2K_IFACE}:vcan")
      else
        warn "J1708 will use externally-managed NMEA 2000 interface '${N2K_IFACE}'."
      fi
    fi
    UNITS+=(keelos-j1708-bridge.service)
    ok "queued: SAE J1708/J1587 ${A_J1708_PORT} @ 9600 bit/s -> NMEA 2000 ${N2K_IFACE}"
  else
    skip "skipped: J1708/J1587 -> NMEA 2000 translator (not requested)"
  fi

  [[ ${#CAN_SPECS[@]} -gt 0 ]] && UNITS+=(keelos-can.service)

  if [[ $A_0183 == y ]]; then
    PKGS+=(gpsd gpsd-clients)
  else
    skip "skipped: NMEA 0183 stack (not requested)"
  fi

  if [[ $A_SIGNALK == y || $A_KIOSK == y ]]; then
    NEED_SIGNALK=1
    UNITS+=(keelos-dashboard.service)
  fi
  if [[ $A_KIOSK == y ]]; then
    PKGS+=("$(pick_chromium)" cage)
    UNITS+=(keelos-kiosk.service)
  else
    skip "skipped: kiosk dashboard/browser (not requested)"
  fi

  if (( NEED_SIGNALK )); then
    PKGS+=(ca-certificates curl xz-utils python3)
  fi

  mapfile -t PKGS < <(printf '%s\n' "${PKGS[@]}" | sed '/^$/d' | sort -u)
  [[ ${#UNITS[@]} -gt 0 ]] && mapfile -t UNITS < <(printf '%s\n' "${UNITS[@]}" | sort -u)
  [[ ${#CAN_IFACES[@]} -gt 0 ]] && mapfile -t CAN_IFACES < <(printf '%s\n' "${CAN_IFACES[@]}" | sort -u)
  [[ ${#CAN_SPECS[@]} -gt 0 ]] && mapfile -t CAN_SPECS < <(printf '%s\n' "${CAN_SPECS[@]}" | sort -u)

  [[ ${#PKGS[@]}       -gt 0 ]] && ok "pkgs:       ${PKGS[*]}"
  ok "hardware:   ${A_HAT} ($(hat_can_channels) onboard CAN channel(s))"
  ok "capability: NMEA2000=${A_N2K} J1939=${A_J1939} convert=${A_CONVERT} SignalK=${A_SIGNALK} J1708=${A_J1708} NMEA0183=${A_0183} kiosk=${A_KIOSK}"
  (( NEED_SIGNALK ))            && ok "runtime:    Node ${KEEL_NODE_VERSION} + Signal K/canboatjs"
  [[ ${#OVERLAYS[@]}   -gt 0 ]] && ok "boot cfg:   ${#OVERLAYS[@]} managed hardware line(s)"
  [[ ${#CAN_SPECS[@]}  -gt 0 ]] && ok "CAN roles:  ${CAN_SPECS[*]}"
  [[ $A_N2K == y              ]] && ok "NMEA2000:   ${N2K_IFACE} @ 250 kbit/s (physical)"
  [[ $A_J1939 == y            ]] && ok "J1939:      ${J1939_IFACE} @ ${A_J1939_BITRATE} bit/s (physical)"
  [[ $A_CONVERT == y          ]] && ok "translator: ${N2K_IFACE} <-> ${J1939_IFACE} for matched engine/transmission/electrical PGNs"
  [[ $A_J1708 == y            ]] && ok "J1587->N2K: ${A_J1708_PORT} @ 9600 bit/s -> ${N2K_IFACE}, including standard diagnostic alarms"
  [[ ${#UNITS[@]}      -gt 0 ]] && ok "units:      ${UNITS[*]}"
  say ""
}

#--------------------------------------------------------------------------
# Signal K — isolated under /opt/keelos, run as its own system user
#--------------------------------------------------------------------------
install_node_runtime() {
  (( NEED_SIGNALK )) || return 0

  local current_major=0
  if command -v node >/dev/null 2>&1; then
    current_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
  fi
  if [[ $current_major =~ ^[0-9]+$ ]] && (( current_major >= 22 )) && command -v npm >/dev/null 2>&1; then
    ok "Node $(node --version) already satisfies Signal K (>=22)"
    return 0
  fi

  local node_arch base tarball root tmp sumline
  case "$ARCH" in
    aarch64|arm64) node_arch="arm64" ;;
    armv7l|armv7*) node_arch="armv7l" ;;
    x86_64|amd64) node_arch="x64" ;;
    *) die "Signal K needs Node >=22; no pinned Node build mapping for architecture '${ARCH}'." ;;
  esac

  base="node-v${KEEL_NODE_VERSION}-linux-${node_arch}"
  tarball="${base}.tar.xz"
  root="/opt/keelos/node-v${KEEL_NODE_VERSION}"

  info "Installing official Node.js v${KEEL_NODE_VERSION} (${node_arch}) for Signal K"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: download + SHA256 verify ${tarball} from nodejs.org${C_RESET}"
    say "${C_DIM}   dry-run: install under ${root} and link node/npm into /usr/local/bin${C_RESET}"
    return 0
  fi

  tmp="$(mktemp -d)"
  curl -fL --retry 3 --retry-delay 2 -o "${tmp}/${tarball}" \
    "https://nodejs.org/dist/v${KEEL_NODE_VERSION}/${tarball}"
  curl -fL --retry 3 --retry-delay 2 -o "${tmp}/SHASUMS256.txt" \
    "https://nodejs.org/dist/v${KEEL_NODE_VERSION}/SHASUMS256.txt"
  sumline="$(grep -E "  ${tarball}$" "${tmp}/SHASUMS256.txt" || true)"
  [[ -n $sumline ]] || die "Could not find ${tarball} in Node.js SHASUMS256.txt"
  ( cd "$tmp" && printf '%s\n' "$sumline" | sha256sum -c - )

  mkdir -p /opt/keelos
  rm -rf "$root"
  tar -xJf "${tmp}/${tarball}" -C /opt/keelos
  mv "/opt/keelos/${base}" "$root"
  record dir "$root"

  local tool
  for tool in node npm npx corepack; do
    [[ -x "${root}/bin/${tool}" ]] || continue
    ln -sfn "${root}/bin/${tool}" "/usr/local/bin/${tool}"
    record file "/usr/local/bin/${tool}"
  done
  hash -r

  current_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
  [[ $current_major =~ ^[0-9]+$ ]] && (( current_major >= 22 )) \
    || die "Node installation completed but Node >=22 is not active."
  rm -rf "$tmp"
  ok "Node $(node --version) ready"
}

install_signalk() {
  (( NEED_SIGNALK )) || return 0
  install_node_runtime
  info "Installing Signal K server"

  if ! id -u keelos >/dev/null 2>&1; then
    run useradd --system --create-home --home-dir /var/lib/keelos/home \
        --shell /usr/sbin/nologin keelos
    record user keelos
  fi
  local g
  for g in video render input dialout; do
    getent group "$g" >/dev/null 2>&1 && run usermod -aG "$g" keelos
  done

  # Keep Signal K isolated under /opt/keelos so its native SocketCAN module
  # is built against the pinned Node runtime rather than an arbitrary system Node.
  local prefix="/opt/keelos/npm"
  local skpkg="${prefix}/lib/node_modules/signalk-server"
  if [[ -x "${prefix}/bin/signalk-server" ]]; then
    ok "KeelOS Signal K already present ($("${prefix}/bin/signalk-server" --version 2>/dev/null || echo 'version n/a')) — converging"
  else
    if (( DRY_RUN )); then
      say "${C_DIM}   dry-run: npm_config_prefix=${prefix} npm install -g --omit=dev --foreground-scripts signalk-server${C_RESET}"
    else
      mkdir -p "$prefix"
      env npm_config_prefix="$prefix" npm install -g --omit=dev --foreground-scripts signalk-server
      [[ -x "${prefix}/bin/signalk-server" ]] || die "Signal K npm install finished but executable was not created."
      record dir "$prefix"
    fi
  fi
  if (( ! DRY_RUN )); then
    [[ -d "$skpkg" ]] || die "Signal K package directory missing at ${skpkg}."
    # socketcan is optional upstream, but mandatory for this installer. If its
    # native build was skipped/fails silently, rebuild it explicitly and verify.
    if ! (cd "$skpkg" && node -e "require('socketcan')" >/dev/null 2>&1); then
      warn "Signal K native socketcan module is missing; rebuilding it now."
      (cd "$skpkg" && npm install --omit=dev --foreground-scripts socketcan)
    fi
    (cd "$skpkg" && node -e "require('socketcan'); require('@canboat/canboatjs')" >/dev/null) \
      || die "Signal K SocketCAN/canboatjs preflight failed."
    ln -sfn "${prefix}/bin/signalk-server" /usr/local/bin/signalk-server
    record file /usr/local/bin/signalk-server
  else
    say "${C_DIM}   dry-run: verify native socketcan + @canboat/canboatjs modules${C_RESET}"
  fi
  ok "signalk-server ready in ${prefix}"

  local skdir="/var/lib/keelos/signalk"
  if (( ! DRY_RUN )); then
    mkdir -p "$skdir"
    [[ -f "$skdir/settings.json" ]] || printf '%s\n' '{"interfaces":{},"pipedProviders":[]}' > "$skdir/settings.json"
    python3 - "$skdir/settings.json" "$N2K_IFACE" <<'PY'
import json, os, sys, tempfile
path, iface = sys.argv[1:3]
try:
    with open(path, 'r', encoding='utf-8') as f:
        cfg = json.load(f)
except (OSError, json.JSONDecodeError) as e:
    raise SystemExit(f"Signal K settings are not valid JSON: {e}")
providers = cfg.setdefault("pipedProviders", [])
# Converge, don't accumulate stale KeelOS CAN providers from an earlier hardware profile.
providers[:] = [p for p in providers if not str(p.get("id", "")).startswith("keelos-n2k-")]
if iface:
    providers.append({
        "id": f"keelos-n2k-{iface}",
        "enabled": True,
        "pipeElements": [
            {"type": "providers/canbus", "options": {"canDevice": iface}},
            {"type": "providers/canboatjs"},
            {"type": "providers/n2k-signalk"},
        ],
    })
d = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".settings.", dir=d, text=True)
try:
    with os.fdopen(fd, 'w', encoding='utf-8') as f:
        json.dump(cfg, f, indent=2)
        f.write("\n")
    os.replace(tmp, path)
finally:
    try:
        os.unlink(tmp)
    except FileNotFoundError:
        pass
PY
    chown -R keelos:keelos "$skdir" 2>/dev/null || true
  else
    [[ -n $N2K_IFACE ]] && say "${C_DIM}   dry-run: converge Signal K canboatjs provider on ${N2K_IFACE}${C_RESET}"
  fi
  record file "$skdir/settings.json"
  ok "Signal K settings at ${skdir}; NMEA 2000 PGNs use the installed canboatjs PGN database"
}

install_j1587_bridge() {
  [[ $A_J1708 == y ]] || return 0
  local dir="/usr/local/lib/keelos" bridge="/usr/local/lib/keelos/j1587_n2k.py"
  info "Installing SAE J1708/J1587 -> NMEA 2000 translator with alarm mapping"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: write ${bridge}; syntax-check + conversion/alarm self-test${C_RESET}"
    say "${C_DIM}   dry-run: link /usr/local/bin/keelos-j1708d -> ${bridge}${C_RESET}"
    return 0
  fi
  mkdir -p "$dir" /usr/local/bin
  cat > "$bridge" <<'PY_J1587_N2K'
#!/usr/bin/env python3
"""KeelOS SAE J1708/J1587 -> NMEA 2000 translator.

Read-only on J1708/J1587. It never transmits onto the legacy bus. Standard J1587
engine/transmission/electrical/fuel values with defensible NMEA 2000 equivalents
are emitted on SocketCAN. Standard diagnostic/warning information is translated
to the alarm/status bitfields in NMEA 2000 PGNs 127489 and 127493.
"""
from __future__ import annotations

import argparse
import glob
import hashlib
import json
import logging
import math
import os
import select
import socket
import struct
import termios
import time
from dataclasses import dataclass, field
from typing import Dict, Iterable, List, Optional, Set, Tuple

CAN_EFF_FLAG = 0x80000000
CAN_FRAME = struct.Struct("=IB3x8s")
NA_U16 = 0xFFFF
NA_S16 = 0x7FFF
NA_U32 = 0xFFFFFFFF
NA_S32 = 0x7FFFFFFF
NA_S8 = 0x7F

PGN_ADDRESS_CLAIM = 60928
PGN_VESSEL_HEADING = 127250
PGN_ENGINE_RAPID = 127488
PGN_ENGINE_DYNAMIC = 127489
PGN_TRANSMISSION_DYNAMIC = 127493
PGN_TRIP_ENGINE = 127497
PGN_ENGINE_STATIC = 127498
PGN_FLUID_LEVEL = 127505
PGN_BATTERY_STATUS = 127508
PGN_SPEED = 128259
PGN_DISTANCE_LOG = 128275
PGN_POSITION_RAPID = 129025
PGN_TEMPERATURE = 130312
PGN_ACTUAL_PRESSURE = 130314
FAST_PGNS = {PGN_ENGINE_DYNAMIC, PGN_TRIP_ENGINE, PGN_ENGINE_STATIC, PGN_DISTANCE_LOG}

# NMEA 2000 127489 ENGINE_STATUS_1 bits.
ES1_CHECK_ENGINE = 1 << 0
ES1_OVER_TEMP = 1 << 1
ES1_LOW_OIL_PRESSURE = 1 << 2
ES1_LOW_OIL_LEVEL = 1 << 3
ES1_LOW_FUEL_PRESSURE = 1 << 4
ES1_LOW_SYSTEM_VOLTAGE = 1 << 5
ES1_LOW_COOLANT_LEVEL = 1 << 6
ES1_WATER_IN_FUEL = 1 << 8
ES1_CHARGE_INDICATOR = 1 << 9
ES1_PREHEAT_INDICATOR = 1 << 10
ES1_HIGH_BOOST = 1 << 11
ES1_REV_LIMIT = 1 << 12
ES1_EGR_SYSTEM = 1 << 13
ES1_THROTTLE_POSITION_SENSOR = 1 << 14

# NMEA 2000 127489 ENGINE_STATUS_2 bits.
ES2_WARNING_LEVEL_1 = 1 << 0
ES2_WARNING_LEVEL_2 = 1 << 1
ES2_POWER_REDUCTION = 1 << 2
ES2_ENGINE_COMM_ERROR = 1 << 4
ES2_ENGINE_SHUTTING_DOWN = 1 << 7

# NMEA 2000 127493 TRANSMISSION_STATUS_1 bits.
TS1_CHECK_TRANSMISSION = 1 << 0
TS1_OVER_TEMP = 1 << 1
TS1_LOW_OIL_PRESSURE = 1 << 2
TS1_LOW_OIL_LEVEL = 1 << 3

ENGINE_MIDS = {128: 0, 175: 1, 183: 2, 184: 3, 185: 4, 186: 5}
TRANSMISSION_MIDS = {130: 0, 176: 1}
# These controller categories usually describe engine #1 auxiliaries rather than
# another engine instance. They are useful for alarm/data routing without
# inventing additional engine instances.
ENGINE_AUX_MIDS = {129, 140, 143, 158, 173, 174, 235, 241}

MAPPINGS = {
    "127488 Engine Parameters, Rapid Update": {
        "PID 190": "Engine speed (0.25 rpm/bit)",
        "PID 102": "Boost pressure (0.862 kPa/bit)",
        "PID 439": "Extended boost pressure #1 (0.125 kPa/bit)",
    },
    "127489 Engine Parameters, Dynamic": {
        "PID 19/100": "Engine oil pressure",
        "PID 175": "Engine oil temperature",
        "PID 110": "Engine coolant temperature",
        "PID 167": "Alternator potential",
        "PID 183": "Instantaneous fuel rate",
        "PID 247": "Total engine hours",
        "PID 20/109": "Engine coolant pressure",
        "PID 18/94": "Fuel delivery pressure",
        "PID 92": "Percent engine load",
        "PID 5/6/44/45/71/97/194": "Engine warning/alarm status bits",
    },
    "127493 Transmission Parameters, Dynamic": {
        "PID 162/163": "Selected/attained gear (P/R/N/D/L/digit collapsed to F/N/R)",
        "PID 127": "Transmission #1 oil pressure",
        "PID 177": "Transmission #1 oil temperature",
        "PID 418": "Transmission #2 oil temperature",
        "PID 194": "Transmission diagnostic status bits",
    },
    "127497 Trip Parameters, Engine": {
        "PID 182": "Trip fuel used",
        "PID 133": "Average fuel rate",
    },
    "127498 Engine Parameters, Static": {
        "PID 189": "Rated engine speed",
        "PID 237": "Vehicle identification number (VIN)",
        "PID 234": "Software identification",
    },
    "127505 Fluid Level": {
        "PID 96": "Primary fuel tank level",
        "PID 38": "Second/right fuel tank level",
    },
    "127508 Battery Status": {
        "PID 158/168": "Battery/switched battery voltage",
        "PID 444": "Battery #2 voltage",
        "PID 114": "Net battery current",
    },
    "127250 Vessel Heading": {
        "PID 165": "Compass bearing -> magnetic vessel heading",
    },
    "128259 Speed": {
        "PID 84": "Road speed -> ground-referenced speed",
    },
    "128275 Distance Log": {
        "PID 244": "Trip distance",
        "PID 245": "Total vehicle distance -> cumulative log",
    },
    "129025 Position, Rapid Update": {
        "PID 239": "Latitude/longitude from J1587 Position",
    },
    "130312 Temperature": {
        "PID 170": "Cab interior temperature -> Inside Temperature",
        "PID 171": "Ambient air temperature -> Outside Temperature",
        "PID 173": "Exhaust gas temperature -> Exhaust Gas Temperature",
    },
    "130314 Actual Pressure": {
        "PID 48/108": "Barometric pressure -> Atmospheric pressure",
    },
}


def clamp(v: float, lo: float, hi: float) -> float:
    return max(lo, min(hi, v))


def u16(data: bytes) -> int:
    return int.from_bytes(data[:2], "little", signed=False)


def s16(data: bytes) -> int:
    return int.from_bytes(data[:2], "little", signed=True)


def u32(data: bytes) -> int:
    return int.from_bytes(data[:4], "little", signed=False)


def s32(data: bytes) -> int:
    return int.from_bytes(data[:4], "little", signed=True)


def s8(v: int) -> int:
    return v - 256 if v & 0x80 else v


def fahrenheit_to_kelvin(f: float) -> float:
    return (f - 32.0) * (5.0 / 9.0) + 273.15


def put_u16(v: int) -> bytes:
    return int(v).to_bytes(2, "little", signed=False)


def put_s16(v: int) -> bytes:
    return int(v).to_bytes(2, "little", signed=True)


def put_u32(v: int) -> bytes:
    return int(v).to_bytes(4, "little", signed=False)


def put_s32(v: int) -> bytes:
    return int(v).to_bytes(4, "little", signed=True)


def make_can_id(pgn: int, src: int, dst: int = 0xFF, priority: int = 6) -> int:
    pf = (pgn >> 8) & 0xFF
    dp = (pgn >> 16) & 0x01
    if pf < 240:
        ps = dst & 0xFF
    else:
        ps = pgn & 0xFF
    cid = ((priority & 0x7) << 26) | (dp << 24) | (pf << 16) | (ps << 8) | (src & 0xFF)
    return cid | CAN_EFF_FLAG


class RawCan:
    def __init__(self, iface: str):
        self.iface = iface
        self.sock = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW)
        self.sock.bind((iface,))

    def send(self, can_id: int, data: bytes) -> None:
        if len(data) > 8:
            raise ValueError("CAN payload exceeds 8 bytes")
        self.sock.send(CAN_FRAME.pack(can_id, len(data), data.ljust(8, b"\xff")))


class FastPacketWriter:
    def __init__(self):
        self.seq: Dict[int, int] = {}

    def frames(self, pgn: int, payload: bytes) -> Iterable[bytes]:
        if len(payload) > 223:
            raise ValueError("NMEA 2000 fast packet payload exceeds 223 bytes")
        seq = self.seq.get(pgn, 0) & 7
        self.seq[pgn] = (seq + 1) & 7
        yield (bytes([(seq << 5), len(payload)]) + payload[:6]).ljust(8, b"\xff")
        pos, frame_no = 6, 1
        while pos < len(payload):
            yield (bytes([(seq << 5) | frame_no]) + payload[pos:pos + 7]).ljust(8, b"\xff")
            pos += 7
            frame_no += 1


@dataclass
class EngineState:
    speed_rpm: Optional[float] = None
    boost_pa: Optional[float] = None
    oil_pressure_pa: Optional[float] = None
    oil_temp_k: Optional[float] = None
    coolant_temp_k: Optional[float] = None
    alternator_v: Optional[float] = None
    fuel_rate_lph: Optional[float] = None
    hours_s: Optional[float] = None
    coolant_pressure_pa: Optional[float] = None
    fuel_pressure_pa: Optional[float] = None
    load_pct: Optional[float] = None
    trip_fuel_l: Optional[float] = None
    average_fuel_lph: Optional[float] = None
    rated_speed_rpm: Optional[float] = None
    vin: Optional[str] = None
    software_id: Optional[str] = None
    lamp_status: Optional[int] = None
    preheat_status: Optional[int] = None
    idle_shutdown_status: Optional[int] = None
    water_in_fuel: bool = False
    legacy_warnings: Dict[Tuple[str, int], float] = field(default_factory=dict)
    faults: Set[Tuple[bool, int, int]] = field(default_factory=set)  # is_sid, pid/sid, FMI


@dataclass
class TransmissionState:
    gear: Optional[int] = None
    oil_pressure_pa: Optional[float] = None
    oil_temp_k: Optional[float] = None
    faults: Set[Tuple[bool, int, int]] = field(default_factory=set)
    legacy_warnings: Dict[Tuple[str, int], float] = field(default_factory=dict)


@dataclass
class BatteryState:
    voltage_v: Optional[float] = None
    current_a: Optional[float] = None
    voltage_rank: int = -1


class Translator:
    def __init__(self, n2k_iface: Optional[str], source: int = 0x24, dry_io: bool = False):
        self.source = source
        self.can = None if dry_io else RawCan(n2k_iface or "vcan0")
        self.fp = FastPacketWriter()
        self.engines: Dict[int, EngineState] = {}
        self.trans: Dict[int, TransmissionState] = {}
        self.batteries: Dict[int, BatteryState] = {}
        self.fuel_levels: Dict[int, float] = {}
        self.trip_distance_m: Optional[float] = None
        self.total_distance_m: Optional[float] = None
        self.identity = self._identity()
        self.sent: List[Tuple[int, bytes]] = []

    @staticmethod
    def _identity() -> int:
        seed = "keelos-j1587"
        for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
            try:
                seed += open(path, "r", encoding="ascii").read().strip()
                break
            except OSError:
                pass
        return int.from_bytes(hashlib.sha256(seed.encode()).digest()[:4], "little") & 0x1FFFFF

    def engine_instance(self, mid: int) -> Optional[int]:
        if mid in ENGINE_MIDS:
            return ENGINE_MIDS[mid]
        if mid in ENGINE_AUX_MIDS:
            return 0
        return None

    def transmission_instance(self, mid: int) -> Optional[int]:
        return TRANSMISSION_MIDS.get(mid)

    def send(self, pgn: int, payload: bytes, priority: int) -> None:
        self.sent.append((pgn, payload))
        if self.can is None:
            return
        cid = make_can_id(pgn, self.source, 0xFF, priority)
        if pgn in FAST_PGNS or len(payload) > 8:
            for frame in self.fp.frames(pgn, payload):
                self.can.send(cid, frame)
        else:
            self.can.send(cid, payload)

    def claim_address(self) -> None:
        name = (
            self.identity
            | (0 << 21) | (0 << 32) | (0 << 35)
            | (130 << 40) | (25 << 49) | (4 << 60) | (1 << 63)
        )
        self.send(PGN_ADDRESS_CLAIM, name.to_bytes(8, "little"), 6)

    @staticmethod
    def _num_u16(value: Optional[float], resolution: float) -> int:
        return NA_U16 if value is None else int(round(clamp(value / resolution, 0, 0xFFFC)))

    @staticmethod
    def _num_s16(value: Optional[float], resolution: float) -> int:
        return NA_S16 if value is None else int(round(clamp(value / resolution, -32767, 32764)))

    def _engine_status(self, st: EngineState) -> Tuple[int, int]:
        now = time.monotonic()
        for key, expiry in list(st.legacy_warnings.items()):
            if expiry <= now:
                st.legacy_warnings.pop(key, None)
        s1 = s2 = 0
        if st.lamp_status is not None:
            red = st.lamp_status & 0x03
            amber = (st.lamp_status >> 2) & 0x03
            protect = (st.lamp_status >> 4) & 0x03
            if red == 1:
                s1 |= ES1_CHECK_ENGINE
                s2 |= ES2_WARNING_LEVEL_2
            elif red == 2:
                s1 |= ES1_CHECK_ENGINE
            if amber == 1:
                s1 |= ES1_CHECK_ENGINE
                s2 |= ES2_WARNING_LEVEL_1
            elif amber == 2:
                s1 |= ES1_CHECK_ENGINE
            if protect == 1:
                s1 |= ES1_CHECK_ENGINE
                s2 |= ES2_POWER_REDUCTION
            elif protect == 2:
                s1 |= ES1_CHECK_ENGINE
        if st.preheat_status == 1:
            s1 |= ES1_PREHEAT_INDICATOR
        if st.idle_shutdown_status is not None:
            # PID 71 bit 1 is the driver alert and bit 2 reports that the engine
            # has shut down by the idle timer. These are a close semantic match
            # to NMEA 2000's Engine Shutting Down status, not Emergency Stop.
            if st.idle_shutdown_status & 0x03:
                s2 |= ES2_ENGINE_SHUTTING_DOWN
            if st.idle_shutdown_status & 0x01:
                s2 |= ES2_WARNING_LEVEL_1
        if st.water_in_fuel:
            s1 |= ES1_WATER_IN_FUEL
        for direction, pid in st.legacy_warnings:
            s1 |= ES1_CHECK_ENGINE
            s1 |= self._engine_specific_alarm(pid, 1 if direction == "low" else 0)
        for is_sid, ident, fmi in st.faults:
            s1 |= ES1_CHECK_ENGINE
            if not is_sid:
                s1 |= self._engine_specific_alarm(ident, fmi)
        return s1, s2

    @staticmethod
    def _engine_specific_alarm(pid: int, fmi: int) -> int:
        if pid in {27, 362}:
            return ES1_EGR_SYSTEM
        if pid == 51:
            return ES1_THROTTLE_POSITION_SENSOR
        if fmi == 1:
            if pid in {19, 100}:
                return ES1_LOW_OIL_PRESSURE
            if pid in {17, 98, 366}:
                return ES1_LOW_OIL_LEVEL
            if pid in {18, 94, 375}:
                return ES1_LOW_FUEL_PRESSURE
            if pid == 111:
                return ES1_LOW_COOLANT_LEVEL
            if pid in {158, 168, 444}:
                return ES1_LOW_SYSTEM_VOLTAGE
            if pid == 167:
                return ES1_LOW_SYSTEM_VOLTAGE | ES1_CHARGE_INDICATOR
            if pid == 115:
                return ES1_CHARGE_INDICATOR
        if fmi == 4 and pid in {158, 168, 444}:
            return ES1_LOW_SYSTEM_VOLTAGE
        if fmi == 4 and pid == 167:
            return ES1_LOW_SYSTEM_VOLTAGE | ES1_CHARGE_INDICATOR
        if fmi == 5 and pid == 115:
            return ES1_CHARGE_INDICATOR
        if fmi == 0:
            if pid in {110, 175}:
                return ES1_OVER_TEMP
            if pid in {102, 439, 440}:
                return ES1_HIGH_BOOST
            if pid == 190:
                return ES1_REV_LIMIT
        return 0

    def _trans_status(self, st: TransmissionState) -> int:
        now = time.monotonic()
        for key, expiry in list(st.legacy_warnings.items()):
            if expiry <= now:
                st.legacy_warnings.pop(key, None)
        s = 0
        for direction, pid in st.legacy_warnings:
            s |= TS1_CHECK_TRANSMISSION
            if direction == "low" and pid == 127:
                s |= TS1_LOW_OIL_PRESSURE
            elif direction == "low" and pid in {124, 125}:
                s |= TS1_LOW_OIL_LEVEL
            elif direction == "high" and pid in {177, 418}:
                s |= TS1_OVER_TEMP
        for is_sid, pid, fmi in st.faults:
            s |= TS1_CHECK_TRANSMISSION
            if is_sid:
                continue
            if fmi == 1 and pid == 127:
                s |= TS1_LOW_OIL_PRESSURE
            elif fmi == 1 and pid in {124, 125}:
                s |= TS1_LOW_OIL_LEVEL
            elif fmi == 0 and pid in {177, 418}:
                s |= TS1_OVER_TEMP
        return s

    def emit_engine_rapid(self, inst: int) -> None:
        st = self.engines.setdefault(inst, EngineState())
        speed = self._num_u16(st.speed_rpm, 0.25)
        boost = self._num_u16(st.boost_pa, 100.0)
        payload = bytes([inst & 0xff]) + put_u16(speed) + put_u16(boost) + bytes([NA_S8, 0xff, 0xff])
        self.send(PGN_ENGINE_RAPID, payload, 2)

    def emit_engine_dynamic(self, inst: int) -> None:
        st = self.engines.setdefault(inst, EngineState())
        oil_p = self._num_u16(st.oil_pressure_pa, 100.0)
        oil_t = self._num_u16(st.oil_temp_k, 0.1)
        cool_t = self._num_u16(st.coolant_temp_k, 0.01)
        alt = self._num_s16(st.alternator_v, 0.01)
        fuel = self._num_s16(st.fuel_rate_lph, 0.1)
        hours = NA_U32 if st.hours_s is None else int(round(clamp(st.hours_s, 0, 0xfffffffc)))
        cool_p = self._num_u16(st.coolant_pressure_pa, 100.0)
        fuel_p = self._num_u16(st.fuel_pressure_pa, 1000.0)
        status1, status2 = self._engine_status(st)
        load = NA_S8 if st.load_pct is None else int(round(clamp(st.load_pct, -127, 124))) & 0xff
        payload = bytearray([inst & 0xff])
        payload += put_u16(oil_p) + put_u16(oil_t) + put_u16(cool_t)
        payload += (put_s16(alt) if alt != NA_S16 else b"\xff\x7f")
        payload += (put_s16(fuel) if fuel != NA_S16 else b"\xff\x7f")
        payload += put_u32(hours) + put_u16(cool_p) + put_u16(fuel_p)
        payload += b"\xff" + put_u16(status1) + put_u16(status2) + bytes([load, NA_S8])
        self.send(PGN_ENGINE_DYNAMIC, bytes(payload), 2)

    def emit_transmission(self, inst: int) -> None:
        st = self.trans.setdefault(inst, TransmissionState())
        gear = 3 if st.gear is None else st.gear
        p = self._num_u16(st.oil_pressure_pa, 100.0)
        t = self._num_u16(st.oil_temp_k, 0.1)
        payload = bytes([inst & 0xff, 0xfc | (gear & 3)]) + put_u16(p) + put_u16(t) + bytes([self._trans_status(st), 0xff])
        self.send(PGN_TRANSMISSION_DYNAMIC, payload, 2)

    def emit_vessel_heading(self, degrees: float) -> None:
        heading_rad = math.radians(degrees % 360.0)
        heading = int(round(clamp(heading_rad / 0.0001, 0, 0xfffc)))
        # PID 165 is explicitly a compass bearing, so use Magnetic reference.
        payload = b"\xff" + put_u16(heading) + b"\xff\x7f\xff\x7f" + bytes([0xFD])
        self.send(PGN_VESSEL_HEADING, payload, 2)

    def emit_ground_speed(self, speed_mps: float) -> None:
        ground = int(round(clamp(speed_mps / 0.01, 0, 0xfffc)))
        # Water-referenced speed/type are unavailable; ground speed is direct.
        payload = b"\xff\xff\xff" + put_u16(ground) + b"\xff\xff\xff"
        self.send(PGN_SPEED, payload, 2)

    def emit_distance_log(self) -> None:
        log = NA_U32 if self.total_distance_m is None else int(round(clamp(self.total_distance_m, 0, 0xfffffffc)))
        trip = NA_U32 if self.trip_distance_m is None else int(round(clamp(self.trip_distance_m, 0, 0xfffffffc)))
        payload = b"\xff\xff" + b"\xff\xff\xff\xff" + put_u32(log) + put_u32(trip)
        self.send(PGN_DISTANCE_LOG, payload, 6)

    def emit_position(self, latitude_deg: float, longitude_deg: float) -> None:
        if not (-90.0 <= latitude_deg <= 90.0 and -180.0 <= longitude_deg <= 180.0):
            return
        lat = int(round(latitude_deg / 1e-7))
        lon = int(round(longitude_deg / 1e-7))
        self.send(PGN_POSITION_RAPID, put_s32(lat) + put_s32(lon), 2)

    def emit_temperature(self, instance: int, source: int, kelvin: float) -> None:
        raw = self._num_u16(kelvin, 0.01)
        payload = bytes([0xff, instance & 0xff, source & 0xff]) + put_u16(raw) + b"\xff\xff\xff"
        self.send(PGN_TEMPERATURE, payload, 5)

    def emit_pressure(self, instance: int, source: int, pressure_pa: float) -> None:
        raw = int(round(clamp(pressure_pa / 0.1, -2147483647, 2147483644)))
        payload = bytes([0xff, instance & 0xff, source & 0xff]) + put_s32(raw) + b"\xff"
        self.send(PGN_ACTUAL_PRESSURE, payload, 5)

    def emit_fuel_level(self, tank: int) -> None:
        pct = self.fuel_levels.get(tank)
        raw = NA_S16 if pct is None else int(round(clamp(pct / 0.004, -32767, 32764)))
        first = tank & 0x0f  # type 0 (fuel) occupies high nibble
        payload = bytes([first]) + (put_s16(raw) if raw != NA_S16 else b"\xff\x7f") + put_u32(NA_U32) + b"\xff"
        self.send(PGN_FLUID_LEVEL, payload, 6)

    def emit_battery(self, inst: int) -> None:
        st = self.batteries.setdefault(inst, BatteryState())
        v = self._num_s16(st.voltage_v, 0.01)
        c = self._num_s16(st.current_a, 0.1)
        payload = bytes([inst & 0xff])
        payload += put_s16(v) if v != NA_S16 else b"\xff\x7f"
        payload += put_s16(c) if c != NA_S16 else b"\xff\x7f"
        payload += b"\xff\xff\xff"  # temperature and SID unavailable
        self.send(PGN_BATTERY_STATUS, payload, 6)

    @staticmethod
    def _lau(text: Optional[str]) -> bytes:
        if not text:
            return bytes([2, 1])
        data = text.encode("latin1", errors="replace")[:250]
        return bytes([len(data) + 2, 1]) + data

    def emit_engine_static(self, inst: int) -> None:
        st = self.engines.setdefault(inst, EngineState())
        rated = self._num_u16(st.rated_speed_rpm, 0.25)
        payload = bytes([inst & 0xff]) + put_u16(rated) + self._lau(st.vin) + self._lau(st.software_id)
        self.send(PGN_ENGINE_STATIC, payload, 5)

    def emit_trip(self, inst: int) -> None:
        st = self.engines.setdefault(inst, EngineState())
        trip = NA_U16 if st.trip_fuel_l is None else int(round(clamp(st.trip_fuel_l, 0, 0xfffc)))
        avg = self._num_s16(st.average_fuel_lph, 0.1)
        payload = bytes([inst & 0xff]) + put_u16(trip)
        payload += put_s16(avg) if avg != NA_S16 else b"\xff\x7f"
        payload += b"\xff\x7f\xff\x7f"  # economy and instantaneous-economy unavailable
        self.send(PGN_TRIP_ENGINE, payload, 5)

    @staticmethod
    def decode_text(data: bytes) -> str:
        return data.decode("latin1", errors="replace").rstrip("\x00\xff ")

    @staticmethod
    def parse_gear(data: bytes) -> Optional[int]:
        try:
            text = data.decode("latin1", errors="ignore").strip(" \x00\xff").upper()
        except Exception:
            return None
        if not text:
            return None
        if text.startswith("R"):
            return 2
        if text.startswith(("N", "P")):
            return 1
        if text.startswith(("D", "L")) or text[0].isdigit():
            return 0
        return None

    def _set_battery_voltage(self, inst: int, volts: float, rank: int) -> None:
        st = self.batteries.setdefault(inst, BatteryState())
        if rank >= st.voltage_rank:
            st.voltage_v = volts
            st.voltage_rank = rank

    def _update_diagnostic(self, mid: int, data: bytes) -> Tuple[Set[int], Set[int]]:
        """Apply PID 194. Return changed engine/transmission instance sets."""
        ei = self.engine_instance(mid)
        ti = self.transmission_instance(mid)
        changed_e: Set[int] = set()
        changed_t: Set[int] = set()
        # SAE J1587 permits PID 194 with a zero byte count to report that no
        # diagnostic codes are active. Clear any previously latched faults for
        # that controller instead of leaving stale NMEA alarms asserted forever.
        if not data:
            if ei is not None:
                self.engines.setdefault(ei, EngineState()).faults.clear()
                changed_e.add(ei)
            if ti is not None:
                self.trans.setdefault(ti, TransmissionState()).faults.clear()
                changed_t.add(ti)
            return changed_e, changed_t

        pos = 0
        while pos + 2 <= len(data):
            ident = data[pos]
            code = data[pos + 1]
            pos += 2
            has_count = bool(code & 0x80)
            inactive = bool(code & 0x40)
            standard = bool(code & 0x20)
            is_sid = bool(code & 0x10) if standard else False
            fmi = code & 0x0f
            if not standard:
                ident += 256
            if has_count:
                if pos >= len(data):
                    raise ValueError("PID 194 occurrence-count flag set without occurrence-count byte")
                pos += 1
            key = (is_sid, ident, fmi)
            if ei is not None:
                st = self.engines.setdefault(ei, EngineState())
                if inactive:
                    st.faults.discard(key)
                else:
                    st.faults.add(key)
                changed_e.add(ei)
            if ti is not None:
                stt = self.trans.setdefault(ti, TransmissionState())
                if inactive:
                    stt.faults.discard(key)
                else:
                    stt.faults.add(key)
                changed_t.add(ti)
        return changed_e, changed_t

    def process_message(self, frame: bytes) -> None:
        if len(frame) < 3 or (sum(frame) & 0xff) != 0:
            raise ValueError("invalid J1708 checksum/frame")
        mid = frame[0]
        params = parse_j1587_params(frame[1:-1])
        ei = self.engine_instance(mid)
        ti = self.transmission_instance(mid)
        engine_rapid = engine_dynamic = trip = engine_static = False
        trans_emit = False
        batteries: Set[int] = set()
        tanks: Set[int] = set()
        now = time.monotonic()

        for pid, data in params:
            if pid == 194:
                ce, ct = self._update_diagnostic(mid, data)
                if ei in ce:
                    engine_dynamic = True
                if ti in ct:
                    trans_emit = True
                continue
            if pid in {5, 6} and data:
                offending = data[0]
                direction = "low" if pid == 5 else "high"
                if ei is not None:
                    self.engines.setdefault(ei, EngineState()).legacy_warnings[(direction, offending)] = now + 3.0
                    engine_dynamic = True
                if ti is not None:
                    self.trans.setdefault(ti, TransmissionState()).legacy_warnings[(direction, offending)] = now + 3.0
                    trans_emit = True
                continue

            if ei is not None:
                st = self.engines.setdefault(ei, EngineState())
                if pid == 44 and data:
                    st.lamp_status = data[0]
                    engine_dynamic = True
                elif pid == 45 and data:
                    st.preheat_status = (data[0] >> 4) & 0x03
                    engine_dynamic = True
                elif pid == 71 and data:
                    st.idle_shutdown_status = data[0]
                    engine_dynamic = True
                elif pid == 92 and data:
                    st.load_pct = data[0] * 0.5
                    engine_dynamic = True
                elif pid == 18 and data:
                    st.fuel_pressure_pa = data[0] * 4000.0
                    engine_dynamic = True
                elif pid == 19 and data:
                    st.oil_pressure_pa = data[0] * 4000.0
                    engine_dynamic = True
                elif pid == 20 and data:
                    st.coolant_pressure_pa = data[0] * 2000.0
                    engine_dynamic = True
                elif pid == 94 and data:
                    st.fuel_pressure_pa = data[0] * 3450.0
                    engine_dynamic = True
                elif pid == 97 and data:
                    st.water_in_fuel = bool(data[0] & 0x80)
                    engine_dynamic = True
                elif pid == 100 and data:
                    st.oil_pressure_pa = data[0] * 3450.0
                    engine_dynamic = True
                elif pid == 102 and data:
                    st.boost_pa = data[0] * 862.0
                    engine_rapid = True
                elif pid == 109 and data:
                    st.coolant_pressure_pa = data[0] * 862.0
                    engine_dynamic = True
                elif pid == 110 and data:
                    st.coolant_temp_k = fahrenheit_to_kelvin(float(data[0]))
                    engine_dynamic = True
                elif pid == 111 and data:
                    # Level itself has no field in 127489; diagnostics can still set Low Coolant Level.
                    pass
                elif pid == 133 and len(data) >= 2:
                    st.average_fuel_lph = u16(data) * (3.785411784 / 64.0)
                    trip = True
                elif pid == 167 and len(data) >= 2:
                    st.alternator_v = u16(data) * 0.05
                    engine_dynamic = True
                elif pid == 175 and len(data) >= 2:
                    st.oil_temp_k = fahrenheit_to_kelvin(s16(data) * 0.25)
                    engine_dynamic = True
                elif pid == 182 and len(data) >= 2:
                    st.trip_fuel_l = u16(data) * 0.473
                    trip = True
                elif pid == 183 and len(data) >= 2:
                    st.fuel_rate_lph = u16(data) * (3.785411784 / 64.0)
                    engine_dynamic = True
                elif pid == 189 and len(data) >= 2:
                    st.rated_speed_rpm = u16(data) * 0.25
                    engine_static = True
                elif pid == 190 and len(data) >= 2:
                    st.speed_rpm = u16(data) * 0.25
                    engine_rapid = True
                elif pid == 234:
                    st.software_id = self.decode_text(data)
                    engine_static = True
                elif pid == 237:
                    st.vin = self.decode_text(data)
                    engine_static = True
                elif pid == 247 and len(data) >= 4:
                    st.hours_s = u32(data) * 0.05 * 3600.0
                    engine_dynamic = True
                elif pid == 439 and len(data) >= 2:
                    st.boost_pa = u16(data) * 125.0
                    engine_rapid = True

            if ti is not None:
                ts = self.trans.setdefault(ti, TransmissionState())
                if pid in {162, 163} and len(data) >= 2:
                    # Prefer attained (163) over selected (162) once available.
                    if pid == 163 or ts.gear is None:
                        ts.gear = self.parse_gear(data)
                    trans_emit = True
                elif pid == 127 and data:
                    ts.oil_pressure_pa = data[0] * 13800.0
                    trans_emit = True
                elif pid == 177 and len(data) >= 2:
                    ts.oil_temp_k = fahrenheit_to_kelvin(s16(data) * 0.25)
                    trans_emit = True
                elif pid == 418 and len(data) >= 2:
                    # Page-2 PID 418 specifically denotes transmission #2.
                    ts2 = self.trans.setdefault(1, TransmissionState())
                    ts2.oil_temp_k = fahrenheit_to_kelvin(s16(data) * 0.25)
                    self.emit_transmission(1)

            # Navigation/environmental values are valid outside engine MIDs too.
            if pid == 84 and data:
                self.emit_ground_speed(data[0] * 0.805 / 3.6)
            elif pid == 165 and len(data) >= 2:
                deg = u16(data) * 0.01
                if deg < 360.0:
                    self.emit_vessel_heading(deg)
            elif pid == 239 and len(data) in {8, 10}:
                lat = s32(data[0:4]) * 1e-6
                lon = s32(data[4:8]) * 1e-6
                self.emit_position(lat, lon)
            elif pid == 244 and len(data) >= 4:
                self.trip_distance_m = u32(data) * 160.0
                self.emit_distance_log()
            elif pid == 245 and len(data) >= 4:
                self.total_distance_m = u32(data) * 161.0
                self.emit_distance_log()
            elif pid == 170 and len(data) >= 2:
                self.emit_temperature(0, 2, fahrenheit_to_kelvin(s16(data) * 0.25))
            elif pid == 171 and len(data) >= 2:
                self.emit_temperature(0, 1, fahrenheit_to_kelvin(s16(data) * 0.25))
            elif pid == 173 and len(data) >= 2:
                self.emit_temperature(ei if ei is not None else 0, 14, fahrenheit_to_kelvin(s16(data) * 0.25))
            elif pid == 48 and data:
                self.emit_pressure(0, 0, data[0] * 600.0)
            elif pid == 108 and data:
                self.emit_pressure(0, 0, data[0] * 431.0)

            # Tank/electrical PIDs may be sent by dedicated controller MIDs, so
            # process them independently of engine MID routing.
            if pid == 96 and data:
                self.fuel_levels[0] = data[0] * 0.5
                tanks.add(0)
            elif pid == 38 and data:
                self.fuel_levels[1] = data[0] * 0.5
                tanks.add(1)
            elif pid == 168 and len(data) >= 2:
                self._set_battery_voltage(0, u16(data) * 0.05, 3)
                batteries.add(0)
            elif pid == 158 and len(data) >= 2:
                self._set_battery_voltage(0, u16(data) * 0.05, 2)
                batteries.add(0)
            elif pid == 444 and len(data) >= 2:
                self._set_battery_voltage(1, u16(data) * 0.05, 3)
                batteries.add(1)
            elif pid == 114 and data:
                self.batteries.setdefault(0, BatteryState()).current_a = s8(data[0]) * 1.2
                batteries.add(0)

        if ei is not None:
            if engine_rapid:
                self.emit_engine_rapid(ei)
            if engine_dynamic:
                self.emit_engine_dynamic(ei)
            if trip:
                self.emit_trip(ei)
            if engine_static:
                self.emit_engine_static(ei)
        if ti is not None and trans_emit:
            self.emit_transmission(ti)
        for tank in sorted(tanks):
            self.emit_fuel_level(tank)
        for batt in sorted(batteries):
            self.emit_battery(batt)


def pid_data_length(pid: int, buf: bytes, pos: int) -> Optional[Tuple[int, int]]:
    """Return (data_start, data_length) for actual PID; None when incomplete."""
    if 0 <= pid <= 127 or 256 <= pid <= 383:
        return (pos, 1) if pos + 1 <= len(buf) else None
    if 128 <= pid <= 191 or 384 <= pid <= 447:
        return (pos, 2) if pos + 2 <= len(buf) else None
    if 192 <= pid <= 253 or 448 <= pid <= 509:
        if pos >= len(buf):
            return None
        n = buf[pos]
        return (pos + 1, n) if pos + 1 + n <= len(buf) else None
    return None


def parse_j1587_params(body: bytes) -> List[Tuple[int, bytes]]:
    """Parse bytes between MID and checksum into actual PID numbers and data."""
    out: List[Tuple[int, bytes]] = []
    pos = 0
    page2 = False
    if body and body[0] == 255:
        page2 = True
        pos = 1
    while pos < len(body):
        raw_pid = body[pos]
        pos += 1
        pid = raw_pid + (256 if page2 else 0)
        if pid in {254, 510}:
            # Escape data is manufacturer-defined and consumes the rest of message.
            break
        if pid in {255, 511}:
            raise ValueError("extension PID is only valid immediately after MID")
        span = pid_data_length(pid, body, pos)
        if span is None:
            raise ValueError(f"incomplete/unsupported J1587 PID {pid}")
        start, length = span
        out.append((pid, bytes(body[start:start + length])))
        pos = start + length
    return out


def frame_is_valid(frame: bytes) -> bool:
    if len(frame) < 3 or not (128 <= frame[0] <= 255) or (sum(frame) & 0xff):
        return False
    try:
        parse_j1587_params(frame[1:-1])
        return True
    except ValueError:
        return False


def extract_frames(buffer: bytearray, flush: bool = False) -> List[bytes]:
    """Extract checksum-valid J1587 frames from a possibly coalesced serial read."""
    frames: List[bytes] = []
    while buffer:
        while buffer and buffer[0] < 128:
            del buffer[0]
        if len(buffer) < 3:
            break
        found = None
        # J1708 packets are short; use a generous cap so malformed input cannot
        # cause unbounded scanning/memory growth.
        cap = min(len(buffer), 64)
        for end in range(3, cap + 1):
            candidate = bytes(buffer[:end])
            if (sum(candidate) & 0xff) != 0:
                continue
            if not frame_is_valid(candidate):
                continue
            # Prefer a boundary followed by another legal J1587 MID. During an
            # idle-gap flush, end-of-buffer is also a definitive boundary.
            if end < len(buffer) and buffer[end] >= 128:
                found = end
                break
            if flush and end == len(buffer):
                found = end
                break
        if found is None:
            if flush:
                # resynchronize without allowing a stale malformed frame to grow forever
                del buffer[0]
                continue
            break
        frames.append(bytes(buffer[:found]))
        del buffer[:found]
    if len(buffer) > 256:
        del buffer[:-64]
    return frames


def checksum_frame(mid: int, payload: bytes) -> bytes:
    base = bytes([mid]) + payload
    return base + bytes([(-sum(base)) & 0xff])


def choose_port(requested: str) -> str:
    if requested != "auto":
        if not requested.startswith("/dev/"):
            raise SystemExit("J1708 serial port must be 'auto' or a /dev/... path")
        if not os.path.exists(requested):
            raise SystemExit(f"J1708 serial port does not exist: {requested}")
        return requested
    # Auto-detection deliberately prefers USB/ACM devices, which are commonly
    # complete RS-485 adapters. A bare Pi UART is TTL, not J1708 electrical
    # signaling, so /dev/serial0 must be explicitly selected by the installer
    # when the Waveshare RS485 CAN HAT provides the actual RS-485 transceiver.
    candidates: List[str] = []
    for pat in ("/dev/ttyUSB*", "/dev/ttyACM*"):
        candidates.extend(sorted(glob.glob(pat)))
    seen = []
    for p in candidates:
        real = os.path.realpath(p)
        if real not in seen:
            seen.append(real)
            return p
    raise SystemExit("No J1708 serial/RS-485 port found; set j1708_port explicitly in answers.yml")


def configure_serial(path: str) -> int:
    fd = os.open(path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
    attrs = termios.tcgetattr(fd)
    attrs[0] = 0
    attrs[1] = 0
    attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
    attrs[3] = 0
    attrs[4] = termios.B9600
    attrs[5] = termios.B9600
    attrs[6][termios.VMIN] = 0
    attrs[6][termios.VTIME] = 0
    termios.tcsetattr(fd, termios.TCSANOW, attrs)
    termios.tcflush(fd, termios.TCIFLUSH)
    return fd


def run(args) -> None:
    port = choose_port(args.port)
    tr = Translator(args.n2k_iface, args.source)
    fd = configure_serial(port)
    logging.info("KeelOS J1587: %s @ 9600 8N1 (read-only) -> NMEA2000 %s", port, args.n2k_iface)
    buf = bytearray()
    last_rx = 0.0
    last_claim = 0.0
    # A few milliseconds comfortably exceeds the inter-message idle period at 9600 bit/s
    # while remaining fast enough for normal J1587 update rates.
    gap = 0.004
    try:
        while True:
            now = time.monotonic()
            if now - last_claim >= 60.0:
                tr.claim_address()
                last_claim = now
            ready, _, _ = select.select([fd], [], [], 0.02)
            if ready:
                try:
                    chunk = os.read(fd, 256)
                except BlockingIOError:
                    chunk = b""
                if chunk:
                    buf.extend(chunk)
                    last_rx = time.monotonic()
                    for frame in extract_frames(buf, flush=False):
                        try:
                            tr.process_message(frame)
                        except Exception:
                            logging.exception("J1587 conversion failed for frame %s", frame.hex())
            elif buf and time.monotonic() - last_rx >= gap:
                for frame in extract_frames(buf, flush=True):
                    try:
                        tr.process_message(frame)
                    except Exception:
                        logging.exception("J1587 conversion failed for frame %s", frame.hex())
    finally:
        os.close(fd)


def self_test() -> None:
    # Basic PID parsing + checksum + engine-speed conversion.
    speed_raw = int(2000 / 0.25)
    f = checksum_frame(128, bytes([190]) + speed_raw.to_bytes(2, "little"))
    assert frame_is_valid(f)
    tr = Translator(None, dry_io=True)
    tr.process_message(f)
    rapid = [p for p in tr.sent if p[0] == PGN_ENGINE_RAPID][-1][1]
    assert int.from_bytes(rapid[1:3], "little") == speed_raw

    # Active low-oil-pressure diagnostic: PID 100 + FMI 1 -> status1 bit 2.
    tr.sent.clear()
    diag = bytes([194, 2, 100, 0x20 | 0x01])
    tr.process_message(checksum_frame(128, diag))
    dyn = [p for p in tr.sent if p[0] == PGN_ENGINE_DYNAMIC][-1][1]
    status1 = int.from_bytes(dyn[20:22], "little")
    assert status1 & ES1_CHECK_ENGINE and status1 & ES1_LOW_OIL_PRESSURE

    # Inactive form clears that exact fault.
    tr.sent.clear()
    diag_inactive = bytes([194, 2, 100, 0x40 | 0x20 | 0x01])
    tr.process_message(checksum_frame(128, diag_inactive))
    dyn = [p for p in tr.sent if p[0] == PGN_ENGINE_DYNAMIC][-1][1]
    assert not (int.from_bytes(dyn[20:22], "little") & ES1_LOW_OIL_PRESSURE)

    # SAE warning lamps and discrete indicators map to their nearest standard N2K status bits.
    tr.sent.clear()
    tr.process_message(checksum_frame(128, bytes([44, 0xC1, 45, 0xD0, 97, 0x80])))
    dyn = [p for p in tr.sent if p[0] == PGN_ENGINE_DYNAMIC][-1][1]
    s1 = int.from_bytes(dyn[20:22], "little")
    s2 = int.from_bytes(dyn[22:24], "little")
    assert s1 & ES1_CHECK_ENGINE and s1 & ES1_PREHEAT_INDICATOR and s1 & ES1_WATER_IN_FUEL
    assert s2 & ES2_WARNING_LEVEL_2

    # Above-normal coolant temperature becomes Over Temperature; transmission low oil
    # pressure becomes the equivalent 127493 status bit.
    tr.sent.clear()
    tr.process_message(checksum_frame(128, bytes([194, 2, 110, 0x20 | 0x00])))
    dyn = [p for p in tr.sent if p[0] == PGN_ENGINE_DYNAMIC][-1][1]
    assert int.from_bytes(dyn[20:22], "little") & ES1_OVER_TEMP
    tr.sent.clear()
    tr.process_message(checksum_frame(130, bytes([194, 2, 127, 0x20 | 0x01])))
    tp = [p for p in tr.sent if p[0] == PGN_TRANSMISSION_DYNAMIC][-1][1]
    assert tp[6] & TS1_CHECK_TRANSMISSION and tp[6] & TS1_LOW_OIL_PRESSURE

    # Static engine data: rated speed plus J1587 variable-length VIN/software fields.
    tr.sent.clear()
    rated = int(2800 / 0.25)
    vin = b"TESTVIN1234567890"
    software = b"ECM-1.2.3"
    static_body = bytes([189]) + rated.to_bytes(2, "little")
    static_body += bytes([237, len(vin)]) + vin
    static_body += bytes([234, len(software)]) + software
    tr.process_message(checksum_frame(128, static_body))
    sp = [p for p in tr.sent if p[0] == PGN_ENGINE_STATIC][-1][1]
    assert int.from_bytes(sp[1:3], "little") == rated and vin in sp and software in sp

    # Page-2 extended boost PID 439 is transmitted modulo 256 as 183 after PID 255.
    tr.sent.clear()
    boost_raw = 1600
    tr.process_message(checksum_frame(128, bytes([255, 183]) + boost_raw.to_bytes(2, "little")))
    rapid = [p for p in tr.sent if p[0] == PGN_ENGINE_RAPID][-1][1]
    assert int.from_bytes(rapid[3:5], "little") == round((boost_raw * 125.0) / 100.0)

    assert Translator.parse_gear(b" R") == 2
    assert Translator.parse_gear(b" N") == 1
    assert Translator.parse_gear(b"D2") == 0
    assert len(rapid) == 8

    # Additional direct J1587 equivalents: ground speed, magnetic heading,
    # position, distance log, environmental temperature/pressure.
    tr.sent.clear()
    tr.process_message(checksum_frame(145, bytes([84, 100])))
    spd = [p for p in tr.sent if p[0] == PGN_SPEED][-1][1]
    assert int.from_bytes(spd[3:5], "little") == round((100 * 0.805 / 3.6) / 0.01)

    tr.sent.clear()
    bearing_raw = int(123.45 / 0.01)
    tr.process_message(checksum_frame(162, bytes([165]) + bearing_raw.to_bytes(2, "little")))
    hdg = [p for p in tr.sent if p[0] == PGN_VESSEL_HEADING][-1][1]
    assert hdg[7] == 0xFD

    tr.sent.clear()
    lat_raw, lon_raw = int(26.123456 / 1e-6), int(-80.123456 / 1e-6)
    posdata = lat_raw.to_bytes(4, "little", signed=True) + lon_raw.to_bytes(4, "little", signed=True)
    tr.process_message(checksum_frame(162, bytes([239, len(posdata)]) + posdata))
    posp = [p for p in tr.sent if p[0] == PGN_POSITION_RAPID][-1][1]
    assert int.from_bytes(posp[:4], "little", signed=True) == lat_raw * 10
    assert int.from_bytes(posp[4:8], "little", signed=True) == lon_raw * 10

    tr.sent.clear()
    dbody = bytes([244, 4]) + (10).to_bytes(4, "little") + bytes([245, 4]) + (20).to_bytes(4, "little")
    tr.process_message(checksum_frame(141, dbody))
    dl = [p for p in tr.sent if p[0] == PGN_DISTANCE_LOG][-1][1]
    assert int.from_bytes(dl[6:10], "little") == 3220
    assert int.from_bytes(dl[10:14], "little") == 1600

    tr.sent.clear()
    amb_raw = int(77.0 / 0.25)
    tr.process_message(checksum_frame(200, bytes([171]) + amb_raw.to_bytes(2, "little", signed=True) + bytes([108, 235])))
    temp = [p for p in tr.sent if p[0] == PGN_TEMPERATURE][-1][1]
    press = [p for p in tr.sent if p[0] == PGN_ACTUAL_PRESSURE][-1][1]
    assert temp[2] == 1 and abs(int.from_bytes(temp[3:5], "little") * 0.01 - 298.15) < 0.02
    assert press[2] == 0 and int.from_bytes(press[3:7], "little", signed=True) == round((235 * 431.0) / 0.1)

    # Alarm extensions: idle-shutdown state, EGR and throttle-position diagnostics.
    tr.sent.clear()
    tr.process_message(checksum_frame(128, bytes([71, 0x01])))
    dyn = [p for p in tr.sent if p[0] == PGN_ENGINE_DYNAMIC][-1][1]
    assert int.from_bytes(dyn[22:24], "little") & ES2_ENGINE_SHUTTING_DOWN
    tr.sent.clear()
    tr.process_message(checksum_frame(128, bytes([194, 2, 27, 0x20 | 0x02])))
    dyn = [p for p in tr.sent if p[0] == PGN_ENGINE_DYNAMIC][-1][1]
    assert int.from_bytes(dyn[20:22], "little") & ES1_EGR_SYSTEM
    tr.sent.clear()
    tr.process_message(checksum_frame(128, bytes([194, 2, 51, 0x20 | 0x02])))
    dyn = [p for p in tr.sent if p[0] == PGN_ENGINE_DYNAMIC][-1][1]
    assert int.from_bytes(dyn[20:22], "little") & ES1_THROTTLE_POSITION_SENSOR

    # Coalesced stream splitter.
    a = checksum_frame(128, bytes([190]) + speed_raw.to_bytes(2, "little"))
    b = checksum_frame(128, bytes([100, 20]))
    buf = bytearray(a + b)
    got = extract_frames(buf, flush=False)
    got += extract_frames(buf, flush=True)
    assert got == [a, b] and not buf
    print("j1587_n2k self-test: OK")


def parse_args(argv=None):
    p = argparse.ArgumentParser(description="KeelOS SAE J1708/J1587 -> NMEA 2000 translator")
    p.add_argument("--port", default="auto", help="J1708 serial port or 'auto'")
    p.add_argument("--n2k-iface", default="vcan0", help="destination SocketCAN NMEA 2000 interface")
    p.add_argument("--source", type=lambda x: int(x, 0), default=0x24, help="NMEA 2000 source address")
    p.add_argument("--list-mappings", action="store_true")
    p.add_argument("--self-test", action="store_true")
    p.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
    return p.parse_args(argv)


def main(argv=None):
    args = parse_args(argv)
    if args.self_test:
        self_test()
        return 0
    if args.list_mappings:
        print(json.dumps({
            "direction": "SAE J1708/J1587 -> NMEA 2000",
            "nmea2000_pgns": MAPPINGS,
            "alarm_inputs": ["PID 5", "PID 6", "PID 44", "PID 45", "PID 71", "PID 97", "PID 194/FMI"],
            "diagnostic_policy": "Only standard, semantically defensible alarm bits are asserted; unknown SID/PID/FMI faults become generic Check Engine/Check Transmission.",
        }, indent=2, sort_keys=True))
        return 0
    if args.self_test:
        self_test()
        return 0
    logging.basicConfig(level=getattr(logging, args.log_level), format="%(asctime)s %(levelname)s %(message)s")
    run(args)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PY_J1587_N2K
  chmod 0755 "$bridge"
  python3 -m py_compile "$bridge"
  python3 "$bridge" --self-test >/dev/null
  python3 "$bridge" --list-mappings >/dev/null
  ln -sfn "$bridge" /usr/local/bin/keelos-j1708d
  record file "$bridge"
  record file /usr/local/bin/keelos-j1708d
  ok "J1587 translator passed syntax, parser, scaling, page-2, framing, and alarm self-tests"
}

install_protocol_bridge() {
  [[ $A_CONVERT == y ]] || return 0
  local dir="/usr/local/lib/keelos" bridge="/usr/local/lib/keelos/protocol_bridge.py"
  info "Installing bidirectional NMEA 2000 <-> J1939 translator"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: write ${bridge}; syntax-check and mapping self-test${C_RESET}"
    return 0
  fi
  mkdir -p "$dir"
  cat > "$bridge" <<'PYBRIDGE'
#!/usr/bin/env python3
"""KeelOS bidirectional NMEA 2000 <-> SAE J1939 bridge.

Uses Linux SocketCAN directly (stdlib only). It deliberately converts only
semantically equivalent, well-known engine/transmission/fuel/electrical data.
Other NMEA 2000 PGNs remain available unchanged to Signal K/canboat on the N2K
interface; they are not fabricated into unrelated J1939 PGNs.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import logging
import os
import selectors
import socket
import struct
import sys
import time
from dataclasses import dataclass, field
from typing import Dict, Optional, Tuple

CAN_EFF_FLAG = 0x80000000
CAN_RTR_FLAG = 0x40000000
CAN_ERR_FLAG = 0x20000000
CAN_EFF_MASK = 0x1FFFFFFF
CAN_FRAME = struct.Struct("=IB3x8s")

# NMEA 2000 PGNs with direct/common J1939 equivalents used by this bridge.
N2K_ENGINE_RAPID = 127488
N2K_ENGINE_DYNAMIC = 127489
N2K_TRANSMISSION_DYNAMIC = 127493
N2K_FLUID_LEVEL = 127505
N2K_BATTERY_STATUS = 127508

# Common SAE J1939 PGNs found on marine engine networks.
J1939_EEC2 = 61443                 # SPN 92 Engine Percent Load at Current Speed
J1939_EEC1 = 61444                 # SPN 190 Engine Speed, SPN 513 Actual Torque
J1939_ETC2 = 61445                 # SPN 523 Current Gear
J1939_DM1 = 65226                  # Active DTCs / lamp status
J1939_HOURS = 65253                # SPN 247 Engine Total Hours
J1939_ET1 = 65262                  # SPNs 110/174/175 temperatures
J1939_EFLP1 = 65263                # SPNs 94/100/109/111 pressures/level
J1939_LFE1 = 65266                 # SPNs 183/184/185 fuel data
J1939_IC1 = 65270                  # SPN 102 Boost/Intake Manifold Pressure
J1939_VEP1 = 65271                 # SPN 168 Battery Potential / Power Input 1
J1939_TRF1 = 65272                 # SPNs 127/177 transmission pressure/temp
J1939_DD1 = 65276                  # SPN 96 Fuel Level 1
J1939_TP_CM = 60416                # Transport Protocol Connection Management
J1939_TP_DT = 60160                # Transport Protocol Data Transfer

# Common marine J1939 PGNs seen in current chartplotter integrations that do
# not have a safe, direct NMEA 2000 engine-field equivalent in this bridge.
# They remain visible on the J1939 bus and are intentionally not fabricated.
J1939_KNOWN_UNMAPPED = {
    65031: "Exhaust manifold temperatures",
    65172: "Auxiliary coolant",
    65248: "Vehicle distance",
    65252: "Engine protection / wait-to-start status",
    65256: "Vehicle navigation speed",
    65279: "Water in fuel",
}

MAPPED_J1939 = {
    J1939_EEC2, J1939_EEC1, J1939_ETC2, J1939_DM1, J1939_HOURS,
    J1939_ET1, J1939_EFLP1, J1939_LFE1, J1939_IC1, J1939_VEP1,
    J1939_TRF1, J1939_DD1,
}
MAPPED_N2K = {
    N2K_ENGINE_RAPID, N2K_ENGINE_DYNAMIC, N2K_TRANSMISSION_DYNAMIC,
    N2K_FLUID_LEVEL, N2K_BATTERY_STATUS,
}
FAST_N2K = {N2K_ENGINE_DYNAMIC}

NA_U8 = 0xFF
NA_U16 = 0xFFFF
NA_U32 = 0xFFFFFFFF
NA_S8 = 0x7F
NA_S16 = 0x7FFF


def clamp(v: float, lo: float, hi: float) -> float:
    return lo if v < lo else hi if v > hi else v


def u16le(b: bytes, off: int) -> int:
    return int.from_bytes(b[off:off + 2], "little", signed=False)


def s16le(b: bytes, off: int) -> int:
    return int.from_bytes(b[off:off + 2], "little", signed=True)


def u32le(b: bytes, off: int) -> int:
    return int.from_bytes(b[off:off + 4], "little", signed=False)


def put_u16(v: int) -> bytes:
    return int(v).to_bytes(2, "little", signed=False)


def put_s16(v: int) -> bytes:
    return int(v).to_bytes(2, "little", signed=True)


def put_u32(v: int) -> bytes:
    return int(v).to_bytes(4, "little", signed=False)


def pgn_parts(can_id: int) -> Tuple[int, int, int, int]:
    """Return (priority, pgn, source, destination)."""
    cid = can_id & CAN_EFF_MASK
    priority = (cid >> 26) & 0x7
    edp = (cid >> 25) & 0x1
    dp = (cid >> 24) & 0x1
    pf = (cid >> 16) & 0xFF
    ps = (cid >> 8) & 0xFF
    src = cid & 0xFF
    if pf < 240:
        pgn = (edp << 17) | (dp << 16) | (pf << 8)
        dst = ps
    else:
        pgn = (edp << 17) | (dp << 16) | (pf << 8) | ps
        dst = 0xFF
    return priority, pgn, src, dst


def make_can_id(pgn: int, src: int, dst: int = 0xFF, priority: int = 6) -> int:
    edp = (pgn >> 17) & 0x1
    dp = (pgn >> 16) & 0x1
    pf = (pgn >> 8) & 0xFF
    ps = dst & 0xFF if pf < 240 else pgn & 0xFF
    cid = ((priority & 0x7) << 26) | (edp << 25) | (dp << 24) | (pf << 16) | (ps << 8) | (src & 0xFF)
    return cid | CAN_EFF_FLAG


class RawCan:
    def __init__(self, iface: str):
        self.iface = iface
        self.sock = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW)
        self.sock.bind((iface,))
        self.sock.setblocking(False)

    def fileno(self) -> int:
        return self.sock.fileno()

    def recv(self) -> Optional[Tuple[int, bytes]]:
        try:
            raw = self.sock.recv(CAN_FRAME.size)
        except BlockingIOError:
            return None
        if len(raw) != CAN_FRAME.size:
            return None
        can_id, dlc, data = CAN_FRAME.unpack(raw)
        if can_id & (CAN_RTR_FLAG | CAN_ERR_FLAG):
            return None
        if not (can_id & CAN_EFF_FLAG):
            return None
        return can_id, data[: min(dlc, 8)]

    def send(self, can_id: int, data: bytes) -> None:
        if len(data) > 8:
            raise ValueError("CAN frame data exceeds 8 bytes")
        payload = data.ljust(8, b"\xFF")
        self.sock.send(CAN_FRAME.pack(can_id, len(data), payload))


class FastPacketAssembler:
    def __init__(self, timeout: float = 1.0):
        self.timeout = timeout
        self.pending: Dict[Tuple[int, int, int], dict] = {}

    def feed(self, pgn: int, src: int, data: bytes) -> Optional[bytes]:
        if len(data) < 2:
            return None
        seq = (data[0] >> 5) & 0x7
        frame_no = data[0] & 0x1F
        key = (pgn, src, seq)
        now = time.monotonic()
        for k, st in list(self.pending.items()):
            if now - st["ts"] > self.timeout:
                self.pending.pop(k, None)
        if frame_no == 0:
            total = data[1]
            if total > 223:
                return None
            buf = bytearray(data[2:])
            self.pending[key] = {"total": total, "buf": buf, "next": 1, "ts": now}
            if len(buf) >= total:
                self.pending.pop(key, None)
                return bytes(buf[:total])
            return None
        st = self.pending.get(key)
        if not st or frame_no != st["next"]:
            self.pending.pop(key, None)
            return None
        st["buf"].extend(data[1:])
        st["next"] += 1
        st["ts"] = now
        if len(st["buf"]) >= st["total"]:
            self.pending.pop(key, None)
            return bytes(st["buf"][: st["total"]])
        return None


class J1939BamAssembler:
    """Reassemble broadcast J1939 TP.BAM messages (enough for multi-DTC DM1)."""
    def __init__(self, timeout: float = 1.5):
        self.timeout = timeout
        self.pending: Dict[int, dict] = {}

    def _expire(self) -> None:
        now = time.monotonic()
        for src, st in list(self.pending.items()):
            if now - st["ts"] > self.timeout:
                self.pending.pop(src, None)

    def feed_cm(self, src: int, data: bytes) -> None:
        self._expire()
        if len(data) < 8 or data[0] != 0x20:  # BAM control byte
            return
        total = u16le(data, 1)
        packets = data[3]
        target_pgn = data[5] | (data[6] << 8) | (data[7] << 16)
        if not (1 <= total <= 1785 and 1 <= packets <= 255):
            return
        self.pending[src] = {
            "total": total, "packets": packets, "target": target_pgn,
            "next": 1, "buf": bytearray(), "ts": time.monotonic(),
        }

    def feed_dt(self, src: int, data: bytes) -> Optional[Tuple[int, bytes]]:
        self._expire()
        st = self.pending.get(src)
        if not st or len(data) < 2:
            return None
        seq = data[0]
        if seq != st["next"]:
            self.pending.pop(src, None)
            return None
        st["buf"].extend(data[1:8])
        st["next"] += 1
        st["ts"] = time.monotonic()
        if seq >= st["packets"] or len(st["buf"]) >= st["total"]:
            self.pending.pop(src, None)
            return st["target"], bytes(st["buf"][:st["total"]])
        return None


class FastPacketWriter:
    def __init__(self):
        self.seq: Dict[int, int] = {}

    def frames(self, pgn: int, payload: bytes):
        if len(payload) > 223:
            raise ValueError("NMEA 2000 fast packet payload exceeds 223 bytes")
        seq = self.seq.get(pgn, 0) & 0x7
        self.seq[pgn] = (seq + 1) & 0x7
        frames = []
        first = bytes([(seq << 5) | 0, len(payload)]) + payload[:6]
        frames.append(first.ljust(8, b"\xFF"))
        pos = 6
        frame_no = 1
        while pos < len(payload):
            chunk = payload[pos:pos + 7]
            frames.append((bytes([(seq << 5) | frame_no]) + chunk).ljust(8, b"\xFF"))
            pos += 7
            frame_no += 1
        return frames


@dataclass
class EngineState:
    speed_rpm: Optional[float] = None
    boost_pa: Optional[float] = None
    oil_pressure_pa: Optional[float] = None
    oil_temp_k: Optional[float] = None
    coolant_temp_k: Optional[float] = None
    alternator_v: Optional[float] = None
    fuel_rate_lph: Optional[float] = None
    hours_s: Optional[float] = None
    coolant_pressure_pa: Optional[float] = None
    fuel_pressure_pa: Optional[float] = None
    load_pct: Optional[float] = None
    torque_pct: Optional[float] = None
    dm1_active: bool = False
    trans_gear: Optional[int] = None  # N2K: 0 forward, 1 neutral, 2 reverse
    trans_oil_pressure_pa: Optional[float] = None
    trans_oil_temp_k: Optional[float] = None
    fuel_level_pct: Optional[float] = None
    battery_v: Optional[float] = None
    updated: Dict[str, float] = field(default_factory=dict)

    def set(self, key: str, value) -> None:
        setattr(self, key, value)
        self.updated[key] = time.monotonic()


class InstanceMap:
    def __init__(self, explicit: Dict[int, int]):
        self.src_to_instance = dict(explicit)
        self.instance_to_src = {v: k for k, v in explicit.items()}

    def instance_for_src(self, src: int) -> int:
        if src in self.src_to_instance:
            return self.src_to_instance[src]
        if 0 <= src <= 3 and src not in self.instance_to_src:
            inst = src
        else:
            inst = next((i for i in range(0, 253) if i not in self.instance_to_src), 0)
        self.src_to_instance[src] = inst
        self.instance_to_src[inst] = src
        logging.info("auto-mapped J1939 source 0x%02X -> N2K engine instance %d", src, inst)
        return inst


class Bridge:
    def __init__(self, args):
        self.args = args
        self.n2k = RawCan(args.n2k_iface)
        self.j1939 = RawCan(args.j1939_iface)
        explicit = {}
        for item in args.engine_map:
            left, right = item.split(":", 1)
            explicit[int(left, 0)] = int(right, 0)
        self.instances = InstanceMap(explicit)
        self.states: Dict[int, EngineState] = {}
        self.fp_rx = FastPacketAssembler()
        self.fp_tx = FastPacketWriter()
        self.j1939_tp = J1939BamAssembler()
        # Cache N2K fields that share one J1939 PGN so reverse conversion does
        # not alternate valid fields with Not-Available values.
        self.n2k_speed: Dict[int, float] = {}
        self.n2k_torque: Dict[int, float] = {}
        self.selector = selectors.DefaultSelector()
        self.selector.register(self.n2k.sock, selectors.EVENT_READ, "n2k")
        self.selector.register(self.j1939.sock, selectors.EVENT_READ, "j1939")
        self.last_emit: Dict[Tuple[str, int], float] = {}
        self.n2k_src = args.n2k_source
        self.j1939_base = args.j1939_source_base
        self.identity = self._identity()

    def _identity(self) -> int:
        seed = "keelos"
        for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
            try:
                seed += open(path, "r", encoding="ascii").read().strip()
                break
            except OSError:
                pass
        digest = hashlib.sha256(seed.encode()).digest()
        return int.from_bytes(digest[:4], "little") & 0x1FFFFF

    def _state(self, src: int) -> Tuple[int, EngineState]:
        inst = self.instances.instance_for_src(src)
        return inst, self.states.setdefault(inst, EngineState())

    def _rate_ok(self, kind: str, inst: int, min_interval: float) -> bool:
        now = time.monotonic()
        key = (kind, inst)
        if now - self.last_emit.get(key, 0.0) < min_interval:
            return False
        self.last_emit[key] = now
        return True

    def send_n2k(self, pgn: int, payload: bytes, priority: int = 2) -> None:
        cid = make_can_id(pgn, self.n2k_src, 0xFF, priority)
        if pgn in FAST_N2K or len(payload) > 8:
            for frame in self.fp_tx.frames(pgn, payload):
                self.n2k.send(cid, frame)
        else:
            self.n2k.send(cid, payload)

    def j1939_tx_source(self, instance: int) -> int:
        src = self.j1939_base + int(instance)
        if src > 253:
            # Keep translated virtual-engine addresses in the dynamic range.
            src = 128 + (int(instance) % 126)
        return src

    def send_j1939(self, pgn: int, instance: int, payload: bytes, priority: int = 6) -> None:
        src = self.j1939_tx_source(instance)
        cid = make_can_id(pgn, src, 0xFF, priority)
        self.j1939.send(cid, payload[:8].ljust(8, b"\xFF"))

    def _claim_name(self, ca_instance: int = 0) -> int:
        # Every J1939 Controller Application needs a unique NAME. The previous
        # build reused one identical NAME on several translated source addresses,
        # which can confuse address arbitration/discovery. Keep the beta identity
        # deterministic while varying the 21-bit identity and ECU instance.
        unique = (self.identity + int(ca_instance)) & 0x1FFFFF
        return (
            unique
            | (0 << 21)                         # manufacturer code (beta/unassigned)
            | ((int(ca_instance) & 0x7) << 32) # ECU instance
            | (0 << 35)                         # function instance
            | (130 << 40)                       # gateway-ish device function
            | (25 << 49)                        # network device class
            | (4 << 60)                         # marine industry group
            | (1 << 63)                         # arbitrary-address capable
        )

    def claim_addresses(self) -> None:
        n2k_payload = self._claim_name(0).to_bytes(8, "little")
        self.n2k.send(make_can_id(60928, self.n2k_src, 0xFF, 6), n2k_payload)
        for inst in sorted(self.states) or [0]:
            src = self.j1939_tx_source(inst)
            payload = self._claim_name(inst + 1).to_bytes(8, "little")
            self.j1939.send(make_can_id(60928, src, 0xFF, 6), payload)

    # ---------------- J1939 -> canonical state ----------------
    def decode_j1939(self, pgn: int, src: int, data: bytes) -> None:
        if len(data) < 8:
            data = data.ljust(8, b"\xFF")
        inst, st = self._state(src)

        if pgn == J1939_EEC1:
            raw_speed = u16le(data, 3)
            if raw_speed < 0xFA00:
                st.set("speed_rpm", raw_speed * 0.125)
            raw_torque = data[2]
            if raw_torque < 0xFA:
                st.set("torque_pct", float(raw_torque - 125))
            if self._rate_ok("rapid", inst, 0.05):
                self.emit_engine_rapid(inst, st)
            if self._rate_ok("dynamic", inst, 0.20):
                self.emit_engine_dynamic(inst, st)

        elif pgn == J1939_EEC2:
            raw = data[2]
            if raw < 0xFA:
                st.set("load_pct", float(raw))
            if self._rate_ok("dynamic", inst, 0.20):
                self.emit_engine_dynamic(inst, st)

        elif pgn == J1939_HOURS:
            raw = u32le(data, 0)
            if raw < 0xFAFFFFFF:
                st.set("hours_s", raw * 0.05 * 3600.0)
            if self._rate_ok("dynamic", inst, 0.20):
                self.emit_engine_dynamic(inst, st)

        elif pgn == J1939_ET1:
            if data[0] < 0xFA:
                st.set("coolant_temp_k", (data[0] - 40.0) + 273.15)
            raw_oil = u16le(data, 2)
            if raw_oil < 0xFA00:
                st.set("oil_temp_k", raw_oil * 0.03125)
            if self._rate_ok("dynamic", inst, 0.20):
                self.emit_engine_dynamic(inst, st)

        elif pgn == J1939_EFLP1:
            if data[0] < 0xFA:
                st.set("fuel_pressure_pa", data[0] * 4000.0)
            if data[3] < 0xFA:
                st.set("oil_pressure_pa", data[3] * 4000.0)
            if data[6] < 0xFA:
                st.set("coolant_pressure_pa", data[6] * 2000.0)
            if self._rate_ok("dynamic", inst, 0.20):
                self.emit_engine_dynamic(inst, st)

        elif pgn == J1939_LFE1:
            raw = u16le(data, 0)
            if raw < 0xFA00:
                st.set("fuel_rate_lph", raw * 0.05)
            if self._rate_ok("dynamic", inst, 0.20):
                self.emit_engine_dynamic(inst, st)

        elif pgn == J1939_IC1:
            if data[1] < 0xFA:
                st.set("boost_pa", data[1] * 2000.0)
            if self._rate_ok("rapid", inst, 0.05):
                self.emit_engine_rapid(inst, st)

        elif pgn == J1939_VEP1:
            raw = u16le(data, 4)  # SPN 168 Battery Potential / Power Input 1
            if raw < 0xFA00:
                st.set("battery_v", raw * 0.05)
                st.set("alternator_v", raw * 0.05)
            if self._rate_ok("dynamic", inst, 0.20):
                self.emit_engine_dynamic(inst, st)
            if self._rate_ok("battery", inst, 0.50):
                self.emit_battery(inst, st)

        elif pgn == J1939_ETC2:
            raw = data[3]  # SPN 523 Current Gear, -125 offset
            if raw < 0xFA:
                gear = raw - 125
                st.set("trans_gear", 1 if gear == 0 else 0 if gear > 0 else 2)
            if self._rate_ok("trans", inst, 0.10):
                self.emit_transmission(inst, st)

        elif pgn == J1939_TRF1:
            raw_p = data[3]  # SPN 127, 16 kPa/bit
            if raw_p < 0xFA:
                st.set("trans_oil_pressure_pa", raw_p * 16000.0)
            raw_t = u16le(data, 4)  # SPN 177, 0.03125 C/bit with -273 C offset == 0.03125 K/bit
            if raw_t < 0xFA00:
                st.set("trans_oil_temp_k", raw_t * 0.03125)
            if self._rate_ok("trans", inst, 0.10):
                self.emit_transmission(inst, st)

        elif pgn == J1939_DD1:
            raw = data[1]
            if raw < 0xFA:
                st.set("fuel_level_pct", raw * 0.4)
            if self._rate_ok("fluid", inst, 0.50):
                self.emit_fuel_level(inst, st)

        elif pgn == J1939_DM1:
            # A DM1 with at least one non-NA DTC marks generic Check Engine in
            # N2K 127489. We do not invent per-SPN NMEA status bits.
            active = False
            if len(data) >= 6:
                spn_lo, spn_mid, spn_hi_fmi = data[2], data[3], data[4]
                active = not (spn_lo == 0xFF and spn_mid == 0xFF and spn_hi_fmi == 0xFF)
            st.set("dm1_active", active)
            if self._rate_ok("dynamic", inst, 0.20):
                self.emit_engine_dynamic(inst, st)

    # ---------------- canonical state -> N2K ----------------
    def emit_engine_rapid(self, inst: int, st: EngineState) -> None:
        speed = NA_U16 if st.speed_rpm is None else int(round(clamp(st.speed_rpm / 0.25, 0, 0xFFFC)))
        boost = NA_U16 if st.boost_pa is None else int(round(clamp(st.boost_pa / 100.0, 0, 0xFFFC)))
        payload = bytes([inst & 0xFF]) + put_u16(speed) + put_u16(boost) + bytes([NA_S8, 0xFF, 0xFF])
        self.send_n2k(N2K_ENGINE_RAPID, payload, 2)

    def emit_engine_dynamic(self, inst: int, st: EngineState) -> None:
        oil_p = NA_U16 if st.oil_pressure_pa is None else int(round(clamp(st.oil_pressure_pa / 100.0, 0, 0xFFFC)))
        oil_t = NA_U16 if st.oil_temp_k is None else int(round(clamp(st.oil_temp_k / 0.1, 0, 0xFFFC)))
        cool_t = NA_U16 if st.coolant_temp_k is None else int(round(clamp(st.coolant_temp_k / 0.01, 0, 0xFFFC)))
        alt = NA_S16 if st.alternator_v is None else int(round(clamp(st.alternator_v / 0.01, -32767, 32764)))
        fuel = NA_S16 if st.fuel_rate_lph is None else int(round(clamp(st.fuel_rate_lph / 0.1, -32767, 32764)))
        hours = NA_U32 if st.hours_s is None else int(round(clamp(st.hours_s, 0, 0xFFFFFFFC)))
        cool_p = NA_U16 if st.coolant_pressure_pa is None else int(round(clamp(st.coolant_pressure_pa / 100.0, 0, 0xFFFC)))
        fuel_p = NA_U16 if st.fuel_pressure_pa is None else int(round(clamp(st.fuel_pressure_pa / 1000.0, 0, 0xFFFC)))
        status1 = 0x0001 if st.dm1_active else 0x0000
        status2 = 0x0000
        load = NA_S8 if st.load_pct is None else int(round(clamp(st.load_pct, -127, 124))) & 0xFF
        torque = NA_S8 if st.torque_pct is None else int(round(clamp(st.torque_pct, -127, 124))) & 0xFF
        payload = bytearray()
        payload += bytes([inst & 0xFF])
        payload += put_u16(oil_p)
        payload += put_u16(oil_t)
        payload += put_u16(cool_t)
        payload += put_s16(alt) if alt != NA_S16 else b"\xFF\x7F"
        payload += put_s16(fuel) if fuel != NA_S16 else b"\xFF\x7F"
        payload += put_u32(hours)
        payload += put_u16(cool_p)
        payload += put_u16(fuel_p)
        payload += b"\xFF"  # reserved
        payload += put_u16(status1)
        payload += put_u16(status2)
        payload += bytes([load, torque])
        self.send_n2k(N2K_ENGINE_DYNAMIC, bytes(payload), 2)

    def emit_transmission(self, inst: int, st: EngineState) -> None:
        gear = 0x03 if st.trans_gear is None else st.trans_gear & 0x03
        gear_byte = gear | 0xFC
        pressure = NA_U16 if st.trans_oil_pressure_pa is None else int(round(clamp(st.trans_oil_pressure_pa / 100.0, 0, 0xFFFC)))
        temp = NA_U16 if st.trans_oil_temp_k is None else int(round(clamp(st.trans_oil_temp_k / 0.1, 0, 0xFFFC)))
        payload = bytes([inst & 0xFF, gear_byte]) + put_u16(pressure) + put_u16(temp) + b"\x00\xFF"
        self.send_n2k(N2K_TRANSMISSION_DYNAMIC, payload, 2)

    def emit_fuel_level(self, inst: int, st: EngineState) -> None:
        # N2K tank type 0 = fuel. Capacity is unknown in J1939 DD1, so mark NA.
        first = (0 << 4) | (inst & 0x0F)
        level = NA_S16 if st.fuel_level_pct is None else int(round(clamp(st.fuel_level_pct / 0.004, -32767, 32764)))
        payload = bytes([first]) + (put_s16(level) if level != NA_S16 else b"\xFF\x7F") + put_u32(NA_U32) + b"\xFF"
        self.send_n2k(N2K_FLUID_LEVEL, payload, 6)

    def emit_battery(self, inst: int, st: EngineState) -> None:
        voltage = NA_S16 if st.battery_v is None else int(round(clamp(st.battery_v / 0.01, -32767, 32764)))
        payload = bytes([inst & 0xFF])
        payload += put_s16(voltage) if voltage != NA_S16 else b"\xFF\x7F"
        payload += b"\xFF\x7F"   # current NA
        payload += b"\xFF\xFF"   # temperature NA
        payload += b"\xFF"        # SID NA
        self.send_n2k(N2K_BATTERY_STATUS, payload, 6)

    # ---------------- N2K -> J1939 ----------------
    def decode_n2k(self, pgn: int, payload: bytes) -> None:
        if pgn == N2K_ENGINE_RAPID and len(payload) >= 8:
            inst = payload[0]
            speed_raw = u16le(payload, 1)
            boost_raw = u16le(payload, 3)
            if speed_raw != NA_U16:
                self.n2k_speed[inst] = speed_raw * 0.25
                self.j1939_set_eec1(inst)
            if boost_raw != NA_U16:
                self.j1939_set_ic1(inst, boost_raw * 100.0)

        elif pgn == N2K_ENGINE_DYNAMIC and len(payload) >= 26:
            inst = payload[0]
            oil_p = u16le(payload, 1)
            oil_t = u16le(payload, 3)
            cool_t = u16le(payload, 5)
            alt = s16le(payload, 7)
            fuel_rate = s16le(payload, 9)
            hours = u32le(payload, 11)
            cool_p = u16le(payload, 15)
            fuel_p = u16le(payload, 17)
            load = int.from_bytes(payload[24:25], "little", signed=True)
            torque = int.from_bytes(payload[25:26], "little", signed=True)
            if payload[25] != NA_S8:
                self.n2k_torque[inst] = float(torque)
            self.j1939_set_dynamic(
                inst,
                None if oil_p == NA_U16 else oil_p * 100.0,
                None if oil_t == NA_U16 else oil_t * 0.1,
                None if cool_t == NA_U16 else cool_t * 0.01,
                None if alt == NA_S16 else alt * 0.01,
                None if fuel_rate == NA_S16 else fuel_rate * 0.1,
                None if hours == NA_U32 else float(hours),
                None if cool_p == NA_U16 else cool_p * 100.0,
                None if fuel_p == NA_U16 else fuel_p * 1000.0,
                None if payload[24] == NA_S8 else float(load),
                None if payload[25] == NA_S8 else float(torque),
            )

        elif pgn == N2K_TRANSMISSION_DYNAMIC and len(payload) >= 8:
            inst = payload[0]
            gear = payload[1] & 0x03
            pressure = u16le(payload, 2)
            temp = u16le(payload, 4)
            self.j1939_set_transmission(
                inst,
                None if gear == 3 else gear,
                None if pressure == NA_U16 else pressure * 100.0,
                None if temp == NA_U16 else temp * 0.1,
            )

        elif pgn == N2K_FLUID_LEVEL and len(payload) >= 8:
            inst = payload[0] & 0x0F
            tank_type = (payload[0] >> 4) & 0x0F
            level = s16le(payload, 1)
            if tank_type == 0 and level != NA_S16:
                self.j1939_set_fuel_level(inst, level * 0.004)

        elif pgn == N2K_BATTERY_STATUS and len(payload) >= 8:
            inst = payload[0]
            voltage = s16le(payload, 1)
            if voltage != NA_S16:
                self.j1939_set_battery(inst, voltage * 0.01)

    def j1939_set_eec1(self, inst: int) -> None:
        d = bytearray(b"\xFF" * 8)
        speed_rpm = self.n2k_speed.get(inst)
        torque = self.n2k_torque.get(inst)
        if speed_rpm is not None:
            raw = int(round(clamp(speed_rpm / 0.125, 0, 0xF9FF)))
            d[3:5] = put_u16(raw)
        if torque is not None:
            d[2] = int(round(clamp(torque + 125.0, 0, 0xF9)))
        self.send_j1939(J1939_EEC1, inst, bytes(d), 3)

    def j1939_set_ic1(self, inst: int, boost_pa: float) -> None:
        d = bytearray(b"\xFF" * 8)
        d[1] = int(round(clamp(boost_pa / 2000.0, 0, 0xF9)))
        self.send_j1939(J1939_IC1, inst, bytes(d), 6)

    def j1939_set_dynamic(self, inst: int, oil_p, oil_t_k, cool_t_k, voltage, fuel_rate, hours_s, cool_p, fuel_p, load, torque) -> None:
        if oil_t_k is not None or cool_t_k is not None:
            d = bytearray(b"\xFF" * 8)
            if cool_t_k is not None:
                c = cool_t_k - 273.15
                d[0] = int(round(clamp(c + 40.0, 0, 0xF9)))
            if oil_t_k is not None:
                raw = int(round(clamp(oil_t_k / 0.03125, 0, 0xF9FF)))
                d[2:4] = put_u16(raw)
            self.send_j1939(J1939_ET1, inst, bytes(d), 6)

        if oil_p is not None or cool_p is not None or fuel_p is not None:
            d = bytearray(b"\xFF" * 8)
            if fuel_p is not None:
                d[0] = int(round(clamp(fuel_p / 4000.0, 0, 0xF9)))
            if oil_p is not None:
                d[3] = int(round(clamp(oil_p / 4000.0, 0, 0xF9)))
            if cool_p is not None:
                d[6] = int(round(clamp(cool_p / 2000.0, 0, 0xF9)))
            self.send_j1939(J1939_EFLP1, inst, bytes(d), 6)

        if fuel_rate is not None:
            d = bytearray(b"\xFF" * 8)
            d[0:2] = put_u16(int(round(clamp(fuel_rate / 0.05, 0, 0xF9FF))))
            self.send_j1939(J1939_LFE1, inst, bytes(d), 6)

        if voltage is not None:
            d = bytearray(b"\xFF" * 8)
            d[4:6] = put_u16(int(round(clamp(voltage / 0.05, 0, 0xF9FF))))
            self.send_j1939(J1939_VEP1, inst, bytes(d), 6)

        if hours_s is not None:
            d = bytearray(b"\xFF" * 8)
            raw = int(round(clamp((hours_s / 3600.0) / 0.05, 0, 0xF9FFFFFF)))
            d[0:4] = put_u32(raw)
            self.send_j1939(J1939_HOURS, inst, bytes(d), 6)

        if load is not None:
            d = bytearray(b"\xFF" * 8)
            d[2] = int(round(clamp(load, 0, 0xF9)))
            self.send_j1939(J1939_EEC2, inst, bytes(d), 3)

        if torque is not None:
            self.n2k_torque[inst] = torque
            self.j1939_set_eec1(inst)

    def j1939_set_transmission(self, inst: int, gear, pressure_pa, temp_k) -> None:
        if gear is not None:
            d = bytearray(b"\xFF" * 8)
            current = 126 if gear == 0 else 125 if gear == 1 else 124
            d[3] = current
            self.send_j1939(J1939_ETC2, inst, bytes(d), 3)
        if pressure_pa is not None or temp_k is not None:
            d = bytearray(b"\xFF" * 8)
            if pressure_pa is not None:
                d[3] = int(round(clamp(pressure_pa / 16000.0, 0, 0xF9)))
            if temp_k is not None:
                d[4:6] = put_u16(int(round(clamp(temp_k / 0.03125, 0, 0xF9FF))))
            self.send_j1939(J1939_TRF1, inst, bytes(d), 6)

    def j1939_set_fuel_level(self, inst: int, level_pct: float) -> None:
        d = bytearray(b"\xFF" * 8)
        d[1] = int(round(clamp(level_pct / 0.4, 0, 0xF9)))
        self.send_j1939(J1939_DD1, inst, bytes(d), 6)

    def j1939_set_battery(self, inst: int, voltage: float) -> None:
        d = bytearray(b"\xFF" * 8)
        d[4:6] = put_u16(int(round(clamp(voltage / 0.05, 0, 0xF9FF))))
        self.send_j1939(J1939_VEP1, inst, bytes(d), 6)

    # ---------------- event loop ----------------
    def handle_n2k_frame(self, can_id: int, data: bytes) -> None:
        _, pgn, src, _ = pgn_parts(can_id)
        # CAN_RAW does not receive frames sent by this same socket unless
        # CAN_RAW_RECV_OWN_MSGS is explicitly enabled (we do not enable it). Do
        # not discard a real device merely because it happens to use our preferred
        # source address; address-claim conflicts can then remain observable.
        if pgn not in MAPPED_N2K:
            return
        if pgn in FAST_N2K:
            payload = self.fp_rx.feed(pgn, src, data)
            if payload is None:
                return
        else:
            payload = data
        self.decode_n2k(pgn, payload)

    def handle_j1939_frame(self, can_id: int, data: bytes) -> None:
        _, pgn, src, _ = pgn_parts(can_id)
        # Do not suppress a broad source-address range. Real J1939 controllers
        # are allowed to use those addresses, and CAN_RAW already suppresses this
        # socket's own transmitted frames by default.
        if pgn == J1939_TP_CM:
            self.j1939_tp.feed_cm(src, data)
            return
        if pgn == J1939_TP_DT:
            complete = self.j1939_tp.feed_dt(src, data)
            if complete:
                target_pgn, payload = complete
                if target_pgn in MAPPED_J1939:
                    self.decode_j1939(target_pgn, src, payload)
            return
        if pgn in MAPPED_J1939:
            self.decode_j1939(pgn, src, data)

    def run(self) -> None:
        logging.info("KeelOS bridge: N2K=%s <-> J1939=%s", self.args.n2k_iface, self.args.j1939_iface)
        logging.info("N2K mapped PGNs: %s", ",".join(str(x) for x in sorted(MAPPED_N2K)))
        logging.info("J1939 mapped PGNs: %s", ",".join(str(x) for x in sorted(MAPPED_J1939)))
        last_claim = 0.0
        while True:
            now = time.monotonic()
            if now - last_claim > 60.0:
                try:
                    self.claim_addresses()
                except OSError as exc:
                    logging.warning("address claim failed: %s", exc)
                last_claim = now
            for key, _ in self.selector.select(timeout=0.5):
                bus = key.data
                rx = self.n2k.recv() if bus == "n2k" else self.j1939.recv()
                if not rx:
                    continue
                can_id, data = rx
                try:
                    if bus == "n2k":
                        self.handle_n2k_frame(can_id, data)
                    else:
                        self.handle_j1939_frame(can_id, data)
                except Exception:
                    logging.exception("conversion error on %s frame id=0x%08X data=%s", bus, can_id & CAN_EFF_MASK, data.hex())


def self_test() -> None:
    # CAN ID/PGN round-trip for every mapping plus transport PGNs.
    for pgn in list(MAPPED_N2K) + list(MAPPED_J1939) + [J1939_TP_CM, J1939_TP_DT, 60928]:
        cid = make_can_id(pgn, 0x23)
        assert pgn_parts(cid)[1] == pgn

    # NMEA 2000 fast-packet assembly.
    payload = bytes(range(26))
    writer, asm = FastPacketWriter(), FastPacketAssembler()
    complete = None
    for frame in writer.frames(N2K_ENGINE_DYNAMIC, payload):
        got = asm.feed(N2K_ENGINE_DYNAMIC, 0x22, frame)
        if got is not None:
            complete = got
    assert complete == payload

    # J1939 BAM reassembly used by multi-packet DM1.
    data = bytes(range(20))
    packets = (len(data) + 6) // 7
    bam = J1939BamAssembler()
    cm = bytes([0x20]) + len(data).to_bytes(2, "little") + bytes([
        packets, 0xFF, J1939_DM1 & 0xFF, (J1939_DM1 >> 8) & 0xFF, (J1939_DM1 >> 16) & 0xFF
    ])
    bam.feed_cm(0, cm)
    complete_bam = None
    for i in range(packets):
        part = data[i * 7:(i + 1) * 7].ljust(7, b"\xFF")
        got = bam.feed_dt(0, bytes([i + 1]) + part)
        if got is not None:
            complete_bam = got
    assert complete_bam == (J1939_DM1, data)

    # Multiple translated J1939 CAs must not claim an identical NAME.
    dummy = Bridge.__new__(Bridge)
    dummy.identity = 12345
    assert dummy._claim_name(1) != dummy._claim_name(2)
    print("protocol_bridge self-test: OK")


def parse_args(argv=None):
    p = argparse.ArgumentParser(description="KeelOS NMEA2000 <-> J1939 bridge")
    p.add_argument("--n2k-iface", required=True)
    p.add_argument("--j1939-iface", required=True)
    p.add_argument("--n2k-source", type=lambda x: int(x, 0), default=0x23)
    p.add_argument("--j1939-source-base", type=lambda x: int(x, 0), default=0x80)
    p.add_argument("--engine-map", action="append", default=[], metavar="J1939_SA:N2K_INSTANCE")
    p.add_argument("--list-mappings", action="store_true")
    p.add_argument("--self-test", action="store_true")
    p.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
    return p.parse_args(argv)


def main(argv=None):
    args = parse_args(argv)
    if args.self_test:
        self_test()
        return 0
    if args.list_mappings:
        print(json.dumps({
            "nmea2000_cross_protocol": sorted(MAPPED_N2K),
            "nmea2000_full_decode": "Signal K/canboatjs installed PGN database",
            "j1939_cross_protocol": sorted(MAPPED_J1939),
            "j1939_known_unmapped": J1939_KNOWN_UNMAPPED,
        }, indent=2))
        return 0
    if args.n2k_iface == args.j1939_iface:
        print("fatal: NMEA2000 and J1939 interfaces must be different to prevent protocol loops", file=sys.stderr)
        return 2
    if not (0 <= args.n2k_source <= 253 and 0 <= args.j1939_source_base <= 253):
        print("fatal: source addresses must be in 0..253", file=sys.stderr)
        return 2
    logging.basicConfig(level=getattr(logging, args.log_level), format="%(asctime)s %(levelname)s %(message)s")
    try:
        Bridge(args).run()
    except KeyboardInterrupt:
        return 0
    except OSError as exc:
        logging.error("cannot open SocketCAN interface: %s", exc)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PYBRIDGE
  chmod 0755 "$bridge"
  python3 -m py_compile "$bridge"
  python3 "$bridge" --n2k-iface "$N2K_IFACE" --j1939-iface "$J1939_IFACE" --self-test >/dev/null
  python3 "$bridge" --n2k-iface "$N2K_IFACE" --j1939-iface "$J1939_IFACE" --list-mappings >/dev/null
  record file "$bridge"
  ok "Protocol bridge installed; Python syntax, transport, and mapping self-tests passed"
}

#--------------------------------------------------------------------------
# Apply — idempotent, everything recorded in the manifest
#--------------------------------------------------------------------------
pkg_install() {
  [[ ${#PKGS[@]} -eq 0 ]] && return 0
  info "Installing packages via ${PKG_MGR}"
  case "$PKG_MGR" in
    apt)    run apt-get update -qq
            run env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${PKGS[@]}" ;;
    dnf)    run dnf install -y -q "${PKGS[@]}" ;;
    pacman) run pacman -S --noconfirm --needed "${PKGS[@]}" ;;
  esac
  local p; for p in "${PKGS[@]}"; do record pkg "$p"; done
}

overlay_dir() {
  if [[ -d /boot/firmware/overlays ]]; then printf '%s\n' /boot/firmware/overlays
  elif [[ -d /boot/overlays ]]; then printf '%s\n' /boot/overlays
  else printf '%s\n' ""
  fi
}

validate_overlay_files() {
  [[ ${#OVERLAYS[@]} -eq 0 ]] && return 0
  (( IS_PI )) || return 0
  local odir line name missing=0
  odir="$(overlay_dir)"
  [[ -n $odir ]] || { warn "Raspberry Pi overlay directory not found; cannot preflight CAN overlays."; return 0; }
  for line in "${OVERLAYS[@]}"; do
    [[ $line == dtoverlay=* ]] || continue
    name="${line#dtoverlay=}"
    name="${name%%,*}"
    if [[ ! -e "${odir}/${name}.dtbo" ]]; then
      warn "Missing device-tree overlay: ${odir}/${name}.dtbo"
      missing=1
    fi
  done
  if (( missing )); then
    die "Required Pi overlay(s) are missing. Update Raspberry Pi OS/kernel/firmware, reboot, then re-run keel.sh."
  fi
  ok "Pi device-tree overlay preflight passed"
}

backup_boot_config() {
  [[ -n $BOOT_CONFIG && -f $BOOT_CONFIG ]] || return 0
  (( DRY_RUN )) && return 0
  local bdir="${STATE_DIR}/backups" backup="${STATE_DIR}/backups/config.txt.before-keelos"
  mkdir -p "$bdir"
  if [[ ! -e $backup ]]; then
    cp -a "$BOOT_CONFIG" "$backup"
    ok "Boot config backup: ${backup}"
  fi
}

apply_overlays() {
  local marker="# --- keelos begin (managed, do not edit inside) ---"
  local endmark="# --- keelos end ---"

  # Converge away from a previously-selected HAT too. Otherwise changing from
  # an SPI HAT to USB/serial leaves stale MCP2515/MCP251XFD overlays enabled.
  if [[ ${#OVERLAYS[@]} -eq 0 ]]; then
    if [[ -n $BOOT_CONFIG && -f $BOOT_CONFIG ]] && grep -qF "$marker" "$BOOT_CONFIG"; then
      backup_boot_config
      if (( DRY_RUN )); then
        say "${C_DIM}   dry-run: remove stale KeelOS boot overlay block from ${BOOT_CONFIG}${C_RESET}"
      else
        sed -i "/^${marker}$/,/^${endmark}$/d" "$BOOT_CONFIG"
      fi
      ok "Removed stale KeelOS CAN overlay block"
    fi
    return 0
  fi

  if [[ -z $BOOT_CONFIG ]]; then
    if (( DRY_RUN )); then
      warn "No Raspberry Pi boot config on this test host; showing overlay plan only."
      say "${C_DIM}   dry-run: dtparam=spi=on${C_RESET}"
      printf '%s\n' "${OVERLAYS[@]/#/   dry-run: }"
      return 0
    fi
    die "CAN HAT selected but no Raspberry Pi boot config was found."
  fi
  validate_overlay_files
  info "Writing device-tree overlays to ${BOOT_CONFIG}"
  backup_boot_config
  if (( ! DRY_RUN )); then
    # converge: remove our previous block, then append fresh. Explicit [all]
    # prevents a preceding [pi4]/[pi5]/serial conditional from capturing our block.
    sed -i "/^${marker}$/,/^${endmark}$/d" "$BOOT_CONFIG"
    {
      echo "$marker"
      echo "[all]"
      echo "dtparam=spi=on"
      printf '%s\n' "${OVERLAYS[@]}"
      echo "$endmark"
    } >> "$BOOT_CONFIG"
  else
    say "${C_DIM}   dry-run: [all]${C_RESET}"
    say "${C_DIM}   dry-run: dtparam=spi=on${C_RESET}"
    printf '%s\n' "${OVERLAYS[@]/#/   dry-run: }"
  fi
  record file "$BOOT_CONFIG:keelos-block"
  ok "Overlays staged (take effect after reboot)"
}

prepare_j1708_uart() {
  [[ $A_J1708 == y ]] || return 0
  case "$A_HAT" in
    ws-rs485-12m|ws-rs485-8m) ;;
    *) return 0 ;;
  esac

  # The original Waveshare RS485 CAN HAT puts its SP3485 on the Pi header
  # UART (GPIO14/15). Free only that UART from a Linux serial login; do not
  # disturb the Pi 5 debug UART (ttyAMA10) or console=tty1.
  local tty="${A_J1708_PORT#/dev/}" cmdline="" regex=""
  case "$tty" in
    serial0|ttyAMA0|ttyS0) ;;
    *) return 0 ;;
  esac

  if (( PI_GEN == 5 )); then
    # uart0-pi5 creates ttyAMA0 on GPIO14/15. /dev/serial0 remains ttyAMA10.
    regex='ttyAMA0'
  else
    # On Pi 4 the GPIO UART may be named through serial0, ttyAMA0, or ttyS0
    # depending on Bluetooth/UART configuration.
    regex='serial0|ttyAMA0|ttyS0'
  fi

  if [[ -f /boot/firmware/cmdline.txt ]]; then
    cmdline=/boot/firmware/cmdline.txt
  elif [[ -f /boot/cmdline.txt ]]; then
    cmdline=/boot/cmdline.txt
  fi

  info "Preparing GPIO14/15 UART for Waveshare J1708/J1587 (${A_J1708_PORT})"
  if [[ -n $cmdline ]]; then
    if grep -Eq "(^| )console=(${regex}),[^ ]+" "$cmdline"; then
      if (( DRY_RUN )); then
        say "${C_DIM}   dry-run: remove console=(${regex}),... from ${cmdline}${C_RESET}"
      else
        local backup="${STATE_DIR}/backups/cmdline.txt.before-keelos"
        mkdir -p "$(dirname "$backup")"
        [[ -e $backup ]] || cp -a "$cmdline" "$backup"
        record restore "${cmdline}::${backup}"
        sed -Ei "s/(^| )console=(${regex}),[^ ]+//g; s/^ +//; s/ +$//; s/  +/ /g" "$cmdline"
        ok "Released Waveshare UART from kernel serial console (${cmdline})"
      fi
    fi
  else
    warn "Could not locate Raspberry Pi cmdline.txt; verify serial console is disabled for ${A_J1708_PORT}."
  fi

  local unit_tty
  if (( PI_GEN == 5 )); then
    for unit_tty in ttyAMA0; do
      if (( DRY_RUN )); then
        say "${C_DIM}   dry-run: disable serial-getty@${unit_tty}.service${C_RESET}"
      else
        systemctl disable --now "serial-getty@${unit_tty}.service" >/dev/null 2>&1 || true
      fi
    done
  else
    for unit_tty in serial0 ttyAMA0 ttyS0; do
      if (( DRY_RUN )); then
        say "${C_DIM}   dry-run: disable serial-getty@${unit_tty}.service${C_RESET}"
      else
        systemctl disable --now "serial-getty@${unit_tty}.service" >/dev/null 2>&1 || true
      fi
    done
  fi
}

apply_can_runtime() {
  [[ ${#CAN_SPECS[@]} -eq 0 ]] && return 0
  info "Installing SocketCAN bring-up helper (per-interface bitrates; virtual CAN supported)"
  local helper="/usr/local/sbin/keelos-can-up"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: write ${helper} for ${CAN_SPECS[*]}${C_RESET}"
    return 0
  fi
  mkdir -p /usr/local/sbin
  cat > "$helper" <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
ACTION="${1:-up}"
shift || true
SPECS=("$@")
[[ ${#SPECS[@]} -gt 0 ]] || { echo "keelos-can-up: no CAN specs supplied" >&2; exit 2; }

if [[ $ACTION == down ]]; then
  for spec in "${SPECS[@]}"; do
    ifc="${spec%%:*}"
    ip link set "$ifc" down 2>/dev/null || true
    [[ ${spec#*:} == vcan ]] && ip link delete "$ifc" 2>/dev/null || true
  done
  exit 0
fi

modprobe can 2>/dev/null || true
modprobe can_raw 2>/dev/null || true
modprobe can_dev 2>/dev/null || true

for spec in "${SPECS[@]}"; do
  ifc="${spec%%:*}"
  mode="${spec#*:}"
  if [[ $mode == vcan ]]; then
    modprobe vcan 2>/dev/null || true
    ip link show "$ifc" >/dev/null 2>&1 || ip link add dev "$ifc" type vcan
    ip link set "$ifc" txqueuelen 1024
    ip link set "$ifc" up
    ip -details link show "$ifc"
    continue
  fi
  [[ $mode =~ ^(250000|500000)$ ]] || { echo "keelos-can-up: bad bitrate '${mode}' for ${ifc}" >&2; exit 2; }
  found=0
  for _ in {1..60}; do
    if ip link show "$ifc" >/dev/null 2>&1; then found=1; break; fi
    sleep 0.5
  done
  (( found )) || { echo "keelos-can-up: ${ifc} did not appear within 30 seconds" >&2; exit 1; }
  ip link set "$ifc" down 2>/dev/null || true
  ip link set "$ifc" type can bitrate "$mode" restart-ms 100
  ip link set "$ifc" txqueuelen 1024
  ip link set "$ifc" up
  ip -details link show "$ifc"
done
EOF
  chmod 0755 "$helper"
  bash -n "$helper"
  record file "$helper"
  ok "CAN helper installed for: ${CAN_SPECS[*]}"
}

write_unit() {  # write_unit <name> <heredoc-on-stdin>
  local name=$1 path="/etc/systemd/system/$1"
  if (( DRY_RUN )); then say "${C_DIM}   dry-run: write ${path}${C_RESET}"; cat >/dev/null; return 0; fi
  cat > "$path"
  record unit "$name"
}

unit_selected() {
  local needle=$1 x
  for x in "${UNITS[@]}"; do [[ $x == "$needle" ]] && return 0; done
  return 1
}

converge_managed_units() {
  local u
  for u in keelos-can.service keelos-j1939.service keelos-j1708-bridge.service keelos-dashboard.service keelos-kiosk.service; do
    unit_selected "$u" && continue
    [[ -e "/etc/systemd/system/${u}" ]] || continue
    if (( DRY_RUN )); then
      say "${C_DIM}   dry-run: disable/remove stale ${u}${C_RESET}"
    else
      systemctl disable --now "$u" >/dev/null 2>&1 || true
      rm -f "/etc/systemd/system/${u}"
    fi
    ok "disabled stale ${u}"
  done
  (( DRY_RUN )) || systemctl daemon-reload
}

can_runtime_ready() {
  local spec ifc mode
  for spec in "${CAN_SPECS[@]}"; do
    ifc="${spec%%:*}"; mode="${spec#*:}"
    [[ $mode == vcan ]] && continue
    ip link show "$ifc" >/dev/null 2>&1 || return 1
  done
  return 0
}

start_runtime_if_ready() {
  (( DRY_RUN )) && return 0
  [[ ${#UNITS[@]} -gt 0 ]] || return 0
  # SPI HATs normally need one reboot before their interfaces exist. USB and
  # already-enumerated external adapters can start immediately. Never launch
  # the kiosk here because it deliberately takes over tty1.
  if [[ ${#OVERLAYS[@]} -gt 0 ]] && ! can_runtime_ready; then
    return 0
  fi
  if ! can_runtime_ready; then
    warn "CAN interface is not present yet; services are enabled and will retry after reboot."
    return 0
  fi
  local u
  for u in keelos-can.service keelos-j1939.service keelos-j1708-bridge.service keelos-dashboard.service; do
    unit_selected "$u" || continue
    systemctl start "$u" >/dev/null 2>&1 || { warn "${u} did not start immediately; it remains enabled for reboot."; continue; }
    ok "${u} started"
  done
}

apply_units() {
  [[ ${#UNITS[@]} -eq 0 ]] && return 0
  info "Installing KeelOS services"
  local u
  for u in "${UNITS[@]}"; do
    case "$u" in
      keelos-can.service)
        local can_args="${CAN_SPECS[*]}"
        write_unit "$u" <<EOF
[Unit]
Description=KeelOS SocketCAN bring-up (${can_args})
After=systemd-modules-load.service
Before=keelos-dashboard.service keelos-j1939.service keelos-j1708-bridge.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/keelos-can-up up ${can_args}
ExecStop=/usr/local/sbin/keelos-can-up down ${can_args}
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-j1939.service)
        write_unit "$u" <<EOF
[Unit]
Description=KeelOS bidirectional NMEA 2000 <-> SAE J1939 translator
After=keelos-can.service
Wants=keelos-can.service
Before=keelos-dashboard.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/keelos/protocol_bridge.py --n2k-iface ${N2K_IFACE} --j1939-iface ${J1939_IFACE}
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-j1708-bridge.service) write_unit "$u" <<EOF
[Unit]
Description=KeelOS SAE J1708/J1587 -> NMEA 2000 translator
After=network.target keelos-can.service
Before=keelos-dashboard.service keelos-j1939.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/keelos/j1587_n2k.py --port ${A_J1708_PORT} --n2k-iface ${N2K_IFACE}
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-dashboard.service) write_unit "$u" <<'EOF'
[Unit]
Description=KeelOS Signal K server (webapp on :3000)
After=network.target keelos-can.service keelos-j1939.service keelos-j1708-bridge.service
Wants=network-online.target

[Service]
User=keelos
Group=keelos
Environment=HOME=/var/lib/keelos/home
Environment=NODE_ENV=production
ExecStart=/usr/bin/env signalk-server -c /var/lib/keelos/signalk
Restart=on-failure
RestartSec=3
Nice=5

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-kiosk.service) write_unit "$u" <<'EOF'
[Unit]
Description=KeelOS kiosk — fullscreen gauge dashboard on tty1
After=keelos-dashboard.service systemd-user-sessions.service
Wants=keelos-dashboard.service
Conflicts=getty@tty1.service

[Service]
User=keelos
Group=keelos
PAMName=login
TTYPath=/dev/tty1
StandardInput=tty
StandardOutput=journal
Environment=XDG_RUNTIME_DIR=/run/keelos-kiosk
RuntimeDirectory=keelos-kiosk
ExecStartPre=/usr/bin/env bash -c 'for i in {1..60}; do (exec 3<>/dev/tcp/127.0.0.1/3000) 2>/dev/null && exec 3>&- && exit 0; sleep 1; done; exit 1'
ExecStart=/usr/bin/env bash -c 'exec cage -d -- "$(command -v chromium-browser || command -v chromium)" \
  --kiosk --noerrdialogs --disable-session-crashed-bubble --disable-infobars \
  --check-for-update-interval=31536000 \
  --ozone-platform=wayland http://127.0.0.1:3000'
Restart=always
RestartSec=5

[Install]
WantedBy=graphical.target
EOF
        ;;
    esac
    run systemctl daemon-reload
    if (( DRY_RUN )); then
      run systemctl enable "$u"
    else
      run systemctl enable "$u" >/dev/null
    fi
    ok "$u enabled"
  done
}

#--------------------------------------------------------------------------
# Uninstall — remove what we added, and nothing we didn't
#--------------------------------------------------------------------------
do_uninstall() {
  need_root
  banner
  [[ -f $MANIFEST ]] || die "No manifest at ${MANIFEST} — nothing to remove."
  info "Reading manifest"
  local kind val
  # units first, then files, then packages
  while IFS='|' read -r kind val; do
    [[ $kind == unit ]] || continue
    run systemctl disable --now "$val" >/dev/null 2>&1 || true
    run rm -f "/etc/systemd/system/${val}"
    ok "removed unit ${val}"
  done < "$MANIFEST"
  while IFS='|' read -r kind val; do
    [[ $kind == restore ]] || continue
    local target="${val%%::*}" backup="${val#*::}"
    if [[ -f $backup ]]; then
      if (( DRY_RUN )); then
        say "${C_DIM}   dry-run: restore ${target} from ${backup}${C_RESET}"
      else
        cp -a "$backup" "$target"
      fi
      ok "restored ${target}"
    fi
  done < "$MANIFEST"
  while IFS='|' read -r kind val; do
    [[ $kind == file ]] || continue
    if [[ $val == *":keelos-block" ]]; then
      local f="${val%:keelos-block}"
      (( DRY_RUN )) || sed -i '/^# --- keelos begin/,/^# --- keelos end ---$/d' "$f"
      ok "removed keelos block from ${f}"
    else
      run rm -f "$val"; ok "removed ${val}"
    fi
  done < "$MANIFEST"
  while IFS='|' read -r kind val; do
    [[ $kind == dir ]] || continue
    run rm -rf "$val"; ok "removed directory ${val}"
  done < "$MANIFEST"
  while IFS='|' read -r kind val; do
    [[ $kind == npm ]] || continue
    run npm rm -g "$val" >/dev/null 2>&1 || true
    ok "removed npm global ${val}"
  done < "$MANIFEST"
  while IFS='|' read -r kind val; do
    [[ $kind == user ]] || continue
    run userdel -r "$val" >/dev/null 2>&1 || true
    ok "removed user ${val}"
  done < "$MANIFEST"
  warn "Packages installed by keel.sh are listed below; remove manually if unused"
  grep '^pkg|' "$MANIFEST" | cut -d'|' -f2 | tr '\n' ' '; say ""
  run systemctl daemon-reload
  (( DRY_RUN )) || rm -rf "$STATE_DIR"
  ok "KeelOS uninstalled. Fair winds."
}

#--------------------------------------------------------------------------
# Main
#--------------------------------------------------------------------------
main() {
  detect_pkg_mgr
  detect_platform

  if (( DETECT_ONLY    )); then detect_report; exit 0; fi
  if (( HAT_GUIDE_ONLY )); then banner; waveshare_hat_guide; exit 0; fi
  if (( UNINSTALL      )); then do_uninstall; exit 0; fi

  need_root
  mkdir -p "$(dirname "$LOG_FILE")"; log "keel.sh v${KEEL_VERSION} start"

  banner
  info "Detected: ${PI_MODEL:-generic $(uname -m)} · ${PKG_MGR} · systemd"
  (( DRY_RUN )) && warn "DRY RUN — showing the plan, touching nothing"
  say ""

  if [[ -n $ANSWER_FILE ]]; then
    load_answers "$ANSWER_FILE"
    validate_selection
  else
    wizard
  fi

  resolve
  if (( ! ASSUME_YES && ! DRY_RUN )); then
    warn "BETA: KeelOS is under testing. It will modify boot config and systemd"
    warn "units on this machine. It does not replace the Pi OS Ethernet/Wi-Fi manager."
    local go; read -r -p "${C_GREEN}> ${C_RESET}Proceed with this Beta plan? [Y/n]: " go || true
    [[ ${go,,} == n* ]] && die "Standing down. Nothing was changed."
  fi

  local t0=$SECONDS
  pkg_install
  install_signalk
  apply_overlays
  prepare_j1708_uart
  apply_can_runtime
  install_protocol_bridge
  install_j1587_bridge
  converge_managed_units
  apply_units
  start_runtime_if_ready
  save_answers

  say ""
  ok "${C_BOLD}Done in $((SECONDS - t0)) s.${C_RESET} ${C_AMBER}[BETA]${C_RESET}"
  warn "Beta build — verify every gauge against a known-good instrument before trusting it."
  [[ ${#OVERLAYS[@]} -gt 0 ]] && info "Reboot to apply CAN HAT overlays before expecting physical CAN interfaces."
  [[ $A_CONVERT == y ]] && info "N2K/J1939 mappings: python3 /usr/local/lib/keelos/protocol_bridge.py --n2k-iface ${N2K_IFACE:-vcan0} --j1939-iface ${J1939_IFACE:-vcan1} --list-mappings"
  [[ $A_J1708 == y ]] && info "J1587/N2K mappings: python3 /usr/local/lib/keelos/j1587_n2k.py --list-mappings"
  info "Re-run anytime — it's idempotent. Uninstall: keel.sh --uninstall"
  log "keel.sh done"
}

main "$@"
