diff options
Diffstat (limited to 'src')
117 files changed, 0 insertions, 13390 deletions
diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/src/__init__.py +++ /dev/null diff --git a/src/camera_node.py b/src/camera_node.py deleted file mode 100755 index d175e17..0000000 --- a/src/camera_node.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import time - -from home.config import config -from home.media import MediaNodeServer, ESP32CameraRecordStorage, CameraRecorder -from home.camera import CameraType, esp32 -from home.util import Addr -from home import http - - -# Implements HTTP API for a camera. -# --------------------------------- - -class ESP32CameraNodeServer(MediaNodeServer): - def __init__(self, web_addr: Addr, *args, **kwargs): - super().__init__(*args, **kwargs) - self.last_settings_sync = 0 - - self.web = esp32.WebClient(web_addr) - self.get('/capture/', self.capture) - - async def capture(self, req: http.Request): - await self.sync_settings_if_needed() - - try: - with_flash = int(req.query['with_flash']) - except KeyError: - with_flash = 0 - - if with_flash: - await self.web.setflash(True) - await asyncio.sleep(0.5) - - bytes = (await self.web.capture()).read() - - if with_flash: - await asyncio.sleep(0.5) - await self.web.setflash(False) - - res = http.StreamResponse() - res.content_type = 'image/jpeg' - res.content_length = len(bytes) - - await res.prepare(req) - await res.write(bytes) - await res.write_eof() - - return res - - async def do_record(self, request: http.Request): - await self.sync_settings_if_needed() - - # sync settings - return await super().do_record(request) - - async def sync_settings_if_needed(self): - if self.last_settings_sync != 0 and time.time() - self.last_settings_sync < 300: - return - changed = await self.web.syncsettings(config['camera']['settings']) - if changed: - self.logger.debug('sync_settings_if_needed: some settings were changed, sleeping for 0.4 sec') - await asyncio.sleep(0.4) - self.last_settings_sync = time.time() - - -if __name__ == '__main__': - config.load('camera_node') - - recorder_kwargs = {} - camera_type = CameraType(config['camera']['type']) - if camera_type == CameraType.ESP32: - recorder_kwargs['stream_addr'] = config.get_addr('camera.web_addr') # this is not a mistake, we don't use stream_addr for esp32-cam anymore - storage = ESP32CameraRecordStorage(config['node']['storage']) - else: - raise RuntimeError(f'unsupported camera type {camera_type}') - - recorder = CameraRecorder(storage=storage, - camera_type=camera_type, - **recorder_kwargs) - recorder.start_thread() - - server = ESP32CameraNodeServer( - recorder=recorder, - storage=storage, - web_addr=config.get_addr('camera.web_addr'), - addr=config.get_addr('node.listen')) - server.run() diff --git a/src/electricity_calc.py b/src/electricity_calc.py deleted file mode 100755 index c3cb233..0000000 --- a/src/electricity_calc.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import sys -import inspect -import zoneinfo - -from home.config import config # do not remove this import! -from datetime import datetime, timedelta -from logging import Logger -from home.database import InverterDatabase -from argparse import ArgumentParser, ArgumentError -from typing import Optional - -_logger: Optional[Logger] = None -_progname = os.path.basename(__file__) -_is_verbose = False - -fmt_time = '%Y-%m-%d %H:%M:%S' -fmt_date = '%Y-%m-%d' - - -def method_usage() -> str: - # https://stackoverflow.com/questions/2654113/how-to-get-the-callers-method-name-in-the-called-method - curframe = inspect.currentframe() - calframe = inspect.getouterframes(curframe, 2) - return f'{_progname} {calframe[1][3]} [ARGS]' - - -def fmt_escape(s: str): - return s.replace('%', '%%') - - -def setup_logging(verbose: bool): - global _is_verbose - - logging_level = logging.INFO if not verbose else logging.DEBUG - logging.basicConfig(level=logging_level) - - _is_verbose = verbose - - -class SubParser: - def __init__(self, description: str, usage: str): - self.parser = ArgumentParser( - description=description, - usage=usage - ) - - def add_argument(self, *args, **kwargs): - self.parser.add_argument(*args, **kwargs) - - def parse_args(self): - self.add_argument('--verbose', '-V', action='store_true', - help='enable debug logs') - - args = self.parser.parse_args(sys.argv[2:]) - setup_logging(args.verbose) - - return args - - -def strptime_auto(s: str) -> datetime: - e = None - for fmt in (fmt_time, fmt_date): - try: - return datetime.strptime(s, fmt) - except ValueError as _e: - e = _e - raise e - - -def get_dt_from_to_arguments(parser): - parser.add_argument('--from', type=str, dest='date_from', required=True, - help=f'From date, format: {fmt_escape(fmt_time)} or {fmt_escape(fmt_date)}') - parser.add_argument('--to', type=str, dest='date_to', default='now', - help=f'To date, format: {fmt_escape(fmt_time)}, {fmt_escape(fmt_date)}, \'now\' or \'24h\'') - arg = parser.parse_args() - - dt_from = strptime_auto(arg.date_from) - - if arg.date_to == 'now': - dt_to = datetime.now() - elif arg.date_to == '24h': - dt_to = dt_from + timedelta(days=1) - else: - dt_to = strptime_auto(arg.date_to) - - return dt_from, dt_to - - -def print_intervals(intervals): - for interval in intervals: - start, end = interval - buf = f'{start.strftime(fmt_time)} .. ' - if end: - buf += f'{end.strftime(fmt_time)}' - else: - buf += 'now' - - print(buf) - - -class Electricity(): - def __init__(self): - global _logger - - methods = [func.replace('_', '-') - for func in dir(Electricity) - if callable(getattr(Electricity, func)) and not func.startswith('_') and func != 'query'] - - parser = ArgumentParser( - usage=f'{_progname} METHOD [ARGS]' - ) - parser.add_argument('method', choices=methods, - help='Method to run') - parser.add_argument('--verbose', '-V', action='store_true', - help='enable debug logs') - - argv = sys.argv[1:2] - for arg in ('-V', '--verbose'): - if arg in sys.argv: - argv.append(arg) - args = parser.parse_args(argv) - - setup_logging(args.verbose) - self.db = InverterDatabase() - - method = args.method.replace('-', '_') - getattr(self, method)() - - def get_grid_connected_intervals(self): - parser = SubParser('Returns datetime intervals when grid was connected', method_usage()) - dt_from, dt_to = get_dt_from_to_arguments(parser) - - intervals = self.db.get_grid_connected_intervals(dt_from, dt_to) - print_intervals(intervals) - - def get_grid_used_intervals(self): - parser = SubParser('Returns datetime intervals when power grid was actually used', method_usage()) - dt_from, dt_to = get_dt_from_to_arguments(parser) - - intervals = self.db.get_grid_used_intervals(dt_from, dt_to) - print_intervals(intervals) - - def get_grid_consumed_energy(self): - parser = SubParser('Returns sum of energy consumed from util grid', method_usage()) - dt_from, dt_to = get_dt_from_to_arguments(parser) - - wh = self.db.get_grid_consumed_energy(dt_from, dt_to) - print('%.2f' % wh,) - - def get_consumed_energy(self): - parser = SubParser('Returns total consumed energy', method_usage()) - dt_from, dt_to = get_dt_from_to_arguments(parser) - - wh = self.db.get_consumed_energy(dt_from, dt_to) - print('%.2f' % wh,) - - -if __name__ == '__main__': - try: - Electricity() - except Exception as e: - _logger.exception(e) - sys.exit(1) diff --git a/src/esp32_capture.py b/src/esp32_capture.py deleted file mode 100755 index 4a9ce10..0000000 --- a/src/esp32_capture.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import logging -import os.path - -from argparse import ArgumentParser -from home.camera.esp32 import WebClient -from home.util import parse_addr, Addr -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from datetime import datetime -from typing import Optional - -logger = logging.getLogger(__name__) -cam: Optional[WebClient] = None - - -class ESP32Capture: - def __init__(self, addr: Addr, interval: float, output_directory: str): - self.logger = logging.getLogger(self.__class__.__name__) - self.client = WebClient(addr) - self.output_directory = output_directory - self.interval = interval - - self.scheduler = AsyncIOScheduler() - self.scheduler.add_job(self.capture, 'interval', seconds=arg.interval) - self.scheduler.start() - - async def capture(self): - self.logger.debug('capture: start') - now = datetime.now() - filename = os.path.join( - self.output_directory, - now.strftime('%Y-%m-%d-%H:%M:%S.%f.jpg') - ) - if not await self.client.capture(filename): - self.logger.error('failed to capture') - self.logger.debug('capture: done') - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('--addr', type=str, required=True) - parser.add_argument('--output-directory', type=str, required=True) - parser.add_argument('--interval', type=float, default=0.5) - parser.add_argument('--verbose', action='store_true') - arg = parser.parse_args() - - if arg.verbose: - logging.basicConfig(level=logging.DEBUG) - - loop = asyncio.get_event_loop() - - ESP32Capture(parse_addr(arg.addr), arg.interval, arg.output_directory) - try: - loop.run_forever() - except KeyboardInterrupt: - pass diff --git a/src/esp32cam_capture_diff_node.py b/src/esp32cam_capture_diff_node.py deleted file mode 100755 index 4363e9e..0000000 --- a/src/esp32cam_capture_diff_node.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import logging -import os.path -import tempfile -import home.telegram.aio as telegram - -from home.config import config -from home.camera.esp32 import WebClient -from home.util import parse_addr, send_datagram, stringify -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from typing import Optional - -logger = logging.getLogger(__name__) -cam: Optional[WebClient] = None - - -async def pyssim(fn1: str, fn2: str) -> float: - args = [config['pyssim']['bin']] - if 'width' in config['pyssim']: - args.extend(['--width', str(config['pyssim']['width'])]) - if 'height' in config['pyssim']: - args.extend(['--height', str(config['pyssim']['height'])]) - args.extend([fn1, fn2]) - proc = await asyncio.create_subprocess_exec(*args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE) - stdout, stderr = await proc.communicate() - if proc.returncode != 0: - logger.error(f'pyssim({fn1}, {fn2}): pyssim returned {proc.returncode}, stderr: {stderr.decode().strip()}') - - return float(stdout.decode().strip()) - - -class ESP32CamCaptureDiffNode: - def __init__(self): - self.client = WebClient(parse_addr(config['esp32cam_web_addr'])) - self.directory = tempfile.gettempdir() - self.nextpic = 1 - self.first = True - self.server_addr = parse_addr(config['node']['server_addr']) - - self.scheduler = AsyncIOScheduler() - self.scheduler.add_job(self.capture, 'interval', seconds=config['node']['interval']) - self.scheduler.start() - - async def capture(self): - logger.debug('capture: start') - - filename = self.getfilename() - if not await self.client.capture(os.path.join(self.directory, filename)): - logger.error('failed to capture') - return - - self.nextpic = 1 if self.nextpic == 2 else 2 - if not self.first: - second_filename = os.path.join(self.directory, self.getfilename()) - score = await pyssim(filename, second_filename) - logger.debug(f'pyssim: score={score}') - if score < config['pyssim']['threshold']: - logger.info(f'score = {score}, informing central server') - send_datagram(stringify([config['node']['name'], 2]), self.server_addr) - - # send to telegram - if 'telegram' in config: - await telegram.send_message(f'pyssim: score={score}') - await telegram.send_photo(filename) - await telegram.send_photo(second_filename) - - self.first = False - - logger.debug('capture: done') - - def getfilename(self): - return os.path.join(self.directory, f'{self.nextpic}.jpg') - - -if __name__ == '__main__': - config.load('esp32cam_capture_diff_node') - - loop = asyncio.get_event_loop() - ESP32CamCaptureDiffNode() - try: - loop.run_forever() - except KeyboardInterrupt: - pass diff --git a/src/esp_mqtt_util.py b/src/esp_mqtt_util.py deleted file mode 100755 index 263128c..0000000 --- a/src/esp_mqtt_util.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -from typing import Optional -from argparse import ArgumentParser -from enum import Enum - -from home.config import config -from home.mqtt import MqttRelay -from home.mqtt.esp import MqttEspBase -from home.mqtt.temphum import MqttTempHum -from home.mqtt.esp import MqttEspDevice - -mqtt_client: Optional[MqttEspBase] = None - - -class NodeType(Enum): - RELAY = 'relay' - TEMPHUM = 'temphum' - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('--device-id', type=str, required=True) - parser.add_argument('--type', type=str, required=True, - choices=[i.name.lower() for i in NodeType]) - - config.load('mqtt_util', parser=parser) - arg = parser.parse_args() - - mqtt_node_type = NodeType(arg.type) - devices = MqttEspDevice(id=arg.device_id) - - if mqtt_node_type == NodeType.RELAY: - mqtt_client = MqttRelay(devices=devices) - elif mqtt_node_type == NodeType.TEMPHUM: - mqtt_client = MqttTempHum(devices=devices) - - mqtt_client.set_message_callback(lambda device_id, payload: print(payload)) - mqtt_client.configure_tls() - try: - mqtt_client.connect_and_loop() - except KeyboardInterrupt: - mqtt_client.disconnect() diff --git a/src/gpiorelayd.py b/src/gpiorelayd.py deleted file mode 100755 index 85015a7..0000000 --- a/src/gpiorelayd.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import sys - -from home.config import config -from home.relay.sunxi_h3_server import RelayServer - -logger = logging.getLogger(__name__) - - -if __name__ == '__main__': - if not os.getegid() == 0: - sys.exit('Must be run as root.') - - config.load() - - try: - s = RelayServer(pinname=config.get('relayd.pin'), - addr=config.get_addr('relayd.listen')) - s.run() - except KeyboardInterrupt: - logger.info('Exiting...') diff --git a/src/home/__init__.py b/src/home/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/src/home/__init__.py +++ /dev/null diff --git a/src/home/api/__init__.py b/src/home/api/__init__.py deleted file mode 100644 index 782a61e..0000000 --- a/src/home/api/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -import importlib - -__all__ = ['WebAPIClient', 'RequestParams'] - - -def __getattr__(name): - if name in __all__: - module = importlib.import_module(f'.web_api_client', __name__) - return getattr(module, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/api/__init__.pyi b/src/home/api/__init__.pyi deleted file mode 100644 index 1b812d6..0000000 --- a/src/home/api/__init__.pyi +++ /dev/null @@ -1,4 +0,0 @@ -from .web_api_client import ( - RequestParams as RequestParams, - WebAPIClient as WebAPIClient -) diff --git a/src/home/api/errors/__init__.py b/src/home/api/errors/__init__.py deleted file mode 100644 index efb06aa..0000000 --- a/src/home/api/errors/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .api_response_error import ApiResponseError diff --git a/src/home/api/errors/api_response_error.py b/src/home/api/errors/api_response_error.py deleted file mode 100644 index 85d788b..0000000 --- a/src/home/api/errors/api_response_error.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Optional, List - - -class ApiResponseError(Exception): - def __init__(self, - status_code: int, - error_type: str, - error_message: str, - error_stacktrace: Optional[List[str]] = None): - super().__init__() - self.status_code = status_code - self.error_message = error_message - self.error_type = error_type - self.error_stacktrace = error_stacktrace - - def __str__(self): - def st_formatter(line: str): - return f'Remote| {line}' - - s = f'{self.error_type}: {self.error_message} (HTTP {self.status_code})' - if self.error_stacktrace is not None: - st = [] - for st_line in self.error_stacktrace: - st.append('\n'.join(st_formatter(st_subline) for st_subline in st_line.split('\n'))) - s += '\nRemote stacktrace:\n' - s += '\n'.join(st) - - return s diff --git a/src/home/api/types/__init__.py b/src/home/api/types/__init__.py deleted file mode 100644 index 9f27ff6..0000000 --- a/src/home/api/types/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .types import ( - BotType, - TemperatureSensorDataType, - TemperatureSensorLocation, - SoundSensorLocation -) diff --git a/src/home/api/types/types.py b/src/home/api/types/types.py deleted file mode 100644 index 981e798..0000000 --- a/src/home/api/types/types.py +++ /dev/null @@ -1,33 +0,0 @@ -from enum import Enum, auto - - -class BotType(Enum): - INVERTER = auto() - PUMP = auto() - SENSORS = auto() - ADMIN = auto() - SOUND = auto() - POLARIS_KETTLE = auto() - PUMP_MQTT = auto() - RELAY_MQTT = auto() - - -class TemperatureSensorLocation(Enum): - BIG_HOUSE_1 = auto() - BIG_HOUSE_2 = auto() - BIG_HOUSE_ROOM = auto() - STREET = auto() - DIANA = auto() - SPB1 = auto() - - -class TemperatureSensorDataType(Enum): - TEMPERATURE = auto() - RELATIVE_HUMIDITY = auto() - - -class SoundSensorLocation(Enum): - DIANA = auto() - BIG_HOUSE = auto() - SPB1 = auto() - diff --git a/src/home/api/web_api_client.py b/src/home/api/web_api_client.py deleted file mode 100644 index 6677182..0000000 --- a/src/home/api/web_api_client.py +++ /dev/null @@ -1,225 +0,0 @@ -import requests -import json -import threading -import logging - -from collections import namedtuple -from datetime import datetime -from enum import Enum, auto -from typing import Optional, Callable, Union, List, Tuple, Dict -from requests.auth import HTTPBasicAuth - -from .errors import ApiResponseError -from .types import * -from ..config import config -from ..util import stringify -from ..media import RecordFile, MediaNodeClient - -logger = logging.getLogger(__name__) - - -RequestParams = namedtuple('RequestParams', 'params, files, method') - - -class HTTPMethod(Enum): - GET = auto() - POST = auto() - - -class WebAPIClient: - token: str - 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): - self.token = config['api']['token'] - self.timeout = timeout - self.basic_auth = None - self.do_async = False - self.async_error_handler = None - self.async_success_handler = None - - if 'basic_auth' in config['api']: - ba = config['api']['basic_auth'] - col = ba.index(':') - - user = ba[:col] - pw = ba[col+1:] - - logger.debug(f'enabling basic auth: {user}:{pw}') - self.basic_auth = HTTPBasicAuth(user, pw) - - # api methods - # ----------- - - def log_bot_request(self, - bot: BotType, - user_id: int, - message: str): - return self._post('log/bot_request/', { - 'bot': bot.value, - 'user_id': str(user_id), - 'message': message - }) - - def log_openwrt(self, - lines: List[Tuple[int, str]], - access_point: int): - return self._post('log/openwrt/', { - 'logs': stringify(lines), - 'ap': access_point - }) - - def get_sensors_data(self, - sensor: TemperatureSensorLocation, - hours: int): - data = self._get('sensors/data/', { - 'sensor': sensor.value, - 'hours': hours - }) - return [(datetime.fromtimestamp(date), temp, hum) for date, temp, hum in data] - - def add_sound_sensor_hits(self, - 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]: - return self._process_sound_sensor_hits_data(self._get('sound_sensors/hits/', { - 'after': int(after.timestamp()), - 'location': location.value - })) - - def get_last_sound_sensor_hits(self, location: SoundSensorLocation, last: int): - return self._process_sound_sensor_hits_data(self._get('sound_sensors/hits/', { - 'last': last, - 'location': location.value - })) - - 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 inverter_get_consumed_energy(self, s_from: str, s_to: str): - return self._get('inverter/consumed_energy/', { - 'from': s_from, - 'to': s_to - }) - - def inverter_get_grid_consumed_energy(self, s_from: str, s_to: str): - return self._get('inverter/grid_consumed_energy/', { - 'from': s_from, - 'to': s_to - }) - - @staticmethod - def _process_sound_sensor_hits_data(data: List[dict]) -> List[dict]: - for item in data: - item['time'] = datetime.fromtimestamp(item['time']) - return data - - # internal methods - # ---------------- - - def _get(self, *args, **kwargs): - return self._call(method=HTTPMethod.GET, *args, **kwargs) - - def _post(self, *args, **kwargs): - return self._call(method=HTTPMethod.POST, *args, **kwargs) - - def _call(self, - name: str, - params: dict, - method: HTTPMethod, - files: Optional[Dict[str, str]] = None): - if not self.do_async: - return self._make_request(name, params, method, files) - else: - t = threading.Thread(target=self._make_request_in_thread, args=(name, params, method, files)) - t.start() - return None - - def _make_request(self, - name: str, - params: dict, - method: HTTPMethod = HTTPMethod.GET, - files: Optional[Dict[str, str]] = None) -> Optional[any]: - domain = config['api']['host'] - kwargs = {} - - if self.basic_auth is not None: - kwargs['auth'] = self.basic_auth - - if method == HTTPMethod.GET: - if files: - raise RuntimeError('can\'t upload files using GET, please use me properly') - kwargs['params'] = params - f = requests.get - else: - kwargs['data'] = params - f = requests.post - - fd = {} - if files: - for fname, fpath in files.items(): - fd[fname] = open(fpath, 'rb') - kwargs['files'] = fd - - try: - r = f(f'https://{domain}/{name}', - headers={'X-Token': self.token}, - timeout=self.timeout, - **kwargs) - - if not r.headers['content-type'].startswith('application/json'): - raise ApiResponseError(r.status_code, 'TypeError', 'content-type is not application/json') - - data = json.loads(r.text) - if r.status_code != 200: - raise ApiResponseError(r.status_code, - data['error'], - data['message'], - data['stacktrace'] if 'stacktrace' in data['error'] else None) - - return data['response'] if 'response' in data else True - finally: - for fname, f in fd.items(): - # logger.debug(f'closing file {fname} (fd={f})') - try: - f.close() - except Exception as exc: - logger.exception(exc) - pass - - def _make_request_in_thread(self, name, params, method, files): - try: - result = self._make_request(name, params, method, files) - self._report_async_success(result, name, RequestParams(params=params, method=method, files=files)) - except Exception as e: - logger.exception(e) - self._report_async_error(e, name, RequestParams(params=params, method=method, files=files)) - - def enable_async(self, - success_handler: Optional[Callable] = None, - error_handler: Optional[Callable] = None): - self.do_async = True - if error_handler: - self.async_error_handler = error_handler - if success_handler: - self.async_success_handler = success_handler - - def _report_async_error(self, *args): - if self.async_error_handler: - self.async_error_handler(*args) - - def _report_async_success(self, *args): - if self.async_success_handler: - self.async_success_handler(*args)
\ No newline at end of file diff --git a/src/home/audio/__init__.py b/src/home/audio/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/src/home/audio/__init__.py +++ /dev/null diff --git a/src/home/audio/amixer.py b/src/home/audio/amixer.py deleted file mode 100644 index 53e6bce..0000000 --- a/src/home/audio/amixer.py +++ /dev/null @@ -1,91 +0,0 @@ -import subprocess - -from ..config import config -from threading import Lock -from typing import Union, List - - -_lock = Lock() -_default_step = 5 - - -def has_control(s: str) -> bool: - for control in config['amixer']['controls']: - if control['name'] == s: - return True - return False - - -def get_caps(s: str) -> List[str]: - for control in config['amixer']['controls']: - if control['name'] == s: - return control['caps'] - raise KeyError(f'control {s} not found') - - -def get_all() -> list: - controls = [] - for control in config['amixer']['controls']: - controls.append({ - 'name': control['name'], - 'info': get(control['name']), - 'caps': control['caps'] - }) - return controls - - -def get(control: str): - return call('get', control) - - -def mute(control): - return call('set', control, 'mute') - - -def unmute(control): - return call('set', control, 'unmute') - - -def cap(control): - return call('set', control, 'cap') - - -def nocap(control): - return call('set', control, 'nocap') - - -def _get_default_step() -> int: - if 'step' in config['amixer']: - return int(config['amixer']['step']) - - return _default_step - - -def incr(control, step=None): - if step is None: - step = _get_default_step() - return call('set', control, f'{step}%+') - - -def decr(control, step=None): - if step is None: - step = _get_default_step() - return call('set', control, f'{step}%-') - - -def call(*args, return_code=False) -> Union[int, str]: - with _lock: - result = subprocess.run([config['amixer']['bin'], *args], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - if return_code: - return result.returncode - - if result.returncode != 0: - raise AmixerError(result.stderr.decode().strip()) - - return result.stdout.decode().strip() - - -class AmixerError(OSError): - pass diff --git a/src/home/camera/__init__.py b/src/home/camera/__init__.py deleted file mode 100644 index 626930b..0000000 --- a/src/home/camera/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .types import CameraType
\ No newline at end of file diff --git a/src/home/camera/esp32.py b/src/home/camera/esp32.py deleted file mode 100644 index fe6de0e..0000000 --- a/src/home/camera/esp32.py +++ /dev/null @@ -1,226 +0,0 @@ -import logging -import requests -import json -import asyncio -import aioshutil - -from io import BytesIO -from functools import partial -from typing import Union, Optional -from enum import Enum -from ..api.errors import ApiResponseError -from ..util import Addr - - -class FrameSize(Enum): - UXGA_1600x1200 = 13 - SXGA_1280x1024 = 12 - HD_1280x720 = 11 - XGA_1024x768 = 10 - SVGA_800x600 = 9 - VGA_640x480 = 8 - HVGA_480x320 = 7 - CIF_400x296 = 6 - QVGA_320x240 = 5 - N_240x240 = 4 - HQVGA_240x176 = 3 - QCIF_176x144 = 2 - QQVGA_160x120 = 1 - N_96x96 = 0 - - -class WBMode(Enum): - AUTO = 0 - SUNNY = 1 - CLOUDY = 2 - OFFICE = 3 - HOME = 4 - - -def _assert_bounds(n: int, min: int, max: int): - if not min <= n <= max: - raise ValueError(f'value must be between {min} and {max}') - - -class WebClient: - def __init__(self, - addr: Addr): - self.endpoint = f'http://{addr[0]}:{addr[1]}' - self.logger = logging.getLogger(self.__class__.__name__) - self.delay = 0 - self.isfirstrequest = True - - async def syncsettings(self, settings) -> bool: - status = await self.getstatus() - self.logger.debug(f'syncsettings: status={status}') - - changed_anything = False - - for name, value in settings.items(): - server_name = name - if name == 'aec_dsp': - server_name = 'aec2' - - if server_name not in status: - # legacy compatibility - if server_name != 'vflip': - self.logger.warning(f'syncsettings: field `{server_name}` not found in camera status') - continue - - try: - # server returns 0 or 1 for bool values - if type(value) is bool: - value = int(value) - - if status[server_name] == value: - continue - except KeyError as exc: - if name != 'vflip': - self.logger.error(exc) - - try: - # fix for cases like when field is called raw_gma, but method is setrawgma() - name = name.replace('_', '') - - func = getattr(self, f'set{name}') - self.logger.debug(f'syncsettings: calling set{name}({value})') - - await func(value) - - changed_anything = True - except AttributeError as exc: - self.logger.exception(exc) - self.logger.error(f'syncsettings: method set{name}() not found') - - return changed_anything - - def setdelay(self, delay: int): - self.delay = delay - - async def capture(self, output: Optional[str] = None) -> Union[BytesIO, bool]: - kw = {} - if output: - kw['save_to'] = output - else: - kw['as_bytes'] = True - return await self._call('capture', **kw) - - async def getstatus(self): - return json.loads(await self._call('status')) - - async def setflash(self, enable: bool): - await self._control('flash', int(enable)) - - async def setframesize(self, fs: Union[int, FrameSize]): - if type(fs) is int: - fs = FrameSize(fs) - await self._control('framesize', fs.value) - - async def sethmirror(self, enable: bool): - await self._control('hmirror', int(enable)) - - async def setvflip(self, enable: bool): - await self._control('vflip', int(enable)) - - async def setawb(self, enable: bool): - await self._control('awb', int(enable)) - - async def setawbgain(self, enable: bool): - await self._control('awb_gain', int(enable)) - - async def setwbmode(self, mode: WBMode): - await self._control('wb_mode', mode.value) - - async def setaecsensor(self, enable: bool): - await self._control('aec', int(enable)) - - async def setaecdsp(self, enable: bool): - await self._control('aec2', int(enable)) - - async def setagc(self, enable: bool): - await self._control('agc', int(enable)) - - async def setagcgain(self, gain: int): - _assert_bounds(gain, 1, 31) - await self._control('agc_gain', gain) - - async def setgainceiling(self, gainceiling: int): - _assert_bounds(gainceiling, 2, 128) - await self._control('gainceiling', gainceiling) - - async def setbpc(self, enable: bool): - await self._control('bpc', int(enable)) - - async def setwpc(self, enable: bool): - await self._control('wpc', int(enable)) - - async def setrawgma(self, enable: bool): - await self._control('raw_gma', int(enable)) - - async def setlenscorrection(self, enable: bool): - await self._control('lenc', int(enable)) - - async def setdcw(self, enable: bool): - await self._control('dcw', int(enable)) - - async def setcolorbar(self, enable: bool): - await self._control('colorbar', int(enable)) - - async def setquality(self, q: int): - _assert_bounds(q, 4, 63) - await self._control('quality', q) - - async def setbrightness(self, brightness: int): - _assert_bounds(brightness, -2, -2) - await self._control('brightness', brightness) - - async def setcontrast(self, contrast: int): - _assert_bounds(contrast, -2, 2) - await self._control('contrast', contrast) - - async def setsaturation(self, saturation: int): - _assert_bounds(saturation, -2, 2) - await self._control('saturation', saturation) - - async def _control(self, var: str, value: Union[int, str]): - return await self._call('control', params={'var': var, 'val': value}) - - async def _call(self, - method: str, - params: Optional[dict] = None, - save_to: Optional[str] = None, - as_bytes=False) -> Union[str, bool, BytesIO]: - loop = asyncio.get_event_loop() - - if not self.isfirstrequest and self.delay > 0: - sleeptime = self.delay / 1000 - self.logger.debug(f'sleeping for {sleeptime}') - - await asyncio.sleep(sleeptime) - - self.isfirstrequest = False - - url = f'{self.endpoint}/{method}' - self.logger.debug(f'calling {url}, params: {params}') - - kwargs = {} - if params: - kwargs['params'] = params - if save_to: - kwargs['stream'] = True - - r = await loop.run_in_executor(None, - partial(requests.get, url, **kwargs)) - if r.status_code != 200: - raise ApiResponseError(status_code=r.status_code) - - if as_bytes: - return BytesIO(r.content) - - if save_to: - r.raise_for_status() - with open(save_to, 'wb') as f: - await aioshutil.copyfileobj(r.raw, f) - return True - - return r.text diff --git a/src/home/camera/types.py b/src/home/camera/types.py deleted file mode 100644 index de59022..0000000 --- a/src/home/camera/types.py +++ /dev/null @@ -1,5 +0,0 @@ -from enum import Enum - - -class CameraType(Enum): - ESP32 = 'esp32' diff --git a/src/home/camera/util.py b/src/home/camera/util.py deleted file mode 100644 index 97f35aa..0000000 --- a/src/home/camera/util.py +++ /dev/null @@ -1,107 +0,0 @@ -import asyncio -import os.path -import logging -import psutil - -from typing import List, Tuple -from ..util import chunks -from ..config import config - -_logger = logging.getLogger(__name__) -_temporary_fixing = '.temporary_fixing.mp4' - - -def _get_ffmpeg_path() -> str: - return 'ffmpeg' if 'ffmpeg' not in config else config['ffmpeg']['path'] - - -def time2seconds(time: str) -> int: - time, frac = time.split('.') - frac = int(frac) - - h, m, s = [int(i) for i in time.split(':')] - - return round(s + m*60 + h*3600 + frac/1000) - - -async def ffmpeg_recreate(filename: str): - filedir = os.path.dirname(filename) - tempname = os.path.join(filedir, _temporary_fixing) - mtime = os.path.getmtime(filename) - - args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname] - proc = await asyncio.create_subprocess_exec(*args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE) - stdout, stderr = await proc.communicate() - if proc.returncode != 0: - _logger.error(f'fix_timestamps({filename}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}') - - if os.path.isfile(tempname): - os.unlink(filename) - os.rename(tempname, filename) - os.utime(filename, (mtime, mtime)) - _logger.info(f'fix_timestamps({filename}): OK') - else: - _logger.error(f'fix_timestamps({filename}): temp file \'{tempname}\' does not exists, fix failed') - - -async def ffmpeg_cut(input: str, - output: str, - start_pos: int, - duration: int): - args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', input, - '-ss', str(start_pos), '-t', str(duration), - '-c', 'copy', '-y', output] - proc = await asyncio.create_subprocess_exec(*args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE) - stdout, stderr = await proc.communicate() - if proc.returncode != 0: - _logger.error(f'ffmpeg_cut({input}, start_pos={start_pos}, duration={duration}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}') - else: - _logger.info(f'ffmpeg_cut({input}): OK') - - -def dvr_scan_timecodes(timecodes: str) -> List[Tuple[int, int]]: - tc_backup = timecodes - - timecodes = timecodes.split(',') - if len(timecodes) % 2 != 0: - raise DVRScanInvalidTimecodes(f'invalid number of timecodes. input: {tc_backup}') - - timecodes = list(map(time2seconds, timecodes)) - timecodes = list(chunks(timecodes, 2)) - - # sort out invalid fragments (dvr-scan returns them sometimes, idk why...) - timecodes = list(filter(lambda f: f[0] < f[1], timecodes)) - if not timecodes: - raise DVRScanInvalidTimecodes(f'no valid timecodes. input: {tc_backup}') - - # https://stackoverflow.com/a/43600953 - timecodes.sort(key=lambda interval: interval[0]) - merged = [timecodes[0]] - for current in timecodes: - previous = merged[-1] - if current[0] <= previous[1]: - previous[1] = max(previous[1], current[1]) - else: - merged.append(current) - - return merged - - -class DVRScanInvalidTimecodes(Exception): - pass - - -def has_handle(fpath): - for proc in psutil.process_iter(): - try: - for item in proc.open_files(): - if fpath == item.path: - return True - except Exception: - pass - - return False
\ No newline at end of file diff --git a/src/home/config/__init__.py b/src/home/config/__init__.py deleted file mode 100644 index cc9c091..0000000 --- a/src/home/config/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .config import ConfigStore, config, is_development_mode, setup_logging diff --git a/src/home/config/config.py b/src/home/config/config.py deleted file mode 100644 index 4681685..0000000 --- a/src/home/config/config.py +++ /dev/null @@ -1,204 +0,0 @@ -import toml -import yaml -import logging -import os - -from os.path import join, isdir, isfile -from typing import Optional, Any, MutableMapping -from argparse import ArgumentParser -from ..util import parse_addr - - -def _get_config_path(name: str) -> str: - formats = ['toml', 'yaml'] - - dirname = join(os.environ['HOME'], '.config', name) - - if isdir(dirname): - for fmt in formats: - filename = join(dirname, f'config.{fmt}') - if isfile(filename): - return filename - - raise IOError(f'config not found in {dirname}') - - else: - filenames = [join(os.environ['HOME'], '.config', f'{name}.{format}') for format in formats] - for file in filenames: - if isfile(file): - return file - - raise IOError(f'config not found') - - -class ConfigStore: - data: MutableMapping[str, Any] - app_name: Optional[str] - - def __init__(self): - self.data = {} - self.app_name = None - - def load(self, name: Optional[str] = None, - use_cli=True, - parser: ArgumentParser = None): - self.app_name = name - - if (name is None) and (not use_cli): - raise RuntimeError('either config name must be none or use_cli must be True') - - log_default_fmt = False - log_file = None - log_verbose = False - no_config = name is False - - path = None - if use_cli: - if parser is None: - parser = ArgumentParser() - if not no_config: - parser.add_argument('-c', '--config', type=str, required=name is None, - help='Path to the config in TOML or YAML format') - parser.add_argument('-V', '--verbose', action='store_true') - parser.add_argument('--log-file', type=str) - parser.add_argument('--log-default-fmt', action='store_true') - args = parser.parse_args() - - if not no_config and args.config: - path = args.config - - if args.verbose: - log_verbose = True - if args.log_file: - log_file = args.log_file - if args.log_default_fmt: - log_default_fmt = args.log_default_fmt - - if not no_config and path is None: - path = _get_config_path(name) - - if no_config: - self.data = {} - else: - if path.endswith('.toml'): - self.data = toml.load(path) - elif path.endswith('.yaml'): - with open(path, 'r') as fd: - self.data = yaml.safe_load(fd) - - if 'logging' in self: - if not log_file and 'file' in self['logging']: - log_file = self['logging']['file'] - if log_default_fmt and 'default_fmt' in self['logging']: - log_default_fmt = self['logging']['default_fmt'] - - setup_logging(log_verbose, log_file, log_default_fmt) - - if use_cli: - return args - - def __getitem__(self, key): - return self.data[key] - - def __setitem__(self, key, value): - raise NotImplementedError('overwriting config values is prohibited') - - def __contains__(self, key): - return key in self.data - - def get(self, key: str, default=None): - cur = self.data - pts = key.split('.') - for i in range(len(pts)): - k = pts[i] - if i < len(pts)-1: - if k not in cur: - raise KeyError(f'key {k} not found') - else: - return cur[k] if k in cur else default - cur = self.data[k] - raise KeyError(f'option {key} not found') - - def get_addr(self, key: str): - return parse_addr(self.get(key)) - - def items(self): - return self.data.items() - - -config = ConfigStore() - - -def is_development_mode() -> bool: - if 'HK_MODE' in os.environ and os.environ['HK_MODE'] == 'dev': - return True - - return ('logging' in config) and ('verbose' in config['logging']) and (config['logging']['verbose'] is True) - - -def setup_logging(verbose=False, log_file=None, default_fmt=False): - logging_level = logging.INFO - if is_development_mode() or verbose: - logging_level = logging.DEBUG - _add_logging_level('TRACE', logging.DEBUG-5) - - log_config = {'level': logging_level} - if not default_fmt: - log_config['format'] = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - - if log_file is not None: - log_config['filename'] = log_file - log_config['encoding'] = 'utf-8' - - logging.basicConfig(**log_config) - - -# https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945 -def _add_logging_level(levelName, levelNum, methodName=None): - """ - Comprehensively adds a new logging level to the `logging` module and the - currently configured logging class. - - `levelName` becomes an attribute of the `logging` module with the value - `levelNum`. `methodName` becomes a convenience method for both `logging` - itself and the class returned by `logging.getLoggerClass()` (usually just - `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is - used. - - To avoid accidental clobberings of existing attributes, this method will - raise an `AttributeError` if the level name is already an attribute of the - `logging` module or if the method name is already present - - Example - ------- - >>> addLoggingLevel('TRACE', logging.DEBUG - 5) - >>> logging.getLogger(__name__).setLevel("TRACE") - >>> logging.getLogger(__name__).trace('that worked') - >>> logging.trace('so did this') - >>> logging.TRACE - 5 - - """ - if not methodName: - methodName = levelName.lower() - - if hasattr(logging, levelName): - raise AttributeError('{} already defined in logging module'.format(levelName)) - if hasattr(logging, methodName): - raise AttributeError('{} already defined in logging module'.format(methodName)) - if hasattr(logging.getLoggerClass(), methodName): - raise AttributeError('{} already defined in logger class'.format(methodName)) - - # This method was inspired by the answers to Stack Overflow post - # http://stackoverflow.com/q/2183233/2988730, especially - # http://stackoverflow.com/a/13638084/2988730 - def logForLevel(self, message, *args, **kwargs): - if self.isEnabledFor(levelNum): - self._log(levelNum, message, args, **kwargs) - def logToRoot(message, *args, **kwargs): - logging.log(levelNum, message, *args, **kwargs) - - logging.addLevelName(levelNum, levelName) - setattr(logging, levelName, levelNum) - setattr(logging.getLoggerClass(), methodName, logForLevel) - setattr(logging, methodName, logToRoot)
\ No newline at end of file diff --git a/src/home/database/__init__.py b/src/home/database/__init__.py deleted file mode 100644 index b50cbce..0000000 --- a/src/home/database/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -import importlib - -__all__ = [ - 'get_mysql', - 'mysql_now', - 'get_clickhouse', - 'SimpleState', - - 'SensorsDatabase', - 'InverterDatabase', - 'BotsDatabase' -] - - -def __getattr__(name: str): - if name in __all__: - if name.endswith('Database'): - file = name[:-8].lower() - elif 'mysql' in name: - file = 'mysql' - elif 'clickhouse' in name: - file = 'clickhouse' - else: - file = 'simple_state' - - module = importlib.import_module(f'.{file}', __name__) - return getattr(module, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/database/__init__.pyi b/src/home/database/__init__.pyi deleted file mode 100644 index 31aae5d..0000000 --- a/src/home/database/__init__.pyi +++ /dev/null @@ -1,11 +0,0 @@ -from .mysql import ( - get_mysql as get_mysql, - mysql_now as mysql_now -) -from .clickhouse import get_clickhouse as get_clickhouse - -from simple_state import SimpleState as SimpleState - -from .sensors import SensorsDatabase as SensorsDatabase -from .inverter import InverterDatabase as InverterDatabase -from .bots import BotsDatabase as BotsDatabase diff --git a/src/home/database/bots.py b/src/home/database/bots.py deleted file mode 100644 index cde48b9..0000000 --- a/src/home/database/bots.py +++ /dev/null @@ -1,106 +0,0 @@ -import pytz - -from .mysql import mysql_now, MySQLDatabase, datetime_fmt -from ..api.types import ( - BotType, - SoundSensorLocation -) -from typing import Optional, List, Tuple -from datetime import datetime -from html import escape - - -class OpenwrtLogRecord: - id: int - log_time: datetime - received_time: datetime - text: str - - def __init__(self, id, text, log_time, received_time): - self.id = id - self.text = text - self.log_time = log_time - self.received_time = received_time - - def __repr__(self): - return f"<b>{self.log_time.strftime('%H:%M:%S')}</b> {escape(self.text)}" - - -class BotsDatabase(MySQLDatabase): - def add_request(self, - bot: BotType, - user_id: int, - message: str): - with self.cursor() as cursor: - cursor.execute("INSERT INTO requests_log (user_id, message, bot, time) VALUES (%s, %s, %s, %s)", - (user_id, message, bot.name.lower(), mysql_now())) - self.commit() - - def add_openwrt_logs(self, - lines: List[Tuple[datetime, str]], - access_point: int): - now = datetime.now() - with self.cursor() as cursor: - for line in lines: - time, text = line - cursor.execute("INSERT INTO openwrt (log_time, received_time, text, ap) VALUES (%s, %s, %s, %s)", - (time.strftime(datetime_fmt), now.strftime(datetime_fmt), text, access_point)) - self.commit() - - def add_sound_hits(self, - hits: List[Tuple[SoundSensorLocation, int]], - time: datetime): - with self.cursor() as cursor: - for loc, count in hits: - cursor.execute("INSERT INTO sound_hits (location, `time`, hits) VALUES (%s, %s, %s)", - (loc.name.lower(), time.strftime(datetime_fmt), count)) - self.commit() - - def get_sound_hits(self, - location: SoundSensorLocation, - after: Optional[datetime] = None, - 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()] - - if after: - sql += ' AND `time` >= %s ORDER BY time DESC' - args.append(after) - elif last: - sql += ' ORDER BY time DESC LIMIT 0, %s' - args.append(last) - else: - raise ValueError('no `after`, no `last`, what do you expect?') - - cursor.execute(sql, tuple(args)) - data = [] - for row in cursor.fetchall(): - data.append({ - 'time': row['time'], - 'hits': row['hits'] - }) - return data - - def get_openwrt_logs(self, - filter_text: str, - min_id: int, - access_point: int, - limit: int = None) -> List[OpenwrtLogRecord]: - tz = pytz.timezone('Europe/Moscow') - with self.cursor(dictionary=True) as cursor: - sql = "SELECT * FROM openwrt WHERE ap=%s AND text LIKE %s AND id > %s" - if limit is not None: - sql += f" LIMIT {limit}" - - cursor.execute(sql, (access_point, f'%{filter_text}%', min_id)) - data = [] - for row in cursor.fetchall(): - data.append(OpenwrtLogRecord( - id=int(row['id']), - text=row['text'], - log_time=row['log_time'].astimezone(tz), - received_time=row['received_time'].astimezone(tz) - )) - - return data diff --git a/src/home/database/clickhouse.py b/src/home/database/clickhouse.py deleted file mode 100644 index ca81628..0000000 --- a/src/home/database/clickhouse.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging - -from zoneinfo import ZoneInfo -from datetime import datetime, timedelta -from clickhouse_driver import Client as ClickhouseClient -from ..config import is_development_mode - -_links = {} - - -def get_clickhouse(db: str) -> ClickhouseClient: - if db not in _links: - _links[db] = ClickhouseClient.from_url(f'clickhouse://localhost/{db}') - - return _links[db] - - -class ClickhouseDatabase: - def __init__(self, db: str): - self.db = get_clickhouse(db) - - self.server_timezone = self.db.execute('SELECT timezone()')[0][0] - self.logger = logging.getLogger(self.__class__.__name__) - - def query(self, *args, **kwargs): - settings = {'use_client_time_zone': True} - kwargs['settings'] = settings - - if 'no_tz_fix' not in kwargs and len(args) > 1 and isinstance(args[1], dict): - for k, v in args[1].items(): - if isinstance(v, datetime): - args[1][k] = v.astimezone(tz=ZoneInfo(self.server_timezone)) - - result = self.db.execute(*args, **kwargs) - - if is_development_mode(): - self.logger.debug(args[0] if len(args) == 1 else args[0] % args[1]) - - return result diff --git a/src/home/database/inverter.py b/src/home/database/inverter.py deleted file mode 100644 index fc3f74f..0000000 --- a/src/home/database/inverter.py +++ /dev/null @@ -1,212 +0,0 @@ -from time import time -from datetime import datetime, timedelta -from typing import Optional -from collections import namedtuple - -from .clickhouse import ClickhouseDatabase - - -IntervalList = list[list[Optional[datetime]]] - - -class InverterDatabase(ClickhouseDatabase): - def __init__(self): - super().__init__('solarmon') - - def add_generation(self, home_id: int, client_time: int, watts: int) -> None: - self.db.execute( - 'INSERT INTO generation (ClientTime, ReceivedTime, HomeID, Watts) VALUES', - [[client_time, round(time()), home_id, watts]] - ) - - def add_status(self, home_id: int, - client_time: int, - grid_voltage: int, - grid_freq: int, - ac_output_voltage: int, - ac_output_freq: int, - ac_output_apparent_power: int, - ac_output_active_power: int, - output_load_percent: int, - battery_voltage: int, - battery_voltage_scc: int, - battery_voltage_scc2: int, - battery_discharge_current: int, - battery_charge_current: int, - battery_capacity: int, - inverter_heat_sink_temp: int, - mppt1_charger_temp: int, - mppt2_charger_temp: int, - pv1_input_power: int, - pv2_input_power: int, - pv1_input_voltage: int, - pv2_input_voltage: int, - mppt1_charger_status: int, - mppt2_charger_status: int, - battery_power_direction: int, - dc_ac_power_direction: int, - line_power_direction: int, - load_connected: int) -> None: - self.db.execute("""INSERT INTO status ( - ClientTime, - ReceivedTime, - HomeID, - GridVoltage, - GridFrequency, - ACOutputVoltage, - ACOutputFrequency, - ACOutputApparentPower, - ACOutputActivePower, - OutputLoadPercent, - BatteryVoltage, - BatteryVoltageSCC, - BatteryVoltageSCC2, - BatteryDischargingCurrent, - BatteryChargingCurrent, - BatteryCapacity, - HeatSinkTemp, - MPPT1ChargerTemp, - MPPT2ChargerTemp, - PV1InputPower, - PV2InputPower, - PV1InputVoltage, - PV2InputVoltage, - MPPT1ChargerStatus, - MPPT2ChargerStatus, - BatteryPowerDirection, - DCACPowerDirection, - LinePowerDirection, - LoadConnected) VALUES""", [[ - client_time, - round(time()), - home_id, - grid_voltage, - grid_freq, - ac_output_voltage, - ac_output_freq, - ac_output_apparent_power, - ac_output_active_power, - output_load_percent, - battery_voltage, - battery_voltage_scc, - battery_voltage_scc2, - battery_discharge_current, - battery_charge_current, - battery_capacity, - inverter_heat_sink_temp, - mppt1_charger_temp, - mppt2_charger_temp, - pv1_input_power, - pv2_input_power, - pv1_input_voltage, - pv2_input_voltage, - mppt1_charger_status, - mppt2_charger_status, - battery_power_direction, - dc_ac_power_direction, - line_power_direction, - load_connected - ]]) - - def get_consumed_energy(self, dt_from: datetime, dt_to: datetime) -> float: - rows = self.query('SELECT ClientTime, ACOutputActivePower FROM status' - ' WHERE ClientTime >= %(from)s AND ClientTime <= %(to)s' - ' ORDER BY ClientTime', {'from': dt_from, 'to': dt_to}) - prev_time = None - prev_wh = 0 - - ws = 0 # watt-seconds - for t, wh in rows: - if prev_time is not None: - n = (t - prev_time).total_seconds() - ws += prev_wh * n - - prev_time = t - prev_wh = wh - - return ws / 3600 # convert to watt-hours - - def get_intervals_by_condition(self, - dt_from: datetime, - dt_to: datetime, - cond_start: str, - cond_end: str) -> IntervalList: - rows = None - ranges = [[None, None]] - - while rows is None or len(rows) > 0: - if ranges[len(ranges)-1][0] is None: - condition = cond_start - range_idx = 0 - else: - condition = cond_end - range_idx = 1 - - rows = self.query('SELECT ClientTime FROM status ' - f'WHERE ClientTime > %(from)s AND ClientTime <= %(to)s AND {condition}' - ' ORDER BY ClientTime LIMIT 1', - {'from': dt_from, 'to': dt_to}) - if not rows: - break - - row = rows[0] - - ranges[len(ranges) - 1][range_idx] = row[0] - if range_idx == 1: - ranges.append([None, None]) - - dt_from = row[0] - - if ranges[len(ranges)-1][0] is None: - ranges.pop() - elif ranges[len(ranges)-1][1] is None: - ranges[len(ranges)-1][1] = dt_to - timedelta(seconds=1) - - return ranges - - def get_grid_connected_intervals(self, dt_from: datetime, dt_to: datetime) -> IntervalList: - return self.get_intervals_by_condition(dt_from, dt_to, 'GridFrequency > 0', 'GridFrequency = 0') - - def get_grid_used_intervals(self, dt_from: datetime, dt_to: datetime) -> IntervalList: - return self.get_intervals_by_condition(dt_from, - dt_to, - "LinePowerDirection = 'Input'", - "LinePowerDirection != 'Input'") - - def get_grid_consumed_energy(self, dt_from: datetime, dt_to: datetime) -> float: - PrevData = namedtuple('PrevData', 'time, pd, bat_chg, bat_dis, wh') - - ws = 0 # watt-seconds - amps = 0 # amper-seconds - - intervals = self.get_grid_used_intervals(dt_from, dt_to) - for dt_start, dt_end in intervals: - fields = ', '.join([ - 'ClientTime', - 'DCACPowerDirection', - 'BatteryChargingCurrent', - 'BatteryDischargingCurrent', - 'ACOutputActivePower' - ]) - rows = self.query(f'SELECT {fields} FROM status' - ' WHERE ClientTime >= %(from)s AND ClientTime < %(to)s ORDER BY ClientTime', - {'from': dt_start, 'to': dt_end}) - - prev = PrevData(time=None, pd=None, bat_chg=None, bat_dis=None, wh=None) - for ct, pd, bat_chg, bat_dis, wh in rows: - if prev.time is not None: - n = (ct-prev.time).total_seconds() - ws += prev.wh * n - - if pd == 'DC/AC': - amps -= prev.bat_dis * n - elif pd == 'AC/DC': - amps += prev.bat_chg * n - - prev = PrevData(time=ct, pd=pd, bat_chg=bat_chg, bat_dis=bat_dis, wh=wh) - - amps /= 3600 - wh = ws / 3600 - wh += amps*48 - - return wh diff --git a/src/home/database/inverter_time_formats.py b/src/home/database/inverter_time_formats.py deleted file mode 100644 index 7c37d30..0000000 --- a/src/home/database/inverter_time_formats.py +++ /dev/null @@ -1,2 +0,0 @@ -FormatTime = '%Y-%m-%d %H:%M:%S' -FormatDate = '%Y-%m-%d' diff --git a/src/home/database/mysql.py b/src/home/database/mysql.py deleted file mode 100644 index fe97cd4..0000000 --- a/src/home/database/mysql.py +++ /dev/null @@ -1,47 +0,0 @@ -import time -import logging - -from mysql.connector import connect, MySQLConnection, Error -from typing import Optional -from ..config import config - -link: Optional[MySQLConnection] = None -logger = logging.getLogger(__name__) - -datetime_fmt = '%Y-%m-%d %H:%M:%S' - - -def get_mysql() -> MySQLConnection: - global link - - if link is not None: - return link - - link = connect( - host=config['mysql']['host'], - user=config['mysql']['user'], - password=config['mysql']['password'], - database=config['mysql']['database'], - ) - link.time_zone = '+01:00' - return link - - -def mysql_now() -> str: - return time.strftime('%Y-%m-%d %H:%M:%S') - - -class MySQLDatabase: - def __init__(self): - self.db = get_mysql() - - def cursor(self, **kwargs): - try: - self.db.ping(reconnect=True, attempts=2) - except Error as e: - logger.exception(e) - self.db = get_mysql() - return self.db.cursor(**kwargs) - - def commit(self): - self.db.commit() diff --git a/src/home/database/sensors.py b/src/home/database/sensors.py deleted file mode 100644 index 8155108..0000000 --- a/src/home/database/sensors.py +++ /dev/null @@ -1,69 +0,0 @@ -from time import time -from datetime import datetime -from typing import Tuple, List -from .clickhouse import ClickhouseDatabase -from ..api.types import TemperatureSensorLocation - - -def get_temperature_table(sensor: TemperatureSensorLocation) -> str: - if sensor == TemperatureSensorLocation.DIANA: - return 'temp_diana' - - elif sensor == TemperatureSensorLocation.STREET: - return 'temp_street' - - elif sensor == TemperatureSensorLocation.BIG_HOUSE_1: - return 'temp' - - elif sensor == TemperatureSensorLocation.BIG_HOUSE_2: - return 'temp_roof' - - elif sensor == TemperatureSensorLocation.BIG_HOUSE_ROOM: - return 'temp_room' - - elif sensor == TemperatureSensorLocation.SPB1: - return 'temp_spb1' - - -class SensorsDatabase(ClickhouseDatabase): - def __init__(self): - super().__init__('home') - - def add_temperature(self, - home_id: int, - client_time: int, - sensor: TemperatureSensorLocation, - temp: int, - rh: int): - table = get_temperature_table(sensor) - sql = """INSERT INTO """ + table + """ ( - ClientTime, - ReceivedTime, - HomeID, - Temperature, - RelativeHumidity - ) VALUES""" - self.db.execute(sql, [[ - client_time, - int(time()), - home_id, - temp, - rh - ]]) - - def get_temperature_recordings(self, - sensor: TemperatureSensorLocation, - time_range: Tuple[datetime, datetime], - home_id=1) -> List[tuple]: - table = get_temperature_table(sensor) - sql = f"""SELECT ClientTime, Temperature, RelativeHumidity - FROM {table} - WHERE ClientTime >= %(from)s AND ClientTime <= %(to)s - ORDER BY ClientTime""" - dt_from, dt_to = time_range - - data = self.query(sql, { - 'from': dt_from, - 'to': dt_to - }) - return [(date, temp/100, humidity/100) for date, temp, humidity in data] diff --git a/src/home/database/simple_state.py b/src/home/database/simple_state.py deleted file mode 100644 index cada9c8..0000000 --- a/src/home/database/simple_state.py +++ /dev/null @@ -1,46 +0,0 @@ -import os -import json -import atexit - - -class SimpleState: - def __init__(self, - file: str, - default: dict = None, - **kwargs): - if default is None: - default = {} - elif type(default) is not dict: - raise TypeError('default must be dictionary') - - if not os.path.exists(file): - self._data = default - else: - with open(file, 'r') as f: - self._data = json.loads(f.read()) - - self._file = file - atexit.register(self.__cleanup) - - def __cleanup(self): - if hasattr(self, '_file'): - with open(self._file, 'w') as f: - f.write(json.dumps(self._data)) - atexit.unregister(self.__cleanup) - - def __del__(self): - if 'open' in __builtins__: - self.__cleanup() - - def __getitem__(self, key): - return self._data[key] - - def __setitem__(self, key, value): - self._data[key] = value - - def __contains__(self, key): - return key in self._data - - def __delitem__(self, key): - if key in self._data: - del self._data[key] diff --git a/src/home/database/sqlite.py b/src/home/database/sqlite.py deleted file mode 100644 index bfba929..0000000 --- a/src/home/database/sqlite.py +++ /dev/null @@ -1,66 +0,0 @@ -import sqlite3 -import os.path -import logging - -from ..config import config, is_development_mode - - -def _get_database_path(name: str, dbname: str) -> str: - return os.path.join(os.environ['HOME'], '.config', name, f'{dbname}.db') - - -class SQLiteBase: - SCHEMA = 1 - - def __init__(self, name=None, dbname='bot', check_same_thread=False): - db_path = config.get('db_path', default=None) - if db_path is None: - if not name: - name = config.app_name - if not dbname: - dbname = name - db_path = _get_database_path(name, dbname) - - self.logger = logging.getLogger(self.__class__.__name__) - self.sqlite = sqlite3.connect(db_path, check_same_thread=check_same_thread) - - if is_development_mode(): - self.sql_logger = logging.getLogger(self.__class__.__name__) - self.sql_logger.setLevel('TRACE') - self.sqlite.set_trace_callback(self.sql_logger.trace) - - sqlite_version = self._get_sqlite_version() - self.logger.debug(f'SQLite version: {sqlite_version}') - - schema_version = self.schema_get_version() - self.logger.debug(f'Schema version: {schema_version}') - - self.schema_init(schema_version) - self.schema_set_version(self.SCHEMA) - - def __del__(self): - if self.sqlite: - self.sqlite.commit() - self.sqlite.close() - - def _get_sqlite_version(self) -> str: - cursor = self.sqlite.cursor() - cursor.execute("SELECT sqlite_version()") - return cursor.fetchone()[0] - - def schema_get_version(self) -> int: - cursor = self.sqlite.execute('PRAGMA user_version') - return int(cursor.fetchone()[0]) - - def schema_set_version(self, v) -> None: - self.sqlite.execute('PRAGMA user_version={:d}'.format(v)) - self.logger.info(f'Schema set to {v}') - - def cursor(self) -> sqlite3.Cursor: - return self.sqlite.cursor() - - def commit(self) -> None: - return self.sqlite.commit() - - def schema_init(self, version: int) -> None: - raise ValueError(f'{self.__class__.__name__}: must override schema_init') diff --git a/src/home/http/__init__.py b/src/home/http/__init__.py deleted file mode 100644 index 6030e95..0000000 --- a/src/home/http/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .http import serve, ok, routes, HTTPServer -from aiohttp.web import FileResponse, StreamResponse, Request, Response diff --git a/src/home/http/http.py b/src/home/http/http.py deleted file mode 100644 index 3e70751..0000000 --- a/src/home/http/http.py +++ /dev/null @@ -1,106 +0,0 @@ -import logging -import asyncio - -from aiohttp import web -from aiohttp.web import Response -from aiohttp.web_exceptions import HTTPNotFound - -from ..util import stringify, format_tb, Addr - - -_logger = logging.getLogger(__name__) - - -@web.middleware -async def errors_handler_middleware(request, handler): - try: - response = await handler(request) - return response - - except HTTPNotFound: - return web.json_response({'error': 'not found'}, status=404) - - except Exception as exc: - _logger.exception(exc) - data = { - 'error': exc.__class__.__name__, - 'message': exc.message if hasattr(exc, 'message') else str(exc) - } - tb = format_tb(exc) - if tb: - data['stacktrace'] = tb - - return web.json_response(data, status=500) - - -def serve(addr: Addr, route_table: web.RouteTableDef, handle_signals: bool = True): - app = web.Application() - app.add_routes(route_table) - app.middlewares.append(errors_handler_middleware) - - host, port = addr - - web.run_app(app, - host=host, - port=port, - handle_signals=handle_signals) - - -def routes() -> web.RouteTableDef: - return web.RouteTableDef() - - -def ok(data=None): - if data is None: - data = 1 - response = {'response': data} - return web.json_response(response, dumps=stringify) - - -class HTTPServer: - def __init__(self, addr: Addr, handle_errors=True): - self.addr = addr - self.app = web.Application() - self.logger = logging.getLogger(self.__class__.__name__) - - if handle_errors: - self.app.middlewares.append(errors_handler_middleware) - - def _add_route(self, - method: str, - path: str, - handler: callable): - self.app.router.add_routes([getattr(web, method)(path, handler)]) - - def get(self, path, handler): - self._add_route('get', path, handler) - - def post(self, path, handler): - self._add_route('post', path, handler) - - def put(self, path, handler): - self._add_route('put', path, handler) - - def delete(self, path, handler): - self._add_route('delete', path, handler) - - def run(self, event_loop=None, handle_signals=True): - if not event_loop: - event_loop = asyncio.get_event_loop() - - runner = web.AppRunner(self.app, handle_signals=handle_signals) - event_loop.run_until_complete(runner.setup()) - - host, port = self.addr - site = web.TCPSite(runner, host=host, port=port) - event_loop.run_until_complete(site.start()) - - self.logger.info(f'Server started at http://{host}:{port}') - - event_loop.run_forever() - - def ok(self, data=None): - return ok(data) - - def plain(self, text: str): - return Response(text=text, content_type='text/plain') diff --git a/src/home/inverter/__init__.py b/src/home/inverter/__init__.py deleted file mode 100644 index 8831ef3..0000000 --- a/src/home/inverter/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .monitor import InverterMonitor -from .inverter_wrapper import wrapper_instance -from .util import beautify_table diff --git a/src/home/inverter/emulator.py b/src/home/inverter/emulator.py deleted file mode 100644 index e86b8bb..0000000 --- a/src/home/inverter/emulator.py +++ /dev/null @@ -1,556 +0,0 @@ -import asyncio -import logging - -from inverterd import Format - -from typing import Union -from enum import Enum -from ..util import Addr, stringify - - -class InverterEnum(Enum): - def as_text(self) -> str: - raise RuntimeError('abstract method') - - -class BatteryType(InverterEnum): - AGM = 0 - Flooded = 1 - User = 2 - - def as_text(self) -> str: - return ('AGM', 'Flooded', 'User')[self.value] - - -class InputVoltageRange(InverterEnum): - Appliance = 0 - USP = 1 - - def as_text(self) -> str: - return ('Appliance', 'USP')[self.value] - - -class OutputSourcePriority(InverterEnum): - SolarUtilityBattery = 0 - SolarBatteryUtility = 1 - - def as_text(self) -> str: - return ('Solar-Utility-Battery', 'Solar-Battery-Utility')[self.value] - - -class ChargeSourcePriority(InverterEnum): - SolarFirst = 0 - SolarAndUtility = 1 - SolarOnly = 2 - - def as_text(self) -> str: - return ('Solar-First', 'Solar-and-Utility', 'Solar-only')[self.value] - - -class MachineType(InverterEnum): - OffGridTie = 0 - GridTie = 1 - - def as_text(self) -> str: - return ('Off-Grid-Tie', 'Grid-Tie')[self.value] - - -class Topology(InverterEnum): - TransformerLess = 0 - Transformer = 1 - - def as_text(self) -> str: - return ('Transformer-less', 'Transformer')[self.value] - - -class OutputMode(InverterEnum): - SingleOutput = 0 - ParallelOutput = 1 - Phase_1_of_3 = 2 - Phase_2_of_3 = 3 - Phase_3_of_3 = 4 - - def as_text(self) -> str: - return ( - 'Single output', - 'Parallel output', - 'Phase 1 of 3-phase output', - 'Phase 2 of 3-phase output', - 'Phase 3 of 3-phase' - )[self.value] - - -class SolarPowerPriority(InverterEnum): - BatteryLoadUtility = 0 - LoadBatteryUtility = 1 - - def as_text(self) -> str: - return ('Battery-Load-Utility', 'Load-Battery-Utility')[self.value] - - -class MPPTChargerStatus(InverterEnum): - Abnormal = 0 - NotCharging = 1 - Charging = 2 - - def as_text(self) -> str: - return ('Abnormal', 'Not charging', 'Charging')[self.value] - - -class BatteryPowerDirection(InverterEnum): - DoNothing = 0 - Charge = 1 - Discharge = 2 - - def as_text(self) -> str: - return ('Do nothing', 'Charge', 'Discharge')[self.value] - - -class DC_AC_PowerDirection(InverterEnum): - DoNothing = 0 - AC_DC = 1 - DC_AC = 2 - - def as_text(self) -> str: - return ('Do nothing', 'AC/DC', 'DC/AC')[self.value] - - -class LinePowerDirection(InverterEnum): - DoNothing = 0 - Input = 1 - Output = 2 - - def as_text(self) -> str: - return ('Do nothing', 'Input', 'Output')[self.value] - - -class WorkingMode(InverterEnum): - PowerOnMode = 0 - StandbyMode = 1 - BypassMode = 2 - BatteryMode = 3 - FaultMode = 4 - HybridMode = 5 - - def as_text(self) -> str: - return ( - 'Power on mode', - 'Standby mode', - 'Bypass mode', - 'Battery mode', - 'Fault mode', - 'Hybrid mode' - )[self.value] - - -class ParallelConnectionStatus(InverterEnum): - NotExistent = 0 - Existent = 1 - - def as_text(self) -> str: - return ('Non-existent', 'Existent')[self.value] - - -class LoadConnectionStatus(InverterEnum): - Disconnected = 0 - Connected = 1 - - def as_text(self) -> str: - return ('Disconnected', 'Connected')[self.value] - - -class ConfigurationStatus(InverterEnum): - Default = 0 - Changed = 1 - - def as_text(self) -> str: - return ('Default', 'Changed')[self.value] - - -_g_human_readable = {"grid_voltage": "Grid voltage", - "grid_freq": "Grid frequency", - "ac_output_voltage": "AC output voltage", - "ac_output_freq": "AC output frequency", - "ac_output_apparent_power": "AC output apparent power", - "ac_output_active_power": "AC output active power", - "output_load_percent": "Output load percent", - "battery_voltage": "Battery voltage", - "battery_voltage_scc": "Battery voltage from SCC", - "battery_voltage_scc2": "Battery voltage from SCC2", - "battery_discharge_current": "Battery discharge current", - "battery_charge_current": "Battery charge current", - "battery_capacity": "Battery capacity", - "inverter_heat_sink_temp": "Inverter heat sink temperature", - "mppt1_charger_temp": "MPPT1 charger temperature", - "mppt2_charger_temp": "MPPT2 charger temperature", - "pv1_input_power": "PV1 input power", - "pv2_input_power": "PV2 input power", - "pv1_input_voltage": "PV1 input voltage", - "pv2_input_voltage": "PV2 input voltage", - "configuration_status": "Configuration state", - "mppt1_charger_status": "MPPT1 charger status", - "mppt2_charger_status": "MPPT2 charger status", - "load_connected": "Load connection", - "battery_power_direction": "Battery power direction", - "dc_ac_power_direction": "DC/AC power direction", - "line_power_direction": "Line power direction", - "local_parallel_id": "Local parallel ID", - "ac_input_rating_voltage": "AC input rating voltage", - "ac_input_rating_current": "AC input rating current", - "ac_output_rating_voltage": "AC output rating voltage", - "ac_output_rating_freq": "AC output rating frequency", - "ac_output_rating_current": "AC output rating current", - "ac_output_rating_apparent_power": "AC output rating apparent power", - "ac_output_rating_active_power": "AC output rating active power", - "battery_rating_voltage": "Battery rating voltage", - "battery_recharge_voltage": "Battery re-charge voltage", - "battery_redischarge_voltage": "Battery re-discharge voltage", - "battery_under_voltage": "Battery under voltage", - "battery_bulk_voltage": "Battery bulk voltage", - "battery_float_voltage": "Battery float voltage", - "battery_type": "Battery type", - "max_charge_current": "Max charge current", - "max_ac_charge_current": "Max AC charge current", - "input_voltage_range": "Input voltage range", - "output_source_priority": "Output source priority", - "charge_source_priority": "Charge source priority", - "parallel_max_num": "Parallel max num", - "machine_type": "Machine type", - "topology": "Topology", - "output_mode": "Output mode", - "solar_power_priority": "Solar power priority", - "mppt": "MPPT string", - "fault_code": "Fault code", - "line_fail": "Line fail", - "output_circuit_short": "Output circuit short", - "inverter_over_temperature": "Inverter over temperature", - "fan_lock": "Fan lock", - "battery_voltage_high": "Battery voltage high", - "battery_low": "Battery low", - "battery_under": "Battery under", - "over_load": "Over load", - "eeprom_fail": "EEPROM fail", - "power_limit": "Power limit", - "pv1_voltage_high": "PV1 voltage high", - "pv2_voltage_high": "PV2 voltage high", - "mppt1_overload_warning": "MPPT1 overload warning", - "mppt2_overload_warning": "MPPT2 overload warning", - "battery_too_low_to_charge_for_scc1": "Battery too low to charge for SCC1", - "battery_too_low_to_charge_for_scc2": "Battery too low to charge for SCC2", - "buzzer": "Buzzer", - "overload_bypass": "Overload bypass function", - "escape_to_default_screen_after_1min_timeout": "Escape to default screen after 1min timeout", - "overload_restart": "Overload restart", - "over_temp_restart": "Over temperature restart", - "backlight_on": "Backlight on", - "alarm_on_on_primary_source_interrupt": "Alarm on on primary source interrupt", - "fault_code_record": "Fault code record", - "wh": "Wh"} - - -class InverterEmulator: - def __init__(self, addr: Addr, wait=True): - self.status = {"grid_voltage": {"unit": "V", "value": 236.3}, - "grid_freq": {"unit": "Hz", "value": 50.0}, - "ac_output_voltage": {"unit": "V", "value": 229.9}, - "ac_output_freq": {"unit": "Hz", "value": 50.0}, - "ac_output_apparent_power": {"unit": "VA", "value": 207}, - "ac_output_active_power": {"unit": "Wh", "value": 146}, - "output_load_percent": {"unit": "%", "value": 4}, - "battery_voltage": {"unit": "V", "value": 49.1}, - "battery_voltage_scc": {"unit": "V", "value": 0.0}, - "battery_voltage_scc2": {"unit": "V", "value": 0.0}, - "battery_discharge_current": {"unit": "A", "value": 3}, - "battery_charge_current": {"unit": "A", "value": 0}, - "battery_capacity": {"unit": "%", "value": 69}, - "inverter_heat_sink_temp": {"unit": "°C", "value": 17}, - "mppt1_charger_temp": {"unit": "°C", "value": 0}, - "mppt2_charger_temp": {"unit": "°C", "value": 0}, - "pv1_input_power": {"unit": "Wh", "value": 0}, - "pv2_input_power": {"unit": "Wh", "value": 0}, - "pv1_input_voltage": {"unit": "V", "value": 0.0}, - "pv2_input_voltage": {"unit": "V", "value": 0.0}, - "configuration_status": ConfigurationStatus.Default, - "mppt1_charger_status": MPPTChargerStatus.Abnormal, - "mppt2_charger_status": MPPTChargerStatus.Abnormal, - "load_connected": LoadConnectionStatus.Connected, - "battery_power_direction": BatteryPowerDirection.Discharge, - "dc_ac_power_direction": DC_AC_PowerDirection.DC_AC, - "line_power_direction": LinePowerDirection.DoNothing, - "local_parallel_id": 0} - - self.rated = {"ac_input_rating_voltage": {"unit": "V", "value": 230.0}, - "ac_input_rating_current": {"unit": "A", "value": 21.7}, - "ac_output_rating_voltage": {"unit": "V", "value": 230.0}, - "ac_output_rating_freq": {"unit": "Hz", "value": 50.0}, - "ac_output_rating_current": {"unit": "A", "value": 21.7}, - "ac_output_rating_apparent_power": {"unit": "VA", "value": 5000}, - "ac_output_rating_active_power": {"unit": "Wh", "value": 5000}, - "battery_rating_voltage": {"unit": "V", "value": 48.0}, - "battery_recharge_voltage": {"unit": "V", "value": 48.0}, - "battery_redischarge_voltage": {"unit": "V", "value": 55.0}, - "battery_under_voltage": {"unit": "V", "value": 42.0}, - "battery_bulk_voltage": {"unit": "V", "value": 57.6}, - "battery_float_voltage": {"unit": "V", "value": 54.0}, - "battery_type": BatteryType.User, - "max_charge_current": {"unit": "A", "value": 60}, - "max_ac_charge_current": {"unit": "A", "value": 30}, - "input_voltage_range": InputVoltageRange.Appliance, - "output_source_priority": OutputSourcePriority.SolarBatteryUtility, - "charge_source_priority": ChargeSourcePriority.SolarAndUtility, - "parallel_max_num": 6, - "machine_type": MachineType.OffGridTie, - "topology": Topology.TransformerLess, - "output_mode": OutputMode.SingleOutput, - "solar_power_priority": SolarPowerPriority.LoadBatteryUtility, - "mppt": "2"} - - self.errors = {"fault_code": 0, - "line_fail": False, - "output_circuit_short": False, - "inverter_over_temperature": False, - "fan_lock": False, - "battery_voltage_high": False, - "battery_low": False, - "battery_under": False, - "over_load": False, - "eeprom_fail": False, - "power_limit": False, - "pv1_voltage_high": False, - "pv2_voltage_high": False, - "mppt1_overload_warning": False, - "mppt2_overload_warning": False, - "battery_too_low_to_charge_for_scc1": False, - "battery_too_low_to_charge_for_scc2": False} - - self.flags = {"buzzer": False, - "overload_bypass": True, - "escape_to_default_screen_after_1min_timeout": False, - "overload_restart": True, - "over_temp_restart": True, - "backlight_on": False, - "alarm_on_on_primary_source_interrupt": True, - "fault_code_record": False} - - self.day_generated = 1000 - - self.logger = logging.getLogger(self.__class__.__name__) - - host, port = addr - asyncio.run(self.run_server(host, port, wait)) - # self.max_ac_charge_current = 30 - # self.max_charge_current = 60 - # self.charge_thresholds = [48, 54] - - async def run_server(self, host, port, wait: bool): - server = await asyncio.start_server(self.client_handler, host, port) - async with server: - self.logger.info(f'listening on {host}:{port}') - if wait: - await server.serve_forever() - else: - asyncio.ensure_future(server.serve_forever()) - - async def client_handler(self, reader, writer): - client_fmt = Format.JSON - - def w(s: str): - writer.write(s.encode('utf-8')) - - def return_error(message=None): - w('err\r\n') - if message: - if client_fmt in (Format.JSON, Format.SIMPLE_JSON): - w(stringify({ - 'result': 'error', - 'message': message - })) - elif client_fmt in (Format.TABLE, Format.SIMPLE_TABLE): - w(f'error: {message}') - w('\r\n') - w('\r\n') - - def return_ok(data=None): - w('ok\r\n') - if client_fmt in (Format.JSON, Format.SIMPLE_JSON): - jdata = { - 'result': 'ok' - } - if data: - jdata['data'] = data - w(stringify(jdata)) - w('\r\n') - elif data: - w(data) - w('\r\n') - w('\r\n') - - request = None - while request != 'quit': - try: - request = await reader.read(255) - if request == b'\x04': - break - request = request.decode('utf-8').strip() - except Exception: - break - - if request.startswith('format '): - requested_format = request[7:] - try: - client_fmt = Format(requested_format) - except ValueError: - return_error('invalid format') - - return_ok() - - elif request.startswith('exec '): - buf = request[5:].split(' ') - command = buf[0] - args = buf[1:] - - try: - return_ok(self.process_command(client_fmt, command, *args)) - except ValueError as e: - return_error(str(e)) - - else: - return_error(f'invalid token: {request}') - - try: - await writer.drain() - except ConnectionResetError as e: - # self.logger.exception(e) - pass - - writer.close() - - def process_command(self, fmt: Format, c: str, *args) -> Union[dict, str, list[int], None]: - ac_charge_currents = [2, 10, 20, 30, 40, 50, 60] - - if c == 'get-status': - return self.format_dict(self.status, fmt) - - elif c == 'get-rated': - return self.format_dict(self.rated, fmt) - - elif c == 'get-errors': - return self.format_dict(self.errors, fmt) - - elif c == 'get-flags': - return self.format_dict(self.flags, fmt) - - elif c == 'get-day-generated': - return self.format_dict({'wh': 1000}, fmt) - - elif c == 'get-allowed-ac-charge-currents': - return self.format_list(ac_charge_currents, fmt) - - elif c == 'set-max-ac-charge-current': - if int(args[0]) != 0: - raise ValueError(f'invalid machine id: {args[0]}') - amps = int(args[1]) - if amps not in ac_charge_currents: - raise ValueError(f'invalid value: {amps}') - self.rated['max_ac_charge_current']['value'] = amps - - elif c == 'set-charge-thresholds': - self.rated['battery_recharge_voltage']['value'] = float(args[0]) - self.rated['battery_redischarge_voltage']['value'] = float(args[1]) - - elif c == 'set-output-source-priority': - self.rated['output_source_priority'] = OutputSourcePriority.SolarBatteryUtility if args[0] == 'SBU' else OutputSourcePriority.SolarUtilityBattery - - elif c == 'set-battery-cutoff-voltage': - self.rated['battery_under_voltage']['value'] = float(args[0]) - - elif c == 'set-flag': - flag = args[0] - val = bool(int(args[1])) - - if flag == 'BUZZ': - k = 'buzzer' - elif flag == 'OLBP': - k = 'overload_bypass' - elif flag == 'LCDE': - k = 'escape_to_default_screen_after_1min_timeout' - elif flag == 'OLRS': - k = 'overload_restart' - elif flag == 'OTRS': - k = 'over_temp_restart' - elif flag == 'BLON': - k = 'backlight_on' - elif flag == 'ALRM': - k = 'alarm_on_on_primary_source_interrupt' - elif flag == 'FTCR': - k = 'fault_code_record' - else: - raise ValueError('invalid flag') - - self.flags[k] = val - - else: - raise ValueError(f'{c}: unsupported command') - - @staticmethod - def format_list(values: list, fmt: Format) -> Union[str, list]: - if fmt in (Format.JSON, Format.SIMPLE_JSON): - return values - return '\n'.join(map(lambda v: str(v), values)) - - @staticmethod - def format_dict(data: dict, fmt: Format) -> Union[str, dict]: - new_data = {} - for k, v in data.items(): - new_val = None - if fmt in (Format.JSON, Format.TABLE, Format.SIMPLE_TABLE): - if isinstance(v, dict): - new_val = v - elif isinstance(v, InverterEnum): - new_val = v.as_text() - else: - new_val = v - elif fmt == Format.SIMPLE_JSON: - if isinstance(v, dict): - new_val = v['value'] - elif isinstance(v, InverterEnum): - new_val = v.value - else: - new_val = str(v) - new_data[k] = new_val - - if fmt in (Format.JSON, Format.SIMPLE_JSON): - return new_data - - lines = [] - - if fmt == Format.SIMPLE_TABLE: - for k, v in new_data.items(): - buf = k - if isinstance(v, dict): - buf += ' ' + str(v['value']) + ' ' + v['unit'] - elif isinstance(v, InverterEnum): - buf += ' ' + v.as_text() - else: - buf += ' ' + str(v) - lines.append(buf) - - elif fmt == Format.TABLE: - max_k_len = 0 - for k in new_data.keys(): - if len(_g_human_readable[k]) > max_k_len: - max_k_len = len(_g_human_readable[k]) - for k, v in new_data.items(): - buf = _g_human_readable[k] + ':' - buf += ' ' * (max_k_len - len(_g_human_readable[k]) + 1) - if isinstance(v, dict): - buf += str(v['value']) + ' ' + v['unit'] - elif isinstance(v, InverterEnum): - buf += v.as_text() - elif isinstance(v, bool): - buf += str(int(v)) - else: - buf += str(v) - lines.append(buf) - - return '\n'.join(lines) diff --git a/src/home/inverter/inverter_wrapper.py b/src/home/inverter/inverter_wrapper.py deleted file mode 100644 index df2c2fc..0000000 --- a/src/home/inverter/inverter_wrapper.py +++ /dev/null @@ -1,48 +0,0 @@ -import json - -from threading import Lock -from inverterd import ( - Format, - Client as InverterClient, - InverterError -) - -_lock = Lock() - - -class InverterClientWrapper: - def __init__(self): - self._inverter = None - self._host = None - self._port = None - - def init(self, host: str, port: int): - self._host = host - self._port = port - self.create() - - def create(self): - self._inverter = InverterClient(host=self._host, port=self._port) - self._inverter.connect() - - def exec(self, command: str, arguments: tuple = (), format=Format.JSON): - with _lock: - try: - self._inverter.format(format) - response = self._inverter.exec(command, arguments) - if format == Format.JSON: - response = json.loads(response) - return response - except InverterError as e: - raise e - except Exception as e: - # silently try to reconnect - try: - self.create() - except Exception: - pass - raise e - - -wrapper_instance = InverterClientWrapper() - diff --git a/src/home/inverter/monitor.py b/src/home/inverter/monitor.py deleted file mode 100644 index 86f75ac..0000000 --- a/src/home/inverter/monitor.py +++ /dev/null @@ -1,499 +0,0 @@ -import logging -import time - -from .types import * -from threading import Thread -from typing import Callable, Optional -from .inverter_wrapper import wrapper_instance as inverter -from inverterd import InverterError -from ..util import Stopwatch, StopwatchError -from ..config import config - -logger = logging.getLogger(__name__) - - -def _pd_from_string(pd: str) -> BatteryPowerDirection: - if pd == 'Discharge': - return BatteryPowerDirection.DISCHARGING - elif pd == 'Charge': - return BatteryPowerDirection.CHARGING - elif pd == 'Do nothing': - return BatteryPowerDirection.DO_NOTHING - else: - raise ValueError(f'invalid power direction: {pd}') - - -class MonitorConfig: - def __getattr__(self, item): - return config['monitor'][item] - - -cfg = MonitorConfig() - - -""" -TODO: -- поддержать возможность ручного (через бота) переключения тока заряда вверх и вниз -- поддержать возможность бесшовного перезапуска бота, когда монитор понимает, что зарядка уже идет, и он - не запускает программу с начала, а продолжает с уже существующей позиции. Уведомления при этом можно не - присылать совсем, либо прислать какое-то одно приложение, в духе "программа была перезапущена" -""" - - -class InverterMonitor(Thread): - charging_event_handler: Optional[Callable] - battery_event_handler: Optional[Callable] - util_event_handler: Optional[Callable] - error_handler: Optional[Callable] - osp_change_cb: Optional[Callable] - osp: Optional[OutputSourcePriority] - - def __init__(self): - super().__init__() - self.setName('InverterMonitor') - - self.interrupted = False - self.min_allowed_current = 0 - self.ac_mode = None - self.osp = None - - # Event handlers for the bot. - self.charging_event_handler = None - self.battery_event_handler = None - self.util_event_handler = None - self.error_handler = None - self.osp_change_cb = None - - # Currents list, defined in the bot config. - self.currents = cfg.gen_currents - self.currents.sort() - - # We start charging at lowest possible current, then increase it once per minute (or so) to the maximum level. - # This is done so that the load on the generator increases smoothly, not abruptly. Generator will thank us. - self.current_change_direction = CurrentChangeDirection.UP - self.next_current_enter_time = 0 - self.active_current_idx = -1 - - self.battery_state = BatteryState.NORMAL - self.charging_state = ChargingState.NOT_CHARGING - - # 'Mostly-charged' means that we've already lowered the charging current to the level - # at which batteries are charging pretty slow. So instead of burning gasoline and shaking the air, - # we can just turn the generator off at this point. - self.mostly_charged = False - - # The stopwatch is used to measure how long does the battery voltage exceeds the float voltage level. - # We don't want to damage our batteries, right? - self.floating_stopwatch = Stopwatch() - - # State variables for utilities charging program - self.util_ac_present = None - self.util_pd = None - self.util_solar = None - - @property - def active_current(self) -> Optional[int]: - try: - if self.active_current_idx < 0: - return None - return self.currents[self.active_current_idx] - except IndexError: - return None - - def run(self): - # Check allowed currents and validate the config. - allowed_currents = list(inverter.exec('get-allowed-ac-charge-currents')['data']) - allowed_currents.sort() - - for a in self.currents: - if a not in allowed_currents: - raise ValueError(f'invalid value {a} in gen_currents list') - - self.min_allowed_current = min(allowed_currents) - - # Reading rated configuration - rated = inverter.exec('get-rated')['data'] - self.osp = OutputSourcePriority.from_text(rated['output_source_priority']) - - # Read data and run implemented programs every 2 seconds. - while not self.interrupted: - try: - response = inverter.exec('get-status') - if response['result'] != 'ok': - logger.error('get-status failed:', response) - else: - gs = response['data'] - - ac = gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0 - solar = gs['pv1_input_voltage']['value'] > 0 or gs['pv2_input_voltage']['value'] > 0 - solar_input = gs['pv1_input_power']['value'] - v = float(gs['battery_voltage']['value']) - load_watts = int(gs['ac_output_active_power']['value']) - pd = _pd_from_string(gs['battery_power_direction']) - - logger.debug(f'got status: ac={ac}, solar={solar}, v={v}, pd={pd}') - - if self.ac_mode == ACMode.GENERATOR: - self.gen_charging_program(ac, solar, v, pd) - - elif self.ac_mode == ACMode.UTILITIES: - self.utilities_monitoring_program(ac, solar, v, load_watts, solar_input, pd) - - if not ac or pd != BatteryPowerDirection.CHARGING: - # if AC is disconnected or not charging, run the low voltage checking program - self.low_voltage_program(v, load_watts) - - elif self.battery_state != BatteryState.NORMAL: - # AC is connected and the battery is charging, assume battery level is normal - self.battery_state = BatteryState.NORMAL - - except InverterError as e: - logger.exception(e) - - time.sleep(2) - - def utilities_monitoring_program(self, - ac: bool, # whether AC is connected - solar: bool, # whether MPPT is active - v: float, # battery voltage - load_watts: int, # load, wh - solar_input: int, # input from solar panels, wh - pd: BatteryPowerDirection # current power direction - ): - pd_event_send = False - if self.util_solar is None or solar != self.util_solar: - self.util_solar = solar - if solar and self.util_ac_present and self.util_pd == BatteryPowerDirection.CHARGING: - self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STOPPED_SOLAR) - pd_event_send = True - - if solar: - if v <= 48 and self.osp == OutputSourcePriority.SolarBatteryUtility: - self.osp_change_cb(OutputSourcePriority.SolarUtilityBattery, solar_input=solar_input, v=v) - self.osp = OutputSourcePriority.SolarUtilityBattery - - if self.osp == OutputSourcePriority.SolarUtilityBattery and solar_input >= 900: - self.osp_change_cb(OutputSourcePriority.SolarBatteryUtility, solar_input=solar_input, v=v) - self.osp = OutputSourcePriority.SolarBatteryUtility - - if self.util_ac_present is None or ac != self.util_ac_present: - self.util_event_handler(ACPresentEvent.CONNECTED if ac else ACPresentEvent.DISCONNECTED) - self.util_ac_present = ac - - if self.util_pd is None or self.util_pd != pd: - self.util_pd = pd - if not pd_event_send and not solar: - if pd == BatteryPowerDirection.CHARGING: - self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STARTED) - - elif pd == BatteryPowerDirection.DISCHARGING: - self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STOPPED) - - def gen_charging_program(self, - ac: bool, # whether AC is connected - solar: bool, # whether MPPT is active - v: float, # current battery voltage - pd: BatteryPowerDirection # current power direction - ): - if self.charging_state == ChargingState.NOT_CHARGING: - if ac and solar: - # Not charging because MPPT is active (solar line is connected). - # Notify users about it and change the current state. - self.charging_state = ChargingState.AC_BUT_SOLAR - self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR) - logger.info('entering AC_BUT_SOLAR state') - elif ac: - # Not charging, but AC is connected and ready to use. - # Start the charging program. - self.gen_start(pd) - - elif self.charging_state == ChargingState.AC_BUT_SOLAR: - if not ac: - # AC charger has been disconnected. Since the state is AC_BUT_SOLAR, - # charging probably never even started. Stop the charging program. - self.gen_stop(ChargingState.NOT_CHARGING) - elif not solar: - # MPPT has been disconnected, and, since AC is still connected, we can - # try to start the charging program. - self.gen_start(pd) - - elif self.charging_state in (ChargingState.AC_OK, ChargingState.AC_WAITING): - if not ac: - # Charging was in progress, but AC has been suddenly disconnected. - # Sad, but what can we do? Stop the charging program and return. - self.gen_stop(ChargingState.NOT_CHARGING) - return - - if solar: - # Charging was in progress, but MPPT has been detected. Inverter doesn't charge - # batteries from AC when MPPT is active, so we have to pause our program. - self.charging_state = ChargingState.AC_BUT_SOLAR - self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR) - try: - self.floating_stopwatch.pause() - except StopwatchError: - msg = 'gen_charging_program: floating_stopwatch.pause() failed at (1)' - logger.warning(msg) - # self.error_handler(msg) - logger.info('solar power connected during charging, entering AC_BUT_SOLAR state') - return - - # No surprises at this point, just check the values and make decisions based on them. - # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # We've reached the 'mostly-charged' point, the voltage level is not float, - # but inverter decided to stop charging (or somebody used a kettle, lol). - # Anyway, assume that charging is complete, stop the program, notify users and return. - if self.mostly_charged and v > (cfg.gen_floating_v - 1) and pd != BatteryPowerDirection.CHARGING: - self.gen_stop(ChargingState.AC_DONE) - return - - # Monitor inverter power direction and notify users when it changes. - state = ChargingState.AC_OK if pd == BatteryPowerDirection.CHARGING else ChargingState.AC_WAITING - if state != self.charging_state: - self.charging_state = state - - evt = ChargingEvent.AC_CHARGING_STARTED if state == ChargingState.AC_OK else ChargingEvent.AC_NOT_CHARGING - self.charging_event_handler(evt) - - if self.floating_stopwatch.get_elapsed_time() >= cfg.gen_floating_time_max: - # We've been at a bulk voltage level too long, so we have to stop charging. - # Set the minimum current possible. - - if self.current_change_direction == CurrentChangeDirection.UP: - # This shouldn't happen, obviously an error. - msg = 'gen_charging_program:' - msg += ' been at bulk voltage level too long, but current change direction is still \'up\'!' - msg += ' This is obviously an error, please fix it' - logger.warning(msg) - self.error_handler(msg) - - self.gen_next_current(current=self.min_allowed_current) - - elif self.active_current is not None: - # If voltage is greater than float voltage, keep the stopwatch ticking - if v > cfg.gen_floating_v and self.floating_stopwatch.is_paused(): - try: - self.floating_stopwatch.go() - except StopwatchError: - msg = 'gen_charging_program: floating_stopwatch.go() failed at (2)' - logger.warning(msg) - self.error_handler(msg) - # Otherwise, pause it - elif v <= cfg.gen_floating_v and not self.floating_stopwatch.is_paused(): - try: - self.floating_stopwatch.pause() - except StopwatchError: - msg = 'gen_charging_program: floating_stopwatch.pause() failed at (3)' - logger.warning(msg) - self.error_handler(msg) - - # Charging current monitoring - if self.current_change_direction == CurrentChangeDirection.UP: - # Generator is warming up in this code path - - if self.next_current_enter_time != 0 and pd != BatteryPowerDirection.CHARGING: - # Generator was warming up and charging, but stopped (pd has changed). - # Resetting to the minimum possible current - logger.info(f'gen_charging_program (warming path): was charging but power direction suddeny changed. resetting to minimum current') - self.next_current_enter_time = 0 - self.gen_next_current(current=self.min_allowed_current) - - elif self.next_current_enter_time == 0 and pd == BatteryPowerDirection.CHARGING: - self.next_current_enter_time = time.time() + cfg.gen_raise_intervals[self.active_current_idx] - logger.info(f'gen_charging_program (warming path): set next_current_enter_time to {self.next_current_enter_time}') - - elif self.next_current_enter_time != 0 and time.time() >= self.next_current_enter_time: - logger.info('gen_charging_program (warming path): hit next_current_enter_time, calling gen_next_current()') - self.gen_next_current() - else: - # Gradually lower the current level, based on how close - # battery voltage has come to the bulk level. - if self.active_current >= 30: - upper_bound = cfg.gen_cur30_v_limit - elif self.active_current == 20: - upper_bound = cfg.gen_cur20_v_limit - else: - upper_bound = cfg.gen_cur10_v_limit - - # Voltage is high enough already and it's close to bulk level; we hit the upper bound, - # so let's lower the current - if v >= upper_bound: - self.gen_next_current() - - elif self.charging_state == ChargingState.AC_DONE: - # We've already finished charging, but AC was connected. Not that it's disconnected, - # set the appropriate state and notify users. - if not ac: - self.gen_stop(ChargingState.NOT_CHARGING) - - def gen_start(self, pd: BatteryPowerDirection): - if pd == BatteryPowerDirection.CHARGING: - self.charging_state = ChargingState.AC_OK - self.charging_event_handler(ChargingEvent.AC_CHARGING_STARTED) - logger.info('AC line connected and charging, entering AC_OK state') - - # Continue the stopwatch, if needed - try: - self.floating_stopwatch.go() - except StopwatchError: - msg = 'floating_stopwatch.go() failed at ac_charging_start(), AC_OK path' - logger.warning(msg) - self.error_handler(msg) - else: - self.charging_state = ChargingState.AC_WAITING - self.charging_event_handler(ChargingEvent.AC_NOT_CHARGING) - logger.info('AC line connected but not charging yet, entering AC_WAITING state') - - # Pause the stopwatch, if needed - try: - if not self.floating_stopwatch.is_paused(): - self.floating_stopwatch.pause() - except StopwatchError: - msg = 'floating_stopwatch.pause() failed at ac_charging_start(), AC_WAITING path' - logger.warning(msg) - self.error_handler(msg) - - # idx == -1 means haven't started our program yet. - if self.active_current_idx == -1: - self.gen_next_current() - # self.set_hw_charging_current(self.min_allowed_current) - - def gen_stop(self, reason: ChargingState): - self.charging_state = reason - - if reason == ChargingState.AC_DONE: - event = ChargingEvent.AC_CHARGING_FINISHED - elif reason == ChargingState.NOT_CHARGING: - event = ChargingEvent.AC_DISCONNECTED - else: - raise ValueError(f'ac_charging_stop: unexpected reason {reason}') - - logger.info(f'charging is finished, entering {reason} state') - self.charging_event_handler(event) - - self.next_current_enter_time = 0 - self.mostly_charged = False - self.active_current_idx = -1 - self.floating_stopwatch.reset() - self.current_change_direction = CurrentChangeDirection.UP - - self.set_hw_charging_current(self.min_allowed_current) - - def gen_next_current(self, current=None): - if current is None: - try: - current = self._next_current() - logger.debug(f'gen_next_current: ready to change charging current to {current} A') - except IndexError: - logger.debug('gen_next_current: was going to change charging current, but no currents left; finishing charging program') - self.gen_stop(ChargingState.AC_DONE) - return - - else: - try: - idx = self.currents.index(current) - except ValueError: - msg = f'gen_next_current: got current={current} but it\'s not in the currents list' - logger.error(msg) - self.error_handler(msg) - return - self.active_current_idx = idx - - if self.current_change_direction == CurrentChangeDirection.DOWN: - if current == self.currents[0]: - self.mostly_charged = True - self.gen_stop(ChargingState.AC_DONE) - - elif current == self.currents[1] and not self.mostly_charged: - self.mostly_charged = True - self.charging_event_handler(ChargingEvent.AC_MOSTLY_CHARGED) - - self.set_hw_charging_current(current) - - def set_hw_charging_current(self, current: int): - try: - response = inverter.exec('set-max-ac-charge-current', (0, current)) - if response['result'] != 'ok': - logger.error(f'failed to change AC charging current to {current} A') - raise InverterError('set-max-ac-charge-current: inverterd reported error') - else: - self.charging_event_handler(ChargingEvent.AC_CURRENT_CHANGED, current=current) - logger.info(f'changed AC charging current to {current} A') - except InverterError as e: - self.error_handler(f'failed to set charging current to {current} A (caught InverterError)') - logger.exception(e) - - def _next_current(self): - if self.current_change_direction == CurrentChangeDirection.UP: - self.active_current_idx += 1 - if self.active_current_idx == len(self.currents)-1: - logger.info('_next_current: charging current power direction to DOWN') - self.current_change_direction = CurrentChangeDirection.DOWN - self.next_current_enter_time = 0 - else: - if self.active_current_idx == 0: - raise IndexError('can\'t go lower') - self.active_current_idx -= 1 - - logger.info(f'_next_current: active_current_idx set to {self.active_current_idx}, returning current of {self.currents[self.active_current_idx]} A') - return self.currents[self.active_current_idx] - - def low_voltage_program(self, v: float, load_watts: int): - crit_level = cfg.vcrit - low_level = cfg.vlow - - if v <= crit_level: - state = BatteryState.CRITICAL - elif v <= low_level: - state = BatteryState.LOW - else: - state = BatteryState.NORMAL - - if state != self.battery_state: - self.battery_state = state - self.battery_event_handler(state, v, load_watts) - - def set_charging_event_handler(self, handler: Callable): - self.charging_event_handler = handler - - def set_battery_event_handler(self, handler: Callable): - self.battery_event_handler = handler - - def set_util_event_handler(self, handler: Callable): - self.util_event_handler = handler - - def set_error_handler(self, handler: Callable): - self.error_handler = handler - - def set_osp_need_change_callback(self, cb: Callable): - self.osp_change_cb = cb - - def set_ac_mode(self, mode: ACMode): - self.ac_mode = mode - - def notify_osp(self, osp: OutputSourcePriority): - self.osp = osp - - def stop(self): - self.interrupted = True - - def dump_status(self) -> dict: - return { - 'interrupted': self.interrupted, - 'currents': self.currents, - 'active_current': self.active_current, - 'current_change_direction': self.current_change_direction.name, - 'battery_state': self.battery_state.name, - 'charging_state': self.charging_state.name, - 'mostly_charged': self.mostly_charged, - 'floating_stopwatch_paused': self.floating_stopwatch.is_paused(), - 'floating_stopwatch_elapsed': self.floating_stopwatch.get_elapsed_time(), - 'time_now': time.time(), - 'next_current_enter_time': self.next_current_enter_time, - 'ac_mode': self.ac_mode, - 'osp': self.osp, - 'util_ac_present': self.util_ac_present, - 'util_pd': self.util_pd.name, - 'util_solar': self.util_solar - } diff --git a/src/home/inverter/types.py b/src/home/inverter/types.py deleted file mode 100644 index 57021f1..0000000 --- a/src/home/inverter/types.py +++ /dev/null @@ -1,64 +0,0 @@ -from enum import Enum, auto - - -class BatteryPowerDirection(Enum): - DISCHARGING = auto() - CHARGING = auto() - DO_NOTHING = auto() - - -class ChargingEvent(Enum): - AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR = auto() - AC_NOT_CHARGING = auto() - AC_CHARGING_STARTED = auto() - AC_DISCONNECTED = auto() - AC_CURRENT_CHANGED = auto() - AC_MOSTLY_CHARGED = auto() - AC_CHARGING_FINISHED = auto() - - UTIL_CHARGING_STARTED = auto() - UTIL_CHARGING_STOPPED = auto() - UTIL_CHARGING_STOPPED_SOLAR = auto() - - -class ACPresentEvent(Enum): - CONNECTED = auto() - DISCONNECTED = auto() - - -class ChargingState(Enum): - NOT_CHARGING = auto() - AC_BUT_SOLAR = auto() - AC_WAITING = auto() - AC_OK = auto() - AC_DONE = auto() - - -class CurrentChangeDirection(Enum): - UP = auto() - DOWN = auto() - - -class BatteryState(Enum): - NORMAL = auto() - LOW = auto() - CRITICAL = auto() - - -class ACMode(Enum): - GENERATOR = 'generator' - UTILITIES = 'utilities' - - -class OutputSourcePriority(Enum): - SolarUtilityBattery = 'SUB' - SolarBatteryUtility = 'SBU' - - @classmethod - def from_text(cls, s: str): - if s == 'Solar-Battery-Utility': - return cls.SolarBatteryUtility - elif s == 'Solar-Utility-Battery': - return cls.SolarUtilityBattery - else: - raise ValueError(f'unknown value: {s}')
\ No newline at end of file diff --git a/src/home/inverter/util.py b/src/home/inverter/util.py deleted file mode 100644 index a577e6a..0000000 --- a/src/home/inverter/util.py +++ /dev/null @@ -1,8 +0,0 @@ -import re - - -def beautify_table(s): - lines = s.split('\n') - lines = list(map(lambda line: re.sub(r'\s+', ' ', line), lines)) - lines = list(map(lambda line: re.sub(r'(.*?): (.*)', r'<b>\1:</b> \2', line), lines)) - return '\n'.join(lines) diff --git a/src/home/media/__init__.py b/src/home/media/__init__.py deleted file mode 100644 index 976c990..0000000 --- a/src/home/media/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import importlib -import itertools - -__map__ = { - 'types': ['MediaNodeType'], - 'record_client': ['SoundRecordClient', 'CameraRecordClient', 'RecordClient'], - 'node_server': ['MediaNodeServer'], - 'node_client': ['SoundNodeClient', 'CameraNodeClient', 'MediaNodeClient'], - 'storage': ['SoundRecordStorage', 'ESP32CameraRecordStorage', 'SoundRecordFile', 'CameraRecordFile', 'RecordFile'], - 'record': ['SoundRecorder', 'CameraRecorder'] -} - -__all__ = list(itertools.chain(*__map__.values())) - -def __getattr__(name): - if name in __all__: - for file, names in __map__.items(): - if name in names: - module = importlib.import_module(f'.{file}', __name__) - return getattr(module, name) - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/media/__init__.pyi b/src/home/media/__init__.pyi deleted file mode 100644 index 77c2176..0000000 --- a/src/home/media/__init__.pyi +++ /dev/null @@ -1,27 +0,0 @@ -from .types import ( - MediaNodeType as MediaNodeType -) -from .record_client import ( - SoundRecordClient as SoundRecordClient, - CameraRecordClient as CameraRecordClient, - RecordClient as RecordClient -) -from .node_server import ( - MediaNodeServer as MediaNodeServer -) -from .node_client import ( - SoundNodeClient as SoundNodeClient, - CameraNodeClient as CameraNodeClient, - MediaNodeClient as MediaNodeClient -) -from .storage import ( - SoundRecordStorage as SoundRecordStorage, - ESP32CameraRecordStorage as ESP32CameraRecordStorage, - SoundRecordFile as SoundRecordFile, - CameraRecordFile as CameraRecordFile, - RecordFile as RecordFile -) -from .record import ( - SoundRecorder as SoundRecorder, - CameraRecorder as CameraRecorder -)
\ No newline at end of file diff --git a/src/home/media/node_client.py b/src/home/media/node_client.py deleted file mode 100644 index eb39898..0000000 --- a/src/home/media/node_client.py +++ /dev/null @@ -1,119 +0,0 @@ -import requests -import shutil -import logging - -from typing import Optional, Union, List -from .storage import RecordFile -from ..util import Addr -from ..api.errors import ApiResponseError - - -class MediaNodeClient: - def __init__(self, addr: Addr): - self.endpoint = f'http://{addr[0]}:{addr[1]}' - self.logger = logging.getLogger(self.__class__.__name__) - - def record(self, duration: int): - return self._call('record/', params={"duration": duration}) - - def record_info(self, record_id: int): - return self._call(f'record/info/{record_id}/') - - def record_forget(self, record_id: int): - return self._call(f'record/forget/{record_id}/') - - 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]]: - r = self._call('storage/list/', params={'extended': int(extended)}) - files = r['files'] - if as_objects: - return self.record_list_from_serialized(files) - return files - - @staticmethod - def record_list_from_serialized(files: Union[List[str], List[dict]]): - new_files = [] - for f in files: - kwargs = {'remote': True} - if isinstance(f, dict): - name = f['filename'] - kwargs['remote_filesize'] = f['filesize'] - else: - name = f - item = RecordFile.create(name, **kwargs) - new_files.append(item) - return new_files - - def storage_delete(self, file_id: str): - return self._call('storage/delete/', params={'file_id': file_id}) - - def storage_download(self, file_id: str, output: str): - return self._call('storage/download/', params={'file_id': file_id}, save_to=output) - - def _call(self, - method: str, - params: dict = None, - save_to: Optional[str] = None): - kwargs = {} - if isinstance(params, dict): - kwargs['params'] = params - if save_to: - kwargs['stream'] = True - - url = f'{self.endpoint}/{method}' - self.logger.debug(f'calling {url}, kwargs: {kwargs}') - - r = requests.get(url, **kwargs) - if r.status_code != 200: - response = r.json() - raise ApiResponseError(status_code=r.status_code, - error_type=response['error'], - error_message=response['message'] or None, - error_stacktrace=response['stacktrace'] if 'stacktrace' in response else None) - - if save_to: - r.raise_for_status() - with open(save_to, 'wb') as f: - shutil.copyfileobj(r.raw, f) - return True - - return r.json()['response'] - - -class SoundNodeClient(MediaNodeClient): - def amixer_get_all(self): - return self._call('amixer/get-all/') - - def amixer_get(self, control: str): - return self._call(f'amixer/get/{control}/') - - def amixer_incr(self, control: str, step: Optional[int] = None): - params = {'step': step} if step is not None else None - return self._call(f'amixer/incr/{control}/', params=params) - - def amixer_decr(self, control: str, step: Optional[int] = None): - params = {'step': step} if step is not None else None - return self._call(f'amixer/decr/{control}/', params=params) - - def amixer_mute(self, control: str): - return self._call(f'amixer/mute/{control}/') - - def amixer_unmute(self, control: str): - return self._call(f'amixer/unmute/{control}/') - - def amixer_cap(self, control: str): - return self._call(f'amixer/cap/{control}/') - - def amixer_nocap(self, control: str): - return self._call(f'amixer/nocap/{control}/') - - -class CameraNodeClient(MediaNodeClient): - def capture(self, - save_to: str, - with_flash: bool = False): - return self._call('capture/', - {'with_flash': int(with_flash)}, - save_to=save_to) diff --git a/src/home/media/node_server.py b/src/home/media/node_server.py deleted file mode 100644 index 5d0803c..0000000 --- a/src/home/media/node_server.py +++ /dev/null @@ -1,86 +0,0 @@ -from .. import http -from .record import Recorder -from .types import RecordStatus -from .storage import RecordStorage - - -class MediaNodeServer(http.HTTPServer): - recorder: Recorder - storage: RecordStorage - - def __init__(self, - recorder: Recorder, - storage: RecordStorage, - *args, **kwargs): - super().__init__(*args, **kwargs) - - self.recorder = recorder - self.storage = storage - - self.get('/record/', self.do_record) - self.get('/record/info/{id}/', self.record_info) - self.get('/record/forget/{id}/', self.record_forget) - self.get('/record/download/{id}/', self.record_download) - - self.get('/storage/list/', self.storage_list) - self.get('/storage/delete/', self.storage_delete) - self.get('/storage/download/', self.storage_download) - - async def do_record(self, request: http.Request): - duration = int(request.query['duration']) - max = Recorder.get_max_record_time()*15 - if not 0 < duration <= max: - raise ValueError(f'invalid duration: max duration is {max}') - - record_id = self.recorder.record(duration) - return http.ok({'id': record_id}) - - async def record_info(self, request: http.Request): - record_id = int(request.match_info['id']) - info = self.recorder.get_info(record_id) - return http.ok(info.as_dict()) - - async def record_forget(self, request: http.Request): - record_id = int(request.match_info['id']) - - info = self.recorder.get_info(record_id) - assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}" - - self.recorder.forget(record_id) - return http.ok() - - async def record_download(self, request: http.Request): - record_id = int(request.match_info['id']) - - info = self.recorder.get_info(record_id) - assert info.status == RecordStatus.FINISHED, f"record status is {info.status}" - - return http.FileResponse(info.file.path) - - async def storage_list(self, request: http.Request): - extended = 'extended' in request.query and int(request.query['extended']) == 1 - - files = self.storage.getfiles(as_objects=extended) - if extended: - files = list(map(lambda file: file.__dict__(), files)) - - return http.ok({ - 'files': files - }) - - async def storage_delete(self, request: http.Request): - file_id = request.query['file_id'] - file = self.storage.find(file_id) - if not file: - raise ValueError(f'file {file} not found') - - self.storage.delete(file) - return http.ok() - - async def storage_download(self, request): - file_id = request.query['file_id'] - file = self.storage.find(file_id) - if not file: - raise ValueError(f'file {file} not found') - - return http.FileResponse(file.path) diff --git a/src/home/media/record.py b/src/home/media/record.py deleted file mode 100644 index cd7447a..0000000 --- a/src/home/media/record.py +++ /dev/null @@ -1,461 +0,0 @@ -import os -import threading -import logging -import time -import subprocess -import signal - -from typing import Optional, List, Dict -from ..util import find_child_processes, Addr -from ..config import config -from .storage import RecordFile, RecordStorage -from .types import RecordStatus -from ..camera.types import CameraType - - -_history_item_timeout = 7200 -_history_cleanup_freq = 3600 - - -class RecordHistoryItem: - id: int - request_time: float - start_time: float - stop_time: float - relations: List[int] - status: RecordStatus - error: Optional[Exception] - file: Optional[RecordFile] - creation_time: float - - def __init__(self, id): - self.id = id - self.request_time = 0 - self.start_time = 0 - self.stop_time = 0 - self.relations = [] - self.status = RecordStatus.WAITING - self.file = None - self.error = None - self.creation_time = time.time() - - def add_relation(self, related_id: int): - self.relations.append(related_id) - - def mark_started(self, start_time: float): - self.start_time = start_time - self.status = RecordStatus.RECORDING - - def mark_finished(self, end_time: float, file: RecordFile): - self.stop_time = end_time - self.file = file - self.status = RecordStatus.FINISHED - - def mark_failed(self, error: Exception): - self.status = RecordStatus.ERROR - self.error = error - - def as_dict(self) -> dict: - data = { - 'id': self.id, - 'request_time': self.request_time, - 'status': self.status.value, - 'relations': self.relations, - 'start_time': self.start_time, - 'stop_time': self.stop_time, - } - if self.error: - data['error'] = str(self.error) - if self.file: - data['file'] = self.file.__dict__() - return data - - -class RecordingNotFoundError(Exception): - pass - - -class RecordHistory: - history: Dict[int, RecordHistoryItem] - - def __init__(self): - self.history = {} - self.logger = logging.getLogger(self.__class__.__name__) - - def add(self, record_id: int): - self.logger.debug(f'add: record_id={record_id}') - - r = RecordHistoryItem(record_id) - r.request_time = time.time() - - self.history[record_id] = r - - def delete(self, record_id: int): - self.logger.debug(f'delete: record_id={record_id}') - del self.history[record_id] - - def cleanup(self): - del_ids = [] - for rid, item in self.history.items(): - if item.creation_time < time.time()-_history_item_timeout: - del_ids.append(rid) - for rid in del_ids: - self.delete(rid) - - def __getitem__(self, key): - if key not in self.history: - raise RecordingNotFoundError() - - return self.history[key] - - def __setitem__(self, key, value): - raise NotImplementedError('setting history item this way is prohibited') - - def __contains__(self, key): - return key in self.history - - -class Recording: - RECORDER_PROGRAM = None - - start_time: float - stop_time: float - duration: int - record_id: int - recorder_program_pid: Optional[int] - process: Optional[subprocess.Popen] - - g_record_id = 1 - - def __init__(self): - if self.RECORDER_PROGRAM is None: - raise RuntimeError('this is abstract class') - - self.start_time = 0 - self.stop_time = 0 - self.duration = 0 - self.process = None - self.recorder_program_pid = None - self.record_id = Recording.next_id() - self.logger = logging.getLogger(self.__class__.__name__) - - def is_started(self) -> bool: - return self.start_time > 0 and self.stop_time > 0 - - def is_waiting(self): - return self.duration > 0 - - def ask_for(self, duration) -> int: - overtime = 0 - orig_duration = duration - - if self.is_started(): - already_passed = time.time() - self.start_time - max_duration = Recorder.get_max_record_time() - already_passed - self.logger.debug(f'ask_for({orig_duration}): recording is in progress, already passed {already_passed}s, max_duration set to {max_duration}') - else: - max_duration = Recorder.get_max_record_time() - - if duration > max_duration: - overtime = duration - max_duration - duration = max_duration - - self.logger.debug(f'ask_for({orig_duration}): requested duration ({orig_duration}) is greater than max ({max_duration}), overtime is {overtime}') - - self.duration += duration - if self.is_started(): - til_end = self.stop_time - time.time() - if til_end < 0: - til_end = 0 - - _prev_stop_time = self.stop_time - _to_add = duration - til_end - if _to_add < 0: - _to_add = 0 - - self.stop_time += _to_add - self.logger.debug(f'ask_for({orig_duration}): adding {_to_add} to stop_time (before: {_prev_stop_time}, after: {self.stop_time})') - - return overtime - - def start(self, output: str): - assert self.start_time == 0 and self.stop_time == 0, "already started?!" - assert self.process is None, "self.process is not None, what the hell?" - - cur = time.time() - self.start_time = cur - self.stop_time = cur + self.duration - - cmd = self.get_command(output) - self.logger.debug(f'start: running `{cmd}`') - self.process = subprocess.Popen(cmd, shell=True, stdin=None, stdout=None, stderr=None, close_fds=True) - - sh_pid = self.process.pid - self.logger.debug(f'start: started, pid of shell is {sh_pid}') - - pid = self.find_recorder_program_pid(sh_pid) - if pid is not None: - self.recorder_program_pid = pid - self.logger.debug(f'start: pid of {self.RECORDER_PROGRAM} is {pid}') - - def get_command(self, output: str) -> str: - pass - - def stop(self): - if self.process: - if self.recorder_program_pid is None: - self.recorder_program_pid = self.find_recorder_program_pid(self.process.pid) - - if self.recorder_program_pid is not None: - os.kill(self.recorder_program_pid, signal.SIGINT) - timeout = config['node']['process_wait_timeout'] - - self.logger.debug(f'stop: sent SIGINT to {self.recorder_program_pid}. now waiting up to {timeout} seconds...') - try: - self.process.wait(timeout=timeout) - except subprocess.TimeoutExpired: - self.logger.warning(f'stop: wait({timeout}): timeout expired, killing it') - try: - os.kill(self.recorder_program_pid, signal.SIGKILL) - self.process.terminate() - except Exception as exc: - self.logger.exception(exc) - else: - self.logger.warning(f'stop: pid of {self.RECORDER_PROGRAM} is unknown, calling terminate()') - self.process.terminate() - - rc = self.process.returncode - self.logger.debug(f'stop: rc={rc}') - - self.process = None - self.recorder_program_pid = 0 - - self.duration = 0 - self.start_time = 0 - self.stop_time = 0 - - def find_recorder_program_pid(self, sh_pid: int): - try: - children = find_child_processes(sh_pid) - except OSError as exc: - self.logger.warning(f'failed to find child process of {sh_pid}: ' + str(exc)) - return None - - for child in children: - if self.RECORDER_PROGRAM in child.cmd: - return child.pid - - return None - - @staticmethod - def next_id() -> int: - cur_id = Recording.g_record_id - Recording.g_record_id += 1 - return cur_id - - def increment_id(self): - self.record_id = Recording.next_id() - - -class Recorder: - TEMP_NAME = None - - interrupted: bool - lock: threading.Lock - history_lock: threading.Lock - recording: Optional[Recording] - overtime: int - history: RecordHistory - next_history_cleanup_time: float - storage: RecordStorage - - def __init__(self, - storage: RecordStorage, - recording: Recording): - if self.TEMP_NAME is None: - raise RuntimeError('this is abstract class') - - self.storage = storage - self.recording = recording - self.interrupted = False - self.lock = threading.Lock() - self.history_lock = threading.Lock() - self.overtime = 0 - self.history = RecordHistory() - self.next_history_cleanup_time = 0 - self.logger = logging.getLogger(self.__class__.__name__) - - def start_thread(self): - t = threading.Thread(target=self.loop) - t.daemon = True - t.start() - - def loop(self) -> None: - tempname = os.path.join(self.storage.root, self.TEMP_NAME) - - while not self.interrupted: - cur = time.time() - stopped = False - cur_record_id = None - - if self.next_history_cleanup_time == 0: - self.next_history_cleanup_time = time.time() + _history_cleanup_freq - elif self.next_history_cleanup_time <= time.time(): - self.logger.debug('loop: calling history.cleanup()') - try: - self.history.cleanup() - except Exception as e: - self.logger.error('loop: error while history.cleanup(): ' + str(e)) - self.next_history_cleanup_time = time.time() + _history_cleanup_freq - - with self.lock: - cur_record_id = self.recording.record_id - # self.logger.debug(f'cur_record_id={cur_record_id}') - - if not self.recording.is_started(): - if self.recording.is_waiting(): - try: - if os.path.exists(tempname): - self.logger.warning(f'loop: going to start new recording, but {tempname} still exists, unlinking..') - try: - os.unlink(tempname) - except OSError as e: - self.logger.exception(e) - self.recording.start(tempname) - with self.history_lock: - self.history[cur_record_id].mark_started(self.recording.start_time) - except Exception as exc: - self.logger.exception(exc) - - # there should not be any errors, but still.. - try: - self.recording.stop() - except Exception as exc: - self.logger.exception(exc) - - with self.history_lock: - self.history[cur_record_id].mark_failed(exc) - - self.logger.debug(f'loop: start exc path: calling increment_id()') - self.recording.increment_id() - else: - if cur >= self.recording.stop_time: - try: - start_time = self.recording.start_time - stop_time = self.recording.stop_time - self.recording.stop() - - saved_name = self.storage.save(tempname, - record_id=cur_record_id, - start_time=int(start_time), - stop_time=int(stop_time)) - - with self.history_lock: - self.history[cur_record_id].mark_finished(stop_time, saved_name) - except Exception as exc: - self.logger.exception(exc) - with self.history_lock: - self.history[cur_record_id].mark_failed(exc) - finally: - self.logger.debug(f'loop: stop exc final path: calling increment_id()') - self.recording.increment_id() - - stopped = True - - if stopped and self.overtime > 0: - self.logger.info(f'recording {cur_record_id} is stopped, but we\'ve got overtime ({self.overtime})') - _overtime = self.overtime - self.overtime = 0 - - related_id = self.record(_overtime) - self.logger.info(f'enqueued another record with id {related_id}') - - if cur_record_id is not None: - with self.history_lock: - self.history[cur_record_id].add_relation(related_id) - - time.sleep(0.2) - - def record(self, duration: int) -> int: - self.logger.debug(f'record: duration={duration}') - with self.lock: - overtime = self.recording.ask_for(duration) - self.logger.debug(f'overtime={overtime}') - - if overtime > self.overtime: - self.overtime = overtime - - if not self.recording.is_started(): - with self.history_lock: - self.history.add(self.recording.record_id) - - return self.recording.record_id - - def stop(self): - self.interrupted = True - - def get_info(self, record_id: int) -> RecordHistoryItem: - with self.history_lock: - return self.history[record_id] - - def forget(self, record_id: int): - with self.history_lock: - self.logger.info(f'forget: removing record {record_id} from history') - self.history.delete(record_id) - - @staticmethod - def get_max_record_time() -> int: - return config['node']['record_max_time'] - - -class SoundRecorder(Recorder): - TEMP_NAME = 'temp.mp3' - - def __init__(self, *args, **kwargs): - super().__init__(recording=SoundRecording(), - *args, **kwargs) - - -class CameraRecorder(Recorder): - TEMP_NAME = 'temp.mp4' - - def __init__(self, - camera_type: CameraType, - *args, **kwargs): - if camera_type == CameraType.ESP32: - recording = ESP32CameraRecording(stream_addr=kwargs['stream_addr']) - del kwargs['stream_addr'] - else: - raise RuntimeError(f'unsupported camera type {camera_type}') - - super().__init__(recording=recording, - *args, **kwargs) - - -class SoundRecording(Recording): - RECORDER_PROGRAM = 'arecord' - - def get_command(self, output: str) -> str: - arecord = config['arecord']['bin'] - lame = config['lame']['bin'] - b = config['lame']['bitrate'] - - return f'{arecord} -f S16 -r 44100 -t raw 2>/dev/null | {lame} -r -s 44.1 -b {b} -m m - {output} >/dev/null 2>/dev/null' - - -class ESP32CameraRecording(Recording): - RECORDER_PROGRAM = 'esp32_capture.py' - - stream_addr: Addr - - def __init__(self, stream_addr: Addr): - super().__init__() - self.stream_addr = stream_addr - - def get_command(self, output: str) -> str: - bin = config['esp32_capture']['bin'] - return f'{bin} --addr {self.stream_addr[0]}:{self.stream_addr[1]} --output-directory {output} >/dev/null 2>/dev/null' - - def start(self, output: str): - output = os.path.dirname(output) - return super().start(output)
\ No newline at end of file diff --git a/src/home/media/record_client.py b/src/home/media/record_client.py deleted file mode 100644 index 322495c..0000000 --- a/src/home/media/record_client.py +++ /dev/null @@ -1,166 +0,0 @@ -import time -import logging -import threading -import os.path - -from tempfile import gettempdir -from .record import RecordStatus -from .node_client import SoundNodeClient, MediaNodeClient, CameraNodeClient -from ..util import Addr -from typing import Optional, Callable, Dict - - -class RecordClient: - DOWNLOAD_EXTENSION = None - - interrupted: bool - logger: logging.Logger - 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], - error_handler: Optional[Callable] = None, - finished_handler: Optional[Callable] = None, - download_on_finish=False): - if self.DOWNLOAD_EXTENSION is None: - raise RuntimeError('this is abstract class') - - self.interrupted = False - self.logger = logging.getLogger(self.__class__.__name__) - self.clients = {} - self.awaiting = {} - - self.download_on_finish = download_on_finish - self.error_handler = error_handler - self.finished_handler = finished_handler - - self.awaiting_lock = threading.Lock() - - self.make_clients(nodes) - - try: - t = threading.Thread(target=self.loop) - t.daemon = True - t.start() - except (KeyboardInterrupt, SystemExit) as exc: - self.stop() - self.logger.exception(exc) - - def make_clients(self, nodes: Dict[str, Addr]): - pass - - def stop(self): - self.interrupted = True - - def loop(self): - while not self.interrupted: - for node in self.awaiting.keys(): - with self.awaiting_lock: - record_ids = list(self.awaiting[node].keys()) - if not record_ids: - continue - - self.logger.debug(f'loop: node `{node}` awaiting list: {record_ids}') - - cl = self.getclient(node) - del_ids = [] - for rid in record_ids: - info = cl.record_info(rid) - - if info['relations']: - for relid in info['relations']: - self.wait_for_record(node, relid, self.awaiting[node][rid], is_relative=True) - - status = RecordStatus(info['status']) - if status in (RecordStatus.FINISHED, RecordStatus.ERROR): - if status == RecordStatus.FINISHED: - if self.download_on_finish: - local_fn = self.download(node, rid, info['file']['fileid']) - else: - local_fn = None - self._report_finished(info, local_fn, self.awaiting[node][rid]) - else: - self._report_error(info, self.awaiting[node][rid]) - del_ids.append(rid) - self.logger.debug(f'record {rid}: status {status}') - - if del_ids: - self.logger.debug(f'deleting {del_ids} from {node}\'s awaiting list') - with self.awaiting_lock: - for del_id in del_ids: - del self.awaiting[node][del_id] - - time.sleep(5) - - self.logger.info('loop ended') - - def getclient(self, node: str): - return self.clients[node] - - def record(self, - node: str, - duration: int, - userdata: Optional[dict] = None) -> int: - self.logger.debug(f'record: node={node}, duration={duration}, userdata={userdata}') - - cl = self.getclient(node) - record_id = cl.record(duration)['id'] - self.logger.debug(f'record: request sent, record_id={record_id}') - - self.wait_for_record(node, record_id, userdata) - return record_id - - def wait_for_record(self, - node: str, - record_id: int, - userdata: Optional[dict] = None, - is_relative=False): - with self.awaiting_lock: - if record_id not in self.awaiting[node]: - msg = f'wait_for_record: adding {record_id} to {node}' - if is_relative: - msg += ' (by relation)' - self.logger.debug(msg) - - self.awaiting[node][record_id] = userdata - - def download(self, node: str, record_id: int, fileid: str): - dst = os.path.join(gettempdir(), f'{node}_{fileid}.{self.DOWNLOAD_EXTENSION}') - cl = self.getclient(node) - cl.record_download(record_id, dst) - return dst - - def forget(self, node: str, rid: int): - self.getclient(node).record_forget(rid) - - def _report_finished(self, *args): - if self.finished_handler: - self.finished_handler(*args) - - def _report_error(self, *args): - if self.error_handler: - self.error_handler(*args) - - -class SoundRecordClient(RecordClient): - DOWNLOAD_EXTENSION = 'mp3' - # clients: Dict[str, SoundNodeClient] - - def make_clients(self, nodes: Dict[str, Addr]): - for node, addr in nodes.items(): - self.clients[node] = SoundNodeClient(addr) - self.awaiting[node] = {} - - -class CameraRecordClient(RecordClient): - DOWNLOAD_EXTENSION = 'mp4' - # clients: Dict[str, CameraNodeClient] - - def make_clients(self, nodes: Dict[str, Addr]): - for node, addr in nodes.items(): - self.clients[node] = CameraNodeClient(addr) - self.awaiting[node] = {}
\ No newline at end of file diff --git a/src/home/media/storage.py b/src/home/media/storage.py deleted file mode 100644 index dd74ff8..0000000 --- a/src/home/media/storage.py +++ /dev/null @@ -1,210 +0,0 @@ -import os -import re -import shutil -import logging - -from typing import Optional, Union, List -from datetime import datetime -from ..util import strgen - -logger = logging.getLogger(__name__) - - -# record file -# ----------- - -class RecordFile: - EXTENSION = None - - start_time: Optional[datetime] - stop_time: Optional[datetime] - record_id: Optional[int] - name: str - file_id: Optional[str] - remote: bool - remote_filesize: int - storage_root: str - - human_date_dmt = '%d.%m.%y' - human_time_fmt = '%H:%M:%S' - - @staticmethod - def create(filename: str, *args, **kwargs): - if filename.endswith(f'.{SoundRecordFile.EXTENSION}'): - return SoundRecordFile(filename, *args, **kwargs) - elif filename.endswith(f'.{CameraRecordFile.EXTENSION}'): - return CameraRecordFile(filename, *args, **kwargs) - else: - raise RuntimeError(f'unsupported file extension: {filename}') - - def __init__(self, filename: str, remote=False, remote_filesize=None, storage_root='/'): - if self.EXTENSION is None: - raise RuntimeError('this is abstract class') - - self.name = filename - self.storage_root = storage_root - - self.remote = remote - self.remote_filesize = remote_filesize - - m = re.match(r'^(\d{6}-\d{6})_(\d{6}-\d{6})_id(\d+)(_\w+)?\.'+self.EXTENSION+'$', filename) - if m: - self.start_time = datetime.strptime(m.group(1), RecordStorage.time_fmt) - self.stop_time = datetime.strptime(m.group(2), RecordStorage.time_fmt) - self.record_id = int(m.group(3)) - self.file_id = (m.group(1) + '_' + m.group(2)).replace('-', '_') - else: - logger.warning(f'unexpected filename: {filename}') - self.start_time = None - self.stop_time = None - self.record_id = None - self.file_id = None - - @property - def path(self): - if self.remote: - return RuntimeError('remote recording, can\'t get real path') - - return os.path.realpath(os.path.join( - self.storage_root, self.name - )) - - @property - def start_humantime(self) -> str: - if self.start_time is None: - return '?' - fmt = f'{RecordFile.human_date_dmt} {RecordFile.human_time_fmt}' - return self.start_time.strftime(fmt) - - @property - def stop_humantime(self) -> str: - if self.stop_time is None: - return '?' - fmt = RecordFile.human_time_fmt - if self.start_time.date() != self.stop_time.date(): - fmt = f'{RecordFile.human_date_dmt} {fmt}' - return self.stop_time.strftime(fmt) - - @property - def start_unixtime(self) -> int: - if self.start_time is None: - return 0 - return int(self.start_time.timestamp()) - - @property - def stop_unixtime(self) -> int: - if self.stop_time is None: - return 0 - return int(self.stop_time.timestamp()) - - @property - def filesize(self): - if self.remote: - if self.remote_filesize is None: - raise RuntimeError('file is remote and remote_filesize is not set') - return self.remote_filesize - return os.path.getsize(self.path) - - def __dict__(self) -> dict: - return { - 'start_unixtime': self.start_unixtime, - 'stop_unixtime': self.stop_unixtime, - 'filename': self.name, - 'filesize': self.filesize, - 'fileid': self.file_id, - 'record_id': self.record_id or 0, - } - - -class PseudoRecordFile(RecordFile): - EXTENSION = 'null' - - def __init__(self): - super().__init__('pseudo.null') - - @property - def filesize(self): - return 0 - - -class SoundRecordFile(RecordFile): - EXTENSION = 'mp3' - - -class CameraRecordFile(RecordFile): - EXTENSION = 'mp4' - - -# record storage -# -------------- - -class RecordStorage: - EXTENSION = None - - time_fmt = '%d%m%y-%H%M%S' - - def __init__(self, root: str): - if self.EXTENSION is None: - raise RuntimeError('this is abstract class') - - self.root = root - - 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) - if os.path.isfile(path) and name.endswith(f'.{self.EXTENSION}'): - files.append(name if not as_objects else RecordFile.create(name, storage_root=self.root)) - return files - - def find(self, file_id: str) -> Optional[RecordFile]: - for name in os.listdir(self.root): - if os.path.isfile(os.path.join(self.root, name)) and name.endswith(f'.{self.EXTENSION}'): - item = RecordFile.create(name, storage_root=self.root) - if item.file_id == file_id: - return item - return None - - def purge(self): - files = self.getfiles() - if files: - logger = logging.getLogger(self.__name__) - for f in files: - try: - path = os.path.join(self.root, f) - logger.debug(f'purge: deleting {path}') - os.unlink(path) - except OSError as exc: - logger.exception(exc) - - def delete(self, file: RecordFile): - os.unlink(file.path) - - def save(self, - fn: str, - record_id: int, - start_time: int, - stop_time: int) -> RecordFile: - - start_time_s = datetime.fromtimestamp(start_time).strftime(self.time_fmt) - stop_time_s = datetime.fromtimestamp(stop_time).strftime(self.time_fmt) - - dst_fn = f'{start_time_s}_{stop_time_s}_id{record_id}' - if os.path.exists(os.path.join(self.root, dst_fn)): - dst_fn += strgen(4) - dst_fn += f'.{self.EXTENSION}' - dst_path = os.path.join(self.root, dst_fn) - - shutil.move(fn, dst_path) - return RecordFile.create(dst_fn, storage_root=self.root) - - -class SoundRecordStorage(RecordStorage): - EXTENSION = 'mp3' - - -class ESP32CameraRecordStorage(RecordStorage): - EXTENSION = 'jpg' # not used anyway - - def save(self, *args, **kwargs): - return PseudoRecordFile()
\ No newline at end of file diff --git a/src/home/media/types.py b/src/home/media/types.py deleted file mode 100644 index acbc291..0000000 --- a/src/home/media/types.py +++ /dev/null @@ -1,13 +0,0 @@ -from enum import Enum, auto - - -class MediaNodeType(Enum): - SOUND = auto() - CAMERA = auto() - - -class RecordStatus(Enum): - WAITING = auto() - RECORDING = auto() - FINISHED = auto() - ERROR = auto() diff --git a/src/home/mqtt/__init__.py b/src/home/mqtt/__init__.py deleted file mode 100644 index 982e2b6..0000000 --- a/src/home/mqtt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .mqtt import MqttBase -from .util import poll_tick -from .relay import MqttRelay, MqttRelayState -from .temphum import MqttTempHum
\ No newline at end of file diff --git a/src/home/mqtt/esp.py b/src/home/mqtt/esp.py deleted file mode 100644 index 56ced83..0000000 --- a/src/home/mqtt/esp.py +++ /dev/null @@ -1,106 +0,0 @@ -import re -import paho.mqtt.client as mqtt - -from .mqtt import MqttBase -from typing import Optional, Union -from .payload.esp import ( - OTAPayload, - OTAResultPayload, - DiagnosticsPayload, - InitialDiagnosticsPayload -) - - -class MqttEspDevice: - id: str - secret: Optional[str] - - def __init__(self, id: str, secret: Optional[str] = None): - self.id = id - self.secret = secret - - -class MqttEspBase(MqttBase): - _devices: list[MqttEspDevice] - _message_callback: Optional[callable] - _ota_publish_callback: Optional[callable] - - TOPIC_LEAF = 'esp' - - def __init__(self, - devices: Union[MqttEspDevice, list[MqttEspDevice]], - subscribe_to_updates=True): - super().__init__(clean_session=True) - if not isinstance(devices, list): - devices = [devices] - self._devices = devices - self._message_callback = None - self._ota_publish_callback = None - self._subscribe_to_updates = subscribe_to_updates - self._ota_mid = None - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - - if self._subscribe_to_updates: - for device in self._devices: - topic = f'hk/{device.id}/{self.TOPIC_LEAF}/#' - self._logger.debug(f"subscribing to {topic}") - client.subscribe(topic, qos=1) - - def on_publish(self, client: mqtt.Client, userdata, mid): - if self._ota_mid is not None and mid == self._ota_mid and self._ota_publish_callback: - self._ota_publish_callback() - - def set_message_callback(self, callback: callable): - self._message_callback = callback - - def on_message(self, client: mqtt.Client, userdata, msg): - try: - match = re.match(self.get_mqtt_topics(), msg.topic) - self._logger.debug(f'topic: {msg.topic}') - if not match: - return - - device_id = match.group(1) - subtopic = match.group(2) - - # try: - next(d for d in self._devices if d.id == device_id) - # except StopIteration:h - # return - - message = None - if subtopic == 'stat': - message = DiagnosticsPayload.unpack(msg.payload) - elif subtopic == 'stat1': - message = InitialDiagnosticsPayload.unpack(msg.payload) - elif subtopic == 'otares': - message = OTAResultPayload.unpack(msg.payload) - - if message and self._message_callback: - self._message_callback(device_id, message) - return True - - except Exception as e: - self._logger.exception(str(e)) - - def push_ota(self, - device_id, - filename: str, - publish_callback: callable, - qos: int): - device = next(d for d in self._devices if d.id == device_id) - assert device.secret is not None, 'device secret not specified' - - self._ota_publish_callback = publish_callback - payload = OTAPayload(secret=device.secret, filename=filename) - publish_result = self._client.publish(f'hk/{device.id}/{self.TOPIC_LEAF}/admin/ota', - payload=payload.pack(), - qos=qos) - self._ota_mid = publish_result.mid - self._client.loop_write() - - @classmethod - def get_mqtt_topics(cls, additional_topics: Optional[list[str]] = None): - return rf'^hk/(.*?)/{cls.TOPIC_LEAF}/(stat|stat1|otares'+('|'+('|'.join(additional_topics)) if additional_topics else '')+')$'
\ No newline at end of file diff --git a/src/home/mqtt/mqtt.py b/src/home/mqtt/mqtt.py deleted file mode 100644 index 4acd4f6..0000000 --- a/src/home/mqtt/mqtt.py +++ /dev/null @@ -1,76 +0,0 @@ -import os.path -import paho.mqtt.client as mqtt -import ssl -import logging - -from typing import Tuple -from ..config import config - - -def username_and_password() -> Tuple[str, str]: - username = config['mqtt']['username'] if 'username' in config['mqtt'] else None - password = config['mqtt']['password'] if 'password' in config['mqtt'] else None - return username, password - - -class MqttBase: - def __init__(self, clean_session=True): - self._client = mqtt.Client(client_id=config['mqtt']['client_id'], - protocol=mqtt.MQTTv311, - clean_session=clean_session) - self._client.on_connect = self.on_connect - self._client.on_disconnect = self.on_disconnect - self._client.on_message = self.on_message - self._client.on_log = self.on_log - self._client.on_publish = self.on_publish - self._loop_started = False - - self._logger = logging.getLogger(self.__class__.__name__) - - username, password = username_and_password() - if username and password: - self._logger.debug(f'username={username} password={password}') - self._client.username_pw_set(username, password) - - def configure_tls(self): - ca_certs = os.path.realpath(os.path.join( - os.path.dirname(os.path.realpath(__file__)), - '..', - '..', - '..', - 'assets', - 'mqtt_ca.crt' - )) - self._client.tls_set(ca_certs=ca_certs, cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLSv1_2) - - def connect_and_loop(self, loop_forever=True): - host = config['mqtt']['host'] - port = config['mqtt']['port'] - - self._client.connect(host, port, 60) - if loop_forever: - self._client.loop_forever() - else: - self._client.loop_start() - self._loop_started = True - - def disconnect(self): - self._client.disconnect() - self._client.loop_write() - self._client.loop_stop() - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - self._logger.info("Connected with result code " + str(rc)) - - def on_disconnect(self, client: mqtt.Client, userdata, rc): - self._logger.info("Disconnected with result code " + str(rc)) - - def on_log(self, client: mqtt.Client, userdata, level, buf): - level = mqtt.LOGGING_LEVEL[level] if level in mqtt.LOGGING_LEVEL else logging.INFO - self._logger.log(level, f'MQTT: {buf}') - - def on_message(self, client: mqtt.Client, userdata, msg): - self._logger.debug(msg.topic + ": " + str(msg.payload)) - - def on_publish(self, client: mqtt.Client, userdata, mid): - self._logger.debug(f'publish done, mid={mid}')
\ No newline at end of file diff --git a/src/home/mqtt/payload/__init__.py b/src/home/mqtt/payload/__init__.py deleted file mode 100644 index eee6709..0000000 --- a/src/home/mqtt/payload/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .base_payload import MqttPayload
\ No newline at end of file diff --git a/src/home/mqtt/payload/base_payload.py b/src/home/mqtt/payload/base_payload.py deleted file mode 100644 index 1abd898..0000000 --- a/src/home/mqtt/payload/base_payload.py +++ /dev/null @@ -1,145 +0,0 @@ -import abc -import struct -import re - -from typing import Optional, Tuple - - -def pldstr(self) -> str: - attrs = [] - for field in self.__class__.__annotations__: - if hasattr(self, field): - attr = getattr(self, field) - attrs.append(f'{field}={attr}') - if attrs: - attrs_s = ' ' - attrs_s += ', '.join(attrs) - else: - attrs_s = '' - return f'<%s{attrs_s}>' % (self.__class__.__name__,) - - -class MqttPayload(abc.ABC): - FORMAT = '' - PACKER = {} - UNPACKER = {} - - def __init__(self, **kwargs): - for field in self.__class__.__annotations__: - setattr(self, field, kwargs[field]) - - def pack(self): - args = [] - bf_number = -1 - bf_arg = 0 - bf_progress = 0 - - for field, field_type in self.__class__.__annotations__.items(): - bfp = _bit_field_params(field_type) - if bfp: - n, s, b = bfp - if n != bf_number: - if bf_number != -1: - args.append(bf_arg) - bf_number = n - bf_progress = 0 - bf_arg = 0 - bf_arg |= (getattr(self, field) & (2 ** b - 1)) << bf_progress - bf_progress += b - - else: - if bf_number != -1: - args.append(bf_arg) - bf_number = -1 - bf_progress = 0 - bf_arg = 0 - - args.append(self._pack_field(field)) - - if bf_number != -1: - args.append(bf_arg) - - return struct.pack(self.FORMAT, *args) - - @classmethod - def unpack(cls, buf: bytes): - data = struct.unpack(cls.FORMAT, buf) - kwargs = {} - i = 0 - bf_number = -1 - bf_progress = 0 - - for field, field_type in cls.__annotations__.items(): - bfp = _bit_field_params(field_type) - if bfp: - n, s, b = bfp - if n != bf_number: - bf_number = n - bf_progress = 0 - kwargs[field] = (data[i] >> bf_progress) & (2 ** b - 1) - bf_progress += b - continue # don't increment i - - if bf_number != -1: - bf_number = -1 - i += 1 - - if issubclass(field_type, MqttPayloadCustomField): - kwargs[field] = field_type.unpack(data[i]) - else: - kwargs[field] = cls._unpack_field(field, data[i]) - i += 1 - - return cls(**kwargs) - - def _pack_field(self, name): - val = getattr(self, name) - if self.PACKER and name in self.PACKER: - return self.PACKER[name](val) - else: - return val - - @classmethod - def _unpack_field(cls, name, val): - if isinstance(val, MqttPayloadCustomField): - return - if cls.UNPACKER and name in cls.UNPACKER: - return cls.UNPACKER[name](val) - else: - return val - - def __str__(self): - return pldstr(self) - - -class MqttPayloadCustomField(abc.ABC): - def __init__(self, **kwargs): - for field in self.__class__.__annotations__: - setattr(self, field, kwargs[field]) - - @abc.abstractmethod - def __index__(self): - pass - - @classmethod - @abc.abstractmethod - def unpack(cls, *args, **kwargs): - pass - - def __str__(self): - return pldstr(self) - - -def bit_field(seq_no: int, total_bits: int, bits: int): - return type(f'MQTTPayloadBitField_{seq_no}_{total_bits}_{bits}', (object,), { - 'seq_no': seq_no, - 'total_bits': total_bits, - 'bits': bits - }) - - -def _bit_field_params(cl) -> Optional[Tuple[int, ...]]: - match = re.match(r'MQTTPayloadBitField_(\d+)_(\d+)_(\d)$', cl.__name__) - if match is not None: - return tuple([int(match.group(i)) for i in range(1, 4)]) - return None diff --git a/src/home/mqtt/payload/esp.py b/src/home/mqtt/payload/esp.py deleted file mode 100644 index 171cdb9..0000000 --- a/src/home/mqtt/payload/esp.py +++ /dev/null @@ -1,78 +0,0 @@ -import hashlib - -from .base_payload import MqttPayload, MqttPayloadCustomField - - -class OTAResultPayload(MqttPayload): - FORMAT = '=BB' - result: int - error_code: int - - -class OTAPayload(MqttPayload): - secret: str - filename: str - - # structure of returned data: - # - # uint8_t[len(secret)] secret; - # uint8_t[16] md5; - # *uint8_t data - - def pack(self): - buf = bytearray(self.secret.encode()) - m = hashlib.md5() - with open(self.filename, 'rb') as fd: - content = fd.read() - m.update(content) - buf.extend(m.digest()) - buf.extend(content) - return buf - - def unpack(cls, buf: bytes): - raise RuntimeError(f'{cls.__class__.__name__}.unpack: not implemented') - # secret = buf[:12].decode() - # filename = buf[12:].decode() - # return OTAPayload(secret=secret, filename=filename) - - -class DiagnosticsFlags(MqttPayloadCustomField): - state: bool - config_changed_value_present: bool - config_changed: bool - - @staticmethod - def unpack(flags: int): - # _logger.debug(f'StatFlags.unpack: flags={flags}') - state = flags & 0x1 - ccvp = (flags >> 1) & 0x1 - cc = (flags >> 2) & 0x1 - # _logger.debug(f'StatFlags.unpack: state={state}') - return DiagnosticsFlags(state=(state == 1), - config_changed_value_present=(ccvp == 1), - config_changed=(cc == 1)) - - def __index__(self): - bits = 0 - bits |= (int(self.state) & 0x1) - bits |= (int(self.config_changed_value_present) & 0x1) << 1 - bits |= (int(self.config_changed) & 0x1) << 2 - return bits - - -class InitialDiagnosticsPayload(MqttPayload): - FORMAT = '=IBbIB' - - ip: int - fw_version: int - rssi: int - free_heap: int - flags: DiagnosticsFlags - - -class DiagnosticsPayload(MqttPayload): - FORMAT = '=bIB' - - rssi: int - free_heap: int - flags: DiagnosticsFlags diff --git a/src/home/mqtt/payload/inverter.py b/src/home/mqtt/payload/inverter.py deleted file mode 100644 index 09388df..0000000 --- a/src/home/mqtt/payload/inverter.py +++ /dev/null @@ -1,73 +0,0 @@ -import struct - -from .base_payload import MqttPayload, bit_field -from typing import Tuple - -_mult_10 = lambda n: int(n*10) -_div_10 = lambda n: n/10 - - -class Status(MqttPayload): - # 46 bytes - FORMAT = 'IHHHHHHBHHHHHBHHHHHHHH' - - PACKER = { - 'grid_voltage': _mult_10, - 'grid_freq': _mult_10, - 'ac_output_voltage': _mult_10, - 'ac_output_freq': _mult_10, - 'battery_voltage': _mult_10, - 'battery_voltage_scc': _mult_10, - 'battery_voltage_scc2': _mult_10, - 'pv1_input_voltage': _mult_10, - 'pv2_input_voltage': _mult_10 - } - UNPACKER = { - 'grid_voltage': _div_10, - 'grid_freq': _div_10, - 'ac_output_voltage': _div_10, - 'ac_output_freq': _div_10, - 'battery_voltage': _div_10, - 'battery_voltage_scc': _div_10, - 'battery_voltage_scc2': _div_10, - 'pv1_input_voltage': _div_10, - 'pv2_input_voltage': _div_10 - } - - time: int - grid_voltage: float - grid_freq: float - ac_output_voltage: float - ac_output_freq: float - ac_output_apparent_power: int - ac_output_active_power: int - output_load_percent: int - battery_voltage: float - battery_voltage_scc: float - battery_voltage_scc2: float - battery_discharge_current: int - battery_charge_current: int - battery_capacity: int - inverter_heat_sink_temp: int - mppt1_charger_temp: int - mppt2_charger_temp: int - pv1_input_power: int - pv2_input_power: int - pv1_input_voltage: float - pv2_input_voltage: float - - # H - mppt1_charger_status: bit_field(0, 16, 2) - mppt2_charger_status: bit_field(0, 16, 2) - battery_power_direction: bit_field(0, 16, 2) - dc_ac_power_direction: bit_field(0, 16, 2) - line_power_direction: bit_field(0, 16, 2) - load_connected: bit_field(0, 16, 1) - - -class Generation(MqttPayload): - # 8 bytes - FORMAT = 'II' - - time: int - wh: int diff --git a/src/home/mqtt/payload/relay.py b/src/home/mqtt/payload/relay.py deleted file mode 100644 index 4902991..0000000 --- a/src/home/mqtt/payload/relay.py +++ /dev/null @@ -1,22 +0,0 @@ -from .base_payload import MqttPayload -from .esp import ( - OTAResultPayload, - OTAPayload, - InitialDiagnosticsPayload, - DiagnosticsPayload -) - - -class PowerPayload(MqttPayload): - FORMAT = '=12sB' - PACKER = { - 'state': lambda n: int(n), - 'secret': lambda s: s.encode('utf-8') - } - UNPACKER = { - 'state': lambda n: bool(n), - 'secret': lambda s: s.decode('utf-8') - } - - secret: str - state: bool diff --git a/src/home/mqtt/payload/sensors.py b/src/home/mqtt/payload/sensors.py deleted file mode 100644 index f99b307..0000000 --- a/src/home/mqtt/payload/sensors.py +++ /dev/null @@ -1,20 +0,0 @@ -from .base_payload import MqttPayload - -_mult_100 = lambda n: int(n*100) -_div_100 = lambda n: n/100 - - -class Temperature(MqttPayload): - FORMAT = 'IhH' - PACKER = { - 'temp': _mult_100, - 'rh': _mult_100, - } - UNPACKER = { - 'temp': _div_100, - 'rh': _div_100, - } - - time: int - temp: float - rh: float diff --git a/src/home/mqtt/payload/temphum.py b/src/home/mqtt/payload/temphum.py deleted file mode 100644 index c0b744e..0000000 --- a/src/home/mqtt/payload/temphum.py +++ /dev/null @@ -1,15 +0,0 @@ -from .base_payload import MqttPayload - -two_digits_precision = lambda x: round(x, 2) - - -class TempHumDataPayload(MqttPayload): - FORMAT = '=ddb' - UNPACKER = { - 'temp': two_digits_precision, - 'rh': two_digits_precision - } - - temp: float - rh: float - error: int diff --git a/src/home/mqtt/relay.py b/src/home/mqtt/relay.py deleted file mode 100644 index a90f19c..0000000 --- a/src/home/mqtt/relay.py +++ /dev/null @@ -1,71 +0,0 @@ -import paho.mqtt.client as mqtt -import re -import datetime - -from .payload.relay import ( - PowerPayload, -) -from .esp import MqttEspBase - - -class MqttRelay(MqttEspBase): - TOPIC_LEAF = 'relay' - - def set_power(self, device_id, enable: bool, secret=None): - device = next(d for d in self._devices if d.id == device_id) - secret = secret if secret else device.secret - - assert secret is not None, 'device secret not specified' - - payload = PowerPayload(secret=secret, - state=enable) - self._client.publish(f'hk/{device.id}/{self.TOPIC_LEAF}/power', - payload=payload.pack(), - qos=1) - self._client.loop_write() - - def on_message(self, client: mqtt.Client, userdata, msg): - if super().on_message(client, userdata, msg): - return - - try: - match = re.match(self.get_mqtt_topics(['power']), msg.topic) - if not match: - return - - device_id = match.group(1) - subtopic = match.group(2) - - message = None - if subtopic == 'power': - message = PowerPayload.unpack(msg.payload) - - if message and self._message_callback: - self._message_callback(device_id, message) - - except Exception as e: - self._logger.exception(str(e)) - - -class MqttRelayState: - enabled: bool - update_time: datetime.datetime - rssi: int - fw_version: int - ever_updated: bool - - def __init__(self): - self.ever_updated = False - self.enabled = False - self.rssi = 0 - - def update(self, - enabled: bool, - rssi: int, - fw_version=None): - self.ever_updated = True - self.enabled = enabled - self.rssi = rssi - self.update_time = datetime.datetime.now() - if fw_version: - self.fw_version = fw_version diff --git a/src/home/mqtt/temphum.py b/src/home/mqtt/temphum.py deleted file mode 100644 index 44810ef..0000000 --- a/src/home/mqtt/temphum.py +++ /dev/null @@ -1,54 +0,0 @@ -import paho.mqtt.client as mqtt -import re - -from enum import auto -from .payload.temphum import TempHumDataPayload -from .esp import MqttEspBase -from ..util import HashableEnum - - -class MqttTempHumNodes(HashableEnum): - KBN_SH_HALL = auto() - KBN_SH_BATHROOM = auto() - KBN_SH_LIVINGROOM = auto() - KBN_SH_BEDROOM = auto() - - KBN_BH_2FL = auto() - KBN_BH_2FL_STREET = auto() - KBN_BH_1FL_LIVINGROOM = auto() - KBN_BH_1FL_BEDROOM = auto() - KBN_BH_1FL_BATHROOM = auto() - - KBN_NH_1FL_INV = auto() - KBN_NH_1FL_CENTER = auto() - KBN_NH_1LF_KT = auto() - KBN_NH_1FL_DS = auto() - KBN_NH_1FS_EZ = auto() - - SPB_FLAT120_CABINET = auto() - - -class MqttTempHum(MqttEspBase): - TOPIC_LEAF = 'temphum' - - def on_message(self, client: mqtt.Client, userdata, msg): - if super().on_message(client, userdata, msg): - return - - try: - match = re.match(self.get_mqtt_topics(['data']), msg.topic) - if not match: - return - - device_id = match.group(1) - subtopic = match.group(2) - - message = None - if subtopic == 'data': - message = TempHumDataPayload.unpack(msg.payload) - - if message and self._message_callback: - self._message_callback(device_id, message) - - except Exception as e: - self._logger.exception(str(e)) diff --git a/src/home/mqtt/util.py b/src/home/mqtt/util.py deleted file mode 100644 index f71ffd8..0000000 --- a/src/home/mqtt/util.py +++ /dev/null @@ -1,8 +0,0 @@ -import time - - -def poll_tick(freq): - t = time.time() - while True: - t += freq - yield max(t - time.time(), 0) diff --git a/src/home/pio/__init__.py b/src/home/pio/__init__.py deleted file mode 100644 index 7216bc4..0000000 --- a/src/home/pio/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .products import get_products, platformio_ini
\ No newline at end of file diff --git a/src/home/pio/exceptions.py b/src/home/pio/exceptions.py deleted file mode 100644 index a6afd20..0000000 --- a/src/home/pio/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class ProductConfigNotFoundError(Exception): - pass diff --git a/src/home/pio/products.py b/src/home/pio/products.py deleted file mode 100644 index 7649078..0000000 --- a/src/home/pio/products.py +++ /dev/null @@ -1,117 +0,0 @@ -import os -import logging - -from io import StringIO -from collections import OrderedDict - - -_logger = logging.getLogger(__name__) -_products_dir = os.path.join( - os.path.dirname(__file__), - '..', '..', '..', - 'platformio' -) - - -def get_products(): - products = [] - for f in os.listdir(_products_dir): - # temp hack - if f.endswith('-esp01'): - continue - # skip the common dir - if f in ('common',): - continue - - if os.path.isdir(os.path.join(_products_dir, f)): - products.append(f) - - return products - - -def platformio_ini(product_config: dict, - target: str, - # node_id: str, - build_specific_defines: dict, - build_specific_defines_enums: list[str], - platform: str, - framework: str = 'arduino', - upload_port: str = '/dev/ttyUSB0', - monitor_speed: int = 115200, - debug=False, - debug_network=False) -> str: - node_id = build_specific_defines['CONFIG_NODE_ID'] - - # defines - defines = { - **product_config['common_defines'], - 'CONFIG_NODE_ID': node_id, - 'CONFIG_WIFI_AP_SSID': ('HK_'+node_id)[:31] - } - try: - defines.update(product_config['target_defines'][target]) - except KeyError: - pass - defines['CONFIG_NODE_SECRET_SIZE'] = len(defines['CONFIG_NODE_SECRET']) - defines['CONFIG_MQTT_CLIENT_ID'] = node_id - - build_type = 'release' - if debug: - defines['DEBUG'] = True - build_type = 'debug' - if debug_network: - defines['DEBUG'] = True - defines['DEBUG_ESP_SSL'] = True - defines['DEBUG_ESP_PORT'] = 'Serial' - build_type = 'debug' - if build_specific_defines: - for k, v in build_specific_defines.items(): - defines[k] = v - defines = OrderedDict(sorted(defines.items(), key=lambda t: t[0])) - - # libs - libs = [] - if 'common_libs' in product_config: - libs.extend(product_config['common_libs']) - if 'target_libs' in product_config and target in product_config['target_libs']: - libs.extend(product_config['target_libs'][target]) - libs = list(set(libs)) - libs.sort() - - try: - target_real_name = product_config['target_board_names'][target] - except KeyError: - target_real_name = target - - buf = StringIO() - - buf.write('; Generated by pio_ini.py\n\n') - buf.write(f'[env:{target_real_name}]\n') - buf.write(f'platform = {platform}\n') - buf.write(f'board = {target_real_name}\n') - buf.write(f'framework = {framework}\n') - buf.write(f'upload_port = {upload_port}\n') - buf.write(f'monitor_speed = {monitor_speed}\n') - if libs: - buf.write(f'lib_deps =') - for lib in libs: - buf.write(f' {lib}\n') - buf.write(f'build_flags =\n') - if defines: - for name, value in defines.items(): - buf.write(f' -D{name}') - is_enum = name in build_specific_defines_enums - if type(value) is not bool: - buf.write('=') - if type(value) is str: - if not is_enum: - buf.write('"\\"') - value = value.replace('"', '\\"') - buf.write(f'{value}') - if type(value) is str and not is_enum: - buf.write('"\\"') - buf.write('\n') - buf.write(f' -I../common/include') - buf.write(f'\nbuild_type = {build_type}') - - return buf.getvalue() diff --git a/src/home/relay/__init__.py b/src/home/relay/__init__.py deleted file mode 100644 index 406403d..0000000 --- a/src/home/relay/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -import importlib - -__all__ = ['RelayClient', 'RelayServer'] - - -def __getattr__(name): - _map = { - 'RelayClient': '.sunxi_h3_client', - 'RelayServer': '.sunxi_h3_server' - } - - if name in __all__: - module = importlib.import_module(_map[name], __name__) - return getattr(module, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/relay/__init__.pyi b/src/home/relay/__init__.pyi deleted file mode 100644 index 7a4a2f4..0000000 --- a/src/home/relay/__init__.pyi +++ /dev/null @@ -1,2 +0,0 @@ -from .sunxi_h3_client import RelayClient as RelayClient -from .sunxi_h3_server import RelayServer as RelayServer diff --git a/src/home/relay/sunxi_h3_client.py b/src/home/relay/sunxi_h3_client.py deleted file mode 100644 index 8c8d6c4..0000000 --- a/src/home/relay/sunxi_h3_client.py +++ /dev/null @@ -1,39 +0,0 @@ -import socket - - -class RelayClient: - def __init__(self, port=8307, host='127.0.0.1'): - self._host = host - self._port = port - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - def __del__(self): - self.sock.close() - - def connect(self): - self.sock.connect((self._host, self._port)) - - def _write(self, line): - self.sock.sendall((line+'\r\n').encode()) - - def _read(self): - buf = bytearray() - while True: - buf.extend(self.sock.recv(256)) - if b'\r\n' in buf: - break - - response = buf.decode().strip() - return response - - def on(self): - self._write('on') - return self._read() - - def off(self): - self._write('off') - return self._read() - - def status(self): - self._write('get') - return self._read() diff --git a/src/home/relay/sunxi_h3_server.py b/src/home/relay/sunxi_h3_server.py deleted file mode 100644 index 1f33969..0000000 --- a/src/home/relay/sunxi_h3_server.py +++ /dev/null @@ -1,82 +0,0 @@ -import asyncio -import logging - -from pyA20.gpio import gpio -from pyA20.gpio import port as gpioport -from ..util import Addr - -logger = logging.getLogger(__name__) - - -class RelayServer: - OFF = 1 - ON = 0 - - def __init__(self, - pinname: str, - addr: Addr): - if not hasattr(gpioport, pinname): - raise ValueError(f'invalid pin {pinname}') - - self.pin = getattr(gpioport, pinname) - self.addr = addr - - gpio.init() - gpio.setcfg(self.pin, gpio.OUTPUT) - - self.lock = asyncio.Lock() - - def run(self): - asyncio.run(self.run_server()) - - async def relay_set(self, value): - async with self.lock: - gpio.output(self.pin, value) - - async def relay_get(self): - async with self.lock: - return int(gpio.input(self.pin)) == RelayServer.ON - - async def handle_client(self, reader, writer): - request = None - while request != 'quit': - try: - request = await reader.read(255) - if request == b'\x04': - break - request = request.decode('utf-8').strip() - except Exception: - break - - data = 'unknown' - if request == 'on': - await self.relay_set(RelayServer.ON) - logger.debug('set on') - data = 'ok' - - elif request == 'off': - await self.relay_set(RelayServer.OFF) - logger.debug('set off') - data = 'ok' - - elif request == 'get': - status = await self.relay_get() - data = 'on' if status is True else 'off' - - writer.write((data + '\r\n').encode('utf-8')) - try: - await writer.drain() - except ConnectionError: - break - - try: - writer.close() - except ConnectionError: - pass - - async def run_server(self): - host, port = self.addr - server = await asyncio.start_server(self.handle_client, host, port) - async with server: - logger.info('Server started.') - await server.serve_forever() diff --git a/src/home/soundsensor/__init__.py b/src/home/soundsensor/__init__.py deleted file mode 100644 index 30052f8..0000000 --- a/src/home/soundsensor/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -import importlib - -__all__ = [ - 'SoundSensorNode', - 'SoundSensorHitHandler', - 'SoundSensorServer', - 'SoundSensorServerGuardClient' -] - - -def __getattr__(name): - if name in __all__: - if name == 'SoundSensorNode': - file = 'node' - elif name == 'SoundSensorServerGuardClient': - file = 'server_client' - else: - file = 'server' - module = importlib.import_module(f'.{file}', __name__) - return getattr(module, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/soundsensor/__init__.pyi b/src/home/soundsensor/__init__.pyi deleted file mode 100644 index cb34972..0000000 --- a/src/home/soundsensor/__init__.pyi +++ /dev/null @@ -1,8 +0,0 @@ -from .server import ( - SoundSensorHitHandler as SoundSensorHitHandler, - SoundSensorServer as SoundSensorServer, -) -from .server_client import ( - SoundSensorServerGuardClient as SoundSensorServerGuardClient -) -from .node import SoundSensorNode as SoundSensorNode diff --git a/src/home/soundsensor/node.py b/src/home/soundsensor/node.py deleted file mode 100644 index 292452f..0000000 --- a/src/home/soundsensor/node.py +++ /dev/null @@ -1,75 +0,0 @@ -import logging -import threading - -from typing import Optional -from time import sleep -from ..util import stringify, send_datagram, Addr - -from pyA20.gpio import gpio -from pyA20.gpio import port as gpioport - -logger = logging.getLogger(__name__) - - -class SoundSensorNode: - def __init__(self, - name: str, - pinname: str, - server_addr: Optional[Addr], - threshold: int = 1, - delay=0.005): - - if not hasattr(gpioport, pinname): - raise ValueError(f'invalid pin {pinname}') - - self.pin = getattr(gpioport, pinname) - self.name = name - self.delay = delay - self.threshold = threshold - - self.server_addr = server_addr - - self.hits = 0 - self.hitlock = threading.Lock() - - self.interrupted = False - - def run(self): - try: - t = threading.Thread(target=self.sensor_reader) - t.daemon = True - t.start() - - while True: - with self.hitlock: - hits = self.hits - self.hits = 0 - - if hits >= self.threshold: - try: - if self.server_addr is not None: - send_datagram(stringify([self.name, hits]), self.server_addr) - else: - logger.debug(f'server reporting disabled, skipping reporting {hits} hits') - except OSError as exc: - logger.exception(exc) - - sleep(1) - - except (KeyboardInterrupt, SystemExit) as e: - self.interrupted = True - logger.info(str(e)) - - def sensor_reader(self): - gpio.init() - gpio.setcfg(self.pin, gpio.INPUT) - gpio.pullup(self.pin, gpio.PULLUP) - - while not self.interrupted: - state = gpio.input(self.pin) - sleep(self.delay) - - if not state: - with self.hitlock: - logger.debug('got a hit') - self.hits += 1 diff --git a/src/home/soundsensor/server.py b/src/home/soundsensor/server.py deleted file mode 100644 index a627390..0000000 --- a/src/home/soundsensor/server.py +++ /dev/null @@ -1,128 +0,0 @@ -import asyncio -import json -import logging -import threading - -from ..database.sqlite import SQLiteBase -from ..config import config -from .. import http - -from typing import Type -from ..util import Addr - -logger = logging.getLogger(__name__) - - -class SoundSensorHitHandler(asyncio.DatagramProtocol): - def datagram_received(self, data, addr): - try: - data = json.loads(data) - except json.JSONDecodeError as e: - logger.error('failed to parse json datagram') - logger.exception(e) - return - - try: - name, hits = data - except (ValueError, IndexError) as e: - logger.error('failed to unpack data') - logger.exception(e) - return - - self.handler(name, hits) - - def handler(self, name: str, hits: int): - pass - - -class Database(SQLiteBase): - SCHEMA = 1 - - def __init__(self): - super().__init__(dbname='sound_sensor_server') - - def schema_init(self, version: int) -> None: - cursor = self.cursor() - - if version < 1: - cursor.execute("CREATE TABLE IF NOT EXISTS status (guard_enabled INTEGER NOT NULL)") - cursor.execute("INSERT INTO status (guard_enabled) VALUES (-1)") - - self.commit() - - def get_guard_enabled(self) -> int: - cur = self.cursor() - cur.execute("SELECT guard_enabled FROM status LIMIT 1") - return int(cur.fetchone()[0]) - - def set_guard_enabled(self, enabled: bool) -> None: - cur = self.cursor() - cur.execute("UPDATE status SET guard_enabled=?", (int(enabled),)) - self.commit() - - -class SoundSensorServer: - def __init__(self, - addr: Addr, - handler_impl: Type[SoundSensorHitHandler]): - self.addr = addr - self.impl = handler_impl - self.db = Database() - - self._recording_lock = threading.Lock() - self._recording_enabled = True - - if self.guard_control_enabled(): - current_status = self.db.get_guard_enabled() - if current_status == -1: - self.set_recording(config['server']['guard_recording_default'] - if 'guard_recording_default' in config['server'] - else False, - update=False) - else: - self.set_recording(bool(current_status), update=False) - - @staticmethod - def guard_control_enabled() -> bool: - return 'guard_control' in config['server'] and config['server']['guard_control'] is True - - def set_recording(self, enabled: bool, update=True): - with self._recording_lock: - self._recording_enabled = enabled - if update: - self.db.set_guard_enabled(enabled) - - def is_recording_enabled(self) -> bool: - with self._recording_lock: - return self._recording_enabled - - def run(self): - if self.guard_control_enabled(): - t = threading.Thread(target=self.run_guard_server) - t.daemon = True - t.start() - - loop = asyncio.get_event_loop() - t = loop.create_datagram_endpoint(self.impl, local_addr=self.addr) - loop.run_until_complete(t) - loop.run_forever() - - def run_guard_server(self): - routes = http.routes() - - @routes.post('/guard/enable') - async def guard_enable(request): - self.set_recording(True) - return http.ok() - - @routes.post('/guard/disable') - async def guard_disable(request): - self.set_recording(False) - return http.ok() - - @routes.get('/guard/status') - async def guard_status(request): - return http.ok({'enabled': self.is_recording_enabled()}) - - asyncio.set_event_loop(asyncio.new_event_loop()) # need to create new event loop in new thread - http.serve(self.addr, routes, handle_signals=False) # handle_signals=True doesn't work in separate thread diff --git a/src/home/soundsensor/server_client.py b/src/home/soundsensor/server_client.py deleted file mode 100644 index 7eef996..0000000 --- a/src/home/soundsensor/server_client.py +++ /dev/null @@ -1,38 +0,0 @@ -import requests -import logging - -from ..util import Addr -from ..api.errors import ApiResponseError - - -class SoundSensorServerGuardClient: - def __init__(self, addr: Addr): - self.endpoint = f'http://{addr[0]}:{addr[1]}' - self.logger = logging.getLogger(self.__class__.__name__) - - def guard_enable(self): - return self._call('guard/enable', is_post=True) - - def guard_disable(self): - return self._call('guard/disable', is_post=True) - - def guard_status(self): - return self._call('guard/status') - - def _call(self, - method: str, - is_post=False): - - url = f'{self.endpoint}/{method}' - self.logger.debug(f'calling {url}') - - r = requests.get(url) if not is_post else requests.post(url) - - if r.status_code != 200: - response = r.json() - raise ApiResponseError(status_code=r.status_code, - error_type=response['error'], - error_message=response['message'] or None, - error_stacktrace=response['stacktrace'] if 'stacktrace' in response else None) - - return r.json()['response'] diff --git a/src/home/telegram/__init__.py b/src/home/telegram/__init__.py deleted file mode 100644 index a68dae1..0000000 --- a/src/home/telegram/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .telegram import send_message, send_photo diff --git a/src/home/telegram/_botcontext.py b/src/home/telegram/_botcontext.py deleted file mode 100644 index f343eeb..0000000 --- a/src/home/telegram/_botcontext.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Optional, List - -from telegram import Update, ParseMode, User, CallbackQuery -from telegram.ext import CallbackContext - -from ._botdb import BotDatabase -from ._botlang import lang -from ._botutil import IgnoreMarkup, exc2text - - -class Context: - _update: Optional[Update] - _callback_context: Optional[CallbackContext] - _markup_getter: callable - db: Optional[BotDatabase] - _user_lang: Optional[str] - - def __init__(self, - update: Optional[Update], - callback_context: Optional[CallbackContext], - markup_getter: callable, - store: Optional[BotDatabase]): - self._update = update - self._callback_context = callback_context - self._markup_getter = markup_getter - self._store = store - self._user_lang = None - - def reply(self, text, markup=None): - if markup is None: - markup = self._markup_getter(self) - kwargs = dict(parse_mode=ParseMode.HTML) - if not isinstance(markup, IgnoreMarkup): - kwargs['reply_markup'] = markup - return self._update.message.reply_text(text, **kwargs) - - def reply_exc(self, e: Exception) -> None: - self.reply(exc2text(e), markup=IgnoreMarkup()) - - def answer(self, text: str = None): - self.callback_query.answer(text) - - def edit(self, text, markup=None): - kwargs = dict(parse_mode=ParseMode.HTML) - if not isinstance(markup, IgnoreMarkup): - kwargs['reply_markup'] = markup - self.callback_query.edit_message_text(text, **kwargs) - - @property - def text(self) -> str: - return self._update.message.text - - @property - def callback_query(self) -> CallbackQuery: - return self._update.callback_query - - @property - def args(self) -> Optional[List[str]]: - return self._callback_context.args - - @property - def user_id(self) -> int: - return self.user.id - - @property - def user_data(self): - return self._callback_context.user_data - - @property - def user(self) -> User: - return self._update.effective_user - - @property - def user_lang(self) -> str: - if self._user_lang is None: - self._user_lang = self._store.get_user_lang(self.user_id) - return self._user_lang - - def lang(self, key: str, *args) -> str: - return lang.get(key, self.user_lang, *args) - - def is_callback_context(self) -> bool: - return self._update.callback_query \ - and self._update.callback_query.data \ - and self._update.callback_query.data != '' diff --git a/src/home/telegram/_botdb.py b/src/home/telegram/_botdb.py deleted file mode 100644 index 9e9cf94..0000000 --- a/src/home/telegram/_botdb.py +++ /dev/null @@ -1,32 +0,0 @@ -from home.database.sqlite import SQLiteBase - - -class BotDatabase(SQLiteBase): - def __init__(self): - super().__init__() - - def schema_init(self, version: int) -> None: - if version < 1: - cursor = self.cursor() - cursor.execute("""CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY, - lang TEXT NOT NULL - )""") - self.commit() - - def get_user_lang(self, user_id: int, default: str = 'en') -> str: - cursor = self.cursor() - cursor.execute('SELECT lang FROM users WHERE id=?', (user_id,)) - row = cursor.fetchone() - - if row is None: - cursor.execute('INSERT INTO users (id, lang) VALUES (?, ?)', (user_id, default)) - self.commit() - return default - else: - return row[0] - - def set_user_lang(self, user_id: int, lang: str) -> None: - cursor = self.cursor() - cursor.execute('UPDATE users SET lang=? WHERE id=?', (lang, user_id)) - self.commit() diff --git a/src/home/telegram/_botlang.py b/src/home/telegram/_botlang.py deleted file mode 100644 index f5f85bb..0000000 --- a/src/home/telegram/_botlang.py +++ /dev/null @@ -1,120 +0,0 @@ -import logging - -from typing import Optional, Dict, List, Union - -_logger = logging.getLogger(__name__) - - -class LangStrings(dict): - _lang: Optional[str] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._lang = None - - def setlang(self, lang: str): - self._lang = lang - - def __missing__(self, key): - _logger.warning(f'key {key} is missing in language {self._lang}') - return '{%s}' % key - - def __setitem__(self, key, value): - raise NotImplementedError(f'setting translation strings this way is prohibited (was trying to set {key}={value})') - - -class LangPack: - strings: Dict[str, LangStrings[str, str]] - default_lang: str - - def __init__(self): - self.strings = {} - self.default_lang = 'en' - - def ru(self, **kwargs) -> None: - self.set(kwargs, 'ru') - - def en(self, **kwargs) -> None: - self.set(kwargs, 'en') - - def set(self, - strings: Union[LangStrings, dict], - lang: str) -> None: - - if isinstance(strings, dict) and not isinstance(strings, LangStrings): - strings = LangStrings(**strings) - strings.setlang(lang) - - if lang not in self.strings: - self.strings[lang] = strings - else: - self.strings[lang].update(strings) - - def all(self, key): - result = [] - for strings in self.strings.values(): - result.append(strings[key]) - return result - - @property - 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 get_langpack(self, _lang: str) -> dict: - return self.strings[_lang] - - def __call__(self, *args, **kwargs): - return self.strings[self.default_lang][args[0]] - - def __getitem__(self, key): - return self.strings[self.default_lang][key] - - def __setitem__(self, key, value): - raise NotImplementedError('setting translation strings this way is prohibited') - - def __contains__(self, key): - return key in self.strings[self.default_lang] - - @staticmethod - def pfx(prefix: str, l: list) -> list: - return list(map(lambda s: f'{prefix}{s}', l)) - - - -languages = { - 'en': 'English', - 'ru': 'Русский' -} - - -lang = LangPack() -lang.en( - en='English', - ru='Russian', - start_message="Select command on the keyboard.", - unknown_message="Unknown message", - cancel="🚫 Cancel", - back='🔙 Back', - select_language="Select language on the keyboard.", - invalid_language="Invalid language. Please try again.", - saved='Saved.', - please_wait="⏳ Please wait..." -) -lang.ru( - en='Английский', - ru='Русский', - start_message="Выберите команду на клавиатуре.", - unknown_message="Неизвестная команда", - cancel="🚫 Отмена", - back='🔙 Назад', - select_language="Выберите язык на клавиатуре.", - invalid_language="Неверный язык. Пожалуйста, попробуйте снова", - saved="Настройки сохранены.", - please_wait="⏳ Ожидайте..." -)
\ No newline at end of file diff --git a/src/home/telegram/_botutil.py b/src/home/telegram/_botutil.py deleted file mode 100644 index 6d1ee8f..0000000 --- a/src/home/telegram/_botutil.py +++ /dev/null @@ -1,47 +0,0 @@ -import logging -import traceback - -from html import escape -from telegram import User -from home.api import WebAPIClient as APIClient -from home.api.types import BotType -from home.api.errors import ApiResponseError - -_logger = logging.getLogger(__name__) - - -def user_any_name(user: User) -> str: - name = [user.first_name, user.last_name] - name = list(filter(lambda s: s is not None, name)) - name = ' '.join(name).strip() - - if not name: - name = user.username - - if not name: - name = str(user.id) - - return name - - -class ReportingHelper: - def __init__(self, client: APIClient, bot_type: BotType): - self.client = client - self.bot_type = bot_type - - def report(self, message, text: str = None) -> None: - if text is None: - text = message.text - try: - self.client.log_bot_request(self.bot_type, message.chat_id, text) - except ApiResponseError as error: - _logger.exception(error) - - -def exc2text(e: Exception) -> str: - tb = ''.join(traceback.format_tb(e.__traceback__)) - return f'{e.__class__.__name__}: ' + escape(str(e)) + "\n\n" + escape(tb) - - -class IgnoreMarkup: - pass diff --git a/src/home/telegram/aio.py b/src/home/telegram/aio.py deleted file mode 100644 index fc87c1c..0000000 --- a/src/home/telegram/aio.py +++ /dev/null @@ -1,18 +0,0 @@ -import functools -import asyncio - -from .telegram import ( - send_message as _send_message_sync, - send_photo as _send_photo_sync -) - - -async def send_message(*args, **kwargs): - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, functools.partial(_send_message_sync, *args, **kwargs)) - - -async def send_photo(*args, **kwargs): - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, functools.partial(_send_photo_sync, *args, **kwargs)) - diff --git a/src/home/telegram/bot.py b/src/home/telegram/bot.py deleted file mode 100644 index 10bfe06..0000000 --- a/src/home/telegram/bot.py +++ /dev/null @@ -1,578 +0,0 @@ -from __future__ import annotations - -import logging -import itertools - -from enum import Enum, auto -from functools import wraps -from typing import Optional, Union, Tuple - -from telegram import Update, ReplyKeyboardMarkup -from telegram.ext import ( - Updater, - Filters, - BaseFilter, - CommandHandler, - MessageHandler, - CallbackQueryHandler, - CallbackContext, - ConversationHandler -) -from telegram.error import TimedOut - -from home.config import config -from home.api import WebAPIClient -from home.api.types import BotType - -from ._botlang import lang, languages -from ._botdb import BotDatabase -from ._botutil import ReportingHelper, exc2text, IgnoreMarkup, user_any_name -from ._botcontext import Context - - -db: Optional[BotDatabase] = None - -_user_filter: Optional[BaseFilter] = None -_cancel_filter = Filters.text(lang.all('cancel')) -_back_filter = Filters.text(lang.all('back')) -_cancel_and_back_filter = Filters.text(lang.all('back') + lang.all('cancel')) - -_logger = logging.getLogger(__name__) -_updater: Optional[Updater] = None -_reporting: Optional[ReportingHelper] = None -_exception_handler: Optional[callable] = None -_dispatcher = None -_markup_getter: Optional[callable] = None -_start_handler_ref: Optional[callable] = None - - -def text_filter(*args): - if not _user_filter: - raise RuntimeError('user_filter is not initialized') - return Filters.text(args[0] if isinstance(args[0], list) else [*args]) & _user_filter - - -def _handler_of_handler(*args, **kwargs): - self = None - context = None - update = None - - _args = list(args) - while len(_args): - v = _args[0] - if isinstance(v, conversation): - self = v - _args.pop(0) - elif isinstance(v, Update): - update = v - _args.pop(0) - elif isinstance(v, CallbackContext): - context = v - _args.pop(0) - break - - ctx = Context(update, - callback_context=context, - markup_getter=lambda _ctx: None if not _markup_getter else _markup_getter(_ctx), - store=db) - try: - _args.insert(0, ctx) - - f = kwargs['f'] - del kwargs['f'] - - if 'return_with_context' in kwargs: - return_with_context = True - del kwargs['return_with_context'] - else: - return_with_context = False - - if 'argument' in kwargs and kwargs['argument'] == 'message_key': - del kwargs['argument'] - mkey = None - for k, v in lang.get_langpack(ctx.user_lang).items(): - if ctx.text == v: - mkey = k - break - _args.insert(0, mkey) - - if self: - _args.insert(0, self) - - result = f(*_args, **kwargs) - return result if not return_with_context else (result, ctx) - - except Exception as e: - if _exception_handler: - if not _exception_handler(e, ctx) and not isinstance(e, TimedOut): - _logger.exception(e) - if not ctx.is_callback_context(): - ctx.reply_exc(e) - else: - notify_user(ctx.user_id, exc2text(e)) - else: - _logger.exception(e) - - -def handler(**kwargs): - def inner(f): - @wraps(f) - def _handler(*args, **inner_kwargs): - if 'argument' in kwargs and kwargs['argument'] == 'message_key': - inner_kwargs['argument'] = 'message_key' - return _handler_of_handler(f=f, *args, **inner_kwargs) - - messages = [] - texts = [] - - if 'messages' in kwargs: - messages += kwargs['messages'] - if 'message' in kwargs: - messages.append(kwargs['message']) - - if 'text' in kwargs: - texts.append(kwargs['text']) - if 'texts' in kwargs: - texts += kwargs['texts'] - - if messages or texts: - new_messages = list(itertools.chain.from_iterable([lang.all(m) for m in messages])) - texts += new_messages - texts = list(set(texts)) - _updater.dispatcher.add_handler( - MessageHandler(text_filter(*texts), _handler), - group=0 - ) - - if 'command' in kwargs: - _updater.dispatcher.add_handler(CommandHandler(kwargs['command'], _handler), group=0) - - if 'callback' in kwargs: - _updater.dispatcher.add_handler(CallbackQueryHandler(_handler, pattern=kwargs['callback']), group=0) - - return _handler - - return inner - - -def simplehandler(f: callable): - @wraps(f) - def _handler(*args, **kwargs): - return _handler_of_handler(f=f, *args, **kwargs) - return _handler - - -def callbackhandler(*args, **kwargs): - def inner(f): - @wraps(f) - def _handler(*args, **kwargs): - return _handler_of_handler(f=f, *args, **kwargs) - pattern_kwargs = {} - if kwargs['callback'] != '*': - pattern_kwargs['pattern'] = kwargs['callback'] - _updater.dispatcher.add_handler(CallbackQueryHandler(_handler, **pattern_kwargs), group=0) - return _handler - return inner - - -def exceptionhandler(f: callable): - global _exception_handler - if _exception_handler: - _logger.warning('exception handler already set, we will overwrite it') - _exception_handler = f - - -def defaultreplymarkup(f: callable): - global _markup_getter - _markup_getter = f - - -def convinput(state, is_enter=False, **kwargs): - def inner(f): - f.__dict__['_conv_data'] = dict( - orig_f=f, - enter=is_enter, - type=ConversationMethodType.ENTRY if is_enter and state == 0 else ConversationMethodType.STATE_HANDLER, - state=state, - **kwargs - ) - - @wraps(f) - def _impl(*args, **kwargs): - result, ctx = _handler_of_handler(f=f, *args, **kwargs, return_with_context=True) - if result == conversation.END: - start(ctx) - return result - - return _impl - - return inner - - -def conventer(state, **kwargs): - return convinput(state, is_enter=True, **kwargs) - - -class ConversationMethodType(Enum): - ENTRY = auto() - STATE_HANDLER = auto() - - -class conversation: - END = ConversationHandler.END - STATE_SEQS = [] - - def __init__(self, enable_back=False): - self._logger = logging.getLogger(self.__class__.__name__) - self._user_state_cache = {} - self._back_enabled = enable_back - - def make_handlers(self, f: callable, **kwargs) -> list: - messages = {} - handlers = [] - - if 'messages' in kwargs: - if isinstance(kwargs['messages'], dict): - messages = kwargs['messages'] - else: - for m in kwargs['messages']: - messages[m] = None - - if 'message' in kwargs: - if isinstance(kwargs['message'], str): - messages[kwargs['message']] = None - else: - AttributeError('invalid message type: ' + type(kwargs['message'])) - - if messages: - for message, target_state in messages.items(): - if not target_state: - handlers.append(MessageHandler(text_filter(lang.all(message) if 'messages_lang_completed' not in kwargs else message), f)) - else: - handlers.append(MessageHandler(text_filter(lang.all(message) if 'messages_lang_completed' not in kwargs else message), self.make_invoker(target_state))) - - if 'regex' in kwargs: - handlers.append(MessageHandler(Filters.regex(kwargs['regex']) & _user_filter, f)) - - if 'command' in kwargs: - handlers.append(CommandHandler(kwargs['command'], f, _user_filter)) - - return handlers - - def make_invoker(self, state): - def _invoke(update: Update, context: CallbackContext): - ctx = Context(update, - callback_context=context, - markup_getter=lambda _ctx: None if not _markup_getter else _markup_getter(_ctx), - store=db) - return self.invoke(state, ctx) - return _invoke - - def invoke(self, state, ctx: Context): - self._logger.debug(f'invoke, state={state}') - for item in dir(self): - f = getattr(self, item) - if not callable(f) or item.startswith('_') or '_conv_data' not in f.__dict__: - continue - cd = f.__dict__['_conv_data'] - if cd['enter'] and cd['state'] == state: - return cd['orig_f'](self, ctx) - - raise RuntimeError(f'invoke: failed to find method for state {state}') - - def get_handler(self) -> ConversationHandler: - entry_points = [] - states = {} - - l_cancel_filter = _cancel_filter if not self._back_enabled else _cancel_and_back_filter - - for item in dir(self): - f = getattr(self, item) - if not callable(f) or item.startswith('_') or '_conv_data' not in f.__dict__: - continue - - cd = f.__dict__['_conv_data'] - - if cd['type'] == ConversationMethodType.ENTRY: - entry_points = self.make_handlers(f, **cd) - elif cd['type'] == ConversationMethodType.STATE_HANDLER: - states[cd['state']] = self.make_handlers(f, **cd) - states[cd['state']].append( - MessageHandler(_user_filter & ~l_cancel_filter, conversation.invalid) - ) - - fallbacks = [MessageHandler(_user_filter & _cancel_filter, self.cancel)] - if self._back_enabled: - fallbacks.append(MessageHandler(_user_filter & _back_filter, self.back)) - - return ConversationHandler( - entry_points=entry_points, - states=states, - fallbacks=fallbacks - ) - - def get_user_state(self, user_id: int) -> Optional[int]: - if user_id not in self._user_state_cache: - return None - return self._user_state_cache[user_id] - - # TODO store in ctx.user_state - def set_user_state(self, user_id: int, state: Union[int, None]): - if not self._back_enabled: - return - if state is not None: - self._user_state_cache[user_id] = state - else: - del self._user_state_cache[user_id] - - @staticmethod - @simplehandler - def invalid(ctx: Context): - ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup()) - # return 0 # FIXME is this needed - - @simplehandler - def cancel(self, ctx: Context): - start(ctx) - self.set_user_state(ctx.user_id, None) - return conversation.END - - @simplehandler - def back(self, ctx: Context): - cur_state = self.get_user_state(ctx.user_id) - if cur_state is None: - start(ctx) - self.set_user_state(ctx.user_id, None) - return conversation.END - - new_state = None - for seq in self.STATE_SEQS: - if cur_state in seq: - idx = seq.index(cur_state) - if idx > 0: - return self.invoke(seq[idx-1], ctx) - - if new_state is None: - raise RuntimeError('failed to determine state to go back to') - - @classmethod - def add_cancel_button(cls, ctx: Context, buttons): - buttons.append([ctx.lang('cancel')]) - - @classmethod - def add_back_button(cls, ctx: Context, buttons): - # buttons.insert(0, [ctx.lang('back')]) - buttons.append([ctx.lang('back')]) - - def reply(self, - ctx: Context, - state: Union[int, Enum], - text: str, - buttons: Optional[list], - with_cancel=False, - with_back=False, - buttons_lang_completed=False): - - if buttons: - new_buttons = [] - if not buttons_lang_completed: - for item in buttons: - if isinstance(item, list): - item = map(lambda s: ctx.lang(s), item) - new_buttons.append(list(item)) - elif isinstance(item, str): - new_buttons.append([ctx.lang(item)]) - else: - raise ValueError('invalid type: ' + type(item)) - else: - new_buttons = list(buttons) - - buttons = None - else: - if with_cancel or with_back: - new_buttons = [] - else: - new_buttons = None - - if with_cancel: - self.add_cancel_button(ctx, new_buttons) - if with_back: - if not self._back_enabled: - raise AttributeError(f'back is not enabled for this conversation ({self.__class__.__name__})') - self.add_back_button(ctx, new_buttons) - - markup = ReplyKeyboardMarkup(new_buttons, one_time_keyboard=True) if new_buttons else IgnoreMarkup() - ctx.reply(text, markup=markup) - self.set_user_state(ctx.user_id, state) - return state - - -class LangConversation(conversation): - START, = range(1) - - @conventer(START, command='lang') - def entry(self, ctx: Context): - self._logger.debug(f'current language: {ctx.user_lang}') - - buttons = [] - for name in languages.values(): - buttons.append(name) - markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False) - - ctx.reply(ctx.lang('select_language'), markup=markup) - return self.START - - @convinput(START, messages=lang.languages) - def input(self, ctx: Context): - selected_lang = None - for key, value in languages.items(): - if value == ctx.text: - selected_lang = key - break - - if selected_lang is None: - raise ValueError('could not find the language') - - db.set_user_lang(ctx.user_id, selected_lang) - ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup()) - - return self.END - - -def initialize(): - global _user_filter - global _updater - global _dispatcher - - # init user_filter - if 'users' in config['bot']: - _logger.info('allowed users: ' + str(config['bot']['users'])) - _user_filter = Filters.user(config['bot']['users']) - else: - _user_filter = Filters.all # not sure if this is correct - - # init updater - _updater = Updater(config['bot']['token'], - request_kwargs={'read_timeout': 6, 'connect_timeout': 7}) - - # transparently log all messages - _updater.dispatcher.add_handler(MessageHandler(Filters.all & _user_filter, _logging_message_handler), group=10) - _updater.dispatcher.add_handler(CallbackQueryHandler(_logging_callback_handler), group=10) - - -def run(start_handler=None, any_handler=None): - global db - global _start_handler_ref - - if not start_handler: - start_handler = _default_start_handler - if not any_handler: - any_handler = _default_any_handler - if not db: - db = BotDatabase() - - _start_handler_ref = start_handler - - _updater.dispatcher.add_handler(LangConversation().get_handler(), group=0) - _updater.dispatcher.add_handler(CommandHandler('start', simplehandler(start_handler), _user_filter)) - _updater.dispatcher.add_handler(MessageHandler(Filters.all & _user_filter, any_handler)) - - _updater.start_polling() - _updater.idle() - - -def add_conversation(conv: conversation) -> None: - _updater.dispatcher.add_handler(conv.get_handler(), group=0) - - -def add_handler(h): - _updater.dispatcher.add_handler(h, group=0) - - -def start(ctx: Context): - return _start_handler_ref(ctx) - - -def _default_start_handler(ctx: Context): - if 'start_message' not in lang: - return ctx.reply('Please define start_message or override start()') - ctx.reply(ctx.lang('start_message')) - - -@simplehandler -def _default_any_handler(ctx: Context): - if 'invalid_command' not in lang: - return ctx.reply('Please define invalid_command or override any()') - ctx.reply(ctx.lang('invalid_command')) - - -def _logging_message_handler(update: Update, context: CallbackContext): - if _reporting: - _reporting.report(update.message) - - -def _logging_callback_handler(update: Update, context: CallbackContext): - if _reporting: - _reporting.report(update.callback_query.message, text=update.callback_query.data) - - -def enable_logging(bot_type: BotType): - api = WebAPIClient(timeout=3) - api.enable_async() - - global _reporting - _reporting = ReportingHelper(api, bot_type) - - -def notify_all(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 - - for user_id in config['bot']['notify_users']: - if user_id in exclude: - continue - - text = text_getter(db.get_user_lang(user_id)) - _updater.bot.send_message(chat_id=user_id, - text=text, - parse_mode='HTML') - - -def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> None: - if isinstance(text, Exception): - text = exc2text(text) - _updater.bot.send_message(chat_id=user_id, - text=text, - parse_mode='HTML', - **kwargs) - - -def send_photo(user_id, **kwargs): - _updater.bot.send_photo(chat_id=user_id, **kwargs) - - -def send_audio(user_id, **kwargs): - _updater.bot.send_audio(chat_id=user_id, **kwargs) - - -def send_file(user_id, **kwargs): - _updater.bot.send_document(chat_id=user_id, **kwargs) - - -def edit_message_text(user_id, message_id, *args, **kwargs): - _updater.bot.edit_message_text(chat_id=user_id, - message_id=message_id, - parse_mode='HTML', - *args, **kwargs) - - -def delete_message(user_id, message_id): - _updater.bot.delete_message(chat_id=user_id, message_id=message_id) - - -def set_database(_db: BotDatabase): - global db - db = _db - diff --git a/src/home/telegram/telegram.py b/src/home/telegram/telegram.py deleted file mode 100644 index 2f94f93..0000000 --- a/src/home/telegram/telegram.py +++ /dev/null @@ -1,47 +0,0 @@ -import requests -import logging - -from typing import Tuple -from ..config import config - - -_logger = logging.getLogger(__name__) - - -def send_message(text: str, - parse_mode: str = None, - disable_web_page_preview: bool = False): - data, token = _send_telegram_data(text, parse_mode, disable_web_page_preview) - req = requests.post('https://api.telegram.org/bot%s/sendMessage' % token, data=data) - return req.json() - - -def send_photo(filename: str): - data = { - 'chat_id': config['telegram']['chat_id'], - } - token = config['telegram']['token'] - - url = f'https://api.telegram.org/bot{token}/sendPhoto' - with open(filename, "rb") as fd: - req = requests.post(url, data=data, files={"photo": fd}) - return req.json() - - -def _send_telegram_data(text: str, - parse_mode: str = None, - disable_web_page_preview: bool = False) -> Tuple[dict, str]: - data = { - 'chat_id': config['telegram']['chat_id'], - 'text': text - } - - if parse_mode is not None: - data['parse_mode'] = parse_mode - elif 'parse_mode' in config['telegram']: - data['parse_mode'] = config['telegram']['parse_mode'] - - if disable_web_page_preview or 'disable_web_page_preview' in config['telegram']: - data['disable_web_page_preview'] = 1 - - return data, config['telegram']['token'] diff --git a/src/home/temphum/__init__.py b/src/home/temphum/__init__.py deleted file mode 100644 index 55a7e1f..0000000 --- a/src/home/temphum/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from .base import SensorType, TempHumSensor -from .si7021 import Si7021 -from .dht12 import DHT12 - -__all__ = [ - 'SensorType', - 'TempHumSensor', - 'create_sensor' -] - - -def create_sensor(type: SensorType, bus: int) -> TempHumSensor: - if type == SensorType.Si7021: - return Si7021(bus) - elif type == SensorType.DHT12: - return DHT12(bus) - else: - raise ValueError('unexpected sensor type') diff --git a/src/home/temphum/base.py b/src/home/temphum/base.py deleted file mode 100644 index e774433..0000000 --- a/src/home/temphum/base.py +++ /dev/null @@ -1,25 +0,0 @@ -import smbus - -from abc import abstractmethod, ABC -from enum import Enum - - -class TempHumSensor: - @abstractmethod - def humidity(self) -> float: - pass - - @abstractmethod - def temperature(self) -> float: - pass - - -class I2CTempHumSensor(TempHumSensor, ABC): - def __init__(self, bus: int): - super().__init__() - self.bus = smbus.SMBus(bus) - - -class SensorType(Enum): - Si7021 = 'si7021' - DHT12 = 'dht12' diff --git a/src/home/temphum/dht12.py b/src/home/temphum/dht12.py deleted file mode 100644 index d495766..0000000 --- a/src/home/temphum/dht12.py +++ /dev/null @@ -1,22 +0,0 @@ -from .base import I2CTempHumSensor - - -class DHT12(I2CTempHumSensor): - i2c_addr = 0x5C - - def _measure(self): - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0, 5) - if (raw[0] + raw[1] + raw[2] + raw[3]) & 0xff != raw[4]: - raise ValueError("checksum error") - return raw - - def temperature(self) -> float: - raw = self._measure() - temp = raw[2] + (raw[3] & 0x7f) * 0.1 - if raw[3] & 0x80: - temp *= -1 - return temp - - def humidity(self) -> float: - raw = self._measure() - return raw[0] + raw[1] * 0.1 diff --git a/src/home/temphum/si7021.py b/src/home/temphum/si7021.py deleted file mode 100644 index 6289e15..0000000 --- a/src/home/temphum/si7021.py +++ /dev/null @@ -1,13 +0,0 @@ -from .base import I2CTempHumSensor - - -class Si7021(I2CTempHumSensor): - i2c_addr = 0x40 - - def temperature(self) -> float: - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE3, 2) - return 175.72 * (raw[0] << 8 | raw[1]) / 65536.0 - 46.85 - - def humidity(self) -> float: - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE5, 2) - return 125.0 * (raw[0] << 8 | raw[1]) / 65536.0 - 6.0 diff --git a/src/home/util.py b/src/home/util.py deleted file mode 100644 index 93a9d8f..0000000 --- a/src/home/util.py +++ /dev/null @@ -1,196 +0,0 @@ -import json -import socket -import time -import subprocess -import traceback -import logging -import string -import random - -from enum import Enum -from datetime import datetime -from typing import Tuple, Optional, List -from zlib import adler32 - -Addr = Tuple[str, int] # network address type (host, port) - -logger = logging.getLogger(__name__) - - -# https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks -def chunks(lst, n): - """Yield successive n-sized chunks from lst.""" - for i in range(0, len(lst), n): - yield lst[i:i + n] - - -def json_serial(obj): - """JSON serializer for datetime objects""" - if isinstance(obj, datetime): - return obj.timestamp() - if isinstance(obj, Enum): - return obj.value - raise TypeError("Type %s not serializable" % type(obj)) - - -def stringify(v) -> str: - return json.dumps(v, separators=(',', ':'), default=json_serial) - - -def ipv4_valid(ip: str) -> bool: - try: - socket.inet_aton(ip) - return True - except socket.error: - return False - - -def parse_addr(addr: str) -> Addr: - if addr.count(':') != 1: - raise ValueError('invalid host:port format') - - host, port = addr.split(':') - if not ipv4_valid(host): - raise ValueError('invalid ipv4 address') - - port = int(port) - if not 0 <= port <= 65535: - raise ValueError('invalid port') - - return host, port - - -def strgen(n: int): - return ''.join(random.choices(string.ascii_letters + string.digits, k=n)) - - -class MySimpleSocketClient: - host: str - port: int - - def __init__(self, host: str, port: int): - self.host = host - self.port = port - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.connect((self.host, self.port)) - self.sock.settimeout(5) - - def __del__(self): - self.sock.close() - - def write(self, line: str) -> None: - self.sock.sendall((line + '\r\n').encode()) - - def read(self) -> str: - buf = bytearray() - while True: - buf.extend(self.sock.recv(256)) - if b'\r\n' in buf: - break - - response = buf.decode().strip() - return response - - -def send_datagram(message: str, addr: Addr) -> None: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.sendto(message.encode(), addr) - - -def format_tb(exc) -> Optional[List[str]]: - tb = traceback.format_tb(exc.__traceback__) - if not tb: - return None - - tb = list(map(lambda s: s.strip(), tb)) - tb.reverse() - if tb[0][-1:] == ':': - tb[0] = tb[0][:-1] - - return tb - - -class ChildProcessInfo: - pid: int - cmd: str - - def __init__(self, - pid: int, - cmd: str): - self.pid = pid - self.cmd = cmd - - -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}') - - children = [] - - lines = p.stdout.decode().strip().split('\n') - for line in lines: - try: - space_idx = line.index(' ') - except ValueError as exc: - logger.exception(exc) - continue - - pid = int(line[0:space_idx]) - cmd = line[space_idx+1:] - - children.append(ChildProcessInfo(pid, cmd)) - - return children - - -class Stopwatch: - elapsed: float - time_started: Optional[float] - - def __init__(self): - self.elapsed = 0 - self.time_started = None - - def go(self): - if self.time_started is not None: - raise StopwatchError('stopwatch was already started') - - self.time_started = time.time() - - def pause(self): - if self.time_started is None: - raise StopwatchError('stopwatch was paused') - - self.elapsed += time.time() - self.time_started - self.time_started = None - - def get_elapsed_time(self): - elapsed = self.elapsed - if self.time_started is not None: - elapsed += time.time() - self.time_started - return elapsed - - def reset(self): - self.time_started = None - self.elapsed = 0 - - def is_paused(self): - return self.time_started is None - - -class StopwatchError(RuntimeError): - pass - - -def filesize_fmt(num, suffix="B") -> str: - for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: - if abs(num) < 1024.0: - return f"{num:3.1f} {unit}{suffix}" - num /= 1024.0 - return f"{num:.1f} Yi{suffix}" - - -class HashableEnum(Enum): - def hash(self) -> int: - return adler32(self.name.encode())
\ No newline at end of file diff --git a/src/inverter_bot.py b/src/inverter_bot.py deleted file mode 100755 index fd5acf3..0000000 --- a/src/inverter_bot.py +++ /dev/null @@ -1,890 +0,0 @@ -#!/usr/bin/env python3 -import logging -import re -import datetime -import json -import itertools - -from inverterd import Format, InverterError -from html import escape -from typing import Optional, Tuple, Union - -from home.util import chunks -from home.config import config -from home.telegram import bot -from home.inverter import ( - wrapper_instance as inverter, - beautify_table, - InverterMonitor, -) -from home.inverter.types import ( - ChargingEvent, - ACPresentEvent, - BatteryState, - ACMode, - OutputSourcePriority -) -from home.database.inverter_time_formats import * -from home.api.types import BotType -from home.api import WebAPIClient -from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton - -monitor: Optional[InverterMonitor] = None -db = None -LT = escape('<=') -flags_map = { - 'buzzer': 'BUZZ', - 'overload_bypass': 'OLBP', - 'escape_to_default_screen_after_1min_timeout': 'LCDE', - 'overload_restart': 'OLRS', - 'over_temp_restart': 'OTRS', - 'backlight_on': 'BLON', - 'alarm_on_on_primary_source_interrupt': 'ALRM', - 'fault_code_record': 'FTCR', -} - -logger = logging.getLogger(__name__) -config.load('inverter_bot') - -bot.initialize() -bot.lang.ru( - socket="В розетке", - status='Статус', - generation='Генерация', - priority='Приоритет', - battery="АКБ", - load="Нагрузка", - generator="Генератор", - utilities="Столб", - consumption="Статистика потребления", - settings="Настройки", - done="Готово", - unexpected_callback_data="Ошибка: неверные данные", - invalid_input="Неверное значение", - invalid_mode="Invalid mode", - - flags_press_button='Нажмите кнопку для переключения настройки', - flags_fail='Не удалось установить настройку', - flags_invalid='Неизвестная настройка', - - # generation - gen_input_power='Зарядная мощность', - - # settings - settings_msg="Что вы хотите настроить?", - settings_osp='Приоритет питания нагрузки', - settings_ac_preset="Применить шаблон режима AC", - settings_bat_thresholds="Пороги заряда АКБ от AC", - settings_bat_cut_off_voltage="Порог отключения АКБ", - settings_ac_max_charging_current="Максимальный ток заряда от AC", - - settings_osp_msg="Установите приоритет:", - settings_osp_sub='Solar-Utility-Battery', - settings_osp_sbu='Solar-Battery-Utility', - - settings_select_bottom_threshold="Выберите нижний порог:", - settings_select_upper_threshold="Выберите верхний порог:", - settings_select_max_current='Выберите максимальный ток:', - settings_enter_cutoff_voltage=f'Введите напряжение V, где 40.0 {LT} V {LT} 48.0', - - # time and date - today='Сегодня', - yday1='Вчера', - yday2='Позавчера', - for_7days='За 7 дней', - for_30days='За 30 дней', - # to_select_interval='Выбрать интервал', - - # consumption - consumption_msg="Выберите тип:", - consumption_total="Домашние приборы", - consumption_grid="Со столба", - consumption_select_interval='Выберите период:', - consumption_request_sent="⏳ Запрос отправлен...", - - # status - charging_at=', ', - pd_charging='заряжается', - pd_discharging='разряжается', - pd_nothing='не используется', - - # flags - flag_buzzer='Звуковой сигнал', - flag_overload_bypass='Разрешить перегрузку', - flag_escape_to_default_screen_after_1min_timeout='Возврат на главный экран через 1 минуту', - flag_overload_restart='Перезапуск при перегрузке', - flag_over_temp_restart='Перезапуск при перегреве', - flag_backlight_on='Подсветка экрана', - flag_alarm_on_on_primary_source_interrupt='Сигнал при разрыве основного источника питания', - flag_fault_code_record='Запись кодов ошибок', - - # commands - setbatuv_v=f'напряжение, 40.0 {LT} V {LT} 48.0', - setgenct_cv=f'напряжение включения заряда, 44 {LT} CV {LT} 51', - setgenct_dv=f'напряжение отключения заряда, 48 {LT} DV {LT} 58', - setgencc_a='максимальный ток заряда, допустимые значения: %s', - - # monitor - chrg_evt_started='✅ Начали заряжать от генератора.', - chrg_evt_finished='✅ Зарядили. Генератор пора выключать.', - chrg_evt_disconnected='ℹ️ Генератор отключен.', - chrg_evt_current_changed='ℹ️ Ток заряда от генератора установлен в %d A.', - chrg_evt_not_charging='ℹ️ Генератор подключен, но не заряжает.', - chrg_evt_na_solar='⛔️ Генератор подключен, но аккумуляторы не заряжаются из-за подключенных панелей.', - chrg_evt_mostly_charged='✅ Аккумуляторы более-менее заряжены, генератор пора выключать.', - battery_level_changed='Уровень заряда АКБ: <b>%s %s</b> (<b>%0.1f V</b> при нагрузке <b>%d W</b>)', - error_message='<b>Ошибка:</b> %s.', - - util_chrg_evt_started='✅ Начали заряжать от столба.', - util_chrg_evt_stopped='ℹ️ Перестали заряжать от столба.', - util_chrg_evt_stopped_solar='ℹ️ Перестали заряжать от столба из-за подключения панелей.', - - util_connected='✅️ Столб подключён.', - util_disconnected='‼️ Столб отключён.', - - # other notifications - ac_mode_changed_notification='Пользователь <a href="tg://user?id=%d">%s</a> установил режим AC: <b>%s</b>.', - osp_changed_notification='Пользователь <a href="tg://user?id=%d">%s</a> установил приоритет источника питания нагрузки: <b>%s</b>.', - osp_auto_changed_notification='ℹ️ Бот установил приоритет источника питания нагрузки: <b>%s</b>. Причины: напряжение АКБ %.1f V, мощность заряда с панелей %d W.', - - bat_state_normal='Нормальный', - bat_state_low='Низкий', - bat_state_critical='Критический', -) - -bot.lang.en( - socket='AC output', - status='Status', - generation='Generation', - priority='Priority', - battery="Battery", - load="Load", - generator="Generator", - utilities="Utilities", - consumption="Consumption statistics", - settings="Settings", - done="Done", - unexpected_callback_data="Unexpected callback data", - select_priortiy="Select priority:", - invalid_input="Invalid input", - invalid_mode="Invalid mode", - - flags_press_button='Press a button to toggle a flag.', - flags_fail='Failed to toggle flag', - flags_invalid='Invalid flag', - - # settings - settings_msg='What do you want to configure?', - settings_osp='Output source priority', - settings_ac_preset="AC preset", - settings_bat_thresholds="Battery charging thresholds", - settings_bat_cut_off_voltage="Battery cut-off voltage", - settings_ac_max_charging_current="Max AC charging current", - - settings_osp_msg="Select priority:", - settings_osp_sub='Solar-Utility-Battery', - settings_osp_sbu='Solar-Battery-Utility', - - settings_select_bottom_threshold="Select bottom (lower) threshold:", - settings_select_upper_threshold="Select top (upper) threshold:", - settings_select_max_current='Select max current:', - settings_enter_cutoff_voltage=f'Enter voltage V (40.0 {LT} V {LT} 48.0):', - - # generation - gen_input_power='Input power', - - # time and date - today='Today', - yday1='Yesterday', - yday2='The day before yesterday', - for_7days='7 days', - for_30days='30 days', - # to_select_interval='Select interval', - - # consumption - consumption_msg="Select type:", - consumption_total="Home appliances", - consumption_grid="Consumed from grid", - consumption_select_interval='Select period:', - consumption_request_sent="⏳ Request sent...", - - # status - charging_at=' @ ', - pd_charging='charging', - pd_discharging='discharging', - pd_nothing='not used', - - # flags - flag_buzzer='Buzzer', - flag_overload_bypass='Overload bypass', - flag_escape_to_default_screen_after_1min_timeout='Reset to default LCD page after 1min timeout', - flag_overload_restart='Restart on overload', - flag_over_temp_restart='Restart on overtemp', - flag_backlight_on='LCD backlight', - flag_alarm_on_on_primary_source_interrupt='Beep on primary source interruption', - flag_fault_code_record='Fault code recording', - - # commands - setbatuv_v=f'floating point number, 40.0 {LT} V {LT} 48.0', - setgenct_cv=f'charging voltage, 44 {LT} CV {LT} 51', - setgenct_dv=f'discharging voltage, 48 {LT} DV {LT} 58', - setgencc_a='max charging current, allowed values: %s', - - # monitor - chrg_evt_started='✅ Started charging from AC.', - chrg_evt_finished='✅ Finished charging, it\'s time to stop the generator.', - chrg_evt_disconnected='ℹ️ AC disconnected.', - chrg_evt_current_changed='ℹ️ AC charging current set to %d A.', - chrg_evt_not_charging='ℹ️ AC connected but not charging.', - chrg_evt_na_solar='⛔️ AC connected, but battery won\'t be charged due to active solar power line.', - chrg_evt_mostly_charged='✅ The battery is mostly charged now. The generator can be turned off.', - battery_level_changed='Battery level: <b>%s</b> (<b>%0.1f V</b> under <b>%d W</b> load)', - error_message='<b>Error:</b> %s.', - - util_chrg_evt_started='✅ Started charging from utilities.', - util_chrg_evt_stopped='ℹ️ Stopped charging from utilities.', - util_chrg_evt_stopped_solar='ℹ️ Stopped charging from utilities because solar panels were connected.', - - util_connected='✅️ Utilities connected.', - util_disconnected='‼️ Utilities disconnected.', - - # other notifications - ac_mode_changed_notification='User <a href="tg://user?id=%d">%s</a> set AC mode to <b>%s</b>.', - osp_changed_notification='User <a href="tg://user?id=%d">%s</a> set output source priority: <b>%s</b>.', - osp_auto_changed_notification='Bot changed output source priority to <b>%s</b>. Reasons: battery voltage is %.1f V, solar input is %d W.', - - bat_state_normal='Normal', - bat_state_low='Low', - bat_state_critical='Critical', -) - - -def monitor_charging(event: ChargingEvent, **kwargs) -> None: - args = [] - is_util = False - if event == ChargingEvent.AC_CHARGING_STARTED: - key = 'started' - elif event == ChargingEvent.AC_CHARGING_FINISHED: - key = 'finished' - elif event == ChargingEvent.AC_DISCONNECTED: - key = 'disconnected' - elif event == ChargingEvent.AC_NOT_CHARGING: - key = 'not_charging' - elif event == ChargingEvent.AC_CURRENT_CHANGED: - key = 'current_changed' - args.append(kwargs['current']) - elif event == ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR: - key = 'na_solar' - elif event == ChargingEvent.AC_MOSTLY_CHARGED: - key = 'mostly_charged' - elif event == ChargingEvent.UTIL_CHARGING_STARTED: - key = 'started' - is_util = True - elif event == ChargingEvent.UTIL_CHARGING_STOPPED: - key = 'stopped' - is_util = True - elif event == ChargingEvent.UTIL_CHARGING_STOPPED_SOLAR: - key = 'stopped_solar' - is_util = True - else: - logger.error('unknown charging event:', event) - return - - key = f'chrg_evt_{key}' - if is_util: - key = f'util_{key}' - bot.notify_all( - lambda lang: bot.lang.get(key, lang, *args) - ) - - -def monitor_battery(state: BatteryState, v: float, load_watts: int) -> None: - if state == BatteryState.NORMAL: - emoji = '✅' - elif state == BatteryState.LOW: - emoji = '⚠️' - elif state == BatteryState.CRITICAL: - emoji = '‼️' - else: - logger.error('unknown battery state:', state) - return - - bot.notify_all( - lambda lang: bot.lang.get('battery_level_changed', lang, - emoji, bot.lang.get(f'bat_state_{state.name.lower()}', lang), v, load_watts) - ) - - -def monitor_util(event: ACPresentEvent): - if event == ACPresentEvent.CONNECTED: - key = 'connected' - else: - key = 'disconnected' - key = f'util_{key}' - bot.notify_all( - lambda lang: bot.lang.get(key, lang) - ) - - -def monitor_error(error: str) -> None: - bot.notify_all( - lambda lang: bot.lang.get('error_message', lang, error) - ) - - -def osp_change_cb(new_osp: OutputSourcePriority, - solar_input: int, - v: float): - - setosp(new_osp) - - bot.notify_all( - lambda lang: bot.lang.get('osp_auto_changed_notification', lang, - bot.lang.get(f'settings_osp_{new_osp.value.lower()}', lang), v, solar_input), - ) - - -@bot.handler(command='status') -def full_status(ctx: bot.Context) -> None: - status = inverter.exec('get-status', format=Format.TABLE) - ctx.reply(beautify_table(status)) - - -@bot.handler(command='config') -def full_rated(ctx: bot.Context) -> None: - rated = inverter.exec('get-rated', format=Format.TABLE) - ctx.reply(beautify_table(rated)) - - -@bot.handler(command='errors') -def full_errors(ctx: bot.Context) -> None: - errors = inverter.exec('get-errors', format=Format.TABLE) - ctx.reply(beautify_table(errors)) - - -@bot.handler(command='flags') -def flags_handler(ctx: bot.Context) -> None: - flags = inverter.exec('get-flags')['data'] - text, markup = build_flags_keyboard(flags, ctx) - ctx.reply(text, markup=markup) - - -def build_flags_keyboard(flags: dict, ctx: bot.Context) -> Tuple[str, InlineKeyboardMarkup]: - keyboard = [] - for k, v in flags.items(): - label = ('✅' if v else '❌') + ' ' + ctx.lang(f'flag_{k}') - proto_flag = flags_map[k] - keyboard.append([InlineKeyboardButton(label, callback_data=f'flag_{proto_flag}')]) - - return ctx.lang('flags_press_button'), InlineKeyboardMarkup(keyboard) - - -def getacmode() -> ACMode: - return ACMode(bot.db.get_param('ac_mode', default=ACMode.GENERATOR)) - - -def setacmode(mode: ACMode): - monitor.set_ac_mode(mode) - - cv, dv = config['ac_mode'][str(mode.value)]['thresholds'] - a = config['ac_mode'][str(mode.value)]['initial_current'] - - logger.debug(f'setacmode: mode={mode}, cv={cv}, dv={dv}, a={a}') - - inverter.exec('set-charge-thresholds', (cv, dv)) - inverter.exec('set-max-ac-charge-current', (0, a)) - - -def setosp(sp: OutputSourcePriority): - logger.debug(f'setosp: sp={sp}') - inverter.exec('set-output-source-priority', (sp.value,)) - monitor.notify_osp(sp) - - -class SettingsConversation(bot.conversation): - START, OSP, AC_PRESET, BAT_THRESHOLDS_1, BAT_THRESHOLDS_2, BAT_CUT_OFF_VOLTAGE, AC_MAX_CHARGING_CURRENT = range(7) - STATE_SEQS = [ - [START, OSP], - [START, AC_PRESET], - [START, BAT_THRESHOLDS_1, BAT_THRESHOLDS_2], - [START, BAT_CUT_OFF_VOLTAGE], - [START, AC_MAX_CHARGING_CURRENT] - ] - - START_BUTTONS = bot.lang.pfx('settings_', ['ac_preset', - 'ac_max_charging_current', - 'bat_thresholds', - 'bat_cut_off_voltage', - 'osp']) - OSP_BUTTONS = bot.lang.pfx('settings_osp_', [sp.value.lower() for sp in OutputSourcePriority]) - AC_PRESET_BUTTONS = [mode.value for mode in ACMode] - - RECHARGE_VOLTAGES = [44, 45, 46, 47, 48, 49, 50, 51] - REDISCHARGE_VOLTAGES = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58] - - @bot.conventer(START, message='settings') - def start_enter(self, ctx: bot.Context): - buttons = list(chunks(list(self.START_BUTTONS), 2)) - buttons.reverse() - return self.reply(ctx, self.START, ctx.lang('settings_msg'), buttons, - with_cancel=True) - - @bot.convinput(START, messages={ - 'settings_osp': OSP, - 'settings_ac_preset': AC_PRESET, - 'settings_bat_thresholds': BAT_THRESHOLDS_1, - 'settings_bat_cut_off_voltage': BAT_CUT_OFF_VOLTAGE, - 'settings_ac_max_charging_current': AC_MAX_CHARGING_CURRENT - }) - def start_input(self, ctx: bot.Context): - pass - - @bot.conventer(OSP) - def osp_enter(self, ctx: bot.Context): - return self.reply(ctx, self.OSP, ctx.lang('settings_osp_msg'), self.OSP_BUTTONS, - with_back=True) - - @bot.convinput(OSP, messages=OSP_BUTTONS) - def osp_input(self, ctx: bot.Context): - selected_sp = None - for sp in OutputSourcePriority: - if ctx.text == ctx.lang(f'settings_osp_{sp.value.lower()}'): - selected_sp = sp - break - - if selected_sp is None: - raise ValueError('invalid sp') - - # apply the mode - setosp(selected_sp) - - # reply to user - ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()) - - # notify other users - bot.notify_all( - lambda lang: bot.lang.get('osp_changed_notification', lang, - ctx.user.id, ctx.user.name, - bot.lang.get(f'settings_osp_{selected_sp.value.lower()}', lang)), - exclude=(ctx.user_id,) - ) - return self.END - - @bot.conventer(AC_PRESET) - def acpreset_enter(self, ctx: bot.Context): - return self.reply(ctx, self.AC_PRESET, ctx.lang('settings_ac_preset_msg'), self.AC_PRESET_BUTTONS, - with_back=True) - - @bot.convinput(AC_PRESET, messages=AC_PRESET_BUTTONS) - def acpreset_input(self, ctx: bot.Context): - if monitor.active_current is not None: - raise RuntimeError('generator charging program is active') - - if ctx.text == ctx.lang('utilities'): - newmode = ACMode.UTILITIES - elif ctx.text == ctx.lang('generator'): - newmode = ACMode.GENERATOR - else: - raise ValueError('invalid mode') - - # apply the mode - setacmode(newmode) - - # save - bot.db.set_param('ac_mode', str(newmode.value)) - - # reply to user - ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()) - - # notify other users - bot.notify_all( - lambda lang: bot.lang.get('ac_mode_changed_notification', lang, - ctx.user.id, ctx.user.name, - bot.lang.get(str(newmode.value), lang)), - exclude=(ctx.user_id,) - ) - return self.END - - @bot.conventer(BAT_THRESHOLDS_1) - def thresholds1_enter(self, ctx: bot.Context): - buttons = list(map(lambda v: f'{v} V', self.RECHARGE_VOLTAGES)) - buttons = chunks(buttons, 4) - return self.reply(ctx, self.BAT_THRESHOLDS_1, ctx.lang('settings_select_bottom_threshold'), buttons, - with_back=True, buttons_lang_completed=True) - - @bot.convinput(BAT_THRESHOLDS_1, - messages=list(map(lambda n: f'{n} V', RECHARGE_VOLTAGES)), - messages_lang_completed=True) - def thresholds1_input(self, ctx: bot.Context): - v = self._parse_voltage(ctx.text) - ctx.user_data['bat_thrsh_v1'] = v - return self.invoke(self.BAT_THRESHOLDS_2, ctx) - - @bot.conventer(BAT_THRESHOLDS_2) - def thresholds2_enter(self, ctx: bot.Context): - buttons = list(map(lambda v: f'{v} V', self.REDISCHARGE_VOLTAGES)) - buttons = chunks(buttons, 4) - return self.reply(ctx, self.BAT_THRESHOLDS_2, ctx.lang('settings_select_upper_threshold'), buttons, - with_back=True, buttons_lang_completed=True) - - @bot.convinput(BAT_THRESHOLDS_2, - messages=list(map(lambda n: f'{n} V', REDISCHARGE_VOLTAGES)), - messages_lang_completed=True) - def thresholds2_input(self, ctx: bot.Context): - v2 = v = self._parse_voltage(ctx.text) - v1 = ctx.user_data['bat_thrsh_v1'] - del ctx.user_data['bat_thrsh_v1'] - - response = inverter.exec('set-charge-thresholds', (v1, v2)) - ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', - markup=bot.IgnoreMarkup()) - return self.END - - @bot.conventer(AC_MAX_CHARGING_CURRENT) - def ac_max_enter(self, ctx: bot.Context): - buttons = self._get_allowed_ac_charge_amps() - buttons = map(lambda n: f'{n} A', buttons) - buttons = [list(buttons)] - return self.reply(ctx, self.AC_MAX_CHARGING_CURRENT, ctx.lang('settings_select_max_current'), buttons, - with_back=True, buttons_lang_completed=True) - - @bot.convinput(AC_MAX_CHARGING_CURRENT, regex=r'^\d+ A$') - def ac_max_input(self, ctx: bot.Context): - a = self._parse_amps(ctx.text) - allowed = self._get_allowed_ac_charge_amps() - if a not in allowed: - raise ValueError('input is not allowed') - - response = inverter.exec('set-max-ac-charge-current', (0, a)) - ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', - markup=bot.IgnoreMarkup()) - return self.END - - @bot.conventer(BAT_CUT_OFF_VOLTAGE) - def cutoff_enter(self, ctx: bot.Context): - return self.reply(ctx, self.BAT_CUT_OFF_VOLTAGE, ctx.lang('settings_enter_cutoff_voltage'), None, - with_back=True) - - @bot.convinput(BAT_CUT_OFF_VOLTAGE, regex=r'^(\d{2}(\.\d{1})?)$') - def cutoff_input(self, ctx: bot.Context): - v = float(ctx.text) - if 40.0 <= v <= 48.0: - response = inverter.exec('set-battery-cutoff-voltage', (v,)) - ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', - markup=bot.IgnoreMarkup()) - else: - raise ValueError('invalid voltage') - - return self.END - - def _get_allowed_ac_charge_amps(self) -> list[int]: - l = inverter.exec('get-allowed-ac-charge-currents')['data'] - l = filter(lambda n: n <= 40, l) - return list(l) - - def _parse_voltage(self, s: str) -> int: - return int(re.match(r'^(\d{2}) V$', s).group(1)) - - def _parse_amps(self, s: str) -> int: - return int(re.match(r'^(\d{1,2}) A$', s).group(1)) - - -class ConsumptionConversation(bot.conversation): - START, TOTAL, GRID = range(3) - STATE_SEQS = [ - [START, TOTAL], - [START, GRID] - ] - - START_BUTTONS = bot.lang.pfx('consumption_', ['total', 'grid']) - INTERVAL_BUTTONS = [ - ['today'], - ['yday1'], - ['for_7days', 'for_30days'], - # ['to_select_interval'] - ] - INTERVAL_BUTTONS_FLAT = list(itertools.chain.from_iterable(INTERVAL_BUTTONS)) - - @bot.conventer(START, message='consumption') - def start_enter(self, ctx: bot.Context): - return self.reply(ctx, self.START, ctx.lang('consumption_msg'), [self.START_BUTTONS], - with_cancel=True) - - @bot.convinput(START, messages={ - 'consumption_total': TOTAL, - 'consumption_grid': GRID - }) - def start_input(self, ctx: bot.Context): - pass - - @bot.conventer(TOTAL) - def total_enter(self, ctx: bot.Context): - return self._render_interval_btns(ctx, self.TOTAL) - - @bot.conventer(GRID) - def grid_enter(self, ctx: bot.Context): - return self._render_interval_btns(ctx, self.GRID) - - def _render_interval_btns(self, ctx: bot.Context, state): - return self.reply(ctx, state, ctx.lang('consumption_select_interval'), self.INTERVAL_BUTTONS, - with_back=True) - - @bot.convinput(TOTAL, messages=INTERVAL_BUTTONS_FLAT) - def total_input(self, ctx: bot.Context): - return self._render_interval_results(ctx, self.TOTAL) - - @bot.convinput(GRID, messages=INTERVAL_BUTTONS_FLAT) - def grid_input(self, ctx: bot.Context): - return self._render_interval_results(ctx, self.GRID) - - def _render_interval_results(self, ctx: bot.Context, state): - # if ctx.text == ctx.lang('to_select_interval'): - # TODO - # pass - # - # else: - - now = datetime.datetime.now() - s_to = now.strftime(FormatDate) - - if ctx.text == ctx.lang('today'): - s_from = now.strftime(FormatDate) - s_to = 'now' - elif ctx.text == ctx.lang('yday1'): - s_from = (now - datetime.timedelta(days=1)).strftime(FormatDate) - elif ctx.text == ctx.lang('for_7days'): - s_from = (now - datetime.timedelta(days=7)).strftime(FormatDate) - elif ctx.text == ctx.lang('for_30days'): - s_from = (now - datetime.timedelta(days=30)).strftime(FormatDate) - - # markup = InlineKeyboardMarkup([ - # [InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait')] - # ]) - - message = ctx.reply(ctx.lang('consumption_request_sent'), - markup=bot.IgnoreMarkup()) - - api = WebAPIClient(timeout=60) - method = 'inverter_get_consumed_energy' if state == self.TOTAL else 'inverter_get_grid_consumed_energy' - - try: - wh = getattr(api, method)(s_from, s_to) - bot.delete_message(message.chat_id, message.message_id) - ctx.reply('%.2f Wh' % (wh,), - markup=bot.IgnoreMarkup()) - return self.END - except Exception as e: - bot.delete_message(message.chat_id, message.message_id) - ctx.reply_exc(e) - -# other -# ----- - -@bot.handler(command='monstatus') -def monstatus_handler(ctx: bot.Context) -> None: - msg = '' - st = monitor.dump_status() - for k, v in st.items(): - msg += k + ': ' + str(v) + '\n' - ctx.reply(msg) - - -@bot.handler(command='monsetcur') -def monsetcur_handler(ctx: bot.Context) -> None: - ctx.reply('not implemented yet') - - -@bot.callbackhandler -def button_callback(ctx: bot.Context) -> None: - query = ctx.callback_query - - if query.data.startswith('flag_'): - flag = query.data[5:] - found = False - json_key = None - for k, v in flags_map.items(): - if v == flag: - found = True - json_key = k - break - if not found: - query.answer(ctx.lang('flags_invalid')) - return - - flags = inverter.exec('get-flags')['data'] - cur_flag_value = flags[json_key] - target_flag_value = '0' if cur_flag_value else '1' - - # set flag - response = inverter.exec('set-flag', (flag, target_flag_value)) - - # notify user - query.answer(ctx.lang('done') if response['result'] == 'ok' else ctx.lang('flags_fail')) - - # edit message - flags[json_key] = not cur_flag_value - text, markup = build_flags_keyboard(flags, ctx) - query.edit_message_text(text, reply_markup=markup) - - else: - query.answer(ctx.lang('unexpected_callback_data')) - - -@bot.exceptionhandler -def exception_handler(e: Exception, ctx: bot.Context) -> Optional[bool]: - if isinstance(e, InverterError): - try: - err = json.loads(str(e))['message'] - except json.decoder.JSONDecodeError: - err = str(e) - err = re.sub(r'((?:.*)?error:) (.*)', r'<b>\1</b> \2', err) - ctx.reply(err, - markup=bot.IgnoreMarkup()) - return True - - -@bot.handler(message='status') -def status_handler(ctx: bot.Context) -> None: - gs = inverter.exec('get-status')['data'] - rated = inverter.exec('get-rated')['data'] - - # render response - power_direction = gs['battery_power_direction'].lower() - power_direction = re.sub(r'ge$', 'ging', power_direction) - - charging_rate = '' - chrg_at = ctx.lang('charging_at') - - if power_direction == 'charging': - charging_rate = f'{chrg_at}%s %s' % ( - gs['battery_charge_current']['value'], gs['battery_charge_current']['unit']) - pd_label = ctx.lang('pd_charging') - elif power_direction == 'discharging': - charging_rate = f'{chrg_at}%s %s' % ( - gs['battery_discharge_current']['value'], gs['battery_discharge_current']['unit']) - pd_label = ctx.lang('pd_discharging') - else: - pd_label = ctx.lang('pd_nothing') - - html = f'<b>{ctx.lang("battery")}:</b> %s %s' % (gs['battery_voltage']['value'], gs['battery_voltage']['unit']) - html += ' (%s%s)' % (pd_label, charging_rate) - - html += f'\n<b>{ctx.lang("load")}:</b> %s %s' % (gs['ac_output_active_power']['value'], gs['ac_output_active_power']['unit']) - html += ' (%s%%)' % (gs['output_load_percent']['value']) - - if gs['pv1_input_power']['value'] > 0: - html += f'\n<b>{ctx.lang("gen_input_power")}:</b> %s %s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit']) - - if gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0: - ac_mode = getacmode() - html += f'\n<b>{ctx.lang(ac_mode.value)}:</b> %s %s' % (gs['grid_voltage']['value'], gs['grid_voltage']['unit']) - html += ', %s %s' % (gs['grid_freq']['value'], gs['grid_freq']['unit']) - - html += f'\n<b>{ctx.lang("socket")}</b>: %s %s, %s %s' % ( - gs['ac_output_voltage']['value'], gs['ac_output_voltage']['unit'], - gs['ac_output_freq']['value'], gs['ac_output_freq']['unit'] - ) - - html += f'\n<b>{ctx.lang("priority")}</b>: {rated["output_source_priority"]}' - - # send response - ctx.reply(html) - - -@bot.handler(message='generation') -def generation_handler(ctx: bot.Context) -> None: - today = datetime.date.today() - yday = today - datetime.timedelta(days=1) - yday2 = today - datetime.timedelta(days=2) - - gs = inverter.exec('get-status')['data'] - - gen_today = inverter.exec('get-day-generated', (today.year, today.month, today.day))['data'] - gen_yday = None - gen_yday2 = None - - if yday.month == today.month: - gen_yday = inverter.exec('get-day-generated', (yday.year, yday.month, yday.day))['data'] - - if yday2.month == today.month: - gen_yday2 = inverter.exec('get-day-generated', (yday2.year, yday2.month, yday2.day))['data'] - - # render response - html = f'<b>{ctx.lang("gen_input_power")}:</b> %s %s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit']) - html += ' (%s %s)' % (gs['pv1_input_voltage']['value'], gs['pv1_input_voltage']['unit']) - - html += f'\n<b>{ctx.lang("today")}:</b> %s Wh' % (gen_today['wh']) - - if gen_yday is not None: - html += f'\n<b>{ctx.lang("yday1")}:</b> %s Wh' % (gen_yday['wh']) - - if gen_yday2 is not None: - html += f'\n<b>{ctx.lang("yday2")}:</b> %s Wh' % (gen_yday2['wh']) - - # send response - ctx.reply(html) - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - button = [ - [ctx.lang('status'), ctx.lang('generation')], - [ctx.lang('consumption')], - [ctx.lang('settings')] - ] - return ReplyKeyboardMarkup(button, one_time_keyboard=False) - - -class InverterStore(bot.BotDatabase): - SCHEMA = 2 - - def schema_init(self, version: int) -> None: - super().schema_init(version) - - if version < 2: - cursor = self.cursor() - cursor.execute("""CREATE TABLE IF NOT EXISTS params ( - id TEXT NOT NULL PRIMARY KEY, - value TEXT NOT NULL - )""") - cursor.execute("CREATE INDEX param_id_idx ON params (id)") - self.commit() - - def get_param(self, key: str, default=None): - cursor = self.cursor() - cursor.execute('SELECT value FROM params WHERE id=?', (key,)) - row = cursor.fetchone() - - return default if row is None else row[0] - - def set_param(self, key: str, value: Union[str, int, float]): - cursor = self.cursor() - cursor.execute('REPLACE INTO params (id, value) VALUES (?, ?)', (key, str(value))) - self.commit() - - -if __name__ == '__main__': - inverter.init(host=config['inverter']['ip'], port=config['inverter']['port']) - - bot.set_database(InverterStore()) - bot.enable_logging(BotType.INVERTER) - - bot.add_conversation(SettingsConversation(enable_back=True)) - bot.add_conversation(ConsumptionConversation(enable_back=True)) - - monitor = InverterMonitor() - monitor.set_charging_event_handler(monitor_charging) - monitor.set_battery_event_handler(monitor_battery) - monitor.set_util_event_handler(monitor_util) - monitor.set_error_handler(monitor_error) - monitor.set_osp_need_change_callback(osp_change_cb) - - setacmode(getacmode()) - - if not config.get('monitor.disabled'): - logging.info('starting monitor') - monitor.start() - - bot.run() - - monitor.stop() diff --git a/src/inverter_mqtt_receiver.py b/src/inverter_mqtt_receiver.py deleted file mode 100755 index d40647e..0000000 --- a/src/inverter_mqtt_receiver.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -import paho.mqtt.client as mqtt -import re - -from home.mqtt import MqttBase -from home.mqtt.payload.inverter import Status, Generation -from home.database import InverterDatabase -from home.config import config - - -class MqttReceiver(MqttBase): - def __init__(self): - super().__init__(clean_session=False) - self.database = InverterDatabase() - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - self._logger.info("subscribing to hk/#") - client.subscribe('hk/#', qos=1) - - def on_message(self, client: mqtt.Client, userdata, msg): - super().on_message(client, userdata, msg) - try: - match = re.match(r'(?:home|hk)/(\d+)/(status|gen)', msg.topic) - if not match: - return - - # FIXME string home_id must be supported - home_id, what = int(match.group(1)), match.group(2) - if what == 'gen': - gen = Generation.unpack(msg.payload) - self.database.add_generation(home_id, gen.time, gen.wh) - - elif what == 'status': - s = Status.unpack(msg.payload) - self.database.add_status(home_id, - client_time=s.time, - grid_voltage=int(s.grid_voltage*10), - grid_freq=int(s.grid_freq * 10), - ac_output_voltage=int(s.ac_output_voltage * 10), - ac_output_freq=int(s.ac_output_freq * 10), - ac_output_apparent_power=s.ac_output_apparent_power, - ac_output_active_power=s.ac_output_active_power, - output_load_percent=s.output_load_percent, - battery_voltage=int(s.battery_voltage * 10), - battery_voltage_scc=int(s.battery_voltage_scc * 10), - battery_voltage_scc2=int(s.battery_voltage_scc2 * 10), - battery_discharge_current=s.battery_discharge_current, - battery_charge_current=s.battery_charge_current, - battery_capacity=s.battery_capacity, - inverter_heat_sink_temp=s.inverter_heat_sink_temp, - mppt1_charger_temp=s.mppt1_charger_temp, - mppt2_charger_temp=s.mppt2_charger_temp, - pv1_input_power=s.pv1_input_power, - pv2_input_power=s.pv2_input_power, - pv1_input_voltage=int(s.pv1_input_voltage * 10), - pv2_input_voltage=int(s.pv2_input_voltage * 10), - mppt1_charger_status=s.mppt1_charger_status, - mppt2_charger_status=s.mppt2_charger_status, - battery_power_direction=s.battery_power_direction, - dc_ac_power_direction=s.dc_ac_power_direction, - line_power_direction=s.line_power_direction, - load_connected=s.load_connected) - - except Exception as e: - self._logger.exception(str(e)) - - -if __name__ == '__main__': - config.load('inverter_mqtt_receiver') - - server = MqttReceiver() - server.connect_and_loop() - diff --git a/src/inverter_mqtt_sender.py b/src/inverter_mqtt_sender.py deleted file mode 100755 index fb2a2d8..0000000 --- a/src/inverter_mqtt_sender.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -import time -import datetime -import json -import inverterd - -from home.config import config -from home.mqtt import MqttBase, poll_tick -from home.mqtt.payload.inverter import Status, Generation - - -class MqttClient(MqttBase): - def __init__(self): - super().__init__() - - self._home_id = config['mqtt']['home_id'] - - self._inverter = inverterd.Client() - self._inverter.connect() - self._inverter.format(inverterd.Format.SIMPLE_JSON) - - def poll_inverter(self): - freq = int(config['mqtt']['inverter']['poll_freq']) - gen_freq = int(config['mqtt']['inverter']['generation_poll_freq']) - - g = poll_tick(freq) - gen_prev = 0 - while True: - time.sleep(next(g)) - - # read status - now = time.time() - try: - raw = self._inverter.exec('get-status') - except inverterd.InverterError as e: - self._logger.error(f'inverter error: {str(e)}') - # TODO send to server - continue - - data = json.loads(raw)['data'] - status = Status(time=round(now), **data) # FIXME this will crash with 99% probability - - self._client.publish(f'hk/{self._home_id}/status', - payload=status.pack(), - qos=1) - - # read today's generation stat - now = time.time() - if gen_prev == 0 or now - gen_prev >= gen_freq: - gen_prev = now - today = datetime.date.today() - try: - raw = self._inverter.exec('get-day-generated', (today.year, today.month, today.day)) - except inverterd.InverterError as e: - self._logger.error(f'inverter error: {str(e)}') - # TODO send to server - continue - - data = json.loads(raw)['data'] - gen = Generation(time=round(now), wh=data['wh']) - self._client.publish(f'hk/{self._home_id}/gen', - payload=gen.pack(), - qos=1) - - -if __name__ == '__main__': - config.load('inverter_mqtt_sender') - - client = MqttClient() - client.configure_tls() - client.connect_and_loop(loop_forever=False) - client.poll_inverter()
\ No newline at end of file diff --git a/src/inverterd_emulator.py b/src/inverterd_emulator.py deleted file mode 100755 index 8c4d0bd..0000000 --- a/src/inverterd_emulator.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env python3 -import logging - -from home.inverter.emulator import InverterEmulator - - -if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG) - InverterEmulator(addr=('127.0.0.1', 8305)) diff --git a/src/ipcam_server.py b/src/ipcam_server.py deleted file mode 100755 index 2c4915d..0000000 --- a/src/ipcam_server.py +++ /dev/null @@ -1,579 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import re -import asyncio -import time -import shutil -import home.telegram.aio as telegram - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from asyncio import Lock - -from home.config import config -from home import http -from home.database.sqlite import SQLiteBase -from home.camera import util as camutil - -from enum import Enum -from typing import Optional, Union, List, Tuple -from datetime import datetime, timedelta -from functools import cmp_to_key - - -class TimeFilterType(Enum): - FIX = 'fix' - MOTION = 'motion' - MOTION_START = 'motion_start' - - -class TelegramLinkType(Enum): - FRAGMENT = 'fragment' - ORIGINAL_FILE = 'original_file' - - -def valid_recording_name(filename: str) -> bool: - return filename.startswith('record_') and filename.endswith('.mp4') - - -def filename_to_datetime(filename: str) -> datetime: - filename = os.path.basename(filename).replace('record_', '').replace('.mp4', '') - return datetime.strptime(filename, datetime_format) - - -def get_all_cams() -> list: - return [cam for cam in config['camera'].keys()] - - -# ipcam database -# -------------- - -class IPCamServerDatabase(SQLiteBase): - SCHEMA = 4 - - def __init__(self): - super().__init__() - - def schema_init(self, version: int) -> None: - cursor = self.cursor() - - if version < 1: - # timestamps - cursor.execute("""CREATE TABLE IF NOT EXISTS timestamps ( - camera INTEGER PRIMARY KEY, - fix_time INTEGER NOT NULL, - motion_time INTEGER NOT NULL - )""") - for cam in config['camera'].keys(): - self.add_camera(cam) - - if version < 2: - # motion_failures - cursor.execute("""CREATE TABLE IF NOT EXISTS motion_failures ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - camera INTEGER NOT NULL, - filename TEXT NOT NULL - )""") - - if version < 3: - cursor.execute("ALTER TABLE motion_failures ADD COLUMN message TEXT NOT NULL DEFAULT ''") - - if version < 4: - cursor.execute("ALTER TABLE timestamps ADD COLUMN motion_start_time INTEGER NOT NULL DEFAULT 0") - cursor.execute("UPDATE timestamps SET motion_start_time=motion_time") - - self.commit() - - def add_camera(self, camera: int): - self.cursor().execute("INSERT INTO timestamps (camera, fix_time, motion_time) VALUES (?, ?, ?)", - (camera, 0, 0)) - self.commit() - - def add_motion_failure(self, - camera: int, - filename: str, - message: Optional[str]): - self.cursor().execute("INSERT INTO motion_failures (camera, filename, message) VALUES (?, ?, ?)", - (camera, filename, message or '')) - self.commit() - - def get_all_timestamps(self): - cur = self.cursor() - data = {} - - cur.execute("SELECT camera, fix_time, motion_time, motion_start_time FROM timestamps") - for cam, fix_time, motion_time, motion_start_time in cur.fetchall(): - data[int(cam)] = { - 'fix': int(fix_time), - 'motion': int(motion_time), - 'motion_start': int(motion_start_time) - } - - return data - - def set_timestamp(self, - camera: int, - time_type: TimeFilterType, - time: Union[int, datetime]): - cur = self.cursor() - if isinstance(time, datetime): - time = int(time.timestamp()) - cur.execute(f"UPDATE timestamps SET {time_type.value}_time=? WHERE camera=?", (time, camera)) - self.commit() - - def get_timestamp(self, - camera: int, - time_type: TimeFilterType) -> int: - cur = self.cursor() - cur.execute(f"SELECT {time_type.value}_time FROM timestamps WHERE camera=?", (camera,)) - return int(cur.fetchone()[0]) - - -# ipcam web api -# ------------- - -class IPCamWebServer(http.HTTPServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.get('/api/recordings', self.get_motion_queue) - self.get('/api/recordings/{name}', self.get_camera_recordings) - self.get('/api/recordings/{name}/download/{file}', self.download_recording) - self.get('/api/camera/list', self.camlist) - self.get('/api/timestamp/{name}/{type}', self.get_timestamp) - self.get('/api/timestamp/all', self.get_all_timestamps) - - self.post('/api/debug/migrate-mtimes', self.debug_migrate_mtimes) - self.post('/api/debug/fix', self.debug_fix) - self.post('/api/debug/cleanup', self.debug_cleanup) - self.post('/api/timestamp/{name}/{type}', self.set_timestamp) - - self.post('/api/motion/done/{name}', self.submit_motion) - self.post('/api/motion/fail/{name}', self.submit_motion_failure) - - self.get('/api/motion/params/{name}', self.get_motion_params) - self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params) - - self.queue_lock = Lock() - - async def get_camera_recordings(self, req): - camera = int(req.match_info['name']) - try: - filter = TimeFilterType(req.query['filter']) - except KeyError: - filter = None - - try: - limit = int(req.query['limit']) - except KeyError: - limit = 0 - - files = get_recordings_files(camera, filter, limit) - if files: - time = filename_to_datetime(files[len(files)-1]['name']) - db.set_timestamp(camera, TimeFilterType.MOTION_START, time) - return self.ok({'files': files}) - - async def get_motion_queue(self, req): - try: - limit = int(req.query['limit']) - except KeyError: - limit = 0 - - async with self.queue_lock: - files = get_recordings_files(None, TimeFilterType.MOTION_START, limit) - if files: - times_by_cam = {} - for file in files: - time = filename_to_datetime(file['name']) - if file['cam'] not in times_by_cam or times_by_cam[file['cam']] < time: - times_by_cam[file['cam']] = time - for cam, time in times_by_cam.items(): - db.set_timestamp(cam, TimeFilterType.MOTION_START, time) - - return self.ok({'files': files}) - - async def download_recording(self, req: http.Request): - cam = int(req.match_info['name']) - file = req.match_info['file'] - - fullpath = os.path.join(config['camera'][cam]['recordings_path'], file) - if not os.path.isfile(fullpath): - raise ValueError(f'file "{fullpath}" does not exists') - - return http.FileResponse(fullpath) - - async def camlist(self, req: http.Request): - return self.ok(config['camera']) - - async def submit_motion(self, req: http.Request): - data = await req.post() - - camera = int(req.match_info['name']) - timecodes = data['timecodes'] - filename = data['filename'] - - time = filename_to_datetime(filename) - - try: - if timecodes != '': - fragments = camutil.dvr_scan_timecodes(timecodes) - asyncio.ensure_future(process_fragments(camera, filename, fragments)) - - db.set_timestamp(camera, TimeFilterType.MOTION, time) - return self.ok() - - except camutil.DVRScanInvalidTimecodes as e: - db.add_motion_failure(camera, filename, str(e)) - db.set_timestamp(camera, TimeFilterType.MOTION, time) - return self.ok('invalid timecodes') - - async def submit_motion_failure(self, req: http.Request): - camera = int(req.match_info['name']) - - data = await req.post() - filename = data['filename'] - message = data['message'] - - db.add_motion_failure(camera, filename, message) - db.set_timestamp(camera, TimeFilterType.MOTION, filename_to_datetime(filename)) - - return self.ok() - - async def debug_migrate_mtimes(self, req: http.Request): - written = {} - for cam in config['camera'].keys(): - confdir = os.path.join(os.getenv('HOME'), '.config', f'video-util-{cam}') - for time_type in TimeFilterType: - txt_file = os.path.join(confdir, f'{time_type.value}_mtime') - if os.path.isfile(txt_file): - with open(txt_file, 'r') as fd: - data = fd.read() - db.set_timestamp(cam, time_type, int(data.strip())) - - if cam not in written: - written[cam] = [] - written[cam].append(time_type) - - return self.ok({'written': written}) - - async def debug_fix(self, req: http.Request): - asyncio.ensure_future(fix_job()) - return self.ok() - - async def debug_cleanup(self, req: http.Request): - asyncio.ensure_future(cleanup_job()) - return self.ok() - - async def set_timestamp(self, req: http.Request): - cam, time_type, time = self._getset_timestamp_params(req, need_time=True) - db.set_timestamp(cam, time_type, time) - return self.ok() - - async def get_timestamp(self, req: http.Request): - cam, time_type = self._getset_timestamp_params(req) - return self.ok(db.get_timestamp(cam, time_type)) - - async def get_all_timestamps(self, req: http.Request): - return self.ok(db.get_all_timestamps()) - - async def get_motion_params(self, req: http.Request): - data = config['motion_params'][int(req.match_info['name'])] - lines = [ - f'threshold={data["threshold"]}', - f'min_event_length=3s', - f'frame_skip=2', - f'downscale_factor=3', - ] - return self.plain('\n'.join(lines)+'\n') - - async def get_motion_roi_params(self, req: http.Request): - data = config['motion_params'][int(req.match_info['name'])] - return self.plain('\n'.join(data['roi'])+'\n') - - @staticmethod - def _getset_timestamp_params(req: http.Request, need_time=False): - values = [] - - cam = int(req.match_info['name']) - assert cam in config['camera'], 'invalid camera' - - values.append(cam) - values.append(TimeFilterType(req.match_info['type'])) - - if need_time: - time = req.query['time'] - if time.startswith('record_'): - time = filename_to_datetime(time) - elif time.isnumeric(): - time = int(time) - else: - raise ValueError('invalid time') - values.append(time) - - return values - - -# other global stuff -# ------------------ - -def open_database(): - global db - db = IPCamServerDatabase() - - # update cams list in database, if needed - cams = db.get_all_timestamps().keys() - for cam in config['camera']: - if cam not in cams: - db.add_camera(cam) - - -def get_recordings_path(cam: int) -> str: - return config['camera'][cam]['recordings_path'] - - -def get_motion_path(cam: int) -> str: - return config['camera'][cam]['motion_path'] - - -def get_recordings_files(cam: Optional[int] = None, - time_filter_type: Optional[TimeFilterType] = None, - limit=0) -> List[dict]: - from_time = 0 - to_time = int(time.time()) - - cams = [cam] if cam is not None else get_all_cams() - files = [] - for cam in cams: - if time_filter_type: - from_time = db.get_timestamp(cam, time_filter_type) - if time_filter_type in (TimeFilterType.MOTION, TimeFilterType.MOTION_START): - to_time = db.get_timestamp(cam, TimeFilterType.FIX) - - from_time = datetime.fromtimestamp(from_time) - to_time = datetime.fromtimestamp(to_time) - - recdir = get_recordings_path(cam) - cam_files = [{ - 'cam': cam, - 'name': file, - 'size': os.path.getsize(os.path.join(recdir, file))} - for file in os.listdir(recdir) - if valid_recording_name(file) and from_time < filename_to_datetime(file) <= to_time] - cam_files.sort(key=lambda file: file['name']) - - if cam_files: - last = cam_files[len(cam_files)-1] - fullpath = os.path.join(recdir, last['name']) - if camutil.has_handle(fullpath): - logger.debug(f'get_recordings_files: file {fullpath} has opened handle, ignoring it') - cam_files.pop() - files.extend(cam_files) - - if limit > 0: - files = files[:limit] - - return files - - -async def process_fragments(camera: int, - filename: str, - fragments: List[Tuple[int, int]]) -> None: - time = filename_to_datetime(filename) - - rec_dir = get_recordings_path(camera) - motion_dir = get_motion_path(camera) - if not os.path.exists(motion_dir): - os.mkdir(motion_dir) - - for fragment in fragments: - start, end = fragment - - start -= config['motion']['padding'] - end += config['motion']['padding'] - - if start < 0: - start = 0 - - duration = end - start - - dt1 = (time + timedelta(seconds=start)).strftime(datetime_format) - dt2 = (time + timedelta(seconds=end)).strftime(datetime_format) - - await camutil.ffmpeg_cut(input=os.path.join(rec_dir, filename), - output=os.path.join(motion_dir, f'{dt1}__{dt2}.mp4'), - start_pos=start, - duration=duration) - - if fragments and 'telegram' in config['motion'] and config['motion']['telegram']: - asyncio.ensure_future(motion_notify_tg(camera, filename, fragments)) - - -async def motion_notify_tg(camera: int, - filename: str, - fragments: List[Tuple[int, int]]): - dt_file = filename_to_datetime(filename) - fmt = '%H:%M:%S' - - text = f'Camera: <b>{camera}</b>\n' - text += f'Original file: <b>{filename}</b> ' - text += _tg_links(TelegramLinkType.ORIGINAL_FILE, camera, filename) - - for start, end in fragments: - start -= config['motion']['padding'] - end += config['motion']['padding'] - - if start < 0: - start = 0 - - duration = end - start - if duration < 0: - duration = 0 - - dt1 = dt_file + timedelta(seconds=start) - dt2 = dt_file + timedelta(seconds=end) - - text += f'\nFragment: <b>{duration}s</b>, {dt1.strftime(fmt)}-{dt2.strftime(fmt)} ' - text += _tg_links(TelegramLinkType.FRAGMENT, camera, f'{dt1.strftime(datetime_format)}__{dt2.strftime(datetime_format)}.mp4') - - await telegram.send_message(text) - - -def _tg_links(link_type: TelegramLinkType, - camera: int, - file: str) -> str: - links = [] - for link_name, link_template in config['telegram'][f'{link_type.value}_url_templates']: - link = link_template.replace('{camera}', str(camera)).replace('{file}', file) - links.append(f'<a href="{link}">{link_name}</a>') - return ' '.join(links) - - -async def fix_job() -> None: - global fix_job_running - logger.debug('fix_job: starting') - - if fix_job_running: - logger.error('fix_job: already running') - return - - try: - fix_job_running = True - for cam in config['camera'].keys(): - files = get_recordings_files(cam, TimeFilterType.FIX) - if not files: - logger.debug(f'fix_job: no files for camera {cam}') - continue - - logger.debug(f'fix_job: got %d files for camera {cam}' % (len(files),)) - - for file in files: - fullpath = os.path.join(get_recordings_path(cam), file['name']) - await camutil.ffmpeg_recreate(fullpath) - timestamp = filename_to_datetime(file['name']) - if timestamp: - db.set_timestamp(cam, TimeFilterType.FIX, timestamp) - - finally: - fix_job_running = False - - -async def cleanup_job() -> None: - def fn2dt(name: str) -> datetime: - name = os.path.basename(name) - - if name.startswith('record_'): - return datetime.strptime(re.match(r'record_(.*?)\.mp4', name).group(1), datetime_format) - - m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.mp4', name) - if m: - return datetime.strptime(m.group(1), datetime_format) - - raise ValueError(f'unrecognized filename format: {name}') - - def compare(i1: str, i2: str) -> int: - dt1 = fn2dt(i1) - dt2 = fn2dt(i2) - - if dt1 < dt2: - return -1 - elif dt1 > dt2: - return 1 - else: - return 0 - - global cleanup_job_running - logger.debug('cleanup_job: starting') - - if cleanup_job_running: - logger.error('cleanup_job: already running') - return - - try: - cleanup_job_running = True - - gb = float(1 << 30) - for storage in config['storages']: - if os.path.exists(storage['mountpoint']): - total, used, free = shutil.disk_usage(storage['mountpoint']) - free_gb = free // gb - if free_gb < config['cleanup_min_gb']: - # print(f"{storage['mountpoint']}: free={free}, free_gb={free_gb}") - cleaned = 0 - files = [] - for cam in storage['cams']: - for _dir in (config['camera'][cam]['recordings_path'], config['camera'][cam]['motion_path']): - files += list(map(lambda file: os.path.join(_dir, file), os.listdir(_dir))) - files = list(filter(lambda path: os.path.isfile(path) and path.endswith('.mp4'), files)) - files.sort(key=cmp_to_key(compare)) - - for file in files: - size = os.stat(file).st_size - try: - os.unlink(file) - cleaned += size - except OSError as e: - logger.exception(e) - if (free + cleaned) // gb >= config['cleanup_min_gb']: - break - else: - logger.error(f"cleanup_job: {storage['mountpoint']} not found") - finally: - cleanup_job_running = False - - -fix_job_running = False -cleanup_job_running = False - -datetime_format = '%Y-%m-%d-%H.%M.%S' -datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}' -db: Optional[IPCamServerDatabase] = None -server: Optional[IPCamWebServer] = None -logger = logging.getLogger(__name__) - - -# start of the program -# -------------------- - -if __name__ == '__main__': - config.load('ipcam_server') - - open_database() - - loop = asyncio.get_event_loop() - - try: - scheduler = AsyncIOScheduler(event_loop=loop) - if config['fix_enabled']: - scheduler.add_job(fix_job, 'interval', seconds=config['fix_interval'], misfire_grace_time=None) - - scheduler.add_job(cleanup_job, 'interval', seconds=config['cleanup_interval'], misfire_grace_time=None) - scheduler.start() - except KeyError: - pass - - asyncio.ensure_future(fix_job()) - asyncio.ensure_future(cleanup_job()) - - server = IPCamWebServer(config.get_addr('server.listen')) - server.run() diff --git a/src/openwrt_log_analyzer.py b/src/openwrt_log_analyzer.py deleted file mode 100755 index d31c3bf..0000000 --- a/src/openwrt_log_analyzer.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -import home.telegram as telegram - -from home.config import config -from home.database import BotsDatabase, SimpleState - -""" -config.toml example: - -[simple_state] -file = "/home/user/.config/openwrt_log_analyzer/state.txt" - -[mysql] -host = "localhost" -database = ".." -user = ".." -password = ".." - -[devices] -Device1 = "00:00:00:00:00:00" -Device2 = "01:01:01:01:01:01" - -[telegram] -chat_id = ".." -token = ".." -parse_mode = "HTML" - -[openwrt_log_analyzer] -limit = 10 -""" - - -def main(mac: str, - title: str, - ap: int) -> int: - db = BotsDatabase() - - data = db.get_openwrt_logs(filter_text=mac, - min_id=state['last_id'], - access_point=ap, - limit=config['openwrt_log_analyzer']['limit']) - if not data: - return 0 - - max_id = 0 - for log in data: - if log.id > max_id: - max_id = log.id - - text = '\n'.join(map(lambda s: str(s), data)) - telegram.send_message(f'<b>{title} (AP #{ap})</b>\n\n' + text) - - return max_id - - -if __name__ == '__main__': - config.load('openwrt_log_analyzer') - for ap in config['openwrt_log_analyzer']['aps']: - state_file = config['simple_state']['file'] - state_file = state_file.replace('.txt', f'-{ap}.txt') - - state = SimpleState(file=state_file, - default={'last_id': 0}) - - max_last_id = 0 - for name, mac in config['devices'].items(): - last_id = main(mac, title=name, ap=ap) - if last_id > max_last_id: - max_last_id = last_id - - if max_last_id: - state['last_id'] = max_last_id diff --git a/src/openwrt_logger.py b/src/openwrt_logger.py deleted file mode 100755 index 3b19de2..0000000 --- a/src/openwrt_logger.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -import os - -from datetime import datetime -from typing import Tuple, List -from argparse import ArgumentParser -from home.config import config -from home.database import SimpleState -from home.api import WebAPIClient - -f""" -This script is supposed to be run by cron every 5 minutes or so. -It looks for new lines in log file and sends them to remote server. - -OpenWRT must have remote logging enabled (UDP; IP of host this script is launched on; port 514) - -/etc/rsyslog.conf contains following (assuming 192.168.1.1 is the router IP): - -$ModLoad imudp -$UDPServerRun 514 -:fromhost-ip, isequal, "192.168.1.1" /var/log/openwrt.log -& ~ - -Also comment out the following line: -$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat - -""" - - -def parse_line(line: str) -> Tuple[int, str]: - space_pos = line.index(' ') - - date = line[:space_pos] - rest = line[space_pos+1:] - - return ( - int(datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z").timestamp()), - rest - ) - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('--file', type=str, required=True, - help='openwrt log file') - parser.add_argument('--access-point', type=int, required=True, - help='access point number') - - arg = config.load('openwrt_logger', parser=parser) - - state = SimpleState(file=config['simple_state']['file'].replace('{ap}', str(arg.access_point)), - default={'seek': 0, 'size': 0}) - - fsize = os.path.getsize(arg.file) - if fsize < state['size']: - state['seek'] = 0 - - with open(arg.file, 'r') as f: - if state['seek']: - # jump to the latest read position - f.seek(state['seek']) - - # read till the end of the file - content = f.read() - - # save new position - state['seek'] = f.tell() - state['size'] = fsize - - lines: List[Tuple[int, str]] = [] - - if content != '': - for line in content.strip().split('\n'): - if not line: - continue - - try: - lines.append(parse_line(line)) - except ValueError: - lines.append((0, line)) - - api = WebAPIClient() - api.log_openwrt(lines, arg.access_point) diff --git a/src/pio_build.py b/src/pio_build.py deleted file mode 100644 index 1916e5e..0000000 --- a/src/pio_build.py +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env python3 - -if __name__ == '__main__': - print('TODO')
\ No newline at end of file diff --git a/src/pio_ini.py b/src/pio_ini.py deleted file mode 100755 index 19dd707..0000000 --- a/src/pio_ini.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -import os -import yaml -import re - -from pprint import pprint -from argparse import ArgumentParser, ArgumentError -from home.pio import get_products, platformio_ini -from home.pio.exceptions import ProductConfigNotFoundError - - -def get_config(product: str) -> dict: - config_path = os.path.join( - os.getenv('HOME'), '.config', - 'homekit_pio', f'{product}.yaml' - ) - if not os.path.exists(config_path): - raise ProductConfigNotFoundError(f'{config_path}: product config not found') - - with open(config_path, 'r') as f: - return yaml.safe_load(f) - - -def bsd_walk(product_config: dict, - f: callable): - try: - for define_name, define_extra_params in product_config['build_specific_defines'].items(): - define_name = re.sub(r'^CONFIG_', '', define_name) - kwargs = {} - if isinstance(define_extra_params, dict): - kwargs = define_extra_params - f(define_name, **kwargs) - except KeyError: - pass - - -# 'bsd' means 'build_specific_defines' -def bsd_parser(product_config: dict, - parser: ArgumentParser): - def f(define_name, **kwargs): - arg_kwargs = {} - define_name = define_name.lower().replace('_', '-') - - if 'type' in kwargs: - if kwargs['type'] in ('str', 'enum'): - arg_kwargs['type'] = str - if kwargs['type'] == 'enum' and 'list_config_key' in kwargs: - if not isinstance(product_config[kwargs['list_config_key']], list): - raise TypeError(f'product_config[{kwargs["list_config_key"]}] enum is not list') - if not product_config[kwargs['list_config_key']]: - raise ValueError(f'product_config[{kwargs["list_config_key"]}] enum cannot be empty') - arg_kwargs['choices'] = product_config[kwargs['list_config_key']] - if isinstance(product_config[kwargs['list_config_key']][0], int): - arg_kwargs['type'] = int - elif kwargs['type'] == 'int': - arg_kwargs['type'] = int - else: - raise TypeError(f'unsupported type {kwargs["type"]} for define {define_name}') - else: - arg_kwargs['action'] = 'store_true' - - parser.add_argument(f'--{define_name}', required=True, **arg_kwargs) - - bsd_walk(product_config, f) - - -def bsd_get(product_config: dict, - arg: object): - defines = {} - enums = [] - def f(define_name, **kwargs): - attr_name = define_name.lower() - attr_value = getattr(arg, attr_name) - if 'type' in kwargs: - if kwargs['type'] == 'enum': - enums.append(f'CONFIG_{define_name}') - defines[f'CONFIG_{define_name}'] = f'HOMEKIT_{attr_value.upper()}' - return - defines[f'CONFIG_{define_name}'] = str(attr_value) - bsd_walk(product_config, f) - return defines, enums - - -if __name__ == '__main__': - products = get_products() - - # first, get the product - product_parser = ArgumentParser(add_help=False) - product_parser.add_argument('--product', type=str, choices=products, required=True, - help='PIO product name') - arg, _ = product_parser.parse_known_args() - if not arg.product: - product = os.path.basename(os.path.realpath(os.getcwd())) - if product not in products: - raise ArgumentError(None, 'invalid product') - else: - product = arg.product - - product_config = get_config(product) - - # then everythingm else - parser = ArgumentParser(parents=[product_parser]) - parser.add_argument('--target', type=str, required=True, choices=product_config['targets'], - help='PIO build target') - parser.add_argument('--platform', default='espressif8266', type=str) - parser.add_argument('--framework', default='arduino', type=str) - parser.add_argument('--upload-port', default='/dev/ttyUSB0', type=str) - parser.add_argument('--monitor-speed', default=115200) - parser.add_argument('--debug', action='store_true') - parser.add_argument('--debug-network', action='store_true') - bsd_parser(product_config, parser) - arg = parser.parse_args() - - if arg.target not in product_config['targets']: - raise ArgumentError(None, f'target {arg.target} not found for product {product}') - - bsd, bsd_enums = bsd_get(product_config, arg) - ini = platformio_ini(product_config=product_config, - target=arg.target, - build_specific_defines=bsd, - build_specific_defines_enums=bsd_enums, - platform=arg.platform, - framework=arg.framework, - upload_port=arg.upload_port, - monitor_speed=arg.monitor_speed, - debug=arg.debug, - debug_network=arg.debug_network) - print(ini) diff --git a/src/polaris_kettle_bot.py b/src/polaris_kettle_bot.py deleted file mode 100755 index 088707d..0000000 --- a/src/polaris_kettle_bot.py +++ /dev/null @@ -1,746 +0,0 @@ -#!/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.telegram import bot -from home.api.types import BotType -from home.mqtt import MqttBase -from home.config import config -from home.util import chunks -from syncleo import ( - Kettle, - PowerType, - DeviceListener, - IncomingMessageListener, - ConnectionStatusListener, - ConnectionStatus -) -import syncleo.protocol as kettle_proto -from typing import Optional, Tuple, List, Union -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__) -config.load('polaris_kettle_bot') - -primary_choices = (70, 80, 90, 100) -all_choices = range( - config['kettle']['temp_min'], - config['kettle']['temp_max']+1, - config['kettle']['temp_step']) - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре:", - invalid_command="Неизвестная команда", - unexpected_callback_data="Ошибка: неверные данные", - disable="❌ Выключить", - server_error="Ошибка сервера", - back="🔙 Назад", - smth_went_wrong="😱 Что-то пошло не так", - - # /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", - - # /temp - select_temperature="Выберите температуру:", - - # enable/disable - enabling="💤 Чайник включается...", - disabling="💤 Чайник выключается...", - enabled="🟢 Чайник <b>включён</b>.", - enabled_target="%s Цель: <b>%d °C</b>", - enabled_reached="✅ <b>Готово!</b> Чайник вскипел, температура <b>%d °C</b>.", - disabled="✅ Чайник <b>выключен</b>.", - please_wait="⏳ Ожидайте..." -) -bot.lang.en( - start_message="Select command on the keyboard:", - invalid_command="Unknown command", - unexpected_callback_data="Unexpected callback data", - disable="❌ Turn OFF", - server_error="Server error", - back="🔙 Back", - smth_went_wrong="😱 Something went wrong", - - # /status - status_not_connected="😟 No connection", - 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", - - # /temp - select_temperature="Select a temperature:", - - # enable/disable - enabling="💤 Turning on...", - disabling="💤 Turning off...", - enabled="🟢 The kettle is <b>turned ON</b>.", - enabled_target="%s Target: <b>%d °C</b>", - enabled_reached="✅ <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..." -) - -kc: Optional[KettleController] = None -RenderedContent = Tuple[str, Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]]] -tasks_lock = threading.Lock() - - -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) - - -def temperature_emoji(temp: int) -> str: - if temp > 90: - return '🔥' - elif temp >= 40: - return '♨️' - elif temp >= 35: - return '🌡' - else: - return '❄️' - - -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'], - read_timeout=config['kettle']['read_timeout']) - 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: - updates = [] - deletions = [] - forget = [] - - with self._muts_lock and self._info_lock: - if self._muts and self._accumulated_updates and (self._info_flushed_time == 0 or time.time() - self._info_flushed_time >= 1): - 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((mut, upd)) - - elif upd.changed: - updates.append((mut, upd)) - - self._info_flushed_time = time.time() - self._accumulated_updates = {} - - # edit messages - for mut, upd in updates: - self._logger.debug(f'loop: got update: {upd}') - try: - do_edit = True - if upd.finished: - # try to delete the old message and send a new one, to notify user more effectively - try: - bot.delete_message(upd.user_id, upd.message_id) - do_edit = False - except TelegramError as exc: - self._logger.error(f'loop: failed to delete old message (in order to send a new one)') - self._logger.exception(exc) - - if do_edit: - bot.edit_message_text(upd.user_id, upd.message_id, - text=upd.html, - reply_markup=upd.markup) - else: - bot.notify_user(upd.user_id, upd.html, reply_markup=upd.markup) - except TelegramError as exc: - if "Message can't be edited" in exc.message: - self._logger.warning("message can't be edited, adding it to forget list") - forget.append(upd) - - self._logger.error(f'loop: edit_message_text failed for update: {upd}') - self._logger.exception(exc) - - # delete messages - for mut, 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) - - # delete muts, if needed - if forget: - with self._muts_lock: - for mut in forget: - self._logger.debug(f'loop: removing mut {mut}') - self._muts.remove(mut) - - 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: bot.Context) -> RenderedContent: - html = f'<b>{ctx.lang("settings")}</b>\n\n' - html += ctx.lang('select_place') - return html, None - - @classmethod - def status(cls, ctx: bot.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 temp(cls, ctx: bot.Context, choices) -> RenderedContent: - buttons = [] - for chunk in chunks(choices, 5): - buttons.append([f'{temperature_emoji(n)} {n}' for n in chunk]) - buttons.append([ctx.lang('back')]) - return ctx.lang('select_temperature'), ReplyKeyboardMarkup(buttons) - - @classmethod - def turned_on(cls, ctx: bot.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: - html = ctx.lang('enabled') - - # target temperature - html += '\n' - html += ctx.lang('enabled_target', temperature_emoji(target_temp), target_temp) - - # current temperature - html += '\n' - html += temperature_emoji(current_temp) + ' ' - 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: bot.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: bot.Context) -> RenderedContent: - return ctx.lang('status_not_connected'), None - - @classmethod - def smth_went_wrong(cls, ctx: bot.Context) -> RenderedContent: - html = ctx.lang('smth_went_wrong') - return html, None - - @classmethod - def updated(cls, ctx: bot.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: bot.Context): - return InlineKeyboardMarkup([ - [ - InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait') - ] - ]) - - -MUTUpdate = namedtuple('MUTUpdate', 'message_id, user_id, finished, changed, delete, html, markup') - - -class MessageUpdatingTarget: - ctx: bot.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: bot.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 - - -@bot.handler(command='status') -def status(ctx: bot.Context) -> None: - 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) - ctx.reply(text, markup=markup) - - -@bot.handler(command='temp') -def temp(ctx: bot.Context) -> None: - text, markup = Renderer.temp( - ctx, choices=all_choices) - ctx.reply(text, markup=markup) - - -def enable(temp: int, ctx: bot.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.debug(f'ctx.reply returned message: {message}') - - if ok: - 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) - - -@bot.handler(message='disable') -def disable(ctx: bot.Context): - if not kc.kettle.is_connected(): - text, markup = Renderer.not_connected(ctx) - ctx.reply(text, markup=markup) - return - - def done(ok: bool): - mode = kc.info.mode - if not ok: - html, markup = Renderer.smth_went_wrong(ctx) - else: - kw = {} - if mode == PowerType.OFF: - kw['reached'] = True - kw['no_keyboard'] = True - html, markup = Renderer.turned_off(ctx, mode=mode, **kw) - message = ctx.reply(html, markup=markup) - logger.debug(f'ctx.reply returned message: {message}') - - if ok and mode != PowerType.OFF: - mut = MessageUpdatingTarget(ctx, message, - initial_power_mode=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) - - -@bot.handler(message='back') -def back(ctx: bot.Context): - bot.start(ctx) - - -@bot.defaultreplymarkup -def defaultmarkup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [ - [f'{temperature_emoji(n)} {n}' for n in primary_choices], - [ctx.lang('disable')] - ] - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - for temp in primary_choices: - bot.handler(text=f'{temperature_emoji(temp)} {temp}')(partial(enable, temp)) - - for temp in all_choices: - bot.handler(text=f'{temperature_emoji(temp)} {temp}')(partial(enable, temp)) - - kc = KettleController() - - 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() diff --git a/src/polaris_kettle_util.py b/src/polaris_kettle_util.py deleted file mode 100755 index 81326dd..0000000 --- a/src/polaris_kettle_util.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: BSD-3-Clause - -import logging -import sys -import paho.mqtt.client as mqtt - -from typing import Optional -from argparse import ArgumentParser -from queue import SimpleQueue -from home.mqtt import MqttBase -from home.config import config -from syncleo import ( - Kettle, - PowerType, - protocol as kettle_proto -) - -k: Optional[Kettle] = None -logger = logging.getLogger(__name__) -control_tasks = SimpleQueue() - - -class MqttServer(MqttBase): - def __init__(self): - super().__init__(clean_session=False) - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - logger.info("subscribing to #") - client.subscribe('#', qos=1) - - def on_message(self, client: mqtt.Client, userdata, msg): - try: - print(msg.topic, msg.payload) - - except Exception as e: - logger.exception(str(e)) - - -def kettle_connection_established(response: kettle_proto.MessageResponse): - try: - assert isinstance(response, kettle_proto.AckMessage), f'ACK expected, but received: {response}' - except AssertionError: - 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 - - if not control_tasks.empty(): - task = control_tasks.get() - f, args = task(k) - args.append(next_task) - f(*args) - else: - k.stop_all() - - next_task(response) - - -def main(): - tempmin = 30 - tempmax = 100 - tempstep = 5 - - parser = ArgumentParser() - parser.add_argument('-m', dest='mode', required=True, type=str, choices=('mqtt', 'control')) - parser.add_argument('--on', action='store_true') - parser.add_argument('--off', action='store_true') - parser.add_argument('-t', '--temperature', dest='temp', type=int, default=tempmax, - choices=range(tempmin, tempmax+tempstep, tempstep)) - - arg = config.load('polaris_kettle_util', use_cli=True, parser=parser) - - if arg.mode == 'mqtt': - server = MqttServer() - try: - server.connect_and_loop(loop_forever=True) - except KeyboardInterrupt: - pass - - elif arg.mode == 'control': - if arg.on and arg.off: - raise RuntimeError('--on and --off are mutually exclusive') - - if arg.off: - control_tasks.put(lambda k: (k.set_power, [PowerType.OFF])) - else: - if arg.temp == tempmax: - control_tasks.put(lambda k: (k.set_power, [PowerType.ON])) - else: - control_tasks.put(lambda k: (k.set_target_temperature, [arg.temp])) - control_tasks.put(lambda k: (k.set_power, [PowerType.CUSTOM])) - - k = Kettle(mac=config['kettle']['mac'], device_token=config['kettle']['token']) - info = k.discover() - if not info: - print('no device found.') - return 1 - - print('found service:', info) - k.start_server_if_needed(kettle_connection_established) - - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/src/pump_bot.py b/src/pump_bot.py deleted file mode 100755 index de925db..0000000 --- a/src/pump_bot.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -from enum import Enum -from typing import Optional -from telegram import ReplyKeyboardMarkup, User - -from home.config import config -from home.telegram import bot -from home.telegram._botutil import user_any_name -from home.relay.sunxi_h3_client import RelayClient -from home.api.types import BotType - -config.load('pump_bot') - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - unknown_command="Неизвестная команда", - - enable="Включить", - enable_silently="Включить тихо", - enabled="Включен ✅", - - disable="Выключить", - disable_silently="Выключить тихо", - disabled="Выключен ❌", - - status="Статус", - done="Готово 👌", - user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> насос.', - user_action_on="включил", - user_action_off="выключил", -) -bot.lang.en( - start_message="Select command on the keyboard", - unknown_command="Unknown command", - - enable="Turn ON", - enable_silently="Turn ON silently", - enabled="Turned ON ✅", - - disable="Turn OFF", - disable_silently="Turn OFF silently", - disabled="Turned OFF ❌", - - status="Status", - done="Done 👌", - user_action_notification='User <a href="tg://user?id=%d">%s</a> turned the pump <b>%s</b>.', - user_action_on="ON", - user_action_off="OFF", -) - - -class UserAction(Enum): - ON = 'on' - OFF = 'off' - - -def get_relay() -> RelayClient: - relay = RelayClient(host=config['relay']['ip'], port=config['relay']['port']) - relay.connect() - return relay - - -def on(ctx: bot.Context, silent=False) -> None: - get_relay().on() - ctx.reply(ctx.lang('done')) - if not silent: - notify(ctx.user, UserAction.ON) - - -def off(ctx: bot.Context, silent=False) -> None: - get_relay().off() - ctx.reply(ctx.lang('done')) - if not silent: - notify(ctx.user, UserAction.OFF) - - -def notify(user: User, action: UserAction) -> None: - def text_getter(lang: str): - action_name = bot.lang.get(f'user_action_{action.value}', lang) - user_name = user_any_name(user) - return 'ℹ ' + bot.lang.get('user_action_notification', lang, - user.id, user_name, action_name) - - bot.notify_all(text_getter, exclude=(user.id,)) - - -@bot.handler(message='enable') -def enable_handler(ctx: bot.Context) -> None: - on(ctx) - - -@bot.handler(message='enable_silently') -def enable_s_handler(ctx: bot.Context) -> None: - on(ctx, True) - - -@bot.handler(message='disable') -def disable_handler(ctx: bot.Context) -> None: - off(ctx) - - -@bot.handler(message='disable_silently') -def disable_s_handler(ctx: bot.Context) -> None: - off(ctx, True) - - -@bot.handler(message='status') -def status(ctx: bot.Context) -> None: - ctx.reply( - ctx.lang('enabled') if get_relay().status() == 'on' else ctx.lang('disabled') - ) - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [ - [ctx.lang('enable'), ctx.lang('disable')], - ] - - if ctx.user_id in config['bot']['silent_users']: - buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')]) - - buttons.append([ctx.lang('status')]) - - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - bot.enable_logging(BotType.PUMP) - bot.run() diff --git a/src/pump_mqtt_bot.py b/src/pump_mqtt_bot.py deleted file mode 100755 index d3b6de4..0000000 --- a/src/pump_mqtt_bot.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 -import datetime - -from enum import Enum -from typing import Optional -from telegram import ReplyKeyboardMarkup, User - -from home.config import config -from home.telegram import bot -from home.telegram._botutil import user_any_name -from home.mqtt.esp import MqttEspDevice -from home.mqtt import MqttRelay, MqttRelayState -from home.mqtt.payload import MqttPayload -from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload - - -config.load('pump_mqtt_bot') - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - start_message_no_access="Доступ запрещён. Вы можете отправить заявку на получение доступа.", - unknown_command="Неизвестная команда", - send_access_request="Отправить заявку", - management="Админка", - - enable="Включить", - enabled="Включен ✅", - - disable="Выключить", - disabled="Выключен ❌", - - status="Статус", - status_updated=' (обновлено %s)', - - done="Готово 👌", - user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> насос.', - user_action_on="включил", - user_action_off="выключил", - date_yday="вчера", - date_yyday="позавчера", - date_at="в" -) -bot.lang.en( - start_message="Select command on the keyboard", - start_message_no_access="You have no access.", - unknown_command="Unknown command", - send_access_request="Send request", - management="Admin options", - - enable="Turn ON", - enable_silently="Turn ON silently", - enabled="Turned ON ✅", - - disable="Turn OFF", - disable_silently="Turn OFF silently", - disabled="Turned OFF ❌", - - status="Status", - status_updated=' (updated %s)', - - done="Done 👌", - user_action_notification='User <a href="tg://user?id=%d">%s</a> turned the pump <b>%s</b>.', - user_action_on="ON", - user_action_off="OFF", - - date_yday="yesterday", - date_yyday="the day before yesterday", - date_at="at" -) - - -mqtt_relay: Optional[MqttRelay] = None -relay_state = MqttRelayState() - - -class UserAction(Enum): - ON = 'on' - OFF = 'off' - - -def on_mqtt_message(home_id, message: MqttPayload): - if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): - kwargs = dict(rssi=message.rssi, enabled=message.flags.state) - if isinstance(message, InitialDiagnosticsPayload): - kwargs['fw_version'] = message.fw_version - relay_state.update(**kwargs) - - -def notify(user: User, action: UserAction) -> None: - def text_getter(lang: str): - action_name = bot.lang.get(f'user_action_{action.value}', lang) - user_name = user_any_name(user) - return 'ℹ ' + bot.lang.get('user_action_notification', lang, - user.id, user_name, action_name) - - bot.notify_all(text_getter, exclude=(user.id,)) - - -@bot.handler(message='enable') -def enable_handler(ctx: bot.Context) -> None: - mqtt_relay.set_power(config['mqtt']['home_id'], True) - ctx.reply(ctx.lang('done')) - notify(ctx.user, UserAction.ON) - - -@bot.handler(message='disable') -def disable_handler(ctx: bot.Context) -> None: - mqtt_relay.set_power(config['mqtt']['home_id'], False) - ctx.reply(ctx.lang('done')) - notify(ctx.user, UserAction.OFF) - - -@bot.handler(message='status') -def status(ctx: bot.Context) -> None: - label = ctx.lang('enabled') if relay_state.enabled else ctx.lang('disabled') - if relay_state.ever_updated: - date_label = '' - today = datetime.date.today() - if today != relay_state.update_time.date(): - yday = today - datetime.timedelta(days=1) - yyday = today - datetime.timedelta(days=2) - if yday == relay_state.update_time.date(): - date_label = ctx.lang('date_yday') - elif yyday == relay_state.update_time.date(): - date_label = ctx.lang('date_yyday') - else: - date_label = relay_state.update_time.strftime('%d.%m.%Y') - date_label += ' ' - date_label += ctx.lang('date_at') + ' ' - date_label += relay_state.update_time.strftime('%H:%M') - label += ctx.lang('status_updated', date_label) - ctx.reply(label) - - -def start(ctx: bot.Context) -> None: - if ctx.user_id in config['bot']['users'] or ctx.user_id in config['bot']['admin_users']: - ctx.reply(ctx.lang('start_message')) - else: - buttons = [ - [ctx.lang('send_access_request')] - ] - ctx.reply(ctx.lang('start_message_no_access'), markup=ReplyKeyboardMarkup(buttons, one_time_keyboard=False)) - - -@bot.exceptionhandler -def exception_handler(e: Exception, ctx: bot.Context) -> bool: - return False - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [[ctx.lang('enable'), ctx.lang('disable')], [ctx.lang('status')]] - if ctx.user_id in config['bot']['admin_users']: - buttons.append([ctx.lang('management')]) - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - mqtt_relay = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'], - secret=config['mqtt']['home_secret'])) - mqtt_relay.set_message_callback(on_mqtt_message) - mqtt_relay.configure_tls() - mqtt_relay.connect_and_loop(loop_forever=False) - - # bot.enable_logging(BotType.PUMP_MQTT) - bot.run(start_handler=start) - - mqtt_relay.disconnect() diff --git a/src/relay_mqtt_bot.py b/src/relay_mqtt_bot.py deleted file mode 100755 index ebbff82..0000000 --- a/src/relay_mqtt_bot.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -from enum import Enum -from typing import Optional -from telegram import ReplyKeyboardMarkup -from functools import partial - -from home.config import config -from home.telegram import bot -from home.mqtt import MqttRelay, MqttRelayState -from home.mqtt.esp import MqttEspDevice -from home.mqtt.payload import MqttPayload -from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload - - -config.load('relay_mqtt_bot') - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - unknown_command="Неизвестная команда", - done="Готово 👌", -) -bot.lang.en( - start_message="Select command on the keyboard", - unknown_command="Unknown command", - done="Done 👌", -) - - -type_emojis = { - 'lamp': '💡' -} -status_emoji = { - 'on': '✅', - 'off': '❌' -} -mqtt_relay: Optional[MqttRelay] = None -relay_states: dict[str, MqttRelayState] = {} - - -class UserAction(Enum): - ON = 'on' - OFF = 'off' - - -def on_mqtt_message(home_id, message: MqttPayload): - if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): - kwargs = dict(rssi=message.rssi, enabled=message.flags.state) - if isinstance(message, InitialDiagnosticsPayload): - kwargs['fw_version'] = message.fw_version - if home_id not in relay_states: - relay_states[home_id] = MqttRelayState() - relay_states[home_id].update(**kwargs) - - -def enable_handler(home_id: str, ctx: bot.Context) -> None: - mqtt_relay.set_power(home_id, True) - ctx.reply(ctx.lang('done')) - - -def disable_handler(home_id: str, ctx: bot.Context) -> None: - mqtt_relay.set_power(home_id, False) - ctx.reply(ctx.lang('done')) - - -def start(ctx: bot.Context) -> None: - ctx.reply(ctx.lang('start_message')) - - -@bot.exceptionhandler -def exception_handler(e: Exception, ctx: bot.Context) -> bool: - return False - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [] - for device_id, data in config['relays'].items(): - labels = data['labels'] - type_emoji = type_emojis[data['type']] - row = [f'{type_emoji}{status_emoji[i.value]} {labels[ctx.user_lang]}' - for i in UserAction] - buttons.append(row) - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - devices = [] - for device_id, data in config['relays'].items(): - devices.append(MqttEspDevice(id=device_id, - secret=data['secret'])) - labels = data['labels'] - bot.lang.ru(**{device_id: labels['ru']}) - bot.lang.en(**{device_id: labels['en']}) - - type_emoji = type_emojis[data['type']] - - for action in UserAction: - messages = [] - for _lang, _label in labels.items(): - messages.append(f'{type_emoji}{status_emoji[action.value]} {labels[_lang]}') - bot.handler(texts=messages)(partial(enable_handler if action == UserAction.ON else disable_handler, device_id)) - - mqtt_relay = MqttRelay(devices=devices) - mqtt_relay.set_message_callback(on_mqtt_message) - mqtt_relay.configure_tls() - mqtt_relay.connect_and_loop(loop_forever=False) - - # bot.enable_logging(BotType.RELAY_MQTT) - bot.run(start_handler=start) - - mqtt_relay.disconnect() diff --git a/src/relay_mqtt_http_proxy.py b/src/relay_mqtt_http_proxy.py deleted file mode 100755 index 098facc..0000000 --- a/src/relay_mqtt_http_proxy.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -from home import http -from home.config import config -from home.mqtt import MqttRelay, MqttRelayState -from home.mqtt.esp import MqttEspDevice -from home.mqtt.payload import MqttPayload -from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload -from typing import Optional - -mqtt_relay: Optional[MqttRelay] = None -relay_states: dict[str, MqttRelayState] = {} - - -def on_mqtt_message(device_id, message: MqttPayload): - if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): - kwargs = dict(rssi=message.rssi, enabled=message.flags.state) - if device_id not in relay_states: - relay_states[device_id] = MqttRelayState() - relay_states[device_id].update(**kwargs) - - -class RelayMqttHttpProxy(http.HTTPServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.get('/relay/{id}/on', self.relay_on) - self.get('/relay/{id}/off', self.relay_off) - self.get('/relay/{id}/toggle', self.relay_toggle) - - async def _relay_on_off(self, - enable: Optional[bool], - req: http.Request): - device_id = req.match_info['id'] - device_secret = req.query['secret'] - - if enable is None: - if device_id in relay_states and relay_states[device_id].ever_updated: - cur_state = relay_states[device_id].enabled - else: - cur_state = False - enable = not cur_state - - mqtt_relay.set_power(device_id, enable, device_secret) - return self.ok() - - async def relay_on(self, req: http.Request): - return await self._relay_on_off(True, req) - - async def relay_off(self, req: http.Request): - return await self._relay_on_off(False, req) - - async def relay_toggle(self, req: http.Request): - return await self._relay_on_off(None, req) - - -if __name__ == '__main__': - config.load('relay_mqtt_http_proxy') - - mqtt_relay = MqttRelay(devices=[MqttEspDevice(id=device_id) for device_id in config.get('relay.devices')]) - mqtt_relay.configure_tls() - mqtt_relay.set_message_callback(on_mqtt_message) - mqtt_relay.connect_and_loop(loop_forever=False) - - proxy = RelayMqttHttpProxy(config.get_addr('server.listen')) - try: - proxy.run() - except KeyboardInterrupt: - mqtt_relay.disconnect() diff --git a/src/sensors_bot.py b/src/sensors_bot.py deleted file mode 100755 index dc081b0..0000000 --- a/src/sensors_bot.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -import json -import socket -import logging -import re -import gc - -from io import BytesIO -from typing import Optional - -import matplotlib.pyplot as plt -import matplotlib.dates as mdates -import matplotlib.ticker as mticker - -from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton - -from home.config import config -from home.telegram import bot -from home.util import chunks, MySimpleSocketClient -from home.api import WebAPIClient -from home.api.types import ( - BotType, - TemperatureSensorLocation -) - -config.load('sensors_bot') -bot.initialize() - -bot.lang.ru( - start_message="Выберите датчик на клавиатуре", - unknown_command="Неизвестная команда", - temperature="Температура", - humidity="Влажность", - plot_3h="График за 3 часа", - plot_6h="График за 6 часов", - plot_12h="График за 12 часов", - plot_24h="График за 24 часа", - unexpected_callback_data="Ошибка: неверные данные", - loading="Загрузка...", - n_hrs="график за %d ч." -) -bot.lang.en( - start_message="Select the sensor on the keyboard", - unknown_command="Unknown command", - temperature="Temperature", - humidity="Relative humidity", - plot_3h="Graph for 3 hours", - plot_6h="Graph for 6 hours", - plot_12h="Graph for 12 hours", - plot_24h="Graph for 24 hours", - unexpected_callback_data="Unexpected callback data", - loading="Loading...", - n_hrs="graph for %d hours" -) - -plt.rcParams['font.size'] = 7 -logger = logging.getLogger(__name__) -plot_hours = [3, 6, 12, 24] - - -_sensor_names = [] -for k, v in config['sensors'].items(): - _sensor_names.append(k) - bot.lang.set({k: v['label_ru']}, 'ru') - bot.lang.set({k: v['label_en']}, 'en') - - -@bot.handler(messages=_sensor_names, argument='message_key') -def read_sensor(sensor: str, ctx: bot.Context) -> None: - host = config['sensors'][sensor]['ip'] - port = config['sensors'][sensor]['port'] - - try: - client = MySimpleSocketClient(host, port) - client.write('read') - data = json.loads(client.read()) - except (socket.timeout, socket.error) as error: - return ctx.reply_exc(error) - - temp = round(data['temp'], 2) - humidity = round(data['humidity'], 2) - - text = ctx.lang('temperature') + f': <b>{temp} °C</b>\n' - text += ctx.lang('humidity') + f': <b>{humidity}%</b>' - - buttons = list(map( - lambda h: InlineKeyboardButton(ctx.lang(f'plot_{h}h'), callback_data=f'plot/{sensor}/{h}'), - plot_hours - )) - ctx.reply(text, markup=InlineKeyboardMarkup(chunks(buttons, 2))) - - -@bot.callbackhandler(callback='*') -def callback_handler(ctx: bot.Context) -> None: - query = ctx.callback_query - - sensors_variants = '|'.join(config['sensors'].keys()) - hour_variants = '|'.join(list(map( - lambda n: str(n), - plot_hours - ))) - - match = re.match(rf'plot/({sensors_variants})/({hour_variants})', query.data) - if not match: - query.answer(ctx.lang('unexpected_callback_data')) - return - - query.answer(ctx.lang('loading')) - - # retrieve data - sensor = TemperatureSensorLocation[match.group(1).upper()] - hours = int(match.group(2)) - - api = WebAPIClient(timeout=20) - data = api.get_sensors_data(sensor, hours) - - title = ctx.lang(sensor.name.lower()) + ' (' + ctx.lang('n_hrs', hours) + ')' - plot = draw_plot(data, title, - ctx.lang('temperature'), - ctx.lang('humidity')) - bot.send_photo(ctx.user_id, photo=plot) - - gc.collect() - - -def draw_plot(data, - title: str, - label_temp: str, - label_hum: str) -> BytesIO: - tempval = [] - humval = [] - dates = [] - for date, temp, humidity in data: - dates.append(date) - tempval.append(temp) - humval.append(humidity) - - fig, axs = plt.subplots(2, 1) - df = mdates.DateFormatter('%H:%M') - - axs[0].set_title(label_temp) - axs[0].plot(dates, tempval) - axs[0].xaxis.set_major_formatter(df) - axs[0].yaxis.set_major_formatter(mticker.FormatStrFormatter('%2.2f °C')) - - fig.suptitle(title, fontsize=10) - - axs[1].set_title(label_hum) - axs[1].plot(dates, humval) - axs[1].xaxis.set_major_formatter(df) - axs[1].yaxis.set_major_formatter(mticker.FormatStrFormatter('%2.1f %%')) - - fig.autofmt_xdate() - - # should be called after all axes have been added - fig.tight_layout() - - buf = BytesIO() - fig.savefig(buf, format='png', dpi=160) - buf.seek(0) - - plt.clf() - plt.close('all') - - return buf - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [] - for k in config['sensors'].keys(): - buttons.append(ctx.lang(k)) - buttons = chunks(buttons, 2) - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - if 'api' in config: - bot.enable_logging(BotType.SENSORS) - - bot.run() diff --git a/src/sensors_mqtt_receiver.py b/src/sensors_mqtt_receiver.py deleted file mode 100755 index a377ddd..0000000 --- a/src/sensors_mqtt_receiver.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -import paho.mqtt.client as mqtt -import re - -from home.mqtt import MqttBase -from home.config import config -from home.mqtt.payload.sensors import Temperature -from home.api.types import TemperatureSensorLocation -from home.database import SensorsDatabase - - -def get_sensor_type(sensor: str) -> TemperatureSensorLocation: - for item in TemperatureSensorLocation: - if sensor == item.name.lower(): - return item - raise ValueError(f'unexpected sensor value: {sensor}') - - -class MqttServer(MqttBase): - def __init__(self): - super().__init__(clean_session=False) - self.database = SensorsDatabase() - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - self._logger.info("subscribing to hk/#") - client.subscribe('hk/#', qos=1) - - def on_message(self, client: mqtt.Client, userdata, msg): - super().on_message(client, userdata, msg) - try: - variants = '|'.join([s.name.lower() for s in TemperatureSensorLocation]) - match = re.match(rf'hk/(\d+)/si7021/({variants})', msg.topic) - if not match: - return - - # FIXME string home_id must be supported - home_id = int(match.group(1)) - sensor = get_sensor_type(match.group(2)) - - payload = Temperature.unpack(msg.payload) - self.database.add_temperature(home_id, payload.time, sensor, - temp=int(payload.temp*100), - rh=int(payload.rh*100)) - except Exception as e: - self._logger.exception(str(e)) - - -if __name__ == '__main__': - config.load('sensors_mqtt_receiver') - - server = MqttServer() - server.connect_and_loop() diff --git a/src/sensors_mqtt_sender.py b/src/sensors_mqtt_sender.py deleted file mode 100755 index 87a28ca..0000000 --- a/src/sensors_mqtt_sender.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -import time -import json - -from home.util import parse_addr, MySimpleSocketClient -from home.mqtt import MqttBase, poll_tick -from home.mqtt.payload.sensors import Temperature -from home.config import config - - -class MqttClient(MqttBase): - def __init__(self): - super().__init__(self) - self._home_id = config['mqtt']['home_id'] - - def poll(self): - freq = int(config['mqtt']['sensors']['poll_freq']) - self._logger.debug(f'freq={freq}') - - g = poll_tick(freq) - while True: - time.sleep(next(g)) - for k, v in config['mqtt']['sensors']['si7021'].items(): - host, port = parse_addr(v['addr']) - self.publish_si7021(host, port, k) - - def publish_si7021(self, host: str, port: int, name: str): - self._logger.debug(f"publish_si7021/{name}: {host}:{port}") - - try: - now = time.time() - socket = MySimpleSocketClient(host, port) - - socket.write('read') - response = json.loads(socket.read().strip()) - - temp = response['temp'] - humidity = response['humidity'] - - self._logger.debug(f'publish_si7021/{name}: temp={temp} humidity={humidity}') - - pld = Temperature(time=round(now), - temp=temp, - rh=humidity) - self._client.publish(f'hk/{self._home_id}/si7021/{name}', - payload=pld.pack(), - qos=1) - except Exception as e: - self._logger.exception(e) - - -if __name__ == '__main__': - config.load('sensors_mqtt_sender') - - client = MqttClient() - client.configure_tls() - client.connect_and_loop(loop_forever=False) - client.poll() diff --git a/src/sound_bot.py b/src/sound_bot.py deleted file mode 100755 index 186337a..0000000 --- a/src/sound_bot.py +++ /dev/null @@ -1,889 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import tempfile - -from enum import Enum -from datetime import datetime, timedelta -from html import escape -from typing import Optional, List, Dict, Tuple - -from home.config import config -from home.api import WebAPIClient -from home.api.types import SoundSensorLocation, BotType -from home.api.errors import ApiResponseError -from home.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient -from home.soundsensor import SoundSensorServerGuardClient -from home.util import parse_addr, chunks, filesize_fmt - -from home.telegram import bot - -from telegram.error import TelegramError -from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton, User - -from PIL import Image - -config.load('sound_bot') - -nodes = {} -for nodename, nodecfg in config['nodes'].items(): - nodes[nodename] = parse_addr(nodecfg['addr']) - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - unknown_command="Неизвестная команда", - unexpected_callback_data="Ошибка: неверные данные", - settings="Настройки микшера", - record="Запись", - loading="Загрузка...", - select_place="Выберите место:", - invalid_location="Неверное место", - invalid_interval="Неверная длительность", - unsupported_action="Неподдерживаемое действие", - # select_control="Выберите контрол для изменения настроек:", - control_state="Состояние контрола %s", - incr="громкость +", - decr="громкость -", - back="◀️ Назад", - n_min="%d мин.", - n_sec="%d сек.", - select_interval="Выберите длительность:", - place="Место", - beginning="Начало", - end="Конец", - record_result="Результат записи", - record_started='Запись запущена!', - record_error="Ошибка записи", - files="Локальные файлы", - remote_files="Файлы на сервере", - file_line="— Запись с <b>%s</b> до <b>%s</b> <i>(%s)</i>", - access_denied="Доступ запрещён", - - guard_disable="Снять с охраны", - guard_enable="Поставить на охрану", - guard_status="Статус охраны", - guard_user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> %s.', - guard_user_action_enable="включил охрану ✅", - guard_user_action_disable="выключил охрану ❌", - guard_status_enabled="Включена ✅", - guard_status_disabled="Выключена ❌", - - done="Готово 👌", - - sound_sensors="Датчики звука", - sound_sensors_info="Здесь можно получить информацию о последних срабатываниях датчиков звука.", - sound_sensors_no_24h_data="За последние 24 часа данных нет.", - sound_sensors_show_anything="Показать, что есть", - - cameras="Камеры", - select_option="Выберите опцию", - w_flash="Со вспышкой", - wo_flash="Без вспышки", -) - -bot.lang.en( - start_message="Select command on the keyboard", - unknown_command="Unknown command", - settings="Mixer settings", - record="Record", - unexpected_callback_data="Unexpected callback data", - loading="Loading...", - select_place="Select place:", - invalid_location="Invalid place", - invalid_interval="Invalid duration", - unsupported_action="Unsupported action", - # select_control="Select control to adjust its parameters:", - control_state="%s control state", - incr="vol +", - decr="vol -", - back="◀️ Back", - n_min="%d min.", - n_sec="%d s.", - select_interval="Select duration:", - place="Place", - beginning="Started", - end="Ended", - record_result="Result", - record_started='Recording started!', - record_error="Recording error", - files="Local files", - remote_files="Remote files", - file_line="— From <b>%s</b> to <b>%s</b> <i>(%s)</i>", - access_denied="Access denied", - - guard_disable="Disable guard", - guard_enable="Enable guard", - guard_status="Guard status", - guard_user_action_notification='User <a href="tg://user?id=%d">%s</a> %s.', - guard_user_action_enable="turned the guard ON ✅", - guard_user_action_disable="turn the guard OFF ❌", - guard_status_enabled="Active ✅", - guard_status_disabled="Disabled ❌", - done="Done 👌", - - sound_sensors="Sound sensors", - sound_sensors_info="Here you can get information about last sound sensors hits.", - sound_sensors_no_24h_data="No data for the last 24 hours.", - sound_sensors_show_anything="Show me at least something", - - cameras="Cameras", - select_option="Select option", - w_flash="With flash", - wo_flash="Without flash", -) - -logger = logging.getLogger(__name__) -RenderedContent = Tuple[str, Optional[InlineKeyboardMarkup]] -record_client: Optional[SoundRecordClient] = None -node_client_links: Dict[str, SoundNodeClient] = {} -cam_client_links: Dict[str, CameraNodeClient] = {} - - -def node_client(node: str) -> SoundNodeClient: - if node not in node_client_links: - node_client_links[node] = SoundNodeClient(parse_addr(config['nodes'][node]['addr'])) - return node_client_links[node] - - -def camera_client(cam: str) -> CameraNodeClient: - if cam not in node_client_links: - cam_client_links[cam] = CameraNodeClient(parse_addr(config['cameras'][cam]['addr'])) - return cam_client_links[cam] - - -def node_exists(node: str) -> bool: - return node in config['nodes'] - - -def camera_exists(name: str) -> bool: - return name in config['cameras'] - - -def camera_settings(name: str) -> Optional[dict]: - try: - return config['cameras'][name]['settings'] - except KeyError: - return None - - -def have_cameras() -> bool: - return 'cameras' in config and config['cameras'] - - -def sound_sensor_exists(node: str) -> bool: - return node in config['sound_sensors'] - - -def interval_defined(interval: int) -> bool: - return interval in config['bot']['record_intervals'] - - -def callback_unpack(ctx: bot.Context) -> List[str]: - return ctx.callback_query.data[3:].split('/') - - -def manual_recording_allowed(user_id: int) -> bool: - return 'manual_record_allowlist' not in config['bot'] or user_id in config['bot']['manual_record_allowlist'] - - -def guard_client() -> SoundSensorServerGuardClient: - return SoundSensorServerGuardClient(parse_addr(config['bot']['guard_server'])) - - -# message renderers -# ----------------- - -class Renderer: - @classmethod - def places_markup(cls, ctx: bot.Context, callback_prefix: str) -> InlineKeyboardMarkup: - buttons = [] - for node, nodeconfig in config['nodes'].items(): - buttons.append([InlineKeyboardButton(nodeconfig['label'][ctx.user_lang], callback_data=f'{callback_prefix}/{node}')]) - return InlineKeyboardMarkup(buttons) - - @classmethod - def back_button(cls, - ctx: bot.Context, - buttons: list, - callback_data: str): - buttons.append([ - InlineKeyboardButton(ctx.lang('back'), callback_data=callback_data) - ]) - - -class SettingsRenderer(Renderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'<b>{ctx.lang("settings")}</b>\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='s0') - - @classmethod - def node(cls, ctx: bot.Context, - controls: List[dict]) -> RenderedContent: - node, = callback_unpack(ctx) - - html = [] - buttons = [] - for control in controls: - html.append(f'<b>{control["name"]}</b>\n{escape(control["info"])}') - buttons.append([ - InlineKeyboardButton(control['name'], callback_data=f's1/{node}/{control["name"]}') - ]) - - html = "\n\n".join(html) - cls.back_button(ctx, buttons, callback_data='s0') - - return html, InlineKeyboardMarkup(buttons) - - @classmethod - def control(cls, ctx: bot.Context, data) -> RenderedContent: - node, control, *rest = callback_unpack(ctx) - - html = '<b>' + ctx.lang('control_state', control) + '</b>\n\n' - html += escape(data['info']) - buttons = [] - callback_prefix = f's2/{node}/{control}' - for cap in data['caps']: - if cap == 'mute': - muted = 'dB] [off]' in data['info'] - act = 'unmute' if muted else 'mute' - buttons.append([InlineKeyboardButton(act, callback_data=f'{callback_prefix}/{act}')]) - - elif cap == 'cap': - cap_dis = 'Capture [off]' in data['info'] - act = 'cap' if cap_dis else 'nocap' - buttons.append([InlineKeyboardButton(act, callback_data=f'{callback_prefix}/{act}')]) - - elif cap == 'volume': - buttons.append( - list(map(lambda s: InlineKeyboardButton(ctx.lang(s), callback_data=f'{callback_prefix}/{s}'), - ['decr', 'incr'])) - ) - - cls.back_button(ctx, buttons, callback_data=f's0/{node}') - - return html, InlineKeyboardMarkup(buttons) - - -class RecordRenderer(Renderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'<b>{ctx.lang("record")}</b>\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='r0') - - @classmethod - def node(cls, ctx: bot.Context, durations: List[int]) -> RenderedContent: - node, = callback_unpack(ctx) - - html = ctx.lang('select_interval') - - buttons = [] - for s in durations: - if s >= 60: - m = int(s / 60) - label = ctx.lang('n_min', m) - else: - label = ctx.lang('n_sec', s) - buttons.append(InlineKeyboardButton(label, callback_data=f'r1/{node}/{s}')) - buttons = list(chunks(buttons, 3)) - cls.back_button(ctx, buttons, callback_data=f'r0') - - return html, InlineKeyboardMarkup(buttons) - - @classmethod - def record_started(cls, ctx: bot.Context, rid: int) -> RenderedContent: - node, *rest = callback_unpack(ctx) - - place = config['nodes'][node]['label'][ctx.user_lang] - - html = f'<b>{ctx.lang("record_started")}</b> (<i>{place}</i>, id={rid})' - return html, None - - @classmethod - def record_done(cls, info: dict, node: str, uid: int) -> str: - ulang = bot.db.get_user_lang(uid) - - def lang(key, *args): - return bot.lang.get(key, ulang, *args) - - rid = info['id'] - fmt = '%d.%m.%y %H:%M:%S' - start_time = datetime.fromtimestamp(int(info['start_time'])).strftime(fmt) - stop_time = datetime.fromtimestamp(int(info['stop_time'])).strftime(fmt) - - place = config['nodes'][node]['label'][ulang] - - html = f'<b>{lang("record_result")}</b> (<i>{place}</i>, id={rid})\n\n' - html += f'<b>{lang("beginning")}</b>: {start_time}\n' - html += f'<b>{lang("end")}</b>: {stop_time}' - - return html - - @classmethod - def record_error(cls, info: dict, node: str, uid: int) -> str: - ulang = bot.db.get_user_lang(uid) - - def lang(key, *args): - return bot.lang.get(key, ulang, *args) - - place = config['nodes'][node]['label'][ulang] - rid = info['id'] - - html = f'<b>{lang("record_error")}</b> (<i>{place}</i>, id={rid})' - if 'error' in info: - html += '\n'+str(info['error']) - - return html - - -class FilesRenderer(Renderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'<b>{ctx.lang("files")}</b>\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='f0') - - @classmethod - def filelist(cls, ctx: bot.Context, files: List[SoundRecordFile]) -> RenderedContent: - node, = callback_unpack(ctx) - - html_files = map(lambda file: cls.file(ctx, file, node), files) - html = '\n\n'.join(html_files) - - buttons = [] - cls.back_button(ctx, buttons, callback_data='f0') - - return html, InlineKeyboardMarkup(buttons) - - @classmethod - def file(cls, ctx: bot.Context, file: SoundRecordFile, node: str) -> str: - html = ctx.lang('file_line', file.start_humantime, file.stop_humantime, filesize_fmt(file.filesize)) - if file.file_id is not None: - html += f'/audio_{node}_{file.file_id}' - return html - - -class RemoteFilesRenderer(FilesRenderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'<b>{ctx.lang("remote_files")}</b>\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='g0') - - -class SoundSensorRenderer(Renderer): - @classmethod - def places_markup(cls, ctx: bot.Context, callback_prefix: str) -> InlineKeyboardMarkup: - buttons = [] - for sensor, sensor_label in config['sound_sensors'].items(): - buttons.append( - [InlineKeyboardButton(sensor_label[ctx.user_lang], callback_data=f'{callback_prefix}/{sensor}')]) - return InlineKeyboardMarkup(buttons) - - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'{ctx.lang("sound_sensors_info")}\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='S0') - - @classmethod - def hits(cls, ctx: bot.Context, data, is_last=False) -> RenderedContent: - node, = callback_unpack(ctx) - buttons = [] - - if not data: - html = ctx.lang('sound_sensors_no_24h_data') - if not is_last: - buttons.append([InlineKeyboardButton(ctx.lang('sound_sensors_show_anything'), callback_data=f'S1/{node}')]) - else: - html = '' - prev_date = None - for item in data: - item_date = item['time'].strftime('%d.%m.%y') - if prev_date is None or prev_date != item_date: - if html != '': - html += '\n\n' - html += f'<b>{item_date}</b>' - prev_date = item_date - html += '\n' + item['time'].strftime('%H:%M:%S') + f' (+{item["hits"]})' - cls.back_button(ctx, buttons, callback_data='S0') - return html, InlineKeyboardMarkup(buttons) - - @classmethod - def hits_plain(cls, ctx: bot.Context, data, is_last=False) -> bytes: - node, = callback_unpack(ctx) - - text = '' - prev_date = None - for item in data: - item_date = item['time'].strftime('%d.%m.%y') - if prev_date is None or prev_date != item_date: - if text != '': - text += '\n\n' - text += item_date - prev_date = item_date - text += '\n' + item['time'].strftime('%H:%M:%S') + f' (+{item["hits"]})' - - return text.encode() - - -class CamerasRenderer(Renderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'<b>{ctx.lang("cameras")}</b>\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='c0') - - @classmethod - def places_markup(cls, ctx: bot.Context, callback_prefix: str) -> InlineKeyboardMarkup: - buttons = [] - for camera_name, camera_data in config['cameras'].items(): - buttons.append( - [InlineKeyboardButton(camera_data['label'][ctx.user_lang], callback_data=f'{callback_prefix}/{camera_name}')]) - return InlineKeyboardMarkup(buttons) - - @classmethod - def camera(cls, ctx: bot.Context, flash_available: bool) -> RenderedContent: - node, = callback_unpack(ctx) - - html = ctx.lang('select_option') - - buttons = [] - if flash_available: - buttons.append(InlineKeyboardButton(ctx.lang('w_flash'), callback_data=f'c1/{node}/1')) - buttons.append(InlineKeyboardButton(ctx.lang('wo_flash'), callback_data=f'c1/{node}/0')) - - cls.back_button(ctx, [buttons], callback_data=f'c0') - - return html, InlineKeyboardMarkup([buttons]) - # - # @classmethod - # def record_started(cls, ctx: bot.Context, rid: int) -> RenderedContent: - # node, *rest = callback_unpack(ctx) - # - # place = config['nodes'][node]['label'][ctx.user_lang] - # - # html = f'<b>{ctx.lang("record_started")}</b> (<i>{place}</i>, id={rid})' - # return html, None - # - # @classmethod - # def record_done(cls, info: dict, node: str, uid: int) -> str: - # ulang = bot.db.get_user_lang(uid) - # - # def lang(key, *args): - # return bot.lang.get(key, ulang, *args) - # - # rid = info['id'] - # fmt = '%d.%m.%y %H:%M:%S' - # start_time = datetime.fromtimestamp(int(info['start_time'])).strftime(fmt) - # stop_time = datetime.fromtimestamp(int(info['stop_time'])).strftime(fmt) - # - # place = config['nodes'][node]['label'][ulang] - # - # html = f'<b>{lang("record_result")}</b> (<i>{place}</i>, id={rid})\n\n' - # html += f'<b>{lang("beginning")}</b>: {start_time}\n' - # html += f'<b>{lang("end")}</b>: {stop_time}' - # - # return html - # - # @classmethod - # def record_error(cls, info: dict, node: str, uid: int) -> str: - # ulang = bot.db.get_user_lang(uid) - # - # def lang(key, *args): - # return bot.lang.get(key, ulang, *args) - # - # place = config['nodes'][node]['label'][ulang] - # rid = info['id'] - # - # html = f'<b>{lang("record_error")}</b> (<i>{place}</i>, id={rid})' - # if 'error' in info: - # html += '\n'+str(info['error']) - # - # return html - - -# cameras handlers -# ---------------- - -@bot.handler(message='cameras', callback=r'^c0$') -def cameras(ctx: bot.Context): - """ List of cameras """ - - text, markup = CamerasRenderer.index(ctx) - if not ctx.is_callback_context(): - return ctx.reply(text, markup=markup) - else: - ctx.answer() - return ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^c0/.*') -def camera_options(ctx: bot.Context) -> None: - """ List of options (with/without flash etc) """ - - cam, = callback_unpack(ctx) - if not camera_exists(cam): - ctx.answer(ctx.lang('invalid_location')) - return - - ctx.answer() - flash_available = 'flash_available' in config['cameras'][cam] and config['cameras'][cam]['flash_available'] is True - - text, markup = CamerasRenderer.camera(ctx, flash_available) - ctx.edit(text, markup) - - -@bot.callbackhandler(callback=r'^c1/.*') -def camera_capture(ctx: bot.Context) -> None: - """ Cheese """ - - cam, flash = callback_unpack(ctx) - flash = int(flash) - if not camera_exists(cam): - ctx.answer(ctx.lang('invalid_location')) - return - - ctx.answer() - - client = camera_client(cam) - fd = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') - fd.close() - - client.capture(fd.name, with_flash=bool(flash)) - logger.debug(f'captured photo ({cam}), saved to {fd.name}') - - camera_config = config['cameras'][cam] - if 'rotate' in camera_config: - im = Image.open(fd.name) - im.rotate(camera_config['rotate'], expand=True) - # im.show() - im.save(fd.name) - logger.debug(f"rotated image {camera_config['rotate']} degrees") - - try: - with open(fd.name, 'rb') as f: - bot.send_photo(ctx.user_id, photo=f) - except TelegramError as exc: - logger.exception(exc) - - try: - os.unlink(fd.name) - except OSError as exc: - logger.exception(exc) - - -# settings handlers -# ----------------- - -@bot.handler(message='settings', callback=r'^s0$') -def settings(ctx: bot.Context): - """ List of nodes """ - - text, markup = SettingsRenderer.index(ctx) - if not ctx.is_callback_context(): - return ctx.reply(text, markup=markup) - else: - ctx.answer() - return ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^s0/.*') -def settings_place(ctx: bot.Context): - """ List of controls """ - - node, = callback_unpack(ctx) - if not node_exists(node): - ctx.answer(ctx.lang('invalid_location')) - return - - cl = node_client(node) - controls = cl.amixer_get_all() - - ctx.answer() - - text, markup = SettingsRenderer.node(ctx, controls) - ctx.edit(text, markup) - - -@bot.callbackhandler(callback=r'^s1/.*') -def settings_place_control(ctx: bot.Context): - """ List of available tunes for control """ - - node, control = callback_unpack(ctx) - if not node_exists(node): - ctx.answer(ctx.lang('invalid_location')) - return - - cl = node_client(node) - control_data = cl.amixer_get(control) - - ctx.answer() - - text, markup = SettingsRenderer.control(ctx, control_data) - ctx.edit(text, markup) - - -@bot.callbackhandler(callback=r'^s2/.*') -def settings_place_control_action(ctx: bot.Context): - """ Tuning """ - - node, control, action = callback_unpack(ctx) - if not node_exists(node): - return - - cl = node_client(node) - if not hasattr(cl, f'amixer_{action}'): - ctx.answer(ctx.lang('invalid_action')) - return - - func = getattr(cl, f'amixer_{action}') - control_data = func(control) - - ctx.answer() - - text, markup = SettingsRenderer.control(ctx, control_data) - ctx.edit(text, markup) - - -# recording handlers -# ------------------ - -@bot.handler(message='record', callback=r'^r0$') -def record(ctx: bot.Context): - """ List of nodes """ - - if not manual_recording_allowed(ctx.user_id): - return ctx.reply(ctx.lang('access_denied')) - - text, markup = RecordRenderer.index(ctx) - if not ctx.is_callback_context(): - return ctx.reply(text, markup=markup) - else: - ctx.answer() - return ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^r0/.*') -def record_place(ctx: bot.Context): - """ List of available intervals """ - - node, = callback_unpack(ctx) - if not node_exists(node): - ctx.answer(ctx.lang('invalid_location')) - return - - ctx.answer() - - text, markup = RecordRenderer.node(ctx, config['bot']['record_intervals']) - ctx.edit(text, markup) - - -@bot.callbackhandler(callback=r'^r1/.*') -def record_place_interval(ctx: bot.Context): - """ Do record! """ - - node, interval = callback_unpack(ctx) - interval = int(interval) - if not node_exists(node): - ctx.answer(ctx.lang('invalid_location')) - return - if not interval_defined(interval): - ctx.answer(ctx.lang('invalid_interval')) - return - - try: - record_id = record_client.record(node, interval, {'user_id': ctx.user_id, 'node': node}) - except ApiResponseError as e: - ctx.answer(e.error_message) - logger.error(e) - return - - ctx.answer() - - html, markup = RecordRenderer.record_started(ctx, record_id) - ctx.edit(html, markup) - - -# sound sensor handlers -# --------------------- - -@bot.handler(message='sound_sensors', callback=r'^S0$') -def sound_sensors(ctx: bot.Context): - """ List of places """ - - text, markup = SoundSensorRenderer.index(ctx) - if not ctx.is_callback_context(): - return ctx.reply(text, markup=markup) - else: - ctx.answer() - return ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^S0/.*') -def sound_sensors_last_24h(ctx: bot.Context): - """ Last 24h log """ - - node, = callback_unpack(ctx) - if not sound_sensor_exists(node): - ctx.answer(ctx.lang('invalid location')) - return - - ctx.answer() - - cl = WebAPIClient() - data = cl.get_sound_sensor_hits(location=SoundSensorLocation[node.upper()], - after=datetime.now() - timedelta(hours=24)) - - text, markup = SoundSensorRenderer.hits(ctx, data) - if len(text) > 4096: - plain = SoundSensorRenderer.hits_plain(ctx, data) - bot.send_file(ctx.user_id, document=plain, filename='data.txt') - else: - ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^S1/.*') -def sound_sensors_last_anything(ctx: bot.Context): - """ Last _something_ """ - - node, = callback_unpack(ctx) - if not sound_sensor_exists(node): - ctx.answer(ctx.lang('invalid location')) - return - - ctx.answer() - - cl = WebAPIClient() - data = cl.get_last_sound_sensor_hits(location=SoundSensorLocation[node.upper()], - last=20) - - text, markup = SoundSensorRenderer.hits(ctx, data, is_last=True) - if len(text) > 4096: - plain = SoundSensorRenderer.hits_plain(ctx, data) - bot.send_file(ctx.user_id, document=plain, filename='data.txt') - else: - ctx.edit(text, markup=markup) - - -# guard enable/disable handlers -# ----------------------------- - -class GuardUserAction(Enum): - ENABLE = 'enable' - DISABLE = 'disable' - - -if 'guard_server' in config['bot']: - @bot.handler(message='guard_status') - def guard_status(ctx: bot.Context): - guard = guard_client() - resp = guard.guard_status() - - key = 'enabled' if resp['enabled'] is True else 'disabled' - ctx.reply(ctx.lang(f'guard_status_{key}')) - - - @bot.handler(message='guard_enable') - def guard_enable(ctx: bot.Context): - guard = guard_client() - guard.guard_enable() - ctx.reply(ctx.lang('done')) - - _guard_notify(ctx.user, GuardUserAction.ENABLE) - - - @bot.handler(message='guard_disable') - def guard_disable(ctx: bot.Context): - guard = guard_client() - guard.guard_disable() - ctx.reply(ctx.lang('done')) - - _guard_notify(ctx.user, GuardUserAction.DISABLE) - - - def _guard_notify(user: User, action: GuardUserAction): - def text_getter(lang: str): - action_name = bot.lang.get(f'guard_user_action_{action.value}', lang) - user_name = bot.user_any_name(user) - return 'ℹ ' + bot.lang.get('guard_user_action_notification', lang, - user.id, user_name, action_name) - - bot.notify_all(text_getter, exclude=(user.id,)) - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [ - [ctx.lang('record'), ctx.lang('settings')], - # [ctx.lang('files'), ctx.lang('remote_files')], - ] - if 'guard_server' in config['bot']: - buttons.append([ - ctx.lang('guard_enable'), ctx.lang('guard_disable'), ctx.lang('guard_status') - ]) - buttons.append([ctx.lang('sound_sensors')]) - if have_cameras(): - buttons.append([ctx.lang('cameras')]) - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -# record client callbacks -# ----------------------- - -def record_onerror(info: dict, userdata: dict): - uid = userdata['user_id'] - node = userdata['node'] - - html = RecordRenderer.record_error(info, node, uid) - try: - bot.notify_user(userdata['user_id'], html) - except TelegramError as exc: - logger.exception(exc) - finally: - record_client.forget(node, info['id']) - - -def record_onfinished(info: dict, fn: str, userdata: dict): - logger.info('record finished: ' + str(info)) - - uid = userdata['user_id'] - node = userdata['node'] - - html = RecordRenderer.record_done(info, node, uid) - bot.notify_user(uid, html) - - try: - # sending audiofile to telegram - with open(fn, 'rb') as f: - bot.send_audio(uid, audio=f, filename='audio.mp3') - - # deleting temp file - try: - os.unlink(fn) - except OSError as exc: - logger.exception(exc) - bot.notify_user(uid, exc) - - # remove the recording from sound_node's history - record_client.forget(node, info['id']) - - # remove file from storage - # node_client(node).storage_delete(info['file']['fileid']) - except Exception as e: - logger.exception(e) - - -if __name__ == '__main__': - record_client = SoundRecordClient(nodes, - error_handler=record_onerror, - finished_handler=record_onfinished, - download_on_finish=True) - - if 'api' in config: - bot.enable_logging(BotType.SOUND) - bot.run() - record_client.stop() diff --git a/src/sound_node.py b/src/sound_node.py deleted file mode 100755 index 9d53362..0000000 --- a/src/sound_node.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -import os - -from typing import Optional - -from home.config import config -from home.audio import amixer -from home.media import MediaNodeServer, SoundRecordStorage, SoundRecorder -from home import http - - -# This script must be run as root as it runs arecord. -# Implements HTTP API for amixer and arecord. -# ------------------------------------------- - -def _amixer_control_response(control): - info = amixer.get(control) - caps = amixer.get_caps(control) - return http.ok({ - 'caps': caps, - 'info': info - }) - - -class SoundNodeServer(MediaNodeServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.get('/amixer/get-all/', self.amixer_get_all) - self.get('/amixer/get/{control}/', self.amixer_get) - self.get('/amixer/{op:mute|unmute|cap|nocap}/{control}/', self.amixer_set) - self.get('/amixer/{op:incr|decr}/{control}/', self.amixer_volume) - - async def amixer_get_all(self, request: http.Request): - controls_info = amixer.get_all() - return self.ok(controls_info) - - async def amixer_get(self, request: http.Request): - control = request.match_info['control'] - if not amixer.has_control(control): - raise ValueError(f'invalid control: {control}') - - return _amixer_control_response(control) - - async def amixer_set(self, request: http.Request): - op = request.match_info['op'] - control = request.match_info['control'] - if not amixer.has_control(control): - raise ValueError(f'invalid control: {control}') - - f = getattr(amixer, op) - f(control) - - return _amixer_control_response(control) - - async def amixer_volume(self, request: http.Request): - op = request.match_info['op'] - control = request.match_info['control'] - if not amixer.has_control(control): - raise ValueError(f'invalid control: {control}') - - def get_step() -> Optional[int]: - if 'step' in request.query: - step = int(request.query['step']) - if not 1 <= step <= 50: - raise ValueError('invalid step value') - return step - return None - - f = getattr(amixer, op) - f(control, step=get_step()) - - return _amixer_control_response(control) - - -if __name__ == '__main__': - if not os.getegid() == 0: - raise RuntimeError("Must be run as root.") - - config.load('sound_node') - - storage = SoundRecordStorage(config['node']['storage']) - - recorder = SoundRecorder(storage=storage) - recorder.start_thread() - - server = SoundNodeServer(recorder=recorder, - storage=storage, - addr=config.get_addr('node.listen')) - server.run() diff --git a/src/sound_sensor_node.py b/src/sound_sensor_node.py deleted file mode 100755 index d9a8999..0000000 --- a/src/sound_sensor_node.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import sys - -from home.config import config -from home.util import parse_addr -from home.soundsensor import SoundSensorNode - -logger = logging.getLogger(__name__) - - -if __name__ == '__main__': - if not os.getegid() == 0: - sys.exit('Must be run as root.') - - config.load('sound_sensor_node') - - kwargs = {} - if 'delay' in config['node']: - kwargs['delay'] = config['node']['delay'] - - if 'server_addr' in config['node']: - server_addr = parse_addr(config['node']['server_addr']) - else: - server_addr = None - - node = SoundSensorNode(name=config['node']['name'], - pinname=config['node']['pin'], - threshold=config['node']['threshold'] if 'threshold' in config['node'] else 1, - server_addr=server_addr, - **kwargs) - node.run() diff --git a/src/sound_sensor_server.py b/src/sound_sensor_server.py deleted file mode 100755 index aa62608..0000000 --- a/src/sound_sensor_server.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -import logging -import threading - -from time import sleep -from typing import Optional, List, Dict, Tuple -from functools import partial -from home.config import config -from home.util import parse_addr -from home.api import WebAPIClient, RequestParams -from home.api.types import SoundSensorLocation -from home.soundsensor import SoundSensorServer, SoundSensorHitHandler -from home.media import MediaNodeType, SoundRecordClient, CameraRecordClient, RecordClient - -interrupted = False -logger = logging.getLogger(__name__) -server: SoundSensorServer - - -def get_related_nodes(node_type: MediaNodeType, - sensor_name: str) -> List[str]: - try: - 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] - except KeyError: - return [] - - -def get_node_config(node_type: MediaNodeType, - name: str) -> Optional[dict]: - if name in config[f'{node_type.name.lower()}_nodes']: - cfg = config[f'{node_type.name.lower()}_nodes'][name] - if 'min_hits' not in cfg: - cfg['min_hits'] = 1 - return cfg - else: - return None - - -class HitCounter: - def __init__(self): - self.sensors = {} - self.lock = threading.Lock() - self._reset_sensors() - - def _reset_sensors(self): - for loc in SoundSensorLocation: - self.sensors[loc.name.lower()] = 0 - - def add(self, name: str, hits: int): - if name not in self.sensors: - raise ValueError(f'sensor {name} not found') - - with self.lock: - self.sensors[name] += hits - - def get_all(self) -> List[Tuple[str, int]]: - vals = [] - with self.lock: - for name, hits in self.sensors.items(): - if hits > 0: - vals.append((name, hits)) - self._reset_sensors() - return vals - - -class HitHandler(SoundSensorHitHandler): - def handler(self, name: str, hits: int): - if not hasattr(SoundSensorLocation, name.upper()): - logger.error(f'invalid sensor name: {name}') - return - - should_continue = False - for node_type in MediaNodeType: - try: - nodes = get_related_nodes(node_type, name) - except ValueError: - logger.error(f'config for {node_type.name.lower()} node {name} not found') - return - - for node in nodes: - node_config = get_node_config(node_type, node) - if node_config is None: - logger.error(f'config for {node_type.name.lower()} node {node} not found') - continue - if hits < node_config['min_hits']: - continue - should_continue = True - - if not should_continue: - return - - hc.add(name, hits) - - if not server.is_recording_enabled(): - return - for node_type in MediaNodeType: - try: - nodes = get_related_nodes(node_type, name) - for node in nodes: - node_config = get_node_config(node_type, node) - if node_config is None: - logger.error(f'node config for {node_type.name.lower()} node {node} not found') - continue - - durations = node_config['durations'] - dur = durations[1] if hits > node_config['min_hits'] else durations[0] - record_clients[node_type].record(node, dur*60, {'node': node}) - - except ValueError as exc: - logger.exception(exc) - - -def hits_sender(): - while not interrupted: - all_hits = hc.get_all() - if all_hits: - api.add_sound_sensor_hits(all_hits) - sleep(5) - - -api: Optional[WebAPIClient] = None -hc: Optional[HitCounter] = None -record_clients: Dict[MediaNodeType, RecordClient] = {} - - -# record callbacks -# ---------------- - -def record_error(type: MediaNodeType, - info: dict, - userdata: dict): - node = userdata['node'] - logger.error('recording ' + str(dict) + f' from {type.name.lower()} node ' + node + ' failed') - - record_clients[type].forget(node, info['id']) - - -def record_finished(type: MediaNodeType, - info: dict, - fn: str, - userdata: dict): - logger.debug(f'{type.name.lower()} record finished: ' + str(info)) - - # audio could have been requested by other user (telegram bot, for example) - # so we shouldn't 'forget' it here - - # node = userdata['node'] - # record.forget(node, info['id']) - - -# api client callbacks -# -------------------- - -def api_error_handler(exc, name, req: RequestParams): - logger.error(f'api call ({name}, params={req.params}) failed, exception below') - logger.exception(exc) - - -if __name__ == '__main__': - config.load('sound_sensor_server') - - hc = HitCounter() - api = WebAPIClient(timeout=(10, 60)) - api.enable_async(error_handler=api_error_handler) - - t = threading.Thread(target=hits_sender) - t.daemon = True - t.start() - - sound_nodes = {} - if 'sound_nodes' in config: - for nodename, nodecfg in config['sound_nodes'].items(): - sound_nodes[nodename] = parse_addr(nodecfg['addr']) - - camera_nodes = {} - if 'camera_nodes' in config: - for nodename, nodecfg in config['camera_nodes'].items(): - camera_nodes[nodename] = parse_addr(nodecfg['addr']) - - if sound_nodes: - record_clients[MediaNodeType.SOUND] = SoundRecordClient(sound_nodes, - error_handler=partial(record_error, MediaNodeType.SOUND), - finished_handler=partial(record_finished, MediaNodeType.SOUND)) - - if camera_nodes: - record_clients[MediaNodeType.CAMERA] = CameraRecordClient(camera_nodes, - error_handler=partial(record_error, MediaNodeType.CAMERA), - finished_handler=partial(record_finished, MediaNodeType.CAMERA)) - - try: - server = SoundSensorServer(config.get_addr('server.listen'), HitHandler) - server.run() - except KeyboardInterrupt: - interrupted = True - for c in record_clients.values(): - c.stop() - logging.info('keyboard interrupt, exiting...') diff --git a/src/ssh_tunnels_config_util.py b/src/ssh_tunnels_config_util.py deleted file mode 100755 index 3b2ba6e..0000000 --- a/src/ssh_tunnels_config_util.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 - -from home.config import config - -if __name__ == '__main__': - config.load('ssh_tunnels_config_util') - - network_prefix = config['network'] - hostnames = [] - - for k, v in config.items(): - if type(v) is not dict: - continue - hostnames.append(k) - - for host in hostnames: - buf = [] - i = 0 - for tun_host in hostnames: - http_bind_port = config['http_bind_base'] + config[host]['bind_slot'] * 10 + i - ssh_bind_port = config['ssh_bind_base'] + config[host]['bind_slot'] * 10 + i - - if tun_host == host: - target_host = '127.0.0.1' - else: - target_host = f'{network_prefix}.{config[tun_host]["ipv4"]}' - - buf.append(f'-R 127.0.0.1:{http_bind_port}:{target_host}:{config[tun_host]["http_port"]}') - buf.append(f'-R 127.0.0.1:{ssh_bind_port}:{target_host}:{config[tun_host]["ssh_port"]}') - - i += 1 - - print(host) - print(' '.join(buf)) - print() diff --git a/src/syncleo/__init__.py b/src/syncleo/__init__.py deleted file mode 100644 index 32563a5..0000000 --- a/src/syncleo/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# Polaris PWK 1725CGLD "smart" kettle python library -# -------------------------------------------------- -# Copyright (C) Evgeny Zinoviev, 2022 -# License: BSD-3c - -from .kettle import Kettle, DeviceListener -from .protocol import ( - PowerType, - IncomingMessageListener, - ConnectionStatusListener, - ConnectionStatus -) diff --git a/src/syncleo/kettle.py b/src/syncleo/kettle.py deleted file mode 100644 index d6e0dd6..0000000 --- a/src/syncleo/kettle.py +++ /dev/null @@ -1,243 +0,0 @@ -# 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 - -from abc import abstractmethod -from ipaddress import ip_address, IPv4Address, IPv6Address -from typing import Optional, List, Union - -from .protocol import ( - UDPConnection, - ModeMessage, - TargetTemperatureMessage, - PowerType, - ConnectionStatus, - ConnectionStatusListener, - WrappedMessage -) - - -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.debug('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()) - - -class DeviceListener: - @abstractmethod - def device_updated(self): - pass - - -class Kettle(DeviceListener, ConnectionStatusListener): - mac: str - device: Optional[DeviceDiscover] - device_token: str - conn: Optional[UDPConnection] - conn_status: Optional[ConnectionStatus] - _read_timeout: Optional[int] - _logger: logging.Logger - _find_evt: threading.Event - - def __init__(self, mac: str, device_token: str, read_timeout: Optional[int] = None): - super().__init__() - self.mac = mac - self.device = None - self.device_token = device_token - self.conn = None - self.conn_status = None - self._read_timeout = read_timeout - self._find_evt = threading.Event() - self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__}') - - def device_updated(self): - self._find_evt.set() - self._logger.info(f'device updated, service info: {self.device.si}') - - def connection_status_updated(self, status: ConnectionStatus): - self.conn_status = status - - 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._find_evt.wait(timeout=timeout) - except KeyboardInterrupt: - self.device.stop() - return None - return self.device.si - - 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 - - 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' - - kw = {} - if self._read_timeout is not None: - kw['read_timeout'] = self._read_timeout - self.conn = UDPConnection(addr=self.device.addr, - port=self.device.port, - device_pubkey=self.device.pubkey, - device_token=bytes.fromhex(self.device_token), **kw) - 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) - - self.conn.start() - - 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 - - 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): - message = ModeMessage(power_type) - self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True)) - - def set_target_temperature(self, temp: int, callback: callable): - message = TargetTemperatureMessage(temp) - self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True)) diff --git a/src/syncleo/protocol.py b/src/syncleo/protocol.py deleted file mode 100644 index 36a1a8f..0000000 --- a/src/syncleo/protocol.py +++ /dev/null @@ -1,1169 +0,0 @@ -# Polaris PWK 1725CGLD "smart" kettle python library -# -------------------------------------------------- -# Copyright (C) Evgeny Zinoviev, 2022 -# License: BSD-3c - -from __future__ import annotations - -import logging -import socket -import random -import struct -import threading -import time - -from abc import abstractmethod, ABC -from enum import Enum, auto -from typing import Union, Optional, Dict, Tuple, List -from ipaddress import IPv4Address, IPv6Address - -import cryptography.hazmat.primitives._serialization as srlz - -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey -from cryptography.hazmat.primitives import ciphers, padding, hashes -from cryptography.hazmat.primitives.ciphers import algorithms, modes - -ReprDict = Dict[str, Union[str, int, float, bool]] -_logger = logging.getLogger(__name__) - -PING_FREQUENCY = 3 -RESEND_ATTEMPTS = 5 -ERROR_TIMEOUT = 15 -MESSAGE_QUEUE_REMOVE_DELAY = 13 # after what time to delete (and pass False to handlers, if needed) messages with phase=DONE from queue -DISCONNECT_TIMEOUT = 15 - - -def safe_callback_call(f: callable, - *args, - logger: logging.Logger = None, - error_message: str = None): - try: - return f(*args) - except Exception as exc: - logger.error(f'{error_message}, see exception below:') - logger.exception(exc) - return None - - -# drop-in replacement for java.lang.System.arraycopy -# TODO: rewrite -def arraycopy(src, src_pos, dest, dest_pos, length): - for i in range(length): - dest[i + dest_pos] = src[i + src_pos] - - -# "convert" unsigned byte to signed -def u8_to_s8(b: int) -> int: - return struct.unpack('b', bytes([b]))[0] - - -class PowerType(Enum): - OFF = 0 # turn off - ON = 1 # turn on, set target temperature to 100 - CUSTOM = 3 # turn on, allows custom target temperature - # MYSTERY_MODE = 2 # don't know what 2 means, needs testing - # update: if I set it to '2', it just resets to '0' - - -# low-level protocol structures -# ----------------------------- - -class FrameType(Enum): - ACK = 0 - CMD = 1 - AUX = 2 - NAK = 3 - - -class FrameHead: - seq: Optional[int] # u8 - type: FrameType # u8 - length: int # u16. This is the length of FrameItem's payload - - @staticmethod - def from_bytes(buf: bytes) -> FrameHead: - seq, ft, length = struct.unpack('<BBH', buf) - return FrameHead(seq, FrameType(ft), length) - - def __init__(self, - seq: Optional[int], - frame_type: FrameType, - length: Optional[int] = None): - self.seq = seq - self.type = frame_type - self.length = length or 0 - - def pack(self) -> bytes: - assert self.length != 0, "FrameHead.length has not been set" - assert self.seq is not None, "FrameHead.seq has not been set" - return struct.pack('<BBH', self.seq, self.type.value, self.length) - - -class FrameItem: - head: FrameHead - payload: bytes - - def __init__(self, head: FrameHead, payload: Optional[bytes] = None): - self.head = head - self.payload = payload - - def setpayload(self, payload: Union[bytes, bytearray]): - if isinstance(payload, bytearray): - payload = bytes(payload) - self.payload = payload - self.head.length = len(payload) - - def pack(self) -> bytes: - ba = bytearray(self.head.pack()) - ba.extend(self.payload) - return bytes(ba) - - -# high-level wrappers around FrameItem -# ------------------------------------ - -class MessagePhase(Enum): - WAITING = 0 - SENT = 1 - DONE = 2 - - -class Message: - frame: Optional[FrameItem] - id: int - - _global_id = 0 - - def __init__(self): - self.frame = None - - # global internal message id, only useful for debugging purposes - self.id = self.next_id() - - def __repr__(self): - return f'<{self.__class__.__name__} id={self.id} seq={self.frame.head.seq}>' - - @staticmethod - def next_id(): - _id = Message._global_id - Message._global_id = (Message._global_id + 1) % 100000 - return _id - - @staticmethod - def from_encrypted(buf: bytes, inkey: bytes, outkey: bytes) -> Message: - _logger.debug(f'Message:from_encrypted: buf={buf.hex()}') - - assert len(buf) >= 4, 'invalid size' - head = FrameHead.from_bytes(buf[:4]) - - assert len(buf) == head.length + 4, f'invalid buf size ({len(buf)} != {head.length})' - payload = buf[4:] - b = head.seq - - j = b & 0xF - k = b >> 4 & 0xF - - key = bytearray(len(inkey)) - arraycopy(inkey, j, key, 0, len(inkey) - j) - arraycopy(inkey, 0, key, len(inkey) - j, j) - - iv = bytearray(len(outkey)) - arraycopy(outkey, k, iv, 0, len(outkey) - k) - arraycopy(outkey, 0, iv, len(outkey) - k, k) - - cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv)) - decryptor = cipher.decryptor() - decrypted_data = decryptor.update(payload) + decryptor.finalize() - - unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() - decrypted_data = unpadder.update(decrypted_data) - decrypted_data += unpadder.finalize() - - assert len(decrypted_data) != 0, 'decrypted data is null' - assert head.seq == decrypted_data[0], f'decrypted seq mismatch {head.seq} != {decrypted_data[0]}' - - # _logger.debug('Message.from_encrypted: plaintext: '+decrypted_data.hex()) - - if head.type == FrameType.ACK: - return AckMessage(head.seq) - - elif head.type == FrameType.NAK: - return NakMessage(head.seq) - - elif head.type == FrameType.AUX: - # TODO implement AUX - raise NotImplementedError('FrameType AUX is not yet implemented') - - elif head.type == FrameType.CMD: - type = decrypted_data[1] - data = decrypted_data[2:] - - cl = UnknownMessage - - subclasses = [cl for cl in CmdIncomingMessage.__subclasses__() if cl is not SimpleBooleanMessage] - subclasses.extend(SimpleBooleanMessage.__subclasses__()) - - for _cl in subclasses: - # `UnknownMessage` is a special class that holds a packed command that we don't recognize. - # It will be used anyway if we don't find a match, so skip it here - if _cl == UnknownMessage: - continue - - if _cl.TYPE == type: - cl = _cl - break - - m = cl.from_packed_data(data, seq=head.seq) - if isinstance(m, UnknownMessage): - m.set_type(type) - return m - - else: - raise NotImplementedError(f'Unexpected frame type: {head.type}') - - def pack_data(self) -> bytes: - return b'' - - @property - def seq(self) -> Union[int, None]: - try: - return self.frame.head.seq - except: - return None - - @seq.setter - def seq(self, seq: int): - self.frame.head.seq = seq - - def encrypt(self, outkey: bytes, inkey: bytes, token: bytes, pubkey: bytes): - assert self.frame is not None - - data = self._get_data_to_encrypt() - assert data is not None - - b = self.frame.head.seq - i = b & 0xf - j = b >> 4 & 0xf - - outkey = bytearray(outkey) - - l = len(outkey) - key = bytearray(l) - - arraycopy(outkey, i, key, 0, l-i) - arraycopy(outkey, 0, key, l-i, i) - - inkey = bytearray(inkey) - - l = len(inkey) - iv = bytearray(l) - - arraycopy(inkey, j, iv, 0, l-j) - arraycopy(inkey, 0, iv, l-j, j) - - cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv)) - encryptor = cipher.encryptor() - - newdata = bytearray(len(data)+1) - newdata[0] = b - - arraycopy(data, 0, newdata, 1, len(data)) - - newdata = bytes(newdata) - _logger.debug('frame payload to be encrypted: ' + newdata.hex()) - - padder = padding.PKCS7(algorithms.AES.block_size).padder() - ciphertext = bytearray() - ciphertext.extend(encryptor.update(padder.update(newdata) + padder.finalize())) - ciphertext.extend(encryptor.finalize()) - - self.frame.setpayload(ciphertext) - - def _get_data_to_encrypt(self) -> bytes: - return self.pack_data() - - -class AckMessage(Message, ABC): - def __init__(self, seq: Optional[int] = None): - super().__init__() - self.frame = FrameItem(FrameHead(seq, FrameType.ACK, None)) - - -class NakMessage(Message, ABC): - def __init__(self, seq: Optional[int] = None): - super().__init__() - self.frame = FrameItem(FrameHead(seq, FrameType.NAK, None)) - - -class CmdMessage(Message): - type: Optional[int] - data: bytes - - TYPE = None - - def _get_data_to_encrypt(self) -> bytes: - buf = bytearray() - buf.append(self.get_type()) - buf.extend(self.pack_data()) - return bytes(buf) - - def __init__(self, seq: Optional[int] = None): - super().__init__() - self.frame = FrameItem(FrameHead(seq, FrameType.CMD)) - self.data = b'' - - def _repr_fields(self) -> ReprDict: - return { - 'cmd': self.get_type() - } - - def __repr__(self): - params = [ - __name__+'.'+self.__class__.__name__, - f'id={self.id}', - f'seq={self.seq}' - ] - fields = self._repr_fields() - if fields: - for k, v in fields.items(): - params.append(f'{k}={v}') - elif self.data: - params.append(f'data={self.data.hex()}') - return '<'+' '.join(params)+'>' - - def get_type(self) -> int: - return self.__class__.TYPE - - -class CmdIncomingMessage(CmdMessage): - @staticmethod - @abstractmethod - def from_packed_data(cls, data: bytes, seq: Optional[int] = None): - pass - - @abstractmethod - def _repr_fields(self) -> ReprDict: - pass - - -class CmdOutgoingMessage(CmdMessage): - @abstractmethod - def pack_data(self) -> bytes: - return b'' - - -class ModeMessage(CmdOutgoingMessage, CmdIncomingMessage): - TYPE = 1 - - pt: PowerType - - def __init__(self, power_type: PowerType, seq: Optional[int] = None): - super().__init__(seq) - self.pt = power_type - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> ModeMessage: - assert len(data) == 1, 'data size expected to be 1' - mode, = struct.unpack('B', data) - return ModeMessage(PowerType(mode), seq=seq) - - def pack_data(self) -> bytes: - return self.pt.value.to_bytes(1, byteorder='little') - - def _repr_fields(self) -> ReprDict: - return {'mode': self.pt.name} - - -class TargetTemperatureMessage(CmdOutgoingMessage, CmdIncomingMessage): - temperature: int - - TYPE = 2 - - def __init__(self, temp: int, seq: Optional[int] = None): - super().__init__(seq) - self.temperature = temp - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> TargetTemperatureMessage: - assert len(data) == 2, 'data size expected to be 2' - nat, frac = struct.unpack('BB', data) - temp = int(nat + (frac / 100)) - return TargetTemperatureMessage(temp, seq=seq) - - def pack_data(self) -> bytes: - return bytes([self.temperature, 0]) - - def _repr_fields(self) -> ReprDict: - return {'temperature': self.temperature} - - -class PingMessage(CmdIncomingMessage, CmdOutgoingMessage): - TYPE = 255 - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> PingMessage: - assert len(data) == 0, 'no data expected' - return PingMessage(seq=seq) - - def pack_data(self) -> bytes: - return b'' - - def _repr_fields(self) -> ReprDict: - return {} - - -# This is the first protocol message. Sent by a client. -# Kettle usually ACKs this, but sometimes i don't get any ACK and the very next message is HandshakeResponseMessage. -class HandshakeMessage(CmdMessage): - TYPE = 0 - - def encrypt(self, - outkey: bytes, - inkey: bytes, - token: bytes, - pubkey: bytes): - cipher = ciphers.Cipher(algorithms.AES(outkey), modes.CBC(inkey)) - encryptor = cipher.encryptor() - - ciphertext = bytearray() - ciphertext.extend(encryptor.update(token)) - ciphertext.extend(encryptor.finalize()) - - pld = bytearray() - pld.append(0) - pld.extend(pubkey) - pld.extend(ciphertext) - - self.frame.setpayload(pld) - - -# Kettle either sends this right after the handshake, of first it ACKs the handshake then sends this. -class HandshakeResponseMessage(CmdIncomingMessage): - TYPE = 0 - - protocol: int - fw_major: int - fw_minor: int - mode: int - token: bytes - - def __init__(self, - protocol: int, - fw_major: int, - fw_minor: int, - mode: int, - token: bytes, - seq: Optional[int] = None): - super().__init__(seq) - self.protocol = protocol - self.fw_major = fw_major - self.fw_minor = fw_minor - self.mode = mode - self.token = token - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> HandshakeResponseMessage: - protocol, fw_major, fw_minor, mode = struct.unpack('<HBBB', data[:5]) - return HandshakeResponseMessage(protocol, fw_major, fw_minor, mode, token=data[5:], seq=seq) - - def _repr_fields(self) -> ReprDict: - return { - 'protocol': self.protocol, - 'fw': f'{self.fw_major}.{self.fw_minor}', - 'mode': self.mode, - 'token': self.token.hex() - } - - -# Apparently, some hardware info. -# On the other hand, if you look at com.syncleiot.iottransport.commands.CmdHardware, its mqtt topic says "mcu_firmware". -# My device returns 1.1.1. The kettle uses on ESP8266 ESP-12F MCU under the hood (or, more precisely, under a piece of -# cheap plastic), so maybe 1.1.1 is some MCU ROM version. -class DeviceHardwareMessage(CmdIncomingMessage): - TYPE = 143 # -113 - - hw: List[int] - - def __init__(self, hw: List[int], seq: Optional[int] = None): - super().__init__(seq) - self.hw = hw - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> DeviceHardwareMessage: - assert len(data) == 3, 'invalid data size, expected 3' - hw = list(struct.unpack('<BBB', data)) - return DeviceHardwareMessage(hw, seq=seq) - - def _repr_fields(self) -> ReprDict: - return {'device_hardware': '.'.join(map(str, self.hw))} - - -# This message is sent by kettle right after the HandshakeMessageResponse. -# The diagnostic data is supposed to be sent to vendor, which we, obviously, not going to do. -# So just ACK and skip it. -class DeviceDiagnosticMessage(CmdIncomingMessage): - TYPE = 145 # -111 - - diag_data: bytes - - def __init__(self, diag_data: bytes, seq: Optional[int] = None): - super().__init__(seq) - self.diag_data = diag_data - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> DeviceDiagnosticMessage: - return DeviceDiagnosticMessage(diag_data=data, seq=seq) - - def _repr_fields(self) -> ReprDict: - return {'diag_data': self.diag_data.hex()} - - -class SimpleBooleanMessage(ABC, CmdIncomingMessage): - value: bool - - def __init__(self, value: bool, seq: Optional[int] = None): - super().__init__(seq) - self.value = value - - @classmethod - def from_packed_data(cls, data: bytes, seq: Optional[int] = None): - assert len(data) == 1, 'invalid data size, expected 1' - enabled, = struct.unpack('<B', data) - return cls(value=enabled == 1, seq=seq) - - @abstractmethod - def _repr_fields(self) -> ReprDict: - pass - - -class AccessControlMessage(SimpleBooleanMessage): - TYPE = 133 # -123 - - def _repr_fields(self) -> ReprDict: - return {'acl_enabled': self.value} - - -class ErrorMessage(SimpleBooleanMessage): - TYPE = 7 - - def _repr_fields(self) -> ReprDict: - return {'error': self.value} - - -class ChildLockMessage(SimpleBooleanMessage): - TYPE = 30 - - def _repr_fields(self) -> ReprDict: - return {'child_lock': self.value} - - -class VolumeMessage(SimpleBooleanMessage): - TYPE = 9 - - def _repr_fields(self) -> ReprDict: - return {'volume': self.value} - - -class BacklightMessage(SimpleBooleanMessage): - TYPE = 28 - - def _repr_fields(self) -> ReprDict: - return {'backlight': self.value} - - -class CurrentTemperatureMessage(CmdIncomingMessage): - TYPE = 20 - - current_temperature: int - - def __init__(self, temp: int, seq: Optional[int] = None): - super().__init__(seq) - self.current_temperature = temp - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> CurrentTemperatureMessage: - assert len(data) == 2, 'data size expected to be 2' - nat, frac = struct.unpack('BB', data) - temp = int(nat + (frac / 100)) - return CurrentTemperatureMessage(temp, seq=seq) - - def pack_data(self) -> bytes: - return bytes([self.current_temperature, 0]) - - def _repr_fields(self) -> ReprDict: - return {'current_temperature': self.current_temperature} - - -class UnknownMessage(CmdIncomingMessage): - type: Optional[int] - data: bytes - - def __init__(self, data: bytes, **kwargs): - super().__init__(**kwargs) - self.type = None - self.data = data - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> UnknownMessage: - return UnknownMessage(data, seq=seq) - - def set_type(self, type: int): - self.type = type - - def get_type(self) -> int: - return self.type - - def _repr_fields(self) -> ReprDict: - return { - 'type': self.type, - 'data': self.data.hex() - } - - -class WrappedMessage: - _message: Message - _handler: Optional[callable] - _validator: Optional[callable] - _logger: Optional[logging.Logger] - _phase: MessagePhase - _phase_update_time: float - - def __init__(self, - message: Message, - handler: Optional[callable] = None, - validator: Optional[callable] = None, - ack=False): - self._message = message - self._handler = handler - self._validator = validator - self._logger = None - self._phase = MessagePhase.WAITING - self._phase_update_time = 0 - if not validator and ack: - self._validator = lambda m: isinstance(m, AckMessage) - - def setlogger(self, logger: logging.Logger): - self._logger = logger - - def validate(self, message: Message): - if not self._validator: - return True - return self._validator(message) - - def call(self, *args, error_message: str = None) -> None: - if not self._handler: - return - try: - self._handler(*args) - except Exception as exc: - logger = self._logger or logging.getLogger(self.__class__.__name__) - logger.error(f'{error_message}, see exception below:') - logger.exception(exc) - - @property - def phase(self) -> MessagePhase: - return self._phase - - @phase.setter - def phase(self, phase: MessagePhase): - self._phase = phase - self._phase_update_time = 0 if phase == MessagePhase.WAITING else time.time() - - @property - def phase_update_time(self) -> float: - return self._phase_update_time - - @property - def message(self) -> Message: - return self._message - - @property - def id(self) -> int: - return self._message.id - - @property - def seq(self) -> int: - return self._message.seq - - @seq.setter - def seq(self, seq: int): - self._message.seq = seq - - def __repr__(self): - return f'<{__name__}.{self.__class__.__name__} message={self._message.__repr__()}>' - - -# Connection stuff -# Well, strictly speaking, as it's UDP, there's no connection, but who cares. -# --------------------------------------------------------------------------- - -class IncomingMessageListener: - @abstractmethod - def incoming_message(self, message: Message) -> Optional[Message]: - pass - - -class ConnectionStatus(Enum): - NOT_CONNECTED = auto() - CONNECTING = auto() - CONNECTED = auto() - RECONNECTING = auto() - DISCONNECTED = auto() - - -class ConnectionStatusListener: - @abstractmethod - def connection_status_updated(self, status: ConnectionStatus): - pass - - -class UDPConnection(threading.Thread, ConnectionStatusListener): - inseq: int - outseq: int - source_port: int - device_addr: str - device_port: int - device_token: bytes - device_pubkey: bytes - interrupted: bool - response_handlers: Dict[int, WrappedMessage] - outgoing_queue: List[WrappedMessage] - pubkey: Optional[bytes] - encinkey: Optional[bytes] - encoutkey: Optional[bytes] - inc_listeners: List[IncomingMessageListener] - conn_listeners: List[ConnectionStatusListener] - outgoing_time: float - outgoing_time_1st: float - incoming_time: float - status: ConnectionStatus - reconnect_tries: int - read_timeout: int - - _addr_lock: threading.Lock - _iml_lock: threading.Lock - _csl_lock: threading.Lock - _st_lock: threading.Lock - - def __init__(self, - addr: Union[IPv4Address, IPv6Address], - port: int, - device_pubkey: bytes, - device_token: bytes, - read_timeout: int = 1): - super().__init__() - self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__} <{hex(id(self))}>') - self.setName(self.__class__.__name__) - - self.inseq = 0 - self.outseq = 0 - self.source_port = random.randint(1024, 65535) - self.device_addr = str(addr) - self.device_port = port - self.device_token = device_token - self.device_pubkey = device_pubkey - self.outgoing_queue = [] - self.response_handlers = {} - self.interrupted = False - self.outgoing_time = 0 - self.outgoing_time_1st = 0 - self.incoming_time = 0 - self.inc_listeners = [] - self.conn_listeners = [self] - self.status = ConnectionStatus.NOT_CONNECTED - self.reconnect_tries = 0 - self.read_timeout = read_timeout - - self._iml_lock = threading.Lock() - self._csl_lock = threading.Lock() - self._addr_lock = threading.Lock() - self._st_lock = threading.Lock() - - self.pubkey = None - self.encinkey = None - self.encoutkey = None - - def connection_status_updated(self, status: ConnectionStatus): - # self._logger.info(f'connection_status_updated: status = {status}') - with self._st_lock: - # self._logger.debug(f'connection_status_updated: lock acquired') - self.status = status - if status == ConnectionStatus.RECONNECTING: - self.reconnect_tries += 1 - if status in (ConnectionStatus.CONNECTED, ConnectionStatus.NOT_CONNECTED, ConnectionStatus.DISCONNECTED): - self.reconnect_tries = 0 - - def _cleanup(self): - # erase outgoing queue - for wm in self.outgoing_queue: - wm.call(False, - error_message=f'_cleanup: exception while calling cb(False) on message {wm.message}') - self.outgoing_queue = [] - self.response_handlers = {} - - # reset timestamps - self.incoming_time = 0 - self.outgoing_time = 0 - self.outgoing_time_1st = 0 - - self._logger.debug('_cleanup: done') - - def set_address(self, addr: Union[IPv4Address, IPv6Address], port: int): - with self._addr_lock: - if self.device_addr != str(addr) or self.device_port != port: - self.device_addr = str(addr) - self.device_port = port - self._logger.info(f'updated device network address: {self.device_addr}:{self.device_port}') - - def set_device_pubkey(self, pubkey: bytes): - if self.device_pubkey.hex() != pubkey.hex(): - self._logger.info(f'device pubkey has changed (old={self.device_pubkey.hex()}, new={pubkey.hex()})') - self.device_pubkey = pubkey - self._notify_cs(ConnectionStatus.RECONNECTING) - - def get_address(self) -> Tuple[str, int]: - with self._addr_lock: - return self.device_addr, self.device_port - - def add_incoming_message_listener(self, listener: IncomingMessageListener): - with self._iml_lock: - if listener not in self.inc_listeners: - self.inc_listeners.append(listener) - - def add_connection_status_listener(self, listener: ConnectionStatusListener): - with self._csl_lock: - if listener not in self.conn_listeners: - self.conn_listeners.append(listener) - - def _notify_cs(self, status: ConnectionStatus): - # self._logger.debug(f'_notify_cs: status={status}') - with self._csl_lock: - for obj in self.conn_listeners: - # self._logger.debug(f'_notify_cs: notifying {obj}') - obj.connection_status_updated(status) - - def _prepare_keys(self): - # generate key pair - privkey = X25519PrivateKey.generate() - - self.pubkey = bytes(reversed(privkey.public_key().public_bytes(encoding=srlz.Encoding.Raw, - format=srlz.PublicFormat.Raw))) - - # generate shared key - device_pubkey = X25519PublicKey.from_public_bytes( - bytes(reversed(self.device_pubkey)) - ) - shared_key = bytes(reversed( - privkey.exchange(device_pubkey) - )) - - # in/out encryption keys - digest = hashes.Hash(hashes.SHA256()) - digest.update(shared_key) - - shared_sha256 = digest.finalize() - - self.encinkey = shared_sha256[:16] - self.encoutkey = shared_sha256[16:] - - self._logger.info('encryption keys have been created') - - def _handshake_callback(self, r: MessageResponse): - # if got error for our HandshakeMessage, reset everything and try again - if r is False: - # self._logger.debug('_handshake_callback: set status=RECONNETING') - self._notify_cs(ConnectionStatus.RECONNECTING) - else: - # self._logger.debug('_handshake_callback: set status=CONNECTED') - self._notify_cs(ConnectionStatus.CONNECTED) - - def run(self): - self._logger.info('starting server loop') - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(('0.0.0.0', self.source_port)) - sock.settimeout(self.read_timeout) - - while not self.interrupted: - with self._st_lock: - status = self.status - - if status in (ConnectionStatus.DISCONNECTED, ConnectionStatus.RECONNECTING): - self._cleanup() - if status == ConnectionStatus.DISCONNECTED: - break - - # no activity for some time means connection is broken - fail = False - fail_path = 0 - if self.incoming_time > 0 and time.time() - self.incoming_time >= DISCONNECT_TIMEOUT: - fail = True - fail_path = 1 - elif self.outgoing_time_1st > 0 and self.incoming_time == 0 and time.time() - self.outgoing_time_1st >= DISCONNECT_TIMEOUT: - fail = True - fail_path = 2 - - if fail: - self._logger.debug(f'run: setting status=RECONNECTING because of long inactivity, fail_path={fail_path}') - self._notify_cs(ConnectionStatus.RECONNECTING) - - # establishing a connection - if status in (ConnectionStatus.RECONNECTING, ConnectionStatus.NOT_CONNECTED): - if status == ConnectionStatus.RECONNECTING and self.reconnect_tries >= 3: - self._notify_cs(ConnectionStatus.DISCONNECTED) - continue - - self._reset_outseq() - self._prepare_keys() - - # shake the imaginary kettle's hand - wrapped = WrappedMessage(HandshakeMessage(), - handler=self._handshake_callback, - validator=lambda m: isinstance(m, (AckMessage, HandshakeResponseMessage))) - self.enqueue_message(wrapped, prepend=True) - self._notify_cs(ConnectionStatus.CONNECTING) - - # pick next (wrapped) message to send - wm = self._get_next_message() # wm means "wrapped message" - if wm: - one_shot = isinstance(wm.message, (AckMessage, NakMessage)) - - if not isinstance(wm.message, (AckMessage, NakMessage)): - old_seq = wm.seq - wm.seq = self.outseq - self._set_response_handler(wm, old_seq=old_seq) - elif wm.seq is None: - # ack/nak is a response to some incoming message (and it must have the same seqno that incoming - # message had) - raise RuntimeError(f'run: seq must be set for {wm.__class__.__name__}') - - self._logger.debug(f'run: sending message: {wm.message}, one_shot={one_shot}, phase={wm.phase}') - encrypted = False - try: - wm.message.encrypt(outkey=self.encoutkey, inkey=self.encinkey, - token=self.device_token, pubkey=self.pubkey) - encrypted = True - except ValueError as exc: - # handle "ValueError: Invalid padding bytes." - self._logger.error('run: failed to encrypt the message.') - self._logger.exception(exc) - - if encrypted: - buf = wm.message.frame.pack() - # self._logger.debug(f'run: raw data to be sent: {buf.hex()}') - - # sending the first time - if wm.phase == MessagePhase.WAITING: - sock.sendto(buf, self.get_address()) - # resending - elif wm.phase == MessagePhase.SENT: - left = RESEND_ATTEMPTS - while left > 0: - sock.sendto(buf, self.get_address()) - left -= 1 - if left > 0: - time.sleep(0.05) - - if one_shot or wm.phase == MessagePhase.SENT: - wm.phase = MessagePhase.DONE - else: - wm.phase = MessagePhase.SENT - - now = time.time() - self.outgoing_time = now - if not self.outgoing_time_1st: - self.outgoing_time_1st = now - - # receiving data - try: - data = sock.recv(4096) - self._handle_incoming(data) - except (TimeoutError, socket.timeout): - pass - - self._logger.info('bye...') - - def _get_next_message(self) -> Optional[WrappedMessage]: - message = None - lpfx = '_get_next_message:' - remove_list = [] - for wm in self.outgoing_queue: - if wm.phase == MessagePhase.DONE: - if isinstance(wm.message, (AckMessage, NakMessage, PingMessage)) or time.time() - wm.phase_update_time >= MESSAGE_QUEUE_REMOVE_DELAY: - remove_list.append(wm) - continue - message = wm - break - - for wm in remove_list: - self._logger.debug(f'{lpfx} rm path: removing id={wm.id} seq={wm.seq}') - - # clear message handler - if wm.seq in self.response_handlers: - self.response_handlers[wm.seq].call( - False, error_message=f'{lpfx} rm path: error while calling callback for seq={wm.seq}') - del self.response_handlers[wm.seq] - - # remove from queue - try: - self.outgoing_queue.remove(wm) - except ValueError as exc: - self._logger.error(f'{lpfx} rm path: removing from outgoing_queue raised an exception: {str(exc)}') - - # ping pong - if not message and self.outgoing_time_1st != 0 and self.status == ConnectionStatus.CONNECTED: - now = time.time() - out_delta = now - self.outgoing_time - in_delta = now - self.incoming_time - if max(out_delta, in_delta) > PING_FREQUENCY: - self._logger.debug(f'{lpfx} no activity: in for {in_delta:.2f}s, out for {out_delta:.2f}s, time to ping the damn thing') - message = WrappedMessage(PingMessage(), ack=True) - # add it to outgoing_queue in order to be aggressively resent in future (if needed) - self.outgoing_queue.insert(0, message) - - return message - - def _handle_incoming(self, buf: bytes): - try: - incoming_message = Message.from_encrypted(buf, inkey=self.encinkey, outkey=self.encoutkey) - except ValueError as exc: - # handle "ValueError: Invalid padding bytes." - self._logger.error('_handle_incoming: failed to decrypt incoming frame:') - self._logger.exception(exc) - return - - self.incoming_time = time.time() - seq = incoming_message.seq - - lpfx = f'handle_incoming({incoming_message.id}):' - self._logger.debug(f'{lpfx} received: {incoming_message}') - - if isinstance(incoming_message, (AckMessage, NakMessage)): - seq_max = self.outseq - seq_name = 'outseq' - else: - seq_max = self.inseq - seq_name = 'inseq' - self.inseq = seq - - if seq < seq_max < 0xfd: - self._logger.debug(f'{lpfx} dropping: seq={seq}, {seq_name}={seq_max}') - return - - if seq not in self.response_handlers: - self._handle_incoming_cmd(incoming_message) - return - - callback_value = None # None means don't call a callback - handler = self.response_handlers[seq] - - if handler.validate(incoming_message): - self._logger.debug(f'{lpfx} response OK') - handler.phase = MessagePhase.DONE - callback_value = incoming_message - self._incr_outseq() - else: - self._logger.warning(f'{lpfx} response is INVALID') - - # It seems that we've received an incoming CmdMessage or PingMessage with the same seqno that our outgoing - # message had. Bad, but what can I say, this is quick-and-dirty made UDP based protocol and this sort of - # shit just happens. - - # (To be fair, maybe my implementation is not perfect either. But hey, what did you expect from a - # reverse-engineered re-implementation of custom UDP-based protocol that some noname vendor uses for their - # cheap IoT devices? I think _that_ is _the_ definition of shit. At least my implementation is FOSS, which - # is more than you'll ever be able to say about them.) - - # All this crapload of code below might not be needed at all, 'cause the protocol uses separate frame seq - # numbers for IN and OUT frames and this situation is not highly likely, as Theresa May could argue. - # After a handshake, a kettle sends us 10 or so CmdMessages, and then either we continuously ping it every - # 3 seconds, or kettle pings us. This in any case widens the gap between inseq and outseq. - - # But! the seqno is only 1 byte in size and once it reaches 0xff, it circles back to zero. And that (plus, - # perhaps, some bad luck) gives a chance for a collision. - - if handler.phase == MessagePhase.DONE or isinstance(handler.message, HandshakeMessage): - # no more attempts left, returning error back to user - # as to handshake, it cannot fail. - callback_value = False - - # else: - # # try resending the message - # handler.phase_reset() - # max_seq = self.outseq - # wait_remap = {} - # for m in self.outgoing_queue: - # if m.seq in self.waiting_for_response: - # wait_remap[m.seq] = (m.seq+1) % 256 - # m.set_seq((m.seq+1) % 256) - # if m.seq > max_seq: - # max_seq = m.seq - # if max_seq > self.outseq: - # self.outseq = max_seq % 256 - # if wait_remap: - # waiting_new = {} - # for old_seq, new_seq in wait_remap.items(): - # waiting_new[new_seq] = self.waiting_for_response[old_seq] - # self.waiting_for_response = waiting_new - - if isinstance(incoming_message, (PingMessage, CmdIncomingMessage)): - # handle incoming message as usual, as we need to ack/nak it anyway - self._handle_incoming_cmd(incoming_message) - - if callback_value is not None: - handler.call(callback_value, - error_message=f'{lpfx} error while calling callback for msg id={handler.message.id} seq={seq}') - del self.response_handlers[seq] - - def _handle_incoming_cmd(self, incoming_message: Message): - if isinstance(incoming_message, (AckMessage, NakMessage)): - self._logger.debug(f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): it\'s {incoming_message.__class__.__name__}, ignoring') - return - - replied = False - with self._iml_lock: - for f in self.inc_listeners: - retval = safe_callback_call(f.incoming_message, incoming_message, - logger=self._logger, - error_message=f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): error while calling message listener') - if isinstance(retval, Message): - if isinstance(retval, (AckMessage, NakMessage)): - retval.seq = incoming_message.seq - self.enqueue_message(WrappedMessage(retval), prepend=True) - replied = True - break - else: - raise RuntimeError('are you sure your response is correct? only ack/nak are allowed') - - if not replied: - self.enqueue_message(WrappedMessage(AckMessage(incoming_message.seq)), prepend=True) - - def enqueue_message(self, wrapped: WrappedMessage, prepend=False): - self._logger.debug(f'enqueue_message: {wrapped.message}') - if not prepend: - self.outgoing_queue.append(wrapped) - else: - self.outgoing_queue.insert(0, wrapped) - - def _set_response_handler(self, wm: WrappedMessage, old_seq=None): - if old_seq in self.response_handlers: - del self.response_handlers[old_seq] - - seq = wm.seq - assert seq is not None, 'seq is not set' - - if seq in self.response_handlers: - self._logger.debug(f'_set_response_handler(seq={seq}): handler is already set, cancelling it') - self.response_handlers[seq].call(False, - error_message=f'_set_response_handler({seq}): error while calling old callback') - self.response_handlers[seq] = wm - - def _incr_outseq(self) -> None: - self.outseq = (self.outseq + 1) % 256 - - def _reset_outseq(self): - self.outseq = 0 - self._logger.debug(f'_reset_outseq: set 0') - - -MessageResponse = Union[Message, bool] diff --git a/src/temphum_nodes_util.py b/src/temphum_nodes_util.py deleted file mode 100755 index c700ca8..0000000 --- a/src/temphum_nodes_util.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 -from home.mqtt.temphum import MqttTempHumNodes - -if __name__ == '__main__': - max_name_len = 0 - for node in MqttTempHumNodes: - if len(node.name) > max_name_len: - max_name_len = len(node.name) - - values = [] - for node in MqttTempHumNodes: - hash = node.hash() - if hash in values: - raise ValueError(f'collision detected: {hash}') - values.append(values) - print(' '*(max_name_len-len(node.name)), end='') - print(f'{node.name}: {hash}') diff --git a/src/temphum_smbus_util.py b/src/temphum_smbus_util.py deleted file mode 100755 index 0f90835..0000000 --- a/src/temphum_smbus_util.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 -from argparse import ArgumentParser -from home.temphum import SensorType, create_sensor - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('-t', '--type', choices=[item.value for item in SensorType], - required=True, - help='Sensor type') - parser.add_argument('-b', '--bus', type=int, default=0, - help='I2C bus number') - arg = parser.parse_args() - - sensor = create_sensor(SensorType(arg.type), arg.bus) - temp = sensor.temperature() - hum = sensor.humidity() - - print(f'temperature: {temp}') - print(f'rel. humidity: {hum}') diff --git a/src/temphumd.py b/src/temphumd.py deleted file mode 100755 index f4d1fca..0000000 --- a/src/temphumd.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import json -import logging - -from typing import Optional - -from home.config import config -from home.temphum import SensorType, create_sensor, TempHumSensor - -logger = logging.getLogger(__name__) -sensor: Optional[TempHumSensor] = None -lock = asyncio.Lock() -delay = 0.01 - - -async def get_measurements(): - async with lock: - await asyncio.sleep(delay) - - temp = sensor.temperature() - rh = sensor.humidity() - - return rh, temp - - -async def handle_client(reader, writer): - request = None - while request != 'quit': - try: - request = await reader.read(255) - if request == b'\x04': - break - request = request.decode('utf-8').strip() - except Exception: - break - - if request == 'read': - try: - rh, temp = await asyncio.wait_for(get_measurements(), timeout=3) - data = dict(humidity=rh, temp=temp) - except asyncio.TimeoutError as e: - logger.exception(e) - data = dict(error='i2c call timed out') - else: - data = dict(error='invalid request') - - writer.write((json.dumps(data) + '\r\n').encode('utf-8')) - try: - await writer.drain() - except ConnectionResetError: - pass - - writer.close() - - -async def run_server(host, port): - server = await asyncio.start_server(handle_client, host, port) - async with server: - logger.info('Server started.') - await server.serve_forever() - - -if __name__ == '__main__': - config.load() - - if 'measure_delay' in config['sensor']: - delay = float(config['sensor']['measure_delay']) - - sensor = create_sensor(SensorType(config['sensor']['type']), - int(config['sensor']['bus'])) - - try: - host, port = config.get_addr('server.listen') - asyncio.run(run_server(host, port)) - except KeyboardInterrupt: - logging.info('Exiting...') diff --git a/src/web_api.py b/src/web_api.py deleted file mode 100755 index 0ddc6bd..0000000 --- a/src/web_api.py +++ /dev/null @@ -1,239 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import json -import os - -from datetime import datetime, timedelta - -from aiohttp import web -from home import http -from home.config import config, is_development_mode -from home.database import BotsDatabase, SensorsDatabase, InverterDatabase -from home.database.inverter_time_formats import * -from home.api.types import BotType, TemperatureSensorLocation, SoundSensorLocation -from home.media import SoundRecordStorage - - -def strptime_auto(s: str) -> datetime: - e = None - for fmt in (FormatTime, FormatDate): - try: - return datetime.strptime(s, fmt) - except ValueError as _e: - e = _e - raise e - - -class AuthError(Exception): - def __init__(self, message: str): - super().__init__() - self.message = message - - -class WebAPIServer(http.HTTPServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.app.middlewares.append(self.validate_auth) - - self.get('/', self.get_index) - self.get('/sensors/data/', self.GET_sensors_data) - self.get('/sound_sensors/hits/', self.GET_sound_sensors_hits) - self.post('/sound_sensors/hits/', self.POST_sound_sensors_hits) - - self.post('/log/bot_request/', self.POST_bot_request_log) - self.post('/log/openwrt/', self.POST_openwrt_log) - - self.get('/inverter/consumed_energy/', self.GET_consumed_energy) - self.get('/inverter/grid_consumed_energy/', self.GET_grid_consumed_energy) - - self.get('/recordings/list/', self.GET_recordings_list) - - @staticmethod - @web.middleware - async def validate_auth(req: http.Request, handler): - def get_token() -> str: - name = 'X-Token' - if name in req.headers: - return req.headers[name] - - return req.query['token'] - - try: - token = get_token() - except KeyError: - raise AuthError('no token') - - if token != config['api']['token']: - raise AuthError('invalid token') - - return await handler(req) - - @staticmethod - async def get_index(req: http.Request): - message = "nothing here, keep lurking" - if is_development_mode(): - message += ' (dev mode)' - return http.Response(text=message, content_type='text/plain') - - async def GET_sensors_data(self, req: http.Request): - try: - hours = int(req.query['hours']) - if hours < 1 or hours > 24: - raise ValueError('invalid hours value') - except KeyError: - hours = 1 - - sensor = TemperatureSensorLocation(int(req.query['sensor'])) - - dt_to = datetime.now() - dt_from = dt_to - timedelta(hours=hours) - - db = SensorsDatabase() - data = db.get_temperature_recordings(sensor, (dt_from, dt_to)) - return self.ok(data) - - async def GET_sound_sensors_hits(self, req: http.Request): - location = SoundSensorLocation(int(req.query['location'])) - - after = int(req.query['after']) - kwargs = {} - if after is None: - last = int(req.query['last']) - if last is None: - raise ValueError('you must pass `after` or `last` params') - else: - if not 0 < last < 100: - raise ValueError('invalid last value: must be between 0 and 100') - kwargs['last'] = last - else: - kwargs['after'] = datetime.fromtimestamp(after) - - data = BotsDatabase().get_sound_hits(location, **kwargs) - return self.ok(data) - - async def POST_sound_sensors_hits(self, req: http.Request): - hits = [] - data = await req.post() - for hit, count in json.loads(data['hits']): - if not hasattr(SoundSensorLocation, hit.upper()): - raise ValueError('invalid sensor location') - if count < 1: - raise ValueError(f'invalid count: {count}') - hits.append((SoundSensorLocation[hit.upper()], count)) - - BotsDatabase().add_sound_hits(hits, datetime.now()) - return self.ok() - - async def POST_bot_request_log(self, req: http.Request): - data = await req.post() - - try: - user_id = int(data['user_id']) - except KeyError: - user_id = 0 - - try: - message = data['message'] - except KeyError: - message = '' - - bot = BotType(int(data['bot'])) - - # validate message - if message.strip() == '': - raise ValueError('message can\'t be empty') - - # add record to the database - BotsDatabase().add_request(bot, user_id, message) - - return self.ok() - - async def POST_openwrt_log(self, req: http.Request): - data = await req.post() - - try: - logs = data['logs'] - ap = int(data['ap']) - except KeyError: - logs = '' - ap = 0 - - # validate it - logs = json.loads(logs) - assert type(logs) is list, "invalid json data (list expected)" - - lines = [] - for line in logs: - assert type(line) is list, "invalid line type (list expected)" - assert len(line) == 2, f"expected 2 items in line, got {len(line)}" - assert type(line[0]) is int, "invalid line[0] type (int expected)" - assert type(line[1]) is str, "invalid line[1] type (str expected)" - - lines.append(( - datetime.fromtimestamp(line[0]), - line[1] - )) - - BotsDatabase().add_openwrt_logs(lines, ap) - return self.ok() - - async def GET_recordings_list(self, req: http.Request): - data = await req.post() - - try: - extended = bool(int(data['extended'])) - except KeyError: - extended = False - - node = data['node'] - - root = os.path.join(config['recordings']['directory'], node) - if not os.path.isdir(root): - raise ValueError(f'invalid node {node}: no such directory') - - storage = SoundRecordStorage(root) - files = storage.getfiles(as_objects=extended) - if extended: - files = list(map(lambda file: file.__dict__(), files)) - - return self.ok(files) - - @staticmethod - def _get_inverter_from_to(req: http.Request): - s_from = req.query['from'] - s_to = req.query['to'] - - dt_from = strptime_auto(s_from) - - if s_to == 'now': - dt_to = datetime.now() - else: - dt_to = strptime_auto(s_to) - - return dt_from, dt_to - - async def GET_consumed_energy(self, req: http.Request): - dt_from, dt_to = self._get_inverter_from_to(req) - wh = InverterDatabase().get_consumed_energy(dt_from, dt_to) - return self.ok(wh) - - async def GET_grid_consumed_energy(self, req: http.Request): - dt_from, dt_to = self._get_inverter_from_to(req) - wh = InverterDatabase().get_grid_consumed_energy(dt_from, dt_to) - return self.ok(wh) - - -# start of the program -# -------------------- - -if __name__ == '__main__': - _app_name = 'web_api' - if is_development_mode(): - _app_name += '_dev' - config.load(_app_name) - - loop = asyncio.get_event_loop() - - server = WebAPIServer(config.get_addr('server.listen')) - server.run() |