A product image of a Philips Hue Starter Kit. It shows a large black box with product imagery on the front. In front of the box are its contents. These include two bulbs, a dimmer knob, and a bridge.

Philips Hue Programming, Off the Charts (Part 3 of 3)

A product image of a Philips Hue Starter Kit. It shows a large black box with product imagery on the front. In front of the box are its contents. These include two bulbs, a dimmer knob, and a bridge.In the second instalment of this series, I left you with a couple of useful scripts — one to fetch the device ID of a light, given a name, and one to perform the conversion from RGB to CIE colour. I developed those two scripts as necessary parts of what comes next.

I have demonstrated the actioning of four different aspects of a light: power, colour temperature, colour, and brightness. In my goal of creating bespoke automation, I wanted simplicity. I didn’t want to use different scripts for different tasks. I didn’t want to have to use “magic numbers” (like 153 mirek), and I wanted an easy way to specify any combination of change, and across multiple lights in one go.

The result of this… insanity… is a single script which addresses all of these aspects. So, how did I get there? Well… buckle up.

My first requirement was that I didn’t want a script in my home directory with bespoke parameters — it had to operate like a native command. I achieved this in three ways. First, I named my script simply hue. There is no .sh suffix. Second, I placed it in a directory that was already in my path. Third, I implemented standard command parameters.

In my case, the directory ~/.local/bin was already present and in my path. This appears to have been created by a third party tool but, from what I have read, it is a recommended location for user scripts. I will leave it as an exercise for the user to worry about where to put the script and how to make that location a permanent part of your path. We have more than enough to cover here already.

For the handling of standard parameters, I have used a third party tool to help me out. I did not consider rolling my own use of getopt or getopts because both have competing drawbacks. getopt is far harder to use but allows long option names. getopts is much easier to use but does not have long option names. So, I called on the services of Argbash.

Argbash is a downloadable tool, but I always use the website provided by the developer, which means I don’t need to install anything. I will leave the reader/listener to peruse the Argbash website but, in a nutshell, you simply provide a description of your parameters in specially-formatted bash comments, then ask Argbash to generate the code to handle them.

I created a very basic script that defined my parameters and had just enough code to prove they worked. I’ve switched to the bash shell because that’s what Argbash knows. It’s in the name.

#!/bin/bash
#
# ARG_OPTIONAL_SINGLE([switch], , [Switch 'on' or 'off'])
# ARG_OPTIONAL_SINGLE([rgb], , [RGB triplet (0-255)])
# ARG_OPTIONAL_SINGLE([temp], , [Colour temperature (2000-6500)K])
# ARG_OPTIONAL_SINGLE([dim], , [Dimming level (1-100)])
# ARG_POSITIONAL_INF([lights], [Lights to set], 1)
# ARG_HELP([Allows control of Philips Hue lights via Hue Bridge])
# ARGBASH_GO

# [ <-- needed because of Argbash

if [ "$_arg_switch" != " " ]; then
  echo "Switch $_arg_switch"
fi
if [ "$_arg_rgb" != " " ]; then
  echo "RGB $_arg_rgb"
fi
if [ "$_arg_temp" != " " ]; then
  echo "temp $_arg_temp"
fi
if [ "$_arg_dim" != " " ]; then
  echo "dim $_arg_dim"
fi
echo "Lights:"
for light in "${_arg_lights[@]}"; do
  echo "> $light"
done

# ] <-- needed because of Argbash

My parameters are comprised of four optional flags; one each for switching on or off, setting a colour, setting a colour temperature, and setting brightness. Beyond those I allow for an infinite number of light names. The initial script simply prints out the parameters it is given.

I will spare readers the full code generated by Argbash, but suffice to say it worked essentially as I wanted, so I did the rest directly in the generated script without needing to return to Argbash. I’ve left the Argbash lines at the top for later reference should I end up expanding on the script.

With my flags and arguments defined, this is the form of command I was aiming for:

hue --rgb 255,127,0 --dim 45 "Study S1" "Study S2" "Study S3"

Now onto how the script turns this kind of command into action.

First, I added a couple of lines to define the Hue application key and the Bridge IP address. These were mentioned in part 1 of the series. To use my script, you will need to edit in your own values.

# ----- EDIT THESE ---------------------------
hak="Qn74cB7YlKursSzMYyPL4pr5oLWxayBqhKyjFD10"  
hba="192.168.1.15"
# --------------------------------------------

I defined a base URL variable that contains the IP address and the correct API path for both querying lights and, with the addition of an ID, actioning them.

burl="https://${hba}/clip/v2/resource/light"

Next up are two functions. These are essentially the two scripts I produced last time. The only tweaks were to make them work with the variables in the rest of the script.

function get_id() {
  local filter='.data[] | select(.metadata.name == "'"$light"'") | .id'
  local dev_id=$(curl --request GET -ks --header "hue-application-key: $hak" "${burl}" | jq -r "$filter")
  echo "$dev_id"
}
                
function get_colour() {
  r_srgb="$(echo "scale=4; ${rgb[0]} / 255" | bc)"
  g_srgb="$(echo "scale=4; ${rgb[1]} / 255" | bc)"
  b_srgb="$(echo "scale=4; ${rgb[2]} / 255" | bc)"  
          
  # Linearise RGB
  if [ 1 -eq "$(echo "${r_srgb} < 0.04045" | bc)" ]; then
    r_lin=$( echo "${r_srgb}" | awk '{print $1 / 12.92};')
  else
    r_lin=$( echo "${r_srgb}" | awk '{print (($1 + 0.055) / 1.055) ^ 2.4};' )
  fi                              

  if [ 1 -eq "$(echo "${g_srgb} < 0.04045" | bc)" ]; then
    g_lin=$( echo "${g_srgb}" | awk '{print $1 / 12.92};')
  else                          
    g_lin=$( echo "${g_srgb}" | awk '{print (($1 + 0.055) / 1.055) ^ 2.4};' )
  fi                    

  if [ 1 -eq "$(echo "${b_srgb} < 0.04045" | bc)" ]; then
    b_lin=$( echo "${b_srgb}" | awk '{print $1 / 12.92};')
  else
    b_lin=$( echo "${b_srgb}" | awk '{print (($1 + 0.055) / 1.055) ^ 2.4};' )
  fi

  # Calculate XYZ
  x_cie=$( echo "${r_lin} ${g_lin} ${b_lin}" | awk '{print $1 * 0.4124 + $2 * 0.3576 + $3 * 0.1805};')
  y_cie=$( echo "${r_lin} ${g_lin} ${b_lin}" | awk '{print $1 * 0.2126 + $2 * 0.7152 + $3 * 0.0722};')
  z_cie=$( echo "${r_lin} ${g_lin} ${b_lin}" | awk '{print $1 * 0.0193 + $2 * 0.1192 + $3 * 0.9505};')
  
  json="\"color\":{\"xy\":{\"x\":$x_cie,\"y\":$y_cie}}"
  echo $json
} 

Note that each of these functions simply echoes out the result. When we call them, we can use the same $(...) construct to capture the value.

Now we move onto the first functional part of the new script. Because fetching light IDs takes a not-insignificant amount of time, especially given I am allowing for an arbitrary number of them, I decided it was worth spending the time up front to gather all of the needed IDs. This is done with the get_idfunction defined above. Also in this process, I am silently discarding any lights that could not be found. The result is an array of light IDs for use later.

It is worth noting at this point that shells were not designed to be fast. If you want instantaneous action across a significant number of lights, you’re better off using a different language.

# Fetch valid light IDs
ids=()
for light in "${_arg_lights[@]}"; do
  light_id=$(get_id)            
  if [ "$light_id" != "" ]; then
    ids+=("$light_id")
  fi
done  

The next phase of the script is validation of the provided values. There may be edge cases, but I think these capture and complain about any fundamentally incorrect values.

First up, I check whether I have at least one valid light ID.

# No valid lights
if [ "${#ids[@]}" = "0" ]; then
  echo "No valid lights given."
  exit 1
fi

If I don’t, the script prints a suitable message and exits right then. All of the following validations follow this basic pattern.

The next validation is the next most simple. If the --switch flag is specified, its value must be on or off. This is all fairly basic bash logic.

# Switch must be on or off
if [ "$_arg_switch" != "" ]; then
  if [ "$_arg_switch" != "on" ] && [ "$_arg_switch" != "off" ]; then
    echo "--switch must be 'on' or 'off'."
    exit 1
  fi
fi

Validating the --rgb flag is a bit more involved. The user must supply 3 values separated by commas, they must be valid integers, and they must be in the range from 0 to 255.

# Validate RGB triplet
if [ "$_arg_rgb" != "" ]; then
  IFS=',' read -ra rgb <<< "$_arg_rgb"
  if [ "${#rgb[@]}" != "3" ]; then
    echo "--rgb must supply 3 comma-separated values"
    exit 1
  fi
  for c in "${rgb[@]}"; do
    if ! [[ $c =~ ^[0-9]+$ ]]; then
      echo "--rgb must supply 3 comma separated numbers, e.g. --rgb 100,255,0"
      exit 1
    fi
    if [[ $c -lt 0 || $c -gt 255 ]]; then
      echo "--rgb must supply 3 comma separated values in the range 0-255"
      exit 1
    fi
  done
fi

There are several more interesting bash constructs in that check. First, splitting up the comma-separated triplet into an array, which involves setting the separator and reading the values into the array. If the array has any number of elements other than three, then I complain.

Once I know I have three values, I check they contain only digits and again complain if not. This uses the very powerful bash regular expression comparison, though in a fairly simple way.

Finally, I now know I have three integers, so I check to see they are in the range 0-255.

Up next, and a little simpler, is the --temp flag for colour temperature.

# Temp must be in the range 2000-6500
if [ "$_arg_temp" != "" ]; then
  if ! [[ $_arg_temp =~ ^[0-9]+$ ]]; then
    echo "--temp must be an integer in the range 2000-6500"
    exit 1
  fi
  if [[ $_arg_temp -lt 2000 ]] || [[ $_arg_temp -gt 6500 ]]; then
    echo "--temp must be in the range 2000-6500"
    exit 1
  fi
fi  

Once again, a check is made for only digits and when that is satisfied, I check the number is in the range 2000-6500.

The final check is the --dim flag for brightness. This needs to be an integer from 0-100. The checks are nearly identical to the colour temperature ones, just a different range check.

# Dimming must be in the range 0-100
if [ "$_arg_dim" != "" ]; then
  if ! [[ $_arg_dim =~ ^[0-9]+$ ]]; then
    echo "-- dim must be an interger in the range 0-100"
    exit 1
  fi
  if [[ $_arg_dim -lt 0 ]] || [[ $_arg_dim -gt 100 ]]; then
    echo "--dim must be in the range 0-100"
    exit 1
  fi
fi

Once I get to this point in the script, whatever values I have been given are in good shape, so the next phase is to build up the JSON string that will write the actions to the lights.

For each of the flags provided, these sections will add to the json variable using straightforward string concatenation with some conditionals thrown in. The --switch flag section is the first.

# Build up JSON actions
json=""

# Switch on or off
if [ "$_arg_switch" != "" ]; then
  json="${json}\"on\":{\"on\":"
  if [ "$_arg_switch" = "on" ]; then
    json="${json}true"
  else  
    json="${json}false" 
  fi
  json="${json}}"
fi

After this, each section also has an extra conditional to insert a comma if there is prior content.

# RGB
if [ "$_arg_rgb" != "" ]; then  
  if [ "$json" != "" ]; then
    json="${json},"
  fi
  json="${json}$(get_colour)"
fi
  
# Temperature
if [ "$_arg_temp" != "" ]; then
  if [ "$json" != "" ]; then
    json="${json},"
  fi
  mirek=$(echo "scale=0; 1000000 / ${_arg_temp}" | bc)
  json="${json}\"color_temperature\":{\"mirek\":${mirek}}"
fi
      
# Dimming
if [ "$_arg_dim" != "" ]; then
  if [ "$json" != "" ]; then
    json="${json},"
  fi  
  json="${json}\"dimming\":{\"brightness\":${_arg_dim}}"
fi  

The --rgb flag processing calls the get_colour function to get the complete JSON object. The --temp flag processing does the simple mirek calculation using bc.

Finally, the series of JSON objects are wrapped in an outer object before looping through the previously-collected IDs and applying the actions with the curl command.

# Wrap the json in an object
json="{${json}}"
    
# Finally, iterate over the lights and action each
for id in "${ids[@]}"; do
  url="${burl}/${id}"
  curl --request PUT -ks --data "${json}" --header "hue-application-key: $hak" "${url}" > /dev/null
done

That’s it! The complete script is 311 lines long. Without blank lines and comments, it is 236 lines, and ignoring the Argbash bits, only 123 lines of code that I have written myself.

In use, the script can seem a little slow, but in my case, I have 6 lights, and 1 switch connected to my bridge and it is able to set 3 of the lights’ colour temperature and brightness in 1 second. Setting a colour takes more work in the conversion of RGB to CIE and sets the same three lights, including brightness, in around 1.6 seconds. In both cases, I can see the changes ripple across the three bulbs.

The speed could be improved either with a more efficient language, or by looking at storing IDs rather than looking up each one every time. Or you could look into light grouping. There is plenty of scope to improve on my effort.

For my own purposes, I will be using Better Touch Tool to script my Elgato Stream Deck buttons to perform various actions on my lights. The possibilities are many!

You can find the full script on my GitHub account.

Leave a Reply

Your email address will not be published. Required fields are marked *

Scroll to top