Tasker is probably my favorite Android app. It gives you total control over your device. Not to mention there’s a great community that develops all sorts of interesting tasks/integrations.
I was recently on a 10+ hour flight where the entertainment system wasn’t working and my laptop was inaccessible so I was kind of bored. But I had my phone/Tasker, and I got the idea to write a little IoT type of integration while I was on the plane. Basically, I wanted to create an interesting integration of shipping the sensors from my phone off to an Elasticsearch cluster. With this, I could:
- Keep track of where I’ve been, including my run paths and any hikes
- Track and visualize my pedometer over days
- Remember what that song was I was listening to on Play Music/Pandora/Spotify/etc but forgot the name to
- See graphs of my battery usage over days so I can see how the profile changes as I add/update/remove apps, as I tether, and as my phone ages
- Not be bored for a while on the plane
Building Kibana Dashboards
Before I walk through how I created this and give you the downloadable task, I just want to show these dashboards, which I really liked:
Here, I’ve plotted the current battery % on the left and using a derivative pipeline aggregation (available in Elasticsearch and Kibana), we can see how much much the device charged or how much it drew down. We can use this to plot the charge speed of different chargers or see how much apps use. Because I also have things like the screen brightness and network information in the same index object, I can actually plot them against one another to see how much the screen on time/brightness uses battery over a multi-day cycle.
I can plot my runs and corresponding step counts:
Or where I’ve been the last 7 days:
Or even the last 90 days:
I was hoping to get a signal strength map of my cellphone signal, but it looks like Tasker may always be returning “0” even when I have full strength. I may have set that up wrong, so if you have info on how to get it to return non-zero values, let me know!
Anyway, on to the code.
Tasker Javascript Tasks: The Framework
Tasker has Javascript tasks which are quite interesting: you can access variables and you have access to things like XMLHttpRequest. That makes it pretty easy to get the Tasker variables and then send them across to a RESTful engine like Elasticsearch.
A few hundred miles across the Atlantic later, I had something like this:
var esServer = par[0]; if (esServer.substr(esServer.length - 1) !== "/") { esServer = esServer + "/"; } setLocal('%esserver', esServer); setLocal('%authinfo', par[1]); var indexPrefix = "tasker"; var d = new Date(); var dateStr = d.toISOString().slice(0,10); var intVars = { battery: '%BATT', cell_signal_strength: '%CELLSIG', display_brightness: '%BRIGHT', light_level: '%LIGHT', uptime: '%UPS', free_memory: '%MEMF', pedometer: '%STEPSTAKEN' }; var doubleVars = { altitude: '%LOCALT', magnetic_strength: '%MFIELD', temperature: '%TEMP' }; var booleanVars = { bluetooth_on: '%BLUE', locked: '%KEYG', muted: '%MUTED', speakerphone: '%SPHONE', wifi_enabled: '%WIFI', wimax_enabled: '%WIMAX', screen_on: '%SCREEN', roaming: '%ROAM', airplane_mode: '%AIR' }; var keywordVars = { bluetooth_on: '%BLUE', cell_network: '%TNET', device: '%DEVID', device_id: '%DEVTID' }; postData = { timestamp: d.toISOString() }; template = { timestamp: { type: 'date' } }; for (var i in keywordVars) { var res = global(keywordVars[i]); if (typeof(res) !== 'undefined') { postData[i] = res; } template[i] = { type: 'keyword' }; } for (var i in booleanVars) { var res = global(booleanVars[i]); if (typeof(res) !== 'undefined' && res !== '') { postData[i] = (res === 'true' || res === 'on'); } template[i] = { type: 'boolean' }; } for (var i in intVars) { var res = global(intVars[i]); if (typeof(res) !== 'undefined' && res !== '') { if (res >= 0) { postData[i] = parseInt(res); } } template[i] = { type: 'long' }; } for (var i in doubleVars) { var res = global(doubleVars[i]); if (typeof(res) !== 'undefined' && res !== '') { if (res !== 0) { postData[i] = parseFloat(res); } } template[i] = { type: 'double' }; } template['location'] = { type: 'geo_point' }; if (getLocation('any', true, 30)) { var loc = global('%LOC'); var latlon = loc.split(','); postData['location'] = { 'lat': parseFloat(latlon[0]), 'lon': parseFloat(latlon[1]) }; } template['song'] = { type: 'text' }; template['music_playing'] = { type: 'boolean' }; if (global('%ARTIST') !== '' && global('%TRACK') !== '' ) { postData['music_playing'] = (global('%ISPLAYING') === 'true' || global('%ISPLAYING') === true); if (postData['music_playing'] === true) { postData['song'] = global('%ARTIST') + ' - ' + global('%TRACK'); } } else { if (global('%ISPLAYING') === 'false' || global('%ISPLAYING') === false) { postData['music_playing'] = false; } } var wifii = global('%WIFII'); if (typeof(wifii) !== 'undefined' && res !== '') { var wifiarr = wifii.split("\n"); if (wifiarr[0] === '>>> CONNECTION <<<') { postData['wifi_name'] = (wifiarr[2]).slice(1,-1); } template['wifi_name'] = { type: 'keyword' }; } var jsondocstring = JSON.stringify(postData); var indexName = indexPrefix + '-' + dateStr; indexType = 'doc'; setLocal('%jsondocbulkheader', JSON.stringify({ "_index": indexName, "_type": indexType})); setLocal('%jsondocstring',jsondocstring); var xhrTemplate = new XMLHttpRequest(); xhrTemplate.open("PUT", esServer + "_template/" + indexPrefix, false); xhrTemplate.setRequestHeader("Content-type", "application/json"); if (typeof(par[1]) !== 'undefined') { xhrTemplate.setRequestHeader("Authorization", "Basic " + btoa(par[1])); } var templateString = JSON.stringify({ template: indexPrefix + '-*', mappings: { doc: { properties: template } } }); try { xhrTemplate.send(templateString); } catch (e) { } try { var xhrDoc = new XMLHttpRequest(); xhrDoc.open("POST", esServer + indexName + '/' + indexType, false); xhrDoc.setRequestHeader("Content-type", "application/json"); if (typeof(par[1]) !== 'undefined') { xhrDoc.setRequestHeader("Authorization", "Basic " + btoa(par[1])); } xhrDoc.send(jsondocstring); setLocal('%sentdoc','1'); } catch (e) { } exit();
Let’s walk through what this does.
In the beginning:
var esServer = par[0]; if (esServer.substr(esServer.length - 1) !== "/") { esServer = esServer + "/"; } setLocal('%esserver', esServer); setLocal('%authinfo', par[1]); var indexPrefix = "tasker"; var d = new Date(); var dateStr = d.toISOString().slice(0,10);
Here, we set up an index prefix (“tasker”) and we’ll get 2 parameters passed in: the Elasticsearch server host/port (in par[0], which goes into %esserver), and any authentication in the form of username:password (in par[1], which goes into %authinfo).So reminder: when we add this Javascript task, we’ll need to pass those bits in.
Next:
var intVars = { battery: '%BATT', cell_signal_strength: '%CELLSIG', display_brightness: '%BRIGHT', light_level: '%LIGHT', uptime: '%UPS', free_memory: '%MEMF', pedometer: '%STEPSTAKEN' }; var doubleVars = { altitude: '%LOCALT', magnetic_strength: '%MFIELD', temperature: '%TEMP' }; var booleanVars = { bluetooth_on: '%BLUE', locked: '%KEYG', muted: '%MUTED', speakerphone: '%SPHONE', wifi_enabled: '%WIFI', wimax_enabled: '%WIMAX', screen_on: '%SCREEN', roaming: '%ROAM', airplane_mode: '%AIR' }; var keywordVars = { bluetooth_on: '%BLUE', cell_network: '%TNET', device: '%DEVID', device_id: '%DEVTID' };
Here, we gather a list of hardware/software variables that Tasker defines into a variety of types. For example, we put %TEMP into a JSON object of other double variables. It’s important that we do this because we need to know what these types are so we can make some assumptions around how we process each of the variables. For example, looking at Tasker’s variables page, we can see that the variable representing if the device is in Airplane mode (%AIR) returns as “on” or “off” while we really want to process them as booleans (true or false). Many numbers in Tasker return -1 if the value is not defined or cannot be determined, where we really want that to be an empty value in storage. So we store our expected types in a JSON object to check later.
The next set of for loops does 2 things:
for (var i in keywordVars) { var res = global(keywordVars[i]); if (typeof(res) !== 'undefined') { postData[i] = res; } template[i] = { type: 'keyword' }; } for (var i in booleanVars) { var res = global(booleanVars[i]); if (typeof(res) !== 'undefined' && res !== '') { postData[i] = (res === 'true' || res === 'on'); } template[i] = { type: 'boolean' }; } for (var i in intVars) { var res = global(intVars[i]); if (typeof(res) !== 'undefined' && res !== '') { if (res >= 0) { postData[i] = parseInt(res); } } template[i] = { type: 'long' }; } for (var i in doubleVars) { var res = global(doubleVars[i]); if (typeof(res) !== 'undefined' && res !== '') { if (res !== 0) { postData[i] = parseFloat(res); } } template[i] = { type: 'double' }; }
First, it tries to convert, parse, or drop the value based upon the aforementioned logic. Next, it adds the variables it finds to an Elasticsearch template variable we use later.
Next, we have a bit of custom logic for parsing the current location (which comes in a single string of the form “Lat,Lon”), the currently playing song (which comes through Media Utilities), and the WiFi connection info to get the current network name (the %WIFII variable has a rather bizarre format: the 3rd line contains the network name if the first line is “>>> CONNECTION <<<“):
template['location'] = { type: 'geo_point' }; if (getLocation('any', true, 30)) { var loc = global('%LOC'); var latlon = loc.split(','); postData['location'] = { 'lat': parseFloat(latlon[0]), 'lon': parseFloat(latlon[1]) }; } template['song'] = { type: 'text' }; template['music_playing'] = { type: 'boolean' }; if (global('%ARTIST') !== '' && global('%TRACK') !== '' ) { postData['music_playing'] = (global('%ISPLAYING') === 'true' || global('%ISPLAYING') === true); if (postData['music_playing'] === true) { postData['song'] = global('%ARTIST') + ' - ' + global('%TRACK'); } } else { if (global('%ISPLAYING') === 'false' || global('%ISPLAYING') === false) { postData['music_playing'] = false; } } var wifii = global('%WIFII'); if (typeof(wifii) !== 'undefined' && res !== '') { var wifiarr = wifii.split("\n"); if (wifiarr[0] === '>>> CONNECTION <<<') { postData['wifi_name'] = (wifiarr[2]).slice(1,-1); } template['wifi_name'] = { type: 'keyword' }; }
Finally:
var jsondocstring = JSON.stringify(postData); var indexName = indexPrefix + '-' + dateStr; indexType = 'doc'; setLocal('%jsondocbulkheader', JSON.stringify({ "_index": indexName, "_type": indexType})); setLocal('%jsondocstring',jsondocstring); var xhrTemplate = new XMLHttpRequest(); xhrTemplate.open("PUT", esServer + "_template/" + indexPrefix, false); xhrTemplate.setRequestHeader("Content-type", "application/json"); if (typeof(par[1]) !== 'undefined') { xhrTemplate.setRequestHeader("Authorization", "Basic " + btoa(par[1])); } var templateString = JSON.stringify({ template: indexPrefix + '-*', mappings: { doc: { properties: template } } }); try { xhrTemplate.send(templateString); } catch (e) { } try { var xhrDoc = new XMLHttpRequest(); xhrDoc.open("POST", esServer + indexName + '/' + indexType, false); xhrDoc.setRequestHeader("Content-type", "application/json"); if (typeof(par[1]) !== 'undefined') { xhrDoc.setRequestHeader("Authorization", "Basic " + btoa(par[1])); } xhrDoc.send(jsondocstring); setLocal('%sentdoc','1'); } catch (e) { } exit();
In this block, we form the document (and the index template!) into a string via JSON.stringify(), we upload them both to Elasticsearch. Tasker seemed to have some issues with the asynchronous nature of XMLHttpRequest, though it’s possible I was doing something wrong, so I converted them into synchronous calls. At the very end, we set “%sentdoc” to 1 to indicate to the rest of the task that we’ve successfully sent the document. If this isn’t set to 1, we know we didn’t have Internet access or some other barrier to uploading the metrics. In a subsequent action in the task, we’ll write out the bulk header and document to a file on disk so we can upload all the data we missed.
Putting It Into A Project
This project has 4 profiles:
- Every 10 steps, run a task that sets %STEPSTAKEN to 10+%STEPSTAKEN
- Every night at midnight, run a task that sets %STEPSTAKEN to 0
- Every 5 minutes, run the “Update Info” task
- Every time Media Utilities has a new track, run the “Update Info” task
That leaves me to the “Update Info” task. That simply grabs the current values of %ISPLAYING, %TRACK, and %ARTIST, and then runs the “Send to Elasticsearch” task.
The “Send to Elasticsearch” task is a bit more complicated. It:
- Sets %sentdoc to 0 (we’ll use this later!)
- Runs the Javacriptlet above
- If %sentdoc isn’t set to 1 at the end of the Javascript, we write out %jsonbulkheader and then %jsondocstring to a file Tasker/tasks/taskQueue.json
- Else if %WIFII contains >>> CONNECTION <<< this means we’re on a wifi network and we can bulk upload the taskQueue contents. So we read that into %jsonqueue and send it up with another (much shorter) Javascript (and then delete the taskQueue.json file so we don’t resend it):
var jsondocstring = jsonqueue; var xhrDoc = new XMLHttpRequest(); xhrDoc.open("POST", esServer + '_bulk', false); xhrDoc.setRequestHeader("Content-type", "application/json"); if (typeof(authinfo) !== 'undefined') { xhrDoc.setRequestHeader("Authorization", "Basic " + btoa(authinfo)); } xhrDoc.send(jsondocstring); setLocal('%jsonqueue','✓'); exit();
And that’s it!
Putting It All Together
I’ve uploaded the entire project here. You can download them directly and import the project or just have a look over the output and see how it’s tied together and make your own. It does need the %ELASTICSEARCH and %ELASTICSEARCH_AUTH variables to be set and you’ll see it needs Media Utilities to be installed as well. I had fun making this and I’m hoping to add more to it in the future!