]> git.vanrenterghem.biz Git - elgato-keylight-script.git/blob - keylights.sh
54515214d1d644e2df1ccd68ec95150a47f2b09d
[elgato-keylight-script.git] / keylights.sh
1 #!/bin/bash
2 set -Eeuo pipefail
4 trap destroy SIGINT SIGTERM ERR EXIT
6 # Settings
7 script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
8 icon="${script_dir}/assets/elgato.png"
10 # Declarations
11 declare -i silent=0
12 declare -i pretty=0
13 declare action="usage"
14 declare target='.'
15 declare limit=""
16 declare format="json"
17 declare -A lights
18 declare lights_json
19 declare full_json
20 declare simple_json
21 declare flat_json
22 declare call='curl --silent --show-error --location --header "Accept: application/json" --request'
23 declare devices="/elgato/lights"
24 declare accessory_info="/elgato/accessory-info"
25 declare settings="/elgato/lights/settings"
27 if [ ! -r "${icon}" ]; then icon=sunny; fi
29 notify() {
30     if [ $silent -eq 0 ]; then
31         notify-send -i "$icon" "Key Light Controller" "$1"
32     fi
33 }
35 die() {
36     echo >&2 -e "${1-}"
37     exit "${2-1}"
38 }
40 destroy() {
41     code=$?
43     exit ${code}
44 }
46 usage() {
47     cat <<EOF
48 Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-f <value>] [-l <value>] [-p] [-s] [-t <value>][-v] [--<option>] [--<option> <value>] <action>
50 Elgato Lights controller. Works for Key Light and Key Light Air.
52 Available actions:
53     list        List available lights
54     status      Get state of lights
55     on          Turn all lights on
56     off         Turn all lights off
57     temperature Set temperature level (260-470)
58     brightness  Set brightness level (0-100)
59     increase    Increases brightness by 10
60     decrease    Decreases brightness by 10
62 Available formats:
63     json        Renders output as JSON (default)
64     simple      Renders output as JSON array of single level objects with subarrays as .(dot) notation JSON
65     flat        Renders output as fully flattened single level JSON with .(dot) notation JSON
66     html        Renders output as basic html table
67     csv         Renders output as csv
68     table       Renders output as a printed table
69     pair        Renders output as flattened key=value pairs
72 Available options:
74 -h, --help               Print this help and exit
75 -f, --format             Set output format
76 -l, --limit <list>       Limit top level output fields to the specified comma separated list
77 -p, --pretty             Pretty print console output
78 -s, --silent             Supress notifications
79 -t, --target <filter>    Only perform action on devices where value matches filter
80 -v, --verbose            Print script debug info
81 EOF
82     exit
83 }
85 parse_params() {
86     # default values of variables set from params
88     while :; do
89         case "${1-}" in
90         -h | --help) usage ;;
91         -f | --format)
92             format="${2-}"
93             shift
94             ;;
95         -l | --limit)
96             limit=$(eval echo "\| { ${2-} } ")
97             shift
98             ;;
99         -p | --pretty) pretty=1 ;;
100         -v | --verbose) set -x ;;
101         -s | --silent) silent=1 ;;
102         -t | --target)
103             target="${2-}"
104             shift
105             ;;
106         -?*) die "Unknown option: $1" ;;
107         *) break ;;
108         esac
109         shift
110     done
112     args=("$@")
114     # check required params and arguments
115     declare -A actions=([help]=1 [list]=1 [status]=1 [on]=1 [off]=1)
116     [[ ${#args[@]} -ne 1 ]] && die "Incorrect argument count"
118     #[[ ($silent -eq 1) && ($pretty -eq 1) ]] && die "Cannot use silent and pretty options simultaneously"
120     [[ -n "${actions[${args[0]}]}" ]] && action="${args[0]}"
122     return 0
125 dependencies() {
126     for var in "$@"; do
127         if ! command -v $var &>/dev/null; then
128             die "Dependency $var was not found, please install and try again"
129         fi
130     done
133 default_light_properties() {
134     # Default values for json type enforcement
135     device="N/A"
136     hostname="N/A"
137     manufacturer="N/A"
138     ipv4="N/A"
139     ipv6="N/A"
140     port=0
141     mac="N/A"
142     sku="N/A"
143     cfg="{}"
144     url="{}"
145     info="{}"
146     light="{}"
150 produce_json() {
151     t=$(eval echo "'[.[] $limit| select($target)]'")
152     f=$(eval echo "'[.[] | select($target)]'")
154     lights_json=$(echo "${lights[@]}" | jq -c -s "$t")
155     full_json=$(echo "${lights[@]}" | jq -c -s "$f")
156     simple_json=$(echo "${lights_json}" | jq -c '.[] | reduce ( tostream | select(length==2) | .[0] |= [join(".")] ) as [$p,$v] ({}; setpath($p; $v)) ')
157     simple_json=$(echo "${simple_json}" | jq -c -s '.') # slurp it to make it an array
158     flat_json=$(echo "${lights_json}" | jq -c -s '.[] | reduce ( tostream | select(length==2) | .[0] |= [join(".")] ) as [$p,$v] ({}; setpath($p; $v)) ')
162 output() {
164     # Mange user requested output format
165     case $format in
166     json) print_json "$lights_json" ;;
167     simple) print_json "$simple_json" ;;
168     flat) print_json "$flat_json" ;;
169     table) print_structured '@tsv' ;;
170     csv) print_structured '@csv' ;;
171     pair) print_structured 'pairs' ;;
172     html) print_html ;;
173     -?*) die "Unknown output format (-f/--format): $format" ;;
174     esac
177 print_json() {
179     # Manage pretty printing
180     if [[ $pretty -eq 1 ]]; then
181         echo "${1-}" | jq '.'
182     else
183         echo "${1-}" | jq -c -M '.'
184     fi
186     exit 0
189 print_structured() {
190     pp=${2-$pretty}
192     # Handle csv and table printing
193     query="(.[0] | keys_unsorted | map(ascii_upcase)), (.[] | [.[]])|${1-@csv}"
195     # Handle printing as key value pairs
196     if [[ ${1} == 'pairs' ]]; then
197         query='.[] | "--------------",(to_entries[] | [.key, "=", .value] | @tsv)'
198     fi
200     # Manage pretty printing
201     if [[ $pp -eq 1 ]]; then
202         echo "${simple_json}" | jq --raw-output "$query" | column -t -s$'\t' | sed -e 's/"//g'
203     else
204         if [[ ${1} == 'pairs' ]]; then
205             echo "${simple_json}" | jq -r "$query" | sed -e 's/\t//g'
206         else
207             echo "${simple_json}" | jq -r "$query"
208         fi
209     fi
212 print_html() {
213     data=$(print_structured '@csv' 1)
215     html="
216     <table>
217     $(
218         print_header=true
219         while read d; do
220             if $print_header; then
221                 echo "<tr><th>${d//,/<\/th><th>}</th></tr>"
222                 print_header=false
223                 continue
224             fi
225             echo "<tr><td>${d//,/</td><td>}</td></tr>"
226         done <<<"${data}"
227     )
228     </table>"
229     echo "$html"
232 set_state() {
233     declare -a data
234     readarray -t data < <(echo "${full_json}" | jq -c '.[] | {displayName, url, numberOfLights, lights}')
235     declare -a updated
237     x=$(echo "${1}" | tr 01 10) # "flip the bit"
239     for d in "${data[@]}"; do
240         query_old="[.lights[] | select(.on==${x})] | length"
241         count_found=$(echo "${d}" | jq "$query_old")
243         # Don't send to lights already in wanted state
244         if [[ $count_found -eq 0 ]]; then continue; fi
246         # Extract relevant data and create new json object
247         url=$(echo "${d}" | jq '.url')
248         dn=$(echo "${d}" | jq -r '.displayName')
249         l=$(echo "${d}" | jq -c 'del(.url, .displayName)' | jq ". | .lights[].on = ${1}")
251         # Send command
252         if eval "${call} PUT -d '${l}' ${url}${devices}" >/dev/null; then updated+=("$dn"); fi
253     done
255     # Text representation of new state
256     state="ON"
257     [[ $1 -eq 0 ]] && state="OFF"
259     # Send notification
260     if [[ ${#updated[*]} -gt 0 ]]; then
261         n="Turned $state ${#updated[@]} lights:\n\n"
262         for i in "${updated[@]}"; do
263             n+="$i\n"
264         done
265         notify "$n"
267     fi
270 find_lights() {
271     # Scan the network for Elgato devices
272     declare -a avahi
273     readarray -t avahi < <(avahi-browse -d local _elg._tcp --resolve -t -p | grep -v "^\+")
275     declare device
276     declare hostname
277     declare manufacturer
278     declare ipv4
279     declare ipv6
280     declare -i port
281     declare mac
282     declare sku
283     declare cfg
284     declare url
285     declare info
286     declare light
287     default_light_properties
289     for l in "${avahi[@]}"; do
290         IFS=';' read -ra data <<<"$l" # split line into array
292         # Gather information about the light
293         device=$(echo "${data[3]}" | sed -e 's/\\032/ /g') # fix avahi output
294         hostname=${data[6]}
295         [[ ${data[7]} =~ fe80 ]] && ipv6=${data[7]} || ipv4=${data[7]}
296         port=${data[8]}
297         txt=$(eval echo "${data[9]}") # eval to strip quotes
298         [[ $txt =~ mf=([^[[:space:]]*]*) ]] && manufacturer=${BASH_REMATCH[1]}
299         [[ $txt =~ id=([^[[:space:]]*]*) ]] && mac=${BASH_REMATCH[1]}
300         [[ $txt =~ md=.+[[:space:]]([^[[:space:]]*]*)[[:space:]]id= ]] && sku=${BASH_REMATCH[1]}
302         # Get information from the light
303         url="http://$ipv4:$port"
305         declare protocol="--ipv4"
306         if [[ $ipv4 == "N/A" ]]; then
307             # Workaround: Ignoring ipv6 as Elgato miss-announces addressing and is not accepting requests
308             # properly for v6. Will not change to filter only on ipv4 from avahi, as that can cause us to only end
309             # up with an ipv6 address even though it was announced as ipv4, which in turn means we cannot communicate.
310             continue
311             # Remove above and uncomment below if a future update fixes ipv6 announcement and requests
312             #protocol="--ipv6"
313             #url="http://[$ip]:$port"
314         fi
316         cfg=$(eval "${call} GET $protocol ${url}${settings}") >/dev/null
317         info=$(eval "${call} GET $protocol ${url}${accessory_info}") >/dev/null
318         light=$(eval "${call} GET $protocol ${url}${devices}") >/dev/null
320         json=$(jq -n \
321             --arg dev "$device" \
322             --arg hn "$hostname" \
323             --arg ipv4 "$ipv4" \
324             --arg ipv6 "$ipv6" \
325             --argjson port "$port" \
326             --arg mf "$manufacturer" \
327             --arg mac "$mac" \
328             --arg sku "$sku" \
329             --arg url "$url" \
330             --argjson cfg "$cfg" \
331             '{device: $dev, manufacturer: $mf, hostname: $hn, url: $url, ipv4: $ipv4, ipv6: $ipv6, 
332                 port: $port, mac: $mac, sku: $sku, settings: $cfg}')
334         # Store the light as json and merge info + light into base object
335         lights["$device"]=$(echo "$info $light $json" | jq -s '. | add')
337         # Reset for next light as we are processing the last avahi line
338         default_light_properties
340     done
343 # Quit if script is run by root
344 [[ "$EUID" -eq 0 ]] && die "Not allowed to run as root"
346 # Manage user parameters
347 parse_params "$@"
349 # Make sure dependencies are installed
350 dependencies avahi-browse curl notify-send jq
352 find_lights
354 # Fail if we cannot find lights
355 [[ ${#lights[@]} -eq 0 ]] && die "No lights found"
357 produce_json
359 # Dispatch actions
360 case $action in
361 usage) usage ;;
362 list) output ;;
363 status) status ;;
364 on) set_state 1 ;;
365 off) set_state 0 ;;
366 -?*) die "Unknown action" ;;
367 esac