Script Examples

Shell scripts demonstrating advanced automation patterns with Shelly CLI.

Usage

1
2
3
4
5
6
7
8
# Make executable
chmod +x examples/scripts/bulk-update.sh

# Run with dry-run first
./examples/scripts/bulk-update.sh --dry-run

# Run for real
./examples/scripts/bulk-update.sh

bulk-update.sh

bulk-update.sh - Bulk firmware update with safety checks

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
#!/usr/bin/env bash
#
# bulk-update.sh - Bulk firmware update with safety checks
#
# Updates firmware on multiple devices with proper error handling,
# progress tracking, and optional backup capability.
#
# Usage: ./bulk-update.sh [--dry-run] [--sequential] [--skip-backup] [--yes]
#
# Options:
#   --dry-run      Check for updates without applying
#   --sequential   Update one device at a time (safer, slower)
#   --skip-backup  Skip backup before update (not recommended)
#   --yes          Skip confirmation prompt

set -euo pipefail

# Configuration
DRY_RUN=false
SEQUENTIAL=false
SKIP_BACKUP=false
SKIP_CONFIRM=false
BACKUP_DIR="${HOME}/shelly-backups/pre-update-$(date '+%Y%m%d-%H%M%S')"

# Parse arguments
while [[ $# -gt 0 ]]; do
    case "${1}" in
        --dry-run)
            DRY_RUN=true
            shift
            ;;
        --sequential)
            SEQUENTIAL=true
            shift
            ;;
        --skip-backup)
            SKIP_BACKUP=true
            shift
            ;;
        --yes|-y)
            SKIP_CONFIRM=true
            shift
            ;;
        *)
            printf 'Unknown option: %s\n' "${1}"
            exit 1
            ;;
    esac
done

log() {
    printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"
}

error() {
    printf '[%s] ERROR: %s\n' "$(date '+%H:%M:%S')" "$*" >&2
}

# Check if shelly CLI is available
if ! command -v shelly &>/dev/null; then
    printf 'Error: shelly CLI not found in PATH\n'
    exit 1
fi

log "Checking for firmware updates..."

# Get list of devices with updates available
updates_json=$(shelly firmware check --all -o json 2>/dev/null || printf '[]')

if [[ "${updates_json}" == "[]" ]]; then
    log "No devices found or unable to check updates"
    exit 0
fi

# Filter devices with updates
devices_with_updates=$(printf '%s' "${updates_json}" | jq -r '.[] | select(.update_available == true) | .name' 2>/dev/null || true)

if [[ -z "${devices_with_updates}" ]]; then
    log "All devices are up to date!"
    exit 0
fi

# Count updates
update_count=$(printf '%s' "${devices_with_updates}" | wc -l | tr -d ' ')
log "Found ${update_count} device(s) with available updates:"
printf '\n'

# Show what will be updated
printf '%s' "${updates_json}" | jq -r '.[] | select(.update_available == true) | "  \(.name): \(.current_version) → \(.new_version)"' 2>/dev/null || true
printf '\n'

if ${DRY_RUN}; then
    log "Dry run mode - no updates will be applied"
    exit 0
fi

# Confirm with user unless --yes
if ! ${SKIP_CONFIRM}; then
    read -p "Proceed with updates? (y/N) " -n 1 -r
    printf '\n'
    if [[ ! ${REPLY} =~ ^[Yy]$ ]]; then
        log "Update cancelled"
        exit 0
    fi
fi

# Create backup unless skipped
if ! ${SKIP_BACKUP}; then
    log "Creating backups in ${BACKUP_DIR}..."
    mkdir -p "${BACKUP_DIR}"

    if shelly backup create --all --dir "${BACKUP_DIR}" -q 2>/dev/null; then
        log "Backups created successfully"
    else
        error "Backup failed! Use --skip-backup to proceed anyway"
        exit 1
    fi
fi

# Perform updates
log "Starting firmware updates..."
success_count=0
fail_count=0

while IFS= read -r device; do
    [[ -z "${device}" ]] && continue

    log "Updating ${device}..."

    if shelly firmware update "${device}" --yes -q 2>/dev/null; then
        log "  ${device} updated successfully"
        ((success_count++)) || true
    else
        error "  Failed to update ${device}"
        ((fail_count++)) || true
    fi

    # Wait between updates if sequential
    if ${SEQUENTIAL}; then
        log "  Waiting for device to reboot..."
        sleep 30

        # Wait for device to come back online
        if shelly wait "${device}" --online --timeout 120s -q 2>/dev/null; then
            log "  ${device} is back online"
        else
            error "  ${device} did not come back online within timeout"
        fi
    fi
done <<< "${devices_with_updates}"

printf '\n'
log "Update complete!"
log "  Successful: ${success_count}"
log "  Failed: ${fail_count}"

if [[ ${fail_count} -gt 0 ]]; then
    error "Some updates failed. Backups available in: ${BACKUP_DIR}"
    exit 1
fi

presence-detect.sh

presence-detect.sh - Network presence-based home automation

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#!/usr/bin/env bash
#
# presence-detect.sh - Network presence-based home automation
#
# Detects when family members arrive/leave by pinging their phones on the
# local network. This goes beyond Shelly app by integrating with network
# state that Shelly devices can't detect themselves.
#
# Usage: ./presence-detect.sh [--daemon]
#
# Configure DEVICES with phone IPs and associated actions below.
# Run with --daemon to continuously monitor (or add to cron).

set -euo pipefail

# Configuration: Map phone IPs to device actions
# Format: "phone_ip:device:action_on_arrive:action_on_leave"
TRACKED_DEVICES=(
    "192.168.1.100:porch-light:on:off"       # Alice's phone
    "192.168.1.101:garage-light:on:off"      # Bob's phone
    "192.168.1.102:entryway-light:on:off"    # Guest phone
)

# How many successful pings = "home"
PING_THRESHOLD=2
# How many failed pings = "away"
AWAY_THRESHOLD=3
# Seconds between checks
CHECK_INTERVAL=30
# State file to track presence
STATE_FILE="${HOME}/.shelly-presence-state"

declare -A presence_state
declare -A away_count

log() {
    printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"
}

# Load previous state
load_state() {
    if [[ -f "${STATE_FILE}" ]]; then
        while IFS='=' read -r key value; do
            presence_state["${key}"]="${value}"
        done < "${STATE_FILE}"
    fi
}

# Save state
save_state() {
    : > "${STATE_FILE}"
    for key in "${!presence_state[@]}"; do
        printf '%s=%s\n' "${key}" "${presence_state[${key}]}" >> "${STATE_FILE}"
    done
}

# Ping a device, return 0 if reachable
is_reachable() {
    local ip="${1}"
    ping -c 1 -W 1 "${ip}" &>/dev/null
}

# Execute shelly action
do_action() {
    local device="${1}"
    local action="${2}"

    case "${action}" in
        on)
            shelly on "${device}" -q 2>/dev/null || log "  Warning: Failed to turn on ${device}"
            ;;
        off)
            shelly off "${device}" -q 2>/dev/null || log "  Warning: Failed to turn off ${device}"
            ;;
        *)
            log "  Unknown action: ${action}"
            ;;
    esac
}

check_presence() {
    for entry in "${TRACKED_DEVICES[@]}"; do
        IFS=':' read -r phone_ip device action_arrive action_leave <<< "${entry}"

        local prev_state="${presence_state[${phone_ip}]:-unknown}"
        local current_away="${away_count[${phone_ip}]:-0}"

        if is_reachable "${phone_ip}"; then
            # Device is reachable
            away_count["${phone_ip}"]=0

            if [[ "${prev_state}" != "home" ]]; then
                log "ARRIVAL: ${phone_ip} detected -> turning ${device} ${action_arrive}"
                do_action "${device}" "${action_arrive}"
                presence_state["${phone_ip}"]="home"
            fi
        else
            # Device not reachable
            ((current_away++)) || true
            away_count["${phone_ip}"]="${current_away}"

            if [[ "${current_away}" -ge "${AWAY_THRESHOLD}" && "${prev_state}" != "away" ]]; then
                log "DEPARTURE: ${phone_ip} gone -> turning ${device} ${action_leave}"
                do_action "${device}" "${action_leave}"
                presence_state["${phone_ip}"]="away"
            fi
        fi
    done

    save_state
}

# One-time check
single_check() {
    load_state
    log "Checking presence..."
    check_presence
    log "Done"
}

# Continuous monitoring
daemon_mode() {
    load_state
    log "Starting presence detection daemon (Ctrl+C to stop)"
    log "Monitoring ${#TRACKED_DEVICES[@]} device(s)"

    while true; do
        check_presence
        sleep "${CHECK_INTERVAL}"
    done
}

# Main
case "${1:-}" in
    --daemon)
        daemon_mode
        ;;
    *)
        single_check
        ;;
esac

weather-automation.sh

weather-automation.sh - Weather-based device automation

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
#!/usr/bin/env bash
#
# weather-automation.sh - Weather-based device automation
#
# Adjusts devices based on current weather conditions using wttr.in API.
# Examples:
# - Close blinds/covers when sunny and hot
# - Turn on lights when cloudy/overcast
# - Reduce AC when temperature drops
#
# This integrates external weather data with Shelly - something the
# devices and app cannot do on their own.
#
# Usage: ./weather-automation.sh [--daemon] [--location CITY]
#
# Requires: curl, jq

set -euo pipefail

# Configuration - customize for your setup
LOCATION="${LOCATION:-}"  # Auto-detect if empty
CHECK_INTERVAL=1800  # 30 minutes

# Device mappings
COVER_DEVICE="living-room-blinds"   # Cover/roller for sun protection
LIGHT_DEVICE="living-room-light"    # Light for cloudy days
AC_DEVICE="ac-outlet"               # Smart plug for AC

# Thresholds
HOT_TEMP=28          # Celsius - close blinds above this + sunny
CLOUDY_LUX=50        # Close blinds when cloud cover % is below this (sunny)
OVERCAST_LUX=70      # Turn on lights when cloud cover % is above this

log() {
    printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"
}

# Get weather data from wttr.in
get_weather() {
    local location="${1:-}"
    local url="https://wttr.in/${location}?format=j1"

    curl -s "${url}" 2>/dev/null
}

# Extract values from weather JSON
parse_weather() {
    local json="${1}"

    temp=$(printf '%s' "${json}" | jq -r '.current_condition[0].temp_C' 2>/dev/null || printf 'N/A')
    feels_like=$(printf '%s' "${json}" | jq -r '.current_condition[0].FeelsLikeC' 2>/dev/null || printf 'N/A')
    cloud_cover=$(printf '%s' "${json}" | jq -r '.current_condition[0].cloudcover' 2>/dev/null || printf '50')
    uv_index=$(printf '%s' "${json}" | jq -r '.current_condition[0].uvIndex' 2>/dev/null || printf '0')
    description=$(printf '%s' "${json}" | jq -r '.current_condition[0].weatherDesc[0].value' 2>/dev/null || printf 'Unknown')
    location_name=$(printf '%s' "${json}" | jq -r '.nearest_area[0].areaName[0].value' 2>/dev/null || printf 'Unknown')
}

# Apply automation rules based on weather
apply_rules() {
    log "Current weather in ${location_name}:"
    log "  Temperature: ${temp}°C (feels like ${feels_like}°C)"
    log "  Conditions: ${description}"
    log "  Cloud cover: ${cloud_cover}%"
    log "  UV index: ${uv_index}"

    local hour
    hour=$(date '+%H')

    # Only apply rules during daytime (7 AM - 9 PM)
    if [[ "${hour}" -lt 7 || "${hour}" -gt 21 ]]; then
        log "Outside daytime hours, skipping automation"
        return
    fi

    # Rule 1: Hot and sunny -> close blinds
    if [[ "${temp}" -gt "${HOT_TEMP}" && "${cloud_cover}" -lt "${CLOUDY_LUX}" ]]; then
        log "Hot and sunny - closing blinds for sun protection"
        shelly cover close "${COVER_DEVICE}" -q 2>/dev/null || log "  Warning: Could not close blinds"
    fi

    # Rule 2: Overcast/dark -> turn on lights
    if [[ "${cloud_cover}" -gt "${OVERCAST_LUX}" ]]; then
        log "Overcast conditions - turning on lights"
        shelly on "${LIGHT_DEVICE}" -q 2>/dev/null || log "  Warning: Could not turn on lights"
    else
        # Clear skies during day - natural light is enough
        log "Clear skies - turning off supplemental lights"
        shelly off "${LIGHT_DEVICE}" -q 2>/dev/null || true
    fi

    # Rule 3: Temperature dropped significantly -> reduce AC
    if [[ "${temp}" -lt 22 ]]; then
        log "Temperature comfortable - reducing AC"
        shelly off "${AC_DEVICE}" -q 2>/dev/null || true
    fi

    log "Automation rules applied"
}

# Single check
single_check() {
    log "Fetching weather data..."

    local weather_json
    weather_json=$(get_weather "${LOCATION}")

    if [[ -z "${weather_json}" || "${weather_json}" == "null" ]]; then
        log "Error: Could not fetch weather data"
        exit 1
    fi

    parse_weather "${weather_json}"
    apply_rules
}

# Daemon mode - continuous monitoring
daemon_mode() {
    log "Starting weather automation daemon (Ctrl+C to stop)"
    log "Checking every $((CHECK_INTERVAL / 60)) minutes"

    while true; do
        single_check
        printf '\n'
        sleep "${CHECK_INTERVAL}"
    done
}

# Parse arguments
while [[ $# -gt 0 ]]; do
    case "${1}" in
        --daemon)
            daemon_mode
            exit 0
            ;;
        --location)
            LOCATION="${2}"
            shift 2
            ;;
        *)
            printf 'Unknown option: %s\n' "${1}"
            exit 1
            ;;
    esac
done

# Default: single check
single_check

workstation-sync.sh

workstation-sync.sh - Sync desk lights with computer activity

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
#!/usr/bin/env bash
#
# workstation-sync.sh - Sync desk lights with computer activity
#
# Automatically adjusts desk/office lights based on your computer's state:
# - Screen unlocks -> lights on at appropriate brightness for time of day
# - Screen locks -> lights dim/off
# - Meeting starts (calendar) -> bias lighting mode
#
# This integrates your computer's state with Shelly - something the app can't do.
#
# macOS: Uses system events
# Linux: Uses dbus/logind
#
# Usage: ./workstation-sync.sh [--install]
#
# --install: Set up as a launch agent (macOS) or systemd service (Linux)

set -euo pipefail

# Configuration
DESK_LIGHT="desk-lamp"
MONITOR_BACKLIGHT="monitor-bias"  # Optional LED strip behind monitor
OFFICE_MAIN="office-ceiling"

# Brightness levels by time of day
get_brightness() {
    local hour
    hour=$(date '+%H')

    if [[ "${hour}" -lt 7 ]]; then
        printf '20'    # Early morning: dim
    elif [[ "${hour}" -lt 9 ]]; then
        printf '60'    # Morning: medium
    elif [[ "${hour}" -lt 17 ]]; then
        printf '100'   # Daytime: full
    elif [[ "${hour}" -lt 20 ]]; then
        printf '70'    # Evening: slightly reduced
    else
        printf '40'    # Night: dim
    fi
}

log() {
    printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"
}

on_unlock() {
    local brightness
    brightness=$(get_brightness)

    log "Screen unlocked - setting up workspace (brightness: ${brightness}%)"

    # Turn on desk light with appropriate brightness
    if shelly light set "${DESK_LIGHT}" --brightness "${brightness}" -q 2>/dev/null; then
        log "  Desk light: ${brightness}%"
    else
        shelly on "${DESK_LIGHT}" -q 2>/dev/null || true
    fi

    # Optional: Turn on bias lighting
    shelly on "${MONITOR_BACKLIGHT}" -q 2>/dev/null || true
}

on_lock() {
    log "Screen locked - reducing lights"

    # Dim desk light significantly or turn off
    if shelly light set "${DESK_LIGHT}" --brightness 10 -q 2>/dev/null; then
        log "  Desk light: dimmed to 10%"
    else
        shelly off "${DESK_LIGHT}" -q 2>/dev/null || true
    fi

    # Turn off bias lighting
    shelly off "${MONITOR_BACKLIGHT}" -q 2>/dev/null || true
}

# macOS: Monitor screen lock/unlock events
monitor_macos() {
    log "Monitoring screen lock events (macOS)..."
    log "Press Ctrl+C to stop"

    # Use ioreg to detect screen state changes
    local prev_state="unknown"

    while true; do
        # Check if screen is locked
        if /usr/libexec/PlistBuddy -c "Print :IOConsoleUsers:0:CGSSessionScreenIsLocked" /dev/stdin 2>/dev/null <<< "$(ioreg -n Root -d1 -a)" | grep -q "true"; then
            if [[ "${prev_state}" != "locked" ]]; then
                on_lock
                prev_state="locked"
            fi
        else
            if [[ "${prev_state}" != "unlocked" ]]; then
                on_unlock
                prev_state="unlocked"
            fi
        fi
        sleep 2
    done
}

# Linux: Monitor screen lock via dbus
monitor_linux() {
    log "Monitoring screen lock events (Linux)..."
    log "Press Ctrl+C to stop"

    # Use gdbus to monitor screensaver signals
    gdbus monitor --session --dest org.gnome.ScreenSaver --object-path /org/gnome/ScreenSaver 2>/dev/null | while read -r line; do
        if [[ "${line}" == *"ActiveChanged"*"true"* ]]; then
            on_lock
        elif [[ "${line}" == *"ActiveChanged"*"false"* ]]; then
            on_unlock
        fi
    done
}

# Detect OS and run appropriate monitor
case "$(uname)" in
    Darwin)
        monitor_macos
        ;;
    Linux)
        monitor_linux
        ;;
    *)
        log "Unsupported OS: $(uname)"
        exit 1
        ;;
esac