#!/usr/bin/env bash
#=============================================================================
#  Keelas.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: Keelas.sh --dry-run
#  ***************************************************************************
#
#  Turns the Linux you already run into a marine instrument bridge:
#  CAN bus, smart passive CAN sniffing, NMEA 2000, J1939, J1708 bridging,
#  NMEA 0183/RS422, MTU RS422, Signal K, and a kiosk gauge dashboard — installing ONLY what your answers require.
#
#  Usage:
#    sudo ./Keelas.sh                    interactive wizard
#    sudo ./Keelas.sh --answers f.yml    unattended (fleet) install
#    sudo ./Keelas.sh --dry-run          show the plan, touch nothing
#    sudo ./Keelas.sh --uninstall        remove everything Keelas.sh added
#    ./Keelas.sh --detect                hardware probe report only (no root)
#    ./Keelas.sh --hat-guide             Waveshare / CAN HAT selection guide
#    ./Keelas.sh --mtu-guide             MTU RS422 + Waveshare gateway prerequisites
#    ./Keelas.sh --rs422-guide           NMEA 0183 / MTU RS422 gateway prerequisites
#
#  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.13-rs422-smart-sniffer-kiosk-autoboot"
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 "Keelas.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
MTU_GUIDE_ONLY=0
RS422_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_ENGINE_PROFILES="standard_j1939" # comma-separated J1939 engine families for profile-specific PGN inventory
A_ENGINE_MAP=""       # optional comma-separated J1939 source:N2K-instance mappings
A_J1939_RAW_CAPTURE="n" # y | n — bounded passive capture for proprietary/unknown J1939 PGNs
A_N2K_PROPRIETARY="n" # y | n — documented proprietary NMEA 2000 compatibility publisher
A_CAN_SNIFFER="n"      # y | n — passive SocketCAN sniffer / protocol discovery
A_SNIFFER_PROTOCOLS="auto" # auto | comma list: nmea2000,j1939,smartcraft,raw
A_SNIFFER_IFACES="auto" # auto | comma-separated SocketCAN interfaces
A_SNIFFER_BITRATE="250000" # used only when sniffer owns an otherwise-unassigned CAN channel
A_SNIFFER_MAX_MB="25"  # rotating NDJSON capture size per file
A_SNIFFER_SUMMARY="30" # seconds between journal summaries
SNIFFER_IFACES=""      # resolved comma-separated interfaces
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_MTU_RS422="n"       # y | n — MTU ECS-5 RS422 input translated toward NMEA 2000
A_MTU_RS422_PORT="auto" # auto | /dev/... — use a true RS422 transceiver/adapter
A_MTU_RS422_ADAPTER="waveshare4ch" # waveshare4ch | generic
A_MTU_RS422_CHANNEL="auto" # auto | A | B — Waveshare USB TO 4CH RS485/422 RS422-capable ports only
A_MTU_RS422_BAUD=""   # required when MTU RS422 is enabled; do not guess engine serial settings
A_MTU_RS422_DATABITS="8" # 7 | 8
A_MTU_RS422_PARITY="n" # n | e | o
A_MTU_RS422_STOPBITS="1" # 1 | 2
A_MTU_RS422_PROFILE="monitoring1" # monitoring1 | monitoring2 (ECS-5 scope)
A_RS422_MODE=""       # none | nmea0183 | mtu | both — canonical serial input selector
A_0183="n"            # y | n — standard NMEA 0183 input translated to NMEA 2000
A_0183_PORT="auto"    # auto | /dev/...
A_0183_ADAPTER="waveshare4ch" # waveshare4ch | generic
A_0183_CHANNEL="auto" # auto | A | B — Waveshare FT4232HL RS422-capable ports only
A_0183_BAUD="4800"    # standard NMEA 0183 is commonly 4800; HS/AIS commonly 38400; custom supported
A_0183_SOURCE="0x26"  # NMEA 2000 source address for the 0183 bridge
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
MTU_ADVISORY_SHOWN=0  # avoid duplicating the prerequisite advisory in interactive mode

# 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 ;;
    --mtu-guide)  MTU_GUIDE_ONLY=1 ;;
    --rs422-guide) RS422_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
}

probe_rs422_serial() {  # true RS422 normally arrives through an external USB/serial adapter
  compgen -G "/dev/serial/by-id/*" 2>/dev/null || true
  compgen -G "/dev/ttyUSB*" 2>/dev/null || true
  compgen -G "/dev/ttyACM*" 2>/dev/null || true
}


serial_sysfs_attr() {  # serial_sysfs_attr /dev/ttyUSB0 bInterfaceNumber|idVendor|idProduct
  local dev="$1" attr="$2" node base
  base="$(basename "$(readlink -f "$dev" 2>/dev/null || printf '%s' "$dev")")"
  node="$(readlink -f "/sys/class/tty/${base}/device" 2>/dev/null || true)"
  while [[ -n $node && $node != / ]]; do
    if [[ -r "${node}/${attr}" ]]; then tr -d '\n' < "${node}/${attr}"; return 0; fi
    node="${node%/*}"
    [[ -n $node ]] || node=/
  done
  return 1
}

stable_serial_path() {  # prefer /dev/serial/by-id so ttyUSB numbering can move
  local dev="$1" link real
  real="$(readlink -f "$dev" 2>/dev/null || printf '%s' "$dev")"
  for link in /dev/serial/by-id/*; do
    [[ -e $link ]] || continue
    [[ $(readlink -f "$link" 2>/dev/null || true) == "$real" ]] && { printf '%s\n' "$link"; return 0; }
  done
  printf '%s\n' "$dev"
}

waveshare_4ch_channel_for_port() {  # A/B only; C/D on this product are RS485-only
  local dev="$1" vid pid ifnum
  vid="$(serial_sysfs_attr "$dev" idVendor 2>/dev/null || true)"
  pid="$(serial_sysfs_attr "$dev" idProduct 2>/dev/null || true)"
  ifnum="$(serial_sysfs_attr "$dev" bInterfaceNumber 2>/dev/null || true)"
  [[ ${vid,,} == 0403 && ${pid,,} == 6011 ]] || return 1
  case "${ifnum^^}" in 00) printf 'A\n' ;; 01) printf 'B\n' ;; *) return 1 ;; esac
}

probe_waveshare_4ch_rs422() {  # prints A:/dev/... and B:/dev/... for FT4232HL default VID/PID
  local dev channel stable
  for dev in /dev/ttyUSB*; do
    [[ -e $dev ]] || continue
    channel="$(waveshare_4ch_channel_for_port "$dev" 2>/dev/null || true)"
    [[ -n $channel ]] || continue
    stable="$(stable_serial_path "$dev")"
    printf '%s:%s\n' "$channel" "$stable"
  done | sort
}

select_waveshare_4ch_rs422_port() {  # desired channel auto|A|B
  local wanted="${1^^}" row ch port fallback=""
  while IFS= read -r row; do
    [[ -n $row ]] || continue
    ch="${row%%:*}"; port="${row#*:}"
    [[ -n $fallback ]] || fallback="$port"
    if [[ $wanted == auto || $wanted == "$ch" ]]; then printf '%s\n' "$port"; return 0; fi
  done < <(probe_waveshare_4ch_rs422)
  [[ $wanted == auto && -n $fallback ]] && { printf '%s\n' "$fallback"; return 0; }
  return 1
}

mtu_rs422_prereq_advisory() {
  MTU_ADVISORY_SHOWN=1
  say "${C_AMBER}${C_BOLD}MTU RS422 -> NMEA 2000 prerequisite advisory${C_RESET}"
  say ""
  say "  Required for the MTU serial side:"
  say "   - Raspberry Pi 4 Model B or Raspberry Pi 5 running Linux/Raspberry Pi OS."
  say "   - Waveshare USB TO 4CH RS485/422 (FT4232HL), connected by USB-B to a Pi USB host port."
  say "   - Use Waveshare Port A or Port B for MTU RS422. Ports C and D are RS485-only on this model."
  say "   - MTU/PIM RS422 transmit pair wired to the Waveshare receive pair RA/RB. Confirm + / - polarity"
  say "     from the vessel/MTU wiring documentation before energizing; KeelOS does not guess O1/O2 polarity."
  say "   - The bridge is read-only on MTU RS422, so TA/TB are not required for monitoring/conversion."
  say ""
  say "  Required for a PHYSICAL NMEA 2000 output:"
  say "   - A separate SocketCAN-capable CAN interface/HAT (for example a supported Waveshare CAN HAT)."
  say "   - A correctly powered and terminated NMEA 2000 backbone/drop."
  say "   - The USB TO 4CH RS485/422 is the RS422 front end only; it is not a CAN/NMEA 2000 transceiver."
  say "   - If you only need Signal K/local translated data, KeelOS can use vcan and physical CAN is optional."
  say ""
  say "  Power / USB:"
  say "   - Pi 4: use a quality 5 V / 3 A USB-C supply (official 15 W supply recommended)."
  say "   - Pi 5: use a 5 V / 5 A USB-C supply (official 27 W supply recommended) when USB peripherals are used."
  say "   - If the total USB load is high, use a properly powered USB hub rather than risking undervoltage."
  say ""
  say "  Gateway notes:"
  say "   - The Waveshare unit is USB bus-powered and provides isolated RS422 channels; no separate isolated-side"
  say "     supply is normally required for the converter itself."
  say "   - KeelOS recognizes the FT4232HL default USB identity 0403:6011 and only auto-selects interfaces A/B."
  say "   - If the adapter EEPROM uses a custom USB identity, set mtu_rs422_port explicitly to its /dev/serial/by-id path."
  say "   - Check existing RS422 termination before changing the converter's 120-ohm termination arrangement."
  say ""
}

rs422_prereq_advisory() {
  MTU_ADVISORY_SHOWN=1
  say "${C_AMBER}${C_BOLD}NMEA 0183 / MTU RS422 -> NMEA 2000 prerequisite advisory${C_RESET}"
  say ""
  say "  Serial / RS422 front end:"
  say "   - Raspberry Pi 4 Model B or Raspberry Pi 5 running Linux/Raspberry Pi OS."
  say "   - Recommended: Waveshare USB TO 4CH RS485/422 (FT4232HL)."
  say "   - Only Ports A and B on that Waveshare unit are RS422-capable; C/D are RS485-only."
  say "   - NMEA 0183 mode reads standard ASCII NMEA sentences over RS422 and converts verified fields to NMEA 2000."
  say "   - MTU mode uses the ECS-5 semantic maps already bundled; live MTU framing still requires E 531 652."
  say "   - BOTH mode automatically assigns Waveshare A to NMEA 0183 and B to MTU when both channels are auto."
  say "   - Both serial bridges are read-only on their RS422 inputs; connect each talker's TX pair to the selected receiver pair."
  say ""
  say "  NMEA 2000 output:"
  say "   - A separate supported SocketCAN CAN interface/HAT is required for a physical NMEA 2000 backbone."
  say "   - If only local/Signal K translated data is needed, KeelOS can use vcan instead."
  say "   - Never electrically join an RS422 pair directly to CAN-H/CAN-L."
  say ""
  say "  NMEA 0183 defaults:"
  say "   - 4800 baud, 8N1. 38400 is commonly used for high-speed NMEA 0183/AIS; custom supported rates may be selected."
  say "   - Direct conversion includes RMC/GGA/GLL/VTG, HDT/HDM/HDG/VHW, DPT/DBT, MWV and MTW."
  say ""
}

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}"
  local rs422s; rs422s="$(probe_rs422_serial | tr '\n' ' ')"
  say  "   RS422 candidates  ${rs422s:-none detected}"
  local ws422; ws422="$(probe_waveshare_4ch_rs422 | tr '\n' ' ' || true)"
  say  "   Waveshare 4CH ... ${ws422:-not detected (A/B are the RS422-capable ports)}"
  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 Keelas.sh answer files
      nmea2000) A_N2K="${v,,}" ;;
      j1939) A_J1939="${v,,}" ;;
      convert) A_CONVERT="${v,,}" ;;
      engine_profiles) A_ENGINE_PROFILES="${v,,}" ;;
      engine_map) A_ENGINE_MAP="$v" ;;
      j1939_raw_capture) A_J1939_RAW_CAPTURE="${v,,}" ;;
      proprietary_nmea2000|maretron_compat) A_N2K_PROPRIETARY="${v,,}" ;;
      can_sniffer|sniffer) A_CAN_SNIFFER="${v,,}" ;;
      sniffer_protocols) A_SNIFFER_PROTOCOLS="${v,,}" ;;
      sniffer_ifaces|sniffer_interfaces) A_SNIFFER_IFACES="$v" ;;
      sniffer_bitrate) A_SNIFFER_BITRATE="$v" ;;
      sniffer_max_mb) A_SNIFFER_MAX_MB="$v" ;;
      sniffer_summary) A_SNIFFER_SUMMARY="$v" ;;
      signalk) A_SIGNALK="${v,,}" ;;
      j1708)  A_J1708="${v,,}" ;;
      j1708_port) A_J1708_PORT="$v" ;;
      mtu_rs422) A_MTU_RS422="${v,,}" ;;
      mtu_rs422_port) A_MTU_RS422_PORT="$v" ;;
      mtu_rs422_adapter) A_MTU_RS422_ADAPTER="${v,,}" ;;
      mtu_rs422_channel) A_MTU_RS422_CHANNEL="${v^^}" ;;
      mtu_rs422_baud) A_MTU_RS422_BAUD="$v" ;;
      mtu_rs422_databits) A_MTU_RS422_DATABITS="$v" ;;
      mtu_rs422_parity) A_MTU_RS422_PARITY="${v,,}" ;;
      mtu_rs422_stopbits) A_MTU_RS422_STOPBITS="$v" ;;
      mtu_rs422_profile) A_MTU_RS422_PROFILE="${v,,}" ;;
      rs422_mode|serial_rs422_mode) A_RS422_MODE="${v,,}" ;;
      nmea0183) A_0183="${v,,}" ;;
      nmea0183_port) A_0183_PORT="$v" ;;
      nmea0183_adapter) A_0183_ADAPTER="${v,,}" ;;
      nmea0183_channel) A_0183_CHANNEL="${v^^}" ;;
      nmea0183_baud) A_0183_BAUD="$v" ;;
      nmea0183_source) A_0183_SOURCE="$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, RS422 input mode (NMEA 0183 / MTU / BOTH), passive CAN sniffing, and kiosk are independent selections."
  say "     MTU RS422 requires a true RS422 electrical interface (typically USB/RS422)."
  say "     The RS485 channel on the Waveshare RS485 CAN HAT is not assumed to be RS422-compatible."
  say "     For MTU RS422, KeelOS directly supports the Waveshare USB TO 4CH RS485/422 FT4232HL gateway."
  say "     Run: Keelas.sh --rs422-guide for NMEA 0183/MTU A+B hardware guidance; --mtu-guide keeps the MTU-only details."
  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
}

engine_profile_guide() {
  say "${C_AMBER}${C_BOLD}J1939 engine profile selection${C_RESET}"
  say "  1) Generic SAE J1939"
  say "  2) Cummins Onan NIM"
  say "  3) Cummins Tier 4"
  say "  4) John Deere PowerTech"
  say "  5) Caterpillar C32 / ADEM"
  say "  6) Scania"
  say "  7) Yamaha Marine"
  say "  8) Suzuki Marine"
  say "  9) FPT Industrial"
  say " 10) MAN standard J1939 (proprietary messages capture-only until verified)"
  say "${C_DIM}   Multiple engines/families: enter comma-separated numbers, e.g. 5,7.${C_RESET}"
}

normalize_engine_profiles_shell() {
  local input="${1:-standard_j1939}" item out=""
  IFS=',' read -r -a _eparts <<< "$input"
  for item in "${_eparts[@]}"; do
    item="${item,,}"; item="${item//-/_}"
    case "$item" in
      1|generic|standard|j1939|standard_j1939) item=standard_j1939 ;;
      2|nim|cummins_nim) item=cummins_nim ;;
      3|cummins|tier4|cummins_tier4) item=cummins_tier4 ;;
      4|deere|jd|john_deere) item=john_deere ;;
      5|cat|caterpillar|cat_c32) item=cat_c32 ;;
      6|scania) item=scania ;;
      7|yamaha) item=yamaha ;;
      8|suzuki) item=suzuki ;;
      9|fpt) item=fpt ;;
      10|man|man_standard) item=man_standard ;;
      *) die "Unknown J1939 engine profile '${item}'." ;;
    esac
    [[ ",${out}," == *",${item},"* ]] || out="${out:+${out},}${item}"
  done
  printf '%s\n' "${out:-standard_j1939}"
}

normalize_sniffer_protocols_shell() {
  local raw="${1,,}" item out=""
  raw="${raw// /}"
  [[ -n $raw ]] || raw="auto"
  # Friendly numeric wizard aliases.
  case "$raw" in
    1) raw="auto" ;;
    2) raw="nmea2000" ;;
    3) raw="j1939" ;;
    4) raw="nmea2000,j1939" ;;
    5) raw="smartcraft" ;;
    6) raw="raw" ;;
  esac
  IFS=',' read -r -a _snproto <<< "$raw"
  for item in "${_snproto[@]}"; do
    case "$item" in
      auto|nmea2000|j1939|smartcraft|raw) ;;
      n2k) item="nmea2000" ;;
      smart-craft|mercury) item="smartcraft" ;;
      *) die "Unknown sniffer protocol '${item}'. Use auto,nmea2000,j1939,smartcraft,raw." ;;
    esac
    [[ ",${out}," == *",${item},"* ]] || out="${out:+${out},}${item}"
  done
  # auto already includes classification/fallback behavior, so don't combine it.
  [[ $out == *auto* ]] && out="auto"
  printf '%s\n' "${out:-auto}"
}

normalize_rs422_mode() {
  # New answer files can use one selector; old files with separate mtu_rs422/nmea0183
  # booleans remain fully compatible.
  if [[ -n $A_RS422_MODE ]]; then
    case "${A_RS422_MODE,,}" in
      none|off) A_RS422_MODE=none; A_0183=n; A_MTU_RS422=n ;;
      nmea0183|0183|nmea) A_RS422_MODE=nmea0183; A_0183=y; A_MTU_RS422=n ;;
      mtu|mtu_rs422) A_RS422_MODE=mtu; A_0183=n; A_MTU_RS422=y ;;
      both) A_RS422_MODE=both; A_0183=y; A_MTU_RS422=y ;;
      *) die "rs422_mode must be none, nmea0183, mtu, or both." ;;
    esac
  else
    case "${A_0183}:${A_MTU_RS422}" in
      y:y) A_RS422_MODE=both ;;
      y:n) A_RS422_MODE=nmea0183 ;;
      n:y) A_RS422_MODE=mtu ;;
      *)   A_RS422_MODE=none ;;
    esac
  fi
}

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
  normalize_rs422_mode
  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
  A_ENGINE_PROFILES="$(normalize_engine_profiles_shell "$A_ENGINE_PROFILES")"
  case "${A_J1939_RAW_CAPTURE,,}" in y|n) ;; *) die "j1939_raw_capture must be y or n." ;; esac
  case "${A_N2K_PROPRIETARY,,}" in y|n) ;; *) die "proprietary_nmea2000 must be y or n." ;; esac
  case "${A_CAN_SNIFFER,,}" in y|n) ;; *) die "can_sniffer must be y or n." ;; esac
  A_SNIFFER_PROTOCOLS="$(normalize_sniffer_protocols_shell "$A_SNIFFER_PROTOCOLS")"
  [[ $A_SNIFFER_IFACES == auto || $A_SNIFFER_IFACES =~ ^[a-zA-Z0-9_.:-]+(,[a-zA-Z0-9_.:-]+)*$ ]] \
    || die "sniffer_ifaces must be auto or comma-separated SocketCAN names."
  case "$A_SNIFFER_BITRATE" in 250000|500000) ;; *) die "sniffer_bitrate must be 250000 or 500000." ;; esac
  [[ $A_SNIFFER_MAX_MB =~ ^[0-9]+$ ]] && (( A_SNIFFER_MAX_MB >= 1 && A_SNIFFER_MAX_MB <= 1024 )) \
    || die "sniffer_max_mb must be 1..1024."
  [[ $A_SNIFFER_SUMMARY =~ ^[0-9]+$ ]] && (( A_SNIFFER_SUMMARY >= 5 && A_SNIFFER_SUMMARY <= 3600 )) \
    || die "sniffer_summary must be 5..3600 seconds."
  if [[ -n $A_ENGINE_MAP ]]; then
    local em
    IFS=',' read -r -a _emaps <<< "$A_ENGINE_MAP"
    for em in "${_emaps[@]}"; do
      [[ $em =~ ^(0[xX][0-9A-Fa-f]+|[0-9]+):[0-9]+$ ]] || die "Bad engine_map '${em}'; use source:instance, comma-separated."
      local esa="${em%%:*}" eins="${em##*:}"
      (( $((esa)) >= 0 && $((esa)) <= 253 && eins >= 0 && eins <= 252 )) || die "engine_map out of range: ${em}"
    done
  fi
  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_MTU_RS422,,}" in y|n) ;; *) die "mtu_rs422 must be y or n." ;; esac
  [[ $A_MTU_RS422_PORT == auto || $A_MTU_RS422_PORT =~ ^/dev/[a-zA-Z0-9_./:-]+$ ]] \
    || die "mtu_rs422_port must be auto or a /dev/... path."
  case "${A_MTU_RS422_ADAPTER,,}" in waveshare4ch|generic) ;; *) die "mtu_rs422_adapter must be waveshare4ch or generic." ;; esac
  case "${A_MTU_RS422_CHANNEL^^}" in
    AUTO) A_MTU_RS422_CHANNEL="auto" ;;
    A|B)  A_MTU_RS422_CHANNEL="${A_MTU_RS422_CHANNEL^^}" ;;
    *) die "mtu_rs422_channel must be auto, A, or B." ;;
  esac
  if [[ $A_MTU_RS422 == y ]]; then
    [[ $A_MTU_RS422_BAUD =~ ^[0-9]+$ ]] || die "mtu_rs422_baud is required when MTU RS422 is enabled."
    case "$A_MTU_RS422_BAUD" in 300|600|1200|2400|4800|9600|19200|38400|57600|115200|230400|460800|921600) ;; *) die "mtu_rs422_baud must be a supported serial rate from 300 through 921600." ;; esac
    case "$A_MTU_RS422_DATABITS" in 7|8) ;; *) die "mtu_rs422_databits must be 7 or 8." ;; esac
    case "${A_MTU_RS422_PARITY,,}" in n|e|o) ;; *) die "mtu_rs422_parity must be n, e, or o." ;; esac
    case "$A_MTU_RS422_STOPBITS" in 1|2) ;; *) die "mtu_rs422_stopbits must be 1 or 2." ;; esac
    case "${A_MTU_RS422_PROFILE,,}" in monitoring1|monitoring2) ;; *) die "mtu_rs422_profile must be monitoring1 or monitoring2." ;; esac
  fi
  case "${A_0183,,}" in y|n) ;; *) die "nmea0183 must be y or n." ;; esac
  [[ $A_0183_PORT == auto || $A_0183_PORT =~ ^/dev/[a-zA-Z0-9_./:-]+$ ]] \
    || die "nmea0183_port must be auto or a /dev/... path."
  case "${A_0183_ADAPTER,,}" in waveshare4ch|generic) ;; *) die "nmea0183_adapter must be waveshare4ch or generic." ;; esac
  case "${A_0183_CHANNEL^^}" in
    AUTO) A_0183_CHANNEL="auto" ;;
    A|B)  A_0183_CHANNEL="${A_0183_CHANNEL^^}" ;;
    *) die "nmea0183_channel must be auto, A, or B." ;;
  esac
  if [[ $A_0183 == y ]]; then
    case "$A_0183_BAUD" in 300|600|1200|2400|4800|9600|19200|38400|57600|115200|230400|460800|921600) ;; *) die "nmea0183_baud must be a supported serial rate from 300 through 921600." ;; esac
    [[ $A_0183_SOURCE =~ ^(0[xX][0-9A-Fa-f]+|[0-9]+)$ ]] || die "nmea0183_source must be a numeric NMEA 2000 source address."
    (( $((A_0183_SOURCE)) >= 0 && $((A_0183_SOURCE)) <= 253 )) || die "nmea0183_source must be 0..253."
  fi

  # When both serial modes use the Waveshare FT4232HL, pin them to different
  # RS422-capable channels automatically. Ports C/D are RS485-only and are never used.
  if [[ $A_0183 == y && $A_MTU_RS422 == y ]]; then
    if [[ $A_0183_ADAPTER == waveshare4ch && $A_MTU_RS422_ADAPTER == waveshare4ch ]]; then
      if [[ $A_0183_CHANNEL == auto && $A_MTU_RS422_CHANNEL == auto ]]; then
        A_0183_CHANNEL=A; A_MTU_RS422_CHANNEL=B
      elif [[ $A_0183_CHANNEL == auto && $A_MTU_RS422_CHANNEL == A ]]; then
        A_0183_CHANNEL=B
      elif [[ $A_0183_CHANNEL == auto && $A_MTU_RS422_CHANNEL == B ]]; then
        A_0183_CHANNEL=A
      elif [[ $A_MTU_RS422_CHANNEL == auto && $A_0183_CHANNEL == A ]]; then
        A_MTU_RS422_CHANNEL=B
      elif [[ $A_MTU_RS422_CHANNEL == auto && $A_0183_CHANNEL == B ]]; then
        A_MTU_RS422_CHANNEL=A
      elif [[ $A_0183_CHANNEL == "$A_MTU_RS422_CHANNEL" ]]; then
        die "NMEA 0183 and MTU RS422 cannot share Waveshare channel ${A_0183_CHANNEL}; use A+B or choose auto."
      fi
    elif [[ $A_0183_PORT == auto || $A_MTU_RS422_PORT == auto ]]; then
      die "Both RS422 inputs with a generic adapter require explicit, different nmea0183_port and mtu_rs422_port values. Waveshare 4CH A/B supports automatic separation."
    fi
    [[ $A_0183_PORT == auto || $A_MTU_RS422_PORT == auto || $A_0183_PORT != "$A_MTU_RS422_PORT" ]] \
      || die "NMEA 0183 and MTU RS422 cannot share the same serial port (${A_0183_PORT})."
  fi

  if [[ $A_J1708 == y && ( $A_MTU_RS422 == y || $A_0183 == y ) ]]; then
    if [[ $A_MTU_RS422 == y ]]; then
      [[ $A_J1708_PORT == auto || $A_MTU_RS422_PORT == auto || $A_J1708_PORT != "$A_MTU_RS422_PORT" ]] \
        || die "J1708 and MTU RS422 cannot share the same serial port (${A_J1708_PORT})."
    fi
    if [[ $A_0183 == y ]]; then
      [[ $A_J1708_PORT == auto || $A_0183_PORT == auto || $A_J1708_PORT != "$A_0183_PORT" ]] \
        || die "J1708 and NMEA 0183 RS422 cannot share the same serial port (${A_J1708_PORT})."
    fi
    if [[ $A_J1708_PORT == auto && $A_HAT != ws-rs485-12m && $A_HAT != ws-rs485-8m ]]; then
      die "J1708 auto-selection could claim an RS422 adapter. Set j1708_port explicitly when NMEA 0183 or MTU RS422 is also enabled."
    fi
  fi
  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}'."
  [[ -z $A_J1939_IFACE || $A_J1939_IFACE =~ ^[a-zA-Z0-9_.:-]+$ ]] || die "Invalid j1939_iface '${A_J1939_IFACE}'."
  [[ -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 && $A_MTU_RS422 == n && $A_0183 == 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/11] 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/11] 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/11] 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/11] 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/11] CAN capabilities skipped — no CAN hardware selected.${C_RESET}"
  fi

  say ""
  say "${C_AMBER}[5/11] Select J1939 engine family/families for conversion and proprietary discovery.${C_RESET}"
  if [[ $A_CONVERT == y ]]; then
    engine_profile_guide
    local eprof emap eraw
    ask eprof "Engine profile number(s) [comma-separated]:" "1"
    A_ENGINE_PROFILES="$(normalize_engine_profiles_shell "$eprof")"
    say "${C_DIM}   Source-address mapping is optional; example twin engines: 0:0,1:1 or 0x80:0,0x81:1.${C_RESET}"
    [[ $A_ENGINE_PROFILES == cummins_nim ]] && say "${C_DIM}   Cummins NIM alone defaults to fixed sources 234->0, 158->1, 179->2, 203->3 when left blank.${C_RESET}"
    ask emap "J1939 source:engine-instance map [blank=auto]:" ""
    A_ENGINE_MAP="$emap"
    ask eraw "Enable bounded read-only capture of unknown/proprietary J1939 PGNs? [Y/n]:" "Y"
    [[ ${eraw,,} != n* ]] && A_J1939_RAW_CAPTURE=y || A_J1939_RAW_CAPTURE=n
  else
    say "${C_DIM}   skipped — J1939 semantic conversion is disabled.${C_RESET}"
  fi

  say ""
  say "${C_AMBER}[6/11] Enable documented proprietary NMEA 2000 compatibility output?${C_RESET}  [y/N]"
  say "${C_DIM}   Adds Maretron-compatible 65286 flow, 65287 trip volume and 130823 high-range exhaust output when matching standard data exists.${C_RESET}"
  say "${C_DIM}   65284 relay current and 130840 Generic Sensor are available only from explicit verified configuration; catalog-only control/config PGNs are never invented.${C_RESET}"
  ask pick "[y/N]:" "N"; [[ ${pick,,} == y* ]] && A_N2K_PROPRIETARY=y || A_N2K_PROPRIETARY=n

  say ""
  say "${C_AMBER}[7/11] 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 ""
  say "${C_AMBER}[8/11] RS422 input mode for conversion to NMEA 2000?${C_RESET}"
  say "  1) Standard NMEA 0183 over RS422"
  say "  2) MTU ECS-5 RS422"
  say "  3) BOTH — NMEA 0183 + MTU on separate RS422 channels"
  say "  4) None"
  ask pick "[1-4]:" "4"
  case "$pick" in
    1) A_RS422_MODE=nmea0183; A_0183=y; A_MTU_RS422=n ;;
    2) A_RS422_MODE=mtu; A_0183=n; A_MTU_RS422=y ;;
    3) A_RS422_MODE=both; A_0183=y; A_MTU_RS422=y ;;
    4) A_RS422_MODE=none; A_0183=n; A_MTU_RS422=n ;;
    *) die "Pick 1-4 for RS422 input mode." ;;
  esac

  if [[ $A_0183 == y || $A_MTU_RS422 == y ]]; then
    rs422_prereq_advisory
  fi

  if [[ $A_0183 == y ]]; then
    local nport nbaud nadapter nchannel nwsports nports
    say ""
    say "${C_AMBER}Standard NMEA 0183 RS422 input${C_RESET}"
    ask nadapter "NMEA 0183 RS422 adapter [waveshare4ch/generic]:" "waveshare4ch"
    A_0183_ADAPTER="${nadapter,,}"
    if [[ $A_0183_ADAPTER == waveshare4ch ]]; then
      nwsports="$(probe_waveshare_4ch_rs422 | tr '\n' ' ' || true)"
      [[ -n $nwsports ]] && info "detected Waveshare/FT4232HL RS422 channels: ${nwsports}" || warn "Waveshare FT4232HL not detected yet; runtime auto-detection will retry."
      if [[ $A_RS422_MODE == both ]]; then
        ask nchannel "NMEA 0183 Waveshare channel [auto/A/B] (auto chooses A when MTU is also auto):" "auto"
      else
        ask nchannel "NMEA 0183 Waveshare channel [auto/A/B]:" "auto"
      fi
      A_0183_CHANNEL="${nchannel^^}"
    else
      A_0183_CHANNEL=auto
      nports="$(probe_rs422_serial | tr '\n' ' ' || true)"
      [[ -n $nports ]] && info "candidate generic RS422 serial adapters: ${nports}"
    fi
    ask nport "NMEA 0183 RS422 port override [auto or /dev/...]:" "auto"
    A_0183_PORT="$nport"
    ask nbaud "NMEA 0183 baud [4800 normal / 38400 high-speed / custom]:" "4800"
    A_0183_BAUD="$nbaud"
    say "${C_DIM}   Serial format is 8N1. The bridge is read-only and validates NMEA checksums when present.${C_RESET}"
  fi

  if [[ $A_MTU_RS422 == y ]]; then
    local mport mbaud mfmt mprofile rsports madapter mchannel wsports
    say ""
    say "${C_AMBER}MTU ECS-5 RS422 input${C_RESET}"
    say "${C_DIM}   Includes Monitoring I/II point, scaling, gear and alarm maps; E 531 652 framing is still pending.${C_RESET}"
    ask madapter "MTU RS422 adapter [waveshare4ch/generic]:" "waveshare4ch"
    A_MTU_RS422_ADAPTER="${madapter,,}"
    if [[ $A_MTU_RS422_ADAPTER == waveshare4ch ]]; then
      wsports="$(probe_waveshare_4ch_rs422 | tr '\n' ' ' || true)"
      [[ -n $wsports ]] && info "detected Waveshare/FT4232HL RS422 channels: ${wsports}" || warn "Waveshare FT4232HL not detected yet; runtime auto-detection will retry."
      if [[ $A_RS422_MODE == both ]]; then
        ask mchannel "MTU Waveshare channel [auto/A/B] (auto chooses B when NMEA 0183 is also auto):" "auto"
      else
        ask mchannel "MTU Waveshare RS422 channel [auto/A/B]:" "auto"
      fi
      A_MTU_RS422_CHANNEL="${mchannel^^}"
    else
      A_MTU_RS422_CHANNEL="auto"
      rsports="$(probe_rs422_serial | tr '\n' ' ' || true)"
      [[ -n $rsports ]] && info "candidate generic RS422 serial adapters: ${rsports}"
    fi
    ask mport "MTU RS422 port override [auto or /dev/...]:" "auto"
    A_MTU_RS422_PORT="$mport"
    ask mbaud "MTU RS422 baud [300..921600] (required; use the MTU-configured rate):" ""
    A_MTU_RS422_BAUD="$mbaud"
    ask mfmt "MTU serial format [8N1/8E1/8O1/7E1/7O1/8N2]:" "8N1"
    mfmt="${mfmt^^}"
    ask mprofile "MTU ECS-5 scope [monitoring1/monitoring2]:" "monitoring1"
    A_MTU_RS422_PROFILE="${mprofile,,}"
    case "$mfmt" in
      8N1) A_MTU_RS422_DATABITS=8; A_MTU_RS422_PARITY=n; A_MTU_RS422_STOPBITS=1 ;;
      8E1) A_MTU_RS422_DATABITS=8; A_MTU_RS422_PARITY=e; A_MTU_RS422_STOPBITS=1 ;;
      8O1) A_MTU_RS422_DATABITS=8; A_MTU_RS422_PARITY=o; A_MTU_RS422_STOPBITS=1 ;;
      7E1) A_MTU_RS422_DATABITS=7; A_MTU_RS422_PARITY=e; A_MTU_RS422_STOPBITS=1 ;;
      7O1) A_MTU_RS422_DATABITS=7; A_MTU_RS422_PARITY=o; A_MTU_RS422_STOPBITS=1 ;;
      8N2) A_MTU_RS422_DATABITS=8; A_MTU_RS422_PARITY=n; A_MTU_RS422_STOPBITS=2 ;;
      *) die "Unsupported MTU serial format '${mfmt}'." ;;
    esac
  fi

  say ""
  say "${C_AMBER}[9/11] Enable the passive CAN sniffer / protocol discovery service?${C_RESET}  [y/N]"
  say "${C_DIM}   Read-only SocketCAN capture. It never transmits CAN frames.${C_RESET}"
  ask pick "[y/N]:" "N"; [[ ${pick,,} == y* ]] && A_CAN_SNIFFER=y || A_CAN_SNIFFER=n
  if [[ $A_CAN_SNIFFER == y ]]; then
    local sprotos sifaces srate
    say "${C_AMBER}Which CAN protocol(s) are you sniffing?${C_RESET}"
    say "  1) Auto classify — NMEA 2000 / J1939 / proprietary hints, unknown stays RAW"
    say "  2) NMEA 2000"
    say "  3) SAE J1939"
    say "  4) NMEA 2000 + J1939"
    say "  5) Mercury SmartCraft / proprietary CAN — experimental passive labeling"
    say "  6) Raw CAN — no protocol assumption"
    ask sprotos "[1-6 or comma list auto,nmea2000,j1939,smartcraft,raw]:" "1"
    A_SNIFFER_PROTOCOLS="$(normalize_sniffer_protocols_shell "$sprotos")"
    say "${C_DIM}   'auto' follows configured CAN roles. Use an explicit interface for a dedicated SmartCraft/raw adapter.${C_RESET}"
    ask sifaces "Sniffer SocketCAN interface(s) [auto or can0,can1]:" "auto"
    A_SNIFFER_IFACES="$sifaces"
    if [[ $A_SNIFFER_PROTOCOLS == smartcraft || $A_SNIFFER_PROTOCOLS == raw ]]; then
      ask srate "Sniffer-only CAN bitrate if KeelOS must bring up an unused channel [250000/500000]:" "250000"
      A_SNIFFER_BITRATE="$srate"
    fi
  fi

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

  say ""
  say "${C_AMBER}[11/11] 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
# Keelas.sh answers — hardware first, capabilities second
# re-run with: Keelas.sh --answers ${ANSWER_CACHE}
hat: ${A_HAT}
nmea2000: ${A_N2K}
j1939: ${A_J1939}
convert: ${A_CONVERT}
engine_profiles: ${A_ENGINE_PROFILES}
engine_map: ${A_ENGINE_MAP}
j1939_raw_capture: ${A_J1939_RAW_CAPTURE}
proprietary_nmea2000: ${A_N2K_PROPRIETARY}
can_sniffer: ${A_CAN_SNIFFER}
sniffer_protocols: ${A_SNIFFER_PROTOCOLS}
sniffer_ifaces: ${A_SNIFFER_IFACES}
sniffer_bitrate: ${A_SNIFFER_BITRATE}
sniffer_max_mb: ${A_SNIFFER_MAX_MB}
sniffer_summary: ${A_SNIFFER_SUMMARY}
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}
mtu_rs422: ${A_MTU_RS422}
mtu_rs422_port: ${A_MTU_RS422_PORT}
mtu_rs422_adapter: ${A_MTU_RS422_ADAPTER}
mtu_rs422_channel: ${A_MTU_RS422_CHANNEL}
mtu_rs422_baud: ${A_MTU_RS422_BAUD}
mtu_rs422_databits: ${A_MTU_RS422_DATABITS}
mtu_rs422_parity: ${A_MTU_RS422_PARITY}
mtu_rs422_stopbits: ${A_MTU_RS422_STOPBITS}
mtu_rs422_profile: ${A_MTU_RS422_PROFILE}
rs422_mode: ${A_RS422_MODE}
nmea0183: ${A_0183}
nmea0183_port: ${A_0183_PORT}
nmea0183_adapter: ${A_0183_ADAPTER}
nmea0183_channel: ${A_0183_CHANNEL}
nmea0183_baud: ${A_0183_BAUD}
nmea0183_source: ${A_0183_SOURCE}
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)
      CAN_IFACES+=(can0)
      [[ $A_N2K == y && $A_J1939 == y ]] && CAN_IFACES+=(can1) ;;
    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_MTU_RS422 == y ]]; then
    PKGS+=(python3)
    # MTU RS422 point/scaling/alarm semantics are loaded from the supplied ECS-5
    # Monitoring I/II tables. The separate E 531 652 serial protocol/framing
    # document is still required before raw RS422 bytes can be safely split into blocks.
    if [[ -z $N2K_IFACE ]]; then
      N2K_IFACE="${A_N2K_IFACE:-vcan0}"
      if [[ -z $A_N2K_IFACE ]]; then
        CAN_SPECS+=("${N2K_IFACE}:vcan")
      else
        warn "MTU RS422 will use externally-managed NMEA 2000 interface '${N2K_IFACE}'."
      fi
    fi
    if [[ $A_MTU_RS422_PORT == auto ]]; then
      local mtu_found=""
      if [[ $A_MTU_RS422_ADAPTER == waveshare4ch ]]; then
        mtu_found="$(select_waveshare_4ch_rs422_port "$A_MTU_RS422_CHANNEL" 2>/dev/null || true)"
        if [[ -n $mtu_found && $A_J1708_PORT != auto && -n $A_J1708_PORT && $(readlink -f "$mtu_found" 2>/dev/null || printf '%s' "$mtu_found") == $(readlink -f "$A_J1708_PORT" 2>/dev/null || printf '%s' "$A_J1708_PORT") ]]; then
          mtu_found=""
        fi
        [[ -n $mtu_found ]] && A_MTU_RS422_PORT="$mtu_found"
      else
        while IFS= read -r mtu_found; do
          [[ -n $mtu_found ]] || continue
          if [[ $A_J1708_PORT != auto && -n $A_J1708_PORT && $(readlink -f "$mtu_found" 2>/dev/null || printf '%s' "$mtu_found") == $(readlink -f "$A_J1708_PORT" 2>/dev/null || printf '%s' "$A_J1708_PORT") ]]; then
            continue
          fi
          A_MTU_RS422_PORT="$mtu_found"
          break
        done < <(probe_rs422_serial)
      fi
      if [[ $A_MTU_RS422_PORT != auto ]]; then
        ok "MTU RS422: auto-selected ${A_MTU_RS422_PORT} (${A_MTU_RS422_ADAPTER}${A_MTU_RS422_ADAPTER:+ channel ${A_MTU_RS422_CHANNEL}})"
      elif [[ $A_MTU_RS422_ADAPTER == waveshare4ch ]]; then
        warn "MTU RS422: Waveshare USB TO 4CH RS485/422 Port ${A_MTU_RS422_CHANNEL} not detected now; runtime auto-detection will retry."
      else
        warn "MTU RS422: no USB RS422 adapter detected now; runtime auto-detection will retry at service start."
      fi
    fi
    UNITS+=(keelos-mtu-rs422.service)
    ok "queued: MTU RS422 ${A_MTU_RS422_ADAPTER}/${A_MTU_RS422_CHANNEL} ${A_MTU_RS422_PORT} @ ${A_MTU_RS422_BAUD} ${A_MTU_RS422_DATABITS}${A_MTU_RS422_PARITY^^}${A_MTU_RS422_STOPBITS} -> NMEA 2000 ${N2K_IFACE} (ECS-5 point/alarm map loaded; serial framing pending)"
  else
    skip "skipped: MTU RS422 input (not requested)"
  fi

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

  if [[ $A_N2K_PROPRIETARY == y ]]; then
    PKGS+=(python3)
    if [[ -z $N2K_IFACE ]]; then
      N2K_IFACE="${A_N2K_IFACE:-vcan0}"
      if [[ -z $A_N2K_IFACE ]]; then CAN_SPECS+=("${N2K_IFACE}:vcan"); else warn "Proprietary NMEA 2000 publisher will use externally-managed '${N2K_IFACE}'."; fi
    fi
    UNITS+=(keelos-proprietary-n2k.service)
    ok "queued: documented proprietary NMEA 2000 compatibility on ${N2K_IFACE} (semantic-only + verified raw config)"
  else
    skip "skipped: proprietary NMEA 2000 compatibility publisher (not requested)"
  fi

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

  if [[ $A_0183 == y ]]; then
    PKGS+=(python3)
    if [[ -z $N2K_IFACE ]]; then
      N2K_IFACE="${A_N2K_IFACE:-vcan0}"
      if [[ -z $A_N2K_IFACE ]]; then CAN_SPECS+=("${N2K_IFACE}:vcan"); else warn "NMEA 0183 will use externally-managed NMEA 2000 interface '${N2K_IFACE}'."; fi
    fi
    if [[ $A_0183_PORT == auto && $A_0183_ADAPTER == waveshare4ch ]]; then
      local nmea_found=""
      nmea_found="$(select_waveshare_4ch_rs422_port "$A_0183_CHANNEL" 2>/dev/null || true)"
      if [[ -n $nmea_found && $A_MTU_RS422_PORT != auto && -n $A_MTU_RS422_PORT && $(readlink -f "$nmea_found" 2>/dev/null || printf '%s' "$nmea_found") == $(readlink -f "$A_MTU_RS422_PORT" 2>/dev/null || printf '%s' "$A_MTU_RS422_PORT") ]]; then nmea_found=""; fi
      [[ -n $nmea_found ]] && A_0183_PORT="$nmea_found"
    fi
    UNITS+=(keelos-nmea0183-bridge.service)
    ok "queued: NMEA 0183 RS422 ${A_0183_ADAPTER}/${A_0183_CHANNEL} ${A_0183_PORT} @ ${A_0183_BAUD} 8N1 -> NMEA 2000 ${N2K_IFACE}"
  else
    skip "skipped: NMEA 0183 RS422 -> NMEA 2000 translator (not requested)"
  fi

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

  if [[ $A_CAN_SNIFFER == y ]]; then
    PKGS+=(python3 can-utils iproute2)
    local _snif_resolved="" _si _spec _owned=0

    if [[ $A_SNIFFER_IFACES != auto ]]; then
      _snif_resolved="$A_SNIFFER_IFACES"
      # Explicit interfaces are treated as externally managed unless they are
      # already part of KeelOS CAN_SPECS. This avoids silently changing a
      # diagnostic adapter's bitrate.
      IFS=',' read -r -a _snif_manual <<< "$_snif_resolved"
      for _si in "${_snif_manual[@]}"; do
        _owned=0
        for _spec in "${CAN_SPECS[@]}"; do [[ ${_spec%%:*} == "$_si" ]] && _owned=1; done
        (( _owned )) || warn "CAN sniffer interface '${_si}' is external/unmanaged; make sure it is UP at the correct bitrate."
      done
    else
      # Auto follows the real configured bus roles first.
      case ",${A_SNIFFER_PROTOCOLS}," in
        *,nmea2000,*)
          [[ -n $N2K_IFACE ]] && _snif_resolved="${_snif_resolved:+${_snif_resolved},}${N2K_IFACE}" ;;
      esac
      case ",${A_SNIFFER_PROTOCOLS}," in
        *,j1939,*)
          [[ -n $J1939_IFACE ]] && [[ ",${_snif_resolved}," != *",${J1939_IFACE},"* ]] \
            && _snif_resolved="${_snif_resolved:+${_snif_resolved},}${J1939_IFACE}" ;;
      esac
      if [[ $A_SNIFFER_PROTOCOLS == auto ]]; then
        [[ -n $N2K_IFACE ]] && _snif_resolved="$N2K_IFACE"
        [[ -n $J1939_IFACE && ",${_snif_resolved}," != *",${J1939_IFACE},"* ]] \
          && _snif_resolved="${_snif_resolved:+${_snif_resolved},}${J1939_IFACE}"
      fi

      # SmartCraft/raw or a sniffer-only install can own one otherwise-unused
      # hardware channel. NMEA2000 sniff-only is fixed at 250k; other contexts
      # use the explicit sniffer bitrate.
      if [[ -z $_snif_resolved && ${#CAN_IFACES[@]} -gt 0 ]]; then
        for _si in "${CAN_IFACES[@]}"; do
          _owned=0
          for _spec in "${CAN_SPECS[@]}"; do [[ ${_spec%%:*} == "$_si" ]] && _owned=1; done
          if (( ! _owned )); then
            local _snbit="$A_SNIFFER_BITRATE"
            [[ $A_SNIFFER_PROTOCOLS == nmea2000 ]] && _snbit=250000
            CAN_SPECS+=("${_si}:${_snbit}")
            _snif_resolved="$_si"
            ok "CAN sniffer: assigned unused ${_si} @ ${_snbit} bit/s"
            break
          fi
        done
      fi

      # If all hardware channels are already configured but the requested
      # protocol was SmartCraft/raw, observing an existing configured bus is
      # still useful and remains passive.
      if [[ -z $_snif_resolved ]]; then
        [[ -n $N2K_IFACE ]] && _snif_resolved="$N2K_IFACE"
        [[ -n $J1939_IFACE && ",${_snif_resolved}," != *",${J1939_IFACE},"* ]] \
          && _snif_resolved="${_snif_resolved:+${_snif_resolved},}${J1939_IFACE}"
      fi
    fi

    [[ -n $_snif_resolved ]] || die "CAN sniffer enabled but no SocketCAN interface could be resolved. Select CAN hardware/role or set sniffer_ifaces explicitly."
    SNIFFER_IFACES="$_snif_resolved"
    UNITS+=(keelos-can-sniffer.service)
    ok "queued: passive CAN sniffer ${SNIFFER_IFACES} protocols=${A_SNIFFER_PROTOCOLS} rotating capture=${A_SNIFFER_MAX_MB}MB summary=${A_SNIFFER_SUMMARY}s"
  else
    skip "skipped: passive CAN sniffer / protocol discovery (not requested)"
  fi

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

  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 dbus kbd)
    case "$PKG_MGR" in
      apt)    PKGS+=(libpam-systemd) ;;
      dnf)    PKGS+=(systemd-pam) ;;
      pacman) PKGS+=(systemd) ;;
    esac
    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} engines=${A_ENGINE_PROFILES} propN2K=${A_N2K_PROPRIETARY} CAN-sniffer=${A_CAN_SNIFFER} SignalK=${A_SIGNALK} J1708=${A_J1708} RS422-mode=${A_RS422_MODE} MTU-RS422=${A_MTU_RS422} NMEA0183->N2K=${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}; profiles=${A_ENGINE_PROFILES}; map=${A_ENGINE_MAP:-auto}; raw-capture=${A_J1939_RAW_CAPTURE}"
  [[ $A_N2K_PROPRIETARY == y  ]] && ok "prop N2K:   ${N2K_IFACE} documented semantic Maretron compatibility + verified passive/status raw config"
  [[ $A_J1708 == y            ]] && ok "J1587->N2K: ${A_J1708_PORT} @ 9600 bit/s -> ${N2K_IFACE}, including standard diagnostic alarms"
  [[ $A_MTU_RS422 == y        ]] && ok "MTU RS422:   ${A_MTU_RS422_ADAPTER}/${A_MTU_RS422_CHANNEL} ${A_MTU_RS422_PORT} @ ${A_MTU_RS422_BAUD} ${A_MTU_RS422_DATABITS}${A_MTU_RS422_PARITY^^}${A_MTU_RS422_STOPBITS} -> ${N2K_IFACE} (ECS-5 point/alarm map loaded; serial framing pending)"
  [[ $A_0183 == y             ]] && ok "0183->N2K:   ${A_0183_ADAPTER}/${A_0183_CHANNEL} ${A_0183_PORT} @ ${A_0183_BAUD} 8N1 -> ${N2K_IFACE}"
  [[ $A_CAN_SNIFFER == y       ]] && ok "CAN sniff:   ${SNIFFER_IFACES} protocols=${A_SNIFFER_PROTOCOLS} log=/var/log/keelos-can-sniffer.ndjson"
  [[ ${#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"
    if [[ -n $N2K_IFACE ]]; then
      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", [])
providers[:] = [p for p in providers if not str(p.get("id", "")).startswith("keelos-n2k-")]
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
    fi
    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.1)
        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()
        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 and pos < len(data):
                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.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_nmea0183_bridge() {
  [[ $A_0183 == y ]] || return 0
  local dir="/usr/local/lib/keelos" bridge="/usr/local/lib/keelos/nmea0183_n2k.py"
  info "Installing NMEA 0183 / RS422 -> NMEA 2000 translator"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: write ${bridge}; syntax-check + NMEA 0183 conversion self-test${C_RESET}"
    say "${C_DIM}   dry-run: link /usr/local/bin/keelos-nmea0183d -> ${bridge}${C_RESET}"
    return 0
  fi
  mkdir -p "$dir" /usr/local/bin
  cat > "$bridge" <<'PY_NMEA0183_N2K'
#!/usr/bin/env python3
"""KeelOS NMEA 0183 / RS422 -> NMEA 2000 bridge.

Read-only on the serial/NMEA 0183 side. Parses a conservative set of common
navigation/environment sentences and publishes only fields with direct NMEA
2000 equivalents. Unknown sentences are ignored; bad checksums are rejected.
"""
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 pathlib import Path
from typing import Iterable, Optional, Tuple

CAN_EFF_FLAG = 0x80000000
CAN_FRAME = struct.Struct("=IB3x8s")

PGN_ADDRESS_CLAIM = 60928
PGN_VESSEL_HEADING = 127250
PGN_SPEED = 128259
PGN_WATER_DEPTH = 128267
PGN_POSITION_RAPID = 129025
PGN_COG_SOG = 129026
PGN_WIND = 130306
PGN_TEMPERATURE = 130312

SUPPORTED_BAUDS = (300,600,1200,2400,4800,9600,19200,38400,57600,115200,230400,460800,921600)
MAPPINGS = {
    "RMC": ["129025 Position, Rapid Update", "129026 COG & SOG, Rapid Update"],
    "GGA": ["129025 Position, Rapid Update"],
    "GLL": ["129025 Position, Rapid Update"],
    "VTG": ["129026 COG & SOG, Rapid Update"],
    "HDT": ["127250 Vessel Heading (true)"],
    "HDM": ["127250 Vessel Heading (magnetic)"],
    "HDG": ["127250 Vessel Heading (magnetic; deviation/variation when supplied)"],
    "VHW": ["127250 Vessel Heading", "128259 Speed (water referenced)"],
    "DPT": ["128267 Water Depth (depth + offset)"],
    "DBT": ["128267 Water Depth"],
    "MWV": ["130306 Wind Data (R=apparent, T=true boat-referenced)"],
    "MTW": ["130312 Temperature (sea temperature)"],
}


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

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)&1
    ps=(dst&0xff) if pf<240 else (pgn&0xff)
    return ((((priority&7)<<26)|(dp<<24)|(pf<<16)|(ps<<8)|(src&0xff)) | CAN_EFF_FLAG)

class RawCan:
    def __init__(self, iface: str):
        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("single-frame NMEA 2000 payload must be 8 bytes")
        self.sock.send(CAN_FRAME.pack(can_id,8,data))


def sysfs_attr_for_tty(dev: str, attr: str) -> Optional[str]:
    try:
        node=Path('/sys/class/tty')/Path(dev).name/'device'
        node=node.resolve()
        for parent in [node,*node.parents]:
            p=parent/attr
            if p.is_file():
                return p.read_text().strip()
    except Exception:
        return None
    return None

def waveshare_channel(dev: str) -> Optional[str]:
    vid=(sysfs_attr_for_tty(dev,'idVendor') or '').lower()
    pid=(sysfs_attr_for_tty(dev,'idProduct') or '').lower()
    ifnum=(sysfs_attr_for_tty(dev,'bInterfaceNumber') or '').upper().zfill(2)
    if (vid,pid)!=("0403","6011"):
        return None
    return {'00':'A','01':'B'}.get(ifnum)

def stable_serial_path(dev: str) -> str:
    real=os.path.realpath(dev)
    for p in glob.glob('/dev/serial/by-id/*'):
        try:
            if os.path.realpath(p)==real:
                return p
        except OSError:
            pass
    return dev

def detect_waveshare(channel: str='auto', exclude: Optional[str]=None) -> Optional[str]:
    wanted=channel.upper()
    ex=os.path.realpath(exclude) if exclude and exclude!='auto' else None
    rows=[]
    for dev in sorted(glob.glob('/dev/ttyUSB*')):
        ch=waveshare_channel(dev)
        if not ch: continue
        stable=stable_serial_path(dev)
        if ex and os.path.realpath(stable)==ex: continue
        rows.append((ch,stable))
    if wanted in ('A','B'):
        for ch,dev in rows:
            if ch==wanted: return dev
        return None
    return rows[0][1] if rows else None

def detect_generic(exclude: Optional[str]=None) -> Optional[str]:
    ex=os.path.realpath(exclude) if exclude and exclude!='auto' else None
    for pat in ('/dev/serial/by-id/*','/dev/ttyUSB*','/dev/ttyACM*','/dev/ttyAMA*','/dev/serial0'):
        for dev in sorted(glob.glob(pat)):
            if not os.path.exists(dev): continue
            if ex and os.path.realpath(dev)==ex: continue
            return dev
    return None

def resolve_port(port: str, adapter: str, channel: str, exclude: Optional[str]) -> str:
    if port!='auto': return port
    found=detect_waveshare(channel,exclude) if adapter=='waveshare4ch' else detect_generic(exclude)
    if not found:
        raise RuntimeError(f"no {adapter} RS422 serial input found (channel {channel})")
    return found


def configure_serial(fd: int, baud: int) -> None:
    if baud not in SUPPORTED_BAUDS:
        raise ValueError(f"unsupported baud {baud}")
    speed=getattr(termios,f'B{baud}',None)
    if speed is None:
        raise ValueError(f"termios does not expose B{baud} on this platform")
    a=termios.tcgetattr(fd)
    a[0]=0; a[1]=0
    a[2]=termios.CLOCAL|termios.CREAD|termios.CS8
    a[3]=0
    a[4]=speed; a[5]=speed
    a[6][termios.VMIN]=0; a[6][termios.VTIME]=1
    termios.tcsetattr(fd,termios.TCSANOW,a)
    termios.tcflush(fd,termios.TCIFLUSH)


def checksum_ok(line: str) -> bool:
    if not line.startswith(('$','!')): return False
    if '*' not in line: return True
    body,tail=line[1:].rsplit('*',1)
    if len(tail)<2: return False
    x=0
    for ch in body: x ^= ord(ch)
    try: expected=int(tail[:2],16)
    except ValueError: return False
    return x==expected

def split_sentence(line: str) -> Optional[Tuple[str,list[str]]]:
    line=line.strip()
    if not checksum_ok(line): return None
    body=line[1:].split('*',1)[0]
    parts=body.split(',')
    if not parts or len(parts[0])<3: return None
    # Accept any talker ID and use the final 3 chars as sentence type.
    return parts[0][-3:].upper(),parts[1:]

def fnum(s: str) -> Optional[float]:
    try: return float(s)
    except (TypeError,ValueError): return None

def latlon(value: str, hemi: str, is_lat: bool) -> Optional[float]:
    v=fnum(value)
    if v is None or not hemi: return None
    deg=int(v//100); mins=v-deg*100
    out=deg+mins/60.0
    if hemi.upper() in ('S','W'): out=-out
    if is_lat and not (-90<=out<=90): return None
    if not is_lat and not (-180<=out<=180): return None
    return out

def knots_to_mps(v: float) -> float: return v*0.5144444444444445

def kmh_to_mps(v: float) -> float: return v/3.6

def wind_to_mps(v: float, unit: str) -> Optional[float]:
    u=unit.upper()
    if u=='N': return knots_to_mps(v)
    if u=='K': return kmh_to_mps(v)
    if u=='M': return v
    return None

def signed_angle(value: str, ew: str) -> Optional[float]:
    v=fnum(value)
    if v is None: return None
    if ew.upper()=='W': v=-v
    elif ew.upper()!='E': return None
    return v

class Bridge:
    def __init__(self, iface: Optional[str], source: int=0x26, dry: bool=False):
        self.source=source
        self.can=None if dry else RawCan(iface or 'vcan0')
        self.sent:list[tuple[int,bytes]]=[]
        self.sid=0
        self.identity=self._identity()
    @staticmethod
    def _identity() -> int:
        seed='keelos-nmea0183'
        for p in ('/etc/machine-id','/var/lib/dbus/machine-id'):
            try:
                seed+=Path(p).read_text().strip(); break
            except OSError: pass
        return int.from_bytes(hashlib.sha256(seed.encode()).digest()[:4],'little') & 0x1fffff
    def next_sid(self) -> int:
        s=self.sid; self.sid=(self.sid+1)%253; return s
    def send(self,pgn:int,payload:bytes,priority:int) -> None:
        if len(payload)!=8: raise ValueError(f'PGN {pgn} payload length {len(payload)} != 8')
        self.sent.append((pgn,payload))
        if self.can: self.can.send(make_can_id(pgn,self.source,0xff,priority),payload)
    def claim(self) -> None:
        name=(self.identity | (130<<40) | (25<<49) | (4<<60) | (1<<63))
        self.send(PGN_ADDRESS_CLAIM,name.to_bytes(8,'little'),6)
    def emit_position(self,lat:float,lon:float) -> None:
        self.send(PGN_POSITION_RAPID,put_s32(round(lat/1e-7))+put_s32(round(lon/1e-7)),2)
    def emit_cog_sog(self,cog_deg:Optional[float],sog_mps:Optional[float],ref:int=0) -> None:
        if cog_deg is None and sog_mps is None: return
        cog=0xffff if cog_deg is None else int(round(clamp(math.radians(cog_deg%360)/0.0001,0,0xfffc)))
        sog=0xffff if sog_mps is None else int(round(clamp(sog_mps/0.01,0,0xfffc)))
        self.send(PGN_COG_SOG,bytes([self.next_sid(),0xfc|(ref&3)])+put_u16(cog)+put_u16(sog)+b'\xff\xff',2)
    def emit_heading(self,deg:float,ref:int,deviation_deg:Optional[float]=None,variation_deg:Optional[float]=None) -> None:
        heading=int(round(clamp(math.radians(deg%360)/0.0001,0,0xfffc)))
        def angle(v:Optional[float])->bytes:
            if v is None:return b'\xff\x7f'
            raw=int(round(clamp(math.radians(v)/0.0001,-32767,32764)))
            return put_s16(raw)
        self.send(PGN_VESSEL_HEADING,bytes([self.next_sid()])+put_u16(heading)+angle(deviation_deg)+angle(variation_deg)+bytes([0xfc|(ref&3)]),2)
    def emit_speed(self,water:Optional[float]=None,ground:Optional[float]=None) -> None:
        def val(v:Optional[float])->bytes:
            return b'\xff\xff' if v is None else put_u16(round(clamp(v/0.01,0,0xfffc)))
        self.send(PGN_SPEED,bytes([self.next_sid()])+val(water)+val(ground)+b'\xff\xff\xff',2)
    def emit_depth(self,depth_m:float,offset_m:Optional[float]=None) -> None:
        depth=round(clamp(depth_m/0.01,0,0xfffffffc))
        off=b'\xff\x7f' if offset_m is None else put_s16(round(clamp(offset_m/0.001,-32767,32764)))
        self.send(PGN_WATER_DEPTH,bytes([self.next_sid()])+put_u32(depth)+off+b'\xff',3)
    def emit_wind(self,speed_mps:float,angle_deg:float,reference:int) -> None:
        spd=round(clamp(speed_mps/0.01,0,0xfffc)); ang=round(clamp(math.radians(angle_deg%360)/0.0001,0,0xfffc))
        self.send(PGN_WIND,bytes([self.next_sid()])+put_u16(spd)+put_u16(ang)+bytes([0xf8|(reference&7),0xff,0xff]),2)
    def emit_sea_temp(self,celsius:float) -> None:
        kelvin=celsius+273.15
        raw=round(clamp(kelvin/0.01,0,0xfffc))
        self.send(PGN_TEMPERATURE,bytes([self.next_sid(),0,0])+put_u16(raw)+b'\xff\xff\xff',5)

    def process(self,line:str) -> int:
        parsed=split_sentence(line)
        if not parsed:return 0
        typ,f=parsed; before=len(self.sent)
        try:
            if typ=='RMC' and len(f)>=8 and f[1].upper()=='A':
                lat=latlon(f[2],f[3],True); lon=latlon(f[4],f[5],False)
                if lat is not None and lon is not None:self.emit_position(lat,lon)
                sog=fnum(f[6]); cog=fnum(f[7])
                self.emit_cog_sog(cog,knots_to_mps(sog) if sog is not None else None,0)
            elif typ=='GGA' and len(f)>=5:
                lat=latlon(f[1],f[2],True); lon=latlon(f[3],f[4],False)
                if lat is not None and lon is not None:self.emit_position(lat,lon)
            elif typ=='GLL' and len(f)>=4:
                lat=latlon(f[0],f[1],True); lon=latlon(f[2],f[3],False)
                status=f[5].upper() if len(f)>5 else 'A'
                if status=='A' and lat is not None and lon is not None:self.emit_position(lat,lon)
            elif typ=='VTG' and len(f)>=7:
                cog=fnum(f[0])
                sog_k=fnum(f[4]); sog_kmh=fnum(f[6])
                sog=knots_to_mps(sog_k) if sog_k is not None else (kmh_to_mps(sog_kmh) if sog_kmh is not None else None)
                self.emit_cog_sog(cog,sog,0)
            elif typ=='HDT' and f and fnum(f[0]) is not None:self.emit_heading(float(f[0]),0)
            elif typ=='HDM' and f and fnum(f[0]) is not None:self.emit_heading(float(f[0]),1)
            elif typ=='HDG' and f and fnum(f[0]) is not None:
                dev=signed_angle(f[1],f[2]) if len(f)>=3 and f[1] else None
                var=signed_angle(f[3],f[4]) if len(f)>=5 and f[3] else None
                self.emit_heading(float(f[0]),1,dev,var)
            elif typ=='VHW' and len(f)>=7:
                true=fnum(f[0]); mag=fnum(f[2]); kn=fnum(f[4]); kmh=fnum(f[6])
                if true is not None:self.emit_heading(true,0)
                elif mag is not None:self.emit_heading(mag,1)
                spd=knots_to_mps(kn) if kn is not None else (kmh_to_mps(kmh) if kmh is not None else None)
                if spd is not None:self.emit_speed(water=spd)
            elif typ=='DPT' and f and fnum(f[0]) is not None:
                self.emit_depth(float(f[0]),fnum(f[1]) if len(f)>1 else None)
            elif typ=='DBT':
                meters=fnum(f[2]) if len(f)>2 else None
                if meters is None:
                    feet=fnum(f[0]) if f else None
                    meters=feet*0.3048 if feet is not None else None
                if meters is not None:self.emit_depth(meters)
            elif typ=='MWV' and len(f)>=5 and f[4].upper()=='A':
                ang=fnum(f[0]); sp=fnum(f[2]); mps=wind_to_mps(sp,f[3]) if sp is not None else None
                ref=2 if f[1].upper()=='R' else (3 if f[1].upper()=='T' else None)
                if ang is not None and mps is not None and ref is not None:self.emit_wind(mps,ang,ref)
            elif typ=='MTW' and f and fnum(f[0]) is not None:
                # NMEA 0183 MTW is normally Celsius (field 2 = C). Reject other explicit units.
                if len(f)<2 or f[1].upper()=='C': self.emit_sea_temp(float(f[0]))
        except (ValueError,IndexError):
            return len(self.sent)-before
        return len(self.sent)-before


def self_test() -> None:
    b=Bridge(None,dry=True)
    # No-checksum examples make the tests readable; checksum validation is tested separately.
    assert b.process('$GPRMC,142218,A,2607.334,N,08006.912,W,18.3,087.2,080826,004.1,W')==2
    assert [p for p,_ in b.sent[-2:]]==[PGN_POSITION_RAPID,PGN_COG_SOG]
    assert b.process('$SDDPT,6.8,0.5')==1 and b.sent[-1][0]==PGN_WATER_DEPTH
    assert b.process('$WIMWV,034.0,R,12.0,N,A')==1 and b.sent[-1][0]==PGN_WIND
    assert b.process('$IIMTW,29.4,C')==1 and b.sent[-1][0]==PGN_TEMPERATURE
    assert b.process('$HCHDG,86.5,,,4.1,W')==1 and b.sent[-1][0]==PGN_VESSEL_HEADING
    assert b.process('$IIVHW,86.0,T,82.0,M,7.5,N,13.9,K')==2
    # Known checksum example from the common RMC test sentence.
    assert checksum_ok('$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A')
    assert not checksum_ok('$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*00')
    # Wire lengths are fixed at eight bytes.
    assert all(len(payload)==8 for _,payload in b.sent)
    print('nmea0183_n2k self-test: OK')


def run(args: argparse.Namespace) -> None:
    while True:
        try:
            port=resolve_port(args.port,args.adapter,args.channel,args.exclude_port)
            fd=os.open(port,os.O_RDONLY|os.O_NOCTTY|os.O_NONBLOCK)
            configure_serial(fd,args.baud)
            break
        except Exception as exc:
            logging.warning('NMEA 0183 RS422 input not ready: %s; retrying in 3s',exc)
            time.sleep(3)
    b=Bridge(args.n2k_iface,args.source)
    b.claim(); last_claim=time.monotonic(); buf=bytearray(); good=bad=emitted=0; last_health=time.monotonic()
    logging.info('NMEA 0183 RS422 -> NMEA 2000: %s @ %d 8N1 (%s/%s) -> %s source 0x%02X',port,args.baud,args.adapter,args.channel,args.n2k_iface,args.source)
    try:
        while True:
            if time.monotonic()-last_claim>=60:
                b.claim(); last_claim=time.monotonic()
            r,_,_=select.select([fd],[],[],0.25)
            if r:
                chunk=os.read(fd,4096)
                if chunk:
                    buf.extend(chunk)
                    if len(buf)>65536: del buf[:-8192]
                    while b'\n' in buf or b'\r' in buf:
                        # split at the earliest CR or LF; consume consecutive terminators
                        positions=[p for p in (buf.find(b'\r'),buf.find(b'\n')) if p>=0]
                        if not positions: break
                        p=min(positions); raw=bytes(buf[:p]); del buf[:p+1]
                        while buf[:1] in (b'\r',b'\n'): del buf[:1]
                        line=raw.decode('ascii','ignore').strip()
                        if not line: continue
                        if not checksum_ok(line): bad+=1; continue
                        good+=1; emitted+=b.process(line)
            if time.monotonic()-last_health>=30:
                logging.info('NMEA0183 health: valid=%d bad_checksum=%d emitted=%d',good,bad,emitted); last_health=time.monotonic()
    finally:
        os.close(fd)


def main(argv=None) -> int:
    p=argparse.ArgumentParser(description='KeelOS NMEA 0183 / RS422 -> NMEA 2000 bridge')
    p.add_argument('--port',default='auto')
    p.add_argument('--adapter',choices=('waveshare4ch','generic'),default='waveshare4ch')
    p.add_argument('--channel',choices=('auto','A','B'),default='auto')
    p.add_argument('--baud',type=int,default=4800)
    p.add_argument('--n2k-iface',default='can0')
    p.add_argument('--source',type=lambda x:int(x,0),default=0x26)
    p.add_argument('--exclude-port',default=None)
    p.add_argument('--list-mappings',action='store_true')
    p.add_argument('--self-test',action='store_true')
    p.add_argument('-v','--verbose',action='store_true')
    args=p.parse_args(argv)
    if args.baud not in SUPPORTED_BAUDS: p.error('unsupported baud')
    if args.list_mappings:
        print(json.dumps({'direction':'NMEA0183/RS422 -> NMEA2000','read_only_serial':True,'sentences':MAPPINGS,'source_address_default':'0x26'},indent=2)); return 0
    if args.self_test: self_test(); return 0
    logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,format='%(asctime)s %(levelname)s %(message)s')
    run(args); return 0

if __name__=='__main__': raise SystemExit(main())
PY_NMEA0183_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-nmea0183d
  record file "$bridge"
  record file /usr/local/bin/keelos-nmea0183d
  ok "NMEA 0183 / RS422 translator installed; self-test passed"
}

install_mtu_rs422_bridge() {
  [[ $A_MTU_RS422 == y ]] || return 0
  local dir="/usr/local/lib/keelos" bridge="/usr/local/lib/keelos/mtu_rs422.py"
  info "Installing MTU ECS-5 RS422 point/scaling/alarm map and capture service"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: write ${bridge}; syntax-check + MTU semantic self-test${C_RESET}"
    say "${C_DIM}   dry-run: link /usr/local/bin/keelos-mtu-rs422d -> ${bridge}${C_RESET}"
    return 0
  fi
  mkdir -p "$dir" /usr/local/bin
  cat > "$bridge" <<'PY_MTU_RS422'
#!/usr/bin/env python3
"""KeelOS MTU ECS-5 RS422 semantic bridge.

The supplied MTU Series 2000 ECS-5 document defines the contents, order,
engineering units and scaling of RS422 data blocks for Monitoring I and
Monitoring II.  Those point maps are implemented here, including the alarm
semantics that can be represented by standard NMEA 2000 engine/transmission
status fields.

Important: that MTU document explicitly points to separate document E 531 652
for the RS422 *serial data protocol*.  It does not define frame delimiters,
checksums, byte order or the binary encoding of a data block.  Therefore this
service remains read-only and capture-safe until E 531 652 (or a verified raw
capture plus framing specification) is supplied.  No raw-byte frame boundaries
are guessed.  The semantic decoder is complete so the future framer only needs
to hand it (profile, block number, ordered values).
"""
from __future__ import annotations

import argparse
import glob
import json
import logging
import os
import select
import termios
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple

SUPPORTED_BAUDS = (300, 600, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600)
PROFILES = ("monitoring1", "monitoring2")

# Standard NMEA 2000 targets used by the existing KeelOS bridges.
PGN_ENGINE_RAPID = 127488
PGN_ENGINE_DYNAMIC = 127489
PGN_TRANSMISSION_DYNAMIC = 127493
PGN_TEMPERATURE = 130312
PGN_TEMPERATURE_EXTENDED = 130316

# 127489 Engine Status 1/2 bits (same definitions used by j1587_n2k.py).
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_HIGH_BOOST = 1 << 11
ES1_REV_LIMIT = 1 << 12
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

# 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


@dataclass(frozen=True)
class Point:
    pv: str
    name: str
    unit: str = ""
    scale: float = 1.0
    n2k: Optional[str] = None

    def physical(self, raw: float) -> float:
        return float(raw) / float(self.scale)


def P(pv: str, name: str, unit: str = "", scale: float = 1.0, n2k: Optional[str] = None) -> Point:
    return Point(pv, name, unit, scale, n2k)


# Common Monitoring-I base.  Monitoring II extends selected blocks below.
B2 = {
  1:P("1X0009","AL Autom. Power Reduct. Active"), 2:P("1X0058","HI ETC1 Speed"),
  3:P("1X0070","SS ETC1 Overspeed"), 4:P("1X0029","LO P-Lube Oil (ECU)"),
  5:P("1X0030","SS P-Lube Oil (ECU)"), 6:P("1X0047","LO P-Fuel"),
  7:P("1X0048","SS P-Fuel"), 8:P("1X0050","LO P-Charge Air"),
  9:P("1X0143","HI T-Lube Oil"), 10:P("1X0144","SS T-Lube Oil"),
  11:P("1X0129","HI T-Coolant (ECU)"), 12:P("1X0130","SS T-Coolant (ECU)"),
  13:P("1X0133","HI T-Charge Air"), 14:P("1X0168","SS T-Charge Air"),
  15:P("1X0170","HI T-ECU"), 16:P("1X0055","LO Coolant Level"),
  17:P("1X0056","HI Leak Fuel Level"), 18:P("1X0122","LO ECU Power Supply"),
  19:P("1X0123","HI ECU Power Supply"), 20:P("1X0101","AL Power Ampl. Bank 1 Failure"),
  21:P("1X0102","AL Power Ampl. Bank 2 Failure"), 22:P("1X0103","AL Status Transistor Outputs"),
  23:P("1X0116","AL ECU Defect"), 24:P("1X0118","AL Speed Demand Failure"),
  25:P("1X0039","LO P-Coolant"), 26:P("1X0040","SS P-Coolant"),
  27:P("1X0054","HI P-Oil Filter Difference"), 28:P("1X0147","HI T-Exhaust Comb. A bef. ETC"),
  29:P("1X0296","SS T-Exhaust Comb. A bef. ETC"), 30:P("1X0150","HI T-Exhaust Comb. B bef. ETC"),
  31:P("1X0297","SS T-Exhaust Comb. B bef. ETC"), 32:P("1X0299","HI T-Fuel"),
  33:P("1X0177","SS Engine Speed too Low"), 34:P("1X0178","AL ECU Failure/See Fault Code"),
}
B2_M2 = {
  35:P("1X2029","LO P-Lube Oil (EMU)"), 36:P("1X2030","SS P-Lube Oil (EMU)"),
  37:P("1X2519","TD P-Lube Oil"), 38:P("1X2503","LO P-Raw Water"),
  39:P("1X2506","LO P-Oil Re-Fill Pump"), 40:P("1X2129","HI T-Coolant (EMU)"),
  41:P("1X2130","SS T-Coolant (EMU)"), 42:P("1X2518","TD T-Coolant"),
  43:P("1X2507","AL Transistor Output 1/2 Fail."), 44:P("1X2508","AL Transistor Output 3/4 Fail."),
  45:P("1X2510","AL Request Power Reduction"), 46:P("1X2513","AL Press.Monitoring Fail.(EMU)"),
  47:P("1X2516","SS Security Channel Defect EMU"),
}

B3 = {
  1:P("1X4002","SS Overspeed (ECU)"), 2:P("1X4003","SS Overspeed (EMU)"),
  3:P("1X4004","SS Emergency Stop External"), 4:P("1X4005","SS Safety System Stop"),
  5:P("1X4006","SS SDAF closed"), 6:P("1X4012","SS Emergency Stop"),
  7:P("1X4008","AL Power Fail. Monitoring"), 8:P("1X4219","MG Check Start Interlocks"),
  9:P("2X0004","LO P-Gear Control Oil (GCU)"), 10:P("2X0005","SS P-Gear Control Oil (GCU)"),
  11:P("2X0009","LO P-Gear Lube Oil (GCU)"), 12:P("2X0010","SS P-Gear Lube Oil (GCU)"),
  13:P("2X0013","HI T-Gear Oil"), 14:P("2X0015","AL Gear Oil Filter Clogged"),
  15:P("2X0147","AL External Clutch Interlock"), 16:P("2X0148","AL Clutch Locked By Shaft Sp."),
  17:P("2X0150","MG Speed High/Low (Clutch)"), 18:P("2X0152","MG Engage Error"),
  19:P("2X0153","MG Disengage Error"), 20:P("8X3117","AL RCS Combined Alarm"),
}

B4 = {
  1:P("1X6003","LO P-Start Air"), 2:P("1X6007","HI P-Exhaust Back"),
  3:P("1X6020","SS Safety System Fail. (EMU)"), 4:P("1X6011","AL Safety System Override"),
  5:P("1X6021","AL Gear Ctrl. Fail. (GCU)"), 6:P("1X6023","AL Water in Fuel Prefilter"),
  7:P("1X6024","AL Oil in Coolant"), 8:P("1X6025","AL Battery Charger Failure"),
  9:P("1X6028","AL Glow Plug Failure"), 10:P("1X6029","AL Pump Failure"),
  11:P("1X6030","AL Power Failure Control"),
}
B4_M2 = {
  12:P("2X2004","LO P-Gear Control Oil (GMU)"), 13:P("2X2005","SS P-Gear Control Oil (GMU)"),
  14:P("2X2009","LO P-Gear Lube Oil (GMU)"), 15:P("2X2010","SS P-Gear Lube Oil (GMU)"),
  16:P("2X2016","LO Gear Oil Level"),
}

B5 = {
  1:P("1X0068","Engine Running"), 2:P("1X0069","ETC2 Cut In"),
  3:P("1X0074","Cylinder Cutout"), 4:P("1X0290","Coolant Level Switch"),
  5:P("1X0291","Fuel Leakage Level Switch"), 6:P("1X4010","Feedback Start"),
  7:P("1X4011","Feedback Stop"), 8:P("1X4211","Ready For Operation"),
  9:P("1X4212","Local Operation"), 10:P("1X4312","Remote Operation"),
  11:P("2X0113","Neutral/Out", n2k="127493.gear.neutral"),
  12:P("2X0114","Ahead/In", n2k="127493.gear.forward"),
  13:P("2X0115","Astern", n2k="127493.gear.reverse"),
  14:P("2X0131","Trolling Active"),
}

B6 = {
  1:P("1X0002","Engine Speed (ECU)","rpm",10, "127488.engine_speed"),
  2:P("1X0162","Engine Speed Limit SS","rpm",10),
  3:P("1X0057","ETC 1 Speed","krpm",10000), 4:P("1X0160","ETC 1 Speed Limit HI","krpm",10000),
  5:P("1X0172","ETC1 Speed Limit Value SS","krpm",10000), 6:P("1X0216","Injection in Relation to DBR","%",1000),
  7:P("1X0026","P-Lube Oil (ECU)","bar",100000, "127489.oil_pressure"),
  8:P("1X0027","P-Lube Oil Limit LO (ECU)","bar",100000), 9:P("1X0028","P-Lube Oil Limit SS (ECU)","bar",100000),
  10:P("1X0046","P-Fuel","bar",100000, "127489.fuel_pressure"),
  11:P("1X0158","P-Fuel Limit LO","bar",100000), 12:P("1X0159","P-Fuel Limit SS","bar",100000),
  13:P("1X0049","P-Charge Air","bar",100000, "127488.boost_pressure"), 14:P("1X0163","P-Charge Air Limit LO","bar",100000),
  15:P("1X0126","T-Coolant (ECU)","degC",100, "127489.coolant_temperature"),
  16:P("1X0127","T-Coolant Limit HI (ECU)","degC",100), 17:P("1X0128","T-Coolant Limit SS (ECU)","degC",100),
  18:P("1X0140","T-Lube Oil","degC",100, "127489.oil_temperature"),
  19:P("1X0141","T-Lube Oil Limit HI","degC",100), 20:P("1X0142","T-Lube Oil Limit SS","degC",100),
  21:P("1X0131","T-Charge Air","degC",100), 22:P("1X0132","T-Charge Air Limit HI","degC",100),
  23:P("1X0167","T-Charge Air Limit SS","degC",100), 24:P("1X0151","T-Intake Air","degC",100),
  25:P("1X0152","T-Fuel","degC",100), 26:P("1X0298","L1H T-Fuel","degC",100),
  27:P("1X0075","ECU Failure Codes","digit",1), 28:P("1X0115","ECU Operating Hours Counter","h",1, "127489.engine_hours"),
  29:P("1X0036","P-Coolant","bar",100000, "127489.coolant_pressure"),
  30:P("1X0037","P-Coolant Limit LO","bar",100000), 31:P("1X0038","P-Coolant Limit SS","bar",100000),
  32:P("1X0145","T-Exhaust A before ETC","degC",100, "130316.exhaust_temperature.instance0"),
  33:P("1X0146","T-Exhaust A bef. ETC Limit HI","degC",100), 34:P("1X0261","T-Exhaust A bef. ETC Limit SS","degC",100),
  35:P("1X0148","T-Exhaust B before ETC","degC",100, "130316.exhaust_temperature.instance1"),
  36:P("1X0149","T-Exhaust B bef. ETC Limit HI","degC",100), 37:P("1X0262","T-Exhaust B bef. ETC Limit SS","degC",100),
}
B6_M2 = {
  38:P("1X2002","Engine Speed (EMU)","rpm",10, "fallback.127488.engine_speed"),
  39:P("1X2162","Engine Speed Limit SS (EMU)","rpm",10),
  40:P("1X2026","P-Lube Oil (EMU)","bar",100000, "fallback.127489.oil_pressure"),
  41:P("1X2027","P-Lube Oil Limit LO (EMU)","bar",100000), 42:P("1X2028","P-Lube Oil Limit SS (EMU)","bar",100000),
  43:P("1X2501","P-Raw Water","bar",100000), 44:P("1X2502","P-Raw Water Limit LO","bar",100000),
  45:P("1X2504","P-Oil Re-Fill Pump","bar",100000), 46:P("1X2505","P-Oil RE-Fill Pump Limit LO","bar",100000),
  47:P("1X2126","T-Coolant (EMU)","degC",100, "fallback.127489.coolant_temperature"),
  48:P("1X2127","T-Coolant Limit HI (EMU)","degC",100), 49:P("1X2128","T-Coolant Limit HIHI SS (EMU)","degC",100),
}

B7 = {
  1:P("1X6001","P-Start Air","bar",100000), 2:P("1X6002","P-Start Air Limit LO","bar",100000),
  3:P("1X6005","P-Exhaust Back","bar",100000), 4:P("1X6006","P-Exhaust Back Limit HI","bar",100000),
  5:P("2X0001","P-Gear Control Oil (GCU)","bar",100000, "fallback.127493.oil_pressure"),
  6:P("2X0002","P-Gear Control Oil Limit LO (GCU)","bar",100000), 7:P("2X0003","P-Gear Control Oil Limit SS (GCU)","bar",100000),
  8:P("2X0006","P-Gear Lube Oil (GCU)","bar",100000, "127493.oil_pressure"),
  9:P("2X0007","P-Gear Lube Oil Limit LO (GCU)","bar",100000), 10:P("2X0008","P-Gear Lube Oil Lim. SS (GCU)","bar",100000),
  11:P("2X0011","T-Gear Oil","degC",100, "127493.oil_temperature"), 12:P("2X0012","T-Gear Oil Limit HI","degC",100),
}
B7_M2 = {
  13:P("2X2001","P-Gear Control Oil (GMU)","bar",100000, "fallback.127493.oil_pressure"),
  14:P("2X2002","P-Gear Control Oil LO (GMU)","bar",100000), 15:P("2X2003","P-Gear Control Oil SS (GMU)","bar",100000),
  16:P("2X2006","P-Gear Lube Oil (GMU)","bar",100000, "fallback.127493.oil_pressure"),
  17:P("2X2007","P-Gear Lube Oil LO (GMU)","bar",100000), 18:P("2X2008","P-Gear Lube Oil SS (GMU)","bar",100000),
  19:P("2X2014","Shaft Speed 1","rpm",10), 20:P("2X2015","Shaft Speed 2","rpm",10),
}


def profile_blocks(profile: str) -> Dict[int, Dict[int, Point]]:
    if profile not in PROFILES:
        raise ValueError(f"unknown MTU profile {profile}")
    blocks = {2:dict(B2), 3:dict(B3), 4:dict(B4), 5:dict(B5), 6:dict(B6), 7:dict(B7)}
    if profile == "monitoring2":
        blocks[2].update(B2_M2); blocks[4].update(B4_M2); blocks[6].update(B6_M2); blocks[7].update(B7_M2)
    return blocks


# Alarm map returns (engine_status1, engine_status2, transmission_status1).
# Generic faults keep CHECK_ENGINE/CHECK_TRANSMISSION rather than inventing a
# narrower NMEA alarm that the MTU designation does not actually guarantee.
def alarm_bits(block: int, index: int) -> Tuple[int, int, int]:
    e1=e2=t1=0
    def engine_warn(level2=False):
        nonlocal e1,e2
        e1 |= ES1_CHECK_ENGINE
        e2 |= ES2_WARNING_LEVEL_2 if level2 else ES2_WARNING_LEVEL_1
    def trans_warn():
        nonlocal t1
        t1 |= TS1_CHECK_TRANSMISSION

    if block == 2:
        if index in (1,45): e2 |= ES2_POWER_REDUCTION
        if index in (4,5,35,36): e1 |= ES1_CHECK_ENGINE|ES1_LOW_OIL_PRESSURE; e2 |= ES2_WARNING_LEVEL_2 if index in (5,36) else ES2_WARNING_LEVEL_1
        elif index in (6,7): e1 |= ES1_CHECK_ENGINE|ES1_LOW_FUEL_PRESSURE; e2 |= ES2_WARNING_LEVEL_2 if index==7 else ES2_WARNING_LEVEL_1
        elif index in (9,10,11,12,13,14,15,28,29,30,31,40,41): e1 |= ES1_CHECK_ENGINE|ES1_OVER_TEMP; e2 |= ES2_WARNING_LEVEL_2 if index in (10,12,14,29,31,41) else ES2_WARNING_LEVEL_1
        elif index == 16: e1 |= ES1_CHECK_ENGINE|ES1_LOW_COOLANT_LEVEL; e2 |= ES2_WARNING_LEVEL_1
        elif index == 18: e1 |= ES1_CHECK_ENGINE|ES1_LOW_SYSTEM_VOLTAGE; e2 |= ES2_WARNING_LEVEL_1
        elif index in (23,24,34,46,47): e1 |= ES1_CHECK_ENGINE; e2 |= ES2_ENGINE_COMM_ERROR|ES2_WARNING_LEVEL_1
        else: engine_warn(index in (3,26,33))
    elif block == 3:
        if index in (1,2): e1 |= ES1_CHECK_ENGINE|ES1_REV_LIMIT; e2 |= ES2_WARNING_LEVEL_2
        elif index in (3,4,5,6): e1 |= ES1_CHECK_ENGINE; e2 |= ES2_WARNING_LEVEL_2|ES2_ENGINE_SHUTTING_DOWN
        elif index in (9,10,11,12): t1 |= TS1_CHECK_TRANSMISSION|TS1_LOW_OIL_PRESSURE
        elif index == 13: t1 |= TS1_CHECK_TRANSMISSION|TS1_OVER_TEMP
        elif index in (14,15,16,17,18,19): trans_warn()
        elif index == 7: e1 |= ES1_CHECK_ENGINE|ES1_LOW_SYSTEM_VOLTAGE; e2 |= ES2_WARNING_LEVEL_1
        else: engine_warn(False)
    elif block == 4:
        if index == 5: trans_warn()
        elif index == 6: e1 |= ES1_CHECK_ENGINE|ES1_WATER_IN_FUEL; e2 |= ES2_WARNING_LEVEL_1
        elif index == 8: e1 |= ES1_CHECK_ENGINE|ES1_CHARGE_INDICATOR; e2 |= ES2_WARNING_LEVEL_1
        elif index in (11,): e1 |= ES1_CHECK_ENGINE|ES1_LOW_SYSTEM_VOLTAGE; e2 |= ES2_WARNING_LEVEL_1
        elif index in (12,13,14,15): t1 |= TS1_CHECK_TRANSMISSION|TS1_LOW_OIL_PRESSURE
        elif index == 16: t1 |= TS1_CHECK_TRANSMISSION|TS1_LOW_OIL_LEVEL
        else: engine_warn(index == 3)
    return e1,e2,t1


def decode_point(profile: str, block: int, index: int, raw: float) -> Dict[str, object]:
    points = profile_blocks(profile).get(block, {})
    p = points.get(index)
    if not p:
        raise KeyError(f"{profile} block {block} index {index} is not defined")
    out: Dict[str, object] = {"profile":profile,"block":block,"index":index,"pv":p.pv,"name":p.name,"raw":raw}
    if block in (6,7):
        out["physical"] = p.physical(raw); out["unit"] = p.unit
    else:
        out["active"] = bool(raw)
    if p.n2k: out["n2k"] = p.n2k
    if block in (2,3,4) and bool(raw):
        e1,e2,t1 = alarm_bits(block,index)
        out["alarm_bits"] = {"127489_status1":e1,"127489_status2":e2,"127493_status1":t1}
    return out


def mapped_points(profile: str) -> List[Dict[str, object]]:
    out=[]
    for block, points in profile_blocks(profile).items():
        for idx,p in sorted(points.items()):
            if p.n2k or block in (2,3,4):
                row={"block":block,"index":idx,"pv":p.pv,"name":p.name}
                if p.unit: row.update(unit=p.unit,scale=p.scale)
                if p.n2k: row["n2k"]=p.n2k
                if block in (2,3,4):
                    e1,e2,t1=alarm_bits(block,idx); row["alarm_bits"]={"127489_status1":e1,"127489_status2":e2,"127493_status1":t1}
                out.append(row)
    return out

MTU_PGN_MAPPINGS = {
    "127488": ["Engine speed", "Charge/boost air pressure"],
    "127489": ["Lube-oil pressure/temperature", "Coolant temperature/pressure", "Fuel pressure", "Engine hours", "engine alarms"],
    "127493": ["Ahead/neutral/astern", "Gear lube-oil pressure", "Gear oil temperature", "transmission alarms"],
    "130316": ["Exhaust temperature bank A/B (extended range; 0.001 C)"],
}


def _speed_constant(baud: int) -> int:
    name=f"B{baud}"
    if not hasattr(termios,name): raise ValueError(f"platform termios does not expose {name}")
    return int(getattr(termios,name))


def _same_device(a: str,b: str)->bool:
    try: return os.path.realpath(a)==os.path.realpath(b)
    except OSError: return a==b


def _usb_attrs(port: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
    """Return (vid,pid,interface-number) from sysfs for a tty device."""
    name=os.path.basename(os.path.realpath(port))
    node=Path(f"/sys/class/tty/{name}/device")
    try: node=node.resolve()
    except OSError: return None,None,None
    vid=pid=ifnum=None
    for parent in (node, *node.parents):
        try:
            if ifnum is None and (parent/"bInterfaceNumber").is_file(): ifnum=(parent/"bInterfaceNumber").read_text().strip().upper()
            if vid is None and (parent/"idVendor").is_file(): vid=(parent/"idVendor").read_text().strip().lower()
            if pid is None and (parent/"idProduct").is_file(): pid=(parent/"idProduct").read_text().strip().lower()
        except OSError: pass
        if vid and pid and ifnum: break
    return vid,pid,ifnum


def _waveshare_channel(port: str) -> Optional[str]:
    # Waveshare USB TO 4CH RS485/422 uses FT4232HL. FTDI's default identity is
    # 0403:6011; interfaces 00/01 correspond to channels A/B. On this Waveshare
    # model only A/B expose RS422, while C/D are RS485-only.
    vid,pid,ifnum=_usb_attrs(port)
    if (vid,pid)!=("0403","6011"): return None
    return {"00":"A","01":"B"}.get(ifnum or "")


def candidate_ports(adapter: str="generic", channel: str="auto")->Iterable[str]:
    seen=set(); rows=[]
    for pattern in ("/dev/serial/by-id/*","/dev/ttyUSB*","/dev/ttyACM*"):
        for port in sorted(glob.glob(pattern)):
            real=os.path.realpath(port)
            if real in seen: continue
            seen.add(real)
            if adapter=="waveshare4ch":
                ch=_waveshare_channel(port)
                if ch is None: continue
                if channel.upper() not in ("AUTO",ch): continue
                rows.append((0 if ch=="A" else 1,port))
            else:
                rows.append((9,port))
    for _,port in sorted(rows,key=lambda x:(x[0],x[1])): yield port


def auto_port(excludes: Iterable[str]=(),adapter: str="generic",channel: str="auto")->str:
    excluded=[x for x in excludes if x and x!="auto"]
    for port in candidate_ports(adapter,channel):
        if any(_same_device(port,x) for x in excluded): continue
        return port
    if adapter=="waveshare4ch":
        raise RuntimeError(f"Waveshare USB TO 4CH RS485/422 RS422 channel {channel} not found; only ports A/B are valid")
    raise RuntimeError("no USB RS422 serial adapter found")


def open_serial(port:str,baud:int,databits:int,parity:str,stopbits:int)->int:
    if baud not in SUPPORTED_BAUDS: raise ValueError(f"unsupported baud {baud}")
    if databits not in (7,8): raise ValueError("data bits must be 7 or 8")
    parity=parity.lower()
    if parity not in ("n","e","o"): raise ValueError("parity must be n/e/o")
    if stopbits not in (1,2): raise ValueError("stop bits must be 1 or 2")
    fd=os.open(port,os.O_RDONLY|os.O_NOCTTY|os.O_NONBLOCK)
    attrs=termios.tcgetattr(fd); attrs[0]=0; attrs[1]=0
    cflag=termios.CLOCAL|termios.CREAD|(termios.CS8 if databits==8 else termios.CS7)
    if parity!="n":
        cflag|=termios.PARENB
        if parity=="o": cflag|=termios.PARODD
    if stopbits==2: cflag|=termios.CSTOPB
    attrs[2]=cflag; attrs[3]=0; speed=_speed_constant(baud); attrs[4]=speed; attrs[5]=speed
    attrs[6][termios.VMIN]=0; attrs[6][termios.VTIME]=1
    termios.tcsetattr(fd,termios.TCSANOW,attrs); termios.tcflush(fd,termios.TCIFLUSH)
    return fd


class BoundedCapture:
    def __init__(self,path:Optional[str],max_bytes:int):
        self.path=Path(path) if path else None; self.max_bytes=max(65536,int(max_bytes)); self.written=0; self.fp=None
        if self.path:
            self.path.parent.mkdir(parents=True,exist_ok=True); self.fp=self.path.open("ab",buffering=0)
            try:self.written=self.path.stat().st_size
            except OSError:self.written=0
    def write(self,data:bytes)->None:
        if not self.fp or not data:return
        if self.written+len(data)>self.max_bytes:
            self.fp.close(); self.fp=self.path.open("wb",buffering=0); self.written=0
        self.fp.write(data); self.written+=len(data)
    def close(self)->None:
        if self.fp:self.fp.close(); self.fp=None


class MtuRs422Transport:
    def __init__(self,args):
        self.args=args; self.total=0; self.last_report=time.monotonic(); self.capture=BoundedCapture(args.capture_file,args.capture_max_bytes)
    def on_bytes(self,data:bytes)->None:
        self.total+=len(data); self.capture.write(data); logging.debug("MTU RS422 rx %d bytes: %s",len(data),data.hex())
        # E 531 652 framing hook intentionally remains the only missing layer:
        # for block_no, ordered_values in mtu_e531652_framer.feed(data):
        #     decoded=[decode_point(self.args.profile,block_no,i+1,v) for i,v in enumerate(ordered_values)]
        #     emit the decoded NMEA targets using the standard KeelOS emitters.
    def run(self)->None:
        excludes=self.args.exclude_port or []; port=auto_port(excludes,self.args.adapter,self.args.channel) if self.args.port=="auto" else self.args.port
        logging.info("MTU ECS-5 RS422: adapter=%s channel=%s port=%s baud=%d format=%d%s%d profile=%s n2k=%s; point map loaded, E531652 framing pending",self.args.adapter,self.args.channel,port,self.args.baud,self.args.data_bits,self.args.parity.upper(),self.args.stop_bits,self.args.profile,self.args.n2k_iface)
        fd=open_serial(port,self.args.baud,self.args.data_bits,self.args.parity,self.args.stop_bits)
        try:
            while True:
                ready,_,_=select.select([fd],[],[],1.0)
                if ready:
                    try:data=os.read(fd,4096)
                    except BlockingIOError:data=b""
                    if data:self.on_bytes(data)
                now=time.monotonic()
                if now-self.last_report>=30.0:
                    logging.info("MTU RS422 healthy: %d bytes captured; ECS-5 semantic map active, framing pending",self.total); self.last_report=now
        finally:
            os.close(fd); self.capture.close()


def self_test()->None:
    assert _same_device("/dev/null","/dev/null")
    assert len(profile_blocks("monitoring1")[6])==37
    assert len(profile_blocks("monitoring2")[6])==49
    rpm=decode_point("monitoring1",6,1,20000); assert rpm["physical"]==2000.0 and rpm["n2k"]=="127488.engine_speed"
    oil=decode_point("monitoring1",6,7,250000); assert abs(oil["physical"]-2.5)<1e-9 and oil["n2k"]=="127489.oil_pressure"
    low_oil=decode_point("monitoring1",2,4,1); assert low_oil["alarm_bits"]["127489_status1"] & ES1_LOW_OIL_PRESSURE
    over=decode_point("monitoring1",3,1,1); assert over["alarm_bits"]["127489_status1"] & ES1_REV_LIMIT
    water=decode_point("monitoring1",4,6,1); assert water["alarm_bits"]["127489_status1"] & ES1_WATER_IN_FUEL
    reduct=decode_point("monitoring2",2,45,1); assert reduct["alarm_bits"]["127489_status2"] & ES2_POWER_REDUCTION
    ahead=decode_point("monitoring1",5,12,1); assert ahead["n2k"]=="127493.gear.forward"
    try: decode_point("monitoring1",6,40,1)
    except KeyError: pass
    else: raise AssertionError("Monitoring I unexpectedly contains Monitoring II EMU point")
    print("mtu_rs422 ECS-5 semantic-map self-test: OK")


def parse_args(argv=None):
    p=argparse.ArgumentParser(description="KeelOS MTU ECS-5 RS422 semantic bridge")
    p.add_argument("--port",default="auto"); p.add_argument("--adapter",default="waveshare4ch",choices=("waveshare4ch","generic")); p.add_argument("--channel",default="auto",choices=("auto","A","B")); p.add_argument("--baud",type=int,required=True,choices=SUPPORTED_BAUDS)
    p.add_argument("--data-bits",type=int,default=8,choices=(7,8)); p.add_argument("--parity",default="n",choices=("n","e","o")); p.add_argument("--stop-bits",type=int,default=1,choices=(1,2))
    p.add_argument("--profile",default="monitoring1",choices=PROFILES); p.add_argument("--n2k-iface",default="vcan0")
    p.add_argument("--exclude-port",action="append",default=[]); p.add_argument("--capture-file",default=None); p.add_argument("--capture-max-bytes",type=int,default=8*1024*1024)
    p.add_argument("--decode-point",metavar="BLOCK:INDEX:RAW",help="decode one ECS-5 table point using --profile")
    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.list_mappings:
        print(json.dumps({"direction":"MTU ECS-5 RS422 -> NMEA 2000","transport":"read-only/capture implemented","gateway":{"recommended":"Waveshare USB TO 4CH RS485/422","chip":"FT4232HL","linux_autodetect":"0403:6011 interfaces A/B only","rs422_ports":["A","B"],"ports_c_d":"RS485 only"},"profiles":{p:{"block_counts":{str(b):len(v) for b,v in profile_blocks(p).items()},"mapped":mapped_points(p)} for p in PROFILES},"nmea2000_pgns":MTU_PGN_MAPPINGS,"framing_status":"E 531 652 serial framing/checksum/encoding specification still required; raw byte boundaries are not guessed"},indent=2,sort_keys=True)); return 0
    if args.decode_point:
        b,i,r=args.decode_point.split(":",2); print(json.dumps(decode_point(args.profile,int(b),int(i),float(r)),indent=2,sort_keys=True)); return 0
    if args.self_test:
        _speed_constant(args.baud); self_test(); return 0
    logging.basicConfig(level=getattr(logging,args.log_level),format="%(asctime)s %(levelname)s %(message)s")
    try:MtuRs422Transport(args).run()
    except KeyboardInterrupt:return 0
    except (OSError,RuntimeError,ValueError) as exc:logging.error("MTU RS422 transport failed: %s",exc); return 1
    return 0


if __name__=="__main__": raise SystemExit(main())
PY_MTU_RS422
  chmod 0755 "$bridge"
  python3 -m py_compile "$bridge"
  python3 "$bridge" --baud "$A_MTU_RS422_BAUD" --profile "$A_MTU_RS422_PROFILE" --self-test >/dev/null
  python3 "$bridge" --baud "$A_MTU_RS422_BAUD" --profile "$A_MTU_RS422_PROFILE" --list-mappings >/dev/null
  ln -sfn "$bridge" /usr/local/bin/keelos-mtu-rs422d
  record file "$bridge"
  record file /usr/local/bin/keelos-mtu-rs422d
  ok "MTU ECS-5 Monitoring I/II point, scaling, gear and alarm maps passed self-tests; E 531 652 serial framing is the remaining decoder layer"
}

# Smart passive CAN sniffer — read-only protocol-aware SocketCAN capture
#--------------------------------------------------------------------------
install_can_sniffer() {
  [[ $A_CAN_SNIFFER == y ]] || return 0
  info "Installing passive protocol-aware CAN sniffer"
  local d="/usr/local/lib/keelos"
  local py="${d}/can_sniffer.py"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: install ${py} (NMEA2000/J1939/SmartCraft/raw classifier + rotating NDJSON capture)${C_RESET}"
    return 0
  fi
  mkdir -p "$d"
  cat > "$py" <<'PY'
#!/usr/bin/env python3
"""KeelOS passive SocketCAN sniffer.

Read-only by design: opens CAN_RAW sockets only for reception and never calls
send()/sendto().  The selected protocol is context supplied by the operator;
AUTO classification is conservative and leaves ambiguous proprietary traffic
as UNKNOWN/RAW instead of inventing semantics.
"""
from __future__ import annotations
import argparse, collections, json, logging, os, select, signal, socket, struct, sys, time

CAN_EFF_FLAG = 0x80000000
CAN_RTR_FLAG = 0x40000000
CAN_ERR_FLAG = 0x20000000
CAN_EFF_MASK = 0x1FFFFFFF
CAN_SFF_MASK = 0x7FF
SOL_CAN_RAW = 101
CAN_RAW_FD_FRAMES = 5

N2K_NAMES = {
    59392:"ISO Acknowledgement",59904:"ISO Request",60928:"ISO Address Claim",
    126208:"NMEA Group Function",126464:"PGN List",126720:"Manufacturer Proprietary",
    126992:"System Time",126993:"Heartbeat",126996:"Product Information",126998:"Configuration Information",
    127245:"Rudder",127250:"Vessel Heading",127251:"Rate of Turn",127257:"Attitude",
    127258:"Magnetic Variation",127488:"Engine Parameters, Rapid Update",
    127489:"Engine Parameters, Dynamic",127493:"Transmission Parameters, Dynamic",
    127497:"Trip Parameters, Engine",127498:"Engine Parameters, Static",
    127505:"Fluid Level",127508:"Battery Status",128259:"Speed",128267:"Water Depth",
    128275:"Distance Log",129025:"Position, Rapid Update",129026:"COG & SOG, Rapid Update",
    129029:"GNSS Position Data",129033:"Time & Date",130306:"Wind Data",
    130312:"Temperature",130314:"Actual Pressure",130316:"Temperature, Extended Range",
    130823:"Maretron Temperature, High Range",130840:"Maretron Generic Sensor",
    65284:"Maretron DC Relay Current",65286:"Maretron Fluid Flow Rate",65287:"Maretron Trip Volume",
}
J1939_NAMES = {
    60416:"TP.CM",60160:"TP.DT",61443:"EEC2",61444:"EEC1",61445:"ETC2",
    64775:"Aftertreatment / indicator family",64891:"Aftertreatment status",64892:"DPF control/status",
    65030:"Generator Average Basic AC Quantities",65031:"Engine Exhaust Temperature",
    65110:"DEF / SCR family",65172:"Auxiliary Coolant",65214:"Engine Rated Speed / Config",
    65226:"DM1 Active Diagnostics",65227:"DM2 Previously Active Diagnostics",
    65242:"Software Identification",65248:"Vehicle Distance",65252:"Shutdown / Indicator Status",
    65253:"Engine Hours",65256:"Vehicle Navigation Speed",65257:"Fuel Consumption",
    65260:"Vehicle Identification",65262:"ET1 Engine Temperature 1",
    65263:"EFL/P1 Engine Fluid Level/Pressure",65265:"Cruise / Vehicle Speed",
    65266:"LFE1 Fuel Economy",65269:"Ambient Conditions / OEM family",
    65270:"IC1 Intake/Exhaust Conditions",65271:"VEP1 Vehicle Electrical Power",
    65272:"TRF1 Transmission Fluids",65276:"DD1 Dash Display",65279:"Water in Fuel",
    65280:"Cummins NIM Genset Status",
    65376:"Scania Proprietary",65409:"Scania DLN2",65414:"Scania Engine Configuration",
    65415:"Scania DLN7",65416:"Scania DLN8",65435:"Scania Marine Aftertreatment/ECA",
    65464:"Scania ADS",
}
PROFILE_HINTS = {
    65030:["cummins_nim"],65280:["cummins_nim"],
    65376:["scania"],65409:["scania"],65414:["scania"],65415:["scania"],65416:["scania"],65435:["scania"],65464:["scania"],
    64775:["cummins_tier4","fpt"],64891:["cummins_tier4","scania"],64892:["cummins_tier4","scania","fpt"],
    65110:["cummins_tier4","fpt"],65269:["cummins_tier4","fpt"],
}
MARETRON_MANUFACTURER = 137

def parse_29bit(can_id: int):
    prio = (can_id >> 26) & 0x7
    dp = (can_id >> 24) & 0x1
    pf = (can_id >> 16) & 0xFF
    ps = (can_id >> 8) & 0xFF
    src = can_id & 0xFF
    if pf < 240:
        pgn = (dp << 16) | (pf << 8)
        dst = ps
    else:
        pgn = (dp << 16) | (pf << 8) | ps
        dst = 0xFF
    return prio,pgn,src,dst

def proprietary_header(data: bytes):
    if len(data) < 2:
        return None,None
    manufacturer = data[0] | ((data[1] & 0x07) << 8)
    industry = (data[1] >> 5) & 0x07
    return manufacturer,industry

def classify(pgn: int|None, data: bytes, selected: set[str]):
    if "raw" in selected and len(selected) == 1:
        return "RAW","Raw CAN"
    if "smartcraft" in selected and len(selected) == 1:
        return "SMARTCRAFT","SmartCraft/proprietary candidate (payload undecoded)"
    if "nmea2000" in selected and "j1939" not in selected and "auto" not in selected:
        return "NMEA2000",N2K_NAMES.get(pgn,"NMEA 2000 / proprietary")
    if "j1939" in selected and "nmea2000" not in selected and "auto" not in selected:
        return "J1939",J1939_NAMES.get(pgn,"J1939 / proprietary")

    if pgn is None:
        return ("SMARTCRAFT","SmartCraft/proprietary candidate") if "smartcraft" in selected else ("RAW","11-bit/raw CAN")

    # Maretron's proprietary N2K header disambiguates low-numbered proprietary
    # PGNs that overlap the SAE J1939 numeric range.
    mfg,ind = proprietary_header(data)
    if pgn in (65284,65286,65287) and mfg == MARETRON_MANUFACTURER:
        return "NMEA2000",N2K_NAMES[pgn]
    if pgn in N2K_NAMES and pgn >= 126000:
        return "NMEA2000",N2K_NAMES[pgn]
    if pgn in J1939_NAMES:
        return "J1939",J1939_NAMES[pgn]
    if pgn in N2K_NAMES:
        return "NMEA2000",N2K_NAMES[pgn]

    # Conservative numeric heuristic. Do not call low proprietary PGNs N2K
    # unless a manufacturer header or explicit operator context supports it.
    if pgn >= 126000:
        return "NMEA2000","Uncatalogued NMEA 2000 PGN"
    if "smartcraft" in selected:
        return "SMARTCRAFT","SmartCraft/proprietary candidate (undecoded)"
    return "UNKNOWN","Ambiguous J1939/NMEA proprietary or raw extended CAN"

def open_can(iface: str):
    s = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW)
    try:
        s.setsockopt(SOL_CAN_RAW, CAN_RAW_FD_FRAMES, 1)
    except OSError:
        pass
    s.bind((iface,))
    s.setblocking(False)
    return s

class RotatingNDJSON:
    def __init__(self,path,max_mb=25,backups=3):
        self.path=path; self.max_bytes=max(1,max_mb)*1024*1024; self.backups=max(1,backups)
        os.makedirs(os.path.dirname(path) or ".",exist_ok=True)
        self.fh=open(path,"a",buffering=1,encoding="utf-8")
    def rotate(self):
        try:
            if self.fh.tell() < self.max_bytes: return
        except OSError:
            return
        self.fh.close()
        for i in range(self.backups,0,-1):
            src=self.path if i==1 else f"{self.path}.{i-1}"
            dst=f"{self.path}.{i}"
            if os.path.exists(src):
                try: os.replace(src,dst)
                except OSError: pass
        self.fh=open(self.path,"a",buffering=1,encoding="utf-8")
    def write(self,obj):
        self.fh.write(json.dumps(obj,separators=(",",":"),sort_keys=False)+"\n")
        self.rotate()
    def close(self):
        try:self.fh.close()
        except Exception:pass

class Stats:
    def __init__(self):
        self.started=time.monotonic(); self.total=0
        self.protocols=collections.Counter(); self.pgns=collections.Counter()
        self.sources=collections.Counter(); self.ifaces=collections.Counter()
        self.unknown=collections.Counter()
    def add(self,iface,proto,pgn,src):
        self.total+=1; self.protocols[proto]+=1; self.ifaces[iface]+=1
        if pgn is not None:self.pgns[pgn]+=1
        if src is not None:self.sources[src]+=1
        if proto in ("UNKNOWN","RAW","SMARTCRAFT") and pgn is not None:self.unknown[pgn]+=1
    def summary(self):
        elapsed=max(.001,time.monotonic()-self.started)
        top=", ".join(f"{p}:{n}" for p,n in self.pgns.most_common(6)) or "none"
        talk=", ".join(f"0x{s:02X}:{n}" for s,n in self.sources.most_common(5)) or "none"
        prots=", ".join(f"{k}={v}" for k,v in self.protocols.most_common()) or "none"
        unk=", ".join(f"{p}:{n}" for p,n in self.unknown.most_common(5)) or "none"
        return f"frames={self.total} avg={self.total/elapsed:.1f}/s protocols[{prots}] top_pgn[{top}] top_src[{talk}] unknown[{unk}]"

def self_test():
    cid=(3<<26)|(0<<24)|(0xF0<<16)|(0x04<<8)|0x22  # PGN 61444, SA 0x22
    pr,pgn,sa,da=parse_29bit(cid)
    assert (pr,pgn,sa,da)==(3,61444,0x22,0xFF)
    proto,name=classify(61444,b"\xff"*8,{"auto"})
    assert proto=="J1939" and "EEC1" in name
    # N2K PGN 127489
    cid2=(2<<26)|(1<<24)|(0xF2<<16)|(0x01<<8)|0x23
    _,p2,sa2,_=parse_29bit(cid2)
    assert p2==127489 and sa2==0x23
    proto2,_=classify(p2,b"\x00"*8,{"auto"})
    assert proto2=="NMEA2000"
    # Maretron manufacturer header: manufacturer 137, industry 4
    hdr=bytes((137 & 0xff, ((137>>8)&0x07) | (4<<5)))
    proto3,_=classify(65286,hdr+b"\x00"*6,{"auto"})
    assert proto3=="NMEA2000"
    print("can_sniffer self-test: OK")

def main():
    ap=argparse.ArgumentParser()
    ap.add_argument("--interfaces",default="can0",help="comma-separated SocketCAN interfaces")
    ap.add_argument("--protocols",default="auto",help="auto or comma list nmea2000,j1939,smartcraft,raw")
    ap.add_argument("--log-path",default="/var/log/keelos-can-sniffer.ndjson")
    ap.add_argument("--max-mb",type=int,default=25)
    ap.add_argument("--summary-interval",type=int,default=30)
    ap.add_argument("--verbose",action="store_true")
    ap.add_argument("--self-test",action="store_true")
    ap.add_argument("--list-protocols",action="store_true")
    args=ap.parse_args()
    if args.self_test:self_test(); return
    if args.list_protocols:
        print(json.dumps({
            "modes":["auto","nmea2000","j1939","smartcraft","raw"],
            "policy":"receive-only; auto classification conservative; SmartCraft payload remains undecoded until verified mappings exist",
            "nmea2000_known":N2K_NAMES,
            "j1939_known":J1939_NAMES,
            "profile_hints":PROFILE_HINTS,
        },indent=2,sort_keys=True)); return

    selected={x.strip().lower() for x in args.protocols.split(",") if x.strip()}
    valid={"auto","nmea2000","j1939","smartcraft","raw"}
    if not selected or not selected <= valid:
        raise SystemExit("bad --protocols; use auto,nmea2000,j1939,smartcraft,raw")
    ifaces=[x.strip() for x in args.interfaces.split(",") if x.strip()]
    if not ifaces: raise SystemExit("no interfaces supplied")

    logging.basicConfig(level=logging.INFO,format="%(asctime)s %(levelname)s %(message)s")
    socks={}; reverse={}
    for iface in ifaces:
        s=open_can(iface); socks[iface]=s; reverse[s.fileno()]=iface
        logging.info("sniffing %s protocols=%s (receive-only)",iface,",".join(sorted(selected)))

    writer=RotatingNDJSON(args.log_path,args.max_mb)
    stats=Stats(); last_summary=time.monotonic(); running=True
    def stop(*_):
        nonlocal running; running=False
    signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGINT,stop)
    try:
        while running:
            ready,_,_=select.select(list(socks.values()),[],[],1.0)
            now=time.time()
            for s in ready:
                raw=s.recv(72)
                iface=reverse[s.fileno()]
                if len(raw)>=72:
                    can_id,dlc,flags,payload=struct.unpack("=IBB2x64s",raw[:72])
                    data=payload[:dlc]; fd=True
                elif len(raw)>=16:
                    can_id,dlc,payload=struct.unpack("=IB3x8s",raw[:16])
                    data=payload[:dlc]; flags=0; fd=False
                else:
                    continue
                is_err=bool(can_id & CAN_ERR_FLAG); is_rtr=bool(can_id & CAN_RTR_FLAG)
                ext=bool(can_id & CAN_EFF_FLAG)
                cid=can_id & (CAN_EFF_MASK if ext else CAN_SFF_MASK)
                pgn=src=dst=prio=None
                if ext and not is_err:
                    prio,pgn,src,dst=parse_29bit(cid)
                proto,name=classify(pgn,data,selected)
                mfg=industry=None
                if ext and pgn is not None:
                    mfg,industry=proprietary_header(data)
                hints=PROFILE_HINTS.get(pgn,[]) if pgn is not None else []
                rec={
                    "ts":round(now,6),"iface":iface,"can_id":f"{cid:08X}" if ext else f"{cid:03X}",
                    "extended":ext,"fd":fd,"rtr":is_rtr,"error":is_err,"dlc":len(data),
                    "data":data.hex().upper(),"protocol":proto,"name":name,
                }
                if pgn is not None:
                    rec.update({"priority":prio,"pgn":pgn,"src":src,"dst":dst})
                if mfg is not None and (pgn in (65284,65286,65287,126720,130823,130840) or mfg==MARETRON_MANUFACTURER):
                    rec["manufacturer_code"]=mfg; rec["industry_group"]=industry
                    if mfg==MARETRON_MANUFACTURER: rec["manufacturer_hint"]="Maretron"
                if hints: rec["engine_profile_hints"]=hints
                writer.write(rec); stats.add(iface,proto,pgn,src)
                if args.verbose:
                    logging.info("%s %s id=%s pgn=%s sa=%s %s %s",
                                 iface,proto,rec["can_id"],pgn,
                                 f"0x{src:02X}" if src is not None else "--",name,rec["data"])
            if time.monotonic()-last_summary >= max(5,args.summary_interval):
                logging.info("sniffer summary: %s",stats.summary()); last_summary=time.monotonic()
    finally:
        logging.info("sniffer final: %s",stats.summary())
        writer.close()
        for s in socks.values():
            try:s.close()
            except Exception:pass

if __name__=="__main__":
    main()
PY
  chmod 0755 "$py"
  python3 -m py_compile "$py"
  python3 "$py" --self-test >/dev/null
  record file "$py"
  ok "CAN sniffer installed; passive classifier self-test passed"
}

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, verified engine/transmission/fuel/electrical data.
Engine profiles select the known J1939 message family for each manufacturer;
unknown/proprietary traffic can be passively captured without transmitting onto
the engine bus. Other NMEA 2000 PGNs remain available unchanged to Signal K.
"""
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, Set, List

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 verified J1939 equivalents used by this bridge.
N2K_ENGINE_RAPID = 127488
N2K_ENGINE_DYNAMIC = 127489
N2K_TRANSMISSION_DYNAMIC = 127493
N2K_TRIP_ENGINE = 127497
N2K_ENGINE_STATIC = 127498
N2K_FLUID_LEVEL = 127505
N2K_BATTERY_STATUS = 127508

# Common SAE J1939 PGNs found on marine engine/genset networks.
J1939_EEC2 = 61443
J1939_EEC1 = 61444
J1939_ETC2 = 61445
J1939_DM1 = 65226
J1939_DM2 = 65227
J1939_RATED_SPEED = 65214
J1939_SOFTWARE_ID = 65242
J1939_HOURS = 65253
J1939_FUEL_CONSUMPTION = 65257
J1939_VIN = 65260
J1939_ET1 = 65262
J1939_EFLP1 = 65263
J1939_LFE1 = 65266
J1939_IC1 = 65270
J1939_VEP1 = 65271
J1939_TRF1 = 65272
J1939_DD1 = 65276
J1939_NIM_GENSET_STATUS = 65280
J1939_TP_CM = 60416
J1939_TP_DT = 60160

# Baseline message family used by common marine J1939 gateways. Entries whose
# byte layout is not verified are still useful for passive capture/inventory.
BASE_STANDARD_PGNS = {
    61443,61444,61445,65031,65172,65214,65226,65227,65242,65248,65252,
    65253,65256,65257,65260,65262,65263,65265,65266,65270,65271,65272,
    65276,65279,
}
ENGINE_PROFILE_PGNS = {
    'standard_j1939': set(BASE_STANDARD_PGNS),
    'cummins_nim': set(BASE_STANDARD_PGNS) | {65030,65280},
    'cummins_tier4': set(BASE_STANDARD_PGNS) | {64775,64891,64892,65110,65269},
    'john_deere': set(BASE_STANDARD_PGNS),
    'cat_c32': set(BASE_STANDARD_PGNS),
    'scania': set(BASE_STANDARD_PGNS) | {64891,64892,65247,65376,65409,65414,65415,65416,65435,65464},
    'yamaha': set(BASE_STANDARD_PGNS),
    'suzuki': set(BASE_STANDARD_PGNS),
    'fpt': set(BASE_STANDARD_PGNS) | {64775,64892,65110,65269},
    'man_standard': set(BASE_STANDARD_PGNS),
}
ENGINE_PROFILE_LABELS = {
    'standard_j1939':'Generic SAE J1939', 'cummins_nim':'Cummins Onan NIM',
    'cummins_tier4':'Cummins Tier 4', 'john_deere':'John Deere PowerTech',
    'cat_c32':'Caterpillar C32/ADEM', 'scania':'Scania', 'yamaha':'Yamaha Marine',
    'suzuki':'Suzuki Marine', 'fpt':'FPT Industrial', 'man_standard':'MAN standard J1939',
}
PROFILE_ALIASES = {
    'generic':'standard_j1939','standard':'standard_j1939','j1939':'standard_j1939',
    'nim':'cummins_nim','cummins':'cummins_tier4','tier4':'cummins_tier4',
    'deere':'john_deere','jd':'john_deere','caterpillar':'cat_c32','cat':'cat_c32',
    'man':'man_standard',
}
NIM_FIXED_ENGINE_MAP = ['234:0','158:1','179:2','203:3']

# Only these PGNs are semantically decoded by this build. Profile-specific
# proprietary messages outside this set are captured as raw data until their
# exact layout is verified from documentation or controlled captures.
SEMANTIC_J1939 = {
    J1939_EEC2,J1939_EEC1,J1939_ETC2,J1939_DM1,J1939_HOURS,
    J1939_FUEL_CONSUMPTION,J1939_SOFTWARE_ID,J1939_VIN,J1939_ET1,
    J1939_EFLP1,J1939_LFE1,J1939_IC1,J1939_VEP1,J1939_TRF1,J1939_DD1,
    J1939_NIM_GENSET_STATUS,
}
MAPPED_J1939 = set(SEMANTIC_J1939)
MAPPED_N2K = {N2K_ENGINE_RAPID,N2K_ENGINE_DYNAMIC,N2K_TRANSMISSION_DYNAMIC,N2K_TRIP_ENGINE,N2K_FLUID_LEVEL,N2K_BATTERY_STATUS}
FAST_N2K = {N2K_ENGINE_DYNAMIC,N2K_TRIP_ENGINE}

J1939_KNOWN_UNMAPPED = {
    65030:'Generator Average Basic AC Quantities (NIM/J1939-75; profile capture)',
    65031:'Engine exhaust temperature (profile recognized; raw until byte layout verified here)',
    65172:'Auxiliary coolant', 65214:'Engine rated speed / configuration family',
    65248:'Vehicle distance',65252:'Shutdown / indicator status',65256:'Navigation speed',
    65265:'Cruise/vehicle speed',65279:'Water in fuel',64891:'Aftertreatment status',
    64892:'DPF control/status',65409:'Scania DLN2',65414:'Scania engine configuration',
    65415:'Scania DLN7',65416:'Scania DLN8',65435:'Scania marine aftertreatment/ECA',
    65464:'Scania ADS',64775:'Aftertreatment/indicator family',65110:'DEF/SCR family',
    65269:'Ambient conditions / OEM family',
}

def normalize_profiles(values: List[str]) -> List[str]:
    out=[]
    for value in values or ['standard_j1939']:
        for raw in str(value).split(','):
            key=raw.strip().lower().replace('-','_')
            if not key: continue
            key=PROFILE_ALIASES.get(key,key)
            if key=='all_verified':
                for p in ENGINE_PROFILE_PGNS:
                    if p not in out: out.append(p)
                continue
            if key not in ENGINE_PROFILE_PGNS:
                raise ValueError(f'unknown engine profile: {raw}')
            if key not in out: out.append(key)
    return out or ['standard_j1939']

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 decode_j1939_text(data: bytes, skip_first: bool = False) -> str:
    raw = data[1:] if skip_first and data else data
    return raw.decode("latin1", errors="replace").replace("*", " ").rstrip("\x00\xff ").strip()


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


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
    trip_fuel_l: Optional[float] = None
    total_fuel_l: Optional[float] = None
    rated_speed_rpm: Optional[float] = None
    software_id: Optional[str] = None
    vin: Optional[str] = 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)
        self.profiles = normalize_profiles(args.engine_profile)
        self.selected_pgns: Set[int] = set()
        for profile in self.profiles:
            self.selected_pgns.update(ENGINE_PROFILE_PGNS[profile])
        engine_map=list(args.engine_map)
        if self.profiles == ['cummins_nim'] and not engine_map:
            engine_map=list(NIM_FIXED_ENGINE_MAP)
            logging.info("Cummins NIM selected: using documented fixed source map %s", ",".join(engine_map))
        explicit = {}
        for item in engine_map:
            left, right = item.split(":", 1)
            explicit[int(left, 0)] = int(right, 0)
        self.instances = InstanceMap(explicit)
        self.raw_capture = bool(args.raw_capture)
        self.raw_capture_path = args.raw_capture_path
        self.raw_seen: Dict[Tuple[int,int], Tuple[float,bytes]] = {}
        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 _profiles_for_pgn(self, pgn: int) -> List[str]:
        return [p for p in self.profiles if pgn in ENGINE_PROFILE_PGNS[p]]

    def capture_j1939(self, pgn: int, src: int, data: bytes) -> None:
        if not self.raw_capture:
            return
        now=time.monotonic(); key=(pgn,src); prev=self.raw_seen.get(key)
        if prev and prev[1]==data and now-prev[0] < 1.0:
            return
        self.raw_seen[key]=(now,bytes(data))
        path=self.raw_capture_path
        try:
            if os.path.exists(path) and os.path.getsize(path) > 5*1024*1024:
                old=path+'.1'
                try: os.replace(path,old)
                except OSError: pass
            rec={'ts':time.time(),'pgn':pgn,'src':src,'instance':self.instances.instance_for_src(src),
                 'profiles':self._profiles_for_pgn(pgn),'semantic':pgn in SEMANTIC_J1939,'data':data.hex()}
            with open(path,'a',encoding='utf-8') as f:
                f.write(json.dumps(rec,separators=(',',':'))+'\n')
        except OSError as exc:
            logging.debug('raw capture write failed: %s',exc)

    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_addresses(self) -> None:
        # J1939/N2K NAME: deterministic identity; manufacturer code 0 is reserved
        # for this beta unless a certified NMEA manufacturer identity is supplied.
        name = (
            self.identity
            | (0 << 21)            # manufacturer code (beta/unassigned)
            | (0 << 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
        )
        payload = name.to_bytes(8, "little")
        self.n2k.send(make_can_id(60928, self.n2k_src, 0xFF, 6), payload)
        for inst in sorted(self.states) or [0]:
            src = self.j1939_tx_source(inst)
            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_FUEL_CONSUMPTION:
            # J1939-71 PGN 65257: total and trip fuel counters, 0.5 L/bit.
            total=u32le(data,0); trip=u32le(data,4)
            if total < 0xFAFFFFFF: st.set("total_fuel_l", total*0.5)
            if trip < 0xFAFFFFFF: st.set("trip_fuel_l", trip*0.5)
            if self._rate_ok("trip",inst,0.8): self.emit_trip(inst,st)

        elif pgn == J1939_SOFTWARE_ID:
            # SPN 965 count byte followed by SPN 234 ASCII identification fields.
            text=decode_j1939_text(data,skip_first=True)
            if text:
                st.set("software_id",text)
                if self._rate_ok("static",inst,5.0): self.emit_static(inst,st)

        elif pgn == J1939_VIN:
            text=decode_j1939_text(data)
            if text:
                st.set("vin",text)
                if self._rate_ok("static",inst,5.0): self.emit_static(inst,st)

        elif pgn == J1939_NIM_GENSET_STATUS:
            # Cummins/Onan NIM compatible status: byte 3 carries percent genset load.
            if len(data)>=3 and data[2] <= 100:
                st.set("load_pct",float(data[2]))
                if self._rate_ok("dynamic",inst,0.5): 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.1, 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_trip(self, inst: int, st: EngineState) -> None:
        trip=NA_U16 if st.trip_fuel_l is None else int(round(clamp(st.trip_fuel_l,0,0xFFFC)))
        payload=bytes([inst&0xFF])+put_u16(trip)+b"\xff\x7f\xff\x7f\xff\x7f"
        self.send_n2k(N2K_TRIP_ENGINE,payload,5)

    def emit_static(self, inst: int, st: EngineState) -> None:
        rated=NA_U16 if st.rated_speed_rpm is None else int(round(clamp(st.rated_speed_rpm/0.25,0,0xFFFC)))
        payload=bytes([inst&0xFF])+put_u16(rated)+lau(st.vin)+lau(st.software_id)
        self.send_n2k(N2K_ENGINE_STATIC,payload,5)

    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.1,
                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_TRIP_ENGINE and len(payload) >= 3:
            inst=payload[0]; trip=u16le(payload,1)
            if trip != NA_U16:
                self.j1939_set_trip(inst,float(trip))

        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_trip(self, inst: int, trip_l: float) -> None:
        d=bytearray(b"\xFF"*8)
        raw=int(round(clamp(trip_l/0.5,0,0xF9FFFFFF)))
        d[4:8]=put_u32(raw)
        self.send_j1939(J1939_FUEL_CONSUMPTION,inst,bytes(d),6)

    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)
        if src == self.n2k_src or 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)
        if self.j1939_base <= src < min(self.j1939_base + 16, 254):
            return
        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,payload=complete
                if target in self.selected_pgns and target in SEMANTIC_J1939:
                    self.decode_j1939(target,src,payload)
                else:
                    self.capture_j1939(target,src,payload)
            return
        if pgn in self.selected_pgns and pgn in SEMANTIC_J1939:
            self.decode_j1939(pgn,src,data)
        elif self.raw_capture:
            # Capture unclassified traffic too. This is read-only and bounded by rotation.
            self.capture_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 engine profiles: %s", ",".join(self.profiles))
        logging.info("J1939 semantic PGNs enabled: %s", ",".join(str(x) for x in sorted(self.selected_pgns & SEMANTIC_J1939)))
        if self.raw_capture: logging.info("J1939 passive raw capture: %s", self.raw_capture_path)
        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 parse_args(argv=None):
    p=argparse.ArgumentParser(description="KeelOS NMEA2000 <-> J1939 engine bridge")
    p.add_argument("--n2k-iface",default="vcan0")
    p.add_argument("--j1939-iface",default="vcan1")
    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-profile",action="append",default=[],help="engine family; repeat or comma-separate")
    p.add_argument("--engine-map",action="append",default=[],metavar="J1939_SA:N2K_INSTANCE")
    p.add_argument("--raw-capture",action="store_true",help="passively log unknown/profile proprietary J1939 frames")
    p.add_argument("--raw-capture-path",default="/var/log/keelos-j1939-proprietary.ndjson")
    p.add_argument("--list-engine-profiles",action="store_true")
    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 self_test():
    assert normalize_profiles(['cat']) == ['cat_c32']
    assert normalize_profiles(['yamaha,suzuki']) == ['yamaha','suzuki']
    assert J1939_FUEL_CONSUMPTION in ENGINE_PROFILE_PGNS['yamaha']
    assert 65415 in ENGINE_PROFILE_PGNS['scania']
    assert 64775 in ENGINE_PROFILE_PGNS['fpt']
    assert NIM_FIXED_ENGINE_MAP == ['234:0','158:1','179:2','203:3']
    # 127489 coolant is 0.1 K/bit in this bridge.
    k=363.15; raw=int(round(k/0.1)); assert abs(raw*0.1-k)<0.051
    # J1939 65257 trip counter round-trip: 0.5 L/bit -> N2K 1 L/bit.
    jraw=246; liters=jraw*0.5; nraw=int(round(liters)); assert nraw==123
    # N2K 127498 strings use NMEA LAU encoding.
    assert lau('ABC') == bytes([5,1])+b'ABC'
    print('protocol_bridge self-test: OK')


def validate_engine_map(items):
    seen_i=set()
    for item in items:
        try:left,right=item.split(':',1); sa=int(left,0); inst=int(right,0)
        except Exception: raise ValueError(f'bad engine map {item!r}; expected SA:INSTANCE')
        if not (0<=sa<=253 and 0<=inst<=252): raise ValueError(f'bad engine map range: {item}')
        if inst in seen_i: raise ValueError(f'duplicate N2K engine instance in map: {inst}')
        seen_i.add(inst)


def main(argv=None):
    args=parse_args(argv)
    try: profiles=normalize_profiles(args.engine_profile); validate_engine_map(args.engine_map)
    except ValueError as exc:
        print(f'fatal: {exc}',file=sys.stderr);return 2
    if args.list_engine_profiles:
        print(json.dumps({p:{'label':ENGINE_PROFILE_LABELS[p],'pgns':sorted(ENGINE_PROFILE_PGNS[p]),'semantic':sorted(ENGINE_PROFILE_PGNS[p]&SEMANTIC_J1939)} for p in ENGINE_PROFILE_PGNS},indent=2));return 0
    if args.list_mappings:
        selected=set();[selected.update(ENGINE_PROFILE_PGNS[p]) for p in profiles]
        print(json.dumps({
          'engine_profiles':profiles,
          'nmea2000_cross_protocol':sorted(MAPPED_N2K),
          'nmea2000_output_only':[N2K_ENGINE_STATIC],
          'nmea2000_full_decode':'Signal K/canboatjs installed PGN database',
          'j1939_semantic':sorted(selected & SEMANTIC_J1939),
          'j1939_profile_inventory':sorted(selected),
          'j1939_known_unmapped':J1939_KNOWN_UNMAPPED,
          'proprietary_policy':'profile-specific or unknown PGNs are passive-capture only until exact layouts are verified'
        },indent=2));return 0
    if args.self_test:self_test();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" --self-test >/dev/null
  python3 "$bridge" --list-engine-profiles >/dev/null
  record file "$bridge"
  ok "Protocol bridge passed syntax, engine-profile, scaling and mapping self-tests"
}

install_proprietary_n2k() {
  [[ $A_N2K_PROPRIETARY == y ]] || return 0
  local dir="/usr/local/lib/keelos" pub="/usr/local/lib/keelos/proprietary_n2k.py" cfg="/etc/keelos/proprietary-n2k.json"
  info "Installing documented proprietary NMEA 2000 compatibility publisher"
  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: write ${pub}; create disabled-safe ${cfg}; run self-test${C_RESET}"
    return 0
  fi
  mkdir -p "$dir" /etc/keelos
  cat > "$pub" <<'PYPROPN2K'
#!/usr/bin/env python3
"""KeelOS documented proprietary NMEA 2000 compatibility publisher.

Listens to standard NMEA 2000 engine/temperature PGNs and, when enabled,
publishes only documented Maretron-compatible semantic equivalents. It never
invents configuration/control messages. Exact verified passive/status payloads
may be added through a local JSON file and are disabled by default.
"""
from __future__ import annotations
import argparse, hashlib, json, logging, os, selectors, socket, struct, sys, time
from pathlib import Path
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')
PGN_ADDRESS_CLAIM=60928
PGN_ENGINE_DYNAMIC=127489
PGN_TRIP_ENGINE=127497
PGN_TEMPERATURE=130312
PGN_TEMPERATURE_EXT=130316
PGN_MARETRON_RELAY_CURRENT=65284
PGN_MARETRON_FLUID_FLOW=65286
PGN_MARETRON_TRIP_VOLUME=65287
PGN_MARETRON_TEMP_HIGH=130823
PGN_MARETRON_GENERIC_SENSOR=130840
MARETRON_MANUFACTURER_CODE=137
MARETRON_INDUSTRY_GROUP=4
TEMP_SOURCE_EXHAUST_GAS=14
NA_U16=0xFFFF; NA_S16=0x7FFF

# Catalog/manual-known passive/status families. Configuration and command-like
# messages are deliberately absent from this allow-list.
SAFE_RAW_PROPRIETARY_PGNS={
  130817,130818,130819,130821,130824,130825,130826,
  130828,130829,130830,130832,130833,130834,130835,130836,130837,
  PGN_MARETRON_GENERIC_SENSOR,
}
CATALOG_ONLY_PGNS={126720,128720,130817,130818,130819,130820,130821,130822,130824,130825,130826,130828,130829,130830,130832,130833,130834,130835,130836,130837}


def clamp(v,lo,hi): return lo if v<lo else hi if v>hi else v
def u16le(b,o=0): return int.from_bytes(b[o:o+2],'little',signed=False)
def s16le(b,o=0): return int.from_bytes(b[o:o+2],'little',signed=True)
def put_u16(v): return int(v).to_bytes(2,'little',signed=False)
def put_s16(v): return int(v).to_bytes(2,'little',signed=True)
def s32le(b,o=0): return int.from_bytes(b[o:o+4],'little',signed=True)
def put_s32(v): return int(v).to_bytes(4,'little',signed=True)
def put_u24(v): return int(v).to_bytes(3,'little',signed=False)
def put_s24(v): return int(v).to_bytes(3,'little',signed=True)

def pgn_parts(can_id:int)->Tuple[int,int,int,int]:
    cid=can_id & CAN_EFF_MASK; pr=(cid>>26)&7; edp=(cid>>25)&1; dp=(cid>>24)&1; pf=(cid>>16)&255; ps=(cid>>8)&255; src=cid&255
    if pf<240: return pr,(edp<<17)|(dp<<16)|(pf<<8),src,ps
    return pr,(edp<<17)|(dp<<16)|(pf<<8)|ps,src,255

def make_can_id(pgn:int,src:int,dst:int=255,priority:int=6)->int:
    edp=(pgn>>17)&1; dp=(pgn>>16)&1; pf=(pgn>>8)&255; ps=(dst&255) if pf<240 else (pgn&255)
    return (((priority&7)<<26)|(edp<<25)|(dp<<24)|(pf<<16)|(ps<<8)|(src&255))|CAN_EFF_FLAG

def maretron_header()->bytes:
    # 11-bit manufacturer + 2 reserved bits set + 3-bit industry group.
    raw=(MARETRON_MANUFACTURER_CODE & 0x7FF) | (0x3<<11) | ((MARETRON_INDUSTRY_GROUP&0x7)<<13)
    return put_u16(raw)

def encode_65284(bank:int, indicator:int, current_a:float)->bytes:
    # DCR100: header, bank, channel, current 0.1 A, 0xffff reserved.
    raw=int(round(clamp(current_a/0.1,-32768,32766)))
    return maretron_header()+bytes([bank&255,indicator&255])+put_s16(raw)+b'\xff\xff'

def _fluid_type_reserved(fluid_type:int)->int:
    # Field is Fluid Type in the low nibble; remaining bits are reserved=1.
    return 0xF0 | (fluid_type & 0x0F)

def encode_65286(instance:int, flow_lph:float, sid:int=0xFF, fluid_type:int=0)->bytes:
    # 1e-4 m^3/h == 0.1 L/h; signed 24-bit fills the remaining three bytes.
    raw=int(round(clamp(flow_lph/0.1,-0x800000,0x7FFFFE)))
    return maretron_header()+bytes([sid&255,instance&255,_fluid_type_reserved(fluid_type)])+put_s24(raw)

def encode_65287(instance:int, trip_l:float, sid:int=0xFF, fluid_type:int=0)->bytes:
    # 1e-3 m^3 == 1 L; unsigned 24-bit trip counter.
    raw=int(round(clamp(trip_l,0,0xFFFFFE)))
    return maretron_header()+bytes([sid&255,instance&255,_fluid_type_reserved(fluid_type)])+put_u24(raw)

def encode_130823(instance:int, temp_c:float, source:int=TEMP_SOURCE_EXHAUST_GAS, sid:int=0xFF)->bytes:
    # TMP100 high-range temperature: 0.1 C actual and unavailable setpoint.
    raw=int(round(clamp(temp_c/0.1,-32768,32766)))
    return maretron_header()+bytes([sid&255,instance&255,source&255])+put_s16(raw)+b'\xff\x7f'

class RawCan:
    def __init__(self,iface):
        self.sock=socket.socket(socket.PF_CAN,socket.SOCK_RAW,socket.CAN_RAW); self.sock.bind((iface,)); self.sock.setblocking(False)
    def recv(self):
        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) or not(can_id&CAN_EFF_FLAG):return None
        return can_id,data[:min(dlc,8)]
    def send(self,can_id,data):
        if len(data)>8: raise ValueError('CAN frame data exceeds 8 bytes')
        self.sock.send(CAN_FRAME.pack(can_id,len(data),data.ljust(8,b'\xff')))

class FastPacketAssembler:
    def __init__(self,timeout=1.0): self.timeout=timeout; self.pending={}
    def feed(self,pgn,src,data):
        if len(data)<2:return None
        seq=(data[0]>>5)&7; no=data[0]&31; 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 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 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 FastPacketWriter:
    def __init__(self): self.seq={}
    def frames(self,pgn,payload):
        if len(payload)>223:raise ValueError('fast packet too large')
        s=self.seq.get(pgn,0)&7;self.seq[pgn]=(s+1)&7
        out=[(bytes([(s<<5),len(payload)])+payload[:6]).ljust(8,b'\xff')];pos=6;no=1
        while pos<len(payload):out.append((bytes([(s<<5)|no])+payload[pos:pos+7]).ljust(8,b'\xff'));pos+=7;no+=1
        return out

class Publisher:
    def __init__(self,args,dry=False):
        self.args=args;self.src=args.source;self.dry=dry;self.bus=None if dry else RawCan(args.iface);self.fp_rx=FastPacketAssembler();self.fp_tx=FastPacketWriter();self.sent=[];self.last={};self.sid=0
        self.config=self.load_config(args.config)
    @staticmethod
    def load_config(path):
        base={'relay_currents':[],'verified_raw':[]}
        try:
            obj=json.loads(Path(path).read_text())
            if isinstance(obj,dict):base.update(obj)
        except FileNotFoundError:pass
        except Exception as e: logging.warning('cannot load %s: %s',path,e)
        return base
    def send(self,pgn,payload,priority=6):
        if self.dry:self.sent.append((pgn,payload));return
        cid=make_can_id(pgn,self.src,255,priority)
        if len(payload)>8:
            for fr in self.fp_tx.frames(pgn,payload):self.bus.send(cid,fr)
        else:self.bus.send(cid,payload)
    def rate(self,key,period):
        now=time.monotonic()
        if now-self.last.get(key,0)<period:return False
        self.last[key]=now;return True
    def process(self,pgn,src,payload):
        if src==self.src:return
        if pgn==PGN_ENGINE_DYNAMIC and len(payload)>=26:
            inst=payload[0]; raw=s16le(payload,9)
            if raw!=NA_S16 and self.rate(('flow',inst),0.45):self.send(PGN_MARETRON_FLUID_FLOW,encode_65286(inst,raw*0.1,self.sid),6)
        elif pgn==PGN_TRIP_ENGINE and len(payload)>=3:
            inst=payload[0];raw=u16le(payload,1)
            if raw!=NA_U16 and self.rate(('trip',inst),0.9):self.send(PGN_MARETRON_TRIP_VOLUME,encode_65287(inst,float(raw),self.sid),6)
        elif pgn==PGN_TEMPERATURE and len(payload)>=5:
            inst=payload[1];source=payload[2];raw=u16le(payload,3)
            if source==TEMP_SOURCE_EXHAUST_GAS and raw!=NA_U16 and self.rate(('egt',inst),1.8):
                temp_c=raw*0.01-273.15;self.send(PGN_MARETRON_TEMP_HIGH,encode_130823(inst,temp_c,source,self.sid),6)
        elif pgn==PGN_TEMPERATURE_EXT and len(payload)>=7:
            inst=payload[1];source=payload[2];raw=s32le(payload,3)
            if source==TEMP_SOURCE_EXHAUST_GAS and raw!=0x7fffffff and self.rate(('egt-ext',inst),1.8):
                temp_c=raw*0.001;self.send(PGN_MARETRON_TEMP_HIGH,encode_130823(inst,temp_c,source,self.sid),6)
        self.sid=(self.sid+1)&0xFF
    def scheduled_config(self):
        now=time.monotonic()
        for item in self.config.get('relay_currents',[]):
            if not item.get('enabled',False):continue
            key=('relay',int(item.get('bank',0)),int(item.get('indicator',1)))
            if self.rate(key,max(0.1,float(item.get('period_ms',1000))/1000.0)):
                self.send(PGN_MARETRON_RELAY_CURRENT,encode_65284(key[1],key[2],float(item['current_a'])),6)
        for i,item in enumerate(self.config.get('verified_raw',[])):
            if not item.get('enabled',False):continue
            pgn=int(item.get('pgn',0))
            if pgn not in SAFE_RAW_PROPRIETARY_PGNS:
                logging.warning('blocked verified_raw PGN %s: not in passive/status allow-list',pgn);continue
            try:payload=bytes.fromhex(str(item.get('payload_hex','')).replace(' ',''))
            except ValueError:continue
            if not payload or len(payload)>223:continue
            if self.rate(('raw',i),max(0.1,float(item.get('period_ms',60000))/1000.0)):self.send(pgn,payload,int(item.get('priority',6)))
    def claim(self):
        ident=int.from_bytes(hashlib.sha256(('keelos-proprietary'+str(self.src)).encode()).digest()[:4],'little')&0x1fffff
        name=ident|(130<<40)|(25<<49)|(4<<60)|(1<<63)
        self.send(PGN_ADDRESS_CLAIM,name.to_bytes(8,'little'),6)
    def run(self):
        logging.info('KeelOS proprietary N2K publisher on %s source 0x%02X',self.args.iface,self.src)
        last_claim=0.0
        while True:
            now=time.monotonic()
            if now-last_claim>60:self.claim();last_claim=now
            self.scheduled_config()
            r=self.bus.recv()
            if not r:time.sleep(0.01);continue
            cid,data=r;_,pgn,src,_=pgn_parts(cid)
            if pgn in (PGN_ENGINE_DYNAMIC,PGN_TRIP_ENGINE):
                payload=self.fp_rx.feed(pgn,src,data)
                if payload is not None:self.process(pgn,src,payload)
            elif pgn==PGN_TEMPERATURE:self.process(pgn,src,data)
            elif pgn==PGN_TEMPERATURE_EXT:
                payload=self.fp_rx.feed(pgn,src,data)
                if payload is not None:self.process(pgn,src,payload)

def self_test():
    assert maretron_header()==bytes.fromhex('8998')
    a=encode_65284(2,3,12.3);assert len(a)==8 and a[:2]==bytes.fromhex('8998') and s16le(a,4)==123 and a[6:]==b'\xff\xff'
    f=encode_65286(1,12.3,7);assert len(f)==8 and f[:2]==bytes.fromhex('8998') and int.from_bytes(f[5:8],'little',signed=True)==123
    t=encode_65287(1,456.0,8);assert len(t)==8 and int.from_bytes(t[5:8],'little')==456
    h=encode_130823(2,650.0,TEMP_SOURCE_EXHAUST_GAS,9);assert len(h)==9 and s16le(h,5)==6500
    p=Publisher(argparse.Namespace(source=0x25,iface='vcan0',config='/nonexistent'),dry=True)
    dyn=bytearray(b'\xff'*26);dyn[0]=1;dyn[9:11]=put_s16(125)
    p.process(PGN_ENGINE_DYNAMIC,0x23,bytes(dyn));assert p.sent[-1][0]==PGN_MARETRON_FLUID_FLOW
    p.last.clear();p.sent.clear();trip=bytes([1])+put_u16(42)+b'\xff'*6;p.process(PGN_TRIP_ENGINE,0x23,trip);assert p.sent[-1][0]==PGN_MARETRON_TRIP_VOLUME
    p.last.clear();p.sent.clear();raw=int(round(600/0.001));temp=bytes([0xff,0,TEMP_SOURCE_EXHAUST_GAS])+put_s32(raw)+b'\xff'*4;p.process(PGN_TEMPERATURE_EXT,0x23,temp);assert p.sent[-1][0]==PGN_MARETRON_TEMP_HIGH
    print('proprietary_n2k self-test: OK')

def parse_args(argv=None):
    p=argparse.ArgumentParser(description='KeelOS documented proprietary NMEA 2000 publisher')
    p.add_argument('--iface',default='can0');p.add_argument('--source',type=lambda x:int(x,0),default=0x25);p.add_argument('--config',default='/etc/keelos/proprietary-n2k.json')
    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):
    a=parse_args(argv)
    if a.list_mappings:
        print(json.dumps({'semantic':{str(PGN_MARETRON_RELAY_CURRENT):'DC Relay Current (configured only)',str(PGN_MARETRON_FLUID_FLOW):'Fluid Flow Rate from N2K 127489 fuel rate',str(PGN_MARETRON_TRIP_VOLUME):'Trip Volume from N2K 127497 trip fuel',str(PGN_MARETRON_TEMP_HIGH):'High Range Temperature from N2K 130312 exhaust gas',str(PGN_MARETRON_GENERIC_SENSOR):'Generic Sensor: exact verified raw/DF only'},'manufacturer_code':MARETRON_MANUFACTURER_CODE,'industry_group':MARETRON_INDUSTRY_GROUP,'catalog_raw_only':sorted(CATALOG_ONLY_PGNS),'safe_verified_raw':sorted(SAFE_RAW_PROPRIETARY_PGNS)},indent=2));return 0
    if a.self_test:self_test();return 0
    if not 0<=a.source<=253:return 2
    logging.basicConfig(level=getattr(logging,a.log_level),format='%(asctime)s %(levelname)s %(message)s')
    try:Publisher(a).run()
    except KeyboardInterrupt:return 0
    except OSError as e:logging.error('cannot open SocketCAN interface: %s',e);return 1
    return 0
if __name__=='__main__':raise SystemExit(main())
PYPROPN2K
  chmod 0755 "$pub"
  python3 -m py_compile "$pub"
  python3 "$pub" --self-test >/dev/null
  python3 "$pub" --list-mappings >/dev/null
  if [[ ! -f $cfg ]]; then
    cat > "$cfg" <<'JSON'
{
  "relay_currents": [],
  "verified_raw": []
}
JSON
    record file "$cfg"
  fi
  record file "$pub"
  ok "Proprietary NMEA 2000 publisher ready (no guessed catalog-only payloads)"
}

#--------------------------------------------------------------------------
# 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 Keelas.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() {
  [[ ${#OVERLAYS[@]} -eq 0 ]] && return 0
  [[ -n $BOOT_CONFIG ]] || die "CAN HAT selected but no Raspberry Pi boot config was found."
  validate_overlay_files
  info "Writing device-tree overlays to ${BOOT_CONFIG}"
  local marker="# --- keelos begin (managed, do not edit inside) ---"
  local endmark="# --- keelos end ---"
  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"
        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
}


install_kiosk_support() {
  [[ $A_KIOSK == y ]] || return 0

  info "Installing kiosk login/session support"
  local pam_file="/etc/pam.d/keelos-kiosk"
  local profile_dir="/var/lib/keelos/home/.config/chromium-keelos"

  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: write ${pam_file} with pam_systemd session registration${C_RESET}"
    say "${C_DIM}   dry-run: prepare ${profile_dir} for Chromium kiosk profile${C_RESET}"
    return 0
  fi

  if ! find /lib /usr/lib -type f -name pam_systemd.so -print -quit 2>/dev/null | grep -q .; then
    die "Kiosk requires pam_systemd.so (libpam-systemd/systemd-pam). Package install completed but the module was not found."
  fi

  cat > "$pam_file" <<'EOF'
#%PAM-1.0
auth       required pam_permit.so
account    required pam_permit.so
session    required pam_unix.so
session    required pam_systemd.so
EOF
  chmod 0644 "$pam_file"
  record file "$pam_file"

  mkdir -p "$profile_dir"
  chown -R keelos:keelos /var/lib/keelos/home
  chmod 0700 /var/lib/keelos/home
  ok "kiosk PAM/logind session support ready"
}

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

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 keelos-mtu-rs422.service keelos-nmea0183-bridge.service keelos-can-sniffer.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)
        local j1939_extra="" ep em
        IFS=',' read -r -a _eps <<< "$A_ENGINE_PROFILES"
        for ep in "${_eps[@]}"; do [[ -n $ep ]] && j1939_extra+=" --engine-profile ${ep}"; done
        if [[ -n $A_ENGINE_MAP ]]; then
          IFS=',' read -r -a _ems <<< "$A_ENGINE_MAP"
          for em in "${_ems[@]}"; do [[ -n $em ]] && j1939_extra+=" --engine-map ${em}"; done
        fi
        [[ $A_J1939_RAW_CAPTURE == y ]] && j1939_extra+=" --raw-capture --raw-capture-path /var/log/keelos-j1939-proprietary.ndjson"
        write_unit "$u" <<EOF
[Unit]
Description=KeelOS bidirectional NMEA 2000 <-> SAE J1939 translator (${A_ENGINE_PROFILES})
After=keelos-can.service
Wants=keelos-can.service
Before=keelos-dashboard.service keelos-proprietary-n2k.service

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

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-proprietary-n2k.service)
        write_unit "$u" <<EOF
[Unit]
Description=KeelOS documented proprietary NMEA 2000 compatibility publisher
After=keelos-can.service keelos-j1939.service keelos-j1708-bridge.service keelos-mtu-rs422.service keelos-nmea0183-bridge.service
Wants=keelos-can.service
Before=keelos-dashboard.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/keelos/proprietary_n2k.py --iface ${N2K_IFACE} --config /etc/keelos/proprietary-n2k.json
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-nmea0183-bridge.service)
        local nmea_exclude=""
        [[ $A_MTU_RS422 == y && $A_MTU_RS422_PORT != auto ]] && nmea_exclude="--exclude-port ${A_MTU_RS422_PORT}"
        write_unit "$u" <<EOF
[Unit]
Description=KeelOS NMEA 0183 RS422 -> NMEA 2000 translator
After=network.target keelos-can.service
Wants=keelos-can.service
Before=keelos-dashboard.service keelos-proprietary-n2k.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/keelos/nmea0183_n2k.py --port ${A_0183_PORT} --adapter ${A_0183_ADAPTER} --channel ${A_0183_CHANNEL} --baud ${A_0183_BAUD} --n2k-iface ${N2K_IFACE} --source ${A_0183_SOURCE} ${nmea_exclude}
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-mtu-rs422.service)
        local mtu_exclude=""
        [[ $A_J1708 == y && $A_J1708_PORT != auto ]] && mtu_exclude="--exclude-port ${A_J1708_PORT}"
        [[ -z $mtu_exclude && $A_0183 == y && $A_0183_PORT != auto ]] && mtu_exclude="--exclude-port ${A_0183_PORT}"
        write_unit "$u" <<EOF
[Unit]
Description=KeelOS MTU ECS-5 RS422 -> NMEA 2000 semantic bridge
After=network.target keelos-can.service
Wants=keelos-can.service
Before=keelos-dashboard.service keelos-j1939.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/keelos/mtu_rs422.py --port ${A_MTU_RS422_PORT} --adapter ${A_MTU_RS422_ADAPTER} --channel ${A_MTU_RS422_CHANNEL} --baud ${A_MTU_RS422_BAUD} --data-bits ${A_MTU_RS422_DATABITS} --parity ${A_MTU_RS422_PARITY} --stop-bits ${A_MTU_RS422_STOPBITS} --profile ${A_MTU_RS422_PROFILE} --n2k-iface ${N2K_IFACE} ${mtu_exclude}
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
EOF
        ;;
      keelos-can-sniffer.service)
        write_unit "$u" <<EOF
[Unit]
Description=KeelOS passive protocol-aware CAN sniffer (${A_SNIFFER_PROTOCOLS})
After=keelos-can.service
Wants=keelos-can.service
Before=keelos-dashboard.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/keelos/can_sniffer.py --interfaces ${SNIFFER_IFACES} --protocols ${A_SNIFFER_PROTOCOLS} --log-path /var/log/keelos-can-sniffer.ndjson --max-mb ${A_SNIFFER_MAX_MB} --summary-interval ${A_SNIFFER_SUMMARY}
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 keelos-mtu-rs422.service keelos-nmea0183-bridge.service keelos-proprietary-n2k.service keelos-can-sniffer.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 plymouth-quit-wait.service getty@tty1.service dbus.socket systemd-logind.service
Before=graphical.target
Wants=keelos-dashboard.service dbus.socket systemd-logind.service
Conflicts=getty@tty1.service
ConditionPathExists=/dev/tty0

[Service]
Type=simple
User=keelos
Group=keelos
PAMName=keelos-kiosk
UtmpIdentifier=tty1
UtmpMode=user
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
TTYVTDisallocate=yes
StandardInput=tty-fail
StandardOutput=journal
StandardError=journal
Environment=HOME=/var/lib/keelos/home
Environment=XDG_RUNTIME_DIR=/run/keelos-kiosk
Environment=XDG_SESSION_TYPE=wayland
RuntimeDirectory=keelos-kiosk
RuntimeDirectoryMode=0700
ExecStartPre=/usr/bin/env bash -c 'for i in {1..120}; do (exec 3<>/dev/tcp/127.0.0.1/3000) 2>/dev/null && exec 3>&- && exit 0; sleep 1; done; echo "KeelOS kiosk: Signal K did not answer on 127.0.0.1:3000 within 120 seconds" >&2; exit 1'
ExecStart=/usr/bin/env bash -c 'CAGE="$(command -v cage)"; BROWSER="$(command -v chromium-browser || command -v chromium)"; test -x "$CAGE" || { echo "cage not found" >&2; exit 127; }; test -x "$BROWSER" || { echo "Chromium not found" >&2; exit 127; }; exec "$CAGE" -- "$BROWSER" \
  --kiosk --noerrdialogs --disable-session-crashed-bubble --disable-infobars \
  --no-first-run --start-maximized --check-for-update-interval=31536000 \
  --user-data-dir=/var/lib/keelos/home/.config/chromium-keelos \
  --ozone-platform=wayland http://127.0.0.1:3000'
ExecStartPost=+/bin/sh -c 'command -v chvt >/dev/null 2>&1 && chvt 1 || true'
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
}

configure_kiosk_autoboot() {
  [[ $A_KIOSK == y ]] || return 0

  info "Configuring automatic fullscreen kiosk boot"

  if (( DRY_RUN )); then
    say "${C_DIM}   dry-run: save existing systemd default target once${C_RESET}"
    say "${C_DIM}   dry-run: systemctl set-default graphical.target${C_RESET}"
    say "${C_DIM}   dry-run: enable keelos-dashboard.service + keelos-kiosk.service${C_RESET}"
    return 0
  fi

  mkdir -p "$STATE_DIR"
  local target_backup="${STATE_DIR}/default-target.before-kiosk"
  if [[ ! -f $target_backup ]]; then
    systemctl get-default > "$target_backup" 2>/dev/null || printf '%s\n' "multi-user.target" > "$target_backup"
  fi

  systemctl set-default graphical.target >/dev/null
  systemctl daemon-reload
  systemctl enable keelos-dashboard.service keelos-kiosk.service >/dev/null
  systemctl reset-failed keelos-dashboard.service keelos-kiosk.service >/dev/null 2>&1 || true

  ok "default boot target: graphical.target"
  ok "automatic kiosk chain enabled: Signal K -> Cage -> Chromium on tty1"
}

maybe_reboot_into_kiosk() {
  [[ $A_KIOSK == y ]] || return 0

  if (( DRY_RUN )); then
    info "Kiosk selected: a real install will offer to reboot into the fullscreen gauge display."
    return 0
  fi

  say ""
  info "KeelOS kiosk is installed for automatic startup."
  info "After reboot, tty1 will be owned by Cage and Chromium will open http://127.0.0.1:3000."

  local reboot_now="y"
  if (( ! ASSUME_YES )); then
    read -r -p "${C_GREEN}> ${C_RESET}Reboot now into KeelOS kiosk mode? [Y/n]: " reboot_now || true
  fi

  if [[ ${reboot_now,,} == n* ]]; then
    warn "Reboot deferred. Kiosk autostart is already configured; run 'sudo reboot' when ready."
    return 0
  fi

  warn "Rebooting into KeelOS kiosk mode in 5 seconds..."
  sync
  sleep 5
  systemctl reboot
}

#--------------------------------------------------------------------------
# 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 == 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 Keelas.sh are listed below; remove manually if unused"
  grep '^pkg|' "$MANIFEST" | cut -d'|' -f2 | tr '\n' ' '; say ""

  local target_backup="${STATE_DIR}/default-target.before-kiosk"
  if [[ -f $target_backup ]]; then
    local previous_target
    previous_target="$(tr -d '[:space:]' < "$target_backup")"
    if [[ $previous_target =~ ^[a-zA-Z0-9_.@-]+\.target$ ]]; then
      if (( DRY_RUN )); then
        say "${C_DIM}   dry-run: restore default target ${previous_target}${C_RESET}"
      else
        systemctl set-default "$previous_target" >/dev/null 2>&1 || true
      fi
      ok "restored pre-KeelOS default target: ${previous_target}"
    fi
  fi

  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 (( MTU_GUIDE_ONLY )); then banner; mtu_rs422_prereq_advisory; exit 0; fi
  if (( RS422_GUIDE_ONLY )); then banner; rs422_prereq_advisory; exit 0; fi
  if (( UNINSTALL      )); then do_uninstall; exit 0; fi

  need_root
  mkdir -p "$(dirname "$LOG_FILE")"; log "Keelas.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

  [[ ( $A_MTU_RS422 == y || $A_0183 == y ) && $MTU_ADVISORY_SHOWN -eq 0 ]] && rs422_prereq_advisory
  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_can_sniffer
  install_protocol_bridge
  install_proprietary_n2k
  install_j1587_bridge
  install_nmea0183_bridge
  install_mtu_rs422_bridge
  install_kiosk_support
  apply_units
  configure_kiosk_autoboot
  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 profiles: python3 /usr/local/lib/keelos/protocol_bridge.py --list-engine-profiles"
  [[ $A_CONVERT == y ]] && info "J1939 passive proprietary capture (if enabled): /var/log/keelos-j1939-proprietary.ndjson"
  [[ $A_CAN_SNIFFER == y ]] && info "CAN sniffer capture: /var/log/keelos-can-sniffer.ndjson (rotates at ${A_SNIFFER_MAX_MB} MB)"
  [[ $A_CAN_SNIFFER == y ]] && info "CAN sniffer protocol inventory: python3 /usr/local/lib/keelos/can_sniffer.py --list-protocols"
  [[ $A_N2K_PROPRIETARY == y ]] && info "Proprietary N2K mappings: python3 /usr/local/lib/keelos/proprietary_n2k.py --list-mappings"
  [[ $A_J1708 == y ]] && info "J1587/N2K mappings: python3 /usr/local/lib/keelos/j1587_n2k.py --list-mappings"
  [[ $A_0183 == y ]] && info "NMEA 0183 mappings: python3 /usr/local/lib/keelos/nmea0183_n2k.py --list-mappings"
  [[ $A_MTU_RS422 == y ]] && info "MTU RS422 status: python3 /usr/local/lib/keelos/mtu_rs422.py --adapter ${A_MTU_RS422_ADAPTER} --channel ${A_MTU_RS422_CHANNEL} --baud ${A_MTU_RS422_BAUD} --profile ${A_MTU_RS422_PROFILE} --list-mappings"
  [[ $A_KIOSK == y ]] && info "Kiosk autostart: graphical.target -> keelos-dashboard.service -> keelos-kiosk.service -> Cage -> Chromium"
  [[ $A_KIOSK == y ]] && info "Kiosk logs after boot: journalctl -b -u keelos-dashboard.service -u keelos-kiosk.service"
  info "Re-run anytime — it's idempotent. Uninstall: Keelas.sh --uninstall"
  log "Keelas.sh done"
  maybe_reboot_into_kiosk
}

main "$@"
