pax_global_header00006660000000000000000000000064143312662100014510gustar00rootroot0000000000000052 comment=094f714de58ceab42329b1ea7f780c1af878669b Danielhiversen-PyXiaomiGateway-094f714/000077500000000000000000000000001433126621000200075ustar00rootroot00000000000000Danielhiversen-PyXiaomiGateway-094f714/.github/000077500000000000000000000000001433126621000213475ustar00rootroot00000000000000Danielhiversen-PyXiaomiGateway-094f714/.github/ISSUE_TEMPLATE/000077500000000000000000000000001433126621000235325ustar00rootroot00000000000000Danielhiversen-PyXiaomiGateway-094f714/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000012521433126621000262240ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve --- **Home Assistant release with the issue (if applicable):** **Last working Home Assistant release (if known):** **PyXiaomiGateway library version (if known):** **Operating environment (Hass.io/Docker/Windows/etc.):** **Traceback (if applicable):** ``` ``` **Description of the bug:** Danielhiversen-PyXiaomiGateway-094f714/.github/workflows/000077500000000000000000000000001433126621000234045ustar00rootroot00000000000000Danielhiversen-PyXiaomiGateway-094f714/.github/workflows/python-publish.yml000066400000000000000000000015151433126621000271160ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* Danielhiversen-PyXiaomiGateway-094f714/.gitignore000066400000000000000000000022051433126621000217760ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ Danielhiversen-PyXiaomiGateway-094f714/.travis.yml000066400000000000000000000024651433126621000221270ustar00rootroot00000000000000sudo: required language: python python: - '3.5' cache: pip install: - sudo modprobe dummy - sudo ip addr add 10.0.0.1/29 dev dummy0 - sudo ip link set dummy0 up - sudo ifconfig dummy0:0 10.0.0.2 netmask 255.255.255.248 - sudo ifconfig dummy0:1 10.0.0.3 netmask 255.255.255.248 - ifconfig - pip install flake8 pylint cryptography==2.1.1 asyncio pytest==5.4.3 pytest-asyncio script: - flake8 xiaomi_gateway --max-line-length=120 - pylint xiaomi_gateway --max-line-length=120 - pytest deploy: provider: pypi user: __token__ password: secure: ZdphWu8cTWMYfUctozIy6P7bKzfAy6zq6U62AzBzXC7CgVytBCSdRnunkMSTkK5FPEVV5WRqROTNPHC8ochp2kOh7o6rOJKm2yzW5h4v1CEg5JA3wMRTayt+Gavc7eW7KqaFiRgMoAIMIgIMTpm3lmJQBLy3He7lr5J9oXGGy5DlRmjuOT9YXbRGv+4rZ2g2NNuaoBNdHMC0pzPGD0kDr1SwjfTL9RWLe2PKQ13PMrnov9M43MHhMv47kq/x8vUUc5mEySgqiJb6ulr2sVbVSmgmBs7N433DGutYPEhn0BY4AScIeXW0knRXrcrtr4IqOuQmAs7cASO7eHmjzetUCGtWyC+oKBCfpXDqYn44PqokjI9QicfTIYg0vZqptFLf408Hp3RS6H6xeQEB60Jjp3+gtjGfeOKKtjRxmKFG7z5fgZcRbrIyKJEbMNP7znBGPkI7V/tHi0slyJRmJa5yxVHWpKgjtZz1eflzEoRvoKtHt5lMVVTnohJGDDh2oQ2UCVgkljiD1oe75AseF8iLGBRoUJ5u7/HIAQJKMIS/WGn5Jg71cbRQacU/jDlrHDkQgrX0SCh0jS99oHdYW1s04EUxBnEIENE404yjCc2syHCtr367Ncry3h1tjWFbWfwf4lstannLzRdriK4dNzF9lrWmsPX0n0FNP9clrwn6eOA= on: tags: true distributions: sdist bdist_wheel repo: Danielhiversen/PyXiaomiGateway Danielhiversen-PyXiaomiGateway-094f714/License.txt000066400000000000000000000021001433126621000221230ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2017 Daniel Høyer Iversen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Danielhiversen-PyXiaomiGateway-094f714/README.md000066400000000000000000000004521433126621000212670ustar00rootroot00000000000000# PyXiaomiGateway [![Build Status](https://travis-ci.org/Danielhiversen/PyXiaomiGateway.svg?branch=master)](https://travis-ci.org/Danielhiversen/PyXiaomiGateway) A Python library to communicate with the Xiaomi Gateway Used by Home Assistant: https://www.home-assistant.io/components/xiaomi_aqara Danielhiversen-PyXiaomiGateway-094f714/pytest.ini000066400000000000000000000000741433126621000220410ustar00rootroot00000000000000[pytest] log_format = %(asctime)s %(levelname)s %(message)s Danielhiversen-PyXiaomiGateway-094f714/setup.py000066400000000000000000000012701433126621000215210ustar00rootroot00000000000000from setuptools import setup setup( name = 'PyXiaomiGateway', packages = ['xiaomi_gateway'], install_requires=['cryptography>=2.1.1'], version = '0.14.3', description = 'A library to communicate with the Xiaomi Gateway', author='Daniel Hjelseth Høyer', url='https://github.com/Danielhiversen/PyXiaomiGateway/', classifiers=[ 'Development Status :: 3 - Alpha', 'Environment :: Other Environment', 'Intended Audience :: Developers', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Topic :: Home Automation', 'Topic :: Software Development :: Libraries :: Python Modules' ] ) Danielhiversen-PyXiaomiGateway-094f714/tests/000077500000000000000000000000001433126621000211515ustar00rootroot00000000000000Danielhiversen-PyXiaomiGateway-094f714/tests/__init__.py000066400000000000000000000000401433126621000232540ustar00rootroot00000000000000"""Tests for PyXiaomiGateway."""Danielhiversen-PyXiaomiGateway-094f714/tests/conftest.py000066400000000000000000000016011433126621000233460ustar00rootroot00000000000000"""Set up some common test helper things.""" import pytest import socket import asyncio from tests.gateway import AqaraGateway from xiaomi_gateway import XiaomiGatewayDiscovery @pytest.yield_fixture def gateway_factory(event_loop): """Factory that creates new gateways""" gateways = [] def start_gateway(config): gateways.append(AqaraGateway(config, event_loop)) yield start_gateway [gateway.stop() for gateway in gateways] @pytest.yield_fixture def client_factory(): """Factory that creates new gateway clients""" clients = [] def start_client(ip, gateways): config = [] [config.append({'key': g['key'], 'sid': g['sid']}) for g in gateways] client = XiaomiGatewayDiscovery(lambda: None, config, ip) clients.append(client) return client yield start_client [client.stop_listen() for client in clients] Danielhiversen-PyXiaomiGateway-094f714/tests/gateway.py000066400000000000000000000137571433126621000232010ustar00rootroot00000000000000"""Limited implementation of Aqara Gateway""" import json import logging import asyncio import random import string import socket import struct from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend _LOGGER = logging.getLogger(__name__) class DiscoveryProtocol(asyncio.DatagramProtocol): """Aqara Gateway discovery requests listener (port 4321)""" def __init__(self, server, main_protocol): self.server = server self.main_protocol = main_protocol def connection_made(self, transport): _LOGGER.info('Discovery %s connected', self.server['ip']) self.transport = transport def datagram_received(self, data, addr): _LOGGER.info('Discovery %s << %s %s', self.server['ip'], data, addr) req = json.loads(data.decode()) # Real gateway replies with 'iam' even if client will send an empty request if req['cmd'] != 'whois': _LOGGER.error('Waited for "whois", got "%s"', req['cmd']) res = json.dumps({ "cmd": "iam", "ip": self.server['ip'], "port": "9898", "model": "gateway", "sid": self.server['sid'], }) _LOGGER.info('Discovery %s >> %s %s', self.server['ip'], res.encode(), addr) # Hack. If we will send from self.transport, client will receive packet from its own IP, not gateway's, which # fails discovery process in client. Maybe it's how linux deals with packets sent from multicast listener socket # listening on aliased NICs. I had no luck with creating separate interfaces: # https://serverfault.com/questions/932412/udp-multicast-on-a-dummy-nics self.main_protocol.transport.sendto(res.encode(), addr) def stop(self): self.transport.close() class MainProtocol(asyncio.DatagramProtocol): """Aqara Gateway main requests listener (port 9898)""" def __init__(self, server): self.server = server def connection_made(self, transport): _LOGGER.info('Main %s connected', self.server['ip']) self.transport = transport self._gen_key() def datagram_received(self, data, addr): _LOGGER.info('Main %s << %s %s', self.server['ip'], data, addr) req = json.loads(data.decode()) if req['cmd'] == 'get_id_list': res = self._on_get_id_list() elif req['cmd'] == 'read': res = self._on_read(req) elif req['cmd'] == 'write': res = self._on_write(req) else: _LOGGER.error('Main %s got unsupported cmd "%s"', self.server['ip'], req['cmd']) return { 'cmd': 'server_ack', 'sid': self.server['sid'], 'data': json.dumps({'error': 'Unsupported cmd'}), } self.transport.sendto(json.dumps(res).encode(), addr) _LOGGER.info('Main %s >> %s %s', self.server['ip'], res, addr) def stop(self): self.transport.close() def _gen_key(self): self.token = ''.join(random.choice( string.ascii_letters + string.digits) for _ in range(16)) # https://aqara.gitbooks.io/lumi-gateway-lan-communication-api/content/chapter1.html#2-encryption-mechanism init_vector = bytes(bytearray.fromhex( '17996d093d28ddb3ba695a2e6f58562e')) encryptor = Cipher(algorithms.AES(self.server['key'].encode()), modes.CBC(init_vector), backend=default_backend()).encryptor() ciphertext = encryptor.update( self.token.encode()) + encryptor.finalize() self.key = ''.join('{:02x}'.format(x) for x in ciphertext) def _on_get_id_list(self): devices = list(self.server['devices'].keys()) devices.remove(self.server['sid']) return { 'cmd': 'get_id_list_ack', 'sid': self.server['sid'], 'token': self.token, 'data': json.dumps(devices), } def _on_read(self, req): device = self.server['devices'][req['sid']] return { 'cmd': 'read_ack', 'model': device['model'], 'sid': device['sid'], 'short_id': device['short_id'], 'data': json.dumps(device['data']), } def _on_write(self, req): device = self.server['devices'][req['sid']] device['data']['status'] = req['data']['status'] if req['data']['key'] != self.key: return { 'cmd': 'write_ack', 'sid': device['sid'], 'data': json.dumps({'error': 'Invalid key'}), } return { 'cmd': 'write_ack', 'model': device['model'], 'sid': device['sid'], 'short_id': device['short_id'], 'data': json.dumps(device['data']), } class AqaraGateway: """Emulates Aqara Gateway""" def __init__(self, config, event_loop): main_protocol = MainProtocol(config) task = event_loop.create_datagram_endpoint(lambda: main_protocol, local_addr=(config['ip'], 9898)) asyncio.ensure_future(task, loop=event_loop) sock = socket.socket( socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(('224.0.0.50', 4321)) mreq = socket.inet_aton('224.0.0.50') + socket.inet_aton(config['ip']) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) discovery_protocol = DiscoveryProtocol(config, main_protocol) task = event_loop.create_datagram_endpoint(lambda: discovery_protocol, sock=sock) asyncio.ensure_future(task, loop=event_loop) self.discovery_protocol = discovery_protocol self.main_protocol = main_protocol def stop(self): self.discovery_protocol.stop() self.main_protocol.stop() Danielhiversen-PyXiaomiGateway-094f714/tests/test_e2e.py000066400000000000000000000057041433126621000232430ustar00rootroot00000000000000"""End-to-End tests""" import asyncio import json import socket import logging import multiprocessing import struct import pytest from concurrent.futures import ThreadPoolExecutor _LOGGER = logging.getLogger(__name__) dev_gateway = { 'model': 'gateway', 'data': { "rgb": 0, "illumination": 306, "proto_version": '1.1.2', } } dev_plug = { 'model': 'plug', 'data': { "voltage": 3600, "status": "off", "inuse": "0", "power_consumed": "12345", "load_power": "0.00" } } dev_magnet = { 'model': 'magnet', 'data': { "voltage": 3035, "status": "close", } } gateway1 = { 'ip': '10.0.0.2', 'sid': '1', 'key': 'a6c567lbkcmr47fp', 'devices': { '1': dict({'sid': '1', 'short_id': 0}, **dev_gateway), '2': dict({'sid': '2', 'short_id': 20}, **dev_magnet), }, } gateway2 = { 'ip': '10.0.0.3', 'sid': '3', 'key': 'c6c36albocvr97fl', 'devices': { '3': dict({'sid': '3', 'short_id': 0}, **dev_gateway), '4': dict({'sid': '4', 'short_id': 40}, **dev_plug), }, } @pytest.yield_fixture(autouse=True) def debug_log(caplog): """Asserts logs are lower than warning""" caplog.set_level(logging.DEBUG) yield for record in caplog.get_records('call'): assert record.levelno < logging.WARNING @pytest.fixture(autouse=True) def gateways(gateway_factory): """Automatically creates 2 gateways for each test""" gateway_factory(gateway1) gateway_factory(gateway2) @pytest.fixture def pool(): """Returns thread pool for sync calls""" return ThreadPoolExecutor(max_workers=multiprocessing.cpu_count()) @pytest.mark.asyncio async def test_simple(event_loop, pool, client_factory): """2 gateways discovery -> read gateway #1 -> write gateway #2""" client = client_factory('10.0.0.1', [gateway1, gateway2]) await event_loop.run_in_executor(pool, client.discover_gateways) ok = await event_loop.run_in_executor(pool, client.gateways[gateway1['ip']].get_from_hub, '2') assert ok ok = await event_loop.run_in_executor(pool, lambda: client.gateways[gateway2['ip']].write_to_hub('4', status='on')) assert ok @pytest.mark.asyncio async def test_race(event_loop, pool, client_factory): """2 gateways discovery -> 100 x (read gateway #1 + write gateway #2) https://github.com/Danielhiversen/PyXiaomiGateway/issues/45 """ client = client_factory('10.0.0.1', [gateway1, gateway2]) await event_loop.run_in_executor(pool, client.discover_gateways) for i in range(100): task1 = event_loop.run_in_executor(pool, client.gateways[gateway1['ip']].get_from_hub, '2') task2 = event_loop.run_in_executor(pool, lambda: client.gateways[gateway2['ip']].write_to_hub('4', status='on')) res = await asyncio.gather(task1, task2) assert res[0], "failed on get_from_hub in %i lap" % i assert res[1], "failed on write_to_hub in %i lap" % i Danielhiversen-PyXiaomiGateway-094f714/xiaomi_gateway/000077500000000000000000000000001433126621000230165ustar00rootroot00000000000000Danielhiversen-PyXiaomiGateway-094f714/xiaomi_gateway/__init__.py000066400000000000000000000471701433126621000251400ustar00rootroot00000000000000"""Library to handle connection with Xiaomi Gateway""" import asyncio import socket import json import logging import platform import struct from collections import defaultdict from threading import Thread from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend _LOGGER = logging.getLogger(__name__) DEFAULT_DISCOVERY_RETRIES = 4 GATEWAY_MODELS = ['gateway', 'gateway.v3', 'acpartner.v3'] SOCKET_BUFSIZE = 4096 MULTICAST_PORT = 9898 MULTICAST_ADDRESS = '224.0.0.50' def create_mcast_socket(interface, port, bind_interface=True, blocking=True): """Create and bind a socket for communication.""" # Host IP adress is recommended as interface. if interface == "any": ip32bit = socket.INADDR_ANY bind_interface = False mreq = struct.pack("=4sl", socket.inet_aton(MULTICAST_ADDRESS), ip32bit) else: ip32bit = socket.inet_aton(interface) mreq = socket.inet_aton(MULTICAST_ADDRESS) + ip32bit udp_socket = socket.socket( socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP ) udp_socket.setblocking(blocking) # Required for receiving multicast udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: udp_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, ip32bit) except: _LOGGER.error( "Error creating multicast socket using IPPROTO_IP, trying SOL_IP" ) udp_socket.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_IF, ip32bit) try: udp_socket.setsockopt( socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq, ) except: _LOGGER.error( "Error adding multicast socket membership using IPPROTO_IP, trying SOL_IP" ) udp_socket.setsockopt( socket.SOL_IP, socket.IP_ADD_MEMBERSHIP, mreq, ) udp_socket.bind((MULTICAST_ADDRESS, port)) return udp_socket class AsyncXiaomiGatewayMulticast: """Async Multicast UDP communication class for a XiaomiGateway.""" def __init__(self, interface="any", bind_interface=True): self._protocol = None self._interface = interface self._bind_interface = bind_interface self._registered_callbacks = {} def _create_udp_listener(self): """Create the UDP multicast socket and protocol.""" udp_socket = create_mcast_socket( self._interface, MULTICAST_PORT, bind_interface=self._bind_interface, blocking=False ) loop = asyncio.get_event_loop() return loop.create_datagram_endpoint( lambda: self.MulticastListenerProtocol(loop, udp_socket, self), sock=udp_socket, ) @property def interface(self): """Return the used interface.""" return self._interface @property def bind_interface(self): """Return if the interface is bound.""" return self._bind_interface def register_gateway(self, ip, callback): """Register a Gateway to this Multicast listener.""" if ip in self._registered_callbacks: _LOGGER.error( "A callback for ip '%s' was already registed, overwriting previous callback", ip, ) self._registered_callbacks[ip] = callback def unregister_gateway(self, ip): """Unregister a Gateway from this Multicast listener.""" if ip in self._registered_callbacks: self._registered_callbacks.pop(ip) async def start_listen(self): """Start listening.""" if self._protocol is not None: _LOGGER.error( "Multicast listener already started, not starting another one." ) return _, self._protocol = await self._create_udp_listener() def stop_listen(self): """Stop listening.""" if self._protocol is None: return self._protocol.close() self._protocol = None class MulticastListenerProtocol: """Handle received multicast messages.""" def __init__(self, loop, udp_socket, parent): """Initialize the class.""" self.transport = None self._loop = loop self._sock = udp_socket self._parent = parent self._connected = False def connection_made(self, transport): """Set the transport.""" self.transport = transport self._connected = True _LOGGER.info("XiaomiMulticast listener started") def connection_lost(self, exc): """Handle connection lost.""" if self._connected: _LOGGER.error( "Connection unexpectedly lost in XiaomiMulticast listener: %s", exc ) def datagram_received(self, data, addr): """Handle received messages.""" try: (ip_add, _) = addr message = json.loads(data.decode("ascii")) if ip_add not in self._parent._registered_callbacks: _LOGGER.info("Unknown Xiaomi gateway ip %s", ip_add) return callback = self._parent._registered_callbacks[ip_add] callback(message) except Exception: _LOGGER.exception("Cannot process multicast message: '%s'", data) def error_received(self, exc): """Log UDP errors.""" _LOGGER.error("UDP error received in XiaomiMulticast listener: %s", exc) def close(self): """Stop the server.""" _LOGGER.debug("XiaomiMulticast listener shutting down") self._connected = False if self.transport: self.transport.close() try: self._loop.remove_writer(self._sock.fileno()) except NotImplementedError: pass try: self._loop.remove_reader(self._sock.fileno()) except NotImplementedError: pass self._sock.close() _LOGGER.info("XiaomiMulticast listener stopped") class XiaomiGatewayDiscovery: """PyXiami.""" # pylint: disable=too-many-instance-attributes GATEWAY_DISCOVERY_PORT = 4321 def __init__(self, interface, device_discovery_retries=DEFAULT_DISCOVERY_RETRIES): self.gateways = defaultdict(list) self._interface = interface self._device_discovery_retries = device_discovery_retries # pylint: disable=too-many-branches, too-many-locals, too-many-statements def discover_gateways(self): """Discover gateways using multicast""" _socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) _socket.settimeout(5.0) if self._interface != 'any': _socket.bind((self._interface, 0)) try: _socket.sendto('{"cmd":"whois"}'.encode(), (MULTICAST_ADDRESS, self.GATEWAY_DISCOVERY_PORT)) while True: data, (ip_add, _) = _socket.recvfrom(SOCKET_BUFSIZE) if len(data) is None: continue if ip_add in self.gateways.keys(): continue resp = json.loads(data.decode()) if resp["cmd"] != 'iam': _LOGGER.error("Response does not match return cmd") continue if resp["model"] not in GATEWAY_MODELS: _LOGGER.error("Response must be gateway model") continue gateway_key = None sid = resp["sid"] _LOGGER.info('Xiaomi Gateway %s found at IP %s', sid, ip_add) self.gateways[ip_add] = XiaomiGateway( ip_add, sid, gateway_key, self._device_discovery_retries, self._interface, resp["port"], resp["proto_version"] if "proto_version" in resp else None) except socket.timeout: _LOGGER.info("Gateway discovery finished in 5 seconds") _socket.close() # pylint: disable=too-many-instance-attributes class XiaomiGateway: """Xiaomi Gateway Component""" # pylint: disable=too-many-arguments def __init__(self, ip_adress, sid, key, discovery_retries, interface, port=MULTICAST_PORT, proto=None): self.ip_adress = ip_adress self.port = int(port) self.sid = sid self.key = key self.devices = defaultdict(list) self.callbacks = defaultdict(list) self.token = None self.connection_error = False self.mac_error = False self._discovery_retries = discovery_retries self._interface = interface if proto is None: cmd = '{"cmd":"read","sid":"' + sid + '"}' resp = self._send_cmd(cmd) proto = _get_value(resp, "proto_version") if _validate_data(resp) else None self.proto = '1.0' if proto is None else proto trycount = 5 for _ in range(trycount): _LOGGER.info('Discovering Xiaomi Devices') if self._discover_devices(): break # pylint: disable=too-many-branches def _discover_devices(self): cmd = '{"cmd" : "get_id_list"}' if int(self.proto[0:1]) == 1 else '{"cmd":"discovery"}' resp = self._send_cmd(cmd, "get_id_list_ack") if int(self.proto[0:1]) == 1 \ else self._send_cmd(cmd, "discovery_rsp") if resp is None or "token" not in resp or ("data" not in resp and "dev_list" not in resp): return False self.token = resp['token'] sids = [] if int(self.proto[0:1]) == 1: sids = json.loads(resp["data"]) else: for dev in resp["dev_list"]: sids.append(dev["sid"]) sids.append(self.sid) _LOGGER.info('Found %s devices', len(sids)) device_types = { 'sensor': ['sensor_ht', 'gateway', 'gateway.v3', 'weather', 'weather.v1', 'sensor_motion.aq2', 'acpartner.v3', 'vibration'], 'binary_sensor': ['magnet', 'sensor_magnet', 'sensor_magnet.aq2', 'motion', 'sensor_motion', 'sensor_motion.aq2', 'switch', 'sensor_switch', 'sensor_switch.aq2', 'sensor_switch.aq3', 'remote.b1acn01', '86sw1', 'sensor_86sw1', 'sensor_86sw1.aq1', 'remote.b186acn01', 'remote.b186acn02', '86sw2', 'sensor_86sw2', 'sensor_86sw2.aq1', 'remote.b286acn01', 'remote.b286acn02', 'cube', 'sensor_cube', 'sensor_cube.aqgl01', 'smoke', 'sensor_smoke', 'natgas', 'sensor_natgas', 'sensor_wleak.aq1', 'vibration', 'vibration.aq1'], 'switch': ['plug', 'ctrl_neutral1', 'ctrl_neutral1.aq1', 'switch_b1lacn02', 'switch.b1lacn02', 'ctrl_neutral2', 'ctrl_neutral2.aq1', 'switch_b2lacn02', 'switch.b2lacn02', 'ctrl_ln1', 'ctrl_ln1.aq1', 'switch_b1nacn02', 'switch.b1nacn02', 'ctrl_ln2', 'ctrl_ln2.aq1', 'switch_b2nacn02', 'switch.b2nacn02', '86plug', 'ctrl_86plug', 'ctrl_86plug.aq1'], 'light': ['gateway', 'gateway.v3'], 'cover': ['curtain', 'curtain.aq2', 'curtain.hagl04'], 'lock': ['lock.aq1', 'lock.acn02']} for sid in sids: cmd = '{"cmd":"read","sid":"' + sid + '"}' for retry in range(self._discovery_retries): _LOGGER.debug("Discovery attempt %d/%d", retry + 1, self._discovery_retries) resp = self._send_cmd(cmd, "read_ack") if int(self.proto[0:1]) == 1 else self._send_cmd(cmd, "read_rsp") if _validate_data(resp): break if not _validate_data(resp): _LOGGER.error("Not a valid device. Check the mac adress and update the firmware.") self.mac_error = True continue model = resp["model"] supported = False for device_type in device_types: if model in device_types[device_type]: supported = True xiaomi_device = { "model": model, "proto": self.proto, "sid": resp["sid"].rjust(12, '0'), "short_id": resp["short_id"] if "short_id" in resp else 0, "data": _list2map(_get_value(resp)), "raw_data": resp} self.devices[device_type].append(xiaomi_device) _LOGGER.debug('Registering device %s, %s as: %s', sid, model, device_type) if not supported: if model: _LOGGER.error( 'Unsupported device found! Please create an issue at ' 'https://github.com/Danielhiversen/PyXiaomiGateway/issues ' 'and provide the following data: %s', resp) else: _LOGGER.error( 'The device with sid %s isn\'t supported of the used ' 'gateway firmware. Please update the gateway firmware if ' 'possible! This is the only way the issue can be solved.', resp["sid"]) continue return True def _send_cmd(self, cmd, rtn_cmd=None): try: _socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) if self._interface != 'any': _socket.bind((self._interface, 0)) _socket.settimeout(10.0) _LOGGER.debug("_send_cmd >> %s", cmd.encode()) _socket.sendto(cmd.encode(), (self.ip_adress, self.port)) data, _ = _socket.recvfrom(SOCKET_BUFSIZE) except socket.timeout: _LOGGER.error("Cannot connect to gateway %s", self.sid) self.connection_error = True return None finally: _socket.close() if data is None: _LOGGER.error("No response from gateway %s", self.sid) return None resp = json.loads(data.decode()) _LOGGER.debug("_send_cmd resp << %s", resp) if rtn_cmd is not None and resp['cmd'] != rtn_cmd: _LOGGER.error("Non matching response. Expecting %s, but got %s", rtn_cmd, resp['cmd']) return None return resp def write_to_hub(self, sid, **kwargs): """Send data to gateway to turn on / off device""" if self.key is None: _LOGGER.error('Gateway Key is not provided. Can not send commands to the gateway.') return False data = {} for key in kwargs: data[key] = kwargs[key] if not self.token: _LOGGER.debug('Gateway Token was not obtained yet. Cannot send commands to the gateway.') return False cmd = dict() cmd['cmd'] = 'write' cmd['sid'] = sid if int(self.proto[0:1]) == 1: data['key'] = self._get_key() cmd['data'] = data else: cmd['key'] = self._get_key() cmd['params'] = [data] resp = self._send_cmd(json.dumps(cmd), "write_ack") if int(self.proto[0:1]) == 1 \ else self._send_cmd(json.dumps(cmd), "write_rsp") _LOGGER.debug("write_ack << %s", resp) if _validate_data(resp): return True if not _validate_keyerror(resp): return False # If 'invalid key' message we ask for a new token resp = self._send_cmd('{"cmd" : "get_id_list"}', "get_id_list_ack") if int(self.proto[0:1]) == 1 \ else self._send_cmd('{"cmd" : "discovery"}', "discovery_rsp") _LOGGER.debug("get_id_list << %s", resp) if resp is None or "token" not in resp: _LOGGER.error('No new token from gateway. Can not send commands to the gateway.') return False self.token = resp['token'] if int(self.proto[0:1]) == 1: data['key'] = self._get_key() cmd['data'] = data else: cmd['key'] = self._get_key() cmd['params'] = [data] resp = self._send_cmd(json.dumps(cmd), "write_ack") if int(self.proto[0:1]) == 1 \ else self._send_cmd(json.dumps(cmd), "write_rsp") _LOGGER.debug("write_ack << %s", resp) return _validate_data(resp) def get_from_hub(self, sid): """Get data from gateway""" cmd = '{ "cmd":"read","sid":"' + sid + '"}' resp = self._send_cmd(cmd, "read_ack") if int(self.proto[0:1]) == 1 else self._send_cmd(cmd, "read_rsp") _LOGGER.debug("read_ack << %s", resp) return self.push_data(resp) def multicast_callback(self, message): """Push data broadcasted from gateway""" cmd = message['cmd'] if cmd == 'heartbeat' and message['model'] in GATEWAY_MODELS: self.token = message['token'] elif cmd in ('report', 'heartbeat'): _LOGGER.debug('MCAST (%s) << %s', cmd, message) self.push_data(message) else: _LOGGER.error('Unknown multicast message: %s', message) def push_data(self, data): """Push data broadcasted from gateway to device""" if not _validate_data(data): return False jdata = json.loads(data['data']) if int(self.proto[0:1]) == 1 else _list2map(data['params']) if jdata is None: return False sid = data['sid'] for func in self.callbacks[sid]: func(jdata, data) return True def _get_key(self): """Get key using token from gateway""" init_vector = bytes(bytearray.fromhex('17996d093d28ddb3ba695a2e6f58562e')) encryptor = Cipher(algorithms.AES(self.key.encode()), modes.CBC(init_vector), backend=default_backend()).encryptor() ciphertext = encryptor.update(self.token.encode()) + encryptor.finalize() if isinstance(ciphertext, str): # For Python 2 compatibility return ''.join('{:02x}'.format(ord(x)) for x in ciphertext) return ''.join('{:02x}'.format(x) for x in ciphertext) def _validate_data(data): if data is None or ("data" not in data and "params" not in data): _LOGGER.error('No data in response from hub %s', data) return False if "data" in data and 'error' in json.loads(data['data']): _LOGGER.error('Got error element in data %s', data['data']) return False if "params" in data: for param in data['params']: if 'error' in param: _LOGGER.error('Got error element in data %s', data['params']) return False return True def _validate_keyerror(data): if data is not None and "data" in data and 'Invalid key' in data['data']: return True if data is not None and "params" in data: for param in data['params']: if 'error' in param and 'Invalid key' in param['error']: return True return False def _get_value(resp, data_key=None): if not _validate_data(resp): return None data = json.loads(resp["data"]) if "data" in resp else resp["params"] if data_key is None: return data if isinstance(data, list): for param in data: if data_key in param: return param[data_key] return None return data.get(data_key) def _list2map(data): if not isinstance(data, list): return data new_data = {} for obj in data: for key in obj: new_data[key] = obj[key] new_data['raw_data'] = data return new_data