feat(@scripts): ✨ add disk reclaim, host probe, power-cycle tools
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
1c575ad263
commit
dbcaf999f9
7 changed files with 549 additions and 14 deletions
162
bin/disk-reclaim
Executable file
162
bin/disk-reclaim
Executable file
|
|
@ -0,0 +1,162 @@
|
|||
#!/bin/sh
|
||||
# disk-reclaim [path] [--min SIZE] [--all] [--no-summary]
|
||||
#
|
||||
# Scan <path> (default $HOME) for generated/cache directories worth deleting.
|
||||
# Read-only — never deletes. Reports dirs that regenerate from source (build
|
||||
# outputs, dependency caches, IDE/framework state) sorted by size desc.
|
||||
#
|
||||
# Flags:
|
||||
# --min SIZE only show entries >= SIZE (e.g. 100M, 1G; default 100M)
|
||||
# --all alias for --min 0
|
||||
# --no-summary skip the totals-per-category section
|
||||
#
|
||||
# Patterns it looks for (project-scoped, found via find):
|
||||
# JS/TS: node_modules, .next, .nuxt, .turbo, .vite, .parcel-cache,
|
||||
# .svelte-kit, .astro, .cache, dist, build, out
|
||||
# Python: __pycache__, .pytest_cache, .mypy_cache, .ruff_cache, .tox, .venv
|
||||
# Rust: target
|
||||
# Other: _build, Pods, DerivedData, .gradle, .android
|
||||
#
|
||||
# Plus top-level cache roots checked once each:
|
||||
# ~/Library/Caches, ~/Library/Developer/Xcode/DerivedData
|
||||
# ~/.cache, ~/.npm, ~/.pnpm-store, ~/.yarn/cache
|
||||
# ~/.cargo/registry, ~/.cargo/git
|
||||
#
|
||||
# Caveats:
|
||||
# - .venv requires a rebuild from pyproject/requirements after deletion
|
||||
# - target (Rust) requires a recompile that can take minutes
|
||||
# - node_modules needs npm/pnpm install
|
||||
# - vendor/ is intentionally NOT scanned — often committed (Go) or required (PHP)
|
||||
|
||||
set -eu
|
||||
|
||||
root=$HOME
|
||||
min_human=100M
|
||||
show_summary=1
|
||||
|
||||
die() { echo "disk-reclaim: $*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 2
|
||||
}
|
||||
|
||||
to_kb() {
|
||||
case "$1" in
|
||||
*[Kk]) echo "${1%[Kk]}" ;;
|
||||
*[Mm]) echo $(( ${1%[Mm]} * 1024 )) ;;
|
||||
*[Gg]) echo $(( ${1%[Gg]} * 1024 * 1024 )) ;;
|
||||
*[Tt]) echo $(( ${1%[Tt]} * 1024 * 1024 * 1024 )) ;;
|
||||
''|*[!0-9]*) die "bad size: $1 (use K/M/G/T suffix or plain bytes)" ;;
|
||||
*) echo "$(( $1 / 1024 ))" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
human() {
|
||||
awk -v kb="$1" 'BEGIN {
|
||||
if (kb >= 1048576) printf "%.1fG", kb/1048576
|
||||
else if (kb >= 1024) printf "%.0fM", kb/1024
|
||||
else printf "%dK", kb
|
||||
}'
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help|help) usage ;;
|
||||
--min) [ $# -ge 2 ] || die "--min needs a value"; min_human=$2; shift 2 ;;
|
||||
--min=*) min_human=${1#--min=}; shift ;;
|
||||
--all) min_human=0; shift ;;
|
||||
--no-summary) show_summary=0; shift ;;
|
||||
-*) die "unknown flag: $1" ;;
|
||||
*) root=$1; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -d "$root" ] || die "not a directory: $root"
|
||||
min_kb=$(to_kb "$min_human")
|
||||
scan_root=$(cd "$root" && pwd -P)
|
||||
|
||||
patterns="node_modules .next .nuxt .turbo .vite .parcel-cache .svelte-kit .astro .cache dist build out __pycache__ .pytest_cache .mypy_cache .ruff_cache .tox .venv target _build Pods DerivedData .gradle .android"
|
||||
|
||||
# Build the find -name OR-chain.
|
||||
expr=""
|
||||
for n in $patterns; do
|
||||
expr="$expr -name $n -o"
|
||||
done
|
||||
expr=${expr% -o}
|
||||
|
||||
echo "scanning $scan_root (min size: $(human "$min_kb"))..."
|
||||
echo
|
||||
|
||||
# Find each matching dir; once matched, -prune so we don't descend into it
|
||||
# looking for nested matches (e.g. avoid target/ inside node_modules).
|
||||
# stderr → /dev/null to silence permission-denied noise on system dirs.
|
||||
# shellcheck disable=SC2086
|
||||
results=$(
|
||||
find "$scan_root" -type d \( $expr \) -prune -print 2>/dev/null \
|
||||
| while IFS= read -r dir; do
|
||||
kb=$(du -sk "$dir" 2>/dev/null | awk '{print $1}')
|
||||
[ -z "$kb" ] && continue
|
||||
[ "$kb" -lt "$min_kb" ] && continue
|
||||
printf '%s\t%s\n' "$kb" "$dir"
|
||||
done \
|
||||
| sort -rn
|
||||
)
|
||||
|
||||
if [ -z "$results" ]; then
|
||||
echo " (no project-scoped entries >= $(human "$min_kb"))"
|
||||
else
|
||||
printf ' %8s %s\n' "SIZE" "PATH"
|
||||
printf ' %8s %s\n' "----" "----"
|
||||
echo "$results" | while IFS="$(printf '\t')" read -r kb path; do
|
||||
printf ' %8s %s\n' "$(human "$kb")" "$path"
|
||||
done
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "top-level cache roots:"
|
||||
cache_results=$(
|
||||
for p in \
|
||||
"$HOME/Library/Caches" \
|
||||
"$HOME/Library/Developer/Xcode/DerivedData" \
|
||||
"$HOME/.cache" \
|
||||
"$HOME/.npm" \
|
||||
"$HOME/.pnpm-store" \
|
||||
"$HOME/.yarn/cache" \
|
||||
"$HOME/.cargo/registry" \
|
||||
"$HOME/.cargo/git"
|
||||
do
|
||||
[ -d "$p" ] || continue
|
||||
kb=$(du -sk "$p" 2>/dev/null | awk '{print $1}')
|
||||
[ -z "$kb" ] && continue
|
||||
[ "$kb" -lt "$min_kb" ] && continue
|
||||
printf '%s\t%s\n' "$kb" "$p"
|
||||
done | sort -rn
|
||||
)
|
||||
if [ -z "$cache_results" ]; then
|
||||
echo " (none >= $(human "$min_kb"))"
|
||||
else
|
||||
echo "$cache_results" | while IFS="$(printf '\t')" read -r kb path; do
|
||||
printf ' %8s %s\n' "$(human "$kb")" "$path"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$show_summary" = 1 ] && [ -n "$results" ]; then
|
||||
echo
|
||||
echo "totals by category:"
|
||||
totals=$(
|
||||
for n in $patterns; do
|
||||
sum=$(echo "$results" | awk -v n="$n" -F'\t' '
|
||||
{ i = split($2, a, "/"); if (a[i] == n) total += $1 }
|
||||
END { print total+0 }
|
||||
')
|
||||
[ "$sum" -gt 0 ] && printf '%s\t%s\n' "$sum" "$n"
|
||||
done | sort -rn
|
||||
)
|
||||
echo "$totals" | while IFS="$(printf '\t')" read -r kb name; do
|
||||
printf ' %8s %s\n' "$(human "$kb")" "$name"
|
||||
done
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "review carefully before rm -rf. some dirs (.venv, target, node_modules) need a rebuild after deletion."
|
||||
83
bin/host-probe
Executable file
83
bin/host-probe
Executable file
|
|
@ -0,0 +1,83 @@
|
|||
#!/bin/sh
|
||||
# host-probe <host> [port] — one-shot: print state and exit
|
||||
# host-probe --watch <host> [port] — loop, emit only on state change
|
||||
#
|
||||
# Distinguishes three states by probing layers independently:
|
||||
# up ICMP + TCP accept + SSH banner exchange all succeed
|
||||
# wedged ICMP + TCP accept succeed, banner exchange times out
|
||||
# (kernel networking alive, userspace frozen — classic
|
||||
# D-state / OOM / disk hang signature)
|
||||
# down no ICMP or no TCP accept
|
||||
#
|
||||
# Suitable both as a standalone check and as the command body for the
|
||||
# Monitor tool (one stdout line per state change).
|
||||
#
|
||||
# Env:
|
||||
# HOST_PROBE_INTERVAL seconds between polls in --watch mode (default 30)
|
||||
# HOST_PROBE_TIMEOUT per-probe timeout in seconds (default 3)
|
||||
|
||||
set -eu
|
||||
|
||||
interval=${HOST_PROBE_INTERVAL:-30}
|
||||
timeout=${HOST_PROBE_TIMEOUT:-3}
|
||||
|
||||
usage() {
|
||||
sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 2
|
||||
}
|
||||
|
||||
watch=false
|
||||
case "${1:-}" in
|
||||
''|-h|--help|help) usage ;;
|
||||
--watch) watch=true; shift ;;
|
||||
esac
|
||||
[ $# -ge 1 ] && [ $# -le 2 ] || usage
|
||||
|
||||
host=$1
|
||||
port=${2:-22}
|
||||
|
||||
probe_icmp() {
|
||||
ping -c1 -W"$timeout" "$host" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
probe_tcp() {
|
||||
# -G is the BSD/macOS connect timeout flag; falls back to -w on Linux nc.
|
||||
nc -z -G"$timeout" "$host" "$port" >/dev/null 2>&1 \
|
||||
|| nc -z -w"$timeout" "$host" "$port" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
probe_banner() {
|
||||
# SSH banner arrives unsolicited within milliseconds on a healthy sshd.
|
||||
# Frozen userspace: TCP accepts but no banner ever lands.
|
||||
banner=$(
|
||||
( nc -G"$timeout" "$host" "$port" </dev/null &
|
||||
nc_pid=$!
|
||||
( sleep "$timeout"; kill "$nc_pid" 2>/dev/null ) &
|
||||
wait "$nc_pid" 2>/dev/null ) 2>/dev/null | head -c 100
|
||||
)
|
||||
[ -n "$banner" ]
|
||||
}
|
||||
|
||||
classify() {
|
||||
if ! probe_icmp; then echo down; return; fi
|
||||
if ! probe_tcp; then echo down; return; fi
|
||||
if ! probe_banner; then echo wedged; return; fi
|
||||
echo up
|
||||
}
|
||||
|
||||
stamp() { date -u +%H:%M:%SZ; }
|
||||
|
||||
if [ "$watch" = false ]; then
|
||||
classify
|
||||
exit 0
|
||||
fi
|
||||
|
||||
prev=""
|
||||
while :; do
|
||||
state=$(classify)
|
||||
if [ "$state" != "$prev" ]; then
|
||||
echo "[$(stamp)] $host:$port $state"
|
||||
prev=$state
|
||||
fi
|
||||
sleep "$interval"
|
||||
done
|
||||
88
bin/power-cycle
Executable file
88
bin/power-cycle
Executable file
|
|
@ -0,0 +1,88 @@
|
|||
#!/bin/sh
|
||||
# power-cycle <host> — off, wait POWER_CYCLE_OFF_SECS (default 5), on
|
||||
# power-cycle off|on|status <host> — explicit single action
|
||||
# power-cycle list — show configured host -> plug mappings
|
||||
#
|
||||
# Targets Shelly Gen2 plugs (Plus Plug US/S, etc.) via their local HTTP RPC.
|
||||
# No cloud, no account. The host must be reachable on the same network/VPN.
|
||||
#
|
||||
# Config: ~/.config/power-cycle/plugs.conf
|
||||
# one entry per line: <host> <plug-base-url>
|
||||
# blank lines and lines starting with # are ignored.
|
||||
# Example:
|
||||
# apricot http://10.0.0.117
|
||||
# plum http://10.0.0.119
|
||||
#
|
||||
# Env:
|
||||
# POWER_CYCLE_OFF_SECS seconds to stay off during a cycle (default 5)
|
||||
# POWER_CYCLE_TIMEOUT per-request curl timeout in seconds (default 5)
|
||||
|
||||
set -eu
|
||||
|
||||
config="${XDG_CONFIG_HOME:-$HOME/.config}/power-cycle/plugs.conf"
|
||||
off_secs=${POWER_CYCLE_OFF_SECS:-5}
|
||||
http_timeout=${POWER_CYCLE_TIMEOUT:-5}
|
||||
|
||||
die() { echo "power-cycle: $*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 2
|
||||
}
|
||||
|
||||
lookup_plug() {
|
||||
# echoes the plug base URL for $1, or exits non-zero with a hint.
|
||||
host=$1
|
||||
[ -f "$config" ] || die "no config at $config — create it (see 'power-cycle' help)"
|
||||
url=$(awk -v h="$host" '
|
||||
/^[[:space:]]*(#|$)/ { next }
|
||||
$1 == h { print $2; found=1; exit }
|
||||
END { exit !found }
|
||||
' "$config") || die "no plug configured for '$host' in $config"
|
||||
[ -n "$url" ] || die "empty plug URL for '$host' in $config"
|
||||
printf %s "$url"
|
||||
}
|
||||
|
||||
shelly_set() {
|
||||
# shelly_set <base-url> <true|false>
|
||||
base=$1; state=$2
|
||||
curl --fail --silent --show-error --max-time "$http_timeout" \
|
||||
"$base/rpc/Switch.Set?id=0&on=$state" >/dev/null \
|
||||
|| die "plug $base unreachable — for modem outages, fall back to BLE (SwitchBot app)"
|
||||
}
|
||||
|
||||
shelly_status() {
|
||||
# echoes "on" or "off"
|
||||
base=$1
|
||||
body=$(curl --fail --silent --show-error --max-time "$http_timeout" \
|
||||
"$base/rpc/Switch.GetStatus?id=0") \
|
||||
|| die "plug $base unreachable"
|
||||
case "$body" in
|
||||
*'"output":true'*) echo on ;;
|
||||
*'"output":false'*) echo off ;;
|
||||
*) die "unrecognized Shelly response: $body" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
[ -f "$config" ] || die "no config at $config"
|
||||
awk '/^[[:space:]]*(#|$)/ { next } { printf " %-15s %s\n", $1, $2 }' "$config"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
''|-h|--help|help) usage ;;
|
||||
list) cmd_list ;;
|
||||
off) [ $# -eq 2 ] || usage; shelly_set "$(lookup_plug "$2")" false ;;
|
||||
on) [ $# -eq 2 ] || usage; shelly_set "$(lookup_plug "$2")" true ;;
|
||||
status) [ $# -eq 2 ] || usage; shelly_status "$(lookup_plug "$2")" ;;
|
||||
*)
|
||||
[ $# -eq 1 ] || usage
|
||||
host=$1
|
||||
base=$(lookup_plug "$host")
|
||||
echo "cycling $host: off -> ${off_secs}s -> on"
|
||||
shelly_set "$base" false
|
||||
sleep "$off_secs"
|
||||
shelly_set "$base" true
|
||||
echo "done. give the host ~30-90s to finish booting."
|
||||
;;
|
||||
esac
|
||||
29
bin/rclaude
29
bin/rclaude
|
|
@ -815,13 +815,32 @@ cmd_resume() {
|
|||
if ($2 == "session") return $4 " " c_dim "[" substr($3,1,8) "]" r
|
||||
return $3
|
||||
}
|
||||
# Col B: the explicit `claude -n` display name (NF). Blank when
|
||||
# the session was never named — col C (dir) carries the project
|
||||
# identity in that case.
|
||||
# Col B: the explicit `claude -n` display name (NF for sessions,
|
||||
# NF-1 for tmux because tmux rows have no name column at the end).
|
||||
function name_col() { return ($2=="tmux") ? "" : $NF }
|
||||
# "Time ago" column. mtime is in different fields depending on
|
||||
# row kind (session=col 6, triage=col 9). Tmux rows have no
|
||||
# mtime — blank in that column.
|
||||
function ago(secs, abs, s) {
|
||||
if (secs == "" || secs+0 == 0) return ""
|
||||
abs = now - secs+0
|
||||
if (abs < 0) abs = 0
|
||||
if (abs < 60) return abs "s"
|
||||
if (abs < 3600) return int(abs/60) "m"
|
||||
if (abs < 86400) return int(abs/3600) "h"
|
||||
if (abs < 86400*30) return int(abs/86400) "d"
|
||||
if (abs < 86400*365) return int(abs/(86400*30)) "mo"
|
||||
return int(abs/(86400*365)) "y"
|
||||
}
|
||||
function age_col() {
|
||||
if ($2 == "session") return ago($6)
|
||||
if ($2 == "triage") return ago($9)
|
||||
return "" # tmux: no age field
|
||||
}
|
||||
{
|
||||
printf "%s%-10s%s %s%-22s%s %s%-22s%s %s",
|
||||
printf "%s%-10s%s %s%5s%s %s%-22s%s %s%-22s%s %s",
|
||||
c_host, $1, r,
|
||||
c_dim, age_col(), r,
|
||||
c_name, fit(name_col(), 22), r,
|
||||
c_dim, fit(dir_label(), 22), r,
|
||||
display()
|
||||
|
|
@ -834,6 +853,7 @@ cmd_resume() {
|
|||
-v c_p5="$_Cp5" -v c_p4="$_Cp4" \
|
||||
-v c_blk="$_Cblk" -v c_wait="$_Cwait" -v c_inp="$_Cinp" -v c_done="$_Cdone" \
|
||||
-v c_name="$_Cname" \
|
||||
-v now="$(date +%s)" \
|
||||
"$_fmt_row"'{printf "\n"}' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -851,6 +871,7 @@ cmd_resume() {
|
|||
-v c_p5="$_Cp5" -v c_p4="$_Cp4" \
|
||||
-v c_blk="$_Cblk" -v c_wait="$_Cwait" -v c_inp="$_Cinp" -v c_done="$_Cdone" \
|
||||
-v c_name="$_Cname" \
|
||||
-v now="$(date +%s)" \
|
||||
"$_fmt_row")
|
||||
printf ' %s[%s]%s %s\n' "$_Ckey" "$_k" "$_R" "$_row_text" >&2
|
||||
_prev_kind=$_kind_now
|
||||
|
|
|
|||
23
bin/rvoice
23
bin/rvoice
|
|
@ -106,7 +106,11 @@ cmd_start() {
|
|||
rm -f "$PID_FILE"
|
||||
fi
|
||||
rm -f "$WAV_FILE"
|
||||
now_ms > "$START_FILE"
|
||||
# Write start timestamp and pid atomically (mv after both files exist)
|
||||
# so a concurrent cmd_stop can't observe a half-written START_FILE.
|
||||
_start_ts=$(now_ms)
|
||||
printf '%s' "$_start_ts" > "${START_FILE}.tmp"
|
||||
mv -f "${START_FILE}.tmp" "$START_FILE"
|
||||
# 16kHz mono PCM, capped at MAX_S. Device "0" is the default macOS input;
|
||||
# change with AVFoundation list if you have multiple mics.
|
||||
nohup ffmpeg -hide_banner -loglevel error -nostdin \
|
||||
|
|
@ -119,10 +123,22 @@ cmd_start() {
|
|||
}
|
||||
|
||||
cmd_stop() {
|
||||
# Optional flag: --print-text emits the transcribed text to stdout
|
||||
# (suppressing other stdout/stderr that would corrupt the consumer).
|
||||
# Used by the Hammerspoon module to surface a transcript toast.
|
||||
_print_text=0
|
||||
if [ "${1:-}" = "--print-text" ]; then _print_text=1; fi
|
||||
[ -f "$PID_FILE" ] || { log "stop: no recording in progress"; return 0; }
|
||||
_pid=$(cat "$PID_FILE")
|
||||
_start=$(cat "$START_FILE" 2>/dev/null || echo 0)
|
||||
# START_FILE may be missing/empty if a concurrent start/stop raced us;
|
||||
# treat anything that isn't a sane recent timestamp as "unknown" and
|
||||
# let the empty-recording / min-ms guards handle the rest.
|
||||
_start=$(cat "$START_FILE" 2>/dev/null || true)
|
||||
case $_start in
|
||||
''|*[!0-9]*) _start=$(now_ms) ;;
|
||||
esac
|
||||
_dur_ms=$(( $(now_ms) - _start ))
|
||||
[ "$_dur_ms" -lt 0 ] && _dur_ms=0
|
||||
# `q` on stdin is ffmpeg's clean-stop signal but with -nostdin we use
|
||||
# SIGINT — ffmpeg flushes the wav header on SIGINT.
|
||||
kill -INT "$_pid" 2>/dev/null || true
|
||||
|
|
@ -174,6 +190,7 @@ cmd_stop() {
|
|||
ssh -o BatchMode=yes "$_host" "tmux send-keys -t '$_sess' Enter" 2>>"$LOG_FILE"
|
||||
fi
|
||||
notify "✓ $_txt" ok
|
||||
[ "$_print_text" = "1" ] && printf '%s\n' "$_txt"
|
||||
}
|
||||
|
||||
cmd_cancel() {
|
||||
|
|
@ -201,7 +218,7 @@ fi
|
|||
|
||||
case ${1:-} in
|
||||
start) cmd_start ;;
|
||||
stop) cmd_stop ;;
|
||||
stop) shift; cmd_stop "$@" ;;
|
||||
cancel) cmd_cancel ;;
|
||||
target) cmd_target ;;
|
||||
log) tail -50 "$LOG_FILE" 2>/dev/null ;;
|
||||
|
|
|
|||
133
docs/lan-power-ctrl.md
Normal file
133
docs/lan-power-ctrl.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# lan-power-ctrl — LAN power-cycle for wedged hosts
|
||||
|
||||
When a host's userspace freezes (D-state, OOM, disk hang), the kernel still
|
||||
answers ICMP and accepts TCP, but no daemon completes a request. SSH banner
|
||||
never arrives → unreachable. The only recovery is a hardware power-cycle.
|
||||
|
||||
This doc covers the hardware to buy, the network topology, and the two
|
||||
scripts (`host-probe`, `power-cycle`) that turn "apricot froze" into a
|
||||
one-liner from any machine on the LAN/VPN.
|
||||
|
||||
## Diagnosis vs recovery
|
||||
|
||||
```
|
||||
layer healthy host frozen userspace unreachable host
|
||||
───── ──────────── ──────────────── ────────────────
|
||||
ICMP reply <50ms reply <50ms no reply
|
||||
TCP accept :22 succeeds succeeds connection refused / timeout
|
||||
SSH banner arrives <100ms never arrives —
|
||||
|
||||
host-probe up wedged down
|
||||
power-cycle n/a needed power's the cure regardless
|
||||
```
|
||||
|
||||
A `wedged` classification is the signature that a remote shell can't help —
|
||||
every login path needs the same userspace that's stuck. Cut power.
|
||||
|
||||
## Shopping list
|
||||
|
||||
### Per host to be remotely recoverable
|
||||
|
||||
| Device | Where | Purpose | Link |
|
||||
|---|---|---|---|
|
||||
| **Shelly Plus Plug US** (Gen2) **or Shelly Plug US Gen4** | Between the host's PSU and the wall | Local HTTP RPC, no cloud | [Plus (Gen2)](https://us.shelly.com/products/shelly-plus-plug-us) · [Gen4](https://us.shelly.com/products/shelly-plug-us-gen4-white) |
|
||||
|
||||
Either generation works — Gen4 adds power monitoring and Matter, both share
|
||||
the same Gen2-compatible `/rpc/Switch.Set` API the `power-cycle` script uses.
|
||||
Buy whichever is in stock and cheapest at order time. ~$25.
|
||||
|
||||
### For the modem (separate problem — can't rescue itself over its own WiFi)
|
||||
|
||||
| Device | Where | Purpose | Link |
|
||||
|---|---|---|---|
|
||||
| **SwitchBot Plug Mini** | Modem | BLE-controllable plug | [product](https://us.switch-bot.com/products/switchbot-plug-mini) |
|
||||
| **USB Bluetooth dongle** | Plugged into **black** (X399 boards have no integrated BT — see [[reference-host-hardware]]) | Lets black drive BLE to the modem plug | Search "USB Bluetooth 5 adapter CSR8510 or RTL8761" (~$10, plug-and-play under bluez) |
|
||||
|
||||
Why black and not apricot: using apricot to rescue the modem couples failure
|
||||
modes — apricot is *also* a recoverable host. Black is the independent
|
||||
watchdog.
|
||||
|
||||
Why not put the SwitchBot Plug Mini on a *host* (so WiFi-controlled)? It
|
||||
*can* be — but for the modem you can't, because the modem dying takes WiFi
|
||||
down with it. BLE from a LAN-attached watchdog works regardless of WAN
|
||||
state, assuming **separate modem and router** (see Caveat).
|
||||
|
||||
### Caveat: combined modem-router vs separate
|
||||
|
||||
If your ISP gave you a combined modem-router (one box does both), modem-dead
|
||||
also means LAN-dead. Nothing on the LAN can rescue it. Options narrow to:
|
||||
phone/laptop in BLE range, or an auto-rebooter plug (watchdog built into the
|
||||
plug itself — pings a target, power-cycles on failure).
|
||||
|
||||
If modem and router are separate (typical home-server setup), LAN stays
|
||||
alive when WAN dies, and the black-as-watchdog plan works as designed.
|
||||
|
||||
## One-time setup after plugs arrive
|
||||
|
||||
1. **Set the Shelly's own restore behavior** (so it remembers its on/off
|
||||
state across a *wall* outage):
|
||||
```sh
|
||||
curl 'http://<plug>/rpc/Switch.SetConfig?id=0&config={"initial_state":"restore_last"}'
|
||||
```
|
||||
2. **Set the host's BIOS to "Restore on AC power loss"** (so when the Shelly
|
||||
restores power, the host actually boots up rather than sitting dark).
|
||||
3. **Add the plug to the config:**
|
||||
```
|
||||
# ~/.config/power-cycle/plugs.conf
|
||||
apricot http://<plug-ip>
|
||||
```
|
||||
Get `<plug-ip>` from the router DHCP table or `arp -a | grep -i shelly`.
|
||||
|
||||
## Scripts
|
||||
|
||||
### `host-probe` — classify reachability
|
||||
|
||||
```sh
|
||||
host-probe apricot.lan # → up | wedged | down (one-shot)
|
||||
host-probe --watch apricot.lan # loop; emits one line per state change
|
||||
HOST_PROBE_INTERVAL=10 HOST_PROBE_TIMEOUT=2 host-probe --watch apricot.lan 22
|
||||
```
|
||||
|
||||
Three independent probes: ICMP, TCP accept, SSH banner. The banner timing is
|
||||
what distinguishes a wedged host from a healthy one — a real sshd flushes
|
||||
its `SSH-2.0-…` banner within milliseconds of connect.
|
||||
|
||||
Composes with the harness `Monitor` tool: each state-change line becomes one
|
||||
notification.
|
||||
|
||||
### `power-cycle` — Shelly Gen2 RPC wrapper
|
||||
|
||||
```sh
|
||||
power-cycle apricot # off → POWER_CYCLE_OFF_SECS (default 5) → on
|
||||
power-cycle off|on|status apricot
|
||||
power-cycle list # show configured host → plug mappings
|
||||
```
|
||||
|
||||
Config: `~/.config/power-cycle/plugs.conf`, one line per host:
|
||||
|
||||
```
|
||||
<host> <plug-base-url>
|
||||
```
|
||||
|
||||
Errors covered: missing config, unknown host, plug unreachable (with hint to
|
||||
fall back to BLE for modem outages), unrecognized Shelly response.
|
||||
|
||||
## Future: modem watchdog daemon on black
|
||||
|
||||
Once the USB BT dongle and SwitchBot plug arrive:
|
||||
|
||||
- `bleak`-based Python daemon, runs as `systemd --user` unit on black
|
||||
- Pings WAN (e.g. `1.1.1.1`) every 30s
|
||||
- After N consecutive failures, BLE-toggles the SwitchBot plug off → 10s → on
|
||||
- Logs to journal; emits a notification via the existing apricot
|
||||
speech-synthesis path (see [[reference-rvoice]]) when it acts
|
||||
|
||||
Not implemented yet — waiting on hardware.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `bin/host-probe` | Three-layer reachability probe |
|
||||
| `bin/power-cycle` | Shelly Gen2 RPC wrapper |
|
||||
| `~/.config/power-cycle/plugs.conf` | Per-host plug URL mapping (user-created) |
|
||||
|
|
@ -10,9 +10,13 @@
|
|||
-- 4. Reload Hammerspoon config (menu bar → Reload Config)
|
||||
-- 5. Grant Accessibility + Microphone permissions when prompted.
|
||||
--
|
||||
-- Behavior: hold Right-Option to talk. Release to transcribe + inject into
|
||||
-- the active iTerm2 tab's remote tmux session. Taps shorter than 200ms are
|
||||
-- ignored (configurable via RVOICE_MIN_MS env in rvoice config).
|
||||
-- Behavior: hold Right ⌥ (Right Option) to talk — but only when the
|
||||
-- focused iTerm2 tab is attached to an rclaude session (i.e. its title
|
||||
-- matches `<host> · claude-…`, the format set by session-tools/tmux.conf).
|
||||
-- Outside that context Right ⌥ passes through unmodified, so the key still
|
||||
-- types its usual special characters in other apps.
|
||||
-- Release to transcribe + inject into the active rclaude tmux session.
|
||||
-- Taps shorter than 200ms are ignored (configurable via RVOICE_MIN_MS).
|
||||
--
|
||||
-- Visual feedback (in order, from least to most intrusive):
|
||||
-- 1. Menu-bar dot — gray idle, red filled while recording, yellow during
|
||||
|
|
@ -171,24 +175,51 @@ local function doStop()
|
|||
end)
|
||||
end
|
||||
|
||||
-- Right-Option keyDown/keyUp. Hammerspoon delivers modifier changes through
|
||||
-- eventtap.flagsChanged; we watch for the rightAlt flag transitioning.
|
||||
-- Context gate: PTT only fires when the focused iTerm2 tab is showing an
|
||||
-- rclaude session. The canonical tmux config sets the title to
|
||||
-- "<host> · <session>"; for rclaude the session name always starts with
|
||||
-- "claude-". Anything else (browser, finder, local iTerm2 shell tab,
|
||||
-- a non-rclaude tmux) returns false so the key behaves normally.
|
||||
local function inRclaudeSession()
|
||||
local front = hs.application.frontmostApplication()
|
||||
if not front then return false end
|
||||
local name = front:name()
|
||||
if name ~= "iTerm2" and name ~= "iTerm" then return false end
|
||||
-- Pull the title of the active session via AppleScript. Cheap (~5ms);
|
||||
-- we only run this on a Right ⌥ keyDown, not on every event.
|
||||
local ok, title = hs.osascript.applescript(
|
||||
'tell application "iTerm2" to tell current session of current window to return name')
|
||||
if not ok or type(title) ~= "string" then return false end
|
||||
-- Canonical tmux title set by session-tools/tmux.conf:
|
||||
-- "#H · #S" → "apricot · claude-natalie-..."
|
||||
-- We're permissive on whitespace around the separator but require the
|
||||
-- session name to start with "claude-" (rclaude's invariant).
|
||||
return title:match("·%s*claude%-") ~= nil
|
||||
end
|
||||
|
||||
-- Right-Option push-to-talk. Hammerspoon delivers modifier transitions via
|
||||
-- flagsChanged; we gate on keycode 61 (Right Option) and read the alt flag
|
||||
-- to determine press vs release.
|
||||
M.tap = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(e)
|
||||
local code = e:getKeyCode()
|
||||
if code ~= 61 then return false end -- 61 = Right Option
|
||||
local flags = e:getFlags()
|
||||
local pressed = flags.alt or false
|
||||
if pressed and not holding then
|
||||
-- Only arm PTT when the focused tab is an rclaude session. If we
|
||||
-- didn't arm on keyDown, the release branch below will also skip
|
||||
-- because `holding` stays false.
|
||||
if not inRclaudeSession() then return false end
|
||||
holding = true
|
||||
doStart()
|
||||
elseif (not pressed) and holding then
|
||||
holding = false
|
||||
doStop()
|
||||
end
|
||||
return false -- don't swallow the modifier; other apps may use it
|
||||
return false -- don't swallow; other apps may want the modifier
|
||||
end)
|
||||
|
||||
M.tap:start()
|
||||
hs.alert.show("rvoice: Right ⌥ to talk", 1.5)
|
||||
hs.alert.show("rvoice: hold Right ⌥ to talk", 1.5)
|
||||
|
||||
return M
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue