#!/bin/sh
# Inkline managed USB modes, adapted from Inkline Outpost (see README.md).
set -eu
umask 077
BASE=$(CDPATH= cd -- "$(dirname -- "$(readlink -f "$0")")" && pwd)
PATH="/home/root/.local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export PATH
G=/sys/kernel/config/usb_gadget
NET=$G/g_ether
HID=$G/inkline_keyboard
TABLET=$G/inkline_tablet
TABLET_RUN=/run/inkline-tablet
OLD=$G/rm_keyboard
RUN=/run/inkline-usb
FFS=$RUN/ffs
ROLE=/sys/bus/platform/devices/ci_hdrc.0/role
UDC=ci_hdrc.0
MODEM=/home/root/usb-modes/bin/modem-data
RADIO=/home/root/usb-modes/bin/gotenna-data
fail() { echo "inkline-usb: $*" >&2; exit 1; }
usage() {
    cat <<'HELP'
Usage: inkline-usb network          Restore stock USB Ethernet and USB SSH
       inkline-usb send-network on|off  Toggle networking alongside Send HID
       inkline-usb send-keyboard [270|0|90|180]  Folio keyboard + pen HID
       inkline-usb send-tablet [270|0|90|180]  Act as a USB pen/mouse (default 270°)
       inkline-usb receive          Receive keyboard, mouse and Ethernet via a powered hub
       inkline-usb modem            Enable modem controls in Receive without dialing
       inkline-usb modem-policy off|on|auto NUMBER IDLE_SECONDS
       inkline-usb modem-settings NUMBER IDLE_SECONDS
       inkline-usb modem-share off|on
       inkline-usb gotenna          Enable radio controls in Receive without connecting
       inkline-usb gotenna-settings Read a JSON profile from stdin
       inkline-usb gotenna-connect|gotenna-disconnect
       inkline-usb status|state     Inspect USB; state prints one mode name
       inkline-usb recover-after SECONDS  Schedule Ethernet recovery (10..3600)
Use the tablet screen or Wi-Fi SSH when changing away from network mode.
Modes are temporary; reboot returns to stock USB Ethernet.
Use keyboard-send --help to send a UTF-8 file. Modem requires the Outpost bundle.
Terminal proxy settings stay fixed. Only On or selected Auto may start a call.

goTenna and goTenna Mesh are trademarks of goTenna, Inc. Inkline and Inkline Outpost are independent projects, not affiliated with or endorsed by goTenna, Inc.
HELP
}
role() { cat "$ROLE"; }
bound() { [ -f "$1/UDC" ] && [ -n "$(cat "$1/UDC")" ]; }
state() {
    if [ ! -r "$ROLE" ]; then echo unavailable
    elif [ "$(role)" = host ]; then echo host
    elif bound "$HID" && [ -f "$RUN/ready" ]; then echo keyboard
    elif bound "$TABLET" && [ -f "$TABLET_RUN/ready" ]; then echo tablet
    elif bound "$NET" && [ -f "$RUN/ready" ] && [ -L "$NET/configs/c.1/ffs.inklinekeys-composite" ]; then echo keyboard
    elif bound "$NET" && [ -f "$TABLET_RUN/ready" ] && [ -L "$NET/configs/c.1/ffs.inklinepen-composite" ]; then echo tablet
    elif bound "$NET"; then echo network
    elif [ "$(cat "$RUN/send-network" 2>/dev/null || :)" = off ]; then echo off
    else echo other; fi
}
set_role() {
    [ "$(role)" = "$1" ] || printf '%s\n' "$1" > "$ROLE"
    [ "$(role)" = "$1" ] || fail "Controller did not enter $1 mode."
}
read_radio_service_state() {
    radio_properties=$(systemctl show --property=LoadState --property=ActiveState outpost-gotenna.service) ||
        fail 'Cannot inspect the goTenna service; USB mode is unchanged.'
    radio_load_state= radio_active_state=
    while IFS='=' read -r property value; do
        case "$property" in
            LoadState) radio_load_state=$value;;
            ActiveState) radio_active_state=$value;;
        esac
    done <<EOF
$radio_properties
EOF
    case "$radio_load_state" in loaded|not-found|stub|merged|masked|error|bad-setting) ;;
        *) fail 'Cannot read the goTenna service state; USB mode is unchanged.';; esac
    case "$radio_active_state" in active|reloading|inactive|failed|activating|deactivating|maintenance|refreshing) ;;
        *) fail 'Cannot read the goTenna service state; USB mode is unchanged.';; esac
}
radio_service_absent() {
    case "$radio_load_state:$radio_active_state" in
        not-found:inactive|not-found:failed) return 0;;
        *) return 1;;
    esac
}
disconnect_radio() {
    # Outpost is optional. Firmware updates can keep its /home helpers while
    # removing system units; an absent, stopped service needs no disconnect.
    read_radio_service_state
    if radio_service_absent; then return 0; fi
    if [ ! -x "$RADIO" ]; then
        case "$radio_active_state" in inactive|failed) return 0;; esac
        fail 'Radio disconnect helper is missing; USB mode is unchanged.'
    fi
    if radio_output=$("$RADIO" disconnect 2>&1); then return 0; fi
    # The unit can disappear between inspection and stop. Suppress that
    # harmless error only after confirming it is absent and no longer active.
    read_radio_service_state
    if radio_service_absent; then return 0; fi
    [ -z "$radio_output" ] || printf '%s\n' "$radio_output" >&2
    fail 'Cannot disconnect goTenna; USB mode is unchanged.'
}
unbind() { if bound "$1"; then printf '\n' > "$1/UDC"; fi; }
stop_keyboard() {
    # FunctionFS must be unbound before its daemons are stopped.
    if [ -d "$NET/functions/ffs.inklinepen-composite" ]; then unbind "$NET"; fi
    unbind "$TABLET"
    systemctl stop inkline-usb-tablet.service 2>/dev/null || :
    rm -f "$TABLET_RUN/ready" "$TABLET_RUN/enabled"
    unbind "$HID"
    systemctl stop inkline-usb-keyboard.service 2>/dev/null || :
    # Outpost may have left its keyboard gadget running. Preserve its files.
    if bound "$OLD"; then
        unbind "$OLD"
        systemctl stop rm-usb-keyboard.service 2>/dev/null || :
    fi
    rm -f "$RUN/ready" "$RUN/enabled" "$RUN/folio-required"
}
network() {
    stop_keyboard
    remove_hid_links
    restore_network_links
    printf 'on\n' > "$RUN/send-network"
    set_role gadget
    [ -d "$NET" ] || fail 'Stock g_ether is missing; reboot to recreate it.'
    [ "$(cat "$NET/UDC")" = "$UDC" ] || printf '%s\n' "$UDC" > "$NET/UDC"
    for interface in usb0 usb1; do
        [ -r "/sys/class/net/$interface/uevent" ] || fail "USB $interface has missing kernel records; save work and reboot."
    done
    systemctl start dropbear-usb0.socket dropbear-usb1.socket
    for interface in usb0 usb1; do
        systemctl is-active --quiet "dropbear-$interface.socket" || fail "USB SSH on $interface did not start."
    done
}
host() { stop_keyboard; unbind "$NET"; remove_hid_links; restore_network_links; set_role host; }
select_modem() {
    # The old mode file is retained for older Outpost radio drivers. Modem
    # policy is independent of it, so selecting a modem must not replace it.
    if [ ! -f "$RUN/modem-selected" ]; then
        if [ "$(cat /run/rm-usb/mode 2>/dev/null || :)" != modem ]; then "$MODEM" select; fi
        touch "$RUN/modem-selected"
    fi
}
folio_available() {
    for input in /sys/class/input/event*/device; do
        [ "$(cat "$input/name" 2>/dev/null || :)" = rM_Keyboard ] || continue
        [ "$(cat "$input/id/bustype" 2>/dev/null || :)" = 0019 ] || continue
        [ "$(cat "$input/id/vendor" 2>/dev/null || :)" = 2edd ] || continue
        [ "$(cat "$input/id/product" 2>/dev/null || :)" = 0001 ] && return 0
    done
    return 1
}
remember_network_links() {
    [ ! -f "$RUN/network-links" ] || return 0
    # Only the stock rM2 layout is supported. Refuse unfamiliar firmware
    # before unbinding anything, instead of guessing how to restore it.
    [ -L "$NET/configs/c.1/rndis.usb0" ] && [ -L "$NET/configs/c.2/ecm.usb1" ] ||
        fail 'Stock USB network configuration is unavailable; USB is unchanged.'
    for entry in 'c.1 rndis.usb0' 'c.2 ecm.usb1'; do
        set -- $entry
        [ "$(readlink -f "$NET/configs/$1/$2")" = "$NET/functions/$2" ] || fail 'Unexpected USB network link.'
    done
    printf 'c.1 rndis.usb0\nc.2 ecm.usb1\n' > "$RUN/network-links.tmp"
    mv "$RUN/network-links.tmp" "$RUN/network-links"
}
restore_network_links() {
    [ -f "$RUN/network-links" ] || return 0
    while read -r config function; do
        [ -L "$NET/configs/$config/$function" ] || ln -s "$NET/functions/$function" "$NET/configs/$config/$function"
    done < "$RUN/network-links"
}
remove_network_links() {
    while read -r config function; do rm -f "$NET/configs/$config/$function"; done < "$RUN/network-links"
}
remove_hid_links() {
    for config in "$NET"/configs/*; do
        rm -f "$config/ffs.inklinekeys-composite" "$config/ffs.inklinepen-composite"
    done
}
start_function() {
    # Each HID function is shared by both stock USB configurations.
    function=$1 runtime=$2 service=$3
    mkdir -p "$NET/functions/ffs.$function" "$runtime/ffs"
    # Remount to migrate older standalone HID gadgets and reset stale ep0.
    if mountpoint -q "$runtime/ffs"; then umount "$runtime/ffs"; fi
    mount -t functionfs "$function" "$runtime/ffs"
    for config in c.1 c.2; do
        ln -s "$NET/functions/ffs.$function" "$NET/configs/$config/ffs.$function"
    done
    systemctl reset-failed "$service" 2>/dev/null || :
    systemctl start "$service"
    count=0
    until [ -f "$runtime/ready" ]; do
        systemctl is-active --quiet "$service" || fail "HID daemon failed to start: $service"
        count=$((count+1)); [ "$count" -lt 100 ] || fail "HID daemon timed out: $service"
        sleep .1
    done
}
composite() {
    send_mode=$1 rotation=$2 networking=$3
    if [ "$send_mode" != network ]; then
        command -v python3 >/dev/null || fail 'Install the Python utility from the Inkline website first.'
        python3 -B -c 'import sys; sys.exit(sys.version_info < (3, 9))' || fail 'Python 3.9 or later is required.'
    fi
    remember_network_links
    stop_keyboard
    unbind "$NET"
    remove_hid_links
    remove_network_links
    [ "$networking" != on ] || restore_network_links
    if [ "$send_mode" != network ]; then
        mkdir -p "$TABLET_RUN"
        printf '%s\n' "$rotation" > "$TABLET_RUN/rotation"
        if [ "$send_mode" = keyboard ]; then
            touch "$RUN/folio-required"
            start_function inklinekeys-composite "$RUN" inkline-usb-keyboard.service
        fi
        start_function inklinepen-composite "$TABLET_RUN" inkline-usb-tablet.service
    fi
    set_role gadget
    if [ "$send_mode" != network ] || [ "$networking" = on ]; then printf '%s\n' "$UDC" > "$NET/UDC"; fi
    printf '%s\n' "$networking" > "$RUN/send-network"
    if [ "$networking" = on ]; then systemctl start dropbear-usb0.socket dropbear-usb1.socket; fi
}

action=${1:-help}; [ "$#" -eq 0 ] || shift
case "$action" in
    help|--help|-h) usage; exit 0;;
    state) state; exit 0;;
    configuration)
        current=$(state)
        printf '%s' "$current"
        if [ "$current" != host ] && bound "$NET" && [ -L "$NET/configs/c.1/rndis.usb0" ]; then printf ' network'; fi
        printf '\n'; exit 0;;
    status)
        printf 'USB mode: '; state
        printf 'Computer connection: '; cat /sys/class/udc/ci_hdrc.0/state 2>/dev/null || :
        printf 'Inkline sender: '; systemctl is-active inkline-usb-keyboard.service || :
        printf 'Tablet interface: '; systemctl is-active inkline-usb-tablet.service || :
        printf 'Wi-Fi: '; ip -4 addr show dev wlan0 | sed -n '/inet /p'
        exit 0;;
    keyboard) action=send-keyboard;;
    host|receive-keyboard) action=receive;;
    satellite) action=modem-on;;
    satellite-disconnect) action=modem-off;;
esac
[ "$(id -u)" = 0 ] || fail 'Run on the tablet as root.'
[ -r "$ROLE" ] && [ -d "$NET" ] || fail 'The stock reMarkable 2 USB controller is unavailable.'
mkdir -p "$RUN" /run/rm-usb
if [ "$action" = recover-after ]; then
    [ "$#" -eq 1 ] || fail 'Usage: inkline-usb recover-after SECONDS'
    case "$1" in ''|*[!0-9]*) fail 'SECONDS must be an integer.';; esac
    [ "$1" -ge 10 ] && [ "$1" -le 3600 ] || fail 'Use 10..3600 seconds.'
    exec systemd-run --unit="inkline-usb-recover-$$" --on-active="${1}s" "$BASE/inkline-usb" network
fi
case "$action" in
    send-tablet|send-keyboard)
        [ "$#" -le 1 ] || fail 'Expected an optional rotation: 0, 90, 180 or 270.'
        case "${1:-270}" in 0|90|180|270) ;; *) fail 'Rotation must be 0, 90, 180 or 270.';; esac;;
    send-network) [ "$#" -eq 1 ] || fail 'Expected on or off.'; case "$1" in on|off) ;; *) fail 'Expected on or off.';; esac;;
    modem-share) [ "$#" -eq 1 ] || fail 'Expected on or off.';;
    gotenna-settings|gotenna-connect|gotenna-disconnect) [ "$#" -eq 0 ] || fail 'Unexpected arguments.';;
    modem-settings) [ "$#" -eq 2 ] || fail 'Expected dial number and idle delay.';;
    modem-policy) [ "$#" -eq 3 ] || fail 'Expected policy, dial number and idle delay.';;
    network|receive|modem|modem-on|modem-off|gotenna) [ "$#" -eq 0 ] || fail 'Unexpected arguments.';;
    *) usage >&2; exit 2;;
esac
if [ "$action" != gotenna-settings ] && [ "$action" != gotenna-connect ] && [ "$action" != gotenna-disconnect ] && [ "$action" != modem-share ] && [ "$action" != network ] && [ "$action" != modem-settings ] && [ "$action" != modem-off ] && [ "$action" != modem-policy ] && [ -n "${SSH_CONNECTION:-}" ]; then
    server=$(printf '%s\n' "$SSH_CONNECTION" | awk '{print $3}')
    for interface in usb0 usb1; do
        addresses=$(ip addr show dev "$interface" 2>/dev/null | awk '$1 == "inet" || $1 == "inet6" {split($2,a,"/"); print a[1]}')
        for address in $addresses; do
            [ "$server" != "$address" ] || fail 'Use the tablet screen or Wi-Fi SSH before changing USB mode.'
        done
    done
fi
if [ "$action" = send-keyboard ]; then
    folio_available || fail 'Open and attach the Type Folio before starting Send keyboard / Typewriter.'
fi
# Coordinate with the existing Outpost scripts without modifying their bundle.
exec 9>/run/rm-usb/mode.lock
attempts=0
until flock -n -x 9; do
    attempts=$((attempts + 1))
    [ "$attempts" -lt 80 ] || fail 'Another USB mode change is in progress.'
    sleep .1
done
case "$action" in
    gotenna-settings)
        [ -x "$RADIO" ] || fail 'Install Inkline Outpost for goTenna Mesh radio support first.'
        exec "$RADIO" configure;;
    gotenna-connect)
        [ -x "$RADIO" ] || fail 'Install Inkline Outpost for goTenna Mesh radio support first.'
        [ "$(state)" = host ] || fail 'Start Receive first.'
        "$RADIO" check
        previous=$(cat /run/rm-usb/mode 2>/dev/null || :)
        printf 'gotenna\n' > /run/rm-usb/mode
        if "$RADIO" connect; then exit 0; fi
        printf '%s\n' "$previous" > /run/rm-usb/mode
        fail 'Radio connection failed; other Receive devices are unchanged.';;
    gotenna-disconnect)
        disconnect_radio
        if [ "$(state)" = host ]; then printf 'host\n' > /run/rm-usb/mode; fi
        echo 'goTenna disconnected.'
        exit 0;;
    modem-share)
        [ -x "$MODEM" ] || fail 'Install the Outpost modem bundle first.'
        [ "$(state)" = host ] || fail 'Start Receive first.'
        exec "$MODEM" share "$1";;
    gotenna)
        [ -x "$RADIO" ] || fail 'Install Inkline Outpost for goTenna Mesh radio support first.'
        "$RADIO" check
        if [ "$(state)" = host ]; then exit 0; fi;;
    modem-settings)
        [ -x "$MODEM" ] || fail 'Install the Inkline Outpost modem bundle first.'
        exec "$MODEM" configure --number "$1" --idle-seconds "$2";;
    modem-policy)
        [ -x "$MODEM" ] || fail 'Install the Inkline Outpost modem bundle first.'
        [ "$(state)" = host ] || fail 'Start Receive first.'
        case "$1" in off|on|auto) ;; *) fail 'Choose Off, On or Auto.';; esac
        # Off must work even if the number currently being edited is invalid.
        [ "$1" != off ] || exec "$MODEM" policy off
        "$MODEM" configure --number "$2" --idle-seconds "$3"
        select_modem
        exec "$MODEM" policy "$1";;
    modem-off)
        [ ! -x "$MODEM" ] || "$MODEM" disconnect
        exit 0;;
    modem|modem-on)
        [ -x "$MODEM" ] || fail 'Install the Inkline Outpost modem bundle first.'
        "$MODEM" check
        if [ "$(state)" = host ]; then
            select_modem
            [ "$action" != modem-on ] || "$MODEM" policy on
            exit 0
        fi;;
    receive)
        # Reopening Receive must preserve calls, mesh connections and the hub.
        [ "$(state)" != host ] || exit 0;;
esac
for gadget in "$G"/*; do
    case "$gadget" in "$NET"|"$HID"|"$TABLET"|"$OLD") ;; *)
        if bound "$gadget"; then fail "Another gadget is using USB: ${gadget##*/}"; fi;;
    esac
done
# Validate stock topology before disrupting any Receive devices.
case "$action" in send-keyboard|send-tablet|send-network) remember_network_links;; esac
networking=$(cat "$RUN/send-network" 2>/dev/null || echo on)
current=$(state)
# A fresh Send session always starts with USB networking available. An explicit
# Off toggle lasts only for this Send session (including HID/profile changes).
case "$current" in keyboard|tablet|network|off) ;; *) networking=on;; esac
# Stop the radio before another USB consumer can open its serial port.
disconnect_radio
# Disarm Auto even if it is waiting between calls before assigning USB elsewhere.
if [ -x "$MODEM" ]; then "$MODEM" release
elif systemctl is-active --quiet rm-usb-ppp.service; then fail 'Modem disconnect helper is missing.'; fi
rm -f "$RUN/modem-selected"

rollback() {
    rc=$?
    trap - EXIT HUP INT TERM
    if [ "$rc" -ne 0 ]; then
        echo 'USB switch failed; attempting to restore USB Ethernet.' >&2
        [ ! -x "$MODEM" ] || "$MODEM" release || :
        (network && printf 'network\n' > /run/rm-usb/mode) || echo 'Ethernet recovery failed; use the screen or Wi-Fi to retry, or reboot.' >&2
    fi
    exit "$rc"
}
trap rollback EXIT
trap 'exit 130' INT
trap 'exit 143' HUP TERM
case "$action" in
    network) network; printf 'network\n' > /run/rm-usb/mode; echo 'USB Ethernet and USB SSH restored (10.11.99.1).';;
    receive) host; printf 'host\n' > /run/rm-usb/mode; echo 'Receive is ready. Keyboard, mouse, Ethernet, modem and radio can share a powered hub.';;
    modem|modem-on)
        host; printf 'host\n' > /run/rm-usb/mode; select_modem
        [ "$action" != modem-on ] || "$MODEM" policy on;;
    gotenna) host; printf 'host\n' > /run/rm-usb/mode; echo 'Receive is ready. Turn on goTenna to join the public mesh.';;
    send-network)
        case "$current" in keyboard|tablet) target=$current;; *) target=network;; esac
        # Detaching a Folio never enables keyboard sending via this toggle.
        if [ "$target" = keyboard ] && ! folio_available; then target=tablet; fi
        rotation=$(cat "$TABLET_RUN/rotation" 2>/dev/null || echo 270)
        composite "$target" "$rotation" "$1"
        printf '%s\n' "$target" > /run/rm-usb/mode
        echo "USB networking $1. HID selection is preserved; press Start to resume live input.";;
    send-keyboard) composite keyboard "${1:-270}" "$networking"; printf 'keyboard\n' > /run/rm-usb/mode; echo 'USB keyboard and pen ready. Open Typewriter with the Folio attached; fingers control the screen.';;
    send-tablet) composite tablet "${1:-270}" "$networking"; printf 'tablet\n' > /run/rm-usb/mode; echo 'USB pen/mouse ready. Connect a laptop, then choose Start in Send tablet or run tablet-send.';;
esac
