diff options
-rw-r--r-- | .gitignore | 12 | ||||
-rw-r--r-- | README.md | 6 | ||||
-rw-r--r-- | arduino/ESP32CameraWebServer/CameraWebServer.ino (renamed from esp32-cam/CameraWebServer/CameraWebServer.ino) | 0 | ||||
-rw-r--r-- | arduino/ESP32CameraWebServer/app_httpd.cpp (renamed from esp32-cam/CameraWebServer/app_httpd.cpp) | 0 | ||||
-rw-r--r-- | arduino/ESP32CameraWebServer/camera_index.h (renamed from esp32-cam/CameraWebServer/camera_index.h) | 0 | ||||
-rw-r--r-- | arduino/ESP32CameraWebServer/camera_pins.h (renamed from esp32-cam/CameraWebServer/camera_pins.h) | 0 | ||||
-rw-r--r-- | arduino/ESP32CameraWebServer/index_ov2640.html (renamed from esp32-cam/CameraWebServer/index_ov2640.html) | 0 | ||||
-rw-r--r-- | bin/__py_include.py | 9 | ||||
-rwxr-xr-x | bin/camera_node.py (renamed from src/camera_node.py) | 13 | ||||
-rwxr-xr-x | bin/electricity_calc.py (renamed from src/electricity_calc.py) | 6 | ||||
-rwxr-xr-x | bin/esp32_capture.py (renamed from src/esp32_capture.py) | 7 | ||||
-rwxr-xr-x | bin/esp32cam_capture_diff_node.py (renamed from src/esp32cam_capture_diff_node.py) | 15 | ||||
-rwxr-xr-x | bin/gpiorelayd.py | 31 | ||||
-rwxr-xr-x | bin/inverter_bot.py (renamed from src/inverter_bot.py) | 355 | ||||
-rwxr-xr-x | bin/inverter_mqtt_util.py | 27 | ||||
-rwxr-xr-x | bin/inverterd_emulator.py (renamed from src/inverterd_emulator.py) | 3 | ||||
-rwxr-xr-x | bin/ipcam_capture.py | 141 | ||||
-rwxr-xr-x | bin/ipcam_motion_worker.sh (renamed from tools/ipcam_motion_worker.sh) | 2 | ||||
-rwxr-xr-x | bin/ipcam_server.py (renamed from src/ipcam_server.py) | 225 | ||||
-rwxr-xr-x | bin/lugovaya_pump_mqtt_bot.py | 207 | ||||
-rwxr-xr-x | bin/mqtt_node_util.py | 68 | ||||
-rwxr-xr-x | bin/openwrt_log_analyzer.py | 79 | ||||
-rwxr-xr-x | bin/openwrt_logger.py (renamed from src/openwrt_logger.py) | 40 | ||||
-rw-r--r-- | bin/pio_build.py (renamed from src/pio_build.py) | 1 | ||||
-rwxr-xr-x | bin/pio_ini.py (renamed from src/pio_ini.py) | 38 | ||||
-rwxr-xr-x | bin/polaris_kettle_bot.py (renamed from src/polaris_kettle_bot.py) | 19 | ||||
-rwxr-xr-x | bin/polaris_kettle_util.py (renamed from src/polaris_kettle_util.py) | 9 | ||||
-rwxr-xr-x | bin/pump_bot.py | 297 | ||||
-rwxr-xr-x | bin/pump_mqtt_bot.py (renamed from src/pump_mqtt_bot.py) | 33 | ||||
-rwxr-xr-x | bin/relay_mqtt_bot.py | 164 | ||||
-rwxr-xr-x | bin/relay_mqtt_http_proxy.py | 134 | ||||
-rwxr-xr-x | bin/sensors_bot.py (renamed from src/sensors_bot.py) | 19 | ||||
-rwxr-xr-x | bin/sound_bot.py (renamed from src/sound_bot.py) | 33 | ||||
-rwxr-xr-x | bin/sound_node.py (renamed from src/sound_node.py) | 11 | ||||
-rwxr-xr-x | bin/sound_sensor_node.py (renamed from src/sound_sensor_node.py) | 11 | ||||
-rwxr-xr-x | bin/sound_sensor_server.py (renamed from src/sound_sensor_server.py) | 23 | ||||
-rwxr-xr-x | bin/ssh_tunnels_config_util.py (renamed from src/ssh_tunnels_config_util.py) | 8 | ||||
-rwxr-xr-x | bin/temphum_mqtt_node.py | 79 | ||||
-rwxr-xr-x | bin/temphum_mqtt_receiver.py (renamed from src/sensors_mqtt_receiver.py) | 27 | ||||
-rwxr-xr-x | bin/temphum_nodes_util.py (renamed from src/temphum_nodes_util.py) | 4 | ||||
-rwxr-xr-x | bin/temphum_smbus_util.py (renamed from src/temphum_smbus_util.py) | 5 | ||||
-rwxr-xr-x | bin/temphumd.py (renamed from src/temphumd.py) | 10 | ||||
-rwxr-xr-x | bin/web_api.py (renamed from src/web_api.py) | 40 | ||||
-rw-r--r-- | doc/openwrt_logger.md | 28 | ||||
-rw-r--r-- | include/bash/include.bash (renamed from tools/lib.bash) | 0 | ||||
-rw-r--r-- | include/pio/include/homekit/logging.h (renamed from platformio/common/include/homekit/logging.h) | 0 | ||||
-rw-r--r-- | include/pio/include/homekit/macros.h (renamed from platformio/common/include/homekit/macros.h) | 0 | ||||
-rw-r--r-- | include/pio/include/homekit/stopwatch.h (renamed from platformio/common/include/homekit/stopwatch.h) | 0 | ||||
-rw-r--r-- | include/pio/include/homekit/util.h (renamed from platformio/common/include/homekit/util.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/config/homekit/config.cpp (renamed from platformio/common/libs/config/homekit/config.cpp) | 0 | ||||
-rw-r--r-- | include/pio/libs/config/homekit/config.h (renamed from platformio/common/libs/config/homekit/config.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/config/library.json (renamed from platformio/common/libs/config/library.json) | 0 | ||||
-rw-r--r-- | include/pio/libs/http_server/homekit/http_server.cpp (renamed from platformio/common/libs/http_server/homekit/http_server.cpp) | 0 | ||||
-rw-r--r-- | include/pio/libs/http_server/homekit/http_server.h (renamed from platformio/common/libs/http_server/homekit/http_server.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/http_server/library.json (renamed from platformio/common/libs/http_server/library.json) | 0 | ||||
-rw-r--r-- | include/pio/libs/led/homekit/led.cpp (renamed from platformio/common/libs/led/homekit/led.cpp) | 0 | ||||
-rw-r--r-- | include/pio/libs/led/homekit/led.h (renamed from platformio/common/libs/led/homekit/led.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/led/library.json (renamed from platformio/common/libs/led/library.json) | 0 | ||||
-rw-r--r-- | include/pio/libs/main/homekit/main.cpp (renamed from platformio/common/libs/main/homekit/main.cpp) | 21 | ||||
-rw-r--r-- | include/pio/libs/main/homekit/main.h (renamed from platformio/common/libs/main/homekit/main.h) | 4 | ||||
-rw-r--r-- | include/pio/libs/main/library.json | 12 | ||||
-rw-r--r-- | include/pio/libs/mqtt/homekit/mqtt/module.cpp (renamed from platformio/common/libs/mqtt/homekit/mqtt/module.cpp) | 2 | ||||
-rw-r--r-- | include/pio/libs/mqtt/homekit/mqtt/module.h (renamed from platformio/common/libs/mqtt/homekit/mqtt/module.h) | 15 | ||||
-rw-r--r-- | include/pio/libs/mqtt/homekit/mqtt/mqtt.cpp (renamed from platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp) | 29 | ||||
-rw-r--r-- | include/pio/libs/mqtt/homekit/mqtt/mqtt.h (renamed from platformio/common/libs/mqtt/homekit/mqtt/mqtt.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/mqtt/homekit/mqtt/payload.h (renamed from platformio/common/libs/mqtt/homekit/mqtt/payload.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/mqtt/library.json (renamed from platformio/common/libs/mqtt/library.json) | 2 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp (renamed from platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp) | 11 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h (renamed from platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h) | 5 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_diagnostics/library.json (renamed from platformio/common/libs/mqtt_module_diagnostics/library.json) | 4 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp (renamed from platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp) | 12 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.h (renamed from platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h) | 7 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_ota/library.json | 11 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp (renamed from platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp) | 27 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.h (renamed from platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h) | 10 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_relay/library.json | 11 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp (renamed from platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp) | 2 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h (renamed from platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h) | 2 | ||||
-rw-r--r-- | include/pio/libs/mqtt_module_temphum/library.json | 11 | ||||
-rw-r--r-- | include/pio/libs/relay/homekit/relay.cpp (renamed from platformio/common/libs/relay/homekit/relay.cpp) | 0 | ||||
-rw-r--r-- | include/pio/libs/relay/homekit/relay.h (renamed from platformio/common/libs/relay/homekit/relay.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/relay/library.json (renamed from platformio/common/libs/relay/library.json) | 0 | ||||
-rw-r--r-- | include/pio/libs/static/homekit/static.cpp (renamed from platformio/common/libs/static/homekit/static.cpp) | 0 | ||||
-rw-r--r-- | include/pio/libs/static/homekit/static.h (renamed from platformio/common/libs/static/homekit/static.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/static/library.json (renamed from platformio/common/libs/static/library.json) | 0 | ||||
-rw-r--r-- | include/pio/libs/temphum/homekit/temphum.cpp (renamed from platformio/common/libs/temphum/homekit/temphum.cpp) | 0 | ||||
-rw-r--r-- | include/pio/libs/temphum/homekit/temphum.h (renamed from platformio/common/libs/temphum/homekit/temphum.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/temphum/library.json (renamed from platformio/common/libs/temphum/library.json) | 0 | ||||
-rw-r--r-- | include/pio/libs/wifi/homekit/wifi.cpp (renamed from platformio/common/libs/wifi/homekit/wifi.cpp) | 0 | ||||
-rw-r--r-- | include/pio/libs/wifi/homekit/wifi.h (renamed from platformio/common/libs/wifi/homekit/wifi.h) | 0 | ||||
-rw-r--r-- | include/pio/libs/wifi/library.json (renamed from platformio/common/libs/wifi/library.json) | 0 | ||||
-rwxr-xr-x | include/pio/make_static.sh (renamed from platformio/common/make_static.sh) | 0 | ||||
-rw-r--r-- | include/pio/static/app.js (renamed from platformio/common/static/app.js) | 0 | ||||
-rw-r--r-- | include/pio/static/favicon.ico (renamed from platformio/common/static/favicon.ico) | bin | 7886 -> 7886 bytes | |||
-rw-r--r-- | include/pio/static/index.html (renamed from platformio/common/static/index.html) | 0 | ||||
-rw-r--r-- | include/pio/static/md5.js (renamed from platformio/common/static/md5.js) | 0 | ||||
-rw-r--r-- | include/pio/static/style.css (renamed from platformio/common/static/style.css) | 0 | ||||
-rw-r--r-- | include/py/__init__.py (renamed from src/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/__init__.py (renamed from src/home/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/api/__init__.py | 19 | ||||
-rw-r--r-- | include/py/homekit/api/__init__.pyi | 5 | ||||
-rw-r--r-- | include/py/homekit/api/config.py | 15 | ||||
-rw-r--r-- | include/py/homekit/api/errors/__init__.py (renamed from src/home/api/errors/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/api/errors/api_response_error.py (renamed from src/home/api/errors/api_response_error.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/api/types/__init__.py (renamed from src/home/api/types/__init__.py) | 1 | ||||
-rw-r--r-- | include/py/homekit/api/types/types.py (renamed from src/home/api/types/types.py) | 11 | ||||
-rw-r--r-- | include/py/homekit/api/web_api_client.py (renamed from src/home/api/web_api_client.py) | 42 | ||||
-rw-r--r-- | include/py/homekit/audio/__init__.py (renamed from src/home/audio/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/audio/amixer.py (renamed from src/home/audio/amixer.py) | 12 | ||||
-rw-r--r-- | include/py/homekit/camera/__init__.py | 2 | ||||
-rw-r--r-- | include/py/homekit/camera/config.py | 130 | ||||
-rw-r--r-- | include/py/homekit/camera/esp32.py (renamed from src/home/camera/esp32.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/camera/types.py | 46 | ||||
-rw-r--r-- | include/py/homekit/camera/util.py (renamed from src/home/camera/util.py) | 70 | ||||
-rw-r--r-- | include/py/homekit/config/__init__.py | 14 | ||||
-rw-r--r-- | include/py/homekit/config/_configs.py | 61 | ||||
-rw-r--r-- | include/py/homekit/config/config.py | 406 | ||||
-rw-r--r-- | include/py/homekit/database/__init__.py (renamed from src/home/database/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/database/__init__.pyi (renamed from src/home/database/__init__.pyi) | 0 | ||||
-rw-r--r-- | include/py/homekit/database/_base.py | 9 | ||||
-rw-r--r-- | include/py/homekit/database/bots.py (renamed from src/home/database/bots.py) | 10 | ||||
-rw-r--r-- | include/py/homekit/database/clickhouse.py (renamed from src/home/database/clickhouse.py) | 2 | ||||
-rw-r--r-- | include/py/homekit/database/inverter.py (renamed from src/home/database/inverter.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/database/inverter_time_formats.py (renamed from src/home/database/inverter_time_formats.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/database/mysql.py (renamed from src/home/database/mysql.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/database/sensors.py (renamed from src/home/database/sensors.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/database/simple_state.py (renamed from src/home/database/simple_state.py) | 14 | ||||
-rw-r--r-- | include/py/homekit/database/sqlite.py (renamed from src/home/database/sqlite.py) | 22 | ||||
-rw-r--r-- | include/py/homekit/http/__init__.py (renamed from src/home/http/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/http/http.py (renamed from src/home/http/http.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/inverter/__init__.py (renamed from src/home/inverter/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/inverter/config.py | 13 | ||||
-rw-r--r-- | include/py/homekit/inverter/emulator.py (renamed from src/home/inverter/emulator.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/inverter/inverter_wrapper.py (renamed from src/home/inverter/inverter_wrapper.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/inverter/monitor.py (renamed from src/home/inverter/monitor.py) | 2 | ||||
-rw-r--r-- | include/py/homekit/inverter/types.py (renamed from src/home/inverter/types.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/inverter/util.py (renamed from src/home/inverter/util.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/media/__init__.py (renamed from src/home/media/__init__.py) | 1 | ||||
-rw-r--r-- | include/py/homekit/media/__init__.pyi (renamed from src/home/media/__init__.pyi) | 0 | ||||
-rw-r--r-- | include/py/homekit/media/node_client.py (renamed from src/home/media/node_client.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/media/node_server.py (renamed from src/home/media/node_server.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/media/record.py (renamed from src/home/media/record.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/media/record_client.py (renamed from src/home/media/record_client.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/media/storage.py (renamed from src/home/media/storage.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/media/types.py (renamed from src/home/media/types.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/mqtt/__init__.py | 7 | ||||
-rw-r--r-- | include/py/homekit/mqtt/_config.py | 165 | ||||
-rw-r--r-- | include/py/homekit/mqtt/_module.py | 70 | ||||
-rw-r--r-- | include/py/homekit/mqtt/_mqtt.py (renamed from src/home/mqtt/mqtt.py) | 55 | ||||
-rw-r--r-- | include/py/homekit/mqtt/_node.py | 92 | ||||
-rw-r--r-- | include/py/homekit/mqtt/_payload.py (renamed from src/home/mqtt/payload/base_payload.py) | 4 | ||||
-rw-r--r-- | include/py/homekit/mqtt/_util.py | 15 | ||||
-rw-r--r-- | include/py/homekit/mqtt/_wrapper.py | 60 | ||||
-rw-r--r-- | include/py/homekit/mqtt/module/diagnostics.py (renamed from src/home/mqtt/payload/esp.py) | 56 | ||||
-rw-r--r-- | include/py/homekit/mqtt/module/inverter.py | 195 | ||||
-rw-r--r-- | include/py/homekit/mqtt/module/ota.py | 77 | ||||
-rw-r--r-- | include/py/homekit/mqtt/module/relay.py | 92 | ||||
-rw-r--r-- | include/py/homekit/mqtt/module/temphum.py | 82 | ||||
-rw-r--r-- | include/py/homekit/pio/__init__.py (renamed from src/home/pio/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/pio/exceptions.py (renamed from src/home/pio/exceptions.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/pio/products.py (renamed from src/home/pio/products.py) | 14 | ||||
-rw-r--r-- | include/py/homekit/relay/__init__.py (renamed from src/home/relay/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/relay/__init__.pyi (renamed from src/home/relay/__init__.pyi) | 0 | ||||
-rw-r--r-- | include/py/homekit/relay/sunxi_h3_client.py (renamed from src/home/relay/sunxi_h3_client.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/relay/sunxi_h3_server.py (renamed from src/home/relay/sunxi_h3_server.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/soundsensor/__init__.py (renamed from src/home/soundsensor/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/soundsensor/__init__.pyi (renamed from src/home/soundsensor/__init__.pyi) | 0 | ||||
-rw-r--r-- | include/py/homekit/soundsensor/node.py (renamed from src/home/soundsensor/node.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/soundsensor/server.py (renamed from src/home/soundsensor/server.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/soundsensor/server_client.py (renamed from src/home/soundsensor/server_client.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/telegram/__init__.py (renamed from src/home/telegram/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/telegram/_botcontext.py (renamed from src/home/telegram/_botcontext.py) | 19 | ||||
-rw-r--r-- | include/py/homekit/telegram/_botdb.py (renamed from src/home/telegram/_botdb.py) | 2 | ||||
-rw-r--r-- | include/py/homekit/telegram/_botlang.py (renamed from src/home/telegram/_botlang.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/telegram/_botutil.py (renamed from src/home/telegram/_botutil.py) | 17 | ||||
-rw-r--r-- | include/py/homekit/telegram/aio.py (renamed from src/home/telegram/aio.py) | 0 | ||||
-rw-r--r-- | include/py/homekit/telegram/bot.py (renamed from src/home/telegram/bot.py) | 242 | ||||
-rw-r--r-- | include/py/homekit/telegram/config.py | 78 | ||||
-rw-r--r-- | include/py/homekit/telegram/telegram.py (renamed from src/home/telegram/telegram.py) | 28 | ||||
-rw-r--r-- | include/py/homekit/temphum/__init__.py | 1 | ||||
-rw-r--r-- | include/py/homekit/temphum/base.py (renamed from src/home/temphum/base.py) | 20 | ||||
-rw-r--r-- | include/py/homekit/temphum/i2c.py | 52 | ||||
-rw-r--r-- | include/py/homekit/util.py (renamed from src/home/util.py) | 97 | ||||
-rw-r--r-- | include/py/pyA20/__init__.pyi (renamed from pyA20/__init__.pyi) | 0 | ||||
-rw-r--r-- | include/py/pyA20/gpio/connector.pyi (renamed from pyA20/gpio/connector.pyi) | 0 | ||||
-rw-r--r-- | include/py/pyA20/gpio/gpio.pyi (renamed from pyA20/gpio/gpio.pyi) | 0 | ||||
-rw-r--r-- | include/py/pyA20/gpio/port.pyi (renamed from pyA20/gpio/port.pyi) | 0 | ||||
-rw-r--r-- | include/py/pyA20/port.pyi (renamed from pyA20/port.pyi) | 0 | ||||
-rw-r--r-- | include/py/syncleo/__init__.py (renamed from src/syncleo/__init__.py) | 0 | ||||
-rw-r--r-- | include/py/syncleo/kettle.py (renamed from src/syncleo/kettle.py) | 0 | ||||
-rw-r--r-- | include/py/syncleo/protocol.py (renamed from src/syncleo/protocol.py) | 0 | ||||
-rw-r--r-- | misc/home_linux_boards/etc/default/homekit_ipcam_server | 2 | ||||
-rwxr-xr-x[-rw-r--r--] | misc/home_linux_boards/usr/local/bin/homekit_ipcam_capture_restart.sh (renamed from misc/scripts/ipcam_capture_restart.sh) | 0 | ||||
-rwxr-xr-x[-rw-r--r--] | misc/home_linux_boards/usr/local/bin/homekit_ipcam_rtsp2hls_restart.sh (renamed from misc/scripts/ipcam_rtsp2hls_restart.sh) | 0 | ||||
-rwxr-xr-x[-rw-r--r--] | misc/home_linux_boards/usr/local/bin/homekit_make_netns_per_upstream.sh (renamed from misc/scripts/make_netns_per_upstream.sh) | 0 | ||||
-rwxr-xr-x[-rw-r--r--] | misc/home_linux_boards/usr/local/bin/homekit_sunxi_h3_i2c_reset.sh (renamed from tools/sunxi-h3-i2c-reset.sh) | 0 | ||||
-rwxr-xr-x | misc/home_linux_boards/usr/local/bin/homekit_sunxi_setup_amixer.sh (renamed from tools/sunxi-setup-amixer.sh) | 0 | ||||
-rwxr-xr-x | misc/home_linux_boards/usr/local/bin/homekit_sync_recordings_to_remote.sh (renamed from tools/sync-recordings-to-remote.sh) | 0 | ||||
-rw-r--r-- | misc/mqtt_ca.crt (renamed from assets/mqtt_ca.crt) | 0 | ||||
-rw-r--r-- | misc/openwrt/etc/rc.local | 2 | ||||
-rwxr-xr-x[-rw-r--r--] | misc/remote_server/usr/local/bin/clickhouse_backup.sh (renamed from tools/clickhouse-backup.sh) | 0 | ||||
-rwxr-xr-x[-rw-r--r--] | misc/remote_server/usr/local/bin/remove_old_recordings.sh (renamed from tools/remove-old-recordings.sh) | 0 | ||||
-rw-r--r-- | pio/dumb_mqtt/src/main.cpp (renamed from platformio/dumb_mqtt/src/main.cpp) | 0 | ||||
-rw-r--r-- | pio/relayctl/src/main.cpp (renamed from platformio/relayctl/src/main.cpp) | 0 | ||||
-rw-r--r-- | pio/temphum/src/main.cpp (renamed from platformio/temphum/src/main.cpp) | 0 | ||||
-rw-r--r-- | pio/temphum_relayctl/src/main.cpp (renamed from platformio/temphum_relayctl/src/main.cpp) | 1 | ||||
-rw-r--r-- | platformio/common/libs/main/library.json | 12 | ||||
-rw-r--r-- | platformio/common/libs/mqtt_module_ota/library.json | 11 | ||||
-rw-r--r-- | platformio/common/libs/mqtt_module_relay/library.json | 11 | ||||
-rw-r--r-- | platformio/common/libs/mqtt_module_temphum/library.json | 11 | ||||
-rw-r--r-- | platformio/dumb_mqtt/.gitignore | 3 | ||||
-rw-r--r-- | platformio/relayctl/.gitignore | 3 | ||||
-rw-r--r-- | platformio/temphum/.gitignore | 3 | ||||
-rw-r--r-- | requirements.txt | 21 | ||||
-rw-r--r-- | requirements_kettle.txt | 3 | ||||
-rwxr-xr-x | src/esp_mqtt_util.py | 42 | ||||
-rwxr-xr-x | src/gpiorelayd.py | 23 | ||||
-rw-r--r-- | src/home/api/__init__.py | 11 | ||||
-rw-r--r-- | src/home/api/__init__.pyi | 4 | ||||
-rw-r--r-- | src/home/camera/__init__.py | 1 | ||||
-rw-r--r-- | src/home/camera/types.py | 5 | ||||
-rw-r--r-- | src/home/config/__init__.py | 1 | ||||
-rw-r--r-- | src/home/config/config.py | 204 | ||||
-rw-r--r-- | src/home/mqtt/__init__.py | 4 | ||||
-rw-r--r-- | src/home/mqtt/esp.py | 106 | ||||
-rw-r--r-- | src/home/mqtt/payload/__init__.py | 1 | ||||
-rw-r--r-- | src/home/mqtt/payload/inverter.py | 73 | ||||
-rw-r--r-- | src/home/mqtt/payload/relay.py | 22 | ||||
-rw-r--r-- | src/home/mqtt/payload/sensors.py | 20 | ||||
-rw-r--r-- | src/home/mqtt/payload/temphum.py | 15 | ||||
-rw-r--r-- | src/home/mqtt/relay.py | 71 | ||||
-rw-r--r-- | src/home/mqtt/temphum.py | 54 | ||||
-rw-r--r-- | src/home/mqtt/util.py | 8 | ||||
-rw-r--r-- | src/home/temphum/__init__.py | 18 | ||||
-rw-r--r-- | src/home/temphum/dht12.py | 22 | ||||
-rw-r--r-- | src/home/temphum/si7021.py | 13 | ||||
-rwxr-xr-x | src/inverter_mqtt_receiver.py | 74 | ||||
-rwxr-xr-x | src/inverter_mqtt_sender.py | 72 | ||||
-rwxr-xr-x | src/openwrt_log_analyzer.py | 72 | ||||
-rwxr-xr-x | src/pump_bot.py | 131 | ||||
-rwxr-xr-x | src/relay_mqtt_bot.py | 112 | ||||
-rwxr-xr-x | src/relay_mqtt_http_proxy.py | 67 | ||||
-rwxr-xr-x | src/sensors_mqtt_sender.py | 58 | ||||
-rw-r--r-- | systemd/camera_node.service | 2 | ||||
-rw-r--r-- | systemd/camera_node@.service | 2 | ||||
-rw-r--r-- | systemd/esp32cam_capture_diff_node.service | 2 | ||||
-rw-r--r-- | systemd/gpiorelayd@.service | 5 | ||||
-rw-r--r-- | systemd/inverter_bot.service | 2 | ||||
-rw-r--r-- | systemd/inverter_mqtt_receiver.service | 13 | ||||
-rw-r--r-- | systemd/inverter_mqtt_sender.service | 2 | ||||
-rw-r--r-- | systemd/ipcam_capture@.service | 15 | ||||
-rw-r--r-- | systemd/ipcam_rtsp2hls@.service | 14 | ||||
-rw-r--r-- | systemd/ipcam_server.service | 5 | ||||
-rw-r--r-- | systemd/polaris_kettle_bot.service | 2 | ||||
-rw-r--r-- | systemd/pump_bot.service | 2 | ||||
-rw-r--r-- | systemd/pump_mqtt_bot.service | 2 | ||||
-rw-r--r-- | systemd/relay_mqtt_bot.service | 2 | ||||
-rw-r--r-- | systemd/relay_mqtt_http_proxy.service | 2 | ||||
-rw-r--r-- | systemd/sensors_bot.service | 2 | ||||
-rw-r--r-- | systemd/sensors_mqtt_receiver.service | 4 | ||||
-rw-r--r-- | systemd/sensors_mqtt_sender.service | 13 | ||||
-rw-r--r-- | systemd/sound_bot.service | 2 | ||||
-rw-r--r-- | systemd/sound_node.service | 2 | ||||
-rw-r--r-- | systemd/sound_sensor_node.service | 2 | ||||
-rw-r--r-- | systemd/sound_sensor_server.service | 2 | ||||
-rw-r--r-- | systemd/temphumd.service | 2 | ||||
-rw-r--r-- | systemd/temphumd@.service | 2 | ||||
-rw-r--r-- | test/__init__.py | 0 | ||||
-rw-r--r-- | test/__py_include.py | 9 | ||||
-rwxr-xr-x | test/mqtt_relay_server_util.py | 11 | ||||
-rwxr-xr-x | test/mqtt_relay_util.py | 32 | ||||
-rwxr-xr-x | test/test.py | 7 | ||||
-rwxr-xr-x | test/test_amixer.py | 11 | ||||
-rwxr-xr-x | test/test_api.py | 19 | ||||
-rwxr-xr-x | test/test_esp32_cam.py | 18 | ||||
-rwxr-xr-x | test/test_inverter_monitor.py | 21 | ||||
-rw-r--r-- | test/test_ipcam_server_cleanup.py | 16 | ||||
-rwxr-xr-x | test/test_polaris_stuff.py | 11 | ||||
-rwxr-xr-x | test/test_record_upload.py | 23 | ||||
-rwxr-xr-x | test/test_send_fake_sound_hit.py | 12 | ||||
-rwxr-xr-x | test/test_sensors_plot.py | 0 | ||||
-rwxr-xr-x | test/test_sound_node_client.py | 9 | ||||
-rwxr-xr-x | test/test_sound_server_api.py | 18 | ||||
-rwxr-xr-x | test/test_stopwatch.py | 3 | ||||
-rw-r--r-- | test/test_telegram_aio_send_photo.py | 15 | ||||
-rwxr-xr-x | tools/ipcam_capture.sh | 119 | ||||
-rwxr-xr-x | tools/ipcam_rtsp2hls.sh | 127 | ||||
-rwxr-xr-x | tools/mcuota.py | 98 | ||||
-rwxr-xr-x | tools/mcuota.sh | 14 | ||||
-rwxr-xr-x | tools/process-motion-timecodes.py | 61 | ||||
-rwxr-xr-x | tools/rotate-video.sh | 2 | ||||
-rwxr-xr-x | tools/video-util.sh | 2 |
292 files changed, 4180 insertions, 2852 deletions
@@ -6,17 +6,19 @@ config.def.h __pycache__ .DS_Store -/src/test/test_inverter_monitor.log +/include/test/test_inverter_monitor.log /youtrack-certificate /cpp -/src/test.py -/esp32-cam/CameraWebServer/wifi_password.h +/test/test.py +/bin/test.py +/arduino/ESP32CameraWebServer/wifi_password.h cmake-build-* .pio platformio.ini CMakeListsPrivate.txt -/platformio/*/CMakeLists.txt -/platformio/*/CMakeListsPrivate.txt +/pio/*/CMakeLists.txt +/pio/*/CMakeListsPrivate.txt +/pio/*/.gitignore *.swp /localwebsite/vendor @@ -5,12 +5,6 @@ a country house, solving real life tasks. Mostly undocumented. -## TODO - -esp8266/esp32 code: - -- move common stuff to the `commom` directory and use it as a framework - ## License BSD-3c diff --git a/esp32-cam/CameraWebServer/CameraWebServer.ino b/arduino/ESP32CameraWebServer/CameraWebServer.ino index ef589d9..ef589d9 100644 --- a/esp32-cam/CameraWebServer/CameraWebServer.ino +++ b/arduino/ESP32CameraWebServer/CameraWebServer.ino diff --git a/esp32-cam/CameraWebServer/app_httpd.cpp b/arduino/ESP32CameraWebServer/app_httpd.cpp index e397c70..e397c70 100644 --- a/esp32-cam/CameraWebServer/app_httpd.cpp +++ b/arduino/ESP32CameraWebServer/app_httpd.cpp diff --git a/esp32-cam/CameraWebServer/camera_index.h b/arduino/ESP32CameraWebServer/camera_index.h index 5ca12e9..5ca12e9 100644 --- a/esp32-cam/CameraWebServer/camera_index.h +++ b/arduino/ESP32CameraWebServer/camera_index.h diff --git a/esp32-cam/CameraWebServer/camera_pins.h b/arduino/ESP32CameraWebServer/camera_pins.h index e1be287..e1be287 100644 --- a/esp32-cam/CameraWebServer/camera_pins.h +++ b/arduino/ESP32CameraWebServer/camera_pins.h diff --git a/esp32-cam/CameraWebServer/index_ov2640.html b/arduino/ESP32CameraWebServer/index_ov2640.html index 4f3738c..4f3738c 100644 --- a/esp32-cam/CameraWebServer/index_ov2640.html +++ b/arduino/ESP32CameraWebServer/index_ov2640.html diff --git a/bin/__py_include.py b/bin/__py_include.py new file mode 100644 index 0000000..8f98830 --- /dev/null +++ b/bin/__py_include.py @@ -0,0 +1,9 @@ +import sys +import os.path + +for _name in ('include/py',): + sys.path.extend([ + os.path.realpath( + os.path.join(os.path.dirname(os.path.join(__file__)), '..', _name) + ) + ])
\ No newline at end of file diff --git a/src/camera_node.py b/bin/camera_node.py index d175e17..1485557 100755 --- a/src/camera_node.py +++ b/bin/camera_node.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 import asyncio import time +import __py_include -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 +from homekit.config import config +from homekit.media import MediaNodeServer, ESP32CameraRecordStorage, CameraRecorder +from homekit.camera import CameraType, esp32 +from homekit.util import Addr +from homekit import http # Implements HTTP API for a camera. @@ -65,7 +66,7 @@ class ESP32CameraNodeServer(MediaNodeServer): if __name__ == '__main__': - config.load('camera_node') + config.load_app('camera_node') recorder_kwargs = {} camera_type = CameraType(config['camera']['type']) diff --git a/src/electricity_calc.py b/bin/electricity_calc.py index c3cb233..cff2327 100755 --- a/src/electricity_calc.py +++ b/bin/electricity_calc.py @@ -3,12 +3,12 @@ import logging import os import sys import inspect -import zoneinfo +import __py_include -from home.config import config # do not remove this import! +from homekit.config import config # do not remove this import! from datetime import datetime, timedelta from logging import Logger -from home.database import InverterDatabase +from homekit.database import InverterDatabase from argparse import ArgumentParser, ArgumentError from typing import Optional diff --git a/src/esp32_capture.py b/bin/esp32_capture.py index 4a9ce10..839114d 100755 --- a/src/esp32_capture.py +++ b/bin/esp32_capture.py @@ -2,10 +2,11 @@ import asyncio import logging import os.path +import __py_include from argparse import ArgumentParser -from home.camera.esp32 import WebClient -from home.util import parse_addr, Addr +from homekit.camera.esp32 import WebClient +from homekit.util import Addr from apscheduler.schedulers.asyncio import AsyncIOScheduler from datetime import datetime from typing import Optional @@ -50,7 +51,7 @@ if __name__ == '__main__': loop = asyncio.get_event_loop() - ESP32Capture(parse_addr(arg.addr), arg.interval, arg.output_directory) + ESP32Capture(Addr.fromstring(arg.addr), arg.interval, arg.output_directory) try: loop.run_forever() except KeyboardInterrupt: diff --git a/src/esp32cam_capture_diff_node.py b/bin/esp32cam_capture_diff_node.py index 4363e9e..d664c6d 100755 --- a/src/esp32cam_capture_diff_node.py +++ b/bin/esp32cam_capture_diff_node.py @@ -3,11 +3,12 @@ import asyncio import logging import os.path import tempfile -import home.telegram.aio as telegram +import __py_include +import homekit.telegram.aio as telegram -from home.config import config -from home.camera.esp32 import WebClient -from home.util import parse_addr, send_datagram, stringify +from homekit.config import config +from homekit.camera.esp32 import WebClient +from homekit.util import Addr, send_datagram, stringify from apscheduler.schedulers.asyncio import AsyncIOScheduler from typing import Optional @@ -34,11 +35,11 @@ async def pyssim(fn1: str, fn2: str) -> float: class ESP32CamCaptureDiffNode: def __init__(self): - self.client = WebClient(parse_addr(config['esp32cam_web_addr'])) + self.client = WebClient(Addr.fromstring(config['esp32cam_web_addr'])) self.directory = tempfile.gettempdir() self.nextpic = 1 self.first = True - self.server_addr = parse_addr(config['node']['server_addr']) + self.server_addr = Addr.fromstring(config['node']['server_addr']) self.scheduler = AsyncIOScheduler() self.scheduler.add_job(self.capture, 'interval', seconds=config['node']['interval']) @@ -76,7 +77,7 @@ class ESP32CamCaptureDiffNode: if __name__ == '__main__': - config.load('esp32cam_capture_diff_node') + config.load_app('esp32cam_capture_diff_node') loop = asyncio.get_event_loop() ESP32CamCaptureDiffNode() diff --git a/bin/gpiorelayd.py b/bin/gpiorelayd.py new file mode 100755 index 0000000..89ba78e --- /dev/null +++ b/bin/gpiorelayd.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +import logging +import os +import sys +import __py_include + +from argparse import ArgumentParser +from homekit.util import Addr +from homekit.config import config +from homekit.relay.sunxi_h3_server import RelayServer + +logger = logging.getLogger(__name__) + + +if __name__ == '__main__': + if os.getegid() != 0: + sys.exit('Must be run as root.') + + parser = ArgumentParser() + parser.add_argument('--pin', type=str, required=True, + help='name of GPIO pin of Allwinner H3 sunxi board') + parser.add_argument('--listen', type=str, required=True, + help='address to listen to, in ip:port format') + + arg = config.load_app(no_config=True, parser=parser) + listen = Addr.fromstring(arg.listen) + + try: + RelayServer(pinname=arg.pin, addr=listen).run() + except KeyboardInterrupt: + logger.info('Exiting...') diff --git a/src/inverter_bot.py b/bin/inverter_bot.py index fd5acf3..032f513 100755 --- a/src/inverter_bot.py +++ b/bin/inverter_bot.py @@ -4,32 +4,40 @@ import re import datetime import json import itertools +import sys +import asyncio +import __py_include from inverterd import Format, InverterError from html import escape from typing import Optional, Tuple, Union -from home.util import chunks -from home.config import config -from home.telegram import bot -from home.inverter import ( +from homekit.util import chunks +from homekit.config import config, AppConfigUnit +from homekit.telegram import bot +from homekit.telegram.config import TelegramBotConfig, TelegramUserListType +from homekit.inverter import ( wrapper_instance as inverter, beautify_table, InverterMonitor, ) -from home.inverter.types import ( +from homekit.inverter.types import ( ChargingEvent, ACPresentEvent, BatteryState, ACMode, OutputSourcePriority ) -from home.database.inverter_time_formats import * -from home.api.types import BotType -from home.api import WebAPIClient +from homekit.database.inverter_time_formats import FormatDate +from homekit.api import WebApiClient from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton -monitor: Optional[InverterMonitor] = None + +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 = { @@ -42,9 +50,56 @@ flags_map = { 'alarm_on_on_primary_source_interrupt': 'ALRM', 'fault_code_record': 'FTCR', } - logger = logging.getLogger(__name__) -config.load('inverter_bot') + + +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( @@ -293,8 +348,11 @@ def monitor_charging(event: ChargingEvent, **kwargs) -> None: key = f'chrg_evt_{key}' if is_util: key = f'util_{key}' - bot.notify_all( - lambda lang: bot.lang.get(key, lang, *args) + + asyncio.ensure_future( + bot.notify_all( + lambda lang: bot.lang.get(key, lang, *args) + ) ) @@ -309,9 +367,11 @@ def monitor_battery(state: BatteryState, v: float, load_watts: int) -> None: 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) + asyncio.ensure_future( + 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) + ) ) @@ -321,14 +381,18 @@ def monitor_util(event: ACPresentEvent): else: key = 'disconnected' key = f'util_{key}' - bot.notify_all( - lambda lang: bot.lang.get(key, lang) + asyncio.ensure_future( + 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) + asyncio.ensure_future( + bot.notify_all( + lambda lang: bot.lang.get('error_message', lang, error) + ) ) @@ -338,35 +402,37 @@ def osp_change_cb(new_osp: OutputSourcePriority, 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), + asyncio.ensure_future( + 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: +async def full_status(ctx: bot.Context) -> None: status = inverter.exec('get-status', format=Format.TABLE) - ctx.reply(beautify_table(status)) + await ctx.reply(beautify_table(status)) @bot.handler(command='config') -def full_rated(ctx: bot.Context) -> None: +async def full_rated(ctx: bot.Context) -> None: rated = inverter.exec('get-rated', format=Format.TABLE) - ctx.reply(beautify_table(rated)) + await ctx.reply(beautify_table(rated)) @bot.handler(command='errors') -def full_errors(ctx: bot.Context) -> None: +async def full_errors(ctx: bot.Context) -> None: errors = inverter.exec('get-errors', format=Format.TABLE) - ctx.reply(beautify_table(errors)) + await ctx.reply(beautify_table(errors)) @bot.handler(command='flags') -def flags_handler(ctx: bot.Context) -> None: +async 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) + await ctx.reply(text, markup=markup) def build_flags_keyboard(flags: dict, ctx: bot.Context) -> Tuple[str, InlineKeyboardMarkup]: @@ -423,11 +489,11 @@ class SettingsConversation(bot.conversation): 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): + async 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) + return await self.reply(ctx, self.START, ctx.lang('settings_msg'), buttons, + with_cancel=True) @bot.convinput(START, messages={ 'settings_osp': OSP, @@ -436,16 +502,16 @@ class SettingsConversation(bot.conversation): '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): + async 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) + async def osp_enter(self, ctx: bot.Context): + return await 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): + async 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()}'): @@ -458,25 +524,28 @@ class SettingsConversation(bot.conversation): # 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,) + await asyncio.gather( + # 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) + async def acpreset_enter(self, ctx: bot.Context): + return await 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): + async def acpreset_input(self, ctx: bot.Context): if monitor.active_current is not None: raise RuntimeError('generator charging program is active') @@ -493,85 +562,88 @@ class SettingsConversation(bot.conversation): # 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,) + await asyncio.gather( + # 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): + async 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) + return await 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): + async 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) + return await self.invoke(self.BAT_THRESHOLDS_2, ctx) @bot.conventer(BAT_THRESHOLDS_2) - def thresholds2_enter(self, ctx: bot.Context): + async 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) + return await 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): + async 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()) + await 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): + async 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) + return await 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): + async 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()) + await 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) + async def cutoff_enter(self, ctx: bot.Context): + return await 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): + async 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()) + await ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', + markup=bot.IgnoreMarkup()) else: raise ValueError('invalid voltage') @@ -606,38 +678,38 @@ class ConsumptionConversation(bot.conversation): 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) + async def start_enter(self, ctx: bot.Context): + return await 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): + async 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) + async def total_enter(self, ctx: bot.Context): + return await 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) + async def grid_enter(self, ctx: bot.Context): + return await 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) + async def _render_interval_btns(self, ctx: bot.Context, state): + return await 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) + async def total_input(self, ctx: bot.Context): + return await 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) + async def grid_input(self, ctx: bot.Context): + return await self._render_interval_results(ctx, self.GRID) - def _render_interval_results(self, ctx: bot.Context, state): + async def _render_interval_results(self, ctx: bot.Context, state): # if ctx.text == ctx.lang('to_select_interval'): # TODO # pass @@ -661,41 +733,43 @@ class ConsumptionConversation(bot.conversation): # [InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait')] # ]) - message = ctx.reply(ctx.lang('consumption_request_sent'), - markup=bot.IgnoreMarkup()) + message = await ctx.reply(ctx.lang('consumption_request_sent'), + markup=bot.IgnoreMarkup()) - api = WebAPIClient(timeout=60) + 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()) + await bot.delete_message(message.chat_id, message.message_id) + await 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) + await asyncio.gather( + 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: +async 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) + await ctx.reply(msg) @bot.handler(command='monsetcur') -def monsetcur_handler(ctx: bot.Context) -> None: - ctx.reply('not implemented yet') +async def monsetcur_handler(ctx: bot.Context) -> None: + await ctx.reply('not implemented yet') @bot.callbackhandler -def button_callback(ctx: bot.Context) -> None: +async def button_callback(ctx: bot.Context) -> None: query = ctx.callback_query if query.data.startswith('flag_'): @@ -708,7 +782,7 @@ def button_callback(ctx: bot.Context) -> None: json_key = k break if not found: - query.answer(ctx.lang('flags_invalid')) + await query.answer(ctx.lang('flags_invalid')) return flags = inverter.exec('get-flags')['data'] @@ -719,32 +793,31 @@ def button_callback(ctx: bot.Context) -> None: 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')) + await 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) + await query.edit_message_text(text, reply_markup=markup) else: - query.answer(ctx.lang('unexpected_callback_data')) + await query.answer(ctx.lang('unexpected_callback_data')) @bot.exceptionhandler -def exception_handler(e: Exception, ctx: bot.Context) -> Optional[bool]: +async 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()) + await ctx.reply(err, markup=bot.IgnoreMarkup()) return True @bot.handler(message='status') -def status_handler(ctx: bot.Context) -> None: +async def status_handler(ctx: bot.Context) -> None: gs = inverter.exec('get-status')['data'] rated = inverter.exec('get-rated')['data'] @@ -788,11 +861,11 @@ def status_handler(ctx: bot.Context) -> None: html += f'\n<b>{ctx.lang("priority")}</b>: {rated["output_source_priority"]}' # send response - ctx.reply(html) + await ctx.reply(html) @bot.handler(message='generation') -def generation_handler(ctx: bot.Context) -> None: +async def generation_handler(ctx: bot.Context) -> None: today = datetime.date.today() yday = today - datetime.timedelta(days=1) yday2 = today - datetime.timedelta(days=2) @@ -822,7 +895,7 @@ def generation_handler(ctx: bot.Context) -> None: html += f'\n<b>{ctx.lang("yday2")}:</b> %s Wh' % (gen_yday2['wh']) # send response - ctx.reply(html) + await ctx.reply(html) @bot.defaultreplymarkup @@ -863,28 +936,26 @@ class InverterStore(bot.BotDatabase): self.commit() -if __name__ == '__main__': - inverter.init(host=config['inverter']['ip'], port=config['inverter']['port']) +inverter.init(host=config['inverter']['ip'], port=config['inverter']['port']) - bot.set_database(InverterStore()) - bot.enable_logging(BotType.INVERTER) +bot.set_database(InverterStore()) - bot.add_conversation(SettingsConversation(enable_back=True)) - bot.add_conversation(ConsumptionConversation(enable_back=True)) +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) +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()) +setacmode(getacmode()) - if not config.get('monitor.disabled'): - logging.info('starting monitor') - monitor.start() +if not config.get('monitor.disabled'): + logging.info('starting monitor') + monitor.start() - bot.run() +bot.run() - monitor.stop() +monitor.stop() diff --git a/bin/inverter_mqtt_util.py b/bin/inverter_mqtt_util.py new file mode 100755 index 0000000..6003c62 --- /dev/null +++ b/bin/inverter_mqtt_util.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +import __py_include + +from argparse import ArgumentParser +from homekit.config import config +from homekit.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(config.app_config['poll_freq']) + module_kwargs['generation_poll_freq'] = int(config.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/bin/inverterd_emulator.py index 8c4d0bd..371d955 100755 --- a/src/inverterd_emulator.py +++ b/bin/inverterd_emulator.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 import logging +import __py_include -from home.inverter.emulator import InverterEmulator +from homekit.inverter.emulator import InverterEmulator if __name__ == '__main__': diff --git a/bin/ipcam_capture.py b/bin/ipcam_capture.py new file mode 100755 index 0000000..5de14af --- /dev/null +++ b/bin/ipcam_capture.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +import __py_include +import sys +import os +import subprocess +import asyncio +import signal + +from typing import TextIO +from argparse import ArgumentParser +from socket import gethostname +from asyncio.streams import StreamReader +from homekit.config import LinuxBoardsConfig, config as homekit_config +from homekit.camera import IpcamConfig, CaptureType +from homekit.camera.util import get_hls_directory, get_hls_channel_name, get_recordings_path + +ipcam_config = IpcamConfig() +lbc_config = LinuxBoardsConfig() +channels = (1, 2) +tasks = [] +restart_delay = 3 +lock = asyncio.Lock() +worker_type: CaptureType + + +async def read_output(stream: StreamReader, + thread_name: str, + output: TextIO): + try: + while True: + line = await stream.readline() + if not line: + break + print(f"[{thread_name}] {line.decode().strip()}", file=output) + + except asyncio.LimitOverrunError: + print(f"[{thread_name}] Output limit exceeded.", file=output) + + except Exception as e: + print(f"[{thread_name}] Error occurred while reading output: {e}", file=sys.stderr) + + +async def run_ffmpeg(cam: int, channel: int): + prefix = get_hls_channel_name(cam, channel) + + if homekit_config.app_config.logging_is_verbose(): + debug_args = ['-v', '-info'] + else: + debug_args = ['-nostats', '-loglevel', 'error'] + + protocol = 'tcp' if ipcam_config.should_use_tcp_for_rtsp(cam) else 'udp' + user, pw = ipcam_config.get_rtsp_creds() + ip = ipcam_config.get_camera_ip(cam) + path = ipcam_config.get_camera_type(cam).get_channel_url(channel) + ext = ipcam_config.get_camera_container(cam) + ffmpeg_command = ['ffmpeg', *debug_args, + '-rtsp_transport', protocol, + '-i', f'rtsp://{user}:{pw}@{ip}:554{path}', + '-c', 'copy',] + + if worker_type == CaptureType.HLS: + ffmpeg_command.extend(['-bufsize', '1835k', + '-pix_fmt', 'yuv420p', + '-flags', '-global_header', + '-hls_time', '2', + '-hls_list_size', '3', + '-hls_flags', 'delete_segments', + os.path.join(get_hls_directory(cam, channel), 'live.m3u8')]) + + elif worker_type == CaptureType.RECORD: + ffmpeg_command.extend(['-f', 'segment', + '-strftime', '1', + '-segment_time', '00:10:00', + '-segment_atclocktime', '1', + os.path.join(get_recordings_path(cam), f'record_%Y-%m-%d-%H.%M.%S.{ext.value}')]) + + else: + raise ValueError(f'invalid worker type: {worker_type}') + + while True: + try: + process = await asyncio.create_subprocess_exec( + *ffmpeg_command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + stdout_task = asyncio.create_task(read_output(process.stdout, prefix, sys.stdout)) + stderr_task = asyncio.create_task(read_output(process.stderr, prefix, sys.stderr)) + + await asyncio.gather(stdout_task, stderr_task) + + # check the return code of the process + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ffmpeg_command) + + except (FileNotFoundError, PermissionError, subprocess.CalledProcessError) as e: + # an error occurred, print the error message + error_message = f"Error occurred in {prefix}: {e}" + print(error_message, file=sys.stderr) + + # sleep for 5 seconds before restarting the process + await asyncio.sleep(restart_delay) + + +async def run(): + kwargs = {} + if worker_type == CaptureType.RECORD: + kwargs['filter_by_server'] = gethostname() + for cam in ipcam_config.get_all_cam_names(**kwargs): + for channel in channels: + task = asyncio.create_task(run_ffmpeg(cam, channel)) + tasks.append(task) + + try: + await asyncio.gather(*tasks) + except KeyboardInterrupt: + print('KeyboardInterrupt: stopping processes...', file=sys.stderr) + for task in tasks: + task.cancel() + + # wait for subprocesses to terminate + await asyncio.gather(*tasks, return_exceptions=True) + + # send termination signal to all subprocesses + for task in tasks: + process = task.get_stack() + if process: + process.send_signal(signal.SIGTERM) + + +if __name__ == '__main__': + capture_types = [t.value for t in CaptureType] + parser = ArgumentParser() + parser.add_argument('type', type=str, metavar='CAPTURE_TYPE', choices=tuple(capture_types), + help='capture type (variants: '+', '.join(capture_types)+')') + + arg = homekit_config.load_app(no_config=True, parser=parser) + worker_type = CaptureType(arg['type']) + + asyncio.run(run()) diff --git a/tools/ipcam_motion_worker.sh b/bin/ipcam_motion_worker.sh index c5f711d..603a407 100755 --- a/tools/ipcam_motion_worker.sh +++ b/bin/ipcam_motion_worker.sh @@ -5,7 +5,7 @@ set -e DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &>/dev/null && pwd )" PROGNAME="$0" -. "$DIR/lib.bash" +. "$DIR/../include/bash/include.bash" curl_opts="-s --connect-timeout 10 --retry 5 --max-time 180 --retry-delay 0 --retry-max-time 180" allow_multiple= diff --git a/src/ipcam_server.py b/bin/ipcam_server.py index 2c4915d..71d5ea1 100755 --- a/src/ipcam_server.py +++ b/bin/ipcam_server.py @@ -1,58 +1,52 @@ #!/usr/bin/env python3 import logging import os -import re import asyncio import time import shutil -import home.telegram.aio as telegram +import __py_include +import homekit.telegram.aio as telegram + +from socket import gethostname +from argparse import ArgumentParser 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 homekit.config import config as homekit_config, LinuxBoardsConfig +from homekit.util import Addr +from homekit import http +from homekit.database.sqlite import SQLiteBase +from homekit.camera import util as camutil, IpcamConfig +from homekit.camera.types import ( + TimeFilterType, + TelegramLinkType, + VideoContainerType +) +from homekit.camera.util import ( + get_recordings_path, + get_motion_path, + is_valid_recording_name, + datetime_from_filename +) -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_config = IpcamConfig() +lbc_config = LinuxBoardsConfig() # ipcam database # -------------- -class IPCamServerDatabase(SQLiteBase): +class IpcamServerDatabase(SQLiteBase): SCHEMA = 4 - def __init__(self): - super().__init__() + def __init__(self, path=None): + super().__init__(path=path) def schema_init(self, version: int) -> None: cursor = self.cursor() @@ -64,7 +58,7 @@ class IPCamServerDatabase(SQLiteBase): fix_time INTEGER NOT NULL, motion_time INTEGER NOT NULL )""") - for cam in config['camera'].keys(): + for cam in ipcam_config.get_all_cam_names_for_this_server(): self.add_camera(cam) if version < 2: @@ -132,7 +126,7 @@ class IPCamServerDatabase(SQLiteBase): # ipcam web api # ------------- -class IPCamWebServer(http.HTTPServer): +class IpcamWebServer(http.HTTPServer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -143,16 +137,16 @@ class IPCamWebServer(http.HTTPServer): 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.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() @@ -170,7 +164,7 @@ class IPCamWebServer(http.HTTPServer): files = get_recordings_files(camera, filter, limit) if files: - time = filename_to_datetime(files[len(files)-1]['name']) + time = datetime_from_filename(files[len(files)-1]['name']) db.set_timestamp(camera, TimeFilterType.MOTION_START, time) return self.ok({'files': files}) @@ -185,7 +179,7 @@ class IPCamWebServer(http.HTTPServer): if files: times_by_cam = {} for file in files: - time = filename_to_datetime(file['name']) + time = datetime_from_filename(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(): @@ -197,14 +191,14 @@ class IPCamWebServer(http.HTTPServer): cam = int(req.match_info['name']) file = req.match_info['file'] - fullpath = os.path.join(config['camera'][cam]['recordings_path'], file) + fullpath = os.path.join(get_recordings_path(cam), 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']) + return self.ok(ipcam_config.get_all_cam_names_for_this_server()) async def submit_motion(self, req: http.Request): data = await req.post() @@ -213,7 +207,7 @@ class IPCamWebServer(http.HTTPServer): timecodes = data['timecodes'] filename = data['filename'] - time = filename_to_datetime(filename) + time = datetime_from_filename(filename) try: if timecodes != '': @@ -236,27 +230,10 @@ class IPCamWebServer(http.HTTPServer): message = data['message'] db.add_motion_failure(camera, filename, message) - db.set_timestamp(camera, TimeFilterType.MOTION, filename_to_datetime(filename)) + db.set_timestamp(camera, TimeFilterType.MOTION, datetime_from_filename(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() @@ -277,26 +254,26 @@ class IPCamWebServer(http.HTTPServer): 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') + # 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' + assert cam in ipcam_config.get_all_cam_names_for_this_server(), 'invalid camera' values.append(cam) values.append(TimeFilterType(req.match_info['type'])) @@ -304,7 +281,7 @@ class IPCamWebServer(http.HTTPServer): if need_time: time = req.query['time'] if time.startswith('record_'): - time = filename_to_datetime(time) + time = datetime_from_filename(time) elif time.isnumeric(): time = int(time) else: @@ -317,32 +294,24 @@ class IPCamWebServer(http.HTTPServer): # other global stuff # ------------------ -def open_database(): +def open_database(database_path: str): global db - db = IPCamServerDatabase() + db = IpcamServerDatabase(database_path) # update cams list in database, if needed - cams = db.get_all_timestamps().keys() - for cam in config['camera']: - if cam not in cams: + stored_cams = db.get_all_timestamps().keys() + for cam in ipcam_config.get_all_cam_names_for_this_server(): + if cam not in stored_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() + cams = [cam] if cam is not None else ipcam_config.get_all_cam_names_for_this_server() files = [] for cam in cams: if time_filter_type: @@ -359,7 +328,7 @@ def get_recordings_files(cam: Optional[int] = None, '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] + if is_valid_recording_name(file) and from_time < datetime_from_filename(file) <= to_time] cam_files.sort(key=lambda file: file['name']) if cam_files: @@ -379,7 +348,7 @@ def get_recordings_files(cam: Optional[int] = None, async def process_fragments(camera: int, filename: str, fragments: List[Tuple[int, int]]) -> None: - time = filename_to_datetime(filename) + time = datetime_from_filename(filename) rec_dir = get_recordings_path(camera) motion_dir = get_motion_path(camera) @@ -389,8 +358,8 @@ async def process_fragments(camera: int, for fragment in fragments: start, end = fragment - start -= config['motion']['padding'] - end += config['motion']['padding'] + start -= ipcam_config['motion_padding'] + end += ipcam_config['motion_padding'] if start < 0: start = 0 @@ -405,14 +374,14 @@ async def process_fragments(camera: int, start_pos=start, duration=duration) - if fragments and 'telegram' in config['motion'] and config['motion']['telegram']: + if fragments and ipcam_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) + dt_file = datetime_from_filename(filename) fmt = '%H:%M:%S' text = f'Camera: <b>{camera}</b>\n' @@ -420,8 +389,8 @@ async def motion_notify_tg(camera: int, text += _tg_links(TelegramLinkType.ORIGINAL_FILE, camera, filename) for start, end in fragments: - start -= config['motion']['padding'] - end += config['motion']['padding'] + start -= ipcam_config['motion_padding'] + end += ipcam_config['motion_padding'] if start < 0: start = 0 @@ -443,7 +412,7 @@ 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']: + for link_name, link_template in ipcam_config[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) @@ -459,7 +428,7 @@ async def fix_job() -> None: try: fix_job_running = True - for cam in config['camera'].keys(): + for cam in ipcam_config.get_all_cam_names_for_this_server(): files = get_recordings_files(cam, TimeFilterType.FIX) if not files: logger.debug(f'fix_job: no files for camera {cam}') @@ -470,7 +439,7 @@ async def fix_job() -> None: 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']) + timestamp = datetime_from_filename(file['name']) if timestamp: db.set_timestamp(cam, TimeFilterType.FIX, timestamp) @@ -479,21 +448,9 @@ async def fix_job() -> None: 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) + dt1 = datetime_from_filename(i1) + dt2 = datetime_from_filename(i2) if dt1 < dt2: return -1 @@ -513,18 +470,19 @@ async def cleanup_job() -> None: cleanup_job_running = True gb = float(1 << 30) - for storage in config['storages']: + disk_number = 0 + for storage in lbc_config.get_board_disks(gethostname()): + disk_number += 1 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}") + if free_gb < ipcam_config['cleanup_min_gb']: cleaned = 0 files = [] - for cam in storage['cams']: - for _dir in (config['camera'][cam]['recordings_path'], config['camera'][cam]['motion_path']): + for cam in ipcam_config.get_all_cam_names_for_this_server(filter_by_disk=disk_number): + for _dir in (get_recordings_path(cam), get_motion_path(cam)): 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 = list(filter(lambda path: os.path.isfile(path) and path.endswith(tuple([f'.{t.value}' for t in VideoContainerType])), files)) files.sort(key=cmp_to_key(compare)) for file in files: @@ -534,7 +492,7 @@ async def cleanup_job() -> None: cleaned += size except OSError as e: logger.exception(e) - if (free + cleaned) // gb >= config['cleanup_min_gb']: + if (free + cleaned) // gb >= ipcam_config['cleanup_min_gb']: break else: logger.error(f"cleanup_job: {storage['mountpoint']} not found") @@ -547,8 +505,8 @@ 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 +db: Optional[IpcamServerDatabase] = None +server: Optional[IpcamWebServer] = None logger = logging.getLogger(__name__) @@ -556,18 +514,25 @@ logger = logging.getLogger(__name__) # -------------------- if __name__ == '__main__': - config.load('ipcam_server') + parser = ArgumentParser() + parser.add_argument('--listen', type=str, required=True) + parser.add_argument('--database-path', type=str, required=True) + arg = homekit_config.load_app(no_config=True, parser=parser) - open_database() + open_database(arg.database_path) 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) + if ipcam_config['fix_enabled']: + scheduler.add_job(fix_job, 'interval', + seconds=ipcam_config['fix_interval'], + misfire_grace_time=None) + + scheduler.add_job(cleanup_job, 'interval', + seconds=ipcam_config['cleanup_interval'], + misfire_grace_time=None) scheduler.start() except KeyError: pass @@ -575,5 +540,5 @@ if __name__ == '__main__': asyncio.ensure_future(fix_job()) asyncio.ensure_future(cleanup_job()) - server = IPCamWebServer(config.get_addr('server.listen')) + server = IpcamWebServer(Addr.fromstring(arg.listen)) server.run() diff --git a/bin/lugovaya_pump_mqtt_bot.py b/bin/lugovaya_pump_mqtt_bot.py new file mode 100755 index 0000000..85402d1 --- /dev/null +++ b/bin/lugovaya_pump_mqtt_bot.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +import datetime +import __py_include + +from enum import Enum +from typing import Optional +from telegram import ReplyKeyboardMarkup, User + +from homekit.config import config, AppConfigUnit +from homekit.telegram import bot +from homekit.telegram.config import TelegramBotConfig +from homekit.telegram._botutil import user_any_name +from homekit.mqtt import MqttNode, MqttPayload, MqttNodesConfig, MqttWrapper +from homekit.mqtt.module.relay import MqttRelayState, MqttRelayModule +from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload + + +class LugovayaPumpMqttBotConfig(TelegramBotConfig, AppConfigUnit): + NAME = 'lugovaya_pump_mqtt_bot' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + **TelegramBotConfig.schema(), + 'relay_node_id': { + 'type': 'string', + 'required': True + }, + } + + @staticmethod + def custom_validator(data): + relay_node_names = MqttNodesConfig().get_nodes(filters=('relay',), only_names=True) + if data['relay_node_id'] not in relay_node_names: + raise ValueError('unknown relay node "%s"' % (data['relay_node_id'],)) + + +config.load_app(LugovayaPumpMqttBotConfig) + +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: MqttWrapper +relay_state = MqttRelayState() +relay_module: MqttRelayModule + + +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) + + +async 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) + + await bot.notify_all(text_getter, exclude=(user.id,)) + + +@bot.handler(message='enable') +async def enable_handler(ctx: bot.Context) -> None: + relay_module.switchpower(True) + await ctx.reply(ctx.lang('done')) + await notify(ctx.user, UserAction.ON) + + +@bot.handler(message='disable') +async def disable_handler(ctx: bot.Context) -> None: + relay_module.switchpower(False) + await ctx.reply(ctx.lang('done')) + await notify(ctx.user, UserAction.OFF) + + +@bot.handler(message='status') +async 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) + await ctx.reply(label) + + +async def start(ctx: bot.Context) -> None: + if ctx.user_id in config['bot']['users']: + await ctx.reply(ctx.lang('start_message')) + else: + buttons = [ + [ctx.lang('send_access_request')] + ] + await 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) + + +node_data = MqttNodesConfig().get_node(config.app_config['relay_node_id']) + +mqtt = MqttWrapper(client_id='lugovaya_pump_mqtt_bot') +mqtt_node = MqttNode(node_id=config.app_config['relay_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_module = mqtt_node.load_module('relay', **module_kwargs) +# mqtt_node.add_payload_callback(on_mqtt_message) +mqtt.add_node(mqtt_node) + +mqtt.connect_and_loop(loop_forever=False) + +bot.run(start_handler=start) + +mqtt.disconnect() diff --git a/bin/mqtt_node_util.py b/bin/mqtt_node_util.py new file mode 100755 index 0000000..cf451fd --- /dev/null +++ b/bin/mqtt_node_util.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +import os.path +import __py_include + +from time import sleep +from typing import Optional +from argparse import ArgumentParser, ArgumentError + +from homekit.config import config +from homekit.mqtt import MqttNode, MqttWrapper, get_mqtt_modules +from homekit.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('--legacy-relay', action='store_true') + 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: + kwargs = {} + if m == 'relay' and arg.legacy_relay: + kwargs['legacy_topics'] = True + module_instance = mqtt_node.load_module(m, **kwargs) + 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/bin/openwrt_log_analyzer.py b/bin/openwrt_log_analyzer.py new file mode 100755 index 0000000..5b14a2f --- /dev/null +++ b/bin/openwrt_log_analyzer.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import __py_include +import homekit.telegram as telegram + +from homekit.telegram.config import TelegramChatsConfig +from homekit.util import validate_mac_address +from typing import Optional +from homekit.config import config, AppConfigUnit +from homekit.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/bin/openwrt_logger.py index 3b19de2..ec67542 100755 --- a/src/openwrt_logger.py +++ b/bin/openwrt_logger.py @@ -1,30 +1,21 @@ #!/usr/bin/env python3 import os +import __py_include from datetime import datetime -from typing import Tuple, List +from typing import Tuple, List, Optional from argparse import ArgumentParser -from home.config import config -from home.database import SimpleState -from home.api import WebAPIClient +from homekit.config import config, AppConfigUnit +from homekit.database import SimpleState +from homekit.api import WebApiClient -f""" -This script is supposed to be run by cron every 5 minutes or so. -It looks for new lines in log file and sends them to remote server. -OpenWRT must have remote logging enabled (UDP; IP of host this script is launched on; port 514) - -/etc/rsyslog.conf contains following (assuming 192.168.1.1 is the router IP): - -$ModLoad imudp -$UDPServerRun 514 -:fromhost-ip, isequal, "192.168.1.1" /var/log/openwrt.log -& ~ - -Also comment out the following line: -$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat - -""" +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]: @@ -46,11 +37,10 @@ if __name__ == '__main__': parser.add_argument('--access-point', type=int, required=True, help='access point number') - arg = config.load('openwrt_logger', parser=parser) - - state = SimpleState(file=config['simple_state']['file'].replace('{ap}', str(arg.access_point)), - default={'seek': 0, 'size': 0}) + 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 @@ -79,5 +69,5 @@ if __name__ == '__main__': except ValueError: lines.append((0, line)) - api = WebAPIClient() + api = WebApiClient() api.log_openwrt(lines, arg.access_point) diff --git a/src/pio_build.py b/bin/pio_build.py index 1916e5e..539df44 100644 --- a/src/pio_build.py +++ b/bin/pio_build.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import __py_include if __name__ == '__main__': print('TODO')
\ No newline at end of file diff --git a/src/pio_ini.py b/bin/pio_ini.py index 19dd707..ee85732 100755 --- a/src/pio_ini.py +++ b/bin/pio_ini.py @@ -2,22 +2,24 @@ import os import yaml import re +import __py_include -from pprint import pprint from argparse import ArgumentParser, ArgumentError -from home.pio import get_products, platformio_ini -from home.pio.exceptions import ProductConfigNotFoundError +from homekit.pio import get_products, platformio_ini +from homekit.pio.exceptions import ProductConfigNotFoundError +from homekit.config import CONFIG_DIRECTORIES 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: + path = None + for directory in CONFIG_DIRECTORIES: + config_path = os.path.join(directory, 'pio', f'{product}.yaml') + if os.path.exists(config_path) and os.path.isfile(config_path): + path = config_path + break + if not path: + raise ProductConfigNotFoundError(f'pio/{product}.yaml not found') + with open(path, 'r') as f: return yaml.safe_load(f) @@ -54,12 +56,17 @@ def bsd_parser(product_config: dict, 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' - parser.add_argument(f'--{define_name}', required=True, **arg_kwargs) + if 'required' not in arg_kwargs: + arg_kwargs['required'] = True + parser.add_argument(f'--{define_name}', **arg_kwargs) bsd_walk(product_config, f) @@ -76,6 +83,10 @@ def bsd_get(product_config: dict, enums.append(f'CONFIG_{define_name}') defines[f'CONFIG_{define_name}'] = f'HOMEKIT_{attr_value.upper()}' return + if kwargs['type'] == 'bool': + if attr_value is True: + defines[f'CONFIG_{define_name}'] = True + return defines[f'CONFIG_{define_name}'] = str(attr_value) bsd_walk(product_config, f) return defines, enums @@ -98,7 +109,7 @@ if __name__ == '__main__': product_config = get_config(product) - # then everythingm else + # then everything else parser = ArgumentParser(parents=[product_parser]) parser.add_argument('--target', type=str, required=True, choices=product_config['targets'], help='PIO build target') @@ -115,6 +126,7 @@ if __name__ == '__main__': 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, diff --git a/src/polaris_kettle_bot.py b/bin/polaris_kettle_bot.py index 088707d..05c2aae 100755 --- a/src/polaris_kettle_bot.py +++ b/bin/polaris_kettle_bot.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import __py_include import logging import locale import queue @@ -8,11 +9,10 @@ import time import threading import paho.mqtt.client as mqtt -from home.telegram import bot -from home.api.types import BotType -from home.mqtt import MqttBase -from home.config import config -from home.util import chunks +from homekit.telegram import bot +from homekit.mqtt import Mqtt +from homekit.config import config +from homekit.util import chunks from syncleo import ( Kettle, PowerType, @@ -41,7 +41,7 @@ from telegram.ext import ( ) logger = logging.getLogger(__name__) -config.load('polaris_kettle_bot') +config.load_app('polaris_kettle_bot') primary_choices = (70, 80, 90, 100) all_choices = range( @@ -204,7 +204,7 @@ class KettleInfo: class KettleController(threading.Thread, - MqttBase, + Mqtt, DeviceListener, IncomingMessageListener, KettleInfoListener, @@ -224,7 +224,7 @@ class KettleController(threading.Thread, def __init__(self): # basic setup - MqttBase.__init__(self, clean_session=False) + Mqtt.__init__(self, clean_session=False) threading.Thread.__init__(self) self._logger = logging.getLogger(self.__class__.__name__) @@ -737,9 +737,6 @@ if __name__ == '__main__': 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 diff --git a/src/polaris_kettle_util.py b/bin/polaris_kettle_util.py index 81326dd..4db0ed4 100755 --- a/src/polaris_kettle_util.py +++ b/bin/polaris_kettle_util.py @@ -4,12 +4,13 @@ import logging import sys import paho.mqtt.client as mqtt +import __py_include from typing import Optional from argparse import ArgumentParser from queue import SimpleQueue -from home.mqtt import MqttBase -from home.config import config +from homekit.mqtt import Mqtt +from homekit.config import config from syncleo import ( Kettle, PowerType, @@ -21,7 +22,7 @@ logger = logging.getLogger(__name__) control_tasks = SimpleQueue() -class MqttServer(MqttBase): +class MqttServer(Mqtt): def __init__(self): super().__init__(clean_session=False) @@ -75,7 +76,7 @@ def main(): parser.add_argument('-t', '--temperature', dest='temp', type=int, default=tempmax, choices=range(tempmin, tempmax+tempstep, tempstep)) - arg = config.load('polaris_kettle_util', use_cli=True, parser=parser) + arg = config.load_app('polaris_kettle_util', use_cli=True, parser=parser) if arg.mode == 'mqtt': server = MqttServer() diff --git a/bin/pump_bot.py b/bin/pump_bot.py new file mode 100755 index 0000000..e00e844 --- /dev/null +++ b/bin/pump_bot.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +import __py_include +import sys +import asyncio + +from enum import Enum +from typing import Optional, Union +from telegram import ReplyKeyboardMarkup, User +from time import time +from datetime import datetime + +from homekit.config import config, is_development_mode, AppConfigUnit +from homekit.telegram import bot +from homekit.telegram.config import TelegramBotConfig, TelegramUserListType +from homekit.telegram._botutil import user_any_name +from homekit.relay.sunxi_h3_client import RelayClient +from homekit.mqtt import MqttNode, MqttWrapper, MqttPayload, MqttNodesConfig, MqttModule +from homekit.mqtt.module.relay import MqttPowerStatusPayload, MqttRelayModule +from homekit.mqtt.module.temphum import MqttTemphumDataPayload +from homekit.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 PumpBotUserListType(TelegramUserListType): + SILENT = 'silent_users' + + +class PumpBotConfig(AppConfigUnit, TelegramBotConfig): + NAME = 'pump_bot' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + **super(TelegramBotConfig).schema(), + PumpBotUserListType.SILENT: TelegramBotConfig._userlist_schema(), + 'watering_relay_node': {'type': 'string'}, + 'pump_relay_addr': cls._addr_schema() + } + + @staticmethod + def custom_validator(data): + relay_node_names = mqtt_nodes_config.get_nodes(filters=('relay',), only_names=True) + if data['watering_relay_node'] not in relay_node_names: + raise ValueError(f'unknown relay node "{data["watering_relay_node"]}"') + + +config.load_app(PumpBotConfig) + +mqtt: MqttWrapper +mqtt_node: MqttNode +mqtt_relay_module: Union[MqttRelayModule, MqttModule] + +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.app_config['pump_relay_addr'].host, + port=config.app_config['pump_relay_addr'].port) + relay.connect() + return relay + + +async def on(ctx: bot.Context, silent=False) -> None: + get_relay().on() + futures = [ctx.reply(ctx.lang('done'))] + if not silent: + futures.append(notify(ctx.user, UserAction.ON)) + await asyncio.gather(*futures) + + +async def off(ctx: bot.Context, silent=False) -> None: + get_relay().off() + futures = [ctx.reply(ctx.lang('done'))] + if not silent: + futures.append(notify(ctx.user, UserAction.OFF)) + await asyncio.gather(*futures) + + +async def watering_on(ctx: bot.Context) -> None: + mqtt_relay_module.switchpower(True) + await asyncio.gather( + ctx.reply(ctx.lang('sent')), + notify(ctx.user, UserAction.WATERING_ON) + ) + + +async def watering_off(ctx: bot.Context) -> None: + mqtt_relay_module.switchpower(False) + await asyncio.gather( + ctx.reply(ctx.lang('sent')), + notify(ctx.user, UserAction.WATERING_OFF) + ) + + +async 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) + + await bot.notify_all(text_getter, exclude=(user.id,)) + + +@bot.handler(message='enable') +async def enable_handler(ctx: bot.Context) -> None: + await on(ctx) + + +@bot.handler(message='enable_silently') +async def enable_s_handler(ctx: bot.Context) -> None: + await on(ctx, True) + + +@bot.handler(message='disable') +async def disable_handler(ctx: bot.Context) -> None: + await off(ctx) + + +@bot.handler(message='start_watering') +async def start_watering(ctx: bot.Context) -> None: + await watering_on(ctx) + + +@bot.handler(message='stop_watering') +async def stop_watering(ctx: bot.Context) -> None: + await watering_off(ctx) + + +@bot.handler(message='disable_silently') +async def disable_s_handler(ctx: bot.Context) -> None: + await off(ctx, True) + + +@bot.handler(message='status') +async def status(ctx: bot.Context) -> None: + await 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') +async 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>' + await ctx.reply(buf) + + +@bot.defaultreplymarkup +def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: + buttons = [] + if ctx.user_id in config.app_config.get_user_ids(PumpBotUserListType.SILENT): + 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 + + +mqtt = MqttWrapper(client_id='pump_bot') +mqtt_node = MqttNode(node_id=config.app_config['watering_relay_node']) +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.run() + +try: + mqtt.disconnect() +except: + pass diff --git a/src/pump_mqtt_bot.py b/bin/pump_mqtt_bot.py index d3b6de4..aea1451 100755 --- a/src/pump_mqtt_bot.py +++ b/bin/pump_mqtt_bot.py @@ -1,20 +1,20 @@ #!/usr/bin/env python3 import datetime +import __py_include from enum import Enum from typing import Optional from telegram import ReplyKeyboardMarkup, User -from home.config import config -from home.telegram import bot -from home.telegram._botutil import user_any_name -from home.mqtt.esp import MqttEspDevice -from home.mqtt import MqttRelay, MqttRelayState -from home.mqtt.payload import MqttPayload -from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload +from homekit.config import config +from homekit.telegram import bot +from homekit.telegram._botutil import user_any_name +from homekit.mqtt import MqttNode, MqttPayload +from homekit.mqtt.module.relay import MqttRelayState +from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload -config.load('pump_mqtt_bot') +config.load_app('pump_mqtt_bot') bot.initialize() bot.lang.ru( @@ -70,7 +70,7 @@ bot.lang.en( ) -mqtt_relay: Optional[MqttRelay] = None +mqtt: Optional[MqttNode] = None relay_state = MqttRelayState() @@ -99,14 +99,14 @@ def notify(user: User, action: UserAction) -> None: @bot.handler(message='enable') def enable_handler(ctx: bot.Context) -> None: - mqtt_relay.set_power(config['mqtt']['home_id'], True) + 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_relay.set_power(config['mqtt']['home_id'], False) + mqtt.set_power(config['mqtt']['home_id'], False) ctx.reply(ctx.lang('done')) notify(ctx.user, UserAction.OFF) @@ -157,13 +157,12 @@ def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: if __name__ == '__main__': - mqtt_relay = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'], - secret=config['mqtt']['home_secret'])) - mqtt_relay.set_message_callback(on_mqtt_message) - mqtt_relay.configure_tls() - mqtt_relay.connect_and_loop(loop_forever=False) + 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_relay.disconnect() + mqtt.disconnect() diff --git a/bin/relay_mqtt_bot.py b/bin/relay_mqtt_bot.py new file mode 100755 index 0000000..3ad0a9b --- /dev/null +++ b/bin/relay_mqtt_bot.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +import sys +import __py_include + +from enum import Enum +from typing import Optional, Union +from telegram import ReplyKeyboardMarkup +from functools import partial + +from homekit.config import config, AppConfigUnit, Translation +from homekit.telegram import bot +from homekit.telegram.config import TelegramBotConfig +from homekit.mqtt import MqttPayload, MqttNode, MqttWrapper, MqttModule, MqttNodesConfig +from homekit.mqtt.module.relay import MqttRelayModule, MqttRelayState +from homekit.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/bin/relay_mqtt_http_proxy.py b/bin/relay_mqtt_http_proxy.py new file mode 100755 index 0000000..23938e1 --- /dev/null +++ b/bin/relay_mqtt_http_proxy.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +import logging +import __py_include + +from homekit import http +from homekit.config import config, AppConfigUnit +from homekit.mqtt import MqttPayload, MqttWrapper, MqttNode, MqttModule, MqttNodesConfig +from homekit.mqtt.module.relay import MqttRelayState, MqttRelayModule, MqttPowerStatusPayload +from homekit.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/bin/sensors_bot.py index dc081b0..43932e1 100755 --- a/src/sensors_bot.py +++ b/bin/sensors_bot.py @@ -4,6 +4,7 @@ import socket import logging import re import gc +import __py_include from io import BytesIO from typing import Optional @@ -14,16 +15,15 @@ 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, +from homekit.config import config +from homekit.telegram import bot +from homekit.util import chunks, MySimpleSocketClient +from homekit.api import WebApiClient +from homekit.api.types import ( TemperatureSensorLocation ) -config.load('sensors_bot') +config.load_app('sensors_bot') bot.initialize() bot.lang.ru( @@ -111,7 +111,7 @@ def callback_handler(ctx: bot.Context) -> None: sensor = TemperatureSensorLocation[match.group(1).upper()] hours = int(match.group(2)) - api = WebAPIClient(timeout=20) + api = WebApiClient(timeout=20) data = api.get_sensors_data(sensor, hours) title = ctx.lang(sensor.name.lower()) + ' (' + ctx.lang('n_hrs', hours) + ')' @@ -175,7 +175,4 @@ def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: if __name__ == '__main__': - if 'api' in config: - bot.enable_logging(BotType.SENSORS) - bot.run() diff --git a/src/sound_bot.py b/bin/sound_bot.py index 186337a..fa22ba7 100755 --- a/src/sound_bot.py +++ b/bin/sound_bot.py @@ -2,32 +2,33 @@ import logging import os import tempfile +import __py_include from enum import Enum from datetime import datetime, timedelta from html import escape from typing import Optional, List, Dict, Tuple -from home.config import config -from home.api import WebAPIClient -from home.api.types import SoundSensorLocation, BotType -from home.api.errors import ApiResponseError -from home.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient -from home.soundsensor import SoundSensorServerGuardClient -from home.util import parse_addr, chunks, filesize_fmt +from homekit.config import config +from homekit.api import WebApiClient +from homekit.api.types import SoundSensorLocation +from homekit.api.errors import ApiResponseError +from homekit.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient +from homekit.soundsensor import SoundSensorServerGuardClient +from homekit.util import Addr, chunks, filesize_fmt -from home.telegram import bot +from homekit.telegram import bot from telegram.error import TelegramError from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton, User from PIL import Image -config.load('sound_bot') +config.load_app('sound_bot') nodes = {} for nodename, nodecfg in config['nodes'].items(): - nodes[nodename] = parse_addr(nodecfg['addr']) + nodes[nodename] = Addr.fromstring(nodecfg['addr']) bot.initialize() bot.lang.ru( @@ -142,13 +143,13 @@ cam_client_links: Dict[str, CameraNodeClient] = {} def node_client(node: str) -> SoundNodeClient: if node not in node_client_links: - node_client_links[node] = SoundNodeClient(parse_addr(config['nodes'][node]['addr'])) + 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(parse_addr(config['cameras'][cam]['addr'])) + cam_client_links[cam] = CameraNodeClient(Addr.fromstring(config['cameras'][cam]['addr'])) return cam_client_links[cam] @@ -188,7 +189,7 @@ def manual_recording_allowed(user_id: int) -> bool: def guard_client() -> SoundSensorServerGuardClient: - return SoundSensorServerGuardClient(parse_addr(config['bot']['guard_server'])) + return SoundSensorServerGuardClient(Addr.fromstring(config['bot']['guard_server'])) # message renderers @@ -734,7 +735,7 @@ def sound_sensors_last_24h(ctx: bot.Context): ctx.answer() - cl = WebAPIClient() + cl = WebApiClient() data = cl.get_sound_sensor_hits(location=SoundSensorLocation[node.upper()], after=datetime.now() - timedelta(hours=24)) @@ -757,7 +758,7 @@ def sound_sensors_last_anything(ctx: bot.Context): ctx.answer() - cl = WebAPIClient() + cl = WebApiClient() data = cl.get_last_sound_sensor_hits(location=SoundSensorLocation[node.upper()], last=20) @@ -883,7 +884,5 @@ if __name__ == '__main__': 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/bin/sound_node.py index 9d53362..90e6997 100755 --- a/src/sound_node.py +++ b/bin/sound_node.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 import os +import __py_include 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 +from homekit.config import config +from homekit.audio import amixer +from homekit.media import MediaNodeServer, SoundRecordStorage, SoundRecorder +from homekit import http # This script must be run as root as it runs arecord. @@ -77,7 +78,7 @@ if __name__ == '__main__': if not os.getegid() == 0: raise RuntimeError("Must be run as root.") - config.load('sound_node') + config.load_app('sound_node') storage = SoundRecordStorage(config['node']['storage']) diff --git a/src/sound_sensor_node.py b/bin/sound_sensor_node.py index d9a8999..39c3905 100755 --- a/src/sound_sensor_node.py +++ b/bin/sound_sensor_node.py @@ -2,10 +2,11 @@ import logging import os import sys +import __py_include -from home.config import config -from home.util import parse_addr -from home.soundsensor import SoundSensorNode +from homekit.config import config +from homekit.util import Addr +from homekit.soundsensor import SoundSensorNode logger = logging.getLogger(__name__) @@ -14,14 +15,14 @@ if __name__ == '__main__': if not os.getegid() == 0: sys.exit('Must be run as root.') - config.load('sound_sensor_node') + 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 = parse_addr(config['node']['server_addr']) + server_addr = Addr.fromstring(config['node']['server_addr']) else: server_addr = None diff --git a/src/sound_sensor_server.py b/bin/sound_sensor_server.py index aa62608..fd7ff5a 100755 --- a/src/sound_sensor_server.py +++ b/bin/sound_sensor_server.py @@ -1,16 +1,17 @@ #!/usr/bin/env python3 import logging import threading +import __py_include from time import sleep from typing import Optional, List, Dict, Tuple from functools import partial -from home.config import config -from home.util import parse_addr -from home.api import WebAPIClient, RequestParams -from home.api.types import SoundSensorLocation -from home.soundsensor import SoundSensorServer, SoundSensorHitHandler -from home.media import MediaNodeType, SoundRecordClient, CameraRecordClient, RecordClient +from homekit.config import config +from homekit.util import Addr +from homekit.api import WebApiClient, RequestParams +from homekit.api.types import SoundSensorLocation +from homekit.soundsensor import SoundSensorServer, SoundSensorHitHandler +from homekit.media import MediaNodeType, SoundRecordClient, CameraRecordClient, RecordClient interrupted = False logger = logging.getLogger(__name__) @@ -120,7 +121,7 @@ def hits_sender(): sleep(5) -api: Optional[WebAPIClient] = None +api: Optional[WebApiClient] = None hc: Optional[HitCounter] = None record_clients: Dict[MediaNodeType, RecordClient] = {} @@ -159,10 +160,10 @@ def api_error_handler(exc, name, req: RequestParams): if __name__ == '__main__': - config.load('sound_sensor_server') + config.load_app('sound_sensor_server') hc = HitCounter() - api = WebAPIClient(timeout=(10, 60)) + api = WebApiClient(timeout=(10, 60)) api.enable_async(error_handler=api_error_handler) t = threading.Thread(target=hits_sender) @@ -172,12 +173,12 @@ if __name__ == '__main__': sound_nodes = {} if 'sound_nodes' in config: for nodename, nodecfg in config['sound_nodes'].items(): - sound_nodes[nodename] = parse_addr(nodecfg['addr']) + 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] = parse_addr(nodecfg['addr']) + camera_nodes[nodename] = Addr.fromstring(nodecfg['addr']) if sound_nodes: record_clients[MediaNodeType.SOUND] = SoundRecordClient(sound_nodes, diff --git a/src/ssh_tunnels_config_util.py b/bin/ssh_tunnels_config_util.py index 3b2ba6e..d08a4f4 100755 --- a/src/ssh_tunnels_config_util.py +++ b/bin/ssh_tunnels_config_util.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 - -from home.config import config +import __py_include +from homekit.config import config if __name__ == '__main__': - config.load('ssh_tunnels_config_util') + config.load_app('ssh_tunnels_config_util') network_prefix = config['network'] hostnames = [] - for k, v in config.items(): + for k, v in config.app_config.get().items(): if type(v) is not dict: continue hostnames.append(k) diff --git a/bin/temphum_mqtt_node.py b/bin/temphum_mqtt_node.py new file mode 100755 index 0000000..9ea436d --- /dev/null +++ b/bin/temphum_mqtt_node.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import asyncio +import json +import logging +import __py_include + +from typing import Optional + +from homekit.config import config +from homekit.temphum import SensorType, BaseSensor +from homekit.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/sensors_mqtt_receiver.py b/bin/temphum_mqtt_receiver.py index a377ddd..d0a378e 100755 --- a/src/sensors_mqtt_receiver.py +++ b/bin/temphum_mqtt_receiver.py @@ -1,22 +1,13 @@ #!/usr/bin/env python3 import paho.mqtt.client as mqtt import re +import __py_include -from home.mqtt import MqttBase -from home.config import config -from home.mqtt.payload.sensors import Temperature -from home.api.types import TemperatureSensorLocation -from home.database import SensorsDatabase +from homekit.config import config +from homekit.mqtt import MqttWrapper, MqttNode -def get_sensor_type(sensor: str) -> TemperatureSensorLocation: - for item in TemperatureSensorLocation: - if sensor == item.name.lower(): - return item - raise ValueError(f'unexpected sensor value: {sensor}') - - -class MqttServer(MqttBase): +class MqttServer(Mqtt): def __init__(self): super().__init__(clean_session=False) self.database = SensorsDatabase() @@ -47,7 +38,11 @@ class MqttServer(MqttBase): if __name__ == '__main__': - config.load('sensors_mqtt_receiver') + 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) - server = MqttServer() - server.connect_and_loop() + mqtt.connect_and_loop()
\ No newline at end of file diff --git a/src/temphum_nodes_util.py b/bin/temphum_nodes_util.py index c700ca8..aa46494 100755 --- a/src/temphum_nodes_util.py +++ b/bin/temphum_nodes_util.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -from home.mqtt.temphum import MqttTempHumNodes +import __py_include + +from homekit.mqtt.temphum import MqttTempHumNodes if __name__ == '__main__': max_name_len = 0 diff --git a/src/temphum_smbus_util.py b/bin/temphum_smbus_util.py index 0f90835..1cfaa84 100755 --- a/src/temphum_smbus_util.py +++ b/bin/temphum_smbus_util.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 +import __py_include + from argparse import ArgumentParser -from home.temphum import SensorType, create_sensor +from homekit.temphum import SensorType +from homekit.temphum.i2c import create_sensor if __name__ == '__main__': diff --git a/src/temphumd.py b/bin/temphumd.py index f4d1fca..9ea436d 100755 --- a/src/temphumd.py +++ b/bin/temphumd.py @@ -2,14 +2,16 @@ import asyncio import json import logging +import __py_include from typing import Optional -from home.config import config -from home.temphum import SensorType, create_sensor, TempHumSensor +from homekit.config import config +from homekit.temphum import SensorType, BaseSensor +from homekit.temphum.i2c import create_sensor logger = logging.getLogger(__name__) -sensor: Optional[TempHumSensor] = None +sensor: Optional[BaseSensor] = None lock = asyncio.Lock() delay = 0.01 @@ -62,7 +64,7 @@ async def run_server(host, port): if __name__ == '__main__': - config.load() + config.load_app() if 'measure_delay' in config['sensor']: delay = float(config['sensor']['measure_delay']) diff --git a/src/web_api.py b/bin/web_api.py index 0ddc6bd..d221838 100755 --- a/src/web_api.py +++ b/bin/web_api.py @@ -2,16 +2,17 @@ import asyncio import json import os +import __py_include 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 +from homekit import http +from homekit.config import config, is_development_mode +from homekit.database import BotsDatabase, SensorsDatabase, InverterDatabase +from homekit.database.inverter_time_formats import * +from homekit.api.types import TemperatureSensorLocation, SoundSensorLocation +from homekit.media import SoundRecordStorage def strptime_auto(s: str) -> datetime: @@ -41,7 +42,6 @@ class WebAPIServer(http.HTTPServer): 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) @@ -125,30 +125,6 @@ class WebAPIServer(http.HTTPServer): 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() @@ -231,7 +207,7 @@ if __name__ == '__main__': _app_name = 'web_api' if is_development_mode(): _app_name += '_dev' - config.load(_app_name) + config.load_app(_app_name) loop = asyncio.get_event_loop() diff --git a/doc/openwrt_logger.md b/doc/openwrt_logger.md new file mode 100644 index 0000000..1179c8b --- /dev/null +++ b/doc/openwrt_logger.md @@ -0,0 +1,28 @@ +# openwrt_logger.py + +This script is supposed to be run by cron every 5 minutes or so. +It looks for new lines in log file and sends them to remote server. + +OpenWRT must have remote logging enabled (UDP; IP of host this script is launched on; port 514) + +`/etc/rsyslog.conf` contains following (assuming `192.168.1.1` is the router IP): + +``` +$ModLoad imudp +$UDPServerRun 514 +:fromhost-ip, isequal, "192.168.1.1" /var/log/openwrt.log +& ~ +``` + +Also comment out the following line: +``` +$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat +``` + +Cron line example: +``` +* * * * * /home/user/homekit/src/openwrt_logger.py --access-point 1 --file /var/wrtlogfs/openwrt-5.log >/dev/null +``` + +`/var/wrtlogfs` is recommended to be tmpfs, to avoid writes on mmc card, in case +you use arm sbcs as I do.
\ No newline at end of file diff --git a/tools/lib.bash b/include/bash/include.bash index 1d73ab2..1d73ab2 100644 --- a/tools/lib.bash +++ b/include/bash/include.bash diff --git a/platformio/common/include/homekit/logging.h b/include/pio/include/homekit/logging.h index 559ca33..559ca33 100644 --- a/platformio/common/include/homekit/logging.h +++ b/include/pio/include/homekit/logging.h diff --git a/platformio/common/include/homekit/macros.h b/include/pio/include/homekit/macros.h index 7d3ad83..7d3ad83 100644 --- a/platformio/common/include/homekit/macros.h +++ b/include/pio/include/homekit/macros.h diff --git a/platformio/common/include/homekit/stopwatch.h b/include/pio/include/homekit/stopwatch.h index bac2fcc..bac2fcc 100644 --- a/platformio/common/include/homekit/stopwatch.h +++ b/include/pio/include/homekit/stopwatch.h diff --git a/platformio/common/include/homekit/util.h b/include/pio/include/homekit/util.h index e0780d8..e0780d8 100644 --- a/platformio/common/include/homekit/util.h +++ b/include/pio/include/homekit/util.h diff --git a/platformio/common/libs/config/homekit/config.cpp b/include/pio/libs/config/homekit/config.cpp index 5bafcad..5bafcad 100644 --- a/platformio/common/libs/config/homekit/config.cpp +++ b/include/pio/libs/config/homekit/config.cpp diff --git a/platformio/common/libs/config/homekit/config.h b/include/pio/libs/config/homekit/config.h index 28f01fb..28f01fb 100644 --- a/platformio/common/libs/config/homekit/config.h +++ b/include/pio/libs/config/homekit/config.h diff --git a/platformio/common/libs/config/library.json b/include/pio/libs/config/library.json index 720d093..720d093 100644 --- a/platformio/common/libs/config/library.json +++ b/include/pio/libs/config/library.json diff --git a/platformio/common/libs/http_server/homekit/http_server.cpp b/include/pio/libs/http_server/homekit/http_server.cpp index ea81f5b..ea81f5b 100644 --- a/platformio/common/libs/http_server/homekit/http_server.cpp +++ b/include/pio/libs/http_server/homekit/http_server.cpp diff --git a/platformio/common/libs/http_server/homekit/http_server.h b/include/pio/libs/http_server/homekit/http_server.h index 8725a88..8725a88 100644 --- a/platformio/common/libs/http_server/homekit/http_server.h +++ b/include/pio/libs/http_server/homekit/http_server.h diff --git a/platformio/common/libs/http_server/library.json b/include/pio/libs/http_server/library.json index ee2d369..ee2d369 100644 --- a/platformio/common/libs/http_server/library.json +++ b/include/pio/libs/http_server/library.json diff --git a/platformio/common/libs/led/homekit/led.cpp b/include/pio/libs/led/homekit/led.cpp index ffefb04..ffefb04 100644 --- a/platformio/common/libs/led/homekit/led.cpp +++ b/include/pio/libs/led/homekit/led.cpp diff --git a/platformio/common/libs/led/homekit/led.h b/include/pio/libs/led/homekit/led.h index 775d2eb..775d2eb 100644 --- a/platformio/common/libs/led/homekit/led.h +++ b/include/pio/libs/led/homekit/led.h diff --git a/platformio/common/libs/led/library.json b/include/pio/libs/led/library.json index 6785d42..6785d42 100644 --- a/platformio/common/libs/led/library.json +++ b/include/pio/libs/led/library.json diff --git a/platformio/common/libs/main/homekit/main.cpp b/include/pio/libs/main/homekit/main.cpp index fd08925..816c764 100644 --- a/platformio/common/libs/main/homekit/main.cpp +++ b/include/pio/libs/main/homekit/main.cpp @@ -6,7 +6,12 @@ namespace homekit::main { +#ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY enum WorkingMode working_mode = WorkingMode::NORMAL; +#endif +#endif + static const uint16_t recovery_boot_detection_ms = 2000; static const uint8_t recovery_boot_delay_ms = 100; @@ -22,8 +27,10 @@ static StopWatch blinkStopWatch; #endif #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY static DNSServer* dnsServer = nullptr; #endif +#endif static void onWifiConnected(const WiFiEventStationModeGotIP& event); static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event); @@ -45,6 +52,7 @@ static void wifiConnect() { } #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY static void wifiHotspot() { led::mcu_led->on(); @@ -71,13 +79,16 @@ static void waitForRecoveryPress() { } } #endif +#endif void setup() { WiFi.disconnect(); +#ifndef CONFIG_NO_RECOVERY #ifndef CONFIG_TARGET_ESP01 homekit::main::waitForRecoveryPress(); #endif +#endif #ifdef DEBUG Serial.begin(115200); @@ -95,6 +106,7 @@ void setup() { } #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY switch (working_mode) { case WorkingMode::RECOVERY: wifiHotspot(); @@ -102,19 +114,24 @@ void setup() { case WorkingMode::NORMAL: #endif +#endif wifiConnectHandler = WiFi.onStationModeGotIP(onWifiConnected); wifiDisconnectHandler = WiFi.onStationModeDisconnected(onWifiDisconnected); wifiConnect(); +#ifndef CONFIG_NO_RECOVERY #ifndef CONFIG_TARGET_ESP01 break; } #endif +#endif } void loop(LoopConfig* config) { +#ifndef CONFIG_NO_RECOVERY #ifndef CONFIG_TARGET_ESP01 if (working_mode == WorkingMode::NORMAL) { #endif +#endif if (wifi_state == WiFiConnectionState::WAITING) { PRINT("."); led::mcu_led->blink(2, 50); @@ -166,6 +183,7 @@ void loop(LoopConfig* config) { } #endif } +#ifndef CONFIG_NO_RECOVERY #ifndef CONFIG_TARGET_ESP01 } else { if (dnsServer != nullptr) @@ -176,6 +194,7 @@ void loop(LoopConfig* config) { httpServer->loop(); } #endif +#endif } static void onWifiConnected(const WiFiEventStationModeGotIP& event) { @@ -191,4 +210,4 @@ static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event) { wifiTimer.once(2, wifiConnect); } -}
\ No newline at end of file +} diff --git a/platformio/common/libs/main/homekit/main.h b/include/pio/libs/main/homekit/main.h index a503dd0..78a0695 100644 --- a/platformio/common/libs/main/homekit/main.h +++ b/include/pio/libs/main/homekit/main.h @@ -10,8 +10,10 @@ #include <homekit/config.h> #include <homekit/logging.h> #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY #include <homekit/http_server.h> #endif +#endif #include <homekit/wifi.h> #include <homekit/mqtt/mqtt.h> @@ -20,6 +22,7 @@ namespace homekit::main { #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY enum class WorkingMode { RECOVERY, // AP mode, http server with configuration NORMAL, // MQTT client @@ -27,6 +30,7 @@ enum class WorkingMode { extern enum WorkingMode working_mode; #endif +#endif enum class WiFiConnectionState { WAITING = 0, diff --git a/include/pio/libs/main/library.json b/include/pio/libs/main/library.json new file mode 100644 index 0000000..c5586d8 --- /dev/null +++ b/include/pio/libs/main/library.json @@ -0,0 +1,12 @@ +{ + "name": "homekit_main", + "version": "1.0.11", + "build": { + "flags": "-I../../include" + }, + "dependencies": { + "homekit_mqtt_module_ota": "file://../../include/pio/libs/mqtt_module_ota", + "homekit_mqtt_module_diagnostics": "file://../../include/pio/libs/mqtt_module_diagnostics" + } +} + diff --git a/platformio/common/libs/mqtt/homekit/mqtt/module.cpp b/include/pio/libs/mqtt/homekit/mqtt/module.cpp index e78ff12..0ac7637 100644 --- a/platformio/common/libs/mqtt/homekit/mqtt/module.cpp +++ b/include/pio/libs/mqtt/homekit/mqtt/module.cpp @@ -21,6 +21,6 @@ void MqttModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, con void MqttModule::handleOnPublish(uint16_t packetId) {} -void MqttModule::handleOnDisconnect(espMqttClientTypes::DisconnectReason reason) {} +void MqttModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) {} } diff --git a/platformio/common/libs/mqtt/homekit/mqtt/module.h b/include/pio/libs/mqtt/homekit/mqtt/module.h index e4a01f8..0a328f3 100644 --- a/platformio/common/libs/mqtt/homekit/mqtt/module.h +++ b/include/pio/libs/mqtt/homekit/mqtt/module.h @@ -28,20 +28,25 @@ public: , receiveOnPublish(_receiveOnPublish) , receiveOnDisconnect(_receiveOnDisconnect) {} - virtual void init(Mqtt& mqtt) = 0; virtual void tick(Mqtt& mqtt) = 0; + virtual void onConnect(Mqtt& mqtt) = 0; + virtual void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason); + virtual void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total); virtual void handleOnPublish(uint16_t packetId); - virtual void handleOnDisconnect(espMqttClientTypes::DisconnectReason reason); inline void setInitialized() { initialized = true; } - inline short getTickInterval() { - return tickInterval; - } + inline void unsetInitialized() { + initialized = false; + } + + inline short getTickInterval() const { + return tickInterval; + } friend class Mqtt; }; diff --git a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp b/include/pio/libs/mqtt/homekit/mqtt/mqtt.cpp index cb2cea7..aa769a5 100644 --- a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp +++ b/include/pio/libs/mqtt/homekit/mqtt/mqtt.cpp @@ -34,7 +34,7 @@ Mqtt::Mqtt() { for (auto* module: modules) { if (!module->initialized) { - module->init(*this); + module->onConnect(*this); module->setInitialized(); } } @@ -50,18 +50,13 @@ Mqtt::Mqtt() { #endif for (auto* module: modules) { - if (module->receiveOnDisconnect) { - module->handleOnDisconnect(reason); - } + module->onDisconnect(*this, reason); + module->unsetInitialized(); } -// if (ota.readyToRestart) { -// restartTimer.once(1, restart); -// } else { - reconnectTimer.once(2, [&]() { - reconnect(); - }); -// } + reconnectTimer.once(2, [&]() { + reconnect(); + }); }); client.onSubscribe([&](uint16_t packetId, const SubscribeReturncode* returncodes, size_t len) { @@ -79,7 +74,7 @@ Mqtt::Mqtt() { PRINTF("mqtt: message received, topic=%s, qos=%d, dup=%d, retain=%d, len=%ul, index=%ul, total=%ul\n", topic, properties.qos, (int)properties.dup, (int)properties.retain, len, index, total); - const char *ptr = topic + nodeId.length() + 10; + const char *ptr = topic + nodeId.length() + 4; String relevantTopic(ptr); auto it = moduleSubscriptions.find(relevantTopic); @@ -87,7 +82,7 @@ Mqtt::Mqtt() { auto module = it->second; module->handlePayload(*this, relevantTopic, properties.packetId, payload, len, index, total); } else { - PRINTF("error: module subscription for topic %s not found\n", topic); + PRINTF("error: module subscription for topic %s not found\n", relevantTopic.c_str()); } }); @@ -130,8 +125,8 @@ void Mqtt::disconnect() { void Mqtt::loop() { client.loop(); for (auto& module: modules) { - if (module->getTickInterval() != 0) - module->tick(*this); + if (module->getTickInterval() != 0) + module->tick(*this); } } @@ -154,14 +149,14 @@ uint16_t Mqtt::subscribe(const String& topic, uint8_t qos) { void Mqtt::addModule(MqttModule* module) { modules.emplace_back(module); if (connected) { - module->init(*this); + module->onConnect(*this); module->setInitialized(); } } void Mqtt::subscribeModule(String& topic, MqttModule* module, uint8_t qos) { moduleSubscriptions[topic] = module; - subscribe(topic, qos); + subscribe(topic, qos); } } diff --git a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.h b/include/pio/libs/mqtt/homekit/mqtt/mqtt.h index 9e0c2be..9e0c2be 100644 --- a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.h +++ b/include/pio/libs/mqtt/homekit/mqtt/mqtt.h diff --git a/platformio/common/libs/mqtt/homekit/mqtt/payload.h b/include/pio/libs/mqtt/homekit/mqtt/payload.h index 3e0fe0c..3e0fe0c 100644 --- a/platformio/common/libs/mqtt/homekit/mqtt/payload.h +++ b/include/pio/libs/mqtt/homekit/mqtt/payload.h diff --git a/platformio/common/libs/mqtt/library.json b/include/pio/libs/mqtt/library.json index d1ad420..f3f2504 100644 --- a/platformio/common/libs/mqtt/library.json +++ b/include/pio/libs/mqtt/library.json @@ -1,6 +1,6 @@ { "name": "homekit_mqtt", - "version": "1.0.9", + "version": "1.0.11", "build": { "flags": "-I../../include" } diff --git a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp b/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp index d36a7e9..e0f797e 100644 --- a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp +++ b/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp @@ -7,12 +7,21 @@ namespace homekit::mqtt { static const char TOPIC_DIAGNOSTICS[] = "diag"; static const char TOPIC_INITIAL_DIAGNOSTICS[] = "d1ag"; -void MqttDiagnosticsModule::init(Mqtt& mqtt) {} +void MqttDiagnosticsModule::onConnect(Mqtt &mqtt) { + sendDiagnostics(mqtt); +} + +void MqttDiagnosticsModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) { + initialSent = false; +} void MqttDiagnosticsModule::tick(Mqtt& mqtt) { if (!tickElapsed()) return; + sendDiagnostics(mqtt); +} +void MqttDiagnosticsModule::sendDiagnostics(Mqtt& mqtt) { auto cfg = config::read(); if (!initialSent) { diff --git a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h b/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h index 055c179..bb7a81a 100644 --- a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h +++ b/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h @@ -32,12 +32,15 @@ class MqttDiagnosticsModule: public MqttModule { private: bool initialSent; + void sendDiagnostics(Mqtt& mqtt); + public: MqttDiagnosticsModule() : MqttModule(30) , initialSent(false) {} - void init(Mqtt& mqtt) override; + void onConnect(Mqtt& mqtt) override; + void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; void tick(Mqtt& mqtt) override; }; diff --git a/platformio/common/libs/mqtt_module_diagnostics/library.json b/include/pio/libs/mqtt_module_diagnostics/library.json index 8df306d..70acb79 100644 --- a/platformio/common/libs/mqtt_module_diagnostics/library.json +++ b/include/pio/libs/mqtt_module_diagnostics/library.json @@ -1,10 +1,10 @@ { "name": "homekit_mqtt_module_diagnostics", - "version": "1.0.1", + "version": "1.0.3", "build": { "flags": "-I../../include" }, "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt" + "homekit_mqtt": "file://../../include/pio/libs/mqtt" } } diff --git a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp b/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp index 2f5f814..4e976cd 100644 --- a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp +++ b/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp @@ -12,7 +12,7 @@ using homekit::led::mcu_led; static const char TOPIC_OTA[] = "ota"; static const char TOPIC_OTA_RESPONSE[] = "otares"; -void MqttOtaModule::init(Mqtt& mqtt) { +void MqttOtaModule::onConnect(Mqtt& mqtt) { String topic(TOPIC_OTA); mqtt.subscribeModule(topic, this); } @@ -140,17 +140,15 @@ uint16_t MqttOtaModule::sendResponse(Mqtt& mqtt, OtaResult status, uint8_t error return mqtt.publish(TOPIC_OTA_RESPONSE, reinterpret_cast<uint8_t*>(&resp), sizeof(resp)); } -void MqttOtaModule::handleOnDisconnect(espMqttClientTypes::DisconnectReason reason) { - if (ota.started()) { +void MqttOtaModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) { + if (ota.readyToRestart) { + restartTimer.once(1, restart); + } else if (ota.started()) { PRINTLN("mqtt: update was in progress, canceling.."); ota.clean(); Update.end(); Update.clearError(); } - - if (ota.readyToRestart) { - restartTimer.once(1, restart); - } } void MqttOtaModule::handleOnPublish(uint16_t packetId) { diff --git a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h b/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.h index 53613c3..df4f7ce 100644 --- a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h +++ b/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.h @@ -57,11 +57,14 @@ private: public: MqttOtaModule() : MqttModule(0, true, true) {} - void init(Mqtt& mqtt) override; + void onConnect(Mqtt& mqtt) override; + void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; + void tick(Mqtt& mqtt) override; + void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; void handleOnPublish(uint16_t packetId) override; - void handleOnDisconnect(espMqttClientTypes::DisconnectReason reason) override; + inline bool isReadyToRestart() const { return ota.readyToRestart; } diff --git a/include/pio/libs/mqtt_module_ota/library.json b/include/pio/libs/mqtt_module_ota/library.json new file mode 100644 index 0000000..1577fed --- /dev/null +++ b/include/pio/libs/mqtt_module_ota/library.json @@ -0,0 +1,11 @@ +{ + "name": "homekit_mqtt_module_ota", + "version": "1.0.6", + "build": { + "flags": "-I../../include" + }, + "dependencies": { + "homekit_led": "file://../../include/pio/libs/led", + "homekit_mqtt": "file://../../include/pio/libs/mqtt" + } +} diff --git a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp b/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp index ab40727..90c57f9 100644 --- a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp +++ b/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp @@ -5,19 +5,28 @@ namespace homekit::mqtt { static const char TOPIC_RELAY_SWITCH[] = "relay/switch"; +static const char TOPIC_RELAY_STATUS[] = "relay/status"; -void MqttRelayModule::init(Mqtt &mqtt) { - String topic(TOPIC_RELAY_SWITCH); - mqtt.subscribeModule(topic, this, 1); +void MqttRelayModule::onConnect(Mqtt &mqtt) { + String topic(TOPIC_RELAY_SWITCH); + mqtt.subscribeModule(topic, this, 1); +} + +void MqttRelayModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) { +#ifdef CONFIG_RELAY_OFF_ON_DISCONNECT + if (relay::state()) { + relay::off(); + } +#endif } void MqttRelayModule::tick(homekit::mqtt::Mqtt& mqtt) {} void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) { - if (topic != TOPIC_RELAY_SWITCH) - return; + if (topic != TOPIC_RELAY_SWITCH) + return; - if (length != sizeof(MqttRelaySwitchPayload)) { + if (length != sizeof(MqttRelaySwitchPayload)) { PRINTF("error: size of payload (%ul) does not match expected (%ul)\n", length, sizeof(MqttRelaySwitchPayload)); return; @@ -29,6 +38,8 @@ void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId return; } + MqttRelayStatusPayload resp{}; + if (pd->state == 1) { PRINTLN("mqtt: turning relay on"); relay::on(); @@ -38,6 +49,10 @@ void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId } else { PRINTLN("error: unexpected state value"); } + + resp.opened = relay::state(); + mqtt.publish(TOPIC_RELAY_STATUS, reinterpret_cast<uint8_t*>(&resp), sizeof(resp)); } } + diff --git a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h b/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.h index 6420de1..e245527 100644 --- a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h +++ b/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.h @@ -10,14 +10,20 @@ struct MqttRelaySwitchPayload { uint8_t state; } __attribute__((packed)); +struct MqttRelayStatusPayload { + uint8_t opened; +} __attribute__((packed)); + class MqttRelayModule : public MqttModule { public: MqttRelayModule() : MqttModule(0) {} - void init(Mqtt& mqtt) override; + void onConnect(Mqtt& mqtt) override; + void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; void tick(Mqtt& mqtt) override; - void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; + void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; }; } #endif //HOMEKIT_LIB_MQTT_MODULE_RELAY_H + diff --git a/include/pio/libs/mqtt_module_relay/library.json b/include/pio/libs/mqtt_module_relay/library.json new file mode 100644 index 0000000..18a510c --- /dev/null +++ b/include/pio/libs/mqtt_module_relay/library.json @@ -0,0 +1,11 @@ +{ + "name": "homekit_mqtt_module_relay", + "version": "1.0.6", + "build": { + "flags": "-I../../include" + }, + "dependencies": { + "homekit_mqtt": "file://../../include/pio/libs/mqtt", + "homekit_relay": "file://../../include/pio/libs/relay" + } +} diff --git a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp b/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp index 82f1d74..409f38f 100644 --- a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp +++ b/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp @@ -4,7 +4,7 @@ namespace homekit::mqtt { static const char TOPIC_TEMPHUM_DATA[] = "temphum/data"; -void MqttTemphumModule::init(Mqtt &mqtt) {} +void MqttTemphumModule::onConnect(Mqtt &mqtt) {} void MqttTemphumModule::tick(homekit::mqtt::Mqtt& mqtt) { if (!tickElapsed()) diff --git a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h b/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h index 5c41cef..7b28afc 100644 --- a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h +++ b/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h @@ -19,7 +19,7 @@ private: public: MqttTemphumModule(temphum::Sensor* _sensor) : MqttModule(10), sensor(_sensor) {} - void init(Mqtt& mqtt) override; + void onConnect(Mqtt& mqtt) override; void tick(Mqtt& mqtt) override; }; diff --git a/include/pio/libs/mqtt_module_temphum/library.json b/include/pio/libs/mqtt_module_temphum/library.json new file mode 100644 index 0000000..c7ee7af --- /dev/null +++ b/include/pio/libs/mqtt_module_temphum/library.json @@ -0,0 +1,11 @@ +{ + "name": "homekit_mqtt_module_temphum", + "version": "1.0.10", + "build": { + "flags": "-I../../include" + }, + "dependencies": { + "homekit_mqtt": "file://../../include/pio/libs/mqtt", + "homekit_temphum": "file://../../include/pio/libs/temphum" + } +} diff --git a/platformio/common/libs/relay/homekit/relay.cpp b/include/pio/libs/relay/homekit/relay.cpp index b00a7a2..b00a7a2 100644 --- a/platformio/common/libs/relay/homekit/relay.cpp +++ b/include/pio/libs/relay/homekit/relay.cpp diff --git a/platformio/common/libs/relay/homekit/relay.h b/include/pio/libs/relay/homekit/relay.h index 288cc05..288cc05 100644 --- a/platformio/common/libs/relay/homekit/relay.h +++ b/include/pio/libs/relay/homekit/relay.h diff --git a/platformio/common/libs/relay/library.json b/include/pio/libs/relay/library.json index e878248..e878248 100644 --- a/platformio/common/libs/relay/library.json +++ b/include/pio/libs/relay/library.json diff --git a/platformio/common/libs/static/homekit/static.cpp b/include/pio/libs/static/homekit/static.cpp index 366a09f..366a09f 100644 --- a/platformio/common/libs/static/homekit/static.cpp +++ b/include/pio/libs/static/homekit/static.cpp diff --git a/platformio/common/libs/static/homekit/static.h b/include/pio/libs/static/homekit/static.h index c2617e9..c2617e9 100644 --- a/platformio/common/libs/static/homekit/static.h +++ b/include/pio/libs/static/homekit/static.h diff --git a/platformio/common/libs/static/library.json b/include/pio/libs/static/library.json index bc650d7..bc650d7 100644 --- a/platformio/common/libs/static/library.json +++ b/include/pio/libs/static/library.json diff --git a/platformio/common/libs/temphum/homekit/temphum.cpp b/include/pio/libs/temphum/homekit/temphum.cpp index e69b3a5..e69b3a5 100644 --- a/platformio/common/libs/temphum/homekit/temphum.cpp +++ b/include/pio/libs/temphum/homekit/temphum.cpp diff --git a/platformio/common/libs/temphum/homekit/temphum.h b/include/pio/libs/temphum/homekit/temphum.h index 1952ce0..1952ce0 100644 --- a/platformio/common/libs/temphum/homekit/temphum.h +++ b/include/pio/libs/temphum/homekit/temphum.h diff --git a/platformio/common/libs/temphum/library.json b/include/pio/libs/temphum/library.json index 329b7ca..329b7ca 100644 --- a/platformio/common/libs/temphum/library.json +++ b/include/pio/libs/temphum/library.json diff --git a/platformio/common/libs/wifi/homekit/wifi.cpp b/include/pio/libs/wifi/homekit/wifi.cpp index 3060dd6..3060dd6 100644 --- a/platformio/common/libs/wifi/homekit/wifi.cpp +++ b/include/pio/libs/wifi/homekit/wifi.cpp diff --git a/platformio/common/libs/wifi/homekit/wifi.h b/include/pio/libs/wifi/homekit/wifi.h index 3fe77cb..3fe77cb 100644 --- a/platformio/common/libs/wifi/homekit/wifi.h +++ b/include/pio/libs/wifi/homekit/wifi.h diff --git a/platformio/common/libs/wifi/library.json b/include/pio/libs/wifi/library.json index c7faecd..c7faecd 100644 --- a/platformio/common/libs/wifi/library.json +++ b/include/pio/libs/wifi/library.json diff --git a/platformio/common/make_static.sh b/include/pio/make_static.sh index d207e57..d207e57 100755 --- a/platformio/common/make_static.sh +++ b/include/pio/make_static.sh diff --git a/platformio/common/static/app.js b/include/pio/static/app.js index 299230c..299230c 100644 --- a/platformio/common/static/app.js +++ b/include/pio/static/app.js diff --git a/platformio/common/static/favicon.ico b/include/pio/static/favicon.ico Binary files differindex 6940e4f..6940e4f 100644 --- a/platformio/common/static/favicon.ico +++ b/include/pio/static/favicon.ico diff --git a/platformio/common/static/index.html b/include/pio/static/index.html index d4a8040..d4a8040 100644 --- a/platformio/common/static/index.html +++ b/include/pio/static/index.html diff --git a/platformio/common/static/md5.js b/include/pio/static/md5.js index b707a4e..b707a4e 100644 --- a/platformio/common/static/md5.js +++ b/include/pio/static/md5.js diff --git a/platformio/common/static/style.css b/include/pio/static/style.css index 32bd02c..32bd02c 100644 --- a/platformio/common/static/style.css +++ b/include/pio/static/style.css diff --git a/src/__init__.py b/include/py/__init__.py index e69de29..e69de29 100644 --- a/src/__init__.py +++ b/include/py/__init__.py diff --git a/src/home/__init__.py b/include/py/homekit/__init__.py index e69de29..e69de29 100644 --- a/src/home/__init__.py +++ b/include/py/homekit/__init__.py diff --git a/include/py/homekit/api/__init__.py b/include/py/homekit/api/__init__.py new file mode 100644 index 0000000..d641f62 --- /dev/null +++ b/include/py/homekit/api/__init__.py @@ -0,0 +1,19 @@ +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/include/py/homekit/api/__init__.pyi b/include/py/homekit/api/__init__.pyi new file mode 100644 index 0000000..5b98161 --- /dev/null +++ b/include/py/homekit/api/__init__.pyi @@ -0,0 +1,5 @@ +from .web_api_client import ( + RequestParams as RequestParams, + WebApiClient as WebApiClient +) +from .config import WebApiConfig as WebApiConfig diff --git a/include/py/homekit/api/config.py b/include/py/homekit/api/config.py new file mode 100644 index 0000000..00c1097 --- /dev/null +++ b/include/py/homekit/api/config.py @@ -0,0 +1,15 @@ +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/include/py/homekit/api/errors/__init__.py index efb06aa..efb06aa 100644 --- a/src/home/api/errors/__init__.py +++ b/include/py/homekit/api/errors/__init__.py diff --git a/src/home/api/errors/api_response_error.py b/include/py/homekit/api/errors/api_response_error.py index 85d788b..85d788b 100644 --- a/src/home/api/errors/api_response_error.py +++ b/include/py/homekit/api/errors/api_response_error.py diff --git a/src/home/api/types/__init__.py b/include/py/homekit/api/types/__init__.py index 9f27ff6..22ce4e6 100644 --- a/src/home/api/types/__init__.py +++ b/include/py/homekit/api/types/__init__.py @@ -1,5 +1,4 @@ from .types import ( - BotType, TemperatureSensorDataType, TemperatureSensorLocation, SoundSensorLocation diff --git a/src/home/api/types/types.py b/include/py/homekit/api/types/types.py index 981e798..294a712 100644 --- a/src/home/api/types/types.py +++ b/include/py/homekit/api/types/types.py @@ -1,17 +1,6 @@ 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() diff --git a/src/home/api/web_api_client.py b/include/py/homekit/api/web_api_client.py index 6677182..f9a8963 100644 --- a/src/home/api/web_api_client.py +++ b/include/py/homekit/api/web_api_client.py @@ -9,13 +9,15 @@ 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__) +_logger = logging.getLogger(__name__) +_config = WebApiConfig() RequestParams = namedtuple('RequestParams', 'params, files, method') @@ -26,7 +28,7 @@ class HTTPMethod(Enum): POST = auto() -class WebAPIClient: +class WebApiClient: token: str timeout: Union[float, Tuple[float, float]] basic_auth: Optional[HTTPBasicAuth] @@ -35,36 +37,26 @@ class WebAPIClient: async_success_handler: Optional[Callable] def __init__(self, timeout: Union[float, Tuple[float, float]] = 5): - self.token = config['api']['token'] + 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) + # 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): @@ -152,7 +144,7 @@ class WebAPIClient: params: dict, method: HTTPMethod = HTTPMethod.GET, files: Optional[Dict[str, str]] = None) -> Optional[any]: - domain = config['api']['host'] + domain = config['host'] kwargs = {} if self.basic_auth is not None: @@ -196,7 +188,7 @@ class WebAPIClient: try: f.close() except Exception as exc: - logger.exception(exc) + _logger.exception(exc) pass def _make_request_in_thread(self, name, params, method, files): @@ -204,7 +196,7 @@ class WebAPIClient: 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) + _logger.exception(e) self._report_async_error(e, name, RequestParams(params=params, method=method, files=files)) def enable_async(self, diff --git a/src/home/audio/__init__.py b/include/py/homekit/audio/__init__.py index e69de29..e69de29 100644 --- a/src/home/audio/__init__.py +++ b/include/py/homekit/audio/__init__.py diff --git a/src/home/audio/amixer.py b/include/py/homekit/audio/amixer.py index 53e6bce..8ed754b 100644 --- a/src/home/audio/amixer.py +++ b/include/py/homekit/audio/amixer.py @@ -10,14 +10,14 @@ _default_step = 5 def has_control(s: str) -> bool: - for control in config['amixer']['controls']: + for control in config.app_config['amixer']['controls']: if control['name'] == s: return True return False def get_caps(s: str) -> List[str]: - for control in config['amixer']['controls']: + for control in config.app_config['amixer']['controls']: if control['name'] == s: return control['caps'] raise KeyError(f'control {s} not found') @@ -25,7 +25,7 @@ def get_caps(s: str) -> List[str]: def get_all() -> list: controls = [] - for control in config['amixer']['controls']: + for control in config.app_config['amixer']['controls']: controls.append({ 'name': control['name'], 'info': get(control['name']), @@ -55,8 +55,8 @@ def nocap(control): def _get_default_step() -> int: - if 'step' in config['amixer']: - return int(config['amixer']['step']) + if 'step' in config.app_config['amixer']: + return int(config.app_config['amixer']['step']) return _default_step @@ -75,7 +75,7 @@ def decr(control, step=None): def call(*args, return_code=False) -> Union[int, str]: with _lock: - result = subprocess.run([config['amixer']['bin'], *args], + result = subprocess.run([config.app_config['amixer']['bin'], *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if return_code: diff --git a/include/py/homekit/camera/__init__.py b/include/py/homekit/camera/__init__.py new file mode 100644 index 0000000..4875031 --- /dev/null +++ b/include/py/homekit/camera/__init__.py @@ -0,0 +1,2 @@ +from .types import CameraType, VideoContainerType, VideoCodecType, CaptureType +from .config import IpcamConfig
\ No newline at end of file diff --git a/include/py/homekit/camera/config.py b/include/py/homekit/camera/config.py new file mode 100644 index 0000000..c7dbc38 --- /dev/null +++ b/include/py/homekit/camera/config.py @@ -0,0 +1,130 @@ +import socket + +from ..config import ConfigUnit, LinuxBoardsConfig +from typing import Optional +from .types import CameraType, VideoContainerType, VideoCodecType + +_lbc = LinuxBoardsConfig() + + +def _validate_roi_line(field, value, error) -> bool: + p = value.split(' ') + if len(p) != 4: + error(field, f'{field}: must contain four coordinates separated by space') + for n in p: + if not n.isnumeric(): + error(field, f'{field}: invalid coordinates (not a number)') + return True + + +class IpcamConfig(ConfigUnit): + NAME = 'ipcam' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'cams': { + 'type': 'dict', + 'keysrules': {'type': ['string', 'integer']}, + 'valuesrules': { + 'type': 'dict', + 'schema': { + 'type': {'type': 'string', 'allowed': [t.value for t in CameraType], 'required': True}, + 'codec': {'type': 'string', 'allowed': [t.value for t in VideoCodecType], 'required': True}, + 'container': {'type': 'string', 'allowed': [t.value for t in VideoContainerType], 'required': True}, + 'server': {'type': 'string', 'allowed': list(_lbc.get().keys()), 'required': True}, + 'disk': {'type': 'integer', 'required': True}, + 'motion': { + 'type': 'dict', + 'schema': { + 'threshold': {'type': ['float', 'integer']}, + 'roi': { + 'type': 'list', + 'schema': {'type': 'string', 'check_with': _validate_roi_line} + } + } + }, + 'rtsp_tcp': {'type': 'boolean'} + } + } + }, + 'motion_padding': {'type': 'integer', 'required': True}, + 'motion_telegram': {'type': 'boolean', 'required': True}, + 'fix_interval': {'type': 'integer', 'required': True}, + 'fix_enabled': {'type': 'boolean', 'required': True}, + 'cleanup_min_gb': {'type': 'integer', 'required': True}, + 'cleanup_interval': {'type': 'integer', 'required': True}, + + # TODO FIXME + 'fragment_url_templates': cls._url_templates_schema(), + 'original_file_url_templates': cls._url_templates_schema(), + + 'hls_path': {'type': 'string', 'required': True}, + 'motion_processing_tmpfs_path': {'type': 'string', 'required': True}, + + 'rtsp_creds': { + 'required': True, + 'type': 'dict', + 'schema': { + 'login': {'type': 'string', 'required': True}, + 'password': {'type': 'string', 'required': True}, + } + } + } + + @staticmethod + def custom_validator(data): + for n, cam in data['cams'].items(): + linux_box = _lbc[cam['server']] + if 'ext_hdd' not in linux_box: + raise ValueError(f'cam-{n}: linux box {cam["server"]} must have ext_hdd defined') + disk = cam['disk']-1 + if disk < 0 or disk >= len(linux_box['ext_hdd']): + raise ValueError(f'cam-{n}: invalid disk index for linux box {cam["server"]}') + + @classmethod + def _url_templates_schema(cls) -> dict: + return { + 'type': 'list', + 'empty': False, + 'schema': { + 'type': 'list', + 'empty': False, + 'schema': {'type': 'string'} + } + } + + def get_all_cam_names(self, + filter_by_server: Optional[str] = None, + filter_by_disk: Optional[int] = None) -> list[int]: + cams = [] + if filter_by_server is not None and filter_by_server not in _lbc: + raise ValueError(f'invalid filter_by_server: {filter_by_server} not found in {_lbc.__class__.__name__}') + for cam, params in self['cams'].items(): + if filter_by_server is None or params['server'] == filter_by_server: + if filter_by_disk is None or params['disk'] == filter_by_disk: + cams.append(int(cam)) + return cams + + def get_all_cam_names_for_this_server(self, + filter_by_disk: Optional[int] = None): + return self.get_all_cam_names(filter_by_server=socket.gethostname(), + filter_by_disk=filter_by_disk) + + def get_cam_server_and_disk(self, cam: int) -> tuple[str, int]: + return self['cams'][cam]['server'], self['cams'][cam]['disk'] + + def get_camera_container(self, cam: int) -> VideoContainerType: + return VideoContainerType(self['cams'][cam]['container']) + + def get_camera_type(self, cam: int) -> CameraType: + return CameraType(self['cams'][cam]['type']) + + def get_rtsp_creds(self) -> tuple[str, str]: + return self['rtsp_creds']['login'], self['rtsp_creds']['password'] + + def should_use_tcp_for_rtsp(self, cam: int) -> bool: + return 'rtsp_tcp' in self['cams'][cam] and self['cams'][cam]['rtsp_tcp'] + + def get_camera_ip(self, camera: int) -> str: + return f'192.168.5.{camera}' diff --git a/src/home/camera/esp32.py b/include/py/homekit/camera/esp32.py index fe6de0e..fe6de0e 100644 --- a/src/home/camera/esp32.py +++ b/include/py/homekit/camera/esp32.py diff --git a/include/py/homekit/camera/types.py b/include/py/homekit/camera/types.py new file mode 100644 index 0000000..c313b58 --- /dev/null +++ b/include/py/homekit/camera/types.py @@ -0,0 +1,46 @@ +from enum import Enum + + +class CameraType(Enum): + ESP32 = 'esp32' + ALIEXPRESS_NONAME = 'ali' + HIKVISION = 'hik' + + def get_channel_url(self, channel: int) -> str: + if channel not in (1, 2): + raise ValueError(f'channel {channel} is invalid') + if channel == 1: + return '' + elif channel == 2: + if self.value == CameraType.HIKVISION: + return '/Streaming/Channels/2' + elif self.value == CameraType.ALIEXPRESS_NONAME: + return '/?stream=1.sdp' + else: + raise ValueError(f'unsupported camera type {self.value}') + + +class VideoContainerType(Enum): + MP4 = 'mp4' + MOV = 'mov' + + +class VideoCodecType(Enum): + H264 = 'h264' + H265 = 'h265' + + +class TimeFilterType(Enum): + FIX = 'fix' + MOTION = 'motion' + MOTION_START = 'motion_start' + + +class TelegramLinkType(Enum): + FRAGMENT = 'fragment' + ORIGINAL_FILE = 'original_file' + + +class CaptureType(Enum): + HLS = 'hls' + RECORD = 'record' diff --git a/src/home/camera/util.py b/include/py/homekit/camera/util.py index 97f35aa..58c2c70 100644 --- a/src/home/camera/util.py +++ b/include/py/homekit/camera/util.py @@ -2,13 +2,21 @@ import asyncio import os.path import logging import psutil +import re +from datetime import datetime from typing import List, Tuple from ..util import chunks -from ..config import config +from ..config import config, LinuxBoardsConfig +from .config import IpcamConfig +from .types import VideoContainerType _logger = logging.getLogger(__name__) -_temporary_fixing = '.temporary_fixing.mp4' +_ipcam_config = IpcamConfig() +_lbc_config = LinuxBoardsConfig() + +datetime_format = '%Y-%m-%d-%H.%M.%S' +datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}' def _get_ffmpeg_path() -> str: @@ -26,7 +34,8 @@ def time2seconds(time: str) -> int: async def ffmpeg_recreate(filename: str): filedir = os.path.dirname(filename) - tempname = os.path.join(filedir, _temporary_fixing) + _, fileext = os.path.splitext(filename) + tempname = os.path.join(filedir, f'.temporary_fixing.{fileext}') mtime = os.path.getmtime(filename) args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname] @@ -104,4 +113,57 @@ def has_handle(fpath): except Exception: pass - return False
\ No newline at end of file + return False + + +def get_recordings_path(cam: int) -> str: + server, disk = _ipcam_config.get_cam_server_and_disk(cam) + disks = _lbc_config.get_board_disks(server) + disk_mountpoint = disks[disk-1] + return f'{disk_mountpoint}/cam-{cam}' + + +def get_motion_path(cam: int) -> str: + return f'{get_recordings_path(cam)}/motion' + + +def is_valid_recording_name(filename: str) -> bool: + if not filename.startswith('record_'): + return False + + for container_type in VideoContainerType: + if filename.endswith(f'.{container_type.value}'): + return True + + return False + + +def datetime_from_filename(name: str) -> datetime: + name = os.path.basename(name) + exts = '|'.join([t.value for t in VideoContainerType]) + + if name.startswith('record_'): + return datetime.strptime(re.match(rf'record_(.*?)\.(?:{exts})', name).group(1), datetime_format) + + m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.(?:{exts})', name) + if m: + return datetime.strptime(m.group(1), datetime_format) + + raise ValueError(f'unrecognized filename format: {name}') + + +def get_hls_channel_name(cam: int, channel: int) -> str: + name = str(cam) + if channel == 2: + name += '-low' + return name + + +def get_hls_directory(cam, channel) -> str: + dirname = os.path.join( + _ipcam_config['hls_path'], + get_hls_channel_name(cam, channel) + ) + if not os.path.exists(dirname): + os.makedirs(dirname) + return dirname
\ No newline at end of file diff --git a/include/py/homekit/config/__init__.py b/include/py/homekit/config/__init__.py new file mode 100644 index 0000000..8fedfa6 --- /dev/null +++ b/include/py/homekit/config/__init__.py @@ -0,0 +1,14 @@ +from .config import ( + Config, + ConfigUnit, + AppConfigUnit, + Translation, + config, + is_development_mode, + setup_logging, + CONFIG_DIRECTORIES +) +from ._configs import ( + LinuxBoardsConfig, + ServicesListConfig +)
\ No newline at end of file diff --git a/include/py/homekit/config/_configs.py b/include/py/homekit/config/_configs.py new file mode 100644 index 0000000..f88c8ea --- /dev/null +++ b/include/py/homekit/config/_configs.py @@ -0,0 +1,61 @@ +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} + } + }, + }, + } + } + + def get_board_disks(self, name: str) -> list[dict]: + return self[name]['ext_hdd'] + + def get_board_disks_count(self, name: str) -> int: + return len(self[name]['ext_hdd']) diff --git a/include/py/homekit/config/config.py b/include/py/homekit/config/config.py new file mode 100644 index 0000000..5fe1ae8 --- /dev/null +++ b/include/py/homekit/config/config.py @@ -0,0 +1,406 @@ +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 +from pprint import pprint + + +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) + if self._data is None: + raise TypeError(f'config file {path} is empty') + + 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' + + _instance = None + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls._instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(ConfigUnit, cls).__new__(cls, *args, **kwargs) + return cls._instance + + 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': { + 'verbose': {'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) + + if not normalized: + raise cerberus.DocumentError(f'validation failed: {v.errors}') + + 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, TypeError): + 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, TypeError): + 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, TypeError): + 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 + print(self.app_config) + self.app_config = name() + print(self.app_config) + 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/include/py/homekit/database/__init__.py index b50cbce..b50cbce 100644 --- a/src/home/database/__init__.py +++ b/include/py/homekit/database/__init__.py diff --git a/src/home/database/__init__.pyi b/include/py/homekit/database/__init__.pyi index 31aae5d..31aae5d 100644 --- a/src/home/database/__init__.pyi +++ b/include/py/homekit/database/__init__.pyi diff --git a/include/py/homekit/database/_base.py b/include/py/homekit/database/_base.py new file mode 100644 index 0000000..dcec9da --- /dev/null +++ b/include/py/homekit/database/_base.py @@ -0,0 +1,9 @@ +import os + + +def get_data_root_directory() -> 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/include/py/homekit/database/bots.py index cde48b9..fb5f326 100644 --- a/src/home/database/bots.py +++ b/include/py/homekit/database/bots.py @@ -2,7 +2,6 @@ import pytz from .mysql import mysql_now, MySQLDatabase, datetime_fmt from ..api.types import ( - BotType, SoundSensorLocation ) from typing import Optional, List, Tuple @@ -27,15 +26,6 @@ class OpenwrtLogRecord: 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): diff --git a/src/home/database/clickhouse.py b/include/py/homekit/database/clickhouse.py index ca81628..d0ec283 100644 --- a/src/home/database/clickhouse.py +++ b/include/py/homekit/database/clickhouse.py @@ -1,7 +1,7 @@ import logging from zoneinfo import ZoneInfo -from datetime import datetime, timedelta +from datetime import datetime from clickhouse_driver import Client as ClickhouseClient from ..config import is_development_mode diff --git a/src/home/database/inverter.py b/include/py/homekit/database/inverter.py index fc3f74f..fc3f74f 100644 --- a/src/home/database/inverter.py +++ b/include/py/homekit/database/inverter.py diff --git a/src/home/database/inverter_time_formats.py b/include/py/homekit/database/inverter_time_formats.py index 7c37d30..7c37d30 100644 --- a/src/home/database/inverter_time_formats.py +++ b/include/py/homekit/database/inverter_time_formats.py diff --git a/src/home/database/mysql.py b/include/py/homekit/database/mysql.py index fe97cd4..fe97cd4 100644 --- a/src/home/database/mysql.py +++ b/include/py/homekit/database/mysql.py diff --git a/src/home/database/sensors.py b/include/py/homekit/database/sensors.py index 8155108..8155108 100644 --- a/src/home/database/sensors.py +++ b/include/py/homekit/database/sensors.py diff --git a/src/home/database/simple_state.py b/include/py/homekit/database/simple_state.py index cada9c8..2b8ebe7 100644 --- a/src/home/database/simple_state.py +++ b/include/py/homekit/database/simple_state.py @@ -2,24 +2,26 @@ import os import json import atexit +from ._base import get_data_root_directory + class SimpleState: def __init__(self, - file: str, - default: dict = None, - **kwargs): + name: str, + default: dict = None): if default is None: default = {} elif type(default) is not dict: raise TypeError('default must be dictionary') - if not os.path.exists(file): + path = os.path.join(get_data_root_directory(), name) + if not os.path.exists(path): self._data = default else: - with open(file, 'r') as f: + with open(path, 'r') as f: self._data = json.loads(f.read()) - self._file = file + self._file = path atexit.register(self.__cleanup) def __cleanup(self): diff --git a/src/home/database/sqlite.py b/include/py/homekit/database/sqlite.py index bfba929..1651a93 100644 --- a/src/home/database/sqlite.py +++ b/include/py/homekit/database/sqlite.py @@ -2,27 +2,31 @@ 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, dbname: str) -> str: - return os.path.join(os.environ['HOME'], '.config', name, f'{dbname}.db') +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, dbname='bot', check_same_thread=False): - db_path = config.get('db_path', default=None) - if db_path is None: + def __init__(self, name=None, path=None, check_same_thread=False): + if not path: if not name: name = config.app_name - if not dbname: - dbname = name - db_path = _get_database_path(name, dbname) + database_path = _get_database_path(name) + else: + database_path = path + 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(db_path, check_same_thread=check_same_thread) + self.sqlite = sqlite3.connect(database_path, check_same_thread=check_same_thread) if is_development_mode(): self.sql_logger = logging.getLogger(self.__class__.__name__) diff --git a/src/home/http/__init__.py b/include/py/homekit/http/__init__.py index 6030e95..6030e95 100644 --- a/src/home/http/__init__.py +++ b/include/py/homekit/http/__init__.py diff --git a/src/home/http/http.py b/include/py/homekit/http/http.py index 3e70751..3e70751 100644 --- a/src/home/http/http.py +++ b/include/py/homekit/http/http.py diff --git a/src/home/inverter/__init__.py b/include/py/homekit/inverter/__init__.py index 8831ef3..8831ef3 100644 --- a/src/home/inverter/__init__.py +++ b/include/py/homekit/inverter/__init__.py diff --git a/include/py/homekit/inverter/config.py b/include/py/homekit/inverter/config.py new file mode 100644 index 0000000..e284dfe --- /dev/null +++ b/include/py/homekit/inverter/config.py @@ -0,0 +1,13 @@ +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/include/py/homekit/inverter/emulator.py index e86b8bb..e86b8bb 100644 --- a/src/home/inverter/emulator.py +++ b/include/py/homekit/inverter/emulator.py diff --git a/src/home/inverter/inverter_wrapper.py b/include/py/homekit/inverter/inverter_wrapper.py index df2c2fc..df2c2fc 100644 --- a/src/home/inverter/inverter_wrapper.py +++ b/include/py/homekit/inverter/inverter_wrapper.py diff --git a/src/home/inverter/monitor.py b/include/py/homekit/inverter/monitor.py index 86f75ac..5955d92 100644 --- a/src/home/inverter/monitor.py +++ b/include/py/homekit/inverter/monitor.py @@ -25,7 +25,7 @@ def _pd_from_string(pd: str) -> BatteryPowerDirection: class MonitorConfig: def __getattr__(self, item): - return config['monitor'][item] + return config.app_config['monitor'][item] cfg = MonitorConfig() diff --git a/src/home/inverter/types.py b/include/py/homekit/inverter/types.py index 57021f1..57021f1 100644 --- a/src/home/inverter/types.py +++ b/include/py/homekit/inverter/types.py diff --git a/src/home/inverter/util.py b/include/py/homekit/inverter/util.py index a577e6a..a577e6a 100644 --- a/src/home/inverter/util.py +++ b/include/py/homekit/inverter/util.py diff --git a/src/home/media/__init__.py b/include/py/homekit/media/__init__.py index 976c990..6923105 100644 --- a/src/home/media/__init__.py +++ b/include/py/homekit/media/__init__.py @@ -12,6 +12,7 @@ __map__ = { __all__ = list(itertools.chain(*__map__.values())) + def __getattr__(name): if name in __all__: for file, names in __map__.items(): diff --git a/src/home/media/__init__.pyi b/include/py/homekit/media/__init__.pyi index 77c2176..77c2176 100644 --- a/src/home/media/__init__.pyi +++ b/include/py/homekit/media/__init__.pyi diff --git a/src/home/media/node_client.py b/include/py/homekit/media/node_client.py index eb39898..eb39898 100644 --- a/src/home/media/node_client.py +++ b/include/py/homekit/media/node_client.py diff --git a/src/home/media/node_server.py b/include/py/homekit/media/node_server.py index 5d0803c..5d0803c 100644 --- a/src/home/media/node_server.py +++ b/include/py/homekit/media/node_server.py diff --git a/src/home/media/record.py b/include/py/homekit/media/record.py index cd7447a..cd7447a 100644 --- a/src/home/media/record.py +++ b/include/py/homekit/media/record.py diff --git a/src/home/media/record_client.py b/include/py/homekit/media/record_client.py index 322495c..322495c 100644 --- a/src/home/media/record_client.py +++ b/include/py/homekit/media/record_client.py diff --git a/src/home/media/storage.py b/include/py/homekit/media/storage.py index dd74ff8..dd74ff8 100644 --- a/src/home/media/storage.py +++ b/include/py/homekit/media/storage.py diff --git a/src/home/media/types.py b/include/py/homekit/media/types.py index acbc291..acbc291 100644 --- a/src/home/media/types.py +++ b/include/py/homekit/media/types.py diff --git a/include/py/homekit/mqtt/__init__.py b/include/py/homekit/mqtt/__init__.py new file mode 100644 index 0000000..707d59c --- /dev/null +++ b/include/py/homekit/mqtt/__init__.py @@ -0,0 +1,7 @@ +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/include/py/homekit/mqtt/_config.py b/include/py/homekit/mqtt/_config.py new file mode 100644 index 0000000..9ba9443 --- /dev/null +++ b/include/py/homekit/mqtt/_config.py @@ -0,0 +1,165 @@ +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/include/py/homekit/mqtt/_module.py b/include/py/homekit/mqtt/_module.py new file mode 100644 index 0000000..80f27bb --- /dev/null +++ b/include/py/homekit/mqtt/_module.py @@ -0,0 +1,70 @@ +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/include/py/homekit/mqtt/_mqtt.py index 4acd4f6..47ee9ae 100644 --- a/src/home/mqtt/mqtt.py +++ b/include/py/homekit/mqtt/_mqtt.py @@ -3,19 +3,24 @@ import paho.mqtt.client as mqtt import ssl import logging -from typing import Tuple -from ..config import config +from ._config import MqttCreds, MqttConfig +from typing import Optional -def username_and_password() -> Tuple[str, str]: - username = config['mqtt']['username'] if 'username' in config['mqtt'] else None - password = config['mqtt']['password'] if 'password' in config['mqtt'] else None - return username, password +class 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') -class MqttBase: - def __init__(self, clean_session=True): - self._client = mqtt.Client(client_id=config['mqtt']['client_id'], + self._client = mqtt.Client(client_id=client_id, protocol=mqtt.MQTTv311, clean_session=clean_session) self._client.on_connect = self.on_connect @@ -24,30 +29,34 @@ class MqttBase: 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__) - username, password = username_and_password() - if username and password: - self._logger.debug(f'username={username} password={password}') - self._client.username_pw_set(username, password) + 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): + def _configure_tls(self): ca_certs = os.path.realpath(os.path.join( os.path.dirname(os.path.realpath(__file__)), '..', '..', '..', - 'assets', + '..', + 'misc', 'mqtt_ca.crt' )) - self._client.tls_set(ca_certs=ca_certs, cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLSv1_2) + self._client.tls_set(ca_certs=ca_certs, + cert_reqs=ssl.CERT_REQUIRED, + tls_version=ssl.PROTOCOL_TLSv1_2) def connect_and_loop(self, loop_forever=True): - host = config['mqtt']['host'] - port = config['mqtt']['port'] - - self._client.connect(host, port, 60) + 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: @@ -61,9 +70,11 @@ class MqttBase: 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 @@ -73,4 +84,4 @@ class MqttBase: self._logger.debug(msg.topic + ": " + str(msg.payload)) def on_publish(self, client: mqtt.Client, userdata, mid): - self._logger.debug(f'publish done, mid={mid}')
\ No newline at end of file + self._logger.debug(f'publish done, mid={mid}') diff --git a/include/py/homekit/mqtt/_node.py b/include/py/homekit/mqtt/_node.py new file mode 100644 index 0000000..4e259a4 --- /dev/null +++ b/include/py/homekit/mqtt/_node.py @@ -0,0 +1,92 @@ +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/base_payload.py b/include/py/homekit/mqtt/_payload.py index 1abd898..58eeae3 100644 --- a/src/home/mqtt/payload/base_payload.py +++ b/include/py/homekit/mqtt/_payload.py @@ -1,5 +1,5 @@ -import abc import struct +import abc import re from typing import Optional, Tuple @@ -142,4 +142,4 @@ 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 + return None
\ No newline at end of file diff --git a/include/py/homekit/mqtt/_util.py b/include/py/homekit/mqtt/_util.py new file mode 100644 index 0000000..390d463 --- /dev/null +++ b/include/py/homekit/mqtt/_util.py @@ -0,0 +1,15 @@ +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/include/py/homekit/mqtt/_wrapper.py b/include/py/homekit/mqtt/_wrapper.py new file mode 100644 index 0000000..3c2774c --- /dev/null +++ b/include/py/homekit/mqtt/_wrapper.py @@ -0,0 +1,60 @@ +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/payload/esp.py b/include/py/homekit/mqtt/module/diagnostics.py index 171cdb9..5db5e99 100644 --- a/src/home/mqtt/payload/esp.py +++ b/include/py/homekit/mqtt/module/diagnostics.py @@ -1,39 +1,8 @@ -import hashlib +from .._payload import MqttPayload, MqttPayloadCustomField +from .._node import MqttNode, MqttModule +from typing import Optional -from .base_payload import MqttPayload, MqttPayloadCustomField - - -class OTAResultPayload(MqttPayload): - FORMAT = '=BB' - result: int - error_code: int - - -class OTAPayload(MqttPayload): - secret: str - filename: str - - # structure of returned data: - # - # uint8_t[len(secret)] secret; - # uint8_t[16] md5; - # *uint8_t data - - def pack(self): - buf = bytearray(self.secret.encode()) - m = hashlib.md5() - with open(self.filename, 'rb') as fd: - content = fd.read() - m.update(content) - buf.extend(m.digest()) - buf.extend(content) - return buf - - def unpack(cls, buf: bytes): - raise RuntimeError(f'{cls.__class__.__name__}.unpack: not implemented') - # secret = buf[:12].decode() - # filename = buf[12:].decode() - # return OTAPayload(secret=secret, filename=filename) +MODULE_NAME = 'MqttDiagnosticsModule' class DiagnosticsFlags(MqttPayloadCustomField): @@ -76,3 +45,20 @@ class DiagnosticsPayload(MqttPayload): 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/include/py/homekit/mqtt/module/inverter.py b/include/py/homekit/mqtt/module/inverter.py new file mode 100644 index 0000000..29bde0a --- /dev/null +++ b/include/py/homekit/mqtt/module/inverter.py @@ -0,0 +1,195 @@ +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 homekit.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/include/py/homekit/mqtt/module/ota.py b/include/py/homekit/mqtt/module/ota.py new file mode 100644 index 0000000..cd34332 --- /dev/null +++ b/include/py/homekit/mqtt/module/ota.py @@ -0,0 +1,77 @@ +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/include/py/homekit/mqtt/module/relay.py b/include/py/homekit/mqtt/module/relay.py new file mode 100644 index 0000000..e968031 --- /dev/null +++ b/include/py/homekit/mqtt/module/relay.py @@ -0,0 +1,92 @@ +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/include/py/homekit/mqtt/module/temphum.py b/include/py/homekit/mqtt/module/temphum.py new file mode 100644 index 0000000..fd02cca --- /dev/null +++ b/include/py/homekit/mqtt/module/temphum.py @@ -0,0 +1,82 @@ +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/include/py/homekit/pio/__init__.py index 7216bc4..7216bc4 100644 --- a/src/home/pio/__init__.py +++ b/include/py/homekit/pio/__init__.py diff --git a/src/home/pio/exceptions.py b/include/py/homekit/pio/exceptions.py index a6afd20..a6afd20 100644 --- a/src/home/pio/exceptions.py +++ b/include/py/homekit/pio/exceptions.py diff --git a/src/home/pio/products.py b/include/py/homekit/pio/products.py index 7649078..a0e7a1f 100644 --- a/src/home/pio/products.py +++ b/include/py/homekit/pio/products.py @@ -8,18 +8,14 @@ from collections import OrderedDict _logger = logging.getLogger(__name__) _products_dir = os.path.join( os.path.dirname(__file__), - '..', '..', '..', - 'platformio' + '..', '..', '..', '..', + 'pio' ) def get_products(): products = [] for f in os.listdir(_products_dir): - # temp hack - if f.endswith('-esp01'): - continue - # skip the common dir if f in ('common',): continue @@ -93,8 +89,10 @@ def platformio_ini(product_config: dict, buf.write(f'upload_port = {upload_port}\n') buf.write(f'monitor_speed = {monitor_speed}\n') if libs: - buf.write(f'lib_deps =') + buf.write(f'lib_deps =\n') for lib in libs: + if lib.startswith('homekit_'): + lib = 'file://../../include/pio/libs/'+lib[8:] buf.write(f' {lib}\n') buf.write(f'build_flags =\n') if defines: @@ -111,7 +109,7 @@ def platformio_ini(product_config: dict, if type(value) is str and not is_enum: buf.write('"\\"') buf.write('\n') - buf.write(f' -I../common/include') + buf.write(f' -I../../include/pio/include') buf.write(f'\nbuild_type = {build_type}') return buf.getvalue() diff --git a/src/home/relay/__init__.py b/include/py/homekit/relay/__init__.py index 406403d..406403d 100644 --- a/src/home/relay/__init__.py +++ b/include/py/homekit/relay/__init__.py diff --git a/src/home/relay/__init__.pyi b/include/py/homekit/relay/__init__.pyi index 7a4a2f4..7a4a2f4 100644 --- a/src/home/relay/__init__.pyi +++ b/include/py/homekit/relay/__init__.pyi diff --git a/src/home/relay/sunxi_h3_client.py b/include/py/homekit/relay/sunxi_h3_client.py index 8c8d6c4..8c8d6c4 100644 --- a/src/home/relay/sunxi_h3_client.py +++ b/include/py/homekit/relay/sunxi_h3_client.py diff --git a/src/home/relay/sunxi_h3_server.py b/include/py/homekit/relay/sunxi_h3_server.py index 1f33969..1f33969 100644 --- a/src/home/relay/sunxi_h3_server.py +++ b/include/py/homekit/relay/sunxi_h3_server.py diff --git a/src/home/soundsensor/__init__.py b/include/py/homekit/soundsensor/__init__.py index 30052f8..30052f8 100644 --- a/src/home/soundsensor/__init__.py +++ b/include/py/homekit/soundsensor/__init__.py diff --git a/src/home/soundsensor/__init__.pyi b/include/py/homekit/soundsensor/__init__.pyi index cb34972..cb34972 100644 --- a/src/home/soundsensor/__init__.pyi +++ b/include/py/homekit/soundsensor/__init__.pyi diff --git a/src/home/soundsensor/node.py b/include/py/homekit/soundsensor/node.py index 292452f..292452f 100644 --- a/src/home/soundsensor/node.py +++ b/include/py/homekit/soundsensor/node.py diff --git a/src/home/soundsensor/server.py b/include/py/homekit/soundsensor/server.py index a627390..a627390 100644 --- a/src/home/soundsensor/server.py +++ b/include/py/homekit/soundsensor/server.py diff --git a/src/home/soundsensor/server_client.py b/include/py/homekit/soundsensor/server_client.py index 7eef996..7eef996 100644 --- a/src/home/soundsensor/server_client.py +++ b/include/py/homekit/soundsensor/server_client.py diff --git a/src/home/telegram/__init__.py b/include/py/homekit/telegram/__init__.py index a68dae1..a68dae1 100644 --- a/src/home/telegram/__init__.py +++ b/include/py/homekit/telegram/__init__.py diff --git a/src/home/telegram/_botcontext.py b/include/py/homekit/telegram/_botcontext.py index f343eeb..a143bfe 100644 --- a/src/home/telegram/_botcontext.py +++ b/include/py/homekit/telegram/_botcontext.py @@ -1,6 +1,7 @@ from typing import Optional, List -from telegram import Update, ParseMode, User, CallbackQuery +from telegram import Update, User, CallbackQuery +from telegram.constants import ParseMode from telegram.ext import CallbackContext from ._botdb import BotDatabase @@ -26,25 +27,25 @@ class Context: self._store = store self._user_lang = None - def reply(self, text, markup=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 self._update.message.reply_text(text, **kwargs) + return await self._update.message.reply_text(text, **kwargs) - def reply_exc(self, e: Exception) -> None: - self.reply(exc2text(e), markup=IgnoreMarkup()) + async def reply_exc(self, e: Exception) -> None: + await self.reply(exc2text(e), markup=IgnoreMarkup()) - def answer(self, text: str = None): - self.callback_query.answer(text) + async def answer(self, text: str = None): + await self.callback_query.answer(text) - def edit(self, text, markup=None): + async def edit(self, text, markup=None): kwargs = dict(parse_mode=ParseMode.HTML) if not isinstance(markup, IgnoreMarkup): kwargs['reply_markup'] = markup - self.callback_query.edit_message_text(text, **kwargs) + await self.callback_query.edit_message_text(text, **kwargs) @property def text(self) -> str: diff --git a/src/home/telegram/_botdb.py b/include/py/homekit/telegram/_botdb.py index 9e9cf94..4e1aec0 100644 --- a/src/home/telegram/_botdb.py +++ b/include/py/homekit/telegram/_botdb.py @@ -1,4 +1,4 @@ -from home.database.sqlite import SQLiteBase +from homekit.database.sqlite import SQLiteBase class BotDatabase(SQLiteBase): diff --git a/src/home/telegram/_botlang.py b/include/py/homekit/telegram/_botlang.py index f5f85bb..f5f85bb 100644 --- a/src/home/telegram/_botlang.py +++ b/include/py/homekit/telegram/_botlang.py diff --git a/src/home/telegram/_botutil.py b/include/py/homekit/telegram/_botutil.py index 6d1ee8f..4fbbf28 100644 --- a/src/home/telegram/_botutil.py +++ b/include/py/homekit/telegram/_botutil.py @@ -3,9 +3,6 @@ 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__) @@ -24,20 +21,6 @@ def user_any_name(user: User) -> str: 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) diff --git a/src/home/telegram/aio.py b/include/py/homekit/telegram/aio.py index fc87c1c..fc87c1c 100644 --- a/src/home/telegram/aio.py +++ b/include/py/homekit/telegram/aio.py diff --git a/src/home/telegram/bot.py b/include/py/homekit/telegram/bot.py index 10bfe06..f5f620a 100644 --- a/src/home/telegram/bot.py +++ b/include/py/homekit/telegram/bot.py @@ -5,54 +5,52 @@ import itertools from enum import Enum, auto from functools import wraps -from typing import Optional, Union, Tuple +from typing import Optional, Union, Tuple, Coroutine from telegram import Update, ReplyKeyboardMarkup from telegram.ext import ( - Updater, - Filters, - BaseFilter, + 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 homekit.config import config from ._botlang import lang, languages from ._botdb import BotDatabase -from ._botutil import ReportingHelper, exc2text, IgnoreMarkup, user_any_name +from ._botutil import exc2text, IgnoreMarkup from ._botcontext import Context +from .config import TelegramUserListType 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')) +_cancel_filter = filters.Text(lang.all('cancel')) +_back_filter = filters.Text(lang.all('back')) +_cancel_and_back_filter = filters.Text(lang.all('back') + lang.all('cancel')) _logger = logging.getLogger(__name__) -_updater: Optional[Updater] = None -_reporting: Optional[ReportingHelper] = None -_exception_handler: Optional[callable] = None +_application: Optional[Application] = None +_exception_handler: Optional[Coroutine] = None _dispatcher = None _markup_getter: Optional[callable] = None -_start_handler_ref: 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 + return filters.Text(args[0] if isinstance(args[0], list) else [*args]) & _user_filter -def _handler_of_handler(*args, **kwargs): +async def _handler_of_handler(*args, **kwargs): self = None context = None update = None @@ -99,7 +97,7 @@ def _handler_of_handler(*args, **kwargs): if self: _args.insert(0, self) - result = f(*_args, **kwargs) + result = await f(*_args, **kwargs) return result if not return_with_context else (result, ctx) except Exception as e: @@ -107,7 +105,7 @@ def _handler_of_handler(*args, **kwargs): if not _exception_handler(e, ctx) and not isinstance(e, TimedOut): _logger.exception(e) if not ctx.is_callback_context(): - ctx.reply_exc(e) + await ctx.reply_exc(e) else: notify_user(ctx.user_id, exc2text(e)) else: @@ -117,10 +115,10 @@ def _handler_of_handler(*args, **kwargs): def handler(**kwargs): def inner(f): @wraps(f) - def _handler(*args, **inner_kwargs): + async def _handler(*args, **inner_kwargs): if 'argument' in kwargs and kwargs['argument'] == 'message_key': inner_kwargs['argument'] = 'message_key' - return _handler_of_handler(f=f, *args, **inner_kwargs) + return await _handler_of_handler(f=f, *args, **inner_kwargs) messages = [] texts = [] @@ -139,43 +137,43 @@ def handler(**kwargs): new_messages = list(itertools.chain.from_iterable([lang.all(m) for m in messages])) texts += new_messages texts = list(set(texts)) - _updater.dispatcher.add_handler( + _application.add_handler( MessageHandler(text_filter(*texts), _handler), group=0 ) if 'command' in kwargs: - _updater.dispatcher.add_handler(CommandHandler(kwargs['command'], _handler), group=0) + _application.add_handler(CommandHandler(kwargs['command'], _handler), group=0) if 'callback' in kwargs: - _updater.dispatcher.add_handler(CallbackQueryHandler(_handler, pattern=kwargs['callback']), group=0) + _application.add_handler(CallbackQueryHandler(_handler, pattern=kwargs['callback']), group=0) return _handler return inner -def simplehandler(f: callable): +def simplehandler(f: Coroutine): @wraps(f) - def _handler(*args, **kwargs): - return _handler_of_handler(f=f, *args, **kwargs) + 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) - def _handler(*args, **kwargs): - return _handler_of_handler(f=f, *args, **kwargs) + async def _handler(*args, **kwargs): + return await _handler_of_handler(f=f, *args, **kwargs) pattern_kwargs = {} if kwargs['callback'] != '*': pattern_kwargs['pattern'] = kwargs['callback'] - _updater.dispatcher.add_handler(CallbackQueryHandler(_handler, **pattern_kwargs), group=0) + _application.add_handler(CallbackQueryHandler(_handler, **pattern_kwargs), group=0) return _handler return inner -def exceptionhandler(f: callable): +async def exceptionhandler(f: callable): global _exception_handler if _exception_handler: _logger.warning('exception handler already set, we will overwrite it') @@ -198,10 +196,10 @@ def convinput(state, is_enter=False, **kwargs): ) @wraps(f) - def _impl(*args, **kwargs): - result, ctx = _handler_of_handler(f=f, *args, **kwargs, return_with_context=True) + async def _impl(*args, **kwargs): + result, ctx = await _handler_of_handler(f=f, *args, **kwargs, return_with_context=True) if result == conversation.END: - start(ctx) + await start(ctx) return result return _impl @@ -252,7 +250,7 @@ class conversation: 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)) + handlers.append(MessageHandler(filters.Regex(kwargs['regex']) & _user_filter, f)) if 'command' in kwargs: handlers.append(CommandHandler(kwargs['command'], f, _user_filter)) @@ -268,7 +266,7 @@ class conversation: return self.invoke(state, ctx) return _invoke - def invoke(self, state, ctx: Context): + async def invoke(self, state, ctx: Context): self._logger.debug(f'invoke, state={state}') for item in dir(self): f = getattr(self, item) @@ -276,7 +274,7 @@ class conversation: continue cd = f.__dict__['_conv_data'] if cd['enter'] and cd['state'] == state: - return cd['orig_f'](self, ctx) + return await cd['orig_f'](self, ctx) raise RuntimeError(f'invoke: failed to find method for state {state}') @@ -327,21 +325,21 @@ class conversation: @staticmethod @simplehandler - def invalid(ctx: Context): - ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup()) + async def invalid(ctx: Context): + await ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup()) # return 0 # FIXME is this needed @simplehandler - def cancel(self, ctx: Context): - start(ctx) + async def cancel(self, ctx: Context): + await start(ctx) self.set_user_state(ctx.user_id, None) return conversation.END @simplehandler - def back(self, ctx: Context): + async def back(self, ctx: Context): cur_state = self.get_user_state(ctx.user_id) if cur_state is None: - start(ctx) + await start(ctx) self.set_user_state(ctx.user_id, None) return conversation.END @@ -364,14 +362,14 @@ class conversation: # 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): + async 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 = [] @@ -402,7 +400,7 @@ class conversation: 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) + await ctx.reply(text, markup=markup) self.set_user_state(ctx.user_id, state) return state @@ -411,7 +409,7 @@ class LangConversation(conversation): START, = range(1) @conventer(START, command='lang') - def entry(self, ctx: Context): + async def entry(self, ctx: Context): self._logger.debug(f'current language: {ctx.user_lang}') buttons = [] @@ -419,11 +417,11 @@ class LangConversation(conversation): buttons.append(name) markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False) - ctx.reply(ctx.lang('select_language'), markup=markup) + await ctx.reply(ctx.lang('select_language'), markup=markup) return self.START @convinput(START, messages=lang.languages) - def input(self, ctx: Context): + async def input(self, ctx: Context): selected_lang = None for key, value in languages.items(): if value == ctx.text: @@ -434,30 +432,34 @@ class LangConversation(conversation): raise ValueError('could not find the language') db.set_user_lang(ctx.user_id, selected_lang) - ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup()) + await ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup()) return self.END def initialize(): global _user_filter - global _updater + global _application + # global _updater global _dispatcher # init user_filter - if 'users' in config['bot']: - _logger.info('allowed users: ' + str(config['bot']['users'])) - _user_filter = Filters.user(config['bot']['users']) + _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 + _user_filter = filters.ALL # not sure if this is correct - # init updater - _updater = Updater(config['bot']['token'], - request_kwargs={'read_timeout': 6, 'connect_timeout': 7}) + _application = Application.builder()\ + .token(config.app_config.get('bot.token'))\ + .connect_timeout(7)\ + .read_timeout(6)\ + .build() # transparently log all messages - _updater.dispatcher.add_handler(MessageHandler(Filters.all & _user_filter, _logging_message_handler), group=10) - _updater.dispatcher.add_handler(CallbackQueryHandler(_logging_callback_handler), group=10) + # _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): @@ -473,103 +475,97 @@ def run(start_handler=None, any_handler=None): _start_handler_ref = start_handler - _updater.dispatcher.add_handler(LangConversation().get_handler(), group=0) - _updater.dispatcher.add_handler(CommandHandler('start', simplehandler(start_handler), _user_filter)) - _updater.dispatcher.add_handler(MessageHandler(Filters.all & _user_filter, any_handler)) + _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)) - _updater.start_polling() - _updater.idle() + _application.run_polling() def add_conversation(conv: conversation) -> None: - _updater.dispatcher.add_handler(conv.get_handler(), group=0) + _application.add_handler(conv.get_handler(), group=0) def add_handler(h): - _updater.dispatcher.add_handler(h, group=0) + _application.add_handler(h, group=0) -def start(ctx: Context): - return _start_handler_ref(ctx) +async def start(ctx: Context): + return await _start_handler_ref(ctx) -def _default_start_handler(ctx: Context): +async def _default_start_handler(ctx: Context): if 'start_message' not in lang: - return ctx.reply('Please define start_message or override start()') - ctx.reply(ctx.lang('start_message')) + return await ctx.reply('Please define start_message or override start()') + await ctx.reply(ctx.lang('start_message')) @simplehandler -def _default_any_handler(ctx: Context): +async def _default_any_handler(ctx: Context): if 'invalid_command' not in lang: - return ctx.reply('Please define invalid_command or override any()') - ctx.reply(ctx.lang('invalid_command')) + 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_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 _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') +async def notify_all(text_getter: callable, + exclude: Tuple[int] = ()) -> None: + notify_user_ids = config.app_config.get_user_ids(TelegramUserListType.NOTIFY) + if not notify_user_ids: + _logger.error('notify_all() called but no notify_users defined in the config') return - for user_id in config['bot']['notify_users']: + for user_id in notify_user_ids: if user_id in exclude: continue text = text_getter(db.get_user_lang(user_id)) - _updater.bot.send_message(chat_id=user_id, - text=text, - parse_mode='HTML') + await _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: +async def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> None: if isinstance(text, Exception): text = exc2text(text) - _updater.bot.send_message(chat_id=user_id, - text=text, - parse_mode='HTML', - **kwargs) + await _application.bot.send_message(chat_id=user_id, + text=text, + parse_mode='HTML', + **kwargs) -def send_photo(user_id, **kwargs): - _updater.bot.send_photo(chat_id=user_id, **kwargs) +async def send_photo(user_id, **kwargs): + await _application.bot.send_photo(chat_id=user_id, **kwargs) -def send_audio(user_id, **kwargs): - _updater.bot.send_audio(chat_id=user_id, **kwargs) +async def send_audio(user_id, **kwargs): + await _application.bot.send_audio(chat_id=user_id, **kwargs) -def send_file(user_id, **kwargs): - _updater.bot.send_document(chat_id=user_id, **kwargs) +async def send_file(user_id, **kwargs): + await _application.bot.send_document(chat_id=user_id, **kwargs) -def edit_message_text(user_id, message_id, *args, **kwargs): - _updater.bot.edit_message_text(chat_id=user_id, - message_id=message_id, - parse_mode='HTML', - *args, **kwargs) +async def edit_message_text(user_id, message_id, *args, **kwargs): + await _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): - _updater.bot.delete_message(chat_id=user_id, message_id=message_id) +async def delete_message(user_id, message_id): + await _application.bot.delete_message(chat_id=user_id, message_id=message_id) def set_database(_db: BotDatabase): diff --git a/include/py/homekit/telegram/config.py b/include/py/homekit/telegram/config.py new file mode 100644 index 0000000..5f41008 --- /dev/null +++ b/include/py/homekit/telegram/config.py @@ -0,0 +1,78 @@ +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.value: {**TelegramBotConfig._userlist_schema(), 'required': True}, + TelegramUserListType.NOTIFY.value: TelegramBotConfig._userlist_schema(), + } + } + } + + @staticmethod + def _userlist_schema() -> dict: + return {'type': 'list', 'schema': {'type': ['string', 'integer']}} + + @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]: + try: + return list(map(_user_id_mapper, self['bot'][ult.value])) + except KeyError: + return []
\ No newline at end of file diff --git a/src/home/telegram/telegram.py b/include/py/homekit/telegram/telegram.py index 2f94f93..f42363e 100644 --- a/src/home/telegram/telegram.py +++ b/include/py/homekit/telegram/telegram.py @@ -2,25 +2,27 @@ import requests import logging from typing import Tuple -from ..config import config - +from .config import TelegramChatsConfig +_chats = TelegramChatsConfig() _logger = logging.getLogger(__name__) def send_message(text: str, - parse_mode: str = None, - disable_web_page_preview: bool = False): - data, token = _send_telegram_data(text, parse_mode, disable_web_page_preview) + 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): +def send_photo(filename: str, chat: str): + chat_data = _chats[chat] data = { - 'chat_id': config['telegram']['chat_id'], + 'chat_id': chat_data['id'], } - token = config['telegram']['token'] + token = chat_data['token'] url = f'https://api.telegram.org/bot{token}/sendPhoto' with open(filename, "rb") as fd: @@ -29,19 +31,19 @@ def send_photo(filename: str): 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': config['telegram']['chat_id'], + 'chat_id': chat_data['id'], 'text': text } if parse_mode is not None: data['parse_mode'] = parse_mode - elif 'parse_mode' in config['telegram']: - data['parse_mode'] = config['telegram']['parse_mode'] - if disable_web_page_preview or 'disable_web_page_preview' in config['telegram']: + if disable_web_page_preview: data['disable_web_page_preview'] = 1 - return data, config['telegram']['token'] + return data, chat_data['token'] diff --git a/include/py/homekit/temphum/__init__.py b/include/py/homekit/temphum/__init__.py new file mode 100644 index 0000000..46d14e6 --- /dev/null +++ b/include/py/homekit/temphum/__init__.py @@ -0,0 +1 @@ +from .base import SensorType, BaseSensor diff --git a/src/home/temphum/base.py b/include/py/homekit/temphum/base.py index e774433..602cab7 100644 --- a/src/home/temphum/base.py +++ b/include/py/homekit/temphum/base.py @@ -1,25 +1,19 @@ -import smbus - -from abc import abstractmethod, ABC +from abc import ABC from enum import Enum -class TempHumSensor: - @abstractmethod +class BaseSensor(ABC): + def __init__(self, bus: int): + super().__init__() + self.bus = smbus.SMBus(bus) + def humidity(self) -> float: pass - @abstractmethod def temperature(self) -> float: pass -class I2CTempHumSensor(TempHumSensor, ABC): - def __init__(self, bus: int): - super().__init__() - self.bus = smbus.SMBus(bus) - - class SensorType(Enum): Si7021 = 'si7021' - DHT12 = 'dht12' + DHT12 = 'dht12'
\ No newline at end of file diff --git a/include/py/homekit/temphum/i2c.py b/include/py/homekit/temphum/i2c.py new file mode 100644 index 0000000..7d8e2e3 --- /dev/null +++ b/include/py/homekit/temphum/i2c.py @@ -0,0 +1,52 @@ +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/include/py/homekit/util.py index 93a9d8f..22bba86 100644 --- a/src/home/util.py +++ b/include/py/homekit/util.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import socket import time @@ -6,17 +8,82 @@ import traceback import logging import string import random +import re from enum import Enum from datetime import datetime -from typing import Tuple, Optional, List +from typing import Optional, List from zlib import adler32 -Addr = Tuple[str, int] # network address type (host, port) - 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.""" @@ -45,21 +112,6 @@ def ipv4_valid(ip: str) -> bool: return False -def parse_addr(addr: str) -> Addr: - if addr.count(':') != 1: - raise ValueError('invalid host:port format') - - host, port = addr.split(':') - if not ipv4_valid(host): - raise ValueError('invalid ipv4 address') - - port = int(port) - if not 0 <= port <= 65535: - raise ValueError('invalid port') - - return host, port - - def strgen(n: int): return ''.join(random.choices(string.ascii_letters + string.digits, k=n)) @@ -193,4 +245,11 @@ def filesize_fmt(num, suffix="B") -> str: class HashableEnum(Enum): def hash(self) -> int: - return adler32(self.name.encode())
\ No newline at end of file + 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/pyA20/__init__.pyi b/include/py/pyA20/__init__.pyi index e69de29..e69de29 100644 --- a/pyA20/__init__.pyi +++ b/include/py/pyA20/__init__.pyi diff --git a/pyA20/gpio/connector.pyi b/include/py/pyA20/gpio/connector.pyi index 12b2b6e..12b2b6e 100644 --- a/pyA20/gpio/connector.pyi +++ b/include/py/pyA20/gpio/connector.pyi diff --git a/pyA20/gpio/gpio.pyi b/include/py/pyA20/gpio/gpio.pyi index 225fcbe..225fcbe 100644 --- a/pyA20/gpio/gpio.pyi +++ b/include/py/pyA20/gpio/gpio.pyi diff --git a/pyA20/gpio/port.pyi b/include/py/pyA20/gpio/port.pyi index 17f69fe..17f69fe 100644 --- a/pyA20/gpio/port.pyi +++ b/include/py/pyA20/gpio/port.pyi diff --git a/pyA20/port.pyi b/include/py/pyA20/port.pyi index e69de29..e69de29 100644 --- a/pyA20/port.pyi +++ b/include/py/pyA20/port.pyi diff --git a/src/syncleo/__init__.py b/include/py/syncleo/__init__.py index 32563a5..32563a5 100644 --- a/src/syncleo/__init__.py +++ b/include/py/syncleo/__init__.py diff --git a/src/syncleo/kettle.py b/include/py/syncleo/kettle.py index d6e0dd6..d6e0dd6 100644 --- a/src/syncleo/kettle.py +++ b/include/py/syncleo/kettle.py diff --git a/src/syncleo/protocol.py b/include/py/syncleo/protocol.py index 36a1a8f..36a1a8f 100644 --- a/src/syncleo/protocol.py +++ b/include/py/syncleo/protocol.py diff --git a/misc/home_linux_boards/etc/default/homekit_ipcam_server b/misc/home_linux_boards/etc/default/homekit_ipcam_server new file mode 100644 index 0000000..e5ee2a3 --- /dev/null +++ b/misc/home_linux_boards/etc/default/homekit_ipcam_server @@ -0,0 +1,2 @@ +LISTEN="0.0.0.0:8320" +DATABASE_PATH="/data1/ipcam_server.db"
\ No newline at end of file diff --git a/misc/scripts/ipcam_capture_restart.sh b/misc/home_linux_boards/usr/local/bin/homekit_ipcam_capture_restart.sh index 85144da..85144da 100644..100755 --- a/misc/scripts/ipcam_capture_restart.sh +++ b/misc/home_linux_boards/usr/local/bin/homekit_ipcam_capture_restart.sh diff --git a/misc/scripts/ipcam_rtsp2hls_restart.sh b/misc/home_linux_boards/usr/local/bin/homekit_ipcam_rtsp2hls_restart.sh index 61ee623..61ee623 100644..100755 --- a/misc/scripts/ipcam_rtsp2hls_restart.sh +++ b/misc/home_linux_boards/usr/local/bin/homekit_ipcam_rtsp2hls_restart.sh diff --git a/misc/scripts/make_netns_per_upstream.sh b/misc/home_linux_boards/usr/local/bin/homekit_make_netns_per_upstream.sh index fb152fa..fb152fa 100644..100755 --- a/misc/scripts/make_netns_per_upstream.sh +++ b/misc/home_linux_boards/usr/local/bin/homekit_make_netns_per_upstream.sh diff --git a/tools/sunxi-h3-i2c-reset.sh b/misc/home_linux_boards/usr/local/bin/homekit_sunxi_h3_i2c_reset.sh index e654dfb..e654dfb 100644..100755 --- a/tools/sunxi-h3-i2c-reset.sh +++ b/misc/home_linux_boards/usr/local/bin/homekit_sunxi_h3_i2c_reset.sh diff --git a/tools/sunxi-setup-amixer.sh b/misc/home_linux_boards/usr/local/bin/homekit_sunxi_setup_amixer.sh index 5746514..5746514 100755 --- a/tools/sunxi-setup-amixer.sh +++ b/misc/home_linux_boards/usr/local/bin/homekit_sunxi_setup_amixer.sh diff --git a/tools/sync-recordings-to-remote.sh b/misc/home_linux_boards/usr/local/bin/homekit_sync_recordings_to_remote.sh index cf979d1..cf979d1 100755 --- a/tools/sync-recordings-to-remote.sh +++ b/misc/home_linux_boards/usr/local/bin/homekit_sync_recordings_to_remote.sh diff --git a/assets/mqtt_ca.crt b/misc/mqtt_ca.crt index 045ae10..045ae10 100644 --- a/assets/mqtt_ca.crt +++ b/misc/mqtt_ca.crt diff --git a/misc/openwrt/etc/rc.local b/misc/openwrt/etc/rc.local index 407d1eb..32b1227 100644 --- a/misc/openwrt/etc/rc.local +++ b/misc/openwrt/etc/rc.local @@ -17,7 +17,7 @@ done sleep 0.1 # block internet access for untrusted cameras -iptables -I FORWARD 1 -m set --match-set ipcam src ! -d 192.168.5.0 -j REJECT +iptables -I FORWARD 1 -m set --match-set ipcam src ! -d 192.168.5.0/24 -j REJECT # add some default routing rules ipset add mts-azov 192.168.5.0/24 # everybody diff --git a/tools/clickhouse-backup.sh b/misc/remote_server/usr/local/bin/clickhouse_backup.sh index 6e938e4..6e938e4 100644..100755 --- a/tools/clickhouse-backup.sh +++ b/misc/remote_server/usr/local/bin/clickhouse_backup.sh diff --git a/tools/remove-old-recordings.sh b/misc/remote_server/usr/local/bin/remove_old_recordings.sh index d376572..d376572 100644..100755 --- a/tools/remove-old-recordings.sh +++ b/misc/remote_server/usr/local/bin/remove_old_recordings.sh diff --git a/platformio/dumb_mqtt/src/main.cpp b/pio/dumb_mqtt/src/main.cpp index eefc165..eefc165 100644 --- a/platformio/dumb_mqtt/src/main.cpp +++ b/pio/dumb_mqtt/src/main.cpp diff --git a/platformio/relayctl/src/main.cpp b/pio/relayctl/src/main.cpp index c399641..c399641 100644 --- a/platformio/relayctl/src/main.cpp +++ b/pio/relayctl/src/main.cpp diff --git a/platformio/temphum/src/main.cpp b/pio/temphum/src/main.cpp index 2df8638..2df8638 100644 --- a/platformio/temphum/src/main.cpp +++ b/pio/temphum/src/main.cpp diff --git a/platformio/temphum_relayctl/src/main.cpp b/pio/temphum_relayctl/src/main.cpp index 0b05316..7f0945e 100644 --- a/platformio/temphum_relayctl/src/main.cpp +++ b/pio/temphum_relayctl/src/main.cpp @@ -27,6 +27,7 @@ void setup() { main::setup(); relay::init(); + relay::off(); #if CONFIG_MODULE == HOMEKIT_SI7021 sensor = new temphum::Si7021(); diff --git a/platformio/common/libs/main/library.json b/platformio/common/libs/main/library.json deleted file mode 100644 index 04eedab..0000000 --- a/platformio/common/libs/main/library.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "homekit_main", - "version": "1.0.8", - "build": { - "flags": "-I../../include" - }, - "dependencies": { - "homekit_mqtt_module_ota": "file://../common/libs/mqtt_module_ota", - "homekit_mqtt_module_diagnostics": "file://../common/libs/mqtt_module_diagnostics" - } -} - diff --git a/platformio/common/libs/mqtt_module_ota/library.json b/platformio/common/libs/mqtt_module_ota/library.json deleted file mode 100644 index 30db7d2..0000000 --- a/platformio/common/libs/mqtt_module_ota/library.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "homekit_mqtt_module_ota", - "version": "1.0.2", - "build": { - "flags": "-I../../include" - }, - "dependencies": { - "homekit_led": "file://../common/libs/led", - "homekit_mqtt": "file://../common/libs/mqtt" - } -} diff --git a/platformio/common/libs/mqtt_module_relay/library.json b/platformio/common/libs/mqtt_module_relay/library.json deleted file mode 100644 index e71cf95..0000000 --- a/platformio/common/libs/mqtt_module_relay/library.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "homekit_mqtt_module_relay", - "version": "1.0.3", - "build": { - "flags": "-I../../include" - }, - "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt", - "homekit_relay": "file://../common/libs/relay" - } -} diff --git a/platformio/common/libs/mqtt_module_temphum/library.json b/platformio/common/libs/mqtt_module_temphum/library.json deleted file mode 100644 index 9bb8cf1..0000000 --- a/platformio/common/libs/mqtt_module_temphum/library.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "homekit_mqtt_module_temphum", - "version": "1.0.9", - "build": { - "flags": "-I../../include" - }, - "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt", - "homekit_temphum": "file://../common/libs/temphum" - } -} diff --git a/platformio/dumb_mqtt/.gitignore b/platformio/dumb_mqtt/.gitignore deleted file mode 100644 index 3fe18ad..0000000 --- a/platformio/dumb_mqtt/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.pio -CMakeListsPrivate.txt -cmake-build-*/ diff --git a/platformio/relayctl/.gitignore b/platformio/relayctl/.gitignore deleted file mode 100644 index 3fe18ad..0000000 --- a/platformio/relayctl/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.pio -CMakeListsPrivate.txt -cmake-build-*/ diff --git a/platformio/temphum/.gitignore b/platformio/temphum/.gitignore deleted file mode 100644 index 3fe18ad..0000000 --- a/platformio/temphum/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.pio -CMakeListsPrivate.txt -cmake-build-*/ diff --git a/requirements.txt b/requirements.txt index 46f9b8c..521ae41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,20 @@ paho-mqtt==1.6.1 inverterd~=1.0.3 clickhouse-driver~=0.2.0 -toml~=0.10.2 mysql-connector-python~=8.0.27 -Werkzeug==2.2.2 +Werkzeug==2.3.6 uwsgi~=2.0.20 -python-telegram-bot==13.15 -requests==2.28.1 +python-telegram-bot==20.3 +requests==2.31.0 aiohttp~=3.8.1 -pytz==2022.6 +pytz==2023.3 PyYAML~=6.0 -apscheduler~=3.9.1 +apscheduler==3.10.1 psutil~=5.9.1 aioshutil~=1.1 -scikit-image~=0.19.3 - +scikit-image==0.21.0 +cerberus~=1.3.4 # following can be installed from debian repositories # matplotlib~=3.5.0 -Pillow~=9.1.1 - -# for polaris kettle protocol implementation -cryptography==38.0.4 -zeroconf==0.39.4
\ No newline at end of file +Pillow==9.5.0
\ No newline at end of file diff --git a/requirements_kettle.txt b/requirements_kettle.txt new file mode 100644 index 0000000..d003269 --- /dev/null +++ b/requirements_kettle.txt @@ -0,0 +1,3 @@ +# for polaris kettle protocol implementation +cryptography==41.0.1 +zeroconf==0.64.1
\ No newline at end of file diff --git a/src/esp_mqtt_util.py b/src/esp_mqtt_util.py deleted file mode 100755 index 263128c..0000000 --- a/src/esp_mqtt_util.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -from typing import Optional -from argparse import ArgumentParser -from enum import Enum - -from home.config import config -from home.mqtt import MqttRelay -from home.mqtt.esp import MqttEspBase -from home.mqtt.temphum import MqttTempHum -from home.mqtt.esp import MqttEspDevice - -mqtt_client: Optional[MqttEspBase] = None - - -class NodeType(Enum): - RELAY = 'relay' - TEMPHUM = 'temphum' - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('--device-id', type=str, required=True) - parser.add_argument('--type', type=str, required=True, - choices=[i.name.lower() for i in NodeType]) - - config.load('mqtt_util', parser=parser) - arg = parser.parse_args() - - mqtt_node_type = NodeType(arg.type) - devices = MqttEspDevice(id=arg.device_id) - - if mqtt_node_type == NodeType.RELAY: - mqtt_client = MqttRelay(devices=devices) - elif mqtt_node_type == NodeType.TEMPHUM: - mqtt_client = MqttTempHum(devices=devices) - - mqtt_client.set_message_callback(lambda device_id, payload: print(payload)) - mqtt_client.configure_tls() - try: - mqtt_client.connect_and_loop() - except KeyboardInterrupt: - mqtt_client.disconnect() diff --git a/src/gpiorelayd.py b/src/gpiorelayd.py deleted file mode 100755 index 85015a7..0000000 --- a/src/gpiorelayd.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import sys - -from home.config import config -from home.relay.sunxi_h3_server import RelayServer - -logger = logging.getLogger(__name__) - - -if __name__ == '__main__': - if not os.getegid() == 0: - sys.exit('Must be run as root.') - - config.load() - - try: - s = RelayServer(pinname=config.get('relayd.pin'), - addr=config.get_addr('relayd.listen')) - s.run() - except KeyboardInterrupt: - logger.info('Exiting...') diff --git a/src/home/api/__init__.py b/src/home/api/__init__.py deleted file mode 100644 index 782a61e..0000000 --- a/src/home/api/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -import importlib - -__all__ = ['WebAPIClient', 'RequestParams'] - - -def __getattr__(name): - if name in __all__: - module = importlib.import_module(f'.web_api_client', __name__) - return getattr(module, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/api/__init__.pyi b/src/home/api/__init__.pyi deleted file mode 100644 index 1b812d6..0000000 --- a/src/home/api/__init__.pyi +++ /dev/null @@ -1,4 +0,0 @@ -from .web_api_client import ( - RequestParams as RequestParams, - WebAPIClient as WebAPIClient -) diff --git a/src/home/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/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/config/__init__.py b/src/home/config/__init__.py deleted file mode 100644 index cc9c091..0000000 --- a/src/home/config/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .config import ConfigStore, config, is_development_mode, setup_logging diff --git a/src/home/config/config.py b/src/home/config/config.py deleted file mode 100644 index 4681685..0000000 --- a/src/home/config/config.py +++ /dev/null @@ -1,204 +0,0 @@ -import toml -import yaml -import logging -import os - -from os.path import join, isdir, isfile -from typing import Optional, Any, MutableMapping -from argparse import ArgumentParser -from ..util import parse_addr - - -def _get_config_path(name: str) -> str: - formats = ['toml', 'yaml'] - - dirname = join(os.environ['HOME'], '.config', name) - - if isdir(dirname): - for fmt in formats: - filename = join(dirname, f'config.{fmt}') - if isfile(filename): - return filename - - raise IOError(f'config not found in {dirname}') - - else: - filenames = [join(os.environ['HOME'], '.config', f'{name}.{format}') for format in formats] - for file in filenames: - if isfile(file): - return file - - raise IOError(f'config not found') - - -class ConfigStore: - data: MutableMapping[str, Any] - app_name: Optional[str] - - def __init__(self): - self.data = {} - self.app_name = None - - def load(self, name: Optional[str] = None, - use_cli=True, - parser: ArgumentParser = None): - self.app_name = name - - if (name is None) and (not use_cli): - raise RuntimeError('either config name must be none or use_cli must be True') - - log_default_fmt = False - log_file = None - log_verbose = False - no_config = name is False - - path = None - if use_cli: - if parser is None: - parser = ArgumentParser() - if not no_config: - parser.add_argument('-c', '--config', type=str, required=name is None, - help='Path to the config in TOML or YAML format') - parser.add_argument('-V', '--verbose', action='store_true') - parser.add_argument('--log-file', type=str) - parser.add_argument('--log-default-fmt', action='store_true') - args = parser.parse_args() - - if not no_config and args.config: - path = args.config - - if args.verbose: - log_verbose = True - if args.log_file: - log_file = args.log_file - if args.log_default_fmt: - log_default_fmt = args.log_default_fmt - - if not no_config and path is None: - path = _get_config_path(name) - - if no_config: - self.data = {} - else: - if path.endswith('.toml'): - self.data = toml.load(path) - elif path.endswith('.yaml'): - with open(path, 'r') as fd: - self.data = yaml.safe_load(fd) - - if 'logging' in self: - if not log_file and 'file' in self['logging']: - log_file = self['logging']['file'] - if log_default_fmt and 'default_fmt' in self['logging']: - log_default_fmt = self['logging']['default_fmt'] - - setup_logging(log_verbose, log_file, log_default_fmt) - - if use_cli: - return args - - def __getitem__(self, key): - return self.data[key] - - def __setitem__(self, key, value): - raise NotImplementedError('overwriting config values is prohibited') - - def __contains__(self, key): - return key in self.data - - def get(self, key: str, default=None): - cur = self.data - pts = key.split('.') - for i in range(len(pts)): - k = pts[i] - if i < len(pts)-1: - if k not in cur: - raise KeyError(f'key {k} not found') - else: - return cur[k] if k in cur else default - cur = self.data[k] - raise KeyError(f'option {key} not found') - - def get_addr(self, key: str): - return parse_addr(self.get(key)) - - def items(self): - return self.data.items() - - -config = ConfigStore() - - -def is_development_mode() -> bool: - if 'HK_MODE' in os.environ and os.environ['HK_MODE'] == 'dev': - return True - - return ('logging' in config) and ('verbose' in config['logging']) and (config['logging']['verbose'] is True) - - -def setup_logging(verbose=False, log_file=None, default_fmt=False): - logging_level = logging.INFO - if is_development_mode() or verbose: - logging_level = logging.DEBUG - _add_logging_level('TRACE', logging.DEBUG-5) - - log_config = {'level': logging_level} - if not default_fmt: - log_config['format'] = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - - if log_file is not None: - log_config['filename'] = log_file - log_config['encoding'] = 'utf-8' - - logging.basicConfig(**log_config) - - -# https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945 -def _add_logging_level(levelName, levelNum, methodName=None): - """ - Comprehensively adds a new logging level to the `logging` module and the - currently configured logging class. - - `levelName` becomes an attribute of the `logging` module with the value - `levelNum`. `methodName` becomes a convenience method for both `logging` - itself and the class returned by `logging.getLoggerClass()` (usually just - `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is - used. - - To avoid accidental clobberings of existing attributes, this method will - raise an `AttributeError` if the level name is already an attribute of the - `logging` module or if the method name is already present - - Example - ------- - >>> addLoggingLevel('TRACE', logging.DEBUG - 5) - >>> logging.getLogger(__name__).setLevel("TRACE") - >>> logging.getLogger(__name__).trace('that worked') - >>> logging.trace('so did this') - >>> logging.TRACE - 5 - - """ - if not methodName: - methodName = levelName.lower() - - if hasattr(logging, levelName): - raise AttributeError('{} already defined in logging module'.format(levelName)) - if hasattr(logging, methodName): - raise AttributeError('{} already defined in logging module'.format(methodName)) - if hasattr(logging.getLoggerClass(), methodName): - raise AttributeError('{} already defined in logger class'.format(methodName)) - - # This method was inspired by the answers to Stack Overflow post - # http://stackoverflow.com/q/2183233/2988730, especially - # http://stackoverflow.com/a/13638084/2988730 - def logForLevel(self, message, *args, **kwargs): - if self.isEnabledFor(levelNum): - self._log(levelNum, message, args, **kwargs) - def logToRoot(message, *args, **kwargs): - logging.log(levelNum, message, *args, **kwargs) - - logging.addLevelName(levelNum, levelName) - setattr(logging, levelName, levelNum) - setattr(logging.getLoggerClass(), methodName, logForLevel) - setattr(logging, methodName, logToRoot)
\ No newline at end of file diff --git a/src/home/mqtt/__init__.py b/src/home/mqtt/__init__.py deleted file mode 100644 index 982e2b6..0000000 --- a/src/home/mqtt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .mqtt import MqttBase -from .util import poll_tick -from .relay import MqttRelay, MqttRelayState -from .temphum import MqttTempHum
\ No newline at end of file diff --git a/src/home/mqtt/esp.py b/src/home/mqtt/esp.py deleted file mode 100644 index 56ced83..0000000 --- a/src/home/mqtt/esp.py +++ /dev/null @@ -1,106 +0,0 @@ -import re -import paho.mqtt.client as mqtt - -from .mqtt import MqttBase -from typing import Optional, Union -from .payload.esp import ( - OTAPayload, - OTAResultPayload, - DiagnosticsPayload, - InitialDiagnosticsPayload -) - - -class MqttEspDevice: - id: str - secret: Optional[str] - - def __init__(self, id: str, secret: Optional[str] = None): - self.id = id - self.secret = secret - - -class MqttEspBase(MqttBase): - _devices: list[MqttEspDevice] - _message_callback: Optional[callable] - _ota_publish_callback: Optional[callable] - - TOPIC_LEAF = 'esp' - - def __init__(self, - devices: Union[MqttEspDevice, list[MqttEspDevice]], - subscribe_to_updates=True): - super().__init__(clean_session=True) - if not isinstance(devices, list): - devices = [devices] - self._devices = devices - self._message_callback = None - self._ota_publish_callback = None - self._subscribe_to_updates = subscribe_to_updates - self._ota_mid = None - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - - if self._subscribe_to_updates: - for device in self._devices: - topic = f'hk/{device.id}/{self.TOPIC_LEAF}/#' - self._logger.debug(f"subscribing to {topic}") - client.subscribe(topic, qos=1) - - def on_publish(self, client: mqtt.Client, userdata, mid): - if self._ota_mid is not None and mid == self._ota_mid and self._ota_publish_callback: - self._ota_publish_callback() - - def set_message_callback(self, callback: callable): - self._message_callback = callback - - def on_message(self, client: mqtt.Client, userdata, msg): - try: - match = re.match(self.get_mqtt_topics(), msg.topic) - self._logger.debug(f'topic: {msg.topic}') - if not match: - return - - device_id = match.group(1) - subtopic = match.group(2) - - # try: - next(d for d in self._devices if d.id == device_id) - # except StopIteration:h - # return - - message = None - if subtopic == 'stat': - message = DiagnosticsPayload.unpack(msg.payload) - elif subtopic == 'stat1': - message = InitialDiagnosticsPayload.unpack(msg.payload) - elif subtopic == 'otares': - message = OTAResultPayload.unpack(msg.payload) - - if message and self._message_callback: - self._message_callback(device_id, message) - return True - - except Exception as e: - self._logger.exception(str(e)) - - def push_ota(self, - device_id, - filename: str, - publish_callback: callable, - qos: int): - device = next(d for d in self._devices if d.id == device_id) - assert device.secret is not None, 'device secret not specified' - - self._ota_publish_callback = publish_callback - payload = OTAPayload(secret=device.secret, filename=filename) - publish_result = self._client.publish(f'hk/{device.id}/{self.TOPIC_LEAF}/admin/ota', - payload=payload.pack(), - qos=qos) - self._ota_mid = publish_result.mid - self._client.loop_write() - - @classmethod - def get_mqtt_topics(cls, additional_topics: Optional[list[str]] = None): - return rf'^hk/(.*?)/{cls.TOPIC_LEAF}/(stat|stat1|otares'+('|'+('|'.join(additional_topics)) if additional_topics else '')+')$'
\ No newline at end of file diff --git a/src/home/mqtt/payload/__init__.py b/src/home/mqtt/payload/__init__.py deleted file mode 100644 index eee6709..0000000 --- a/src/home/mqtt/payload/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .base_payload import MqttPayload
\ No newline at end of file diff --git a/src/home/mqtt/payload/inverter.py b/src/home/mqtt/payload/inverter.py deleted file mode 100644 index 09388df..0000000 --- a/src/home/mqtt/payload/inverter.py +++ /dev/null @@ -1,73 +0,0 @@ -import struct - -from .base_payload import MqttPayload, bit_field -from typing import Tuple - -_mult_10 = lambda n: int(n*10) -_div_10 = lambda n: n/10 - - -class Status(MqttPayload): - # 46 bytes - FORMAT = 'IHHHHHHBHHHHHBHHHHHHHH' - - PACKER = { - 'grid_voltage': _mult_10, - 'grid_freq': _mult_10, - 'ac_output_voltage': _mult_10, - 'ac_output_freq': _mult_10, - 'battery_voltage': _mult_10, - 'battery_voltage_scc': _mult_10, - 'battery_voltage_scc2': _mult_10, - 'pv1_input_voltage': _mult_10, - 'pv2_input_voltage': _mult_10 - } - UNPACKER = { - 'grid_voltage': _div_10, - 'grid_freq': _div_10, - 'ac_output_voltage': _div_10, - 'ac_output_freq': _div_10, - 'battery_voltage': _div_10, - 'battery_voltage_scc': _div_10, - 'battery_voltage_scc2': _div_10, - 'pv1_input_voltage': _div_10, - 'pv2_input_voltage': _div_10 - } - - time: int - grid_voltage: float - grid_freq: float - ac_output_voltage: float - ac_output_freq: float - ac_output_apparent_power: int - ac_output_active_power: int - output_load_percent: int - battery_voltage: float - battery_voltage_scc: float - battery_voltage_scc2: float - battery_discharge_current: int - battery_charge_current: int - battery_capacity: int - inverter_heat_sink_temp: int - mppt1_charger_temp: int - mppt2_charger_temp: int - pv1_input_power: int - pv2_input_power: int - pv1_input_voltage: float - pv2_input_voltage: float - - # H - mppt1_charger_status: bit_field(0, 16, 2) - mppt2_charger_status: bit_field(0, 16, 2) - battery_power_direction: bit_field(0, 16, 2) - dc_ac_power_direction: bit_field(0, 16, 2) - line_power_direction: bit_field(0, 16, 2) - load_connected: bit_field(0, 16, 1) - - -class Generation(MqttPayload): - # 8 bytes - FORMAT = 'II' - - time: int - wh: int diff --git a/src/home/mqtt/payload/relay.py b/src/home/mqtt/payload/relay.py deleted file mode 100644 index 4902991..0000000 --- a/src/home/mqtt/payload/relay.py +++ /dev/null @@ -1,22 +0,0 @@ -from .base_payload import MqttPayload -from .esp import ( - OTAResultPayload, - OTAPayload, - InitialDiagnosticsPayload, - DiagnosticsPayload -) - - -class PowerPayload(MqttPayload): - FORMAT = '=12sB' - PACKER = { - 'state': lambda n: int(n), - 'secret': lambda s: s.encode('utf-8') - } - UNPACKER = { - 'state': lambda n: bool(n), - 'secret': lambda s: s.decode('utf-8') - } - - secret: str - state: bool diff --git a/src/home/mqtt/payload/sensors.py b/src/home/mqtt/payload/sensors.py deleted file mode 100644 index f99b307..0000000 --- a/src/home/mqtt/payload/sensors.py +++ /dev/null @@ -1,20 +0,0 @@ -from .base_payload import MqttPayload - -_mult_100 = lambda n: int(n*100) -_div_100 = lambda n: n/100 - - -class Temperature(MqttPayload): - FORMAT = 'IhH' - PACKER = { - 'temp': _mult_100, - 'rh': _mult_100, - } - UNPACKER = { - 'temp': _div_100, - 'rh': _div_100, - } - - time: int - temp: float - rh: float diff --git a/src/home/mqtt/payload/temphum.py b/src/home/mqtt/payload/temphum.py deleted file mode 100644 index c0b744e..0000000 --- a/src/home/mqtt/payload/temphum.py +++ /dev/null @@ -1,15 +0,0 @@ -from .base_payload import MqttPayload - -two_digits_precision = lambda x: round(x, 2) - - -class TempHumDataPayload(MqttPayload): - FORMAT = '=ddb' - UNPACKER = { - 'temp': two_digits_precision, - 'rh': two_digits_precision - } - - temp: float - rh: float - error: int diff --git a/src/home/mqtt/relay.py b/src/home/mqtt/relay.py deleted file mode 100644 index a90f19c..0000000 --- a/src/home/mqtt/relay.py +++ /dev/null @@ -1,71 +0,0 @@ -import paho.mqtt.client as mqtt -import re -import datetime - -from .payload.relay import ( - PowerPayload, -) -from .esp import MqttEspBase - - -class MqttRelay(MqttEspBase): - TOPIC_LEAF = 'relay' - - def set_power(self, device_id, enable: bool, secret=None): - device = next(d for d in self._devices if d.id == device_id) - secret = secret if secret else device.secret - - assert secret is not None, 'device secret not specified' - - payload = PowerPayload(secret=secret, - state=enable) - self._client.publish(f'hk/{device.id}/{self.TOPIC_LEAF}/power', - payload=payload.pack(), - qos=1) - self._client.loop_write() - - def on_message(self, client: mqtt.Client, userdata, msg): - if super().on_message(client, userdata, msg): - return - - try: - match = re.match(self.get_mqtt_topics(['power']), msg.topic) - if not match: - return - - device_id = match.group(1) - subtopic = match.group(2) - - message = None - if subtopic == 'power': - message = PowerPayload.unpack(msg.payload) - - if message and self._message_callback: - self._message_callback(device_id, message) - - except Exception as e: - self._logger.exception(str(e)) - - -class MqttRelayState: - enabled: bool - update_time: datetime.datetime - rssi: int - fw_version: int - ever_updated: bool - - def __init__(self): - self.ever_updated = False - self.enabled = False - self.rssi = 0 - - def update(self, - enabled: bool, - rssi: int, - fw_version=None): - self.ever_updated = True - self.enabled = enabled - self.rssi = rssi - self.update_time = datetime.datetime.now() - if fw_version: - self.fw_version = fw_version diff --git a/src/home/mqtt/temphum.py b/src/home/mqtt/temphum.py deleted file mode 100644 index 44810ef..0000000 --- a/src/home/mqtt/temphum.py +++ /dev/null @@ -1,54 +0,0 @@ -import paho.mqtt.client as mqtt -import re - -from enum import auto -from .payload.temphum import TempHumDataPayload -from .esp import MqttEspBase -from ..util import HashableEnum - - -class MqttTempHumNodes(HashableEnum): - KBN_SH_HALL = auto() - KBN_SH_BATHROOM = auto() - KBN_SH_LIVINGROOM = auto() - KBN_SH_BEDROOM = auto() - - KBN_BH_2FL = auto() - KBN_BH_2FL_STREET = auto() - KBN_BH_1FL_LIVINGROOM = auto() - KBN_BH_1FL_BEDROOM = auto() - KBN_BH_1FL_BATHROOM = auto() - - KBN_NH_1FL_INV = auto() - KBN_NH_1FL_CENTER = auto() - KBN_NH_1LF_KT = auto() - KBN_NH_1FL_DS = auto() - KBN_NH_1FS_EZ = auto() - - SPB_FLAT120_CABINET = auto() - - -class MqttTempHum(MqttEspBase): - TOPIC_LEAF = 'temphum' - - def on_message(self, client: mqtt.Client, userdata, msg): - if super().on_message(client, userdata, msg): - return - - try: - match = re.match(self.get_mqtt_topics(['data']), msg.topic) - if not match: - return - - device_id = match.group(1) - subtopic = match.group(2) - - message = None - if subtopic == 'data': - message = TempHumDataPayload.unpack(msg.payload) - - if message and self._message_callback: - self._message_callback(device_id, message) - - except Exception as e: - self._logger.exception(str(e)) diff --git a/src/home/mqtt/util.py b/src/home/mqtt/util.py deleted file mode 100644 index f71ffd8..0000000 --- a/src/home/mqtt/util.py +++ /dev/null @@ -1,8 +0,0 @@ -import time - - -def poll_tick(freq): - t = time.time() - while True: - t += freq - yield max(t - time.time(), 0) diff --git a/src/home/temphum/__init__.py b/src/home/temphum/__init__.py deleted file mode 100644 index 55a7e1f..0000000 --- a/src/home/temphum/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from .base import SensorType, TempHumSensor -from .si7021 import Si7021 -from .dht12 import DHT12 - -__all__ = [ - 'SensorType', - 'TempHumSensor', - 'create_sensor' -] - - -def create_sensor(type: SensorType, bus: int) -> TempHumSensor: - if type == SensorType.Si7021: - return Si7021(bus) - elif type == SensorType.DHT12: - return DHT12(bus) - else: - raise ValueError('unexpected sensor type') diff --git a/src/home/temphum/dht12.py b/src/home/temphum/dht12.py deleted file mode 100644 index d495766..0000000 --- a/src/home/temphum/dht12.py +++ /dev/null @@ -1,22 +0,0 @@ -from .base import I2CTempHumSensor - - -class DHT12(I2CTempHumSensor): - i2c_addr = 0x5C - - def _measure(self): - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0, 5) - if (raw[0] + raw[1] + raw[2] + raw[3]) & 0xff != raw[4]: - raise ValueError("checksum error") - return raw - - def temperature(self) -> float: - raw = self._measure() - temp = raw[2] + (raw[3] & 0x7f) * 0.1 - if raw[3] & 0x80: - temp *= -1 - return temp - - def humidity(self) -> float: - raw = self._measure() - return raw[0] + raw[1] * 0.1 diff --git a/src/home/temphum/si7021.py b/src/home/temphum/si7021.py deleted file mode 100644 index 6289e15..0000000 --- a/src/home/temphum/si7021.py +++ /dev/null @@ -1,13 +0,0 @@ -from .base import I2CTempHumSensor - - -class Si7021(I2CTempHumSensor): - i2c_addr = 0x40 - - def temperature(self) -> float: - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE3, 2) - return 175.72 * (raw[0] << 8 | raw[1]) / 65536.0 - 46.85 - - def humidity(self) -> float: - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE5, 2) - return 125.0 * (raw[0] << 8 | raw[1]) / 65536.0 - 6.0 diff --git a/src/inverter_mqtt_receiver.py b/src/inverter_mqtt_receiver.py deleted file mode 100755 index d40647e..0000000 --- a/src/inverter_mqtt_receiver.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -import paho.mqtt.client as mqtt -import re - -from home.mqtt import MqttBase -from home.mqtt.payload.inverter import Status, Generation -from home.database import InverterDatabase -from home.config import config - - -class MqttReceiver(MqttBase): - def __init__(self): - super().__init__(clean_session=False) - self.database = InverterDatabase() - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - self._logger.info("subscribing to hk/#") - client.subscribe('hk/#', qos=1) - - def on_message(self, client: mqtt.Client, userdata, msg): - super().on_message(client, userdata, msg) - try: - match = re.match(r'(?:home|hk)/(\d+)/(status|gen)', msg.topic) - if not match: - return - - # FIXME string home_id must be supported - home_id, what = int(match.group(1)), match.group(2) - if what == 'gen': - gen = Generation.unpack(msg.payload) - self.database.add_generation(home_id, gen.time, gen.wh) - - elif what == 'status': - s = Status.unpack(msg.payload) - self.database.add_status(home_id, - client_time=s.time, - grid_voltage=int(s.grid_voltage*10), - grid_freq=int(s.grid_freq * 10), - ac_output_voltage=int(s.ac_output_voltage * 10), - ac_output_freq=int(s.ac_output_freq * 10), - ac_output_apparent_power=s.ac_output_apparent_power, - ac_output_active_power=s.ac_output_active_power, - output_load_percent=s.output_load_percent, - battery_voltage=int(s.battery_voltage * 10), - battery_voltage_scc=int(s.battery_voltage_scc * 10), - battery_voltage_scc2=int(s.battery_voltage_scc2 * 10), - battery_discharge_current=s.battery_discharge_current, - battery_charge_current=s.battery_charge_current, - battery_capacity=s.battery_capacity, - inverter_heat_sink_temp=s.inverter_heat_sink_temp, - mppt1_charger_temp=s.mppt1_charger_temp, - mppt2_charger_temp=s.mppt2_charger_temp, - pv1_input_power=s.pv1_input_power, - pv2_input_power=s.pv2_input_power, - pv1_input_voltage=int(s.pv1_input_voltage * 10), - pv2_input_voltage=int(s.pv2_input_voltage * 10), - mppt1_charger_status=s.mppt1_charger_status, - mppt2_charger_status=s.mppt2_charger_status, - battery_power_direction=s.battery_power_direction, - dc_ac_power_direction=s.dc_ac_power_direction, - line_power_direction=s.line_power_direction, - load_connected=s.load_connected) - - except Exception as e: - self._logger.exception(str(e)) - - -if __name__ == '__main__': - config.load('inverter_mqtt_receiver') - - server = MqttReceiver() - server.connect_and_loop() - diff --git a/src/inverter_mqtt_sender.py b/src/inverter_mqtt_sender.py deleted file mode 100755 index fb2a2d8..0000000 --- a/src/inverter_mqtt_sender.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -import time -import datetime -import json -import inverterd - -from home.config import config -from home.mqtt import MqttBase, poll_tick -from home.mqtt.payload.inverter import Status, Generation - - -class MqttClient(MqttBase): - def __init__(self): - super().__init__() - - self._home_id = config['mqtt']['home_id'] - - self._inverter = inverterd.Client() - self._inverter.connect() - self._inverter.format(inverterd.Format.SIMPLE_JSON) - - def poll_inverter(self): - freq = int(config['mqtt']['inverter']['poll_freq']) - gen_freq = int(config['mqtt']['inverter']['generation_poll_freq']) - - g = poll_tick(freq) - gen_prev = 0 - while True: - time.sleep(next(g)) - - # read status - now = time.time() - try: - raw = self._inverter.exec('get-status') - except inverterd.InverterError as e: - self._logger.error(f'inverter error: {str(e)}') - # TODO send to server - continue - - data = json.loads(raw)['data'] - status = Status(time=round(now), **data) # FIXME this will crash with 99% probability - - self._client.publish(f'hk/{self._home_id}/status', - payload=status.pack(), - qos=1) - - # read today's generation stat - now = time.time() - if gen_prev == 0 or now - gen_prev >= gen_freq: - gen_prev = now - today = datetime.date.today() - try: - raw = self._inverter.exec('get-day-generated', (today.year, today.month, today.day)) - except inverterd.InverterError as e: - self._logger.error(f'inverter error: {str(e)}') - # TODO send to server - continue - - data = json.loads(raw)['data'] - gen = Generation(time=round(now), wh=data['wh']) - self._client.publish(f'hk/{self._home_id}/gen', - payload=gen.pack(), - qos=1) - - -if __name__ == '__main__': - config.load('inverter_mqtt_sender') - - client = MqttClient() - client.configure_tls() - client.connect_and_loop(loop_forever=False) - client.poll_inverter()
\ No newline at end of file diff --git a/src/openwrt_log_analyzer.py b/src/openwrt_log_analyzer.py deleted file mode 100755 index d31c3bf..0000000 --- a/src/openwrt_log_analyzer.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -import home.telegram as telegram - -from home.config import config -from home.database import BotsDatabase, SimpleState - -""" -config.toml example: - -[simple_state] -file = "/home/user/.config/openwrt_log_analyzer/state.txt" - -[mysql] -host = "localhost" -database = ".." -user = ".." -password = ".." - -[devices] -Device1 = "00:00:00:00:00:00" -Device2 = "01:01:01:01:01:01" - -[telegram] -chat_id = ".." -token = ".." -parse_mode = "HTML" - -[openwrt_log_analyzer] -limit = 10 -""" - - -def main(mac: str, - title: str, - ap: int) -> int: - db = BotsDatabase() - - data = db.get_openwrt_logs(filter_text=mac, - min_id=state['last_id'], - access_point=ap, - limit=config['openwrt_log_analyzer']['limit']) - if not data: - return 0 - - max_id = 0 - for log in data: - if log.id > max_id: - max_id = log.id - - text = '\n'.join(map(lambda s: str(s), data)) - telegram.send_message(f'<b>{title} (AP #{ap})</b>\n\n' + text) - - return max_id - - -if __name__ == '__main__': - config.load('openwrt_log_analyzer') - for ap in config['openwrt_log_analyzer']['aps']: - state_file = config['simple_state']['file'] - state_file = state_file.replace('.txt', f'-{ap}.txt') - - state = SimpleState(file=state_file, - default={'last_id': 0}) - - max_last_id = 0 - for name, mac in config['devices'].items(): - last_id = main(mac, title=name, ap=ap) - if last_id > max_last_id: - max_last_id = last_id - - if max_last_id: - state['last_id'] = max_last_id diff --git a/src/pump_bot.py b/src/pump_bot.py deleted file mode 100755 index de925db..0000000 --- a/src/pump_bot.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -from enum import Enum -from typing import Optional -from telegram import ReplyKeyboardMarkup, User - -from home.config import config -from home.telegram import bot -from home.telegram._botutil import user_any_name -from home.relay.sunxi_h3_client import RelayClient -from home.api.types import BotType - -config.load('pump_bot') - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - unknown_command="Неизвестная команда", - - enable="Включить", - enable_silently="Включить тихо", - enabled="Включен ✅", - - disable="Выключить", - disable_silently="Выключить тихо", - disabled="Выключен ❌", - - status="Статус", - done="Готово 👌", - user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> насос.', - user_action_on="включил", - user_action_off="выключил", -) -bot.lang.en( - start_message="Select command on the keyboard", - unknown_command="Unknown command", - - enable="Turn ON", - enable_silently="Turn ON silently", - enabled="Turned ON ✅", - - disable="Turn OFF", - disable_silently="Turn OFF silently", - disabled="Turned OFF ❌", - - status="Status", - done="Done 👌", - user_action_notification='User <a href="tg://user?id=%d">%s</a> turned the pump <b>%s</b>.', - user_action_on="ON", - user_action_off="OFF", -) - - -class UserAction(Enum): - ON = 'on' - OFF = 'off' - - -def get_relay() -> RelayClient: - relay = RelayClient(host=config['relay']['ip'], port=config['relay']['port']) - relay.connect() - return relay - - -def on(ctx: bot.Context, silent=False) -> None: - get_relay().on() - ctx.reply(ctx.lang('done')) - if not silent: - notify(ctx.user, UserAction.ON) - - -def off(ctx: bot.Context, silent=False) -> None: - get_relay().off() - ctx.reply(ctx.lang('done')) - if not silent: - notify(ctx.user, UserAction.OFF) - - -def notify(user: User, action: UserAction) -> None: - def text_getter(lang: str): - action_name = bot.lang.get(f'user_action_{action.value}', lang) - user_name = user_any_name(user) - return 'ℹ ' + bot.lang.get('user_action_notification', lang, - user.id, user_name, action_name) - - bot.notify_all(text_getter, exclude=(user.id,)) - - -@bot.handler(message='enable') -def enable_handler(ctx: bot.Context) -> None: - on(ctx) - - -@bot.handler(message='enable_silently') -def enable_s_handler(ctx: bot.Context) -> None: - on(ctx, True) - - -@bot.handler(message='disable') -def disable_handler(ctx: bot.Context) -> None: - off(ctx) - - -@bot.handler(message='disable_silently') -def disable_s_handler(ctx: bot.Context) -> None: - off(ctx, True) - - -@bot.handler(message='status') -def status(ctx: bot.Context) -> None: - ctx.reply( - ctx.lang('enabled') if get_relay().status() == 'on' else ctx.lang('disabled') - ) - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [ - [ctx.lang('enable'), ctx.lang('disable')], - ] - - if ctx.user_id in config['bot']['silent_users']: - buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')]) - - buttons.append([ctx.lang('status')]) - - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - bot.enable_logging(BotType.PUMP) - bot.run() diff --git a/src/relay_mqtt_bot.py b/src/relay_mqtt_bot.py deleted file mode 100755 index ebbff82..0000000 --- a/src/relay_mqtt_bot.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -from enum import Enum -from typing import Optional -from telegram import ReplyKeyboardMarkup -from functools import partial - -from home.config import config -from home.telegram import bot -from home.mqtt import MqttRelay, MqttRelayState -from home.mqtt.esp import MqttEspDevice -from home.mqtt.payload import MqttPayload -from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload - - -config.load('relay_mqtt_bot') - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - unknown_command="Неизвестная команда", - done="Готово 👌", -) -bot.lang.en( - start_message="Select command on the keyboard", - unknown_command="Unknown command", - done="Done 👌", -) - - -type_emojis = { - 'lamp': '💡' -} -status_emoji = { - 'on': '✅', - 'off': '❌' -} -mqtt_relay: Optional[MqttRelay] = None -relay_states: dict[str, MqttRelayState] = {} - - -class UserAction(Enum): - ON = 'on' - OFF = 'off' - - -def on_mqtt_message(home_id, message: MqttPayload): - if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): - kwargs = dict(rssi=message.rssi, enabled=message.flags.state) - if isinstance(message, InitialDiagnosticsPayload): - kwargs['fw_version'] = message.fw_version - if home_id not in relay_states: - relay_states[home_id] = MqttRelayState() - relay_states[home_id].update(**kwargs) - - -def enable_handler(home_id: str, ctx: bot.Context) -> None: - mqtt_relay.set_power(home_id, True) - ctx.reply(ctx.lang('done')) - - -def disable_handler(home_id: str, ctx: bot.Context) -> None: - mqtt_relay.set_power(home_id, False) - ctx.reply(ctx.lang('done')) - - -def start(ctx: bot.Context) -> None: - ctx.reply(ctx.lang('start_message')) - - -@bot.exceptionhandler -def exception_handler(e: Exception, ctx: bot.Context) -> bool: - return False - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [] - for device_id, data in config['relays'].items(): - labels = data['labels'] - type_emoji = type_emojis[data['type']] - row = [f'{type_emoji}{status_emoji[i.value]} {labels[ctx.user_lang]}' - for i in UserAction] - buttons.append(row) - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - devices = [] - for device_id, data in config['relays'].items(): - devices.append(MqttEspDevice(id=device_id, - secret=data['secret'])) - labels = data['labels'] - bot.lang.ru(**{device_id: labels['ru']}) - bot.lang.en(**{device_id: labels['en']}) - - type_emoji = type_emojis[data['type']] - - for action in UserAction: - messages = [] - for _lang, _label in labels.items(): - messages.append(f'{type_emoji}{status_emoji[action.value]} {labels[_lang]}') - bot.handler(texts=messages)(partial(enable_handler if action == UserAction.ON else disable_handler, device_id)) - - mqtt_relay = MqttRelay(devices=devices) - mqtt_relay.set_message_callback(on_mqtt_message) - mqtt_relay.configure_tls() - mqtt_relay.connect_and_loop(loop_forever=False) - - # bot.enable_logging(BotType.RELAY_MQTT) - bot.run(start_handler=start) - - mqtt_relay.disconnect() diff --git a/src/relay_mqtt_http_proxy.py b/src/relay_mqtt_http_proxy.py deleted file mode 100755 index 098facc..0000000 --- a/src/relay_mqtt_http_proxy.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -from home import http -from home.config import config -from home.mqtt import MqttRelay, MqttRelayState -from home.mqtt.esp import MqttEspDevice -from home.mqtt.payload import MqttPayload -from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload -from typing import Optional - -mqtt_relay: Optional[MqttRelay] = None -relay_states: dict[str, MqttRelayState] = {} - - -def on_mqtt_message(device_id, message: MqttPayload): - if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): - kwargs = dict(rssi=message.rssi, enabled=message.flags.state) - if device_id not in relay_states: - relay_states[device_id] = MqttRelayState() - relay_states[device_id].update(**kwargs) - - -class RelayMqttHttpProxy(http.HTTPServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.get('/relay/{id}/on', self.relay_on) - self.get('/relay/{id}/off', self.relay_off) - self.get('/relay/{id}/toggle', self.relay_toggle) - - async def _relay_on_off(self, - enable: Optional[bool], - req: http.Request): - device_id = req.match_info['id'] - device_secret = req.query['secret'] - - if enable is None: - if device_id in relay_states and relay_states[device_id].ever_updated: - cur_state = relay_states[device_id].enabled - else: - cur_state = False - enable = not cur_state - - mqtt_relay.set_power(device_id, enable, device_secret) - return self.ok() - - async def relay_on(self, req: http.Request): - return await self._relay_on_off(True, req) - - async def relay_off(self, req: http.Request): - return await self._relay_on_off(False, req) - - async def relay_toggle(self, req: http.Request): - return await self._relay_on_off(None, req) - - -if __name__ == '__main__': - config.load('relay_mqtt_http_proxy') - - mqtt_relay = MqttRelay(devices=[MqttEspDevice(id=device_id) for device_id in config.get('relay.devices')]) - mqtt_relay.configure_tls() - mqtt_relay.set_message_callback(on_mqtt_message) - mqtt_relay.connect_and_loop(loop_forever=False) - - proxy = RelayMqttHttpProxy(config.get_addr('server.listen')) - try: - proxy.run() - except KeyboardInterrupt: - mqtt_relay.disconnect() diff --git a/src/sensors_mqtt_sender.py b/src/sensors_mqtt_sender.py deleted file mode 100755 index 87a28ca..0000000 --- a/src/sensors_mqtt_sender.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -import time -import json - -from home.util import parse_addr, MySimpleSocketClient -from home.mqtt import MqttBase, poll_tick -from home.mqtt.payload.sensors import Temperature -from home.config import config - - -class MqttClient(MqttBase): - def __init__(self): - super().__init__(self) - self._home_id = config['mqtt']['home_id'] - - def poll(self): - freq = int(config['mqtt']['sensors']['poll_freq']) - self._logger.debug(f'freq={freq}') - - g = poll_tick(freq) - while True: - time.sleep(next(g)) - for k, v in config['mqtt']['sensors']['si7021'].items(): - host, port = parse_addr(v['addr']) - self.publish_si7021(host, port, k) - - def publish_si7021(self, host: str, port: int, name: str): - self._logger.debug(f"publish_si7021/{name}: {host}:{port}") - - try: - now = time.time() - socket = MySimpleSocketClient(host, port) - - socket.write('read') - response = json.loads(socket.read().strip()) - - temp = response['temp'] - humidity = response['humidity'] - - self._logger.debug(f'publish_si7021/{name}: temp={temp} humidity={humidity}') - - pld = Temperature(time=round(now), - temp=temp, - rh=humidity) - self._client.publish(f'hk/{self._home_id}/si7021/{name}', - payload=pld.pack(), - qos=1) - except Exception as e: - self._logger.exception(e) - - -if __name__ == '__main__': - config.load('sensors_mqtt_sender') - - client = MqttClient() - client.configure_tls() - client.connect_and_loop(loop_forever=False) - client.poll() diff --git a/systemd/camera_node.service b/systemd/camera_node.service index 0de3cc1..83471bd 100644 --- a/systemd/camera_node.service +++ b/systemd/camera_node.service @@ -6,7 +6,7 @@ After=network-online.target User=user Group=user Restart=on-failure -ExecStart=/home/user/homekit/src/camera_node.py +ExecStart=/home/user/homekit/bin/camera_node.py WorkingDirectory=/home/user [Install] diff --git a/systemd/camera_node@.service b/systemd/camera_node@.service index 414881e..a272002 100644 --- a/systemd/camera_node@.service +++ b/systemd/camera_node@.service @@ -6,7 +6,7 @@ After=network-online.target User=user Group=user Restart=on-failure -ExecStart=/home/user/homekit/src/camera_node.py --config /home/user/.config/camera_node.%i.yaml +ExecStart=/home/user/homekit/bin/camera_node.py --config /home/user/.config/camera_node.%i.yaml WorkingDirectory=/home/user [Install] diff --git a/systemd/esp32cam_capture_diff_node.service b/systemd/esp32cam_capture_diff_node.service index ecc4861..a742edc 100644 --- a/systemd/esp32cam_capture_diff_node.service +++ b/systemd/esp32cam_capture_diff_node.service @@ -6,7 +6,7 @@ After=network-online.target User=user Group=user Restart=on-failure -ExecStart=/home/user/homekit/src/esp32cam_capture_diff_node.py +ExecStart=/home/user/homekit/bin/esp32cam_capture_diff_node.py WorkingDirectory=/home/user [Install] diff --git a/systemd/gpiorelayd@.service b/systemd/gpiorelayd@.service index 0cc0582..e3922dc 100644 --- a/systemd/gpiorelayd@.service +++ b/systemd/gpiorelayd@.service @@ -1,12 +1,13 @@ [Unit] -Description=GPIO Relay Daemon +Description=Homekit: GPIO Relay Daemon for H3 boards After=network-online.target [Service] User=root Group=root Restart=on-failure -ExecStart=/home/user/homekit/src/gpiorelayd.py -c /etc/gpiorelayd.conf.d/%i.toml +EnvironmentFile=/etc/default/homekit_gpiorelayd_%i +ExecStart=/home/user/homekit/bin/gpiorelayd.py --pin $PIN --listen $LISTEN WorkingDirectory=/root [Install] diff --git a/systemd/inverter_bot.service b/systemd/inverter_bot.service index 96612ae..c5d4aec 100644 --- a/systemd/inverter_bot.service +++ b/systemd/inverter_bot.service @@ -6,7 +6,7 @@ After=inverterd.service User=user Group=user Restart=on-failure -ExecStart=/home/user/homekit/src/inverter_bot.py +ExecStart=/home/user/homekit/bin/inverter_bot.py WorkingDirectory=/home/user [Install] diff --git a/systemd/inverter_mqtt_receiver.service b/systemd/inverter_mqtt_receiver.service new file mode 100644 index 0000000..88f9169 --- /dev/null +++ b/systemd/inverter_mqtt_receiver.service @@ -0,0 +1,13 @@ +[Unit] +Description=Inverter MQTT receiver +After=clickhouse-server.service + +[Service] +User=user +Group=user +Restart=on-failure +ExecStart=/home/user/homekit/bin/inverter_mqtt_util.py receiver +WorkingDirectory=/home/user + +[Install] +WantedBy=multi-user.target
\ No newline at end of file diff --git a/systemd/inverter_mqtt_sender.service b/systemd/inverter_mqtt_sender.service index e3925f6..bf6ab61 100644 --- a/systemd/inverter_mqtt_sender.service +++ b/systemd/inverter_mqtt_sender.service @@ -6,7 +6,7 @@ After=inverterd.service User=user Group=user Restart=on-failure -ExecStart=/home/user/homekit/src/inverter_mqtt_sender.py +ExecStart=/home/user/homekit/bin/inverter_mqtt_util.py sender WorkingDirectory=/home/user [Install] diff --git a/systemd/ipcam_capture@.service b/systemd/ipcam_capture@.service deleted file mode 100644 index b1c363e..0000000 --- a/systemd/ipcam_capture@.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=save ipcam streams -After=network-online.target - -[Service] -Restart=always -RestartSec=3 -User=user -Group=user -EnvironmentFile=/etc/ipcam_capture.conf.d/%i.conf -ExecStart=/home/user/homekit/tools/ipcam_capture.sh --outdir $OUTDIR --creds $CREDS --ip $IP --port $PORT $ARGS -Restart=always - -[Install] -WantedBy=multi-user.target diff --git a/systemd/ipcam_rtsp2hls@.service b/systemd/ipcam_rtsp2hls@.service deleted file mode 100644 index addd819..0000000 --- a/systemd/ipcam_rtsp2hls@.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=convert rtsp to hls for viewing live camera feeds in browser -After=network-online.target - -[Service] -Restart=always -RestartSec=3 -User=user -Group=user -EnvironmentFile=/etc/ipcam_rtsp2hls.conf.d/%i.conf -ExecStart=/home/user/homekit/tools/ipcam_rtsp2hls.sh --name %i --user $USER --password $PASSWORD --ip $IP --port $PORT $ARGS - -[Install] -WantedBy=multi-user.target diff --git a/systemd/ipcam_server.service b/systemd/ipcam_server.service index 07ac95f..53e588d 100644 --- a/systemd/ipcam_server.service +++ b/systemd/ipcam_server.service @@ -1,5 +1,5 @@ [Unit] -Description=HomeKit IPCam Server +Description=Homekit IPCam Server After=network-online.target [Service] @@ -7,7 +7,8 @@ User=user Group=user Restart=always RestartSec=10 -ExecStart=/home/user/homekit/src/ipcam_server.py +EnvironmentFile=/etc/default/homekit_ipcam_server +ExecStart=/home/user/homekit/bin/ipcam_server.py --listen "$LISTEN" --database-path "$DATABASE_PATH" WorkingDirectory=/home/user [Install] diff --git a/systemd/polaris_kettle_bot.service b/systemd/polaris_kettle_bot.service index f91ed60..86bb293 100644 --- a/systemd/polaris_kettle_bot.service +++ b/systemd/polaris_kettle_bot.service @@ -6,7 +6,7 @@ After=network-online.target Restart=on-failure User=user WorkingDirectory=/home/user -ExecStart=/home/user/homekit/src/polaris_kettle_bot.py +ExecStart=/home/user/homekit/bin/polaris_kettle_bot.py [Install] WantedBy=multi-user.target
\ No newline at end of file diff --git a/systemd/pump_bot.service b/systemd/pump_bot.service index dd8a46b..b59f5b9 100644 --- a/systemd/pump_bot.service +++ b/systemd/pump_bot.service @@ -6,7 +6,7 @@ After=gpiorelayd.service User=user Group=user Restart=on-failure -ExecStart=/home/user/homekit/src/pump_bot.py +ExecStart=/home/user/homekit/bin/pump_bot.py WorkingDirectory=/home/user [Install] diff --git a/systemd/pump_mqtt_bot.service b/systemd/pump_mqtt_bot.service index 95f9419..6c72cbf 100644 --- a/systemd/pump_mqtt_bot.service +++ b/systemd/pump_mqtt_bot.service @@ -6,7 +6,7 @@ After=network-online.target Restart=on-failure User=user WorkingDirectory=/home/user -ExecStart=/home/user/homekit/src/pump_mqtt_bot.py +ExecStart=/home/user/homekit/bin/pump_mqtt_bot.py [Install] WantedBy=multi-user.target
\ No newline at end of file diff --git a/systemd/relay_mqtt_bot.service b/systemd/relay_mqtt_bot.service index 93696ac..3bac158 100644 --- a/systemd/relay_mqtt_bot.service +++ b/systemd/relay_mqtt_bot.service @@ -6,7 +6,7 @@ After=network-online.target Restart=on-failure User=user WorkingDirectory=/home/user -ExecStart=/home/user/homekit/src/relay_mqtt_bot.py +ExecStart=/home/user/homekit/bin/relay_mqtt_bot.py [Install] WantedBy=multi-user.target
\ No newline at end of file diff --git a/systemd/relay_mqtt_http_proxy.service b/systemd/relay_mqtt_http_proxy.service index 316a920..8301d52 100644 --- a/systemd/relay_mqtt_http_proxy.service +++ b/systemd/relay_mqtt_http_proxy.service @@ -6,7 +6,7 @@ After=network-online.target Restart=on-failure User=user WorkingDirectory=/home/user -ExecStart=/home/user/homekit/src/relay_mqtt_http_proxy.py +ExecStart=/home/user/homekit/bin/relay_mqtt_http_proxy.py [Install] WantedBy=multi-user.target
\ No newline at end of file diff --git a/systemd/sensors_bot.service b/systemd/sensors_bot.service index 50128b3..2470d92 100644 --- a/systemd/sensors_bot.service +++ b/systemd/sensors_bot.service @@ -6,7 +6,7 @@ After=network-online.target Restart=on-failure User=user WorkingDirectory=/home/user -ExecStart=/home/user/homekit/src/sensors_bot.py +ExecStart=/home/user/homekit/bin/sensors_bot.py [Install] WantedBy=multi-user.target
\ No newline at end of file diff --git a/systemd/sensors_mqtt_receiver.service b/systemd/sensors_mqtt_receiver.service index e67c112..5b9ff6a 100644 --- a/systemd/sensors_mqtt_receiver.service +++ b/systemd/sensors_mqtt_receiver.service @@ -1,12 +1,12 @@ [Unit] -Description=sensors mqtt receiver +Description=temphum mqtt receiver After=network.target [Service] User=user Group=user Restart=on-failure -ExecStart=python3 /home/user/home/src/sensors_mqtt_receiver.py +ExecStart=python3 /home/user/home/src/temphum_mqtt_receiver.py WorkingDirectory=/home/user [Install] diff --git a/systemd/sensors_mqtt_sender.service b/systemd/sensors_mqtt_sender.service deleted file mode 100644 index a271d72..0000000 --- a/systemd/sensors_mqtt_sender.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Sensors MQTT sender -After=temphumd.service - -[Service] -User=user -Group=user -Restart=on-failure -ExecStart=/home/user/homekit/src/sensors_mqtt_sender.py -WorkingDirectory=/home/user - -[Install] -WantedBy=multi-user.target
\ No newline at end of file diff --git a/systemd/sound_bot.service b/systemd/sound_bot.service index 51a9e0f..e0b5500 100644 --- a/systemd/sound_bot.service +++ b/systemd/sound_bot.service @@ -6,7 +6,7 @@ After=network-online.target Restart=on-failure User=user WorkingDirectory=/home/user -ExecStart=/home/user/homekit/src/sound_bot.py +ExecStart=/home/user/homekit/bin/sound_bot.py [Install] WantedBy=multi-user.target
\ No newline at end of file diff --git a/systemd/sound_node.service b/systemd/sound_node.service index e3e3afd..a14ec1f 100644 --- a/systemd/sound_node.service +++ b/systemd/sound_node.service @@ -6,7 +6,7 @@ After=network-online.target User=root Group=root Restart=on-failure -ExecStart=/home/user/homekit/src/sound_node.py --config /etc/sound_node.toml +ExecStart=/home/user/homekit/bin/sound_node.py --config /etc/sound_node.toml WorkingDirectory=/root [Install] diff --git a/systemd/sound_sensor_node.service b/systemd/sound_sensor_node.service index d10f976..dfc2ecd 100644 --- a/systemd/sound_sensor_node.service +++ b/systemd/sound_sensor_node.service @@ -6,7 +6,7 @@ After=network-online.target User=root Group=root Restart=on-failure -ExecStart=/home/user/homekit/src/sound_sensor_node.py --config /etc/sound_sensor_node.toml +ExecStart=/home/user/homekit/bin/sound_sensor_node.py --config /etc/sound_sensor_node.toml WorkingDirectory=/root [Install] diff --git a/systemd/sound_sensor_server.service b/systemd/sound_sensor_server.service index 0133e53..5ab08cd 100644 --- a/systemd/sound_sensor_server.service +++ b/systemd/sound_sensor_server.service @@ -6,7 +6,7 @@ After=network-online.target User=user Group=user Restart=on-failure -ExecStart=/home/user/homekit/src/sound_sensor_server.py +ExecStart=/home/user/homekit/bin/sound_sensor_server.py WorkingDirectory=/home/user [Install] diff --git a/systemd/temphumd.service b/systemd/temphumd.service index 1da9617..dd5ec55 100644 --- a/systemd/temphumd.service +++ b/systemd/temphumd.service @@ -4,7 +4,7 @@ After=network-online.target [Service] Restart=on-failure -ExecStart=/home/user/homekit/src/temphumd.py --config /etc/temphumd.toml +ExecStart=/home/user/homekit/bin/temphumd.py --config /etc/temphumd.toml [Install] WantedBy=multi-user.target diff --git a/systemd/temphumd@.service b/systemd/temphumd@.service index d1c840d..7b1b11e 100644 --- a/systemd/temphumd@.service +++ b/systemd/temphumd@.service @@ -4,7 +4,7 @@ After=network-online.target [Service] Restart=on-failure -ExecStart=/home/user/homekit/src/temphumd.py --config /etc/temphumd-%i.toml +ExecStart=/home/user/homekit/bin/temphumd.py --config /etc/temphumd-%i.toml [Install] WantedBy=multi-user.target diff --git a/test/__init__.py b/test/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/test/__init__.py +++ /dev/null diff --git a/test/__py_include.py b/test/__py_include.py new file mode 100644 index 0000000..8f98830 --- /dev/null +++ b/test/__py_include.py @@ -0,0 +1,9 @@ +import sys +import os.path + +for _name in ('include/py',): + sys.path.extend([ + os.path.realpath( + os.path.join(os.path.dirname(os.path.join(__file__)), '..', _name) + ) + ])
\ No newline at end of file diff --git a/test/mqtt_relay_server_util.py b/test/mqtt_relay_server_util.py new file mode 100755 index 0000000..6c02d75 --- /dev/null +++ b/test/mqtt_relay_server_util.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +import __py_include + +from homekit.config import config + + +if __name__ == '__main__': + print(config) + # config.load_app('test_mqtt_relay_server') + # relay = MQTTRelayClient('test') + # relay.connect_and_loop() diff --git a/test/mqtt_relay_util.py b/test/mqtt_relay_util.py new file mode 100755 index 0000000..394bbe8 --- /dev/null +++ b/test/mqtt_relay_util.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +import __py_include + +from argparse import ArgumentParser +from homekit.config import config +from homekit.mqtt.relay import MQTTRelayController + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('--on', action='store_true') + parser.add_argument('--off', action='store_true') + parser.add_argument('--stat', action='store_true') + + config.load_app('test_mqtt_relay', parser=parser) + arg = parser.parse_args() + + relay = MQTTRelayController('test') + relay.connect_and_loop(loop_forever=False) + + if arg.on: + relay.set_power(True) + + elif arg.off: + relay.set_power(False) + + elif arg.stat: + relay.send_stat(dict( + state=False, + signal=-59, + fw_v=1.0 + ))
\ No newline at end of file diff --git a/test/test.py b/test/test.py deleted file mode 100755 index 7ea37e6..0000000 --- a/test/test.py +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env python -from home.relay import RelayClient - - -if __name__ == '__main__': - c = RelayClient() - print(c, c._host)
\ No newline at end of file diff --git a/test/test_amixer.py b/test/test_amixer.py index c8bd546..e4abc73 100755 --- a/test/test_amixer.py +++ b/test/test_amixer.py @@ -1,12 +1,9 @@ #!/usr/bin/env python3 -import sys, os.path -sys.path.extend([ - os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..')), -]) +import __py_include from argparse import ArgumentParser -from src.home.config import config -from src.home.audio import amixer +from homekit.config import config +from homekit.audio import amixer def validate_control(input: str): @@ -28,7 +25,7 @@ if __name__ == '__main__': parser.add_argument('--decr', type=str) # parser.add_argument('--dump-config', action='store_true') - args = config.load('test_amixer', parser=parser) + args = config.load_app('test_amixer', parser=parser) # if args.dump_config: # print(config.data) diff --git a/test/test_api.py b/test/test_api.py index 1f6361c..b35a597 100755 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,19 +1,12 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include -from src.home.api import WebAPIClient -from src.home.api.types import BotType -from src.home.config import config +from homekit.api import WebApiClient +from homekit.config import config if __name__ == '__main__': - config.load('test_api') + config.load_app('test_api') - api = WebAPIClient() - print(api.log_bot_request(BotType.ADMIN, 1, "test_api.py")) + # api = WebApiClient() + # print(api.log_bot_request(BotType.ADMIN, 1, "test_api.py")) diff --git a/test/test_esp32_cam.py b/test/test_esp32_cam.py index 27ce379..962768f 100755 --- a/test/test_esp32_cam.py +++ b/test/test_esp32_cam.py @@ -1,18 +1,12 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include from pprint import pprint from argparse import ArgumentParser from time import sleep -from src.home.util import parse_addr -from src.home.camera import esp32 -from src.home.config import config +from homekit.util import Addr +from homekit.camera import esp32 +from homekit.config import config if __name__ == '__main__': parser = ArgumentParser() @@ -21,8 +15,8 @@ if __name__ == '__main__': parser.add_argument('--status', action='store_true', help='print status and exit') - arg = config.load(False, parser=parser) - cam = esp32.WebClient(addr=parse_addr(arg.addr)) + arg = config.load_app(False, parser=parser) + cam = esp32.WebClient(addr=Addr.fromstring(arg.addr)) if arg.status: status = cam.getstatus() diff --git a/test/test_inverter_monitor.py b/test/test_inverter_monitor.py index 3b1c6b0..3231bab 100755 --- a/test/test_inverter_monitor.py +++ b/test/test_inverter_monitor.py @@ -1,22 +1,11 @@ #!/usr/bin/env python3 -import cmd -import time -import logging -import socket -import sys -import threading -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include from enum import Enum, auto from typing import Optional -from src.home.util import stringify -from src.home.config import config -from src.home.inverter import ( +from homekit.util import stringify +from homekit.config import config +from homekit.inverter import ( wrapper_instance as inverter, InverterMonitor, @@ -372,5 +361,5 @@ def main(): if __name__ == '__main__': - config.load('test_inverter_monitor') + config.load_app('test_inverter_monitor') main() diff --git a/test/test_ipcam_server_cleanup.py b/test/test_ipcam_server_cleanup.py index b7eb23a..ae8d31c 100644 --- a/test/test_ipcam_server_cleanup.py +++ b/test/test_ipcam_server_cleanup.py @@ -1,19 +1,13 @@ #!/usr/bin/env python3 -import shutil -import sys +import __py_include +import logging import os +import shutil import re -import logging -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) from functools import cmp_to_key from datetime import datetime -from pprint import pprint -from src.home.config import config +from homekit.config import config logger = logging.getLogger(__name__) @@ -77,5 +71,5 @@ def cleanup_job(): if __name__ == '__main__': - config.load('ipcam_server') + config.load_app('ipcam_server') cleanup_job() diff --git a/test/test_polaris_stuff.py b/test/test_polaris_stuff.py index b921891..7778667 100755 --- a/test/test_polaris_stuff.py +++ b/test/test_polaris_stuff.py @@ -1,13 +1,6 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) - -import src.syncleo as polaris +import __py_include +import syncleo if __name__ == '__main__': diff --git a/test/test_record_upload.py b/test/test_record_upload.py index cbd3ca2..f9c83d8 100755 --- a/test/test_record_upload.py +++ b/test/test_record_upload.py @@ -1,19 +1,12 @@ #!/usr/bin/env python3 +import __py_include import logging -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) - import time -from src.home.api import WebAPIClient, RequestParams -from src.home.config import config -from src.home.media import SoundRecordClient -from src.home.util import parse_addr +from homekit.api import WebApiClient, RequestParams +from homekit.config import config +from homekit.media import SoundRecordClient +from homekit.util import Addr logger = logging.getLogger(__name__) @@ -64,17 +57,17 @@ def api_success_handler(response, name, req: RequestParams): if __name__ == '__main__': - config.load('test_record_upload') + config.load_app('test_record_upload') nodes = {} for name, addr in config['nodes'].items(): - nodes[name] = parse_addr(addr) + nodes[name] = Addr(addr) record = SoundRecordClient(nodes, error_handler=record_error, finished_handler=record_finished, download_on_finish=True) - api = WebAPIClient() + api = WebApiClient() api.enable_async(error_handler=api_error_handler, success_handler=api_success_handler) diff --git a/test/test_send_fake_sound_hit.py b/test/test_send_fake_sound_hit.py index 9660c45..3cc3e50 100755 --- a/test/test_send_fake_sound_hit.py +++ b/test/test_send_fake_sound_hit.py @@ -1,14 +1,8 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include from argparse import ArgumentParser -from src.home.util import send_datagram, stringify, parse_addr +from homekit.util import send_datagram, stringify, Addr if __name__ == '__main__': @@ -22,4 +16,4 @@ if __name__ == '__main__': args = parser.parse_args() - send_datagram(stringify([args.name, args.hits]), parse_addr(args.server)) + send_datagram(stringify([args.name, args.hits]), Addr.fromstring(args.server)) diff --git a/test/test_sensors_plot.py b/test/test_sensors_plot.py deleted file mode 100755 index e69de29..0000000 --- a/test/test_sensors_plot.py +++ /dev/null diff --git a/test/test_sound_node_client.py b/test/test_sound_node_client.py index 16feb78..c3748ca 100755 --- a/test/test_sound_node_client.py +++ b/test/test_sound_node_client.py @@ -1,11 +1,8 @@ #!/usr/bin/env python3 -import sys, os.path -sys.path.extend([ - os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..')), -]) +import __py_include -from src.home.api.errors import ApiResponseError -from src.home.media import SoundNodeClient +from homekit.api.errors import ApiResponseError +from homekit.media import SoundNodeClient if __name__ == '__main__': diff --git a/test/test_sound_server_api.py b/test/test_sound_server_api.py index e68c6f8..11cd422 100755 --- a/test/test_sound_server_api.py +++ b/test/test_sound_server_api.py @@ -1,17 +1,11 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include import threading from time import sleep -from src.home.config import config -from src.home.api import WebAPIClient -from src.home.api.types import SoundSensorLocation +from homekit.config import config +from homekit.api import WebApiClient +from homekit.api.types import SoundSensorLocation from typing import List, Tuple interrupted = False @@ -56,10 +50,10 @@ def hits_sender(): if __name__ == '__main__': - config.load('test_api') + config.load_app('test_api') hc = HitCounter() - api = WebAPIClient() + api = WebApiClient() hc.add('spb1', 1) # hc.add('big_house', 123) diff --git a/test/test_stopwatch.py b/test/test_stopwatch.py index 6ff2c0e..1da0fe7 100755 --- a/test/test_stopwatch.py +++ b/test/test_stopwatch.py @@ -1,4 +1,5 @@ -from home.util import Stopwatch, StopwatchError +import __py_include +from homekit.util import Stopwatch, StopwatchError from time import sleep diff --git a/test/test_telegram_aio_send_photo.py b/test/test_telegram_aio_send_photo.py index 705e534..019fa92 100644 --- a/test/test_telegram_aio_send_photo.py +++ b/test/test_telegram_aio_send_photo.py @@ -1,16 +1,9 @@ #!/usr/bin/env python3 +import __py_include import asyncio -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import homekit.telegram.aio as telegram -import src.home.telegram.aio as telegram - -from src.home.config import config +from homekit.config import config async def main(): @@ -20,7 +13,7 @@ async def main(): if __name__ == '__main__': - config.load('test_telegram_aio_send_photo') + config.load_app('test_telegram_aio_send_photo') loop = asyncio.get_event_loop() asyncio.ensure_future(main()) diff --git a/tools/ipcam_capture.sh b/tools/ipcam_capture.sh deleted file mode 100755 index 08b9093..0000000 --- a/tools/ipcam_capture.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/bash - -PROGNAME="$0" -PORT=554 -IP= -CREDS= -DEBUG=0 -CHANNEL=1 -FORCE_UDP=0 -FORCE_TCP=0 -EXTENSION="mp4" - -die() { - echo >&2 "error: $@" - exit 1 -} - -usage() { - cat <<EOF -usage: $PROGNAME [OPTIONS] COMMAND - -Options: - --outdir output directory - --ip camera IP - --port RTSP port (default: 554) - --creds - --debug - --force-tcp - --force-udp - --channel 1|2 - -EOF - exit -} - -validate_channel() { - local c="$1" - case "$c" in - 1 | 2) - : - ;; - *) - die "Invalid channel" - ;; - esac -} - -[ -z "$1" ] && usage - -while [[ $# -gt 0 ]]; do - case "$1" in - --ip | --port | --creds | --outdir) - _var=${1:2} - _var=${_var^^} - printf -v "$_var" '%s' "$2" - shift - ;; - - --debug) - DEBUG=1 - ;; - - --force-tcp) - FORCE_TCP=1 - ;; - - --force-udp) - FORCE_UDP=1 - ;; - - --channel) - CHANNEL="$2" - shift - ;; - - --mov) - EXTENSION="mov" - ;; - - --mpv) - EXTENSION="mpv" - ;; - - *) - die "Unrecognized argument: $1" - ;; - esac - shift -done - -[ -z "$OUTDIR" ] && die "You must specify output directory (--outdir)." -[ -z "$IP" ] && die "You must specify camera IP address (--ip)." -[ -z "$PORT" ] && die "Port can't be empty." -[ -z "$CREDS" ] && die "You must specify credentials (--creds)." -validate_channel "$CHANNEL" - -if [ ! -d "${OUTDIR}" ]; then - mkdir "${OUTDIR}" || die "Failed to create ${OUTDIR}/${NAME}!" - echo "Created $OUTDIR." -fi - -args= -if [ "$DEBUG" = "1" ]; then - args="$args -v info" -else - args="$args -nostats -loglevel warning" -fi - -if [ "$FORCE_TCP" = "1" ]; then - args="$args -rtsp_transport tcp" -elif [ "$FORCE_UDP" = "1" ]; then - args="$args -rtsp_transport udp" -fi - -[ ! -z "$CREDS" ] && CREDS="${CREDS}@" - -ffmpeg $args -i rtsp://${CREDS}${IP}:${PORT}/Streaming/Channels/${CHANNEL} \ - -c copy -f segment -strftime 1 -segment_time 00:10:00 -segment_atclocktime 1 \ - "$OUTDIR/record_%Y-%m-%d-%H.%M.%S.${EXTENSION}" diff --git a/tools/ipcam_rtsp2hls.sh b/tools/ipcam_rtsp2hls.sh deleted file mode 100755 index c321820..0000000 --- a/tools/ipcam_rtsp2hls.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/bin/bash - -PROGNAME="$0" -OUTDIR=/var/ipcamfs # should be tmpfs -PORT=554 -NAME= -IP= -USER= -PASSWORD= -DEBUG=0 -CHANNEL=1 -FORCE_UDP=0 -FORCE_TCP=0 -CUSTOM_PATH= - -die() { - echo >&2 "error: $@" - exit 1 -} - -usage() { - cat <<EOF -usage: $PROGNAME [OPTIONS] COMMAND - -Options: - --ip camera IP - --port RTSP port (default: 554) - --name camera name (chunks will be stored under $OUTDIR/{name}/) - --user - --password - --debug - --force-tcp - --force-udp - --channel 1|2 - --custom-path PATH - -EOF - exit -} - -validate_channel() { - local c="$1" - case "$c" in - 1|2) - : - ;; - *) - die "Invalid channel" - ;; - esac -} - -[ -z "$1" ] && usage - -while [[ $# -gt 0 ]]; do - case "$1" in - --ip|--port|--name|--user|--password) - _var=${1:2} - _var=${_var^^} - printf -v "$_var" '%s' "$2" - shift - ;; - - --debug) - DEBUG=1 - ;; - - --force-tcp) - FORCE_TCP=1 - ;; - - --force-udp) - FORCE_UDP=1 - ;; - - --channel) - CHANNEL="$2" - shift - ;; - - --custom-path) - CUSTOM_PATH="$2" - shift - ;; - - *) - die "Unrecognized argument: $1" - ;; - esac - shift -done - -[ -z "$IP" ] && die "You must specify camera IP address (--ip)." -[ -z "$PORT" ] && die "Port can't be empty." -[ -z "$NAME" ] && die "You must specify camera name (--name)." -[ -z "$USER" ] && die "You must specify username (--user)." -[ -z "$PASSWORD" ] && die "You must specify username (--password)." -validate_channel "$CHANNEL" - -if [ ! -d "${OUTDIR}/${NAME}" ]; then - mkdir "${OUTDIR}/${NAME}" || die "Failed to create ${OUTDIR}/${NAME}!" -fi - -args= -if [ "$DEBUG" = "1" ]; then - args="-v info" -else - args="-nostats -loglevel error" -fi - -if [ "$FORCE_TCP" = "1" ]; then - args="$args -rtsp_transport tcp" -elif [ "$FORCE_UDP" = "1" ]; then - args="$args -rtsp_transport udp" -fi - -if [ -z "$CUSTOM_PATH" ]; then - path="/Streaming/Channels/${CHANNEL}" -else - path="$CUSTOM_PATH" -fi - -ffmpeg $args -i "rtsp://${USER}:${PASSWORD}@${IP}:${PORT}${path}" \ - -c:v copy -c:a copy -bufsize 1835k \ - -pix_fmt yuv420p \ - -flags -global_header -hls_time 2 -hls_list_size 3 -hls_flags delete_segments \ - ${OUTDIR}/${NAME}/live.m3u8 diff --git a/tools/mcuota.py b/tools/mcuota.py deleted file mode 100755 index 46968a8..0000000 --- a/tools/mcuota.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) - -from time import sleep -from argparse import ArgumentParser -from src.home.config import config -from src.home.mqtt import MqttRelay -from src.home.mqtt.esp import MqttEspDevice - - -def guess_filename(product: str, build_target: str): - return os.path.join( - products_dir, - product, - '.pio', - 'build', - build_target, - 'firmware.bin' - ) - - -def relayctl_publish_ota(filename: str, - device_id: str, - home_secret: str, - qos: int): - global stop - - def published(): - global stop - stop = True - - mqtt_relay = MqttRelay(devices=MqttEspDevice(id=device_id, secret=home_secret)) - mqtt_relay.configure_tls() - mqtt_relay.connect_and_loop(loop_forever=False) - mqtt_relay.push_ota(device_id, filename, published, qos) - while not stop: - sleep(0.1) - mqtt_relay.disconnect() - - -stop = False -products = { - 'relayctl': { - 'build_target': 'esp12e', - 'callback': relayctl_publish_ota - } -} - -products_dir = os.path.join( - os.path.dirname(__file__), - '..', - 'platformio' -) - - -def main(): - parser = ArgumentParser() - parser.add_argument('--filename', type=str) - parser.add_argument('--device-id', type=str, required=True) - parser.add_argument('--product', type=str, required=True) - parser.add_argument('--qos', type=int, default=1) - - config.load('mcuota_push', parser=parser) - arg = parser.parse_args() - - if arg.product not in products: - raise ValueError(f'invalid product: \'{arg.product}\' not found') - - if arg.device_id not in config['mqtt']['home_secrets']: - raise ValueError(f'home_secret for home {arg.device_id} not found in config!') - - filename = arg.filename if arg.filename else guess_filename(arg.product, products[arg.product]['build_target']) - if not os.path.exists(filename): - raise OSError(f'file \'{filename}\' does not exists') - - print('Please confirm following OTA params.') - print('') - print(f' Device ID: {arg.device_id}') - print(f' Product: {arg.product}') - print(f'Firmware file: {filename}') - print('') - input('Press any key to continue or Ctrl+C to abort.') - - products[arg.product]['callback'](filename, arg.device_id, config['mqtt']['home_secrets'][arg.device_id], qos=arg.qos) - - -if __name__ == '__main__': - try: - main() - except Exception as e: - print(str(e), file=sys.stderr) - sys.exit(1) diff --git a/tools/mcuota.sh b/tools/mcuota.sh deleted file mode 100755 index b2e7910..0000000 --- a/tools/mcuota.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - -. "$DIR/lib.bash" - -if [ -d "$DIR/../venv" ]; then - echoinfo "activating python venv" - . "$DIR/../venv/bin/activate" -else - echowarn "python venv not found" -fi - -"$DIR/mcuota.py" "$@"
\ No newline at end of file diff --git a/tools/process-motion-timecodes.py b/tools/process-motion-timecodes.py deleted file mode 100755 index 7be7977..0000000 --- a/tools/process-motion-timecodes.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -import os.path -from src.home.camera.util import dvr_scan_timecodes - -from argparse import ArgumentParser -from datetime import datetime, timedelta - -DATETIME_FORMAT = '%Y-%m-%d-%H.%M.%S' - - -def chunks(lst, n): - for i in range(0, len(lst), n): - yield lst[i:i + n] - - -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) - - -def filename_to_datetime(filename: str) -> datetime: - filename = os.path.basename(filename).replace('record_', '').replace('.mp4', '') - return datetime.strptime(filename, DATETIME_FORMAT) - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('--source-filename', type=str, required=True, - help='recording filename') - parser.add_argument('--timecodes', type=str, required=True, - help='timecodes') - parser.add_argument('--padding', type=int, default=2, - help='amount of seconds to add before and after each fragment') - arg = parser.parse_args() - - if arg.padding < 0: - raise ValueError('invalid padding') - - fragments = dvr_scan_timecodes(arg.timecodes) - file_dt = filename_to_datetime(arg.source_filename) - - for fragment in fragments: - start, end = fragment - - start -= arg.padding - end += arg.padding - - if start < 0: - start = 0 - - duration = end - start - - dt1 = (file_dt + timedelta(seconds=start)).strftime(DATETIME_FORMAT) - dt2 = (file_dt + timedelta(seconds=end)).strftime(DATETIME_FORMAT) - filename = f'{dt1}__{dt2}.mp4' - - print(f'{start} {duration} {filename}') diff --git a/tools/rotate-video.sh b/tools/rotate-video.sh index 6d27b44..5ce4efe 100755 --- a/tools/rotate-video.sh +++ b/tools/rotate-video.sh @@ -5,7 +5,7 @@ set -e DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &>/dev/null && pwd )" PROGNAME="$0" -. "$DIR/lib.bash" +. "$DIR/../include/bash/include.bash" usage() { diff --git a/tools/video-util.sh b/tools/video-util.sh index 0ee5560..6fe6109 100755 --- a/tools/video-util.sh +++ b/tools/video-util.sh @@ -5,7 +5,7 @@ set -e DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &> /dev/null && pwd )" PROGNAME="$0" -. "$DIR/lib.bash" +. "$DIR/../include/bash/include.bash" input= output= |