From 68fc7192394d4b2196a83e0e9d1f6002e31e1d1d Mon Sep 17 00:00:00 2001 From: iou1name Date: Mon, 18 Nov 2019 13:05:37 -0500 Subject: [PATCH] lightstrip controls single leds --- README.md | 2 +- events.py | 28 ++++++++++++++++++++++++++++ juice.py | 23 +++++++++++++---------- models.py | 32 ++++++++++++++++++++++++++++++++ mqtt.py | 38 ++++++++++++++++++++++++++++++++++++++ static/juice.css | 2 +- static/juice.js | 26 ++++++++++++++++++++++---- templates/index.html | 6 +++++- tools.py | 23 ++++++++++++++--------- 9 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 mqtt.py 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 @@
{{ device.id }}{% if not device.locked %}{% else %}{% endif %}
{{ device.description }}{% if not device.locked %}{% endif %}
{{ device.location }}{% if not device.locked %}{% endif %}
+ {% if device.type == 'RelayDevice' %}
{{ device.ip_address }}{% if not device.locked %}{% endif %}
+ {% elif device.type == 'LightStrip' %} +
{{ device.ip_address }}{% if not device.locked %}{% endif %}
+ {% endif %}
{% for sub_device in device.sub_devices %}
@@ -45,7 +49,7 @@
{{ sub_device.description }}{% if not device.locked %}{% endif %}
{% elif device.type == 'LightStrip' %} {% endif %}
diff --git a/tools.py b/tools.py index 3bfea86..e42a507 100644 --- a/tools.py +++ b/tools.py @@ -2,14 +2,19 @@ """ Miscellaneous tools and helper functions. """ -from aiohttp import web +def to_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 -def make_error(code, message): - """ - Returns a JSON error. - """ - d = {'ok': False, 'status': code, 'message': message} - res = web.json_response(d) - res.set_status(code) - return res +def from_html_color(html_color): + """Converts an HTML color code to decimal list format.""" + html_color = html_color[1:] + color = [] + color.append(int(html_color[0:2], base=16)) + color.append(int(html_color[2:4], base=16)) + color.append(int(html_color[4:6], base=16)) + return color