diff --git a/README.md b/README.md index 8ae640c..7a57c24 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A hub for controlling IOT devices. ## Requirements Python 3.6+ -Python packages: `gunicorn aiohttp aiohttp_jinja2 uvloop requests` +Python packages: `gunicorn aiohttp aiohttp_jinja2 uvloop requests paho-mqtt` ## Install 1. Get on the floor diff --git a/events.py b/events.py index 7c175ef..2f5cdcd 100644 --- a/events.py +++ b/events.py @@ -3,6 +3,7 @@ import re import types +import tools import models from models import network @@ -183,6 +184,33 @@ async def delete_device(request, ws, data): await request.app.send_json_all(res) +async def neopixel(request, ws, data): + """Changes the state of a NeoPixel strip.""" + device_id = data.get('device_id') + + device = network.find(device_id) + if not device: + data['ok'] = False + data['error'] = "device_id not found" + await ws.send_json(data) + return + + # TODO: form validation + + mqtt_msg = [device.mqtt_root] + mqtt_msg.append(data.get('change_mode')) + mqtt_msg.append(data.get('type')) + if data.get('type') == 'solid': + mqtt_msg.append(data.get('amount')) + if data.get('amount') == 'single': + mqtt_msg.append(data.get('sub_device_id').replace('led', '')) + mqtt_msg = '/'.join(mqtt_msg) + payload = tools.from_html_color(data.get('color')) + payload = str(payload)[1:-1].replace(' ', '') + request.app['mqtt'].publish(mqtt_msg, payload) + # websocket response is handled under LightStrip.mqtt_callback + + events = {} for obj in dir(): if type(locals()[obj]) == types.FunctionType: diff --git a/juice.py b/juice.py index 85c6cbd..7f1000c 100644 --- a/juice.py +++ b/juice.py @@ -14,11 +14,12 @@ from aiohttp_jinja2 import render_template import uvloop import requests +import mqtt +import tools import config import events import models import buckler_aiohttp -from tools import make_error from models import network uvloop.install() @@ -66,20 +67,20 @@ async def websocket_handler(request): return ws -def html_color(color): - """Converts a decimal color code to HTML format.""" - html_color = "#" - for i in range(3): - html_color += hex(color[i])[2:].zfill(2) - return html_color - - async def send_json_all(self, d): """Sends to all connected websockets.""" for ws in self['websockets']: await ws.send_json(d) +async def start_background_tasks(app): + app['mqtt'] = mqtt.init_client(app) + + +async def cleanup_background_tasks(app): + app['mqtt'].loop_stop() + + async def init_app(): """Initializes the application.""" web.Application.send_json_all = send_json_all @@ -90,8 +91,10 @@ async def init_app(): lstrip_blocks=True, undefined=jinja2.StrictUndefined, loader=jinja2.FileSystemLoader('templates'), - filters={'html_color': html_color}, + filters={'html_color': tools.to_html_color}, ) + app.on_startup.append(start_background_tasks) + app.on_cleanup.append(cleanup_background_tasks) app['websockets'] = [] app.router.add_routes(routes) diff --git a/models.py b/models.py index 71bb535..c7fe00f 100644 --- a/models.py +++ b/models.py @@ -5,6 +5,8 @@ Data models for different device types managed by the Juice IOT hub. import re import json +import tools + import requests class Network(list): @@ -34,6 +36,7 @@ class Device: self.description = "" self.location = "" self.ip_address = "" + self.mqtt_root = "" self.locked = False self.sub_devices = [] @@ -70,6 +73,13 @@ class Device: raise ValueError(f"Unknown sub_device type {typ}") return self + async def mqtt_callback(self, msg): + """ + Called when the MQTT client receives a message directed to this + device. Should be overridden by the device implementation. + """ + pass + class SubDevice: """ @@ -147,6 +157,28 @@ class LightStrip(Device): super().__init__() self.type = "LightStrip" + async def mqtt_callback(self, app, msg): + topic = msg.topic.split('/') + payload = msg.payload.decode('utf8').split(',') + payload = [int(num) for num in payload] + + sub_device = self.find('led' + topic[4]) + sub_device.color = payload + save_network(network) + + data = {} + data['ok'] = True + data['device_id'] = self.id + data['change_mode'] = topic[1] + data['type'] = topic[2] + if topic[2] == 'solid': + data['amount'] = topic[3] + if topic[3] == 'single': + data['sub_device_id'] = 'led' + topic[4] + data['color'] = tools.to_html_color(payload) + res = {'event': 'neopixel', 'data': data} + await app.send_json_all(res) + class NeoPixel(SubDevice): """ diff --git a/mqtt.py b/mqtt.py new file mode 100644 index 0000000..a96453f --- /dev/null +++ b/mqtt.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +""" +Contains MQTT funtionality. +""" +import asyncio + +import paho.mqtt.client as mqtt + +from models import network + +def on_connect(client, userdata, flags, rc): + """Called when the client successfully connects to the broker.""" + client.subscribe("juice/#") + for device in network: + if device.mqtt_root: + client.subscribe(device.mqtt_root + '/#') + + +def on_message(client, userdata, msg): + """Called when the client receives a message.""" + root = msg.topic.partition('/')[0] + devices = [device for device in network if root == device.mqtt_root] + for device in devices: + coro = device.mqtt_callback(client.app, msg) + future = asyncio.run_coroutine_threadsafe(coro, client.app.loop) + future.result() + + +def init_client(app): + """Initializes the MQTT client.""" + client = mqtt.Client() + client.app = app + client.on_connect = on_connect + client.on_message = on_message + + client.connect_async("localhost", 1883, 60) + client.loop_start() + return client diff --git a/static/juice.css b/static/juice.css index 87a1aa1..ad5355b 100644 --- a/static/juice.css +++ b/static/juice.css @@ -79,7 +79,7 @@ nav span:hover { background-color: whitesmoke; } -.ip_address { +.ip_address, .mqtt_root { font-size: 0.8em; font-style: italic; color: dimgray; diff --git a/static/juice.js b/static/juice.js index 0581505..2c2811d 100644 --- a/static/juice.js +++ b/static/juice.js @@ -16,11 +16,10 @@ function load() { }); }); - document.querySelectorAll('input[type=color]').forEach((led_input) => { + /*document.querySelectorAll('input[type=color]').forEach((led_input) => { led_input.onchange = function(event) { - event.target.parentElement.style.backgroundColor = event.target.value; } - }); + });*/ } /* Websocket setup */ @@ -44,6 +43,7 @@ function init_websocket() { socket.events['new_device'] = new_device_recv; socket.events['lock_device'] = lock_device_recv; socket.events['delete_device'] = delete_device_recv; + socket.events['neopixel'] = neopixel_recv; return socket; } @@ -52,7 +52,7 @@ function onmessage (e) { let event = data.event; data = data.data; if (!data.ok) { - throw new Error("Socket error: event = " + event +", error = " + data.error); + throw new Error("Socket error: event = " + event + ", error = " + data.error); } if (socket.events[event] === undefined) { console.log("Unknown socket event: " + event); @@ -164,6 +164,12 @@ function delete_device_recv(data) { device.remove() } +function neopixel_recv(data) { + let device = document.querySelector('#' + data.device_id); + let sub_device = device.querySelector('.' + data.sub_device_id); + sub_device.firstElementChild.style.backgroundColor = data.color; +} + /* Websocket send */ function toggle_outlet(svg) { let sub_dev = get_object_from_svg(svg).parentElement; @@ -225,6 +231,18 @@ function delete_device(device) { socket.send('delete_device', data); } +function neopixel(device) { + let data = { + device_id: device.parentElement.parentElement.id, + sub_device_id: device.classList[2], + change_mode: 'state', + type: 'solid', + amount: 'single', + color: device.firstElementChild.firstElementChild.value + } + socket.send('neopixel', data); +} + /* DOM */ function edit_field(field) { let value = field.firstElementChild.innerText; diff --git a/templates/index.html b/templates/index.html index e4273aa..97312de 100644 --- a/templates/index.html +++ b/templates/index.html @@ -35,7 +35,11 @@