Created
March 23, 2017 16:06
-
-
Save andrewkroh/132e86bbb7b81477958dd35811fcf2e3 to your computer and use it in GitHub Desktop.
AWS SNS Output for SmartThings
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Amazon SNS Event Publisher | |
* | |
* Copyright 2016 Andrew Kroh | |
*/ | |
import java.text.DateFormat | |
import java.text.SimpleDateFormat | |
import javax.crypto.Mac | |
import javax.crypto.spec.SecretKeySpec | |
definition( | |
name: "Amazon SNS Event Publisher", | |
namespace: "com.andrewkroh", | |
author: "Andrew Kroh", | |
description: "Publishes events to an Amazon SNS topic.", | |
category: "My Apps", | |
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", | |
iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/[email protected]", | |
iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/[email protected]" | |
) { | |
appSetting "AWS_ACCESS_KEY" | |
appSetting "AWS_SECRET_KEY" | |
appSetting "TOPIC_ARN" | |
appSetting "ENDPOINT" | |
} | |
def capabilities() { | |
return [ | |
accelerationSensor: [ | |
name: 'Acceleration Sensor', | |
attributes: ['acceleration'], | |
], | |
actuator: [ | |
name: 'Actuator', | |
attributes: [], | |
], | |
alarm: [ | |
name: 'Alarm', | |
attributes: ['alarm'], | |
], | |
battery: [ | |
name: 'Battery', | |
attributes: ['battery'], | |
], | |
beacon: [ | |
name: 'Beacon', | |
attributes: ['presence'], | |
], | |
button: [ | |
name: 'Button', | |
attributes: ['button'], | |
], | |
carbonDioxideMeasurement: [ | |
name: 'Carbon Dioxide Measurement', | |
attributes: ['carbonDioxide'], | |
], | |
carbonMonoxideDetector: [ | |
name: 'Carbon Monoxide Detector', | |
attributes: ['carbonMonoxide'], | |
], | |
colorControl: [ | |
name: 'Color Control', | |
attributes: ['hue', 'saturation', 'color'], | |
], | |
colorTemperature: [ | |
name: 'Color Temperature', | |
attributes: ['colorTemperature'], | |
], | |
configuration: [ | |
name: 'Configuration', | |
attributes: [], | |
], | |
consumable: [ | |
name: 'Consumable', | |
attributes: ['consumable'], | |
], | |
contactSensor: [ | |
name: 'Contact Sensor', | |
attributes: ['contact'], | |
], | |
doorControl: [ | |
name: 'Door Control', | |
attributes: ['door'], | |
], | |
energyMeter: [ | |
name: 'Energy Meter', | |
attributes: ['energy'], | |
], | |
garageDoorControl: [ | |
name: 'Garage Door Control', | |
attributes: ['door'], | |
], | |
illuminanceMeasurement: [ | |
name: 'Illuminance Measurement', | |
attributes: ['illuminance'], | |
], | |
imageCapture: [ | |
name: 'Image Capture', | |
attributes: ['image'], | |
], | |
lock: [ | |
name: 'Lock', | |
attributes: ['lock'], | |
], | |
mediaController: [ | |
name: 'Media Controller', | |
attributes: ['activities', 'currentActivity'], | |
], | |
momentary: [ | |
name: 'Momentary', | |
attributes: [], | |
], | |
motionSensor: [ | |
name: 'Motion Sensor', | |
attributes: ['motion'], | |
], | |
musicPlayer: [ | |
name: 'Music Player', | |
attributes: ['status', 'level', 'trackDescription', 'trackData', 'mute'], | |
], | |
notification: [ | |
name: 'Notification', | |
attributes: [], | |
], | |
pHMeasurement: [ | |
name: 'pH Measurement', | |
attributes: ['pH'], | |
], | |
polling: [ | |
name: 'Polling', | |
attributes: [], | |
], | |
powerMeter: [ | |
name: 'Power Meter', | |
attributes: ['power'], | |
], | |
presenceSensor: [ | |
name: 'Presence Sensor', | |
attributes: ['presence'], | |
], | |
refresh: [ | |
name: 'Refresh', | |
attributes: [], | |
], | |
relativeHumidityMeasurement: [ | |
name: 'Relative Humidity Measurement', | |
attributes: ['humidity'], | |
], | |
relaySwitch: [ | |
name: 'Relay Switch', | |
attributes: ['switch'], | |
], | |
sensor: [ | |
name: 'Sensor', | |
attributes: [], | |
], | |
shockSensor: [ | |
name: 'Shock Sensor', | |
attributes: ['shock'], | |
], | |
signalStrength: [ | |
name: 'Signal Strength', | |
attributes: ['lqi', 'rssi'], | |
], | |
sleepSensor: [ | |
name: 'Sleep Sensor', | |
attributes: ['sleeping'], | |
], | |
smokeDetector: [ | |
name: 'Smoke Detector', | |
attributes: ['smoke'], | |
], | |
soundSensor: [ | |
name: 'Sound Sensor', | |
attributes: ['sound'], | |
], | |
speechSynthesis: [ | |
name: 'Speech Synthesis', | |
attributes: [], | |
], | |
stepSensor: [ | |
name: 'Step Sensor', | |
attributes: ['steps', 'goal'], | |
], | |
switch: [ | |
name: 'Switch', | |
attributes: ['switch'], | |
], | |
switchLevel: [ | |
name: 'Switch Level', | |
attributes: ['level'], | |
], | |
soundPressureLevel: [ | |
name: 'Sound Pressure Level', | |
attributes: ['soundPressureLevel'], | |
], | |
tamperAlert: [ | |
name: 'Tamper Alert', | |
attributes: ['tamper'], | |
], | |
temperatureMeasurement: [ | |
name: 'Temperature Measurement', | |
attributes: ['temperature'], | |
], | |
thermostat: [ | |
name: 'Thermostat', | |
attributes: ['temperature', 'heatingSetpoint', 'coolingSetpoint', 'thermostatSetpoint', 'thermostatMode', 'thermostatFanMode', 'thermostatOperatingState'], | |
], | |
thermostatCoolingSetpoint: [ | |
name: 'Thermostat Cooling Setpoint', | |
attributes: ['coolingSetpoint'], | |
], | |
thermostatFanMode: [ | |
name: 'Thermostat Fan Mode', | |
attributes: ['thermostatFanMode'], | |
], | |
thermostatHeatingSetpoint: [ | |
name: 'Thermostat Heating Setpoint', | |
attributes: ['heatingSetpoint'], | |
], | |
thermostatMode: [ | |
name: 'Thermostat Mode', | |
attributes: ['thermostatMode'], | |
], | |
thermostatOperatingState: [ | |
name: 'Thermostat Operating State', | |
attributes: ['thermostatOperatingState'], | |
], | |
thermostatSetpoint: [ | |
name: 'Thermostat Setpoint', | |
attributes: ['thermostatSetpoint'], | |
], | |
threeAxis: [ | |
name: 'Three Axis', | |
attributes: ['threeAxis'], | |
], | |
timedSession: [ | |
name: 'Timed Session', | |
attributes: ['sessionStatus', 'timeRemaining'], | |
], | |
tone: [ | |
name: 'Tone', | |
attributes: [], | |
], | |
touchSensor: [ | |
name: 'Touch Sensor', | |
attributes: ['touch'], | |
], | |
valve: [ | |
name: 'Valve', | |
attributes: ['contact'], | |
], | |
voltageMeasurement: [ | |
name: 'Voltage Measurement', | |
attributes: ['voltage'], | |
], | |
waterSensor: [ | |
name: 'Water Sensor', | |
attributes: ['water'], | |
], | |
windowShade: [ | |
name: 'Window Shade', | |
attributes: ['windowShade'], | |
], | |
] | |
} | |
preferences { | |
section("Choose one or more, when..."){ | |
capabilities().each{ capability, data -> | |
input capability, "capability.${capability}", title: data['name'], required: false, multiple:true | |
} | |
} | |
} | |
def installed() { | |
log.debug "Installed with settings: ${settings}" | |
subscribeToEvents() | |
} | |
def updated() { | |
log.debug "Updated with settings: ${settings}" | |
unsubscribe() | |
subscribeToEvents() | |
} | |
def subscribeToEvents() { | |
capabilities().each{ capability, data -> | |
def attributes = data['attributes'] | |
attributes.each{ attribute -> | |
log.trace "Subscribing to capability=${capability} attribute: ${attribute}" | |
subscribe(settings[capability], attribute, eventHandler) | |
} | |
} | |
} | |
def toJson(data) { | |
return new groovy.json.JsonBuilder(data) | |
} | |
def eventToMap(evt) { | |
// These fields are not safe to call unconditionally. They can cause | |
// exceptions. | |
//doubleValue: evt.doubleValue | |
//floatValue: evt.floatValue | |
//integerValue: evt.integerValue | |
//isoDate: evt.isoDate | |
//jsonValue: evt.jsonValue | |
//location: evt.location | |
//dateValue: evt.dateValue | |
//longValue: evt.longValue | |
//numberValue: evt.numberValue | |
//numericValue: evt.numericValue | |
//stringValue: evt.stringValue | |
//xyzValue: evt.xyzValue | |
// This field is usually null. | |
//installedSmartAppId: evt.installedSmartAppId, | |
// This field causes a StackOverflow because it of circular | |
// references. | |
//device: evt.device | |
return [ | |
id: evt.id, | |
data: evt.data, | |
description: evt.description, | |
descriptionText: evt.descriptionText, | |
displayName: evt.displayName, | |
deviceId: evt.deviceId, | |
hubId: evt.hubId, | |
isDigital: evt.isDigital(), | |
isPhysical: evt.isPhysical(), | |
isStateChange: evt.isStateChange(), | |
linkText: evt.linkText, | |
locationId: evt.locationId, | |
name: evt.name, | |
source: evt.source, | |
unit: evt.unit, | |
unixTimeMs: evt.date.time, | |
value: evt.value, | |
] | |
} | |
def eventHandler(evt) { | |
try { | |
def eventMap = eventToMap(evt) | |
def json = toJson([event: eventMap]) | |
def req = signedRequest("POST", appSettings.ENDPOINT, '/', [ | |
"Action": "Publish", | |
"Message": json.toString(), | |
"TopicArn": appSettings.TOPIC_ARN, | |
]) | |
log.trace "Request parameters: ${req}" | |
httpPost(req) { resp -> | |
log.debug "Response status:${resp.status} contentType:${resp.contentType} data:${resp.data}" | |
} | |
} catch (Throwable t) { | |
log.error "Exception while processing event ${evt.value}: ${t}" | |
} | |
} | |
String timestamp() { | |
String timestamp = null; | |
Calendar cal = Calendar.getInstance(); | |
DateFormat dfm = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); | |
dfm.setTimeZone(TimeZone.getTimeZone("GMT")); | |
timestamp = dfm.format(cal.getTime()); | |
return timestamp; | |
} | |
String canonicalize(SortedMap<String, String> sortedParamMap) | |
{ | |
if (sortedParamMap.isEmpty()) { | |
return ""; | |
} | |
return sortedParamMap.collect{ k, v -> | |
"${percentEncodeRfc3986(k)}=${percentEncodeRfc3986(v)}" | |
}.join('&') | |
} | |
String percentEncodeRfc3986(String s) { | |
String out; | |
try { | |
out = URLEncoder.encode(s, "UTF-8") | |
.replace("+", "%20") | |
.replace("*", "%2A") | |
.replace("%7E", "~"); | |
} catch (UnsupportedEncodingException e) { | |
out = s; | |
} | |
return out; | |
} | |
String HmacSHA256(String data) { | |
String algorithm = "HmacSHA256" | |
Mac mac = javax.crypto.Mac.getInstance(algorithm) | |
mac.init(new SecretKeySpec(appSettings.AWS_SECRET_KEY.getBytes("UTF-8"), algorithm)) | |
return "${mac.doFinal(data.getBytes("UTF-8")).encodeBase64()}" | |
} | |
Map signedRequest(String method, String endpoint, String uri, Map params) { | |
if (uri == null) { uri = '/' } | |
params.put("AWSAccessKeyId", appSettings.AWS_ACCESS_KEY); | |
params.put("Timestamp", timestamp()); | |
params.put("SignatureMethod", "HmacSHA256") | |
params.put("SignatureVersion", "2") | |
SortedMap<String, String> sortedParamMap = | |
new TreeMap<String, String>(params); | |
String canonicalQS = canonicalize(sortedParamMap); | |
String toSign = method + "\n" + endpoint + "\n" + uri + "\n" + canonicalQS; | |
String hmac = HmacSHA256(toSign); | |
String sig = percentEncodeRfc3986(hmac); | |
LinkedHashMap lm = new LinkedHashMap(); | |
sortedParamMap.each{ k, v -> lm.put(k, v) } | |
lm.put("Signature", hmac) | |
return [ | |
uri: "https://${endpoint}", | |
path: uri, | |
query: lm, | |
] | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment