polaris kettle work

- polaris iot protocol implementation rewrite
- fix temperature setting
- fix power mode setting
- sometimes responses don't come back, i think it's due to
  fragile nature of UDP. Need to implement message resending
  until response is received.
This commit is contained in:
Evgeny Zinoviev 2022-06-27 01:37:39 +03:00
parent 7e9f1eaba2
commit ee09bc98ae
6 changed files with 681 additions and 473 deletions

171
doc/polaris_pwk_1725cgld.md Normal file
View File

@ -0,0 +1,171 @@
## Random research notes
### Device features
From `devices.json`:
```
"id": "cec1f5ed-be5e-4545-9ce9-e2d843032587",
"vendor": "polaris",
"type": 6,
"class": "kettle",
"name": "PWK 1725CGLD",
"connectivity": [
"wifi",
"hotspot"
],
...
"features": [
"program",
"temperature",
"current_temperature",
"schedule",
"child_lock",
"semi_recipe",
"demo"
],
"temperature_units": "celsius",
"limits": {
"temperature": {
"min": 30,
"max": 100,
"default": 40,
"step": 5
},
"child_lock": {
"min": 0,
"max": 1,
"default": 0,
"step": 1
}
},
```
### Random notes
All commands, from `com/polaris/iot/api/comments`:
```
$ grep -A1 -r "public byte getType()" .
./CmdAccessControl.java: public byte getType() {
./CmdAccessControl.java- return -123;
--
./CmdBattery.java: public byte getType() {
./CmdBattery.java- return 27;
--
./CmdCurrentHumidity.java: public byte getType() {
./CmdCurrentHumidity.java- return 19;
--
./CmdMultiStepCurrent.java: public byte getType() {
./CmdMultiStepCurrent.java- return 21;
--
./CmdSmartMode.java: public byte getType() {
./CmdSmartMode.java- return 40;
--
./CmdTimeStart.java: public byte getType() {
./CmdTimeStart.java- return 0;
--
./CmdProgramData.java: public byte getType() {
./CmdProgramData.java- return 66;
--
./CmdTank.java: public byte getType() {
./CmdTank.java- return 31;
--
./CmdSpeed.java: public byte getType() {
./CmdSpeed.java- return 15;
--
./CmdTargetHumidity.java: public byte getType() {
./CmdTargetHumidity.java- return 18;
--
./CmdTargetTime.java: public byte getType() {
./CmdTargetTime.java- return 3;
--
./CmdRecipeStep.java: public byte getType() {
./CmdRecipeStep.java- return 5;
--
./CmdWarmStream.java: public byte getType() {
./CmdWarmStream.java- return 25;
--
./CmdMapData.java: public byte getType() {
./CmdMapData.java- return 10;
--
./CmdRecipeId.java: public byte getType() {
./CmdRecipeId.java- return 4;
--
./CmdBacklight.java: public byte getType() {
./CmdBacklight.java- return 28;
--
./CmdCurrentTemperature.java: public byte getType() {
./CmdCurrentTemperature.java- return 20;
--
./CmdIonization.java: public byte getType() {
./CmdIonization.java- return 24;
--
./CmdMapTarget.java: public byte getType() {
./CmdMapTarget.java- return 67;
--
./CmdKeepWarm.java: public byte getType() {
./CmdKeepWarm.java- return 16;
--
./CmdMultiStep.java: public byte getType() {
./CmdMultiStep.java- return 14;
--
./CmdCleanArea.java: public byte getType() {
./CmdCleanArea.java- return 36;
--
./CmdVolume.java: public byte getType() {
./CmdVolume.java- return 9;
--
./CmdTargetTemperature.java: public byte getType() {
./CmdTargetTemperature.java- return 2;
--
./CmdError.java: public byte getType() {
./CmdError.java- return 7;
--
./CmdCleanTime.java: public byte getType() {
./CmdCleanTime.java- return 35;
--
./CmdScheduleRemove.java: public byte getType() {
./CmdScheduleRemove.java- return 65;
--
./CmdBatteryState.java: public byte getType() {
./CmdBatteryState.java- return 29;
--
./CmdExpendables.java: public byte getType() {
./CmdExpendables.java- return 34;
--
./CmdContour.java: public byte getType() {
./CmdContour.java- return 68;
--
./CmdJoystick.java: public byte getType() {
./CmdJoystick.java- return 8;
--
./CmdMode.java: public byte getType() {
./CmdMode.java- return 1;
--
./CmdDelayStart.java: public byte getType() {
./CmdDelayStart.java- return 13;
--
./CmdTargetId.java: public byte getType() {
./CmdTargetId.java- return -112;
--
./CmdInternalLogs.java: public byte getType() {
./CmdInternalLogs.java- return -16;
--
./CmdFindMe.java: public byte getType() {
./CmdFindMe.java- return 69;
--
./CmdCustomCommand.java: public byte getType() {
./CmdCustomCommand.java- return 0;
--
./CmdScheduleSet.java: public byte getType() {
./CmdScheduleSet.java- return 64;
--
./CmdTotalTime.java: public byte getType() {
./CmdTotalTime.java- return 26;
--
./CmdChildLock.java: public byte getType() {
./CmdChildLock.java- return 30;
```
See also `com/syncleoiot/**/commands`.

View File

@ -19,3 +19,8 @@ scikit-image~=0.19.3
# following can be installed from debian repositories
# matplotlib~=3.5.0
Pillow~=9.1.1
# for polaris kettle protocol implementation
cryptography~=37.0.2
zeroconf~=0.38.7

View File

@ -1 +1,4 @@
# SPDX-License-Identifier: BSD-3-Clause
from .kettle import Kettle
from .protocol import Message, FrameType, PowerType

View File

@ -1,391 +1,53 @@
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import logging
import zeroconf
import socket
import random
import struct
from enum import Enum
from ipaddress import ip_address, IPv4Address, IPv6Address
from typing import Union, Optional, Any
import cryptography
import cryptography.hazmat.primitives._serialization
from cryptography.hazmat.primitives.asymmetric.ec import SECP192R1, SECP256R1, SECP384R1
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives import hashes, ciphers, padding
from cryptography.hazmat.primitives.ciphers import algorithms, modes
from cryptography.hazmat.primitives import hashes
from functools import partial
from abc import ABC
from ipaddress import ip_address
from typing import Optional
from .protocol import (
Connection,
ModeMessage,
HandshakeMessage,
TargetTemperatureMessage,
Message,
PowerType
)
_logger = logging.getLogger(__name__)
PubkeyType = Union[Any, X25519PublicKey, bytes]
PrivkeyType = Union[Any, X25519PrivateKey, bytes]
# com.syncleoiot.iottransport.utils.crypto.EllipticCurveCoder
class CurveType(Enum):
secp192r1 = 19
secp256r1 = 23
secp384r1 = 24
x25519 = 29
def key_to_bytes(key: Union[str, bytes, X25519PrivateKey, X25519PublicKey], reverse=False) -> bytes:
val = None
if isinstance(key, str):
val = bytes.fromhex(key)
if isinstance(key, bytes):
# logger.warning('key_to_bytes: key is bytes already')
val = key
raw_kwargs = dict(encoding=cryptography.hazmat.primitives._serialization.Encoding.Raw,
format=cryptography.hazmat.primitives._serialization.PublicFormat.Raw)
if isinstance(key, X25519PublicKey):
val = key.public_bytes(**raw_kwargs)
elif isinstance(key, X25519PrivateKey):
val = key.private_bytes(**raw_kwargs)
assert type(val) is bytes
if reverse:
val = bytes(reversed(val))
return val
def key_to_hex(key: Union[str, bytes, X25519PrivateKey, X25519PublicKey]) -> str:
return key_to_bytes(key).hex()
def arraycopy(src, src_pos, dest, dest_pos, length):
for i in range(length):
dest[i + dest_pos] = src[i + src_pos]
def pack(fmt, *args):
# enforce little endian
return struct.pack(f'<{fmt}', *args)
def unpack(fmt, *args):
# enforce little endian
return struct.unpack(f'<{fmt}', *args)
class FrameType(Enum):
ACK = 0
CMD = 1
AUX = 2
NAK = 3
class FrameHead:
seq: int # u8
type: FrameType # u8
length: int # u16
@staticmethod
def from_bytes(buf: bytes) -> FrameHead:
seq, ft, length = unpack('BBH', buf)
return FrameHead(seq, FrameType(ft), length)
def __init__(self, seq: 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"
return 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)
class Message:
frame: Optional[FrameItem]
def __init__(self):
self.frame = None
@staticmethod
def from_encrypted(buf: bytes, inkey: bytes, outkey: bytes) -> Message:
_logger.debug('[from_encrypted] buf='+buf.hex())
# print(f'buf len={len(buf)}')
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:]
# byte b = paramFrameHead.seq;
b = head.seq
# TODO check if protocol is 2, otherwise raise an exception
j = b & 0xF
k = b >> 4 & 0xF
# arrayOfByte1 = this.encryptionInKey;
key = bytearray(len(inkey))
arraycopy(inkey, j, key, 0, len(inkey) - j)
arraycopy(inkey, 0, key, len(inkey) - j, j)
# arrayOfByte1 = this.encryptionOutKey;
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()
# print(f'head.length={head.length} len(decr)={len(decrypted_data)}')
if len(decrypted_data) > head.length:
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
decrypted_data = unpadder.update(decrypted_data)
try:
decrypted_data += unpadder.finalize()
except ValueError as exc:
_logger.exception(exc)
pass
_logger.debug('decrypted data:', decrypted_data.hex())
assert len(decrypted_data) != 0, 'decrypted data is null'
assert head.seq == decrypted_data[0], f'decrypted seq mismatch {head.seq} != {decrypted_data[0]}'
if head.type == FrameType.ACK:
return AckMessage(head.seq)
elif head.type == FrameType.NAK:
return NakMessage(head.seq)
else:
cmd = decrypted_data[0]
data = decrypted_data[2:]
return CmdMessage(head.seq, cmd, data)
def encrypt(self):
raise RuntimeError('this method is abstract')
@property
def data(self) -> bytes:
raise RuntimeError('this method is abstract')
def _encrypt(self,
outkey: bytes,
inkey: bytes,
token: bytes,
pubkey: bytes):
assert self.frame is not None
data = self.data
assert data is not None
# print('data: '+data.hex())
b = self.frame.head.seq
i = b & 0xf
j = b >> 4 & 0xf
# byte[] arrayOfByte1 = this.encryptionOutKey;
outkey = bytearray(outkey)
# arrayOfByte = new byte[arrayOfByte1.length];
l = len(outkey)
key = bytearray(l)
# System.arraycopy(arrayOfByte1, i, arrayOfByte, 0, arrayOfByte1.length - i);
arraycopy(outkey, i, key, 0, l-i)
# arrayOfByte1 = this.encryptionOutKey;
# System.arraycopy(arrayOfByte1, 0, arrayOfByte, arrayOfByte1.length - i, i);
arraycopy(outkey, 0, key, l-i, i)
# byte[] arrayOfByte2 = this.encryptionInKey;
inkey = bytearray(inkey)
# arrayOfByte1 = new byte[arrayOfByte2.length];
l = len(inkey)
iv = bytearray(l)
# System.arraycopy(arrayOfByte2, j, arrayOfByte1, 0, arrayOfByte2.length - j);
arraycopy(inkey, j, iv, 0, l-j)
# arrayOfByte2 = this.encryptionInKey;
# System.arraycopy(arrayOfByte2, 0, arrayOfByte1, arrayOfByte2.length - j, j);
arraycopy(inkey, 0, iv, l-j, j)
# Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
# SecretKeySpec secretKeySpec = new SecretKeySpec();
# this(arrayOfByte, "AES");
# IvParameterSpec ivParameterSpec = new IvParameterSpec();
# this(arrayOfByte1);
# cipher.init(1, secretKeySpec, ivParameterSpec);
cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv))
encryptor = cipher.encryptor()
# arrayOfByte = new byte[paramArrayOfbyte.length + 1];
# arrayOfByte[0] = b;
# System.arraycopy(paramArrayOfbyte, 0, arrayOfByte, 1, paramArrayOfbyte.length);
# data = bytearray(data)
newdata = bytearray(len(data)+1)
newdata[0] = b
# data = bytearray(len(payload)+1)
# data[0] = b
arraycopy(data, 0, newdata, 1, len(data))
newdata = bytes(newdata)
_logger.debug('payload to be sent:' + newdata.hex())
# arrayOfByte = ByteUtils.concatArrays(cipher.update(arrayOfByte), cipher.doFinal());
encdata = bytearray()
padder = padding.PKCS7(algorithms.AES.block_size).padder()
encdata.extend(encryptor.update(padder.update(newdata) + padder.finalize()))
encdata.extend(encryptor.finalize())
self.frame.setpayload(encdata)
def construct(self) -> FrameItem:
raise RuntimeError('this is an abstract method')
class AckMessage(Message):
def __init__(self, seq: int):
super().__init__()
self.frame = FrameItem(FrameHead(seq, FrameType.ACK, 0))
class NakMessage(Message):
def __init__(self, seq: int):
super().__init__()
self.frame = FrameItem(FrameHead(seq, FrameType.NAK, 0))
class CmdMessage(Message):
cmd: Optional[int]
cmd_data: Optional[Union[bytes, str]]
def __init__(self,
seq: Optional[int] = None,
cmd: Optional[int] = None,
cmd_data: Optional[bytes] = None):
super().__init__()
if (seq is not None) and (cmd is not None) and (cmd_data is not None):
self.frame = FrameItem(FrameHead(seq, FrameType.CMD))
# self.frame.setpayload(data)
self.cmd = cmd
self.cmd_data = cmd_data
else:
self.cmd = None
self.cmd_data = None
@property
def data(self) -> bytes:
buf = bytearray()
buf.append(self.cmd)
buf.extend(self.cmd_data)
# print(buf)
return bytes(buf)
class ModeMessage(CmdMessage):
def __init__(self, seq: int, on: bool):
super().__init__(seq, 1, b'\x01' if on else b'\x00')
class TargetTemperatureMessage(CmdMessage):
def __init__(self, seq: int, temp: int):
super().__init__(seq, 2, bytes(bytearray([temp, 0])))
class HandshakeMessage(CmdMessage):
def _encrypt(self,
outkey: bytes,
inkey: bytes,
token: bytes,
pubkey: bytes):
cipher = ciphers.Cipher(algorithms.AES(outkey), modes.CBC(inkey))
encryptor = cipher.encryptor()
encr_data = bytearray()
encr_data.extend(encryptor.update(token))
encr_data.extend(encryptor.finalize())
payload = bytearray()
# const/4 v7, 0x0
# aput-byte v7, v5, v7
payload.append(0)
payload.extend(pubkey)
payload.extend(encr_data)
self.frame = FrameItem(FrameHead(0, FrameType.CMD))
self.frame.setpayload(payload)
# Polaris PWK 1725CGLD IoT kettle
class Kettle(zeroconf.ServiceListener):
class Kettle(zeroconf.ServiceListener, ABC):
macaddr: str
token: str
device_token: str
sb: Optional[zeroconf.ServiceBrowser]
found_device: Optional[zeroconf.ServiceInfo]
privkey: Optional[Union[Any, X25519PrivateKey]]
pubkey: Optional[bytes]
sharedkey: Optional[bytes]
sharedsha256: Optional[bytes]
encinkey: Optional[bytes]
encoutkey: Optional[bytes]
seqno: int
conn: Optional[Connection]
def __init__(self, mac: str, token: str):
def __init__(self, mac: str, device_token: str):
super().__init__()
self.zeroconf = zeroconf.Zeroconf()
self.sb = None
self.macaddr = mac
self.token = token
self.device_token = device_token
self.found_device = None
self.privkey = None
self.pubkey = None
self.sharedkey = None
self.sharedsha256 = None
self.encinkey = None
self.encoutkey = None
self.sourceport = random.randint(1024, 65535)
self.seqno = 0
self.conn = None
def find(self):
def find(self) -> zeroconf.ServiceInfo:
self.sb = zeroconf.ServiceBrowser(self.zeroconf, "_syncleo._udp.local.", self)
self.sb.join()
# return self.found_device
return self.found_device
# zeroconf.ServiceListener implementation
def add_service(self,
@ -401,96 +63,35 @@ class Kettle(zeroconf.ServiceListener):
self.zeroconf.close()
self.found_device = info
# def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
# print(f"Service {name} updated")
#
# def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
# print(f"Service {name} removed")
assert self.device_curve == 29, f'curve type {self.device_curve} is not implemented'
def start_server(self, callback: callable):
addresses = list(map(ip_address, self.found_device.addresses))
self.conn = Connection(addr=addresses[0],
port=int(self.found_device.port),
device_pubkey=self.device_pubkey,
device_token=bytes.fromhex(self.device_token))
# shake the kettle's hand
self._pass_message(HandshakeMessage(), callback)
self.conn.start()
def stop_server(self):
self.conn.interrupted = True
@property
def device_pubkey(self) -> str:
return self.found_device.properties[b'public'].decode()
def device_pubkey(self) -> bytes:
return bytes.fromhex(self.found_device.properties[b'public'].decode())
@property
def device_addresses(self) -> list[Union[IPv4Address, IPv6Address]]:
return list(map(ip_address, self.found_device.addresses))
def device_curve(self) -> int:
return int(self.found_device.properties[b'curve'].decode())
@property
def device_port(self) -> int:
return int(self.found_device.port)
def set_power(self, power_type: PowerType, callback: callable):
self._pass_message(ModeMessage(power_type), callback)
# @property
# def device_pubkey_bytes(self) -> bytes:
# return bytes.fromhex(self.device_pubkey)
def set_target_temperature(self, temp: int, callback: callable):
self._pass_message(TargetTemperatureMessage(temp), callback)
@property
def curve_type(self) -> CurveType:
return CurveType(int(self.found_device.properties[b'curve'].decode()))
def genkeys(self):
# based on decompiled EllipticCurveCoder.java
if self.curve_type in (CurveType.secp192r1, CurveType.secp256r1, CurveType.secp384r1):
if self.curve_type == CurveType.secp192r1:
curve = SECP192R1()
elif self.curve_type == CurveType.secp256r1:
curve = SECP256R1()
elif self.curve_type == CurveType.secp384r1:
curve = SECP384R1()
else:
raise TypeError(f'unexpected curve type: {self.curve_type}')
self.privkey = cryptography.hazmat.primitives.asymmetric.ec.generate_private_key(curve)
elif self.curve_type == CurveType.x25519:
self.privkey = X25519PrivateKey.generate()
self.pubkey = key_to_bytes(self.privkey.public_key(), reverse=True)
def genshared(self):
self.sharedkey = bytes(reversed(
self.privkey.exchange(X25519PublicKey.from_public_bytes(
key_to_bytes(self.device_pubkey, reverse=True))
)
))
digest = hashes.Hash(hashes.SHA256())
digest.update(self.sharedkey)
self.sharedsha256 = digest.finalize()
self.encinkey = self.sharedsha256[:16]
self.encoutkey = self.sharedsha256[16:]
def next_seqno(self) -> int:
self.seqno += 1
return self.seqno
def setpower(self, on: bool):
message = ModeMessage(self.next_seqno(), on)
print(self.do_send(message))
def settemperature(self, temp: int):
message = TargetTemperatureMessage(self.next_seqno(), temp)
print(self.do_send(message))
def handshake(self):
message = HandshakeMessage()
response = self.do_send(message)
assert response.frame.head.type == FrameType.ACK, 'ACK expected'
def do_send(self, message: Message) -> Message:
message._encrypt(pubkey=self.pubkey,
outkey=self.encoutkey,
inkey=self.encinkey,
token=bytes.fromhex(self.token))
dst_addr = str(self.device_addresses[0])
dst_port = self.device_port
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', self.sourceport))
sock.sendto(message.frame.pack(), (dst_addr, dst_port))
_logger.debug('data has been sent, waiting for incoming data....')
data = sock.recv(4096)
return Message.from_encrypted(data, inkey=self.encinkey, outkey=self.encoutkey)
def _pass_message(self, message: Message, callback: callable):
self.conn.send_message(message, partial(callback, self))

412
src/polaris/protocol.py Normal file
View File

@ -0,0 +1,412 @@
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import logging
import socket
import random
import struct
import threading
import queue
from enum import Enum
from typing import Union, Optional, Any
from ipaddress import IPv4Address, IPv6Address
import cryptography.hazmat.primitives._serialization
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
_logger = logging.getLogger(__name__)
# drop-in replacement for Java API
# TODO: rewrite
def arraycopy(src, src_pos, dest, dest_pos, length):
for i in range(length):
dest[i + dest_pos] = src[i + src_pos]
class FrameType(Enum):
ACK = 0
CMD = 1
AUX = 2
NAK = 3
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'
class FrameHead:
seq: int # u8
type: FrameType # u8
length: int # u16
@staticmethod
def from_bytes(buf: bytes) -> FrameHead:
seq, ft, length = struct.unpack('<BBH', buf)
return FrameHead(seq, FrameType(ft), length)
def __init__(self, seq: 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"
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)
class Message:
frame: Optional[FrameItem]
def __init__(self):
self.frame = None
@staticmethod
def from_encrypted(buf: bytes,
inkey: bytes,
outkey: bytes) -> Message:
# _logger.debug('[from_encrypted] buf='+buf.hex())
# print(f'buf len={len(buf)}')
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:]
# byte b = paramFrameHead.seq;
b = head.seq
# TODO check if protocol is 2, otherwise raise an exception
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()
# print(f'head.length={head.length} len(decr)={len(decrypted_data)}')
# if len(decrypted_data) > head.length:
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
decrypted_data = unpadder.update(decrypted_data)
# try:
decrypted_data += unpadder.finalize()
# except ValueError as exc:
# _logger.exception(exc)
# pass
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:
raise NotImplementedError('FrameType AUX is not yet implemented')
elif head.type == FrameType.CMD:
cmd = decrypted_data[0]
data = decrypted_data[2:]
return CmdMessage(head.seq, cmd, data)
else:
raise NotImplementedError(f'Unexpected frame type: {head.type}')
@property
def data(self) -> bytes:
return b''
def encrypt(self,
outkey: bytes,
inkey: bytes,
token: bytes,
pubkey: bytes):
assert self.frame is not None
data = self.data
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('payload to be sent: ' + 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 set_seq(self, seq: int):
self.frame.head.seq = seq
def __repr__(self):
return f'<{self.__class__.__name__} seq={self.frame.head.seq}>'
class AckMessage(Message):
def __init__(self, seq: int = 0):
super().__init__()
self.frame = FrameItem(FrameHead(seq, FrameType.ACK, 0))
class NakMessage(Message):
def __init__(self, seq: int = 0):
super().__init__()
self.frame = FrameItem(FrameHead(seq, FrameType.NAK, 0))
class CmdMessage(Message):
_type: Optional[int]
_data: bytes
def __init__(self, seq=0,
type: Optional[int] = None,
data: bytes = b''):
super().__init__()
self._data = data
if type is not None:
self.frame = FrameItem(FrameHead(seq, FrameType.CMD))
self._type = type
else:
self._type = None
@property
def data(self) -> bytes:
buf = bytearray()
buf.append(self._type)
buf.extend(self._data)
return bytes(buf)
def __repr__(self):
params = [
__name__+'.'+self.__class__.__name__,
f'seq={self.frame.head.seq}',
# f'type={self.frame.head.type}',
f'cmd={self._type}'
]
if self._data:
params.append(f'data={self._data.hex()}')
return '<'+' '.join(params)+'>'
class ModeMessage(CmdMessage):
def __init__(self, power_type: PowerType):
super().__init__(type=1,
data=(power_type.value).to_bytes(1, byteorder='little'))
class TargetTemperatureMessage(CmdMessage):
def __init__(self, temp: int):
super().__init__(type=2,
data=bytes(bytearray([temp, 0])))
class HandshakeMessage(CmdMessage):
def __init__(self):
super().__init__(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)
# TODO
# implement resending UDP messages if no answer has been received in a second
# try at least 5 times, then give up
class Connection(threading.Thread):
seq_no: int
source_port: int
device_addr: str
device_port: int
device_token: bytes
interrupted: bool
waiting_for_response: dict[int, callable]
pubkey: Optional[bytes]
encinkey: Optional[bytes]
encoutkey: Optional[bytes]
def __init__(self,
addr: Union[IPv4Address, IPv6Address],
port: int,
device_pubkey: bytes,
device_token: bytes):
super().__init__()
self.logger = logging.getLogger(__name__+'.'+self.__class__.__name__)
self.setName(self.__class__.__name__)
# self.daemon = True
self.seq_no = -1
self.source_port = random.randint(1024, 65535)
self.device_addr = str(addr)
self.device_port = port
self.device_token = device_token
self.lock = threading.Lock()
self.outgoing_queue = queue.SimpleQueue()
self.waiting_for_response = {}
self.interrupted = False
self.pubkey = None
self.encinkey = None
self.encoutkey = None
self.prepare_keys(device_pubkey)
def prepare_keys(self, device_pubkey: bytes):
# generate key pair
privkey = X25519PrivateKey.generate()
self.pubkey = bytes(reversed(privkey.public_key().public_bytes(encoding=cryptography.hazmat.primitives._serialization.Encoding.Raw,
format=cryptography.hazmat.primitives._serialization.PublicFormat.Raw)))
# generate shared key
device_pubkey = X25519PublicKey.from_public_bytes(
bytes(reversed(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:]
def run(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', self.source_port))
sock.settimeout(1)
while not self.interrupted:
if not self.outgoing_queue.empty():
message = self.outgoing_queue.get()
message.encrypt(outkey=self.encoutkey,
inkey=self.encinkey,
token=self.device_token,
pubkey=self.pubkey)
buf = message.frame.pack()
self.logger.debug('send: '+buf.hex())
self.logger.debug(f'sendto: {self.device_addr}:{self.device_port}')
sock.sendto(buf, (self.device_addr, self.device_port))
try:
data = sock.recv(4096)
self.handle_incoming(data)
except TimeoutError:
pass
def handle_incoming(self, buf: bytes):
self.logger.debug('handle_incoming: '+buf.hex())
message = Message.from_encrypted(buf, inkey=self.encinkey, outkey=self.encoutkey)
if message.frame.head.seq in self.waiting_for_response:
self.logger.info(f'received awaited message: {message}')
try:
f = self.waiting_for_response[message.frame.head.seq]
f(message)
except Exception as exc:
self.logger.exception(exc)
finally:
del self.waiting_for_response[message.frame.head.seq]
else:
self.logger.info(f'received message (not awaited): {message}')
def send_message(self, message: Message, callback: callable):
seq = self.next_seqno()
message.set_seq(seq)
self.outgoing_queue.put(message)
self.waiting_for_response[seq] = callback
def next_seqno(self) -> int:
with self.lock:
self.seq_no += 1
self.logger.debug(f'next_seqno: set to {self.seq_no}')
return self.seq_no

View File

@ -1,18 +1,21 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: BSD-3-Clause
import logging
# import os
import sys
import time
import paho.mqtt.client as mqtt
# from datetime import datetime
# from html import escape
from argparse import ArgumentParser
from queue import SimpleQueue
# from home.bot import Wrapper, Context
# from home.api.types import BotType
# from home.util import parse_addr
from home.mqtt import MQTTBase
from home.config import config
from polaris import Kettle
from polaris import Kettle, Message, FrameType, PowerType
# from telegram.error import TelegramError
# from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
@ -24,6 +27,7 @@ from polaris import Kettle
logger = logging.getLogger(__name__)
control_tasks = SimpleQueue()
# bot: Optional[Wrapper] = None
# RenderedContent = tuple[str, Optional[InlineKeyboardMarkup]]
@ -88,6 +92,24 @@ class MQTTServer(MQTTBase):
# ]
# return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
def kettle_connection_established(k: Kettle, response: Message):
try:
assert response.frame.head.type == FrameType.ACK, f'ACK expected, but received: {response}'
except AssertionError:
k.stop_server()
return
def next_task(k, response):
if not control_tasks.empty():
task = control_tasks.get()
f, args = task(k)
args.append(next_task)
f(*args)
else:
k.stop_server()
next_task(k, response)
def main():
tempmin = 30
@ -98,7 +120,8 @@ def main():
parser.add_argument('-m', dest='mode', required=True, type=str, choices=('mqtt', 'control'))
parser.add_argument('--on', action='store_true')
parser.add_argument('--off', action='store_true')
parser.add_argument('-t', '--temperature', dest='temp', type=int, choices=range(tempmin, tempmax+tempstep, tempstep))
parser.add_argument('-t', '--temperature', dest='temp', type=int, default=tempmax,
choices=range(tempmin, tempmax+tempstep, tempstep))
arg = config.load('polaris_kettle_bot', use_cli=True, parser=parser)
@ -113,27 +136,20 @@ def main():
if arg.on and arg.off:
raise RuntimeError('--on and --off are mutually exclusive')
k = Kettle(mac='40f52018dec1', token='3a5865f015950cae82cd120e76a80d28')
k.find()
print('device found')
if arg.off:
control_tasks.put(lambda k: (k.set_power, [PowerType.OFF]))
else:
if arg.temp == tempmax:
control_tasks.put(lambda k: (k.set_power, [PowerType.ON]))
else:
control_tasks.put(lambda k: (k.set_target_temperature, [arg.temp]))
control_tasks.put(lambda k: (k.set_power, [PowerType.CUSTOM]))
k.genkeys()
k.genshared()
k.handshake()
k = Kettle(mac='40f52018dec1', device_token='3a5865f015950cae82cd120e76a80d28')
info = k.find()
print('found service:', info)
if arg.on:
k.setpower(True)
elif arg.off:
k.setpower(False)
elif arg.temp:
k.settemperature(arg.temp)
# k.sendfirst()
# print('shared key:', key_to_hex(k.sharedkey))
# print('shared hash:', key_to_hex(k.sharedsha256))
# print(len(k.sharedsha256))
k.start_server(kettle_connection_established)
return 0