From 58f1bd2979ff95f420a5b6e4b67aed084ce32528 Mon Sep 17 00:00:00 2001 From: iou1name Date: Fri, 15 Nov 2019 10:35:56 -0500 Subject: [PATCH] begin converting AJAX to Websocket --- events.py | 143 ++++++++++++++++++++++++++++++++++++++ juice.py | 162 ++++++++----------------------------------- models.py | 59 ++++++++++++---- static/juice.js | 117 +++++++++++++++++++------------ templates/index.html | 7 +- 5 files changed, 297 insertions(+), 191 deletions(-) create mode 100644 events.py diff --git a/events.py b/events.py new file mode 100644 index 0000000..a221529 --- /dev/null +++ b/events.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Websocket events.""" +import types + +import models +from models import network + + +async def toggle_outlet(ws, data): + """Toggles the state of a RelayDevice.""" + device_id = data.get('device_id') + sub_device_id = data.get('sub_device_id') + + device = network.find(device_id) + if not device: + await ws.send_json({'ok': False, 'message': "device_id not found"}) + return + state = device.toggle(sub_device_id) + if state is None: + await ws.send_json({'ok': False, 'message': "sub_device_id not found"}) + return + models.save_network(network) + + data = {} + data['ok'] = True + data['device_id'] = device_id + data['sub_device_id'] = sub_device_id + data['state'] = state + res = {'event': 'toggle_outlet', 'data': data} + await ws.send_json(res) + + +async def edit(ws, data): + """Edits the text of a particular field.""" + device_id = data.get('device_id') + sub_device_id = data.get('sub_device_id') + field = data.get('field') + value = data.get('value') + + device = network.find(device_id) + if not device: + await ws.send_json({'ok': False, 'message': "device_id not found"}) + return + + if device.locked: + await ws.send_json({'ok': False, 'message': "device is locked for editing"}) + return + + if sub_device_id: + sub_device = device.find(sub_device_id) + if not sub_device: + await ws.send_json({'ok': False, 'message': "sub_device_id not found"}) + return + + if hasattr(sub_device, field): + setattr(sub_device, field, value) + else: + await ws.send_json({'ok': False, 'message': "sub_device field not found"}) + return + else: + if hasattr(device, field): + setattr(device, field, value) + else: + await ws.send_json({'ok': False, 'message': "device field not found"}) + return + + res = { + 'ok': True, + 'field': field, + 'value': value + } + models.save_network(network) + await ws.send_json(res) + + +async def new_device(ws, data): + """ + Allows adding a new device. Accepts device_type parameter, returns + the device_id. + """ + device_type = data.get('device_type') + + if device_type == 'RelayDevice': + device = models.RelayDevice() + else: + await ws.send_json({'ok': False, 'message': "unknown device type"}) + return + + devices = [dev for dev in network if dev.type == device_type] + devices.sort(key=lambda dev: dev.id) + if not devices: + device.id = device_type + '01' + else: + num = re.search(r'(\d*)$', devices[-1].id).groups() + if not num: + device.id = device_type + '01' + else: + num = str(int(num[0]) + 1).zfill(2) + device.id = device_type + num + network.append(device) + models.save_network(network) + res = {'ok': True, 'device_id': device.id} + await ws.send_json(res) + + +async def lock_device(ws, data): + """Locks or unlocks a device to prevent or allow editing it's fields.""" + device_id = data.get('device_id') + locked = data.get('locked') == 'true' + + device = network.find(device_id) + if not device: + await ws.send_json({'ok': False, 'message': "device_id not found"}) + return + + if locked: + device.locked = True + else: + device.locked = False + models.save_network(network) + + await ws.send_json({'ok': True, 'device_id': device.id, 'locked': device.locked}) + + +async def delete(ws, data): + """Deletes a device.""" + device_id = data.get('device_id') + + device = network.find(device_id) + if not device: + await ws.send_json({'ok': False, 'message': "device_id not found"}) + return + + network.remove(device) + models.save_network(network) + + await ws.send_json({'ok': True}) + + +events = {} +for obj in dir(): + if type(locals()[obj]) == types.FunctionType: + events[locals()[obj].__name__] = locals()[obj] diff --git a/juice.py b/juice.py index f12ccf6..eb8f15a 100644 --- a/juice.py +++ b/juice.py @@ -7,177 +7,73 @@ import re import copy import json -from aiohttp import web +from aiohttp import web, WSMsgType import jinja2 import aiohttp_jinja2 from aiohttp_jinja2 import render_template -#import uvloop +import uvloop import requests import config +import events import models import buckler_aiohttp from tools import make_error +from models import network -#uvloop.install() -network = models.init_network() +uvloop.install() routes = web.RouteTableDef() @routes.get('/', name='index') async def index(request): """The index page.""" - global network init_state = {} for device in network: device_state = {} for sub_dev in device.sub_devices: device_state[sub_dev.id] = sub_dev.state init_state[device.id] = device_state - d = {'network': network, 'init_state': init_state} + d = {'network': network, 'init_state': init_state, 'request': request} return render_template('index.html', request, d) -@routes.get('/toggle', name='toggle') -async def toggle(request): - """Toggles the state of a RelayDevice.""" - global network - device_id = request.query.get('device_id') - sub_dev_id = request.query.get('sub_dev_id') +@routes.get('/ws', name='ws') +async def websocket_handler(request): + """The websocket endpoint.""" + ws = web.WebSocketResponse(heartbeat=30) + ws_ready = ws.can_prepare(request) + if not ws_ready.ok: + return web.Response(text="Cannot start websocket.") + await ws.prepare(request) - for device in network: - if device.id == device_id: + async for msg in ws: + if msg.type != WSMsgType.TEXT: break - else: - return make_error(404, "device_id not found") - res = device.toggle(sub_dev_id) - if not res: - return make_error(404, "sub_dev_id not found") - models.save_network(network) - return web.json_response(res) - -@routes.get('/edit', name='edit') -async def edit(request): - """Edits the text of a particular field.""" - global network - device_id = request.query.get('device_id') - sub_dev_id = request.query.get('sub_dev_id') - field = request.query.get('field') - value = request.query.get('value') - - for device in network: - if device.id == device_id: + try: + data = json.loads(msg.data) + except json.JSONDecodeError: break - else: - return make_error(404, "device_id not found") - if device.locked: - return make_error(400, "device is locked for editing") - - if sub_dev_id: - for sub_dev in device.sub_devices: - if sub_dev.id == sub_dev_id: - break - else: - return make_error(404, "sub_dev_id not found") - if hasattr(sub_dev, field): - setattr(sub_dev, field, value) - else: - return make_error(404, "sub_device field not found") - else: - if hasattr(device, field): - setattr(device, field, value) - else: - return make_error(404, "device field not found") - - data = { - 'ok': True, - 'field': field, - 'value': value - } - models.save_network(network) - return web.json_response(data) - - -@routes.get('/new_device', name='new_device') -async def new_device(request): - """ - Allows adding a new device. Accepts device_type parameter, returns - the device_id. - """ - global network - device_type = request.query.get('device_type') - - if device_type == 'RelayDevice': - device = models.RelayDevice() - else: - return make_error(400, "Unknown device type") - - devices = [dev for dev in network if dev.type == device_type] - devices.sort(key=lambda dev: dev.type) - if not devices: - device.id = device_type + '01' - else: - num = re.search(r'(\d*)$', devices[-1].id).groups() - if not num: - device.id = device_type + '01' - else: - num = str(int(num[0]) + 1).zfill(2) - device.id = device_type + num - network.append(device) - models.save_network(network) - data = {'device_id': device.id} - return web.json_response(data) - - -@routes.get('/lock_device', name='lock_device') -async def lock_device(request): - """Locks or unlocks a device to prevent or allow editing it's fields.""" - global network - device_id = request.query.get('device_id') - locked = request.query.get('locked') == 'true' - - for device in network: - if device.id == device_id: + event = data.get('event') + if not event or event not in events.events.keys(): break - else: - return make_error(404, "device_id not found") - - if locked: - device.locked = True - else: - device.locked = False - models.save_network(network) - - return web.json_response({'device_id': device.id, 'locked': device.locked}) - - -@routes.get('/delete', name='delete') -async def delete(request): - """Deletes a device.""" - global network - device_id = request.query.get('device_id') - - for device in network: - if device.id == device_id: - break - else: - return make_error(404, "device_id not found") - - network.remove(device) - models.save_network(network) - - return web.json_response(True) + await events.events[event](ws, data.get('data')) + await ws.close() + return ws async def init_app(): """Initializes the application.""" app = web.Application(middlewares=[buckler_aiohttp.buckler_session]) aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader('templates')) - #app.on_startup.append(start_background_tasks) - #app.on_cleanup.append(cleanup_background_tasks) app.router.add_routes(routes) app_wrap = web.Application() app_wrap.add_subapp(config.url_prefix, app) return app_wrap + + +if __name__ == "__main__": + app = init_app() + aiohttp.web.run_app(app, host='0.0.0.0', port=5300) diff --git a/models.py b/models.py index 6384ff2..dd1801b 100644 --- a/models.py +++ b/models.py @@ -7,7 +7,41 @@ import json import requests -class RelayDevice: +class Network(list): + """ + Represents a network of IOT devices. + """ + def find(self, device_id): + """ + Searches through the network and returns the matching device, + or None. + """ + for device in self: + if device.id == device_id: + break + else: + return None + return device + + +class Device: + """ + Represents a generic device on the Network. + """ + def find(self, sub_device_id): + """ + Searches through the devices sub_devices and returns the matching + sub_device, or None. + """ + for sub_device in self.sub_devices: + if sub_device.id == sub_device_id: + break + else: + return None + return sub_device + + +class RelayDevice(Device): """ 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 @@ -43,27 +77,25 @@ class RelayDevice: gpio0 = re.search(r"GPIO0: (\bLow|\bHigh)", res.text).groups()[0] sub_dev.state = gpio0 == 'High' - def toggle(self, sub_dev_id): + def toggle(self, sub_device_id): """ Toggles the state of a sub device. """ - for sub_dev in self.sub_devices: - if sub_dev.id == sub_dev_id: - break - else: + sub_device = self.find(sub_device_id) + if not sub_device: return None - params = {sub_dev.gpio: 'toggle'} + params = {sub_device.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)", + rf"GPIO{sub_device.gpio}: (\bLow|\bHigh)", res.text ).groups()[0] == 'High' - sub_dev.state = state + sub_device.state = state - return {'ok': True, sub_dev_id: state} + return state def from_dict(self, data): """ @@ -102,7 +134,7 @@ class RelayOutlet: return self -class LightStrip: +class LightStrip(Device): """ Represents an RGB LED light strip controller. """ @@ -146,7 +178,7 @@ def init_network(filepath="devices.json"): with open(filepath, 'r') as file: data = file.read() data = json.loads(data) - network = [] + network = Network() for device_dict in data: if device_dict.get('type') == 'RelayDevice': device = RelayDevice().from_dict(device_dict) @@ -165,3 +197,6 @@ def save_network(network, filepath="devices.json"): """ with open(filepath, 'w') as file: file.write(json.dumps(network, cls=DeviceEncoder, indent='\t')) + + +network = init_network() diff --git a/static/juice.js b/static/juice.js index e1de1e7..ef37310 100644 --- a/static/juice.js +++ b/static/juice.js @@ -1,4 +1,8 @@ +var socket; + function load() { + socket = init_websocket(); + Object.entries(init_state).forEach(([device_id, sub_devs]) => { let device = document.querySelector('#' + device_id); Object.entries(sub_devs).forEach(([sub_dev_id, state]) => { @@ -18,41 +22,74 @@ function load() { }); } -function get_object_from_svg(svg) { - var all_objects = document.getElementsByTagName("object"); - for (var i=0; i < all_objects.length; i++) { - if (svg === all_objects[i].getSVGDocument().firstElementChild) { - return all_objects[i]; +/* Websocket setup */ +function init_websocket() { + let socket = new Websocket('wss://' + window.location.hostname + ws_uri); + socket._send = socket.send; + socket.send = function(event_title, data) { + data = JSON.stringify({event: event_title, data: data}); + if (socket.readyState == 0) { + console.log("Socket is still opening!"); } + socket._send(data); } - return null; + socket.onmessage = onmessage; + socket.onclose = onclose; + socket.onerror = onerror; + socket.events = {}; + socket.events['toggle_outlet'] = toggle_outlet_recv; + return socket; } +function onmessage (e) { + let data = JSON.parse(e.data); + let event = data.event; + data = data.data; + if (socket.events[event] === undefined) { return; } + socket.events[event](data); +} + +function onclose(e) { + if (e.wasClean) { return; } // no need to reconnect + console.log(e); + console.log('Websocket lost connection to server. Re-trying...'); + socket = init_websocket(); +} + +function onerror(e) { + console.log("Websocket error!") + console.log(e); +} + +/* Websocket receive */ +function toggle_outlet_recv(data) { + if (!data.ok) { + throw new Error('Socket Event Error: toggle_outlet, message = ' + data.message); + return; + } + let device = document.querySelector('#' + data.device_id); + let sub_device = document.querySelector('.' + data.sub_device_id); + let svg = sub_device.querySelector('.outlet_image').getSVGDocument().firstChild; + if (data.state === true) { + svg.classList.remove('off'); + svg.classList.add('on'); + } else { + svg.classList.remove('on'); + svg.classList.add('off'); + } +} + +/* Websocket send */ function toggle_outlet(svg) { let sub_dev = get_object_from_svg(svg).parentElement; - let params = { + let data = { device_id: sub_dev.parentElement.parentElement.id, - sub_dev_id: sub_dev.querySelector('.id').innerText, + sub_device_id: sub_dev.querySelector('.id').innerText, }; - let query = Object.keys(params) - .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) - .join('&'); - fetch(window.location.href + 'toggle?' + query) - .then(function(response) { - return response.json(); - }) - .then(function(json) { - if (!json.ok) { throw new Error('HTTP error, status = ' + json.status + ', message = ' + json.message); } - if (json[sub_dev.querySelector('.id').innerText]) { - svg.classList.remove('off'); - svg.classList.add('on'); - } else { - svg.classList.remove('on'); - svg.classList.add('off'); - } - }); + socket.send('toggle_outlet', data); } +/* AJAX */ function edit_field(field) { let value = field.firstElementChild.innerText; let input = document.createElement('input'); @@ -217,25 +254,6 @@ function delete_device(device) { }); } -function delete_key(key_id) { - if (!window.confirm("Are you sure you want to delete this key? This action may leave you unable to access your account.")) { return; } - let params = { - key_id: key_id, - }; - let query = Object.keys(params) - .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) - .join('&'); - - fetch(url_prefix + '/delete_key?' + query) - .then(function(response) { - return response.json(); - }) - .then(function(json) { - if (!json.ok) { return; } - document.querySelector('#key_' + key_id).remove(); - }); -} - function delete_token(token_id) { if (!window.confirm("Are you sure you want to delete this token?")) { return; } let params = { @@ -254,3 +272,14 @@ function delete_token(token_id) { document.querySelector('#token_' + token_id).remove(); }); } + +/* Misc */ +function get_object_from_svg(svg) { + let all_objects = document.getElementsByTagName("object"); + for (let i=0; i < all_objects.length; i++) { + if (svg === all_objects[i].getSVGDocument().firstElementChild) { + return all_objects[i]; + } + } + return null; +} diff --git a/templates/index.html b/templates/index.html index 2be219e..2d058e6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,8 +3,11 @@ Juice + - @@ -43,7 +46,7 @@ {% endfor %} {% elif device.type == 'LightStrip' %} - {% for n in range(48) %} + {% for n in range(24) %}