From 6c976ba4ef79a0bf4d58885f7ab6e8e01732bd96 Mon Sep 17 00:00:00 2001 From: Einsten42 Date: Sun, 25 Jun 2017 13:15:10 -0500 Subject: [PATCH] UDI Polyglot v2 Interface Module --- .gitignore | 70 ++++++ LICENSE | 21 ++ README.md | 1 + polyinterface/polyinterface.py | 442 +++++++++++++++++++++++++++++++++ setup.cfg | 2 + setup.py | 33 +++ 6 files changed, 569 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 polyinterface/polyinterface.py create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..084c71f --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Project Specific +logs/ +polyglot.py + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +test.py +login.py + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +#Ipython Notebook +.ipynb_checkpoints +.idea/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c78763f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 James Milne + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3700e51 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# UDI Polyglot v2 Interface Module diff --git a/polyinterface/polyinterface.py b/polyinterface/polyinterface.py new file mode 100644 index 0000000..222d551 --- /dev/null +++ b/polyinterface/polyinterface.py @@ -0,0 +1,442 @@ +#!/bin/python3 +""" +Python Interface for UDI Polyglot v2 NodeServers +by Einstein.42 (James Milne) milne.james@gmail.com +""" + +import logging +import logging.handlers +import warnings +import time +import json +import sys +import os +import queue +import asyncio +from os.path import join, expanduser +from dotenv import load_dotenv +import paho.mqtt.client as mqtt +from threading import Thread, Timer +from copy import deepcopy + +VERSION = '0.1' + +def warning_on_one_line(message, category, filename, lineno, file=None, line=None): + return '{}:{}: {}: {}'.format(filename, lineno, category.__name__, message) + +def setup_log(): + # Log Location + path = os.path.dirname(sys.argv[0]) + if not os.path.exists(path + '/logs'): + os.makedirs(path + '/logs') + log_filename = path + "/logs/lifx.log" + log_level = logging.DEBUG # Could be e.g. "DEBUG" or "WARNING" + + #### Logging Section ################################################################################ + logging.captureWarnings(True) + logger = logging.getLogger(__name__) + warnlog = logging.getLogger('py.warnings') + warnings.formatwarning = warning_on_one_line + logger.setLevel(log_level) + # Set the log level to LOG_LEVEL + # Make a handler that writes to a file, + # making a new file at midnight and keeping 3 backups + handler = logging.handlers.TimedRotatingFileHandler(log_filename, when="midnight", backupCount=30) + # Format each log message like this + formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s') + # Attach the formatter to the handler + handler.setFormatter(formatter) + # Attach the handler to the logger + logger.addHandler(handler) + warnlog.addHandler(handler) + return logger + +LOGGER = setup_log() + +LOGGER.info('Polyglot v2 Interface Starting...') +""" +Grab the ~/.polyglot/.env file for variables +If you are running Polyglot v2 on this same machine +then it should already exist. If not create it. +""" +warnings.simplefilter('error', UserWarning) +try: + load_dotenv(join(expanduser("~") + '/.polyglot/.env')) +except (UserWarning) as e: + LOGGER.warning('File does not exist: {} Exiting.'.format(join(expanduser("~") + '/.polyglot/.env'))) + sys.exit(1) +warnings.resetwarnings() + +class Interface: + """ + Polyglot Interface Class + """ + # pylint: disable=too-many-instance-attributes + # pylint: disable=unused-argument + + __exists = False + + def __init__(self): + if self.__exists: + warnings.warn('Only one Interface is allowed.') + return + self.connected = False + self.profileNum = str(os.environ.get("LIFX_NS")) + self.topicPolyglotConnection = 'udi/polyglot/connections/polyglot' + self.topicInput = 'udi/polyglot/ns/{}'.format(self.profileNum) + self.topicSelfConnection = 'udi/polyglot/connections/{}'.format(self.profileNum) + self._mqttc = mqtt.Client('lifx', True) + self._mqttc.will_set(self.topicSelfConnection, json.dumps({'node': self.profileNum, 'connected': False}), retain=True) + self._mqttc.on_connect = self._connect + self._mqttc.on_message = self._message + self._mqttc.on_subscribe = self._subscribe + self._mqttc.on_disconnect = self._disconnect + self._mqttc.on_publish = self._publish + self._mqttc.on_log = self._log + self.config = None + self.loop = asyncio.new_event_loop() + self.inQueue = queue.Queue() + self.thread = Thread(target=self.start_loop) + self._longPoll = None + self._shortPoll = None + self._server = os.environ.get("MQTT_HOST") + self._port = str(os.environ.get("MQTT_PORT")) + self.polyglotConnected = False + self.__configObservers = [] + Interface.__exists = True + + def bind_toConfig(self, callback): + self.__configObservers.append(callback) + + def _connect(self, mqttc, userdata, flags, rc): + """ + The callback for when the client receives a CONNACK response from the server. + Subscribing in on_connect() means that if we lose the connection and + reconnect then subscriptions will be renewed. + + :param mqttc: The client instance for this callback + :param userdata: The private userdata for the mqtt client. Not used in Polyglot + :param flags: The flags set on the connection. + :param rc: Result code of connection, 0 = Success, anything else is a failure + """ + if rc == 0: + self.connected = True + results = [] + LOGGER.info("MQTT Connected with result code " + str(rc) + " (Success)") + # result, mid = self._mqttc.subscribe(self.topicInput) + results.append((self.topicInput, tuple(self._mqttc.subscribe(self.topicInput)))) + results.append((self.topicPolyglotConnection, tuple(self._mqttc.subscribe(self.topicPolyglotConnection)))) + for (topic, (result, mid)) in results: + if result == 0: + LOGGER.info("MQTT Subscribing to topic: " + topic + " - " + " MID: " + str(mid) + " Result: " + str(result)) + else: + LOGGER.info("MQTT Subscription to " + topic + " failed. This is unusual. MID: " + str(mid) + " Result: " + str(result)) + # If subscription fails, try to reconnect. + self._mqttc.reconnect() + else: + LOGGER.error("MQTT Failed to connect. Result code: " + str(rc)) + + def _message(self, mqttc, userdata, msg): + """ + The callback for when a PUBLISH message is received from the server. + + :param mqttc: The client instance for this callback + :param userdata: The private userdata for the mqtt client. Not used in Polyglot + :param flags: The flags set on the connection. + :param msg: Dictionary of MQTT received message. Uses: msg.topic, msg.qos, msg.payload + """ + try: + #LOGGER.debug(msg.payload.decode('utf-8')) + parsed_msg = json.loads(msg.payload.decode('utf-8')) + if parsed_msg['node'] == self.profileNum: return + del parsed_msg['node'] + for key in parsed_msg: + #LOGGER.debug('MQTT Received Message: {}: {}'.format(msg.topic, parsed_msg)) + if key == 'config': + self.inConfig(parsed_msg[key]) + elif key == 'status' or key == 'query' or key == 'command' or key == 'result': + self.input(parsed_msg) + elif key == 'connected': + self.polyglotConnected = parsed_msg[key] + else: + LOGGER.error('Invalid command received in message from Polyglot: {}'.format(key)) + + except (ValueError, json.decoder.JSONDecodeError) as err: + LOGGER.error('MQTT Received Payload Error: {}'.format(err)) + + def _disconnect(self, mqttc, userdata, rc): + """ + The callback for when a DISCONNECT occurs. + + :param mqttc: The client instance for this callback + :param userdata: The private userdata for the mqtt client. Not used in Polyglot + :param rc: Result code of connection, 0 = Graceful, anything else is unclean + """ + self.connected = False + if rc != 0: + LOGGER.info("MQTT Unexpected disconnection. Trying reconnect.") + try: + self._mqttc.reconnect() + except Exception as ex: + template = "An exception of type {0} occured. Arguments:\n{1!r}" + message = template.format(type(ex).__name__, ex.args) + LOGGER.error("MQTT Connection error: " + message) + else: + LOGGER.info("MQTT Graceful disconnection.") + + def _log(self, mqttc, userdata, level, string): + """ Use for debugging MQTT Packets, disable for normal use, NOISY. """ + # LOGGER.info('MQTT Log - {}: {}'.format(str(level), str(string))) + pass + + def _subscribe(self, mqttc, userdata, mid, granted_qos): + """ Callback for Subscribe message. Unused currently. """ + # LOGGER.info("MQTT Subscribed Succesfully for Message ID: {} - QoS: {}".format(str(mid), str(granted_qos))) + pass + + def _publish(self, mqttc, userdata, mid): + """ Callback for publish message. Unused currently. """ + #LOGGER.info("MQTT Published message ID: {}".format(str(mid))) + pass + + def start(self): + self.thread.daemon = True + self.thread.start() + + def start_loop(self): + self.loop.create_task(self._start()) + self.loop.run_forever() + + async def _start(self): + """ + The client start method. Starts the thread for the MQTT Client + and publishes the connected message. + """ + LOGGER.info('Connecting to MQTT... {}:{}'.format(self._server, self._port)) + try: + self._mqttc.connect_async(str(self._server), int(self._port), 10) + self._mqttc.loop_start() + time.sleep(.5) + self._mqttc.publish(self.topicSelfConnection, json.dumps({'node': self.profileNum, 'connected': True}), retain = True) + LOGGER.info('Sent Connected message to Polyglot') + except Exception as ex: + template = "An exception of type {0} occurred. Arguments:\n{1!r}" + message = template.format(type(ex).__name__, ex.args) + LOGGER.error("MQTT Connection error: {}".format(message)) + + def stop(self): + """ + The client stop method. If the client is currently connected + stop the thread and disconnect. Publish the disconnected + message if clean shutdown. + """ + #self.loop.call_soon_threadsafe(self.loop.stop) + self.loop.stop() + self._longPoll.cancel() + self._shortPoll.cancel() + if self.connected: + LOGGER.info('Disconnecting from MQTT... {}:{}'.format(self._server, self._port)) + self._mqttc.publish(self.topicSelfConnection, json.dumps({'node': self.profileNum, 'connected': False}), retain = True) + self._mqttc.loop_stop() + self._mqttc.disconnect() + + def send(self, message): + """ + Formatted Message to send to Polyglot. Connection messages are sent automatically from this module + so this method is used to send commands to/from Polyglot and formats it for consumption + """ + if not isinstance(message, dict) and self.connected: + warnings.warn('payload not a dictionary') + return False + try: + message['node'] = self.profileNum + # LOGGER.debug(message) + self._mqttc.publish(self.topicInput, json.dumps(message), retain = False) + except TypeError as err: + LOGGER.error('MQTT Send Error: {}'.format(err)) + + def addNode(self, node): + LOGGER.info('Adding node {}({})'.format(node.name, node.address)) + message = { + 'addnode': { + 'nodes': [{ + 'address': node.address, + 'name': node.name, + 'node_def_id': node.node_def_id, + 'primary': node.primary, + 'drivers': node.drivers + }] + } + } + self.send(message) + + def getNode(self, address): + try: + for node in self.config['nodes']: + if node['address'] == address: + return node + return False + except KeyError as e: + LOGGER.error('Usually means we have not received the config yet.') + return False + + def inConfig(self, config): + self.config = config + try: + for callback in self.__configObservers: + callback(config) + except KeyError as e: + LOGGER.error('Could not find Nodes in Config') + + def input(self, command): + self.inQueue.put(command) + +class Node: + """ + Node Class for individual devices. + """ + def __init__(self, parent, primary, address, name): + try: + self.parent = parent + self.primary = primary + self.address = address + self.name = name + self.polyConfig = None + self._drivers = deepcopy(self.drivers) + self.isPrimary = None + self.timeAdded = None + self.enabled = None + self.added = None + except (KeyError) as err: + LOGGER.error('Error Creating node: {}'.format(err)) + + def setDriver(self, driver, value, report = True): + for d in self.drivers: + if d['driver'] == driver: + d['value'] = value + if report: + self.reportDriver(d, report) + break + + def reportDriver(self, driver, report): + for d in self._drivers: + if d['driver'] == driver['driver'] and d['value'] != driver['value']: + LOGGER.info('Updating Driver {} - {}: {}'.format(self.address, driver['driver'], driver['value'])) + d['value'] = deepcopy(driver['value']) + message = { + 'status': { + 'address': self.address, + 'driver': driver['driver'], + 'value': driver['value'], + 'uom': driver['uom'] + } + } + self.parent.poly.send(message) + break + + def reportDrivers(self): + LOGGER.info('Updating All Drivers to ISY for {}({})'.format(self.name, self.address)) + for driver in self.drivers: + message = { + 'status': { + 'address': self.address, + 'driver': driver['driver'], + 'value': driver['value'], + 'uom': driver['uom'] + } + } + self.parent.poly.send(message) + + def updateDrivers(self, drivers): + self._drivers = deepcopy(drivers) + + def runCmd(self, command): + if command['cmd'] in self._commands: + fun = self._commands[command['cmd']] + fun(self, command) + + def toJSON(self): + LOGGER.debug(json.dumps(self.__dict__)) + + def __rep__(self): + return self.toJSON() + + _commands = {} + node_def_id = '' + _sends = {} + _drivers = {} + +class Controller: + """ + Controller Class for controller management. + """ + def __init__(self, poly): + try: + self.poly = poly + self.poly.bind_toConfig(self.gotConfig) + self.name = None + self.address = None + self.nodes = {} + self.polyNodes = None + self.polyConfig = None + self.nodesAdding = [] + except (KeyError) as err: + LOGGER.error('Error Creating node: {}'.format(err)) + + def addNode(self, node): + if not self.poly.getNode(node.address): + self.nodesAdding.append(node.address) + self.poly.addNode(node) + else: + self.nodes[node.address].start() + + + def gotConfig(self, config): + self.polyConfig = config + for node in config['nodes']: + if node['address'] in self.nodes: + n = self.nodes[node['address']] + n.updateDrivers(node['drivers']) + n.polyConfig = node + n.isPrimary = node['isprimary'] + n.timeAdded = node['time_added'] + n.enabled = node['enabled'] + n.added = node['added'] + + def parseInput(self, input): + for key in input: + if key == 'command': + try: + self.nodes[input[key]['address']].runCmd(input[key]) + except KeyError as e: + LOGGER.error('parseInput: {}'.format(e)) + elif key == 'result': + self.handleResult(input[key]) + + def handleResult(self, result): + try: + if 'addnode' in result: + if result['addnode']['success'] == True: + if result['addnode']['address'] == self.address: + self.start() + else: + self.nodes[result['addnode']['address']].start() + self.nodesAdding.remove(result['addnode']['address']) + except KeyError as e: + LOGGER.error('handleResult: {}'.format(e)) + + def startPolls(self, long = 30, short = 10): + Timer(long, self.longPoll, args = []).start() + Timer(short, self.shortPoll, args = []).start() + + def longPoll(self, timer = 30): + self.poly._longPoll = Timer(timer, self.longPoll, args = [timer]).start() + + def shortPoll(self, timer = 10): + self.poly._shortPoll = Timer(timer, self.shortPoll, args = [timer]).start() + + def toJSON(self): + LOGGER.debug(json.dumps(self.__dict__)) + + def __rep__(self): + return self.toJSON() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b88034e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.md diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b36bc70 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +from distutils.core import setup +#from setuptools import setup + +setup(name='lifxlan', + version='1.0.0', + description='UDI Polyglot v2 Interface', + url='http://github.com/mclarkk/lifxlan', + author='James Milne', + author_email='milne.james@gmail.com', + license='MIT', + packages=['polyinterface'], + install_requires=[ + "bitstring", + "paho-mqtt", + "python-dotenv", + ], + zip_safe=False, + # See https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + # Pick your license as you wish (should match "license" above) + 'License :: OSI Approved :: MIT License', + + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6' + ])