SmartThings Smart Home Dashboard #5

Up to this point we’ve taken a lot of steps to understand how to make a SmartThings Webhook SmartApp, including Creating a Public URL, Creating a SmartApp Framework, Configuring your SmartApp in SmartThings Developer Workspace, and Sending Live Events to the Browser through Web Sockets.

Now we’re going to start gathering all of our rooms and devices along with their current state. To do this, we will need to create a Personal Access Token (PAT). This is needed because we won’t have an authToken being passed to us to use when we first start up our server. This PAT will grant us all of the access we need for anything we need to do outside of a SmartThings lifecycle.

Creating a Personal Access Token:

  1. Login to your SmartThings (Samsung) Account here.
  1. Click Generate New Token.
  1. Give your token a name (i.e., ST Webhook) and select ALL scopes. Then click Generate Token.
  1. Copy your token and save it somewhere safe. You won’t be able to see it again after you close this window, but you can always delete it and/or create a new token if needed.
  1. Now we’re going to save your token on your server, but we’re going to be adding some new files. Create a new file and move your ST_WEBHOOK = ‘XXXXXX’ constant to it, then add a new constant for PA_TOKEN. It should look something like this:
ST_WEBHOOK = 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' # SmartThings AppID
PA_TOKEN = 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' # Personal Access Token with ALL permissions

While we’re in our new secrets.py file, let’s also create an appropriate app.config[‘SECRET_KEY’] value. Open a terminal and open a python3 session by typing python3 and pressing <ENTER>. Once in python, enter the following commands to generate a random value.

import os
print(os.urandom(64).hex())

That will print a long string of random numbers and characters to your terminal. Copy and paste that into your secrets.py file as shown:

ST_WEBHOOK = 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' # SmartThings AppID
PA_TOKEN = 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' # Personal Access Token with ALL permissions
SECRET_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXX......' #My secret key
  1. Save the file as /home/pi/st_webhook/my_secrets/secrets.py. Note that we created a new my_secrets folder under our project.

This will allow you to separate your private tokens and other data that you don’t want to expose publicly from your main code line. Then if you put it in a public repository (like Github) or you want to share it with someone, you can withhold the secrets.py file.

  1. Create another empty file and save it as /home/pi/st_webhook/my_secrets/__init__.py. This simply tells Python that the folder is a Python package.
  1. Back in your server where your ST_WEBHOOK constant was defined, replace it with:
from my_secrets.secrets import ST_WEBHOOK, SECRET_KEY

Also replace the line where you set the app.config[‘SECRET_KEY’] with:

app.config['SECRET_KEY'] = SECRET_KEY

If you save your changes and restart your server now (and update SmartThings with your current Ngrok tunnel), then you should see that everything is still working just as before.

Next, I think it makes sense at this point to start breaking up our code. We’re going to create a SmartThings class that will handle all API calls to SmartThings and will manage devices for us.

Creating the SmartThings Class:

  1. Create a new file and save it as /home/pi/st_webhook/smartthings.py.
  1. Add the following code to smartthings.py:
#My Secrets
from my_secrets.secrets import ST_WEBHOOK, PA_TOKEN

#HTTP Libs
import requests

#JSON Libs
import json

HOME_URL = 'https://api.smartthings.com/v1/'
APP_HEADERS = {'Authorization': 'Bearer ' + PA_TOKEN}  # Use this header when you don't have an authToken being passed in

# This is the list of supported capabilities and attributes.  Add to this list as you add more support.  This helps keep your JSON payload smaller.
DEV_LIST = [('presenceSensor', 'presence'), ('battery', 'battery'), ('switch', 'switch'), ('switchLevel', 'level'), 
	('doorControl', 'door'), ('lock', 'lock'), ('temperatureMeasurement', 'temperature'), 
	('relativeHumidityMeasurement', 'humidity'), ('contactSensor', 'contact'), ('motionSensor', 'motion'),
	('thermostatCoolingSetpoint', 'coolingSetpoint'), ('thermostatOperatingState', 'thermostatOperatingState'),
	('thermostatFanMode', 'thermostatFanMode'), ('thermostatHeatingSetpoint', 'heatingSetpoint'), ('thermostatMode', 'thermostatMode')]


class SmartThings:

	def __init__(self, location_id=''): #Pass a location_id if you have multiple locations
		self.location_id = location_id
		self.app_id = ''
		self.installed_app_id = ''
		self.app_name = ''
		self.configuration_id = ''
		self.name = ''
		self.location = {'location': {'locationId' : '', 'name' : ''}, 'presence':[], 'rooms' : [], 'scenes': []}
		
	def initialize(self):
		#Calls routines in sequence to collect app, location, rooms, devices, capabilities, current state, current health, and scenes.
		#  This builds out the self.location JSON structure with the data to pass to our HTML.
		if not self.location_id:
			self.readInstalledApps()
		self.readAppConfig()
		self.readLocation()
		self.readRooms()
		self.readDevices()
		self.readAllDevicesStatus()
		self.readAllDevicesHealth()
		self.readAllScenes()

	def readInstalledApps(self):
		#If you only have one location, this will read by AppID to get the installed location_id for you.
		fullURL = HOME_URL + 'installedapps?appid=' + ST_WEBHOOK
		headers = APP_HEADERS
		r = requests.get(fullURL, headers=headers)
		print('Get Installed Apps: %d' % r.status_code)
		if r.status_code == 200:
			data = json.loads(r.text)
			self.location_id = data['items'][0]['locationId']
		
	def readAppConfig(self):
		#This tree will give you all of the information about your app, including the installedAppId, displayName, description, configurationId, 
		#  and all configuration data.
		#  If you're only interested in the devices selected in the configuration, you can get all of that info here.
		fullURL = HOME_URL + 'installedapps?locationId=' + self.location_id + '&appId=' + ST_WEBHOOK
		headers = APP_HEADERS
		r = requests.get(fullURL, headers=headers)
		print('Get installedAppId: %d' % r.status_code)
		if r.status_code == 200:
			print('Config - App *****************************************\n')
			#print(r.text)
			listData = json.loads(r.text)
			installedAppId = ''
			displayName = ''
			configurationId = ''
			for item in listData['items']:
				if item['appId'] == ST_WEBHOOK and item['installedAppStatus'] == 'AUTHORIZED':
					installedAppId = item['installedAppId']
					displayName = item['displayName']
					self.installed_app_id = installedAppId
			baseURL = HOME_URL + 'installedapps/' + installedAppId
			endURL = '/configs'
			headers = APP_HEADERS
			fullURL = baseURL + endURL
			r = requests.get(fullURL, headers=headers)
			print('Get configurationId: %d' % r.status_code)
			if r.status_code == 200:
				print('Config ID *****************************************\n')
				#print(r.text)
				appData = json.loads(r.text)
				for item in appData['items']:
					if item['configurationStatus'] == 'AUTHORIZED':
						configurationId = item['configurationId']
						insert_app = 'insert or replace into app values(?,?,?,?,?)'
						self.app_name = displayName
						self.configuration_id = configurationId
						fullURL = fullURL + '/' + configurationId
						r = requests.get(fullURL, headers=headers)
						print('Get appConfig: %d' % r.status_code)
						if r.status_code == 200:
							status = True
							print('Config Data *****************************************\n')
							print(r.text)
				
	def readLocation(self):
		#This will give you all of the location related data.
		fullURL = HOME_URL + 'locations/' + self.location_id
		headers = APP_HEADERS
		r = requests.get(fullURL, headers=headers)
		print('Get Location: %d' % r.status_code)
		if r.status_code == 200:
			data = json.loads(r.text)
			self.name = data['name']
			self.location['location']['locationId'] = self.location_id			
			self.location['location']['name'] = self.name			
			print('Location Name: %s' % data['name'])
		
	def readRooms(self):
		#This will return all rooms at this location.
		baseURL = HOME_URL + 'locations/'
		endURL = '/rooms'
		headers = APP_HEADERS
		fullURL = baseURL + self.location_id + endURL
		r = requests.get(fullURL, headers=headers)
		print('Get Rooms: %d' % r.status_code)
		if r.status_code == 200:
			data = json.loads(r.text)
			#print(data)
			for rm in data['items']:
				room = {'roomId': rm['roomId'], 'name': rm['name'], 'devices': []}
				self.location['rooms'].append(room)
		else:
			print('Get Rooms Failed.  Status: %s' % r.status_cd)
			
	def readDevices(self):
		#This will give us all devices at this location, but we have to put them into room groupings or presenceSensor groupings.
		baseURL = HOME_URL + 'devices'
		endURL = '?locationId=' + self.location_id
		headers = APP_HEADERS
		fullURL = baseURL + endURL
		r = requests.get(fullURL, headers=headers)
		print('Get Devices: %d' % r.status_code)
		if r.status_code == 200:
			data = json.loads(r.text)
			#print(data)
			for dev in data['items']:
				room_id = dev.get('roomId', '0')
				presence = False
				device = {'deviceId' : dev['deviceId'], 'name' : dev['name'], 'label' : dev['label'], 'health': '?', 'capabilities' : []}
				for comp in dev['components']:
					for cap in comp['capabilities']:
						for dev in DEV_LIST:
							if cap['id'] == dev[0]:
								if cap['id'] == 'presenceSensor':
									presence = True
								capability = {'id' : cap['id'], 'state' : '?'}
								device['capabilities'].append(capability)
				if room_id == '0' and presence:
					self.location['presence'].append(device)
				else:
					for room in self.location['rooms']:
						if room['roomId'] == room_id:
							if len(device['capabilities']) > 0:
								room['devices'].append(device)
							break
		else:
			print('Get Devices Failed.  Status = %s' % r.status_code)
		
	def readAllDevicesStatus(self):
		#We spin through each device to get the current status of all of it's capabilities.
		baseURL = HOME_URL + 'devices/'
		headers = APP_HEADERS
		endURL = '/status'

		for pres in self.location['presence']:
			fullURL = baseURL + pres['deviceId'] + endURL
			r = requests.get(fullURL, headers=headers)
			if r.status_code == 200:
				print('Device Loaded: %s' % pres['label'])
				data = json.loads(r.text)

				main = dict(data.get('components','')).get('main','')				
				if main:
					for dev in DEV_LIST:
						cap = dict(main.get(dev[0],'')).get(dev[1],'')
						if cap:
							for capability in pres['capabilities']:
								if capability['id'] == dev[0]:
									capability['state'] = cap['value']
			
		for room in self.location['rooms']:
			for device in room['devices']:			
				fullURL = baseURL + device['deviceId'] + endURL
				r = requests.get(fullURL, headers=headers)
				if r.status_code == 200:
					print('Device Loaded: %s' % device['label'])
					data = json.loads(r.text)

					main = dict(data.get('components','')).get('main','')					
					if main:
						for dev in DEV_LIST:
							cap = dict(main.get(dev[0],'')).get(dev[1],'')
							if cap:
								for capability in device['capabilities']:
									if capability['id'] == dev[0]:
										capability['state'] = cap['value']

	def readAllDevicesHealth(self):
		#Here we spin through all devices to get it's current health status (online/offline).
		baseURL = HOME_URL + 'devices/'
		headers = APP_HEADERS
		endURL = '/health'

		
		for pres in self.location['presence']:
			deviceId = pres['deviceId']
			fullURL = baseURL + str(deviceId) + endURL
			r = requests.get(fullURL, headers=headers)
			print('Get Presence: %d' % r.status_code)
			if r.status_code == 200:
				data = json.loads(r.text)
				pres['health'] = data['state']

		for rm in self.location['rooms']:
			for dev in rm['devices']:
				deviceId = dev['deviceId']    
				fullURL = baseURL + deviceId + endURL
				r = requests.get(fullURL, headers=headers)
				print('Get Device Status: %d' % r.status_code)
				if r.status_code == 200:
					data = json.loads(r.text)
					dev['health'] = data['state']

	def updateDeviceHealth(self, deviceId, status):
		#This gets called when a device health event fires.
		for pres in self.location['presence']:
			for dev in pres['devices']:
				if dev['deviceId'] == deviceId:
					dev['health'] = status
					return True
			
		for room in self.location['rooms']:
			for dev in room['devices']:
				if dev['deviceId'] == deviceId:
					dev['health'] = status
					return True
		return False

			
	def readAllScenes(self):
		#This will load all scenes and must be filtered for the location.
		baseURL = HOME_URL + 'scenes'
		headers = APP_HEADERS
		fullURL = baseURL
		r = requests.get(fullURL, headers=headers)
		print(f'loadAllScenes() r.status_code: {r.status_code}')
		if r.status_code == 200:
			data = json.loads(r.text)
			#print(f'*****Scenes\nr.text\n******')
			for scene in data['items']:
				if scene['locationId'] == self.location_id:
					scene_data = {'sceneId': scene['sceneId'], 'sceneName': scene['sceneName']}
					self.location['scenes'].append(scene_data)

	def updateDevice(self, deviceId, capability, attribute, value):
		#This is called when a device event occurs.  It updates the self.location data and then returns the values to be emitted to the browsers.
		print('Updating: %s / %s / %s / %s' % (deviceId, capability, attribute, value))

		emit_data = True
		emit_val = ()

		if capability == 'presenceSensor':
			for pres in self.location['presence']:
				if pres['deviceId'] == deviceId:
					for cap in pres['capabilities']:
						if cap['id'] == capability:
							cap['state'] = value
							print(self.location['presence'])
							dev_json = json.dumps({'deviceId': deviceId,'capability': capability, 'value': value})
							emit_val = ('presence_chg', dev_json)
		else:
			for rm in self.location['rooms']:
				for dev in rm['devices']:
					if dev['deviceId'] == deviceId:
						for cap in dev['capabilities']:
							if cap['id'] == capability:

								if cap['state'] == value:
									emit_data = False

								cap['state'] = value
								if emit_data:
									dev_json = json.dumps({'deviceId': deviceId,'capability': capability, 'value': value})
									emit_val = ('device_chg', dev_json)
		return emit_val


	def deleteSubscriptions(self, authToken, appID):
		#Deletes all subscriptions.
		baseURL = HOME_URL + 'installedapps/'
		headers = {'Authorization': 'Bearer ' + authToken}
		endURL = '/subscriptions'
		
		r = requests.delete(baseURL + str(appID) + endURL, headers=headers)
		
		if r.status_code == 200:
			return True
		
		return False

	def deviceHealthSubscriptions(self, authToken, locationID, appID):
		#Subscribes to device health changes.
		baseURL = HOME_URL + 'installedapps/'
		headers = {'Authorization': 'Bearer ' + authToken}
		endURL = '/subscriptions'
		fullURL = baseURL + str(appID) + endURL

		datasub = {
			'sourceType':'DEVICE_HEALTH',
			'deviceHealth': {
				'locationId':locationID,
				'subscriptionName':'deviceHealthSubscription'
				}
			}
		r = requests.post(fullURL, headers=headers, json=datasub)
		print('Device Health Subscription: %d' % r.status_code)
		if r.status_code == 200:
			return True
			
		return False

	def capabilitySubscriptions(self, authToken, locationID, appID, capability, attribute, subName, stateChangeOnly=True):
		#Subscribes to specific capability status changes.
		baseURL = HOME_URL + 'installedapps/'
		headers = {'Authorization': 'Bearer ' + authToken}
		endURL = '/subscriptions'
		fullURL = baseURL + str(appID) + endURL

		datasub = {
			'sourceType':'CAPABILITY',
			'capability': {
				'locationId':locationID,
				'capability':capability,
				'attribute':attribute,
				'value':'*',
				'stateChangeOnly':stateChangeOnly,
				'subscriptionName':subName
				}
			}
		r = requests.post(fullURL, headers=headers, json=datasub)
		print('Capability Subscription [%s / %s]: %d' % (capability, attribute, r.status_code))
		if r.status_code == 200:
			return True
			
		return False

	def deviceSubscriptions(self, authToken, appID, deviceID, capability, attribute, subName):
		#Subscribes to device-specific events.
		baseURL = HOME_URL + 'installedapps/'
		headers = {'Authorization': 'Bearer ' + authToken}
		endURL = '/subscriptions'
		fullURL = baseURL + str(appID) + endURL

		datasub = {
			'sourceType':'DEVICE',
			'device': {
				'deviceId':deviceID,
				'componentId':'*',
				'capability':capability,
				'attribute':'*',
				'value':'*',
				'stateChangeOnly':True,
				'subscriptionName':subName
				}
			}
		r = requests.post(fullURL, headers=headers, json=datasub)
		print('Device Subscription: %d' % r.status_code)
		if r.status_code == 200:
			return True

		return False
		
	def changeDevice(self, deviceId, capability, value, level):		
		#This is called when a user requests to change a device state.
		baseURL = HOME_URL + 'devices/'
		headers = APP_HEADERS
		endURL = '/commands'
		fullURL = baseURL + str(deviceId) + endURL
		if capability == 'switchLevel':
			datasub = {
				'commands': [ {
					'component':'main',
					'capability':capability,
					'command':'setLevel',
					'arguments': [
						int(level)
					]
				}
			  ]
			}
		else:
			datasub = {
				'commands': [ {
					'component':'main',
					'capability':capability,
					'command':value
					}
				]
			}
		r = requests.post(fullURL, headers=headers, json=datasub)
		print('Change Device: %d' % r.status_code)
		print (r.text)
		if r.status_code == 200:
			return True
			
		return False
		
if __name__ == '__main__': #This will only be True if we are directly running this file for testing.
	st = SmartThings()
	
	#  If multiple locations associated with account, use this instead #
	#
	#location_id = 'xxxxxxxxxxxxxxxx' # copy value from incoming request
	#st = SmartThings(location_id)
	
	st.initialize()
	print('*******************************\n\n')
	print(st.location)

You might notice that we moved the SmartThings API calls that we were previously making from st_webhook.py into this class file. Also note that the code if __name__ == ‘__main__’ will only evaluate to True if you run the file directly (i.e., python3 smartthings.py). This piece of code will allow you to test your code before importing it into other files.

The updated server…

There have been enough changes in st_webhook.py that I think it’s easier to just copy it here in it’s entirety.

#!/usr/bin/env python

#eventlet WSGI server
import eventlet
eventlet.monkey_patch()

#Flask Libs
from flask import Flask, abort, request, jsonify, render_template, send_from_directory

#Web Sockets
from flask_socketio import SocketIO, send, emit, join_room, leave_room, disconnect, rooms

#HTTP Libs
import requests

#JSON Libs
import json

#My Libs
from smartthings import SmartThings
from my_secrets.secrets import ST_WEBHOOK, SECRET_KEY

app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins=['http://localhost:5000','http://192.168.2.221:5000','http://b016dcd112aa.ngrok.io','https://b016dcd112aa.ngrok.io'])
app.config['SECRET_KEY'] = SECRET_KEY

@socketio.on('connect')
def socket_connect():
	data = json.dumps({'status': 'connected'})
	emit('conn', data, broadcast=False)
	location_data = json.dumps(st.location)
	room = st.location_id
	print('Joining room: %s' % room)
	join_room(room)
	emit('location_data', location_data, broadcast=False) #We only need to send this to the user currently connecting, not all.


@app.route('/', methods=['POST', 'GET'])
def smarthings_requests():
	if request.method == 'GET':
		return render_template('shd.html')
	
	content = request.get_json()
	print('AppId: %s\nLifeCycle: %s' % (content['appId'], content['lifecycle']))
	
	if (content['lifecycle'] == 'PING'):
		print('PING: %s' % content)
		challenge = content['pingData']['challenge']
		data = {'pingData':{'challenge': challenge}}
		return jsonify(data)
		
	elif (content['lifecycle'] == 'CONFIRMATION'):
		confirmationURL = content['confirmationData']['confirmationUrl']
		r = requests.get(confirmationURL)
		print('CONFIRMATION\nContent: %s\nURL: %s\nStatus: %s' % (content,confirmationURL,r.status_code))
		if r.status_code == 200:
			return r.text
		else:
			abort(r.status_code)

	elif (content['lifecycle'] == 'CONFIGURATION' and content['configurationData']['phase'] == 'INITIALIZE'):
		print(content['configurationData']['phase'])
				
		if content['appId'] == ST_WEBHOOK:
			data = {
					  "configurationData": {
						"initialize": {
						  "name": "ST Test Webhook App",
						  "description": "ST Test Webhook App",
						  "id": "st_webhook_app_page_1",
						  "permissions": [
							"r:devices:*"
						  ],
						  "firstPageId": "1"
						}
					  }
					}
		else:
			data = {'appId':'Not Recognized'}
			print('Initialize Unknown appId: %s' % content['appId'])

		return jsonify(data)

	elif (content['lifecycle'] == 'CONFIGURATION' and content['configurationData']['phase'] == 'PAGE'):
		print(content['configurationData']['phase'])
		pageId = content['configurationData']['pageId']
		
		if content['appId'] == ST_WEBHOOK:
			data = {
					  "configurationData": {
						"page": {
						  "pageId": "1",
						  "name": "Select Devices",
						  "nextPageId": "null",
						  "previousPageId": "null",
						  "complete": "true",
						  "sections": [
							{
							  "name": "When this opens/closes...",
							  "settings": [
								{
								  "id": "contactSensor",
								  "name": "Which contact sensor?",
								  "description": "Tap to set",
								  "type": "DEVICE",
								  "required": "true",
								  "multiple": "false",
								  "capabilities": [
									"contactSensor"
								  ],
								  "permissions": [
									"r"
								  ]
								}
							  ]
							},
							{
							  "name": "Turn on/off this light...",
							  "settings": [
								{
								  "id": "lightSwitch",
								  "name": "Which switch?",
								  "description": "Tap to set",
								  "type": "DEVICE",
								  "required": "true",
								  "multiple": "false",
								  "capabilities": [
									"switch"
								  ],
								  "permissions": [
									"r",
									"x"
								  ]
								}
							  ]
							}
						  ]
						}
					  }
					}
		else:
			data = {'appId':'Not Recognized'}
			print('Page Unknown appId: %s' % content['appId'])
			
		return jsonify(data)

	elif (content['lifecycle'] == 'INSTALL'):
		print(content['lifecycle'])
		data = {'installData':{}}
		resp = content['installData']
		
		if content['appId'] == ST_WEBHOOK:
			print('Installing ST Webhook')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'switch', 'switch', 'capSwitchSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'lock', 'lock', 'capLockSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'temperatureMeasurement', 'temperature', 'capTempSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'relativeHumidityMeasurement', 'humidity', 'capHumiditySubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'doorControl', 'door', 'capDoorSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'contactSensor', 'contact', 'capContactSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'motionSensor', 'motion', 'capMotionSubscription')            
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'switchLevel', 'level', 'capSwitchLevelSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'battery', 'battery', 'capBatterySubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'presenceSensor', 'presence', 'capPresenceSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'thermostatOperatingState', 'thermostatOperatingState', 'capOperatingStateSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'thermostatMode', 'thermostatMode', 'capModeSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'thermostatCoolingSetpoint', 'coolingSetpoint', 'capCoolSetpointSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'thermostatHeatingSetpoint', 'heatingSetpoint', 'capHeatSetpointSubscription')
			st.deviceHealthSubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'])
		else:
			data = {'appId':'Not Recognized'}
			print('Install Unknown appId: %s' % content['appId'])
			
		return jsonify(data)

	elif (content['lifecycle'] == 'UPDATE'):
		print(content['lifecycle'])
		data = {'updateData':{}}
		resp = content['updateData']
		print('resp: %s' % resp)
		
		if content['appId'] == ST_WEBHOOK:
			print('Updating ST Webhook')
			st.deleteSubscriptions(resp['authToken'], resp['installedApp']['installedAppId'])
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'switch', 'switch', 'capSwitchSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'lock', 'lock', 'capLockSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'temperatureMeasurement', 'temperature', 'capTempSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'relativeHumidityMeasurement', 'humidity', 'capHumiditySubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'doorControl', 'door', 'capDoorSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'contactSensor', 'contact', 'capContactSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'motionSensor', 'motion', 'capMotionSubscription')            
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'switchLevel', 'level', 'capSwitchLevelSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'battery', 'battery', 'capBatterySubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'presenceSensor', 'presence', 'capPresenceSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'thermostatOperatingState', 'thermostatOperatingState', 'capOperatingStateSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'thermostatMode', 'thermostatMode', 'capModeSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'thermostatCoolingSetpoint', 'coolingSetpoint', 'capCoolSetpointSubscription')
			st.capabilitySubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'], 'thermostatHeatingSetpoint', 'heatingSetpoint', 'capHeatSetpointSubscription')
			st.deviceHealthSubscriptions(resp['authToken'], resp['installedApp']['locationId'], resp['installedApp']['installedAppId'])
		else:
			data = {'appId':'Not Recognized'}
			print('Update Unknown appId: %s' % content['appId'])
			
		return jsonify(data)

	elif (content['lifecycle'] == 'OAUTH_CALLBACK'):
		print(content['lifecycle'])
		data = {'oAuthCallbackData':{}}
		return jsonify(data)

	elif (content['lifecycle'] == 'EVENT'):
		data = {'eventData':{}}
		
		event = content['eventData']['events'][0]
		
		if content['appId'] == ST_WEBHOOK:
			if event['eventType'] == 'DEVICE_EVENT':
				device = event['deviceEvent']
				emit_val = st.updateDevice(device['deviceId'], device['capability'], device['attribute'], device['value'])
				if emit_val:
					print('emit_val: ', emit_val)
					print('Emitting: %s: %s to room: %s' % (emit_val[0], emit_val[1], device['locationId']))
					socketio.emit(emit_val[0],emit_val[1], room=device['locationId'])
			elif event['eventType'] == 'DEVICE_HEALTH_EVENT':
				data = event['deviceHealthEvent']
				if st.updateDeviceHealth(data['deviceId'], data['status']):
					socketio.emit('location-data', json.dumps(st.location), room=data['locationId'])
		else:
			data = {'appId':'Not Recognized'}
			print('Event Unknown appId: %s' % content['appId'])

		return jsonify(data)

	elif (content['lifecycle'] == 'UNINSTALL'):
		print(content['lifecycle'])
		data = {'uninstallData':{}}
		return jsonify(data)

	else:
		print('Unknown Lifecycle: %s' % content['lifecycle'])
		return '',404

@app.route('/apple-touch-icon-152x152.png')
@app.route('/apple-touch-icon-152x152-precomposed.png')
@app.route('/apple-touch-icon-120x120-precomposed.png')
@app.route('/apple-touch-icon-120x120.png')
@app.route('/apple-touch-icon-precomposed.png')
@app.route('/apple-touch-icon.png')
@app.route('/favicon.ico')
def favicon():
	print('favicon')
	return send_from_directory('/home/pi/static', 'favicon.png')
		
if __name__ == '__main__':
	st = SmartThings()
	st.initialize()
	socketio.run(app, debug=True, host='0.0.0.0', port=5000)

Here are a few of the changes:

  • Importing secrets and SmartThings class
  • In the @socketio.on(‘connect’), we are now joining the user to a room that is the same as the location_id. This is for future support for multiple locations. Also, we are now emitting the contents of the location variable from our SmartThings object. This will prime the connected user with all rooms/devices and presence sensors as well as their current state.
  • In the lifecycle events for INSTALL and UPDATE, I removed our device subscription for the contactSensor and added several new capability subscriptions plus a new device health subscription, which will notify us if devices change to offline or online.
  • In the lifecycle event for EVENT, I removed our previous code to turn the selected switch on/off and replaced it with a call to our SmartThings object to updateDevice for a DEVICE_EVENT and to updateDeviceHealth for a DEVICE_HEALTH_EVENT. Both of those functions update our location structure and then emit the change to connected browsers so they can update their display.

Note that after making changes to the UPDATE lifecycle, you will need to go back into the SmartThings mobile app and update your SmartApp for that new code to be executed and the new subscriptions to become active.

Updating our HTML…

The final step for this post is to update our HTML file to better reflect all of our rooms and devices. Here’s the updated code:

<!DOCTYPE html>
<html>

<head>
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<meta name="apple-mobile-web-app-capable" content="yes">	
	<title>SmartThings Events</title>
	<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/socket.io/4.1.3/socket.io.min.js"></script>
	<style>
	body {
	  background-color: lightblue;
	  color: blue;
	  font-size: 24px;
	}
	
	.room {
		font-size: 24px;
		font-weight: bold;
	}
	
	.device {
		font-size: 20px;
		margin-left: 20px;
	}
	
	.capability {
		font-size: 18px;
		margin-left: 40px;
	}
	
	.active {
		background-color: yellow;
	}
	
	.offline {
		background-color: lightgray;
	}
	</style>
</head>

<body>
  <p id="events"></p>

<script>
	const DOW_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
	var protocol = window.location.protocol;
	var locationData

	function getTimeDisplay(dt) {
		var time_str = "";
		var hrs = "";
		var mins = "";
		var ap = "AM";
		var val = 0;
		
		val = dt.getHours();
		if (val >=12) {
			ap = "PM";
			if (val > 12) {
				val -= 12;
			}
		}
		else if (val == 0) {
			val = 12;
		}
		hrs = val.toString();
		
		val = dt.getMinutes();
		mins = ("0" + val).slice(-2);
		
		time_str = hrs + ":" + mins + " " + ap;
		return time_str;
	}

	var socket = io.connect(protocol + '//' + document.domain + ':' + location.port, {
		reconnection: true,
		reconnectionDelay: 1000,
		reconnectionDelayMax: 10000,
		reconnectionAttempts: 99999
	});

	socket.on('conn', function(msg) {
		var dt = new Date();
		var dtDisp = DOW_SHORT[dt.getDay()] + " " + getTimeDisplay(dt);
		var data = JSON.parse(msg);
		console.log(dtDisp + " - " + data.status);
	});

	socket.on('location_data', function(msg) {
		console.log("location-data");
		var dt = new Date();
		var dtDisp = DOW_SHORT[dt.getDay()] + " " + getTimeDisplay(dt);
		locationData = JSON.parse(msg);
		console.log(JSON.stringify(locationData, null, 2));
		displayLocation();
	});

	socket.on('presence_chg', function(msg) {
		console.log("presence_chg: " + msg);
		
	});

	socket.on('device_chg', function(msg) {
		console.log("device_chg: " + msg);
		data = JSON.parse(msg);
		locationData.rooms.forEach( room => {
			room.devices.forEach(device => {
				if (data.deviceId == device.deviceId) {
					device.capabilities.forEach(capability => {
						if (capability.id == data.capability) {
							capability.state = data.value;
							displayLocation();
						}
					});
				}
			});
		});
	});

	function displayLocation() {
		var dispElement = document.querySelector("#events");
		var disp = `<h3>${locationData.location.name}</h3>`;
		
		var deviceStateClass = "";
		var stateClass = "";
		
		locationData.presence.forEach( presence => {
			var presenceStatus = "?";
			stateClass = presence.health == "ONLINE" ? "" : "offline";
			presence.capabilities.forEach(capability => {
				if (capability.id == "presenceSensor") {
					presenceStatus = capability.state;
					if (presenceStatus == "present" && !stateClass) {
						stateClass = "active";
					}
				}
			});
			disp += `<div class='presence ${stateClass}'>`;
			disp += presence.label + ": " + presenceStatus + "</div>";
		});
		
		var rooms = locationData.rooms;
		rooms.forEach(room => {
			if (room.devices.length > 0) {
				disp += `<p>`;
				disp += "<div class='room'>" + room.name + "</div>";
				room.devices.forEach(device => {
					deviceStateClass = device.health == "ONLINE" ? "" : "offline";
					disp += `<div class='device ${deviceStateClass}'>`;
					disp += device.label + "</div>";
					device.capabilities.forEach(capability => {
						stateClass = "";
						if (["on","open","unlocked","motion","cooling","heating"].includes(capability.state)) {
							stateClass = "active";
						}
						stateClass = deviceStateClass ? deviceStateClass : stateClass;
						disp += `<div class='capability ${stateClass}'>`
						disp += capability.id + ": " + capability.state + "</div>";
					});
				});
				disp += `</p>`;
			}
		});

		dispElement.innerHTML = disp;
	}

</script>
</body>
</html>

Now if you run you server, update your SmartThings config to reflect your current Ngrok tunnel, and load your server URL in your browser, you should see something like below:

Notice that all devices that meet our DEV_LIST capability/attribute requirement we defined in SmartThings.py should be showing in the room that you have them assigned. If a room is defined but has no associated devices, the room will not be displayed.

Also notice that devices that are in certain states are shown with a yellow background and devices that are OFFLINE are shown with a gray background.

This is a real-time updated display, so if you turn a switch on, you will see it reflected in your browser. And since the server maintains the current state of each device, your browser will always reflect the correct state regardless when you open it.

What’s next?

I’m hoping that this gives you a much better feel for how a Webhook SmartApp works. We’ve now covered most of the SmartThings-specific pieces and can begin focusing more on the details of our Smart Home Dashboard.

Like I’m sure you’ve noticed that every time you make a simple change you have to wait for your server to reload EVERYTHING again. In reality, once you get your devices setup and assigned to rooms, you really don’t make that many frequent changes there. You really just need to make calls to get the current state and health of each device. So in the next post we’re going to look at moving much of this data into a database. That will also let us start making some configuration settings that will allow us to better tune the data and display to suit our needs. Here’s the link!

See you there!

Leave a Comment

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