Compare commits

...

4 Commits

Author SHA1 Message Date
921163f454 begin strip light 2019-07-02 13:09:31 -04:00
2a628908bb device_dict.get() instead of device.get() 2019-07-02 09:05:34 -04:00
bd61bb6819 split data models into separate module 2019-07-02 08:56:17 -04:00
c6fe1216c2 pwa 2019-07-02 08:47:34 -04:00
14 changed files with 372 additions and 139 deletions

147
juice.py
View File

@ -7,142 +7,15 @@ import re
import copy import copy
import json import json
import requests
from flask import Flask, render_template, request, abort, jsonify from flask import Flask, render_template, request, abort, jsonify
from flask import Blueprint from flask import Blueprint
import auth import auth
import config import config
import models
from auth import auth_required from auth import auth_required
from tools import make_error from tools import make_error
class RelayDevice:
"""
Represents a relay receptacle device controlled by an ESP-01. The
ESP-01 acts a simple switch to turn each receptable on or off via
a relay. Each device only has two outputs, `OUT0` and `OUT2`.
"""
def __init__(self):
self.id = ""
self.type = "RelayDevice"
self.description = ""
self.location = ""
self.ip_address = ""
self.locked = False
self.sub_devices = []
self.sub_devices.append(RelayOutlet())
self.sub_devices.append(RelayOutlet())
self.sub_devices[0].id = 'OUT0'
self.sub_devices[0].gpio = '0'
self.sub_devices[1].id = 'OUT2'
self.sub_devices[1].gpio = '2'
self.update()
def update(self):
"""
Queries the physical device and updates the internal model as
appropriate.
"""
if not self.ip_address:
return
for sub_dev in self.sub_devices:
res = requests.get(self.ip_address)
gpio0 = re.search(r"GPIO0: (\bLow|\bHigh)", res.text).groups()[0]
sub_dev.state = gpio0 == 'High'
def toggle(self, sub_dev_id):
"""
Toggles the state of a sub device.
"""
for sub_dev in self.sub_devices:
if sub_dev.id == sub_dev_id:
break
else:
return None
params = {sub_dev.gpio: 'toggle'}
res = requests.get('http://' + self.ip_address + '/gpio',params=params)
res.raise_for_status()
state = re.search(
rf"GPIO{sub_dev.gpio}: (\bLow|\bHigh)",
res.text
).groups()[0] == 'High'
sub_dev.state = state
return json.dumps({'ok': True, sub_dev_id: state})
def from_dict(self, data):
"""
Initializes self with data from JSON dict.
"""
for key, value in data.items():
setattr(self, key, value)
self.sub_devices = []
for sub_dev_dict in data.get('sub_devices'):
if sub_dev_dict.get('type') == 'RelayOutlet':
sub_dev = RelayOutlet().from_dict(sub_dev_dict)
self.sub_devices.append(sub_dev)
else:
typ = sub_dev.get('type')
raise ValueError(f"Unknown sub_device type {typ}")
return self
class RelayOutlet:
"""
Represents a single outlet on a RelayDevice.
"""
def __init__(self):
self.id = ""
self.type = "RelayOutlet"
self.description = ""
self.gpio = ""
self.state = False
def from_dict(self, data):
"""
Initializes self with data from JSON dict.
"""
for key, value in data.items():
setattr(self, key, value)
return self
class DeviceEncoder(json.JSONEncoder):
"""
A custom json encoder for dumping devices to file.
"""
def default(self, o):
return vars(o)
def init_network(filepath="devices.json"):
"""
Initializes the network of IOT devices.
"""
with open(filepath, 'r') as file:
data = file.read()
data = json.loads(data)
network = []
for device_dict in data:
if device_dict.get('type') == 'RelayDevice':
device = RelayDevice().from_dict(device_dict)
network.append(device)
else:
raise ValueError(f"Unknown device type {device.get('type')}")
return network
def save_network(filepath="devices.json"):
"""
Dumps the network to file.
"""
with open(filepath, 'w') as file:
file.write(json.dumps(network, cls=DeviceEncoder, indent='\t'))
app_views = Blueprint('app_views', __name__) app_views = Blueprint('app_views', __name__)
@app_views.route('/') @app_views.route('/')
@ -151,6 +24,7 @@ def index():
""" """
The index page. The index page.
""" """
global network
init_state = {} init_state = {}
for device in network: for device in network:
device_state = {} device_state = {}
@ -166,6 +40,7 @@ def toggle():
""" """
Toggles the state of a RelayDevice. Toggles the state of a RelayDevice.
""" """
global network
device_id = request.args.get('device_id') device_id = request.args.get('device_id')
sub_dev_id = request.args.get('sub_dev_id') sub_dev_id = request.args.get('sub_dev_id')
@ -177,7 +52,7 @@ def toggle():
res = device.toggle(sub_dev_id) res = device.toggle(sub_dev_id)
if not res: if not res:
return make_error(404, "sub_dev_id not found") return make_error(404, "sub_dev_id not found")
save_network() models.save_network(network)
return res return res
@app_views.route('/edit') @app_views.route('/edit')
@ -186,6 +61,7 @@ def edit():
""" """
Edits the text of a particular field. Edits the text of a particular field.
""" """
global network
device_id = request.args.get('device_id') device_id = request.args.get('device_id')
sub_dev_id = request.args.get('sub_dev_id') sub_dev_id = request.args.get('sub_dev_id')
field = request.args.get('field') field = request.args.get('field')
@ -221,7 +97,7 @@ def edit():
'field': field, 'field': field,
'value': value 'value': value
} }
save_network() models.save_network(network)
return json.dumps(data) return json.dumps(data)
@ -232,6 +108,7 @@ def new_device():
Allows adding a new device. Accepts device_type parameter, returns Allows adding a new device. Accepts device_type parameter, returns
the device_id. the device_id.
""" """
global network
device_type = request.args.get('device_type') device_type = request.args.get('device_type')
if device_type == 'RelayDevice': if device_type == 'RelayDevice':
@ -251,7 +128,7 @@ def new_device():
num = str(int(num[0]) + 1).zfill(2) num = str(int(num[0]) + 1).zfill(2)
device.id = device_type + num device.id = device_type + num
network.append(device) network.append(device)
save_network() models.save_network(network)
data = {'device_id': device.id} data = {'device_id': device.id}
return json.dumps(data) return json.dumps(data)
@ -262,6 +139,7 @@ def lock_device():
""" """
Locks or unlocks a device to prevent or allow editing it's fields. Locks or unlocks a device to prevent or allow editing it's fields.
""" """
global network
device_id = request.args.get('device_id') device_id = request.args.get('device_id')
locked = request.args.get('locked') == 'true' locked = request.args.get('locked') == 'true'
@ -275,7 +153,7 @@ def lock_device():
device.locked = True device.locked = True
else: else:
device.locked = False device.locked = False
save_network() models.save_network(network)
return jsonify(device_id=device.id, locked=device.locked) return jsonify(device_id=device.id, locked=device.locked)
@ -286,6 +164,7 @@ def delete():
""" """
Deletes a device. Deletes a device.
""" """
global network
device_id = request.args.get('device_id') device_id = request.args.get('device_id')
for device in network: for device in network:
@ -295,7 +174,7 @@ def delete():
return make_error(404, "device_id not found") return make_error(404, "device_id not found")
network.remove(device) network.remove(device)
save_network() models.save_network(network)
return jsonify(True) return jsonify(True)
@ -312,7 +191,7 @@ else:
app.secret_key = secret_key app.secret_key = secret_key
with open('secret_key', 'wb') as file: with open('secret_key', 'wb') as file:
file.write(secret_key) file.write(secret_key)
network = init_network() network = models.init_network()
if __name__ == '__main__': if __name__ == '__main__':

167
models.py Normal file
View File

@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""
Data models for different device types managed by the Juice IOT hub.
"""
import re
import json
import requests
class RelayDevice:
"""
Represents a relay receptacle device controlled by an ESP-01. The
ESP-01 acts a simple switch to turn each receptable on or off via
a relay. Each device only has two outputs, `OUT0` and `OUT2`.
"""
def __init__(self):
self.id = ""
self.type = "RelayDevice"
self.description = ""
self.location = ""
self.ip_address = ""
self.locked = False
self.sub_devices = []
self.sub_devices.append(RelayOutlet())
self.sub_devices.append(RelayOutlet())
self.sub_devices[0].id = 'OUT0'
self.sub_devices[0].gpio = '0'
self.sub_devices[1].id = 'OUT2'
self.sub_devices[1].gpio = '2'
self.update()
def update(self):
"""
Queries the physical device and updates the internal model as
appropriate.
"""
if not self.ip_address:
return
for sub_dev in self.sub_devices:
res = requests.get(self.ip_address)
gpio0 = re.search(r"GPIO0: (\bLow|\bHigh)", res.text).groups()[0]
sub_dev.state = gpio0 == 'High'
def toggle(self, sub_dev_id):
"""
Toggles the state of a sub device.
"""
for sub_dev in self.sub_devices:
if sub_dev.id == sub_dev_id:
break
else:
return None
params = {sub_dev.gpio: 'toggle'}
res = requests.get('http://' + self.ip_address + '/gpio',params=params)
res.raise_for_status()
state = re.search(
rf"GPIO{sub_dev.gpio}: (\bLow|\bHigh)",
res.text
).groups()[0] == 'High'
sub_dev.state = state
return json.dumps({'ok': True, sub_dev_id: state})
def from_dict(self, data):
"""
Initializes self with data from JSON dict.
"""
for key, value in data.items():
setattr(self, key, value)
self.sub_devices = []
for sub_dev_dict in data.get('sub_devices'):
if sub_dev_dict.get('type') == 'RelayOutlet':
sub_dev = RelayOutlet().from_dict(sub_dev_dict)
self.sub_devices.append(sub_dev)
else:
typ = sub_dev.get('type')
raise ValueError(f"Unknown sub_device type {typ}")
return self
class RelayOutlet:
"""
Represents a single outlet on a RelayDevice.
"""
def __init__(self):
self.id = ""
self.type = "RelayOutlet"
self.description = ""
self.gpio = ""
self.state = False
def from_dict(self, data):
"""
Initializes self with data from JSON dict.
"""
for key, value in data.items():
setattr(self, key, value)
return self
class LightStrip:
"""
Represents an RGB LED light strip controller.
"""
def __init__(self):
self.id = ""
self.type = "LightStrip"
self.description = ""
self.location = ""
self.ip_address = ""
self.locked = False
def from_dict(self, data):
"""
Initializes self with data from JSON dict.
"""
for key, value in data.items():
setattr(self, key, value)
self.sub_devices = []
for sub_dev_dict in data.get('sub_devices'):
if sub_dev_dict.get('type') == 'RelayOutlet':
sub_dev = RelayOutlet().from_dict(sub_dev_dict)
self.sub_devices.append(sub_dev)
else:
typ = sub_dev.get('type')
raise ValueError(f"Unknown sub_device type {typ}")
return self
class DeviceEncoder(json.JSONEncoder):
"""
A custom json encoder for dumping devices to file.
"""
def default(self, o):
return vars(o)
def init_network(filepath="devices.json"):
"""
Initializes the network of IOT devices.
"""
with open(filepath, 'r') as file:
data = file.read()
data = json.loads(data)
network = []
for device_dict in data:
if device_dict.get('type') == 'RelayDevice':
device = RelayDevice().from_dict(device_dict)
network.append(device)
elif device_dict.get('type') == 'LightStrip':
device = LightStrip().from_dict(device_dict)
network.append(device)
else:
raise ValueError(f"Unknown device type {device_dict.get('type')}")
return network
def save_network(network, filepath="devices.json"):
"""
Dumps the network to file.
"""
with open(filepath, 'w') as file:
file.write(json.dumps(network, cls=DeviceEncoder, indent='\t'))

Binary file not shown.

View File

@ -0,0 +1,21 @@
{
"name": "Juice",
"short_name": "Juice",
"start_url": "/juice",
"display": "standalone",
"background_color": "lightblue",
"theme_color": "lightblue",
"description": "An IOT hub.",
"icons": [
{
"src": "/static/orange-juice-144p.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/static/orange-juice-512p.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@ -4,6 +4,33 @@ body {
margin: 0; margin: 0;
} }
#logo-line {
display: flex;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
#logo {
height: 5em;
}
h1 {
margin-top: auto;
margin-bottom: 0;
}
h3 {
margin-top: auto;
margin-bottom: 0.1em;
margin-left: 0.5em;
color: whitesmoke;
text-shadow:
1px 1px 0 dimgray,
-1px 1px 0 dimgray,
1px -1px 0 dimgray,
-1px -1px 0 dimgray;
}
.font-awesome { .font-awesome {
font-family: FontAwesome; font-family: FontAwesome;
font-style: normal; font-style: normal;
@ -11,12 +38,13 @@ body {
nav { nav {
background-color: whitesmoke; background-color: whitesmoke;
border-top: 1px solid darkgray;
border-bottom: 1px solid darkgray; border-bottom: 1px solid darkgray;
display: flex; display: flex;
} }
nav span { nav span {
color: dimgrey; color: dimgray;
cursor: pointer; cursor: pointer;
transition: 0.1s; transition: 0.1s;
padding: 0.5em; padding: 0.5em;
@ -77,6 +105,29 @@ nav span:hover {
height: 5em; height: 5em;
} }
.LightStrip .sub_devices {
flex-wrap: wrap;
}
.led {
padding: 0.25em;
margin: 0.125em;
}
.led_color {
border-radius: 0.25em;
width: 1em;
height: 1em;
}
.led_color_input {
opacity: 0;
display: block;
width: 1em;
height: 1em;
border: 0;
}
@font-face { @font-face {
font-family: FontAwesome; font-family: FontAwesome;
src: url("/static/fontawesome-webfont.woff2"); src: url("/static/fontawesome-webfont.woff2");

View File

@ -10,6 +10,12 @@ function load() {
} }
}); });
}); });
document.querySelectorAll('input[type=color]').forEach((led_input) => {
led_input.onchange = function(event) {
event.target.parentElement.style.backgroundColor = event.target.value;
}
});
} }
function get_object_from_svg(svg) { function get_object_from_svg(svg) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

85
static/orange-juice.svg Normal file
View File

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 155.883 155.883" style="enable-background:new 0 0 155.883 155.883;" xml:space="preserve">
<g>
<g>
<g>
<circle style="fill:#FF7500;" cx="98.922" cy="49.126" r="27.492"/>
</g>
<g>
<circle style="fill:#FFD01F;" cx="99.304" cy="48.744" r="22.528"/>
</g>
<g>
<circle style="fill:#FFD01F;" cx="99.304" cy="48.744" r="22.528"/>
</g>
<g>
<g>
<path style="fill:#FFB000;" d="M98.255,47.472V30.184c-9.617,0.434-17.407,7.843-18.452,17.288H98.255z"/>
</g>
<g>
<path style="fill:#FFB000;" d="M118.805,47.217c-1.584-9.02-8.963-16.036-18.167-17.056v17.056H118.805z"/>
</g>
<g>
<path style="fill:#FFB000;" d="M98.255,50.018v17.287c-9.617-0.434-17.407-7.842-18.452-17.287
C79.803,50.018,98.255,50.018,98.255,50.018z"/>
</g>
<g>
<path style="fill:#FFB000;" d="M118.805,50.271c-1.584,9.02-8.963,16.037-18.167,17.056V50.271H118.805z"/>
</g>
</g>
</g>
<g>
<polygon style="fill:#ABD9D5;" points="94.687,155.883 37.939,155.883 29.469,47.883 103.157,47.883 "/>
</g>
<g>
<polygon style="fill:#CDE8E6;" points="64.663,155.883 37.939,155.883 29.469,47.883 64.663,47.883 "/>
</g>
<g>
<polygon style="fill:#DDF0EE;" points="88.758,141.883 43.868,141.883 35.398,47.883 97.228,47.883 "/>
</g>
<g>
<polygon style="fill:#FF7500;" points="37.304,68.606 43.868,141.449 88.758,141.449 95.321,68.606 "/>
</g>
<g>
<polygon style="fill:#FF4F0C;" points="66.736,68.606 37.304,68.606 43.868,141.449 66.736,141.449 "/>
</g>
<path style="fill:#FFFFFF;" d="M84.175,127.346c-0.215,2.329-2.278,4.044-4.606,3.828l0,0c-2.329-0.215-4.043-2.277-3.828-4.606
l4.048-43.857c0.215-2.329,2.278-4.043,4.606-3.828l0,0c2.329,0.215,4.043,2.277,3.828,4.606L84.175,127.346z"/>
<g>
<g>
<polygon style="fill:#F62D8D;" points="37.694,0 42.682,47.433 51.565,47.433 46.576,0 "/>
</g>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -6,15 +6,25 @@
<script type="text/javascript" src="/static/juice.js"></script> <script type="text/javascript" src="/static/juice.js"></script>
<script>var init_state = {{ init_state|tojson|safe }};</script> <script>var init_state = {{ init_state|tojson|safe }};</script>
<script>window.onload = load;</script> <script>window.onload = load;</script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=0.8">
<meta name="description" content="An IOT hub."> <meta name="description" content="An IOT hub.">
<meta name="theme-color" content="lightblue">
<link rel="manifest" href="/static/juice-manifest.webmanifest">
<link rel="icon" href="/static/orange-juice.svg">
<link rel="icon" href="/static/orange-juice-144p.png">
</head> </head>
<body> <body>
<header> <header>
<div id="logo-line">
<object id="logo" aria-label="Juice: An IOT hub" data="/static/orange-juice.svg"></object>
<h1>Juice</h1> <h1>Juice</h1>
<h3>An IOT hub</h3>
</div>
<nav> <nav>
<span title="Add new device" onclick="new_device()"><span class="font-awesome">&#xe802;</span></span> <span class="font-awesome" title="Home"><a href="./">&#xe806;</a></span>
<span title="Manage security keys and tokens"><a href="./manage">Manage</a></span> <span class="font-awesome" title="Manage security keys and tokens"><a href="./manage">&#xe807;</a></span>
<span class="font-awesome" title="Add new device" onclick="new_device()">&#xe802;</span>
<span class="font-awesome" title="Logout"><a href="./logout">&#xe805;</a></span>
</nav> </nav>
</header> </header>
<main> <main>
@ -26,6 +36,7 @@
<div class="location editable"><span class="field_value">{{ device.location }}</span>{% if not device.locked %}<span class="edit font-awesome" onclick="edit_field(this.parentElement)">&#xe800;</span>{% endif %}</div> <div class="location editable"><span class="field_value">{{ device.location }}</span>{% if not device.locked %}<span class="edit font-awesome" onclick="edit_field(this.parentElement)">&#xe800;</span>{% endif %}</div>
<div class="ip_address editable"><span class="field_value">{{ device.ip_address }}</span>{% if not device.locked %}<span class="edit font-awesome" onclick="edit_field(this.parentElement)">&#xe800;</span>{% endif %}</div> <div class="ip_address editable"><span class="field_value">{{ device.ip_address }}</span>{% if not device.locked %}<span class="edit font-awesome" onclick="edit_field(this.parentElement)">&#xe800;</span>{% endif %}</div>
<div class="sub_devices"> <div class="sub_devices">
{% if device.type == 'RelayDevice' %}
{% for sub_dev in device.sub_devices %} {% for sub_dev in device.sub_devices %}
<div class="sub_device {{ sub_dev.type }} {{ sub_dev.id }}"> <div class="sub_device {{ sub_dev.type }} {{ sub_dev.id }}">
<div class="id">{{ sub_dev.id }}</div> <div class="id">{{ sub_dev.id }}</div>
@ -33,6 +44,15 @@
<div class="description editable"><span class="field_value">{{ sub_dev.description }}</span>{% if not device.locked %}<span class="edit font-awesome" onclick="edit_field(this.parentElement)">&#xe800;</span>{% endif %}</div> <div class="description editable"><span class="field_value">{{ sub_dev.description }}</span>{% if not device.locked %}<span class="edit font-awesome" onclick="edit_field(this.parentElement)">&#xe800;</span>{% endif %}</div>
</div> </div>
{% endfor %} {% endfor %}
{% elif device.type == 'LightStrip' %}
{% for n in range(48) %}
<div class="sub_device led led{{n}}">
<label class="led_color">
<input class="led_color_input" type="color">
</label>
</div>
{% endfor %}
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@ -8,6 +8,7 @@
<script>url_prefix = '{{ url_prefix }}';</script> <script>url_prefix = '{{ url_prefix }}';</script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="An IOT hub."> <meta name="description" content="An IOT hub.">
<link rel="icon" href="/static/orange-juice.svg">
</head> </head>
<body> <body>
<main> <main>

View File

@ -7,6 +7,7 @@
<script>url_prefix = '{{ url_prefix }}';</script> <script>url_prefix = '{{ url_prefix }}';</script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="An IOT hub."> <meta name="description" content="An IOT hub.">
<link rel="icon" href="/static/orange-juice.svg">
</head> </head>
<body> <body>
<main> <main>

View File

@ -5,6 +5,7 @@
<link rel="stylesheet" type="text/css" href="/static/juice.css"> <link rel="stylesheet" type="text/css" href="/static/juice.css">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="An IOT hub."> <meta name="description" content="An IOT hub.">
<link rel="icon" href="/static/orange-juice.svg">
</head> </head>
<body> <body>
<main> <main>

View File

@ -8,6 +8,7 @@
<script>url_prefix = '{{ url_prefix }}';</script> <script>url_prefix = '{{ url_prefix }}';</script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="An IOT hub."> <meta name="description" content="An IOT hub.">
<link rel="icon" href="/static/orange-juice.svg">
</head> </head>
<body> <body>
<main> <main>