From ea1da2d7326d4a02496ff5359a379802f055234e Mon Sep 17 00:00:00 2001 From: Igor Sankovich Date: Mon, 6 Nov 2017 00:12:29 +0300 Subject: [PATCH] New device: * Care - Main Brush * Care - Side Brush * Care - Sensors * Care - Filter * Care Reset Control New wrapper-tcp-server for python-miio --- .gitignore | 4 +- .vendors/.gitkeep | 0 README.md | 63 +++++------- mirobo-wrapper.py | 107 -------------------- pip_req.txt | 21 ++++ plugin.py | 250 +++++++++++++++++++++++++++++++++------------- server.py | 202 +++++++++++++++++++++++++++++++++++++ test.py | 59 +++++++++++ 8 files changed, 488 insertions(+), 218 deletions(-) delete mode 100755 .vendors/.gitkeep delete mode 100755 mirobo-wrapper.py create mode 100644 pip_req.txt create mode 100644 server.py create mode 100644 test.py diff --git a/.gitignore b/.gitignore index 608addc..211ff08 100755 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -.vendors/ .idea/ __pycache__ .pyc -python-mirobo.seq \ No newline at end of file +.env +log \ No newline at end of file diff --git a/.vendors/.gitkeep b/.vendors/.gitkeep deleted file mode 100755 index e69de29..0000000 diff --git a/README.md b/README.md index b011fd6..e0bb958 100755 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ + # Xiaomi Mi Robot Vacuum - Domoticz Python plugin -*This plugin uses the [Python-mirobo](https://github.com/rytilahti/python-mirobo) library.* +*This plugin uses the [Python-miio](https://github.com/rytilahti/python-miio) library.* *See this [link](https://www.domoticz.com/wiki/Using_Python_plugins) for more information on the Domoticz plugins.* ## How it works -Plugin provides: Status, Control, Fan Level and Battery devices. +Plugin provides: Status, Control, Fan Level, Battery, Care status devices **Status**: show current status in readable layout of switch. Status updates by polls (interval) and when you click Control device (for instant status change). @@ -17,8 +18,9 @@ Plugin provides: Status, Control, Fan Level and Battery devices. **Battery**: since ```0.0.4``` as new device -Plugin calls **python-mirobo** (in subprocess) via own wrapper behind for converting results from lib to JSON and then update status of device. -Domoticz has some limitation in python plugin system, so this lib doesn't work well directly in plugin (plugin halted after first heartbeat). +**Care**: since ```0.1.0``` new 5 devices (care status + reset tool) + +Since ```0.1.0``` plugin uses wrapper-server for python-miio lib. It helps to use this plugin in Domoticz without blocking mode. ## Installation @@ -28,20 +30,29 @@ Before installation plugin check the `python3` and `python3-dev` is installed fo Also do note that the setuptools version is too old for installing some requirements, so before trying to install this package you should update the setuptools with: -```pip3 install -U setuptools```. +```sudo pip3 install -U setuptools```. + +Also need to install virtualenv: +```sudo pip3 install -U virtualenv```. Please make sure you have libffi and openssl headers installed, you can do this on Debian-based systems (like Rasperry Pi) with -```apt-get install libffi-dev libssl-dev```. +```sudo apt-get install libffi-dev libssl-dev```. -Then go to plugins folder and clone plugin: +Then go to plugins folder and clone plugin then activate virtualenv: ``` cd domoticz/plugins git clone https://github.com/mrin/domoticz-mirobot-plugin.git xiaomi-mirobot + ``` -Then install [python-mirobo](https://github.com/rytilahti/python-mirobo) locally (due issues with python paths) with all dependencies: + +Now create virtualenv and install required libs: ``` cd xiaomi-mirobot -pip3 install python-mirobo -t .vendors +virtualenv -p python3 .env + +pip3 install -r pip_req.txt +# or pip3 install gevent msgpack-python python-miio==0.3.1 + ``` Restart the Domoticz service @@ -56,6 +67,7 @@ Now go to **Setup** -> **Hardware** in your Domoticz interface and add type with | Data Timeout | Keep Disabled | | IP address | Enter the IP address of your Vacuum (see the MiHome app or router dhcp, should be static) | | Token | This token is only attainable before the device has been connected over the app to your local wifi (or alternatively, if you have paired your rooted mobile device with the vacuum, or if you share access to Vacuum via MiHome to rooted device) | +| MIIOServer host:port | Local server will be started with plugin on 127.0.0.1:22222 | | Update interval | In seconds, this determines with which interval the plugin polls the status of Vacuum. Suggested is no lower then 5 sec due timeout in python-mirobo lib, but you can try any. | | Fan Level Type | ```Standard``` - standard set of buttons (values supported by MiHome); ```Slider``` - allow to set custom values, up to 100 (in standard Max=90) (values not supported by MiHome) | | Python Path | Path to Python 3, default is python3 | @@ -72,8 +84,6 @@ cd domoticz/plugins/xiaomi-mirobot git pull ``` -For update python-mirobo use ```pip3 install python-mirobo -t .vendors --upgrade``` - Restart the Domoticz service ``` sudo service domoticz.sh restart @@ -87,33 +97,12 @@ sudo service domoticz.sh restart ![fan_level](https://user-images.githubusercontent.com/93999/29668575-6906ea22-88e9-11e7-8508-8f0ff48e2f78.png) ![fan_level2](https://user-images.githubusercontent.com/93999/29713051-86cd023c-89a5-11e7-83cc-5953b8cbbfa5.png) -![bat](https://user-images.githubusercontent.com/93999/29769383-c8202814-8bf2-11e7-86b2-3629bfc63dc0.png) - -### How to obtain device Token - -*Important* For Mi Robot with firmware 3.3.9_003077 or higher use these methods to obtain the device token: https://github.com/jghaanstra/com.xiaomi-miio/blob/master/docs/obtain_token_mirobot_new.md - -**Android rooted device** - -*(Also you can share Vacuum via MiHome to the other rooted device)* +![care1](https://user-images.githubusercontent.com/93999/32418537-08d3c918-c27d-11e7-89e9-10daf79bcdb4.png) +![care2](https://user-images.githubusercontent.com/93999/32418538-08ef7e10-c27d-11e7-9ff8-8dfff1c20377.png) -Need database file miio2.db which located here: -``` -/data/data/com.xiaomi.smarthome/databases/miio2.db -``` -Open file with any SQLite db editor/manager. Table "devicerecord" with column "token". +![bat](https://user-images.githubusercontent.com/93999/29769383-c8202814-8bf2-11e7-86b2-3629bfc63dc0.png) -**Reset robot** -Install lib and check it -``` -pip3 install python-mirobo -mirobo discover -``` -You should see something like this: -``` -mirobo.vacuum: IP 192.168.1.12: Xiaomi Mi Robot Vacuum - token: b'ffffffffffffffffffffffffffffffff' -``` +### How to obtain device Token -Reset the robot, then connect to the network its announcing (SSID "rockrobo-XXXX"). -Then run ```mirobo discover``` and you should receive token. +Check the [instruction](https://github.com/rytilahti/python-miio#finding-the-token) diff --git a/mirobo-wrapper.py b/mirobo-wrapper.py deleted file mode 100755 index 91ceb29..0000000 --- a/mirobo-wrapper.py +++ /dev/null @@ -1,107 +0,0 @@ -import os -import sys -sys.path.append(os.path.join(os.path.dirname(__file__), '.', '.vendors')) - -import argparse -import mirobo -import json -import logging - -parser = argparse.ArgumentParser() -parser.add_argument('ip', type=str, help='vacuum ip address') -parser.add_argument('token', type=str, help='token') -parser.add_argument('cmd', nargs='*') -parser.add_argument('--id-file', type=str, default=os.path.dirname(__file__) + '/python-mirobo.seq') -args = parser.parse_args() - -def cleanup(vac, id_file): - seqs = {'seq': vac.raw_id, 'manual_seq': vac.manual_seqnum} - with open(args.id_file, 'w') as f: - json.dump(seqs, f) - - -def get_vac(): - start_id = manual_seq = 0 - try: - with open(args.id_file, 'r') as f: - x = json.load(f) - start_id = x.get("seq", 0) - manual_seq = x.get("manual_seq", 0) - except (FileNotFoundError, TypeError) as ex: - pass - - vac = mirobo.Vacuum(args.ip, args.token, start_id) - vac.manual_seqnum = manual_seq - - logging.basicConfig(level=logging.CRITICAL) - return vac - -def vac_status(vac): - res = vac.status() - if not res: return dict() - - if res.error_code: - return { - 'error_message': res.error, - 'error_code': res.error_code - } - - return { - 'state_code': res.state_code, - 'battery': res.battery, - 'fan_level': res.fanspeed, - 'clean_seconds': res.data["clean_time"], - 'clean_area': res.clean_area - } - -def vac_start(vac): - return {'code': vac.start()} - -def vac_stop(vac): - return {'code': vac.stop()} - -def vac_spot(vac): - return {'code': vac.spot()} - -def vac_pause(vac): - return {'code': vac.pause()} - -def vac_home(vac): - return {'code': vac.home()} - -def vac_find(vac): - return {'code': vac.find()} - -def vac_set_fan_level(vac, level): - return {'code': vac.set_fan_speed(int(level))} - - - -vac = get_vac() -cmd_name = args.cmd[0] - -try: - - if cmd_name == 'status': - print(json.dumps(vac_status(vac))) - elif cmd_name == 'start': - print(json.dumps(vac_start(vac))) - elif cmd_name == 'stop': - print(json.dumps(vac_stop(vac))) - elif cmd_name == 'spot': - print(json.dumps(vac_spot(vac))) - elif cmd_name == 'pause': - print(json.dumps(vac_pause(vac))) - elif cmd_name == 'home': - print(json.dumps(vac_home(vac))) - elif cmd_name == 'find': - print(json.dumps(vac_find(vac))) - elif cmd_name == 'fan_level' and len(args.cmd) == 2: - print(json.dumps(vac_set_fan_level(vac, args.cmd[1]))) - -except Exception as e: - print(json.dumps({ - 'exception': str(e) - })) - -cleanup(vac, args.id_file) diff --git a/pip_req.txt b/pip_req.txt new file mode 100644 index 0000000..87472d0 --- /dev/null +++ b/pip_req.txt @@ -0,0 +1,21 @@ +android-backup==0.1.0 +asn1crypto==0.23.0 +attrs==17.2.0 +cffi==1.11.2 +click==6.7 +construct==2.8.16 +cryptography==2.1.3 +enum-compat==0.0.2 +gevent==1.2.2 +greenlet==0.4.12 +idna==2.6 +msgpack-python==0.4.8 +netifaces==0.10.6 +pretty-cron==1.0.2 +pycparser==2.18 +pycrypto==2.6.1 +python-miio==0.3.1 +pytz==2017.3 +six==1.11.0 +typing==3.6.2 +zeroconf==0.19.1 diff --git a/plugin.py b/plugin.py index d20d4e2..c1f3aa1 100755 --- a/plugin.py +++ b/plugin.py @@ -3,10 +3,11 @@ # Author: mrin, 2017 # """ - + - + + @@ -25,13 +26,22 @@ """ + +import os +import sys + +module_paths = [x[0] for x in os.walk( os.path.join(os.path.dirname(__file__), '.', '.env/lib/') ) if x[0].endswith('site-packages') ] +for mp in module_paths: + sys.path.append(mp) + import Domoticz import subprocess -import os -import json +import signal import datetime import time +import msgpack + class BasePlugin: controlOptions = { @@ -46,7 +56,14 @@ class BasePlugin: "LevelOffHidden": "true", "SelectorStyle": "0" } - batteryOptions = {"Custom": "1;%"} + careOptions = { + "LevelActions": "||||", + "LevelNames": "Off|Main Brush|Side Brush|Filter|Sensor", + "LevelOffHidden": "true", + "SelectorStyle": "0" + } + + customSensorOptions = {"Custom": "1;%"} iconName = 'xiaomi-mi-robot-vacuum-icon' @@ -55,6 +72,11 @@ class BasePlugin: fanDimmerUnit = 3 fanSelectorUnit = 4 batteryUnit = 5 + cMainBrushUnit = 6 + cSideBrushUnit = 7 + cSensorsUnit = 8 + cFilterUnit = 9 + cResetControlUnit = 10 # statuses by protocol # https://github.com/marcelrv/XiaomiRobotVacuumProtocol/blob/master/StatusMessage.md @@ -78,11 +100,36 @@ class BasePlugin: 100: 'Full' } + subProc = None + subHost = None + subPort = None + + tcpConn = None + + heartBeatCnt = 0 + def onStart(self): if Parameters['Mode4'] == 'Debug': Domoticz.Debugging(1) DumpConfigToLog() + self.heartBeatCnt = 0 + + self.subHost, self.subPort = Parameters['Mode6'].split(':') + + self.subProc = subprocess.Popen([ + Parameters['Mode3'], + os.path.join(os.path.dirname(__file__), '.') + '/server.py', + Parameters['Address'], + Parameters['Mode1'], + '--host', self.subHost, + '--port', self.subPort + ], shell=False, preexec_fn=os.setsid) + + Domoticz.Debug('Starting MIIOServer pid:%s %s:%s' % (self.subProc.pid, self.subHost, self.subPort)) + + self.tcpConn = Domoticz.Connection(Name='MIIOServer', Transport='TCP/IP', Address=self.subHost, Port=self.subPort) + if self.iconName not in Images: Domoticz.Image('icons.zip').Create() iconID = Images[self.iconName].ID @@ -102,26 +149,76 @@ def onStart(self): if self.batteryUnit not in Devices: Domoticz.Device(Name='Battery', Unit=self.batteryUnit, TypeName='Custom', Image=iconID, - Options=self.batteryOptions).Create() + Options=self.customSensorOptions).Create() + + if self.cMainBrushUnit not in Devices: + Domoticz.Device(Name='Care Main Brush', Unit=self.cMainBrushUnit, TypeName='Custom', Image=iconID, + Options=self.customSensorOptions).Create() + + if self.cSideBrushUnit not in Devices: + Domoticz.Device(Name='Care Side Brush', Unit=self.cSideBrushUnit, TypeName='Custom', Image=iconID, + Options=self.customSensorOptions).Create() + + if self.cSensorsUnit not in Devices: + Domoticz.Device(Name='Care Sensors ', Unit=self.cSensorsUnit, TypeName='Custom', Image=iconID, + Options=self.customSensorOptions).Create() + + if self.cFilterUnit not in Devices: + Domoticz.Device(Name='Care Filter', Unit=self.cFilterUnit, TypeName='Custom', Image=iconID, + Options=self.customSensorOptions).Create() + + if self.cResetControlUnit not in Devices: + Domoticz.Device(Name='Care Reset Control', Unit=self.cResetControlUnit, TypeName='Selector Switch', Image=iconID, + Options=self.careOptions).Create() - # todo remove it in future releases - UpdateIcon(self.statusUnit, iconID) - UpdateIcon(self.controlUnit, iconID) - UpdateIcon(self.fanDimmerUnit, iconID) - UpdateIcon(self.batteryUnit, iconID) - # ./ Domoticz.Heartbeat(int(Parameters['Mode2'])) def onStop(self): - Domoticz.Debug("onStop called") + Domoticz.Debug("Trying stop MIIOServer pid:%s" % self.subProc.pid) + + os.killpg(os.getpgid(self.subProc.pid), signal.SIGTERM) def onConnect(self, Connection, Status, Description): - Domoticz.Debug("onConnect called") + Domoticz.Debug("MIIOServer connection status is [%s] [%s]" % (Status, Description)) + + def onMessage(self, Connection, Data): + result = msgpack.unpackb(Data, encoding='utf-8') + + Domoticz.Debug("Got: %s" % result) + + if 'error' in result: return + + if result['cmd'] == 'status': + + UpdateDevice(self.statusUnit, + (1 if result['state_code'] in [5, 6, 11] else 0), # ON is Cleaning, Back to home, Spot cleaning + self.states.get(result['state_code'], 'Undefined') + ) + + if self.batteryUnit in Devices: + UpdateDevice(self.batteryUnit, result['battery'], str(result['battery']), result['battery'], + isNeedForceUpdate(self.batteryUnit)) + + if Parameters['Mode5'] == 'dimmer': + UpdateDevice(self.fanDimmerUnit, 2, str(result['fan_level'])) # nValue=2 for show percentage, instead ON/OFF state + else: + level = {38: 10, 60: 20, 77: 30, 90: 40}.get(result['fan_level'], None) + if level: UpdateDevice(self.fanSelectorUnit, 1, str(level)) + + elif result['cmd'] == 'consumable_status': + + mainBrush = cPercent(result['main_brush'], 300) + sideBrush = cPercent(result['side_brush'], 200) + filter = cPercent(result['filter'], 150) + sensors = cPercent(result['sensor'], 30) + + UpdateDevice(self.cMainBrushUnit, mainBrush, str(mainBrush), AlwaysUpdate=True) + UpdateDevice(self.cSideBrushUnit, sideBrush, str(sideBrush), AlwaysUpdate=True) + UpdateDevice(self.cFilterUnit, filter, str(filter), AlwaysUpdate=True) + UpdateDevice(self.cSensorsUnit, sensors, str(sensors), AlwaysUpdate=True) - def onMessage(self, Connection, Data, Status, Extra): - Domoticz.Debug("onMessage called") def onCommand(self, Unit, Command, Level, Hue): Domoticz.Debug("onCommand called for Unit " + str(Unit) + ": Command '" + str(Command) + "', Level: " + str(Level)) @@ -134,78 +231,92 @@ def onCommand(self, Unit, Command, Level, Hue): if self.statusUnit == Unit: if 'On' == Command and self.isOFF: - if callWrappedCommand('start'): UpdateDevice(Unit, 1, self.states[5]) # Cleaning + if self.apiRequest('start'): UpdateDevice(Unit, 1, self.states[5]) # Cleaning elif 'Off' == Command and self.isON: - if sDevice.sValue == self.states[11] and callWrappedCommand('pause'): # Stop if Spot cleaning + if sDevice.sValue == self.states[11] and self.apiRequest('pause'): # Stop if Spot cleaning UpdateDevice(Unit, 0, self.states[3]) # Waiting - elif callWrappedCommand('home'): + elif self.apiRequest('home'): UpdateDevice(Unit, 1, self.states[6]) # Back to home elif self.controlUnit == Unit: if Level == 10: # Clean - if callWrappedCommand('start') and self.isOFF: + if self.apiRequest('start') and self.isOFF: UpdateDevice(self.statusUnit, 1, self.states[5]) # Cleaning elif Level == 20: # Home - if callWrappedCommand('home') and sDevice.sValue in [ + if self.apiRequest('home') and sDevice.sValue in [ self.states[5], self.states[3], self.states[10]]: # Cleaning, Waiting, Paused UpdateDevice(self.statusUnit, 1, self.states[6]) # Back to home elif Level == 30: # Spot - if callWrappedCommand('spot') and self.isOFF and sDevice.sValue != self.states[8]: # Spot cleaning will not start if Charging + if self.apiRequest('spot') and self.isOFF and sDevice.sValue != self.states[8]: # Spot cleaning will not start if Charging UpdateDevice(self.statusUnit, 1, self.states[11]) # Spot cleaning elif Level == 40: # Pause - if callWrappedCommand('pause') and self.isON: + if self.apiRequest('pause') and self.isON: if sDevice.sValue == self.states[11]: # For Spot cleaning - Pause treats as Stop UpdateDevice(self.statusUnit, 0, self.states[3]) # Waiting else: UpdateDevice(self.statusUnit, 0, self.states[10]) # Paused elif Level == 50: # Stop - if callWrappedCommand('stop') and self.isON and sDevice.sValue not in [self.states[11], self.states[6]]: # Stop doesn't work for Spot cleaning, Back to home + if self.apiRequest('stop') and self.isON and sDevice.sValue not in [self.states[11], self.states[6]]: # Stop doesn't work for Spot cleaning, Back to home UpdateDevice(self.statusUnit, 0, self.states[3]) # Waiting elif Level == 60: # Find - callWrappedCommand('find') + self.apiRequest('find') elif self.fanDimmerUnit == Unit and Parameters['Mode5'] == 'dimmer': Level = 1 if Level == 0 else 100 if Level > 100 else Level - if callWrappedCommand('fan_level', Level): UpdateDevice(self.fanDimmerUnit, 2, str(Level)) + if self.apiRequest('set_fan_level', Level): UpdateDevice(self.fanDimmerUnit, 2, str(Level)) elif self.fanSelectorUnit == Unit and Parameters['Mode5'] == 'selector': num_level = {10: 38, 20: 60, 30: 77, 40: 90}.get(Level, None) - if num_level and callWrappedCommand('fan_level', num_level): UpdateDevice(self.fanSelectorUnit, 1, str(Level)) + if num_level and self.apiRequest('set_fan_level', num_level): UpdateDevice(self.fanSelectorUnit, 1, str(Level)) + + elif self.cResetControlUnit == Unit: + + if Level == 10: # Reset Main Brush + if self.apiRequest('care_reset_main_brush'): + UpdateDevice(self.cMainBrushUnit, 100, '100') + + elif Level == 20: # Reset Side Brush + if self.apiRequest('care_reset_side_brush'): + UpdateDevice(self.cSideBrushUnit, 100, '100') + + elif Level == 30: # Reset Filter + if self.apiRequest('care_reset_filter'): + UpdateDevice(self.cFilterUnit, 100, '100') + + elif Level == 40: # Reset Sensors + if self.apiRequest('care_reset_sensor'): + UpdateDevice(self.cSensorsUnit, 100, '100') + + self.apiRequest('consumable_status') def onNotification(self, Name, Subject, Text, Status, Priority, Sound, ImageFile): Domoticz.Debug("Notification: " + Name + "," + Subject + "," + Text + "," + Status + "," + str(Priority) + "," + Sound + "," + ImageFile) def onDisconnect(self, Connection): - Domoticz.Debug("onDisconnect called") + Domoticz.Debug("MIIOServer disconnected") def onHeartbeat(self): - result = callWrappedCommand('status') - if not result or 'state_code' not in result or 'error_code' in result: return - - UpdateDevice(self.statusUnit, - (1 if result['state_code'] in [5, 6, 11] else 0), # ON is Cleaning, Back to home, Spot cleaning - self.states.get(result['state_code'], 'Undefined') - ) - - if self.batteryUnit in Devices: - # crazy converting due C python embedding - bLastSeen = datetime.datetime.fromtimestamp(time.mktime(time.strptime(Devices[self.batteryUnit].LastUpdate, '%Y-%m-%d %H:%M:%S'))).timestamp() - bForceUpdate = (bLastSeen < (time.time() - 15*60)) - UpdateDevice(self.batteryUnit, result['battery'], str(result['battery']), result['battery'], bForceUpdate) - - if Parameters['Mode5'] == 'dimmer': - UpdateDevice(self.fanDimmerUnit, 2, str(result['fan_level'])) # nValue=2 for show percentage, instead ON/OFF state - else: - level = {38: 10, 60: 20, 77: 30, 90: 40}.get(result['fan_level'], None) - if level: UpdateDevice(self.fanSelectorUnit, 1, str(level)) + if not self.tcpConn.Connecting() and not self.tcpConn.Connected(): + self.tcpConn.Connect() + Domoticz.Debug("Trying connect to MIIOServer %s:%s" % (self.subHost, self.subPort)) + + elif self.tcpConn.Connecting(): + Domoticz.Debug("Still connecting to MIIOServer %s:%s" % (self.subHost, self.subPort)) + + elif self.tcpConn.Connected(): + if self.heartBeatCnt % 30 == 0 or self.heartBeatCnt == 0: + self.apiRequest('consumable_status') + self.apiRequest('status') + self.heartBeatCnt += 1 + @property def isON(self): @@ -215,30 +326,13 @@ def isON(self): def isOFF(self): return Devices[self.statusUnit].nValue == 0 + def apiRequest(self, cmd_name, cmd_value=None): + if not self.tcpConn.Connected(): return False + cmd = [cmd_name] + if cmd_value: cmd.append(cmd_value) + self.tcpConn.Send(msgpack.packb(cmd, use_bin_type=True)) + return True -def callWrappedCommand(cmd_name=None, cmd_value=None): - call_params = [Parameters['Mode3'], os.path.dirname(__file__) + '/mirobo-wrapper.py', Parameters['Address'], Parameters['Mode1']] - if cmd_name: call_params.append(cmd_name) - if cmd_value: call_params.append(str(cmd_value)) - - try: - call_resp = subprocess.check_output(call_params, universal_newlines=True) - Domoticz.Debug('Poll: %s' % call_resp) - - try: - result = json.loads(call_resp) - if 'exception' in result: - Domoticz.Error('Response mirobo-wrapper exception: %s' % result['exception']) - return None - return result - - except Exception: - Domoticz.Error('callWrappedCommand() json parse exception: %s' % call_resp) - return None - - except Exception as e: - Domoticz.Error('Call mirobo-wrapper exception: %s' % str(e)) - return None def UpdateDevice(Unit, nValue, sValue, BatteryLevel=255, AlwaysUpdate=False): @@ -263,6 +357,18 @@ def UpdateIcon(Unit, iconID): d = Devices[Unit] if d.Image != iconID: d.Update(d.nValue, d.sValue, Image=iconID) +def isNeedForceUpdate(Unit): + if Unit not in Devices: return False + # crazy converting due C python embedding + LastSeen = datetime.datetime.fromtimestamp( + time.mktime(time.strptime(Devices[Unit].LastUpdate, '%Y-%m-%d %H:%M:%S')) + ).timestamp() + return (LastSeen < (time.time() - 15 * 60)) + + +def cPercent(used, max): + return 100 - round(used / 3600 * 100 / max) + global _plugin _plugin = BasePlugin() @@ -279,9 +385,9 @@ def onConnect(Connection, Status, Description): global _plugin _plugin.onConnect(Connection, Status, Description) -def onMessage(Connection, Data, Status, Extra): +def onMessage(Connection, Data): global _plugin - _plugin.onMessage(Connection, Data, Status, Extra) + _plugin.onMessage(Connection, Data) def onCommand(Unit, Command, Level, Hue): global _plugin diff --git a/server.py b/server.py new file mode 100644 index 0000000..d5710fd --- /dev/null +++ b/server.py @@ -0,0 +1,202 @@ +import os +import sys + +module_paths = [x[0] for x in os.walk( os.path.join(os.path.dirname(__file__), '.', '.env/lib/') ) if x[0].endswith('site-packages') ] +for mp in module_paths: + sys.path.append(mp) + +from gevent import monkey +monkey.patch_all() + +import msgpack +from gevent.queue import Queue +from gevent.pool import Group +from gevent.server import StreamServer +import argparse +from miio import Vacuum, DeviceException +from msgpack import Unpacker +from logging.handlers import RotatingFileHandler +import logging + +parser = argparse.ArgumentParser() +parser.add_argument('ip', type=str, help='vacuum ip address', default='192.168.1.12"') +parser.add_argument('token', type=str, help='token', default='476e6b70343055483230644c53707a12') +parser.add_argument('--host', type=str, default='127.0.0.1') +parser.add_argument('--port', type=int, default=22222) +args = parser.parse_args() + +send = Queue() +receive = Queue() +sockets = {} + +#### LOGGING + +# fh = RotatingFileHandler(os.path.join(os.path.dirname(__file__), '.', 'log/server.log'), maxBytes=1024 * 1024, backupCount=5) +# fh.setLevel(logging.DEBUG) +# fh.setFormatter(logging.Formatter("%(asctime)s [%(process)s]:%(levelname)s:%(name)-10s| %(message)s", datefmt='%Y-%m-%d %H:%M:%S')) + +s = logging.StreamHandler(sys.stdout) +s.setLevel(logging.DEBUG) +s.setFormatter(logging.Formatter("%(message)s")) + +logger = logging.getLogger('server') +logger.setLevel(logging.DEBUG) +# logger.addHandler(fh) +logger.addHandler(s) + +#### ./LOGGING + +def socket_incoming_connection(socket, address): + + logger.debug('connected %s', address) + + sockets[address] = socket + + unpacker = Unpacker(encoding='utf-8') + while True: + data = socket.recv(4096) + + if not data: + logger.debug('closed connection %s', address) + break + + unpacker.feed(data) + + for msg in unpacker: + receive.put(InMsg(msg, address)) + # logger.debug('got socket msg: %s', msg) + + sockets.pop(address) + +def socket_msg_sender(sockets, q): + while True: + msg = q.get() + if isinstance(msg, OutMsg) and msg.to in sockets: + sockets[msg.to].sendall(msgpack.packb(msg, use_bin_type=True)) + # logger.debug('send reply %s', msg.to) + + + +def vacuum_commands_handler(ip, token, q): + vac = Vacuum(ip, token, 0) + vac.manual_seqnum = 0 + + while True: + msg = q.get() + try: + cmd = msg.pop(0) + if hasattr(VacuumCommand, cmd): + result = getattr(VacuumCommand, cmd)(vac, *msg) + else: + result = {'error': 'command [%s] not found' % cmd} + except (DeviceException, Exception) as e: + result = {'error': 'python-miio: %s' % e} + finally: + result.update({'cmd': cmd}) + # logger.debug('vac result %s', result) + send.put(OutMsg(result, msg.to)) + + + +class VacuumCommand(object): + + @classmethod + def status(cls, vac): + res = vac.status() + if not res: + return { + 'error': 'no response' + } + + if res.error_code: + return { + 'error': res.error + } + + return { + 'state_code': res.state_code, + 'battery': res.battery, + 'fan_level': res.fanspeed, + 'clean_seconds': res.data["clean_time"], + 'clean_area': res.clean_area + } + + @classmethod + def start(cls, vac): + return {'code': vac.start()} + + @classmethod + def stop(cls, vac): + return {'code': vac.stop()} + + @classmethod + def spot(cls, vac): + return {'code': vac.spot()} + + @classmethod + def pause(cls, vac): + return {'code': vac.pause()} + + @classmethod + def home(cls, vac): + return {'code': vac.home()} + + @classmethod + def find(cls, vac): + return {'code': vac.find()} + + @classmethod + def set_fan_level(cls, vac, level): + return {'code': vac.set_fan_speed(int(level))} + + @classmethod + def consumable_status(cls, vac): + res = vac.consumable_status() + return { + 'main_brush': res.data['main_brush_work_time'], + 'side_brush': res.data['side_brush_work_time'], + 'filter': res.data['filter_work_time'], + 'sensor': res.data['sensor_dirty_time'] + } + + @classmethod + def care_reset_main_brush(cls, vac): + return {'code': vac.send('reset_consumable', ['main_brush_work_time'])} + + @classmethod + def care_reset_side_brush(cls, vac): + return {'code': vac.send('reset_consumable', ['side_brush_work_time'])} + + @classmethod + def care_reset_filter(cls, vac): + return {'code': vac.send('reset_consumable', ['filter_work_time'])} + + @classmethod + def care_reset_sensor(cls, vac): + return {'code': vac.send('reset_consumable', ['sensor_dirty_time'])} + + +class InMsg(list): + def __init__(self, data, to, **kwargs): + super(InMsg, self).__init__(**kwargs) + self.extend(data) + self.to = to + + +class OutMsg(dict): + def __init__(self, data, to, **kwargs): + super(OutMsg, self).__init__(**kwargs) + self.update(data) + self.to = to + + +if __name__ == '__main__': + + server = StreamServer((args.host, args.port), socket_incoming_connection) + logger.debug('Starting server on %s %s' % (args.host, args.port)) + + services = Group() + services.spawn(server.serve_forever) + services.spawn(vacuum_commands_handler, args.ip, args.token, receive) + services.spawn(socket_msg_sender, sockets, send) + services.join() diff --git a/test.py b/test.py new file mode 100644 index 0000000..7fbe84a --- /dev/null +++ b/test.py @@ -0,0 +1,59 @@ +import argparse +import os +import subprocess +import sys +from time import sleep + +module_paths = [x[0] for x in os.walk( os.path.join(os.path.dirname(__file__), '.', '.env/lib/') ) if x[0].endswith('site-packages') ] +for mp in module_paths: + sys.path.append(mp) + +from msgpack import Unpacker +import socket +import msgpack + +parser = argparse.ArgumentParser() +parser.add_argument('ip', type=str, help='vacuum ip address', default='192.168.1.12"') +parser.add_argument('token', type=str, help='token', default='476e6b70343055483230644c53707a12') +parser.add_argument('--host', type=str, default='127.0.0.1') +parser.add_argument('--port', type=int, default=22222) +args = parser.parse_args() + +print('starting server.py') + +FNULL = open(os.devnull, 'w') +sProc = subprocess.Popen(['python3', + os.path.join(os.path.dirname(__file__), '.') + '/server.py', + args.ip, args.token, '--host', args.host, '--port', str(args.port)], + shell=False, stdout=FNULL, stderr=subprocess.PIPE) +sleep(1) + +print('trying connect to %s:%s' % (args.host, args.port)) + +client = socket.create_connection((args.host, args.port)) +client.sendall(msgpack.packb(['status'], use_bin_type=True)) + +print("sent request to server [status]") +print("reading response...") + +def _reader(client): + unpacker = Unpacker(encoding='utf-8') + + while True: + data = client.recv(4096) + + if not data: + print('connection closed') + break + + unpacker.feed(data) + + for msg in unpacker: + print('got server reply', msg) + return + +_reader(client) + +sProc.kill() + +