summaryrefslogtreecommitdiff
path: root/src/polaris
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2022-12-25 02:13:45 +0300
committerEvgeny Zinoviev <me@ch1p.io>2022-12-25 02:13:45 +0300
commited590fdd74a466832ee5bb788c3ea69da2f53a3c (patch)
treed88120712d0384b9534517f408d3bed122d896c0 /src/polaris
parente4ad17d9068ebfd077e3a73cd2e9f5ab4fdc83b7 (diff)
polaris -> syncleo
Diffstat (limited to 'src/polaris')
-rw-r--r--src/polaris/__init__.py12
-rw-r--r--src/polaris/kettle.py243
-rw-r--r--src/polaris/protocol.py1169
3 files changed, 0 insertions, 1424 deletions
diff --git a/src/polaris/__init__.py b/src/polaris/__init__.py
deleted file mode 100644
index f1a7c1d..0000000
--- a/src/polaris/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-# Polaris PWK 1725CGLD "smart" kettle python library
-# --------------------------------------------------
-# Copyright (C) Evgeny Zinoviev, 2022
-# License: BSD-3c
-
-from .kettle import Kettle, DeviceListener
-from .protocol import (
- PowerType,
- IncomingMessageListener,
- ConnectionStatusListener,
- ConnectionStatus
-) \ No newline at end of file
diff --git a/src/polaris/kettle.py b/src/polaris/kettle.py
deleted file mode 100644
index d6e0dd6..0000000
--- a/src/polaris/kettle.py
+++ /dev/null
@@ -1,243 +0,0 @@
-# Polaris PWK 1725CGLD smart kettle python library
-# ------------------------------------------------
-# Copyright (C) Evgeny Zinoviev, 2022
-# License: BSD-3c
-
-from __future__ import annotations
-
-import threading
-import logging
-import zeroconf
-
-from abc import abstractmethod
-from ipaddress import ip_address, IPv4Address, IPv6Address
-from typing import Optional, List, Union
-
-from .protocol import (
- UDPConnection,
- ModeMessage,
- TargetTemperatureMessage,
- PowerType,
- ConnectionStatus,
- ConnectionStatusListener,
- WrappedMessage
-)
-
-
-class DeviceDiscover(threading.Thread, zeroconf.ServiceListener):
- si: Optional[zeroconf.ServiceInfo]
- _mac: str
- _sb: Optional[zeroconf.ServiceBrowser]
- _zc: Optional[zeroconf.Zeroconf]
- _listeners: List[DeviceListener]
- _valid_addresses: List[Union[IPv4Address, IPv6Address]]
- _only_ipv4: bool
-
- def __init__(self, mac: str,
- listener: Optional[DeviceListener] = None,
- only_ipv4=True):
- super().__init__()
- self.si = None
- self._mac = mac
- self._zc = None
- self._sb = None
- self._only_ipv4 = only_ipv4
- self._valid_addresses = []
- self._listeners = []
- if isinstance(listener, DeviceListener):
- self._listeners.append(listener)
- self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__}')
-
- def add_listener(self, listener: DeviceListener):
- if listener not in self._listeners:
- self._listeners.append(listener)
- else:
- self._logger.warning(f'add_listener: listener {listener} already in the listeners list')
-
- def set_info(self, info: zeroconf.ServiceInfo):
- valid_addresses = self._get_valid_addresses(info)
- if not valid_addresses:
- raise ValueError('no valid addresses')
- self._valid_addresses = valid_addresses
- self.si = info
- for f in self._listeners:
- try:
- f.device_updated()
- except Exception as exc:
- self._logger.error(f'set_info: error while calling device_updated on {f}')
- self._logger.exception(exc)
-
- def add_service(self, zc: zeroconf.Zeroconf, type_: str, name: str) -> None:
- self._add_update_service('add_service', zc, type_, name)
-
- def update_service(self, zc: zeroconf.Zeroconf, type_: str, name: str) -> None:
- self._add_update_service('update_service', zc, type_, name)
-
- def _add_update_service(self, method: str, zc: zeroconf.Zeroconf, type_: str, name: str) -> None:
- info = zc.get_service_info(type_, name)
- if name.startswith(f'{self._mac}.'):
- self._logger.info(f'{method}: type={type_} name={name}')
- try:
- self.set_info(info)
- except ValueError as exc:
- self._logger.error(f'{method}: rejected: {str(exc)}')
- else:
- self._logger.debug(f'{method}: mac not matched: {info}')
-
- def remove_service(self, zc: zeroconf.Zeroconf, type_: str, name: str) -> None:
- if name.startswith(f'{self._mac}.'):
- self._logger.info(f'remove_service: type={type_} name={name}')
- # TODO what to do here?!
-
- def run(self):
- self._logger.debug('starting zeroconf service browser')
- ip_version = zeroconf.IPVersion.V4Only if self._only_ipv4 else zeroconf.IPVersion.All
- self._zc = zeroconf.Zeroconf(ip_version=ip_version)
- self._sb = zeroconf.ServiceBrowser(self._zc, "_syncleo._udp.local.", self)
- self._sb.join()
-
- def stop(self):
- if self._sb:
- try:
- self._sb.cancel()
- except RuntimeError:
- pass
- self._sb = None
- self._zc.close()
- self._zc = None
-
- def _get_valid_addresses(self, si: zeroconf.ServiceInfo) -> List[Union[IPv4Address, IPv6Address]]:
- valid = []
- for addr in map(ip_address, si.addresses):
- if self._only_ipv4 and not isinstance(addr, IPv4Address):
- continue
- if isinstance(addr, IPv4Address) and str(addr).startswith('169.254.'):
- continue
- valid.append(addr)
- return valid
-
- @property
- def pubkey(self) -> bytes:
- return bytes.fromhex(self.si.properties[b'public'].decode())
-
- @property
- def curve(self) -> int:
- return int(self.si.properties[b'curve'].decode())
-
- @property
- def addr(self) -> Union[IPv4Address, IPv6Address]:
- return self._valid_addresses[0]
-
- @property
- def port(self) -> int:
- return int(self.si.port)
-
- @property
- def protocol(self) -> int:
- return int(self.si.properties[b'protocol'].decode())
-
-
-class DeviceListener:
- @abstractmethod
- def device_updated(self):
- pass
-
-
-class Kettle(DeviceListener, ConnectionStatusListener):
- mac: str
- device: Optional[DeviceDiscover]
- device_token: str
- conn: Optional[UDPConnection]
- conn_status: Optional[ConnectionStatus]
- _read_timeout: Optional[int]
- _logger: logging.Logger
- _find_evt: threading.Event
-
- def __init__(self, mac: str, device_token: str, read_timeout: Optional[int] = None):
- super().__init__()
- self.mac = mac
- self.device = None
- self.device_token = device_token
- self.conn = None
- self.conn_status = None
- self._read_timeout = read_timeout
- self._find_evt = threading.Event()
- self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__}')
-
- def device_updated(self):
- self._find_evt.set()
- self._logger.info(f'device updated, service info: {self.device.si}')
-
- def connection_status_updated(self, status: ConnectionStatus):
- self.conn_status = status
-
- def discover(self, wait=True, timeout=None, listener=None) -> Optional[zeroconf.ServiceInfo]:
- do_start = False
- if not self.device:
- self.device = DeviceDiscover(self.mac, listener=self, only_ipv4=True)
- do_start = True
- self._logger.debug('discover: started device discovery')
- else:
- self._logger.warning('discover: already started')
-
- if listener is not None:
- self.device.add_listener(listener)
-
- if do_start:
- self.device.start()
-
- if wait:
- self._find_evt.clear()
- try:
- self._find_evt.wait(timeout=timeout)
- except KeyboardInterrupt:
- self.device.stop()
- return None
- return self.device.si
-
- def start_server_if_needed(self,
- incoming_message_listener=None,
- connection_status_listener=None):
- if self.conn:
- self._logger.warning('start_server_if_needed: server is already started!')
- self.conn.set_address(self.device.addr, self.device.port)
- self.conn.set_device_pubkey(self.device.pubkey)
- return
-
- assert self.device.curve == 29, f'curve type {self.device.curve} is not implemented'
- assert self.device.protocol == 2, f'protocol {self.device.protocol} is not supported'
-
- kw = {}
- if self._read_timeout is not None:
- kw['read_timeout'] = self._read_timeout
- self.conn = UDPConnection(addr=self.device.addr,
- port=self.device.port,
- device_pubkey=self.device.pubkey,
- device_token=bytes.fromhex(self.device_token), **kw)
- if incoming_message_listener:
- self.conn.add_incoming_message_listener(incoming_message_listener)
-
- self.conn.add_connection_status_listener(self)
- if connection_status_listener:
- self.conn.add_connection_status_listener(connection_status_listener)
-
- self.conn.start()
-
- def stop_all(self):
- # when we stop server, we should also stop device discovering service
- if self.conn:
- self.conn.interrupted = True
- self.conn = None
- self.device.stop()
- self.device = None
-
- def is_connected(self) -> bool:
- return self.conn is not None and self.conn_status == ConnectionStatus.CONNECTED
-
- def set_power(self, power_type: PowerType, callback: callable):
- message = ModeMessage(power_type)
- self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True))
-
- def set_target_temperature(self, temp: int, callback: callable):
- message = TargetTemperatureMessage(temp)
- self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True))
diff --git a/src/polaris/protocol.py b/src/polaris/protocol.py
deleted file mode 100644
index 36a1a8f..0000000
--- a/src/polaris/protocol.py
+++ /dev/null
@@ -1,1169 +0,0 @@
-# Polaris PWK 1725CGLD "smart" kettle python library
-# --------------------------------------------------
-# Copyright (C) Evgeny Zinoviev, 2022
-# License: BSD-3c
-
-from __future__ import annotations
-
-import logging
-import socket
-import random
-import struct
-import threading
-import time
-
-from abc import abstractmethod, ABC
-from enum import Enum, auto
-from typing import Union, Optional, Dict, Tuple, List
-from ipaddress import IPv4Address, IPv6Address
-
-import cryptography.hazmat.primitives._serialization as srlz
-
-from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
-from cryptography.hazmat.primitives import ciphers, padding, hashes
-from cryptography.hazmat.primitives.ciphers import algorithms, modes
-
-ReprDict = Dict[str, Union[str, int, float, bool]]
-_logger = logging.getLogger(__name__)
-
-PING_FREQUENCY = 3
-RESEND_ATTEMPTS = 5
-ERROR_TIMEOUT = 15
-MESSAGE_QUEUE_REMOVE_DELAY = 13 # after what time to delete (and pass False to handlers, if needed) messages with phase=DONE from queue
-DISCONNECT_TIMEOUT = 15
-
-
-def safe_callback_call(f: callable,
- *args,
- logger: logging.Logger = None,
- error_message: str = None):
- try:
- return f(*args)
- except Exception as exc:
- logger.error(f'{error_message}, see exception below:')
- logger.exception(exc)
- return None
-
-
-# drop-in replacement for java.lang.System.arraycopy
-# TODO: rewrite
-def arraycopy(src, src_pos, dest, dest_pos, length):
- for i in range(length):
- dest[i + dest_pos] = src[i + src_pos]
-
-
-# "convert" unsigned byte to signed
-def u8_to_s8(b: int) -> int:
- return struct.unpack('b', bytes([b]))[0]
-
-
-class PowerType(Enum):
- OFF = 0 # turn off
- ON = 1 # turn on, set target temperature to 100
- CUSTOM = 3 # turn on, allows custom target temperature
- # MYSTERY_MODE = 2 # don't know what 2 means, needs testing
- # update: if I set it to '2', it just resets to '0'
-
-
-# low-level protocol structures
-# -----------------------------
-
-class FrameType(Enum):
- ACK = 0
- CMD = 1
- AUX = 2
- NAK = 3
-
-
-class FrameHead:
- seq: Optional[int] # u8
- type: FrameType # u8
- length: int # u16. This is the length of FrameItem's payload
-
- @staticmethod
- def from_bytes(buf: bytes) -> FrameHead:
- seq, ft, length = struct.unpack('<BBH', buf)
- return FrameHead(seq, FrameType(ft), length)
-
- def __init__(self,
- seq: Optional[int],
- frame_type: FrameType,
- length: Optional[int] = None):
- self.seq = seq
- self.type = frame_type
- self.length = length or 0
-
- def pack(self) -> bytes:
- assert self.length != 0, "FrameHead.length has not been set"
- assert self.seq is not None, "FrameHead.seq has not been set"
- return struct.pack('<BBH', self.seq, self.type.value, self.length)
-
-
-class FrameItem:
- head: FrameHead
- payload: bytes
-
- def __init__(self, head: FrameHead, payload: Optional[bytes] = None):
- self.head = head
- self.payload = payload
-
- def setpayload(self, payload: Union[bytes, bytearray]):
- if isinstance(payload, bytearray):
- payload = bytes(payload)
- self.payload = payload
- self.head.length = len(payload)
-
- def pack(self) -> bytes:
- ba = bytearray(self.head.pack())
- ba.extend(self.payload)
- return bytes(ba)
-
-
-# high-level wrappers around FrameItem
-# ------------------------------------
-
-class MessagePhase(Enum):
- WAITING = 0
- SENT = 1
- DONE = 2
-
-
-class Message:
- frame: Optional[FrameItem]
- id: int
-
- _global_id = 0
-
- def __init__(self):
- self.frame = None
-
- # global internal message id, only useful for debugging purposes
- self.id = self.next_id()
-
- def __repr__(self):
- return f'<{self.__class__.__name__} id={self.id} seq={self.frame.head.seq}>'
-
- @staticmethod
- def next_id():
- _id = Message._global_id
- Message._global_id = (Message._global_id + 1) % 100000
- return _id
-
- @staticmethod
- def from_encrypted(buf: bytes, inkey: bytes, outkey: bytes) -> Message:
- _logger.debug(f'Message:from_encrypted: buf={buf.hex()}')
-
- assert len(buf) >= 4, 'invalid size'
- head = FrameHead.from_bytes(buf[:4])
-
- assert len(buf) == head.length + 4, f'invalid buf size ({len(buf)} != {head.length})'
- payload = buf[4:]
- b = head.seq
-
- j = b & 0xF
- k = b >> 4 & 0xF
-
- key = bytearray(len(inkey))
- arraycopy(inkey, j, key, 0, len(inkey) - j)
- arraycopy(inkey, 0, key, len(inkey) - j, j)
-
- iv = bytearray(len(outkey))
- arraycopy(outkey, k, iv, 0, len(outkey) - k)
- arraycopy(outkey, 0, iv, len(outkey) - k, k)
-
- cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv))
- decryptor = cipher.decryptor()
- decrypted_data = decryptor.update(payload) + decryptor.finalize()
-
- unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
- decrypted_data = unpadder.update(decrypted_data)
- decrypted_data += unpadder.finalize()
-
- assert len(decrypted_data) != 0, 'decrypted data is null'
- assert head.seq == decrypted_data[0], f'decrypted seq mismatch {head.seq} != {decrypted_data[0]}'
-
- # _logger.debug('Message.from_encrypted: plaintext: '+decrypted_data.hex())
-
- if head.type == FrameType.ACK:
- return AckMessage(head.seq)
-
- elif head.type == FrameType.NAK:
- return NakMessage(head.seq)
-
- elif head.type == FrameType.AUX:
- # TODO implement AUX
- raise NotImplementedError('FrameType AUX is not yet implemented')
-
- elif head.type == FrameType.CMD:
- type = decrypted_data[1]
- data = decrypted_data[2:]
-
- cl = UnknownMessage
-
- subclasses = [cl for cl in CmdIncomingMessage.__subclasses__() if cl is not SimpleBooleanMessage]
- subclasses.extend(SimpleBooleanMessage.__subclasses__())
-
- for _cl in subclasses:
- # `UnknownMessage` is a special class that holds a packed command that we don't recognize.
- # It will be used anyway if we don't find a match, so skip it here
- if _cl == UnknownMessage:
- continue
-
- if _cl.TYPE == type:
- cl = _cl
- break
-
- m = cl.from_packed_data(data, seq=head.seq)
- if isinstance(m, UnknownMessage):
- m.set_type(type)
- return m
-
- else:
- raise NotImplementedError(f'Unexpected frame type: {head.type}')
-
- def pack_data(self) -> bytes:
- return b''
-
- @property
- def seq(self) -> Union[int, None]:
- try:
- return self.frame.head.seq
- except:
- return None
-
- @seq.setter
- def seq(self, seq: int):
- self.frame.head.seq = seq
-
- def encrypt(self, outkey: bytes, inkey: bytes, token: bytes, pubkey: bytes):
- assert self.frame is not None
-
- data = self._get_data_to_encrypt()
- assert data is not None
-
- b = self.frame.head.seq
- i = b & 0xf
- j = b >> 4 & 0xf
-
- outkey = bytearray(outkey)
-
- l = len(outkey)
- key = bytearray(l)
-
- arraycopy(outkey, i, key, 0, l-i)
- arraycopy(outkey, 0, key, l-i, i)
-
- inkey = bytearray(inkey)
-
- l = len(inkey)
- iv = bytearray(l)
-
- arraycopy(inkey, j, iv, 0, l-j)
- arraycopy(inkey, 0, iv, l-j, j)
-
- cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv))
- encryptor = cipher.encryptor()
-
- newdata = bytearray(len(data)+1)
- newdata[0] = b
-
- arraycopy(data, 0, newdata, 1, len(data))
-
- newdata = bytes(newdata)
- _logger.debug('frame payload to be encrypted: ' + newdata.hex())
-
- padder = padding.PKCS7(algorithms.AES.block_size).padder()
- ciphertext = bytearray()
- ciphertext.extend(encryptor.update(padder.update(newdata) + padder.finalize()))
- ciphertext.extend(encryptor.finalize())
-
- self.frame.setpayload(ciphertext)
-
- def _get_data_to_encrypt(self) -> bytes:
- return self.pack_data()
-
-
-class AckMessage(Message, ABC):
- def __init__(self, seq: Optional[int] = None):
- super().__init__()
- self.frame = FrameItem(FrameHead(seq, FrameType.ACK, None))
-
-
-class NakMessage(Message, ABC):
- def __init__(self, seq: Optional[int] = None):
- super().__init__()
- self.frame = FrameItem(FrameHead(seq, FrameType.NAK, None))
-
-
-class CmdMessage(Message):
- type: Optional[int]
- data: bytes
-
- TYPE = None
-
- def _get_data_to_encrypt(self) -> bytes:
- buf = bytearray()
- buf.append(self.get_type())
- buf.extend(self.pack_data())
- return bytes(buf)
-
- def __init__(self, seq: Optional[int] = None):
- super().__init__()
- self.frame = FrameItem(FrameHead(seq, FrameType.CMD))
- self.data = b''
-
- def _repr_fields(self) -> ReprDict:
- return {
- 'cmd': self.get_type()
- }
-
- def __repr__(self):
- params = [
- __name__+'.'+self.__class__.__name__,
- f'id={self.id}',
- f'seq={self.seq}'
- ]
- fields = self._repr_fields()
- if fields:
- for k, v in fields.items():
- params.append(f'{k}={v}')
- elif self.data:
- params.append(f'data={self.data.hex()}')
- return '<'+' '.join(params)+'>'
-
- def get_type(self) -> int:
- return self.__class__.TYPE
-
-
-class CmdIncomingMessage(CmdMessage):
- @staticmethod
- @abstractmethod
- def from_packed_data(cls, data: bytes, seq: Optional[int] = None):
- pass
-
- @abstractmethod
- def _repr_fields(self) -> ReprDict:
- pass
-
-
-class CmdOutgoingMessage(CmdMessage):
- @abstractmethod
- def pack_data(self) -> bytes:
- return b''
-
-
-class ModeMessage(CmdOutgoingMessage, CmdIncomingMessage):
- TYPE = 1
-
- pt: PowerType
-
- def __init__(self, power_type: PowerType, seq: Optional[int] = None):
- super().__init__(seq)
- self.pt = power_type
-
- @classmethod
- def from_packed_data(cls, data: bytes, seq=0) -> ModeMessage:
- assert len(data) == 1, 'data size expected to be 1'
- mode, = struct.unpack('B', data)
- return ModeMessage(PowerType(mode), seq=seq)
-
- def pack_data(self) -> bytes:
- return self.pt.value.to_bytes(1, byteorder='little')
-
- def _repr_fields(self) -> ReprDict:
- return {'mode': self.pt.name}
-
-
-class TargetTemperatureMessage(CmdOutgoingMessage, CmdIncomingMessage):
- temperature: int
-
- TYPE = 2
-
- def __init__(self, temp: int, seq: Optional[int] = None):
- super().__init__(seq)
- self.temperature = temp
-
- @classmethod
- def from_packed_data(cls, data: bytes, seq=0) -> TargetTemperatureMessage:
- assert len(data) == 2, 'data size expected to be 2'
- nat, frac = struct.unpack('BB', data)
- temp = int(nat + (frac / 100))
- return TargetTemperatureMessage(temp, seq=seq)
-
- def pack_data(self) -> bytes:
- return bytes([self.temperature, 0])
-
- def _repr_fields(self) -> ReprDict:
- return {'temperature': self.temperature}
-
-
-class PingMessage(CmdIncomingMessage, CmdOutgoingMessage):
- TYPE = 255
-
- @classmethod
- def from_packed_data(cls, data: bytes, seq=0) -> PingMessage:
- assert len(data) == 0, 'no data expected'
- return PingMessage(seq=seq)
-
- def pack_data(self) -> bytes:
- return b''
-
- def _repr_fields(self) -> ReprDict:
- return {}
-
-
-# This is the first protocol message. Sent by a client.
-# Kettle usually ACKs this, but sometimes i don't get any ACK and the very next message is HandshakeResponseMessage.
-class HandshakeMessage(CmdMessage):
- TYPE = 0
-
- def encrypt(self,
- outkey: bytes,
- inkey: bytes,
- token: bytes,
- pubkey: bytes):
- cipher = ciphers.Cipher(algorithms.AES(outkey), modes.CBC(inkey))
- encryptor = cipher.encryptor()
-
- ciphertext = bytearray()
- ciphertext.extend(encryptor.update(token))
- ciphertext.extend(encryptor.finalize())
-
- pld = bytearray()
- pld.append(0)
- pld.extend(pubkey)
- pld.extend(ciphertext)
-
- self.frame.setpayload(pld)
-
-
-# Kettle either sends this right after the handshake, of first it ACKs the handshake then sends this.
-class HandshakeResponseMessage(CmdIncomingMessage):
- TYPE = 0
-
- protocol: int
- fw_major: int
- fw_minor: int
- mode: int
- token: bytes
-
- def __init__(self,
- protocol: int,
- fw_major: int,
- fw_minor: int,
- mode: int,
- token: bytes,
- seq: Optional[int] = None):
- super().__init__(seq)
- self.protocol = protocol
- self.fw_major = fw_major
- self.fw_minor = fw_minor
- self.mode = mode
- self.token = token
-
- @classmethod
- def from_packed_data(cls, data: bytes, seq=0) -> HandshakeResponseMessage:
- protocol, fw_major, fw_minor, mode = struct.unpack('<HBBB', data[:5])
- return HandshakeResponseMessage(protocol, fw_major, fw_minor, mode, token=data[5:], seq=seq)
-
- def _repr_fields(self) -> ReprDict:
- return {
- 'protocol': self.protocol,
- 'fw': f'{self.fw_major}.{self.fw_minor}',
- 'mode': self.mode,
- 'token': self.token.hex()
- }
-
-
-# Apparently, some hardware info.
-# On the other hand, if you look at com.syncleiot.iottransport.commands.CmdHardware, its mqtt topic says "mcu_firmware".
-# My device returns 1.1.1. The kettle uses on ESP8266 ESP-12F MCU under the hood (or, more precisely, under a piece of
-# cheap plastic), so maybe 1.1.1 is some MCU ROM version.
-class DeviceHardwareMessage(CmdIncomingMessage):
- TYPE = 143 # -113
-
- hw: List[int]
-
- def __init__(self, hw: List[int], seq: Optional[int] = None):
- super().__init__(seq)
- self.hw = hw
-
- @classmethod
- def from_packed_data(cls, data: bytes, seq=0) -> DeviceHardwareMessage:
- assert len(data) == 3, 'invalid data size, expected 3'
- hw = list(struct.unpack('<BBB', data))
- return DeviceHardwareMessage(hw, seq=seq)
-
- def _repr_fields(self) -> ReprDict:
- return {'device_hardware': '.'.join(map(str, self.hw))}
-
-
-# This message is sent by kettle right after the HandshakeMessageResponse.
-# The diagnostic data is supposed to be sent to vendor, which we, obviously, not going to do.
-# So just ACK and skip it.
-class DeviceDiagnosticMessage(CmdIncomingMessage):
- TYPE = 145 # -111
-
- diag_data: bytes
-
- def __init__(self, diag_data: bytes, seq: Optional[int] = None):
- super().__init__(seq)
- self.diag_data = diag_data
-
- @classmethod
- def from_packed_data(cls, data: bytes, seq=0) -> DeviceDiagnosticMessage:
- return DeviceDiagnosticMessage(diag_data=data, seq=seq)
-
- def _repr_fields(self) -> ReprDict:
- return {'diag_data': self.diag_data.hex()}
-
-
-class SimpleBooleanMessage(ABC, CmdIncomingMessage):
- value: bool
-
- def __init__(self, value: bool, seq: Optional[int] = None):
- super().__init__(seq)
- self.value = value
-
- @classmethod
- def from_packed_data(cls, data: bytes, seq: Optional[int] = None):
- assert len(data) == 1, 'invalid data size, expected 1'
- enabled, = struct.unpack('<B', data)
- return cls(value=enabled == 1, seq=seq)
-
- @abstractmethod
- def _repr_fields(self) -> ReprDict:
- pass
-
-
-class AccessControlMessage(SimpleBooleanMessage):
- TYPE = 133 # -123
-
- def _repr_fields(self) -> ReprDict:
- return {'acl_enabled': self.value}
-
-
-class ErrorMessage(SimpleBooleanMessage):
- TYPE = 7
-
- def _repr_fields(self) -> ReprDict:
- return {'error': self.value}
-
-
-class ChildLockMessage(SimpleBooleanMessage):
- TYPE = 30
-
- def _repr_fields(self) -> ReprDict:
- return {'child_lock': self.value}
-
-
-class VolumeMessage(SimpleBooleanMessage):
- TYPE = 9
-
- def _repr_fields(self) -> ReprDict:
- return {'volume': self.value}
-
-
-class BacklightMessage(SimpleBooleanMessage):
- TYPE = 28
-
- def _repr_fields(self) -> ReprDict:
- return {'backlight': self.value}
-
-
-class CurrentTemperatureMessage(CmdIncomingMessage):
- TYPE = 20
-
- current_temperature: int
-
- def __init__(self, temp: int, seq: Optional[int] = None):
- super().__init__(seq)
- self.current_temperature = temp
-
- @classmethod
- def from_packed_data(cls, data: bytes, seq=0) -> CurrentTemperatureMessage:
- assert len(data) == 2, 'data size expected to be 2'
- nat, frac = struct.unpack('BB', data)
- temp = int(nat + (frac / 100))
- return CurrentTemperatureMessage(temp, seq=seq)
-
- def pack_data(self) -> bytes:
- return bytes([self.current_temperature, 0])
-
- def _repr_fields(self) -> ReprDict:
- return {'current_temperature': self.current_temperature}
-
-
-class UnknownMessage(CmdIncomingMessage):
- type: Optional[int]
- data: bytes
-
- def __init__(self, data: bytes, **kwargs):
- super().__init__(**kwargs)
- self.type = None
- self.data = data
-
- @classmethod
- def from_packed_data(cls, data: bytes, seq=0) -> UnknownMessage:
- return UnknownMessage(data, seq=seq)
-
- def set_type(self, type: int):
- self.type = type
-
- def get_type(self) -> int:
- return self.type
-
- def _repr_fields(self) -> ReprDict:
- return {
- 'type': self.type,
- 'data': self.data.hex()
- }
-
-
-class WrappedMessage:
- _message: Message
- _handler: Optional[callable]
- _validator: Optional[callable]
- _logger: Optional[logging.Logger]
- _phase: MessagePhase
- _phase_update_time: float
-
- def __init__(self,
- message: Message,
- handler: Optional[callable] = None,
- validator: Optional[callable] = None,
- ack=False):
- self._message = message
- self._handler = handler
- self._validator = validator
- self._logger = None
- self._phase = MessagePhase.WAITING
- self._phase_update_time = 0
- if not validator and ack:
- self._validator = lambda m: isinstance(m, AckMessage)
-
- def setlogger(self, logger: logging.Logger):
- self._logger = logger
-
- def validate(self, message: Message):
- if not self._validator:
- return True
- return self._validator(message)
-
- def call(self, *args, error_message: str = None) -> None:
- if not self._handler:
- return
- try:
- self._handler(*args)
- except Exception as exc:
- logger = self._logger or logging.getLogger(self.__class__.__name__)
- logger.error(f'{error_message}, see exception below:')
- logger.exception(exc)
-
- @property
- def phase(self) -> MessagePhase:
- return self._phase
-
- @phase.setter
- def phase(self, phase: MessagePhase):
- self._phase = phase
- self._phase_update_time = 0 if phase == MessagePhase.WAITING else time.time()
-
- @property
- def phase_update_time(self) -> float:
- return self._phase_update_time
-
- @property
- def message(self) -> Message:
- return self._message
-
- @property
- def id(self) -> int:
- return self._message.id
-
- @property
- def seq(self) -> int:
- return self._message.seq
-
- @seq.setter
- def seq(self, seq: int):
- self._message.seq = seq
-
- def __repr__(self):
- return f'<{__name__}.{self.__class__.__name__} message={self._message.__repr__()}>'
-
-
-# Connection stuff
-# Well, strictly speaking, as it's UDP, there's no connection, but who cares.
-# ---------------------------------------------------------------------------
-
-class IncomingMessageListener:
- @abstractmethod
- def incoming_message(self, message: Message) -> Optional[Message]:
- pass
-
-
-class ConnectionStatus(Enum):
- NOT_CONNECTED = auto()
- CONNECTING = auto()
- CONNECTED = auto()
- RECONNECTING = auto()
- DISCONNECTED = auto()
-
-
-class ConnectionStatusListener:
- @abstractmethod
- def connection_status_updated(self, status: ConnectionStatus):
- pass
-
-
-class UDPConnection(threading.Thread, ConnectionStatusListener):
- inseq: int
- outseq: int
- source_port: int
- device_addr: str
- device_port: int
- device_token: bytes
- device_pubkey: bytes
- interrupted: bool
- response_handlers: Dict[int, WrappedMessage]
- outgoing_queue: List[WrappedMessage]
- pubkey: Optional[bytes]
- encinkey: Optional[bytes]
- encoutkey: Optional[bytes]
- inc_listeners: List[IncomingMessageListener]
- conn_listeners: List[ConnectionStatusListener]
- outgoing_time: float
- outgoing_time_1st: float
- incoming_time: float
- status: ConnectionStatus
- reconnect_tries: int
- read_timeout: int
-
- _addr_lock: threading.Lock
- _iml_lock: threading.Lock
- _csl_lock: threading.Lock
- _st_lock: threading.Lock
-
- def __init__(self,
- addr: Union[IPv4Address, IPv6Address],
- port: int,
- device_pubkey: bytes,
- device_token: bytes,
- read_timeout: int = 1):
- super().__init__()
- self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__} <{hex(id(self))}>')
- self.setName(self.__class__.__name__)
-
- self.inseq = 0
- self.outseq = 0
- self.source_port = random.randint(1024, 65535)
- self.device_addr = str(addr)
- self.device_port = port
- self.device_token = device_token
- self.device_pubkey = device_pubkey
- self.outgoing_queue = []
- self.response_handlers = {}
- self.interrupted = False
- self.outgoing_time = 0
- self.outgoing_time_1st = 0
- self.incoming_time = 0
- self.inc_listeners = []
- self.conn_listeners = [self]
- self.status = ConnectionStatus.NOT_CONNECTED
- self.reconnect_tries = 0
- self.read_timeout = read_timeout
-
- self._iml_lock = threading.Lock()
- self._csl_lock = threading.Lock()
- self._addr_lock = threading.Lock()
- self._st_lock = threading.Lock()
-
- self.pubkey = None
- self.encinkey = None
- self.encoutkey = None
-
- def connection_status_updated(self, status: ConnectionStatus):
- # self._logger.info(f'connection_status_updated: status = {status}')
- with self._st_lock:
- # self._logger.debug(f'connection_status_updated: lock acquired')
- self.status = status
- if status == ConnectionStatus.RECONNECTING:
- self.reconnect_tries += 1
- if status in (ConnectionStatus.CONNECTED, ConnectionStatus.NOT_CONNECTED, ConnectionStatus.DISCONNECTED):
- self.reconnect_tries = 0
-
- def _cleanup(self):
- # erase outgoing queue
- for wm in self.outgoing_queue:
- wm.call(False,
- error_message=f'_cleanup: exception while calling cb(False) on message {wm.message}')
- self.outgoing_queue = []
- self.response_handlers = {}
-
- # reset timestamps
- self.incoming_time = 0
- self.outgoing_time = 0
- self.outgoing_time_1st = 0
-
- self._logger.debug('_cleanup: done')
-
- def set_address(self, addr: Union[IPv4Address, IPv6Address], port: int):
- with self._addr_lock:
- if self.device_addr != str(addr) or self.device_port != port:
- self.device_addr = str(addr)
- self.device_port = port
- self._logger.info(f'updated device network address: {self.device_addr}:{self.device_port}')
-
- def set_device_pubkey(self, pubkey: bytes):
- if self.device_pubkey.hex() != pubkey.hex():
- self._logger.info(f'device pubkey has changed (old={self.device_pubkey.hex()}, new={pubkey.hex()})')
- self.device_pubkey = pubkey
- self._notify_cs(ConnectionStatus.RECONNECTING)
-
- def get_address(self) -> Tuple[str, int]:
- with self._addr_lock:
- return self.device_addr, self.device_port
-
- def add_incoming_message_listener(self, listener: IncomingMessageListener):
- with self._iml_lock:
- if listener not in self.inc_listeners:
- self.inc_listeners.append(listener)
-
- def add_connection_status_listener(self, listener: ConnectionStatusListener):
- with self._csl_lock:
- if listener not in self.conn_listeners:
- self.conn_listeners.append(listener)
-
- def _notify_cs(self, status: ConnectionStatus):
- # self._logger.debug(f'_notify_cs: status={status}')
- with self._csl_lock:
- for obj in self.conn_listeners:
- # self._logger.debug(f'_notify_cs: notifying {obj}')
- obj.connection_status_updated(status)
-
- def _prepare_keys(self):
- # generate key pair
- privkey = X25519PrivateKey.generate()
-
- self.pubkey = bytes(reversed(privkey.public_key().public_bytes(encoding=srlz.Encoding.Raw,
- format=srlz.PublicFormat.Raw)))
-
- # generate shared key
- device_pubkey = X25519PublicKey.from_public_bytes(
- bytes(reversed(self.device_pubkey))
- )
- shared_key = bytes(reversed(
- privkey.exchange(device_pubkey)
- ))
-
- # in/out encryption keys
- digest = hashes.Hash(hashes.SHA256())
- digest.update(shared_key)
-
- shared_sha256 = digest.finalize()
-
- self.encinkey = shared_sha256[:16]
- self.encoutkey = shared_sha256[16:]
-
- self._logger.info('encryption keys have been created')
-
- def _handshake_callback(self, r: MessageResponse):
- # if got error for our HandshakeMessage, reset everything and try again
- if r is False:
- # self._logger.debug('_handshake_callback: set status=RECONNETING')
- self._notify_cs(ConnectionStatus.RECONNECTING)
- else:
- # self._logger.debug('_handshake_callback: set status=CONNECTED')
- self._notify_cs(ConnectionStatus.CONNECTED)
-
- def run(self):
- self._logger.info('starting server loop')
-
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- sock.bind(('0.0.0.0', self.source_port))
- sock.settimeout(self.read_timeout)
-
- while not self.interrupted:
- with self._st_lock:
- status = self.status
-
- if status in (ConnectionStatus.DISCONNECTED, ConnectionStatus.RECONNECTING):
- self._cleanup()
- if status == ConnectionStatus.DISCONNECTED:
- break
-
- # no activity for some time means connection is broken
- fail = False
- fail_path = 0
- if self.incoming_time > 0 and time.time() - self.incoming_time >= DISCONNECT_TIMEOUT:
- fail = True
- fail_path = 1
- elif self.outgoing_time_1st > 0 and self.incoming_time == 0 and time.time() - self.outgoing_time_1st >= DISCONNECT_TIMEOUT:
- fail = True
- fail_path = 2
-
- if fail:
- self._logger.debug(f'run: setting status=RECONNECTING because of long inactivity, fail_path={fail_path}')
- self._notify_cs(ConnectionStatus.RECONNECTING)
-
- # establishing a connection
- if status in (ConnectionStatus.RECONNECTING, ConnectionStatus.NOT_CONNECTED):
- if status == ConnectionStatus.RECONNECTING and self.reconnect_tries >= 3:
- self._notify_cs(ConnectionStatus.DISCONNECTED)
- continue
-
- self._reset_outseq()
- self._prepare_keys()
-
- # shake the imaginary kettle's hand
- wrapped = WrappedMessage(HandshakeMessage(),
- handler=self._handshake_callback,
- validator=lambda m: isinstance(m, (AckMessage, HandshakeResponseMessage)))
- self.enqueue_message(wrapped, prepend=True)
- self._notify_cs(ConnectionStatus.CONNECTING)
-
- # pick next (wrapped) message to send
- wm = self._get_next_message() # wm means "wrapped message"
- if wm:
- one_shot = isinstance(wm.message, (AckMessage, NakMessage))
-
- if not isinstance(wm.message, (AckMessage, NakMessage)):
- old_seq = wm.seq
- wm.seq = self.outseq
- self._set_response_handler(wm, old_seq=old_seq)
- elif wm.seq is None:
- # ack/nak is a response to some incoming message (and it must have the same seqno that incoming
- # message had)
- raise RuntimeError(f'run: seq must be set for {wm.__class__.__name__}')
-
- self._logger.debug(f'run: sending message: {wm.message}, one_shot={one_shot}, phase={wm.phase}')
- encrypted = False
- try:
- wm.message.encrypt(outkey=self.encoutkey, inkey=self.encinkey,
- token=self.device_token, pubkey=self.pubkey)
- encrypted = True
- except ValueError as exc:
- # handle "ValueError: Invalid padding bytes."
- self._logger.error('run: failed to encrypt the message.')
- self._logger.exception(exc)
-
- if encrypted:
- buf = wm.message.frame.pack()
- # self._logger.debug(f'run: raw data to be sent: {buf.hex()}')
-
- # sending the first time
- if wm.phase == MessagePhase.WAITING:
- sock.sendto(buf, self.get_address())
- # resending
- elif wm.phase == MessagePhase.SENT:
- left = RESEND_ATTEMPTS
- while left > 0:
- sock.sendto(buf, self.get_address())
- left -= 1
- if left > 0:
- time.sleep(0.05)
-
- if one_shot or wm.phase == MessagePhase.SENT:
- wm.phase = MessagePhase.DONE
- else:
- wm.phase = MessagePhase.SENT
-
- now = time.time()
- self.outgoing_time = now
- if not self.outgoing_time_1st:
- self.outgoing_time_1st = now
-
- # receiving data
- try:
- data = sock.recv(4096)
- self._handle_incoming(data)
- except (TimeoutError, socket.timeout):
- pass
-
- self._logger.info('bye...')
-
- def _get_next_message(self) -> Optional[WrappedMessage]:
- message = None
- lpfx = '_get_next_message:'
- remove_list = []
- for wm in self.outgoing_queue:
- if wm.phase == MessagePhase.DONE:
- if isinstance(wm.message, (AckMessage, NakMessage, PingMessage)) or time.time() - wm.phase_update_time >= MESSAGE_QUEUE_REMOVE_DELAY:
- remove_list.append(wm)
- continue
- message = wm
- break
-
- for wm in remove_list:
- self._logger.debug(f'{lpfx} rm path: removing id={wm.id} seq={wm.seq}')
-
- # clear message handler
- if wm.seq in self.response_handlers:
- self.response_handlers[wm.seq].call(
- False, error_message=f'{lpfx} rm path: error while calling callback for seq={wm.seq}')
- del self.response_handlers[wm.seq]
-
- # remove from queue
- try:
- self.outgoing_queue.remove(wm)
- except ValueError as exc:
- self._logger.error(f'{lpfx} rm path: removing from outgoing_queue raised an exception: {str(exc)}')
-
- # ping pong
- if not message and self.outgoing_time_1st != 0 and self.status == ConnectionStatus.CONNECTED:
- now = time.time()
- out_delta = now - self.outgoing_time
- in_delta = now - self.incoming_time
- if max(out_delta, in_delta) > PING_FREQUENCY:
- self._logger.debug(f'{lpfx} no activity: in for {in_delta:.2f}s, out for {out_delta:.2f}s, time to ping the damn thing')
- message = WrappedMessage(PingMessage(), ack=True)
- # add it to outgoing_queue in order to be aggressively resent in future (if needed)
- self.outgoing_queue.insert(0, message)
-
- return message
-
- def _handle_incoming(self, buf: bytes):
- try:
- incoming_message = Message.from_encrypted(buf, inkey=self.encinkey, outkey=self.encoutkey)
- except ValueError as exc:
- # handle "ValueError: Invalid padding bytes."
- self._logger.error('_handle_incoming: failed to decrypt incoming frame:')
- self._logger.exception(exc)
- return
-
- self.incoming_time = time.time()
- seq = incoming_message.seq
-
- lpfx = f'handle_incoming({incoming_message.id}):'
- self._logger.debug(f'{lpfx} received: {incoming_message}')
-
- if isinstance(incoming_message, (AckMessage, NakMessage)):
- seq_max = self.outseq
- seq_name = 'outseq'
- else:
- seq_max = self.inseq
- seq_name = 'inseq'
- self.inseq = seq
-
- if seq < seq_max < 0xfd:
- self._logger.debug(f'{lpfx} dropping: seq={seq}, {seq_name}={seq_max}')
- return
-
- if seq not in self.response_handlers:
- self._handle_incoming_cmd(incoming_message)
- return
-
- callback_value = None # None means don't call a callback
- handler = self.response_handlers[seq]
-
- if handler.validate(incoming_message):
- self._logger.debug(f'{lpfx} response OK')
- handler.phase = MessagePhase.DONE
- callback_value = incoming_message
- self._incr_outseq()
- else:
- self._logger.warning(f'{lpfx} response is INVALID')
-
- # It seems that we've received an incoming CmdMessage or PingMessage with the same seqno that our outgoing
- # message had. Bad, but what can I say, this is quick-and-dirty made UDP based protocol and this sort of
- # shit just happens.
-
- # (To be fair, maybe my implementation is not perfect either. But hey, what did you expect from a
- # reverse-engineered re-implementation of custom UDP-based protocol that some noname vendor uses for their
- # cheap IoT devices? I think _that_ is _the_ definition of shit. At least my implementation is FOSS, which
- # is more than you'll ever be able to say about them.)
-
- # All this crapload of code below might not be needed at all, 'cause the protocol uses separate frame seq
- # numbers for IN and OUT frames and this situation is not highly likely, as Theresa May could argue.
- # After a handshake, a kettle sends us 10 or so CmdMessages, and then either we continuously ping it every
- # 3 seconds, or kettle pings us. This in any case widens the gap between inseq and outseq.
-
- # But! the seqno is only 1 byte in size and once it reaches 0xff, it circles back to zero. And that (plus,
- # perhaps, some bad luck) gives a chance for a collision.
-
- if handler.phase == MessagePhase.DONE or isinstance(handler.message, HandshakeMessage):
- # no more attempts left, returning error back to user
- # as to handshake, it cannot fail.
- callback_value = False
-
- # else:
- # # try resending the message
- # handler.phase_reset()
- # max_seq = self.outseq
- # wait_remap = {}
- # for m in self.outgoing_queue:
- # if m.seq in self.waiting_for_response:
- # wait_remap[m.seq] = (m.seq+1) % 256
- # m.set_seq((m.seq+1) % 256)
- # if m.seq > max_seq:
- # max_seq = m.seq
- # if max_seq > self.outseq:
- # self.outseq = max_seq % 256
- # if wait_remap:
- # waiting_new = {}
- # for old_seq, new_seq in wait_remap.items():
- # waiting_new[new_seq] = self.waiting_for_response[old_seq]
- # self.waiting_for_response = waiting_new
-
- if isinstance(incoming_message, (PingMessage, CmdIncomingMessage)):
- # handle incoming message as usual, as we need to ack/nak it anyway
- self._handle_incoming_cmd(incoming_message)
-
- if callback_value is not None:
- handler.call(callback_value,
- error_message=f'{lpfx} error while calling callback for msg id={handler.message.id} seq={seq}')
- del self.response_handlers[seq]
-
- def _handle_incoming_cmd(self, incoming_message: Message):
- if isinstance(incoming_message, (AckMessage, NakMessage)):
- self._logger.debug(f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): it\'s {incoming_message.__class__.__name__}, ignoring')
- return
-
- replied = False
- with self._iml_lock:
- for f in self.inc_listeners:
- retval = safe_callback_call(f.incoming_message, incoming_message,
- logger=self._logger,
- error_message=f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): error while calling message listener')
- if isinstance(retval, Message):
- if isinstance(retval, (AckMessage, NakMessage)):
- retval.seq = incoming_message.seq
- self.enqueue_message(WrappedMessage(retval), prepend=True)
- replied = True
- break
- else:
- raise RuntimeError('are you sure your response is correct? only ack/nak are allowed')
-
- if not replied:
- self.enqueue_message(WrappedMessage(AckMessage(incoming_message.seq)), prepend=True)
-
- def enqueue_message(self, wrapped: WrappedMessage, prepend=False):
- self._logger.debug(f'enqueue_message: {wrapped.message}')
- if not prepend:
- self.outgoing_queue.append(wrapped)
- else:
- self.outgoing_queue.insert(0, wrapped)
-
- def _set_response_handler(self, wm: WrappedMessage, old_seq=None):
- if old_seq in self.response_handlers:
- del self.response_handlers[old_seq]
-
- seq = wm.seq
- assert seq is not None, 'seq is not set'
-
- if seq in self.response_handlers:
- self._logger.debug(f'_set_response_handler(seq={seq}): handler is already set, cancelling it')
- self.response_handlers[seq].call(False,
- error_message=f'_set_response_handler({seq}): error while calling old callback')
- self.response_handlers[seq] = wm
-
- def _incr_outseq(self) -> None:
- self.outseq = (self.outseq + 1) % 256
-
- def _reset_outseq(self):
- self.outseq = 0
- self._logger.debug(f'_reset_outseq: set 0')
-
-
-MessageResponse = Union[Message, bool]