#!/bin/bash # WLED Tools # A utility for managing WLED devices in a local network # https://github.com/wled/WLED # Color Definitions GREEN="\e[32m" RED="\e[31m" BLUE="\e[34m" YELLOW="\e[33m" RESET="\e[0m" # Logging function log() { local category="$1" local color="$2" local text="$3" if [ "$quiet" = true ]; then return fi if [ -t 1 ]; then # Check if output is a terminal echo -e "${color}[${category}]${RESET} ${text}" else echo "[${category}] ${text}" fi } # Fetch a URL to a destination file, validating status codes. # Usage: fetch "" "" "200 404" fetch() { local url="$1" local dest="$2" local accepted="${3:-200}" # If no dest given, just discard body local out if [ -n "$dest" ]; then # Write to ".tmp" files first, then move when success, to ensure we don't write partial files out="${dest}.tmp" else out="/dev/null" fi response=$(curl --connect-timeout 5 --max-time 30 -s -w "%{http_code}" -o "$out" "$url") local curl_exit_code=$? if [ $curl_exit_code -ne 0 ]; then [ -n "$dest" ] && rm -f "$out" log "ERROR" "$RED" "Connection error during request to $url (curl exit code: $curl_exit_code)." return 1 fi for code in $accepted; do if [ "$response" = "$code" ]; then # Accepted; only persist body for 2xx responses if [ -n "$dest" ]; then if [[ "$response" =~ ^2 ]]; then mv "$out" "$dest" else rm -f "$out" fi fi return 0 fi done # not accepted [ -n "$dest" ] && rm -f "$out" log "ERROR" "$RED" "Unexpected response from $url (HTTP $response)." return 2 } # POST a file to a URL, validating status codes. # Usage: post_file "" "" "200" post_file() { local url="$1" local file="$2" local accepted="${3:-200}" response=$(curl --connect-timeout 5 --max-time 300 -s -w "%{http_code}" -o /dev/null -X POST -F "file=@$file" "$url") local curl_exit_code=$? if [ $curl_exit_code -ne 0 ]; then log "ERROR" "$RED" "Connection error during POST to $url (curl exit code: $curl_exit_code)." return 1 fi for code in $accepted; do if [ "$response" -eq "$code" ]; then return 0 fi done log "ERROR" "$RED" "Unexpected response from $url (HTTP $response)." return 2 } # Print help message show_help() { cat << EOF Usage: wled-tools.sh [OPTIONS] COMMAND [ARGS...] Options: -h, --help Show this help message and exit. -t, --target Specify a single WLED device by IP address or hostname. -D, --discover Discover multiple WLED devices using mDNS. -d, --directory Specify a directory for saving backups (default: working directory). -f, --firmware Specify the firmware file for updating devices. -q, --quiet Suppress logging output (also makes discover output hostnames only). Commands: backup Backup the current state of a WLED device or multiple discovered devices. update Update the firmware of a WLED device or multiple discovered devices. discover Discover WLED devices using mDNS and list their IP addresses and names. Examples: # Discover all WLED devices on the network ./wled-tools discover # Backup a specific WLED device ./wled-tools -t 192.168.1.100 backup # Backup all discovered WLED devices to a specific directory ./wled-tools -D -d /path/to/backups backup # Update firmware on all discovered WLED devices ./wled-tools -D -f /path/to/firmware.bin update EOF } # Discover devices using mDNS discover_devices() { if ! command -v avahi-browse &> /dev/null; then log "ERROR" "$RED" "'avahi-browse' is required but not installed, please install avahi-utils using your preferred package manager." exit 1 fi # Map avahi responses to strings seperated by 0x1F (unit separator) mapfile -t raw_devices < <(avahi-browse _wled._tcp --terminate -r -p | awk -F';' '/^=/ {print $7"\x1F"$8"\x1F"$9}') local devices_array=() for device in "${raw_devices[@]}"; do IFS=$'\x1F' read -r hostname address port <<< "$device" devices_array+=("$hostname" "$address" "$port") done echo "${devices_array[@]}" } # Backup one device backup_one() { local hostname="$1" local address="$2" local port="$3" log "INFO" "$YELLOW" "Backing up device config/presets/ir: $hostname ($address:$port)" mkdir -p "$backup_dir" local file_prefix="${backup_dir}/${hostname}" if ! fetch "http://$address:$port/cfg.json" "${file_prefix}.cfg.json"; then log "ERROR" "$RED" "Failed to backup configuration for $hostname" return 1 fi if ! fetch "http://$address:$port/presets.json" "${file_prefix}.presets.json"; then log "ERROR" "$RED" "Failed to backup presets for $hostname" return 1 fi # ir.json is optional if ! fetch "http://$address:$port/ir.json" "${file_prefix}.ir.json" "200 404"; then log "ERROR" "$RED" "Failed to backup ir configs for $hostname" fi log "INFO" "$GREEN" "Successfully backed up config and presets for $hostname" return 0 } # Update one device update_one() { local hostname="$1" local address="$2" local port="$3" local firmware="$4" log "INFO" "$YELLOW" "Starting firmware update for device: $hostname ($address:$port)" local url="http://$address:$port/update" if ! post_file "$url" "$firmware" "200"; then log "ERROR" "$RED" "Failed to update firmware for $hostname" return 1 fi log "INFO" "$GREEN" "Successfully initiated firmware update for $hostname" return 0 } # Command-line arguments processing command="" target="" discover=false quiet=false backup_dir="./" firmware_file="" if [ $# -eq 0 ]; then show_help exit 0 fi while [[ $# -gt 0 ]]; do case "$1" in -h|--help) show_help exit 0 ;; -t|--target) if [ -z "$2" ] || [[ "$2" == -* ]]; then log "ERROR" "$RED" "The --target option requires an argument." exit 1 fi target="$2" shift 2 ;; -D|--discover) discover=true shift ;; -d|--directory) if [ -z "$2" ] || [[ "$2" == -* ]]; then log "ERROR" "$RED" "The --directory option requires an argument." exit 1 fi backup_dir="$2" shift 2 ;; -f|--firmware) if [ -z "$2" ] || [[ "$2" == -* ]]; then log "ERROR" "$RED" "The --firmware option requires an argument." exit 1 fi firmware_file="$2" shift 2 ;; -q|--quiet) quiet=true shift ;; backup|update|discover) command="$1" shift ;; *) log "ERROR" "$RED" "Unknown argument: $1" exit 1 ;; esac done # Execute the appropriate command case "$command" in discover) read -ra devices <<< "$(discover_devices)" for ((i=0; i<${#devices[@]}; i+=3)); do hostname="${devices[$i]}" address="${devices[$i+1]}" port="${devices[$i+2]}" if [ "$quiet" = true ]; then echo "$hostname" else log "INFO" "$BLUE" "Discovered device: Hostname=$hostname, Address=$address, Port=$port" fi done ;; backup) if [ -n "$target" ]; then # Assume target is both the hostname and address, with port 80 backup_one "$target" "$target" "80" elif [ "$discover" = true ]; then read -ra devices <<< "$(discover_devices)" for ((i=0; i<${#devices[@]}; i+=3)); do hostname="${devices[$i]}" address="${devices[$i+1]}" port="${devices[$i+2]}" backup_one "$hostname" "$address" "$port" done else log "ERROR" "$RED" "No target specified. Use --target or --discover." exit 1 fi ;; update) # Validate firmware before proceeding if [ -z "$firmware_file" ] || [ ! -f "$firmware_file" ]; then log "ERROR" "$RED" "Please provide a file in --firmware that exists" exit 1 fi if [ -n "$target" ]; then # Assume target is both the hostname and address, with port 80 update_one "$target" "$target" "80" "$firmware_file" elif [ "$discover" = true ]; then read -ra devices <<< "$(discover_devices)" for ((i=0; i<${#devices[@]}; i+=3)); do hostname="${devices[$i]}" address="${devices[$i+1]}" port="${devices[$i+2]}" update_one "$hostname" "$address" "$port" "$firmware_file" done else log "ERROR" "$RED" "No target specified. Use --target or --discover." exit 1 fi ;; *) show_help exit 1 ;; esac