Skip to content

Instantly share code, notes, and snippets.

@andrewkroh
Created March 23, 2017 16:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save andrewkroh/132e86bbb7b81477958dd35811fcf2e3 to your computer and use it in GitHub Desktop.
Save andrewkroh/132e86bbb7b81477958dd35811fcf2e3 to your computer and use it in GitHub Desktop.
AWS SNS Output for SmartThings
/**
* 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/Cat-Convenience@2x.png",
iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png"
) {
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