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:
parent
ee09bc98ae
commit
8f20c9b825
@ -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;
|
||||
```
|
@ -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
|
||||
|
@ -7,6 +7,7 @@ class BotType(Enum):
|
||||
SENSORS = auto()
|
||||
ADMIN = auto()
|
||||
SOUND = auto()
|
||||
POLARIS_KETTLE = auto()
|
||||
|
||||
|
||||
class TemperatureSensorLocation(Enum):
|
||||
|
@ -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 = {}
|
||||
|
||||
|
@ -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']
|
||||
|
@ -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
|
@ -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:
|
||||
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]]
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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(',')
|
||||
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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 = {}
|
||||
|
@ -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] = {}
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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}')
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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'):
|
||||
|
@ -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
|
||||
)
|
@ -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,
|
||||
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):
|
||||
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
684
src/polaris_kettle_bot.py
Normal 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()
|
@ -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(response: kettle_proto.MessageResponse):
|
||||
try:
|
||||
assert response is not False, 'server error'
|
||||
except AssertionError:
|
||||
k.stop_all()
|
||||
return
|
||||
|
||||
def next_task(k, response):
|
||||
if not control_tasks.empty():
|
||||
task = control_tasks.get()
|
||||
f, args = task(k)
|
||||
args.append(next_task)
|
||||
f(*args)
|
||||
else:
|
||||
k.stop_server()
|
||||
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()
|
||||
|
@ -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')],
|
||||
|
@ -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
21
test/test_polaris_stuff.py
Executable 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)
|
@ -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():
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user