SmartThings Smart Home Dashboard #6

At this point we’re able to load all of our data through SmartThings API’s (location, app, scenes, presence sensors, rooms, devices, capabilities and states) and provide a real-time display as devices change states. But it would be nice if we didn’t have to call SmartThings API’s to load all of the infrequently changing data, such as location, app, rooms, devices and capabilities, each time we start our server. There’s enough work to be done requesting the current state and health status of each device, so let’s push as much of this to a database as possible to help speed things up a little.

You might also be looking at the data being displayed and think that you might prefer some of it to not be cluttering up your dashboard. I have a room that I move some devices to that are online but I’m not currently using (such as virtual switches and sensors). I have some devices that are offline because they are currently disconnected, but I don’t want to remove them from my hub in case I put them back in service. I also have some devices with capabilities that I’m not currently using, like the multi-purpose sensor that I am only using the temperature capability right now, not the contact sensor. I’d like to get those out of my dashboard so I can focus on the things that matter. A byproduct of using a database is that we can configure the data we see and even what order we see it in.

Another major concern is unauthorized access to our dashboard and devices. We need to authenticate our users to prevent unwanted access.

That’s what we’re going to cover in this post. Let’s get started.

Install DB Browser for SQLite…

If you already have a solution for browsing a SQLite database, then you can skip this step. Otherwise, I’ll tell you what I use and you can decide if it’s right for you. It’s free, so that meets my first criteria. It’s definitely not perfect, but it has worked fairly well for me.

Start by going to the website: https://sqlitebrowser.org/. Click the download link and scroll down for installation instructions on your Raspberry Pi. I use the standard Raspberry Pi OS, so I follow the directions for Debian. We’ll use this tool a little later, but now we have it installed and ready when we are.

Add authentication…

We’re about to start allowing users to make changes to our server and to control our smart home devices. Before we do that, we need to add the ability to authenticate users to make sure they are authorized. We will also create support for several user roles: Admin, User, and Guest.

The Admin will have full control of the dashboard, but will also be able to make configuration changes. The User will have full control of the dashboard. And the Guest will have read-only access to the dashboard, but only while connected to the local network. This could be extended to give full access to certain devices, such as switches, while providing read-only access to others devices, such as locks, but we won’t do that here.

We will also create a table to store user login and connection data. You can turn this off at any point, but I find it interesting to see how much the dashboard gets used.

And finally, we will be storing information regarding failed login attempts. This will help us identify any unauthorized attempts to access our dashboard.

Note that currently there is no other way to programmatically add new users other than in our server startup code. We’ll add that ability down the road, but later in this post I’ll show you how to use our DB Browser that we installed above to add a new user.

Open a terminal and enter the following commands:

sudo pip3 install flask-sqlalchemy
sudo pip3 install flask-login
st_webhook.py

We need to update st_webhook.py to add a few more libraries to our import from flask. Notice we also need session, redirect, url_for, and flash. Session will allow us to create an expiration for connected sessions. Redirect and url_for will help us redirect the browser to the appropriate URL, based on the situation. And finally, flash will allow us to send messages to the user when appropriate.

#Flask Libs
from flask import Flask, abort, request, jsonify, render_template, send_from_directory, session, redirect, url_for, flash

Now we can add the libraries we need to support user authentication:

#Flask Login Libs 
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.urls import url_parse
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_required, current_user, login_user, logout_user

We’ll also add some datetime libraries so we can capture the date/time of certain events and set expiration dates for sessions and cookies:

#datetime
from datetime import datetime, timedelta

Let’s add constants for our cors_allowed_origins values and our local network ip. This will make it easier to update these values as needed, especially the cors_allowed_origins which needs to be updated each time your Ngrok public URL changes. We’ll test against the local ip for Guest users to make sure they are locally connected. You’ll need to update the second item to reflect your local IP addresses.

# Replace with your local and public URLs
CORS_ALLOWED_ORIGINS = ['http://localhost:5000','http://192.168.2.209:5000','http://b016dcd112aa.ngrok.io','https://b016dcd112aa.ngrok.io']

# Replace the second item with your local IP address info
LOCAL_NETWORK_IP = ['127.0.0.1', '192.168.2.']

We have to add a few more app.config settings for flask-login, right below our SECRET_KEY. The comments describe each setting’s intended purpose.

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db' # Defines our flask-login user database
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # Mute flask-sqlalchemy warning message
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=30) # Flask session expiration
app.config['REMEMBER_COOKIE_DURATION'] = timedelta(days=30) # Remember Me cookie expiration (Not sure this works???)
app.config['REMEMBER_COOKIE_SECURE'] = None # Change to True to force using HTTPS to store cookies.
app.config['REMEMBER_COOKIE_HTTPONLY'] = True # Prevents cookies from being accessed on the client-side.

Here’s st_webhook.py in it’s entirety. I’ve attempted to comment all of the lines related to the flask-login authentication process. One of the key things to notice and remember is that by adding the @login_required decorator between the @app.route decorator and the function definition, you allow the flask-login login_manager to validate that the current user is authenticated before accessing that route. If they are not, it will redirect them to the route defined in the login_manager.login_view statement. But keep in mind that if you want to further validate the user’s role, you need to do that manually using the current_user placeholder (i.e., current_user.role == ‘Admin’). The current_user will be set by the login_manager who manages user sessions. You can access all of the User class attributes from the current_user placeholder, such as current_user.id, current_user.name, current_user.email, etc. You can also access other attributes, such as current_user.is_authenticated.

Another item of note is that there are no @login_required decorators for the @socketio web socket decorators. That’s because there are no user interactions to intercept and redirect (i.e., the user isn’t trying to access a URL), but also the user has to be authenticated before we return the HTML that contains the web socket javascript, so authentication is implied. However, I still do a check to make sure the user is still active before responding to the event. If the user is no longer active or authenticated, we return an empty string in the location-data web socket event. The Javascript will replace the HTML body with an unauthorized message, effectively removing the dashboard from the user. The last act for the Javascript will be to emit a disconn event which will log the disconnect event for the user.

Don’t forget to update your SmartThings config with your current Ngrok Public URL and to update your server cors_allowed_origins setting to align with your Public URL.

Here’s the code:

#!/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, session, redirect, url_for, flash

#Flask Login Libs 
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.urls import url_parse
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_required, current_user, login_user, logout_user

#Web Sockets
from flask_socketio import SocketIO, send, emit, join_room, leave_room, disconnect, rooms
 
#HTTP Libs
import requests
 
#JSON Libs
import json
 
#datetime
from datetime import datetime, timedelta
 
#My Libs
from smartthings import SmartThings
from my_secrets.secrets import ST_WEBHOOK, SECRET_KEY

# Replace with your local and public URLs
CORS_ALLOWED_ORIGINS = ['http://localhost:5000','http://192.168.2.209:5000','http://b016dcd112aa.ngrok.io','https://b016dcd112aa.ngrok.io']

# Replace the second item with your local IP address info
LOCAL_NETWORK_IP = ['127.0.0.1', '192.168.2.']
 
app = Flask(__name__)
# Update the cors_allowed_origins with your local and public URL's.
socketio = SocketIO(app, cors_allowed_origins=CORS_ALLOWED_ORIGINS)
app.config['SECRET_KEY'] = SECRET_KEY

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db' # Defines our flask-login user database
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # Mute flask-sqlalchemy warning message
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=30) # Flask session expiration
app.config['REMEMBER_COOKIE_DURATION'] = timedelta(days=30) # Remember Me cookie expiration (Not sure this works???)
app.config['REMEMBER_COOKIE_SECURE'] = None # Change to True to force using HTTPS to store cookies.
app.config['REMEMBER_COOKIE_HTTPONLY'] = True # Prevents cookies from being accessed on the client-side.

db = SQLAlchemy(app) # This gives us our database/datamodel object

login_manager = LoginManager(app) # This creates our login manager object
login_manager.login_view = 'login' # Defines our login view (basically calls url_for('login'))

class User(UserMixin, db.Model): # This is our User class/model.  It will store our valid users.
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True) # primary keys are required by SQLAlchemy
    active = db.Column(db.Boolean, server_default='False') # This value must be True (1) before the user can login.
    email = db.Column(db.String(100), unique=True)
    password = db.Column(db.String(100))
    name = db.Column(db.String(1000))
    role = db.Column(db.String(25))
    # db.relationship defines the one-to-many relationship with the UserLogin class/table and can be accessible here (but we won't use it that way)
    #   backref tells sqlalchemy that we can also go from UserLogin to User
    #   lazy='dynamic' tells sqlalchemy not to automatically load the related data into the logins attribute.  It could get large.
    logins = db.relationship('UserLogin', backref='users', lazy='dynamic')

class UserLogin(db.Model): # This is our UserLogin class/model.  It will store login related data for our users
    __tablename__ = 'user_login'
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'))
    event = db.Column(db.String(50))
    date = db.Column(db.String(100))
    ip = db.Column(db.String(50))

class FailedLogin(db.Model): # This is our FailedLogin class/model.  It will store failed login attempts.
    __tablename__ = 'failed_login'
    id = db.Column(db.Integer, primary_key=True)  
    email = db.Column(db.String(100))
    password = db.Column(db.String(100))
    date = db.Column(db.String(100))
    ip = db.Column(db.String(50))

db.create_all() # Creates our database and tables as defined in the above classes.

# Create first user if it doesn't already exist.  Notice it doesn't have to be a valid email.  It basically serves as our username.
if not User.query.filter(User.email == 'jeff@example.com').first():
    user = User(
        active=True,
        email='jeff@example.com',
        password=generate_password_hash('Password', method='sha256'), # We don't store the actual password, just the hash.
        name='Jeff',
        role='Admin'
    )
    db.session.add(user)
    db.session.commit()


@login_manager.user_loader # This is the login manager user loader.  Used to load current_user.
def load_user(user_id):
    # since the user_id is the primary key of our user table, use it in the query for the user
    user = User.query.get(int(user_id))
    print('User.active: %s' % user.active)
    if user.active: # Only return the user if they are active
        print('Returning active user...')
        return user
    print('Not returning inactive user...')
    return None



@socketio.on('connect')
def socket_connect():
    # Make sure the current_user is still authenticated.
    if current_user.is_authenticated:
        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)
        print('ip: %s' % ip)
        try: # We wrap this in a try statement in case the current_user is no longer active 
             #  (i.e. session expired).
            user = User.query.filter_by(id=current_user.id).first()
            if user: # We are recording the web socket connection for the user.  Remove if desired.
                print(f'{user.name} is a(n) {user.role}')
                if request.headers.getlist('X-Forwarded-For'):
                    ip = request.headers.getlist('X-Forwarded-For')[0]
                else:
                    ip = request.remote_addr
                user.logins.append(UserLogin(event='connect', date=datetime.now().strftime('%m/%d/%y %H:%M:%S'), ip=ip))
                db.session.commit()
            join_room(room)
            emit('location_data', location_data, broadcast=False) #We only need to send this to the user currently connecting, not all.
        except:
            pass
    else:
        print('Current user no longer authenticated!')
        emit('location_data', '', broadcast=False)  # Send an empty event to notify browser user is no longer authorized

@socketio.on('disconnect')
def socket_disconnect():
    try: # Wrapped in a try in case the current_user is no longer active.
        user = User.query.filter_by(id=current_user.id).first()
        if user: # Record the web socket disconnect.  Remove if desired.
            if request.headers.getlist('X-Forwarded-For'):
                ip = request.headers.getlist('X-Forwarded-For')[0]
            else:
                ip = request.remote_addr
            user.logins.append(UserLogin(event='disconnect', date=datetime.now().strftime('%m/%d/%y %H:%M:%S'), ip=ip))
            db.session.commit()
    except:
        print('Unable to log disconnected user')

@socketio.on('disconn')
def socket_disconn():
    print('Disconnecting unauthorized user! [user: %s]' % User.query.get(int(session['_user_id'])).email)
    try: # Wrapped in a try in case the current_user is no longer active.
        user = User.query.get(int(session['_user_id']))
        if user: # Record the web socket disconnect.  Remove if desired.
            if request.headers.getlist('X-Forwarded-For'):
                ip = request.headers.getlist('X-Forwarded-For')[0]
            else:
                ip = request.remote_addr
            user.logins.append(UserLogin(event='disconnect', date=datetime.now().strftime('%m/%d/%y %H:%M:%S'), ip=ip))
            db.session.commit()
    except:
        pass
    disconnect()

@socketio.on('refresh')
def socket_refresh():
    # Make sure the current_user is still authenticated.
    if current_user.is_authenticated:
        if current_user.role == 'Guest': # For this example, we are prohibiting Guest users from performing a refresh event.
            print("Guest Users can't refresh...")
        else:
            if st:
                st.readData(refresh=False)
                location_data = json.dumps(st.location)
                emit('location_data', location_data, room=st.location_id) #Broadcast any changes to all users.
    else:
        print('Current user no longer authenticated! [user_id: %s]' % session['_user_id'])
        emit('location_data', '', broadcast=False)  # Send an empty event to notify browser user is no longer authorized
  
# This is the login route.  If a users tries to go directly to any URL that requires a login (has the @login_required decorator)
#   before being authenticated, they will be redirected to this URL.  This is defined in the login_manager.login_view setting above.
@app.route('/login', methods=['GET']) 
def login():
    if current_user.is_authenticated: # No need to have a logged in user login again.
        return redirect(url_for('index'))
    # The 'next' query parameter will be set automatically by the login_manager 
    #   if the user tried to go directly to @login_required URL before authenticating.
    next_page = request.args.get('next') 
    print('next: %s' % next_page)
    if not next_page or url_parse(next_page).netloc != '':  # If there is no next query parameter, default to index.
        next_page = url_for('index')    
    return render_template('login.html', next_page=next_page)

@app.route('/login', methods=['POST']) # The browser user click the Login button...
def login_post():
    email = request.form.get('email')
    password = request.form.get('password')
    remember = True if request.form.get('remember') else False

    user = User.query.filter_by(email=email).first() # Let's see if this user exists...
    print('User: %s' % user)

    # Capture the IP address so we can check Guest users and log it...
    if request.headers.getlist('X-Forwarded-For'):
        ip = request.headers.getlist('X-Forwarded-For')[0]
    else:
        ip = request.remote_addr

    if user and user.role == 'Guest': # If this is a Guest user, make sure they are logging in from the local network only...
        if LOCAL_NETWORK_IP[0] in ip or LOCAL_NETWORK_IP[1] in ip:
            print(f'Guest User [{user.name}] is connected to local network.  Allowed...')
        else:
            print(f'Guest User [{user.name}] is NOT connected to local network.  Aborting...')
            flash(f'Guest Users Must Be Connected to Local Network')
            return redirect(url_for('login')) # If not, send them back to the Login page.

    # If the user exists in the db, but the password is empty, then take the entered password, hash it, and update the db.
    #   This is how I add a new user to the db without setting the password for them.
    if user and user.password == '':
        print('Setup user!')
        user.password=generate_password_hash(password, method='sha256')
        user.active=True
        db.session.commit()
            
    # Check if the user actually exists and is active
    # Take the user-supplied password, hash it, and compare it to the hashed password in the database
    if not user or not user.active or not check_password_hash(user.password, password):
        # If there's a problem, create a FailedLogin event.
        failed_user = FailedLogin(email=email, password=password, date=datetime.now().strftime('%m/%d/%y %H:%M:%S'), ip=ip)
        db.session.add(failed_user)
        db.session.commit()
        
        flash('Please check your login details and try again.')
        return redirect(url_for('login')) # if the user doesn't exist or password is wrong, reload the page

    try: # This just captures the last login for the user in case we decide to use it later.
         # We wrap it in a try in case there was no previous login.
        userLogin = UserLogin.query.filter_by(user_id=user.id).filter_by(event='login').order_by(UserLogin.date.desc()).first()
        print("Last Login: %s" % userLogin.date)
    except:
        pass

    # If the above check passes, then we know the user has the right credentials
    login_user(user, remember=remember)
    session.permanent = True # This is the flask session.  It's set to permanent, but the PERMANENT_SESSION_LIFETIME is applied for expiration.

    # Record the login event.  Remove if desired.
    user.logins.append(UserLogin(event='login', date=datetime.now().strftime('%m/%d/%y %H:%M:%S'), ip=ip))
    db.session.commit()
    
    # This is the next query parameter that we passed through from the login GET request.
    #  If it was set, we want to now redirect the user to the URL they originally tried to go to.
    next_page = request.args.get('next')
    print('next: %s' % next_page)
    if not next_page or url_parse(next_page).netloc != '':
        next_page = url_for('index')
    return redirect(next_page)

@app.route('/logout')
def logout():
    if current_user.is_authenticated:  # If the user is logged in, record the event and log them out.
        user = User.query.filter_by(id=current_user.id).first()
        if user:
            if request.headers.getlist('X-Forwarded-For'):
                ip = request.headers.getlist('X-Forwarded-For')[0]
            else:
                ip = request.remote_addr
            user.logins.append(UserLogin(event='logout', date=datetime.now().strftime('%m/%d/%y %H:%M:%S'), ip=ip))
            db.session.commit()
        logout_user()
    return redirect(url_for('login')) # Logged in or not, redirect to the login page.

# To access this page, the user must be logged in and also have an Admin role.
@app.route('/config')
@login_required
def config():
    if current_user.role == 'Admin':
        configData = st.getConfig()
        user = User.query.filter_by(id=current_user.id).first()
        if user: # Record the event.  Remove if desired.
            if request.headers.getlist('X-Forwarded-For'):
                ip = request.headers.getlist('X-Forwarded-For')[0]
            else:
                ip = request.remote_addr
            user.logins.append(UserLogin(event='config-view', date=datetime.now().strftime('%m/%d/%y %H:%M:%S'), ip=ip))
            db.session.commit()
        return render_template('config.html', configData=configData)
    # If the user isn't an Admin, log them out, flash them a message, and send back to the login page.
    logout_user()
    flash("You must be an Administrator to access this page!")
    return redirect(url_for('login'))

# Obviously, we want to make sure the user is logged in here and is an admin.    
@app.route('/update-configs', methods=['POST'])
@login_required
def update_configs():
    if current_user.role == 'Admin':
        print('update-configs')
        configData = request.get_json()
        print(configData)
        print('Location items: %d' % len(configData['location']))
        if st.updateConfigs(configData):
            st.readData(refresh=False)
            location_data = json.dumps(st.location)
            socketio.emit('location_data', location_data, room=st.location_id) #Broadcast any changes to all users.
            user = User.query.filter_by(id=current_user.id).first()
            if user: # Record the event.  Remove if desired.
                if request.headers.getlist('X-Forwarded-For'):
                    ip = request.headers.getlist('X-Forwarded-For')[0]
                else:
                    ip = request.remote_addr
                user.logins.append(UserLogin(event='config-update', date=datetime.now().strftime('%m/%d/%y %H:%M:%S'), ip=ip))
                db.session.commit()
            return 'OK', 200
        return 'Fail', 200
    return 'Fail', 403

# Only logged in users can see the dashboard.
@app.route('/', methods=['GET'])
@login_required
def index():
    return render_template('shd.html')
     

# Can't require login here.  SmartThings sends us data through this route.      
@app.route('/', methods=['POST'])
def smarthings_requests():
    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(refresh=False) # Use this during development (after calling st.initialize() first) to eliminate API calls.
    st.initialize()
    socketio.run(app, debug=True, host='0.0.0.0', port=5000)

Notice that we call st.initialize() near the bottom. Once we run this the first time and the database is fully populated, we can comment that out and uncomment the st.initialize(refresh=False) statement. This will eliminate the API calls to get the current device status, device health, and scenes to help speed up server restarts while you’re working on your server. Change it back once you’re done just to make sure you have the current status for all devices at server startup.

base.html

Before you run the server, we need to create the base.html and login.html files. Save them in your /templates folder. You don’t have to split these two files, but to demonstrate another way you can leverage jinja for templating, I’ve split them up here.

Here’s base.html. Notice the highlighted placeholder where another block can be inserted. This is especially handy when you have multiple pages and you want to maintain a consistent look and feel such as with headers and footers.

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>SmartThings Dashboard</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css" />
	
    <style>
	html {
	    background-color: lightblue;
	}
	
    </style>
</head>

<body>
    <div class="container has-text-centered">
       {% block content %}
       {% endblock %}
    </div>    
</body>

</html>

login.html

Here’s login.html. Notice that it extends base.html and defines the block content that will be inserted into the placeholder for base.html.

In lines 7 – 13 we capture and display any messages that were flashed from our server.

On line 14 we pass through the next query parameter (next_page) that was passed to the login route in our server by the login_manager.

{% extends "base.html" %}

{% block content %}
<div class="column is-4 is-offset-4">
    <h3 class="title">Login</h3>
    <div class="box">
{% with messages = get_flashed_messages() %}
{% if messages %}
    <div class="notification is-danger">
        {{ messages[0] }}
    </div>
{% endif %}
{% endwith %}
    <form method="POST" action="/login?next={{ next_page }}">         
        <form method="POST" action="/login">
            <div class="field">
                <div class="control">
                    <input class="input is-large" type="email" name="email" placeholder="Your Email" autofocus="">
                </div>
            </div>

            <div class="field">
                <div class="control">
                    <input class="input is-large" type="password" name="password" placeholder="Your Password">
                </div>
            </div>
            <div class="field">
                <label class="checkbox">
                    <input type="checkbox" name="remember">
                    Remember me
                </label>
            </div>
            <button class="button is-block is-info is-large is-fullwidth">Login</button>
        </form>
    </div>
</div>
{% endblock %}

config.html

We also need to create our config.html file. Again, save this in your /templates folder. Notice again how we use jinja to iterate through the passed in configData to build our display. We build a table that contains all rooms, devices and capabilities, but we hide everything below the rooms by default. The user can expand each room to configure items within the room.

This calls our /update-configs route in our server when the user clicks the save button, where the server validates that the user is authenticated and is an Admin.

You may have to create your own FontAwesome account to use their icons. It’s a free account and they have lots of nice icons. If you see an error in your browser console or you don’t get the little edit icons next to the location nickname and email fields, then you’ll need to create an account and replace the <script src=”https://kit.fontawesome.com/….js”></script> line below with one that contains your kit account. You can also download the css file and webfonts folder from FontAwesome to your pi and serve them directly for free. I’ll probably change to the downloaded version in the future. You can get all the info from their website: https://fontawesome.com.

<!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 Configuration</title>
    <script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/socket.io/4.1.3/socket.io.min.js"></script>
    <script src="https://kit.fontawesome.com/a076d05399.js"></script>
    <style>
    body {
      background-color: lightblue;
      color: blue;
      font-size: 24px;
    }
    
    table {
        width: 90%;
        margin: auto;
    }
    
    table, th, td {
        border: 1px solid black;
        border-collapse: collapse;
    }
    
    th, td {
      padding: 3px;
    }
    
    .room, .room-header {
        background-color: white;
    }
    
    .device, .device-header {
        background-color: khaki;
        display: none;
    }
    
    .capability, .capability-header {
        background-color: beige;
        display: none;
    }
    
    .right-align {
        text-align: right;
    }
    
    .not-visible {
        background-color: lightgray;
    }
    
    .collapse {
        display: none;
    }
    
    .save-container {
        width: 100%;
        text-align: center;
        margin: auto;
    }
    
    .save-button {
        margin-left: auto;
        width: 100px;
        height: 50px;
        font-size: 24px;
    }
    
    .unchanged {
      color: blue;
      background-color: white;
    }
    
    .changed {
      color: black;
      background-color: red;
    }
    </style>
</head>
 
<body>
  <p>
    <div></div>Location Name: [<span style="background-color: white;">{{ configData.location.name }}</span>]</div>
    <div>Location Nickname: [<span class="unchanged" id="location-nickname">{{ configData.location.nickname }}</span>] <span><i class='fas fa-edit unchanged' id="edit-nickname" style='font-size: 20px;' onclick='editNickname()'></i></span></div>
    <div>Location Email: [<span class="unchanged" id="location-email">{{ configData.location.email }}</span>] <span><i class='fas fa-edit unchanged' id="edit-email" style='font-size: 20px;' onclick='editEmail()'></i></span></div>
  </p>
  <table id="room-table">
      <tr class="room-header">
          <th></th>
          <th>Room</th>
          <th>Seq</th>
          <th>Visible</th>
          <th></th>
          <th></th>
      </tr>
  {% for room in configData.rooms %}
    {% set roomIdx = loop.index0 %}
    {% if room.visible != 1 %}
        {% set visClass = "not-visible" %}
    {% else %}
        {% set visClass = "" %}
    {% endif %}
    <tr class="room {{ visClass }}" id="room-{{ loop.index0 }}">
        <td onclick='toggle("{{room.name }}")'> +/- </td>
        <td>{{ room.name }}</td>
        <td class="right-align" contenteditable>{{ room.seq }}</td>
        <td class="right-align" contenteditable>{{ room.visible }}</td>
        <td></td>
        <td></td>
    </tr>
    
    <tr class="device-header {{ visClass }}">
        <th></th>
        <th></th>
        <th>Device</th>
        <th>Seq</th>
        <th>Visible</th>
        <th></th>
    </tr>
    {% for device in room.devices %}
        {% set deviceIdx = loop.index0 %}
        {% if room.visible != 1 or device.visible != 1 %}
            {% set visClass = "not-visible" %}
        {% else %}
            {% set visClass = "" %}
        {% endif %}
        <tr class="device {{ visClass }}" data-room="{{ roomIdx }}" id="device-{{ loop.index0 }}">
            <td></td>
            <td></td>
            <td>{{ device.label }}</td>
            <td class="right-align" contenteditable>{{ device.seq }}</td>
            <td class="right-align" contenteditable>{{ device.visible }}</td>
            <td></td>
        </tr>
        <tr class="capability-header {{ visClass }}">
            <th></th>
            <th></th>
            <th></th>
            <th>Capability</th>
            <th>Seq</th>
            <th>Visible</th>
        </tr>
        {% for capability in device.capabilities %}
            {% if room.visible != 1 or device.visible != 1 or capability.visible != 1 %}
                {% set visClass = "not-visible" %}
            {% else %}
                {% set visClass = "" %}
            {% endif %}
            <tr class="capability {{ visClass }}" data-room="{{ roomIdx }}" data-device="{{ deviceIdx }}" id="capability-{{ loop.index0 }}">
                <td></td>
                <td></td>
                <td></td>
                <td>{{ capability.capability_id }}</td>
                <td class="right-align" contenteditable>{{ capability.seq }}</td>
                <td class="right-align" contenteditable>{{ capability.visible }}</td>
            </tr>
        {% endfor %}
    {% endfor %}
    
    
  {% endfor %}
  </table>
  <div class="save-container"><p><button type="button" class="save-button" onclick="saveConfig()">Save</button></p></div>
  
  <script>
    const ROOM_NAME = 1;
    const ROOM_SEQ = 2;
    const ROOM_VIS = 3;
    const DEVICE_LABEL = 2;
    const DEVICE_SEQ = 3;
    const DEVICE_VIS = 4;
    const CAPABILITY_ID = 3;
    const CAPABILITY_SEQ = 4;
    const CAPABILITY_VIS = 5;
    
    disp = document.querySelector("#configs");
    var configData = {{ configData | safe }};
    console.log(JSON.stringify(configData, null, 2));
    
    var table = document.querySelector("#room-table");
    table.addEventListener("keypress", function(e) {
      if (e.key == "Enter") {
        e.preventDefault();
      }
    });

    table.addEventListener("focusout", function(e) {
      var tableRow = e.target.closest("tr");
      console.log("ID: " + tableRow.id);
      if (tableRow.classList.contains("room")) {
        var roomId = tableRow.id;
        var roomIdx = parseInt(roomId.substring(roomId.indexOf("-")+1));
        if (e.target.cellIndex == ROOM_SEQ) {
          if (configData.rooms[roomIdx].seq != e.target.innerHTML) {
            e.target.style.backgroundColor = "red";
          }
        } else if (e.target.cellIndex == ROOM_VIS) {
          if (configData.rooms[roomIdx].visible != e.target.innerHTML) {
            e.target.style.backgroundColor = "red";
          }
        }
      } else if (tableRow.classList.contains("device")) {
        var roomIdx = tableRow.getAttribute("data-room");
        console.log("data-room: " + roomIdx);
        var deviceId = tableRow.id
        var deviceIdx = parseInt(deviceId.substring(deviceId.indexOf("-")+1));
        if (e.target.cellIndex == DEVICE_SEQ) {
          console.log("Device Seq");
          if (configData.rooms[roomIdx].devices[deviceIdx].seq != e.target.innerHTML) {
            e.target.style.backgroundColor = "red";
          }
        } else if (e.target.cellIndex == DEVICE_VIS) {
          if (configData.rooms[roomIdx].devices[deviceIdx].visible != e.target.innerHTML) {
            e.target.style.backgroundColor = "red";
          }
        }
      } else if (tableRow.classList.contains("capability")) {
        var roomIdx = tableRow.getAttribute("data-room");
        var deviceIdx = tableRow.getAttribute("data-device");
        console.log("data-room: " + roomIdx + " / data-device: " + deviceIdx);
        var capabilityId = tableRow.id
        var capabilityIdx = parseInt(capabilityId.substring(capabilityId.indexOf("-")+1));
        if (e.target.cellIndex == CAPABILITY_SEQ) {
          console.log("Capability Seq");
          if (configData.rooms[roomIdx].devices[deviceIdx].capabilities[capabilityIdx].seq != e.target.innerHTML) {
            e.target.style.backgroundColor = "red";
          }
        } else if (e.target.cellIndex == CAPABILITY_VIS) {
          if (configData.rooms[roomIdx].devices[deviceIdx].capabilities[capabilityIdx].visible != e.target.innerHTML) {
            e.target.style.backgroundColor = "red";
          }
        }
      }
    });

    function editNickname() {
      nickname = document.querySelector("#location-nickname");
      editNicknameBtn = document.querySelector("#edit-nickname");
      
      var name = prompt("Enter a nickname for this location:", nickname.innerHTML);
      if (name != null) {
        if (nickname.innerHTML != name) {
          nickname.innerHTML = name;
          if (name != configData.location.nickname) {
            nickname.classList.remove("unchanged");
            nickname.classList.add("changed");
            editNicknameBtn.classList.remove("unchanged");
            editNicknameBtn.classList.add("changed");
          } else {
            nickname.classList.remove("changed");
            nickname.classList.add("unchanged");
            editNicknameBtn.classList.remove("changed");
            editNicknameBtn.classList.add("unchanged");
          }
        }
      }
    }
    
    function editEmail() {
      email = document.querySelector("#location-email");
      editEmailBtn = document.querySelector("#edit-email");
      
      var name = prompt("Enter an email address for this location:", email.innerHTML);
      if (name != null) {
        if (email.innerHTML != name) {
          email.innerHTML = name;
          if (name != configData.location.email) {
            email.classList.remove("unchanged");
            email.classList.add("changed");
            editEmailBtn.classList.remove("unchanged");
            editEmailBtn.classList.add("changed");
          } else {
            email.classList.remove("changed");
            email.classList.add("unchanged");
            editEmailBtn.classList.remove("changed");
            editEmailBtn.classList.add("unchanged");
          }
        }
      }
    }
    
    function toggle(element) {
        console.log("Clicked on " + element);
        var rows = table.rows.length;
        console.log("Table rows: " + rows);
        var found = false;
        var display = "none";
        for (row = 0; row < rows; row++) {
            var room = table.rows[row].cells[1].innerHTML;
            if (room.length == 0 && found) {
                table.rows[row].style.display = display;
            } else {
                found = false;
            }
            if (room == element) {
                console.log("Found [" + room + "] at row " + row);
                found = true;
                if (row < rows-1) {
                    var dispVal = window.getComputedStyle(table.rows[row+1], null).getPropertyValue("display");
                    console.log("Display [" + dispVal + "]");
                    if (dispVal == "none") {
                        display = "table-row";
                    } else {
                        display = "none";
                    }                    
                }
            }
        }
        
    }
    
    function saveConfig() {
        var table = document.querySelector("#room-table");
        var rows = table.rows.length;
        var configChanges = {"location": [], "rooms": [], "devices": [], "capabilities": []};

        var nickname = document.querySelector("#location-nickname");
        var email = document.querySelector("#location-email");
        if (nickname.innerHTML != configData.location.nickname ||
            email.innerHTML != configData.location.email) {
              nicknameVal = nickname.innerHTML;
              emailVal = email.innerHTML;
          configChanges.location.push({"location_id": configData.location.location_id});
          configChanges.location.push({"nickname": nicknameVal});
          configChanges.location.push({"email": emailVal});
        }
        
        var roomIdx = 0;
        var deviceIdx = 0;
        var capabilityIdx = 0;
        for (row = 1; row < rows; row++) {
            var tableRow = table.rows[row];
            if (table.rows[row].classList.contains("room")) {
                var room = tableRow.cells[ROOM_NAME];
                var roomId = tableRow.id;
                roomIdx = parseInt(roomId.substring(roomId.indexOf("-")+1))
                if (configData.rooms[roomIdx].seq != tableRow.cells[ROOM_SEQ].innerHTML ||
                    configData.rooms[roomIdx].visible != tableRow.cells[ROOM_VIS].innerHTML) {
                  var change = {"room_id": configData.rooms[roomIdx].room_id}
                  change.seq = tableRow.cells[ROOM_SEQ].innerHTML;
                  change.visible = tableRow.cells[ROOM_VIS].innerHTML;
                  configChanges.rooms.push(change);
                }
            } else if (tableRow.classList.contains("device")) {
                var device = tableRow.cells[DEVICE_LABEL];
                var deviceId = tableRow.id;
                console.log("Device Id: " + deviceId);
                deviceIdx = parseInt(deviceId.substring(deviceId.indexOf("-")+1));
                console.log("Room/Device Index: " + roomIdx + " / " + deviceIdx);
                console.log("device.seq / table.seq: " + configData.rooms[roomIdx].devices[deviceIdx].seq + " / " + tableRow.cells[DEVICE_SEQ].innerHTML);
                if (configData.rooms[roomIdx].devices[deviceIdx].seq != tableRow.cells[DEVICE_SEQ].innerHTML ||
                    configData.rooms[roomIdx].devices[deviceIdx].visible != tableRow.cells[DEVICE_VIS].innerHTML) {
                        var change = {"device_id": configData.rooms[roomIdx].devices[deviceIdx].device_id};
                        if (configData.rooms[roomIdx].devices[deviceIdx].seq != tableRow.cells[DEVICE_SEQ].innerHTML ||
                            configData.rooms[roomIdx].devices[deviceIdx].visible != tableRow.cells[DEVICE_VIS].innerHTML) {
                          change.seq = table.rows[row].cells[DEVICE_SEQ].innerHTML;
                          change.visible = table.rows[row].cells[DEVICE_VIS].innerHTML;
                          configChanges.devices.push(change);
                        }
                }
            } else if (tableRow.classList.contains("capability")) {
                var capability = tableRow.cells[CAPABILITY_ID];
                var capabilityId = tableRow.id;
                console.log("Capability Id: " + capabilityId);
                capabilityIdx = parseInt(capabilityId.substring(capabilityId.indexOf("-")+1));
                console.log("Room/Device/Capability Index: " + roomIdx + " / " + deviceIdx + " / " + capabilityIdx);
                if (configData.rooms[roomIdx].devices[deviceIdx].capabilities[capabilityIdx].seq != tableRow.cells[CAPABILITY_SEQ].innerHTML ||
                    configData.rooms[roomIdx].devices[deviceIdx].capabilities[capabilityIdx].visible != tableRow.cells[CAPABILITY_VIS].innerHTML) {
                      var change = {"device_id": configData.rooms[roomIdx].devices[deviceIdx].device_id,
                            "capability_id": configData.rooms[roomIdx].devices[deviceIdx].capabilities[capabilityIdx].capability_id};
                      if (configData.rooms[roomIdx].devices[deviceIdx].capabilities[capabilityIdx].seq != tableRow.cells[CAPABILITY_SEQ].innerHTML ||
                          configData.rooms[roomIdx].devices[deviceIdx].capabilities[capabilityIdx].visible != tableRow.cells[CAPABILITY_VIS].innerHTML) {
                        change.seq = tableRow.cells[CAPABILITY_SEQ].innerHTML;
                        change.visible = tableRow.cells[CAPABILITY_VIS].innerHTML;
                        configChanges.capabilities.push(change);
                      }
                }
            }
        }
        console.log(configChanges);
        if (configChanges.location.length > 0 || configChanges.rooms.length > 0 ||
            configChanges.devices.length > 0 || configChanges.capabilities.length > 0) {
          updateConfigs(configChanges);
          window.location.reload();
        }
    }

		function updateConfigs(configChanges) {
			var furl = "/update-configs";
			console.log("furl: " + furl);

			var xhttp=new XMLHttpRequest();
			xhttp.onreadystatechange = function() {
				if (this.readyState == 4 && this.status == 200) {
					if (this.response != "OK") {
						alert("Update Failed!  Please try again.");
					}
				}
			};
			xhttp.open("POST", furl);
      xhttp.setRequestHeader("Content-Type", "application/json");
			xhttp.send(JSON.stringify(configChanges));
		};
  </script>
</body>
</html>
shd.html

And finally, let’s capture the updated shd.html file (save in your /templates folder). Notice that we’ve added the user name/role at the top and some links in the upper right corner (Refresh, Logout and Config) and that we flex their display based on the current_user.role. All logged in users get the Logout option, Users and Admins also get the Refresh option, and Admins get the Config option. Of course we also validate these on the server side as well because any user can try to go directly to the /config URL.

I just want to emphasize that the reason this HTML file doesn’t leverage more of jinja to build the display is because it’s receiving real-time updates via web sockets. So, for example, if an Admin updates the configuration and sets a room to be hidden, the display will immediately refresh to remove the display of the room. We don’t want to have to refresh the page to accomplish that. Generally it’s most useful (to me at least) for those things that are more static, like the user in this particular case or the configuration page above.

<!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;
    }
    
    .logout {
        position: absolute;
        top: 60px;
        right: 20px;
        text-decoration: underline;
        cursor: pointer;
    }
    
    .refresh {
        position: absolute;
        top: 30px;
        right: 20px;
        text-decoration: underline;
        cursor: pointer;
    }
    
    .config {
        position: absolute;
        top: 90px;
        right: 20px;
        text-decoration: underline;
        cursor: pointer;
    }
    
    .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="dashboard"></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");
        // If nothing is passed to location-data, user is no longer authorized to view dashboard.  Blank it out!
        if (!msg) {
            socket.emit('disconn');
            socket = null;
            document.body.innerHTML = "<div>Not Authorized!<br />Contact System Administrator!</div>";
        } else {
            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 refresh() {
        socket.emit("refresh");
    }
    
    Array.prototype.sortOn = function(key){
        this.sort(function(a, b){
            if(a[key] < b[key]){
                return -1;
            }else if(a[key] > b[key]){
                return 1;
            }
            return 0;
        });
    }

    function displayLocation() {
        var dispElement = document.querySelector("#dashboard");
        var disp = `<h5>User: {{current_user.name }} / {{ current_user.role }}</h5>`;
        disp += `<h3>${locationData.location.name}</h3>`;
        {% if current_user.role != 'Guest' %}
        disp += `<div class="refresh" onclick="refresh()">Refresh</div>`;
        {% endif %}
        disp += `<div class="logout"><a href="/logout">Logout</a></div>`;
        {% if current_user.role == 'Admin' %}
        disp += `<div class="config"><a href="/config" target="_blank">Config</a></div>`;
        {% endif %}
         
        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>";
        });
         
        locationData.rooms.sortOn("seq");
        console.log("Sorted: " + JSON.stringify(locationData.rooms, null, 2));
        
        var rooms = locationData.rooms;
        rooms.forEach(room => {
            if (room.devices.length > 0) {
                disp += `<p>`;
                disp += "<div class='room'>" + room.name + "</div>";
                room.devices.sortOn("seq");
                room.devices.forEach(device => {
                    deviceStateClass = device.health == "ONLINE" ? "" : "offline";
                    disp += `<div class='device ${deviceStateClass}'>`;
                    disp += device.label + "</div>";
                    device.capabilities.sortOn("seq");
                    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>

Create the SmartThings database…

Now that we have basic authentication in place and we can control who accesses our server and Smart Home data, let’s create our SmartThings database. Notice that we are intentionally keeping this database separate from our users database. That’s just a personal preference, but since they really have different purposes I like to keep them separated. You’ll also notice that we will be creating and accessing this database directly by writing our own sql statements. I’m more comfortable doing it myself as opposed to using flask-sqlalchemy as the middle-man. The users database is much more straight forward, so flask-sqlalchemy works fine for that.

smartthings.py

Again, this code will be located in smartthings.py, along with all the code that manages our SmartThings data and devices. Here’s the code to create the database…

First we need to include sqlite3. We will also import exists from os.path, which we’ll use to determine if the database needs to be created, and datetime so we can capture the date and time of events.

#sqlite3 Libs
import sqlite3

#os Libs
from os.path import exists

#datetime Libs
from datetime import datetime

Identify where we want the database stored…

STDB = '/home/pi/smartthings/smartthings.db'  #Path to SmartThings DB - It's best to use the full path.

This is our updated initialize(self, refresh=True) function. Notice we added a refresh parameter which defaults to True. This is really only used during development. If we pass in False, it will skip the SmartThings API calls to get current device status and health and just return whatever is currently in the database. No reason to pound the API’s when doing development!

Notice that we also check to see if the database exists and, if not, we call createDB() to create it and then call loadData() to seed the database with all of the infrequently changing data (location, app, rooms, devices and capabilities). And finally we call readData(refresh). If our refresh variable is True, it will call the SmartThings API’s to get current device status and health. Otherwise, it will just read the database.

	def initialize(self, refresh=True):
		#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.getInstalledApps()
		if not exists(STDB):
			self.createDB()
			self.loadData()
		self.readData(refresh)

Then we can create the database and tables…

	def createDB(self):
		conn = sqlite3.connect(STDB)
		cursor = conn.cursor()

		create_location_table = '''CREATE TABLE IF NOT EXISTS location(
			location_id TEXT NOT NULL PRIMARY KEY,
			name TEXT NOT NULL,
			nickname TEXT UNIQUE NOT NULL,
			latitude TEXT,
			longitude TEXT,
			time_zone_id TEXT,
			email TEXT
			)'''
		cursor.execute(create_location_table)

		create_app_table = '''CREATE TABLE IF NOT EXISTS app(
			location_id TEXT NOT NULL,
			app_id TEXT NOT NULL,
			installed_app_id TEXT,
			display_name TEXT,
			configuration_id TEXT,
			PRIMARY KEY (location_id, app_id),
			FOREIGN KEY (location_id)
			REFERENCES location (location_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_app_table)

		create_scene_table = '''CREATE TABLE IF NOT EXISTS scene(
			scene_id TEXT NOT NULL PRIMARY KEY,
			name TEXT NOT NULL,
			location_id TEXT NOT NULL,
			visible INTEGER,
			seq INTEGER,
			FOREIGN KEY (location_id)
			REFERENCES location (location_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_scene_table)

		create_room_table = '''CREATE TABLE IF NOT EXISTS room(
			location_id TEXT NOT NULL,
			room_id TEXT NOT NULL,
			name TEXT NOT NULL,
			visible INTEGER,
			seq INTEGER,
			PRIMARY KEY (room_id),
			FOREIGN KEY (location_id)
			REFERENCES location (location_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_room_table)

		create_device_table = '''CREATE TABLE IF NOT EXISTS device(
			location_id TEXT NOT NULL,
			room_id TEXT NOT NULL,
			device_id TEXT NOT NULL,
			presentation_id TEXT,
			name TEXT,
			health TEXT,
			label TEXT,
			category TEXT,
			device_type_name TEXT,
			visible INTEGER,
			seq INTEGER,
			PRIMARY KEY (device_id),
			FOREIGN KEY (room_id)
			REFERENCES room (room_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_device_table)

		create_capability_table = '''CREATE TABLE IF NOT EXISTS capability(
			location_id TEXT NOT NULL,
			device_id TEXT NOT NULL,
			capability_id TEXT NOT NULL,
			visible INTEGER,
			state TEXT,
			seq INTEGER,
			updated TEXT,
			PRIMARY KEY (device_id, capability_id),
			FOREIGN KEY (device_id)
			REFERENCES device (device_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_capability_table)

		conn.commit()
		conn.close()

Populate the database…

Now that our database is created, we can call loadData() to call the various load functions to seed it with the infrequently changing data.

	def loadData(self):
		if self.loadLocation():
			if self.loadAppConfig():
				if self.loadRooms():
					if self.loadDevices():
						print('Success')
					else:
						print('Failed loading devices.')
				else:
					print('Failed loading rooms.')
			else:
				print('Failed loading App Config.')
		else:
			print(f'Failed loading location ({self.location_id}).')

I’ll provide the code in it’s entirety below, so I don’t want to duplicate everything, but here’s the loadLocation() function to give an example of how the database is populated:

	def loadLocation(self):
		#This will give you all of the location related data.
		status = False
		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)
			conn = sqlite3.connect(STDB)
			cursor = conn.cursor()
			insert_location = 'insert or replace into location values(?,?,?,?,?,?,?)'
			location_values = (self.location_id, data['name'], '', data['latitude'], data['longitude'], data['timeZoneId'], '')
			cursor.execute(insert_location, location_values)
			conn.commit()
			conn.close()
			status = True
		return status
Read the (near) static data from the database…

Now that we’ve pushed this seed data to the database, we read it using the functions called by the readData() function. Notice the status, health and scenes are only updated if the refresh parameter is True.

	def readData(self, refresh=True):
		if self.readLocation():
			if self.readAppConfig():
				if self.readRooms():
					if self.readDevices():
						if refresh:
							if self.loadAllDevicesStatus():
								if self.loadAllDevicesHealth():
									if self.loadAllScenes():
										pass
									else:
										print('Failed loading scenes.')
								else:
									print('Failed loading All Devices Health.')
							else:
								print('Failed loading All Devices Status.')
						if not self.readAllScenes():
							print('Failed reading scenes.')
					else:
						print('Failed reading devices.')
				else:
					print('Failed reading rooms.')
			else:
				print('Failed reading App Config.')
		else:
			print(f'Failed reading location ({self.location_id}).')
		print(self.location)

Here’s what the readLocation() function looks like. We pull the data out of the database and begin populating our self.location dictionary that will be passed to the browser as a JSON payload.

	def readLocation(self):
		status = False
		conn = sqlite3.connect(STDB)
		cursor = conn.cursor()
		for row in cursor.execute('select * from location where location_id=?', (self.location_id,)):
			location_id, name, nickname, latitude, longitude, time_zone, email = row
			self.name = name
			self.display_name = nickname if len(nickname) > 0 else name
			self.latitude = latitude
			self.longitude = longitude
			self.location = {'location': {'locationId' : location_id, 'name' : self.display_name, 'latitude' : latitude, 'longitude' : longitude, 'timeZoneId' : time_zone, 'email' : email}, 'presence':[], 'rooms' : []}
			status = True
		conn.close()
		return status

Complete smartthings.py code

Here’s the code in it’s entirety for you to take a closer look at the database interaction:

#My Secrets
from my_secrets.secrets import ST_WEBHOOK, PA_TOKEN

#HTTP Libs
import requests

#JSON Libs
import json

#sqlite3 Libs
import sqlite3

#os Libs
from os.path import exists

#datetime Libs
from datetime import datetime

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

STDB = '/home/pi/smartthings/smartthings.db'  #Path to SmartThings DB - It's best to use the full path.

# 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')]

# This just gives us a list of supported capabilities (the first item in each tuple in DEV_LIST) that we can use to test agains later
CAP_LIST = [cap[0] for cap in DEV_LIST]

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, refresh=True):
		#Calls routines in sequence to collect app, location, rooms, devices, capabilities, current state, 
		#  current health, and scenes.
		#  This creates and seeds the database, if needed, and updates the database with device status
		#  and builds out the self.location JSON structure with the data to pass to our HTML.
		if not self.location_id:
			self.getInstalledApps()
		if not exists(STDB):
			self.createDB()
			self.loadData()
		self.readData(refresh)

	def getInstalledApps(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 createDB(self):
		conn = sqlite3.connect(STDB)
		cursor = conn.cursor()

		create_location_table = '''CREATE TABLE IF NOT EXISTS location(
			location_id TEXT NOT NULL PRIMARY KEY,
			name TEXT NOT NULL,
			nickname TEXT UNIQUE NOT NULL,
			latitude TEXT,
			longitude TEXT,
			time_zone_id TEXT,
			email TEXT
			)'''
		cursor.execute(create_location_table)

		create_app_table = '''CREATE TABLE IF NOT EXISTS app(
			location_id TEXT NOT NULL,
			app_id TEXT NOT NULL,
			installed_app_id TEXT,
			display_name TEXT,
			configuration_id TEXT,
			PRIMARY KEY (location_id, app_id),
			FOREIGN KEY (location_id)
			REFERENCES location (location_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_app_table)

		create_scene_table = '''CREATE TABLE IF NOT EXISTS scene(
			scene_id TEXT NOT NULL PRIMARY KEY,
			name TEXT NOT NULL,
			location_id TEXT NOT NULL,
			visible INTEGER,
			seq INTEGER,
			FOREIGN KEY (location_id)
			REFERENCES location (location_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_scene_table)

		create_room_table = '''CREATE TABLE IF NOT EXISTS room(
			location_id TEXT NOT NULL,
			room_id TEXT NOT NULL,
			name TEXT NOT NULL,
			visible INTEGER,
			seq INTEGER,
			PRIMARY KEY (room_id),
			FOREIGN KEY (location_id)
			REFERENCES location (location_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_room_table)

		create_device_table = '''CREATE TABLE IF NOT EXISTS device(
			location_id TEXT NOT NULL,
			room_id TEXT NOT NULL,
			device_id TEXT NOT NULL,
			presentation_id TEXT,
			name TEXT,
			health TEXT,
			label TEXT,
			category TEXT,
			device_type_name TEXT,
			visible INTEGER,
			seq INTEGER,
			PRIMARY KEY (device_id),
			FOREIGN KEY (room_id)
			REFERENCES room (room_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_device_table)

		create_capability_table = '''CREATE TABLE IF NOT EXISTS capability(
			location_id TEXT NOT NULL,
			device_id TEXT NOT NULL,
			capability_id TEXT NOT NULL,
			visible INTEGER,
			state TEXT,
			seq INTEGER,
			updated TEXT,
			PRIMARY KEY (device_id, capability_id),
			FOREIGN KEY (device_id)
			REFERENCES device (device_id)
			ON DELETE CASCADE
			ON UPDATE CASCADE
			)'''
		cursor.execute(create_capability_table)

		conn.commit()
		conn.close()

	# Load seed data into the database
	def loadData(self):
		if self.loadLocation():
			if self.loadAppConfig():
				if self.loadRooms():
					if self.loadDevices():
						print('Data Loaded...')
					else:
						print('Failed loading devices.')
				else:
					print('Failed loading rooms.')
			else:
				print('Failed loading App Config.')
		else:
			print(f'Failed loading location ({self.location_id}).')
		

	# Update device status/health and scenes and build the self.location JSON for the browser
	def readData(self, refresh=True):
		if self.readLocation():
			if self.readAppConfig():
				if self.readRooms():
					if self.readDevices():
						if refresh:
							if self.loadAllDevicesStatus():
								if self.loadAllDevicesHealth():
									if self.loadAllScenes():
										pass
									else:
										print('Failed loading scenes.')
								else:
									print('Failed loading All Devices Health.')
							else:
								print('Failed loading All Devices Status.')
						if self.readAllScenes():
							print('Data Read...')
						else:
							print('Failed reading scenes.')
					else:
						print('Failed reading devices.')
				else:
					print('Failed reading rooms.')
			else:
				print('Failed reading App Config.')
		else:
			print(f'Failed reading location ({self.location_id}).')
		#print(self.location)

	def loadLocation(self):
		#This will give you all of the location related data and populate the database.
		status = False
		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)
			conn = sqlite3.connect(STDB)
			cursor = conn.cursor()
			insert_location = 'insert or replace into location values(?,?,?,?,?,?,?)'
			location_values = (self.location_id, data['name'], '', data['latitude'], data['longitude'], data['timeZoneId'], '')
			cursor.execute(insert_location, location_values)
			conn.commit()
			conn.close()
			status = True
		return status

	def readLocation(self):
		#This will read location data from the database and populate self.location
		status = False
		conn = sqlite3.connect(STDB)
		cursor = conn.cursor()
		for row in cursor.execute('select * from location where location_id=?', (self.location_id,)):
			location_id, name, nickname, latitude, longitude, time_zone, email = row
			self.name = name
			self.display_name = nickname if len(nickname) > 0 else name
			self.latitude = latitude
			self.longitude = longitude
			self.location = {'location': {'locationId' : location_id, 'name' : self.display_name, 'latitude' : latitude, 'longitude' : longitude, 'timeZoneId' : time_zone, 'email' : email}, 'presence':[], 'rooms' : []}
			status = True
		conn.close()
		return status

	def loadAppConfig(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 during user install/update, you can get all of that info here.
		status = False
		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)
				conn = sqlite3.connect(STDB)
				cursor = conn.cursor()
				for item in appData['items']:
					if item['configurationStatus'] == 'AUTHORIZED':
						configurationId = item['configurationId']
						insert_app = 'insert or replace into app values(?,?,?,?,?)'
						app_values = (self.location_id, ST_WEBHOOK, installedAppId, displayName, configurationId)
						cursor.execute(insert_app, app_values)
						conn.commit()
						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)
		conn.close()
		return status

	def readAppConfig(self):
		status = False
		conn = sqlite3.connect(STDB)
		cursor = conn.cursor()
		for row in cursor.execute('select installed_app_id, display_name from app where app_id=? and location_id=?', (ST_WEBHOOK, self.location_id)):
			self.installed_app_id =row[0]
			self.app_name = row[1]
			status = True
		conn.close()
		return status

	def loadRooms(self):
		#This will return all rooms at this location and populate the database.
		status = False
		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)
			conn = sqlite3.connect(STDB)
			cursor = conn.cursor()
			insert_room = 'insert or replace into room values(?,?,?,?,?)'
			for rm in data['items']:
				room_values = (self.location_id, rm['roomId'], rm['name'], 1, 99)
				cursor.execute(insert_room, room_values)
			conn.commit()
			conn.close()
			status = True
		else:
			print('Get Rooms Failed.  Status: %s' % r.status_cd)
		return status

	def readRooms(self):
		#Load all room data from the database.
		status = False
		conn = sqlite3.connect(STDB)
		cursor = conn.cursor()

		self.location['rooms'] = []
		
		for row in cursor.execute('select * from room where location_id=? and visible=?', (self.location_id,1)):
			location_id, room_id, name, visible_val, seq = row
			room = {'roomId' : room_id, 'name' : name, 'seq': seq, 'devices' : []}
			self.location['rooms'].append(room)
			status = True
		conn.close()
		return status

	def loadDevices(self):
		#This will give us all devices at this location, but we have to put them into room groupings or 
		#  presenceSensor groupings.  Stores the data in the database.
		status = False
		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)
			conn = sqlite3.connect(STDB)
			cursor = conn.cursor()
			insert_device = 'insert or replace into device values(?,?,?,?,?,?,?,?,?,?,?)'
			insert_capability = 'insert or replace into capability values(?,?,?,?,?,?,?)'
			for dev in data['items']:
				dtn = dev.get('dth','')
				if dtn:
					dtn = dtn.get('deviceTypeName', '')
				device_values = (self.location_id, dev.get('roomId', 0), dev['deviceId'], dev.get('presentationId',''), dev['name'], '?', dev['label'], dev['components'][0]['categories'][0]['name'], dtn, 1, 99)
				cursor.execute(insert_device, device_values)
				for comp in dev['components']:
					for cap in comp['capabilities']:
						capability_values = (self.location_id, dev['deviceId'], cap['id'], 1, '', 99, '')
						cursor.execute(insert_capability, capability_values)
			status = True
			conn.commit()
			conn.close()
		return status

	def readDevices(self):
		# Reads device data from the database.
		status = False
		conn = sqlite3.connect(STDB)
		cursor = conn.cursor()
		c2 = conn.cursor()
		self.location['presence'] = []
		for room in self.location['rooms']:
			room['devices'] = []
		for row in cursor.execute('select * from device where location_id=? and visible=?', (self.location_id,1)):
			d_location_id, d_room_id, d_device_id, d_presentation_id, d_name, d_health, d_label, d_category, d_device_type, d_visible, d_seq = row
			device = {'deviceId' : d_device_id, 'name' : d_name, 'label' : d_label, 'seq': d_seq, 'health': d_health, 'capabilities' : []}
			for r2 in c2.execute('select * from capability where device_id=? and visible=?', (d_device_id, 1)):
				status = True
				c_location_id, c_device_id, c_capability_id, c_visible, c_state, c_seq, dt = r2
				if c_capability_id in CAP_LIST:
					capability = {'id' : c_capability_id, 'state' : c_state, 'seq': c_seq, 'updated': dt}
					device['capabilities'].append(capability)
			if len(device['capabilities']) > 0 and (d_room_id == 0 or d_room_id == '0'):
				self.location['presence'].append(device)
			else:
				if len(device['capabilities']) > 0:
					for room in self.location['rooms']:
						if room['roomId'] == d_room_id:
							room['devices'].append(device)

		conn.close()
		return status

	def loadAllDevicesStatus(self):
		#We spin through each device to get the current status of all of it's capabilities.
		#  This data gets written to the database and updates self.location.
		status = False
		baseURL = HOME_URL + 'devices/'
		headers = APP_HEADERS
		endURL = '/status'

		conn = sqlite3.connect(STDB)
		c1 = conn.cursor()
		dt = datetime.now().strftime('%m/%d/%y %H:%M:%S')

		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']
									capability['updated'] = dt
									c1.execute('update capability set state=?, updated=? where device_id=? and capability_id=?', 
										(cap['value'], dt, pres['deviceId'], dev[0]))
									conn.commit()

		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']
										c1.execute('update capability set state=?, updated=? where device_id=? and capability_id=?', 
											(cap['value'], dt, device['deviceId'], dev[0]))
										conn.commit()
										status = True
		conn.close()
		return status

	def loadAllDevicesHealth(self):
		#Here we spin through all devices to get it's current health status (online/offline).
		#  This data gets written to the database and updates self.location.
		status = False
		baseURL = HOME_URL + 'devices/'
		headers = APP_HEADERS
		endURL = '/health'

		conn = sqlite3.connect(STDB)
		c1 = conn.cursor()

		for pres in self.location['presence']:
			deviceId = pres['deviceId']
			fullURL = baseURL + str(deviceId) + endURL
			r = requests.get(fullURL, headers=headers)
			if r.status_code == 200:
				status = True
				data = json.loads(r.text)
				print('Get Presence Health: %s - %s' % (data['state'], pres['label']))
				pres['health'] = data['state']
				c1.execute('update device set health=? where device_id=?', (data['state'], deviceId))
				conn.commit()

		for rm in self.location['rooms']:
			for dev in rm['devices']:
				deviceId = dev['deviceId']
				fullURL = baseURL + deviceId + endURL
				r = requests.get(fullURL, headers=headers)
				if r.status_code == 200:
					status = True
					data = json.loads(r.text)
					print('Get Device Health: %s - %s' % (data['state'], dev['label']))
					dev['health'] = data['state']
					c1.execute('update device set health=? where device_id=?', (data['state'], deviceId))
					conn.commit()
		conn.close()
		return status

	def updateDeviceHealth(self, deviceId, status):
		#This gets called when a device health event fires.
		#  It updates the database and self.location.
		conn = sqlite3.connect(STDB)
		c1 = conn.cursor()
		
		for pres in self.location['presence']:
			for dev in pres['devices']:
				if dev['deviceId'] == deviceId:
					dev['health'] = status
					c1.execute('update device set health=? where device_id=?', (status, deviceId))
					conn.commit()
					conn.close()
					return True

		for room in self.location['rooms']:
			for dev in room['devices']:
				if dev['deviceId'] == deviceId:
					dev['health'] = status
					c1.execute('update device set health=? where device_id=?', (status, deviceId))
					conn.commit()
					conn.close()
					return True
		return False


	def loadAllScenes(self):
		#This will load all scenes and must be filtered for the location and writes them to the database.
		status = False
		baseURL = HOME_URL + 'scenes'
		headers = APP_HEADERS
		fullURL = baseURL
		r = requests.get(fullURL, headers=headers)
		
		conn = sqlite3.connect(STDB)
		c1 = conn.cursor()
		
		print(f'loadAllScenes() r.status_code: {r.status_code}')
		if r.status_code == 200:
			status = True
			data = json.loads(r.text)
			#print(f'*****Scenes\nr.text\n******')
			self.location['scenes'] = []
			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)
					c1.execute('insert or replace into scene values (?,?,?,?,?)', 
						(scene['sceneId'], scene['sceneName'], self.location_id, 1, 99))
					conn.commit()
		conn.close()
		return status

	def readAllScenes(self):
		#Reads scenes from the database.
		status = False

		conn = sqlite3.connect(STDB)
		conn.row_factory = sqlite3.Row
		c1 = conn.cursor()
		self.location['scenes'] = []
		for scene in c1.execute('select * from scene where location_id=?', (self.location_id,)):
			self.location['scenes'].append(dict(scene))
			status = True
		return status

	def updateDevice(self, deviceId, capability, attribute, value):
		#This is called when a device event occurs.  It updates the database and 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 = ()
		
		conn = sqlite3.connect(STDB)
		c1 = conn.cursor()
		dt = datetime.now().strftime('%m/%d/%y %H:%M:%S')

		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
							c1.execute('update capability set state=?, updated=? where device_id=? and capability_id=?',
								(value, dt, deviceId, capability))
							conn.commit()
							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:
									c1.execute('update capability set state=?, updated=? where device_id=? and capability_id=?',
										(value, dt, deviceId, capability))
									conn.commit()									
									dev_json = json.dumps({'deviceId': deviceId,'capability': capability, 'value': value})
									emit_val = ('device_chg', dev_json)
		conn.close()
		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.
		#  It calls an API which, if successful, will trigger a subsequent device event.
		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)

With these changes, we are now pushing all of our SmartThings data to the database as well as returning it to the browser as we did before. You will see once you browse the database that we are storing ALL capabilities, not just the selected ones that our dashboard supports. You can filter this out if you want, but I find it helpful to see what capabilities a device supports when deciding what I might want to add support for in the future.

You should now be able to run your server and access the dashboard (after logging in). Don’t forget to update your SmartThings config if necessary with your current Ngrok Public URL.

Back to the DB Browser…

After we started the server, our users.db and smartthings.db should have been created and populated. It’s time to open our DB Browser. By default, after install it should be located under the Raspberry Pi -> Programming menu. Once you launch it, you’ll need to open a database.

users.db

Let’s start with the users.db. Click File -> Open Database… and navigate to your project directory. Select users.db to open it.

The Database Structure tab will show you all tables and columns/data types in your database.

The Browse Data tab will show you the data stored in each table. This is where you’ll spend most of your time. Let’s select this tab and choose the users table. Unless you changed the code, there is only one Admin user (jeff@example.com). Let’s create a new user.

Click the New Record button on the top right of the tab. That will give you a new row with the id automatically populated. Click in the active field and enter 1, press tab and enter an email address for your user (i.e., user@gmail.com). Press tab to get to the password field. Leave it blank and press tab again. Enter a name for your user (i.e., TestUser) and press tab. In the final field, enter User for the role. You’ll need to click out of the field before saving. Now click the Write Changes button at the top of the app, right below the menu.

Now you can login to your dashboard with your new user. The first time you login, your password will be set to whatever you enter that first time. If you go back to the DB Browser and click the refresh button next to the table name, you’ll see your password is now populated with a hash value. If you ever need to reset a password, just blank out the password field on the database and log back in again to set it. You’ll notice that since this user has a role of User, they don’t have access to the Configs link. Only Refresh and Logout. If you try to access the /config URL directly, you’ll be logged out and redirected to the login page with a message that was flashed from the server.

If you leave your user logged in to the dashboard and then update his role from User to Guest (and click the Write Changes button and click refresh to confirm the save was successful), you’ll notice that if you click the Refresh link on the dashboard, you’ll see a message written out to the terminal by your server that says ‘Guest users can’t refresh’. The change is immediate.

Change your new user’s role back to User and log out and back into your dashboard with this user. If you update the active field to 0 (and write changes), your user will no longer be able to do anything that requires a logged in user. If you click refresh or perform any action, the user’s browser will be updated to reflect that they are no longer authorized. Change the active field back to 1, save changes, and refresh the browser and everything is back to normal again.

Hopefully this gives you a good idea of how flask-login works and how you can use it to control user access.

smartthings.db

Close the users.db (File -> Close Database). Now open smartthings.db (File -> Open Database.., browse to /home/pi/smartthings and open smartthings.db).

If you browse through the tables, you’ll notice that several of the tables have parent-child relationships. The location table is the table that ties all of the other data together and location_id is stored on all other tables. This is simply to account for those instances where there are multiple locations and allows us to quickly separate all data for each location. You can also see that capability is a child of device, and device is a child of room, each having a one-to-many relationship.

If you pick a device and want to see the respective capabilities, you can copy that device_id and paste it into the device_id column heading on the capability table to filter the display.

You’ll also notice that room, device, and capability all have visible and seq columns. You can set these either directly using the DB Browser tool, or you can navigate to the /config URL on your server while logged in as an Admin. Any changes you make using the Configuration page in your browser will be pushed out to all dashboards immediately with a web socket event. If you make a change with the DB Browser tool, you can click the Refresh link on the dashboard to trigger the update.

Play around with the DB Browser tool to get comfortable with what it can and can’t do. From my experience it doesn’t work very well for adding new rows for tables that have Not Null constraints and isn’t good at modifying tables. The good news is that if you wan’t to make changes to the database structure, you can always delete the database and it will be recreated. But you will lose any configuration changes that you have made.

What’s Next?

We really haven’t done much with scenes up to now, but we will. We will setup a configuration page for scenes so you can decide which ones show in your dashboard and in what order. We’ll also add an Admin page that lets us create new users and manage existing users.

It’s also time to move from an event list like we’ve been using, to a more user-friendly dashboard that not only gives us a better view of our smart devices, but also lets us control them. Here’s the link!

See you there!

Leave a Comment

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