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 ### Random notes
All commands, from `com/polaris/iot/api/comments`: All commands, from `com/polaris/iot/api/commands`:
``` ```
$ grep -A1 -r "public byte getType()" . $ grep -A1 -r "public byte getType()" .
./CmdAccessControl.java: public byte getType() { ./CmdAccessControl.java: public byte getType() {
@ -168,4 +168,43 @@ $ grep -A1 -r "public byte getType()" .
./CmdChildLock.java- return 30; ./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): class ApiResponseError(Exception):
@ -6,7 +6,7 @@ class ApiResponseError(Exception):
status_code: int, status_code: int,
error_type: str, error_type: str,
error_message: str, error_message: str,
error_stacktrace: Optional[list[str]] = None): error_stacktrace: Optional[List[str]] = None):
super().__init__() super().__init__()
self.status_code = status_code self.status_code = status_code
self.error_message = error_message self.error_message = error_message

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ from telegram import (
ReplyKeyboardMarkup, ReplyKeyboardMarkup,
CallbackQuery, CallbackQuery,
User, User,
Message,
) )
from telegram.ext import ( from telegram.ext import (
Updater, Updater,
@ -22,7 +23,7 @@ from telegram.ext import (
) )
from telegram.error import TimedOut from telegram.error import TimedOut
from ..config import config from ..config import config
from typing import Optional, Union from typing import Optional, Union, List, Tuple
from .store import Store from .store import Store
from .lang import LangPack from .lang import LangPack
from ..api.types import BotType from ..api.types import BotType
@ -110,7 +111,7 @@ class Context:
kwargs = dict(parse_mode=ParseMode.HTML) kwargs = dict(parse_mode=ParseMode.HTML)
if not isinstance(markup, IgnoreMarkup): if not isinstance(markup, IgnoreMarkup):
kwargs['reply_markup'] = markup 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: def reply_exc(self, e: Exception) -> None:
self.reply(exc2text(e)) self.reply(exc2text(e))
@ -133,7 +134,7 @@ class Context:
return self._update.callback_query return self._update.callback_query
@property @property
def args(self) -> Optional[list[str]]: def args(self) -> Optional[List[str]]:
return self._callback_context.args return self._callback_context.args
@property @property
@ -157,6 +158,25 @@ class Context:
return self._update.callback_query and self._update.callback_query.data and self._update.callback_query.data != '' 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: class Wrapper:
store: Optional[Store] store: Optional[Store]
updater: Updater updater: Updater
@ -252,7 +272,7 @@ class Wrapper:
def exception_handler(self, e: Exception, ctx: Context) -> Optional[bool]: def exception_handler(self, e: Exception, ctx: Context) -> Optional[bool]:
pass 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']: if 'notify_users' not in config['bot']:
logger.error('notify_all() called but no notify_users directive found in the config') logger.error('notify_all() called but no notify_users directive found in the config')
return return
@ -280,6 +300,12 @@ class Wrapper:
def send_file(self, user_id, **kwargs): def send_file(self, user_id, **kwargs):
self.updater.bot.send_document(chat_id=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 # Language Selection
# #

View File

@ -3,6 +3,7 @@ import os.path
import logging import logging
import psutil import psutil
from typing import List, Tuple
from ..util import chunks from ..util import chunks
from ..config import config from ..config import config
@ -62,7 +63,7 @@ async def ffmpeg_cut(input: str,
_logger.info(f'ffmpeg_cut({input}): OK') _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 tc_backup = timecodes
timecodes = timecodes.split(',') timecodes = timecodes.split(',')

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import re
import shutil import shutil
import logging import logging
from typing import Optional, Union from typing import Optional, Union, List
from datetime import datetime from datetime import datetime
from ..util import strgen from ..util import strgen
@ -149,7 +149,7 @@ class RecordStorage:
self.root = root 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 = [] files = []
for name in os.listdir(self.root): for name in os.listdir(self.root):
path = os.path.join(self.root, name) path = os.path.join(self.root, name)

View File

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

View File

@ -9,7 +9,7 @@ import random
from enum import Enum from enum import Enum
from datetime import datetime from datetime import datetime
from typing import Tuple, Optional from typing import Tuple, Optional, List
Addr = Tuple[str, int] # network address type (host, port) 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) 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__) tb = traceback.format_tb(exc.__traceback__)
if not tb: if not tb:
return None return None
@ -120,7 +120,7 @@ class ChildProcessInfo:
self.cmd = cmd 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) p = subprocess.run(['pgrep', '-P', str(ppid), '--list-full'], capture_output=True)
if p.returncode != 0: if p.returncode != 0:
raise OSError(f'pgrep returned {p.returncode}') 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 home.camera import util as camutil
from enum import Enum from enum import Enum
from typing import Optional, Union from typing import Optional, Union, List
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -273,7 +273,7 @@ def get_motion_path(cam: int) -> str:
def get_recordings_files(cam: int, 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 from_time = 0
to_time = int(time.time()) to_time = int(time.time())
@ -305,7 +305,7 @@ def get_recordings_files(cam: int,
async def process_fragments(camera: int, async def process_fragments(camera: int,
filename: str, filename: str,
fragments: list[tuple[int, int]]) -> None: fragments: List[Tuple[int, int]]) -> None:
time = filename_to_datetime(filename) time = filename_to_datetime(filename)
rec_dir = get_recordings_path(camera) rec_dir = get_recordings_path(camera)
@ -338,7 +338,7 @@ async def process_fragments(camera: int,
async def motion_notify_tg(camera: int, async def motion_notify_tg(camera: int,
filename: str, filename: str,
fragments: list[tuple[int, int]]): fragments: List[Tuple[int, int]]):
dt_file = filename_to_datetime(filename) dt_file = filename_to_datetime(filename)
fmt = '%H:%M:%S' fmt = '%H:%M:%S'

View File

@ -5,6 +5,7 @@ from datetime import datetime
from home.config import config from home.config import config
from home.database import SimpleState from home.database import SimpleState
from home.api import WebAPIClient from home.api import WebAPIClient
from typing import Tuple
log_file = '/var/log/openwrt.log' 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(' ') space_pos = line.index(' ')
date = line[:space_pos] date = line[:space_pos]
@ -58,7 +59,7 @@ if __name__ == '__main__':
state['seek'] = f.tell() state['seek'] = f.tell()
state['size'] = fsize state['size'] = fsize
lines: list[tuple[int, str]] = [] lines: List[Tuple[int, str]] = []
if content != '': if content != '':
for line in content.strip().split('\n'): 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 .kettle import Kettle, DeviceListener
from .protocol import Message, FrameType, PowerType 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 from __future__ import annotations
import threading
import logging import logging
import zeroconf import zeroconf
import cryptography.hazmat.primitives._serialization from abc import abstractmethod
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey from ipaddress import ip_address, IPv4Address, IPv6Address
from cryptography.hazmat.primitives import hashes from typing import Optional, List, Union
from functools import partial
from abc import ABC
from ipaddress import ip_address
from typing import Optional
from .protocol import ( from .protocol import (
Connection, UDPConnection,
ModeMessage, ModeMessage,
HandshakeMessage,
TargetTemperatureMessage, 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 DeviceListener:
class Kettle(zeroconf.ServiceListener, ABC): @abstractmethod
macaddr: str def device_updated(self):
pass
class Kettle(DeviceListener, ConnectionStatusListener):
mac: str
device: Optional[DeviceDiscover]
device_token: str device_token: str
sb: Optional[zeroconf.ServiceBrowser] conn: Optional[UDPConnection]
found_device: Optional[zeroconf.ServiceInfo] conn_status: Optional[ConnectionStatus]
conn: Optional[Connection] _logger: logging.Logger
_find_evt: threading.Event
def __init__(self, mac: str, device_token: str): def __init__(self, mac: str, device_token: str):
super().__init__() super().__init__()
self.zeroconf = zeroconf.Zeroconf() self.mac = mac
self.sb = None self.device = None
self.macaddr = mac
self.device_token = device_token self.device_token = device_token
self.found_device = None
self.conn = 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: def device_updated(self):
self.sb = zeroconf.ServiceBrowser(self.zeroconf, "_syncleo._udp.local.", self) self._find_evt.set()
self.sb.join() 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 discover(self, wait=True, timeout=None, listener=None) -> Optional[zeroconf.ServiceInfo]:
def add_service(self, do_start = False
zc: zeroconf.Zeroconf, if not self.device:
type_: str, self.device = DeviceDiscover(self.mac, listener=self, only_ipv4=True)
name: str) -> None: do_start = True
if name.startswith(f'{self.macaddr}.'): self._logger.debug('discover: started device discovery')
info = zc.get_service_info(type_, name) 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: try:
self.sb.cancel() self._find_evt.wait(timeout=timeout)
except RuntimeError: except KeyboardInterrupt:
pass self.device.stop()
self.zeroconf.close() return None
self.found_device = info 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): assert self.device.curve == 29, f'curve type {self.device.curve} is not implemented'
addresses = list(map(ip_address, self.found_device.addresses)) assert self.device.protocol == 2, f'protocol {self.device.protocol} is not supported'
self.conn = Connection(addr=addresses[0],
port=int(self.found_device.port), self.conn = UDPConnection(addr=self.device.addr,
device_pubkey=self.device_pubkey, port=self.device.port,
device_token=bytes.fromhex(self.device_token)) 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() self.conn.start()
def stop_server(self): def stop_all(self):
self.conn.interrupted = True # 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 is_connected(self) -> bool:
def device_pubkey(self) -> bytes: return self.conn is not None and self.conn_status == ConnectionStatus.CONNECTED
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 set_power(self, power_type: PowerType, callback: callable): 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): def set_target_temperature(self, temp: int, callback: callable):
self._pass_message(TargetTemperatureMessage(temp), callback) message = TargetTemperatureMessage(temp)
self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True))
def _pass_message(self, message: Message, callback: callable):
self.conn.send_message(message, partial(callback, self))

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 logging
import sys import sys
import time
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
# from datetime import datetime from typing import Optional
# from html import escape
from argparse import ArgumentParser from argparse import ArgumentParser
from queue import SimpleQueue 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.mqtt import MQTTBase
from home.config import config from home.config import config
from polaris import Kettle, Message, FrameType, PowerType from polaris import (
Kettle,
# from telegram.error import TelegramError PowerType,
# from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton protocol as kettle_proto
# from telegram.ext import ( )
# CallbackQueryHandler,
# MessageHandler,
# CommandHandler
# )
k: Optional[Kettle] = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
control_tasks = SimpleQueue() control_tasks = SimpleQueue()
# bot: Optional[Wrapper] = None
# RenderedContent = tuple[str, Optional[InlineKeyboardMarkup]]
class MQTTServer(MQTTBase): class MQTTServer(MQTTBase):
def __init__(self): def __init__(self):
@ -50,65 +38,29 @@ class MQTTServer(MQTTBase):
logger.exception(str(e)) logger.exception(str(e))
# class Renderer: def kettle_connection_established(response: kettle_proto.MessageResponse):
# @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):
try: 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: except AssertionError:
k.stop_server() k.stop_all()
return 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(): if not control_tasks.empty():
task = control_tasks.get() task = control_tasks.get()
f, args = task(k) f, args = task(k)
args.append(next_task) args.append(next_task)
f(*args) f(*args)
else: else:
k.stop_server() k.stop_all()
next_task(k, response) next_task(response)
def main(): def main():
@ -123,7 +75,7 @@ def main():
parser.add_argument('-t', '--temperature', dest='temp', type=int, default=tempmax, parser.add_argument('-t', '--temperature', dest='temp', type=int, default=tempmax,
choices=range(tempmin, tempmax+tempstep, tempstep)) 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': if arg.mode == 'mqtt':
server = MQTTServer() 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_target_temperature, [arg.temp]))
control_tasks.put(lambda k: (k.set_power, [PowerType.CUSTOM])) control_tasks.put(lambda k: (k.set_power, [PowerType.CUSTOM]))
k = Kettle(mac='40f52018dec1', device_token='3a5865f015950cae82cd120e76a80d28') k = Kettle(mac=config['kettle']['mac'], device_token=config['kettle']['token'])
info = k.find() info = k.discover()
print('found service:', info) 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 return 0
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(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 enum import Enum
from datetime import datetime, timedelta from datetime import datetime, timedelta
from html import escape from html import escape
from typing import Optional from typing import Optional, List, Dict, Tuple
from home.config import config from home.config import config
from home.bot import Wrapper, Context, text_filter, user_any_name from home.bot import Wrapper, Context, text_filter, user_any_name
@ -27,11 +27,11 @@ from telegram.ext import (
from PIL import Image from PIL import Image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RenderedContent = tuple[str, Optional[InlineKeyboardMarkup]] RenderedContent = Tuple[str, Optional[InlineKeyboardMarkup]]
record_client: Optional[SoundRecordClient] = None record_client: Optional[SoundRecordClient] = None
bot: Optional[Wrapper] = None bot: Optional[Wrapper] = None
node_client_links: dict[str, SoundNodeClient] = {} node_client_links: Dict[str, SoundNodeClient] = {}
cam_client_links: dict[str, CameraNodeClient] = {} cam_client_links: Dict[str, CameraNodeClient] = {}
def node_client(node: str) -> SoundNodeClient: def node_client(node: str) -> SoundNodeClient:
@ -73,7 +73,7 @@ def interval_defined(interval: int) -> bool:
return interval in config['bot']['record_intervals'] 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('/') return ctx.callback_query.data[3:].split('/')
@ -115,7 +115,7 @@ class SettingsRenderer(Renderer):
@classmethod @classmethod
def node(cls, ctx: Context, def node(cls, ctx: Context,
controls: list[dict]) -> RenderedContent: controls: List[dict]) -> RenderedContent:
node, = callback_unpack(ctx) node, = callback_unpack(ctx)
html = [] html = []
@ -169,7 +169,7 @@ class RecordRenderer(Renderer):
return html, cls.places_markup(ctx, callback_prefix='r0') return html, cls.places_markup(ctx, callback_prefix='r0')
@classmethod @classmethod
def node(cls, ctx: Context, durations: list[int]) -> RenderedContent: def node(cls, ctx: Context, durations: List[int]) -> RenderedContent:
node, = callback_unpack(ctx) node, = callback_unpack(ctx)
html = ctx.lang('select_interval') html = ctx.lang('select_interval')
@ -241,7 +241,7 @@ class FilesRenderer(Renderer):
return html, cls.places_markup(ctx, callback_prefix='f0') return html, cls.places_markup(ctx, callback_prefix='f0')
@classmethod @classmethod
def filelist(cls, ctx: Context, files: list[SoundRecordFile]) -> RenderedContent: def filelist(cls, ctx: Context, files: List[SoundRecordFile]) -> RenderedContent:
node, = callback_unpack(ctx) node, = callback_unpack(ctx)
html_files = map(lambda file: cls.file(ctx, file, node), files) html_files = map(lambda file: cls.file(ctx, file, node), files)
@ -936,7 +936,6 @@ class SoundBot(Wrapper):
# cheese # cheese
self.add_handler(CallbackQueryHandler(self.wrap(camera_capture), pattern=r'^c1/.*')) self.add_handler(CallbackQueryHandler(self.wrap(camera_capture), pattern=r'^c1/.*'))
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]: def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
buttons = [ buttons = [
[ctx.lang('record'), ctx.lang('settings')], [ctx.lang('record'), ctx.lang('settings')],

View File

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

View File

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

View File

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