summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2023-06-10 23:02:34 +0300
committerEvgeny Zinoviev <me@ch1p.io>2023-06-10 23:02:34 +0300
commitb0bf43e6a272d42a55158e657bd937cb82fc3d8d (patch)
treef1bc13253bc028abcaed9c88882f5aee384a269c /src
parentf3b9d50496257d87757802dfb472b5ffae11962c (diff)
move files, rename home package to homekit
Diffstat (limited to 'src')
-rw-r--r--src/__init__.py0
-rwxr-xr-xsrc/camera_node.py88
-rwxr-xr-xsrc/electricity_calc.py165
-rwxr-xr-xsrc/esp32_capture.py57
-rwxr-xr-xsrc/esp32cam_capture_diff_node.py86
-rwxr-xr-xsrc/gpiorelayd.py23
-rw-r--r--src/home/__init__.py0
-rw-r--r--src/home/api/__init__.py19
-rw-r--r--src/home/api/__init__.pyi5
-rw-r--r--src/home/api/config.py15
-rw-r--r--src/home/api/errors/__init__.py1
-rw-r--r--src/home/api/errors/api_response_error.py28
-rw-r--r--src/home/api/types/__init__.py6
-rw-r--r--src/home/api/types/types.py33
-rw-r--r--src/home/api/web_api_client.py227
-rw-r--r--src/home/audio/__init__.py0
-rw-r--r--src/home/audio/amixer.py91
-rw-r--r--src/home/camera/__init__.py1
-rw-r--r--src/home/camera/esp32.py226
-rw-r--r--src/home/camera/types.py5
-rw-r--r--src/home/camera/util.py107
-rw-r--r--src/home/config/__init__.py13
-rw-r--r--src/home/config/_configs.py55
-rw-r--r--src/home/config/config.py387
-rw-r--r--src/home/database/__init__.py29
-rw-r--r--src/home/database/__init__.pyi11
-rw-r--r--src/home/database/_base.py9
-rw-r--r--src/home/database/bots.py106
-rw-r--r--src/home/database/clickhouse.py39
-rw-r--r--src/home/database/inverter.py212
-rw-r--r--src/home/database/inverter_time_formats.py2
-rw-r--r--src/home/database/mysql.py47
-rw-r--r--src/home/database/sensors.py69
-rw-r--r--src/home/database/simple_state.py48
-rw-r--r--src/home/database/sqlite.py67
-rw-r--r--src/home/http/__init__.py2
-rw-r--r--src/home/http/http.py106
-rw-r--r--src/home/inverter/__init__.py3
-rw-r--r--src/home/inverter/config.py13
-rw-r--r--src/home/inverter/emulator.py556
-rw-r--r--src/home/inverter/inverter_wrapper.py48
-rw-r--r--src/home/inverter/monitor.py499
-rw-r--r--src/home/inverter/types.py64
-rw-r--r--src/home/inverter/util.py8
-rw-r--r--src/home/media/__init__.py22
-rw-r--r--src/home/media/__init__.pyi27
-rw-r--r--src/home/media/node_client.py119
-rw-r--r--src/home/media/node_server.py86
-rw-r--r--src/home/media/record.py461
-rw-r--r--src/home/media/record_client.py166
-rw-r--r--src/home/media/storage.py210
-rw-r--r--src/home/media/types.py13
-rw-r--r--src/home/mqtt/__init__.py7
-rw-r--r--src/home/mqtt/_config.py165
-rw-r--r--src/home/mqtt/_module.py70
-rw-r--r--src/home/mqtt/_mqtt.py86
-rw-r--r--src/home/mqtt/_node.py92
-rw-r--r--src/home/mqtt/_payload.py145
-rw-r--r--src/home/mqtt/_util.py15
-rw-r--r--src/home/mqtt/_wrapper.py60
-rw-r--r--src/home/mqtt/module/diagnostics.py64
-rw-r--r--src/home/mqtt/module/inverter.py195
-rw-r--r--src/home/mqtt/module/ota.py77
-rw-r--r--src/home/mqtt/module/relay.py92
-rw-r--r--src/home/mqtt/module/temphum.py82
-rw-r--r--src/home/pio/__init__.py1
-rw-r--r--src/home/pio/exceptions.py2
-rw-r--r--src/home/pio/products.py113
-rw-r--r--src/home/relay/__init__.py16
-rw-r--r--src/home/relay/__init__.pyi2
-rw-r--r--src/home/relay/sunxi_h3_client.py39
-rw-r--r--src/home/relay/sunxi_h3_server.py82
-rw-r--r--src/home/soundsensor/__init__.py22
-rw-r--r--src/home/soundsensor/__init__.pyi8
-rw-r--r--src/home/soundsensor/node.py75
-rw-r--r--src/home/soundsensor/server.py128
-rw-r--r--src/home/soundsensor/server_client.py38
-rw-r--r--src/home/telegram/__init__.py1
-rw-r--r--src/home/telegram/_botcontext.py86
-rw-r--r--src/home/telegram/_botdb.py32
-rw-r--r--src/home/telegram/_botlang.py120
-rw-r--r--src/home/telegram/_botutil.py47
-rw-r--r--src/home/telegram/aio.py18
-rw-r--r--src/home/telegram/bot.py583
-rw-r--r--src/home/telegram/config.py75
-rw-r--r--src/home/telegram/telegram.py49
-rw-r--r--src/home/temphum/__init__.py1
-rw-r--r--src/home/temphum/base.py19
-rw-r--r--src/home/temphum/i2c.py52
-rw-r--r--src/home/util.py255
-rwxr-xr-xsrc/inverter_bot.py943
-rwxr-xr-xsrc/inverter_mqtt_util.py25
-rwxr-xr-xsrc/inverterd_emulator.py9
-rwxr-xr-xsrc/ipcam_server.py579
-rwxr-xr-xsrc/mqtt_node_util.py63
-rwxr-xr-xsrc/openwrt_log_analyzer.py78
-rwxr-xr-xsrc/openwrt_logger.py72
-rw-r--r--src/pio_build.py4
-rwxr-xr-xsrc/pio_ini.py136
-rwxr-xr-xsrc/polaris_kettle_bot.py746
-rwxr-xr-xsrc/polaris_kettle_util.py113
-rwxr-xr-xsrc/pump_bot.py255
-rwxr-xr-xsrc/pump_mqtt_bot.py167
-rwxr-xr-xsrc/relay_mqtt_bot.py164
-rwxr-xr-xsrc/relay_mqtt_http_proxy.py133
-rwxr-xr-xsrc/sensors_bot.py181
-rwxr-xr-xsrc/sound_bot.py889
-rwxr-xr-xsrc/sound_node.py90
-rwxr-xr-xsrc/sound_sensor_node.py33
-rwxr-xr-xsrc/sound_sensor_server.py199
-rwxr-xr-xsrc/ssh_tunnels_config_util.py35
-rw-r--r--src/syncleo/__init__.py12
-rw-r--r--src/syncleo/kettle.py243
-rw-r--r--src/syncleo/protocol.py1169
-rwxr-xr-xsrc/temphum_mqtt_node.py78
-rwxr-xr-xsrc/temphum_mqtt_receiver.py47
-rwxr-xr-xsrc/temphum_nodes_util.py17
-rwxr-xr-xsrc/temphum_smbus_util.py21
-rwxr-xr-xsrc/temphumd.py78
-rwxr-xr-xsrc/web_api.py239
120 files changed, 0 insertions, 14512 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 3f2c5a4..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_app('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 8ea5a1c..0000000
--- a/src/electricity_calc.py
+++ /dev/null
@@ -1,165 +0,0 @@
-#!/usr/bin/env python3
-import logging
-import os
-import sys
-import inspect
-
-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 0441565..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 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(Addr.fromstring(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 59482f7..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 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(Addr.fromstring(config['esp32cam_web_addr']))
- self.directory = tempfile.gettempdir()
- self.nextpic = 1
- self.first = True
- self.server_addr = Addr.fromstring(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_app('esp32cam_capture_diff_node')
-
- loop = asyncio.get_event_loop()
- ESP32CamCaptureDiffNode()
- try:
- loop.run_forever()
- except KeyboardInterrupt:
- pass
diff --git a/src/gpiorelayd.py b/src/gpiorelayd.py
deleted file mode 100755
index f1a9e57..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_app()
-
- 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 d641f62..0000000
--- a/src/home/api/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import importlib
-
-__all__ = [
- # web_api_client.py
- 'WebApiClient',
- 'RequestParams',
-
- # config.py
- 'WebApiConfig'
-]
-
-
-def __getattr__(name):
- if name in __all__:
- file = 'config' if name == 'WebApiConfig' else 'web_api_client'
- 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/api/__init__.pyi b/src/home/api/__init__.pyi
deleted file mode 100644
index 5b98161..0000000
--- a/src/home/api/__init__.pyi
+++ /dev/null
@@ -1,5 +0,0 @@
-from .web_api_client import (
- RequestParams as RequestParams,
- WebApiClient as WebApiClient
-)
-from .config import WebApiConfig as WebApiConfig
diff --git a/src/home/api/config.py b/src/home/api/config.py
deleted file mode 100644
index 00c1097..0000000
--- a/src/home/api/config.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from ..config import ConfigUnit
-from typing import Optional, Union
-
-
-class WebApiConfig(ConfigUnit):
- NAME = 'web_api'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'listen_addr': cls._addr_schema(required=True),
- 'host': cls._addr_schema(required=True),
- 'token': dict(type='string', required=True),
- 'recordings_dir': dict(type='string', required=True)
- } \ No newline at end of file
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 15c1915..0000000
--- a/src/home/api/web_api_client.py
+++ /dev/null
@@ -1,227 +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 .config import WebApiConfig
-from .errors import ApiResponseError
-from .types import *
-from ..config import config
-from ..util import stringify
-from ..media import RecordFile, MediaNodeClient
-
-_logger = logging.getLogger(__name__)
-_config = WebApiConfig()
-
-
-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['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['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 5133c97..0000000
--- a/src/home/audio/amixer.py
+++ /dev/null
@@ -1,91 +0,0 @@
-import subprocess
-
-from ..config import app_config as 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 2fa5214..0000000
--- a/src/home/config/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from .config import (
- Config,
- ConfigUnit,
- AppConfigUnit,
- Translation,
- config,
- is_development_mode,
- setup_logging
-)
-from ._configs import (
- LinuxBoardsConfig,
- ServicesListConfig
-) \ No newline at end of file
diff --git a/src/home/config/_configs.py b/src/home/config/_configs.py
deleted file mode 100644
index 1628cba..0000000
--- a/src/home/config/_configs.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from .config import ConfigUnit
-from typing import Optional
-
-
-class ServicesListConfig(ConfigUnit):
- NAME = 'services_list'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'type': 'list',
- 'empty': False,
- 'schema': {
- 'type': 'string'
- }
- }
-
-
-class LinuxBoardsConfig(ConfigUnit):
- NAME = 'linux_boards'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'type': 'dict',
- 'schema': {
- 'mdns': {'type': 'string', 'required': True},
- 'board': {'type': 'string', 'required': True},
- 'network': {
- 'type': 'list',
- 'required': True,
- 'empty': False,
- 'allowed': ['wifi', 'ethernet']
- },
- 'ram': {'type': 'integer', 'required': True},
- 'online': {'type': 'boolean', 'required': True},
-
- # optional
- 'services': {
- 'type': 'list',
- 'empty': False,
- 'allowed': ServicesListConfig().get()
- },
- 'ext_hdd': {
- 'type': 'list',
- 'schema': {
- 'type': 'dict',
- 'schema': {
- 'mountpoint': {'type': 'string', 'required': True},
- 'size': {'type': 'integer', 'required': True}
- }
- },
- },
- }
- }
diff --git a/src/home/config/config.py b/src/home/config/config.py
deleted file mode 100644
index 7344386..0000000
--- a/src/home/config/config.py
+++ /dev/null
@@ -1,387 +0,0 @@
-import yaml
-import logging
-import os
-import cerberus
-import cerberus.errors
-
-from abc import ABC
-from typing import Optional, Any, MutableMapping, Union
-from argparse import ArgumentParser
-from enum import Enum, auto
-from os.path import join, isdir, isfile
-from ..util import Addr
-
-
-class MyValidator(cerberus.Validator):
- def _normalize_coerce_addr(self, value):
- return Addr.fromstring(value)
-
-
-MyValidator.types_mapping['addr'] = cerberus.TypeDefinition('Addr', (Addr,), ())
-
-
-CONFIG_DIRECTORIES = (
- join(os.environ['HOME'], '.config', 'homekit'),
- '/etc/homekit'
-)
-
-
-class RootSchemaType(Enum):
- DEFAULT = auto()
- DICT = auto()
- LIST = auto()
-
-
-class BaseConfigUnit(ABC):
- _data: MutableMapping[str, Any]
- _logger: logging.Logger
-
- def __init__(self):
- self._data = {}
- self._logger = logging.getLogger(self.__class__.__name__)
-
- 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 load_from(self, path: str):
- with open(path, 'r') as fd:
- self._data = yaml.safe_load(fd)
-
- def get(self,
- key: Optional[str] = None,
- default=None):
- if key is None:
- return self._data
-
- 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')
-
-
-class ConfigUnit(BaseConfigUnit):
- NAME = 'dumb'
-
- def __init__(self, name=None, load=True):
- super().__init__()
-
- self._data = {}
- self._logger = logging.getLogger(self.__class__.__name__)
-
- if self.NAME != 'dumb' and load:
- self.load_from(self.get_config_path())
- self.validate()
-
- elif name is not None:
- self.NAME = name
-
- @classmethod
- def get_config_path(cls, name=None) -> str:
- if name is None:
- name = cls.NAME
- if name is None:
- raise ValueError('get_config_path: name is none')
-
- for dirname in CONFIG_DIRECTORIES:
- if isdir(dirname):
- filename = join(dirname, f'{name}.yaml')
- if isfile(filename):
- return filename
-
- raise IOError(f'\'{name}.yaml\' not found')
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return None
-
- @classmethod
- def _addr_schema(cls, required=False, **kwargs):
- return {
- 'type': 'addr',
- 'coerce': Addr.fromstring,
- 'required': required,
- **kwargs
- }
-
- def validate(self):
- schema = self.schema()
- if not schema:
- self._logger.warning('validate: no schema')
- return
-
- if isinstance(self, AppConfigUnit):
- schema['logging'] = {
- 'type': 'dict',
- 'schema': {
- 'logging': {'type': 'boolean'}
- }
- }
-
- rst = RootSchemaType.DEFAULT
- try:
- if schema['type'] == 'dict':
- rst = RootSchemaType.DICT
- elif schema['type'] == 'list':
- rst = RootSchemaType.LIST
- elif schema['roottype'] == 'dict':
- del schema['roottype']
- rst = RootSchemaType.DICT
- except KeyError:
- pass
-
- v = MyValidator()
-
- if rst == RootSchemaType.DICT:
- normalized = v.validated({'document': self._data},
- {'document': {
- 'type': 'dict',
- 'keysrules': {'type': 'string'},
- 'valuesrules': schema
- }})['document']
- elif rst == RootSchemaType.LIST:
- v = MyValidator()
- normalized = v.validated({'document': self._data}, {'document': schema})['document']
- else:
- normalized = v.validated(self._data, schema)
-
- self._data = normalized
-
- try:
- self.custom_validator(self._data)
- except Exception as e:
- raise cerberus.DocumentError(f'{self.__class__.__name__}: {str(e)}')
-
- @staticmethod
- def custom_validator(data):
- pass
-
- def get_addr(self, key: str):
- return Addr.fromstring(self.get(key))
-
-
-class AppConfigUnit(ConfigUnit):
- _logging_verbose: bool
- _logging_fmt: Optional[str]
- _logging_file: Optional[str]
-
- def __init__(self, *args, **kwargs):
- super().__init__(load=False, *args, **kwargs)
- self._logging_verbose = False
- self._logging_fmt = None
- self._logging_file = None
-
- def logging_set_fmt(self, fmt: str) -> None:
- self._logging_fmt = fmt
-
- def logging_get_fmt(self) -> Optional[str]:
- try:
- return self['logging']['default_fmt']
- except KeyError:
- return self._logging_fmt
-
- def logging_set_file(self, file: str) -> None:
- self._logging_file = file
-
- def logging_get_file(self) -> Optional[str]:
- try:
- return self['logging']['file']
- except KeyError:
- return self._logging_file
-
- def logging_set_verbose(self):
- self._logging_verbose = True
-
- def logging_is_verbose(self) -> bool:
- try:
- return bool(self['logging']['verbose'])
- except KeyError:
- return self._logging_verbose
-
-
-class TranslationUnit(BaseConfigUnit):
- pass
-
-
-class Translation:
- LANGUAGES = ('en', 'ru')
- _langs: dict[str, TranslationUnit]
-
- def __init__(self, name: str):
- super().__init__()
- self._langs = {}
- for lang in self.LANGUAGES:
- for dirname in CONFIG_DIRECTORIES:
- if isdir(dirname):
- filename = join(dirname, f'i18n-{lang}', f'{name}.yaml')
- if lang in self._langs:
- raise RuntimeError(f'{name}: translation unit for lang \'{lang}\' already loaded')
- self._langs[lang] = TranslationUnit()
- self._langs[lang].load_from(filename)
- diff = set()
- for data in self._langs.values():
- diff ^= data.get().keys()
- if len(diff) > 0:
- raise RuntimeError(f'{name}: translation units have difference in keys: ' + ', '.join(diff))
-
- def get(self, lang: str) -> TranslationUnit:
- return self._langs[lang]
-
-
-class Config:
- app_name: Optional[str]
- app_config: AppConfigUnit
-
- def __init__(self):
- self.app_name = None
- self.app_config = AppConfigUnit()
-
- def load_app(self,
- name: Optional[Union[str, AppConfigUnit, bool]] = None,
- use_cli=True,
- parser: ArgumentParser = None,
- no_config=False):
- global app_config
-
- if not no_config \
- and not isinstance(name, str) \
- and not isinstance(name, bool) \
- and issubclass(name, AppConfigUnit) or name == AppConfigUnit:
- self.app_name = name.NAME
- self.app_config = name()
- app_config = self.app_config
- else:
- self.app_name = name if isinstance(name, str) else None
-
- if self.app_name is None and not use_cli:
- raise RuntimeError('either config name must be none or use_cli must be True')
-
- no_config = name is False or no_config
- 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:
- self.app_config.logging_set_verbose()
- if args.log_file:
- self.app_config.logging_set_file(args.log_file)
- if args.log_default_fmt:
- self.app_config.logging_set_fmt(args.log_default_fmt)
-
- if not isinstance(name, ConfigUnit):
- if not no_config and path is None:
- path = ConfigUnit.get_config_path(name=self.app_name)
-
- if not no_config:
- self.app_config.load_from(path)
- self.app_config.validate()
-
- setup_logging(self.app_config.logging_is_verbose(),
- self.app_config.logging_get_file(),
- self.app_config.logging_get_fmt())
-
- if use_cli:
- return args
-
-
-config = Config()
-
-
-def is_development_mode() -> bool:
- if 'HK_MODE' in os.environ and os.environ['HK_MODE'] == 'dev':
- return True
-
- return ('logging' in config.app_config) and ('verbose' in config.app_config['logging']) and (config.app_config['logging']['verbose'] is True)
-
-
-def setup_logging(verbose=False, log_file=None, default_fmt=None):
- 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/_base.py b/src/home/database/_base.py
deleted file mode 100644
index c01e62b..0000000
--- a/src/home/database/_base.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import os
-
-
-def get_data_root_directory(name: str) -> str:
- return os.path.join(
- os.environ['HOME'],
- '.config',
- 'homekit',
- 'data') \ No newline at end of file
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 d0ec283..0000000
--- a/src/home/database/clickhouse.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import logging
-
-from zoneinfo import ZoneInfo
-from datetime import datetime
-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 2b8ebe7..0000000
--- a/src/home/database/simple_state.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import os
-import json
-import atexit
-
-from ._base import get_data_root_directory
-
-
-class SimpleState:
- def __init__(self,
- name: str,
- default: dict = None):
- if default is None:
- default = {}
- elif type(default) is not dict:
- raise TypeError('default must be dictionary')
-
- path = os.path.join(get_data_root_directory(), name)
- if not os.path.exists(path):
- self._data = default
- else:
- with open(path, 'r') as f:
- self._data = json.loads(f.read())
-
- self._file = path
- 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 0af1f54..0000000
--- a/src/home/database/sqlite.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import sqlite3
-import os.path
-import logging
-
-from ._base import get_data_root_directory
-from ..config import config, is_development_mode
-
-
-def _get_database_path(name: str) -> str:
- return os.path.join(
- get_data_root_directory(),
- f'{name}.db')
-
-
-class SQLiteBase:
- SCHEMA = 1
-
- def __init__(self, name=None, check_same_thread=False):
- if name is None:
- name = config.app_config['database_name']
- database_path = _get_database_path(name)
- if not os.path.exists(os.path.dirname(database_path)):
- os.makedirs(os.path.dirname(database_path))
-
- self.logger = logging.getLogger(self.__class__.__name__)
- self.sqlite = sqlite3.connect(database_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/config.py b/src/home/inverter/config.py
deleted file mode 100644
index e284dfe..0000000
--- a/src/home/inverter/config.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from ..config import ConfigUnit
-from typing import Optional
-
-
-class InverterdConfig(ConfigUnit):
- NAME = 'inverterd'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'remote_addr': {'type': 'string'},
- 'local_addr': {'type': 'string'},
- } \ No newline at end of file
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 6923105..0000000
--- a/src/home/media/__init__.py
+++ /dev/null
@@ -1,22 +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 707d59c..0000000
--- a/src/home/mqtt/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from ._mqtt import Mqtt
-from ._node import MqttNode
-from ._module import MqttModule
-from ._wrapper import MqttWrapper
-from ._config import MqttConfig, MqttCreds, MqttNodesConfig
-from ._payload import MqttPayload, MqttPayloadCustomField
-from ._util import get_modules as get_mqtt_modules \ No newline at end of file
diff --git a/src/home/mqtt/_config.py b/src/home/mqtt/_config.py
deleted file mode 100644
index 9ba9443..0000000
--- a/src/home/mqtt/_config.py
+++ /dev/null
@@ -1,165 +0,0 @@
-from ..config import ConfigUnit
-from typing import Optional, Union
-from ..util import Addr
-from collections import namedtuple
-
-MqttCreds = namedtuple('MqttCreds', 'username, password')
-
-
-class MqttConfig(ConfigUnit):
- NAME = 'mqtt'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- addr_schema = {
- 'type': 'dict',
- 'required': True,
- 'schema': {
- 'host': {'type': 'string', 'required': True},
- 'port': {'type': 'integer', 'required': True}
- }
- }
-
- schema = {}
- for key in ('local', 'remote'):
- schema[f'{key}_addr'] = addr_schema
-
- schema['creds'] = {
- 'type': 'dict',
- 'required': True,
- 'keysrules': {'type': 'string'},
- 'valuesrules': {
- 'type': 'dict',
- 'schema': {
- 'username': {'type': 'string', 'required': True},
- 'password': {'type': 'string', 'required': True},
- }
- }
- }
-
- for key in ('client', 'server'):
- schema[f'default_{key}_creds'] = {'type': 'string', 'required': True}
-
- return schema
-
- def remote_addr(self) -> Addr:
- return Addr(host=self['remote_addr']['host'],
- port=self['remote_addr']['port'])
-
- def local_addr(self) -> Addr:
- return Addr(host=self['local_addr']['host'],
- port=self['local_addr']['port'])
-
- def creds_by_name(self, name: str) -> MqttCreds:
- return MqttCreds(username=self['creds'][name]['username'],
- password=self['creds'][name]['password'])
-
- def creds(self) -> MqttCreds:
- return self.creds_by_name(self['default_client_creds'])
-
- def server_creds(self) -> MqttCreds:
- return self.creds_by_name(self['default_server_creds'])
-
-
-class MqttNodesConfig(ConfigUnit):
- NAME = 'mqtt_nodes'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'common': {
- 'type': 'dict',
- 'schema': {
- 'temphum': {
- 'type': 'dict',
- 'schema': {
- 'interval': {'type': 'integer'}
- }
- },
- 'password': {'type': 'string'}
- }
- },
- 'nodes': {
- 'type': 'dict',
- 'required': True,
- 'keysrules': {'type': 'string'},
- 'valuesrules': {
- 'type': 'dict',
- 'schema': {
- 'type': {'type': 'string', 'required': True, 'allowed': ['esp8266', 'linux', 'none'],},
- 'board': {'type': 'string', 'allowed': ['nodemcu', 'd1_mini_lite', 'esp12e']},
- 'temphum': {
- 'type': 'dict',
- 'schema': {
- 'module': {'type': 'string', 'required': True, 'allowed': ['si7021', 'dht12']},
- 'interval': {'type': 'integer'},
- 'i2c_bus': {'type': 'integer'},
- 'tcpserver': {
- 'type': 'dict',
- 'schema': {
- 'port': {'type': 'integer', 'required': True}
- }
- }
- }
- },
- 'relay': {
- 'type': 'dict',
- 'schema': {
- 'device_type': {'type': 'string', 'allowed': ['lamp', 'pump', 'solenoid'], 'required': True},
- 'legacy_topics': {'type': 'boolean'}
- }
- },
- 'password': {'type': 'string'}
- }
- }
- }
- }
-
- @staticmethod
- def custom_validator(data):
- for name, node in data['nodes'].items():
- if 'temphum' in node:
- if node['type'] == 'linux':
- if 'i2c_bus' not in node['temphum']:
- raise KeyError(f'nodes.{name}.temphum: i2c_bus is missing but required for type=linux')
- if node['type'] in ('esp8266',) and 'board' not in node:
- raise KeyError(f'nodes.{name}: board is missing but required for type={node["type"]}')
-
- def get_node(self, name: str) -> dict:
- node = self['nodes'][name]
- if node['type'] == 'none':
- return node
-
- try:
- if 'password' not in node:
- node['password'] = self['common']['password']
- except KeyError:
- pass
-
- try:
- if 'temphum' in node:
- for ckey, cval in self['common']['temphum'].items():
- if ckey not in node['temphum']:
- node['temphum'][ckey] = cval
- except KeyError:
- pass
-
- return node
-
- def get_nodes(self,
- filters: Optional[Union[list[str], tuple[str]]] = None,
- only_names=False) -> Union[dict, list[str]]:
- if filters:
- for f in filters:
- if f not in ('temphum', 'relay'):
- raise ValueError(f'{self.__class__.__name__}::get_node(): invalid filter {f}')
- reslist = []
- resdict = {}
- for name in self['nodes'].keys():
- node = self.get_node(name)
- if (not filters) or ('temphum' in filters and 'temphum' in node) or ('relay' in filters and 'relay' in node):
- if only_names:
- reslist.append(name)
- else:
- resdict[name] = node
- return reslist if only_names else resdict
diff --git a/src/home/mqtt/_module.py b/src/home/mqtt/_module.py
deleted file mode 100644
index 80f27bb..0000000
--- a/src/home/mqtt/_module.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from __future__ import annotations
-
-import abc
-import logging
-import threading
-
-from time import sleep
-from ..util import next_tick_gen
-
-from typing import TYPE_CHECKING, Optional
-if TYPE_CHECKING:
- from ._node import MqttNode
- from ._payload import MqttPayload
-
-
-class MqttModule(abc.ABC):
- _tick_interval: int
- _initialized: bool
- _connected: bool
- _ticker: Optional[threading.Thread]
- _mqtt_node_ref: Optional[MqttNode]
-
- def __init__(self, tick_interval=0):
- self._tick_interval = tick_interval
- self._initialized = False
- self._ticker = None
- self._logger = logging.getLogger(self.__class__.__name__)
- self._connected = False
- self._mqtt_node_ref = None
-
- def on_connect(self, mqtt: MqttNode):
- self._connected = True
- self._mqtt_node_ref = mqtt
- if self._tick_interval:
- self._start_ticker()
-
- def on_disconnect(self, mqtt: MqttNode):
- self._connected = False
- self._mqtt_node_ref = None
-
- def is_initialized(self):
- return self._initialized
-
- def set_initialized(self):
- self._initialized = True
-
- def unset_initialized(self):
- self._initialized = False
-
- def tick(self):
- pass
-
- def _tick(self):
- g = next_tick_gen(self._tick_interval)
- while self._connected:
- sleep(next(g))
- if not self._connected:
- break
- self.tick()
-
- def _start_ticker(self):
- if not self._ticker or not self._ticker.is_alive():
- name_part = f'{self._mqtt_node_ref.id}/' if self._mqtt_node_ref else ''
- self._ticker = None
- self._ticker = threading.Thread(target=self._tick,
- name=f'mqtt:{self.__class__.__name__}/{name_part}ticker')
- self._ticker.start()
-
- def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]:
- pass
diff --git a/src/home/mqtt/_mqtt.py b/src/home/mqtt/_mqtt.py
deleted file mode 100644
index 746ae2e..0000000
--- a/src/home/mqtt/_mqtt.py
+++ /dev/null
@@ -1,86 +0,0 @@
-import os.path
-import paho.mqtt.client as mqtt
-import ssl
-import logging
-
-from ._config import MqttCreds, MqttConfig
-from typing import Optional
-
-
-class Mqtt:
- _connected: bool
- _is_server: bool
- _mqtt_config: MqttConfig
-
- def __init__(self,
- clean_session=True,
- client_id='',
- creds: Optional[MqttCreds] = None,
- is_server=False):
- if not client_id:
- raise ValueError('client_id must not be empty')
-
- self._client = mqtt.Client(client_id=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._connected = False
- self._is_server = is_server
- self._mqtt_config = MqttConfig()
- self._logger = logging.getLogger(self.__class__.__name__)
-
- if not creds:
- creds = self._mqtt_config.creds() if not is_server else self._mqtt_config.server_creds()
-
- self._client.username_pw_set(creds.username, creds.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):
- self._configure_tls()
- addr = self._mqtt_config.local_addr() if self._is_server else self._mqtt_config.remote_addr()
- self._client.connect(addr.host, addr.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))
- self._connected = True
-
- def on_disconnect(self, client: mqtt.Client, userdata, rc):
- self._logger.info("Disconnected with result code " + str(rc))
- self._connected = False
-
- 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}')
diff --git a/src/home/mqtt/_node.py b/src/home/mqtt/_node.py
deleted file mode 100644
index 4e259a4..0000000
--- a/src/home/mqtt/_node.py
+++ /dev/null
@@ -1,92 +0,0 @@
-import logging
-import importlib
-
-from typing import List, TYPE_CHECKING, Optional
-from ._payload import MqttPayload
-from ._module import MqttModule
-if TYPE_CHECKING:
- from ._wrapper import MqttWrapper
-else:
- MqttWrapper = None
-
-
-class MqttNode:
- _modules: List[MqttModule]
- _module_subscriptions: dict[str, MqttModule]
- _node_id: str
- _node_secret: str
- _payload_callbacks: list[callable]
- _wrapper: Optional[MqttWrapper]
-
- def __init__(self,
- node_id: str,
- node_secret: Optional[str] = None):
- self._modules = []
- self._module_subscriptions = {}
- self._node_id = node_id
- self._node_secret = node_secret
- self._payload_callbacks = []
- self._logger = logging.getLogger(self.__class__.__name__)
- self._wrapper = None
-
- def on_connect(self, wrapper: MqttWrapper):
- self._wrapper = wrapper
- for module in self._modules:
- if not module.is_initialized():
- module.on_connect(self)
- module.set_initialized()
-
- def on_disconnect(self):
- self._wrapper = None
- for module in self._modules:
- module.unset_initialized()
-
- def on_message(self, topic, payload):
- if topic in self._module_subscriptions:
- payload = self._module_subscriptions[topic].handle_payload(self, topic, payload)
- if isinstance(payload, MqttPayload):
- for f in self._payload_callbacks:
- f(self, payload)
-
- def load_module(self, module_name: str, *args, **kwargs) -> MqttModule:
- module = importlib.import_module(f'..module.{module_name}', __name__)
- if not hasattr(module, 'MODULE_NAME'):
- raise RuntimeError(f'MODULE_NAME not found in module {module}')
- cl = getattr(module, getattr(module, 'MODULE_NAME'))
- instance = cl(*args, **kwargs)
- self.add_module(instance)
- return instance
-
- def add_module(self, module: MqttModule):
- self._modules.append(module)
- if self._wrapper and self._wrapper._connected:
- module.on_connect(self)
- module.set_initialized()
-
- def subscribe_module(self, topic: str, module: MqttModule, qos: int = 1):
- if not self._wrapper or not self._wrapper._connected:
- raise RuntimeError('not connected')
-
- self._module_subscriptions[topic] = module
- self._wrapper.subscribe(self.id, topic, qos)
-
- def publish(self,
- topic: str,
- payload: bytes,
- qos: int = 1):
- self._wrapper.publish(self.id, topic, payload, qos)
-
- def add_payload_callback(self, callback: callable):
- self._payload_callbacks.append(callback)
-
- @property
- def id(self) -> str:
- return self._node_id
-
- @property
- def secret(self) -> str:
- return self._node_secret
-
- @secret.setter
- def secret(self, secret: str) -> None:
- self._node_secret = secret
diff --git a/src/home/mqtt/_payload.py b/src/home/mqtt/_payload.py
deleted file mode 100644
index 58eeae3..0000000
--- a/src/home/mqtt/_payload.py
+++ /dev/null
@@ -1,145 +0,0 @@
-import struct
-import abc
-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 \ No newline at end of file
diff --git a/src/home/mqtt/_util.py b/src/home/mqtt/_util.py
deleted file mode 100644
index 390d463..0000000
--- a/src/home/mqtt/_util.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import os
-import re
-
-from typing import List
-
-
-def get_modules() -> List[str]:
- modules = []
- modules_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'module')
- for name in os.listdir(modules_dir):
- if os.path.isdir(os.path.join(modules_dir, name)):
- continue
- name = re.sub(r'\.py$', '', name)
- modules.append(name)
- return modules
diff --git a/src/home/mqtt/_wrapper.py b/src/home/mqtt/_wrapper.py
deleted file mode 100644
index 3c2774c..0000000
--- a/src/home/mqtt/_wrapper.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import paho.mqtt.client as mqtt
-
-from ._mqtt import Mqtt
-from ._node import MqttNode
-from ..util import strgen
-
-
-class MqttWrapper(Mqtt):
- _nodes: list[MqttNode]
-
- def __init__(self,
- client_id: str,
- topic_prefix='hk',
- randomize_client_id=False,
- clean_session=True):
- if randomize_client_id:
- client_id += '_'+strgen(6)
- super().__init__(clean_session=clean_session,
- client_id=client_id)
- self._nodes = []
- self._topic_prefix = topic_prefix
-
- def on_connect(self, client: mqtt.Client, userdata, flags, rc):
- super().on_connect(client, userdata, flags, rc)
- for node in self._nodes:
- node.on_connect(self)
-
- def on_disconnect(self, client: mqtt.Client, userdata, rc):
- super().on_disconnect(client, userdata, rc)
- for node in self._nodes:
- node.on_disconnect()
-
- def on_message(self, client: mqtt.Client, userdata, msg):
- try:
- topic = msg.topic
- topic_node = topic[len(self._topic_prefix)+1:topic.find('/', len(self._topic_prefix)+1)]
- for node in self._nodes:
- if node.id in ('+', topic_node):
- node.on_message(topic[len(f'{self._topic_prefix}/{node.id}/'):], msg.payload)
- except Exception as e:
- self._logger.exception(str(e))
-
- def add_node(self, node: MqttNode):
- self._nodes.append(node)
- if self._connected:
- node.on_connect(self)
-
- def subscribe(self,
- node_id: str,
- topic: str,
- qos: int):
- self._client.subscribe(f'{self._topic_prefix}/{node_id}/{topic}', qos)
-
- def publish(self,
- node_id: str,
- topic: str,
- payload: bytes,
- qos: int):
- self._client.publish(f'{self._topic_prefix}/{node_id}/{topic}', payload, qos)
- self._client.loop_write()
diff --git a/src/home/mqtt/module/diagnostics.py b/src/home/mqtt/module/diagnostics.py
deleted file mode 100644
index 5db5e99..0000000
--- a/src/home/mqtt/module/diagnostics.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from .._payload import MqttPayload, MqttPayloadCustomField
-from .._node import MqttNode, MqttModule
-from typing import Optional
-
-MODULE_NAME = 'MqttDiagnosticsModule'
-
-
-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
-
-
-class MqttDiagnosticsModule(MqttModule):
- def on_connect(self, mqtt: MqttNode):
- super().on_connect(mqtt)
- for topic in ('diag', 'd1ag', 'stat', 'stat1'):
- mqtt.subscribe_module(topic, self)
-
- def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]:
- message = None
- if topic in ('stat', 'diag'):
- message = DiagnosticsPayload.unpack(payload)
- elif topic in ('stat1', 'd1ag'):
- message = InitialDiagnosticsPayload.unpack(payload)
- if message:
- self._logger.debug(message)
- return message
diff --git a/src/home/mqtt/module/inverter.py b/src/home/mqtt/module/inverter.py
deleted file mode 100644
index d927a06..0000000
--- a/src/home/mqtt/module/inverter.py
+++ /dev/null
@@ -1,195 +0,0 @@
-import time
-import json
-import datetime
-try:
- import inverterd
-except:
- pass
-
-from typing import Optional
-from .._module import MqttModule
-from .._node import MqttNode
-from .._payload import MqttPayload, bit_field
-try:
- from home.database import InverterDatabase
-except:
- pass
-
-_mult_10 = lambda n: int(n*10)
-_div_10 = lambda n: n/10
-
-
-MODULE_NAME = 'MqttInverterModule'
-
-STATUS_TOPIC = 'status'
-GENERATION_TOPIC = 'generation'
-
-
-class MqttInverterStatusPayload(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 MqttInverterGenerationPayload(MqttPayload):
- # 8 bytes
- FORMAT = 'II'
-
- time: int
- wh: int
-
-
-class MqttInverterModule(MqttModule):
- _status_poll_freq: int
- _generation_poll_freq: int
- _inverter: Optional[inverterd.Client]
- _database: Optional[InverterDatabase]
- _gen_prev: float
-
- def __init__(self, status_poll_freq=0, generation_poll_freq=0):
- super().__init__(tick_interval=status_poll_freq)
- self._status_poll_freq = status_poll_freq
- self._generation_poll_freq = generation_poll_freq
-
- # this defines whether this is a publisher or a subscriber
- if status_poll_freq > 0:
- self._inverter = inverterd.Client()
- self._inverter.connect()
- self._inverter.format(inverterd.Format.SIMPLE_JSON)
- self._database = None
- else:
- self._inverter = None
- self._database = InverterDatabase()
-
- self._gen_prev = 0
-
- def on_connect(self, mqtt: MqttNode):
- super().on_connect(mqtt)
- if not self._inverter:
- mqtt.subscribe_module(STATUS_TOPIC, self)
- mqtt.subscribe_module(GENERATION_TOPIC, self)
-
- def tick(self):
- if not self._inverter:
- return
-
- # 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
- return
-
- data = json.loads(raw)['data']
- status = MqttInverterStatusPayload(time=round(now), **data)
- self._mqtt_node_ref.publish(STATUS_TOPIC, status.pack())
-
- # read today's generation stat
- now = time.time()
- if self._gen_prev == 0 or now - self._gen_prev >= self._generation_poll_freq:
- self._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
- return
-
- data = json.loads(raw)['data']
- gen = MqttInverterGenerationPayload(time=round(now), wh=data['wh'])
- self._mqtt_node_ref.publish(GENERATION_TOPIC, gen.pack())
-
- def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]:
- home_id = 1 # legacy compat
-
- if topic == STATUS_TOPIC:
- s = MqttInverterStatusPayload.unpack(payload)
- self._database.add_status(home_id=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)
- return s
-
- elif topic == GENERATION_TOPIC:
- gen = MqttInverterGenerationPayload.unpack(payload)
- self._database.add_generation(home_id, gen.time, gen.wh)
- return gen
diff --git a/src/home/mqtt/module/ota.py b/src/home/mqtt/module/ota.py
deleted file mode 100644
index cd34332..0000000
--- a/src/home/mqtt/module/ota.py
+++ /dev/null
@@ -1,77 +0,0 @@
-import hashlib
-
-from typing import Optional
-from .._payload import MqttPayload
-from .._node import MqttModule, MqttNode
-
-MODULE_NAME = 'MqttOtaModule'
-
-
-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 MqttOtaModule(MqttModule):
- _ota_request: Optional[tuple[str, int]]
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self._ota_request = None
-
- def on_connect(self, mqtt: MqttNode):
- super().on_connect(mqtt)
- mqtt.subscribe_module("otares", self)
-
- if self._ota_request is not None:
- filename, qos = self._ota_request
- self._ota_request = None
- self.do_push_ota(self._mqtt_node_ref.secret, filename, qos)
-
- def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]:
- if topic == 'otares':
- message = OtaResultPayload.unpack(payload)
- self._logger.debug(message)
- return message
-
- def do_push_ota(self, secret: str, filename: str, qos: int):
- payload = OtaPayload(secret=secret, filename=filename)
- self._mqtt_node_ref.publish('ota',
- payload=payload.pack(),
- qos=qos)
-
- def push_ota(self,
- filename: str,
- qos: int):
- if not self._initialized:
- self._ota_request = (filename, qos)
- else:
- self.do_push_ota(filename, qos)
diff --git a/src/home/mqtt/module/relay.py b/src/home/mqtt/module/relay.py
deleted file mode 100644
index e968031..0000000
--- a/src/home/mqtt/module/relay.py
+++ /dev/null
@@ -1,92 +0,0 @@
-import datetime
-
-from typing import Optional
-from .. import MqttModule, MqttPayload, MqttNode
-
-MODULE_NAME = 'MqttRelayModule'
-
-
-class MqttPowerSwitchPayload(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
-
-
-class MqttPowerStatusPayload(MqttPayload):
- FORMAT = '=B'
- PACKER = {
- 'opened': lambda n: int(n),
- }
- UNPACKER = {
- 'opened': lambda n: bool(n),
- }
-
- opened: bool
-
-
-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
-
-
-class MqttRelayModule(MqttModule):
- _legacy_topics: bool
-
- def __init__(self, legacy_topics=False, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self._legacy_topics = legacy_topics
-
- def on_connect(self, mqtt: MqttNode):
- super().on_connect(mqtt)
- mqtt.subscribe_module(self._get_switch_topic(), self)
- mqtt.subscribe_module('relay/status', self)
-
- def switchpower(self,
- enable: bool):
- payload = MqttPowerSwitchPayload(secret=self._mqtt_node_ref.secret,
- state=enable)
- self._mqtt_node_ref.publish(self._get_switch_topic(),
- payload=payload.pack())
-
- def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]:
- message = None
-
- if topic == self._get_switch_topic():
- message = MqttPowerSwitchPayload.unpack(payload)
- elif topic == 'relay/status':
- message = MqttPowerStatusPayload.unpack(payload)
-
- if message is not None:
- self._logger.debug(message)
- return message
-
- def _get_switch_topic(self) -> str:
- return 'relay/power' if self._legacy_topics else 'relay/switch'
diff --git a/src/home/mqtt/module/temphum.py b/src/home/mqtt/module/temphum.py
deleted file mode 100644
index fd02cca..0000000
--- a/src/home/mqtt/module/temphum.py
+++ /dev/null
@@ -1,82 +0,0 @@
-from .._node import MqttNode
-from .._module import MqttModule
-from .._payload import MqttPayload
-from typing import Optional
-from ...temphum import BaseSensor
-
-two_digits_precision = lambda x: round(x, 2)
-
-MODULE_NAME = 'MqttTempHumModule'
-DATA_TOPIC = 'temphum/data'
-
-
-class MqttTemphumDataPayload(MqttPayload):
- FORMAT = '=ddb'
- UNPACKER = {
- 'temp': two_digits_precision,
- 'rh': two_digits_precision
- }
-
- temp: float
- rh: float
- error: int
-
-
-# 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 MqttTempHumModule(MqttModule):
- def __init__(self,
- sensor: Optional[BaseSensor] = None,
- write_to_database=False,
- *args, **kwargs):
- if sensor is not None:
- kwargs['tick_interval'] = 10
- super().__init__(*args, **kwargs)
- self._sensor = sensor
-
- def on_connect(self, mqtt: MqttNode):
- super().on_connect(mqtt)
- mqtt.subscribe_module(DATA_TOPIC, self)
-
- def tick(self):
- if not self._sensor:
- return
-
- error = 0
- temp = 0
- rh = 0
- try:
- temp = self._sensor.temperature()
- rh = self._sensor.humidity()
- except:
- error = 1
- pld = MqttTemphumDataPayload(temp=temp, rh=rh, error=error)
- self._mqtt_node_ref.publish(DATA_TOPIC, pld.pack())
-
- def handle_payload(self,
- mqtt: MqttNode,
- topic: str,
- payload: bytes) -> Optional[MqttPayload]:
- if topic == DATA_TOPIC:
- message = MqttTemphumDataPayload.unpack(payload)
- self._logger.debug(message)
- return message
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 388da03..0000000
--- a/src/home/pio/products.py
+++ /dev/null
@@ -1,113 +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):
- 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 a143bfe..0000000
--- a/src/home/telegram/_botcontext.py
+++ /dev/null
@@ -1,86 +0,0 @@
-from typing import Optional, List
-
-from telegram import Update, User, CallbackQuery
-from telegram.constants import ParseMode
-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
-
- async 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 await self._update.message.reply_text(text, **kwargs)
-
- async def reply_exc(self, e: Exception) -> None:
- await self.reply(exc2text(e), markup=IgnoreMarkup())
-
- async def answer(self, text: str = None):
- await self.callback_query.answer(text)
-
- async def edit(self, text, markup=None):
- kwargs = dict(parse_mode=ParseMode.HTML)
- if not isinstance(markup, IgnoreMarkup):
- kwargs['reply_markup'] = markup
- await 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 b551a55..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 e6ebc6e..0000000
--- a/src/home/telegram/bot.py
+++ /dev/null
@@ -1,583 +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, Coroutine
-
-from telegram import Update, ReplyKeyboardMarkup
-from telegram.ext import (
- Application,
- filters,
- CommandHandler,
- MessageHandler,
- CallbackQueryHandler,
- CallbackContext,
- ConversationHandler
-)
-from telegram.ext.filters import BaseFilter
-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__)
-_application: Optional[Application] = None
-_reporting: Optional[ReportingHelper] = None
-_exception_handler: Optional[Coroutine] = None
-_dispatcher = None
-_markup_getter: Optional[callable] = None
-_start_handler_ref: Optional[Coroutine] = 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
-
-
-async 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 = await 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():
- await ctx.reply_exc(e)
- else:
- notify_user(ctx.user_id, exc2text(e))
- else:
- _logger.exception(e)
-
-
-def handler(**kwargs):
- def inner(f):
- @wraps(f)
- async def _handler(*args, **inner_kwargs):
- if 'argument' in kwargs and kwargs['argument'] == 'message_key':
- inner_kwargs['argument'] = 'message_key'
- return await _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))
- _application.add_handler(
- MessageHandler(text_filter(*texts), _handler),
- group=0
- )
-
- if 'command' in kwargs:
- _application.add_handler(CommandHandler(kwargs['command'], _handler), group=0)
-
- if 'callback' in kwargs:
- _application.add_handler(CallbackQueryHandler(_handler, pattern=kwargs['callback']), group=0)
-
- return _handler
-
- return inner
-
-
-def simplehandler(f: Coroutine):
- @wraps(f)
- async def _handler(*args, **kwargs):
- return await _handler_of_handler(f=f, *args, **kwargs)
- return _handler
-
-
-def callbackhandler(*args, **kwargs):
- def inner(f):
- @wraps(f)
- async def _handler(*args, **kwargs):
- return await _handler_of_handler(f=f, *args, **kwargs)
- pattern_kwargs = {}
- if kwargs['callback'] != '*':
- pattern_kwargs['pattern'] = kwargs['callback']
- _application.add_handler(CallbackQueryHandler(_handler, **pattern_kwargs), group=0)
- return _handler
- return inner
-
-
-async 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)
- async def _impl(*args, **kwargs):
- result, ctx = await _handler_of_handler(f=f, *args, **kwargs, return_with_context=True)
- if result == conversation.END:
- await 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
- async def invalid(ctx: Context):
- await ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup())
- # return 0 # FIXME is this needed
-
- @simplehandler
- async def cancel(self, ctx: Context):
- await start(ctx)
- self.set_user_state(ctx.user_id, None)
- return conversation.END
-
- @simplehandler
- async def back(self, ctx: Context):
- cur_state = self.get_user_state(ctx.user_id)
- if cur_state is None:
- await 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')
- async 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)
-
- await ctx.reply(ctx.lang('select_language'), markup=markup)
- return self.START
-
- @convinput(START, messages=lang.languages)
- async 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)
- await ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup())
-
- return self.END
-
-
-def initialize():
- global _user_filter
- global _application
- # global _updater
- global _dispatcher
-
- # init user_filter
- _user_ids = config.app_config.get_user_ids()
- if len(_user_ids) > 0:
- _logger.info('allowed users: ' + str(_user_ids))
- _user_filter = filters.User(_user_ids)
- else:
- _user_filter = filters.ALL # not sure if this is correct
-
- _application = Application.builder()\
- .token(config.app_config.get('bot.token'))\
- .connect_timeout(7)\
- .read_timeout(6)\
- .build()
-
- # transparently log all messages
- # _application.dispatcher.add_handler(MessageHandler(filters.ALL & _user_filter, _logging_message_handler), group=10)
- # _application.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
-
- _application.add_handler(LangConversation().get_handler(), group=0)
- _application.add_handler(CommandHandler('start',
- callback=simplehandler(start_handler),
- filters=_user_filter))
- _application.add_handler(MessageHandler(filters.ALL & _user_filter, any_handler))
-
- _application.run_polling()
-
-
-def add_conversation(conv: conversation) -> None:
- _application.add_handler(conv.get_handler(), group=0)
-
-
-def add_handler(h):
- _application.add_handler(h, group=0)
-
-
-async def start(ctx: Context):
- return await _start_handler_ref(ctx)
-
-
-async def _default_start_handler(ctx: Context):
- if 'start_message' not in lang:
- return await ctx.reply('Please define start_message or override start()')
- await ctx.reply(ctx.lang('start_message'))
-
-
-@simplehandler
-async def _default_any_handler(ctx: Context):
- if 'invalid_command' not in lang:
- return await ctx.reply('Please define invalid_command or override any()')
- await 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))
- _application.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)
- _application.bot.send_message(chat_id=user_id,
- text=text,
- parse_mode='HTML',
- **kwargs)
-
-
-def send_photo(user_id, **kwargs):
- _application.bot.send_photo(chat_id=user_id, **kwargs)
-
-
-def send_audio(user_id, **kwargs):
- _application.bot.send_audio(chat_id=user_id, **kwargs)
-
-
-def send_file(user_id, **kwargs):
- _application.bot.send_document(chat_id=user_id, **kwargs)
-
-
-def edit_message_text(user_id, message_id, *args, **kwargs):
- _application.bot.edit_message_text(chat_id=user_id,
- message_id=message_id,
- parse_mode='HTML',
- *args, **kwargs)
-
-
-def delete_message(user_id, message_id):
- _application.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/config.py b/src/home/telegram/config.py
deleted file mode 100644
index 4c7d74b..0000000
--- a/src/home/telegram/config.py
+++ /dev/null
@@ -1,75 +0,0 @@
-from ..config import ConfigUnit
-from typing import Optional, Union
-from abc import ABC
-from enum import Enum
-
-
-class TelegramUserListType(Enum):
- USERS = 'users'
- NOTIFY = 'notify_users'
-
-
-class TelegramUserIdsConfig(ConfigUnit):
- NAME = 'telegram_user_ids'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'roottype': 'dict',
- 'type': 'integer'
- }
-
-
-_user_ids_config = TelegramUserIdsConfig()
-
-
-def _user_id_mapper(user: Union[str, int]) -> int:
- if isinstance(user, int):
- return user
- return _user_ids_config[user]
-
-
-class TelegramChatsConfig(ConfigUnit):
- NAME = 'telegram_chats'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'type': 'dict',
- 'schema': {
- 'id': {'type': 'string', 'required': True},
- 'token': {'type': 'string', 'required': True},
- }
- }
-
-
-class TelegramBotConfig(ConfigUnit, ABC):
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'bot': {
- 'type': 'dict',
- 'schema': {
- 'token': {'type': 'string', 'required': True},
- TelegramUserListType.USERS: {**TelegramBotConfig._userlist_schema(), 'required': True},
- TelegramUserListType.NOTIFY: TelegramBotConfig._userlist_schema(),
- }
- }
- }
-
- @staticmethod
- def _userlist_schema() -> dict:
- return {'type': 'list', 'schema': {'type': ['string', 'int']}}
-
- @staticmethod
- def custom_validator(data):
- for ult in TelegramUserListType:
- users = data['bot'][ult.value]
- for user in users:
- if isinstance(user, str):
- if user not in _user_ids_config:
- raise ValueError(f'user {user} not found in {TelegramUserIdsConfig.NAME}')
-
- def get_user_ids(self,
- ult: TelegramUserListType = TelegramUserListType.USERS) -> list[int]:
- return list(map(_user_id_mapper, self['bot'][ult.value])) \ No newline at end of file
diff --git a/src/home/telegram/telegram.py b/src/home/telegram/telegram.py
deleted file mode 100644
index f42363e..0000000
--- a/src/home/telegram/telegram.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import requests
-import logging
-
-from typing import Tuple
-from .config import TelegramChatsConfig
-
-_chats = TelegramChatsConfig()
-_logger = logging.getLogger(__name__)
-
-
-def send_message(text: str,
- chat: str,
- parse_mode: str = 'HTML',
- disable_web_page_preview: bool = False,):
- data, token = _send_telegram_data(text, chat, 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, chat: str):
- chat_data = _chats[chat]
- data = {
- 'chat_id': chat_data['id'],
- }
- token = chat_data['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,
- chat: str,
- parse_mode: str = None,
- disable_web_page_preview: bool = False) -> Tuple[dict, str]:
- chat_data = _chats[chat]
- data = {
- 'chat_id': chat_data['id'],
- 'text': text
- }
-
- if parse_mode is not None:
- data['parse_mode'] = parse_mode
-
- if disable_web_page_preview:
- data['disable_web_page_preview'] = 1
-
- return data, chat_data['token']
diff --git a/src/home/temphum/__init__.py b/src/home/temphum/__init__.py
deleted file mode 100644
index 46d14e6..0000000
--- a/src/home/temphum/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .base import SensorType, BaseSensor
diff --git a/src/home/temphum/base.py b/src/home/temphum/base.py
deleted file mode 100644
index 602cab7..0000000
--- a/src/home/temphum/base.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from abc import ABC
-from enum import Enum
-
-
-class BaseSensor(ABC):
- def __init__(self, bus: int):
- super().__init__()
- self.bus = smbus.SMBus(bus)
-
- def humidity(self) -> float:
- pass
-
- def temperature(self) -> float:
- pass
-
-
-class SensorType(Enum):
- Si7021 = 'si7021'
- DHT12 = 'dht12' \ No newline at end of file
diff --git a/src/home/temphum/i2c.py b/src/home/temphum/i2c.py
deleted file mode 100644
index 7d8e2e3..0000000
--- a/src/home/temphum/i2c.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import abc
-import smbus
-
-from .base import BaseSensor, SensorType
-
-
-class I2CSensor(BaseSensor, abc.ABC):
- def __init__(self, bus: int):
- super().__init__()
- self.bus = smbus.SMBus(bus)
-
-
-class DHT12(I2CSensor):
- 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
-
-
-class Si7021(I2CSensor):
- 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
-
-
-def create_sensor(type: SensorType, bus: int) -> BaseSensor:
- 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/util.py b/src/home/util.py
deleted file mode 100644
index 11e7116..0000000
--- a/src/home/util.py
+++ /dev/null
@@ -1,255 +0,0 @@
-from __future__ import annotations
-
-import json
-import socket
-import time
-import subprocess
-import traceback
-import logging
-import string
-import random
-import re
-
-from enum import Enum
-from datetime import datetime
-from typing import Optional, List
-from zlib import adler32
-
-logger = logging.getLogger(__name__)
-
-
-def validate_ipv4_or_hostname(address: str, raise_exception: bool = False) -> bool:
- if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', address):
- parts = address.split('.')
- if all(0 <= int(part) < 256 for part in parts):
- return True
- else:
- if raise_exception:
- raise ValueError(f"invalid IPv4 address: {address}")
- return False
-
- if re.match(r'^[a-zA-Z0-9.-]+$', address):
- return True
- else:
- if raise_exception:
- raise ValueError(f"invalid hostname: {address}")
- return False
-
-
-def validate_mac_address(mac_address: str) -> bool:
- mac_pattern = r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$'
- if re.match(mac_pattern, mac_address):
- return True
- else:
- return False
-
-
-class Addr:
- host: str
- port: Optional[int]
-
- def __init__(self, host: str, port: Optional[int] = None):
- self.host = host
- self.port = port
-
- @staticmethod
- def fromstring(addr: str) -> Addr:
- colons = addr.count(':')
- if colons != 1:
- raise ValueError('invalid host:port format')
-
- if not colons:
- host = addr
- port= None
- else:
- host, port = addr.split(':')
-
- validate_ipv4_or_hostname(host, raise_exception=True)
-
- if port is not None:
- port = int(port)
- if not 0 <= port <= 65535:
- raise ValueError(f'invalid port {port}')
-
- return Addr(host, port)
-
- def __str__(self):
- buf = self.host
- if self.port is not None:
- buf += ':'+str(self.port)
- return buf
-
- def __iter__(self):
- yield self.host
- yield self.port
-
-
-# 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 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())
-
-
-def next_tick_gen(freq):
- t = time.time()
- while True:
- t += freq
- yield max(t - time.time(), 0) \ No newline at end of file
diff --git a/src/inverter_bot.py b/src/inverter_bot.py
deleted file mode 100755
index 1dd167e..0000000
--- a/src/inverter_bot.py
+++ /dev/null
@@ -1,943 +0,0 @@
-#!/usr/bin/env python3
-import logging
-import re
-import datetime
-import json
-import itertools
-import sys
-
-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, AppConfigUnit
-from home.telegram import bot
-from home.telegram.config import TelegramBotConfig, TelegramUserListType
-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 FormatDate
-from home.api.types import BotType
-from home.api import WebApiClient
-from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
-
-
-if __name__ != '__main__':
- print(f'this script can not be imported as module', file=sys.stderr)
- sys.exit(1)
-
-
-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__)
-
-
-class InverterBotConfig(AppConfigUnit, TelegramBotConfig):
- NAME = 'inverter_bot'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- acmode_item_schema = {
- 'thresholds': {
- 'type': 'list',
- 'required': True,
- 'schema': {
- 'type': 'list',
- 'min': 40,
- 'max': 60
- },
- },
- 'initial_current': {'type': 'integer'}
- }
-
- return {
- **super(TelegramBotConfig).schema(),
- 'ac_mode': {
- 'type': 'dict',
- 'required': True,
- 'schema': {
- 'generator': acmode_item_schema,
- 'utilities': acmode_item_schema
- }
- },
- 'monitor': {
- 'type': 'dict',
- 'required': True,
- 'schema': {
- 'vlow': {'type': 'integer', 'required': True},
- 'vcrit': {'type': 'integer', 'required': True},
- 'gen_currents': {'type': 'list', 'schema': {'type': 'integer'}, 'required': True},
- 'gen_raise_intervals': {'type': 'list', 'schema': {'type': 'integer'}, 'required': True},
- 'gen_cur30_v_limit': {'type': 'float', 'required': True},
- 'gen_cur20_v_limit': {'type': 'float', 'required': True},
- 'gen_cur10_v_limit': {'type': 'float', 'required': True},
- 'gen_floating_v': {'type': 'integer', 'required': True},
- 'gen_floating_time_max': {'type': 'integer', 'required': True}
- }
- }
- }
-
-
-config.load_app(InverterBotConfig)
-
-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()
-
-
-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_util.py b/src/inverter_mqtt_util.py
deleted file mode 100755
index 791bf80..0000000
--- a/src/inverter_mqtt_util.py
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/usr/bin/env python3
-from argparse import ArgumentParser
-from home.config import config, app_config
-from home.mqtt import MqttWrapper, MqttNode
-
-
-if __name__ == '__main__':
- parser = ArgumentParser()
- parser.add_argument('mode', type=str, choices=('sender', 'receiver'), nargs=1)
-
- config.load_app('inverter_mqtt_util', parser=parser)
- arg = parser.parse_args()
- mode = arg.mode[0]
-
- mqtt = MqttWrapper(client_id=f'inverter_mqtt_{mode}',
- clean_session=mode != 'receiver')
- node = MqttNode(node_id='inverter')
- module_kwargs = {}
- if mode == 'sender':
- module_kwargs['status_poll_freq'] = int(app_config['poll_freq'])
- module_kwargs['generation_poll_freq'] = int(app_config['generation_poll_freq'])
- node.load_module('inverter', **module_kwargs)
- mqtt.add_node(node)
-
- mqtt.connect_and_loop()
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 a54cd35..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_app('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/mqtt_node_util.py b/src/mqtt_node_util.py
deleted file mode 100755
index ce954ae..0000000
--- a/src/mqtt_node_util.py
+++ /dev/null
@@ -1,63 +0,0 @@
-#!/usr/bin/env python3
-import os.path
-
-from time import sleep
-from typing import Optional
-from argparse import ArgumentParser, ArgumentError
-
-from home.config import config
-from home.mqtt import MqttNode, MqttWrapper, get_mqtt_modules
-from home.mqtt import MqttNodesConfig
-
-mqtt_node: Optional[MqttNode] = None
-mqtt: Optional[MqttWrapper] = None
-
-
-if __name__ == '__main__':
- nodes_config = MqttNodesConfig()
-
- parser = ArgumentParser()
- parser.add_argument('--node-id', type=str, required=True, choices=nodes_config.get_nodes(only_names=True))
- parser.add_argument('--modules', type=str, choices=get_mqtt_modules(), nargs='*',
- help='mqtt modules to include')
- parser.add_argument('--switch-relay', choices=[0, 1], type=int,
- help='send relay state')
- parser.add_argument('--push-ota', type=str, metavar='OTA_FILENAME',
- help='push OTA, receives path to firmware.bin')
-
- config.load_app(parser=parser, no_config=True)
- arg = parser.parse_args()
-
- if arg.switch_relay is not None and 'relay' not in arg.modules:
- raise ArgumentError(None, '--relay is only allowed when \'relay\' module included in --modules')
-
- mqtt = MqttWrapper(randomize_client_id=True,
- client_id='mqtt_node_util')
- mqtt_node = MqttNode(node_id=arg.node_id,
- node_secret=nodes_config.get_node(arg.node_id)['password'])
-
- mqtt.add_node(mqtt_node)
-
- # must-have modules
- ota_module = mqtt_node.load_module('ota')
- mqtt_node.load_module('diagnostics')
-
- if arg.modules:
- for m in arg.modules:
- module_instance = mqtt_node.load_module(m)
- if m == 'relay' and arg.switch_relay is not None:
- module_instance.switchpower(arg.switch_relay == 1)
-
- try:
- mqtt.connect_and_loop(loop_forever=False)
-
- if arg.push_ota:
- if not os.path.exists(arg.push_ota):
- raise OSError(f'--push-ota: file \"{arg.push_ota}\" does not exists')
- ota_module.push_ota(arg.push_ota, 1)
-
- while True:
- sleep(0.1)
-
- except KeyboardInterrupt:
- mqtt.disconnect()
diff --git a/src/openwrt_log_analyzer.py b/src/openwrt_log_analyzer.py
deleted file mode 100755
index 96023cd..0000000
--- a/src/openwrt_log_analyzer.py
+++ /dev/null
@@ -1,78 +0,0 @@
-#!/usr/bin/env python3
-import home.telegram as telegram
-
-from home.telegram.config import TelegramChatsConfig
-from home.util import validate_mac_address
-from typing import Optional
-from home.config import config, AppConfigUnit
-from home.database import BotsDatabase, SimpleState
-
-
-class OpenwrtLogAnalyzerConfig(AppConfigUnit):
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'database_name': {'type': 'string', 'required': True},
- 'devices': {
- 'type': 'dict',
- 'keysrules': {'type': 'string'},
- 'valuesrules': {
- 'type': 'string',
- 'check_with': validate_mac_address
- }
- },
- 'limit': {'type': 'integer'},
- 'telegram_chat': {'type': 'string'},
- 'aps': {
- 'type': 'list',
- 'schema': {'type': 'integer'}
- }
- }
-
- @staticmethod
- def custom_validator(data):
- chats = TelegramChatsConfig()
- if data['telegram_chat'] not in chats:
- return ValueError(f'unknown telegram chat {data["telegram_chat"]}')
-
-
-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, config.app_config['telegram_chat'])
-
- return max_id
-
-
-if __name__ == '__main__':
- config.load_app(OpenwrtLogAnalyzerConfig)
- for ap in config.app_config['aps']:
- dbname = config.app_config['database_name']
- dbname = dbname.replace('.txt', f'-{ap}.txt')
-
- state = SimpleState(name=dbname,
- 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 82f11ac..0000000
--- a/src/openwrt_logger.py
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/env python3
-import os
-
-from datetime import datetime
-from typing import Tuple, List, Optional
-from argparse import ArgumentParser
-from home.config import config, AppConfigUnit
-from home.database import SimpleState
-from home.api import WebApiClient
-
-
-class OpenwrtLoggerConfig(AppConfigUnit):
- @classmethod
- def schema(cls) -> Optional[dict]:
- return dict(
- database_name_template=dict(type='string', required=True)
- )
-
-
-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_app(OpenwrtLoggerConfig, parser=parser)
-
- state = SimpleState(name=config.app_config['database_name_template'].replace('{ap}', str(arg.access_point)),
- default=dict(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 920c3e5..0000000
--- a/src/pio_ini.py
+++ /dev/null
@@ -1,136 +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
- elif kwargs['type'] == 'bool':
- arg_kwargs['action'] = 'store_true'
- arg_kwargs['required'] = False
- else:
- raise TypeError(f'unsupported type {kwargs["type"]} for define {define_name}')
- else:
- arg_kwargs['action'] = 'store_true'
-
- if 'required' not in arg_kwargs:
- arg_kwargs['required'] = True
- parser.add_argument(f'--{define_name}', **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
- if kwargs['type'] == 'bool':
- defines[f'CONFIG_{define_name}'] = True
- 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 80baef3..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 Mqtt
-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_app('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,
- Mqtt,
- 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
- Mqtt.__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 12c4388..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 Mqtt
-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(Mqtt):
- 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_app('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 25f06fd..0000000
--- a/src/pump_bot.py
+++ /dev/null
@@ -1,255 +0,0 @@
-#!/usr/bin/env python3
-from enum import Enum
-from typing import Optional
-from telegram import ReplyKeyboardMarkup, User
-from time import time
-from datetime import datetime
-
-from home.config import config, is_development_mode
-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
-from home.mqtt import MqttNode, MqttWrapper, MqttPayload
-from home.mqtt.module.relay import MqttPowerStatusPayload, MqttRelayModule
-from home.mqtt.module.temphum import MqttTemphumDataPayload
-from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload
-
-
-config.load_app('pump_bot')
-
-mqtt: Optional[MqttWrapper] = None
-mqtt_node: Optional[MqttNode] = None
-mqtt_relay_module: Optional[MqttRelayModule] = None
-time_format = '%d.%m.%Y, %H:%M:%S'
-
-watering_mcu_status = {
- 'last_time': 0,
- 'last_boot_time': 0,
- 'relay_opened': False,
- 'ambient_temp': 0.0,
- 'ambient_rh': 0.0,
-}
-
-bot.initialize()
-bot.lang.ru(
- start_message="Выберите команду на клавиатуре",
- unknown_command="Неизвестная команда",
-
- enable="Включить",
- enable_silently="Включить тихо",
- enabled="Насос включен ✅",
-
- disable="Выключить",
- disable_silently="Выключить тихо",
- disabled="Насос выключен ❌",
-
- start_watering="Включить полив",
- stop_watering="Отключить полив",
-
- status="Статус насоса",
- watering_status="Статус полива",
-
- done="Готово 👌",
- sent="Команда отправлена",
-
- user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> насос.',
- user_watering_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> полив.',
- user_action_on="включил",
- user_action_off="выключил",
- user_action_watering_on="включил",
- user_action_watering_off="выключил",
-)
-bot.lang.en(
- start_message="Select command on the keyboard",
- unknown_command="Unknown command",
-
- enable="Turn ON",
- enable_silently="Turn ON silently",
- enabled="The pump is turned ON ✅",
-
- disable="Turn OFF",
- disable_silently="Turn OFF silently",
- disabled="The pump is turned OFF ❌",
-
- start_watering="Start watering",
- stop_watering="Stop watering",
-
- status="Pump status",
- watering_status="Watering status",
-
- done="Done 👌",
- sent="Request sent",
-
- user_action_notification='User <a href="tg://user?id=%d">%s</a> turned the pump <b>%s</b>.',
- user_watering_notification='User <a href="tg://user?id=%d">%s</a> <b>%s</b> the watering.',
- user_action_on="ON",
- user_action_off="OFF",
- user_action_watering_on="started",
- user_action_watering_off="stopped",
-)
-
-
-class UserAction(Enum):
- ON = 'on'
- OFF = 'off'
- WATERING_ON = 'watering_on'
- WATERING_OFF = 'watering_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 watering_on(ctx: bot.Context) -> None:
- mqtt_relay_module.switchpower(True, config.get('mqtt_water_relay.secret'))
- ctx.reply(ctx.lang('sent'))
- notify(ctx.user, UserAction.WATERING_ON)
-
-
-def watering_off(ctx: bot.Context) -> None:
- mqtt_relay_module.switchpower(False, config.get('mqtt_water_relay.secret'))
- ctx.reply(ctx.lang('sent'))
- notify(ctx.user, UserAction.WATERING_OFF)
-
-
-def notify(user: User, action: UserAction) -> None:
- notification_key = 'user_watering_notification' if action in (UserAction.WATERING_ON, UserAction.WATERING_OFF) else 'user_action_notification'
- 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(notification_key, 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='start_watering')
-def start_watering(ctx: bot.Context) -> None:
- watering_on(ctx)
-
-
-@bot.handler(message='stop_watering')
-def stop_watering(ctx: bot.Context) -> None:
- watering_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')
- )
-
-
-def _get_timestamp_as_string(timestamp: int) -> str:
- if timestamp != 0:
- return datetime.fromtimestamp(timestamp).strftime(time_format)
- else:
- return 'unknown'
-
-
-@bot.handler(message='watering_status')
-def watering_status(ctx: bot.Context) -> None:
- buf = ''
- if 0 < watering_mcu_status["last_time"] < time()-1800:
- buf += '<b>WARNING! long time no reports from mcu! maybe something\'s wrong</b>\n'
- buf += f'last report time: <b>{_get_timestamp_as_string(watering_mcu_status["last_time"])}</b>\n'
- if watering_mcu_status["last_boot_time"] != 0:
- buf += f'boot time: <b>{_get_timestamp_as_string(watering_mcu_status["last_boot_time"])}</b>\n'
- buf += 'relay opened: <b>' + ('yes' if watering_mcu_status['relay_opened'] else 'no') + '</b>\n'
- buf += f'ambient temp & humidity: <b>{watering_mcu_status["ambient_temp"]} °C, {watering_mcu_status["ambient_rh"]}%</b>'
- ctx.reply(buf)
-
-
-@bot.defaultreplymarkup
-def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
- buttons = []
- if ctx.user_id in config['bot']['silent_users']:
- buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')])
- buttons.append([ctx.lang('enable'), ctx.lang('disable'), ctx.lang('status')],)
- buttons.append([ctx.lang('start_watering'), ctx.lang('stop_watering'), ctx.lang('watering_status')])
-
- return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
-
-
-def mqtt_payload_callback(mqtt_node: MqttNode, payload: MqttPayload):
- global watering_mcu_status
-
- types_the_node_can_send = (
- InitialDiagnosticsPayload,
- DiagnosticsPayload,
- MqttTemphumDataPayload,
- MqttPowerStatusPayload
- )
- for cl in types_the_node_can_send:
- if isinstance(payload, cl):
- watering_mcu_status['last_time'] = int(time())
- break
-
- if isinstance(payload, InitialDiagnosticsPayload):
- watering_mcu_status['last_boot_time'] = int(time())
-
- elif isinstance(payload, MqttTemphumDataPayload):
- watering_mcu_status['ambient_temp'] = payload.temp
- watering_mcu_status['ambient_rh'] = payload.rh
-
- elif isinstance(payload, MqttPowerStatusPayload):
- watering_mcu_status['relay_opened'] = payload.opened
-
-
-if __name__ == '__main__':
- mqtt = MqttWrapper()
- mqtt_node = MqttNode(node_id=config.get('mqtt_water_relay.node_id'))
- if is_development_mode():
- mqtt_node.load_module('diagnostics')
-
- mqtt_node.load_module('temphum')
- mqtt_relay_module = mqtt_node.load_module('relay')
-
- mqtt_node.add_payload_callback(mqtt_payload_callback)
-
- mqtt.connect_and_loop(loop_forever=False)
-
- bot.enable_logging(BotType.PUMP)
- bot.run()
-
- try:
- mqtt.disconnect()
- except:
- pass
diff --git a/src/pump_mqtt_bot.py b/src/pump_mqtt_bot.py
deleted file mode 100755
index 4036d3a..0000000
--- a/src/pump_mqtt_bot.py
+++ /dev/null
@@ -1,167 +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 import MqttNode, MqttPayload
-from home.mqtt.module.relay import MqttRelayState
-from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload
-
-
-config.load_app('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: Optional[MqttNode] = 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.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.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 = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'],
- secret=config['mqtt']['home_secret']))
- mqtt.set_message_callback(on_mqtt_message)
- mqtt.connect_and_loop(loop_forever=False)
-
- # bot.enable_logging(BotType.PUMP_MQTT)
- bot.run(start_handler=start)
-
- mqtt.disconnect()
diff --git a/src/relay_mqtt_bot.py b/src/relay_mqtt_bot.py
deleted file mode 100755
index 020dc08..0000000
--- a/src/relay_mqtt_bot.py
+++ /dev/null
@@ -1,164 +0,0 @@
-#!/usr/bin/env python3
-import sys
-
-from enum import Enum
-from typing import Optional, Union
-from telegram import ReplyKeyboardMarkup
-from functools import partial
-
-from home.config import config, AppConfigUnit, Translation
-from home.telegram import bot
-from home.telegram.config import TelegramBotConfig
-from home.mqtt import MqttPayload, MqttNode, MqttWrapper, MqttModule
-from home.mqtt import MqttNodesConfig
-from home.mqtt.module.relay import MqttRelayModule, MqttRelayState
-from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload
-
-
-if __name__ != '__main__':
- print(f'this script can not be imported as module', file=sys.stderr)
- sys.exit(1)
-
-
-mqtt_nodes_config = MqttNodesConfig()
-
-
-class RelayMqttBotConfig(AppConfigUnit, TelegramBotConfig):
- NAME = 'relay_mqtt_bot'
-
- _strings: Translation
-
- def __init__(self):
- super().__init__()
- self._strings = Translation('mqtt_nodes')
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- **super(TelegramBotConfig).schema(),
- 'relay_nodes': {
- 'type': 'list',
- 'required': True,
- 'schema': {
- 'type': 'string'
- }
- },
- }
-
- @staticmethod
- def custom_validator(data):
- relay_node_names = mqtt_nodes_config.get_nodes(filters=('relay',), only_names=True)
- for node in data['relay_nodes']:
- if node not in relay_node_names:
- raise ValueError(f'unknown relay node "{node}"')
-
- def get_relay_name_translated(self, lang: str, relay_name: str) -> str:
- return self._strings.get(lang)[relay_name]['relay']
-
-
-config.load_app(RelayMqttBotConfig)
-
-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: MqttWrapper
-relay_nodes: dict[str, Union[MqttRelayModule, MqttModule]] = {}
-relay_states: dict[str, MqttRelayState] = {}
-
-
-class UserAction(Enum):
- ON = 'on'
- OFF = 'off'
-
-
-def on_mqtt_message(node: MqttNode,
- 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 node.id not in relay_states:
- relay_states[node.id] = MqttRelayState()
- relay_states[node.id].update(**kwargs)
-
-
-async def enable_handler(node_id: str, ctx: bot.Context) -> None:
- relay_nodes[node_id].switchpower(True)
- await ctx.reply(ctx.lang('done'))
-
-
-async def disable_handler(node_id: str, ctx: bot.Context) -> None:
- relay_nodes[node_id].switchpower(False)
- await ctx.reply(ctx.lang('done'))
-
-
-async def start(ctx: bot.Context) -> None:
- await ctx.reply(ctx.lang('start_message'))
-
-
-@bot.exceptionhandler
-async def exception_handler(e: Exception, ctx: bot.Context) -> bool:
- return False
-
-
-@bot.defaultreplymarkup
-def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
- buttons = []
- for node_id in config.app_config['relay_nodes']:
- node_data = mqtt_nodes_config.get_node(node_id)
- type_emoji = type_emojis[node_data['relay']['device_type']]
- row = [f'{type_emoji}{status_emoji[i.value]} {config.app_config.get_relay_name_translated(ctx.user_lang, node_id)}'
- for i in UserAction]
- buttons.append(row)
- return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
-
-
-devices = []
-mqtt = MqttWrapper(client_id='relay_mqtt_bot')
-for node_id in config.app_config['relay_nodes']:
- node_data = mqtt_nodes_config.get_node(node_id)
- mqtt_node = MqttNode(node_id=node_id,
- node_secret=node_data['password'])
- module_kwargs = {}
- try:
- if node_data['relay']['legacy_topics']:
- module_kwargs['legacy_topics'] = True
- except KeyError:
- pass
- relay_nodes[node_id] = mqtt_node.load_module('relay', **module_kwargs)
- mqtt_node.add_payload_callback(on_mqtt_message)
- mqtt.add_node(mqtt_node)
-
- type_emoji = type_emojis[node_data['relay']['device_type']]
-
- for action in UserAction:
- messages = []
- for _lang in Translation.LANGUAGES:
- _label = config.app_config.get_relay_name_translated(_lang, node_id)
- messages.append(f'{type_emoji}{status_emoji[action.value]} {_label}')
- bot.handler(texts=messages)(partial(enable_handler if action == UserAction.ON else disable_handler, node_id))
-
-mqtt.connect_and_loop(loop_forever=False)
-
-bot.run(start_handler=start)
-
-mqtt.disconnect()
diff --git a/src/relay_mqtt_http_proxy.py b/src/relay_mqtt_http_proxy.py
deleted file mode 100755
index e13c04a..0000000
--- a/src/relay_mqtt_http_proxy.py
+++ /dev/null
@@ -1,133 +0,0 @@
-#!/usr/bin/env python3
-import logging
-
-from home import http
-from home.config import config, AppConfigUnit
-from home.mqtt import MqttPayload, MqttWrapper, MqttNode, MqttModule, MqttNodesConfig
-from home.mqtt.module.relay import MqttRelayState, MqttRelayModule, MqttPowerStatusPayload
-from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload
-from typing import Optional, Union
-
-
-logger = logging.getLogger(__name__)
-mqtt: Optional[MqttWrapper] = None
-mqtt_nodes: dict[str, MqttNode] = {}
-relay_modules: dict[str, Union[MqttRelayModule, MqttModule]] = {}
-relay_states: dict[str, MqttRelayState] = {}
-
-mqtt_nodes_config = MqttNodesConfig()
-
-
-class RelayMqttHttpProxyConfig(AppConfigUnit):
- NAME = 'relay_mqtt_http_proxy'
-
- @classmethod
- def schema(cls) -> Optional[dict]:
- return {
- 'relay_nodes': {
- 'type': 'list',
- 'required': True,
- 'schema': {
- 'type': 'string'
- }
- },
- 'listen_addr': cls._addr_schema(required=True)
- }
-
- @staticmethod
- def custom_validator(data):
- relay_node_names = mqtt_nodes_config.get_nodes(filters=('relay',), only_names=True)
- for node in data['relay_nodes']:
- if node not in relay_node_names:
- raise ValueError(f'unknown relay node "{node}"')
-
-
-def on_mqtt_message(node: MqttNode,
- message: MqttPayload):
- try:
- is_legacy = mqtt_nodes_config[node.id]['relay']['legacy_topics']
- logger.debug(f'on_mqtt_message: relay {node.id} uses legacy topic names')
- except KeyError:
- is_legacy = False
- kwargs = {}
-
- if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload):
- kwargs['rssi'] = message.rssi
- if is_legacy:
- kwargs['enabled'] = message.flags.state
-
- if not is_legacy and isinstance(message, MqttPowerStatusPayload):
- kwargs['enabled'] = message.opened
-
- if len(kwargs):
- logger.debug(f'on_mqtt_message: {node.id}: going to update relay state: {str(kwargs)}')
- if node.id not in relay_states:
- relay_states[node.id] = MqttRelayState()
- relay_states[node.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):
- node_id = req.match_info['id']
- node_secret = req.query['secret']
-
- node = mqtt_nodes[node_id]
- relay_module = relay_modules[node_id]
-
- if enable is None:
- if node_id in relay_states and relay_states[node_id].ever_updated:
- cur_state = relay_states[node_id].enabled
- else:
- cur_state = False
- enable = not cur_state
-
- node.secret = node_secret
- relay_module.switchpower(enable)
- 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_app(RelayMqttHttpProxyConfig)
-
- mqtt = MqttWrapper(client_id='relay_mqtt_http_proxy',
- randomize_client_id=True)
- for node_id in config.app_config['relay_nodes']:
- node_data = mqtt_nodes_config.get_node(node_id)
- mqtt_node = MqttNode(node_id=node_id)
- module_kwargs = {}
- try:
- if node_data['relay']['legacy_topics']:
- module_kwargs['legacy_topics'] = True
- except KeyError:
- pass
- relay_modules[node_id] = mqtt_node.load_module('relay', **module_kwargs)
- if 'legacy_topics' in module_kwargs:
- mqtt_node.load_module('diagnostics')
- mqtt_node.add_payload_callback(on_mqtt_message)
- mqtt.add_node(mqtt_node)
- mqtt_nodes[node_id] = mqtt_node
-
- mqtt.connect_and_loop(loop_forever=False)
-
- proxy = RelayMqttHttpProxy(config.app_config['listen_addr'])
- try:
- proxy.run()
- except KeyboardInterrupt:
- mqtt.disconnect()
diff --git a/src/sensors_bot.py b/src/sensors_bot.py
deleted file mode 100755
index 441c212..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_app('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/sound_bot.py b/src/sound_bot.py
deleted file mode 100755
index bc9edce..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 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_app('sound_bot')
-
-nodes = {}
-for nodename, nodecfg in config['nodes'].items():
- nodes[nodename] = Addr.fromstring(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(Addr.fromstring(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(Addr.fromstring(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(Addr.fromstring(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 b0b4a67..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_app('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 404fdf4..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 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_app('sound_sensor_node')
-
- kwargs = {}
- if 'delay' in config['node']:
- kwargs['delay'] = config['node']['delay']
-
- if 'server_addr' in config['node']:
- server_addr = Addr.fromstring(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 3446b80..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 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_app('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] = Addr.fromstring(nodecfg['addr'])
-
- camera_nodes = {}
- if 'camera_nodes' in config:
- for nodename, nodecfg in config['camera_nodes'].items():
- camera_nodes[nodename] = Addr.fromstring(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 963c01b..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_app('ssh_tunnels_config_util')
-
- network_prefix = config['network']
- hostnames = []
-
- for k, v in config.app_config.get().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_mqtt_node.py b/src/temphum_mqtt_node.py
deleted file mode 100755
index c3d1975..0000000
--- a/src/temphum_mqtt_node.py
+++ /dev/null
@@ -1,78 +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, BaseSensor
-from home.temphum.i2c import create_sensor
-
-logger = logging.getLogger(__name__)
-sensor: Optional[BaseSensor] = 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_app()
-
- 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/temphum_mqtt_receiver.py b/src/temphum_mqtt_receiver.py
deleted file mode 100755
index 2b30800..0000000
--- a/src/temphum_mqtt_receiver.py
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/usr/bin/env python3
-import paho.mqtt.client as mqtt
-import re
-
-from home.config import config
-from home.mqtt import MqttWrapper, MqttNode
-
-
-class MqttServer(Mqtt):
- 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_app('temphum_mqtt_receiver')
-
- mqtt = MqttWrapper(clean_session=False)
- node = MqttNode(node_id='+')
- node.load_module('temphum', write_to_database=True)
- mqtt.add_node(node)
-
- mqtt.connect_and_loop() \ No newline at end of file
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 c06bacd..0000000
--- a/src/temphum_smbus_util.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env python3
-from argparse import ArgumentParser
-from home.temphum import SensorType
-from home.temphum.i2c import 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 c3d1975..0000000
--- a/src/temphumd.py
+++ /dev/null
@@ -1,78 +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, BaseSensor
-from home.temphum.i2c import create_sensor
-
-logger = logging.getLogger(__name__)
-sensor: Optional[BaseSensor] = 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_app()
-
- 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 0aa994a..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(_app_name)
-
- loop = asyncio.get_event_loop()
-
- server = WebAPIServer(config.get_addr('server.listen'))
- server.run()