#!/bin/sh
#
# S82dcentrald - Start DCENTos mining daemon (am2-s19jpro variant)
#
# This init script overlays on top of the shared board/zynq init script
# (via overlay-on-overlay in the defconfig). It adds am2-specific logic:
#
#   1. Reads /etc/dcentos/platform + /etc/dcentos/board_target to detect am2
#      (zynq-bm3-am2 / am2-s19j) vs am1-s9.
#   2. Injects --s19j-hybrid into dcentrald launch on am2 (belt-and-suspenders
#      with Agent α's /etc/bos_platform auto-detection in main.rs).
#   3. **MILESTONE-PATH ENV VARS (Phase 4A, 2026-05-15 .109 FIRST ACCEPTED
#      SHARES)** — exports the three DCENT_AM2_* env vars that unlocked the
#      first AM2 accepted-share proof on Public Pool (20/20 accepted in 5
#      minutes). Without them, dcentrald falls through to the FPGA-FIFO path
#      which has never produced an AM2 nonce, S99upgrade health gate fails,
#      and U-Boot auto-reverts the firmware. See:
#        - docs/reviews/2026-05-15-xil-flash-q1-ce.md (CE finding #4)
#        - feedback_am2_serial_dispatch_bip320_version_rolling_required.md
#        - reference_xil_s19jpro_bringup.md
#   4. Leaves I2C ownership to dcentrald. The production .139 path can use
#      gpio_bitbang PSU transport and must not force-bind xiic-i2c from init.
#
# Variant scope: only zynq-bm3-am2 (AM2 S19j Pro / S19 Pro Zynq) gets the
# milestone env-var + --s19j-hybrid injection. am1-s9 (the empty/unmatched
# fallback in the case below) keeps the legacy launch shape so S9 cold-boot
# mining is NOT regressed.
#
# SAFETY: command fans to PWM 30 while hashboard power is unknown/present; if
# PWR_CONTROL is already proven low, leave the parked unit at idle PWM 10.
# NEVER PWM 127. Home mining.
#
# D-Central Technologies - DCENTos
#

DAEMON="/usr/local/bin/dcentrald"
PIDFILE="/var/run/dcentrald.pid"
CHILD_PIDFILE="/var/run/dcentrald-child.pid"
EXPECTFILE="/var/run/dcentrald.expected_exit.pid"
LOGFILE="/tmp/dcentrald.log"
FAN_BASE=0x42800000
# AM2 persistent fan-custodian pidfile (see am2_start_fan_custodian below).
FANHOLD_PIDFILE="/var/run/dcentrald-fanhold.pid"
FANHOLD_READYFILE="/var/run/dcentrald-fanhold.ready"
DEFAULT_COMPAT="/etc/default/dcentos-compat"
PERSISTENT_COMPAT="/data/dcentos-compat"
API_PORT=8080
SCRIPT="$0"
SESSION_LATCH_HELPER="/usr/libexec/dcentos/dcentrald-session-latch.sh"
PROCESS_IDENTITY_HELPER="/usr/libexec/dcentos/dcentrald-process-identity.sh"
FAN_CUSTODY_HELPER="/usr/libexec/dcentos/dcentrald-fan-custody.sh"
CRASH_LATCH_FILE="/data/dcent/dcentrald-hardware-session.crash-latched"
DCENT_PROCESS_IDENTITY_TEST_AUTHORITY=0
readonly DCENT_PROCESS_IDENTITY_TEST_AUTHORITY
PROCESS_IDENTITY_AVAILABLE=0
if [ -r "$PROCESS_IDENTITY_HELPER" ] && . "$PROCESS_IDENTITY_HELPER"; then
    PROCESS_IDENTITY_AVAILABLE=1
fi
FAN_CUSTODY_AVAILABLE=0
if [ "$PROCESS_IDENTITY_AVAILABLE" -eq 1 ] \
    && [ -r "$FAN_CUSTODY_HELPER" ] && . "$FAN_CUSTODY_HELPER"; then
    FAN_CUSTODY_AVAILABLE=1
fi
FAN_TRANSITION_LOCK_HELD=0

dcent_release_fan_transition_lock() {
    if [ "$FAN_TRANSITION_LOCK_HELD" -eq 1 ]; then
        dcent_fan_lock_release "${FANHOLD_READYFILE}.lock" || true
        FAN_TRANSITION_LOCK_HELD=0
    fi
}

dcent_acquire_fan_transition_lock() {
    [ "$FAN_CUSTODY_AVAILABLE" -eq 1 ] || return 1
    dcent_fan_lock_acquire "${FANHOLD_READYFILE}.lock" 10 || return 1
    FAN_TRANSITION_LOCK_HELD=1
    trap 'dcent_release_fan_transition_lock' 0
    trap 'exit 1' 1 2 15
}

# Use persistent config if available, otherwise default.
#
# Config search order (highest priority first):
#   1. /data/dcentrald.toml             — operator override (persists across upgrades)
#   2. /etc/dcentrald/xil_override.toml — baked am2 milestone-path override
#                                          (zynq-bm3-am2 only; see post-build.sh,
#                                          source: dcentrald/configs/dcentrald_s19jpro_xil.toml)
#   3. /etc/dcentrald.toml              — shared default (post-build.sh stamps
#                                          dcentrald_s19jpro_am2.toml here today)
if [ -f /data/dcentrald.toml ]; then
    CONFIG="/data/dcentrald.toml"
elif [ -f /etc/dcentrald/xil_override.toml ]; then
    CONFIG="/etc/dcentrald/xil_override.toml"
else
    CONFIG="/etc/dcentrald.toml"
fi

migrate_legacy_api_port() {
    [ -f "$CONFIG" ] || return 0

    # Rewrite legacy dcentrald API http_port 80 to 8080 so S80dashboard owns :80.
    if ! grep -Eq '^[[:space:]]*http_port[[:space:]]*=[[:space:]]*80([[:space:]]*(#.*)?)?$' "$CONFIG" 2>/dev/null; then
        return 0
    fi

    TMPFILE="${CONFIG}.tmp.$$"
    if awk '
        BEGIN { in_api = 0 }
        /^[[:space:]]*\[[^]]+\][[:space:]]*$/ {
            in_api = ($0 ~ /^[[:space:]]*\[api\][[:space:]]*$/)
        }
        in_api && /^[[:space:]]*http_port[[:space:]]*=[[:space:]]*80([[:space:]]*(#.*)?)?$/ {
            sub(/80/, "8080")
        }
        { print }
    ' "$CONFIG" > "$TMPFILE" 2>/dev/null; then
        if mv "$TMPFILE" "$CONFIG" 2>/dev/null; then
            echo "  [MIGRATE] dcentrald API http_port 80 to 8080 in $CONFIG"
        else
            rm -f "$TMPFILE"
            echo "  [WARN] Could not migrate legacy dcentrald API port in $CONFIG"
        fi
    else
        rm -f "$TMPFILE"
        echo "  [WARN] Could not inspect dcentrald API port in $CONFIG"
    fi
}

# Platform + board target are written by post-build.sh.
#   /etc/dcentos/platform     — "zynq-bm3-am2" (am2-s19jpro), "am1-s9" (S9)
#   /etc/dcentos/board_target — "am2-s19j"     (am2-s19jpro), "am1-s9" (S9)
# Both keys are consulted so a half-stamped image still routes correctly.
PLATFORM=""
[ -r /etc/dcentos/platform ] && PLATFORM=$(cat /etc/dcentos/platform 2>/dev/null)
BOARD_TARGET=""
[ -r /etc/dcentos/board_target ] && BOARD_TARGET=$(cat /etc/dcentos/board_target 2>/dev/null)

# Extra CLI args populated per platform below.
EXTRA_ARGS=""

# am2 detection — either marker matches. Empty/unmatched values fall through
# to the S9 (am1-s9) launch shape unchanged.
IS_AM2=0
case "$PLATFORM" in
    zynq-bm3-am2) IS_AM2=1 ;;
esac
case "$BOARD_TARGET" in
    am2-s17|am2-s17p|am2-s17pro|am2-s17plus|am2-t17|am2-t17plus|am2-s19|am2-s19j|am2-s19jpro|am2-s19jpro-zynq|am2-s19pro|am2-t19) IS_AM2=1 ;;
esac

if [ "$IS_AM2" = "1" ]; then
    # am2 S19j Pro Zynq — hybrid profile (s19-am2 FPGA + BM1362 chain).
    # Belt-and-suspenders alongside Agent α's main.rs auto-detection: if
    # Rust-side detection breaks, the CLI flag still forces the right
    # profile. dcentrald treats the flag as idempotent.
    EXTRA_ARGS="$EXTRA_ARGS --s19j-hybrid"

    # **MILESTONE-PATH ENV VARS** — `.109` FIRST ACCEPTED SHARES (2026-05-15,
    # 20/20 accepted on Public Pool). These three flags re-create the exact
    # `dcentrald` runtime that proved AM2 native mining. They are exported
    # here (not just prefixed on one launch line) so the wrapper's restart
    # loop, the safety subcommand, and any child `sh -c` re-fork all inherit
    # them. See docs/reviews/2026-05-15-xil-flash-q1-ce.md finding #4.
    #
    #   DCENT_AM2_SKIP_FAST_UART      — keep command/work path at 115200
    #                                    (FastUART handoff never produced an
    #                                    AM2 nonce in our 2026-05-12/14 runs)
    #   DCENT_AM2_SERIAL_WORK_DISPATCH — route work through the proven BM1362
    #                                    88-byte serial frame over the chain
    #                                    UART (FPGA WORK_TX FIFO bypassed)
    #   DCENT_AM2_SKIP_115200_PER_CHIP — leave BM1362 cores broadcast-only
    #                                    activated (per-chip A8/MiscCtrl x3
    #                                    sequence collapses chain addressing
    #                                    on AM2 — proven on .79 only)
    #
    # NOT exported (operator-opt-in diagnostic only, default-OFF, additive):
    #   DCENT_AM2_VERIFY_PRESENCE_AFTER_EACH_PHASE — PR-019 / R11-2 ablation.
    #                                    Per-phase read-only GetAddress probe
    #                                    + [AM2-ABLATION] / [AM2-ABLATION-PARAMS]
    #                                    log lines to localize the 126->28
    #                                    chain-collapse. Zero behaviour / wire
    #                                    change when unset. Operators set it
    #                                    explicitly per bring-up run.
    export DCENT_AM2_SKIP_FAST_UART=1
    export DCENT_AM2_SERIAL_WORK_DISPATCH=1
    export DCENT_AM2_SKIP_115200_PER_CHIP=1

    # Wave-55a universal env recipe (Gate-1 Q1, 2026-05-24) — load the full
    # 13-required + 4-forbidden Wave-54 PROVEN MINING RECIPE from the baked
    # env file. The file restates the 3 milestone vars above (idempotent) and
    # adds the remaining 10 (TRUST_RAIL_FALLBACK, MCR_OUT2, IER_BOSMINER_PARITY,
    # GET_VERSION_FRAMED_4B, PSU_BITBANG_*, DSPIC_BOSMINER_FAITHFUL,
    # EEPROM_BUS_WARMUP, I2C_SLAVE_SAFE, BM1362_ENABLE_UART_RELAY_LAB) plus
    # explicit `unset` of the 4 forbidden gates as defense-in-depth next to
    # the wave55a_recipe_guard.rs runtime fail-closed check.
    #
    # Sources of truth (do NOT diverge):
    #   - projects/dcentos/scripts/run_wave54_25_PROVEN_MINING.sh
    #   - feedback_am2_25_proven_mining_recipe_2026_05_24.md
    #   - docs/dev/2026-05-24-25-proven-mining/RUNBOOK.md
    #
    # The `-f` test makes this source line a no-op on overlays that don't
    # ship the env file (S9 am1, am3-aml, am3-bb).
    [ -f /etc/default/dcentrald-am2-xil-env ] && . /etc/default/dcentrald-am2-xil-env

    # Clear lab-only overrides that could mask a manageability regression on
    # a flashed image. Operators can re-export from /data/dcentrald-env if a
    # bring-up override is ever needed.
    unset DCENT_AM2_TRUST_DEGRADED_FW
    unset DCENT_AM2_FORCE_FAST_UART
    unset DCENT_AM2_FAST_UART_VALUE
    unset DCENT_AM2_FAST_UART_BAUD
    unset DCENT_AM2_FASTUART_SWITCH_HOST_FIRST
    unset DCENT_AM2_WORK_TX_BOSMODE
fi

# Operator escape hatch for live debugging on a flashed unit — sourced AFTER
# the milestone defaults so /data overrides win. Format: standard sh export
# lines. Never shipped in the image (/data/ is persistent storage).
if [ -r /data/dcentrald-env ]; then
    . /data/dcentrald-env
fi

# DO NOT normalize DCENT_AM2_PWR_CONTROL_ACTIVE_{LOW,HIGH} here.
#
# A previous revision force-set ACTIVE_HIGH for am2-s19j|am2-s19jpro|
# am2-s19jpro-zynq (after unsetting ACTIVE_LOW), claiming gpio907 is active
# HIGH. That is INVERTED, and it is a safe-off correctness bug, not a
# preference:
#
#   * strace-verified sequence — gpio907 reads `1` during the reset hold and
#     `0` coincident with "PSU: Enable", i.e. 0 = rail ON
#     (`docs/dev/2026-06-13-xil25-w1-deep-re/W1_DEEP_RE_FINDINGS.md` line 52).
#   * `W1_2_JIG_VS_DCENT_OP_DIFF.md` states "gpio907 rail polarity active-LOW".
#   * `/etc/default/dcentrald-am2-xil-env` documents the same, citing
#     `psu_gpio_gate.rs:111-120`: "0" = rail ON, "1" = rail OFF.
#
# `am2_power_cut_proven()` below INVERTS its gpio907 comparison based on this
# flag, so asserting ACTIVE_HIGH on an active-LOW board makes the script report
# "power cut proven" at exactly the value that means the rail is ENERGIZED —
# a false safe-off proof on the operator's home `.25` unit.
#
# Polarity is per-unit evidence, not a board-target constant: the env file
# deliberately scopes ACTIVE_LOW to the proven xil-25 branch and leaves every
# other AM2 Xilinx unit UNSET so `am2_pwr_control_polarity_known()` returns
# false and the caller conservatively skips with a WARN. Force-setting a
# polarity for all three board targets destroys that fail-closed default.

bosminer_pic_bootstrap_enabled() {
    BOSMINER_PIC_BOOTSTRAP=0
    [ -f "$DEFAULT_COMPAT" ] && . "$DEFAULT_COMPAT"
    [ -f "$PERSISTENT_COMPAT" ] && . "$PERSISTENT_COMPAT"

    case "$BOSMINER_PIC_BOOTSTRAP" in
        1|true|TRUE|yes|YES|on|ON|enabled|ENABLED)
            return 0
            ;;
        *)
            return 1
            ;;
    esac
}

compat_bootstrap_model_allowed() {
    BOSMINER_PIC_BOOTSTRAP_ALLOW_MODEL="am1-s9"
    [ -f "$DEFAULT_COMPAT" ] && . "$DEFAULT_COMPAT"
    [ -f "$PERSISTENT_COMPAT" ] && . "$PERSISTENT_COMPAT"

    [ "$BOSMINER_PIC_BOOTSTRAP_ALLOW_MODEL" = "am1-s9" ] || return 1
    [ -d /sys/class/uio ] || return 1

    UIO_COUNT=$(ls -d /sys/class/uio/uio* 2>/dev/null | wc -l)
    [ "$UIO_COUNT" -gt 0 ] || return 1
    [ "$UIO_COUNT" -le 14 ] || return 1

    for name_file in /sys/class/uio/*/name; do
        [ -f "$name_file" ] || continue
        if grep -q "board-control" "$name_file" 2>/dev/null; then
            return 1
        fi
    done

    return 0
}

# am2 preflight: ensure the kernel xiic-i2c driver is bound to the FPGA AXI IIC
# controller at 41600000.i2c and that /dev/i2c-0 exists. Mirrors the Rust-side
# logic in dcentrald_hal; duplicating at shell level guarantees the char device
# is present even if the binary is a stale build that skipped the call.
#
# Safe on am1-s9 because we gate the caller on PLATFORM=zynq-bm3-am2.
ensure_i2c0_kernel_bound() {
    # Already bound + visible → nothing to do.
    [ -c /dev/i2c-0 ] && return 0

    # Bind xiic-i2c (no-op if already bound, logs to stderr we silence).
    if [ -d /sys/bus/platform/drivers/xiic-i2c ]; then
        echo 41600000.i2c > /sys/bus/platform/drivers/xiic-i2c/bind 2>/dev/null
        # Give udev a beat to pick it up and create the node.
        sleep 1
    fi

    # Fallback: if udev/mdev didn't materialize /dev/i2c-0, create it manually.
    # Major 89 = i2c-dev, minor 0 = bus 0. Standard kernel assignment.
    if [ ! -c /dev/i2c-0 ]; then
        mknod /dev/i2c-0 c 89 0 2>/dev/null
        chmod 600 /dev/i2c-0 2>/dev/null
    fi

    if [ -c /dev/i2c-0 ]; then
        echo "  [OK] /dev/i2c-0 ready (xiic-i2c bound)"
    else
        echo "  [WARN] /dev/i2c-0 still missing — dcentrald devmem fallback must cover it"
    fi
}

# Safety: command fans to the home cap. Tach/RPM is the physical noise proof.
# PIC watchdog cuts voltage within 10-60s if dcentrald is dead.
# NEVER PWM 127 — home mining, noise is unacceptable.
#
# Platform dispatch (feedback_am2_fan_control_devmem_unreliable_use_uio_mmap.md):
#   am2 (zynq-bm3-am2): the fan-control IP @ 0x42800000 is UIO-bound — devmem
#     reads/writes are a proven NO-OP there (a /dev/mem shadow). The ONLY
#     reliable am2 fan write is the fan-control UIO mmap path, exposed as the
#     `dcentrald --set-fan <PWM>` one-shot (R2; fan-register-only, no
#     PIC/PSU/I2C, synchronous, clamped <= 30). NOTE: the safety response
#     spawns a FRESH one-shot process here — safe precisely because the
#     one-shot is fan-register-only and exits immediately.
#   am1-s9: devmem to the fan IP works correctly — keep it (no regression).
# If the daemon binary is absent on am2 we log a WARN and do NOT pretend the
# fans were capped (the PIC watchdog still cuts voltage within 10-60 s).
am2_pwr_control_polarity_known() {
    [ "${DCENT_AM2_PWR_CONTROL_ACTIVE_LOW:-0}" = "1" ] || [ "${DCENT_AM2_PWR_CONTROL_ACTIVE_HIGH:-0}" = "1" ]
}

am2_power_cut_proven() {
    [ -r /sys/class/gpio/gpio907/value ] || return 1
    _v=$(cat /sys/class/gpio/gpio907/value 2>/dev/null)
    if [ "${DCENT_AM2_PWR_CONTROL_ACTIVE_LOW:-0}" = "1" ]; then
        [ "$_v" = "1" ]
    elif [ "${DCENT_AM2_PWR_CONTROL_ACTIVE_HIGH:-0}" = "1" ]; then
        [ "$_v" = "0" ]
    else
        return 1
    fi
}

# AUDIT FIX (2026-05-29, feedback_am2_never_leave_unit_without_fan_manager.md):
# On AM2 (XIL Zynq) the fan PWM is held only while a process owns the uio16 mmap
# AND keeps re-commanding it. The old `$DAEMON --set-fan N` here wrote the PWM
# once and EXITED, so the board's fan IP drifted the fans back to its full-speed
# default — a home unit blasts and the documented AM2 quiet/safety remediation
# silently failed. The fix is a BACKGROUNDED persistent custodian
# (`dcentrald --hold-fan N`) that stays resident and re-asserts the PWM every
# ~5 s (mmap-hold alone is not enough — a daemon that wrote PWM once still
# drifted; the 5 s re-assert is the cure). We kill any prior custodian first so
# only one owns the mmap. AM2 ONLY (gated on $IS_AM2): S9/am1 fan registers hold
# via devmem, so the non-AM2 branch is unchanged. PWM is only ever driven DOWN
# (<= 30, clamped again inside dcentrald).
am2_start_fan_custodian() {
    _pwm="$1"
    FAN_CUSTODY_RESULT=1
    if [ "$FAN_CUSTODY_AVAILABLE" -eq 1 ]; then
        if [ "$FAN_TRANSITION_LOCK_HELD" -eq 1 ]; then
            dcent_fan_custodian_start_locked "$DAEMON" "$_pwm" "$FANHOLD_PIDFILE" \
                "$FANHOLD_READYFILE" start-stop-daemon "$LOGFILE" 10
        else
            dcent_fan_custodian_start "$DAEMON" "$_pwm" "$FANHOLD_PIDFILE" \
                "$FANHOLD_READYFILE" start-stop-daemon "$LOGFILE" 10
        fi
        FAN_CUSTODY_RESULT=$?
    fi
    if [ "$FAN_CUSTODY_RESULT" -eq 0 ]; then
        echo "$(date): SAFETY: verified am2 fan custody at PWM $_pwm (pid $(cat "$FANHOLD_PIDFILE" 2>/dev/null))" >> "$LOGFILE"
        return 0
    fi
    echo "$(date): WARN: am2 fan custodian (--hold-fan $_pwm) failed readiness/custody validation" >> "$LOGFILE"
    return 1
}

am2_stop_fan_custodian() {
    [ "$FAN_CUSTODY_AVAILABLE" -eq 1 ] || return 1
    if [ "$FAN_TRANSITION_LOCK_HELD" -eq 1 ]; then
        dcent_fan_custodian_stop_locked "$DAEMON" "$FANHOLD_PIDFILE" \
            "$FANHOLD_READYFILE" 5 2
    else
        dcent_fan_custodian_stop "$DAEMON" "$FANHOLD_PIDFILE" "$FANHOLD_READYFILE" 5 2
    fi
}

fan_safety_override() {
    if [ "$IS_AM2" = "1" ]; then
        if [ -x "$DAEMON" ]; then
            FAN_PWM=30
            if am2_power_cut_proven; then
                FAN_PWM=10
            fi
            # Persistent custodian, NOT the exit-immediately --set-fan one-shot
            # (which left the AM2 board to revert fans to full speed). See
            # am2_start_fan_custodian + feedback_am2_never_leave_unit_without_fan_manager.md.
            am2_start_fan_custodian "$FAN_PWM"
            return $?
        else
            echo "$(date): WARN: am2 dcentrald binary absent at $DAEMON — could NOT cap fans (devmem is a no-op on am2; PIC watchdog will cut voltage)" >> $LOGFILE
            return 1
        fi
    elif command -v devmem > /dev/null 2>&1; then
        devmem $((FAN_BASE + 0x10)) 32 30 2>/dev/null || return 1
        devmem $((FAN_BASE + 0x14)) 32 30 2>/dev/null || return 1
        echo "$(date): SAFETY: fans commanded to PWM 30 (dcentrald exited)" >> $LOGFILE
        return 0
    fi
    return 1
}

case "$1" in
    start)
        EXTERNAL_MEDIA_MARKER=${DCENTOS_EXTERNAL_MEDIA_MARKER:-/etc/dcentos/external-media-ephemeral-root}
        if [ -e "$EXTERNAL_MEDIA_MARKER" ] || [ -L "$EXTERNAL_MEDIA_MARKER" ]; then
            echo "  [SKIP] dcentrald hardware owner: external-media boot remains safe-idle/management-only"
            exit 0
        fi
        if [ ! -x "$DAEMON" ]; then
            echo "  [SKIP] dcentrald: binary not found at $DAEMON"
            exit 0
        fi
        dcent_acquire_fan_transition_lock || {
            echo "  [FAIL] Cannot serialize dcentrald owner admission"
            exit 1
        }

        if [ "$IS_AM2" = "1" ] \
            && { [ -e "$FANHOLD_PIDFILE" ] || [ -e "$FANHOLD_READYFILE" ] \
                || [ -e "${FANHOLD_READYFILE}.pending" ]; }; then
            am2_stop_fan_custodian || {
                echo "  [FAIL] Prior fan custodian cannot be verified and stopped"
                exit 1
            }
            echo "  [OK] exact prior am2 fan custodian stopped before owner admission"
        fi

        RUNNING_PIDS=$(pidof dcentrald 2>/dev/null || true)
        if [ -n "$RUNNING_PIDS" ]; then
            echo "  [FAIL] Refusing a second dcentrald owner (PID(s): $RUNNING_PIDS)"
            exit 1
        fi
        if [ ! -r "$SESSION_LATCH_HELPER" ] \
            || [ "$PROCESS_IDENTITY_AVAILABLE" -ne 1 ]; then
            echo "  [FAIL] Persistent session or exact-process identity helper is missing"
            fan_safety_override
            exit 1
        fi
        SESSION_TOKEN=$(/bin/sh "$SESSION_LATCH_HELPER" prepare 2>> "$LOGFILE") || {
            echo "  [FAIL] Hardware-session admission is blocked; see $LOGFILE"
            fan_safety_override
            exit 1
        }

        # W1.5 (2026-05-07): re-assert tight perms on /data/dcent/ + auth.json
        # before launching the daemon. dcentrald will also auto-correct via
        # verify_auth_file_perms() at startup, but doing it here closes the
        # window between mount(/data) and the first daemon write.
        if [ -d /data/dcent ]; then
            chmod 0700 /data/dcent 2>/dev/null
        fi
        if [ -f /data/dcent/auth.json ]; then
            chmod 0600 /data/dcent/auth.json 2>/dev/null
        fi

        # Do not force xiic-i2c from init. The Rust AM2 path binds kernel I2C
        # only when its configured transport requires it.

        # Legacy compatibility mode only. Native DCENT_OS cold boot is the
        # default product path, so only wait for S15pic_boot when the explicit
        # compat bootstrap is enabled and the board is positively allowlisted.
        if bosminer_pic_bootstrap_enabled && compat_bootstrap_model_allowed && [ -x /usr/bin/bosminer ] && [ ! -f /tmp/pic_init_done ]; then
            echo "  [WAIT] dcentrald: waiting for legacy PIC bootstrap..."
            for i in $(seq 1 120); do
                [ -f /tmp/pic_init_done ] && break
                [ -f /tmp/pic_init_failed ] && break
                sleep 1
            done
            if [ -f /tmp/pic_init_done ]; then
                :
            elif [ -f /tmp/pic_init_failed ]; then
                echo "  [WARN] S15pic_boot did not confirm PIC init - starting dcentrald native path"
            else
                echo "  [WARN] PIC init flag not found after 120s - starting anyway"
            fi
        fi

        echo "Starting dcentrald mining daemon..."
        migrate_legacy_api_port
        echo "  Config:   $CONFIG"
        echo "  Platform: ${PLATFORM:-unknown}"
        echo "  API: REST/WebSocket :$API_PORT"
        echo "  Dashboard: HTTP :80 via S80dashboard"
        [ -n "$EXTRA_ARGS" ] && echo "  Extra args:$EXTRA_ARGS"
        if [ "$IS_AM2" = "1" ]; then
            echo "  AM2 milestone env: DCENT_AM2_SKIP_FAST_UART=${DCENT_AM2_SKIP_FAST_UART:-unset} DCENT_AM2_SERIAL_WORK_DISPATCH=${DCENT_AM2_SERIAL_WORK_DISPATCH:-unset} DCENT_AM2_SKIP_115200_PER_CHIP=${DCENT_AM2_SKIP_115200_PER_CHIP:-unset}"
            echo "  AM2 Wave-54 env: TRUST_RAIL_FALLBACK=${DCENT_AM2_TRUST_RAIL_FALLBACK:-unset} MCR_OUT2=${DCENT_AM2_MCR_OUT2:-unset} IER_BOSMINER_PARITY=${DCENT_AM2_IER_BOSMINER_PARITY:-unset} GET_VERSION_FRAMED_4B=${DCENT_AM2_GET_VERSION_FRAMED_4B:-unset} DSPIC_BOSMINER_FAITHFUL=${DCENT_AM2_DSPIC_BOSMINER_FAITHFUL:-unset} EEPROM_BUS_WARMUP=${DCENT_AM2_EEPROM_BUS_WARMUP:-unset} I2C_SLAVE_SAFE=${DCENT_AM2_I2C_SLAVE_SAFE:-unset} PSU_BITBANG_USE_MMAP=${DCENT_AM2_PSU_BITBANG_USE_MMAP:-unset} PSU_BITBANG_HALF_PERIOD_US=${DCENT_AM2_PSU_BITBANG_HALF_PERIOD_US:-unset} BM1362_ENABLE_UART_RELAY_LAB=${DCENT_BM1362_ENABLE_UART_RELAY_LAB:-unset}"
        fi

        # Do not kill all Python interpreters here.
        # S81mcp starts immediately before us and must keep running.

        # NOTE (updated 2026-03-26): dcentrald uses devmem AXI IIC bypass for I2C.
        # It unbinds the kernel xiic-i2c driver internally on startup.
        # The kernel driver is NOT used - devmem provides direct register access
        # with proper clock timing (THIGH/TLOW=993 for 100kHz).

        # Kill any orphaned dcentrald from previous manual runs.
        # Expected exits are marked so the wrapper does not misclassify replacement
        # as a crash and blast the fans.
        # Reap ALL live dcentrald PIDs, not just the first: two stale energized
        # daemons (e.g. a manual mining run + a lost-pidfile fan custodian) would
        # otherwise survive and fight the new daemon over the single /dev/i2c-0
        # owner + PWR_CONTROL + chain UART (the single-I2C-owner violation that can
        # wedge the dsPIC/I2C bus). Mirrors the base zynq/amlogic/am3-bb wrappers.
        # A live owner is refused above. Never kill-and-replace an orphan from
        # the start path: its hardware disposition is unknown.

        set -- $EXTRA_ARGS
        set -- "$DAEMON" --config "$CONFIG" "$@"
        if ! start-stop-daemon -S -b -m -p "$PIDFILE" \
            -x /bin/sh -- "$SESSION_LATCH_HELPER" supervise "$SESSION_TOKEN" \
            "$SCRIPT" "$LOGFILE" "$CHILD_PIDFILE" "$EXPECTFILE" "$@"; then
            /bin/sh "$SESSION_LATCH_HELPER" abandon "$SESSION_TOKEN" supervisor-launch-failed \
                >> "$LOGFILE" 2>&1 || true
            fan_safety_override
            echo "  [FAIL] dcentrald supervisor did not start"
            exit 1
        fi
        echo "  [OK] dcentrald started (REST/WebSocket :$API_PORT, CGMiner :4028)"
        echo "  Log: $LOGFILE"
        ;;

    stop)
        echo "Stopping dcentrald mining daemon..."
        # Send SIGTERM for graceful shutdown. dcentrald keeps PIC heartbeats alive
        # while disabling voltage, then cools fans. Full sequence takes ~12s:
        #   0.5s nonce drain + voltage disable + 2s heartbeat stop + 2s cap discharge
        #   + 5s fan cooldown = ~12s total
        # NEVER SIGKILL before voltage is off - kills heartbeat thread, PIC watchdog
        # fires, corrupts I2C bus, requires PSU power cycle.
        if [ "$PROCESS_IDENTITY_AVAILABLE" -ne 1 ]; then
            echo "  [FAIL] Exact-process identity helper is unavailable; refusing stop mutations"
            exit 1
        fi
        dcent_stop_managed_session "$CHILD_PIDFILE" "$EXPECTFILE" "$DAEMON" \
            "$PIDFILE" "$CRASH_LATCH_FILE" "$SCRIPT" "$LOGFILE" \
            30 5 30 || exit $?
        echo "  [OK] dcentrald stopped after exact owner-death and terminal-custody evidence"
        ;;

    restart)
        "$0" stop || exit $?
        sleep 2
        exec "$0" start
        ;;

    safety)
        dcent_acquire_fan_transition_lock || exit 1
        if [ -e "$FANHOLD_PIDFILE" ] || [ -e "$FANHOLD_READYFILE" ] \
            || [ -e "${FANHOLD_READYFILE}.pending" ]; then
            am2_stop_fan_custodian || exit 1
        fi
        if dcent_dcentrald_may_execute; then
            echo "$(date): SAFETY-FAIL: refusing a competing safety writer while dcentrald may execute" >> "$LOGFILE"
            exit 1
        fi
        # G22 (no-brick safety, gap-swarm 2026-05-28): the emergency safety response
        # must CUT HASH POWER, not just cap fans. Delegate the cut to the daemon's
        # audited per-platform one-shot `--safe-off` (am2 -> explicit PWR_CONTROL
        # OFF polarity, then fans to a quiet floor) rather than hardcoding contested
        # GPIO/devmem here. This is cut-hash-before-noise order. Falls back to the
        # fan-only override if the binary is absent, polarity is unknown, or the cut
        # fails, so an emergency NEVER ends up doing nothing.
        if [ "$IS_AM2" = "1" ] && ! am2_pwr_control_polarity_known; then
            echo "$(date): SAFETY-FAIL: AM2 PWR_CONTROL polarity unknown; fan-only fallback is not a power-cut receipt" >> "$LOGFILE"
            fan_safety_override || true
            exit 1
        fi
        if [ ! -x "$DAEMON" ]; then
            echo "$(date): SAFETY-FAIL: $DAEMON absent; PSU cut unavailable" >> "$LOGFILE"
            exit 1
        fi
        if ! "$DAEMON" --safe-off >> "$LOGFILE" 2>&1; then
            echo "$(date): SAFETY-FAIL: $DAEMON --safe-off failed; fan-only fallback is not a power-cut receipt" >> "$LOGFILE"
            fan_safety_override || true
            exit 1
        fi
        if ! fan_safety_override; then
            echo "$(date): SAFETY-FAIL: power cut returned evidence but persistent fan custody failed" >> "$LOGFILE"
            exit 1
        fi
        echo "$(date): SAFETY: power-cut command/readback and persistent fan custody returned evidence; physical rail state remains separately classified" >> "$LOGFILE"
        ;;

    safety-hold)
        # Crash-give-up path: the prior `safety` (--safe-off) one-shot already cut
        # hash power but EXITED, so on AM2 it dropped the uio16 fan mmap and the
        # board reverts fans to FULL speed. Leave a PERSISTENT fan custodian
        # resident (fan_safety_override -> am2_start_fan_custodian -> dcentrald
        # --hold-fan, PWM 10 since power is cut) so a 5-crash give-up does not
        # leave a home unit blasting. Reaped on the next start/stop/safety.
        # (feedback_am2_never_leave_unit_without_fan_manager.md)
        fan_safety_override
        ;;

    status)
        if start-stop-daemon -K -p "$PIDFILE" -t -q 2>/dev/null; then
            PID=$(cat "$PIDFILE" 2>/dev/null)
            echo "dcentrald is running (PID $PID)"
        else
            echo "dcentrald is not running"
        fi
        ;;

    *)
        echo "Usage: $0 {start|stop|restart|status}"
        exit 1
        ;;
esac

exit 0
