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
|
### 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;
|
||||||
|
```
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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 = {}
|
||||||
|
|
||||||
|
@ -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']
|
||||||
|
@ -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
|
@ -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]]
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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(',')
|
||||||
|
@ -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"
|
||||||
|
@ -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}
|
||||||
|
@ -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 = {}
|
||||||
|
@ -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] = {}
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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}')
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
@ -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'):
|
||||||
|
@ -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
|
||||||
|
)
|
@ -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
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 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()
|
|
||||||
|
@ -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')],
|
||||||
|
@ -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
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.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():
|
||||||
|
@ -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,
|
||||||
|
@ -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:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user