#!/bin/bash ## ## Control Bluetooth LE LED strips that use the MohuanLED app (advertise as BJ_LED*). ## Protocol per github.com/8none1/bj_led. Needs python3 with bleak on a machine with Bluetooth. ## CMD=$(basename $0) TMP=$HOME/.tmp/$CMD ; mkdir -p $TMP TENTEN_DIR=$(dirname "$(dirname "$(realpath "${BASH_SOURCE[0]}")")") LIBEXEC="$TENTEN_DIR/libexec/$CMD" ALIASES="$HOME/.config/$CMD/aliases" USER_COLORS="$HOME/.config/$CMD/colors" CSS_COLORS="$LIBEXEC/css-colors" COLORS="ff0000:red 00ff00:green 0000ff:blue ffff00:yellow ff00ff:magenta 00ffff:cyan" ## A daemon that has not written its heartbeat this recently is treated as gone. BEAT_STALE=5 IDLE_SECONDS=600 CONNECT_WAIT=60 ## Longer than the daemon keeps a queued command before expiring it. COMMAND_WAIT=12 usage() { echo "Usage: $CMD -a scan # name|address|rssi of nearby BJ_LED strips" echo "Usage: $CMD -a on -i address" echo "Usage: $CMD -a off -i address" echo "Usage: $CMD -a color -i address -n color # static color, RRGGBB or a name, see $USER_COLORS" echo "Usage: $CMD -a effect -i address -n 0-21 [-s 1-10] # effect mode, speed 1 fastest" echo "Usage: $CMD -a send -i address -n HEX # raw bytes, for protocol experiments" echo "Usage: $CMD -a fade -i address -s start -e end -d seconds # colors are RRGGBB or a name, see $USER_COLORS" echo "Usage: $CMD -a listen -i address [-t seconds] # connect and hold the connection open so later commands are fast; returns once connected" echo "Usage: $CMD -a hangup -i address # end a listen; no-op if not listening" echo "Usage: $CMD -a identify # light each unnamed strip a different color, add it to $ALIASES" echo "" echo " -i an address from $CMD -a scan, or a name from $ALIASES (lines of name|address)" echo " -t idle seconds before a listener drops the connection (default $IDLE_SECONDS, 0 listens until hangup)" exit 1 } ## WSL2 has no Bluetooth, so there the script runs under Windows Python. ## Outputs the interpreter, then the path to $1 (default led.py) in that ## interpreter's terms. Run it with stdin from /dev/null: Windows Python under ## WSL swallows the stdin it inherits, which is the script when org runs a block. python_and_script() { local win name=${1:-led.py} if [ -n "$WSL_DISTRO_NAME" ] ; then win=$(ls /mnt/c/Users/*/AppData/Local/Programs/Python/Python3*/python.exe 2>/dev/null | tail -1) if [ -z "$win" ] ; then echo "No Windows Python; winget install Python.Python.3.12, then py -m pip install bleak" >&2 return 1 fi echo "$win" wslpath -w "$LIBEXEC/$name" else echo python3 echo "$LIBEXEC/$name" fi } run_led() { local python script { read -r python ; read -r script ; } < <(python_and_script) [ -z "$script" ] && return 1 "$python" "$script" "$@" "${VERBOSE:-}" < /dev/null } ## Where a client and one strip's daemon meet, in WSL terms. Colons are not ## legal in Windows filenames, so an address becomes 02-24-05-13-3C-7C. ipc_root() { echo "$TMP/ipc/$(echo "$1" | tr ':' '-')" } ## The same directory in the daemon's own terms; under WSL it is a Windows ## process reaching back across the \\wsl.localhost share. ipc_root_native() { if [ -n "$WSL_DISTRO_NAME" ] ; then wslpath -w "$1" else echo "$1" fi } ## Returns 0 when a daemon holds this strip and is worth queueing for. daemon_alive() { local stamp stamp=$(awk '{print $1}' "$1/beat" 2>/dev/null) [ -z "$stamp" ] && return 1 [ $(( $(date +%s) - stamp )) -le "$BEAT_STALE" ] } ## Written whole then moved in, so the daemon never reads an empty command. enqueue() { local root=$1 id=$2 action=$3 name=$4 speed=$5 mkdir -p "$root/cmd" printf '%s\t%s\t%s\n' "$action" "$name" "$speed" > "$root/$id.new" mv "$root/$id.new" "$root/cmd/$id" } queue_command() { enqueue "$1" "$$.$RANDOM.cmd" "$2" "$3" "$4" } ## Returns 0 once the daemon has written the command to the strip. send_confirmed() { local root=$1 action=$2 name=$3 speed=$4 id="$$.$RANDOM.wait" i outcome enqueue "$root" "$id" "$action" "$name" "$speed" for (( i = 0 ; i < COMMAND_WAIT * 100 ; i++ )) ; do for outcome in sent rejected expired ; do [ -e "$root/done/$id.$outcome" ] || continue rm -f "$root/done/$id.$outcome" [ "$outcome" = sent ] && return 0 echo "$action not sent: $outcome" >&2 return 1 done sleep 0.01 done echo "$action not confirmed by the listener" >&2 return 1 } ## Returns once the daemon is announcing itself, so nothing races it for the strip. start_daemon() { local address=$1 root=$2 python script i rm -f "$root/hangup" "$root/connected" rm -rf "$root/done" { read -r python ; read -r script ; } < <(python_and_script leddaemon.py) [ -z "$script" ] && return 1 nohup "$python" "$script" "$address" "$(ipc_root_native "$root")" "$IDLE_SECONDS" \ >> "$root/daemon.out" 2>&1 < /dev/null & disown for (( i = 0 ; i < 50 ; i++ )) ; do daemon_alive "$root" && return 0 sleep 0.1 done return 1 } ## Returns 0 once a daemon holds an open connection to the strip, starting one ## if needed. Otherwise hangs up and returns 1, after $CONNECT_WAIT seconds. listen() { local address=$1 root i root=$(ipc_root "$address") mkdir -p "$root/cmd" daemon_alive "$root" || start_daemon "$address" "$root" || return 1 for (( i = 0 ; i < CONNECT_WAIT * 10 ; i++ )) ; do [ -e "$root/connected" ] && return 0 sleep 0.1 done echo "Could not connect to $address" >&2 hangup "$address" return 1 } ## Returns once the daemon has let go of the strip, so a listen right after it ## starts a new daemon instead of mistaking the departing one for a live one. hangup() { local root i root=$(ipc_root "$1") daemon_alive "$root" || return 0 touch "$root/hangup" for (( i = 0 ; i < 150 ; i++ )) ; do daemon_alive "$root" || return 0 sleep 0.1 done echo "Listener for $1 did not hang up" >&2 return 1 } resolve_alias() { local found found=$(grep -s -i "^$1|" "$ALIASES" | head -1 | cut -d'|' -f2 | tr -d '\r') echo "${found:-$1}" } touch_aliases() { mkdir -p "$(dirname "$ALIASES")" touch "$ALIASES" } identify() { local palette=($COLORS) i address pair touch_aliases i=$(wc -l < "$ALIASES") while IFS='|' read -r -u 3 _ address _ ; do address=$(echo "$address" | tr -d '\r') [ -z "$address" ] && continue grep -q -i "|$address\$" "$ALIASES" && continue pair=${palette[$((i % ${#palette[@]}))]} run_led on "$address" "" 1 && run_led color "$address" "${pair%%:*}" 1 || continue echo "${pair##*:}|$address" >> "$ALIASES" echo "$address -> ${pair##*:}" i=$((i + 1)) done 3< <(run_led scan "" "" 1) } ## Case, spaces, hyphens and underscores do not matter: "Forest Green" is forestgreen. normalize_color_name() { echo "$1" | tr 'A-Z' 'a-z' | tr -d ' _-' } ## First hit in a name:RRGGBB file, comparing names normalized. color_in_file() { awk -F: -v key="$1" '{ name = tolower($1) ; gsub(/[ _-]/, "", name) ; if (name == key) { print $2 ; exit } }' "$2" 2>/dev/null | tr -d '\r #' } color_in_builtins() { local pair for pair in $COLORS ; do [ "${pair##*:}" = "$1" ] && { echo "${pair%%:*}" ; return ; } done } ## Your colors file wins, then the six strip colors identify uses, then the CSS ## names. Anything unmatched is taken as RRGGBB. color_hex() { local key hex key=$(normalize_color_name "$1") hex=$(color_in_file "$key" "$USER_COLORS") [ -z "$hex" ] && hex=$(color_in_builtins "$key") [ -z "$hex" ] && hex=$(color_in_file "$key" "$CSS_COLORS") echo "${hex:-${1#\#}}" } ## Queues one color per tick from fade.py, which paces them on the wall clock. fade() { local address=$1 start end seconds=$4 python script root color start=$(color_hex "$2") end=$(color_hex "$3") for color in "$start" "$end" ; do [[ "$color" =~ ^[0-9a-fA-F]{6}$ ]] || { echo "Not a color: $color" >&2 ; return 1 ; } done { read -r python ; read -r script ; } < <(python_and_script fade.py) [ -z "$script" ] && return 1 listen "$address" || return 1 root=$(ipc_root "$address") queue_command "$root" on "" 1 while read -r color ; do queue_command "$root" color "$color" 1 done < <("$python" -I "$script" "$start" "$end" "$seconds" < /dev/null) } ACTION="" while getopts ":ha:i:n:s:t:vd:e:" o; do case "$o" in a) ACTION=$OPTARG ;; i) ADDRESS="$OPTARG" ;; n) NAME="$OPTARG" ;; s) SPEED="$OPTARG" ;; t) IDLE_SECONDS=$OPTARG ;; v) VERBOSE=yes ;; d) DURATION=$OPTARG ;; e) END=$OPTARG ;; *|h) usage ;; esac done [ -z "$ACTION" ] && usage case "$ACTION" in scan) run_led scan "" "" 1 ;; on|off|color|effect|send) [ -z "$ADDRESS" ] && usage address=$(resolve_alias "$ADDRESS") root=$(ipc_root "$address") [ "$ACTION" = color ] && NAME=$(color_hex "$NAME") if daemon_alive "$root" ; then send_confirmed "$root" "$ACTION" "${NAME:-}" "${SPEED:-1}" else run_led "$ACTION" "$address" "${NAME:-}" "${SPEED:-1}" fi ;; fade) [ -z "$ADDRESS" ] || [ -z "$SPEED" ] || [ -z "$END" ] || [ -z "$DURATION" ] && usage fade "$(resolve_alias "$ADDRESS")" "$SPEED" "$END" "$DURATION" ;; listen) [ -z "$ADDRESS" ] && usage listen "$(resolve_alias "$ADDRESS")" ;; hangup) [ -z "$ADDRESS" ] && usage hangup "$(resolve_alias "$ADDRESS")" ;; identify) identify ;; *) usage ;; esac