GeoHopper and iBeacons for proximity scripting

I have been working for a long time to make lights in my house follow me around with accurate proximity detection. I always have my iPhone on me, so I’ve been using Bluetooth to handle this. Originally, I hacked around with Proximity to accomplish this, but ultimately found EventScripts to be a better solution. It still wasn’t as accurate as I wanted it to be.

I started playing with iBeacons. iBeacons use Bluetooth Low Energy (BTLE) and can provide more precise Bluetooth proximity detection than any other method I’ve tried. It’s also easier on mobile device batteries than Bluetooth, which is relevant in this case because a setup like this requires polling and broadcasting on the mobile device.

The technique I’m using can be set up on any web host, depending on what actions you want to trigger. My scenario requires a local web host, so the examples provided will only be of interest to Mac users who have the knowledge (or search engine skills) to set up a local server. It’s also tailored to AppleScript and Indigo, but you can use this method to perform any actions you want on a computer when your phone comes into range.

Choosing your weapon

I’ve been through many iterations at this point. I’m using Bleu Station beacons for the most part, but I learned you can also set up a BTLE-equipped Mac as a transmitting beacon with little effort. I’ve gotten far enough to have written my own prototype apps for triggering my lights, but the most constructive solution I’ve found so far is to use the GeoHopper app.

With both the Bleu beacons and the BeaconOSX method, you can set the power of the signal to control the range of the beacon (within a margin of error1). With the Bleu beacons, you use the iOS setup app to control the power, and with a homebrew Mac-beacon, you can set the transmit power in the code.

Triggering scripts

The trick is to get events to trigger when a device enters the beacon’s region. I’m using GeoHopper on my iPhone to trigger events when I come into or leave the region of a beacon. There’s a GeoHopper Mac app that can trigger scripts when devices enter and exit, but I’ve found it a bit “crashy.” The iOS app has been quite reliable, though.

You can add iBeacons to GeoHopper on your iPhone (also see the url scheme), and when the device running it enters or exits a beacon’s region, it can send JSON payloads to webhooks. This can be used with services like IFTTT, or — as I’ve done — you can set up your own CGI for it.

Writing the CGI

This requires a local web server. I’m not going to go into details on how to set that up, but I highly recommend MAMP Pro as an easy way to handle virtual hosts and settings. I set mine up to handle Ruby scripts as CGIs and built out a handler for the JSON messages.

To make a virtual host under Apache run Ruby scripts, make sure ExecCGI is enabled and add this to the virtual host directory settings:

AddHandler cgi-script .rb

Now you can make any Ruby script executable and have it use the ‘cgi’ library to handle tasks without building out an entire API. Of course, all of this can be done with PHP or whatever language your preferred platform supports.

I built my script to handle query strings first, so I can ping an address such as “http://myserver.com?a=lightson” directly from any web browser and turn lights on. Then I added a handler for the GeoHopper webhook.

The payload that GeoHopper sends looks like this:

{
	"sender":"your.device@email.com",
	"location":"office",
	"event":"LocationExit",
	"time":"2013-09-11 02:03:33 +0000"
}

My script just checks to make sure the sending device is authorized, then takes the event — “LocationEnter” or “LocationExit” — and runs AppleScripts (using osascript) for Indigo based on which event occurred. It currently only handles my default location (“office”), but the “location” key can provide a pivot for setting up different scripts to execute for multiple locations from the same web host.

Example webhook

Since anyone who wants to set this up will have to customize the script to some extent, I’m just providing my version below as an example.

geohoppercgi.rbraw
#!/usr/bin/env ruby
# Requires json: `sudo gem install json`
require 'cgi'
require 'rubygems'
require 'json'
# CONFIG
# The email address or domain from which GeoHopper events will be accepted
geohopper_email = "brettterpstra.com"
# END CONFIG

# hash to hold JSON responses
result = {}

cgi = CGI.new
print cgi.header( 'type' => 'application/json','expires' => Time.now - (180) )

# Hash of query parameter keys
p = cgi.keys[0]
# if the query comes from GeoHopper, this is the JSON payload
# Otherwise it uses the "a" parameter to define the action


if p.nil? # No query params or payload
	print "No action specified"
	Process.exit
end

result["request"] = p
script = ""

### MANUAL QUERY STRING
# if the key "a" exists in the query string, check
# its value for an available action
if p == "a"
	# create a 'when' statement for each available action
	# script:
	# 	The action to run. By default, these are the names
	# 	of Indigo Actions to run
	# result["action"]:
	# 	status message for the JSON response
	case cgi.params['a'][0]
	when "officelightsoff"
		script = "All Office Lights Off"
		result["action"] = "Turning office lights OFF"
	when "officelightson"
		script = "All Office Lights On"
		result["action"] = "Turning office lights ON"
	else
		result["action"] = "No action recognized"
	end
### GEOHOPPER
# if there's no "a" key in the query string, assume
# JSON payload from GeoHopper
else
	begin
		# Parse the first key as JSON
		json = JSON.parse(p)
		result["valid"] = true
	rescue
		# if we can't parse the key as JSON, return an error
		result["valid"] = false
		result["result"] = "Invalid JSON"
		print result.to_json
		Process.exit
	end

	# GeoHopper device's registered email (or just domain)
	if json["sender"] =~ /#{geohopper_email}$/
		# script:
		# 	The action to run. By default, these are the names
		# 	of Indigo Actions to run
		# result["action"]:
		# 	status message for the JSON response
		## Action to take on exit event
		if json["event"] == "LocationExit"
			script = "All Office Lights Off"
			result["action"] = "Turning office lights OFF"
		## Action to take on enter event
		elsif json["event"] == "LocationEnter"
			script = "All Office Lights On"
			result["action"] = "Turning office lights ON"
		else
			result["action"] = "No action recognized"
		end
	end
end

# Run applescript and store any response in "result" key
# If osascript exits cleanly, the result will be empty
# If the script variable has not been defined by an event
# above, this is bypassed
result["result"] = %x{osascript -e 'tell application "IndigoServer.app" to «event INDOExeG» "#{script}"'} unless script == ""

# return the response object as JSON
print result.to_json

Once you have the CGI set up and a URL for it, see the GeoHopper FAQ for information on adding a web service as a notification.

I’m not providing support for this, but if you know enough about web servers and Ruby to get started and run into specific problems, feel free to drop me a line.

  1. proximity is determined by a ratio of transmit and reception strength, which can fluctuate significantly.

Brett Terpstra

Brett is a writer and developer living in Minnesota, USA. You can follow him as ttscoff on Twitter, GitHub, and Mastodon. Keep up with this blog by subscribing in your favorite news reader.

This content is supported by readers like you.