polaris pwk 1725cgld full support

- significant improvements, correctnesses and stability fixes in
  protocol implementation
- correct handling of device appearances and disappearances
- flawlessly functioning telegram bot that re-renders kettle's state
  (temperature and other) in real time
This commit is contained in:
Evgeny Zinoviev 2022-06-28 03:22:30 +03:00
parent ee09bc98ae
commit 8f20c9b825
29 changed files with 1975 additions and 358 deletions

View File

@ -44,7 +44,7 @@ From `devices.json`:
### Random notes
All commands, from `com/polaris/iot/api/comments`:
All commands, from `com/polaris/iot/api/commands`:
```
$ grep -A1 -r "public byte getType()" .
./CmdAccessControl.java: public byte getType() {
@ -168,4 +168,43 @@ $ grep -A1 -r "public byte getType()" .
./CmdChildLock.java- return 30;
```
See also `com/syncleoiot/**/commands`.
From `com/syncleoiot/iottransport/udp/commands`:
```
$ grep -A1 -r "public byte getType()" .
./CmdDeviceDiagnostics.java: public byte getType() {
./CmdDeviceDiagnostics.java- return -111;
--
./CmdHandshake.java: public byte getType() {
./CmdHandshake.java- return 0;
--
./CmdUdpFirmware.java: public byte getType() {
./CmdUdpFirmware.java- return -3;
--
./CmdTimeSync.java: public byte getType() {
./CmdTimeSync.java- return -128;
--
./CmdPing.java: public byte getType() {
./CmdPing.java- return -1;
```
From `com/syncleoiot/iottransport/commands`:
```
$ grep -A1 -r "public byte getType()" .
./CmdCrossConfig.java: public byte getType() {
./CmdCrossConfig.java- return -125;
--
./CmdWifiConfiguration.java: public byte getType() {
./CmdWifiConfiguration.java- return -126;
--
./CmdDiagnostics.java: public byte getType() {
./CmdDiagnostics.java- return -115;
--
./CmdWifiStatus.java: public byte getType() {
./CmdWifiStatus.java- return -126;
--
./CmdHardware.java: public byte getType() {
./CmdHardware.java- return 0;
--
./CmdWifiList.java: public byte getType() {
./CmdWifiList.java- return -127;
```

View File

@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional, List
class ApiResponseError(Exception):
@ -6,7 +6,7 @@ class ApiResponseError(Exception):
status_code: int,
error_type: str,
error_message: str,
error_stacktrace: Optional[list[str]] = None):
error_stacktrace: Optional[List[str]] = None):
super().__init__()
self.status_code = status_code
self.error_message = error_message

View File

@ -7,6 +7,7 @@ class BotType(Enum):
SENSORS = auto()
ADMIN = auto()
SOUND = auto()
POLARIS_KETTLE = auto()
class TemperatureSensorLocation(Enum):

View File

@ -6,7 +6,7 @@ import logging
from collections import namedtuple
from datetime import datetime
from enum import Enum, auto
from typing import Optional, Callable, Union
from typing import Optional, Callable, Union, List, Tuple, Dict
from requests.auth import HTTPBasicAuth
from .errors import ApiResponseError
@ -28,13 +28,13 @@ class HTTPMethod(Enum):
class WebAPIClient:
token: str
timeout: Union[float, tuple[float, float]]
timeout: Union[float, Tuple[float, float]]
basic_auth: Optional[HTTPBasicAuth]
do_async: bool
async_error_handler: Optional[Callable]
async_success_handler: Optional[Callable]
def __init__(self, timeout: Union[float, tuple[float, float]] = 5):
def __init__(self, timeout: Union[float, Tuple[float, float]] = 5):
self.token = config['api']['token']
self.timeout = timeout
self.basic_auth = None
@ -66,7 +66,7 @@ class WebAPIClient:
})
def log_openwrt(self,
lines: list[tuple[int, str]]):
lines: List[Tuple[int, str]]):
return self._post('logs/openwrt', {
'logs': stringify(lines)
})
@ -81,14 +81,14 @@ class WebAPIClient:
return [(datetime.fromtimestamp(date), temp, hum) for date, temp, hum in data]
def add_sound_sensor_hits(self,
hits: list[tuple[str, int]]):
hits: List[Tuple[str, int]]):
return self._post('sound_sensors/hits/', {
'hits': stringify(hits)
})
def get_sound_sensor_hits(self,
location: SoundSensorLocation,
after: datetime) -> list[dict]:
after: datetime) -> List[dict]:
return self._process_sound_sensor_hits_data(self._get('sound_sensors/hits/', {
'after': int(after.timestamp()),
'location': location.value
@ -100,13 +100,13 @@ class WebAPIClient:
'location': location.value
}))
def recordings_list(self, extended=False, as_objects=False) -> Union[list[str], list[dict], list[RecordFile]]:
def recordings_list(self, extended=False, as_objects=False) -> Union[List[str], List[dict], List[RecordFile]]:
files = self._get('recordings/list/', {'extended': int(extended)})['data']
if as_objects:
return MediaNodeClient.record_list_from_serialized(files)
return files
def _process_sound_sensor_hits_data(self, data: list[dict]) -> list[dict]:
def _process_sound_sensor_hits_data(self, data: List[dict]) -> List[dict]:
for item in data:
item['time'] = datetime.fromtimestamp(item['time'])
return data
@ -124,7 +124,7 @@ class WebAPIClient:
name: str,
params: dict,
method: HTTPMethod,
files: Optional[dict[str, str]] = None):
files: Optional[Dict[str, str]] = None):
if not self.do_async:
return self._make_request(name, params, method, files)
else:
@ -136,7 +136,7 @@ class WebAPIClient:
name: str,
params: dict,
method: HTTPMethod = HTTPMethod.GET,
files: Optional[dict[str, str]] = None) -> Optional[any]:
files: Optional[Dict[str, str]] = None) -> Optional[any]:
domain = config['api']['host']
kwargs = {}

View File

@ -2,7 +2,7 @@ import subprocess
from ..config import config
from threading import Lock
from typing import Union
from typing import Union, List
_lock = Lock()
@ -16,7 +16,7 @@ def has_control(s: str) -> bool:
return False
def get_caps(s: str) -> list[str]:
def get_caps(s: str) -> List[str]:
for control in config['amixer']['controls']:
if control['name'] == s:
return control['caps']

View File

@ -1,6 +1,6 @@
from .reporting import ReportingHelper
from .lang import LangPack
from .wrapper import Wrapper, Context, text_filter
from .wrapper import Wrapper, Context, text_filter, handlermethod
from .store import Store
from .errors import *
from .util import command_usage, user_any_name

View File

@ -1,6 +1,8 @@
from __future__ import annotations
import logging
from typing import Union, Optional
from typing import Union, Optional, List, Dict
logger = logging.getLogger(__name__)
@ -24,7 +26,7 @@ class LangStrings(dict):
class LangPack:
strings: dict[str, LangStrings[str, str]]
strings: Dict[str, LangStrings[str, str]]
default_lang: str
def __init__(self):
@ -57,11 +59,14 @@ class LangPack:
return result
@property
def languages(self) -> list[str]:
def languages(self) -> List[str]:
return list(self.strings.keys())
def get(self, key: str, lang: str, *args) -> str:
return self.strings[lang][key] % args
if args:
return self.strings[lang][key] % args
else:
return self.strings[lang][key]
def __call__(self, *args, **kwargs):
return self.strings[self.default_lang][args[0]]

View File

@ -8,6 +8,7 @@ from telegram import (
ReplyKeyboardMarkup,
CallbackQuery,
User,
Message,
)
from telegram.ext import (
Updater,
@ -22,7 +23,7 @@ from telegram.ext import (
)
from telegram.error import TimedOut
from ..config import config
from typing import Optional, Union
from typing import Optional, Union, List, Tuple
from .store import Store
from .lang import LangPack
from ..api.types import BotType
@ -110,7 +111,7 @@ class Context:
kwargs = dict(parse_mode=ParseMode.HTML)
if not isinstance(markup, IgnoreMarkup):
kwargs['reply_markup'] = markup
self._update.message.reply_text(text, **kwargs)
return self._update.message.reply_text(text, **kwargs)
def reply_exc(self, e: Exception) -> None:
self.reply(exc2text(e))
@ -133,7 +134,7 @@ class Context:
return self._update.callback_query
@property
def args(self) -> Optional[list[str]]:
def args(self) -> Optional[List[str]]:
return self._callback_context.args
@property
@ -157,6 +158,25 @@ class Context:
return self._update.callback_query and self._update.callback_query.data and self._update.callback_query.data != ''
def handlermethod(f: callable):
def _handler(self, update: Update, context: CallbackContext, *args, **kwargs):
ctx = Context(update,
callback_context=context,
markup_getter=self.markup,
lang=self.lang,
store=self.store)
try:
return f(self, ctx, *args, **kwargs)
except Exception as e:
if not self.exception_handler(e, ctx) and not isinstance(e, TimedOut):
logger.exception(e)
if not ctx.is_callback_context():
ctx.reply_exc(e)
else:
self.notify_user(ctx.user_id, exc2text(e))
return _handler
class Wrapper:
store: Optional[Store]
updater: Updater
@ -252,7 +272,7 @@ class Wrapper:
def exception_handler(self, e: Exception, ctx: Context) -> Optional[bool]:
pass
def notify_all(self, text_getter: callable, exclude: tuple[int] = ()) -> None:
def notify_all(self, text_getter: callable, exclude: Tuple[int] = ()) -> None:
if 'notify_users' not in config['bot']:
logger.error('notify_all() called but no notify_users directive found in the config')
return
@ -280,6 +300,12 @@ class Wrapper:
def send_file(self, user_id, **kwargs):
self.updater.bot.send_document(chat_id=user_id, **kwargs)
def edit_message_text(self, user_id, message_id, *args, **kwargs):
self.updater.bot.edit_message_text(chat_id=user_id, message_id=message_id, parse_mode='HTML', *args, **kwargs)
def delete_message(self, user_id, message_id):
self.updater.bot.delete_message(chat_id=user_id, message_id=message_id)
#
# Language Selection
#

View File

@ -3,6 +3,7 @@ import os.path
import logging
import psutil
from typing import List, Tuple
from ..util import chunks
from ..config import config
@ -62,7 +63,7 @@ async def ffmpeg_cut(input: str,
_logger.info(f'ffmpeg_cut({input}): OK')
def dvr_scan_timecodes(timecodes: str) -> list[tuple[int, int]]:
def dvr_scan_timecodes(timecodes: str) -> List[Tuple[int, int]]:
tc_backup = timecodes
timecodes = timecodes.split(',')

View File

@ -5,7 +5,7 @@ from ..api.types import (
BotType,
SoundSensorLocation
)
from typing import Optional
from typing import Optional, List, Tuple
from datetime import datetime
from html import escape
@ -37,7 +37,7 @@ class BotsDatabase(MySQLDatabase):
self.commit()
def add_openwrt_logs(self,
lines: list[tuple[datetime, str]]):
lines: List[Tuple[datetime, str]]):
now = datetime.now()
with self.cursor() as cursor:
for line in lines:
@ -47,7 +47,7 @@ class BotsDatabase(MySQLDatabase):
self.commit()
def add_sound_hits(self,
hits: list[tuple[SoundSensorLocation, int]],
hits: List[Tuple[SoundSensorLocation, int]],
time: datetime):
with self.cursor() as cursor:
for loc, count in hits:
@ -58,7 +58,7 @@ class BotsDatabase(MySQLDatabase):
def get_sound_hits(self,
location: SoundSensorLocation,
after: Optional[datetime] = None,
last: Optional[int] = None) -> list[dict]:
last: Optional[int] = None) -> List[dict]:
with self.cursor(dictionary=True) as cursor:
sql = "SELECT `time`, hits FROM sound_hits WHERE location=%s"
args = [location.name.lower()]
@ -84,7 +84,7 @@ class BotsDatabase(MySQLDatabase):
def get_openwrt_logs(self,
filter_text: str,
min_id: int,
limit: int = None) -> list[OpenwrtLogRecord]:
limit: int = None) -> List[OpenwrtLogRecord]:
tz = pytz.timezone('Europe/Moscow')
with self.cursor(dictionary=True) as cursor:
sql = "SELECT * FROM openwrt WHERE text LIKE %s AND id > %s"

View File

@ -2,7 +2,7 @@ import requests
import shutil
import logging
from typing import Optional, Union
from typing import Optional, Union, List
from .storage import RecordFile
from ..util import Addr
from ..api.errors import ApiResponseError
@ -25,7 +25,7 @@ class MediaNodeClient:
def record_download(self, record_id: int, output: str):
return self._call(f'record/download/{record_id}/', save_to=output)
def storage_list(self, extended=False, as_objects=False) -> Union[list[str], list[dict], list[RecordFile]]:
def storage_list(self, extended=False, as_objects=False) -> Union[List[str], List[dict], List[RecordFile]]:
r = self._call('storage/list/', params={'extended': int(extended)})
files = r['files']
if as_objects:
@ -33,7 +33,7 @@ class MediaNodeClient:
return files
@staticmethod
def record_list_from_serialized(files: Union[list[str], list[dict]]):
def record_list_from_serialized(files: Union[List[str], List[dict]]):
new_files = []
for f in files:
kwargs = {'remote': True}

View File

@ -5,7 +5,7 @@ import time
import subprocess
import signal
from typing import Optional
from typing import Optional, List, Dict
from ..util import find_child_processes, Addr
from ..config import config
from .storage import RecordFile, RecordStorage
@ -22,7 +22,7 @@ class RecordHistoryItem:
request_time: float
start_time: float
stop_time: float
relations: list[int]
relations: List[int]
status: RecordStatus
error: Optional[Exception]
file: Optional[RecordFile]
@ -76,7 +76,7 @@ class RecordingNotFoundError(Exception):
class RecordHistory:
history: dict[int, RecordHistoryItem]
history: Dict[int, RecordHistoryItem]
def __init__(self):
self.history = {}

View File

@ -7,7 +7,7 @@ from tempfile import gettempdir
from .record import RecordStatus
from .node_client import SoundNodeClient, MediaNodeClient, CameraNodeClient
from ..util import Addr
from typing import Optional, Callable
from typing import Optional, Callable, Dict
class RecordClient:
@ -15,14 +15,14 @@ class RecordClient:
interrupted: bool
logger: logging.Logger
clients: dict[str, MediaNodeClient]
awaiting: dict[str, dict[int, Optional[dict]]]
clients: Dict[str, MediaNodeClient]
awaiting: Dict[str, Dict[int, Optional[dict]]]
error_handler: Optional[Callable]
finished_handler: Optional[Callable]
download_on_finish: bool
def __init__(self,
nodes: dict[str, Addr],
nodes: Dict[str, Addr],
error_handler: Optional[Callable] = None,
finished_handler: Optional[Callable] = None,
download_on_finish=False):
@ -50,7 +50,7 @@ class RecordClient:
self.stop()
self.logger.exception(exc)
def make_clients(self, nodes: dict[str, Addr]):
def make_clients(self, nodes: Dict[str, Addr]):
pass
def stop(self):
@ -148,9 +148,9 @@ class RecordClient:
class SoundRecordClient(RecordClient):
DOWNLOAD_EXTENSION = 'mp3'
# clients: dict[str, SoundNodeClient]
# clients: Dict[str, SoundNodeClient]
def make_clients(self, nodes: dict[str, Addr]):
def make_clients(self, nodes: Dict[str, Addr]):
for node, addr in nodes.items():
self.clients[node] = SoundNodeClient(addr)
self.awaiting[node] = {}
@ -158,9 +158,9 @@ class SoundRecordClient(RecordClient):
class CameraRecordClient(RecordClient):
DOWNLOAD_EXTENSION = 'mp4'
# clients: dict[str, CameraNodeClient]
# clients: Dict[str, CameraNodeClient]
def make_clients(self, nodes: dict[str, Addr]):
def make_clients(self, nodes: Dict[str, Addr]):
for node, addr in nodes.items():
self.clients[node] = CameraNodeClient(addr)
self.awaiting[node] = {}

View File

@ -3,7 +3,7 @@ import re
import shutil
import logging
from typing import Optional, Union
from typing import Optional, Union, List
from datetime import datetime
from ..util import strgen
@ -149,7 +149,7 @@ class RecordStorage:
self.root = root
def getfiles(self, as_objects=False) -> Union[list[str], list[RecordFile]]:
def getfiles(self, as_objects=False) -> Union[List[str], List[RecordFile]]:
files = []
for name in os.listdir(self.root):
path = os.path.join(self.root, name)

View File

@ -1,6 +1,7 @@
import requests
import logging
from typing import Tuple
from ..config import config
@ -29,7 +30,7 @@ def send_photo(filename: str):
def _send_telegram_data(text: str,
parse_mode: str = None,
disable_web_page_preview: bool = False) -> tuple[dict, str]:
disable_web_page_preview: bool = False) -> Tuple[dict, str]:
data = {
'chat_id': config['telegram']['chat_id'],
'text': text

View File

@ -9,7 +9,7 @@ import random
from enum import Enum
from datetime import datetime
from typing import Tuple, Optional
from typing import Tuple, Optional, List
Addr = Tuple[str, int] # network address type (host, port)
@ -96,7 +96,7 @@ def send_datagram(message: str, addr: Addr) -> None:
sock.sendto(message.encode(), addr)
def format_tb(exc) -> Optional[list[str]]:
def format_tb(exc) -> Optional[List[str]]:
tb = traceback.format_tb(exc.__traceback__)
if not tb:
return None
@ -120,7 +120,7 @@ class ChildProcessInfo:
self.cmd = cmd
def find_child_processes(ppid: int) -> list[ChildProcessInfo]:
def find_child_processes(ppid: int) -> List[ChildProcessInfo]:
p = subprocess.run(['pgrep', '-P', str(ppid), '--list-full'], capture_output=True)
if p.returncode != 0:
raise OSError(f'pgrep returned {p.returncode}')

View File

@ -14,7 +14,7 @@ from home.database.sqlite import SQLiteBase
from home.camera import util as camutil
from enum import Enum
from typing import Optional, Union
from typing import Optional, Union, List
from datetime import datetime, timedelta
@ -273,7 +273,7 @@ def get_motion_path(cam: int) -> str:
def get_recordings_files(cam: int,
time_filter_type: Optional[TimeFilterType] = None) -> list[dict]:
time_filter_type: Optional[TimeFilterType] = None) -> List[dict]:
from_time = 0
to_time = int(time.time())
@ -305,7 +305,7 @@ def get_recordings_files(cam: int,
async def process_fragments(camera: int,
filename: str,
fragments: list[tuple[int, int]]) -> None:
fragments: List[Tuple[int, int]]) -> None:
time = filename_to_datetime(filename)
rec_dir = get_recordings_path(camera)
@ -338,7 +338,7 @@ async def process_fragments(camera: int,
async def motion_notify_tg(camera: int,
filename: str,
fragments: list[tuple[int, int]]):
fragments: List[Tuple[int, int]]):
dt_file = filename_to_datetime(filename)
fmt = '%H:%M:%S'

View File

@ -5,6 +5,7 @@ from datetime import datetime
from home.config import config
from home.database import SimpleState
from home.api import WebAPIClient
from typing import Tuple
log_file = '/var/log/openwrt.log'
@ -24,7 +25,7 @@ $UDPServerRun 514
"""
def parse_line(line: str) -> tuple[int, str]:
def parse_line(line: str) -> Tuple[int, str]:
space_pos = line.index(' ')
date = line[:space_pos]
@ -58,7 +59,7 @@ if __name__ == '__main__':
state['seek'] = f.tell()
state['size'] = fsize
lines: list[tuple[int, str]] = []
lines: List[Tuple[int, str]] = []
if content != '':
for line in content.strip().split('\n'):

View File

@ -1,4 +1,12 @@
# SPDX-License-Identifier: BSD-3-Clause
# Polaris PWK 1725CGLD "smart" kettle python library
# --------------------------------------------------
# Copyright (C) Evgeny Zinoviev, 2022
# License: BSD-3c
from .kettle import Kettle
from .protocol import Message, FrameType, PowerType
from .kettle import Kettle, DeviceListener
from .protocol import (
PowerType,
IncomingMessageListener,
ConnectionStatusListener,
ConnectionStatus
)

View File

@ -1,97 +1,238 @@
# SPDX-License-Identifier: BSD-3-Clause
# 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
import cryptography.hazmat.primitives._serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives import hashes
from functools import partial
from abc import ABC
from ipaddress import ip_address
from typing import Optional
from abc import abstractmethod
from ipaddress import ip_address, IPv4Address, IPv6Address
from typing import Optional, List, Union
from .protocol import (
Connection,
UDPConnection,
ModeMessage,
HandshakeMessage,
TargetTemperatureMessage,
Message,
PowerType
PowerType,
ConnectionStatus,
ConnectionStatusListener,
WrappedMessage
)
_logger = logging.getLogger(__name__)
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.info('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())
# Polaris PWK 1725CGLD IoT kettle
class Kettle(zeroconf.ServiceListener, ABC):
macaddr: str
class DeviceListener:
@abstractmethod
def device_updated(self):
pass
class Kettle(DeviceListener, ConnectionStatusListener):
mac: str
device: Optional[DeviceDiscover]
device_token: str
sb: Optional[zeroconf.ServiceBrowser]
found_device: Optional[zeroconf.ServiceInfo]
conn: Optional[Connection]
conn: Optional[UDPConnection]
conn_status: Optional[ConnectionStatus]
_logger: logging.Logger
_find_evt: threading.Event
def __init__(self, mac: str, device_token: str):
super().__init__()
self.zeroconf = zeroconf.Zeroconf()
self.sb = None
self.macaddr = mac
self.mac = mac
self.device = None
self.device_token = device_token
self.found_device = None
self.conn = None
self.conn_status = None
self._find_evt = threading.Event()
self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__}')
def find(self) -> zeroconf.ServiceInfo:
self.sb = zeroconf.ServiceBrowser(self.zeroconf, "_syncleo._udp.local.", self)
self.sb.join()
def device_updated(self):
self._find_evt.set()
self._logger.info(f'device updated, service info: {self.device.si}')
return self.found_device
def connection_status_updated(self, status: ConnectionStatus):
self.conn_status = status
# zeroconf.ServiceListener implementation
def add_service(self,
zc: zeroconf.Zeroconf,
type_: str,
name: str) -> None:
if name.startswith(f'{self.macaddr}.'):
info = zc.get_service_info(type_, name)
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.sb.cancel()
except RuntimeError:
pass
self.zeroconf.close()
self.found_device = info
self._find_evt.wait(timeout=timeout)
except KeyboardInterrupt:
self.device.stop()
return None
return self.device.si
assert self.device_curve == 29, f'curve type {self.device_curve} is not implemented'
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
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))
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'
self.conn = UDPConnection(addr=self.device.addr,
port=self.device.port,
device_pubkey=self.device.pubkey,
device_token=bytes.fromhex(self.device_token))
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)
# shake the kettle's hand
self._pass_message(HandshakeMessage(), callback)
self.conn.start()
def stop_server(self):
self.conn.interrupted = True
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
@property
def device_pubkey(self) -> bytes:
return bytes.fromhex(self.found_device.properties[b'public'].decode())
@property
def device_curve(self) -> int:
return int(self.found_device.properties[b'curve'].decode())
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):
self._pass_message(ModeMessage(power_type), callback)
message = ModeMessage(power_type)
self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True))
def set_target_temperature(self, temp: int, callback: callable):
self._pass_message(TargetTemperatureMessage(temp), callback)
def _pass_message(self, message: Message, callback: callable):
self.conn.send_message(message, partial(callback, self))
message = TargetTemperatureMessage(temp)
self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True))

File diff suppressed because it is too large Load Diff

684
src/polaris_kettle_bot.py Normal file
View File

@ -0,0 +1,684 @@
#!/usr/bin/env python3
from __future__ import annotations
import logging
import locale
import queue
import time
import threading
import paho.mqtt.client as mqtt
from home.bot import Wrapper, Context, text_filter, handlermethod
from home.api.types import BotType
from home.mqtt import MQTTBase
from home.config import config
from polaris import (
Kettle,
PowerType,
DeviceListener,
IncomingMessageListener,
ConnectionStatusListener,
ConnectionStatus
)
import polaris.protocol as kettle_proto
from typing import Optional, Tuple, List
from collections import namedtuple
from functools import partial
from datetime import datetime
from abc import abstractmethod
from telegram.error import TelegramError
from telegram import (
ReplyKeyboardMarkup,
InlineKeyboardMarkup,
InlineKeyboardButton,
Message
)
from telegram.ext import (
CallbackQueryHandler,
MessageHandler,
CommandHandler
)
logger = logging.getLogger(__name__)
kc: Optional[KettleController] = None
bot: Optional[Wrapper] = None
RenderedContent = Tuple[str, Optional[InlineKeyboardMarkup]]
tasks_lock = threading.Lock()
class KettleInfoListener:
@abstractmethod
def info_updated(self, field: str):
pass
# class that holds data coming from the kettle over mqtt
class KettleInfo:
update_time: int
_mode: Optional[PowerType]
_temperature: Optional[int]
_target_temperature: Optional[int]
_update_listener: KettleInfoListener
def __init__(self, update_listener: KettleInfoListener):
self.update_time = 0
self._mode = None
self._temperature = None
self._target_temperature = None
self._update_listener = update_listener
def _update(self, field: str):
self.update_time = int(time.time())
if self._update_listener:
self._update_listener.info_updated(field)
@property
def temperature(self) -> int:
return self._temperature
@temperature.setter
def temperature(self, value: int):
self._temperature = value
self._update('temperature')
@property
def mode(self) -> PowerType:
return self._mode
@mode.setter
def mode(self, value: PowerType):
self._mode = value
self._update('mode')
@property
def target_temperature(self) -> int:
return self._target_temperature
@target_temperature.setter
def target_temperature(self, value: int):
self._target_temperature = value
self._update('target_temperature')
class KettleController(threading.Thread,
MQTTBase,
DeviceListener,
IncomingMessageListener,
KettleInfoListener,
ConnectionStatusListener):
kettle: Kettle
info: KettleInfo
_logger: logging.Logger
_stopped: bool
_restart_server_at: int
_lock: threading.Lock
_info_lock: threading.Lock
_accumulated_updates: dict
_info_flushed_time: float
_mqtt_root_topic: str
_muts: List[MessageUpdatingTarget]
def __init__(self):
# basic setup
MQTTBase.__init__(self, clean_session=False)
threading.Thread.__init__(self)
self._logger = logging.getLogger(self.__class__.__name__)
self.kettle = Kettle(mac=config['kettle']['mac'],
device_token=config['kettle']['token'])
self.kettle_reconnect()
# info
self.info = KettleInfo(update_listener=self)
self._accumulated_updates = {}
self._info_flushed_time = 0
# mqtt
self._mqtt_root_topic = '/polaris/6/'+config['kettle']['token']+'/#'
self.connect_and_loop(loop_forever=False)
# thread loop related
self._stopped = False
# self._lock = threading.Lock()
self._info_lock = threading.Lock()
self._restart_server_at = 0
# bot
self._muts = []
self._muts_lock = threading.Lock()
self.start()
def kettle_reconnect(self):
self.kettle.discover(wait=False, listener=self)
def stop_all(self):
self.kettle.stop_all()
self._stopped = True
def add_updating_message(self, mut: MessageUpdatingTarget):
with self._muts_lock:
for m in self._muts:
if m.user_id == m.user_id and m.user_did_turn_on() or m.user_did_turn_on() != mut.user_did_turn_on():
m.delete()
self._muts.append(mut)
# ---------------------
# threading.Thread impl
def run(self):
while not self._stopped:
# do_restart_srv = False
#
# with self._lock:
# if self._restart_server_at != 0 and time.time() - self._restart_server_at:
# self._restart_server_at = 0
# do_restart_srv = True
#
# if do_restart_srv:
# self.kettle_connect()
updates = []
deletions = []
with self._muts_lock and self._info_lock:
# self._logger.debug('muts size: '+str(len(self._muts)))
if self._muts and self._accumulated_updates and (self._info_flushed_time == 0 or time.time() - self._info_flushed_time >= 1):
forget = []
deletions = []
for mut in self._muts:
upd = mut.update(
mode=self.info.mode,
current_temp=self.info.temperature,
target_temp=self.info.target_temperature)
if upd.finished or upd.delete:
forget.append(mut)
if upd.delete:
deletions.append(upd)
elif upd.changed:
updates.append(upd)
if forget:
for mut in forget:
self._logger.debug(f'loop: removing mut {mut}')
self._muts.remove(mut)
self._info_flushed_time = time.time()
self._accumulated_updates = {}
for upd in updates:
self._logger.debug(f'loop: got update: {upd}')
try:
bot.edit_message_text(upd.user_id, upd.message_id,
text=upd.html,
reply_markup=upd.markup)
except TelegramError as exc:
self._logger.error(f'loop: edit_message_text failed for update: {upd}')
self._logger.exception(exc)
for upd in deletions:
self._logger.debug(f'loop: got deletion: {upd}')
try:
bot.delete_message(upd.user_id, upd.message_id)
except TelegramError as exc:
self._logger.error(f'loop: delete_message failed for update: {upd}')
self._logger.exception(exc)
time.sleep(0.5)
# -------------------
# DeviceListener impl
def device_updated(self):
self._logger.info(f'device updated: {self.kettle.device.si}')
self.kettle.start_server_if_needed(incoming_message_listener=self,
connection_status_listener=self)
# -----------------------
# KettleInfoListener impl
def info_updated(self, field: str):
with self._info_lock:
newval = getattr(self.info, field)
self._logger.debug(f'info_updated: updated {field}, new value is {newval}')
self._accumulated_updates[field] = newval
# ----------------------------
# IncomingMessageListener impl
def incoming_message(self, message: kettle_proto.Message) -> Optional[kettle_proto.Message]:
self._logger.info(f'incoming message: {message}')
if isinstance(message, kettle_proto.ModeMessage):
self.info.mode = message.pt
elif isinstance(message, kettle_proto.CurrentTemperatureMessage):
self.info.temperature = message.current_temperature
elif isinstance(message, kettle_proto.TargetTemperatureMessage):
self.info.target_temperature = message.temperature
return kettle_proto.AckMessage()
# -----------------------------
# ConnectionStatusListener impl
def connection_status_updated(self, status: ConnectionStatus):
self._logger.info(f'connection status updated: {status}')
if status == ConnectionStatus.DISCONNECTED:
self.kettle.stop_all()
self.kettle_reconnect()
# -------------
# MQTTBase impl
def on_connect(self, client: mqtt.Client, userdata, flags, rc):
super().on_connect(client, userdata, flags, rc)
client.subscribe(self._mqtt_root_topic, qos=1)
self._logger.info(f'subscribed to {self._mqtt_root_topic}')
def on_message(self, client: mqtt.Client, userdata, msg):
try:
topic = msg.topic[len(self._mqtt_root_topic)-2:]
pld = msg.payload.decode()
self._logger.debug(f'mqtt: on message: topic={topic} pld={pld}')
if topic == 'state/sensor/temperature':
self.info.temperature = int(float(pld))
elif topic == 'state/mode':
self.info.mode = PowerType(int(pld))
elif topic == 'state/temperature':
self.info.target_temperature = int(float(pld))
except Exception as e:
self._logger.exception(str(e))
class Renderer:
@classmethod
def index(cls, ctx: Context) -> RenderedContent:
html = f'<b>{ctx.lang("settings")}</b>\n\n'
html += ctx.lang('select_place')
return html, None
@classmethod
def status(cls, ctx: Context,
connected: bool,
mode: PowerType,
current_temp: int,
target_temp: int,
update_time: int) -> RenderedContent:
if not connected:
return cls.not_connected(ctx)
else:
# power status
if mode != PowerType.OFF:
html = ctx.lang('status_on', target_temp)
else:
html = ctx.lang('status_off')
# current temperature
html += '\n'
html += ctx.lang('status_current_temp', current_temp)
# updated on
html += '\n'
html += cls.updated(ctx, update_time)
return html, None
@classmethod
def turned_on(cls, ctx: Context,
target_temp: int,
current_temp: int,
mode: PowerType,
update_time: Optional[int] = None,
reached=False,
no_keyboard=False) -> RenderedContent:
if mode == PowerType.OFF and not reached:
html = ctx.lang('enabling')
else:
if not reached:
emoji = '♨️' if current_temp <= 90 else '🔥'
html = ctx.lang('enabled', emoji, target_temp)
# current temperature
html += '\n'
html += ctx.lang('status_current_temp', current_temp)
else:
html = ctx.lang('enabled_reached', current_temp)
# updated on
if not reached and update_time is not None:
html += '\n'
html += cls.updated(ctx, update_time)
return html, None if no_keyboard else cls.wait_buttons(ctx)
@classmethod
def turned_off(cls, ctx: Context,
mode: PowerType,
update_time: Optional[int] = None,
reached=False,
no_keyboard=False) -> RenderedContent:
if mode != PowerType.OFF:
html = ctx.lang('disabling')
else:
html = ctx.lang('disabled')
# updated on
if not reached and update_time is not None:
html += '\n'
html += cls.updated(ctx, update_time)
return html, None if no_keyboard else cls.wait_buttons(ctx)
@classmethod
def not_connected(cls, ctx: Context) -> RenderedContent:
return ctx.lang('status_not_connected'), None
@classmethod
def smth_went_wrong(cls, ctx: Context) -> RenderedContent:
html = ctx.lang('smth_went_wrong')
return html, None
@classmethod
def updated(cls, ctx: Context, update_time: int):
locale_bak = locale.getlocale(locale.LC_TIME)
locale.setlocale(locale.LC_TIME, 'ru_RU.UTF-8' if ctx.user_lang == 'ru' else 'en_US.UTF-8')
dt = datetime.fromtimestamp(update_time)
html = ctx.lang('status_update_time', dt.strftime(ctx.lang('status_update_time_fmt')))
locale.setlocale(locale.LC_TIME, locale_bak)
return html
@classmethod
def wait_buttons(cls, ctx: Context):
return InlineKeyboardMarkup([
[
InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait')
]
])
def run_tasks(tasks: queue.SimpleQueue, done: callable):
def next_task(r: Optional[kettle_proto.MessageResponse]):
if r is not None:
try:
assert r is not False, 'server error'
except AssertionError as exc:
logger.exception(exc)
tasks_lock.release()
return done(False)
if not tasks.empty():
task = tasks.get()
args = task[1:]
args.append(next_task)
f = getattr(kc.kettle, task[0])
f(*args)
else:
tasks_lock.release()
return done(True)
tasks_lock.acquire()
next_task(None)
MUTUpdate = namedtuple('MUTUpdate', 'message_id, user_id, finished, changed, delete, html, markup')
class MessageUpdatingTarget:
ctx: Context
message: Message
user_target_temp: Optional[int]
user_enabled_power_mode: PowerType
initial_power_mode: PowerType
need_to_delete: bool
rendered_content: Optional[RenderedContent]
def __init__(self,
ctx: Context,
message: Message,
user_enabled_power_mode: PowerType,
initial_power_mode: PowerType,
user_target_temp: Optional[int] = None):
self.ctx = ctx
self.message = message
self.initial_power_mode = initial_power_mode
self.user_enabled_power_mode = user_enabled_power_mode
self.ignore_pm = initial_power_mode is PowerType.OFF and self.user_did_turn_on()
self.user_target_temp = user_target_temp
self.need_to_delete = False
self.rendered_content = None
self.last_reported_temp = None
def set_rendered_content(self, content: RenderedContent):
self.rendered_content = content
def rendered_content_changed(self, content: RenderedContent) -> bool:
return content != self.rendered_content
def update(self,
mode: PowerType,
current_temp: int,
target_temp: int) -> MUTUpdate:
# determine whether status updating is finished
finished = False
reached = False
if self.ignore_pm:
if mode != PowerType.OFF:
self.ignore_pm = False
elif mode == PowerType.OFF:
reached = True
if self.user_did_turn_on():
# when target is 100 degrees, this kettle sometimes turns off at 91, sometimes at 95, sometimes at 98.
# it's totally unpredictable, so in this case, we keep updating the message until it reaches at least 97
# degrees, or if temperature started dropping.
if self.user_target_temp < 100 \
or current_temp >= self.user_target_temp - 3 \
or current_temp < self.last_reported_temp:
finished = True
else:
finished = True
self.last_reported_temp = current_temp
# render message
if self.user_did_turn_on():
rc = Renderer.turned_on(self.ctx,
target_temp=target_temp,
current_temp=current_temp,
mode=mode,
reached=reached,
no_keyboard=finished)
else:
rc = Renderer.turned_off(self.ctx,
mode=mode,
reached=reached,
no_keyboard=finished)
changed = self.rendered_content_changed(rc)
update = MUTUpdate(message_id=self.message.message_id,
user_id=self.ctx.user_id,
finished=finished,
changed=changed,
delete=self.need_to_delete,
html=rc[0],
markup=rc[1])
if changed:
self.set_rendered_content(rc)
return update
def user_did_turn_on(self) -> bool:
return self.user_enabled_power_mode in (PowerType.ON, PowerType.CUSTOM)
def delete(self):
self.need_to_delete = True
@property
def user_id(self) -> int:
return self.ctx.user_id
class KettleBot(Wrapper):
def __init__(self):
super().__init__()
self.lang.ru(
start_message="Выберите команду на клавиатуре",
unknown_command="Неизвестная команда",
unexpected_callback_data="Ошибка: неверные данные",
enable_70="♨️ 70 °C",
enable_80="♨️ 80 °C",
enable_90="♨️ 90 °C",
enable_100="🔥 100 °C",
disable="❌ Выключить",
server_error="Ошибка сервера",
# /status
status_not_connected="😟 Связь с чайником не установлена",
status_on="✅ Чайник <b>включён</b> (до <b>%d °C</b>)",
status_off="❌ Чайник <b>выключен</b>",
status_current_temp="Сейчас: <b>%d °C</b>",
status_update_time="<i>Обновлено %s</i>",
status_update_time_fmt="%d %b в %H:%M:%S",
# enable
enabling="💤 Чайник включается...",
disabling="💤 Чайник выключается...",
enabled="%s Чайник <b>включён</b>.\nЦель: <b>%d °C</b>",
enabled_reached="✅ <b>Готово!</b> Чайник вскипел, температура <b>%d °C</b>.",
disabled="✅ Чайник <b>выключен</b>.",
please_wait="⏳ Ожидайте..."
)
self.lang.en(
start_message="Select command on the keyboard",
unknown_command="Unknown command",
unexpected_callback_data="Unexpected callback data",
enable_70="♨️ 70 °C",
enable_80="♨️ 80 °C",
enable_90="♨️ 90 °C",
enable_100="🔥 100 °C",
disable="❌ Turn OFF",
server_error="Server error",
# /status
not_connected="😟 Connection has not been established",
status_on="✅ Turned <b>ON</b>! Target: <b>%d °C</b>",
status_off="❌ Turned <b>OFF</b>",
status_current_temp="Now: <b>%d °C</b>",
status_update_time="<i>Updated on %s</i>",
status_update_time_fmt="%b %d, %Y at %H:%M:%S",
# enable
enabling="💤 Turning on...",
disabling="💤 Turning off...",
enabled="%s The kettle is <b>turned ON</b>.\nTarget: <b>%d °C</b>",
enabled_reached="✅ It's <b>done</b>! The kettle has boiled, the temperature is <b>%d °C</b>.",
disabled="✅ The kettle is <b>turned OFF</b>.",
please_wait="⏳ Please wait..."
)
# commands
self.add_handler(CommandHandler('status', self.status))
# messages
for temp in (70, 80, 90, 100):
self.add_handler(MessageHandler(text_filter(self.lang.all(f'enable_{temp}')), self.wrap(partial(self.on, temp))))
self.add_handler(MessageHandler(text_filter(self.lang.all('disable')), self.off))
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
buttons = [
[ctx.lang(f'enable_{x}') for x in (70, 80, 90, 100)],
[ctx.lang('disable')]
]
return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
def on(self, temp: int, ctx: Context) -> None:
if not kc.kettle.is_connected():
text, markup = Renderer.not_connected(ctx)
ctx.reply(text, markup=markup)
return
tasks = queue.SimpleQueue()
if temp == 100:
power_mode = PowerType.ON
else:
power_mode = PowerType.CUSTOM
tasks.put(['set_target_temperature', temp])
tasks.put(['set_power', power_mode])
def done(ok: bool):
if not ok:
html, markup = Renderer.smth_went_wrong(ctx)
else:
html, markup = Renderer.turned_on(ctx,
target_temp=temp,
current_temp=kc.info.temperature,
mode=kc.info.mode)
message = ctx.reply(html, markup=markup)
logger.info(f'ctx.reply returned message: {message}')
mut = MessageUpdatingTarget(ctx, message,
initial_power_mode=kc.info.mode,
user_enabled_power_mode=power_mode,
user_target_temp=temp)
mut.set_rendered_content((html, markup))
kc.add_updating_message(mut)
run_tasks(tasks, done)
@handlermethod
def off(self, ctx: Context) -> None:
if not kc.kettle.is_connected():
text, markup = Renderer.not_connected(ctx)
ctx.reply(text, markup=markup)
return
def done(ok: bool):
if not ok:
html, markup = Renderer.smth_went_wrong(ctx)
else:
html, markup = Renderer.turned_off(ctx, mode=kc.info.mode)
message = ctx.reply(html, markup=markup)
logger.info(f'ctx.reply returned message: {message}')
mut = MessageUpdatingTarget(ctx, message,
initial_power_mode=kc.info.mode,
user_enabled_power_mode=PowerType.OFF)
mut.set_rendered_content((html, markup))
kc.add_updating_message(mut)
tasks = queue.SimpleQueue()
tasks.put(['set_power', PowerType.OFF])
run_tasks(tasks, done)
@handlermethod
def status(self, ctx: Context):
text, markup = Renderer.status(ctx,
connected=kc.kettle.is_connected(),
mode=kc.info.mode,
current_temp=kc.info.temperature,
target_temp=kc.info.target_temperature,
update_time=kc.info.update_time)
return ctx.reply(text, markup=markup)
if __name__ == '__main__':
config.load('polaris_kettle_bot')
kc = KettleController()
bot = KettleBot()
if 'api' in config:
bot.enable_logging(BotType.POLARIS_KETTLE)
bot.run()
# bot library handles signals, so when sigterm or something like that happens, we should stop all other threads here
kc.stop_all()

View File

@ -3,35 +3,23 @@
import logging
import sys
import time
import paho.mqtt.client as mqtt
# from datetime import datetime
# from html import escape
from typing import Optional
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, Message, FrameType, PowerType
# from telegram.error import TelegramError
# from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
# from telegram.ext import (
# CallbackQueryHandler,
# MessageHandler,
# CommandHandler
# )
from polaris import (
Kettle,
PowerType,
protocol as kettle_proto
)
k: Optional[Kettle] = None
logger = logging.getLogger(__name__)
control_tasks = SimpleQueue()
# bot: Optional[Wrapper] = None
# RenderedContent = tuple[str, Optional[InlineKeyboardMarkup]]
class MQTTServer(MQTTBase):
def __init__(self):
@ -50,65 +38,29 @@ class MQTTServer(MQTTBase):
logger.exception(str(e))
# class Renderer:
# @classmethod
# def index(cls, ctx: Context) -> RenderedContent:
# html = f'<b>{ctx.lang("settings")}</b>\n\n'
# html += ctx.lang('select_place')
# return html, None
# status handler
# --------------
# def status(ctx: Context):
# text, markup = Renderer.index(ctx)
# return ctx.reply(text, markup=markup)
# class SoundBot(Wrapper):
# def __init__(self):
# super().__init__()
#
# self.lang.ru(
# start_message="Выберите команду на клавиатуре",
# unknown_command="Неизвестная команда",
# unexpected_callback_data="Ошибка: неверные данные",
# status="Статус",
# )
#
# self.lang.en(
# start_message="Select command on the keyboard",
# unknown_command="Unknown command",
# unexpected_callback_data="Unexpected callback data",
# status="Status",
# )
#
# self.add_handler(CommandHandler('status', self.wrap(status)))
#
# def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
# buttons = [
# [ctx.lang('status')]
# ]
# return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
def kettle_connection_established(k: Kettle, response: Message):
def kettle_connection_established(response: kettle_proto.MessageResponse):
try:
assert response.frame.head.type == FrameType.ACK, f'ACK expected, but received: {response}'
assert isinstance(response, kettle_proto.AckMessage), f'ACK expected, but received: {response}'
except AssertionError:
k.stop_server()
k.stop_all()
return
def next_task(k, response):
def next_task(response: kettle_proto.MessageResponse):
try:
assert response is not False, 'server error'
except AssertionError:
k.stop_all()
return
if not control_tasks.empty():
task = control_tasks.get()
f, args = task(k)
args.append(next_task)
f(*args)
else:
k.stop_server()
k.stop_all()
next_task(k, response)
next_task(response)
def main():
@ -123,7 +75,7 @@ def main():
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)
arg = config.load('polaris_kettle_util', use_cli=True, parser=parser)
if arg.mode == 'mqtt':
server = MQTTServer()
@ -145,19 +97,17 @@ def main():
control_tasks.put(lambda k: (k.set_target_temperature, [arg.temp]))
control_tasks.put(lambda k: (k.set_power, [PowerType.CUSTOM]))
k = Kettle(mac='40f52018dec1', device_token='3a5865f015950cae82cd120e76a80d28')
info = k.find()
print('found service:', info)
k = Kettle(mac=config['kettle']['mac'], device_token=config['kettle']['token'])
info = k.discover()
if not info:
print('no device found.')
return 1
k.start_server(kettle_connection_established)
print('found service:', info)
k.start_server_if_needed(kettle_connection_established)
return 0
if __name__ == '__main__':
sys.exit(main())
# bot = SoundBot()
# if 'api' in config:
# bot.enable_logging(BotType.POLARIS_KETTLE)
# bot.run()

View File

@ -6,7 +6,7 @@ import tempfile
from enum import Enum
from datetime import datetime, timedelta
from html import escape
from typing import Optional
from typing import Optional, List, Dict, Tuple
from home.config import config
from home.bot import Wrapper, Context, text_filter, user_any_name
@ -27,11 +27,11 @@ from telegram.ext import (
from PIL import Image
logger = logging.getLogger(__name__)
RenderedContent = tuple[str, Optional[InlineKeyboardMarkup]]
RenderedContent = Tuple[str, Optional[InlineKeyboardMarkup]]
record_client: Optional[SoundRecordClient] = None
bot: Optional[Wrapper] = None
node_client_links: dict[str, SoundNodeClient] = {}
cam_client_links: dict[str, CameraNodeClient] = {}
node_client_links: Dict[str, SoundNodeClient] = {}
cam_client_links: Dict[str, CameraNodeClient] = {}
def node_client(node: str) -> SoundNodeClient:
@ -73,7 +73,7 @@ def interval_defined(interval: int) -> bool:
return interval in config['bot']['record_intervals']
def callback_unpack(ctx: Context) -> list[str]:
def callback_unpack(ctx: Context) -> List[str]:
return ctx.callback_query.data[3:].split('/')
@ -115,7 +115,7 @@ class SettingsRenderer(Renderer):
@classmethod
def node(cls, ctx: Context,
controls: list[dict]) -> RenderedContent:
controls: List[dict]) -> RenderedContent:
node, = callback_unpack(ctx)
html = []
@ -169,7 +169,7 @@ class RecordRenderer(Renderer):
return html, cls.places_markup(ctx, callback_prefix='r0')
@classmethod
def node(cls, ctx: Context, durations: list[int]) -> RenderedContent:
def node(cls, ctx: Context, durations: List[int]) -> RenderedContent:
node, = callback_unpack(ctx)
html = ctx.lang('select_interval')
@ -241,7 +241,7 @@ class FilesRenderer(Renderer):
return html, cls.places_markup(ctx, callback_prefix='f0')
@classmethod
def filelist(cls, ctx: Context, files: list[SoundRecordFile]) -> RenderedContent:
def filelist(cls, ctx: Context, files: List[SoundRecordFile]) -> RenderedContent:
node, = callback_unpack(ctx)
html_files = map(lambda file: cls.file(ctx, file, node), files)
@ -936,7 +936,6 @@ class SoundBot(Wrapper):
# cheese
self.add_handler(CallbackQueryHandler(self.wrap(camera_capture), pattern=r'^c1/.*'))
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
buttons = [
[ctx.lang('record'), ctx.lang('settings')],

View File

@ -3,7 +3,7 @@ import logging
import threading
from time import sleep
from typing import Optional
from typing import Optional, List, Dict, Tuple
from functools import partial
from home.config import config
from home.util import parse_addr
@ -18,7 +18,7 @@ server: SoundSensorServer
def get_related_nodes(node_type: MediaNodeType,
sensor_name: str) -> list[str]:
sensor_name: str) -> List[str]:
if sensor_name not in config[f'sensor_to_{node_type.name.lower()}_nodes_relations']:
raise ValueError(f'unexpected sensor name {sensor_name}')
return config[f'sensor_to_{node_type.name.lower()}_nodes_relations'][sensor_name]
@ -52,7 +52,7 @@ class HitCounter:
with self.lock:
self.sensors[name] += hits
def get_all(self) -> list[tuple[str, int]]:
def get_all(self) -> List[Tuple[str, int]]:
vals = []
with self.lock:
for name, hits in self.sensors.items():
@ -119,7 +119,7 @@ def hits_sender():
api: Optional[WebAPIClient] = None
hc: Optional[HitCounter] = None
record_clients: dict[MediaNodeType, RecordClient] = {}
record_clients: Dict[MediaNodeType, RecordClient] = {}
# record callbacks

21
test/test_polaris_stuff.py Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env python3
import sys
import os.path
sys.path.extend([
os.path.realpath(
os.path.join(os.path.dirname(os.path.join(__file__)), '..')
)
])
import src.polaris as polaris
if __name__ == '__main__':
sc = [cl for cl in polaris.protocol.CmdIncomingMessage.__subclasses__()
if cl is not polaris.protocol.SimpleBooleanMessage]
sc.extend(polaris.protocol.SimpleBooleanMessage.__subclasses__())
for cl in sc:
# if cl == polaris.protocol.HandshakeMessage:
# print('skip')
# continue
print(cl.__name__, cl.TYPE)

View File

@ -12,6 +12,7 @@ from time import sleep
from src.home.config import config
from src.home.api import WebAPIClient
from src.home.api.types import SoundSensorLocation
from typing import List, Tuple
interrupted = False
@ -33,7 +34,7 @@ class HitCounter:
with self.lock:
self.sensors[name] += hits
def get_all(self) -> list[tuple[str, int]]:
def get_all(self) -> List[Tuple[str, int]]:
vals = []
with self.lock:
for name, hits in self.sensors.items():

View File

@ -7,6 +7,7 @@ import sys
from datetime import datetime, timedelta
from argparse import ArgumentParser
from typing import List
input_fmt = '%Y-%m-%d-%H:%M:%S.%f'
@ -14,7 +15,7 @@ output_fmt = '%Y-%m-%d-%H:%M:%S'
# declare types
File = dict
FileList = list[File]
FileList = List[File]
def get_files(source_directory: str) -> FileList:
@ -33,7 +34,7 @@ def get_files(source_directory: str) -> FileList:
return files
def group_files(files: FileList, timedelta_val: int) -> list[FileList]:
def group_files(files: FileList, timedelta_val: int) -> List[FileList]:
groups = []
group_idx = None
@ -52,7 +53,7 @@ def group_files(files: FileList, timedelta_val: int) -> list[FileList]:
return groups
def merge(groups: list[FileList],
def merge(groups: List[FileList],
output_directory: str,
delete_source_files=False,
cedrus=False,

View File

@ -5,6 +5,7 @@ import subprocess
import tempfile
import sys
from typing import List
from datetime import datetime, timedelta
from argparse import ArgumentParser
@ -12,7 +13,7 @@ from argparse import ArgumentParser
fmt = '%d%m%y-%H%M%S'
File = dict
FileList = list[File]
FileList = List[File]
def get_files(source_directory: str) -> FileList:
@ -31,7 +32,7 @@ def get_files(source_directory: str) -> FileList:
return files
def group_files(files: FileList) -> list[FileList]:
def group_files(files: FileList) -> List[FileList]:
groups = []
group_idx = None
@ -55,7 +56,7 @@ def group_files(files: FileList) -> list[FileList]:
return groups
def merge(groups: list[FileList],
def merge(groups: List[FileList],
output_directory: str,
delete_source_files=False,
vbr=False) -> None: