#!/usr/bin/env bash
#
# Aillio roaster — Linux udev fix
#
#   curl -fsSL https://public.roast.world/linux-udev-fix.sh | bash
#
# Installs /etc/udev/rules.d/99-aillio-roaster.rules, which does two separate
# things. Only doing the second is the configuration found on machines where the
# roaster connects but never sends data:
#
#   1. Keeps ModemManager off the port. Aillio roasters enumerate as USB CDC ACM
#      devices using STMicroelectronics' stock Virtual COM Port identifiers, so on
#      Linux they appear as /dev/ttyACM*. ACM is the historical modem interface
#      class, and ModemManager probes cdc-acm ports for modems by default: it opens
#      the port and writes AT commands to it. A tty is not exclusive, and two
#      readers do not each get a copy of the stream — every byte goes to exactly one
#      of them. So while ModemManager is probing, some of the roaster's replies are
#      delivered to it instead of to RoasTime.
#
#   2. Grants the desktop user access to the device.
#
# Safe to run more than once: an install that already matches changes nothing.
#
# Everything is inside main(), called on the last line. That matters for a script
# piped into bash: if the download is cut short, bash runs whatever arrived. With
# the body in a function, a truncated file defines part of a function and then ends
# without ever calling it, instead of executing half an install.

set -euo pipefail

AILLIO_VENDOR_ID="0483"

# Product IDs must match DeviceFriendlyNames in the roastime-comms transport.
# A test in that repository fails if a roaster is added there without being added
# here.
AILLIO_PRODUCT_IDS=(
    "5741"  # Aillio Bullet R1
    "a27e"  # Aillio Bullet R1
    "a27f"  # Aillio AiO
    "a4cd"  # Aillio Bullet R2
)

RULE_FILE="/etc/udev/rules.d/99-aillio-roaster.rules"

say()  { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
die()  { printf 'Error: %s\n' "$*" >&2; exit 1; }

# detect_serial_group returns the serial group this distribution uses, or an empty
# string if none of the usual ones exist.
#
# It reads /etc/group rather than inspecting a device node. The group on something
# like /dev/ttyS0 belongs to a legacy motherboard serial port that has nothing to do
# with the roaster and may not exist at all, and opening a /dev/tty* node to look at
# it is not free: opening a serial device asserts DTR, which can reset whatever is
# attached to it.
detect_serial_group() {
    local group
    for group in uucp dialout plugdev; do
        if getent group "$group" >/dev/null 2>&1; then
            printf '%s' "$group"
            return 0
        fi
    done
    printf ''
}

# build_rules prints the rules file content. $1 is the serial group, or "" for none.
build_rules() {
    local serial_group="$1" pid subsystem

    cat <<EOF
# Aillio roaster udev rules
#
# Installed by https://public.roast.world/linux-udev-fix.sh
#
# 1. Keep ModemManager off the port.
#
# Aillio roasters enumerate as USB CDC ACM devices, which ModemManager treats as
# modem candidates by default: it opens the port and writes AT commands to it. A
# tty is not exclusive and every byte goes to exactly one reader, so its probing
# takes replies meant for RoasTime, and a port reset makes in-flight writes fail
# with EIO. The property is set on the USB device and on the tty, because
# ModemManager consults both.
#
# 2. Let the desktop user open the device.
#
# uaccess hands the device to whoever is logged in at the seat, via systemd-logind:
# no group membership, no logout. The group rule below it, if present, covers
# headless and non-systemd setups where uaccess does not apply.
#
# Verify:
#     udevadm info -q property -n /dev/ttyACM0 | grep ID_MM
EOF

    for pid in "${AILLIO_PRODUCT_IDS[@]}"; do
        echo
        for subsystem in usb tty; do
            printf 'SUBSYSTEM=="%s", ATTRS{idVendor}=="%s", ATTRS{idProduct}=="%s", ENV{ID_MM_DEVICE_IGNORE}="1"\n' \
                "$subsystem" "$AILLIO_VENDOR_ID" "$pid"
        done
    done

    echo
    echo '# Grant the logged-in desktop user access to every Aillio device'
    for subsystem in usb tty; do
        printf 'SUBSYSTEM=="%s", ATTRS{idVendor}=="%s", TAG+="uaccess"\n' \
            "$subsystem" "$AILLIO_VENDOR_ID"
    done

    if [ -n "$serial_group" ]; then
        echo
        echo '# Fallback for sessions uaccess does not cover (headless, non-systemd).'
        echo '# 0660 rather than 0666: the group is the grant, so there is no reason to'
        echo '# open the roaster to every account on the machine.'
        for subsystem in usb tty; do
            printf 'SUBSYSTEM=="%s", ATTRS{idVendor}=="%s", MODE="0660", GROUP="%s"\n' \
                "$subsystem" "$AILLIO_VENDOR_ID" "$serial_group"
        done
    fi
}

# run_privileged runs its arguments as root, using sudo only when it is needed.
#
# sudo reads a password from the terminal directly, not from stdin, so it still
# works when this script itself arrived on stdin from curl.
run_privileged() {
    if [ "$(id -u)" -eq 0 ]; then
        "$@"
    else
        sudo "$@"
    fi
}

report_modemmanager() {
    if ! pgrep -x ModemManager >/dev/null 2>&1; then
        say "ModemManager is not running on this machine."
        return
    fi
    say "ModemManager is running. The new rules take effect for the roaster the next"
    say "time it is plugged in — unplug and replug it before starting RoasTime."
}

main() {
    say "--- Aillio roaster: Linux udev fix ---"

    [ "$(uname -s)" = "Linux" ] || die "this fix applies to Linux only (this is $(uname -s))."

    local serial_group new_rules
    serial_group="$(detect_serial_group)"
    new_rules="$(build_rules "$serial_group")"

    if [ -n "$serial_group" ]; then
        say "Serial group on this system: $serial_group"
    else
        say "No serial group found; relying on uaccess for permissions."
    fi

    # Compare before touching anything, so re-running is genuinely a no-op and does
    # not prompt for a password or reload udev for nothing.
    if [ -f "$RULE_FILE" ] && [ "$(cat "$RULE_FILE")" = "$new_rules" ]; then
        say "$RULE_FILE is already up to date."
        report_modemmanager
        exit 0
    fi

    if [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
        die "writing $RULE_FILE needs root, and sudo was not found. Re-run this script as root."
    fi

    say "Writing $RULE_FILE ..."
    printf '%s\n' "$new_rules" | run_privileged tee "$RULE_FILE" >/dev/null
    run_privileged chmod 0644 "$RULE_FILE"

    if command -v udevadm >/dev/null 2>&1; then
        say "Reloading udev rules ..."
        run_privileged udevadm control --reload-rules
        run_privileged udevadm trigger --subsystem-match=tty --subsystem-match=usb
    else
        warn "udevadm not found; the rules will apply on the next reboot."
    fi

    # Group membership is deliberately not changed here. Adding a user to a group
    # only takes effect after a full logout, so doing it silently leaves someone
    # with a script that reported success and a roaster that still does not work.
    if [ -n "$serial_group" ]; then
        local target_user="${SUDO_USER:-${USER:-}}"
        if [ -n "$target_user" ] && ! id -nG "$target_user" 2>/dev/null | tr ' ' '\n' | grep -qx "$serial_group"; then
            say ""
            say "Note: $target_user is not in the '$serial_group' group. uaccess should still"
            say "cover a normal desktop login. If the roaster is still not found, run:"
            say "    sudo usermod -aG $serial_group $target_user"
            say "and then log out and log back in."
        fi
    fi

    say ""
    say "Done. The rules are permanent — udev reapplies them on every boot and every"
    say "time the roaster is plugged in, so this only needs running once."
    report_modemmanager
    say ""
    say "To check it worked:"
    say "    udevadm info -q property -n /dev/ttyACM0 | grep ID_MM"
    say "ID_MM_DEVICE_IGNORE=1 means ModemManager will leave the roaster alone."
}

main "$@"