From e2e9d45cd7ef614750630f171fb8e48557155b71 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 13 Jan 2017 16:09:55 +0300 Subject: [PATCH] Code rewrited --- .gitignore | 8 +- Dockerfile-i386 | 11 +++ Dockerfile => Dockerfile-x64 | 1 - README.md | 37 +++++--- config/config-sample.yaml | 21 +++++ docker-compose.yaml | 7 ++ src/lightbulbstate.py | 22 +++-- src/main.py | 177 +++++++++++++++++------------------ src/mqtt.py | 142 ++++++++++++++++++++++++++++ src/requirements.txt | 4 +- src/yamlparser.py | 24 +++++ 11 files changed, 337 insertions(+), 117 deletions(-) create mode 100644 Dockerfile-i386 rename Dockerfile => Dockerfile-x64 (94%) create mode 100644 config/config-sample.yaml create mode 100644 docker-compose.yaml create mode 100644 src/mqtt.py create mode 100644 src/yamlparser.py diff --git a/.gitignore b/.gitignore index 827de0b..f5c364c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ -#docker-compose file, because it contains passwords -docker-compose.yaml -docker-compose.yml +#config file, because it contains passwords +config.yml +config.yaml +config/config.yaml +config/config.yml # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/Dockerfile-i386 b/Dockerfile-i386 new file mode 100644 index 0000000..dbd7fa7 --- /dev/null +++ b/Dockerfile-i386 @@ -0,0 +1,11 @@ +FROM monster1025/alpine86-python + +ENV LIBRARY_PATH=/lib:/usr/lib + +ADD src/requirements.txt / +RUN pip install --upgrade pip && pip install -r /requirements.txt + +WORKDIR /app +COPY src /app + +CMD ["python", "-u", "/app/main.py"] diff --git a/Dockerfile b/Dockerfile-x64 similarity index 94% rename from Dockerfile rename to Dockerfile-x64 index 2ed75ff..88b00e0 100644 --- a/Dockerfile +++ b/Dockerfile-x64 @@ -8,5 +8,4 @@ RUN pip install --upgrade pip && pip install -r /requirements.txt WORKDIR /app COPY src /app -EXPOSE 5000 CMD ["python", "-u", "/app/main.py"] diff --git a/README.md b/README.md index 1fdb019..2c49c3b 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,34 @@ Works with Yeelight WiFi bulbs (color and monochrome). You need to edit you bulb IPs in main.py (not yet fixed). -Sample docker-compose.yml file: +You need to activate developer mode (http://forum.yeelight.com/t/trying-to-enable-developer-mode-with-yeelight-app-lamp-always-offline/137) + +Bridge accept following MQTT set: +``` +"home/light/main-color/status/set" -> on +``` + +will turn on light and translate devices state from gateway: +``` +"home/light/main-color/status" on +"home/light/main-color/ct" 3500 +"home/light/main-color/bright" 3 +"home/light/main-color/rgb" 1247743 +``` + +## Config +Edit file config/config-sample.yaml and rename it to config/config.yaml + +## Docker-Compose +Sample docker-compose.yaml file for user: ``` yeelight: - build: . + image: "monster1025/yeelight-mqtt" container_name: yeelight - environment: - - MQTT_SERVER=192.168.1.93 - - MQTT_USER=mqtt_user - - MQTT_PASS=passw0rd + volumes: + - "./config:/app/config" restart: always ``` -``` -docker-compose build && docker-compose up -d -``` - ## Known bugs: -- Lamp's alive status updated only at startup. -- IP-Name list in main.py -- Bad code style =( \ No newline at end of file +- Lamp's alive status updated only at startup. \ No newline at end of file diff --git a/config/config-sample.yaml b/config/config-sample.yaml new file mode 100644 index 0000000..351e830 --- /dev/null +++ b/config/config-sample.yaml @@ -0,0 +1,21 @@ +mqtt: + server: 127.0.0.1 + port: 1883 + username: mqttuser + password: passw0rd + prefix: home + +sids: + # lamps + 192.168.1.51: + model: light + name: main-mono + 192.168.1.54: + model: light + name: main-color + 192.168.1.52: + model: light + name: kitchen + 192.168.1.53: + model: light + name: hall \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..39baf73 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,7 @@ +yeelight: + build: . + dockerfile: Dockerfile-i386 + container_name: yeelight + volumes: + - "./config:/app/config" + restart: always diff --git a/src/lightbulbstate.py b/src/lightbulbstate.py index 1d976a4..11bc5bb 100644 --- a/src/lightbulbstate.py +++ b/src/lightbulbstate.py @@ -1,20 +1,22 @@ import pyyeelight -import os -import time -import threading +import logging + +_LOGGER = logging.getLogger(__name__) class LightBulbState: bright = 0 color_temperature = 0 status = "off" rgb = 0 + model = "" ip = "" name = "" yeelight = None # yeelight object - def __init__(self, ip, yeelightObj): + def __init__(self, ip, model, yeelightObj): self.yeelight = yeelightObj + self.model = model self.name = yeelightObj.__name__ self.ip = ip @@ -42,23 +44,23 @@ class LightBulbState: try: if (param == 'status'): if (value == "on"): - print("Turning on bulb", self.name) + _LOGGER.info("Turning on bulb: " + self.name) self.yeelight.turn_on() if (value == "off"): - print("Turning off bulb", self.name) + _LOGGER.info("Turning off bulb: " + self.name) self.yeelight.turn_off() if (param == 'bright' and self.is_int(value)): - print("Setting brightness of bulb", self.name, 'to', value) + _LOGGER.info("Setting brightness of bulb " + self.name + " to " + str(value)) self.yeelight.set_brightness(int(value)) if (param == 'ct' and self.is_int(value)): - print("Setting temperature of bulb", self.name, 'to', value) + _LOGGER.info("Setting temperature of bulb " + self.name + " to " + str(value)) self.yeelight.set_color_temperature(int(value)) if (param == 'rgb' and self.is_int(value)): intval = int(value) Blue = intval & 255 Green = (intval >> 8) & 255 Red = (intval >> 16) & 255 - print("Setting rgb of bulb", self.name, 'to', value) + _LOGGER.info("Setting rgb of bulb", self.name, 'to', value) self.yeelight.set_rgb_color(Red, Green, Blue) except Exception as e: - print ('Error while set value of bulb', self.name , ' error:', e) \ No newline at end of file + _LOGGER.error('Error while set value of bulb ' + self.name + ' error:', e) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 6f8ba1c..19d5cc9 100644 --- a/src/main.py +++ b/src/main.py @@ -1,113 +1,112 @@ -import pyyeelight -import paho.mqtt.client as mqtt -import os +import logging import time import threading +import os +import json +import pyyeelight from lightbulbstate import LightBulbState -MQTT_SERVER = os.getenv('MQTT_SERVER', "") -MQTT_PORT = os.getenv('MQTT_PORT', 1883) -MQTT_USER = os.getenv('MQTT_USER', "") -MQTT_PASS = os.getenv('MQTT_PASS', "") -QUERY_TIME = os.getenv('QUERY_TIME', 3) -PREFIX = os.getenv('PREFIX', 'home') +#mine +import mqtt +import yamlparser + +logging.basicConfig(level=logging.INFO) +_LOGGER = logging.getLogger(__name__) +QUERY_TIME = 2 -ips = dict({ - '192.168.1.51':'main-mono', - '192.168.1.54':'main-color', - '192.168.1.52':'kitchen', - '192.168.1.53':'hall' -}) bulbs=[] processNow = False - -PATH_FMT = PREFIX + "/{model}/{sid}/{prop}" # short_id or sid ? -def prepare_mqtt(): - print("Connecting to MQTT server", MQTT_SERVER, ":", MQTT_PORT, "with username", MQTT_USER,":",MQTT_PASS) - client = mqtt.Client() - if (MQTT_USER != "" and MQTT_PASS != ""): - client.username_pw_set(MQTT_USER, MQTT_PASS) - client.connect(MQTT_SERVER, MQTT_PORT, 60) - - return client - -def push_data(client, model, sid, data): - for key, value in data.items(): - path = PATH_FMT.format(model=model, - sid=sid, - prop=key) - client.publish(path, payload=value, qos=0, retain=True) +def init_lamps(config): + if config is None: + raise "Config is None." + sids = config.get("sids", "None") + if sids is None: + raise "Config -> sids is None." -def init_lamps(): lamps=[] - for ip in ips: + # sid is IP-address + for sid in sids: + if (sid is None): + continue try: - yeelight = pyyeelight.YeelightBulb(ip) - yeelight.__name__ = ips[ip] #add name - bulb = LightBulbState(ip, yeelight) + data = sids[sid] + yeelight = pyyeelight.YeelightBulb(sid) + name = data.get("name", sid) + model = data.get("model", "light") + yeelight.__name__ = name #add name + bulb = LightBulbState(sid, model, yeelight) bulb.update_properties() lamps.append(bulb) except Exception as e: - print('Connection to ', str(ip) , ' error:', str(e)) + _LOGGER.error('Connection to ', str(sid) , ' error:', str(e)) return lamps - -def refresh_bulb_states(data_callback): - for bulb in bulbs: - try: - hashold = bulb.hash() - bulb.update_properties(force=True) - hashnew = bulb.hash() - # print(str(bulb.name),hashold, hashnew) - - if (hashold != hashnew): - print("!!!! ", bulb.name, ":", hashold, "->", hashnew) - if data_callback is not None: - data = {'status':bulb.status, 'ct':bulb.color_temperature, 'bright':bulb.bright, 'rgb':bulb.rgb} - data_callback("lamp", bulb.name, data) - except Exception as e: - print('Connection to ', str(bulb.name) , ' error:', str(e)) - -def on_mqtt_message(client, userdata, msg): +def wait(): global processNow - print(msg.topic+" "+str(msg.payload)) - parts = msg.topic.split("/") - if (len(parts) != 5): - return - name = parts[2] #name part - param = parts[3] #param part - value = (msg.payload).decode('utf-8') + for x in range(1,10): + if (processNow): + processNow=False + break + time.sleep(QUERY_TIME/10) - for bulb in bulbs: - if (bulb.name != name): - continue - bulb.process_command(param, value) - processNow=True - -def on_connect(client, userdata, rc): - client.subscribe(PREFIX + "/lamp/+/+/set") - -def refresh_loop(client): - global processNow - cb = lambda m, s, d: push_data(client, m, s, d) +def process_lamp_states(client): + global bulbs while True: - refresh_bulb_states(cb) - for x in range(1,10): - if (processNow): - processNow=False - break - time.sleep(QUERY_TIME/10) + wait(); + try: + for bulb in bulbs: + hashold = bulb.hash() + bulb.update_properties(force=True) + hashnew = bulb.hash() + # _LOGGER.debug(str(bulb.name) + " ===> " + hashold + "-" + hashnew) + + if (hashold != hashnew): + _LOGGER.info("!!!! " + bulb.name + ":" + hashold + "->" + hashnew) + data = {'status':bulb.status, 'ct':bulb.color_temperature, 'bright':bulb.bright, 'rgb':bulb.rgb} + client.publish(bulb.model, bulb.name, data) + except Exception as e: + _LOGGER.error('Error while sending from gateway to mqtt: ', str(e)) + +def process_mqtt_messages(client): + global processNow, bulbs + while True: + try: + data = client._queue.get() + _LOGGER.debug("data from mqtt: " + format(data)) + + sid = data.get("sid", None) + param = data.get("param", None) + value = data.get("value", None) + for bulb in bulbs: + if (bulb.ip != sid): + continue + bulb.process_command(param, value) + processNow=True + + client._queue.task_done() + except Exception as e: + _LOGGER.error('Error while sending from mqtt to gateway: ', str(e)) if __name__ == "__main__": - client = prepare_mqtt() - bulbs = init_lamps() - client.on_message = on_mqtt_message - client.on_connect = on_connect + _LOGGER.info("Loading config file...") + config=yamlparser.load_yaml('config/config.yaml') - #start thread for lamp refresh loop - t1 = threading.Thread(target=refresh_loop, args=[client]) + _LOGGER.info("Init mqtt client.") + client = mqtt.Mqtt(config) + client.connect() + #only this devices can be controlled from MQTT + client.subscribe("light", "+", "+", "set") + + bulbs = init_lamps(config) + + t1 = threading.Thread(target=process_lamp_states, args=[client]) + t1.daemon = True t1.start() - # and process mqtt messages in this thread - client.loop_forever() \ No newline at end of file + t2 = threading.Thread(target=process_mqtt_messages, args=[client]) + t2.daemon = True + t2.start() + + while True: + time.sleep(10) \ No newline at end of file diff --git a/src/mqtt.py b/src/mqtt.py new file mode 100644 index 0000000..1dce908 --- /dev/null +++ b/src/mqtt.py @@ -0,0 +1,142 @@ +import paho.mqtt.client as mqtt +import os +import logging +from queue import Queue +from threading import Thread + +_LOGGER = logging.getLogger(__name__) + +class Mqtt: + username = "" + password = "" + server = "localhost" + port = 1883 + prefix = "home" + + _client = None + _sids = None + _queue = None + _threads = None + + def __init__(self, config): + if (config == None): + raise "Config is null" + + #load sids dictionary + self._sids = config.get("sids", None) + if (self._sids == None): + self._sids = dict({}) + + #load mqtt settings + mqttConfig = config.get("mqtt", None) + if (mqttConfig == None): + raise "Config mqtt section is null" + + self.username = mqttConfig.get("username", "") + self.password = mqttConfig.get("password", "") + self.server = mqttConfig.get("server", "localhost") + self.port = mqttConfig.get("port", 1883) + self.prefix = mqttConfig.get("prefix", "home") + self._queue = Queue() + self._threads = [] + + def connect(self): + _LOGGER.info("Connecting to MQTT server " + self.server + ":" + str(self.port) + " with username (" + self.username + ":" + self.password + ")") + self._client = mqtt.Client() + if (self.username != "" and self.password != ""): + self._client.username_pw_set(self.username, self.password) + self._client.on_message = self._mqtt_process_message + self._client.on_connect = self._mqtt_on_connect + self._client.connect(self.server, self.port, 60) + + #run message processing loop + t1 = Thread(target=self._mqtt_loop) + t1.start() + self._threads.append(t1) + + def subscribe(self, model="+", name="+", prop="+", command="set"): + topic = self.prefix + "/" + model + "/" + name + "/" + prop + "/" + command + _LOGGER.info("Subscibing to " + topic + ".") + self._client.subscribe(topic) + + def publish(self, model, sid, data, retain=True): + sidprops = self._sids.get(sid, None) + if (sidprops != None): + model = sidprops.get("model",model) + sid = sidprops.get("name",sid) + + # _LOGGER.info("data is " + format(data)) + PATH_FMT = self.prefix + "/{model}/{sid}/{prop}" + for key, value in data.items(): + # fix for latest motion value + if (model == "motion" and key == "no_motion"): + key="status" + value="no_motion" + + # fix for rgb format + # if (key == "rgb" and self._is_int(value)): + # intval = int(value) + # blue = (intval) & 255 + # green = (intval >> 8) & 255 + # red = (intval >> 16) & 255 + # value = str(red)+","+str(green)+","+str(blue) + + topic = PATH_FMT.format(model=model, sid=sid, prop=key) + _LOGGER.info("Publishing message to topic " + topic + ": " + str(value) + ".") + self._client.publish(topic, payload=value, qos=0, retain=retain) + + def _mqtt_on_connect(self, client, userdata, rc, unk): + _LOGGER.info("Connected to mqtt server.") + + def _mqtt_process_message(self, client, userdata, msg): + _LOGGER.info("Processing message in " + str(msg.topic) + ": " + str(msg.payload) + ".") + parts = msg.topic.split("/") + if (len(parts) != 5): + return + model = parts[1] + query_sid = parts[2] #sid or name part + param = parts[3] #param part + value = (msg.payload).decode('utf-8') + if self._is_int(value): + value = int(value) + name = "" # we will find it next + sid = query_sid + + for current_sid in self._sids: + if (current_sid == None): + continue + sidprops = self._sids.get(current_sid, None) + if sidprops == None: + continue + sidname = sidprops.get("name", current_sid) + sidmodel = sidprops.get("model", "") + if (sidname == query_sid and sidmodel == model): + sid = current_sid + name = sidname + break + else: + _LOGGER.debug(sidmodel + "-" + sidname + " is not " + model + "-" + query_sid + ".") + continue + + # fix for rgb format + if (param == "rgb" and "," in str(value)): + arr = value.split(",") + r = int(arr[0]) + g = int(arr[1]) + b = int(arr[2]) + value = int('%02x%02x%02x%02x' % (255, r, g, b), 16) + + data = {'sid': sid, 'model': model, 'name': name, 'param':param, 'value':value} + # put in process queuee + self._queue.put(data) + + def _mqtt_loop(self): + _LOGGER.info("Starting mqtt loop.") + self._client.loop_forever() + + def _is_int(self, x): + try: + tmp = int(x) + return True + except Exception as e: + return False diff --git a/src/requirements.txt b/src/requirements.txt index 2c82f62..8edcbf3 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,2 +1,4 @@ paho-mqtt -pyyeelight==1.0-beta \ No newline at end of file +pyyeelight==1.0-beta +pyyaml +pycrypto \ No newline at end of file diff --git a/src/yamlparser.py b/src/yamlparser.py new file mode 100644 index 0000000..c2e991f --- /dev/null +++ b/src/yamlparser.py @@ -0,0 +1,24 @@ +import yaml +import logging + +_LOGGER = logging.getLogger(__name__) + +def load_yaml(file): + try: + stram = open(file, "r") + yaml_data = yaml.load(stram) + return yaml_data + except Exception as e: + raise + _LOGGER.error("Can't load yaml with sids %r (%r)" % (file, e)) + +def get_gateway_password(config, ip=""): + if (config == None): + raise "Config is null" + configGateway = config.get("gateway", None) + if (configGateway == None): + raise "Config gateway is null" + password = configGateway.get("password", None) + if (password == None): + raise "Config gateway passowrd is null" + return password \ No newline at end of file