├── README ├── docker.png ├── tmall.png ├── xmolo-zx.png └── tmall-device.png ├── molobot ├── manifest.json ├── const.py ├── molo_bot_main.py ├── molo_client_config.py ├── molo_socket_helper.py ├── utils.py ├── molo_client_app.py ├── __init__.py ├── molo_tcp_pack.py └── molo_bot_client.py ├── auto_install.py └── README.md /README/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smarthomefans/molobot/HEAD/README/docker.png -------------------------------------------------------------------------------- /README/tmall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smarthomefans/molobot/HEAD/README/tmall.png -------------------------------------------------------------------------------- /README/xmolo-zx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smarthomefans/molobot/HEAD/README/xmolo-zx.png -------------------------------------------------------------------------------- /README/tmall-device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smarthomefans/molobot/HEAD/README/tmall-device.png -------------------------------------------------------------------------------- /molobot/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "molobot", 3 | "name": "molobot", 4 | "documentation": "https://smarthomefans.jtsh.top", 5 | "dependencies": [], 6 | "codeowners": [], 7 | "requirements": [], 8 | "version": "1.0.0" 9 | } 10 | -------------------------------------------------------------------------------- /molobot/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Molobot.""" 2 | 3 | BUFFER_SIZE = 1024 4 | 5 | CLIENT_VERSION = '0.1' 6 | CONNECTED = 1 7 | 8 | PING_INTERVAL_DEFAULT = 10 9 | 10 | RECONNECT_INTERVAL = 5 11 | 12 | STAGE_SERVER_UNCONNECTED = 'server_unconnected' 13 | STAGE_SERVER_CONNECTED = 'server_connected' 14 | STAGE_AUTH_BINDED = 'auth_binded' 15 | 16 | TCP_PACK_HEADER_LEN = 16 17 | TOKEN_KEY_NAME = 'slavertoken' 18 | 19 | CLIENT_STATUS_UNBINDED = "unbinded" 20 | CLIENT_STATUS_BINDED = "binded" 21 | 22 | CONFIG_FILE_NAME = "molo_bot_config_x.yaml" 23 | 24 | TCP_CONNECTION_ACTIVATE_TIME = 60 25 | -------------------------------------------------------------------------------- /molobot/molo_bot_main.py: -------------------------------------------------------------------------------- 1 | """Main interface for Molobot.""" 2 | from .molo_client_app import MOLO_CLIENT_APP 3 | from .molo_client_config import MOLO_CONFIGS 4 | from .molo_bot_client import MoloBotClient 5 | 6 | 7 | def run_aligenie(hass): 8 | """Run Molobot application.""" 9 | molo_client = MoloBotClient( 10 | MOLO_CONFIGS.get_config_object()['server']['host'], 11 | int(MOLO_CONFIGS.get_config_object()['server']['port']), 12 | MOLO_CLIENT_APP.async_map) 13 | MOLO_CLIENT_APP.run_aligenie_bot(hass, molo_client) 14 | 15 | 16 | def stop_aligenie(): 17 | """Stop Molobot application.""" 18 | MOLO_CLIENT_APP.stop_aligenie_bot() 19 | -------------------------------------------------------------------------------- /molobot/molo_client_config.py: -------------------------------------------------------------------------------- 1 | """Configuration class for Molobot.""" 2 | 3 | 4 | class MoloConfigs: 5 | """Configuration class for Molobot.""" 6 | 7 | config_object = dict() 8 | 9 | config_debug = { 10 | 'server': { 11 | 'haweb': "127.0.0.1", 12 | 'host': "127.0.0.1", 13 | 'port': 4443, 14 | 'bufsize': 1024 15 | }, 16 | 'ha': { 17 | 'host': "127.0.0.1", 18 | 'port': 8123 19 | } 20 | } 21 | 22 | config_release = { 23 | 'server': { 24 | 'haweb': "smarthomefans.jtsh.top", 25 | 'host': "haprx.jtsh.top", 26 | 'port': 4343, 27 | 'bufsize': 1024 28 | }, 29 | 'ha': { 30 | 'host': "127.0.0.1", 31 | 'port': 8123 32 | } 33 | } 34 | 35 | def load(self, mode): 36 | """Load configs by reading mode in configuration.yaml.""" 37 | if mode == 'debug': 38 | self.config_object = self.config_debug 39 | else: 40 | self.config_object = self.config_release 41 | 42 | def get_config_object(self): 43 | """Get config_object, reload if not exist.""" 44 | if not self.config_object: 45 | self.load('release') 46 | return self.config_object 47 | 48 | 49 | MOLO_CONFIGS = MoloConfigs() 50 | -------------------------------------------------------------------------------- /molobot/molo_socket_helper.py: -------------------------------------------------------------------------------- 1 | """Socket helper class for Molobot.""" 2 | import platform 3 | 4 | from .const import CONFIG_FILE_NAME 5 | from .utils import get_local_seed, get_mac_addr, get_rand_char, save_local_seed, load_uuid 6 | 7 | 8 | class MoloSocketHelper: 9 | """Socket helper class for Molobot.""" 10 | 11 | @classmethod 12 | def molo_auth(cls, client_version, hass, ha_version): 13 | """Construct register authorization packet.""" 14 | payload = dict() 15 | payload['ClientId'] = '' 16 | payload['OS'] = platform.platform() 17 | payload['PyVersion'] = platform.python_version() 18 | payload['ClientVersion'] = client_version 19 | payload['HAVersion'] = ha_version 20 | payload['UUID'] = load_uuid(hass) 21 | payload['App'] = 'molobot' 22 | 23 | payload['MacAddr'] = get_mac_addr() 24 | local_seed = get_rand_char(32).lower() 25 | local_seed_saved = get_local_seed(hass.config.path(CONFIG_FILE_NAME)) 26 | if local_seed_saved: 27 | local_seed = local_seed_saved 28 | else: 29 | save_local_seed(hass.config.path(CONFIG_FILE_NAME), local_seed) 30 | payload['LocalSeed'] = local_seed 31 | 32 | body = dict() 33 | body['Type'] = 'Auth' 34 | body['Payload'] = payload 35 | return body 36 | 37 | @classmethod 38 | def req_tunnel(cls, protocol, hostname, subdomain, remote_port, clientid): 39 | """Construct request tunnel packet.""" 40 | payload = dict() 41 | payload['ReqId'] = get_rand_char(8) 42 | payload['Protocol'] = protocol 43 | payload['Hostname'] = hostname 44 | payload['Subdomain'] = subdomain 45 | payload['HttpAuth'] = '' 46 | payload['RemotePort'] = remote_port 47 | payload['MacAddr'] = get_mac_addr() 48 | if clientid: 49 | payload['ClientId'] = clientid 50 | body = dict() 51 | body['Type'] = 'ReqTunnel' 52 | body['Payload'] = payload 53 | return body 54 | 55 | @classmethod 56 | def ping(cls, token, client_status): 57 | """Construct ping packet.""" 58 | payload = dict() 59 | body = dict() 60 | if token: 61 | payload['Token'] = token 62 | if client_status and client_status: 63 | payload['Status'] = client_status 64 | body['Type'] = 'Ping' 65 | body['Payload'] = payload 66 | return body 67 | -------------------------------------------------------------------------------- /molobot/utils.py: -------------------------------------------------------------------------------- 1 | """Utils for Molobot.""" 2 | import logging 3 | import random 4 | import socket 5 | import uuid 6 | import yaml 7 | import json 8 | 9 | from .const import TCP_PACK_HEADER_LEN 10 | 11 | LOGGER = logging.getLogger(__package__) 12 | 13 | 14 | def get_mac_addr(): 15 | """Get local mac address.""" 16 | import uuid 17 | node = uuid.getnode() 18 | mac = uuid.UUID(int=node).hex[-12:] 19 | return mac 20 | 21 | 22 | def dns_open(host): 23 | """Get ip from hostname.""" 24 | try: 25 | ip_host = socket.gethostbyname(host) 26 | except socket.error: 27 | return None 28 | 29 | return ip_host 30 | 31 | 32 | def len_to_byte(length): 33 | """Write length integer to bytes buffer.""" 34 | return length.to_bytes(TCP_PACK_HEADER_LEN, byteorder='little') 35 | 36 | 37 | def byte_to_len(byteval): 38 | """Read length integer from bytes.""" 39 | if len(byteval) == TCP_PACK_HEADER_LEN: 40 | return int.from_bytes(byteval, byteorder='little') 41 | return 0 42 | 43 | 44 | def get_rand_char(length): 45 | """Generate random string by length.""" 46 | _chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz" 47 | return ''.join(random.sample(_chars, length)) 48 | 49 | 50 | def get_local_seed(config_file): 51 | """Read seed from local file.""" 52 | local_seed = "" 53 | try: 54 | with open(config_file, 'r') as file_obj: 55 | config_data = yaml.load(file_obj) 56 | if config_data and 'molobot' in config_data: 57 | if 'localseed' in config_data['molobot']: 58 | local_seed = config_data['molobot']['localseed'] 59 | except (EnvironmentError, yaml.YAMLError): 60 | pass 61 | return local_seed 62 | 63 | 64 | def save_local_seed(config_file, local_seed): 65 | """Save seed to local file.""" 66 | config_data = None 67 | try: 68 | with open(config_file, 'r') as rfile: 69 | config_data = yaml.load(rfile) 70 | except (EnvironmentError, yaml.YAMLError): 71 | pass 72 | 73 | if not config_data: 74 | config_data = {} 75 | config_data['molobot'] = {} 76 | try: 77 | with open(config_file, 'w') as wfile: 78 | config_data['molobot']['localseed'] = local_seed 79 | yaml.dump(config_data, wfile, default_flow_style=False) 80 | except (EnvironmentError, yaml.YAMLError): 81 | pass 82 | 83 | 84 | def load_uuid(hass, filename='.uuid'): 85 | """Load UUID from a file or return None.""" 86 | try: 87 | with open(hass.config.path(filename)) as fptr: 88 | jsonf = json.loads(fptr.read()) 89 | return uuid.UUID(jsonf['uuid'], version=4).hex 90 | except (ValueError, AttributeError, FileNotFoundError): 91 | return None 92 | -------------------------------------------------------------------------------- /auto_install.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zipfile 3 | import shutil 4 | import sys 5 | 6 | input_fun = None 7 | if sys.version_info < (3, 0): 8 | input_fun = raw_input 9 | else: 10 | input_fun = input 11 | 12 | def find(name, path): 13 | for root, dirs, files in os.walk(path): 14 | if name in files: 15 | return os.path.join(root, name) 16 | return None 17 | 18 | 19 | def get_config_path(): 20 | path = find('.HA_VERSION', '/') 21 | if not path: 22 | return None 23 | path = path[:len(path) - 11] 24 | print("Home Assistant configuration path found: %s" % (path)) 25 | return path 26 | 27 | 28 | def uninstall_old(path): 29 | print("Uninstall molobot old version...") 30 | path += '/custom_components/molobot' 31 | try: 32 | shutil.rmtree(path) 33 | except Exception: 34 | pass 35 | 36 | 37 | def download_file(): 38 | global start_time 39 | print("Downloading file...") 40 | curl = 'curl --show-error --retry 5 https://codeload.github.com/smarthomefans/molobot/zip/master >> molobot-master.zip' 41 | os.system(curl) 42 | 43 | 44 | def extract_file(): 45 | print("Extracting file...") 46 | try: 47 | shutil.rmtree('molo_install_temp/') 48 | except Exception: 49 | pass 50 | with zipfile.ZipFile("molobot-master.zip", 'r') as f: 51 | for file in f.namelist(): 52 | f.extract(file, "molo_install_temp/") 53 | 54 | 55 | def copy_file(path): 56 | print("Copying file...") 57 | path += '/custom_components/molobot' 58 | frompath = 'molo_install_temp/molobot-master/molobot' 59 | shutil.copytree(frompath, path) 60 | 61 | 62 | def configurate(path): 63 | print("Configurating...") 64 | path += '/configuration.yaml' 65 | shutil.copy(path, path + '.bak') 66 | file_str = open(path, 'r').read() 67 | if '\nmolobot:' in file_str: 68 | return 69 | print("input your bound phone number:") 70 | phone = str(input_fun()) 71 | print("input your bound password:") 72 | password = str(input_fun()) 73 | with open(path, 'a') as f: 74 | f.write('\nmolobot:\n phone: %s\n password: %s\n' % (phone, password)) 75 | 76 | 77 | def delete_file(): 78 | delete_list = ['auto_install.py', 'molobot-master.zip', 'molo_install_temp/'] 79 | for item in delete_list: 80 | try: 81 | shutil.rmtree(item, ignore_errors=True) 82 | except Exception: 83 | pass 84 | os.remove(item) 85 | except Exception: 86 | pass 87 | 88 | 89 | if __name__ == '__main__': 90 | path = get_config_path() 91 | if not path: 92 | print("Error finding Home Assistant configuration path!") 93 | print("Install failed.") 94 | exit(0) 95 | 96 | uninstall_old(path) 97 | download_file() 98 | extract_file() 99 | copy_file(path) 100 | configurate(path) 101 | delete_file() 102 | 103 | print("Successfully installed.") 104 | print("configuration.yaml has backed up to configuration.yaml.bak") 105 | print("For any questions, please contact us:") 106 | print(" - Email: yaming1106@gmail.com") 107 | print(" - QQGroup: 598514359") 108 | -------------------------------------------------------------------------------- /molobot/molo_client_app.py: -------------------------------------------------------------------------------- 1 | """Application class for Molobot.""" 2 | import asyncore 3 | import logging 4 | import threading 5 | import time 6 | 7 | from .const import (PING_INTERVAL_DEFAULT, RECONNECT_INTERVAL, 8 | TCP_CONNECTION_ACTIVATE_TIME) 9 | from .utils import LOGGER 10 | 11 | 12 | class MoloClientApp: 13 | """Application class for Molobot.""" 14 | 15 | ping_thread = None 16 | main_thread = None 17 | is_exited = False 18 | ping_interval = PING_INTERVAL_DEFAULT 19 | last_activate_time = None 20 | async_map = None 21 | 22 | def __init__(self): 23 | """Initialize application arguments.""" 24 | self.molo_client = None 25 | self.lock = threading.Lock() 26 | self.ping_buffer = None 27 | self.hass_context = None 28 | self.reset_activate_time() 29 | self.async_map = {} 30 | 31 | def main_loop(self): 32 | """Handle main loop and reconnection.""" 33 | self.molo_client.sock_connect() 34 | while not self.is_exited: 35 | try: 36 | asyncore.loop(map=self.async_map) 37 | except asyncore.ExitNow as exc: 38 | logging.exception(exc) 39 | LOGGER.error("asyncore.loop exception") 40 | 41 | if not self.is_exited: 42 | try: 43 | asyncore.close_all() 44 | self.molo_client.sock_connect() 45 | time.sleep(RECONNECT_INTERVAL) 46 | LOGGER.info("moloserver reconnecting...") 47 | except Exception as exc: 48 | print("main_loop(): " + str(exc)) 49 | LOGGER.info("reconnect failed, retry...") 50 | time.sleep(RECONNECT_INTERVAL) 51 | asyncore.close_all() 52 | LOGGER.debug("main loop exited") 53 | 54 | def run_aligenie_bot(self, hass, molo_client): 55 | """Start application main thread and ping thread.""" 56 | self.hass_context = hass 57 | self.molo_client = molo_client 58 | self.ping_thread = threading.Thread(target=self.ping_server) 59 | self.ping_thread.setDaemon(True) 60 | self.ping_thread.start() 61 | 62 | self.main_thread = threading.Thread(target=self.main_loop) 63 | self.main_thread.setDaemon(True) 64 | self.main_thread.start() 65 | 66 | def ping_server(self): 67 | """Send ping to server every ping_interval.""" 68 | while not self.is_exited: 69 | try: 70 | if self.molo_client: 71 | self.set_ping_buffer(self.molo_client.ping_server_buffer()) 72 | time.sleep(self.ping_interval) 73 | 74 | time_interval = time.time() - self.last_activate_time 75 | LOGGER.debug("data interval: %f", time_interval) 76 | if time_interval > TCP_CONNECTION_ACTIVATE_TIME: 77 | LOGGER.info("connection timeout, reconnecting server") 78 | self.molo_client.handle_close() 79 | self.reset_activate_time() 80 | 81 | except Exception as exc: 82 | LOGGER.error("ping_server(): %s", exc) 83 | asyncore.close_all() 84 | self.molo_client.sock_connect() 85 | time.sleep(RECONNECT_INTERVAL) 86 | LOGGER.info("moloserver reconnecting...") 87 | 88 | def reset_activate_time(self): 89 | """Reset last activate time for timeout.""" 90 | self.last_activate_time = time.time() 91 | 92 | def set_ping_buffer(self, buffer): 93 | """Send ping.""" 94 | with self.lock: 95 | self.ping_buffer = buffer 96 | 97 | def get_ping_buffer(self): 98 | """Get ping sending buffer.""" 99 | if not self.ping_buffer: 100 | return None 101 | 102 | with self.lock: 103 | buffer = self.ping_buffer 104 | self.ping_buffer = None 105 | return buffer 106 | 107 | def stop_aligenie_bot(self): 108 | """Stop application, close all sessions.""" 109 | LOGGER.debug("stopping aligenie bot") 110 | self.is_exited = True 111 | asyncore.close_all() 112 | 113 | 114 | MOLO_CLIENT_APP = MoloClientApp() 115 | -------------------------------------------------------------------------------- /molobot/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Molobot. 3 | 4 | For more details about this component, please refer to the documentation at 5 | https://github.com/haoctopus/molobot 6 | """ 7 | 8 | from homeassistant.const import (EVENT_HOMEASSISTANT_START, 9 | EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED,) 10 | 11 | from .molo_client_config import MOLO_CONFIGS 12 | from .molo_client_app import MOLO_CLIENT_APP 13 | from .utils import LOGGER 14 | import time 15 | 16 | DOMAIN = 'molobot' 17 | SERVICE_NAME = 'force_update' 18 | NOTIFYID = 'molobotnotifyid' 19 | VERSION = 101 20 | is_init = False 21 | last_start_time = time.time() 22 | 23 | 24 | def setup(hass, config): 25 | """Set up molobot component.""" 26 | LOGGER.info("Begin setup molobot!") 27 | 28 | 29 | # Load config mode from configuration.yaml. 30 | cfg = config[DOMAIN] 31 | cfg.update({"__version__": VERSION}) 32 | 33 | if 'mode' in cfg: 34 | MOLO_CONFIGS.load(cfg['mode']) 35 | else: 36 | MOLO_CONFIGS.load('release') 37 | 38 | if 'http' in config and 'server_host' in config['http']: 39 | tmp_host = config['http']['server_host'] 40 | MOLO_CONFIGS.get_config_object()['ha']['host'] = tmp_host 41 | if 'http' in config and 'server_port' in config['http']: 42 | tmp_port = config['http']['server_port'] 43 | MOLO_CONFIGS.get_config_object()['ha']['port'] = tmp_port 44 | 45 | MOLO_CONFIGS.get_config_object()["hassconfig"] = cfg 46 | 47 | async def stop_molobot(event): 48 | """Stop Molobot while closing ha.""" 49 | LOGGER.info("Begin stop molobot!") 50 | from .molo_bot_main import stop_aligenie 51 | stop_aligenie() 52 | 53 | async def start_molobot(event): 54 | """Start Molobot while starting ha.""" 55 | LOGGER.debug("molobot started!") 56 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_molobot) 57 | last_start_time = time.time() 58 | 59 | 60 | 61 | async def hass_started(event): 62 | global is_init 63 | is_init = True 64 | 65 | async def on_state_changed(event): 66 | """Disable the dismiss button.""" 67 | global is_init 68 | global last_start_time 69 | 70 | if MOLO_CLIENT_APP.molo_client: 71 | if is_init : 72 | MOLO_CLIENT_APP.molo_client.sync_device(True, 2) 73 | is_init = False 74 | elif last_start_time and (time.time() - last_start_time > 30): 75 | last_start_time = None 76 | MOLO_CLIENT_APP.molo_client.sync_device(True, 2) 77 | elif not is_init or not last_start_time: 78 | new_state = event.data.get("new_state") 79 | if not new_state: 80 | return 81 | 82 | entity_id = event.data.get("entity_id") or new_state.entity_id 83 | domain = _get_domain(entity_id) 84 | 85 | if domain == 'switch': 86 | # notify to tmall 87 | state = { 88 | 'entity_id': entity_id, 89 | 'state': new_state.state, 90 | 'domain': domain 91 | 92 | } 93 | LOGGER.error(state) 94 | MOLO_CLIENT_APP.molo_client.sync_device_state(state) 95 | else: 96 | MOLO_CLIENT_APP.molo_client.sync_device(False, 60) 97 | 98 | def _get_domain(entity_id): 99 | return entity_id.split(".")[0] 100 | 101 | 102 | def force_update(call): 103 | """Handle the service call.""" 104 | MOLO_CLIENT_APP.molo_client.sync_device(True, 2, force_diff=True) 105 | hass.states.set("%s_service.%s" % (DOMAIN, SERVICE_NAME) , 'update time: %s' % time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())) 106 | 107 | hass.services.register(DOMAIN, SERVICE_NAME, force_update) 108 | 109 | from .molo_bot_main import run_aligenie 110 | run_aligenie(hass) 111 | 112 | if not cfg.get("disablenotify", False): 113 | hass.components.persistent_notification.async_create( 114 | "Welcome to molobot!", "Molo Bot Infomation", "molo_bot_notify") 115 | 116 | hass.bus.async_listen_once('homeassistant_started', hass_started) 117 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_molobot) 118 | hass.bus.async_listen(EVENT_STATE_CHANGED, on_state_changed) 119 | 120 | 121 | return True 122 | 123 | 124 | -------------------------------------------------------------------------------- /molobot/molo_tcp_pack.py: -------------------------------------------------------------------------------- 1 | """TCP packet header define for Molobot.""" 2 | import json 3 | import logging 4 | 5 | from .utils import LOGGER 6 | 7 | # pack format: 8 | # MAGIC 2BYTES 9 | # HEADER_LEN 4BYTES 10 | # HEADER_JSON_STR HEADER_LEN 11 | # BODY_LEN 4BYTES 12 | # BODY_JSON_STR BODY_LEN 13 | 14 | 15 | def bytetolen(byteval): 16 | """Read length integer from bytes.""" 17 | if len(byteval) == MoloTcpPack.PACK_LEN_SIZE: 18 | return int.from_bytes(byteval, byteorder='little') 19 | return 0 20 | 21 | 22 | def lentobyte(length): 23 | """Write length integer to bytes buffer.""" 24 | return length.to_bytes(MoloTcpPack.PACK_LEN_SIZE, byteorder='little') 25 | 26 | 27 | class MoloTcpPack(): 28 | """TCP packet header define class for Molobot.""" 29 | 30 | HEADER_PREFIX_EN = 34 31 | MAGIC_LEN = 2 32 | MOLO_TCP_MAGIC = b"MP" 33 | PACK_VERSION = 1 34 | PACK_LEN_SIZE = 32 35 | 36 | ERR_OK = 0 37 | ERR_INSUFFICIENT_BUFFER = 1 38 | ERR_MALFORMED = 2 39 | 40 | @classmethod 41 | def generate_tcp_buffer(cls, body_jdata): 42 | """Construct TCP packet from json data.""" 43 | header_jdata = {} 44 | header_jdata["ver"] = MoloTcpPack.PACK_VERSION 45 | header_jdata_str = json.dumps(header_jdata) 46 | header_jdata_bytes = header_jdata_str.encode('utf-8') 47 | tcp_buffer = MoloTcpPack.MOLO_TCP_MAGIC + lentobyte( 48 | len(header_jdata_bytes)) + header_jdata_bytes 49 | 50 | body_jdata_str = json.dumps(body_jdata) 51 | body_jdata_bytes = body_jdata_str.encode('utf-8') 52 | tcp_buffer += lentobyte( 53 | len(body_jdata_bytes)) + body_jdata_bytes 54 | return tcp_buffer 55 | 56 | def __init__(self): 57 | """Initialize TCP packet arguments.""" 58 | self.header_jdata = None 59 | self.header_len = None 60 | self.magic = None 61 | self.tmp_buffer = None 62 | self.error_code = None 63 | self.body_len = None 64 | self.body_jdata = None 65 | self.clear() 66 | 67 | def clear(self): 68 | """Reset TCP packet arguments.""" 69 | self.header_jdata = None 70 | self.header_len = None 71 | self.magic = None 72 | self.tmp_buffer = None 73 | self.error_code = MoloTcpPack.ERR_OK 74 | self.body_len = None 75 | self.body_jdata = None 76 | 77 | def recv_header_prefix(self): 78 | """Read received TCP header prefix.""" 79 | if len(self.tmp_buffer) < MoloTcpPack.HEADER_PREFIX_EN: 80 | return False 81 | self.magic = self.tmp_buffer[:MoloTcpPack.MAGIC_LEN] 82 | if self.magic != MoloTcpPack.MOLO_TCP_MAGIC: 83 | self.error_code = MoloTcpPack.ERR_MALFORMED 84 | LOGGER.error("wrong tcp header magic %s", self.magic) 85 | return False 86 | 87 | self.header_len = bytetolen(self.tmp_buffer[ 88 | MoloTcpPack.MAGIC_LEN:MoloTcpPack.HEADER_PREFIX_EN]) 89 | 90 | self.tmp_buffer = self.tmp_buffer[MoloTcpPack.HEADER_PREFIX_EN:] 91 | return True 92 | 93 | def recv_header(self): 94 | """Read received TCP header.""" 95 | if len(self.tmp_buffer) < self.header_len: 96 | return False 97 | try: 98 | json_buff = self.tmp_buffer[:self.header_len].decode('utf-8') 99 | self.header_jdata = json.loads(json_buff) 100 | except (json.JSONDecodeError, UnicodeDecodeError) as exc: 101 | self.error_code = MoloTcpPack.ERR_MALFORMED 102 | LOGGER.error("MoloTcpPack recv header error %s", 103 | self.tmp_buffer[:self.header_len]) 104 | logging.exception(exc) 105 | return False 106 | 107 | self.tmp_buffer = self.tmp_buffer[self.header_len:] 108 | return True 109 | 110 | def recv_body_len(self): 111 | """Read received TCP body length.""" 112 | if len(self.tmp_buffer) < MoloTcpPack.PACK_LEN_SIZE: 113 | return False 114 | self.body_len = bytetolen( 115 | self.tmp_buffer[:MoloTcpPack.PACK_LEN_SIZE]) 116 | self.tmp_buffer = self.tmp_buffer[MoloTcpPack.PACK_LEN_SIZE:] 117 | return True 118 | 119 | def recv_body(self): 120 | """Read received TCP body.""" 121 | if len(self.tmp_buffer) < self.body_len: 122 | return False 123 | try: 124 | json_buff = self.tmp_buffer[:self.body_len].decode('utf-8') 125 | self.body_jdata = json.loads(json_buff) 126 | except (json.JSONDecodeError, UnicodeDecodeError) as exc: 127 | self.error_code = MoloTcpPack.ERR_MALFORMED 128 | LOGGER.error("MoloTcpPack recv body error %s", 129 | self.tmp_buffer[:self.body_len]) 130 | logging.exception(exc) 131 | return False 132 | self.tmp_buffer = self.tmp_buffer[self.body_len:] 133 | return True 134 | 135 | def has_recved_header_prefix(self): 136 | """If self has received header prefix.""" 137 | return self.header_len is not None and self.magic is not None 138 | 139 | def has_recved_header(self): 140 | """If self has received header.""" 141 | return self.header_jdata is not None 142 | 143 | def has_recved_body_len(self): 144 | """If self has received body length.""" 145 | return self.body_len is not None 146 | 147 | def has_recved_body(self): 148 | """If self has received body.""" 149 | return self.body_jdata is not None 150 | 151 | def recv_buffer(self, buffer): 152 | """Handle received.""" 153 | if not buffer: 154 | return False 155 | 156 | ret = False 157 | if self.error_code == MoloTcpPack.ERR_OK: 158 | self.clear() 159 | self.error_code = MoloTcpPack.ERR_INSUFFICIENT_BUFFER 160 | 161 | self.tmp_buffer = buffer 162 | 163 | if not self.has_recved_header_prefix(): 164 | ret = self.recv_header_prefix() 165 | if not ret: 166 | return ret 167 | 168 | if not self.has_recved_header(): 169 | ret = self.recv_header() 170 | if not ret: 171 | return ret 172 | 173 | if not self.has_recved_body_len(): 174 | ret = self.recv_body_len() 175 | if not ret: 176 | return ret 177 | 178 | if not self.has_recved_body(): 179 | ret = self.recv_body() 180 | if not ret: 181 | return ret 182 | 183 | self.error_code = MoloTcpPack.ERR_OK 184 | return True 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 中文简介 2 | 3 | ![img](README/xmolo-zx.png) 4 | 5 | **HA交流QQ群: 598514359** 6 | 7 | **注意天猫精灵里面搜索 泛艺** 8 | **注意天猫精灵里面搜索 泛艺** 9 | **注意天猫精灵里面搜索 泛艺** 10 | 11 | 这是一个能让天猫精灵接入Home Assistant的组件. 通过使用此组件可以实现用语音让天猫精灵控制家里已经连上HA的硬件. 12 | 13 | 本组件不会上传用户所绑定天猫精灵的手机号和密码到服务器上, 而是将其通过SHA1哈希算法生成token来与阿里平台交互, 账号数据对于服务器透明, 不用担心账号安全问题: 14 | 15 | ```python 16 | # molo_bot_client.py: get_phonesign(): Line 99 17 | ... 18 | hkey = ("molobot:%s:%s" % (phone, password)).encode('utf-8') 19 | self._phone_sign = hashlib.sha1(hkey).hexdigest() 20 | return self._phone_sign 21 | ... 22 | ``` 23 | 24 | 25 | ### 一键安装 26 | 27 | >在终端直接执行下面命令一键安装molobot: 28 | 29 | ```shell 30 | python <(curl "https://raw.githubusercontent.com/smarthomefans/molobot/master/auto_install.py" -s -N) 31 | ``` 32 | 33 | >等待提示安装成功后手动重启Home Assistant即可。 34 | 35 | >若此方法安装失败,请用下面的方法手动安装。有`curl`组件的Windows用户也可以通过`cmd`。 36 | 37 | ### 手动安装 38 | 39 | - [molobot组件](https://github.com/smarthomefans/molobot) 40 | 41 | >>1、下载`molobot`文件夹,保存在`/custom_components/`目录中,若`custom_components`目录不存在则自行创建。 42 | 43 | 44 | **用户名和密码仅用于在天猫添加设备授权使用,保持一致即可。不是天猫或者阿里其他平台的密码** 45 | **用户名和密码仅用于在天猫添加设备授权使用,保持一致即可。不是天猫或者阿里其他平台的密码** 46 | **用户名和密码仅用于在天猫添加设备授权使用,保持一致即可。不是天猫或者阿里其他平台的密码** 47 | >>2、编辑`/configuration.yaml`文件,添加如下配置 48 | ```yaml 49 | molobot: 50 | phone: 131xxxxxxxx # 天猫精灵绑定的手机号 51 | password: 123456 # 绑定密码 52 | ``` 53 | 54 | 55 | - homeassistant配置目录在哪? 56 | 57 | >>**Windows用户:** `%APPDATA%\.homeassistant` 58 | 59 | >>**Linux-based用户:** 可以通过执行`locate .homeassistant/configuration.yaml`命令,查找到的`.homeassistant`文件夹就是配置目录。 60 | 61 | >>**群晖Docker用户:** 进入Docker - 映像 - homeassistant - 高级设置 - 卷, `/config`对应的路径就是配置目录 62 | 63 | 64 | ## 天猫精灵app中配置实例 65 | 66 | * 安装最新版`天猫精灵`APP 67 | * 打开`天猫精灵`APP 68 | * 点击`我的`TAB 69 | * 点击`添加智能设备` 70 | * 搜索`molobot` 71 | * 点击`MoloBot M1` 72 | * 点击`绑定平台账号` 73 | * 填写`手机号`和`密码` 74 | * 确认授权,返回我的TAB,智能家居下查看全部 75 | 76 | ## 高级配置 77 | 78 | ### 设备分组查询或控制 79 | >比如小米传感器支持温度、湿度,在ha中被分为两个设备,但猫精不支持两个相同设备再同一房间,此时可以如下解决 80 | ```yaml 81 | molobot: 82 | phone: 131******** 83 | password: ****** 84 | 85 | group: 86 | - name: test_input #多开关控制,查询时其中一个为关,则状态为关 87 | entities: 88 | - input_boolean.notify_home1 89 | - input_boolean.notify_home2 90 | 91 | - name: test_sensor #可同时查询温度、湿度 92 | entities: 93 | - sensor.humidity_1 94 | - sensor.temperature_2 95 | ``` 96 | 97 | ### 自定义设备至`天猫精灵`的过滤配置,慎重配置,可能导致天猫精灵获取不到设备 98 | ```yaml 99 | molobot: 100 | phone: 131******** 101 | password: ****** 102 | 103 | filter: 104 | #白名单,只同步在白名单内的列表 105 | include_domains: 106 | #配置同exclude_domains 107 | 108 | include_entities: 109 | #配置同exclude_entities 110 | 111 | #黑名单,设备不同步至天猫精灵 112 | exclude_domains: #按domain过滤 113 | - input_boolean #过滤所有传感器 114 | 115 | exclude_entities: #按设备ID过滤 116 | - light.gateway_light_1 #设备1 117 | - light.gateway_light_2 #设备2 118 | ``` 119 | 120 | ### 禁止welcome molobot的通知提醒 121 | ```yaml 122 | molobot: 123 | phone: 131******** 124 | password: ****** 125 | 126 | disablenotify: true 127 | ``` 128 | 129 | ## 支持设备及属性 130 | 131 | 目前支持的设备类型有: 灯、开关、传感器、风扇、空调、摄像头、播放器、二元选择器. 132 | 133 | 目前支持的属性: 亮度、颜色、开关、温度、湿度、模式. 134 | 135 | 灯支持调整颜色 136 | 空调支持更换模式,自动、制冷、制热、通风、送风、除湿;支持调节温度 137 | 空调、风扇风速支持 自动、低风、中风、高风、最小、最大 138 | 摄像头、播放器、二元选择器 只支持开关 139 | 140 | 141 | ## 备注 142 | 143 | __注意,由于天猫精灵本身不支持自定义别名,在绑定成功后请在app中对设备设置位置和别名,否则将不能对这些设备进行操作。例如在“客厅”内有两个“灯”,则这两个灯都不能正常操作,需要改为“卧室”的“灯”和“客厅”的“灯”,或者改为“客厅”的“灯”和“客厅”的“吊灯”,才能正常操作。__ 144 | 145 | ### 相关链接 146 | 147 | 平台网站: 148 | 149 | molobot组件: 150 | 151 | ### 联系我们 152 | 153 | 如果安装和使用过程中遇到任何问题,可以通过以下方式联系我们,我们将在看到反馈后第一时间作出回应: 154 | 155 | Email: octopus201806@gmail.com 156 | 157 | QQ群: 598514359 158 | 159 | **** 160 | 161 | ![img](README/tmall.png) 162 | 163 | 绑定成功后就能在天猫精灵app中看到HA中绑定的设备. 164 | 165 | ![img](README/tmall-device.png) 166 | 167 | ## Description in English 168 | 169 | ![img](README/xmolo-zx.png) 170 | 171 | This is a component that allows the Tmall Genie to access Home Assistant. By using this component, you can use the voice to let the Tmall Genie control the devices that has connected to the HA. 172 | 173 | This component does not upload the phone number and password binds to the Tmall Genie to server. Instead, it generates a token through the SHA1 hash algorithm to interact with the Aligenie platform. The account data is transparent to the server, so there is no need to worry about account security: 174 | 175 | ```python 176 | # molo_bot_client.py: get_phonesign(): Line 93 177 | ... 178 | hkey = ("molobot:%s:%s" % (phone, password)).encode('utf-8') 179 | self._phone_sign = hashlib.sha1(hkey).hexdigest() 180 | return self._phone_sign 181 | ... 182 | ``` 183 | 184 | **【One-key install】** 185 | 186 | If you are Linux-based user, run the command below to install molobot automatically: 187 | 188 | ```shell 189 | python <(curl "https://raw.githubusercontent.com/smarthomefans/molobot/master/auto_install.py" -s -N) 190 | ``` 191 | 192 | Wait untill installation success, and restart your Home Assistant. 193 | 194 | If this not working, please install molobot manually according to the next section. For Windows user with `curl` component, run the command line in `cmd`. 195 | 196 | **【Installation】** 197 | 198 | - [molobot component](https://github.com/smarthomefans/molobot) 199 | 200 | Download `molobot` folder and put it under `homeassistant configuration directory/custom_components/`. If `custom_components` doesn't exist, create one. 201 | 202 | - Where is homeassistant configuration directory? 203 | 204 | **Windows user:** `%APPDATA%\.homeassistant` 205 | 206 | **Linux-based user:** Run command line `locate .homeassistant/configuration.yaml`. The `.homeassistant` folder in the returning result is the configuration directory. 207 | 208 | **Synology NAS Docker user:** Go to Docker - Images - Homeassistant - Advanced settings - Volumes, the path corresponding to `/config` is the configuration directory. 209 | 210 | ![img](README/docker.png) 211 | 212 | **【Configuration】** 213 | 214 | ```yaml 215 | molobot: 216 | phone: 131xxxxxxxx # phone number binds to the Tmall Genie 217 | password: 123456 # binding password 218 | ``` 219 | 220 | **【Tmall Genie app configuration】** 221 | 222 | Open Tmall Genie app - 我的 - 添加智能设备 - 找到MoloBot - 绑定设备 - 账户配置 - 填写手机号和密码: 223 | 224 | ![img](README/tmall.png) 225 | 226 | After the binding is successful, you can see the device bound in HA in the Tmall Genie app. 227 | 228 | ![img](README/tmall-device.png) 229 | 230 | __Note that since the Tmall Genie itself does not support custom aliases, please set the location and alias for the device in the app after the binding is successful, otherwise you will not be able to operate on these devices. For example, if there are two "lights" in the "living room", then these two lights can not operate normally, and need to be changed to "bedroom light" and "living room light", or "living room light" and "living room chandelier" can be operated normally.__ 231 | 232 | **【Supported devices and attributes】** 233 | 234 | Currently supported device types: lights, switches, sensors. 235 | 236 | Currently supported attributes: brightness, color, switch, temperature, humidity. 237 | 238 | **【Reference link】** 239 | 240 | Platform link: 241 | 242 | molobot component: 243 | 244 | **【Contact us】** 245 | 246 | Please contact us if you have any questions about installation and using molobot. We will respond as soon as we see the feedback. 247 | 248 | Email: octopus201806@gmail.com 249 | 250 | QQGroup: 598514359 251 | -------------------------------------------------------------------------------- /molobot/molo_bot_client.py: -------------------------------------------------------------------------------- 1 | """Client protocol class for Molobot.""" 2 | import re 3 | import copy 4 | import asyncore 5 | import queue 6 | import socket 7 | import time 8 | import json 9 | import hashlib 10 | import traceback 11 | 12 | from homeassistant.const import __short_version__ 13 | 14 | from .const import (BUFFER_SIZE, CLIENT_VERSION, CONFIG_FILE_NAME) 15 | from .molo_client_app import MOLO_CLIENT_APP 16 | from .molo_client_config import MOLO_CONFIGS 17 | from .molo_socket_helper import MoloSocketHelper 18 | from .molo_tcp_pack import MoloTcpPack 19 | from .utils import LOGGER, dns_open, get_rand_char, save_local_seed 20 | from homeassistant.helpers.json import JSONEncoder 21 | 22 | 23 | class MoloBotClient(asyncore.dispatcher): 24 | """Client protocol class for Molobot.""" 25 | 26 | tunnel = {} 27 | tunnel['protocol'] = 'http' 28 | tunnel['hostname'] = '' 29 | tunnel['subdomain'] = '' 30 | tunnel['rport'] = 0 31 | tunnel['lhost'] = MOLO_CONFIGS.get_config_object()['ha']['host'] 32 | tunnel['lport'] = MOLO_CONFIGS.get_config_object()['ha']['port'] 33 | black_domains = ['weather', 'group', 'persistent_notification', 'person', 'sun'] 34 | 35 | client_id = '' 36 | client_token = '' 37 | 38 | protocol_func_bind_map = {} 39 | 40 | def __init__(self, host, port, map): 41 | """Initialize protocol arguments.""" 42 | asyncore.dispatcher.__init__(self, map=map) 43 | self.host = host 44 | self.port = port 45 | self.molo_tcp_pack = MoloTcpPack() 46 | self.ping_dequeue = queue.Queue() 47 | self.append_recv_buffer = None 48 | self.append_send_buffer = None 49 | self.append_connect = None 50 | self.client_status = None 51 | self._last_report_device = 0 52 | self._phone_sign = '' 53 | self._sync_config = False 54 | self.clear() 55 | self.init_func_bind_map() 56 | self.last_entity_ids = [] 57 | 58 | def handle_connect(self): 59 | """When connected, this method will be call.""" 60 | LOGGER.debug("server connected") 61 | self.append_connect = False 62 | self.send_dict_pack( 63 | MoloSocketHelper.molo_auth(CLIENT_VERSION, 64 | MOLO_CLIENT_APP.hass_context, 65 | __short_version__)) 66 | 67 | def handle_close(self): 68 | """When closed, this method will be call. clean itself.""" 69 | LOGGER.debug("server closed") 70 | self.clear() 71 | self.close() 72 | 73 | # close all and restart 74 | asyncore.close_all() 75 | 76 | def handle_read(self): 77 | """Handle read message.""" 78 | try: 79 | buff = self.recv(BUFFER_SIZE) 80 | self.append_recv_buffer += buff 81 | self.process_molo_tcp_pack() 82 | except Exception as e: 83 | LOGGER.info("recv error: %s", e) 84 | 85 | def get_phonesign(self): 86 | if self._phone_sign: 87 | return self._phone_sign 88 | hassconfig = MOLO_CONFIGS.get_config_object().get("hassconfig", {}) 89 | phone = hassconfig.get("phone", "") 90 | password = hassconfig.get("password", "") 91 | phone = str(phone and phone or "").strip() 92 | password = str(password and password or "").strip() 93 | if not phone or not re.match(r'1\d{10}', phone): 94 | MOLO_CLIENT_APP.hass_context.components.persistent_notification.async_create( 95 | "Invalid phone number, please check your configuration.", 96 | "Molo Bot Infomation", "molo_bot_notify") 97 | LOGGER.error("hass configuration.yaml haweb phone error") 98 | self._phone_sign = "null" 99 | return self._phone_sign 100 | 101 | hkey = ("molobot:%s:%s" % (phone, password)).encode('utf-8') 102 | self._phone_sign = hashlib.sha1(hkey).hexdigest() 103 | return self._phone_sign 104 | 105 | def _get_domain(self, entity_id): 106 | return entity_id.split(".")[0] 107 | 108 | def sync_device(self, force=False, interval=180, force_diff=False): 109 | now = time.time() 110 | if (not force) and (now - self._last_report_device < interval): 111 | return None 112 | self._last_report_device = now 113 | 114 | self._phone_sign = self.get_phonesign() 115 | if self._phone_sign == "null": 116 | return None 117 | 118 | devicelist = MOLO_CLIENT_APP.hass_context.states.async_all() 119 | 120 | usefull_entity = [] 121 | entity_ids = [] 122 | for sinfo in devicelist: 123 | dinfo = sinfo.as_dict() 124 | 125 | entity_id = dinfo['entity_id'] 126 | domain = self._get_domain(entity_id) 127 | 128 | if domain not in self.black_domains: 129 | usefull_entity.append(dinfo) 130 | entity_ids.append(entity_id) 131 | 132 | if len(self.last_entity_ids) < 1 or force_diff: 133 | diff = {'1'} 134 | else : 135 | entity_ids.sort() 136 | diff = set(entity_ids) - set(self.last_entity_ids) 137 | if len(diff) == 0: 138 | diff = set(self.last_entity_ids) - set(entity_ids) 139 | updateTime = True 140 | LOGGER.info('=====diff: %s', diff) 141 | if len(diff) > 0 : 142 | updateTime = False 143 | self.last_entity_ids = entity_ids 144 | jlist = json.dumps( 145 | usefull_entity, sort_keys=True, cls=JSONEncoder).encode('UTF-8') 146 | jlist = jlist.decode("UTF-8") 147 | 148 | else: 149 | jlist = "" 150 | if not self.client_token or (not jlist and not updateTime): 151 | return None 152 | 153 | body = { 154 | 'Type': 'SyncDevice', 155 | 'Payload': { 156 | "ClientId": self.client_id, 157 | 'PhoneSign': self._phone_sign, 158 | 'Token': self.client_token, 159 | 'Action': "synclist", 160 | 'List': jlist, 161 | 'updateTime': updateTime 162 | } 163 | } 164 | self.send_dict_pack(body) 165 | 166 | def sync_device_state(self, state): 167 | if not self.client_token or not state: 168 | return None 169 | 170 | State = json.dumps( 171 | state, sort_keys=True, cls=JSONEncoder).encode('UTF-8') 172 | State = State.decode("UTF-8") 173 | 174 | body = { 175 | 'Type': 'SyncDevice', 176 | 'Payload': { 177 | "ClientId": self.client_id, 178 | 'PhoneSign': self._phone_sign, 179 | 'Token': self.client_token, 180 | 'Action': "synclist", 181 | 'State': State 182 | } 183 | } 184 | self.send_dict_pack(body) 185 | 186 | def sync_config(self): 187 | self._phone_sign = self.get_phonesign() 188 | if self._phone_sign == "null": 189 | return False 190 | 191 | hassconfig = MOLO_CONFIGS.get_config_object().get("hassconfig", {}) 192 | configcopy = copy.deepcopy(hassconfig) 193 | configcopy.update({"phone":"", "password":""}) 194 | 195 | jlist = json.dumps(configcopy) 196 | if not self.client_token or not jlist: 197 | return False 198 | 199 | body = { 200 | 'Type': 'SyncDevice', 201 | 'Payload': { 202 | "ClientId": self.client_id, 203 | 'PhoneSign': self._phone_sign, 204 | 'Token': self.client_token, 205 | 'Action': "syncconfig", 206 | 'Data': jlist 207 | } 208 | } 209 | 210 | self.send_dict_pack(body) 211 | return True 212 | 213 | def writable(self): 214 | """If the socket send buffer writable.""" 215 | ping_buffer = MOLO_CLIENT_APP.get_ping_buffer() 216 | if ping_buffer: 217 | self.append_send_buffer += ping_buffer 218 | 219 | if not self._sync_config: 220 | self._sync_config = self.sync_config() 221 | 222 | self.sync_device() 223 | return self.append_connect or (self.append_send_buffer) 224 | 225 | def handle_write(self): 226 | """Write socket send buffer.""" 227 | sent = self.send(self.append_send_buffer) 228 | self.append_send_buffer = self.append_send_buffer[sent:] 229 | 230 | # The above are base class methods. 231 | def clear(self): 232 | """Reset client protocol arguments.""" 233 | self.molo_tcp_pack.clear() 234 | self.append_recv_buffer = bytes() 235 | self.append_send_buffer = bytes() 236 | self.append_connect = True 237 | self.client_status = None 238 | 239 | def sock_connect(self): 240 | """Connect to host:port.""" 241 | self.clear() 242 | dns_ip = dns_open(self.host) 243 | if not dns_ip: 244 | return 245 | self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 246 | self.connect((dns_ip, self.port)) 247 | 248 | def on_bind_status(self, jdata): 249 | """Handle on_bind_status json packet.""" 250 | LOGGER.debug("on_bind_status %s", str(jdata)) 251 | jpayload = jdata['Payload'] 252 | self.client_status = jpayload['Status'] 253 | jpayload['token'] = self.client_token 254 | 255 | def on_auth_resp(self, jdata): 256 | """Handle on_auth_resp json packet.""" 257 | LOGGER.debug('on_auth_resp %s', str(jdata)) 258 | self.client_id = jdata['Payload']['ClientId'] 259 | 260 | self.send_dict_pack( 261 | MoloSocketHelper.req_tunnel(self.tunnel['protocol'], 262 | self.tunnel['hostname'], 263 | self.tunnel['subdomain'], 264 | self.tunnel['rport'], self.client_id)) 265 | 266 | def on_new_tunnel(self, jdata): 267 | """Handle on_new_tunnel json packet.""" 268 | LOGGER.debug("on_new_tunnel %s", str(jdata)) 269 | if 'ping_interval' in jdata['OnlineConfig']: 270 | MOLO_CLIENT_APP.ping_interval = jdata['OnlineConfig'][ 271 | 'ping_interval'] 272 | if jdata['Payload']['Error'] != '': 273 | LOGGER.error('Server failed to allocate tunnel: %s', 274 | jdata['Payload']['Error']) 275 | return 276 | 277 | self.client_token = jdata['Payload']['token'] 278 | self.on_bind_status(jdata) 279 | 280 | def on_token_expired(self, jdata): 281 | """Handle on_token_expired json packet.""" 282 | LOGGER.debug('on_token_expired %s', str(jdata)) 283 | if 'Payload' not in jdata: 284 | return 285 | data = jdata['Payload'] 286 | self.client_token = data['token'] 287 | 288 | def on_pong(self, jdata): 289 | """Handle on_pong json packet.""" 290 | LOGGER.debug('on_pong %s, self token: %s', str(jdata), 291 | self.client_token) 292 | 293 | def on_reset_clientid(self, jdata): 294 | """Handle on_reset_clientid json packet.""" 295 | local_seed = get_rand_char(32).lower() 296 | save_local_seed( 297 | MOLO_CLIENT_APP.hass_context.config.path(CONFIG_FILE_NAME), 298 | local_seed) 299 | LOGGER.debug("reset clientid %s to %s", self.client_id, local_seed) 300 | self.handle_close() 301 | 302 | def process_molo_tcp_pack(self): 303 | """Handle received TCP packet.""" 304 | ret = True 305 | while ret: 306 | ret = self.molo_tcp_pack.recv_buffer(self.append_recv_buffer) 307 | if ret and self.molo_tcp_pack.error_code == MoloTcpPack.ERR_OK: 308 | self.process_json_pack(self.molo_tcp_pack.body_jdata) 309 | self.append_recv_buffer = self.molo_tcp_pack.tmp_buffer 310 | if self.molo_tcp_pack.error_code == MoloTcpPack.ERR_MALFORMED: 311 | LOGGER.error("tcp pack malformed!") 312 | self.handle_close() 313 | 314 | def process_json_pack(self, jdata): 315 | """Handle received json packet.""" 316 | LOGGER.debug("process_json_pack %s", str(jdata)) 317 | if jdata['Type'] in self.protocol_func_bind_map: 318 | MOLO_CLIENT_APP.reset_activate_time() 319 | self.protocol_func_bind_map[jdata['Type']](jdata) 320 | 321 | def process_new_tunnel(self, jdata): 322 | """Handle new tunnel.""" 323 | jpayload = jdata['Payload'] 324 | self.client_id = jpayload['clientid'] 325 | self.client_token = jpayload['token'] 326 | LOGGER.debug("Get client id:%s token:%s", self.client_id, 327 | self.client_token) 328 | data = {} 329 | data['clientid'] = self.client_id 330 | data['token'] = self.client_token 331 | 332 | def send_raw_pack(self, raw_data): 333 | """Send raw data packet.""" 334 | if self.append_connect: 335 | return 336 | self.append_send_buffer += raw_data 337 | self.handle_write() 338 | 339 | def send_dict_pack(self, dict_data): 340 | """Convert and send dict packet.""" 341 | if self.append_connect: 342 | return 343 | body = MoloTcpPack.generate_tcp_buffer(dict_data) 344 | self.send_raw_pack(body) 345 | 346 | def ping_server_buffer(self): 347 | """Get ping buffer.""" 348 | if not self.client_status: 349 | return 350 | body = MoloTcpPack.generate_tcp_buffer( 351 | MoloSocketHelper.ping(self.client_token, self.client_status)) 352 | return body 353 | 354 | def on_device_state(self, jdata): 355 | LOGGER.info("receive device state:%s", jdata) 356 | jpayload = jdata['Payload'] 357 | action = jpayload.get("action") 358 | header = jpayload.get("header") 359 | if action == "control": 360 | data = jpayload.get("data") 361 | extdata = "extdata" in data and data.pop("extdata") or None 362 | exc = {} 363 | try: 364 | if isinstance(extdata, (tuple, list)) and len(extdata)>0: 365 | ndata = [] 366 | for info in extdata: 367 | dexc = MOLO_CLIENT_APP.hass_context.services.call( 368 | info.get("domain"), info.get("service"), info.get("data"), blocking=True) 369 | ndata.append(dexc) 370 | exc.update(data) 371 | exc["extdata"] = ndata 372 | else: 373 | domain = jpayload.get("domain") 374 | service = jpayload.get("service") 375 | exc = MOLO_CLIENT_APP.hass_context.services.call( 376 | domain, service, data, blocking=True) 377 | except Exception as e: 378 | exc = traceback.format_exc() 379 | 380 | body = { # return state where server query 381 | 'Type': 'SyncDevice', 382 | 'Payload': { 383 | "Header": header, 384 | "ClientId": self.client_id, 385 | 'PhoneSign': self._phone_sign, 386 | 'Token': self.client_token, 387 | 'Action': "synccontrol", 388 | 'Data': exc 389 | } 390 | } 391 | self.send_dict_pack(body) 392 | 393 | elif action == "query": 394 | data = jpayload.get("data") 395 | extdata = "extdata" in data and data.pop("extdata") or None 396 | state = {} 397 | if isinstance(extdata, (tuple, list)) and len(extdata)>0: 398 | ndata = [] 399 | for ent in extdata: 400 | st = MOLO_CLIENT_APP.hass_context.states.get(ent) 401 | if st: 402 | ndata.append(st) 403 | state.update(data) 404 | state["extdata"] = ndata 405 | if len(ndata)<1: 406 | return None 407 | else: 408 | state = MOLO_CLIENT_APP.hass_context.states.get( 409 | data.get("entity_id")) 410 | if not state: 411 | return None 412 | 413 | strst = json.dumps(state, sort_keys=True, cls=JSONEncoder) 414 | self._phone_sign = self.get_phonesign() 415 | if self._phone_sign == "null": 416 | return None 417 | 418 | body = { # return state where server query 419 | 'Type': 'SyncDevice', 420 | 'Payload': { 421 | "Header": header, 422 | "ClientId": self.client_id, 423 | 'PhoneSign': self._phone_sign, 424 | 'Token': self.client_token, 425 | 'Action': "syncstate", 426 | 'Data': strst 427 | } 428 | } 429 | self.send_dict_pack(body) 430 | 431 | def init_func_bind_map(self): 432 | """Initialize protocol function bind map.""" 433 | self.protocol_func_bind_map = { 434 | "BindStatus": self.on_bind_status, 435 | "AuthResp": self.on_auth_resp, 436 | "NewTunnel": self.on_new_tunnel, 437 | "TokenExpired": self.on_token_expired, 438 | "Pong": self.on_pong, 439 | "ResetClientid": self.on_reset_clientid, 440 | "DeviceState": self.on_device_state 441 | } 442 | --------------------------------------------------------------------------------