From b0bf43e6a272d42a55158e657bd937cb82fc3d8d Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sat, 10 Jun 2023 23:02:34 +0300 Subject: move files, rename home package to homekit --- .gitignore | 5 +- assets/mqtt_ca.crt | 23 - bin/__py_include.py | 9 + bin/camera_node.py | 89 ++ bin/electricity_calc.py | 166 +++ bin/esp32_capture.py | 58 + bin/esp32cam_capture_diff_node.py | 87 ++ bin/gpiorelayd.py | 24 + bin/inverter_bot.py | 944 ++++++++++++++++ bin/inverter_mqtt_util.py | 27 + bin/inverterd_emulator.py | 10 + bin/ipcam_server.py | 581 ++++++++++ bin/mqtt_node_util.py | 64 ++ bin/openwrt_log_analyzer.py | 79 ++ bin/openwrt_logger.py | 73 ++ bin/pio_build.py | 5 + bin/pio_ini.py | 137 +++ bin/polaris_kettle_bot.py | 747 +++++++++++++ bin/polaris_kettle_util.py | 114 ++ bin/pump_bot.py | 257 +++++ bin/pump_mqtt_bot.py | 168 +++ bin/relay_mqtt_bot.py | 165 +++ bin/relay_mqtt_http_proxy.py | 134 +++ bin/sensors_bot.py | 182 +++ bin/sound_bot.py | 890 +++++++++++++++ bin/sound_node.py | 91 ++ bin/sound_sensor_node.py | 34 + bin/sound_sensor_server.py | 200 ++++ bin/ssh_tunnels_config_util.py | 35 + bin/temphum_mqtt_node.py | 79 ++ bin/temphum_mqtt_receiver.py | 48 + bin/temphum_nodes_util.py | 19 + bin/temphum_smbus_util.py | 23 + bin/temphumd.py | 79 ++ bin/web_api.py | 240 ++++ misc/mqtt_ca.crt | 23 + pyA20/__init__.pyi | 0 pyA20/gpio/connector.pyi | 2 - pyA20/gpio/gpio.pyi | 24 - pyA20/gpio/port.pyi | 36 - pyA20/port.pyi | 0 py_include/__init__.py | 0 py_include/homekit/__init__.py | 0 py_include/homekit/api/__init__.py | 19 + py_include/homekit/api/__init__.pyi | 5 + py_include/homekit/api/config.py | 15 + py_include/homekit/api/errors/__init__.py | 1 + .../homekit/api/errors/api_response_error.py | 28 + py_include/homekit/api/types/__init__.py | 6 + py_include/homekit/api/types/types.py | 33 + py_include/homekit/api/web_api_client.py | 227 ++++ py_include/homekit/audio/__init__.py | 0 py_include/homekit/audio/amixer.py | 91 ++ py_include/homekit/camera/__init__.py | 1 + py_include/homekit/camera/esp32.py | 226 ++++ py_include/homekit/camera/types.py | 5 + py_include/homekit/camera/util.py | 107 ++ py_include/homekit/config/__init__.py | 13 + py_include/homekit/config/_configs.py | 55 + py_include/homekit/config/config.py | 387 +++++++ py_include/homekit/database/__init__.py | 29 + py_include/homekit/database/__init__.pyi | 11 + py_include/homekit/database/_base.py | 9 + py_include/homekit/database/bots.py | 106 ++ py_include/homekit/database/clickhouse.py | 39 + py_include/homekit/database/inverter.py | 212 ++++ .../homekit/database/inverter_time_formats.py | 2 + py_include/homekit/database/mysql.py | 47 + py_include/homekit/database/sensors.py | 69 ++ py_include/homekit/database/simple_state.py | 48 + py_include/homekit/database/sqlite.py | 67 ++ py_include/homekit/http/__init__.py | 2 + py_include/homekit/http/http.py | 106 ++ py_include/homekit/inverter/__init__.py | 3 + py_include/homekit/inverter/config.py | 13 + py_include/homekit/inverter/emulator.py | 556 ++++++++++ py_include/homekit/inverter/inverter_wrapper.py | 48 + py_include/homekit/inverter/monitor.py | 499 +++++++++ py_include/homekit/inverter/types.py | 64 ++ py_include/homekit/inverter/util.py | 8 + py_include/homekit/media/__init__.py | 22 + py_include/homekit/media/__init__.pyi | 27 + py_include/homekit/media/node_client.py | 119 ++ py_include/homekit/media/node_server.py | 86 ++ py_include/homekit/media/record.py | 461 ++++++++ py_include/homekit/media/record_client.py | 166 +++ py_include/homekit/media/storage.py | 210 ++++ py_include/homekit/media/types.py | 13 + py_include/homekit/mqtt/__init__.py | 7 + py_include/homekit/mqtt/_config.py | 165 +++ py_include/homekit/mqtt/_module.py | 70 ++ py_include/homekit/mqtt/_mqtt.py | 86 ++ py_include/homekit/mqtt/_node.py | 92 ++ py_include/homekit/mqtt/_payload.py | 145 +++ py_include/homekit/mqtt/_util.py | 15 + py_include/homekit/mqtt/_wrapper.py | 60 + py_include/homekit/mqtt/module/diagnostics.py | 64 ++ py_include/homekit/mqtt/module/inverter.py | 195 ++++ py_include/homekit/mqtt/module/ota.py | 77 ++ py_include/homekit/mqtt/module/relay.py | 92 ++ py_include/homekit/mqtt/module/temphum.py | 82 ++ py_include/homekit/pio/__init__.py | 1 + py_include/homekit/pio/exceptions.py | 2 + py_include/homekit/pio/products.py | 113 ++ py_include/homekit/relay/__init__.py | 16 + py_include/homekit/relay/__init__.pyi | 2 + py_include/homekit/relay/sunxi_h3_client.py | 39 + py_include/homekit/relay/sunxi_h3_server.py | 82 ++ py_include/homekit/soundsensor/__init__.py | 22 + py_include/homekit/soundsensor/__init__.pyi | 8 + py_include/homekit/soundsensor/node.py | 75 ++ py_include/homekit/soundsensor/server.py | 128 +++ py_include/homekit/soundsensor/server_client.py | 38 + py_include/homekit/telegram/__init__.py | 1 + py_include/homekit/telegram/_botcontext.py | 86 ++ py_include/homekit/telegram/_botdb.py | 32 + py_include/homekit/telegram/_botlang.py | 120 ++ py_include/homekit/telegram/_botutil.py | 47 + py_include/homekit/telegram/aio.py | 18 + py_include/homekit/telegram/bot.py | 583 ++++++++++ py_include/homekit/telegram/config.py | 75 ++ py_include/homekit/telegram/telegram.py | 49 + py_include/homekit/temphum/__init__.py | 1 + py_include/homekit/temphum/base.py | 19 + py_include/homekit/temphum/i2c.py | 52 + py_include/homekit/util.py | 255 +++++ py_include/pyA20/__init__.pyi | 0 py_include/pyA20/gpio/connector.pyi | 2 + py_include/pyA20/gpio/gpio.pyi | 24 + py_include/pyA20/gpio/port.pyi | 36 + py_include/pyA20/port.pyi | 0 py_include/syncleo/__init__.py | 12 + py_include/syncleo/kettle.py | 243 ++++ py_include/syncleo/protocol.py | 1169 ++++++++++++++++++++ src/__init__.py | 0 src/camera_node.py | 88 -- src/electricity_calc.py | 165 --- src/esp32_capture.py | 57 - src/esp32cam_capture_diff_node.py | 86 -- src/gpiorelayd.py | 23 - src/home/__init__.py | 0 src/home/api/__init__.py | 19 - src/home/api/__init__.pyi | 5 - src/home/api/config.py | 15 - src/home/api/errors/__init__.py | 1 - src/home/api/errors/api_response_error.py | 28 - src/home/api/types/__init__.py | 6 - src/home/api/types/types.py | 33 - src/home/api/web_api_client.py | 227 ---- src/home/audio/__init__.py | 0 src/home/audio/amixer.py | 91 -- src/home/camera/__init__.py | 1 - src/home/camera/esp32.py | 226 ---- src/home/camera/types.py | 5 - src/home/camera/util.py | 107 -- src/home/config/__init__.py | 13 - src/home/config/_configs.py | 55 - src/home/config/config.py | 387 ------- src/home/database/__init__.py | 29 - src/home/database/__init__.pyi | 11 - src/home/database/_base.py | 9 - src/home/database/bots.py | 106 -- src/home/database/clickhouse.py | 39 - src/home/database/inverter.py | 212 ---- src/home/database/inverter_time_formats.py | 2 - src/home/database/mysql.py | 47 - src/home/database/sensors.py | 69 -- src/home/database/simple_state.py | 48 - src/home/database/sqlite.py | 67 -- src/home/http/__init__.py | 2 - src/home/http/http.py | 106 -- src/home/inverter/__init__.py | 3 - src/home/inverter/config.py | 13 - src/home/inverter/emulator.py | 556 ---------- src/home/inverter/inverter_wrapper.py | 48 - src/home/inverter/monitor.py | 499 --------- src/home/inverter/types.py | 64 -- src/home/inverter/util.py | 8 - src/home/media/__init__.py | 22 - src/home/media/__init__.pyi | 27 - src/home/media/node_client.py | 119 -- src/home/media/node_server.py | 86 -- src/home/media/record.py | 461 -------- src/home/media/record_client.py | 166 --- src/home/media/storage.py | 210 ---- src/home/media/types.py | 13 - src/home/mqtt/__init__.py | 7 - src/home/mqtt/_config.py | 165 --- src/home/mqtt/_module.py | 70 -- src/home/mqtt/_mqtt.py | 86 -- src/home/mqtt/_node.py | 92 -- src/home/mqtt/_payload.py | 145 --- src/home/mqtt/_util.py | 15 - src/home/mqtt/_wrapper.py | 60 - src/home/mqtt/module/diagnostics.py | 64 -- src/home/mqtt/module/inverter.py | 195 ---- src/home/mqtt/module/ota.py | 77 -- src/home/mqtt/module/relay.py | 92 -- src/home/mqtt/module/temphum.py | 82 -- src/home/pio/__init__.py | 1 - src/home/pio/exceptions.py | 2 - src/home/pio/products.py | 113 -- src/home/relay/__init__.py | 16 - src/home/relay/__init__.pyi | 2 - src/home/relay/sunxi_h3_client.py | 39 - src/home/relay/sunxi_h3_server.py | 82 -- src/home/soundsensor/__init__.py | 22 - src/home/soundsensor/__init__.pyi | 8 - src/home/soundsensor/node.py | 75 -- src/home/soundsensor/server.py | 128 --- src/home/soundsensor/server_client.py | 38 - src/home/telegram/__init__.py | 1 - src/home/telegram/_botcontext.py | 86 -- src/home/telegram/_botdb.py | 32 - src/home/telegram/_botlang.py | 120 -- src/home/telegram/_botutil.py | 47 - src/home/telegram/aio.py | 18 - src/home/telegram/bot.py | 583 ---------- src/home/telegram/config.py | 75 -- src/home/telegram/telegram.py | 49 - src/home/temphum/__init__.py | 1 - src/home/temphum/base.py | 19 - src/home/temphum/i2c.py | 52 - src/home/util.py | 255 ----- src/inverter_bot.py | 943 ---------------- src/inverter_mqtt_util.py | 25 - src/inverterd_emulator.py | 9 - src/ipcam_server.py | 579 ---------- src/mqtt_node_util.py | 63 -- src/openwrt_log_analyzer.py | 78 -- src/openwrt_logger.py | 72 -- src/pio_build.py | 4 - src/pio_ini.py | 136 --- src/polaris_kettle_bot.py | 746 ------------- src/polaris_kettle_util.py | 113 -- src/pump_bot.py | 255 ----- src/pump_mqtt_bot.py | 167 --- src/relay_mqtt_bot.py | 164 --- src/relay_mqtt_http_proxy.py | 133 --- src/sensors_bot.py | 181 --- src/sound_bot.py | 889 --------------- src/sound_node.py | 90 -- src/sound_sensor_node.py | 33 - src/sound_sensor_server.py | 199 ---- src/ssh_tunnels_config_util.py | 35 - src/syncleo/__init__.py | 12 - src/syncleo/kettle.py | 243 ---- src/syncleo/protocol.py | 1169 -------------------- src/temphum_mqtt_node.py | 78 -- src/temphum_mqtt_receiver.py | 47 - src/temphum_nodes_util.py | 17 - src/temphum_smbus_util.py | 21 - src/temphumd.py | 78 -- src/web_api.py | 239 ---- systemd/camera_node.service | 2 +- systemd/camera_node@.service | 2 +- systemd/esp32cam_capture_diff_node.service | 2 +- systemd/gpiorelayd@.service | 2 +- systemd/inverter_bot.service | 2 +- systemd/inverter_mqtt_receiver.service | 2 +- systemd/inverter_mqtt_sender.service | 2 +- systemd/ipcam_server.service | 2 +- systemd/polaris_kettle_bot.service | 2 +- systemd/pump_bot.service | 2 +- systemd/pump_mqtt_bot.service | 2 +- systemd/relay_mqtt_bot.service | 2 +- systemd/relay_mqtt_http_proxy.service | 2 +- systemd/sensors_bot.service | 2 +- systemd/sound_bot.service | 2 +- systemd/sound_node.service | 2 +- systemd/sound_sensor_node.service | 2 +- systemd/sound_sensor_server.service | 2 +- systemd/temphumd.service | 2 +- systemd/temphumd@.service | 2 +- test/__init__.py | 0 test/test.py | 2 +- test/test_stopwatch.py | 2 +- 277 files changed, 14667 insertions(+), 14621 deletions(-) delete mode 100644 assets/mqtt_ca.crt create mode 100644 bin/__py_include.py create mode 100755 bin/camera_node.py create mode 100755 bin/electricity_calc.py create mode 100755 bin/esp32_capture.py create mode 100755 bin/esp32cam_capture_diff_node.py create mode 100755 bin/gpiorelayd.py create mode 100755 bin/inverter_bot.py create mode 100755 bin/inverter_mqtt_util.py create mode 100755 bin/inverterd_emulator.py create mode 100755 bin/ipcam_server.py create mode 100755 bin/mqtt_node_util.py create mode 100755 bin/openwrt_log_analyzer.py create mode 100755 bin/openwrt_logger.py create mode 100644 bin/pio_build.py create mode 100755 bin/pio_ini.py create mode 100755 bin/polaris_kettle_bot.py create mode 100755 bin/polaris_kettle_util.py create mode 100755 bin/pump_bot.py create mode 100755 bin/pump_mqtt_bot.py create mode 100755 bin/relay_mqtt_bot.py create mode 100755 bin/relay_mqtt_http_proxy.py create mode 100755 bin/sensors_bot.py create mode 100755 bin/sound_bot.py create mode 100755 bin/sound_node.py create mode 100755 bin/sound_sensor_node.py create mode 100755 bin/sound_sensor_server.py create mode 100755 bin/ssh_tunnels_config_util.py create mode 100755 bin/temphum_mqtt_node.py create mode 100755 bin/temphum_mqtt_receiver.py create mode 100755 bin/temphum_nodes_util.py create mode 100755 bin/temphum_smbus_util.py create mode 100755 bin/temphumd.py create mode 100755 bin/web_api.py create mode 100644 misc/mqtt_ca.crt delete mode 100644 pyA20/__init__.pyi delete mode 100644 pyA20/gpio/connector.pyi delete mode 100644 pyA20/gpio/gpio.pyi delete mode 100644 pyA20/gpio/port.pyi delete mode 100644 pyA20/port.pyi create mode 100644 py_include/__init__.py create mode 100644 py_include/homekit/__init__.py create mode 100644 py_include/homekit/api/__init__.py create mode 100644 py_include/homekit/api/__init__.pyi create mode 100644 py_include/homekit/api/config.py create mode 100644 py_include/homekit/api/errors/__init__.py create mode 100644 py_include/homekit/api/errors/api_response_error.py create mode 100644 py_include/homekit/api/types/__init__.py create mode 100644 py_include/homekit/api/types/types.py create mode 100644 py_include/homekit/api/web_api_client.py create mode 100644 py_include/homekit/audio/__init__.py create mode 100644 py_include/homekit/audio/amixer.py create mode 100644 py_include/homekit/camera/__init__.py create mode 100644 py_include/homekit/camera/esp32.py create mode 100644 py_include/homekit/camera/types.py create mode 100644 py_include/homekit/camera/util.py create mode 100644 py_include/homekit/config/__init__.py create mode 100644 py_include/homekit/config/_configs.py create mode 100644 py_include/homekit/config/config.py create mode 100644 py_include/homekit/database/__init__.py create mode 100644 py_include/homekit/database/__init__.pyi create mode 100644 py_include/homekit/database/_base.py create mode 100644 py_include/homekit/database/bots.py create mode 100644 py_include/homekit/database/clickhouse.py create mode 100644 py_include/homekit/database/inverter.py create mode 100644 py_include/homekit/database/inverter_time_formats.py create mode 100644 py_include/homekit/database/mysql.py create mode 100644 py_include/homekit/database/sensors.py create mode 100644 py_include/homekit/database/simple_state.py create mode 100644 py_include/homekit/database/sqlite.py create mode 100644 py_include/homekit/http/__init__.py create mode 100644 py_include/homekit/http/http.py create mode 100644 py_include/homekit/inverter/__init__.py create mode 100644 py_include/homekit/inverter/config.py create mode 100644 py_include/homekit/inverter/emulator.py create mode 100644 py_include/homekit/inverter/inverter_wrapper.py create mode 100644 py_include/homekit/inverter/monitor.py create mode 100644 py_include/homekit/inverter/types.py create mode 100644 py_include/homekit/inverter/util.py create mode 100644 py_include/homekit/media/__init__.py create mode 100644 py_include/homekit/media/__init__.pyi create mode 100644 py_include/homekit/media/node_client.py create mode 100644 py_include/homekit/media/node_server.py create mode 100644 py_include/homekit/media/record.py create mode 100644 py_include/homekit/media/record_client.py create mode 100644 py_include/homekit/media/storage.py create mode 100644 py_include/homekit/media/types.py create mode 100644 py_include/homekit/mqtt/__init__.py create mode 100644 py_include/homekit/mqtt/_config.py create mode 100644 py_include/homekit/mqtt/_module.py create mode 100644 py_include/homekit/mqtt/_mqtt.py create mode 100644 py_include/homekit/mqtt/_node.py create mode 100644 py_include/homekit/mqtt/_payload.py create mode 100644 py_include/homekit/mqtt/_util.py create mode 100644 py_include/homekit/mqtt/_wrapper.py create mode 100644 py_include/homekit/mqtt/module/diagnostics.py create mode 100644 py_include/homekit/mqtt/module/inverter.py create mode 100644 py_include/homekit/mqtt/module/ota.py create mode 100644 py_include/homekit/mqtt/module/relay.py create mode 100644 py_include/homekit/mqtt/module/temphum.py create mode 100644 py_include/homekit/pio/__init__.py create mode 100644 py_include/homekit/pio/exceptions.py create mode 100644 py_include/homekit/pio/products.py create mode 100644 py_include/homekit/relay/__init__.py create mode 100644 py_include/homekit/relay/__init__.pyi create mode 100644 py_include/homekit/relay/sunxi_h3_client.py create mode 100644 py_include/homekit/relay/sunxi_h3_server.py create mode 100644 py_include/homekit/soundsensor/__init__.py create mode 100644 py_include/homekit/soundsensor/__init__.pyi create mode 100644 py_include/homekit/soundsensor/node.py create mode 100644 py_include/homekit/soundsensor/server.py create mode 100644 py_include/homekit/soundsensor/server_client.py create mode 100644 py_include/homekit/telegram/__init__.py create mode 100644 py_include/homekit/telegram/_botcontext.py create mode 100644 py_include/homekit/telegram/_botdb.py create mode 100644 py_include/homekit/telegram/_botlang.py create mode 100644 py_include/homekit/telegram/_botutil.py create mode 100644 py_include/homekit/telegram/aio.py create mode 100644 py_include/homekit/telegram/bot.py create mode 100644 py_include/homekit/telegram/config.py create mode 100644 py_include/homekit/telegram/telegram.py create mode 100644 py_include/homekit/temphum/__init__.py create mode 100644 py_include/homekit/temphum/base.py create mode 100644 py_include/homekit/temphum/i2c.py create mode 100644 py_include/homekit/util.py create mode 100644 py_include/pyA20/__init__.pyi create mode 100644 py_include/pyA20/gpio/connector.pyi create mode 100644 py_include/pyA20/gpio/gpio.pyi create mode 100644 py_include/pyA20/gpio/port.pyi create mode 100644 py_include/pyA20/port.pyi create mode 100644 py_include/syncleo/__init__.py create mode 100644 py_include/syncleo/kettle.py create mode 100644 py_include/syncleo/protocol.py delete mode 100644 src/__init__.py delete mode 100755 src/camera_node.py delete mode 100755 src/electricity_calc.py delete mode 100755 src/esp32_capture.py delete mode 100755 src/esp32cam_capture_diff_node.py delete mode 100755 src/gpiorelayd.py delete mode 100644 src/home/__init__.py delete mode 100644 src/home/api/__init__.py delete mode 100644 src/home/api/__init__.pyi delete mode 100644 src/home/api/config.py delete mode 100644 src/home/api/errors/__init__.py delete mode 100644 src/home/api/errors/api_response_error.py delete mode 100644 src/home/api/types/__init__.py delete mode 100644 src/home/api/types/types.py delete mode 100644 src/home/api/web_api_client.py delete mode 100644 src/home/audio/__init__.py delete mode 100644 src/home/audio/amixer.py delete mode 100644 src/home/camera/__init__.py delete mode 100644 src/home/camera/esp32.py delete mode 100644 src/home/camera/types.py delete mode 100644 src/home/camera/util.py delete mode 100644 src/home/config/__init__.py delete mode 100644 src/home/config/_configs.py delete mode 100644 src/home/config/config.py delete mode 100644 src/home/database/__init__.py delete mode 100644 src/home/database/__init__.pyi delete mode 100644 src/home/database/_base.py delete mode 100644 src/home/database/bots.py delete mode 100644 src/home/database/clickhouse.py delete mode 100644 src/home/database/inverter.py delete mode 100644 src/home/database/inverter_time_formats.py delete mode 100644 src/home/database/mysql.py delete mode 100644 src/home/database/sensors.py delete mode 100644 src/home/database/simple_state.py delete mode 100644 src/home/database/sqlite.py delete mode 100644 src/home/http/__init__.py delete mode 100644 src/home/http/http.py delete mode 100644 src/home/inverter/__init__.py delete mode 100644 src/home/inverter/config.py delete mode 100644 src/home/inverter/emulator.py delete mode 100644 src/home/inverter/inverter_wrapper.py delete mode 100644 src/home/inverter/monitor.py delete mode 100644 src/home/inverter/types.py delete mode 100644 src/home/inverter/util.py delete mode 100644 src/home/media/__init__.py delete mode 100644 src/home/media/__init__.pyi delete mode 100644 src/home/media/node_client.py delete mode 100644 src/home/media/node_server.py delete mode 100644 src/home/media/record.py delete mode 100644 src/home/media/record_client.py delete mode 100644 src/home/media/storage.py delete mode 100644 src/home/media/types.py delete mode 100644 src/home/mqtt/__init__.py delete mode 100644 src/home/mqtt/_config.py delete mode 100644 src/home/mqtt/_module.py delete mode 100644 src/home/mqtt/_mqtt.py delete mode 100644 src/home/mqtt/_node.py delete mode 100644 src/home/mqtt/_payload.py delete mode 100644 src/home/mqtt/_util.py delete mode 100644 src/home/mqtt/_wrapper.py delete mode 100644 src/home/mqtt/module/diagnostics.py delete mode 100644 src/home/mqtt/module/inverter.py delete mode 100644 src/home/mqtt/module/ota.py delete mode 100644 src/home/mqtt/module/relay.py delete mode 100644 src/home/mqtt/module/temphum.py delete mode 100644 src/home/pio/__init__.py delete mode 100644 src/home/pio/exceptions.py delete mode 100644 src/home/pio/products.py delete mode 100644 src/home/relay/__init__.py delete mode 100644 src/home/relay/__init__.pyi delete mode 100644 src/home/relay/sunxi_h3_client.py delete mode 100644 src/home/relay/sunxi_h3_server.py delete mode 100644 src/home/soundsensor/__init__.py delete mode 100644 src/home/soundsensor/__init__.pyi delete mode 100644 src/home/soundsensor/node.py delete mode 100644 src/home/soundsensor/server.py delete mode 100644 src/home/soundsensor/server_client.py delete mode 100644 src/home/telegram/__init__.py delete mode 100644 src/home/telegram/_botcontext.py delete mode 100644 src/home/telegram/_botdb.py delete mode 100644 src/home/telegram/_botlang.py delete mode 100644 src/home/telegram/_botutil.py delete mode 100644 src/home/telegram/aio.py delete mode 100644 src/home/telegram/bot.py delete mode 100644 src/home/telegram/config.py delete mode 100644 src/home/telegram/telegram.py delete mode 100644 src/home/temphum/__init__.py delete mode 100644 src/home/temphum/base.py delete mode 100644 src/home/temphum/i2c.py delete mode 100644 src/home/util.py delete mode 100755 src/inverter_bot.py delete mode 100755 src/inverter_mqtt_util.py delete mode 100755 src/inverterd_emulator.py delete mode 100755 src/ipcam_server.py delete mode 100755 src/mqtt_node_util.py delete mode 100755 src/openwrt_log_analyzer.py delete mode 100755 src/openwrt_logger.py delete mode 100644 src/pio_build.py delete mode 100755 src/pio_ini.py delete mode 100755 src/polaris_kettle_bot.py delete mode 100755 src/polaris_kettle_util.py delete mode 100755 src/pump_bot.py delete mode 100755 src/pump_mqtt_bot.py delete mode 100755 src/relay_mqtt_bot.py delete mode 100755 src/relay_mqtt_http_proxy.py delete mode 100755 src/sensors_bot.py delete mode 100755 src/sound_bot.py delete mode 100755 src/sound_node.py delete mode 100755 src/sound_sensor_node.py delete mode 100755 src/sound_sensor_server.py delete mode 100755 src/ssh_tunnels_config_util.py delete mode 100644 src/syncleo/__init__.py delete mode 100644 src/syncleo/kettle.py delete mode 100644 src/syncleo/protocol.py delete mode 100755 src/temphum_mqtt_node.py delete mode 100755 src/temphum_mqtt_receiver.py delete mode 100755 src/temphum_nodes_util.py delete mode 100755 src/temphum_smbus_util.py delete mode 100755 src/temphumd.py delete mode 100755 src/web_api.py delete mode 100644 test/__init__.py diff --git a/.gitignore b/.gitignore index 4ffc1b1..1280ea2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,11 @@ config.def.h __pycache__ .DS_Store -/src/test/test_inverter_monitor.log +/py_include/test/test_inverter_monitor.log /youtrack-certificate /cpp -/src/test.py +/py_include/test.py +/bin/test.py /esp32-cam/CameraWebServer/wifi_password.h cmake-build-* .pio diff --git a/assets/mqtt_ca.crt b/assets/mqtt_ca.crt deleted file mode 100644 index 045ae10..0000000 --- a/assets/mqtt_ca.crt +++ /dev/null @@ -1,23 +0,0 @@ ------BEGIN CERTIFICATE----- -MIID4zCCAsugAwIBAgIUcW9D2Yym/nNf//Sfv1G8kwpEBCMwDQYJKoZIhvcNAQEL -BQAwgYAxCzAJBgNVBAYTAlJVMQ8wDQYDVQQIDAZNb3Njb3cxDzANBgNVBAcMBk1v -c2NvdzEUMBIGA1UECgwLU29sYXJNb24uUlUxFzAVBgNVBAMMDmNhLnNvbGFybW9u -LnJ1MSAwHgYJKoZIhvcNAQkBFhFhZG1pbkBzb2xhcm1vbi5ydTAeFw0yMTA1MTYx -NzI2MjRaFw0zMTA1MTQxNzI2MjRaMIGAMQswCQYDVQQGEwJSVTEPMA0GA1UECAwG -TW9zY293MQ8wDQYDVQQHDAZNb3Njb3cxFDASBgNVBAoMC1NvbGFyTW9uLlJVMRcw -FQYDVQQDDA5jYS5zb2xhcm1vbi5ydTEgMB4GCSqGSIb3DQEJARYRYWRtaW5Ac29s -YXJtb24ucnUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEEPOhEE74 -LDWVhtY3fFQu1HD3GYv2b8SgXXk1evFs2QiLtw7wtvVG9jM+JjLadY50gMZYlrKe -NqFxj7OutTx0RnkFLQ0Q3xkEsQOlWVvgFf4qwZ8pEgAnmVGHQjBeM4vmgY0Dxnqd -GLrjLVKwEMYM1PiV3pp1vMDJGouoxp3bOL7Iz++/07Atim9g8RZ+gyw080JJUKdB -7alR3ZfND2GMFXd03aosE5c7YqIwjGrT73K4sdqP8ydwEPtjBfn4b746uERllsT1 -EBc4Iv25RWdUy1p1YIaa8y9/34h7QPUSawjdnnL+Ktq9DCxv8WDKoSRK5E7bwswf -DKHFEmoI4IjHAgMBAAGjUzBRMB0GA1UdDgQWBBSqdoh/ZkUgfDWQoxjXU6CeIO4H -FDAfBgNVHSMEGDAWgBSqdoh/ZkUgfDWQoxjXU6CeIO4HFDAPBgNVHRMBAf8EBTAD -AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCM6JdaY+pT3E/8Tfz+M0R4kgqasyc9fAQP -g7tf2HrMPCtuIZF8aJYMNi0pfcnuUtr9FXFgGjyG+PZxqD2lHS+F/U5I8XqtTNJM -FW5Ls9dulRjmiGs0u8JbEX3igFTuCh0EZbtJgOLt2rOwSLv9PwI+ng4n8LBtbXVl -icfzWxGbnx/Bzoa7/Rk6Gs10Jf5bAeklchx/DbytSmoYSs9TxGdsrYkllznRts76 -6DHptSctecdi0svL4cE9dXWl6OSgG674khWPTd0I9bcHgJCQ6T1gPLRpnFJJ1ZT6 -ORgl25mkt+AX5U+naLMuUXU9TBKr3foxBMWqrSu5uC5K494Lbrvv ------END CERTIFICATE----- diff --git a/bin/__py_include.py b/bin/__py_include.py new file mode 100644 index 0000000..7f95e28 --- /dev/null +++ b/bin/__py_include.py @@ -0,0 +1,9 @@ +import sys +import os.path + +for _name in ('py_include',): + 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/bin/camera_node.py b/bin/camera_node.py new file mode 100755 index 0000000..1485557 --- /dev/null +++ b/bin/camera_node.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +import asyncio +import time +import __py_include + +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. +# --------------------------------- + +class ESP32CameraNodeServer(MediaNodeServer): + def __init__(self, web_addr: Addr, *args, **kwargs): + super().__init__(*args, **kwargs) + self.last_settings_sync = 0 + + self.web = esp32.WebClient(web_addr) + self.get('/capture/', self.capture) + + async def capture(self, req: http.Request): + await self.sync_settings_if_needed() + + try: + with_flash = int(req.query['with_flash']) + except KeyError: + with_flash = 0 + + if with_flash: + await self.web.setflash(True) + await asyncio.sleep(0.5) + + bytes = (await self.web.capture()).read() + + if with_flash: + await asyncio.sleep(0.5) + await self.web.setflash(False) + + res = http.StreamResponse() + res.content_type = 'image/jpeg' + res.content_length = len(bytes) + + await res.prepare(req) + await res.write(bytes) + await res.write_eof() + + return res + + async def do_record(self, request: http.Request): + await self.sync_settings_if_needed() + + # sync settings + return await super().do_record(request) + + async def sync_settings_if_needed(self): + if self.last_settings_sync != 0 and time.time() - self.last_settings_sync < 300: + return + changed = await self.web.syncsettings(config['camera']['settings']) + if changed: + self.logger.debug('sync_settings_if_needed: some settings were changed, sleeping for 0.4 sec') + await asyncio.sleep(0.4) + self.last_settings_sync = time.time() + + +if __name__ == '__main__': + config.load_app('camera_node') + + recorder_kwargs = {} + camera_type = CameraType(config['camera']['type']) + if camera_type == CameraType.ESP32: + recorder_kwargs['stream_addr'] = config.get_addr('camera.web_addr') # this is not a mistake, we don't use stream_addr for esp32-cam anymore + storage = ESP32CameraRecordStorage(config['node']['storage']) + else: + raise RuntimeError(f'unsupported camera type {camera_type}') + + recorder = CameraRecorder(storage=storage, + camera_type=camera_type, + **recorder_kwargs) + recorder.start_thread() + + server = ESP32CameraNodeServer( + recorder=recorder, + storage=storage, + web_addr=config.get_addr('camera.web_addr'), + addr=config.get_addr('node.listen')) + server.run() diff --git a/bin/electricity_calc.py b/bin/electricity_calc.py new file mode 100755 index 0000000..cff2327 --- /dev/null +++ b/bin/electricity_calc.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +import logging +import os +import sys +import inspect +import __py_include + +from homekit.config import config # do not remove this import! +from datetime import datetime, timedelta +from logging import Logger +from homekit.database import InverterDatabase +from argparse import ArgumentParser, ArgumentError +from typing import Optional + +_logger: Optional[Logger] = None +_progname = os.path.basename(__file__) +_is_verbose = False + +fmt_time = '%Y-%m-%d %H:%M:%S' +fmt_date = '%Y-%m-%d' + + +def method_usage() -> str: + # https://stackoverflow.com/questions/2654113/how-to-get-the-callers-method-name-in-the-called-method + curframe = inspect.currentframe() + calframe = inspect.getouterframes(curframe, 2) + return f'{_progname} {calframe[1][3]} [ARGS]' + + +def fmt_escape(s: str): + return s.replace('%', '%%') + + +def setup_logging(verbose: bool): + global _is_verbose + + logging_level = logging.INFO if not verbose else logging.DEBUG + logging.basicConfig(level=logging_level) + + _is_verbose = verbose + + +class SubParser: + def __init__(self, description: str, usage: str): + self.parser = ArgumentParser( + description=description, + usage=usage + ) + + def add_argument(self, *args, **kwargs): + self.parser.add_argument(*args, **kwargs) + + def parse_args(self): + self.add_argument('--verbose', '-V', action='store_true', + help='enable debug logs') + + args = self.parser.parse_args(sys.argv[2:]) + setup_logging(args.verbose) + + return args + + +def strptime_auto(s: str) -> datetime: + e = None + for fmt in (fmt_time, fmt_date): + try: + return datetime.strptime(s, fmt) + except ValueError as _e: + e = _e + raise e + + +def get_dt_from_to_arguments(parser): + parser.add_argument('--from', type=str, dest='date_from', required=True, + help=f'From date, format: {fmt_escape(fmt_time)} or {fmt_escape(fmt_date)}') + parser.add_argument('--to', type=str, dest='date_to', default='now', + help=f'To date, format: {fmt_escape(fmt_time)}, {fmt_escape(fmt_date)}, \'now\' or \'24h\'') + arg = parser.parse_args() + + dt_from = strptime_auto(arg.date_from) + + if arg.date_to == 'now': + dt_to = datetime.now() + elif arg.date_to == '24h': + dt_to = dt_from + timedelta(days=1) + else: + dt_to = strptime_auto(arg.date_to) + + return dt_from, dt_to + + +def print_intervals(intervals): + for interval in intervals: + start, end = interval + buf = f'{start.strftime(fmt_time)} .. ' + if end: + buf += f'{end.strftime(fmt_time)}' + else: + buf += 'now' + + print(buf) + + +class Electricity(): + def __init__(self): + global _logger + + methods = [func.replace('_', '-') + for func in dir(Electricity) + if callable(getattr(Electricity, func)) and not func.startswith('_') and func != 'query'] + + parser = ArgumentParser( + usage=f'{_progname} METHOD [ARGS]' + ) + parser.add_argument('method', choices=methods, + help='Method to run') + parser.add_argument('--verbose', '-V', action='store_true', + help='enable debug logs') + + argv = sys.argv[1:2] + for arg in ('-V', '--verbose'): + if arg in sys.argv: + argv.append(arg) + args = parser.parse_args(argv) + + setup_logging(args.verbose) + self.db = InverterDatabase() + + method = args.method.replace('-', '_') + getattr(self, method)() + + def get_grid_connected_intervals(self): + parser = SubParser('Returns datetime intervals when grid was connected', method_usage()) + dt_from, dt_to = get_dt_from_to_arguments(parser) + + intervals = self.db.get_grid_connected_intervals(dt_from, dt_to) + print_intervals(intervals) + + def get_grid_used_intervals(self): + parser = SubParser('Returns datetime intervals when power grid was actually used', method_usage()) + dt_from, dt_to = get_dt_from_to_arguments(parser) + + intervals = self.db.get_grid_used_intervals(dt_from, dt_to) + print_intervals(intervals) + + def get_grid_consumed_energy(self): + parser = SubParser('Returns sum of energy consumed from util grid', method_usage()) + dt_from, dt_to = get_dt_from_to_arguments(parser) + + wh = self.db.get_grid_consumed_energy(dt_from, dt_to) + print('%.2f' % wh,) + + def get_consumed_energy(self): + parser = SubParser('Returns total consumed energy', method_usage()) + dt_from, dt_to = get_dt_from_to_arguments(parser) + + wh = self.db.get_consumed_energy(dt_from, dt_to) + print('%.2f' % wh,) + + +if __name__ == '__main__': + try: + Electricity() + except Exception as e: + _logger.exception(e) + sys.exit(1) diff --git a/bin/esp32_capture.py b/bin/esp32_capture.py new file mode 100755 index 0000000..839114d --- /dev/null +++ b/bin/esp32_capture.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import asyncio +import logging +import os.path +import __py_include + +from argparse import ArgumentParser +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 + +logger = logging.getLogger(__name__) +cam: Optional[WebClient] = None + + +class ESP32Capture: + def __init__(self, addr: Addr, interval: float, output_directory: str): + self.logger = logging.getLogger(self.__class__.__name__) + self.client = WebClient(addr) + self.output_directory = output_directory + self.interval = interval + + self.scheduler = AsyncIOScheduler() + self.scheduler.add_job(self.capture, 'interval', seconds=arg.interval) + self.scheduler.start() + + async def capture(self): + self.logger.debug('capture: start') + now = datetime.now() + filename = os.path.join( + self.output_directory, + now.strftime('%Y-%m-%d-%H:%M:%S.%f.jpg') + ) + if not await self.client.capture(filename): + self.logger.error('failed to capture') + self.logger.debug('capture: done') + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('--addr', type=str, required=True) + parser.add_argument('--output-directory', type=str, required=True) + parser.add_argument('--interval', type=float, default=0.5) + parser.add_argument('--verbose', action='store_true') + arg = parser.parse_args() + + if arg.verbose: + logging.basicConfig(level=logging.DEBUG) + + loop = asyncio.get_event_loop() + + ESP32Capture(Addr.fromstring(arg.addr), arg.interval, arg.output_directory) + try: + loop.run_forever() + except KeyboardInterrupt: + pass diff --git a/bin/esp32cam_capture_diff_node.py b/bin/esp32cam_capture_diff_node.py new file mode 100755 index 0000000..d664c6d --- /dev/null +++ b/bin/esp32cam_capture_diff_node.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +import asyncio +import logging +import os.path +import tempfile +import __py_include +import homekit.telegram.aio as telegram + +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 + +logger = logging.getLogger(__name__) +cam: Optional[WebClient] = None + + +async def pyssim(fn1: str, fn2: str) -> float: + args = [config['pyssim']['bin']] + if 'width' in config['pyssim']: + args.extend(['--width', str(config['pyssim']['width'])]) + if 'height' in config['pyssim']: + args.extend(['--height', str(config['pyssim']['height'])]) + args.extend([fn1, fn2]) + proc = await asyncio.create_subprocess_exec(*args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + logger.error(f'pyssim({fn1}, {fn2}): pyssim returned {proc.returncode}, stderr: {stderr.decode().strip()}') + + return float(stdout.decode().strip()) + + +class ESP32CamCaptureDiffNode: + def __init__(self): + self.client = WebClient(Addr.fromstring(config['esp32cam_web_addr'])) + self.directory = tempfile.gettempdir() + self.nextpic = 1 + self.first = True + self.server_addr = Addr.fromstring(config['node']['server_addr']) + + self.scheduler = AsyncIOScheduler() + self.scheduler.add_job(self.capture, 'interval', seconds=config['node']['interval']) + self.scheduler.start() + + async def capture(self): + logger.debug('capture: start') + + filename = self.getfilename() + if not await self.client.capture(os.path.join(self.directory, filename)): + logger.error('failed to capture') + return + + self.nextpic = 1 if self.nextpic == 2 else 2 + if not self.first: + second_filename = os.path.join(self.directory, self.getfilename()) + score = await pyssim(filename, second_filename) + logger.debug(f'pyssim: score={score}') + if score < config['pyssim']['threshold']: + logger.info(f'score = {score}, informing central server') + send_datagram(stringify([config['node']['name'], 2]), self.server_addr) + + # send to telegram + if 'telegram' in config: + await telegram.send_message(f'pyssim: score={score}') + await telegram.send_photo(filename) + await telegram.send_photo(second_filename) + + self.first = False + + logger.debug('capture: done') + + def getfilename(self): + return os.path.join(self.directory, f'{self.nextpic}.jpg') + + +if __name__ == '__main__': + config.load_app('esp32cam_capture_diff_node') + + loop = asyncio.get_event_loop() + ESP32CamCaptureDiffNode() + try: + loop.run_forever() + except KeyboardInterrupt: + pass diff --git a/bin/gpiorelayd.py b/bin/gpiorelayd.py new file mode 100755 index 0000000..1f4d2e2 --- /dev/null +++ b/bin/gpiorelayd.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +import logging +import os +import sys +import __py_include + +from homekit.config import config +from homekit.relay.sunxi_h3_server import RelayServer + +logger = logging.getLogger(__name__) + + +if __name__ == '__main__': + if not os.getegid() == 0: + sys.exit('Must be run as root.') + + config.load_app() + + try: + s = RelayServer(pinname=config.get('relayd.pin'), + addr=config.get_addr('relayd.listen')) + s.run() + except KeyboardInterrupt: + logger.info('Exiting...') diff --git a/bin/inverter_bot.py b/bin/inverter_bot.py new file mode 100755 index 0000000..fdfe436 --- /dev/null +++ b/bin/inverter_bot.py @@ -0,0 +1,944 @@ +#!/usr/bin/env python3 +import logging +import re +import datetime +import json +import itertools +import sys +import __py_include + +from inverterd import Format, InverterError +from html import escape +from typing import Optional, Tuple, Union + +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 homekit.inverter.types import ( + ChargingEvent, + ACPresentEvent, + BatteryState, + ACMode, + OutputSourcePriority +) +from homekit.database.inverter_time_formats import FormatDate +from homekit.api.types import BotType +from homekit.api import WebApiClient +from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton + + +if __name__ != '__main__': + print(f'this script can not be imported as module', file=sys.stderr) + sys.exit(1) + + +db = None +LT = escape('<=') +flags_map = { + 'buzzer': 'BUZZ', + 'overload_bypass': 'OLBP', + 'escape_to_default_screen_after_1min_timeout': 'LCDE', + 'overload_restart': 'OLRS', + 'over_temp_restart': 'OTRS', + 'backlight_on': 'BLON', + 'alarm_on_on_primary_source_interrupt': 'ALRM', + 'fault_code_record': 'FTCR', +} +logger = logging.getLogger(__name__) + + +class InverterBotConfig(AppConfigUnit, TelegramBotConfig): + NAME = 'inverter_bot' + + @classmethod + def schema(cls) -> Optional[dict]: + acmode_item_schema = { + 'thresholds': { + 'type': 'list', + 'required': True, + 'schema': { + 'type': 'list', + 'min': 40, + 'max': 60 + }, + }, + 'initial_current': {'type': 'integer'} + } + + return { + **super(TelegramBotConfig).schema(), + 'ac_mode': { + 'type': 'dict', + 'required': True, + 'schema': { + 'generator': acmode_item_schema, + 'utilities': acmode_item_schema + } + }, + 'monitor': { + 'type': 'dict', + 'required': True, + 'schema': { + 'vlow': {'type': 'integer', 'required': True}, + 'vcrit': {'type': 'integer', 'required': True}, + 'gen_currents': {'type': 'list', 'schema': {'type': 'integer'}, 'required': True}, + 'gen_raise_intervals': {'type': 'list', 'schema': {'type': 'integer'}, 'required': True}, + 'gen_cur30_v_limit': {'type': 'float', 'required': True}, + 'gen_cur20_v_limit': {'type': 'float', 'required': True}, + 'gen_cur10_v_limit': {'type': 'float', 'required': True}, + 'gen_floating_v': {'type': 'integer', 'required': True}, + 'gen_floating_time_max': {'type': 'integer', 'required': True} + } + } + } + + +config.load_app(InverterBotConfig) + +bot.initialize() +bot.lang.ru( + socket="В розетке", + status='Статус', + generation='Генерация', + priority='Приоритет', + battery="АКБ", + load="Нагрузка", + generator="Генератор", + utilities="Столб", + consumption="Статистика потребления", + settings="Настройки", + done="Готово", + unexpected_callback_data="Ошибка: неверные данные", + invalid_input="Неверное значение", + invalid_mode="Invalid mode", + + flags_press_button='Нажмите кнопку для переключения настройки', + flags_fail='Не удалось установить настройку', + flags_invalid='Неизвестная настройка', + + # generation + gen_input_power='Зарядная мощность', + + # settings + settings_msg="Что вы хотите настроить?", + settings_osp='Приоритет питания нагрузки', + settings_ac_preset="Применить шаблон режима AC", + settings_bat_thresholds="Пороги заряда АКБ от AC", + settings_bat_cut_off_voltage="Порог отключения АКБ", + settings_ac_max_charging_current="Максимальный ток заряда от AC", + + settings_osp_msg="Установите приоритет:", + settings_osp_sub='Solar-Utility-Battery', + settings_osp_sbu='Solar-Battery-Utility', + + settings_select_bottom_threshold="Выберите нижний порог:", + settings_select_upper_threshold="Выберите верхний порог:", + settings_select_max_current='Выберите максимальный ток:', + settings_enter_cutoff_voltage=f'Введите напряжение V, где 40.0 {LT} V {LT} 48.0', + + # time and date + today='Сегодня', + yday1='Вчера', + yday2='Позавчера', + for_7days='За 7 дней', + for_30days='За 30 дней', + # to_select_interval='Выбрать интервал', + + # consumption + consumption_msg="Выберите тип:", + consumption_total="Домашние приборы", + consumption_grid="Со столба", + consumption_select_interval='Выберите период:', + consumption_request_sent="⏳ Запрос отправлен...", + + # status + charging_at=', ', + pd_charging='заряжается', + pd_discharging='разряжается', + pd_nothing='не используется', + + # flags + flag_buzzer='Звуковой сигнал', + flag_overload_bypass='Разрешить перегрузку', + flag_escape_to_default_screen_after_1min_timeout='Возврат на главный экран через 1 минуту', + flag_overload_restart='Перезапуск при перегрузке', + flag_over_temp_restart='Перезапуск при перегреве', + flag_backlight_on='Подсветка экрана', + flag_alarm_on_on_primary_source_interrupt='Сигнал при разрыве основного источника питания', + flag_fault_code_record='Запись кодов ошибок', + + # commands + setbatuv_v=f'напряжение, 40.0 {LT} V {LT} 48.0', + setgenct_cv=f'напряжение включения заряда, 44 {LT} CV {LT} 51', + setgenct_dv=f'напряжение отключения заряда, 48 {LT} DV {LT} 58', + setgencc_a='максимальный ток заряда, допустимые значения: %s', + + # monitor + chrg_evt_started='✅ Начали заряжать от генератора.', + chrg_evt_finished='✅ Зарядили. Генератор пора выключать.', + chrg_evt_disconnected='ℹ️ Генератор отключен.', + chrg_evt_current_changed='ℹ️ Ток заряда от генератора установлен в %d A.', + chrg_evt_not_charging='ℹ️ Генератор подключен, но не заряжает.', + chrg_evt_na_solar='⛔️ Генератор подключен, но аккумуляторы не заряжаются из-за подключенных панелей.', + chrg_evt_mostly_charged='✅ Аккумуляторы более-менее заряжены, генератор пора выключать.', + battery_level_changed='Уровень заряда АКБ: %s %s (%0.1f V при нагрузке %d W)', + error_message='Ошибка: %s.', + + util_chrg_evt_started='✅ Начали заряжать от столба.', + util_chrg_evt_stopped='ℹ️ Перестали заряжать от столба.', + util_chrg_evt_stopped_solar='ℹ️ Перестали заряжать от столба из-за подключения панелей.', + + util_connected='✅️ Столб подключён.', + util_disconnected='‼️ Столб отключён.', + + # other notifications + ac_mode_changed_notification='Пользователь %s установил режим AC: %s.', + osp_changed_notification='Пользователь %s установил приоритет источника питания нагрузки: %s.', + osp_auto_changed_notification='ℹ️ Бот установил приоритет источника питания нагрузки: %s. Причины: напряжение АКБ %.1f V, мощность заряда с панелей %d W.', + + bat_state_normal='Нормальный', + bat_state_low='Низкий', + bat_state_critical='Критический', +) + +bot.lang.en( + socket='AC output', + status='Status', + generation='Generation', + priority='Priority', + battery="Battery", + load="Load", + generator="Generator", + utilities="Utilities", + consumption="Consumption statistics", + settings="Settings", + done="Done", + unexpected_callback_data="Unexpected callback data", + select_priortiy="Select priority:", + invalid_input="Invalid input", + invalid_mode="Invalid mode", + + flags_press_button='Press a button to toggle a flag.', + flags_fail='Failed to toggle flag', + flags_invalid='Invalid flag', + + # settings + settings_msg='What do you want to configure?', + settings_osp='Output source priority', + settings_ac_preset="AC preset", + settings_bat_thresholds="Battery charging thresholds", + settings_bat_cut_off_voltage="Battery cut-off voltage", + settings_ac_max_charging_current="Max AC charging current", + + settings_osp_msg="Select priority:", + settings_osp_sub='Solar-Utility-Battery', + settings_osp_sbu='Solar-Battery-Utility', + + settings_select_bottom_threshold="Select bottom (lower) threshold:", + settings_select_upper_threshold="Select top (upper) threshold:", + settings_select_max_current='Select max current:', + settings_enter_cutoff_voltage=f'Enter voltage V (40.0 {LT} V {LT} 48.0):', + + # generation + gen_input_power='Input power', + + # time and date + today='Today', + yday1='Yesterday', + yday2='The day before yesterday', + for_7days='7 days', + for_30days='30 days', + # to_select_interval='Select interval', + + # consumption + consumption_msg="Select type:", + consumption_total="Home appliances", + consumption_grid="Consumed from grid", + consumption_select_interval='Select period:', + consumption_request_sent="⏳ Request sent...", + + # status + charging_at=' @ ', + pd_charging='charging', + pd_discharging='discharging', + pd_nothing='not used', + + # flags + flag_buzzer='Buzzer', + flag_overload_bypass='Overload bypass', + flag_escape_to_default_screen_after_1min_timeout='Reset to default LCD page after 1min timeout', + flag_overload_restart='Restart on overload', + flag_over_temp_restart='Restart on overtemp', + flag_backlight_on='LCD backlight', + flag_alarm_on_on_primary_source_interrupt='Beep on primary source interruption', + flag_fault_code_record='Fault code recording', + + # commands + setbatuv_v=f'floating point number, 40.0 {LT} V {LT} 48.0', + setgenct_cv=f'charging voltage, 44 {LT} CV {LT} 51', + setgenct_dv=f'discharging voltage, 48 {LT} DV {LT} 58', + setgencc_a='max charging current, allowed values: %s', + + # monitor + chrg_evt_started='✅ Started charging from AC.', + chrg_evt_finished='✅ Finished charging, it\'s time to stop the generator.', + chrg_evt_disconnected='ℹ️ AC disconnected.', + chrg_evt_current_changed='ℹ️ AC charging current set to %d A.', + chrg_evt_not_charging='ℹ️ AC connected but not charging.', + chrg_evt_na_solar='⛔️ AC connected, but battery won\'t be charged due to active solar power line.', + chrg_evt_mostly_charged='✅ The battery is mostly charged now. The generator can be turned off.', + battery_level_changed='Battery level: %s (%0.1f V under %d W load)', + error_message='Error: %s.', + + util_chrg_evt_started='✅ Started charging from utilities.', + util_chrg_evt_stopped='ℹ️ Stopped charging from utilities.', + util_chrg_evt_stopped_solar='ℹ️ Stopped charging from utilities because solar panels were connected.', + + util_connected='✅️ Utilities connected.', + util_disconnected='‼️ Utilities disconnected.', + + # other notifications + ac_mode_changed_notification='User %s set AC mode to %s.', + osp_changed_notification='User %s set output source priority: %s.', + osp_auto_changed_notification='Bot changed output source priority to %s. Reasons: battery voltage is %.1f V, solar input is %d W.', + + bat_state_normal='Normal', + bat_state_low='Low', + bat_state_critical='Critical', +) + + +def monitor_charging(event: ChargingEvent, **kwargs) -> None: + args = [] + is_util = False + if event == ChargingEvent.AC_CHARGING_STARTED: + key = 'started' + elif event == ChargingEvent.AC_CHARGING_FINISHED: + key = 'finished' + elif event == ChargingEvent.AC_DISCONNECTED: + key = 'disconnected' + elif event == ChargingEvent.AC_NOT_CHARGING: + key = 'not_charging' + elif event == ChargingEvent.AC_CURRENT_CHANGED: + key = 'current_changed' + args.append(kwargs['current']) + elif event == ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR: + key = 'na_solar' + elif event == ChargingEvent.AC_MOSTLY_CHARGED: + key = 'mostly_charged' + elif event == ChargingEvent.UTIL_CHARGING_STARTED: + key = 'started' + is_util = True + elif event == ChargingEvent.UTIL_CHARGING_STOPPED: + key = 'stopped' + is_util = True + elif event == ChargingEvent.UTIL_CHARGING_STOPPED_SOLAR: + key = 'stopped_solar' + is_util = True + else: + logger.error('unknown charging event:', event) + return + + key = f'chrg_evt_{key}' + if is_util: + key = f'util_{key}' + bot.notify_all( + lambda lang: bot.lang.get(key, lang, *args) + ) + + +def monitor_battery(state: BatteryState, v: float, load_watts: int) -> None: + if state == BatteryState.NORMAL: + emoji = '✅' + elif state == BatteryState.LOW: + emoji = '⚠️' + elif state == BatteryState.CRITICAL: + emoji = '‼️' + else: + logger.error('unknown battery state:', state) + return + + bot.notify_all( + lambda lang: bot.lang.get('battery_level_changed', lang, + emoji, bot.lang.get(f'bat_state_{state.name.lower()}', lang), v, load_watts) + ) + + +def monitor_util(event: ACPresentEvent): + if event == ACPresentEvent.CONNECTED: + key = 'connected' + else: + key = 'disconnected' + key = f'util_{key}' + bot.notify_all( + lambda lang: bot.lang.get(key, lang) + ) + + +def monitor_error(error: str) -> None: + bot.notify_all( + lambda lang: bot.lang.get('error_message', lang, error) + ) + + +def osp_change_cb(new_osp: OutputSourcePriority, + solar_input: int, + v: float): + + setosp(new_osp) + + bot.notify_all( + lambda lang: bot.lang.get('osp_auto_changed_notification', lang, + bot.lang.get(f'settings_osp_{new_osp.value.lower()}', lang), v, solar_input), + ) + + +@bot.handler(command='status') +def full_status(ctx: bot.Context) -> None: + status = inverter.exec('get-status', format=Format.TABLE) + ctx.reply(beautify_table(status)) + + +@bot.handler(command='config') +def full_rated(ctx: bot.Context) -> None: + rated = inverter.exec('get-rated', format=Format.TABLE) + ctx.reply(beautify_table(rated)) + + +@bot.handler(command='errors') +def full_errors(ctx: bot.Context) -> None: + errors = inverter.exec('get-errors', format=Format.TABLE) + ctx.reply(beautify_table(errors)) + + +@bot.handler(command='flags') +def flags_handler(ctx: bot.Context) -> None: + flags = inverter.exec('get-flags')['data'] + text, markup = build_flags_keyboard(flags, ctx) + ctx.reply(text, markup=markup) + + +def build_flags_keyboard(flags: dict, ctx: bot.Context) -> Tuple[str, InlineKeyboardMarkup]: + keyboard = [] + for k, v in flags.items(): + label = ('✅' if v else '❌') + ' ' + ctx.lang(f'flag_{k}') + proto_flag = flags_map[k] + keyboard.append([InlineKeyboardButton(label, callback_data=f'flag_{proto_flag}')]) + + return ctx.lang('flags_press_button'), InlineKeyboardMarkup(keyboard) + + +def getacmode() -> ACMode: + return ACMode(bot.db.get_param('ac_mode', default=ACMode.GENERATOR)) + + +def setacmode(mode: ACMode): + monitor.set_ac_mode(mode) + + cv, dv = config['ac_mode'][str(mode.value)]['thresholds'] + a = config['ac_mode'][str(mode.value)]['initial_current'] + + logger.debug(f'setacmode: mode={mode}, cv={cv}, dv={dv}, a={a}') + + inverter.exec('set-charge-thresholds', (cv, dv)) + inverter.exec('set-max-ac-charge-current', (0, a)) + + +def setosp(sp: OutputSourcePriority): + logger.debug(f'setosp: sp={sp}') + inverter.exec('set-output-source-priority', (sp.value,)) + monitor.notify_osp(sp) + + +class SettingsConversation(bot.conversation): + START, OSP, AC_PRESET, BAT_THRESHOLDS_1, BAT_THRESHOLDS_2, BAT_CUT_OFF_VOLTAGE, AC_MAX_CHARGING_CURRENT = range(7) + STATE_SEQS = [ + [START, OSP], + [START, AC_PRESET], + [START, BAT_THRESHOLDS_1, BAT_THRESHOLDS_2], + [START, BAT_CUT_OFF_VOLTAGE], + [START, AC_MAX_CHARGING_CURRENT] + ] + + START_BUTTONS = bot.lang.pfx('settings_', ['ac_preset', + 'ac_max_charging_current', + 'bat_thresholds', + 'bat_cut_off_voltage', + 'osp']) + OSP_BUTTONS = bot.lang.pfx('settings_osp_', [sp.value.lower() for sp in OutputSourcePriority]) + AC_PRESET_BUTTONS = [mode.value for mode in ACMode] + + RECHARGE_VOLTAGES = [44, 45, 46, 47, 48, 49, 50, 51] + REDISCHARGE_VOLTAGES = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58] + + @bot.conventer(START, message='settings') + def start_enter(self, ctx: bot.Context): + buttons = list(chunks(list(self.START_BUTTONS), 2)) + buttons.reverse() + return self.reply(ctx, self.START, ctx.lang('settings_msg'), buttons, + with_cancel=True) + + @bot.convinput(START, messages={ + 'settings_osp': OSP, + 'settings_ac_preset': AC_PRESET, + 'settings_bat_thresholds': BAT_THRESHOLDS_1, + 'settings_bat_cut_off_voltage': BAT_CUT_OFF_VOLTAGE, + 'settings_ac_max_charging_current': AC_MAX_CHARGING_CURRENT + }) + def start_input(self, ctx: bot.Context): + pass + + @bot.conventer(OSP) + def osp_enter(self, ctx: bot.Context): + return self.reply(ctx, self.OSP, ctx.lang('settings_osp_msg'), self.OSP_BUTTONS, + with_back=True) + + @bot.convinput(OSP, messages=OSP_BUTTONS) + def osp_input(self, ctx: bot.Context): + selected_sp = None + for sp in OutputSourcePriority: + if ctx.text == ctx.lang(f'settings_osp_{sp.value.lower()}'): + selected_sp = sp + break + + if selected_sp is None: + raise ValueError('invalid sp') + + # apply the mode + setosp(selected_sp) + + # reply to user + ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()) + + # notify other users + bot.notify_all( + lambda lang: bot.lang.get('osp_changed_notification', lang, + ctx.user.id, ctx.user.name, + bot.lang.get(f'settings_osp_{selected_sp.value.lower()}', lang)), + exclude=(ctx.user_id,) + ) + return self.END + + @bot.conventer(AC_PRESET) + def acpreset_enter(self, ctx: bot.Context): + return self.reply(ctx, self.AC_PRESET, ctx.lang('settings_ac_preset_msg'), self.AC_PRESET_BUTTONS, + with_back=True) + + @bot.convinput(AC_PRESET, messages=AC_PRESET_BUTTONS) + def acpreset_input(self, ctx: bot.Context): + if monitor.active_current is not None: + raise RuntimeError('generator charging program is active') + + if ctx.text == ctx.lang('utilities'): + newmode = ACMode.UTILITIES + elif ctx.text == ctx.lang('generator'): + newmode = ACMode.GENERATOR + else: + raise ValueError('invalid mode') + + # apply the mode + setacmode(newmode) + + # save + bot.db.set_param('ac_mode', str(newmode.value)) + + # reply to user + ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()) + + # notify other users + bot.notify_all( + lambda lang: bot.lang.get('ac_mode_changed_notification', lang, + ctx.user.id, ctx.user.name, + bot.lang.get(str(newmode.value), lang)), + exclude=(ctx.user_id,) + ) + return self.END + + @bot.conventer(BAT_THRESHOLDS_1) + def thresholds1_enter(self, ctx: bot.Context): + buttons = list(map(lambda v: f'{v} V', self.RECHARGE_VOLTAGES)) + buttons = chunks(buttons, 4) + return self.reply(ctx, self.BAT_THRESHOLDS_1, ctx.lang('settings_select_bottom_threshold'), buttons, + with_back=True, buttons_lang_completed=True) + + @bot.convinput(BAT_THRESHOLDS_1, + messages=list(map(lambda n: f'{n} V', RECHARGE_VOLTAGES)), + messages_lang_completed=True) + def thresholds1_input(self, ctx: bot.Context): + v = self._parse_voltage(ctx.text) + ctx.user_data['bat_thrsh_v1'] = v + return self.invoke(self.BAT_THRESHOLDS_2, ctx) + + @bot.conventer(BAT_THRESHOLDS_2) + def thresholds2_enter(self, ctx: bot.Context): + buttons = list(map(lambda v: f'{v} V', self.REDISCHARGE_VOLTAGES)) + buttons = chunks(buttons, 4) + return self.reply(ctx, self.BAT_THRESHOLDS_2, ctx.lang('settings_select_upper_threshold'), buttons, + with_back=True, buttons_lang_completed=True) + + @bot.convinput(BAT_THRESHOLDS_2, + messages=list(map(lambda n: f'{n} V', REDISCHARGE_VOLTAGES)), + messages_lang_completed=True) + def thresholds2_input(self, ctx: bot.Context): + v2 = v = self._parse_voltage(ctx.text) + v1 = ctx.user_data['bat_thrsh_v1'] + del ctx.user_data['bat_thrsh_v1'] + + response = inverter.exec('set-charge-thresholds', (v1, v2)) + ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', + markup=bot.IgnoreMarkup()) + return self.END + + @bot.conventer(AC_MAX_CHARGING_CURRENT) + def ac_max_enter(self, ctx: bot.Context): + buttons = self._get_allowed_ac_charge_amps() + buttons = map(lambda n: f'{n} A', buttons) + buttons = [list(buttons)] + return self.reply(ctx, self.AC_MAX_CHARGING_CURRENT, ctx.lang('settings_select_max_current'), buttons, + with_back=True, buttons_lang_completed=True) + + @bot.convinput(AC_MAX_CHARGING_CURRENT, regex=r'^\d+ A$') + def ac_max_input(self, ctx: bot.Context): + a = self._parse_amps(ctx.text) + allowed = self._get_allowed_ac_charge_amps() + if a not in allowed: + raise ValueError('input is not allowed') + + response = inverter.exec('set-max-ac-charge-current', (0, a)) + ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', + markup=bot.IgnoreMarkup()) + return self.END + + @bot.conventer(BAT_CUT_OFF_VOLTAGE) + def cutoff_enter(self, ctx: bot.Context): + return self.reply(ctx, self.BAT_CUT_OFF_VOLTAGE, ctx.lang('settings_enter_cutoff_voltage'), None, + with_back=True) + + @bot.convinput(BAT_CUT_OFF_VOLTAGE, regex=r'^(\d{2}(\.\d{1})?)$') + def cutoff_input(self, ctx: bot.Context): + v = float(ctx.text) + if 40.0 <= v <= 48.0: + response = inverter.exec('set-battery-cutoff-voltage', (v,)) + ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', + markup=bot.IgnoreMarkup()) + else: + raise ValueError('invalid voltage') + + return self.END + + def _get_allowed_ac_charge_amps(self) -> list[int]: + l = inverter.exec('get-allowed-ac-charge-currents')['data'] + l = filter(lambda n: n <= 40, l) + return list(l) + + def _parse_voltage(self, s: str) -> int: + return int(re.match(r'^(\d{2}) V$', s).group(1)) + + def _parse_amps(self, s: str) -> int: + return int(re.match(r'^(\d{1,2}) A$', s).group(1)) + + +class ConsumptionConversation(bot.conversation): + START, TOTAL, GRID = range(3) + STATE_SEQS = [ + [START, TOTAL], + [START, GRID] + ] + + START_BUTTONS = bot.lang.pfx('consumption_', ['total', 'grid']) + INTERVAL_BUTTONS = [ + ['today'], + ['yday1'], + ['for_7days', 'for_30days'], + # ['to_select_interval'] + ] + INTERVAL_BUTTONS_FLAT = list(itertools.chain.from_iterable(INTERVAL_BUTTONS)) + + @bot.conventer(START, message='consumption') + def start_enter(self, ctx: bot.Context): + return self.reply(ctx, self.START, ctx.lang('consumption_msg'), [self.START_BUTTONS], + with_cancel=True) + + @bot.convinput(START, messages={ + 'consumption_total': TOTAL, + 'consumption_grid': GRID + }) + def start_input(self, ctx: bot.Context): + pass + + @bot.conventer(TOTAL) + def total_enter(self, ctx: bot.Context): + return self._render_interval_btns(ctx, self.TOTAL) + + @bot.conventer(GRID) + def grid_enter(self, ctx: bot.Context): + return self._render_interval_btns(ctx, self.GRID) + + def _render_interval_btns(self, ctx: bot.Context, state): + return self.reply(ctx, state, ctx.lang('consumption_select_interval'), self.INTERVAL_BUTTONS, + with_back=True) + + @bot.convinput(TOTAL, messages=INTERVAL_BUTTONS_FLAT) + def total_input(self, ctx: bot.Context): + return self._render_interval_results(ctx, self.TOTAL) + + @bot.convinput(GRID, messages=INTERVAL_BUTTONS_FLAT) + def grid_input(self, ctx: bot.Context): + return self._render_interval_results(ctx, self.GRID) + + def _render_interval_results(self, ctx: bot.Context, state): + # if ctx.text == ctx.lang('to_select_interval'): + # TODO + # pass + # + # else: + + now = datetime.datetime.now() + s_to = now.strftime(FormatDate) + + if ctx.text == ctx.lang('today'): + s_from = now.strftime(FormatDate) + s_to = 'now' + elif ctx.text == ctx.lang('yday1'): + s_from = (now - datetime.timedelta(days=1)).strftime(FormatDate) + elif ctx.text == ctx.lang('for_7days'): + s_from = (now - datetime.timedelta(days=7)).strftime(FormatDate) + elif ctx.text == ctx.lang('for_30days'): + s_from = (now - datetime.timedelta(days=30)).strftime(FormatDate) + + # markup = InlineKeyboardMarkup([ + # [InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait')] + # ]) + + message = ctx.reply(ctx.lang('consumption_request_sent'), + markup=bot.IgnoreMarkup()) + + api = WebApiClient(timeout=60) + method = 'inverter_get_consumed_energy' if state == self.TOTAL else 'inverter_get_grid_consumed_energy' + + try: + wh = getattr(api, method)(s_from, s_to) + bot.delete_message(message.chat_id, message.message_id) + ctx.reply('%.2f Wh' % (wh,), + markup=bot.IgnoreMarkup()) + return self.END + except Exception as e: + bot.delete_message(message.chat_id, message.message_id) + ctx.reply_exc(e) + +# other +# ----- + +@bot.handler(command='monstatus') +def monstatus_handler(ctx: bot.Context) -> None: + msg = '' + st = monitor.dump_status() + for k, v in st.items(): + msg += k + ': ' + str(v) + '\n' + ctx.reply(msg) + + +@bot.handler(command='monsetcur') +def monsetcur_handler(ctx: bot.Context) -> None: + ctx.reply('not implemented yet') + + +@bot.callbackhandler +def button_callback(ctx: bot.Context) -> None: + query = ctx.callback_query + + if query.data.startswith('flag_'): + flag = query.data[5:] + found = False + json_key = None + for k, v in flags_map.items(): + if v == flag: + found = True + json_key = k + break + if not found: + query.answer(ctx.lang('flags_invalid')) + return + + flags = inverter.exec('get-flags')['data'] + cur_flag_value = flags[json_key] + target_flag_value = '0' if cur_flag_value else '1' + + # set flag + response = inverter.exec('set-flag', (flag, target_flag_value)) + + # notify user + query.answer(ctx.lang('done') if response['result'] == 'ok' else ctx.lang('flags_fail')) + + # edit message + flags[json_key] = not cur_flag_value + text, markup = build_flags_keyboard(flags, ctx) + query.edit_message_text(text, reply_markup=markup) + + else: + query.answer(ctx.lang('unexpected_callback_data')) + + +@bot.exceptionhandler +def exception_handler(e: Exception, ctx: bot.Context) -> Optional[bool]: + if isinstance(e, InverterError): + try: + err = json.loads(str(e))['message'] + except json.decoder.JSONDecodeError: + err = str(e) + err = re.sub(r'((?:.*)?error:) (.*)', r'\1 \2', err) + ctx.reply(err, + markup=bot.IgnoreMarkup()) + return True + + +@bot.handler(message='status') +def status_handler(ctx: bot.Context) -> None: + gs = inverter.exec('get-status')['data'] + rated = inverter.exec('get-rated')['data'] + + # render response + power_direction = gs['battery_power_direction'].lower() + power_direction = re.sub(r'ge$', 'ging', power_direction) + + charging_rate = '' + chrg_at = ctx.lang('charging_at') + + if power_direction == 'charging': + charging_rate = f'{chrg_at}%s %s' % ( + gs['battery_charge_current']['value'], gs['battery_charge_current']['unit']) + pd_label = ctx.lang('pd_charging') + elif power_direction == 'discharging': + charging_rate = f'{chrg_at}%s %s' % ( + gs['battery_discharge_current']['value'], gs['battery_discharge_current']['unit']) + pd_label = ctx.lang('pd_discharging') + else: + pd_label = ctx.lang('pd_nothing') + + html = f'{ctx.lang("battery")}: %s %s' % (gs['battery_voltage']['value'], gs['battery_voltage']['unit']) + html += ' (%s%s)' % (pd_label, charging_rate) + + html += f'\n{ctx.lang("load")}: %s %s' % (gs['ac_output_active_power']['value'], gs['ac_output_active_power']['unit']) + html += ' (%s%%)' % (gs['output_load_percent']['value']) + + if gs['pv1_input_power']['value'] > 0: + html += f'\n{ctx.lang("gen_input_power")}: %s %s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit']) + + if gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0: + ac_mode = getacmode() + html += f'\n{ctx.lang(ac_mode.value)}: %s %s' % (gs['grid_voltage']['value'], gs['grid_voltage']['unit']) + html += ', %s %s' % (gs['grid_freq']['value'], gs['grid_freq']['unit']) + + html += f'\n{ctx.lang("socket")}: %s %s, %s %s' % ( + gs['ac_output_voltage']['value'], gs['ac_output_voltage']['unit'], + gs['ac_output_freq']['value'], gs['ac_output_freq']['unit'] + ) + + html += f'\n{ctx.lang("priority")}: {rated["output_source_priority"]}' + + # send response + ctx.reply(html) + + +@bot.handler(message='generation') +def generation_handler(ctx: bot.Context) -> None: + today = datetime.date.today() + yday = today - datetime.timedelta(days=1) + yday2 = today - datetime.timedelta(days=2) + + gs = inverter.exec('get-status')['data'] + + gen_today = inverter.exec('get-day-generated', (today.year, today.month, today.day))['data'] + gen_yday = None + gen_yday2 = None + + if yday.month == today.month: + gen_yday = inverter.exec('get-day-generated', (yday.year, yday.month, yday.day))['data'] + + if yday2.month == today.month: + gen_yday2 = inverter.exec('get-day-generated', (yday2.year, yday2.month, yday2.day))['data'] + + # render response + html = f'{ctx.lang("gen_input_power")}: %s %s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit']) + html += ' (%s %s)' % (gs['pv1_input_voltage']['value'], gs['pv1_input_voltage']['unit']) + + html += f'\n{ctx.lang("today")}: %s Wh' % (gen_today['wh']) + + if gen_yday is not None: + html += f'\n{ctx.lang("yday1")}: %s Wh' % (gen_yday['wh']) + + if gen_yday2 is not None: + html += f'\n{ctx.lang("yday2")}: %s Wh' % (gen_yday2['wh']) + + # send response + ctx.reply(html) + + +@bot.defaultreplymarkup +def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: + button = [ + [ctx.lang('status'), ctx.lang('generation')], + [ctx.lang('consumption')], + [ctx.lang('settings')] + ] + return ReplyKeyboardMarkup(button, one_time_keyboard=False) + + +class InverterStore(bot.BotDatabase): + SCHEMA = 2 + + def schema_init(self, version: int) -> None: + super().schema_init(version) + + if version < 2: + cursor = self.cursor() + cursor.execute("""CREATE TABLE IF NOT EXISTS params ( + id TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL + )""") + cursor.execute("CREATE INDEX param_id_idx ON params (id)") + self.commit() + + def get_param(self, key: str, default=None): + cursor = self.cursor() + cursor.execute('SELECT value FROM params WHERE id=?', (key,)) + row = cursor.fetchone() + + return default if row is None else row[0] + + def set_param(self, key: str, value: Union[str, int, float]): + cursor = self.cursor() + cursor.execute('REPLACE INTO params (id, value) VALUES (?, ?)', (key, str(value))) + self.commit() + + +inverter.init(host=config['inverter']['ip'], port=config['inverter']['port']) + +bot.set_database(InverterStore()) +bot.enable_logging(BotType.INVERTER) + +bot.add_conversation(SettingsConversation(enable_back=True)) +bot.add_conversation(ConsumptionConversation(enable_back=True)) + +monitor = InverterMonitor() +monitor.set_charging_event_handler(monitor_charging) +monitor.set_battery_event_handler(monitor_battery) +monitor.set_util_event_handler(monitor_util) +monitor.set_error_handler(monitor_error) +monitor.set_osp_need_change_callback(osp_change_cb) + +setacmode(getacmode()) + +if not config.get('monitor.disabled'): + logging.info('starting monitor') + monitor.start() + +bot.run() + +monitor.stop() diff --git a/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/bin/inverterd_emulator.py b/bin/inverterd_emulator.py new file mode 100755 index 0000000..371d955 --- /dev/null +++ b/bin/inverterd_emulator.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +import logging +import __py_include + +from homekit.inverter.emulator import InverterEmulator + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG) + InverterEmulator(addr=('127.0.0.1', 8305)) diff --git a/bin/ipcam_server.py b/bin/ipcam_server.py new file mode 100755 index 0000000..211bc86 --- /dev/null +++ b/bin/ipcam_server.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +import logging +import os +import re +import asyncio +import time +import shutil +import __py_include + +import homekit.telegram.aio as telegram + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from asyncio import Lock + +from homekit.config import config +from homekit import http +from homekit.database.sqlite import SQLiteBase +from homekit.camera import util as camutil + +from enum import Enum +from typing import Optional, Union, List, Tuple +from datetime import datetime, timedelta +from functools import cmp_to_key + + +class TimeFilterType(Enum): + FIX = 'fix' + MOTION = 'motion' + MOTION_START = 'motion_start' + + +class TelegramLinkType(Enum): + FRAGMENT = 'fragment' + ORIGINAL_FILE = 'original_file' + + +def valid_recording_name(filename: str) -> bool: + return filename.startswith('record_') and filename.endswith('.mp4') + + +def filename_to_datetime(filename: str) -> datetime: + filename = os.path.basename(filename).replace('record_', '').replace('.mp4', '') + return datetime.strptime(filename, datetime_format) + + +def get_all_cams() -> list: + return [cam for cam in config['camera'].keys()] + + +# ipcam database +# -------------- + +class IPCamServerDatabase(SQLiteBase): + SCHEMA = 4 + + def __init__(self): + super().__init__() + + def schema_init(self, version: int) -> None: + cursor = self.cursor() + + if version < 1: + # timestamps + cursor.execute("""CREATE TABLE IF NOT EXISTS timestamps ( + camera INTEGER PRIMARY KEY, + fix_time INTEGER NOT NULL, + motion_time INTEGER NOT NULL + )""") + for cam in config['camera'].keys(): + self.add_camera(cam) + + if version < 2: + # motion_failures + cursor.execute("""CREATE TABLE IF NOT EXISTS motion_failures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + camera INTEGER NOT NULL, + filename TEXT NOT NULL + )""") + + if version < 3: + cursor.execute("ALTER TABLE motion_failures ADD COLUMN message TEXT NOT NULL DEFAULT ''") + + if version < 4: + cursor.execute("ALTER TABLE timestamps ADD COLUMN motion_start_time INTEGER NOT NULL DEFAULT 0") + cursor.execute("UPDATE timestamps SET motion_start_time=motion_time") + + self.commit() + + def add_camera(self, camera: int): + self.cursor().execute("INSERT INTO timestamps (camera, fix_time, motion_time) VALUES (?, ?, ?)", + (camera, 0, 0)) + self.commit() + + def add_motion_failure(self, + camera: int, + filename: str, + message: Optional[str]): + self.cursor().execute("INSERT INTO motion_failures (camera, filename, message) VALUES (?, ?, ?)", + (camera, filename, message or '')) + self.commit() + + def get_all_timestamps(self): + cur = self.cursor() + data = {} + + cur.execute("SELECT camera, fix_time, motion_time, motion_start_time FROM timestamps") + for cam, fix_time, motion_time, motion_start_time in cur.fetchall(): + data[int(cam)] = { + 'fix': int(fix_time), + 'motion': int(motion_time), + 'motion_start': int(motion_start_time) + } + + return data + + def set_timestamp(self, + camera: int, + time_type: TimeFilterType, + time: Union[int, datetime]): + cur = self.cursor() + if isinstance(time, datetime): + time = int(time.timestamp()) + cur.execute(f"UPDATE timestamps SET {time_type.value}_time=? WHERE camera=?", (time, camera)) + self.commit() + + def get_timestamp(self, + camera: int, + time_type: TimeFilterType) -> int: + cur = self.cursor() + cur.execute(f"SELECT {time_type.value}_time FROM timestamps WHERE camera=?", (camera,)) + return int(cur.fetchone()[0]) + + +# ipcam web api +# ------------- + +class IPCamWebServer(http.HTTPServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.get('/api/recordings', self.get_motion_queue) + self.get('/api/recordings/{name}', self.get_camera_recordings) + self.get('/api/recordings/{name}/download/{file}', self.download_recording) + self.get('/api/camera/list', self.camlist) + self.get('/api/timestamp/{name}/{type}', self.get_timestamp) + self.get('/api/timestamp/all', self.get_all_timestamps) + + self.post('/api/debug/migrate-mtimes', self.debug_migrate_mtimes) + self.post('/api/debug/fix', self.debug_fix) + self.post('/api/debug/cleanup', self.debug_cleanup) + self.post('/api/timestamp/{name}/{type}', self.set_timestamp) + + self.post('/api/motion/done/{name}', self.submit_motion) + self.post('/api/motion/fail/{name}', self.submit_motion_failure) + + self.get('/api/motion/params/{name}', self.get_motion_params) + self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params) + + self.queue_lock = Lock() + + async def get_camera_recordings(self, req): + camera = int(req.match_info['name']) + try: + filter = TimeFilterType(req.query['filter']) + except KeyError: + filter = None + + try: + limit = int(req.query['limit']) + except KeyError: + limit = 0 + + files = get_recordings_files(camera, filter, limit) + if files: + time = filename_to_datetime(files[len(files)-1]['name']) + db.set_timestamp(camera, TimeFilterType.MOTION_START, time) + return self.ok({'files': files}) + + async def get_motion_queue(self, req): + try: + limit = int(req.query['limit']) + except KeyError: + limit = 0 + + async with self.queue_lock: + files = get_recordings_files(None, TimeFilterType.MOTION_START, limit) + if files: + times_by_cam = {} + for file in files: + time = filename_to_datetime(file['name']) + if file['cam'] not in times_by_cam or times_by_cam[file['cam']] < time: + times_by_cam[file['cam']] = time + for cam, time in times_by_cam.items(): + db.set_timestamp(cam, TimeFilterType.MOTION_START, time) + + return self.ok({'files': files}) + + async def download_recording(self, req: http.Request): + cam = int(req.match_info['name']) + file = req.match_info['file'] + + fullpath = os.path.join(config['camera'][cam]['recordings_path'], file) + if not os.path.isfile(fullpath): + raise ValueError(f'file "{fullpath}" does not exists') + + return http.FileResponse(fullpath) + + async def camlist(self, req: http.Request): + return self.ok(config['camera']) + + async def submit_motion(self, req: http.Request): + data = await req.post() + + camera = int(req.match_info['name']) + timecodes = data['timecodes'] + filename = data['filename'] + + time = filename_to_datetime(filename) + + try: + if timecodes != '': + fragments = camutil.dvr_scan_timecodes(timecodes) + asyncio.ensure_future(process_fragments(camera, filename, fragments)) + + db.set_timestamp(camera, TimeFilterType.MOTION, time) + return self.ok() + + except camutil.DVRScanInvalidTimecodes as e: + db.add_motion_failure(camera, filename, str(e)) + db.set_timestamp(camera, TimeFilterType.MOTION, time) + return self.ok('invalid timecodes') + + async def submit_motion_failure(self, req: http.Request): + camera = int(req.match_info['name']) + + data = await req.post() + filename = data['filename'] + message = data['message'] + + db.add_motion_failure(camera, filename, message) + db.set_timestamp(camera, TimeFilterType.MOTION, filename_to_datetime(filename)) + + return self.ok() + + async def debug_migrate_mtimes(self, req: http.Request): + written = {} + for cam in config['camera'].keys(): + confdir = os.path.join(os.getenv('HOME'), '.config', f'video-util-{cam}') + for time_type in TimeFilterType: + txt_file = os.path.join(confdir, f'{time_type.value}_mtime') + if os.path.isfile(txt_file): + with open(txt_file, 'r') as fd: + data = fd.read() + db.set_timestamp(cam, time_type, int(data.strip())) + + if cam not in written: + written[cam] = [] + written[cam].append(time_type) + + return self.ok({'written': written}) + + async def debug_fix(self, req: http.Request): + asyncio.ensure_future(fix_job()) + return self.ok() + + async def debug_cleanup(self, req: http.Request): + asyncio.ensure_future(cleanup_job()) + return self.ok() + + async def set_timestamp(self, req: http.Request): + cam, time_type, time = self._getset_timestamp_params(req, need_time=True) + db.set_timestamp(cam, time_type, time) + return self.ok() + + async def get_timestamp(self, req: http.Request): + cam, time_type = self._getset_timestamp_params(req) + return self.ok(db.get_timestamp(cam, time_type)) + + async def get_all_timestamps(self, req: http.Request): + return self.ok(db.get_all_timestamps()) + + async def get_motion_params(self, req: http.Request): + data = config['motion_params'][int(req.match_info['name'])] + lines = [ + f'threshold={data["threshold"]}', + f'min_event_length=3s', + f'frame_skip=2', + f'downscale_factor=3', + ] + return self.plain('\n'.join(lines)+'\n') + + async def get_motion_roi_params(self, req: http.Request): + data = config['motion_params'][int(req.match_info['name'])] + return self.plain('\n'.join(data['roi'])+'\n') + + @staticmethod + def _getset_timestamp_params(req: http.Request, need_time=False): + values = [] + + cam = int(req.match_info['name']) + assert cam in config['camera'], 'invalid camera' + + values.append(cam) + values.append(TimeFilterType(req.match_info['type'])) + + if need_time: + time = req.query['time'] + if time.startswith('record_'): + time = filename_to_datetime(time) + elif time.isnumeric(): + time = int(time) + else: + raise ValueError('invalid time') + values.append(time) + + return values + + +# other global stuff +# ------------------ + +def open_database(): + global db + db = IPCamServerDatabase() + + # update cams list in database, if needed + cams = db.get_all_timestamps().keys() + for cam in config['camera']: + if cam not in cams: + db.add_camera(cam) + + +def get_recordings_path(cam: int) -> str: + return config['camera'][cam]['recordings_path'] + + +def get_motion_path(cam: int) -> str: + return config['camera'][cam]['motion_path'] + + +def get_recordings_files(cam: Optional[int] = None, + time_filter_type: Optional[TimeFilterType] = None, + limit=0) -> List[dict]: + from_time = 0 + to_time = int(time.time()) + + cams = [cam] if cam is not None else get_all_cams() + files = [] + for cam in cams: + if time_filter_type: + from_time = db.get_timestamp(cam, time_filter_type) + if time_filter_type in (TimeFilterType.MOTION, TimeFilterType.MOTION_START): + to_time = db.get_timestamp(cam, TimeFilterType.FIX) + + from_time = datetime.fromtimestamp(from_time) + to_time = datetime.fromtimestamp(to_time) + + recdir = get_recordings_path(cam) + cam_files = [{ + 'cam': cam, + 'name': file, + 'size': os.path.getsize(os.path.join(recdir, file))} + for file in os.listdir(recdir) + if valid_recording_name(file) and from_time < filename_to_datetime(file) <= to_time] + cam_files.sort(key=lambda file: file['name']) + + if cam_files: + last = cam_files[len(cam_files)-1] + fullpath = os.path.join(recdir, last['name']) + if camutil.has_handle(fullpath): + logger.debug(f'get_recordings_files: file {fullpath} has opened handle, ignoring it') + cam_files.pop() + files.extend(cam_files) + + if limit > 0: + files = files[:limit] + + return files + + +async def process_fragments(camera: int, + filename: str, + fragments: List[Tuple[int, int]]) -> None: + time = filename_to_datetime(filename) + + rec_dir = get_recordings_path(camera) + motion_dir = get_motion_path(camera) + if not os.path.exists(motion_dir): + os.mkdir(motion_dir) + + for fragment in fragments: + start, end = fragment + + start -= config['motion']['padding'] + end += config['motion']['padding'] + + if start < 0: + start = 0 + + duration = end - start + + dt1 = (time + timedelta(seconds=start)).strftime(datetime_format) + dt2 = (time + timedelta(seconds=end)).strftime(datetime_format) + + await camutil.ffmpeg_cut(input=os.path.join(rec_dir, filename), + output=os.path.join(motion_dir, f'{dt1}__{dt2}.mp4'), + start_pos=start, + duration=duration) + + if fragments and 'telegram' in config['motion'] and config['motion']['telegram']: + asyncio.ensure_future(motion_notify_tg(camera, filename, fragments)) + + +async def motion_notify_tg(camera: int, + filename: str, + fragments: List[Tuple[int, int]]): + dt_file = filename_to_datetime(filename) + fmt = '%H:%M:%S' + + text = f'Camera: {camera}\n' + text += f'Original file: {filename} ' + text += _tg_links(TelegramLinkType.ORIGINAL_FILE, camera, filename) + + for start, end in fragments: + start -= config['motion']['padding'] + end += config['motion']['padding'] + + if start < 0: + start = 0 + + duration = end - start + if duration < 0: + duration = 0 + + dt1 = dt_file + timedelta(seconds=start) + dt2 = dt_file + timedelta(seconds=end) + + text += f'\nFragment: {duration}s, {dt1.strftime(fmt)}-{dt2.strftime(fmt)} ' + text += _tg_links(TelegramLinkType.FRAGMENT, camera, f'{dt1.strftime(datetime_format)}__{dt2.strftime(datetime_format)}.mp4') + + await telegram.send_message(text) + + +def _tg_links(link_type: TelegramLinkType, + camera: int, + file: str) -> str: + links = [] + for link_name, link_template in config['telegram'][f'{link_type.value}_url_templates']: + link = link_template.replace('{camera}', str(camera)).replace('{file}', file) + links.append(f'{link_name}') + return ' '.join(links) + + +async def fix_job() -> None: + global fix_job_running + logger.debug('fix_job: starting') + + if fix_job_running: + logger.error('fix_job: already running') + return + + try: + fix_job_running = True + for cam in config['camera'].keys(): + files = get_recordings_files(cam, TimeFilterType.FIX) + if not files: + logger.debug(f'fix_job: no files for camera {cam}') + continue + + logger.debug(f'fix_job: got %d files for camera {cam}' % (len(files),)) + + for file in files: + fullpath = os.path.join(get_recordings_path(cam), file['name']) + await camutil.ffmpeg_recreate(fullpath) + timestamp = filename_to_datetime(file['name']) + if timestamp: + db.set_timestamp(cam, TimeFilterType.FIX, timestamp) + + finally: + fix_job_running = False + + +async def cleanup_job() -> None: + def fn2dt(name: str) -> datetime: + name = os.path.basename(name) + + if name.startswith('record_'): + return datetime.strptime(re.match(r'record_(.*?)\.mp4', name).group(1), datetime_format) + + m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.mp4', name) + if m: + return datetime.strptime(m.group(1), datetime_format) + + raise ValueError(f'unrecognized filename format: {name}') + + def compare(i1: str, i2: str) -> int: + dt1 = fn2dt(i1) + dt2 = fn2dt(i2) + + if dt1 < dt2: + return -1 + elif dt1 > dt2: + return 1 + else: + return 0 + + global cleanup_job_running + logger.debug('cleanup_job: starting') + + if cleanup_job_running: + logger.error('cleanup_job: already running') + return + + try: + cleanup_job_running = True + + gb = float(1 << 30) + for storage in config['storages']: + if os.path.exists(storage['mountpoint']): + total, used, free = shutil.disk_usage(storage['mountpoint']) + free_gb = free // gb + if free_gb < config['cleanup_min_gb']: + # print(f"{storage['mountpoint']}: free={free}, free_gb={free_gb}") + cleaned = 0 + files = [] + for cam in storage['cams']: + for _dir in (config['camera'][cam]['recordings_path'], config['camera'][cam]['motion_path']): + files += list(map(lambda file: os.path.join(_dir, file), os.listdir(_dir))) + files = list(filter(lambda path: os.path.isfile(path) and path.endswith('.mp4'), files)) + files.sort(key=cmp_to_key(compare)) + + for file in files: + size = os.stat(file).st_size + try: + os.unlink(file) + cleaned += size + except OSError as e: + logger.exception(e) + if (free + cleaned) // gb >= config['cleanup_min_gb']: + break + else: + logger.error(f"cleanup_job: {storage['mountpoint']} not found") + finally: + cleanup_job_running = False + + +fix_job_running = False +cleanup_job_running = False + +datetime_format = '%Y-%m-%d-%H.%M.%S' +datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}' +db: Optional[IPCamServerDatabase] = None +server: Optional[IPCamWebServer] = None +logger = logging.getLogger(__name__) + + +# start of the program +# -------------------- + +if __name__ == '__main__': + config.load_app('ipcam_server') + + open_database() + + loop = asyncio.get_event_loop() + + try: + scheduler = AsyncIOScheduler(event_loop=loop) + if config['fix_enabled']: + scheduler.add_job(fix_job, 'interval', seconds=config['fix_interval'], misfire_grace_time=None) + + scheduler.add_job(cleanup_job, 'interval', seconds=config['cleanup_interval'], misfire_grace_time=None) + scheduler.start() + except KeyError: + pass + + asyncio.ensure_future(fix_job()) + asyncio.ensure_future(cleanup_job()) + + server = IPCamWebServer(config.get_addr('server.listen')) + server.run() diff --git a/bin/mqtt_node_util.py b/bin/mqtt_node_util.py new file mode 100755 index 0000000..420a87e --- /dev/null +++ b/bin/mqtt_node_util.py @@ -0,0 +1,64 @@ +#!/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('--push-ota', type=str, metavar='OTA_FILENAME', + help='push OTA, receives path to firmware.bin') + + config.load_app(parser=parser, no_config=True) + arg = parser.parse_args() + + if arg.switch_relay is not None and 'relay' not in arg.modules: + raise ArgumentError(None, '--relay is only allowed when \'relay\' module included in --modules') + + mqtt = MqttWrapper(randomize_client_id=True, + client_id='mqtt_node_util') + mqtt_node = MqttNode(node_id=arg.node_id, + node_secret=nodes_config.get_node(arg.node_id)['password']) + + mqtt.add_node(mqtt_node) + + # must-have modules + ota_module = mqtt_node.load_module('ota') + mqtt_node.load_module('diagnostics') + + if arg.modules: + for m in arg.modules: + module_instance = mqtt_node.load_module(m) + if m == 'relay' and arg.switch_relay is not None: + module_instance.switchpower(arg.switch_relay == 1) + + try: + mqtt.connect_and_loop(loop_forever=False) + + if arg.push_ota: + if not os.path.exists(arg.push_ota): + raise OSError(f'--push-ota: file \"{arg.push_ota}\" does not exists') + ota_module.push_ota(arg.push_ota, 1) + + while True: + sleep(0.1) + + except KeyboardInterrupt: + mqtt.disconnect() diff --git a/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'{title} (AP #{ap})\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/bin/openwrt_logger.py b/bin/openwrt_logger.py new file mode 100755 index 0000000..ec67542 --- /dev/null +++ b/bin/openwrt_logger.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +import os +import __py_include + +from datetime import datetime +from typing import Tuple, List, Optional +from argparse import ArgumentParser +from homekit.config import config, AppConfigUnit +from homekit.database import SimpleState +from homekit.api import WebApiClient + + +class OpenwrtLoggerConfig(AppConfigUnit): + @classmethod + def schema(cls) -> Optional[dict]: + return dict( + database_name_template=dict(type='string', required=True) + ) + + +def parse_line(line: str) -> Tuple[int, str]: + space_pos = line.index(' ') + + date = line[:space_pos] + rest = line[space_pos+1:] + + return ( + int(datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z").timestamp()), + rest + ) + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('--file', type=str, required=True, + help='openwrt log file') + parser.add_argument('--access-point', type=int, required=True, + help='access point number') + + arg = config.load_app(OpenwrtLoggerConfig, parser=parser) + + state = SimpleState(name=config.app_config['database_name_template'].replace('{ap}', str(arg.access_point)), + default=dict(seek=0, size=0)) + fsize = os.path.getsize(arg.file) + if fsize < state['size']: + state['seek'] = 0 + + with open(arg.file, 'r') as f: + if state['seek']: + # jump to the latest read position + f.seek(state['seek']) + + # read till the end of the file + content = f.read() + + # save new position + state['seek'] = f.tell() + state['size'] = fsize + + lines: List[Tuple[int, str]] = [] + + if content != '': + for line in content.strip().split('\n'): + if not line: + continue + + try: + lines.append(parse_line(line)) + except ValueError: + lines.append((0, line)) + + api = WebApiClient() + api.log_openwrt(lines, arg.access_point) diff --git a/bin/pio_build.py b/bin/pio_build.py new file mode 100644 index 0000000..539df44 --- /dev/null +++ b/bin/pio_build.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import __py_include + +if __name__ == '__main__': + print('TODO') \ No newline at end of file diff --git a/bin/pio_ini.py b/bin/pio_ini.py new file mode 100755 index 0000000..34ad395 --- /dev/null +++ b/bin/pio_ini.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +import os +import yaml +import re +import __py_include + +from pprint import pprint +from argparse import ArgumentParser, ArgumentError +from homekit.pio import get_products, platformio_ini +from homekit.pio.exceptions import ProductConfigNotFoundError + + +def get_config(product: str) -> dict: + config_path = os.path.join( + os.getenv('HOME'), '.config', + 'homekit_pio', f'{product}.yaml' + ) + if not os.path.exists(config_path): + raise ProductConfigNotFoundError(f'{config_path}: product config not found') + + with open(config_path, 'r') as f: + return yaml.safe_load(f) + + +def bsd_walk(product_config: dict, + f: callable): + try: + for define_name, define_extra_params in product_config['build_specific_defines'].items(): + define_name = re.sub(r'^CONFIG_', '', define_name) + kwargs = {} + if isinstance(define_extra_params, dict): + kwargs = define_extra_params + f(define_name, **kwargs) + except KeyError: + pass + + +# 'bsd' means 'build_specific_defines' +def bsd_parser(product_config: dict, + parser: ArgumentParser): + def f(define_name, **kwargs): + arg_kwargs = {} + define_name = define_name.lower().replace('_', '-') + + if 'type' in kwargs: + if kwargs['type'] in ('str', 'enum'): + arg_kwargs['type'] = str + if kwargs['type'] == 'enum' and 'list_config_key' in kwargs: + if not isinstance(product_config[kwargs['list_config_key']], list): + raise TypeError(f'product_config[{kwargs["list_config_key"]}] enum is not list') + if not product_config[kwargs['list_config_key']]: + raise ValueError(f'product_config[{kwargs["list_config_key"]}] enum cannot be empty') + arg_kwargs['choices'] = product_config[kwargs['list_config_key']] + if isinstance(product_config[kwargs['list_config_key']][0], int): + arg_kwargs['type'] = int + elif kwargs['type'] == 'int': + arg_kwargs['type'] = int + elif kwargs['type'] == 'bool': + arg_kwargs['action'] = 'store_true' + arg_kwargs['required'] = False + else: + raise TypeError(f'unsupported type {kwargs["type"]} for define {define_name}') + else: + arg_kwargs['action'] = 'store_true' + + if 'required' not in arg_kwargs: + arg_kwargs['required'] = True + parser.add_argument(f'--{define_name}', **arg_kwargs) + + bsd_walk(product_config, f) + + +def bsd_get(product_config: dict, + arg: object): + defines = {} + enums = [] + def f(define_name, **kwargs): + attr_name = define_name.lower() + attr_value = getattr(arg, attr_name) + if 'type' in kwargs: + if kwargs['type'] == 'enum': + enums.append(f'CONFIG_{define_name}') + defines[f'CONFIG_{define_name}'] = f'HOMEKIT_{attr_value.upper()}' + return + if kwargs['type'] == 'bool': + defines[f'CONFIG_{define_name}'] = True + return + defines[f'CONFIG_{define_name}'] = str(attr_value) + bsd_walk(product_config, f) + return defines, enums + + +if __name__ == '__main__': + products = get_products() + + # first, get the product + product_parser = ArgumentParser(add_help=False) + product_parser.add_argument('--product', type=str, choices=products, required=True, + help='PIO product name') + arg, _ = product_parser.parse_known_args() + if not arg.product: + product = os.path.basename(os.path.realpath(os.getcwd())) + if product not in products: + raise ArgumentError(None, 'invalid product') + else: + product = arg.product + + product_config = get_config(product) + + # then everythingm else + parser = ArgumentParser(parents=[product_parser]) + parser.add_argument('--target', type=str, required=True, choices=product_config['targets'], + help='PIO build target') + parser.add_argument('--platform', default='espressif8266', type=str) + parser.add_argument('--framework', default='arduino', type=str) + parser.add_argument('--upload-port', default='/dev/ttyUSB0', type=str) + parser.add_argument('--monitor-speed', default=115200) + parser.add_argument('--debug', action='store_true') + parser.add_argument('--debug-network', action='store_true') + bsd_parser(product_config, parser) + arg = parser.parse_args() + + if arg.target not in product_config['targets']: + raise ArgumentError(None, f'target {arg.target} not found for product {product}') + + bsd, bsd_enums = bsd_get(product_config, arg) + ini = platformio_ini(product_config=product_config, + target=arg.target, + build_specific_defines=bsd, + build_specific_defines_enums=bsd_enums, + platform=arg.platform, + framework=arg.framework, + upload_port=arg.upload_port, + monitor_speed=arg.monitor_speed, + debug=arg.debug, + debug_network=arg.debug_network) + print(ini) diff --git a/bin/polaris_kettle_bot.py b/bin/polaris_kettle_bot.py new file mode 100755 index 0000000..3a24fe0 --- /dev/null +++ b/bin/polaris_kettle_bot.py @@ -0,0 +1,747 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import __py_include +import logging +import locale +import queue +import time +import threading +import paho.mqtt.client as mqtt + +from homekit.telegram import bot +from homekit.api.types import BotType +from homekit.mqtt import Mqtt +from homekit.config import config +from homekit.util import chunks +from syncleo import ( + Kettle, + PowerType, + DeviceListener, + IncomingMessageListener, + ConnectionStatusListener, + ConnectionStatus +) +import syncleo.protocol as kettle_proto +from typing import Optional, Tuple, List, Union +from collections import namedtuple +from functools import partial +from datetime import datetime +from abc import abstractmethod +from telegram.error import TelegramError +from telegram import ( + ReplyKeyboardMarkup, + InlineKeyboardMarkup, + InlineKeyboardButton, + Message +) +from telegram.ext import ( + CallbackQueryHandler, + MessageHandler, + CommandHandler +) + +logger = logging.getLogger(__name__) +config.load_app('polaris_kettle_bot') + +primary_choices = (70, 80, 90, 100) +all_choices = range( + config['kettle']['temp_min'], + config['kettle']['temp_max']+1, + config['kettle']['temp_step']) + +bot.initialize() +bot.lang.ru( + start_message="Выберите команду на клавиатуре:", + invalid_command="Неизвестная команда", + unexpected_callback_data="Ошибка: неверные данные", + disable="❌ Выключить", + server_error="Ошибка сервера", + back="🔙 Назад", + smth_went_wrong="😱 Что-то пошло не так", + + # /status + status_not_connected="😟 Связь с чайником не установлена", + status_on="🟢 Чайник включён (до %d °C)", + status_off="🔴 Чайник выключен", + status_current_temp="Сейчас: %d °C", + status_update_time="Обновлено %s", + status_update_time_fmt="%d %b в %H:%M:%S", + + # /temp + select_temperature="Выберите температуру:", + + # enable/disable + enabling="💤 Чайник включается...", + disabling="💤 Чайник выключается...", + enabled="🟢 Чайник включён.", + enabled_target="%s Цель: %d °C", + enabled_reached="✅ Готово! Чайник вскипел, температура %d °C.", + disabled="✅ Чайник выключен.", + please_wait="⏳ Ожидайте..." +) +bot.lang.en( + start_message="Select command on the keyboard:", + invalid_command="Unknown command", + unexpected_callback_data="Unexpected callback data", + disable="❌ Turn OFF", + server_error="Server error", + back="🔙 Back", + smth_went_wrong="😱 Something went wrong", + + # /status + status_not_connected="😟 No connection", + status_on="🟢 Turned ON! Target: %d °C", + status_off="🔴 Turned OFF", + status_current_temp="Now: %d °C", + status_update_time="Updated on %s", + status_update_time_fmt="%b %d, %Y at %H:%M:%S", + + # /temp + select_temperature="Select a temperature:", + + # enable/disable + enabling="💤 Turning on...", + disabling="💤 Turning off...", + enabled="🟢 The kettle is turned ON.", + enabled_target="%s Target: %d °C", + enabled_reached="✅ Done! The kettle has boiled, the temperature is %d °C.", + disabled="✅ The kettle is turned OFF.", + please_wait="⏳ Please wait..." +) + +kc: Optional[KettleController] = None +RenderedContent = Tuple[str, Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]]] +tasks_lock = threading.Lock() + + +def run_tasks(tasks: queue.SimpleQueue, done: callable): + def next_task(r: Optional[kettle_proto.MessageResponse]): + if r is not None: + try: + assert r is not False, 'server error' + except AssertionError as exc: + logger.exception(exc) + tasks_lock.release() + return done(False) + + if not tasks.empty(): + task = tasks.get() + args = task[1:] + args.append(next_task) + f = getattr(kc.kettle, task[0]) + f(*args) + else: + tasks_lock.release() + return done(True) + + tasks_lock.acquire() + next_task(None) + + +def temperature_emoji(temp: int) -> str: + if temp > 90: + return '🔥' + elif temp >= 40: + return '♨️' + elif temp >= 35: + return '🌡' + else: + return '❄️' + + +class KettleInfoListener: + @abstractmethod + def info_updated(self, field: str): + pass + + +# class that holds data coming from the kettle over mqtt +class KettleInfo: + update_time: int + _mode: Optional[PowerType] + _temperature: Optional[int] + _target_temperature: Optional[int] + _update_listener: KettleInfoListener + + def __init__(self, update_listener: KettleInfoListener): + self.update_time = 0 + self._mode = None + self._temperature = None + self._target_temperature = None + self._update_listener = update_listener + + def _update(self, field: str): + self.update_time = int(time.time()) + if self._update_listener: + self._update_listener.info_updated(field) + + @property + def temperature(self) -> int: + return self._temperature + + @temperature.setter + def temperature(self, value: int): + self._temperature = value + self._update('temperature') + + @property + def mode(self) -> PowerType: + return self._mode + + @mode.setter + def mode(self, value: PowerType): + self._mode = value + self._update('mode') + + @property + def target_temperature(self) -> int: + return self._target_temperature + + @target_temperature.setter + def target_temperature(self, value: int): + self._target_temperature = value + self._update('target_temperature') + + +class KettleController(threading.Thread, + Mqtt, + DeviceListener, + IncomingMessageListener, + KettleInfoListener, + ConnectionStatusListener): + kettle: Kettle + info: KettleInfo + + _logger: logging.Logger + _stopped: bool + _restart_server_at: int + _lock: threading.Lock + _info_lock: threading.Lock + _accumulated_updates: dict + _info_flushed_time: float + _mqtt_root_topic: str + _muts: List[MessageUpdatingTarget] + + def __init__(self): + # basic setup + Mqtt.__init__(self, clean_session=False) + threading.Thread.__init__(self) + + self._logger = logging.getLogger(self.__class__.__name__) + + self.kettle = Kettle(mac=config['kettle']['mac'], + device_token=config['kettle']['token'], + read_timeout=config['kettle']['read_timeout']) + self.kettle_reconnect() + + # info + self.info = KettleInfo(update_listener=self) + self._accumulated_updates = {} + self._info_flushed_time = 0 + + # mqtt + self._mqtt_root_topic = '/polaris/6/'+config['kettle']['token']+'/#' + self.connect_and_loop(loop_forever=False) + + # thread loop related + self._stopped = False + # self._lock = threading.Lock() + self._info_lock = threading.Lock() + self._restart_server_at = 0 + + # bot + self._muts = [] + self._muts_lock = threading.Lock() + + self.start() + + def kettle_reconnect(self): + self.kettle.discover(wait=False, listener=self) + + def stop_all(self): + self.kettle.stop_all() + self._stopped = True + + def add_updating_message(self, mut: MessageUpdatingTarget): + with self._muts_lock: + for m in self._muts: + if m.user_id == m.user_id and m.user_did_turn_on() or m.user_did_turn_on() != mut.user_did_turn_on(): + m.delete() + self._muts.append(mut) + + # --------------------- + # threading.Thread impl + + def run(self): + while not self._stopped: + updates = [] + deletions = [] + forget = [] + + with self._muts_lock and self._info_lock: + if self._muts and self._accumulated_updates and (self._info_flushed_time == 0 or time.time() - self._info_flushed_time >= 1): + deletions = [] + + for mut in self._muts: + upd = mut.update( + mode=self.info.mode, + current_temp=self.info.temperature, + target_temp=self.info.target_temperature) + + if upd.finished or upd.delete: + forget.append(mut) + + if upd.delete: + deletions.append((mut, upd)) + + elif upd.changed: + updates.append((mut, upd)) + + self._info_flushed_time = time.time() + self._accumulated_updates = {} + + # edit messages + for mut, upd in updates: + self._logger.debug(f'loop: got update: {upd}') + try: + do_edit = True + if upd.finished: + # try to delete the old message and send a new one, to notify user more effectively + try: + bot.delete_message(upd.user_id, upd.message_id) + do_edit = False + except TelegramError as exc: + self._logger.error(f'loop: failed to delete old message (in order to send a new one)') + self._logger.exception(exc) + + if do_edit: + bot.edit_message_text(upd.user_id, upd.message_id, + text=upd.html, + reply_markup=upd.markup) + else: + bot.notify_user(upd.user_id, upd.html, reply_markup=upd.markup) + except TelegramError as exc: + if "Message can't be edited" in exc.message: + self._logger.warning("message can't be edited, adding it to forget list") + forget.append(upd) + + self._logger.error(f'loop: edit_message_text failed for update: {upd}') + self._logger.exception(exc) + + # delete messages + for mut, upd in deletions: + self._logger.debug(f'loop: got deletion: {upd}') + try: + bot.delete_message(upd.user_id, upd.message_id) + except TelegramError as exc: + self._logger.error(f'loop: delete_message failed for update: {upd}') + self._logger.exception(exc) + + # delete muts, if needed + if forget: + with self._muts_lock: + for mut in forget: + self._logger.debug(f'loop: removing mut {mut}') + self._muts.remove(mut) + + time.sleep(0.5) + + # ------------------- + # DeviceListener impl + + def device_updated(self): + self._logger.info(f'device updated: {self.kettle.device.si}') + self.kettle.start_server_if_needed(incoming_message_listener=self, + connection_status_listener=self) + + # ----------------------- + # KettleInfoListener impl + + def info_updated(self, field: str): + with self._info_lock: + newval = getattr(self.info, field) + self._logger.debug(f'info_updated: updated {field}, new value is {newval}') + self._accumulated_updates[field] = newval + + # ---------------------------- + # IncomingMessageListener impl + + def incoming_message(self, message: kettle_proto.Message) -> Optional[kettle_proto.Message]: + self._logger.info(f'incoming message: {message}') + + if isinstance(message, kettle_proto.ModeMessage): + self.info.mode = message.pt + elif isinstance(message, kettle_proto.CurrentTemperatureMessage): + self.info.temperature = message.current_temperature + elif isinstance(message, kettle_proto.TargetTemperatureMessage): + self.info.target_temperature = message.temperature + + return kettle_proto.AckMessage() + + # ----------------------------- + # ConnectionStatusListener impl + + def connection_status_updated(self, status: ConnectionStatus): + self._logger.info(f'connection status updated: {status}') + if status == ConnectionStatus.DISCONNECTED: + self.kettle.stop_all() + self.kettle_reconnect() + + # ------------- + # MQTTBase impl + + def on_connect(self, client: mqtt.Client, userdata, flags, rc): + super().on_connect(client, userdata, flags, rc) + client.subscribe(self._mqtt_root_topic, qos=1) + self._logger.info(f'subscribed to {self._mqtt_root_topic}') + + def on_message(self, client: mqtt.Client, userdata, msg): + try: + topic = msg.topic[len(self._mqtt_root_topic)-2:] + pld = msg.payload.decode() + + self._logger.debug(f'mqtt: on message: topic={topic} pld={pld}') + + if topic == 'state/sensor/temperature': + self.info.temperature = int(float(pld)) + elif topic == 'state/mode': + self.info.mode = PowerType(int(pld)) + elif topic == 'state/temperature': + self.info.target_temperature = int(float(pld)) + + except Exception as e: + self._logger.exception(str(e)) + + +class Renderer: + @classmethod + def index(cls, ctx: bot.Context) -> RenderedContent: + html = f'{ctx.lang("settings")}\n\n' + html += ctx.lang('select_place') + return html, None + + @classmethod + def status(cls, ctx: bot.Context, + connected: bool, + mode: PowerType, + current_temp: int, + target_temp: int, + update_time: int) -> RenderedContent: + if not connected: + return cls.not_connected(ctx) + else: + # power status + if mode != PowerType.OFF: + html = ctx.lang('status_on', target_temp) + else: + html = ctx.lang('status_off') + + # current temperature + html += '\n' + html += ctx.lang('status_current_temp', current_temp) + + # updated on + html += '\n' + html += cls.updated(ctx, update_time) + + return html, None + + @classmethod + def temp(cls, ctx: bot.Context, choices) -> RenderedContent: + buttons = [] + for chunk in chunks(choices, 5): + buttons.append([f'{temperature_emoji(n)} {n}' for n in chunk]) + buttons.append([ctx.lang('back')]) + return ctx.lang('select_temperature'), ReplyKeyboardMarkup(buttons) + + @classmethod + def turned_on(cls, ctx: bot.Context, + target_temp: int, + current_temp: int, + mode: PowerType, + update_time: Optional[int] = None, + reached=False, + no_keyboard=False) -> RenderedContent: + if mode == PowerType.OFF and not reached: + html = ctx.lang('enabling') + else: + if not reached: + html = ctx.lang('enabled') + + # target temperature + html += '\n' + html += ctx.lang('enabled_target', temperature_emoji(target_temp), target_temp) + + # current temperature + html += '\n' + html += temperature_emoji(current_temp) + ' ' + html += ctx.lang('status_current_temp', current_temp) + else: + html = ctx.lang('enabled_reached', current_temp) + + # updated on + if not reached and update_time is not None: + html += '\n' + html += cls.updated(ctx, update_time) + + return html, None if no_keyboard else cls.wait_buttons(ctx) + + @classmethod + def turned_off(cls, ctx: bot.Context, + mode: PowerType, + update_time: Optional[int] = None, + reached=False, + no_keyboard=False) -> RenderedContent: + if mode != PowerType.OFF: + html = ctx.lang('disabling') + else: + html = ctx.lang('disabled') + + # updated on + if not reached and update_time is not None: + html += '\n' + html += cls.updated(ctx, update_time) + + return html, None if no_keyboard else cls.wait_buttons(ctx) + + @classmethod + def not_connected(cls, ctx: bot.Context) -> RenderedContent: + return ctx.lang('status_not_connected'), None + + @classmethod + def smth_went_wrong(cls, ctx: bot.Context) -> RenderedContent: + html = ctx.lang('smth_went_wrong') + return html, None + + @classmethod + def updated(cls, ctx: bot.Context, update_time: int): + locale_bak = locale.getlocale(locale.LC_TIME) + locale.setlocale(locale.LC_TIME, 'ru_RU.UTF-8' if ctx.user_lang == 'ru' else 'en_US.UTF-8') + dt = datetime.fromtimestamp(update_time) + html = ctx.lang('status_update_time', dt.strftime(ctx.lang('status_update_time_fmt'))) + locale.setlocale(locale.LC_TIME, locale_bak) + return html + + @classmethod + def wait_buttons(cls, ctx: bot.Context): + return InlineKeyboardMarkup([ + [ + InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait') + ] + ]) + + +MUTUpdate = namedtuple('MUTUpdate', 'message_id, user_id, finished, changed, delete, html, markup') + + +class MessageUpdatingTarget: + ctx: bot.Context + message: Message + user_target_temp: Optional[int] + user_enabled_power_mode: PowerType + initial_power_mode: PowerType + need_to_delete: bool + rendered_content: Optional[RenderedContent] + + def __init__(self, + ctx: bot.Context, + message: Message, + user_enabled_power_mode: PowerType, + initial_power_mode: PowerType, + user_target_temp: Optional[int] = None): + self.ctx = ctx + self.message = message + self.initial_power_mode = initial_power_mode + self.user_enabled_power_mode = user_enabled_power_mode + self.ignore_pm = initial_power_mode is PowerType.OFF and self.user_did_turn_on() + self.user_target_temp = user_target_temp + self.need_to_delete = False + self.rendered_content = None + self.last_reported_temp = None + + def set_rendered_content(self, content: RenderedContent): + self.rendered_content = content + + def rendered_content_changed(self, content: RenderedContent) -> bool: + return content != self.rendered_content + + def update(self, + mode: PowerType, + current_temp: int, + target_temp: int) -> MUTUpdate: + + # determine whether status updating is finished + finished = False + reached = False + if self.ignore_pm: + if mode != PowerType.OFF: + self.ignore_pm = False + elif mode == PowerType.OFF: + reached = True + if self.user_did_turn_on(): + # when target is 100 degrees, this kettle sometimes turns off at 91, sometimes at 95, sometimes at 98. + # it's totally unpredictable, so in this case, we keep updating the message until it reaches at least 97 + # degrees, or if temperature started dropping. + if self.user_target_temp < 100 \ + or current_temp >= self.user_target_temp - 3 \ + or current_temp < self.last_reported_temp: + finished = True + else: + finished = True + + self.last_reported_temp = current_temp + + # render message + if self.user_did_turn_on(): + rc = Renderer.turned_on(self.ctx, + target_temp=target_temp, + current_temp=current_temp, + mode=mode, + reached=reached, + no_keyboard=finished) + else: + rc = Renderer.turned_off(self.ctx, + mode=mode, + reached=reached, + no_keyboard=finished) + + changed = self.rendered_content_changed(rc) + update = MUTUpdate(message_id=self.message.message_id, + user_id=self.ctx.user_id, + finished=finished, + changed=changed, + delete=self.need_to_delete, + html=rc[0], + markup=rc[1]) + if changed: + self.set_rendered_content(rc) + return update + + def user_did_turn_on(self) -> bool: + return self.user_enabled_power_mode in (PowerType.ON, PowerType.CUSTOM) + + def delete(self): + self.need_to_delete = True + + @property + def user_id(self) -> int: + return self.ctx.user_id + + +@bot.handler(command='status') +def status(ctx: bot.Context) -> None: + text, markup = Renderer.status(ctx, + connected=kc.kettle.is_connected(), + mode=kc.info.mode, + current_temp=kc.info.temperature, + target_temp=kc.info.target_temperature, + update_time=kc.info.update_time) + ctx.reply(text, markup=markup) + + +@bot.handler(command='temp') +def temp(ctx: bot.Context) -> None: + text, markup = Renderer.temp( + ctx, choices=all_choices) + ctx.reply(text, markup=markup) + + +def enable(temp: int, ctx: bot.Context) -> None: + if not kc.kettle.is_connected(): + text, markup = Renderer.not_connected(ctx) + ctx.reply(text, markup=markup) + return + + tasks = queue.SimpleQueue() + if temp == 100: + power_mode = PowerType.ON + else: + power_mode = PowerType.CUSTOM + tasks.put(['set_target_temperature', temp]) + tasks.put(['set_power', power_mode]) + + def done(ok: bool): + if not ok: + html, markup = Renderer.smth_went_wrong(ctx) + else: + html, markup = Renderer.turned_on(ctx, + target_temp=temp, + current_temp=kc.info.temperature, + mode=kc.info.mode) + message = ctx.reply(html, markup=markup) + logger.debug(f'ctx.reply returned message: {message}') + + if ok: + mut = MessageUpdatingTarget(ctx, message, + initial_power_mode=kc.info.mode, + user_enabled_power_mode=power_mode, + user_target_temp=temp) + mut.set_rendered_content((html, markup)) + kc.add_updating_message(mut) + + run_tasks(tasks, done) + + +@bot.handler(message='disable') +def disable(ctx: bot.Context): + if not kc.kettle.is_connected(): + text, markup = Renderer.not_connected(ctx) + ctx.reply(text, markup=markup) + return + + def done(ok: bool): + mode = kc.info.mode + if not ok: + html, markup = Renderer.smth_went_wrong(ctx) + else: + kw = {} + if mode == PowerType.OFF: + kw['reached'] = True + kw['no_keyboard'] = True + html, markup = Renderer.turned_off(ctx, mode=mode, **kw) + message = ctx.reply(html, markup=markup) + logger.debug(f'ctx.reply returned message: {message}') + + if ok and mode != PowerType.OFF: + mut = MessageUpdatingTarget(ctx, message, + initial_power_mode=mode, + user_enabled_power_mode=PowerType.OFF) + mut.set_rendered_content((html, markup)) + kc.add_updating_message(mut) + + tasks = queue.SimpleQueue() + tasks.put(['set_power', PowerType.OFF]) + run_tasks(tasks, done) + + +@bot.handler(message='back') +def back(ctx: bot.Context): + bot.start(ctx) + + +@bot.defaultreplymarkup +def defaultmarkup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: + buttons = [ + [f'{temperature_emoji(n)} {n}' for n in primary_choices], + [ctx.lang('disable')] + ] + return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) + + +if __name__ == '__main__': + for temp in primary_choices: + bot.handler(text=f'{temperature_emoji(temp)} {temp}')(partial(enable, temp)) + + for temp in all_choices: + bot.handler(text=f'{temperature_emoji(temp)} {temp}')(partial(enable, temp)) + + kc = KettleController() + + if 'api' in config: + bot.enable_logging(BotType.POLARIS_KETTLE) + + bot.run() + + # bot library handles signals, so when sigterm or something like that happens, we should stop all other threads here + kc.stop_all() diff --git a/bin/polaris_kettle_util.py b/bin/polaris_kettle_util.py new file mode 100755 index 0000000..4db0ed4 --- /dev/null +++ b/bin/polaris_kettle_util.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause + +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 homekit.mqtt import Mqtt +from homekit.config import config +from syncleo import ( + Kettle, + PowerType, + protocol as kettle_proto +) + +k: Optional[Kettle] = None +logger = logging.getLogger(__name__) +control_tasks = SimpleQueue() + + +class MqttServer(Mqtt): + def __init__(self): + super().__init__(clean_session=False) + + def on_connect(self, client: mqtt.Client, userdata, flags, rc): + super().on_connect(client, userdata, flags, rc) + logger.info("subscribing to #") + client.subscribe('#', qos=1) + + def on_message(self, client: mqtt.Client, userdata, msg): + try: + print(msg.topic, msg.payload) + + except Exception as e: + logger.exception(str(e)) + + +def kettle_connection_established(response: kettle_proto.MessageResponse): + try: + assert isinstance(response, kettle_proto.AckMessage), f'ACK expected, but received: {response}' + except AssertionError: + k.stop_all() + return + + def next_task(response: kettle_proto.MessageResponse): + try: + assert response is not False, 'server error' + except AssertionError: + k.stop_all() + return + + if not control_tasks.empty(): + task = control_tasks.get() + f, args = task(k) + args.append(next_task) + f(*args) + else: + k.stop_all() + + next_task(response) + + +def main(): + tempmin = 30 + tempmax = 100 + tempstep = 5 + + parser = ArgumentParser() + parser.add_argument('-m', dest='mode', required=True, type=str, choices=('mqtt', 'control')) + parser.add_argument('--on', action='store_true') + parser.add_argument('--off', action='store_true') + parser.add_argument('-t', '--temperature', dest='temp', type=int, default=tempmax, + choices=range(tempmin, tempmax+tempstep, tempstep)) + + arg = config.load_app('polaris_kettle_util', use_cli=True, parser=parser) + + if arg.mode == 'mqtt': + server = MqttServer() + try: + server.connect_and_loop(loop_forever=True) + except KeyboardInterrupt: + pass + + elif arg.mode == 'control': + if arg.on and arg.off: + raise RuntimeError('--on and --off are mutually exclusive') + + if arg.off: + control_tasks.put(lambda k: (k.set_power, [PowerType.OFF])) + else: + if arg.temp == tempmax: + control_tasks.put(lambda k: (k.set_power, [PowerType.ON])) + else: + control_tasks.put(lambda k: (k.set_target_temperature, [arg.temp])) + control_tasks.put(lambda k: (k.set_power, [PowerType.CUSTOM])) + + k = Kettle(mac=config['kettle']['mac'], device_token=config['kettle']['token']) + info = k.discover() + if not info: + print('no device found.') + return 1 + + print('found service:', info) + k.start_server_if_needed(kettle_connection_established) + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/bin/pump_bot.py b/bin/pump_bot.py new file mode 100755 index 0000000..08d0dc6 --- /dev/null +++ b/bin/pump_bot.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +import __py_include + +from enum import Enum +from typing import Optional +from telegram import ReplyKeyboardMarkup, User +from time import time +from datetime import datetime + +from homekit.config import config, is_development_mode +from homekit.telegram import bot +from homekit.telegram._botutil import user_any_name +from homekit.relay.sunxi_h3_client import RelayClient +from homekit.api.types import BotType +from homekit.mqtt import MqttNode, MqttWrapper, MqttPayload +from homekit.mqtt.module.relay import MqttPowerStatusPayload, MqttRelayModule +from homekit.mqtt.module.temphum import MqttTemphumDataPayload +from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload + + +config.load_app('pump_bot') + +mqtt: Optional[MqttWrapper] = None +mqtt_node: Optional[MqttNode] = None +mqtt_relay_module: Optional[MqttRelayModule] = None +time_format = '%d.%m.%Y, %H:%M:%S' + +watering_mcu_status = { + 'last_time': 0, + 'last_boot_time': 0, + 'relay_opened': False, + 'ambient_temp': 0.0, + 'ambient_rh': 0.0, +} + +bot.initialize() +bot.lang.ru( + start_message="Выберите команду на клавиатуре", + unknown_command="Неизвестная команда", + + enable="Включить", + enable_silently="Включить тихо", + enabled="Насос включен ✅", + + disable="Выключить", + disable_silently="Выключить тихо", + disabled="Насос выключен ❌", + + start_watering="Включить полив", + stop_watering="Отключить полив", + + status="Статус насоса", + watering_status="Статус полива", + + done="Готово 👌", + sent="Команда отправлена", + + user_action_notification='Пользователь %s %s насос.', + user_watering_notification='Пользователь %s %s полив.', + 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 %s turned the pump %s.', + user_watering_notification='User %s %s the watering.', + user_action_on="ON", + user_action_off="OFF", + user_action_watering_on="started", + user_action_watering_off="stopped", +) + + +class UserAction(Enum): + ON = 'on' + OFF = 'off' + WATERING_ON = 'watering_on' + WATERING_OFF = 'watering_off' + + +def get_relay() -> RelayClient: + relay = RelayClient(host=config['relay']['ip'], port=config['relay']['port']) + relay.connect() + return relay + + +def on(ctx: bot.Context, silent=False) -> None: + get_relay().on() + ctx.reply(ctx.lang('done')) + if not silent: + notify(ctx.user, UserAction.ON) + + +def off(ctx: bot.Context, silent=False) -> None: + get_relay().off() + ctx.reply(ctx.lang('done')) + if not silent: + notify(ctx.user, UserAction.OFF) + + +def watering_on(ctx: bot.Context) -> None: + mqtt_relay_module.switchpower(True, config.get('mqtt_water_relay.secret')) + ctx.reply(ctx.lang('sent')) + notify(ctx.user, UserAction.WATERING_ON) + + +def watering_off(ctx: bot.Context) -> None: + mqtt_relay_module.switchpower(False, config.get('mqtt_water_relay.secret')) + ctx.reply(ctx.lang('sent')) + notify(ctx.user, UserAction.WATERING_OFF) + + +def notify(user: User, action: UserAction) -> None: + notification_key = 'user_watering_notification' if action in (UserAction.WATERING_ON, UserAction.WATERING_OFF) else 'user_action_notification' + def text_getter(lang: str): + action_name = bot.lang.get(f'user_action_{action.value}', lang) + user_name = user_any_name(user) + return 'ℹ ' + bot.lang.get(notification_key, lang, + user.id, user_name, action_name) + + bot.notify_all(text_getter, exclude=(user.id,)) + + +@bot.handler(message='enable') +def enable_handler(ctx: bot.Context) -> None: + on(ctx) + + +@bot.handler(message='enable_silently') +def enable_s_handler(ctx: bot.Context) -> None: + on(ctx, True) + + +@bot.handler(message='disable') +def disable_handler(ctx: bot.Context) -> None: + off(ctx) + + +@bot.handler(message='start_watering') +def start_watering(ctx: bot.Context) -> None: + watering_on(ctx) + + +@bot.handler(message='stop_watering') +def stop_watering(ctx: bot.Context) -> None: + watering_off(ctx) + + +@bot.handler(message='disable_silently') +def disable_s_handler(ctx: bot.Context) -> None: + off(ctx, True) + + +@bot.handler(message='status') +def status(ctx: bot.Context) -> None: + ctx.reply( + ctx.lang('enabled') if get_relay().status() == 'on' else ctx.lang('disabled') + ) + + +def _get_timestamp_as_string(timestamp: int) -> str: + if timestamp != 0: + return datetime.fromtimestamp(timestamp).strftime(time_format) + else: + return 'unknown' + + +@bot.handler(message='watering_status') +def watering_status(ctx: bot.Context) -> None: + buf = '' + if 0 < watering_mcu_status["last_time"] < time()-1800: + buf += 'WARNING! long time no reports from mcu! maybe something\'s wrong\n' + buf += f'last report time: {_get_timestamp_as_string(watering_mcu_status["last_time"])}\n' + if watering_mcu_status["last_boot_time"] != 0: + buf += f'boot time: {_get_timestamp_as_string(watering_mcu_status["last_boot_time"])}\n' + buf += 'relay opened: ' + ('yes' if watering_mcu_status['relay_opened'] else 'no') + '\n' + buf += f'ambient temp & humidity: {watering_mcu_status["ambient_temp"]} °C, {watering_mcu_status["ambient_rh"]}%' + ctx.reply(buf) + + +@bot.defaultreplymarkup +def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: + buttons = [] + if ctx.user_id in config['bot']['silent_users']: + buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')]) + buttons.append([ctx.lang('enable'), ctx.lang('disable'), ctx.lang('status')],) + buttons.append([ctx.lang('start_watering'), ctx.lang('stop_watering'), ctx.lang('watering_status')]) + + return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) + + +def mqtt_payload_callback(mqtt_node: MqttNode, payload: MqttPayload): + global watering_mcu_status + + types_the_node_can_send = ( + InitialDiagnosticsPayload, + DiagnosticsPayload, + MqttTemphumDataPayload, + MqttPowerStatusPayload + ) + for cl in types_the_node_can_send: + if isinstance(payload, cl): + watering_mcu_status['last_time'] = int(time()) + break + + if isinstance(payload, InitialDiagnosticsPayload): + watering_mcu_status['last_boot_time'] = int(time()) + + elif isinstance(payload, MqttTemphumDataPayload): + watering_mcu_status['ambient_temp'] = payload.temp + watering_mcu_status['ambient_rh'] = payload.rh + + elif isinstance(payload, MqttPowerStatusPayload): + watering_mcu_status['relay_opened'] = payload.opened + + +if __name__ == '__main__': + mqtt = MqttWrapper() + mqtt_node = MqttNode(node_id=config.get('mqtt_water_relay.node_id')) + if is_development_mode(): + mqtt_node.load_module('diagnostics') + + mqtt_node.load_module('temphum') + mqtt_relay_module = mqtt_node.load_module('relay') + + mqtt_node.add_payload_callback(mqtt_payload_callback) + + mqtt.connect_and_loop(loop_forever=False) + + bot.enable_logging(BotType.PUMP) + bot.run() + + try: + mqtt.disconnect() + except: + pass diff --git a/bin/pump_mqtt_bot.py b/bin/pump_mqtt_bot.py new file mode 100755 index 0000000..aea1451 --- /dev/null +++ b/bin/pump_mqtt_bot.py @@ -0,0 +1,168 @@ +#!/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 +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_app('pump_mqtt_bot') + +bot.initialize() +bot.lang.ru( + start_message="Выберите команду на клавиатуре", + start_message_no_access="Доступ запрещён. Вы можете отправить заявку на получение доступа.", + unknown_command="Неизвестная команда", + send_access_request="Отправить заявку", + management="Админка", + + enable="Включить", + enabled="Включен ✅", + + disable="Выключить", + disabled="Выключен ❌", + + status="Статус", + status_updated=' (обновлено %s)', + + done="Готово 👌", + user_action_notification='Пользователь %s %s насос.', + 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 %s turned the pump %s.', + user_action_on="ON", + user_action_off="OFF", + + date_yday="yesterday", + date_yyday="the day before yesterday", + date_at="at" +) + + +mqtt: Optional[MqttNode] = None +relay_state = MqttRelayState() + + +class UserAction(Enum): + ON = 'on' + OFF = 'off' + + +def on_mqtt_message(home_id, message: MqttPayload): + if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): + kwargs = dict(rssi=message.rssi, enabled=message.flags.state) + if isinstance(message, InitialDiagnosticsPayload): + kwargs['fw_version'] = message.fw_version + relay_state.update(**kwargs) + + +def notify(user: User, action: UserAction) -> None: + def text_getter(lang: str): + action_name = bot.lang.get(f'user_action_{action.value}', lang) + user_name = user_any_name(user) + return 'ℹ ' + bot.lang.get('user_action_notification', lang, + user.id, user_name, action_name) + + bot.notify_all(text_getter, exclude=(user.id,)) + + +@bot.handler(message='enable') +def enable_handler(ctx: bot.Context) -> None: + mqtt.set_power(config['mqtt']['home_id'], True) + ctx.reply(ctx.lang('done')) + notify(ctx.user, UserAction.ON) + + +@bot.handler(message='disable') +def disable_handler(ctx: bot.Context) -> None: + mqtt.set_power(config['mqtt']['home_id'], False) + ctx.reply(ctx.lang('done')) + notify(ctx.user, UserAction.OFF) + + +@bot.handler(message='status') +def status(ctx: bot.Context) -> None: + label = ctx.lang('enabled') if relay_state.enabled else ctx.lang('disabled') + if relay_state.ever_updated: + date_label = '' + today = datetime.date.today() + if today != relay_state.update_time.date(): + yday = today - datetime.timedelta(days=1) + yyday = today - datetime.timedelta(days=2) + if yday == relay_state.update_time.date(): + date_label = ctx.lang('date_yday') + elif yyday == relay_state.update_time.date(): + date_label = ctx.lang('date_yyday') + else: + date_label = relay_state.update_time.strftime('%d.%m.%Y') + date_label += ' ' + date_label += ctx.lang('date_at') + ' ' + date_label += relay_state.update_time.strftime('%H:%M') + label += ctx.lang('status_updated', date_label) + ctx.reply(label) + + +def start(ctx: bot.Context) -> None: + if ctx.user_id in config['bot']['users'] or ctx.user_id in config['bot']['admin_users']: + ctx.reply(ctx.lang('start_message')) + else: + buttons = [ + [ctx.lang('send_access_request')] + ] + ctx.reply(ctx.lang('start_message_no_access'), markup=ReplyKeyboardMarkup(buttons, one_time_keyboard=False)) + + +@bot.exceptionhandler +def exception_handler(e: Exception, ctx: bot.Context) -> bool: + return False + + +@bot.defaultreplymarkup +def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: + buttons = [[ctx.lang('enable'), ctx.lang('disable')], [ctx.lang('status')]] + if ctx.user_id in config['bot']['admin_users']: + buttons.append([ctx.lang('management')]) + return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) + + +if __name__ == '__main__': + mqtt = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'], + secret=config['mqtt']['home_secret'])) + mqtt.set_message_callback(on_mqtt_message) + mqtt.connect_and_loop(loop_forever=False) + + # bot.enable_logging(BotType.PUMP_MQTT) + bot.run(start_handler=start) + + mqtt.disconnect() diff --git a/bin/relay_mqtt_bot.py b/bin/relay_mqtt_bot.py new file mode 100755 index 0000000..1c1cc94 --- /dev/null +++ b/bin/relay_mqtt_bot.py @@ -0,0 +1,165 @@ +#!/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 +from homekit.mqtt import 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/bin/sensors_bot.py b/bin/sensors_bot.py new file mode 100755 index 0000000..c2b0070 --- /dev/null +++ b/bin/sensors_bot.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +import json +import socket +import logging +import re +import gc +import __py_include + +from io import BytesIO +from typing import Optional + +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import matplotlib.ticker as mticker + +from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton + +from 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 ( + BotType, + TemperatureSensorLocation +) + +config.load_app('sensors_bot') +bot.initialize() + +bot.lang.ru( + start_message="Выберите датчик на клавиатуре", + unknown_command="Неизвестная команда", + temperature="Температура", + humidity="Влажность", + plot_3h="График за 3 часа", + plot_6h="График за 6 часов", + plot_12h="График за 12 часов", + plot_24h="График за 24 часа", + unexpected_callback_data="Ошибка: неверные данные", + loading="Загрузка...", + n_hrs="график за %d ч." +) +bot.lang.en( + start_message="Select the sensor on the keyboard", + unknown_command="Unknown command", + temperature="Temperature", + humidity="Relative humidity", + plot_3h="Graph for 3 hours", + plot_6h="Graph for 6 hours", + plot_12h="Graph for 12 hours", + plot_24h="Graph for 24 hours", + unexpected_callback_data="Unexpected callback data", + loading="Loading...", + n_hrs="graph for %d hours" +) + +plt.rcParams['font.size'] = 7 +logger = logging.getLogger(__name__) +plot_hours = [3, 6, 12, 24] + + +_sensor_names = [] +for k, v in config['sensors'].items(): + _sensor_names.append(k) + bot.lang.set({k: v['label_ru']}, 'ru') + bot.lang.set({k: v['label_en']}, 'en') + + +@bot.handler(messages=_sensor_names, argument='message_key') +def read_sensor(sensor: str, ctx: bot.Context) -> None: + host = config['sensors'][sensor]['ip'] + port = config['sensors'][sensor]['port'] + + try: + client = MySimpleSocketClient(host, port) + client.write('read') + data = json.loads(client.read()) + except (socket.timeout, socket.error) as error: + return ctx.reply_exc(error) + + temp = round(data['temp'], 2) + humidity = round(data['humidity'], 2) + + text = ctx.lang('temperature') + f': {temp} °C\n' + text += ctx.lang('humidity') + f': {humidity}%' + + buttons = list(map( + lambda h: InlineKeyboardButton(ctx.lang(f'plot_{h}h'), callback_data=f'plot/{sensor}/{h}'), + plot_hours + )) + ctx.reply(text, markup=InlineKeyboardMarkup(chunks(buttons, 2))) + + +@bot.callbackhandler(callback='*') +def callback_handler(ctx: bot.Context) -> None: + query = ctx.callback_query + + sensors_variants = '|'.join(config['sensors'].keys()) + hour_variants = '|'.join(list(map( + lambda n: str(n), + plot_hours + ))) + + match = re.match(rf'plot/({sensors_variants})/({hour_variants})', query.data) + if not match: + query.answer(ctx.lang('unexpected_callback_data')) + return + + query.answer(ctx.lang('loading')) + + # retrieve data + sensor = TemperatureSensorLocation[match.group(1).upper()] + hours = int(match.group(2)) + + api = WebApiClient(timeout=20) + data = api.get_sensors_data(sensor, hours) + + title = ctx.lang(sensor.name.lower()) + ' (' + ctx.lang('n_hrs', hours) + ')' + plot = draw_plot(data, title, + ctx.lang('temperature'), + ctx.lang('humidity')) + bot.send_photo(ctx.user_id, photo=plot) + + gc.collect() + + +def draw_plot(data, + title: str, + label_temp: str, + label_hum: str) -> BytesIO: + tempval = [] + humval = [] + dates = [] + for date, temp, humidity in data: + dates.append(date) + tempval.append(temp) + humval.append(humidity) + + fig, axs = plt.subplots(2, 1) + df = mdates.DateFormatter('%H:%M') + + axs[0].set_title(label_temp) + axs[0].plot(dates, tempval) + axs[0].xaxis.set_major_formatter(df) + axs[0].yaxis.set_major_formatter(mticker.FormatStrFormatter('%2.2f °C')) + + fig.suptitle(title, fontsize=10) + + axs[1].set_title(label_hum) + axs[1].plot(dates, humval) + axs[1].xaxis.set_major_formatter(df) + axs[1].yaxis.set_major_formatter(mticker.FormatStrFormatter('%2.1f %%')) + + fig.autofmt_xdate() + + # should be called after all axes have been added + fig.tight_layout() + + buf = BytesIO() + fig.savefig(buf, format='png', dpi=160) + buf.seek(0) + + plt.clf() + plt.close('all') + + return buf + + +@bot.defaultreplymarkup +def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: + buttons = [] + for k in config['sensors'].keys(): + buttons.append(ctx.lang(k)) + buttons = chunks(buttons, 2) + return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) + + +if __name__ == '__main__': + if 'api' in config: + bot.enable_logging(BotType.SENSORS) + + bot.run() diff --git a/bin/sound_bot.py b/bin/sound_bot.py new file mode 100755 index 0000000..518151d --- /dev/null +++ b/bin/sound_bot.py @@ -0,0 +1,890 @@ +#!/usr/bin/env python3 +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 homekit.config import config +from homekit.api import WebApiClient +from homekit.api.types import SoundSensorLocation, BotType +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 homekit.telegram import bot + +from telegram.error import TelegramError +from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton, User + +from PIL import Image + +config.load_app('sound_bot') + +nodes = {} +for nodename, nodecfg in config['nodes'].items(): + nodes[nodename] = Addr.fromstring(nodecfg['addr']) + +bot.initialize() +bot.lang.ru( + start_message="Выберите команду на клавиатуре", + unknown_command="Неизвестная команда", + unexpected_callback_data="Ошибка: неверные данные", + settings="Настройки микшера", + record="Запись", + loading="Загрузка...", + select_place="Выберите место:", + invalid_location="Неверное место", + invalid_interval="Неверная длительность", + unsupported_action="Неподдерживаемое действие", + # select_control="Выберите контрол для изменения настроек:", + control_state="Состояние контрола %s", + incr="громкость +", + decr="громкость -", + back="◀️ Назад", + n_min="%d мин.", + n_sec="%d сек.", + select_interval="Выберите длительность:", + place="Место", + beginning="Начало", + end="Конец", + record_result="Результат записи", + record_started='Запись запущена!', + record_error="Ошибка записи", + files="Локальные файлы", + remote_files="Файлы на сервере", + file_line="— Запись с %s до %s (%s)", + access_denied="Доступ запрещён", + + guard_disable="Снять с охраны", + guard_enable="Поставить на охрану", + guard_status="Статус охраны", + guard_user_action_notification='Пользователь %s %s.', + guard_user_action_enable="включил охрану ✅", + guard_user_action_disable="выключил охрану ❌", + guard_status_enabled="Включена ✅", + guard_status_disabled="Выключена ❌", + + done="Готово 👌", + + sound_sensors="Датчики звука", + sound_sensors_info="Здесь можно получить информацию о последних срабатываниях датчиков звука.", + sound_sensors_no_24h_data="За последние 24 часа данных нет.", + sound_sensors_show_anything="Показать, что есть", + + cameras="Камеры", + select_option="Выберите опцию", + w_flash="Со вспышкой", + wo_flash="Без вспышки", +) + +bot.lang.en( + start_message="Select command on the keyboard", + unknown_command="Unknown command", + settings="Mixer settings", + record="Record", + unexpected_callback_data="Unexpected callback data", + loading="Loading...", + select_place="Select place:", + invalid_location="Invalid place", + invalid_interval="Invalid duration", + unsupported_action="Unsupported action", + # select_control="Select control to adjust its parameters:", + control_state="%s control state", + incr="vol +", + decr="vol -", + back="◀️ Back", + n_min="%d min.", + n_sec="%d s.", + select_interval="Select duration:", + place="Place", + beginning="Started", + end="Ended", + record_result="Result", + record_started='Recording started!', + record_error="Recording error", + files="Local files", + remote_files="Remote files", + file_line="— From %s to %s (%s)", + access_denied="Access denied", + + guard_disable="Disable guard", + guard_enable="Enable guard", + guard_status="Guard status", + guard_user_action_notification='User %s %s.', + guard_user_action_enable="turned the guard ON ✅", + guard_user_action_disable="turn the guard OFF ❌", + guard_status_enabled="Active ✅", + guard_status_disabled="Disabled ❌", + done="Done 👌", + + sound_sensors="Sound sensors", + sound_sensors_info="Here you can get information about last sound sensors hits.", + sound_sensors_no_24h_data="No data for the last 24 hours.", + sound_sensors_show_anything="Show me at least something", + + cameras="Cameras", + select_option="Select option", + w_flash="With flash", + wo_flash="Without flash", +) + +logger = logging.getLogger(__name__) +RenderedContent = Tuple[str, Optional[InlineKeyboardMarkup]] +record_client: Optional[SoundRecordClient] = None +node_client_links: Dict[str, SoundNodeClient] = {} +cam_client_links: Dict[str, CameraNodeClient] = {} + + +def node_client(node: str) -> SoundNodeClient: + if node not in node_client_links: + node_client_links[node] = SoundNodeClient(Addr.fromstring(config['nodes'][node]['addr'])) + return node_client_links[node] + + +def camera_client(cam: str) -> CameraNodeClient: + if cam not in node_client_links: + cam_client_links[cam] = CameraNodeClient(Addr.fromstring(config['cameras'][cam]['addr'])) + return cam_client_links[cam] + + +def node_exists(node: str) -> bool: + return node in config['nodes'] + + +def camera_exists(name: str) -> bool: + return name in config['cameras'] + + +def camera_settings(name: str) -> Optional[dict]: + try: + return config['cameras'][name]['settings'] + except KeyError: + return None + + +def have_cameras() -> bool: + return 'cameras' in config and config['cameras'] + + +def sound_sensor_exists(node: str) -> bool: + return node in config['sound_sensors'] + + +def interval_defined(interval: int) -> bool: + return interval in config['bot']['record_intervals'] + + +def callback_unpack(ctx: bot.Context) -> List[str]: + return ctx.callback_query.data[3:].split('/') + + +def manual_recording_allowed(user_id: int) -> bool: + return 'manual_record_allowlist' not in config['bot'] or user_id in config['bot']['manual_record_allowlist'] + + +def guard_client() -> SoundSensorServerGuardClient: + return SoundSensorServerGuardClient(Addr.fromstring(config['bot']['guard_server'])) + + +# message renderers +# ----------------- + +class Renderer: + @classmethod + def places_markup(cls, ctx: bot.Context, callback_prefix: str) -> InlineKeyboardMarkup: + buttons = [] + for node, nodeconfig in config['nodes'].items(): + buttons.append([InlineKeyboardButton(nodeconfig['label'][ctx.user_lang], callback_data=f'{callback_prefix}/{node}')]) + return InlineKeyboardMarkup(buttons) + + @classmethod + def back_button(cls, + ctx: bot.Context, + buttons: list, + callback_data: str): + buttons.append([ + InlineKeyboardButton(ctx.lang('back'), callback_data=callback_data) + ]) + + +class SettingsRenderer(Renderer): + @classmethod + def index(cls, ctx: bot.Context) -> RenderedContent: + html = f'{ctx.lang("settings")}\n\n' + html += ctx.lang('select_place') + return html, cls.places_markup(ctx, callback_prefix='s0') + + @classmethod + def node(cls, ctx: bot.Context, + controls: List[dict]) -> RenderedContent: + node, = callback_unpack(ctx) + + html = [] + buttons = [] + for control in controls: + html.append(f'{control["name"]}\n{escape(control["info"])}') + buttons.append([ + InlineKeyboardButton(control['name'], callback_data=f's1/{node}/{control["name"]}') + ]) + + html = "\n\n".join(html) + cls.back_button(ctx, buttons, callback_data='s0') + + return html, InlineKeyboardMarkup(buttons) + + @classmethod + def control(cls, ctx: bot.Context, data) -> RenderedContent: + node, control, *rest = callback_unpack(ctx) + + html = '' + ctx.lang('control_state', control) + '\n\n' + html += escape(data['info']) + buttons = [] + callback_prefix = f's2/{node}/{control}' + for cap in data['caps']: + if cap == 'mute': + muted = 'dB] [off]' in data['info'] + act = 'unmute' if muted else 'mute' + buttons.append([InlineKeyboardButton(act, callback_data=f'{callback_prefix}/{act}')]) + + elif cap == 'cap': + cap_dis = 'Capture [off]' in data['info'] + act = 'cap' if cap_dis else 'nocap' + buttons.append([InlineKeyboardButton(act, callback_data=f'{callback_prefix}/{act}')]) + + elif cap == 'volume': + buttons.append( + list(map(lambda s: InlineKeyboardButton(ctx.lang(s), callback_data=f'{callback_prefix}/{s}'), + ['decr', 'incr'])) + ) + + cls.back_button(ctx, buttons, callback_data=f's0/{node}') + + return html, InlineKeyboardMarkup(buttons) + + +class RecordRenderer(Renderer): + @classmethod + def index(cls, ctx: bot.Context) -> RenderedContent: + html = f'{ctx.lang("record")}\n\n' + html += ctx.lang('select_place') + return html, cls.places_markup(ctx, callback_prefix='r0') + + @classmethod + def node(cls, ctx: bot.Context, durations: List[int]) -> RenderedContent: + node, = callback_unpack(ctx) + + html = ctx.lang('select_interval') + + buttons = [] + for s in durations: + if s >= 60: + m = int(s / 60) + label = ctx.lang('n_min', m) + else: + label = ctx.lang('n_sec', s) + buttons.append(InlineKeyboardButton(label, callback_data=f'r1/{node}/{s}')) + buttons = list(chunks(buttons, 3)) + cls.back_button(ctx, buttons, callback_data=f'r0') + + return html, InlineKeyboardMarkup(buttons) + + @classmethod + def record_started(cls, ctx: bot.Context, rid: int) -> RenderedContent: + node, *rest = callback_unpack(ctx) + + place = config['nodes'][node]['label'][ctx.user_lang] + + html = f'{ctx.lang("record_started")} ({place}, id={rid})' + return html, None + + @classmethod + def record_done(cls, info: dict, node: str, uid: int) -> str: + ulang = bot.db.get_user_lang(uid) + + def lang(key, *args): + return bot.lang.get(key, ulang, *args) + + rid = info['id'] + fmt = '%d.%m.%y %H:%M:%S' + start_time = datetime.fromtimestamp(int(info['start_time'])).strftime(fmt) + stop_time = datetime.fromtimestamp(int(info['stop_time'])).strftime(fmt) + + place = config['nodes'][node]['label'][ulang] + + html = f'{lang("record_result")} ({place}, id={rid})\n\n' + html += f'{lang("beginning")}: {start_time}\n' + html += f'{lang("end")}: {stop_time}' + + return html + + @classmethod + def record_error(cls, info: dict, node: str, uid: int) -> str: + ulang = bot.db.get_user_lang(uid) + + def lang(key, *args): + return bot.lang.get(key, ulang, *args) + + place = config['nodes'][node]['label'][ulang] + rid = info['id'] + + html = f'{lang("record_error")} ({place}, id={rid})' + if 'error' in info: + html += '\n'+str(info['error']) + + return html + + +class FilesRenderer(Renderer): + @classmethod + def index(cls, ctx: bot.Context) -> RenderedContent: + html = f'{ctx.lang("files")}\n\n' + html += ctx.lang('select_place') + return html, cls.places_markup(ctx, callback_prefix='f0') + + @classmethod + def filelist(cls, ctx: bot.Context, files: List[SoundRecordFile]) -> RenderedContent: + node, = callback_unpack(ctx) + + html_files = map(lambda file: cls.file(ctx, file, node), files) + html = '\n\n'.join(html_files) + + buttons = [] + cls.back_button(ctx, buttons, callback_data='f0') + + return html, InlineKeyboardMarkup(buttons) + + @classmethod + def file(cls, ctx: bot.Context, file: SoundRecordFile, node: str) -> str: + html = ctx.lang('file_line', file.start_humantime, file.stop_humantime, filesize_fmt(file.filesize)) + if file.file_id is not None: + html += f'/audio_{node}_{file.file_id}' + return html + + +class RemoteFilesRenderer(FilesRenderer): + @classmethod + def index(cls, ctx: bot.Context) -> RenderedContent: + html = f'{ctx.lang("remote_files")}\n\n' + html += ctx.lang('select_place') + return html, cls.places_markup(ctx, callback_prefix='g0') + + +class SoundSensorRenderer(Renderer): + @classmethod + def places_markup(cls, ctx: bot.Context, callback_prefix: str) -> InlineKeyboardMarkup: + buttons = [] + for sensor, sensor_label in config['sound_sensors'].items(): + buttons.append( + [InlineKeyboardButton(sensor_label[ctx.user_lang], callback_data=f'{callback_prefix}/{sensor}')]) + return InlineKeyboardMarkup(buttons) + + @classmethod + def index(cls, ctx: bot.Context) -> RenderedContent: + html = f'{ctx.lang("sound_sensors_info")}\n\n' + html += ctx.lang('select_place') + return html, cls.places_markup(ctx, callback_prefix='S0') + + @classmethod + def hits(cls, ctx: bot.Context, data, is_last=False) -> RenderedContent: + node, = callback_unpack(ctx) + buttons = [] + + if not data: + html = ctx.lang('sound_sensors_no_24h_data') + if not is_last: + buttons.append([InlineKeyboardButton(ctx.lang('sound_sensors_show_anything'), callback_data=f'S1/{node}')]) + else: + html = '' + prev_date = None + for item in data: + item_date = item['time'].strftime('%d.%m.%y') + if prev_date is None or prev_date != item_date: + if html != '': + html += '\n\n' + html += f'{item_date}' + prev_date = item_date + html += '\n' + item['time'].strftime('%H:%M:%S') + f' (+{item["hits"]})' + cls.back_button(ctx, buttons, callback_data='S0') + return html, InlineKeyboardMarkup(buttons) + + @classmethod + def hits_plain(cls, ctx: bot.Context, data, is_last=False) -> bytes: + node, = callback_unpack(ctx) + + text = '' + prev_date = None + for item in data: + item_date = item['time'].strftime('%d.%m.%y') + if prev_date is None or prev_date != item_date: + if text != '': + text += '\n\n' + text += item_date + prev_date = item_date + text += '\n' + item['time'].strftime('%H:%M:%S') + f' (+{item["hits"]})' + + return text.encode() + + +class CamerasRenderer(Renderer): + @classmethod + def index(cls, ctx: bot.Context) -> RenderedContent: + html = f'{ctx.lang("cameras")}\n\n' + html += ctx.lang('select_place') + return html, cls.places_markup(ctx, callback_prefix='c0') + + @classmethod + def places_markup(cls, ctx: bot.Context, callback_prefix: str) -> InlineKeyboardMarkup: + buttons = [] + for camera_name, camera_data in config['cameras'].items(): + buttons.append( + [InlineKeyboardButton(camera_data['label'][ctx.user_lang], callback_data=f'{callback_prefix}/{camera_name}')]) + return InlineKeyboardMarkup(buttons) + + @classmethod + def camera(cls, ctx: bot.Context, flash_available: bool) -> RenderedContent: + node, = callback_unpack(ctx) + + html = ctx.lang('select_option') + + buttons = [] + if flash_available: + buttons.append(InlineKeyboardButton(ctx.lang('w_flash'), callback_data=f'c1/{node}/1')) + buttons.append(InlineKeyboardButton(ctx.lang('wo_flash'), callback_data=f'c1/{node}/0')) + + cls.back_button(ctx, [buttons], callback_data=f'c0') + + return html, InlineKeyboardMarkup([buttons]) + # + # @classmethod + # def record_started(cls, ctx: bot.Context, rid: int) -> RenderedContent: + # node, *rest = callback_unpack(ctx) + # + # place = config['nodes'][node]['label'][ctx.user_lang] + # + # html = f'{ctx.lang("record_started")} ({place}, id={rid})' + # return html, None + # + # @classmethod + # def record_done(cls, info: dict, node: str, uid: int) -> str: + # ulang = bot.db.get_user_lang(uid) + # + # def lang(key, *args): + # return bot.lang.get(key, ulang, *args) + # + # rid = info['id'] + # fmt = '%d.%m.%y %H:%M:%S' + # start_time = datetime.fromtimestamp(int(info['start_time'])).strftime(fmt) + # stop_time = datetime.fromtimestamp(int(info['stop_time'])).strftime(fmt) + # + # place = config['nodes'][node]['label'][ulang] + # + # html = f'{lang("record_result")} ({place}, id={rid})\n\n' + # html += f'{lang("beginning")}: {start_time}\n' + # html += f'{lang("end")}: {stop_time}' + # + # return html + # + # @classmethod + # def record_error(cls, info: dict, node: str, uid: int) -> str: + # ulang = bot.db.get_user_lang(uid) + # + # def lang(key, *args): + # return bot.lang.get(key, ulang, *args) + # + # place = config['nodes'][node]['label'][ulang] + # rid = info['id'] + # + # html = f'{lang("record_error")} ({place}, id={rid})' + # if 'error' in info: + # html += '\n'+str(info['error']) + # + # return html + + +# cameras handlers +# ---------------- + +@bot.handler(message='cameras', callback=r'^c0$') +def cameras(ctx: bot.Context): + """ List of cameras """ + + text, markup = CamerasRenderer.index(ctx) + if not ctx.is_callback_context(): + return ctx.reply(text, markup=markup) + else: + ctx.answer() + return ctx.edit(text, markup=markup) + + +@bot.callbackhandler(callback=r'^c0/.*') +def camera_options(ctx: bot.Context) -> None: + """ List of options (with/without flash etc) """ + + cam, = callback_unpack(ctx) + if not camera_exists(cam): + ctx.answer(ctx.lang('invalid_location')) + return + + ctx.answer() + flash_available = 'flash_available' in config['cameras'][cam] and config['cameras'][cam]['flash_available'] is True + + text, markup = CamerasRenderer.camera(ctx, flash_available) + ctx.edit(text, markup) + + +@bot.callbackhandler(callback=r'^c1/.*') +def camera_capture(ctx: bot.Context) -> None: + """ Cheese """ + + cam, flash = callback_unpack(ctx) + flash = int(flash) + if not camera_exists(cam): + ctx.answer(ctx.lang('invalid_location')) + return + + ctx.answer() + + client = camera_client(cam) + fd = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') + fd.close() + + client.capture(fd.name, with_flash=bool(flash)) + logger.debug(f'captured photo ({cam}), saved to {fd.name}') + + camera_config = config['cameras'][cam] + if 'rotate' in camera_config: + im = Image.open(fd.name) + im.rotate(camera_config['rotate'], expand=True) + # im.show() + im.save(fd.name) + logger.debug(f"rotated image {camera_config['rotate']} degrees") + + try: + with open(fd.name, 'rb') as f: + bot.send_photo(ctx.user_id, photo=f) + except TelegramError as exc: + logger.exception(exc) + + try: + os.unlink(fd.name) + except OSError as exc: + logger.exception(exc) + + +# settings handlers +# ----------------- + +@bot.handler(message='settings', callback=r'^s0$') +def settings(ctx: bot.Context): + """ List of nodes """ + + text, markup = SettingsRenderer.index(ctx) + if not ctx.is_callback_context(): + return ctx.reply(text, markup=markup) + else: + ctx.answer() + return ctx.edit(text, markup=markup) + + +@bot.callbackhandler(callback=r'^s0/.*') +def settings_place(ctx: bot.Context): + """ List of controls """ + + node, = callback_unpack(ctx) + if not node_exists(node): + ctx.answer(ctx.lang('invalid_location')) + return + + cl = node_client(node) + controls = cl.amixer_get_all() + + ctx.answer() + + text, markup = SettingsRenderer.node(ctx, controls) + ctx.edit(text, markup) + + +@bot.callbackhandler(callback=r'^s1/.*') +def settings_place_control(ctx: bot.Context): + """ List of available tunes for control """ + + node, control = callback_unpack(ctx) + if not node_exists(node): + ctx.answer(ctx.lang('invalid_location')) + return + + cl = node_client(node) + control_data = cl.amixer_get(control) + + ctx.answer() + + text, markup = SettingsRenderer.control(ctx, control_data) + ctx.edit(text, markup) + + +@bot.callbackhandler(callback=r'^s2/.*') +def settings_place_control_action(ctx: bot.Context): + """ Tuning """ + + node, control, action = callback_unpack(ctx) + if not node_exists(node): + return + + cl = node_client(node) + if not hasattr(cl, f'amixer_{action}'): + ctx.answer(ctx.lang('invalid_action')) + return + + func = getattr(cl, f'amixer_{action}') + control_data = func(control) + + ctx.answer() + + text, markup = SettingsRenderer.control(ctx, control_data) + ctx.edit(text, markup) + + +# recording handlers +# ------------------ + +@bot.handler(message='record', callback=r'^r0$') +def record(ctx: bot.Context): + """ List of nodes """ + + if not manual_recording_allowed(ctx.user_id): + return ctx.reply(ctx.lang('access_denied')) + + text, markup = RecordRenderer.index(ctx) + if not ctx.is_callback_context(): + return ctx.reply(text, markup=markup) + else: + ctx.answer() + return ctx.edit(text, markup=markup) + + +@bot.callbackhandler(callback=r'^r0/.*') +def record_place(ctx: bot.Context): + """ List of available intervals """ + + node, = callback_unpack(ctx) + if not node_exists(node): + ctx.answer(ctx.lang('invalid_location')) + return + + ctx.answer() + + text, markup = RecordRenderer.node(ctx, config['bot']['record_intervals']) + ctx.edit(text, markup) + + +@bot.callbackhandler(callback=r'^r1/.*') +def record_place_interval(ctx: bot.Context): + """ Do record! """ + + node, interval = callback_unpack(ctx) + interval = int(interval) + if not node_exists(node): + ctx.answer(ctx.lang('invalid_location')) + return + if not interval_defined(interval): + ctx.answer(ctx.lang('invalid_interval')) + return + + try: + record_id = record_client.record(node, interval, {'user_id': ctx.user_id, 'node': node}) + except ApiResponseError as e: + ctx.answer(e.error_message) + logger.error(e) + return + + ctx.answer() + + html, markup = RecordRenderer.record_started(ctx, record_id) + ctx.edit(html, markup) + + +# sound sensor handlers +# --------------------- + +@bot.handler(message='sound_sensors', callback=r'^S0$') +def sound_sensors(ctx: bot.Context): + """ List of places """ + + text, markup = SoundSensorRenderer.index(ctx) + if not ctx.is_callback_context(): + return ctx.reply(text, markup=markup) + else: + ctx.answer() + return ctx.edit(text, markup=markup) + + +@bot.callbackhandler(callback=r'^S0/.*') +def sound_sensors_last_24h(ctx: bot.Context): + """ Last 24h log """ + + node, = callback_unpack(ctx) + if not sound_sensor_exists(node): + ctx.answer(ctx.lang('invalid location')) + return + + ctx.answer() + + cl = WebApiClient() + data = cl.get_sound_sensor_hits(location=SoundSensorLocation[node.upper()], + after=datetime.now() - timedelta(hours=24)) + + text, markup = SoundSensorRenderer.hits(ctx, data) + if len(text) > 4096: + plain = SoundSensorRenderer.hits_plain(ctx, data) + bot.send_file(ctx.user_id, document=plain, filename='data.txt') + else: + ctx.edit(text, markup=markup) + + +@bot.callbackhandler(callback=r'^S1/.*') +def sound_sensors_last_anything(ctx: bot.Context): + """ Last _something_ """ + + node, = callback_unpack(ctx) + if not sound_sensor_exists(node): + ctx.answer(ctx.lang('invalid location')) + return + + ctx.answer() + + cl = WebApiClient() + data = cl.get_last_sound_sensor_hits(location=SoundSensorLocation[node.upper()], + last=20) + + text, markup = SoundSensorRenderer.hits(ctx, data, is_last=True) + if len(text) > 4096: + plain = SoundSensorRenderer.hits_plain(ctx, data) + bot.send_file(ctx.user_id, document=plain, filename='data.txt') + else: + ctx.edit(text, markup=markup) + + +# guard enable/disable handlers +# ----------------------------- + +class GuardUserAction(Enum): + ENABLE = 'enable' + DISABLE = 'disable' + + +if 'guard_server' in config['bot']: + @bot.handler(message='guard_status') + def guard_status(ctx: bot.Context): + guard = guard_client() + resp = guard.guard_status() + + key = 'enabled' if resp['enabled'] is True else 'disabled' + ctx.reply(ctx.lang(f'guard_status_{key}')) + + + @bot.handler(message='guard_enable') + def guard_enable(ctx: bot.Context): + guard = guard_client() + guard.guard_enable() + ctx.reply(ctx.lang('done')) + + _guard_notify(ctx.user, GuardUserAction.ENABLE) + + + @bot.handler(message='guard_disable') + def guard_disable(ctx: bot.Context): + guard = guard_client() + guard.guard_disable() + ctx.reply(ctx.lang('done')) + + _guard_notify(ctx.user, GuardUserAction.DISABLE) + + + def _guard_notify(user: User, action: GuardUserAction): + def text_getter(lang: str): + action_name = bot.lang.get(f'guard_user_action_{action.value}', lang) + user_name = bot.user_any_name(user) + return 'ℹ ' + bot.lang.get('guard_user_action_notification', lang, + user.id, user_name, action_name) + + bot.notify_all(text_getter, exclude=(user.id,)) + + +@bot.defaultreplymarkup +def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: + buttons = [ + [ctx.lang('record'), ctx.lang('settings')], + # [ctx.lang('files'), ctx.lang('remote_files')], + ] + if 'guard_server' in config['bot']: + buttons.append([ + ctx.lang('guard_enable'), ctx.lang('guard_disable'), ctx.lang('guard_status') + ]) + buttons.append([ctx.lang('sound_sensors')]) + if have_cameras(): + buttons.append([ctx.lang('cameras')]) + return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) + + +# record client callbacks +# ----------------------- + +def record_onerror(info: dict, userdata: dict): + uid = userdata['user_id'] + node = userdata['node'] + + html = RecordRenderer.record_error(info, node, uid) + try: + bot.notify_user(userdata['user_id'], html) + except TelegramError as exc: + logger.exception(exc) + finally: + record_client.forget(node, info['id']) + + +def record_onfinished(info: dict, fn: str, userdata: dict): + logger.info('record finished: ' + str(info)) + + uid = userdata['user_id'] + node = userdata['node'] + + html = RecordRenderer.record_done(info, node, uid) + bot.notify_user(uid, html) + + try: + # sending audiofile to telegram + with open(fn, 'rb') as f: + bot.send_audio(uid, audio=f, filename='audio.mp3') + + # deleting temp file + try: + os.unlink(fn) + except OSError as exc: + logger.exception(exc) + bot.notify_user(uid, exc) + + # remove the recording from sound_node's history + record_client.forget(node, info['id']) + + # remove file from storage + # node_client(node).storage_delete(info['file']['fileid']) + except Exception as e: + logger.exception(e) + + +if __name__ == '__main__': + record_client = SoundRecordClient(nodes, + error_handler=record_onerror, + finished_handler=record_onfinished, + download_on_finish=True) + + if 'api' in config: + bot.enable_logging(BotType.SOUND) + bot.run() + record_client.stop() diff --git a/bin/sound_node.py b/bin/sound_node.py new file mode 100755 index 0000000..90e6997 --- /dev/null +++ b/bin/sound_node.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +import os +import __py_include + +from typing import Optional + +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. +# Implements HTTP API for amixer and arecord. +# ------------------------------------------- + +def _amixer_control_response(control): + info = amixer.get(control) + caps = amixer.get_caps(control) + return http.ok({ + 'caps': caps, + 'info': info + }) + + +class SoundNodeServer(MediaNodeServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.get('/amixer/get-all/', self.amixer_get_all) + self.get('/amixer/get/{control}/', self.amixer_get) + self.get('/amixer/{op:mute|unmute|cap|nocap}/{control}/', self.amixer_set) + self.get('/amixer/{op:incr|decr}/{control}/', self.amixer_volume) + + async def amixer_get_all(self, request: http.Request): + controls_info = amixer.get_all() + return self.ok(controls_info) + + async def amixer_get(self, request: http.Request): + control = request.match_info['control'] + if not amixer.has_control(control): + raise ValueError(f'invalid control: {control}') + + return _amixer_control_response(control) + + async def amixer_set(self, request: http.Request): + op = request.match_info['op'] + control = request.match_info['control'] + if not amixer.has_control(control): + raise ValueError(f'invalid control: {control}') + + f = getattr(amixer, op) + f(control) + + return _amixer_control_response(control) + + async def amixer_volume(self, request: http.Request): + op = request.match_info['op'] + control = request.match_info['control'] + if not amixer.has_control(control): + raise ValueError(f'invalid control: {control}') + + def get_step() -> Optional[int]: + if 'step' in request.query: + step = int(request.query['step']) + if not 1 <= step <= 50: + raise ValueError('invalid step value') + return step + return None + + f = getattr(amixer, op) + f(control, step=get_step()) + + return _amixer_control_response(control) + + +if __name__ == '__main__': + if not os.getegid() == 0: + raise RuntimeError("Must be run as root.") + + config.load_app('sound_node') + + storage = SoundRecordStorage(config['node']['storage']) + + recorder = SoundRecorder(storage=storage) + recorder.start_thread() + + server = SoundNodeServer(recorder=recorder, + storage=storage, + addr=config.get_addr('node.listen')) + server.run() diff --git a/bin/sound_sensor_node.py b/bin/sound_sensor_node.py new file mode 100755 index 0000000..39c3905 --- /dev/null +++ b/bin/sound_sensor_node.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import logging +import os +import sys +import __py_include + +from homekit.config import config +from homekit.util import Addr +from homekit.soundsensor import SoundSensorNode + +logger = logging.getLogger(__name__) + + +if __name__ == '__main__': + if not os.getegid() == 0: + sys.exit('Must be run as root.') + + config.load_app('sound_sensor_node') + + kwargs = {} + if 'delay' in config['node']: + kwargs['delay'] = config['node']['delay'] + + if 'server_addr' in config['node']: + server_addr = Addr.fromstring(config['node']['server_addr']) + else: + server_addr = None + + node = SoundSensorNode(name=config['node']['name'], + pinname=config['node']['pin'], + threshold=config['node']['threshold'] if 'threshold' in config['node'] else 1, + server_addr=server_addr, + **kwargs) + node.run() diff --git a/bin/sound_sensor_server.py b/bin/sound_sensor_server.py new file mode 100755 index 0000000..fd7ff5a --- /dev/null +++ b/bin/sound_sensor_server.py @@ -0,0 +1,200 @@ +#!/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 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__) +server: SoundSensorServer + + +def get_related_nodes(node_type: MediaNodeType, + sensor_name: str) -> List[str]: + try: + if sensor_name not in config[f'sensor_to_{node_type.name.lower()}_nodes_relations']: + raise ValueError(f'unexpected sensor name {sensor_name}') + return config[f'sensor_to_{node_type.name.lower()}_nodes_relations'][sensor_name] + except KeyError: + return [] + + +def get_node_config(node_type: MediaNodeType, + name: str) -> Optional[dict]: + if name in config[f'{node_type.name.lower()}_nodes']: + cfg = config[f'{node_type.name.lower()}_nodes'][name] + if 'min_hits' not in cfg: + cfg['min_hits'] = 1 + return cfg + else: + return None + + +class HitCounter: + def __init__(self): + self.sensors = {} + self.lock = threading.Lock() + self._reset_sensors() + + def _reset_sensors(self): + for loc in SoundSensorLocation: + self.sensors[loc.name.lower()] = 0 + + def add(self, name: str, hits: int): + if name not in self.sensors: + raise ValueError(f'sensor {name} not found') + + with self.lock: + self.sensors[name] += hits + + def get_all(self) -> List[Tuple[str, int]]: + vals = [] + with self.lock: + for name, hits in self.sensors.items(): + if hits > 0: + vals.append((name, hits)) + self._reset_sensors() + return vals + + +class HitHandler(SoundSensorHitHandler): + def handler(self, name: str, hits: int): + if not hasattr(SoundSensorLocation, name.upper()): + logger.error(f'invalid sensor name: {name}') + return + + should_continue = False + for node_type in MediaNodeType: + try: + nodes = get_related_nodes(node_type, name) + except ValueError: + logger.error(f'config for {node_type.name.lower()} node {name} not found') + return + + for node in nodes: + node_config = get_node_config(node_type, node) + if node_config is None: + logger.error(f'config for {node_type.name.lower()} node {node} not found') + continue + if hits < node_config['min_hits']: + continue + should_continue = True + + if not should_continue: + return + + hc.add(name, hits) + + if not server.is_recording_enabled(): + return + for node_type in MediaNodeType: + try: + nodes = get_related_nodes(node_type, name) + for node in nodes: + node_config = get_node_config(node_type, node) + if node_config is None: + logger.error(f'node config for {node_type.name.lower()} node {node} not found') + continue + + durations = node_config['durations'] + dur = durations[1] if hits > node_config['min_hits'] else durations[0] + record_clients[node_type].record(node, dur*60, {'node': node}) + + except ValueError as exc: + logger.exception(exc) + + +def hits_sender(): + while not interrupted: + all_hits = hc.get_all() + if all_hits: + api.add_sound_sensor_hits(all_hits) + sleep(5) + + +api: Optional[WebApiClient] = None +hc: Optional[HitCounter] = None +record_clients: Dict[MediaNodeType, RecordClient] = {} + + +# record callbacks +# ---------------- + +def record_error(type: MediaNodeType, + info: dict, + userdata: dict): + node = userdata['node'] + logger.error('recording ' + str(dict) + f' from {type.name.lower()} node ' + node + ' failed') + + record_clients[type].forget(node, info['id']) + + +def record_finished(type: MediaNodeType, + info: dict, + fn: str, + userdata: dict): + logger.debug(f'{type.name.lower()} record finished: ' + str(info)) + + # audio could have been requested by other user (telegram bot, for example) + # so we shouldn't 'forget' it here + + # node = userdata['node'] + # record.forget(node, info['id']) + + +# api client callbacks +# -------------------- + +def api_error_handler(exc, name, req: RequestParams): + logger.error(f'api call ({name}, params={req.params}) failed, exception below') + logger.exception(exc) + + +if __name__ == '__main__': + config.load_app('sound_sensor_server') + + hc = HitCounter() + api = WebApiClient(timeout=(10, 60)) + api.enable_async(error_handler=api_error_handler) + + t = threading.Thread(target=hits_sender) + t.daemon = True + t.start() + + sound_nodes = {} + if 'sound_nodes' in config: + for nodename, nodecfg in config['sound_nodes'].items(): + sound_nodes[nodename] = Addr.fromstring(nodecfg['addr']) + + camera_nodes = {} + if 'camera_nodes' in config: + for nodename, nodecfg in config['camera_nodes'].items(): + camera_nodes[nodename] = Addr.fromstring(nodecfg['addr']) + + if sound_nodes: + record_clients[MediaNodeType.SOUND] = SoundRecordClient(sound_nodes, + error_handler=partial(record_error, MediaNodeType.SOUND), + finished_handler=partial(record_finished, MediaNodeType.SOUND)) + + if camera_nodes: + record_clients[MediaNodeType.CAMERA] = CameraRecordClient(camera_nodes, + error_handler=partial(record_error, MediaNodeType.CAMERA), + finished_handler=partial(record_finished, MediaNodeType.CAMERA)) + + try: + server = SoundSensorServer(config.get_addr('server.listen'), HitHandler) + server.run() + except KeyboardInterrupt: + interrupted = True + for c in record_clients.values(): + c.stop() + logging.info('keyboard interrupt, exiting...') diff --git a/bin/ssh_tunnels_config_util.py b/bin/ssh_tunnels_config_util.py new file mode 100755 index 0000000..d08a4f4 --- /dev/null +++ b/bin/ssh_tunnels_config_util.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import __py_include +from homekit.config import config + +if __name__ == '__main__': + config.load_app('ssh_tunnels_config_util') + + network_prefix = config['network'] + hostnames = [] + + for k, v in config.app_config.get().items(): + if type(v) is not dict: + continue + hostnames.append(k) + + for host in hostnames: + buf = [] + i = 0 + for tun_host in hostnames: + http_bind_port = config['http_bind_base'] + config[host]['bind_slot'] * 10 + i + ssh_bind_port = config['ssh_bind_base'] + config[host]['bind_slot'] * 10 + i + + if tun_host == host: + target_host = '127.0.0.1' + else: + target_host = f'{network_prefix}.{config[tun_host]["ipv4"]}' + + buf.append(f'-R 127.0.0.1:{http_bind_port}:{target_host}:{config[tun_host]["http_port"]}') + buf.append(f'-R 127.0.0.1:{ssh_bind_port}:{target_host}:{config[tun_host]["ssh_port"]}') + + i += 1 + + print(host) + print(' '.join(buf)) + print() diff --git a/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/bin/temphum_mqtt_receiver.py b/bin/temphum_mqtt_receiver.py new file mode 100755 index 0000000..d0a378e --- /dev/null +++ b/bin/temphum_mqtt_receiver.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +import paho.mqtt.client as mqtt +import re +import __py_include + +from homekit.config import config +from homekit.mqtt import MqttWrapper, MqttNode + + +class MqttServer(Mqtt): + def __init__(self): + super().__init__(clean_session=False) + self.database = SensorsDatabase() + + def on_connect(self, client: mqtt.Client, userdata, flags, rc): + super().on_connect(client, userdata, flags, rc) + self._logger.info("subscribing to hk/#") + client.subscribe('hk/#', qos=1) + + def on_message(self, client: mqtt.Client, userdata, msg): + super().on_message(client, userdata, msg) + try: + variants = '|'.join([s.name.lower() for s in TemperatureSensorLocation]) + match = re.match(rf'hk/(\d+)/si7021/({variants})', msg.topic) + if not match: + return + + # FIXME string home_id must be supported + home_id = int(match.group(1)) + sensor = get_sensor_type(match.group(2)) + + payload = Temperature.unpack(msg.payload) + self.database.add_temperature(home_id, payload.time, sensor, + temp=int(payload.temp*100), + rh=int(payload.rh*100)) + except Exception as e: + self._logger.exception(str(e)) + + +if __name__ == '__main__': + config.load_app('temphum_mqtt_receiver') + + mqtt = MqttWrapper(clean_session=False) + node = MqttNode(node_id='+') + node.load_module('temphum', write_to_database=True) + mqtt.add_node(node) + + mqtt.connect_and_loop() \ No newline at end of file diff --git a/bin/temphum_nodes_util.py b/bin/temphum_nodes_util.py new file mode 100755 index 0000000..aa46494 --- /dev/null +++ b/bin/temphum_nodes_util.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +import __py_include + +from homekit.mqtt.temphum import MqttTempHumNodes + +if __name__ == '__main__': + max_name_len = 0 + for node in MqttTempHumNodes: + if len(node.name) > max_name_len: + max_name_len = len(node.name) + + values = [] + for node in MqttTempHumNodes: + hash = node.hash() + if hash in values: + raise ValueError(f'collision detected: {hash}') + values.append(values) + print(' '*(max_name_len-len(node.name)), end='') + print(f'{node.name}: {hash}') diff --git a/bin/temphum_smbus_util.py b/bin/temphum_smbus_util.py new file mode 100755 index 0000000..1cfaa84 --- /dev/null +++ b/bin/temphum_smbus_util.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +import __py_include + +from argparse import ArgumentParser +from homekit.temphum import SensorType +from homekit.temphum.i2c import create_sensor + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('-t', '--type', choices=[item.value for item in SensorType], + required=True, + help='Sensor type') + parser.add_argument('-b', '--bus', type=int, default=0, + help='I2C bus number') + arg = parser.parse_args() + + sensor = create_sensor(SensorType(arg.type), arg.bus) + temp = sensor.temperature() + hum = sensor.humidity() + + print(f'temperature: {temp}') + print(f'rel. humidity: {hum}') diff --git a/bin/temphumd.py b/bin/temphumd.py new file mode 100755 index 0000000..9ea436d --- /dev/null +++ b/bin/temphumd.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/bin/web_api.py b/bin/web_api.py new file mode 100755 index 0000000..0e0fd0b --- /dev/null +++ b/bin/web_api.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +import asyncio +import json +import os +import __py_include + +from datetime import datetime, timedelta + +from aiohttp import web +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 BotType, TemperatureSensorLocation, SoundSensorLocation +from homekit.media import SoundRecordStorage + + +def strptime_auto(s: str) -> datetime: + e = None + for fmt in (FormatTime, FormatDate): + try: + return datetime.strptime(s, fmt) + except ValueError as _e: + e = _e + raise e + + +class AuthError(Exception): + def __init__(self, message: str): + super().__init__() + self.message = message + + +class WebAPIServer(http.HTTPServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.app.middlewares.append(self.validate_auth) + + self.get('/', self.get_index) + self.get('/sensors/data/', self.GET_sensors_data) + self.get('/sound_sensors/hits/', self.GET_sound_sensors_hits) + self.post('/sound_sensors/hits/', self.POST_sound_sensors_hits) + + self.post('/log/bot_request/', self.POST_bot_request_log) + self.post('/log/openwrt/', self.POST_openwrt_log) + + self.get('/inverter/consumed_energy/', self.GET_consumed_energy) + self.get('/inverter/grid_consumed_energy/', self.GET_grid_consumed_energy) + + self.get('/recordings/list/', self.GET_recordings_list) + + @staticmethod + @web.middleware + async def validate_auth(req: http.Request, handler): + def get_token() -> str: + name = 'X-Token' + if name in req.headers: + return req.headers[name] + + return req.query['token'] + + try: + token = get_token() + except KeyError: + raise AuthError('no token') + + if token != config['api']['token']: + raise AuthError('invalid token') + + return await handler(req) + + @staticmethod + async def get_index(req: http.Request): + message = "nothing here, keep lurking" + if is_development_mode(): + message += ' (dev mode)' + return http.Response(text=message, content_type='text/plain') + + async def GET_sensors_data(self, req: http.Request): + try: + hours = int(req.query['hours']) + if hours < 1 or hours > 24: + raise ValueError('invalid hours value') + except KeyError: + hours = 1 + + sensor = TemperatureSensorLocation(int(req.query['sensor'])) + + dt_to = datetime.now() + dt_from = dt_to - timedelta(hours=hours) + + db = SensorsDatabase() + data = db.get_temperature_recordings(sensor, (dt_from, dt_to)) + return self.ok(data) + + async def GET_sound_sensors_hits(self, req: http.Request): + location = SoundSensorLocation(int(req.query['location'])) + + after = int(req.query['after']) + kwargs = {} + if after is None: + last = int(req.query['last']) + if last is None: + raise ValueError('you must pass `after` or `last` params') + else: + if not 0 < last < 100: + raise ValueError('invalid last value: must be between 0 and 100') + kwargs['last'] = last + else: + kwargs['after'] = datetime.fromtimestamp(after) + + data = BotsDatabase().get_sound_hits(location, **kwargs) + return self.ok(data) + + async def POST_sound_sensors_hits(self, req: http.Request): + hits = [] + data = await req.post() + for hit, count in json.loads(data['hits']): + if not hasattr(SoundSensorLocation, hit.upper()): + raise ValueError('invalid sensor location') + if count < 1: + raise ValueError(f'invalid count: {count}') + hits.append((SoundSensorLocation[hit.upper()], count)) + + BotsDatabase().add_sound_hits(hits, datetime.now()) + return self.ok() + + async def POST_bot_request_log(self, req: http.Request): + data = await req.post() + + try: + user_id = int(data['user_id']) + except KeyError: + user_id = 0 + + try: + message = data['message'] + except KeyError: + message = '' + + bot = BotType(int(data['bot'])) + + # validate message + if message.strip() == '': + raise ValueError('message can\'t be empty') + + # add record to the database + BotsDatabase().add_request(bot, user_id, message) + + return self.ok() + + async def POST_openwrt_log(self, req: http.Request): + data = await req.post() + + try: + logs = data['logs'] + ap = int(data['ap']) + except KeyError: + logs = '' + ap = 0 + + # validate it + logs = json.loads(logs) + assert type(logs) is list, "invalid json data (list expected)" + + lines = [] + for line in logs: + assert type(line) is list, "invalid line type (list expected)" + assert len(line) == 2, f"expected 2 items in line, got {len(line)}" + assert type(line[0]) is int, "invalid line[0] type (int expected)" + assert type(line[1]) is str, "invalid line[1] type (str expected)" + + lines.append(( + datetime.fromtimestamp(line[0]), + line[1] + )) + + BotsDatabase().add_openwrt_logs(lines, ap) + return self.ok() + + async def GET_recordings_list(self, req: http.Request): + data = await req.post() + + try: + extended = bool(int(data['extended'])) + except KeyError: + extended = False + + node = data['node'] + + root = os.path.join(config['recordings']['directory'], node) + if not os.path.isdir(root): + raise ValueError(f'invalid node {node}: no such directory') + + storage = SoundRecordStorage(root) + files = storage.getfiles(as_objects=extended) + if extended: + files = list(map(lambda file: file.__dict__(), files)) + + return self.ok(files) + + @staticmethod + def _get_inverter_from_to(req: http.Request): + s_from = req.query['from'] + s_to = req.query['to'] + + dt_from = strptime_auto(s_from) + + if s_to == 'now': + dt_to = datetime.now() + else: + dt_to = strptime_auto(s_to) + + return dt_from, dt_to + + async def GET_consumed_energy(self, req: http.Request): + dt_from, dt_to = self._get_inverter_from_to(req) + wh = InverterDatabase().get_consumed_energy(dt_from, dt_to) + return self.ok(wh) + + async def GET_grid_consumed_energy(self, req: http.Request): + dt_from, dt_to = self._get_inverter_from_to(req) + wh = InverterDatabase().get_grid_consumed_energy(dt_from, dt_to) + return self.ok(wh) + + +# start of the program +# -------------------- + +if __name__ == '__main__': + _app_name = 'web_api' + if is_development_mode(): + _app_name += '_dev' + config.load_app(_app_name) + + loop = asyncio.get_event_loop() + + server = WebAPIServer(config.get_addr('server.listen')) + server.run() diff --git a/misc/mqtt_ca.crt b/misc/mqtt_ca.crt new file mode 100644 index 0000000..045ae10 --- /dev/null +++ b/misc/mqtt_ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID4zCCAsugAwIBAgIUcW9D2Yym/nNf//Sfv1G8kwpEBCMwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlJVMQ8wDQYDVQQIDAZNb3Njb3cxDzANBgNVBAcMBk1v +c2NvdzEUMBIGA1UECgwLU29sYXJNb24uUlUxFzAVBgNVBAMMDmNhLnNvbGFybW9u +LnJ1MSAwHgYJKoZIhvcNAQkBFhFhZG1pbkBzb2xhcm1vbi5ydTAeFw0yMTA1MTYx +NzI2MjRaFw0zMTA1MTQxNzI2MjRaMIGAMQswCQYDVQQGEwJSVTEPMA0GA1UECAwG +TW9zY293MQ8wDQYDVQQHDAZNb3Njb3cxFDASBgNVBAoMC1NvbGFyTW9uLlJVMRcw +FQYDVQQDDA5jYS5zb2xhcm1vbi5ydTEgMB4GCSqGSIb3DQEJARYRYWRtaW5Ac29s +YXJtb24ucnUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEEPOhEE74 +LDWVhtY3fFQu1HD3GYv2b8SgXXk1evFs2QiLtw7wtvVG9jM+JjLadY50gMZYlrKe +NqFxj7OutTx0RnkFLQ0Q3xkEsQOlWVvgFf4qwZ8pEgAnmVGHQjBeM4vmgY0Dxnqd +GLrjLVKwEMYM1PiV3pp1vMDJGouoxp3bOL7Iz++/07Atim9g8RZ+gyw080JJUKdB +7alR3ZfND2GMFXd03aosE5c7YqIwjGrT73K4sdqP8ydwEPtjBfn4b746uERllsT1 +EBc4Iv25RWdUy1p1YIaa8y9/34h7QPUSawjdnnL+Ktq9DCxv8WDKoSRK5E7bwswf +DKHFEmoI4IjHAgMBAAGjUzBRMB0GA1UdDgQWBBSqdoh/ZkUgfDWQoxjXU6CeIO4H +FDAfBgNVHSMEGDAWgBSqdoh/ZkUgfDWQoxjXU6CeIO4HFDAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCM6JdaY+pT3E/8Tfz+M0R4kgqasyc9fAQP +g7tf2HrMPCtuIZF8aJYMNi0pfcnuUtr9FXFgGjyG+PZxqD2lHS+F/U5I8XqtTNJM +FW5Ls9dulRjmiGs0u8JbEX3igFTuCh0EZbtJgOLt2rOwSLv9PwI+ng4n8LBtbXVl +icfzWxGbnx/Bzoa7/Rk6Gs10Jf5bAeklchx/DbytSmoYSs9TxGdsrYkllznRts76 +6DHptSctecdi0svL4cE9dXWl6OSgG674khWPTd0I9bcHgJCQ6T1gPLRpnFJJ1ZT6 +ORgl25mkt+AX5U+naLMuUXU9TBKr3foxBMWqrSu5uC5K494Lbrvv +-----END CERTIFICATE----- diff --git a/pyA20/__init__.pyi b/pyA20/__init__.pyi deleted file mode 100644 index e69de29..0000000 diff --git a/pyA20/gpio/connector.pyi b/pyA20/gpio/connector.pyi deleted file mode 100644 index 12b2b6e..0000000 --- a/pyA20/gpio/connector.pyi +++ /dev/null @@ -1,2 +0,0 @@ -gpio1 = 0 -LED = 0 \ No newline at end of file diff --git a/pyA20/gpio/gpio.pyi b/pyA20/gpio/gpio.pyi deleted file mode 100644 index 225fcbe..0000000 --- a/pyA20/gpio/gpio.pyi +++ /dev/null @@ -1,24 +0,0 @@ -HIGH = 1 -LOW = 0 -INPUT = 0 -OUTPUT = 0 -PULLUP = 0 -PULLDOWN = 0 - -def init(): - pass - -def setcfg(gpio: int, cfg: int): - pass - -def getcfg(gpio: int): - pass - -def output(gpio: int, value: int): - pass - -def pullup(gpio: int, pull: int): - pass - -def input(gpio: int): - pass \ No newline at end of file diff --git a/pyA20/gpio/port.pyi b/pyA20/gpio/port.pyi deleted file mode 100644 index 17f69fe..0000000 --- a/pyA20/gpio/port.pyi +++ /dev/null @@ -1,36 +0,0 @@ -# these are not real values, just placeholders - -PA12 = 0 -PA11 = 0 -PA6 = 0 - -PA1 = 0 -PA0 = 0 - -PA3 = 0 -PC0 = 0 -PC1 = 0 -PC2 = 0 -PA19 = 0 -PA7 = 0 -PA8 = 0 -PA9 = 0 -PA10 = 0 -PA20 = 0 - -PA13 = 0 -PA14 = 0 -PD14 = 0 -PC4 = 0 -PC7 = 0 -PA2 = 0 -PC3 = 0 -PA21 = 0 -PA18 = 0 -PG8 = 0 -PG9 = 0 -PG6 = 0 -PG7 = 0 - -POWER_LED = 0 -STATUS_LED = 0 \ No newline at end of file diff --git a/pyA20/port.pyi b/pyA20/port.pyi deleted file mode 100644 index e69de29..0000000 diff --git a/py_include/__init__.py b/py_include/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/py_include/homekit/__init__.py b/py_include/homekit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/py_include/homekit/api/__init__.py b/py_include/homekit/api/__init__.py new file mode 100644 index 0000000..d641f62 --- /dev/null +++ b/py_include/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/py_include/homekit/api/__init__.pyi b/py_include/homekit/api/__init__.pyi new file mode 100644 index 0000000..5b98161 --- /dev/null +++ b/py_include/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/py_include/homekit/api/config.py b/py_include/homekit/api/config.py new file mode 100644 index 0000000..00c1097 --- /dev/null +++ b/py_include/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/py_include/homekit/api/errors/__init__.py b/py_include/homekit/api/errors/__init__.py new file mode 100644 index 0000000..efb06aa --- /dev/null +++ b/py_include/homekit/api/errors/__init__.py @@ -0,0 +1 @@ +from .api_response_error import ApiResponseError diff --git a/py_include/homekit/api/errors/api_response_error.py b/py_include/homekit/api/errors/api_response_error.py new file mode 100644 index 0000000..85d788b --- /dev/null +++ b/py_include/homekit/api/errors/api_response_error.py @@ -0,0 +1,28 @@ +from typing import Optional, List + + +class ApiResponseError(Exception): + def __init__(self, + status_code: int, + error_type: str, + error_message: str, + error_stacktrace: Optional[List[str]] = None): + super().__init__() + self.status_code = status_code + self.error_message = error_message + self.error_type = error_type + self.error_stacktrace = error_stacktrace + + def __str__(self): + def st_formatter(line: str): + return f'Remote| {line}' + + s = f'{self.error_type}: {self.error_message} (HTTP {self.status_code})' + if self.error_stacktrace is not None: + st = [] + for st_line in self.error_stacktrace: + st.append('\n'.join(st_formatter(st_subline) for st_subline in st_line.split('\n'))) + s += '\nRemote stacktrace:\n' + s += '\n'.join(st) + + return s diff --git a/py_include/homekit/api/types/__init__.py b/py_include/homekit/api/types/__init__.py new file mode 100644 index 0000000..9f27ff6 --- /dev/null +++ b/py_include/homekit/api/types/__init__.py @@ -0,0 +1,6 @@ +from .types import ( + BotType, + TemperatureSensorDataType, + TemperatureSensorLocation, + SoundSensorLocation +) diff --git a/py_include/homekit/api/types/types.py b/py_include/homekit/api/types/types.py new file mode 100644 index 0000000..981e798 --- /dev/null +++ b/py_include/homekit/api/types/types.py @@ -0,0 +1,33 @@ +from enum import Enum, auto + + +class BotType(Enum): + INVERTER = auto() + PUMP = auto() + SENSORS = auto() + ADMIN = auto() + SOUND = auto() + POLARIS_KETTLE = auto() + PUMP_MQTT = auto() + RELAY_MQTT = auto() + + +class TemperatureSensorLocation(Enum): + BIG_HOUSE_1 = auto() + BIG_HOUSE_2 = auto() + BIG_HOUSE_ROOM = auto() + STREET = auto() + DIANA = auto() + SPB1 = auto() + + +class TemperatureSensorDataType(Enum): + TEMPERATURE = auto() + RELATIVE_HUMIDITY = auto() + + +class SoundSensorLocation(Enum): + DIANA = auto() + BIG_HOUSE = auto() + SPB1 = auto() + diff --git a/py_include/homekit/api/web_api_client.py b/py_include/homekit/api/web_api_client.py new file mode 100644 index 0000000..15c1915 --- /dev/null +++ b/py_include/homekit/api/web_api_client.py @@ -0,0 +1,227 @@ +import requests +import json +import threading +import logging + +from collections import namedtuple +from datetime import datetime +from enum import Enum, auto +from typing import Optional, Callable, Union, List, Tuple, Dict +from requests.auth import HTTPBasicAuth + +from .config import WebApiConfig +from .errors import ApiResponseError +from .types import * +from ..config import config +from ..util import stringify +from ..media import RecordFile, MediaNodeClient + +_logger = logging.getLogger(__name__) +_config = WebApiConfig() + + +RequestParams = namedtuple('RequestParams', 'params, files, method') + + +class HTTPMethod(Enum): + GET = auto() + POST = auto() + + +class WebApiClient: + token: str + timeout: Union[float, Tuple[float, float]] + basic_auth: Optional[HTTPBasicAuth] + do_async: bool + async_error_handler: Optional[Callable] + async_success_handler: Optional[Callable] + + def __init__(self, timeout: Union[float, Tuple[float, float]] = 5): + self.token = config['token'] + self.timeout = timeout + self.basic_auth = None + self.do_async = False + self.async_error_handler = None + self.async_success_handler = None + + # if 'basic_auth' in config['api']: + # ba = config['api']['basic_auth'] + # col = ba.index(':') + # + # user = ba[:col] + # pw = ba[col+1:] + # + # _logger.debug(f'enabling basic auth: {user}:{pw}') + # self.basic_auth = HTTPBasicAuth(user, pw) + + # api methods + # ----------- + + def log_bot_request(self, + bot: BotType, + user_id: int, + message: str): + return self._post('log/bot_request/', { + 'bot': bot.value, + 'user_id': str(user_id), + 'message': message + }) + + def log_openwrt(self, + lines: List[Tuple[int, str]], + access_point: int): + return self._post('log/openwrt/', { + 'logs': stringify(lines), + 'ap': access_point + }) + + def get_sensors_data(self, + sensor: TemperatureSensorLocation, + hours: int): + data = self._get('sensors/data/', { + 'sensor': sensor.value, + 'hours': hours + }) + return [(datetime.fromtimestamp(date), temp, hum) for date, temp, hum in data] + + def add_sound_sensor_hits(self, + hits: List[Tuple[str, int]]): + return self._post('sound_sensors/hits/', { + 'hits': stringify(hits) + }) + + def get_sound_sensor_hits(self, + location: SoundSensorLocation, + after: datetime) -> List[dict]: + return self._process_sound_sensor_hits_data(self._get('sound_sensors/hits/', { + 'after': int(after.timestamp()), + 'location': location.value + })) + + def get_last_sound_sensor_hits(self, location: SoundSensorLocation, last: int): + return self._process_sound_sensor_hits_data(self._get('sound_sensors/hits/', { + 'last': last, + 'location': location.value + })) + + def recordings_list(self, extended=False, as_objects=False) -> Union[List[str], List[dict], List[RecordFile]]: + files = self._get('recordings/list/', {'extended': int(extended)})['data'] + if as_objects: + return MediaNodeClient.record_list_from_serialized(files) + return files + + def inverter_get_consumed_energy(self, s_from: str, s_to: str): + return self._get('inverter/consumed_energy/', { + 'from': s_from, + 'to': s_to + }) + + def inverter_get_grid_consumed_energy(self, s_from: str, s_to: str): + return self._get('inverter/grid_consumed_energy/', { + 'from': s_from, + 'to': s_to + }) + + @staticmethod + def _process_sound_sensor_hits_data(data: List[dict]) -> List[dict]: + for item in data: + item['time'] = datetime.fromtimestamp(item['time']) + return data + + # internal methods + # ---------------- + + def _get(self, *args, **kwargs): + return self._call(method=HTTPMethod.GET, *args, **kwargs) + + def _post(self, *args, **kwargs): + return self._call(method=HTTPMethod.POST, *args, **kwargs) + + def _call(self, + name: str, + params: dict, + method: HTTPMethod, + files: Optional[Dict[str, str]] = None): + if not self.do_async: + return self._make_request(name, params, method, files) + else: + t = threading.Thread(target=self._make_request_in_thread, args=(name, params, method, files)) + t.start() + return None + + def _make_request(self, + name: str, + params: dict, + method: HTTPMethod = HTTPMethod.GET, + files: Optional[Dict[str, str]] = None) -> Optional[any]: + domain = config['host'] + kwargs = {} + + if self.basic_auth is not None: + kwargs['auth'] = self.basic_auth + + if method == HTTPMethod.GET: + if files: + raise RuntimeError('can\'t upload files using GET, please use me properly') + kwargs['params'] = params + f = requests.get + else: + kwargs['data'] = params + f = requests.post + + fd = {} + if files: + for fname, fpath in files.items(): + fd[fname] = open(fpath, 'rb') + kwargs['files'] = fd + + try: + r = f(f'https://{domain}/{name}', + headers={'X-Token': self.token}, + timeout=self.timeout, + **kwargs) + + if not r.headers['content-type'].startswith('application/json'): + raise ApiResponseError(r.status_code, 'TypeError', 'content-type is not application/json') + + data = json.loads(r.text) + if r.status_code != 200: + raise ApiResponseError(r.status_code, + data['error'], + data['message'], + data['stacktrace'] if 'stacktrace' in data['error'] else None) + + return data['response'] if 'response' in data else True + finally: + for fname, f in fd.items(): + # logger.debug(f'closing file {fname} (fd={f})') + try: + f.close() + except Exception as exc: + _logger.exception(exc) + pass + + def _make_request_in_thread(self, name, params, method, files): + try: + result = self._make_request(name, params, method, files) + self._report_async_success(result, name, RequestParams(params=params, method=method, files=files)) + except Exception as e: + _logger.exception(e) + self._report_async_error(e, name, RequestParams(params=params, method=method, files=files)) + + def enable_async(self, + success_handler: Optional[Callable] = None, + error_handler: Optional[Callable] = None): + self.do_async = True + if error_handler: + self.async_error_handler = error_handler + if success_handler: + self.async_success_handler = success_handler + + def _report_async_error(self, *args): + if self.async_error_handler: + self.async_error_handler(*args) + + def _report_async_success(self, *args): + if self.async_success_handler: + self.async_success_handler(*args) \ No newline at end of file diff --git a/py_include/homekit/audio/__init__.py b/py_include/homekit/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/py_include/homekit/audio/amixer.py b/py_include/homekit/audio/amixer.py new file mode 100644 index 0000000..5133c97 --- /dev/null +++ b/py_include/homekit/audio/amixer.py @@ -0,0 +1,91 @@ +import subprocess + +from ..config import app_config as config +from threading import Lock +from typing import Union, List + + +_lock = Lock() +_default_step = 5 + + +def has_control(s: str) -> bool: + for control in config['amixer']['controls']: + if control['name'] == s: + return True + return False + + +def get_caps(s: str) -> List[str]: + for control in config['amixer']['controls']: + if control['name'] == s: + return control['caps'] + raise KeyError(f'control {s} not found') + + +def get_all() -> list: + controls = [] + for control in config['amixer']['controls']: + controls.append({ + 'name': control['name'], + 'info': get(control['name']), + 'caps': control['caps'] + }) + return controls + + +def get(control: str): + return call('get', control) + + +def mute(control): + return call('set', control, 'mute') + + +def unmute(control): + return call('set', control, 'unmute') + + +def cap(control): + return call('set', control, 'cap') + + +def nocap(control): + return call('set', control, 'nocap') + + +def _get_default_step() -> int: + if 'step' in config['amixer']: + return int(config['amixer']['step']) + + return _default_step + + +def incr(control, step=None): + if step is None: + step = _get_default_step() + return call('set', control, f'{step}%+') + + +def decr(control, step=None): + if step is None: + step = _get_default_step() + return call('set', control, f'{step}%-') + + +def call(*args, return_code=False) -> Union[int, str]: + with _lock: + result = subprocess.run([config['amixer']['bin'], *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + if return_code: + return result.returncode + + if result.returncode != 0: + raise AmixerError(result.stderr.decode().strip()) + + return result.stdout.decode().strip() + + +class AmixerError(OSError): + pass diff --git a/py_include/homekit/camera/__init__.py b/py_include/homekit/camera/__init__.py new file mode 100644 index 0000000..626930b --- /dev/null +++ b/py_include/homekit/camera/__init__.py @@ -0,0 +1 @@ +from .types import CameraType \ No newline at end of file diff --git a/py_include/homekit/camera/esp32.py b/py_include/homekit/camera/esp32.py new file mode 100644 index 0000000..fe6de0e --- /dev/null +++ b/py_include/homekit/camera/esp32.py @@ -0,0 +1,226 @@ +import logging +import requests +import json +import asyncio +import aioshutil + +from io import BytesIO +from functools import partial +from typing import Union, Optional +from enum import Enum +from ..api.errors import ApiResponseError +from ..util import Addr + + +class FrameSize(Enum): + UXGA_1600x1200 = 13 + SXGA_1280x1024 = 12 + HD_1280x720 = 11 + XGA_1024x768 = 10 + SVGA_800x600 = 9 + VGA_640x480 = 8 + HVGA_480x320 = 7 + CIF_400x296 = 6 + QVGA_320x240 = 5 + N_240x240 = 4 + HQVGA_240x176 = 3 + QCIF_176x144 = 2 + QQVGA_160x120 = 1 + N_96x96 = 0 + + +class WBMode(Enum): + AUTO = 0 + SUNNY = 1 + CLOUDY = 2 + OFFICE = 3 + HOME = 4 + + +def _assert_bounds(n: int, min: int, max: int): + if not min <= n <= max: + raise ValueError(f'value must be between {min} and {max}') + + +class WebClient: + def __init__(self, + addr: Addr): + self.endpoint = f'http://{addr[0]}:{addr[1]}' + self.logger = logging.getLogger(self.__class__.__name__) + self.delay = 0 + self.isfirstrequest = True + + async def syncsettings(self, settings) -> bool: + status = await self.getstatus() + self.logger.debug(f'syncsettings: status={status}') + + changed_anything = False + + for name, value in settings.items(): + server_name = name + if name == 'aec_dsp': + server_name = 'aec2' + + if server_name not in status: + # legacy compatibility + if server_name != 'vflip': + self.logger.warning(f'syncsettings: field `{server_name}` not found in camera status') + continue + + try: + # server returns 0 or 1 for bool values + if type(value) is bool: + value = int(value) + + if status[server_name] == value: + continue + except KeyError as exc: + if name != 'vflip': + self.logger.error(exc) + + try: + # fix for cases like when field is called raw_gma, but method is setrawgma() + name = name.replace('_', '') + + func = getattr(self, f'set{name}') + self.logger.debug(f'syncsettings: calling set{name}({value})') + + await func(value) + + changed_anything = True + except AttributeError as exc: + self.logger.exception(exc) + self.logger.error(f'syncsettings: method set{name}() not found') + + return changed_anything + + def setdelay(self, delay: int): + self.delay = delay + + async def capture(self, output: Optional[str] = None) -> Union[BytesIO, bool]: + kw = {} + if output: + kw['save_to'] = output + else: + kw['as_bytes'] = True + return await self._call('capture', **kw) + + async def getstatus(self): + return json.loads(await self._call('status')) + + async def setflash(self, enable: bool): + await self._control('flash', int(enable)) + + async def setframesize(self, fs: Union[int, FrameSize]): + if type(fs) is int: + fs = FrameSize(fs) + await self._control('framesize', fs.value) + + async def sethmirror(self, enable: bool): + await self._control('hmirror', int(enable)) + + async def setvflip(self, enable: bool): + await self._control('vflip', int(enable)) + + async def setawb(self, enable: bool): + await self._control('awb', int(enable)) + + async def setawbgain(self, enable: bool): + await self._control('awb_gain', int(enable)) + + async def setwbmode(self, mode: WBMode): + await self._control('wb_mode', mode.value) + + async def setaecsensor(self, enable: bool): + await self._control('aec', int(enable)) + + async def setaecdsp(self, enable: bool): + await self._control('aec2', int(enable)) + + async def setagc(self, enable: bool): + await self._control('agc', int(enable)) + + async def setagcgain(self, gain: int): + _assert_bounds(gain, 1, 31) + await self._control('agc_gain', gain) + + async def setgainceiling(self, gainceiling: int): + _assert_bounds(gainceiling, 2, 128) + await self._control('gainceiling', gainceiling) + + async def setbpc(self, enable: bool): + await self._control('bpc', int(enable)) + + async def setwpc(self, enable: bool): + await self._control('wpc', int(enable)) + + async def setrawgma(self, enable: bool): + await self._control('raw_gma', int(enable)) + + async def setlenscorrection(self, enable: bool): + await self._control('lenc', int(enable)) + + async def setdcw(self, enable: bool): + await self._control('dcw', int(enable)) + + async def setcolorbar(self, enable: bool): + await self._control('colorbar', int(enable)) + + async def setquality(self, q: int): + _assert_bounds(q, 4, 63) + await self._control('quality', q) + + async def setbrightness(self, brightness: int): + _assert_bounds(brightness, -2, -2) + await self._control('brightness', brightness) + + async def setcontrast(self, contrast: int): + _assert_bounds(contrast, -2, 2) + await self._control('contrast', contrast) + + async def setsaturation(self, saturation: int): + _assert_bounds(saturation, -2, 2) + await self._control('saturation', saturation) + + async def _control(self, var: str, value: Union[int, str]): + return await self._call('control', params={'var': var, 'val': value}) + + async def _call(self, + method: str, + params: Optional[dict] = None, + save_to: Optional[str] = None, + as_bytes=False) -> Union[str, bool, BytesIO]: + loop = asyncio.get_event_loop() + + if not self.isfirstrequest and self.delay > 0: + sleeptime = self.delay / 1000 + self.logger.debug(f'sleeping for {sleeptime}') + + await asyncio.sleep(sleeptime) + + self.isfirstrequest = False + + url = f'{self.endpoint}/{method}' + self.logger.debug(f'calling {url}, params: {params}') + + kwargs = {} + if params: + kwargs['params'] = params + if save_to: + kwargs['stream'] = True + + r = await loop.run_in_executor(None, + partial(requests.get, url, **kwargs)) + if r.status_code != 200: + raise ApiResponseError(status_code=r.status_code) + + if as_bytes: + return BytesIO(r.content) + + if save_to: + r.raise_for_status() + with open(save_to, 'wb') as f: + await aioshutil.copyfileobj(r.raw, f) + return True + + return r.text diff --git a/py_include/homekit/camera/types.py b/py_include/homekit/camera/types.py new file mode 100644 index 0000000..de59022 --- /dev/null +++ b/py_include/homekit/camera/types.py @@ -0,0 +1,5 @@ +from enum import Enum + + +class CameraType(Enum): + ESP32 = 'esp32' diff --git a/py_include/homekit/camera/util.py b/py_include/homekit/camera/util.py new file mode 100644 index 0000000..97f35aa --- /dev/null +++ b/py_include/homekit/camera/util.py @@ -0,0 +1,107 @@ +import asyncio +import os.path +import logging +import psutil + +from typing import List, Tuple +from ..util import chunks +from ..config import config + +_logger = logging.getLogger(__name__) +_temporary_fixing = '.temporary_fixing.mp4' + + +def _get_ffmpeg_path() -> str: + return 'ffmpeg' if 'ffmpeg' not in config else config['ffmpeg']['path'] + + +def time2seconds(time: str) -> int: + time, frac = time.split('.') + frac = int(frac) + + h, m, s = [int(i) for i in time.split(':')] + + return round(s + m*60 + h*3600 + frac/1000) + + +async def ffmpeg_recreate(filename: str): + filedir = os.path.dirname(filename) + tempname = os.path.join(filedir, _temporary_fixing) + mtime = os.path.getmtime(filename) + + args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname] + proc = await asyncio.create_subprocess_exec(*args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + _logger.error(f'fix_timestamps({filename}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}') + + if os.path.isfile(tempname): + os.unlink(filename) + os.rename(tempname, filename) + os.utime(filename, (mtime, mtime)) + _logger.info(f'fix_timestamps({filename}): OK') + else: + _logger.error(f'fix_timestamps({filename}): temp file \'{tempname}\' does not exists, fix failed') + + +async def ffmpeg_cut(input: str, + output: str, + start_pos: int, + duration: int): + args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', input, + '-ss', str(start_pos), '-t', str(duration), + '-c', 'copy', '-y', output] + proc = await asyncio.create_subprocess_exec(*args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + _logger.error(f'ffmpeg_cut({input}, start_pos={start_pos}, duration={duration}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}') + else: + _logger.info(f'ffmpeg_cut({input}): OK') + + +def dvr_scan_timecodes(timecodes: str) -> List[Tuple[int, int]]: + tc_backup = timecodes + + timecodes = timecodes.split(',') + if len(timecodes) % 2 != 0: + raise DVRScanInvalidTimecodes(f'invalid number of timecodes. input: {tc_backup}') + + timecodes = list(map(time2seconds, timecodes)) + timecodes = list(chunks(timecodes, 2)) + + # sort out invalid fragments (dvr-scan returns them sometimes, idk why...) + timecodes = list(filter(lambda f: f[0] < f[1], timecodes)) + if not timecodes: + raise DVRScanInvalidTimecodes(f'no valid timecodes. input: {tc_backup}') + + # https://stackoverflow.com/a/43600953 + timecodes.sort(key=lambda interval: interval[0]) + merged = [timecodes[0]] + for current in timecodes: + previous = merged[-1] + if current[0] <= previous[1]: + previous[1] = max(previous[1], current[1]) + else: + merged.append(current) + + return merged + + +class DVRScanInvalidTimecodes(Exception): + pass + + +def has_handle(fpath): + for proc in psutil.process_iter(): + try: + for item in proc.open_files(): + if fpath == item.path: + return True + except Exception: + pass + + return False \ No newline at end of file diff --git a/py_include/homekit/config/__init__.py b/py_include/homekit/config/__init__.py new file mode 100644 index 0000000..2fa5214 --- /dev/null +++ b/py_include/homekit/config/__init__.py @@ -0,0 +1,13 @@ +from .config import ( + Config, + ConfigUnit, + AppConfigUnit, + Translation, + config, + is_development_mode, + setup_logging +) +from ._configs import ( + LinuxBoardsConfig, + ServicesListConfig +) \ No newline at end of file diff --git a/py_include/homekit/config/_configs.py b/py_include/homekit/config/_configs.py new file mode 100644 index 0000000..1628cba --- /dev/null +++ b/py_include/homekit/config/_configs.py @@ -0,0 +1,55 @@ +from .config import ConfigUnit +from typing import Optional + + +class ServicesListConfig(ConfigUnit): + NAME = 'services_list' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'type': 'list', + 'empty': False, + 'schema': { + 'type': 'string' + } + } + + +class LinuxBoardsConfig(ConfigUnit): + NAME = 'linux_boards' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'type': 'dict', + 'schema': { + 'mdns': {'type': 'string', 'required': True}, + 'board': {'type': 'string', 'required': True}, + 'network': { + 'type': 'list', + 'required': True, + 'empty': False, + 'allowed': ['wifi', 'ethernet'] + }, + 'ram': {'type': 'integer', 'required': True}, + 'online': {'type': 'boolean', 'required': True}, + + # optional + 'services': { + 'type': 'list', + 'empty': False, + 'allowed': ServicesListConfig().get() + }, + 'ext_hdd': { + 'type': 'list', + 'schema': { + 'type': 'dict', + 'schema': { + 'mountpoint': {'type': 'string', 'required': True}, + 'size': {'type': 'integer', 'required': True} + } + }, + }, + } + } diff --git a/py_include/homekit/config/config.py b/py_include/homekit/config/config.py new file mode 100644 index 0000000..7344386 --- /dev/null +++ b/py_include/homekit/config/config.py @@ -0,0 +1,387 @@ +import yaml +import logging +import os +import cerberus +import cerberus.errors + +from abc import ABC +from typing import Optional, Any, MutableMapping, Union +from argparse import ArgumentParser +from enum import Enum, auto +from os.path import join, isdir, isfile +from ..util import Addr + + +class MyValidator(cerberus.Validator): + def _normalize_coerce_addr(self, value): + return Addr.fromstring(value) + + +MyValidator.types_mapping['addr'] = cerberus.TypeDefinition('Addr', (Addr,), ()) + + +CONFIG_DIRECTORIES = ( + join(os.environ['HOME'], '.config', 'homekit'), + '/etc/homekit' +) + + +class RootSchemaType(Enum): + DEFAULT = auto() + DICT = auto() + LIST = auto() + + +class BaseConfigUnit(ABC): + _data: MutableMapping[str, Any] + _logger: logging.Logger + + def __init__(self): + self._data = {} + self._logger = logging.getLogger(self.__class__.__name__) + + def __getitem__(self, key): + return self._data[key] + + def __setitem__(self, key, value): + raise NotImplementedError('overwriting config values is prohibited') + + def __contains__(self, key): + return key in self._data + + def load_from(self, path: str): + with open(path, 'r') as fd: + self._data = yaml.safe_load(fd) + + def get(self, + key: Optional[str] = None, + default=None): + if key is None: + return self._data + + cur = self._data + pts = key.split('.') + for i in range(len(pts)): + k = pts[i] + if i < len(pts)-1: + if k not in cur: + raise KeyError(f'key {k} not found') + else: + return cur[k] if k in cur else default + cur = self._data[k] + + raise KeyError(f'option {key} not found') + + +class ConfigUnit(BaseConfigUnit): + NAME = 'dumb' + + def __init__(self, name=None, load=True): + super().__init__() + + self._data = {} + self._logger = logging.getLogger(self.__class__.__name__) + + if self.NAME != 'dumb' and load: + self.load_from(self.get_config_path()) + self.validate() + + elif name is not None: + self.NAME = name + + @classmethod + def get_config_path(cls, name=None) -> str: + if name is None: + name = cls.NAME + if name is None: + raise ValueError('get_config_path: name is none') + + for dirname in CONFIG_DIRECTORIES: + if isdir(dirname): + filename = join(dirname, f'{name}.yaml') + if isfile(filename): + return filename + + raise IOError(f'\'{name}.yaml\' not found') + + @classmethod + def schema(cls) -> Optional[dict]: + return None + + @classmethod + def _addr_schema(cls, required=False, **kwargs): + return { + 'type': 'addr', + 'coerce': Addr.fromstring, + 'required': required, + **kwargs + } + + def validate(self): + schema = self.schema() + if not schema: + self._logger.warning('validate: no schema') + return + + if isinstance(self, AppConfigUnit): + schema['logging'] = { + 'type': 'dict', + 'schema': { + 'logging': {'type': 'boolean'} + } + } + + rst = RootSchemaType.DEFAULT + try: + if schema['type'] == 'dict': + rst = RootSchemaType.DICT + elif schema['type'] == 'list': + rst = RootSchemaType.LIST + elif schema['roottype'] == 'dict': + del schema['roottype'] + rst = RootSchemaType.DICT + except KeyError: + pass + + v = MyValidator() + + if rst == RootSchemaType.DICT: + normalized = v.validated({'document': self._data}, + {'document': { + 'type': 'dict', + 'keysrules': {'type': 'string'}, + 'valuesrules': schema + }})['document'] + elif rst == RootSchemaType.LIST: + v = MyValidator() + normalized = v.validated({'document': self._data}, {'document': schema})['document'] + else: + normalized = v.validated(self._data, schema) + + self._data = normalized + + try: + self.custom_validator(self._data) + except Exception as e: + raise cerberus.DocumentError(f'{self.__class__.__name__}: {str(e)}') + + @staticmethod + def custom_validator(data): + pass + + def get_addr(self, key: str): + return Addr.fromstring(self.get(key)) + + +class AppConfigUnit(ConfigUnit): + _logging_verbose: bool + _logging_fmt: Optional[str] + _logging_file: Optional[str] + + def __init__(self, *args, **kwargs): + super().__init__(load=False, *args, **kwargs) + self._logging_verbose = False + self._logging_fmt = None + self._logging_file = None + + def logging_set_fmt(self, fmt: str) -> None: + self._logging_fmt = fmt + + def logging_get_fmt(self) -> Optional[str]: + try: + return self['logging']['default_fmt'] + except KeyError: + return self._logging_fmt + + def logging_set_file(self, file: str) -> None: + self._logging_file = file + + def logging_get_file(self) -> Optional[str]: + try: + return self['logging']['file'] + except KeyError: + return self._logging_file + + def logging_set_verbose(self): + self._logging_verbose = True + + def logging_is_verbose(self) -> bool: + try: + return bool(self['logging']['verbose']) + except KeyError: + return self._logging_verbose + + +class TranslationUnit(BaseConfigUnit): + pass + + +class Translation: + LANGUAGES = ('en', 'ru') + _langs: dict[str, TranslationUnit] + + def __init__(self, name: str): + super().__init__() + self._langs = {} + for lang in self.LANGUAGES: + for dirname in CONFIG_DIRECTORIES: + if isdir(dirname): + filename = join(dirname, f'i18n-{lang}', f'{name}.yaml') + if lang in self._langs: + raise RuntimeError(f'{name}: translation unit for lang \'{lang}\' already loaded') + self._langs[lang] = TranslationUnit() + self._langs[lang].load_from(filename) + diff = set() + for data in self._langs.values(): + diff ^= data.get().keys() + if len(diff) > 0: + raise RuntimeError(f'{name}: translation units have difference in keys: ' + ', '.join(diff)) + + def get(self, lang: str) -> TranslationUnit: + return self._langs[lang] + + +class Config: + app_name: Optional[str] + app_config: AppConfigUnit + + def __init__(self): + self.app_name = None + self.app_config = AppConfigUnit() + + def load_app(self, + name: Optional[Union[str, AppConfigUnit, bool]] = None, + use_cli=True, + parser: ArgumentParser = None, + no_config=False): + global app_config + + if not no_config \ + and not isinstance(name, str) \ + and not isinstance(name, bool) \ + and issubclass(name, AppConfigUnit) or name == AppConfigUnit: + self.app_name = name.NAME + self.app_config = name() + app_config = self.app_config + else: + self.app_name = name if isinstance(name, str) else None + + if self.app_name is None and not use_cli: + raise RuntimeError('either config name must be none or use_cli must be True') + + no_config = name is False or no_config + path = None + + if use_cli: + if parser is None: + parser = ArgumentParser() + if not no_config: + parser.add_argument('-c', '--config', type=str, required=name is None, + help='Path to the config in TOML or YAML format') + parser.add_argument('-V', '--verbose', action='store_true') + parser.add_argument('--log-file', type=str) + parser.add_argument('--log-default-fmt', action='store_true') + args = parser.parse_args() + + if not no_config and args.config: + path = args.config + + if args.verbose: + self.app_config.logging_set_verbose() + if args.log_file: + self.app_config.logging_set_file(args.log_file) + if args.log_default_fmt: + self.app_config.logging_set_fmt(args.log_default_fmt) + + if not isinstance(name, ConfigUnit): + if not no_config and path is None: + path = ConfigUnit.get_config_path(name=self.app_name) + + if not no_config: + self.app_config.load_from(path) + self.app_config.validate() + + setup_logging(self.app_config.logging_is_verbose(), + self.app_config.logging_get_file(), + self.app_config.logging_get_fmt()) + + if use_cli: + return args + + +config = Config() + + +def is_development_mode() -> bool: + if 'HK_MODE' in os.environ and os.environ['HK_MODE'] == 'dev': + return True + + return ('logging' in config.app_config) and ('verbose' in config.app_config['logging']) and (config.app_config['logging']['verbose'] is True) + + +def setup_logging(verbose=False, log_file=None, default_fmt=None): + logging_level = logging.INFO + if is_development_mode() or verbose: + logging_level = logging.DEBUG + _add_logging_level('TRACE', logging.DEBUG-5) + + log_config = {'level': logging_level} + if not default_fmt: + log_config['format'] = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + + if log_file is not None: + log_config['filename'] = log_file + log_config['encoding'] = 'utf-8' + + logging.basicConfig(**log_config) + + +# https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945 +def _add_logging_level(levelName, levelNum, methodName=None): + """ + Comprehensively adds a new logging level to the `logging` module and the + currently configured logging class. + + `levelName` becomes an attribute of the `logging` module with the value + `levelNum`. `methodName` becomes a convenience method for both `logging` + itself and the class returned by `logging.getLoggerClass()` (usually just + `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is + used. + + To avoid accidental clobberings of existing attributes, this method will + raise an `AttributeError` if the level name is already an attribute of the + `logging` module or if the method name is already present + + Example + ------- + >>> addLoggingLevel('TRACE', logging.DEBUG - 5) + >>> logging.getLogger(__name__).setLevel("TRACE") + >>> logging.getLogger(__name__).trace('that worked') + >>> logging.trace('so did this') + >>> logging.TRACE + 5 + + """ + if not methodName: + methodName = levelName.lower() + + if hasattr(logging, levelName): + raise AttributeError('{} already defined in logging module'.format(levelName)) + if hasattr(logging, methodName): + raise AttributeError('{} already defined in logging module'.format(methodName)) + if hasattr(logging.getLoggerClass(), methodName): + raise AttributeError('{} already defined in logger class'.format(methodName)) + + # This method was inspired by the answers to Stack Overflow post + # http://stackoverflow.com/q/2183233/2988730, especially + # http://stackoverflow.com/a/13638084/2988730 + def logForLevel(self, message, *args, **kwargs): + if self.isEnabledFor(levelNum): + self._log(levelNum, message, args, **kwargs) + def logToRoot(message, *args, **kwargs): + logging.log(levelNum, message, *args, **kwargs) + + logging.addLevelName(levelNum, levelName) + setattr(logging, levelName, levelNum) + setattr(logging.getLoggerClass(), methodName, logForLevel) + setattr(logging, methodName, logToRoot) \ No newline at end of file diff --git a/py_include/homekit/database/__init__.py b/py_include/homekit/database/__init__.py new file mode 100644 index 0000000..b50cbce --- /dev/null +++ b/py_include/homekit/database/__init__.py @@ -0,0 +1,29 @@ +import importlib + +__all__ = [ + 'get_mysql', + 'mysql_now', + 'get_clickhouse', + 'SimpleState', + + 'SensorsDatabase', + 'InverterDatabase', + 'BotsDatabase' +] + + +def __getattr__(name: str): + if name in __all__: + if name.endswith('Database'): + file = name[:-8].lower() + elif 'mysql' in name: + file = 'mysql' + elif 'clickhouse' in name: + file = 'clickhouse' + else: + file = 'simple_state' + + module = importlib.import_module(f'.{file}', __name__) + return getattr(module, name) + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/py_include/homekit/database/__init__.pyi b/py_include/homekit/database/__init__.pyi new file mode 100644 index 0000000..31aae5d --- /dev/null +++ b/py_include/homekit/database/__init__.pyi @@ -0,0 +1,11 @@ +from .mysql import ( + get_mysql as get_mysql, + mysql_now as mysql_now +) +from .clickhouse import get_clickhouse as get_clickhouse + +from simple_state import SimpleState as SimpleState + +from .sensors import SensorsDatabase as SensorsDatabase +from .inverter import InverterDatabase as InverterDatabase +from .bots import BotsDatabase as BotsDatabase diff --git a/py_include/homekit/database/_base.py b/py_include/homekit/database/_base.py new file mode 100644 index 0000000..c01e62b --- /dev/null +++ b/py_include/homekit/database/_base.py @@ -0,0 +1,9 @@ +import os + + +def get_data_root_directory(name: str) -> str: + return os.path.join( + os.environ['HOME'], + '.config', + 'homekit', + 'data') \ No newline at end of file diff --git a/py_include/homekit/database/bots.py b/py_include/homekit/database/bots.py new file mode 100644 index 0000000..cde48b9 --- /dev/null +++ b/py_include/homekit/database/bots.py @@ -0,0 +1,106 @@ +import pytz + +from .mysql import mysql_now, MySQLDatabase, datetime_fmt +from ..api.types import ( + BotType, + SoundSensorLocation +) +from typing import Optional, List, Tuple +from datetime import datetime +from html import escape + + +class OpenwrtLogRecord: + id: int + log_time: datetime + received_time: datetime + text: str + + def __init__(self, id, text, log_time, received_time): + self.id = id + self.text = text + self.log_time = log_time + self.received_time = received_time + + def __repr__(self): + return f"{self.log_time.strftime('%H:%M:%S')} {escape(self.text)}" + + +class BotsDatabase(MySQLDatabase): + def add_request(self, + bot: BotType, + user_id: int, + message: str): + with self.cursor() as cursor: + cursor.execute("INSERT INTO requests_log (user_id, message, bot, time) VALUES (%s, %s, %s, %s)", + (user_id, message, bot.name.lower(), mysql_now())) + self.commit() + + def add_openwrt_logs(self, + lines: List[Tuple[datetime, str]], + access_point: int): + now = datetime.now() + with self.cursor() as cursor: + for line in lines: + time, text = line + cursor.execute("INSERT INTO openwrt (log_time, received_time, text, ap) VALUES (%s, %s, %s, %s)", + (time.strftime(datetime_fmt), now.strftime(datetime_fmt), text, access_point)) + self.commit() + + def add_sound_hits(self, + hits: List[Tuple[SoundSensorLocation, int]], + time: datetime): + with self.cursor() as cursor: + for loc, count in hits: + cursor.execute("INSERT INTO sound_hits (location, `time`, hits) VALUES (%s, %s, %s)", + (loc.name.lower(), time.strftime(datetime_fmt), count)) + self.commit() + + def get_sound_hits(self, + location: SoundSensorLocation, + after: Optional[datetime] = None, + last: Optional[int] = None) -> List[dict]: + with self.cursor(dictionary=True) as cursor: + sql = "SELECT `time`, hits FROM sound_hits WHERE location=%s" + args = [location.name.lower()] + + if after: + sql += ' AND `time` >= %s ORDER BY time DESC' + args.append(after) + elif last: + sql += ' ORDER BY time DESC LIMIT 0, %s' + args.append(last) + else: + raise ValueError('no `after`, no `last`, what do you expect?') + + cursor.execute(sql, tuple(args)) + data = [] + for row in cursor.fetchall(): + data.append({ + 'time': row['time'], + 'hits': row['hits'] + }) + return data + + def get_openwrt_logs(self, + filter_text: str, + min_id: int, + access_point: int, + limit: int = None) -> List[OpenwrtLogRecord]: + tz = pytz.timezone('Europe/Moscow') + with self.cursor(dictionary=True) as cursor: + sql = "SELECT * FROM openwrt WHERE ap=%s AND text LIKE %s AND id > %s" + if limit is not None: + sql += f" LIMIT {limit}" + + cursor.execute(sql, (access_point, f'%{filter_text}%', min_id)) + data = [] + for row in cursor.fetchall(): + data.append(OpenwrtLogRecord( + id=int(row['id']), + text=row['text'], + log_time=row['log_time'].astimezone(tz), + received_time=row['received_time'].astimezone(tz) + )) + + return data diff --git a/py_include/homekit/database/clickhouse.py b/py_include/homekit/database/clickhouse.py new file mode 100644 index 0000000..d0ec283 --- /dev/null +++ b/py_include/homekit/database/clickhouse.py @@ -0,0 +1,39 @@ +import logging + +from zoneinfo import ZoneInfo +from datetime import datetime +from clickhouse_driver import Client as ClickhouseClient +from ..config import is_development_mode + +_links = {} + + +def get_clickhouse(db: str) -> ClickhouseClient: + if db not in _links: + _links[db] = ClickhouseClient.from_url(f'clickhouse://localhost/{db}') + + return _links[db] + + +class ClickhouseDatabase: + def __init__(self, db: str): + self.db = get_clickhouse(db) + + self.server_timezone = self.db.execute('SELECT timezone()')[0][0] + self.logger = logging.getLogger(self.__class__.__name__) + + def query(self, *args, **kwargs): + settings = {'use_client_time_zone': True} + kwargs['settings'] = settings + + if 'no_tz_fix' not in kwargs and len(args) > 1 and isinstance(args[1], dict): + for k, v in args[1].items(): + if isinstance(v, datetime): + args[1][k] = v.astimezone(tz=ZoneInfo(self.server_timezone)) + + result = self.db.execute(*args, **kwargs) + + if is_development_mode(): + self.logger.debug(args[0] if len(args) == 1 else args[0] % args[1]) + + return result diff --git a/py_include/homekit/database/inverter.py b/py_include/homekit/database/inverter.py new file mode 100644 index 0000000..fc3f74f --- /dev/null +++ b/py_include/homekit/database/inverter.py @@ -0,0 +1,212 @@ +from time import time +from datetime import datetime, timedelta +from typing import Optional +from collections import namedtuple + +from .clickhouse import ClickhouseDatabase + + +IntervalList = list[list[Optional[datetime]]] + + +class InverterDatabase(ClickhouseDatabase): + def __init__(self): + super().__init__('solarmon') + + def add_generation(self, home_id: int, client_time: int, watts: int) -> None: + self.db.execute( + 'INSERT INTO generation (ClientTime, ReceivedTime, HomeID, Watts) VALUES', + [[client_time, round(time()), home_id, watts]] + ) + + def add_status(self, home_id: int, + client_time: int, + grid_voltage: int, + grid_freq: int, + ac_output_voltage: int, + ac_output_freq: int, + ac_output_apparent_power: int, + ac_output_active_power: int, + output_load_percent: int, + battery_voltage: int, + battery_voltage_scc: int, + battery_voltage_scc2: int, + battery_discharge_current: int, + battery_charge_current: int, + battery_capacity: int, + inverter_heat_sink_temp: int, + mppt1_charger_temp: int, + mppt2_charger_temp: int, + pv1_input_power: int, + pv2_input_power: int, + pv1_input_voltage: int, + pv2_input_voltage: int, + mppt1_charger_status: int, + mppt2_charger_status: int, + battery_power_direction: int, + dc_ac_power_direction: int, + line_power_direction: int, + load_connected: int) -> None: + self.db.execute("""INSERT INTO status ( + ClientTime, + ReceivedTime, + HomeID, + GridVoltage, + GridFrequency, + ACOutputVoltage, + ACOutputFrequency, + ACOutputApparentPower, + ACOutputActivePower, + OutputLoadPercent, + BatteryVoltage, + BatteryVoltageSCC, + BatteryVoltageSCC2, + BatteryDischargingCurrent, + BatteryChargingCurrent, + BatteryCapacity, + HeatSinkTemp, + MPPT1ChargerTemp, + MPPT2ChargerTemp, + PV1InputPower, + PV2InputPower, + PV1InputVoltage, + PV2InputVoltage, + MPPT1ChargerStatus, + MPPT2ChargerStatus, + BatteryPowerDirection, + DCACPowerDirection, + LinePowerDirection, + LoadConnected) VALUES""", [[ + client_time, + round(time()), + home_id, + grid_voltage, + grid_freq, + ac_output_voltage, + ac_output_freq, + ac_output_apparent_power, + ac_output_active_power, + output_load_percent, + battery_voltage, + battery_voltage_scc, + battery_voltage_scc2, + battery_discharge_current, + battery_charge_current, + battery_capacity, + inverter_heat_sink_temp, + mppt1_charger_temp, + mppt2_charger_temp, + pv1_input_power, + pv2_input_power, + pv1_input_voltage, + pv2_input_voltage, + mppt1_charger_status, + mppt2_charger_status, + battery_power_direction, + dc_ac_power_direction, + line_power_direction, + load_connected + ]]) + + def get_consumed_energy(self, dt_from: datetime, dt_to: datetime) -> float: + rows = self.query('SELECT ClientTime, ACOutputActivePower FROM status' + ' WHERE ClientTime >= %(from)s AND ClientTime <= %(to)s' + ' ORDER BY ClientTime', {'from': dt_from, 'to': dt_to}) + prev_time = None + prev_wh = 0 + + ws = 0 # watt-seconds + for t, wh in rows: + if prev_time is not None: + n = (t - prev_time).total_seconds() + ws += prev_wh * n + + prev_time = t + prev_wh = wh + + return ws / 3600 # convert to watt-hours + + def get_intervals_by_condition(self, + dt_from: datetime, + dt_to: datetime, + cond_start: str, + cond_end: str) -> IntervalList: + rows = None + ranges = [[None, None]] + + while rows is None or len(rows) > 0: + if ranges[len(ranges)-1][0] is None: + condition = cond_start + range_idx = 0 + else: + condition = cond_end + range_idx = 1 + + rows = self.query('SELECT ClientTime FROM status ' + f'WHERE ClientTime > %(from)s AND ClientTime <= %(to)s AND {condition}' + ' ORDER BY ClientTime LIMIT 1', + {'from': dt_from, 'to': dt_to}) + if not rows: + break + + row = rows[0] + + ranges[len(ranges) - 1][range_idx] = row[0] + if range_idx == 1: + ranges.append([None, None]) + + dt_from = row[0] + + if ranges[len(ranges)-1][0] is None: + ranges.pop() + elif ranges[len(ranges)-1][1] is None: + ranges[len(ranges)-1][1] = dt_to - timedelta(seconds=1) + + return ranges + + def get_grid_connected_intervals(self, dt_from: datetime, dt_to: datetime) -> IntervalList: + return self.get_intervals_by_condition(dt_from, dt_to, 'GridFrequency > 0', 'GridFrequency = 0') + + def get_grid_used_intervals(self, dt_from: datetime, dt_to: datetime) -> IntervalList: + return self.get_intervals_by_condition(dt_from, + dt_to, + "LinePowerDirection = 'Input'", + "LinePowerDirection != 'Input'") + + def get_grid_consumed_energy(self, dt_from: datetime, dt_to: datetime) -> float: + PrevData = namedtuple('PrevData', 'time, pd, bat_chg, bat_dis, wh') + + ws = 0 # watt-seconds + amps = 0 # amper-seconds + + intervals = self.get_grid_used_intervals(dt_from, dt_to) + for dt_start, dt_end in intervals: + fields = ', '.join([ + 'ClientTime', + 'DCACPowerDirection', + 'BatteryChargingCurrent', + 'BatteryDischargingCurrent', + 'ACOutputActivePower' + ]) + rows = self.query(f'SELECT {fields} FROM status' + ' WHERE ClientTime >= %(from)s AND ClientTime < %(to)s ORDER BY ClientTime', + {'from': dt_start, 'to': dt_end}) + + prev = PrevData(time=None, pd=None, bat_chg=None, bat_dis=None, wh=None) + for ct, pd, bat_chg, bat_dis, wh in rows: + if prev.time is not None: + n = (ct-prev.time).total_seconds() + ws += prev.wh * n + + if pd == 'DC/AC': + amps -= prev.bat_dis * n + elif pd == 'AC/DC': + amps += prev.bat_chg * n + + prev = PrevData(time=ct, pd=pd, bat_chg=bat_chg, bat_dis=bat_dis, wh=wh) + + amps /= 3600 + wh = ws / 3600 + wh += amps*48 + + return wh diff --git a/py_include/homekit/database/inverter_time_formats.py b/py_include/homekit/database/inverter_time_formats.py new file mode 100644 index 0000000..7c37d30 --- /dev/null +++ b/py_include/homekit/database/inverter_time_formats.py @@ -0,0 +1,2 @@ +FormatTime = '%Y-%m-%d %H:%M:%S' +FormatDate = '%Y-%m-%d' diff --git a/py_include/homekit/database/mysql.py b/py_include/homekit/database/mysql.py new file mode 100644 index 0000000..fe97cd4 --- /dev/null +++ b/py_include/homekit/database/mysql.py @@ -0,0 +1,47 @@ +import time +import logging + +from mysql.connector import connect, MySQLConnection, Error +from typing import Optional +from ..config import config + +link: Optional[MySQLConnection] = None +logger = logging.getLogger(__name__) + +datetime_fmt = '%Y-%m-%d %H:%M:%S' + + +def get_mysql() -> MySQLConnection: + global link + + if link is not None: + return link + + link = connect( + host=config['mysql']['host'], + user=config['mysql']['user'], + password=config['mysql']['password'], + database=config['mysql']['database'], + ) + link.time_zone = '+01:00' + return link + + +def mysql_now() -> str: + return time.strftime('%Y-%m-%d %H:%M:%S') + + +class MySQLDatabase: + def __init__(self): + self.db = get_mysql() + + def cursor(self, **kwargs): + try: + self.db.ping(reconnect=True, attempts=2) + except Error as e: + logger.exception(e) + self.db = get_mysql() + return self.db.cursor(**kwargs) + + def commit(self): + self.db.commit() diff --git a/py_include/homekit/database/sensors.py b/py_include/homekit/database/sensors.py new file mode 100644 index 0000000..8155108 --- /dev/null +++ b/py_include/homekit/database/sensors.py @@ -0,0 +1,69 @@ +from time import time +from datetime import datetime +from typing import Tuple, List +from .clickhouse import ClickhouseDatabase +from ..api.types import TemperatureSensorLocation + + +def get_temperature_table(sensor: TemperatureSensorLocation) -> str: + if sensor == TemperatureSensorLocation.DIANA: + return 'temp_diana' + + elif sensor == TemperatureSensorLocation.STREET: + return 'temp_street' + + elif sensor == TemperatureSensorLocation.BIG_HOUSE_1: + return 'temp' + + elif sensor == TemperatureSensorLocation.BIG_HOUSE_2: + return 'temp_roof' + + elif sensor == TemperatureSensorLocation.BIG_HOUSE_ROOM: + return 'temp_room' + + elif sensor == TemperatureSensorLocation.SPB1: + return 'temp_spb1' + + +class SensorsDatabase(ClickhouseDatabase): + def __init__(self): + super().__init__('home') + + def add_temperature(self, + home_id: int, + client_time: int, + sensor: TemperatureSensorLocation, + temp: int, + rh: int): + table = get_temperature_table(sensor) + sql = """INSERT INTO """ + table + """ ( + ClientTime, + ReceivedTime, + HomeID, + Temperature, + RelativeHumidity + ) VALUES""" + self.db.execute(sql, [[ + client_time, + int(time()), + home_id, + temp, + rh + ]]) + + def get_temperature_recordings(self, + sensor: TemperatureSensorLocation, + time_range: Tuple[datetime, datetime], + home_id=1) -> List[tuple]: + table = get_temperature_table(sensor) + sql = f"""SELECT ClientTime, Temperature, RelativeHumidity + FROM {table} + WHERE ClientTime >= %(from)s AND ClientTime <= %(to)s + ORDER BY ClientTime""" + dt_from, dt_to = time_range + + data = self.query(sql, { + 'from': dt_from, + 'to': dt_to + }) + return [(date, temp/100, humidity/100) for date, temp, humidity in data] diff --git a/py_include/homekit/database/simple_state.py b/py_include/homekit/database/simple_state.py new file mode 100644 index 0000000..2b8ebe7 --- /dev/null +++ b/py_include/homekit/database/simple_state.py @@ -0,0 +1,48 @@ +import os +import json +import atexit + +from ._base import get_data_root_directory + + +class SimpleState: + def __init__(self, + name: str, + default: dict = None): + if default is None: + default = {} + elif type(default) is not dict: + raise TypeError('default must be dictionary') + + path = os.path.join(get_data_root_directory(), name) + if not os.path.exists(path): + self._data = default + else: + with open(path, 'r') as f: + self._data = json.loads(f.read()) + + self._file = path + atexit.register(self.__cleanup) + + def __cleanup(self): + if hasattr(self, '_file'): + with open(self._file, 'w') as f: + f.write(json.dumps(self._data)) + atexit.unregister(self.__cleanup) + + def __del__(self): + if 'open' in __builtins__: + self.__cleanup() + + def __getitem__(self, key): + return self._data[key] + + def __setitem__(self, key, value): + self._data[key] = value + + def __contains__(self, key): + return key in self._data + + def __delitem__(self, key): + if key in self._data: + del self._data[key] diff --git a/py_include/homekit/database/sqlite.py b/py_include/homekit/database/sqlite.py new file mode 100644 index 0000000..0af1f54 --- /dev/null +++ b/py_include/homekit/database/sqlite.py @@ -0,0 +1,67 @@ +import sqlite3 +import os.path +import logging + +from ._base import get_data_root_directory +from ..config import config, is_development_mode + + +def _get_database_path(name: str) -> str: + return os.path.join( + get_data_root_directory(), + f'{name}.db') + + +class SQLiteBase: + SCHEMA = 1 + + def __init__(self, name=None, check_same_thread=False): + if name is None: + name = config.app_config['database_name'] + database_path = _get_database_path(name) + if not os.path.exists(os.path.dirname(database_path)): + os.makedirs(os.path.dirname(database_path)) + + self.logger = logging.getLogger(self.__class__.__name__) + self.sqlite = sqlite3.connect(database_path, check_same_thread=check_same_thread) + + if is_development_mode(): + self.sql_logger = logging.getLogger(self.__class__.__name__) + self.sql_logger.setLevel('TRACE') + self.sqlite.set_trace_callback(self.sql_logger.trace) + + sqlite_version = self._get_sqlite_version() + self.logger.debug(f'SQLite version: {sqlite_version}') + + schema_version = self.schema_get_version() + self.logger.debug(f'Schema version: {schema_version}') + + self.schema_init(schema_version) + self.schema_set_version(self.SCHEMA) + + def __del__(self): + if self.sqlite: + self.sqlite.commit() + self.sqlite.close() + + def _get_sqlite_version(self) -> str: + cursor = self.sqlite.cursor() + cursor.execute("SELECT sqlite_version()") + return cursor.fetchone()[0] + + def schema_get_version(self) -> int: + cursor = self.sqlite.execute('PRAGMA user_version') + return int(cursor.fetchone()[0]) + + def schema_set_version(self, v) -> None: + self.sqlite.execute('PRAGMA user_version={:d}'.format(v)) + self.logger.info(f'Schema set to {v}') + + def cursor(self) -> sqlite3.Cursor: + return self.sqlite.cursor() + + def commit(self) -> None: + return self.sqlite.commit() + + def schema_init(self, version: int) -> None: + raise ValueError(f'{self.__class__.__name__}: must override schema_init') diff --git a/py_include/homekit/http/__init__.py b/py_include/homekit/http/__init__.py new file mode 100644 index 0000000..6030e95 --- /dev/null +++ b/py_include/homekit/http/__init__.py @@ -0,0 +1,2 @@ +from .http import serve, ok, routes, HTTPServer +from aiohttp.web import FileResponse, StreamResponse, Request, Response diff --git a/py_include/homekit/http/http.py b/py_include/homekit/http/http.py new file mode 100644 index 0000000..3e70751 --- /dev/null +++ b/py_include/homekit/http/http.py @@ -0,0 +1,106 @@ +import logging +import asyncio + +from aiohttp import web +from aiohttp.web import Response +from aiohttp.web_exceptions import HTTPNotFound + +from ..util import stringify, format_tb, Addr + + +_logger = logging.getLogger(__name__) + + +@web.middleware +async def errors_handler_middleware(request, handler): + try: + response = await handler(request) + return response + + except HTTPNotFound: + return web.json_response({'error': 'not found'}, status=404) + + except Exception as exc: + _logger.exception(exc) + data = { + 'error': exc.__class__.__name__, + 'message': exc.message if hasattr(exc, 'message') else str(exc) + } + tb = format_tb(exc) + if tb: + data['stacktrace'] = tb + + return web.json_response(data, status=500) + + +def serve(addr: Addr, route_table: web.RouteTableDef, handle_signals: bool = True): + app = web.Application() + app.add_routes(route_table) + app.middlewares.append(errors_handler_middleware) + + host, port = addr + + web.run_app(app, + host=host, + port=port, + handle_signals=handle_signals) + + +def routes() -> web.RouteTableDef: + return web.RouteTableDef() + + +def ok(data=None): + if data is None: + data = 1 + response = {'response': data} + return web.json_response(response, dumps=stringify) + + +class HTTPServer: + def __init__(self, addr: Addr, handle_errors=True): + self.addr = addr + self.app = web.Application() + self.logger = logging.getLogger(self.__class__.__name__) + + if handle_errors: + self.app.middlewares.append(errors_handler_middleware) + + def _add_route(self, + method: str, + path: str, + handler: callable): + self.app.router.add_routes([getattr(web, method)(path, handler)]) + + def get(self, path, handler): + self._add_route('get', path, handler) + + def post(self, path, handler): + self._add_route('post', path, handler) + + def put(self, path, handler): + self._add_route('put', path, handler) + + def delete(self, path, handler): + self._add_route('delete', path, handler) + + def run(self, event_loop=None, handle_signals=True): + if not event_loop: + event_loop = asyncio.get_event_loop() + + runner = web.AppRunner(self.app, handle_signals=handle_signals) + event_loop.run_until_complete(runner.setup()) + + host, port = self.addr + site = web.TCPSite(runner, host=host, port=port) + event_loop.run_until_complete(site.start()) + + self.logger.info(f'Server started at http://{host}:{port}') + + event_loop.run_forever() + + def ok(self, data=None): + return ok(data) + + def plain(self, text: str): + return Response(text=text, content_type='text/plain') diff --git a/py_include/homekit/inverter/__init__.py b/py_include/homekit/inverter/__init__.py new file mode 100644 index 0000000..8831ef3 --- /dev/null +++ b/py_include/homekit/inverter/__init__.py @@ -0,0 +1,3 @@ +from .monitor import InverterMonitor +from .inverter_wrapper import wrapper_instance +from .util import beautify_table diff --git a/py_include/homekit/inverter/config.py b/py_include/homekit/inverter/config.py new file mode 100644 index 0000000..e284dfe --- /dev/null +++ b/py_include/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/py_include/homekit/inverter/emulator.py b/py_include/homekit/inverter/emulator.py new file mode 100644 index 0000000..e86b8bb --- /dev/null +++ b/py_include/homekit/inverter/emulator.py @@ -0,0 +1,556 @@ +import asyncio +import logging + +from inverterd import Format + +from typing import Union +from enum import Enum +from ..util import Addr, stringify + + +class InverterEnum(Enum): + def as_text(self) -> str: + raise RuntimeError('abstract method') + + +class BatteryType(InverterEnum): + AGM = 0 + Flooded = 1 + User = 2 + + def as_text(self) -> str: + return ('AGM', 'Flooded', 'User')[self.value] + + +class InputVoltageRange(InverterEnum): + Appliance = 0 + USP = 1 + + def as_text(self) -> str: + return ('Appliance', 'USP')[self.value] + + +class OutputSourcePriority(InverterEnum): + SolarUtilityBattery = 0 + SolarBatteryUtility = 1 + + def as_text(self) -> str: + return ('Solar-Utility-Battery', 'Solar-Battery-Utility')[self.value] + + +class ChargeSourcePriority(InverterEnum): + SolarFirst = 0 + SolarAndUtility = 1 + SolarOnly = 2 + + def as_text(self) -> str: + return ('Solar-First', 'Solar-and-Utility', 'Solar-only')[self.value] + + +class MachineType(InverterEnum): + OffGridTie = 0 + GridTie = 1 + + def as_text(self) -> str: + return ('Off-Grid-Tie', 'Grid-Tie')[self.value] + + +class Topology(InverterEnum): + TransformerLess = 0 + Transformer = 1 + + def as_text(self) -> str: + return ('Transformer-less', 'Transformer')[self.value] + + +class OutputMode(InverterEnum): + SingleOutput = 0 + ParallelOutput = 1 + Phase_1_of_3 = 2 + Phase_2_of_3 = 3 + Phase_3_of_3 = 4 + + def as_text(self) -> str: + return ( + 'Single output', + 'Parallel output', + 'Phase 1 of 3-phase output', + 'Phase 2 of 3-phase output', + 'Phase 3 of 3-phase' + )[self.value] + + +class SolarPowerPriority(InverterEnum): + BatteryLoadUtility = 0 + LoadBatteryUtility = 1 + + def as_text(self) -> str: + return ('Battery-Load-Utility', 'Load-Battery-Utility')[self.value] + + +class MPPTChargerStatus(InverterEnum): + Abnormal = 0 + NotCharging = 1 + Charging = 2 + + def as_text(self) -> str: + return ('Abnormal', 'Not charging', 'Charging')[self.value] + + +class BatteryPowerDirection(InverterEnum): + DoNothing = 0 + Charge = 1 + Discharge = 2 + + def as_text(self) -> str: + return ('Do nothing', 'Charge', 'Discharge')[self.value] + + +class DC_AC_PowerDirection(InverterEnum): + DoNothing = 0 + AC_DC = 1 + DC_AC = 2 + + def as_text(self) -> str: + return ('Do nothing', 'AC/DC', 'DC/AC')[self.value] + + +class LinePowerDirection(InverterEnum): + DoNothing = 0 + Input = 1 + Output = 2 + + def as_text(self) -> str: + return ('Do nothing', 'Input', 'Output')[self.value] + + +class WorkingMode(InverterEnum): + PowerOnMode = 0 + StandbyMode = 1 + BypassMode = 2 + BatteryMode = 3 + FaultMode = 4 + HybridMode = 5 + + def as_text(self) -> str: + return ( + 'Power on mode', + 'Standby mode', + 'Bypass mode', + 'Battery mode', + 'Fault mode', + 'Hybrid mode' + )[self.value] + + +class ParallelConnectionStatus(InverterEnum): + NotExistent = 0 + Existent = 1 + + def as_text(self) -> str: + return ('Non-existent', 'Existent')[self.value] + + +class LoadConnectionStatus(InverterEnum): + Disconnected = 0 + Connected = 1 + + def as_text(self) -> str: + return ('Disconnected', 'Connected')[self.value] + + +class ConfigurationStatus(InverterEnum): + Default = 0 + Changed = 1 + + def as_text(self) -> str: + return ('Default', 'Changed')[self.value] + + +_g_human_readable = {"grid_voltage": "Grid voltage", + "grid_freq": "Grid frequency", + "ac_output_voltage": "AC output voltage", + "ac_output_freq": "AC output frequency", + "ac_output_apparent_power": "AC output apparent power", + "ac_output_active_power": "AC output active power", + "output_load_percent": "Output load percent", + "battery_voltage": "Battery voltage", + "battery_voltage_scc": "Battery voltage from SCC", + "battery_voltage_scc2": "Battery voltage from SCC2", + "battery_discharge_current": "Battery discharge current", + "battery_charge_current": "Battery charge current", + "battery_capacity": "Battery capacity", + "inverter_heat_sink_temp": "Inverter heat sink temperature", + "mppt1_charger_temp": "MPPT1 charger temperature", + "mppt2_charger_temp": "MPPT2 charger temperature", + "pv1_input_power": "PV1 input power", + "pv2_input_power": "PV2 input power", + "pv1_input_voltage": "PV1 input voltage", + "pv2_input_voltage": "PV2 input voltage", + "configuration_status": "Configuration state", + "mppt1_charger_status": "MPPT1 charger status", + "mppt2_charger_status": "MPPT2 charger status", + "load_connected": "Load connection", + "battery_power_direction": "Battery power direction", + "dc_ac_power_direction": "DC/AC power direction", + "line_power_direction": "Line power direction", + "local_parallel_id": "Local parallel ID", + "ac_input_rating_voltage": "AC input rating voltage", + "ac_input_rating_current": "AC input rating current", + "ac_output_rating_voltage": "AC output rating voltage", + "ac_output_rating_freq": "AC output rating frequency", + "ac_output_rating_current": "AC output rating current", + "ac_output_rating_apparent_power": "AC output rating apparent power", + "ac_output_rating_active_power": "AC output rating active power", + "battery_rating_voltage": "Battery rating voltage", + "battery_recharge_voltage": "Battery re-charge voltage", + "battery_redischarge_voltage": "Battery re-discharge voltage", + "battery_under_voltage": "Battery under voltage", + "battery_bulk_voltage": "Battery bulk voltage", + "battery_float_voltage": "Battery float voltage", + "battery_type": "Battery type", + "max_charge_current": "Max charge current", + "max_ac_charge_current": "Max AC charge current", + "input_voltage_range": "Input voltage range", + "output_source_priority": "Output source priority", + "charge_source_priority": "Charge source priority", + "parallel_max_num": "Parallel max num", + "machine_type": "Machine type", + "topology": "Topology", + "output_mode": "Output mode", + "solar_power_priority": "Solar power priority", + "mppt": "MPPT string", + "fault_code": "Fault code", + "line_fail": "Line fail", + "output_circuit_short": "Output circuit short", + "inverter_over_temperature": "Inverter over temperature", + "fan_lock": "Fan lock", + "battery_voltage_high": "Battery voltage high", + "battery_low": "Battery low", + "battery_under": "Battery under", + "over_load": "Over load", + "eeprom_fail": "EEPROM fail", + "power_limit": "Power limit", + "pv1_voltage_high": "PV1 voltage high", + "pv2_voltage_high": "PV2 voltage high", + "mppt1_overload_warning": "MPPT1 overload warning", + "mppt2_overload_warning": "MPPT2 overload warning", + "battery_too_low_to_charge_for_scc1": "Battery too low to charge for SCC1", + "battery_too_low_to_charge_for_scc2": "Battery too low to charge for SCC2", + "buzzer": "Buzzer", + "overload_bypass": "Overload bypass function", + "escape_to_default_screen_after_1min_timeout": "Escape to default screen after 1min timeout", + "overload_restart": "Overload restart", + "over_temp_restart": "Over temperature restart", + "backlight_on": "Backlight on", + "alarm_on_on_primary_source_interrupt": "Alarm on on primary source interrupt", + "fault_code_record": "Fault code record", + "wh": "Wh"} + + +class InverterEmulator: + def __init__(self, addr: Addr, wait=True): + self.status = {"grid_voltage": {"unit": "V", "value": 236.3}, + "grid_freq": {"unit": "Hz", "value": 50.0}, + "ac_output_voltage": {"unit": "V", "value": 229.9}, + "ac_output_freq": {"unit": "Hz", "value": 50.0}, + "ac_output_apparent_power": {"unit": "VA", "value": 207}, + "ac_output_active_power": {"unit": "Wh", "value": 146}, + "output_load_percent": {"unit": "%", "value": 4}, + "battery_voltage": {"unit": "V", "value": 49.1}, + "battery_voltage_scc": {"unit": "V", "value": 0.0}, + "battery_voltage_scc2": {"unit": "V", "value": 0.0}, + "battery_discharge_current": {"unit": "A", "value": 3}, + "battery_charge_current": {"unit": "A", "value": 0}, + "battery_capacity": {"unit": "%", "value": 69}, + "inverter_heat_sink_temp": {"unit": "°C", "value": 17}, + "mppt1_charger_temp": {"unit": "°C", "value": 0}, + "mppt2_charger_temp": {"unit": "°C", "value": 0}, + "pv1_input_power": {"unit": "Wh", "value": 0}, + "pv2_input_power": {"unit": "Wh", "value": 0}, + "pv1_input_voltage": {"unit": "V", "value": 0.0}, + "pv2_input_voltage": {"unit": "V", "value": 0.0}, + "configuration_status": ConfigurationStatus.Default, + "mppt1_charger_status": MPPTChargerStatus.Abnormal, + "mppt2_charger_status": MPPTChargerStatus.Abnormal, + "load_connected": LoadConnectionStatus.Connected, + "battery_power_direction": BatteryPowerDirection.Discharge, + "dc_ac_power_direction": DC_AC_PowerDirection.DC_AC, + "line_power_direction": LinePowerDirection.DoNothing, + "local_parallel_id": 0} + + self.rated = {"ac_input_rating_voltage": {"unit": "V", "value": 230.0}, + "ac_input_rating_current": {"unit": "A", "value": 21.7}, + "ac_output_rating_voltage": {"unit": "V", "value": 230.0}, + "ac_output_rating_freq": {"unit": "Hz", "value": 50.0}, + "ac_output_rating_current": {"unit": "A", "value": 21.7}, + "ac_output_rating_apparent_power": {"unit": "VA", "value": 5000}, + "ac_output_rating_active_power": {"unit": "Wh", "value": 5000}, + "battery_rating_voltage": {"unit": "V", "value": 48.0}, + "battery_recharge_voltage": {"unit": "V", "value": 48.0}, + "battery_redischarge_voltage": {"unit": "V", "value": 55.0}, + "battery_under_voltage": {"unit": "V", "value": 42.0}, + "battery_bulk_voltage": {"unit": "V", "value": 57.6}, + "battery_float_voltage": {"unit": "V", "value": 54.0}, + "battery_type": BatteryType.User, + "max_charge_current": {"unit": "A", "value": 60}, + "max_ac_charge_current": {"unit": "A", "value": 30}, + "input_voltage_range": InputVoltageRange.Appliance, + "output_source_priority": OutputSourcePriority.SolarBatteryUtility, + "charge_source_priority": ChargeSourcePriority.SolarAndUtility, + "parallel_max_num": 6, + "machine_type": MachineType.OffGridTie, + "topology": Topology.TransformerLess, + "output_mode": OutputMode.SingleOutput, + "solar_power_priority": SolarPowerPriority.LoadBatteryUtility, + "mppt": "2"} + + self.errors = {"fault_code": 0, + "line_fail": False, + "output_circuit_short": False, + "inverter_over_temperature": False, + "fan_lock": False, + "battery_voltage_high": False, + "battery_low": False, + "battery_under": False, + "over_load": False, + "eeprom_fail": False, + "power_limit": False, + "pv1_voltage_high": False, + "pv2_voltage_high": False, + "mppt1_overload_warning": False, + "mppt2_overload_warning": False, + "battery_too_low_to_charge_for_scc1": False, + "battery_too_low_to_charge_for_scc2": False} + + self.flags = {"buzzer": False, + "overload_bypass": True, + "escape_to_default_screen_after_1min_timeout": False, + "overload_restart": True, + "over_temp_restart": True, + "backlight_on": False, + "alarm_on_on_primary_source_interrupt": True, + "fault_code_record": False} + + self.day_generated = 1000 + + self.logger = logging.getLogger(self.__class__.__name__) + + host, port = addr + asyncio.run(self.run_server(host, port, wait)) + # self.max_ac_charge_current = 30 + # self.max_charge_current = 60 + # self.charge_thresholds = [48, 54] + + async def run_server(self, host, port, wait: bool): + server = await asyncio.start_server(self.client_handler, host, port) + async with server: + self.logger.info(f'listening on {host}:{port}') + if wait: + await server.serve_forever() + else: + asyncio.ensure_future(server.serve_forever()) + + async def client_handler(self, reader, writer): + client_fmt = Format.JSON + + def w(s: str): + writer.write(s.encode('utf-8')) + + def return_error(message=None): + w('err\r\n') + if message: + if client_fmt in (Format.JSON, Format.SIMPLE_JSON): + w(stringify({ + 'result': 'error', + 'message': message + })) + elif client_fmt in (Format.TABLE, Format.SIMPLE_TABLE): + w(f'error: {message}') + w('\r\n') + w('\r\n') + + def return_ok(data=None): + w('ok\r\n') + if client_fmt in (Format.JSON, Format.SIMPLE_JSON): + jdata = { + 'result': 'ok' + } + if data: + jdata['data'] = data + w(stringify(jdata)) + w('\r\n') + elif data: + w(data) + w('\r\n') + w('\r\n') + + request = None + while request != 'quit': + try: + request = await reader.read(255) + if request == b'\x04': + break + request = request.decode('utf-8').strip() + except Exception: + break + + if request.startswith('format '): + requested_format = request[7:] + try: + client_fmt = Format(requested_format) + except ValueError: + return_error('invalid format') + + return_ok() + + elif request.startswith('exec '): + buf = request[5:].split(' ') + command = buf[0] + args = buf[1:] + + try: + return_ok(self.process_command(client_fmt, command, *args)) + except ValueError as e: + return_error(str(e)) + + else: + return_error(f'invalid token: {request}') + + try: + await writer.drain() + except ConnectionResetError as e: + # self.logger.exception(e) + pass + + writer.close() + + def process_command(self, fmt: Format, c: str, *args) -> Union[dict, str, list[int], None]: + ac_charge_currents = [2, 10, 20, 30, 40, 50, 60] + + if c == 'get-status': + return self.format_dict(self.status, fmt) + + elif c == 'get-rated': + return self.format_dict(self.rated, fmt) + + elif c == 'get-errors': + return self.format_dict(self.errors, fmt) + + elif c == 'get-flags': + return self.format_dict(self.flags, fmt) + + elif c == 'get-day-generated': + return self.format_dict({'wh': 1000}, fmt) + + elif c == 'get-allowed-ac-charge-currents': + return self.format_list(ac_charge_currents, fmt) + + elif c == 'set-max-ac-charge-current': + if int(args[0]) != 0: + raise ValueError(f'invalid machine id: {args[0]}') + amps = int(args[1]) + if amps not in ac_charge_currents: + raise ValueError(f'invalid value: {amps}') + self.rated['max_ac_charge_current']['value'] = amps + + elif c == 'set-charge-thresholds': + self.rated['battery_recharge_voltage']['value'] = float(args[0]) + self.rated['battery_redischarge_voltage']['value'] = float(args[1]) + + elif c == 'set-output-source-priority': + self.rated['output_source_priority'] = OutputSourcePriority.SolarBatteryUtility if args[0] == 'SBU' else OutputSourcePriority.SolarUtilityBattery + + elif c == 'set-battery-cutoff-voltage': + self.rated['battery_under_voltage']['value'] = float(args[0]) + + elif c == 'set-flag': + flag = args[0] + val = bool(int(args[1])) + + if flag == 'BUZZ': + k = 'buzzer' + elif flag == 'OLBP': + k = 'overload_bypass' + elif flag == 'LCDE': + k = 'escape_to_default_screen_after_1min_timeout' + elif flag == 'OLRS': + k = 'overload_restart' + elif flag == 'OTRS': + k = 'over_temp_restart' + elif flag == 'BLON': + k = 'backlight_on' + elif flag == 'ALRM': + k = 'alarm_on_on_primary_source_interrupt' + elif flag == 'FTCR': + k = 'fault_code_record' + else: + raise ValueError('invalid flag') + + self.flags[k] = val + + else: + raise ValueError(f'{c}: unsupported command') + + @staticmethod + def format_list(values: list, fmt: Format) -> Union[str, list]: + if fmt in (Format.JSON, Format.SIMPLE_JSON): + return values + return '\n'.join(map(lambda v: str(v), values)) + + @staticmethod + def format_dict(data: dict, fmt: Format) -> Union[str, dict]: + new_data = {} + for k, v in data.items(): + new_val = None + if fmt in (Format.JSON, Format.TABLE, Format.SIMPLE_TABLE): + if isinstance(v, dict): + new_val = v + elif isinstance(v, InverterEnum): + new_val = v.as_text() + else: + new_val = v + elif fmt == Format.SIMPLE_JSON: + if isinstance(v, dict): + new_val = v['value'] + elif isinstance(v, InverterEnum): + new_val = v.value + else: + new_val = str(v) + new_data[k] = new_val + + if fmt in (Format.JSON, Format.SIMPLE_JSON): + return new_data + + lines = [] + + if fmt == Format.SIMPLE_TABLE: + for k, v in new_data.items(): + buf = k + if isinstance(v, dict): + buf += ' ' + str(v['value']) + ' ' + v['unit'] + elif isinstance(v, InverterEnum): + buf += ' ' + v.as_text() + else: + buf += ' ' + str(v) + lines.append(buf) + + elif fmt == Format.TABLE: + max_k_len = 0 + for k in new_data.keys(): + if len(_g_human_readable[k]) > max_k_len: + max_k_len = len(_g_human_readable[k]) + for k, v in new_data.items(): + buf = _g_human_readable[k] + ':' + buf += ' ' * (max_k_len - len(_g_human_readable[k]) + 1) + if isinstance(v, dict): + buf += str(v['value']) + ' ' + v['unit'] + elif isinstance(v, InverterEnum): + buf += v.as_text() + elif isinstance(v, bool): + buf += str(int(v)) + else: + buf += str(v) + lines.append(buf) + + return '\n'.join(lines) diff --git a/py_include/homekit/inverter/inverter_wrapper.py b/py_include/homekit/inverter/inverter_wrapper.py new file mode 100644 index 0000000..df2c2fc --- /dev/null +++ b/py_include/homekit/inverter/inverter_wrapper.py @@ -0,0 +1,48 @@ +import json + +from threading import Lock +from inverterd import ( + Format, + Client as InverterClient, + InverterError +) + +_lock = Lock() + + +class InverterClientWrapper: + def __init__(self): + self._inverter = None + self._host = None + self._port = None + + def init(self, host: str, port: int): + self._host = host + self._port = port + self.create() + + def create(self): + self._inverter = InverterClient(host=self._host, port=self._port) + self._inverter.connect() + + def exec(self, command: str, arguments: tuple = (), format=Format.JSON): + with _lock: + try: + self._inverter.format(format) + response = self._inverter.exec(command, arguments) + if format == Format.JSON: + response = json.loads(response) + return response + except InverterError as e: + raise e + except Exception as e: + # silently try to reconnect + try: + self.create() + except Exception: + pass + raise e + + +wrapper_instance = InverterClientWrapper() + diff --git a/py_include/homekit/inverter/monitor.py b/py_include/homekit/inverter/monitor.py new file mode 100644 index 0000000..86f75ac --- /dev/null +++ b/py_include/homekit/inverter/monitor.py @@ -0,0 +1,499 @@ +import logging +import time + +from .types import * +from threading import Thread +from typing import Callable, Optional +from .inverter_wrapper import wrapper_instance as inverter +from inverterd import InverterError +from ..util import Stopwatch, StopwatchError +from ..config import config + +logger = logging.getLogger(__name__) + + +def _pd_from_string(pd: str) -> BatteryPowerDirection: + if pd == 'Discharge': + return BatteryPowerDirection.DISCHARGING + elif pd == 'Charge': + return BatteryPowerDirection.CHARGING + elif pd == 'Do nothing': + return BatteryPowerDirection.DO_NOTHING + else: + raise ValueError(f'invalid power direction: {pd}') + + +class MonitorConfig: + def __getattr__(self, item): + return config['monitor'][item] + + +cfg = MonitorConfig() + + +""" +TODO: +- поддержать возможность ручного (через бота) переключения тока заряда вверх и вниз +- поддержать возможность бесшовного перезапуска бота, когда монитор понимает, что зарядка уже идет, и он + не запускает программу с начала, а продолжает с уже существующей позиции. Уведомления при этом можно не + присылать совсем, либо прислать какое-то одно приложение, в духе "программа была перезапущена" +""" + + +class InverterMonitor(Thread): + charging_event_handler: Optional[Callable] + battery_event_handler: Optional[Callable] + util_event_handler: Optional[Callable] + error_handler: Optional[Callable] + osp_change_cb: Optional[Callable] + osp: Optional[OutputSourcePriority] + + def __init__(self): + super().__init__() + self.setName('InverterMonitor') + + self.interrupted = False + self.min_allowed_current = 0 + self.ac_mode = None + self.osp = None + + # Event handlers for the bot. + self.charging_event_handler = None + self.battery_event_handler = None + self.util_event_handler = None + self.error_handler = None + self.osp_change_cb = None + + # Currents list, defined in the bot config. + self.currents = cfg.gen_currents + self.currents.sort() + + # We start charging at lowest possible current, then increase it once per minute (or so) to the maximum level. + # This is done so that the load on the generator increases smoothly, not abruptly. Generator will thank us. + self.current_change_direction = CurrentChangeDirection.UP + self.next_current_enter_time = 0 + self.active_current_idx = -1 + + self.battery_state = BatteryState.NORMAL + self.charging_state = ChargingState.NOT_CHARGING + + # 'Mostly-charged' means that we've already lowered the charging current to the level + # at which batteries are charging pretty slow. So instead of burning gasoline and shaking the air, + # we can just turn the generator off at this point. + self.mostly_charged = False + + # The stopwatch is used to measure how long does the battery voltage exceeds the float voltage level. + # We don't want to damage our batteries, right? + self.floating_stopwatch = Stopwatch() + + # State variables for utilities charging program + self.util_ac_present = None + self.util_pd = None + self.util_solar = None + + @property + def active_current(self) -> Optional[int]: + try: + if self.active_current_idx < 0: + return None + return self.currents[self.active_current_idx] + except IndexError: + return None + + def run(self): + # Check allowed currents and validate the config. + allowed_currents = list(inverter.exec('get-allowed-ac-charge-currents')['data']) + allowed_currents.sort() + + for a in self.currents: + if a not in allowed_currents: + raise ValueError(f'invalid value {a} in gen_currents list') + + self.min_allowed_current = min(allowed_currents) + + # Reading rated configuration + rated = inverter.exec('get-rated')['data'] + self.osp = OutputSourcePriority.from_text(rated['output_source_priority']) + + # Read data and run implemented programs every 2 seconds. + while not self.interrupted: + try: + response = inverter.exec('get-status') + if response['result'] != 'ok': + logger.error('get-status failed:', response) + else: + gs = response['data'] + + ac = gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0 + solar = gs['pv1_input_voltage']['value'] > 0 or gs['pv2_input_voltage']['value'] > 0 + solar_input = gs['pv1_input_power']['value'] + v = float(gs['battery_voltage']['value']) + load_watts = int(gs['ac_output_active_power']['value']) + pd = _pd_from_string(gs['battery_power_direction']) + + logger.debug(f'got status: ac={ac}, solar={solar}, v={v}, pd={pd}') + + if self.ac_mode == ACMode.GENERATOR: + self.gen_charging_program(ac, solar, v, pd) + + elif self.ac_mode == ACMode.UTILITIES: + self.utilities_monitoring_program(ac, solar, v, load_watts, solar_input, pd) + + if not ac or pd != BatteryPowerDirection.CHARGING: + # if AC is disconnected or not charging, run the low voltage checking program + self.low_voltage_program(v, load_watts) + + elif self.battery_state != BatteryState.NORMAL: + # AC is connected and the battery is charging, assume battery level is normal + self.battery_state = BatteryState.NORMAL + + except InverterError as e: + logger.exception(e) + + time.sleep(2) + + def utilities_monitoring_program(self, + ac: bool, # whether AC is connected + solar: bool, # whether MPPT is active + v: float, # battery voltage + load_watts: int, # load, wh + solar_input: int, # input from solar panels, wh + pd: BatteryPowerDirection # current power direction + ): + pd_event_send = False + if self.util_solar is None or solar != self.util_solar: + self.util_solar = solar + if solar and self.util_ac_present and self.util_pd == BatteryPowerDirection.CHARGING: + self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STOPPED_SOLAR) + pd_event_send = True + + if solar: + if v <= 48 and self.osp == OutputSourcePriority.SolarBatteryUtility: + self.osp_change_cb(OutputSourcePriority.SolarUtilityBattery, solar_input=solar_input, v=v) + self.osp = OutputSourcePriority.SolarUtilityBattery + + if self.osp == OutputSourcePriority.SolarUtilityBattery and solar_input >= 900: + self.osp_change_cb(OutputSourcePriority.SolarBatteryUtility, solar_input=solar_input, v=v) + self.osp = OutputSourcePriority.SolarBatteryUtility + + if self.util_ac_present is None or ac != self.util_ac_present: + self.util_event_handler(ACPresentEvent.CONNECTED if ac else ACPresentEvent.DISCONNECTED) + self.util_ac_present = ac + + if self.util_pd is None or self.util_pd != pd: + self.util_pd = pd + if not pd_event_send and not solar: + if pd == BatteryPowerDirection.CHARGING: + self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STARTED) + + elif pd == BatteryPowerDirection.DISCHARGING: + self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STOPPED) + + def gen_charging_program(self, + ac: bool, # whether AC is connected + solar: bool, # whether MPPT is active + v: float, # current battery voltage + pd: BatteryPowerDirection # current power direction + ): + if self.charging_state == ChargingState.NOT_CHARGING: + if ac and solar: + # Not charging because MPPT is active (solar line is connected). + # Notify users about it and change the current state. + self.charging_state = ChargingState.AC_BUT_SOLAR + self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR) + logger.info('entering AC_BUT_SOLAR state') + elif ac: + # Not charging, but AC is connected and ready to use. + # Start the charging program. + self.gen_start(pd) + + elif self.charging_state == ChargingState.AC_BUT_SOLAR: + if not ac: + # AC charger has been disconnected. Since the state is AC_BUT_SOLAR, + # charging probably never even started. Stop the charging program. + self.gen_stop(ChargingState.NOT_CHARGING) + elif not solar: + # MPPT has been disconnected, and, since AC is still connected, we can + # try to start the charging program. + self.gen_start(pd) + + elif self.charging_state in (ChargingState.AC_OK, ChargingState.AC_WAITING): + if not ac: + # Charging was in progress, but AC has been suddenly disconnected. + # Sad, but what can we do? Stop the charging program and return. + self.gen_stop(ChargingState.NOT_CHARGING) + return + + if solar: + # Charging was in progress, but MPPT has been detected. Inverter doesn't charge + # batteries from AC when MPPT is active, so we have to pause our program. + self.charging_state = ChargingState.AC_BUT_SOLAR + self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR) + try: + self.floating_stopwatch.pause() + except StopwatchError: + msg = 'gen_charging_program: floating_stopwatch.pause() failed at (1)' + logger.warning(msg) + # self.error_handler(msg) + logger.info('solar power connected during charging, entering AC_BUT_SOLAR state') + return + + # No surprises at this point, just check the values and make decisions based on them. + # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + # We've reached the 'mostly-charged' point, the voltage level is not float, + # but inverter decided to stop charging (or somebody used a kettle, lol). + # Anyway, assume that charging is complete, stop the program, notify users and return. + if self.mostly_charged and v > (cfg.gen_floating_v - 1) and pd != BatteryPowerDirection.CHARGING: + self.gen_stop(ChargingState.AC_DONE) + return + + # Monitor inverter power direction and notify users when it changes. + state = ChargingState.AC_OK if pd == BatteryPowerDirection.CHARGING else ChargingState.AC_WAITING + if state != self.charging_state: + self.charging_state = state + + evt = ChargingEvent.AC_CHARGING_STARTED if state == ChargingState.AC_OK else ChargingEvent.AC_NOT_CHARGING + self.charging_event_handler(evt) + + if self.floating_stopwatch.get_elapsed_time() >= cfg.gen_floating_time_max: + # We've been at a bulk voltage level too long, so we have to stop charging. + # Set the minimum current possible. + + if self.current_change_direction == CurrentChangeDirection.UP: + # This shouldn't happen, obviously an error. + msg = 'gen_charging_program:' + msg += ' been at bulk voltage level too long, but current change direction is still \'up\'!' + msg += ' This is obviously an error, please fix it' + logger.warning(msg) + self.error_handler(msg) + + self.gen_next_current(current=self.min_allowed_current) + + elif self.active_current is not None: + # If voltage is greater than float voltage, keep the stopwatch ticking + if v > cfg.gen_floating_v and self.floating_stopwatch.is_paused(): + try: + self.floating_stopwatch.go() + except StopwatchError: + msg = 'gen_charging_program: floating_stopwatch.go() failed at (2)' + logger.warning(msg) + self.error_handler(msg) + # Otherwise, pause it + elif v <= cfg.gen_floating_v and not self.floating_stopwatch.is_paused(): + try: + self.floating_stopwatch.pause() + except StopwatchError: + msg = 'gen_charging_program: floating_stopwatch.pause() failed at (3)' + logger.warning(msg) + self.error_handler(msg) + + # Charging current monitoring + if self.current_change_direction == CurrentChangeDirection.UP: + # Generator is warming up in this code path + + if self.next_current_enter_time != 0 and pd != BatteryPowerDirection.CHARGING: + # Generator was warming up and charging, but stopped (pd has changed). + # Resetting to the minimum possible current + logger.info(f'gen_charging_program (warming path): was charging but power direction suddeny changed. resetting to minimum current') + self.next_current_enter_time = 0 + self.gen_next_current(current=self.min_allowed_current) + + elif self.next_current_enter_time == 0 and pd == BatteryPowerDirection.CHARGING: + self.next_current_enter_time = time.time() + cfg.gen_raise_intervals[self.active_current_idx] + logger.info(f'gen_charging_program (warming path): set next_current_enter_time to {self.next_current_enter_time}') + + elif self.next_current_enter_time != 0 and time.time() >= self.next_current_enter_time: + logger.info('gen_charging_program (warming path): hit next_current_enter_time, calling gen_next_current()') + self.gen_next_current() + else: + # Gradually lower the current level, based on how close + # battery voltage has come to the bulk level. + if self.active_current >= 30: + upper_bound = cfg.gen_cur30_v_limit + elif self.active_current == 20: + upper_bound = cfg.gen_cur20_v_limit + else: + upper_bound = cfg.gen_cur10_v_limit + + # Voltage is high enough already and it's close to bulk level; we hit the upper bound, + # so let's lower the current + if v >= upper_bound: + self.gen_next_current() + + elif self.charging_state == ChargingState.AC_DONE: + # We've already finished charging, but AC was connected. Not that it's disconnected, + # set the appropriate state and notify users. + if not ac: + self.gen_stop(ChargingState.NOT_CHARGING) + + def gen_start(self, pd: BatteryPowerDirection): + if pd == BatteryPowerDirection.CHARGING: + self.charging_state = ChargingState.AC_OK + self.charging_event_handler(ChargingEvent.AC_CHARGING_STARTED) + logger.info('AC line connected and charging, entering AC_OK state') + + # Continue the stopwatch, if needed + try: + self.floating_stopwatch.go() + except StopwatchError: + msg = 'floating_stopwatch.go() failed at ac_charging_start(), AC_OK path' + logger.warning(msg) + self.error_handler(msg) + else: + self.charging_state = ChargingState.AC_WAITING + self.charging_event_handler(ChargingEvent.AC_NOT_CHARGING) + logger.info('AC line connected but not charging yet, entering AC_WAITING state') + + # Pause the stopwatch, if needed + try: + if not self.floating_stopwatch.is_paused(): + self.floating_stopwatch.pause() + except StopwatchError: + msg = 'floating_stopwatch.pause() failed at ac_charging_start(), AC_WAITING path' + logger.warning(msg) + self.error_handler(msg) + + # idx == -1 means haven't started our program yet. + if self.active_current_idx == -1: + self.gen_next_current() + # self.set_hw_charging_current(self.min_allowed_current) + + def gen_stop(self, reason: ChargingState): + self.charging_state = reason + + if reason == ChargingState.AC_DONE: + event = ChargingEvent.AC_CHARGING_FINISHED + elif reason == ChargingState.NOT_CHARGING: + event = ChargingEvent.AC_DISCONNECTED + else: + raise ValueError(f'ac_charging_stop: unexpected reason {reason}') + + logger.info(f'charging is finished, entering {reason} state') + self.charging_event_handler(event) + + self.next_current_enter_time = 0 + self.mostly_charged = False + self.active_current_idx = -1 + self.floating_stopwatch.reset() + self.current_change_direction = CurrentChangeDirection.UP + + self.set_hw_charging_current(self.min_allowed_current) + + def gen_next_current(self, current=None): + if current is None: + try: + current = self._next_current() + logger.debug(f'gen_next_current: ready to change charging current to {current} A') + except IndexError: + logger.debug('gen_next_current: was going to change charging current, but no currents left; finishing charging program') + self.gen_stop(ChargingState.AC_DONE) + return + + else: + try: + idx = self.currents.index(current) + except ValueError: + msg = f'gen_next_current: got current={current} but it\'s not in the currents list' + logger.error(msg) + self.error_handler(msg) + return + self.active_current_idx = idx + + if self.current_change_direction == CurrentChangeDirection.DOWN: + if current == self.currents[0]: + self.mostly_charged = True + self.gen_stop(ChargingState.AC_DONE) + + elif current == self.currents[1] and not self.mostly_charged: + self.mostly_charged = True + self.charging_event_handler(ChargingEvent.AC_MOSTLY_CHARGED) + + self.set_hw_charging_current(current) + + def set_hw_charging_current(self, current: int): + try: + response = inverter.exec('set-max-ac-charge-current', (0, current)) + if response['result'] != 'ok': + logger.error(f'failed to change AC charging current to {current} A') + raise InverterError('set-max-ac-charge-current: inverterd reported error') + else: + self.charging_event_handler(ChargingEvent.AC_CURRENT_CHANGED, current=current) + logger.info(f'changed AC charging current to {current} A') + except InverterError as e: + self.error_handler(f'failed to set charging current to {current} A (caught InverterError)') + logger.exception(e) + + def _next_current(self): + if self.current_change_direction == CurrentChangeDirection.UP: + self.active_current_idx += 1 + if self.active_current_idx == len(self.currents)-1: + logger.info('_next_current: charging current power direction to DOWN') + self.current_change_direction = CurrentChangeDirection.DOWN + self.next_current_enter_time = 0 + else: + if self.active_current_idx == 0: + raise IndexError('can\'t go lower') + self.active_current_idx -= 1 + + logger.info(f'_next_current: active_current_idx set to {self.active_current_idx}, returning current of {self.currents[self.active_current_idx]} A') + return self.currents[self.active_current_idx] + + def low_voltage_program(self, v: float, load_watts: int): + crit_level = cfg.vcrit + low_level = cfg.vlow + + if v <= crit_level: + state = BatteryState.CRITICAL + elif v <= low_level: + state = BatteryState.LOW + else: + state = BatteryState.NORMAL + + if state != self.battery_state: + self.battery_state = state + self.battery_event_handler(state, v, load_watts) + + def set_charging_event_handler(self, handler: Callable): + self.charging_event_handler = handler + + def set_battery_event_handler(self, handler: Callable): + self.battery_event_handler = handler + + def set_util_event_handler(self, handler: Callable): + self.util_event_handler = handler + + def set_error_handler(self, handler: Callable): + self.error_handler = handler + + def set_osp_need_change_callback(self, cb: Callable): + self.osp_change_cb = cb + + def set_ac_mode(self, mode: ACMode): + self.ac_mode = mode + + def notify_osp(self, osp: OutputSourcePriority): + self.osp = osp + + def stop(self): + self.interrupted = True + + def dump_status(self) -> dict: + return { + 'interrupted': self.interrupted, + 'currents': self.currents, + 'active_current': self.active_current, + 'current_change_direction': self.current_change_direction.name, + 'battery_state': self.battery_state.name, + 'charging_state': self.charging_state.name, + 'mostly_charged': self.mostly_charged, + 'floating_stopwatch_paused': self.floating_stopwatch.is_paused(), + 'floating_stopwatch_elapsed': self.floating_stopwatch.get_elapsed_time(), + 'time_now': time.time(), + 'next_current_enter_time': self.next_current_enter_time, + 'ac_mode': self.ac_mode, + 'osp': self.osp, + 'util_ac_present': self.util_ac_present, + 'util_pd': self.util_pd.name, + 'util_solar': self.util_solar + } diff --git a/py_include/homekit/inverter/types.py b/py_include/homekit/inverter/types.py new file mode 100644 index 0000000..57021f1 --- /dev/null +++ b/py_include/homekit/inverter/types.py @@ -0,0 +1,64 @@ +from enum import Enum, auto + + +class BatteryPowerDirection(Enum): + DISCHARGING = auto() + CHARGING = auto() + DO_NOTHING = auto() + + +class ChargingEvent(Enum): + AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR = auto() + AC_NOT_CHARGING = auto() + AC_CHARGING_STARTED = auto() + AC_DISCONNECTED = auto() + AC_CURRENT_CHANGED = auto() + AC_MOSTLY_CHARGED = auto() + AC_CHARGING_FINISHED = auto() + + UTIL_CHARGING_STARTED = auto() + UTIL_CHARGING_STOPPED = auto() + UTIL_CHARGING_STOPPED_SOLAR = auto() + + +class ACPresentEvent(Enum): + CONNECTED = auto() + DISCONNECTED = auto() + + +class ChargingState(Enum): + NOT_CHARGING = auto() + AC_BUT_SOLAR = auto() + AC_WAITING = auto() + AC_OK = auto() + AC_DONE = auto() + + +class CurrentChangeDirection(Enum): + UP = auto() + DOWN = auto() + + +class BatteryState(Enum): + NORMAL = auto() + LOW = auto() + CRITICAL = auto() + + +class ACMode(Enum): + GENERATOR = 'generator' + UTILITIES = 'utilities' + + +class OutputSourcePriority(Enum): + SolarUtilityBattery = 'SUB' + SolarBatteryUtility = 'SBU' + + @classmethod + def from_text(cls, s: str): + if s == 'Solar-Battery-Utility': + return cls.SolarBatteryUtility + elif s == 'Solar-Utility-Battery': + return cls.SolarUtilityBattery + else: + raise ValueError(f'unknown value: {s}') \ No newline at end of file diff --git a/py_include/homekit/inverter/util.py b/py_include/homekit/inverter/util.py new file mode 100644 index 0000000..a577e6a --- /dev/null +++ b/py_include/homekit/inverter/util.py @@ -0,0 +1,8 @@ +import re + + +def beautify_table(s): + lines = s.split('\n') + lines = list(map(lambda line: re.sub(r'\s+', ' ', line), lines)) + lines = list(map(lambda line: re.sub(r'(.*?): (.*)', r'\1: \2', line), lines)) + return '\n'.join(lines) diff --git a/py_include/homekit/media/__init__.py b/py_include/homekit/media/__init__.py new file mode 100644 index 0000000..6923105 --- /dev/null +++ b/py_include/homekit/media/__init__.py @@ -0,0 +1,22 @@ +import importlib +import itertools + +__map__ = { + 'types': ['MediaNodeType'], + 'record_client': ['SoundRecordClient', 'CameraRecordClient', 'RecordClient'], + 'node_server': ['MediaNodeServer'], + 'node_client': ['SoundNodeClient', 'CameraNodeClient', 'MediaNodeClient'], + 'storage': ['SoundRecordStorage', 'ESP32CameraRecordStorage', 'SoundRecordFile', 'CameraRecordFile', 'RecordFile'], + 'record': ['SoundRecorder', 'CameraRecorder'] +} + +__all__ = list(itertools.chain(*__map__.values())) + + +def __getattr__(name): + if name in __all__: + for file, names in __map__.items(): + if name in names: + module = importlib.import_module(f'.{file}', __name__) + return getattr(module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/py_include/homekit/media/__init__.pyi b/py_include/homekit/media/__init__.pyi new file mode 100644 index 0000000..77c2176 --- /dev/null +++ b/py_include/homekit/media/__init__.pyi @@ -0,0 +1,27 @@ +from .types import ( + MediaNodeType as MediaNodeType +) +from .record_client import ( + SoundRecordClient as SoundRecordClient, + CameraRecordClient as CameraRecordClient, + RecordClient as RecordClient +) +from .node_server import ( + MediaNodeServer as MediaNodeServer +) +from .node_client import ( + SoundNodeClient as SoundNodeClient, + CameraNodeClient as CameraNodeClient, + MediaNodeClient as MediaNodeClient +) +from .storage import ( + SoundRecordStorage as SoundRecordStorage, + ESP32CameraRecordStorage as ESP32CameraRecordStorage, + SoundRecordFile as SoundRecordFile, + CameraRecordFile as CameraRecordFile, + RecordFile as RecordFile +) +from .record import ( + SoundRecorder as SoundRecorder, + CameraRecorder as CameraRecorder +) \ No newline at end of file diff --git a/py_include/homekit/media/node_client.py b/py_include/homekit/media/node_client.py new file mode 100644 index 0000000..eb39898 --- /dev/null +++ b/py_include/homekit/media/node_client.py @@ -0,0 +1,119 @@ +import requests +import shutil +import logging + +from typing import Optional, Union, List +from .storage import RecordFile +from ..util import Addr +from ..api.errors import ApiResponseError + + +class MediaNodeClient: + def __init__(self, addr: Addr): + self.endpoint = f'http://{addr[0]}:{addr[1]}' + self.logger = logging.getLogger(self.__class__.__name__) + + def record(self, duration: int): + return self._call('record/', params={"duration": duration}) + + def record_info(self, record_id: int): + return self._call(f'record/info/{record_id}/') + + def record_forget(self, record_id: int): + return self._call(f'record/forget/{record_id}/') + + def record_download(self, record_id: int, output: str): + return self._call(f'record/download/{record_id}/', save_to=output) + + def storage_list(self, extended=False, as_objects=False) -> Union[List[str], List[dict], List[RecordFile]]: + r = self._call('storage/list/', params={'extended': int(extended)}) + files = r['files'] + if as_objects: + return self.record_list_from_serialized(files) + return files + + @staticmethod + def record_list_from_serialized(files: Union[List[str], List[dict]]): + new_files = [] + for f in files: + kwargs = {'remote': True} + if isinstance(f, dict): + name = f['filename'] + kwargs['remote_filesize'] = f['filesize'] + else: + name = f + item = RecordFile.create(name, **kwargs) + new_files.append(item) + return new_files + + def storage_delete(self, file_id: str): + return self._call('storage/delete/', params={'file_id': file_id}) + + def storage_download(self, file_id: str, output: str): + return self._call('storage/download/', params={'file_id': file_id}, save_to=output) + + def _call(self, + method: str, + params: dict = None, + save_to: Optional[str] = None): + kwargs = {} + if isinstance(params, dict): + kwargs['params'] = params + if save_to: + kwargs['stream'] = True + + url = f'{self.endpoint}/{method}' + self.logger.debug(f'calling {url}, kwargs: {kwargs}') + + r = requests.get(url, **kwargs) + if r.status_code != 200: + response = r.json() + raise ApiResponseError(status_code=r.status_code, + error_type=response['error'], + error_message=response['message'] or None, + error_stacktrace=response['stacktrace'] if 'stacktrace' in response else None) + + if save_to: + r.raise_for_status() + with open(save_to, 'wb') as f: + shutil.copyfileobj(r.raw, f) + return True + + return r.json()['response'] + + +class SoundNodeClient(MediaNodeClient): + def amixer_get_all(self): + return self._call('amixer/get-all/') + + def amixer_get(self, control: str): + return self._call(f'amixer/get/{control}/') + + def amixer_incr(self, control: str, step: Optional[int] = None): + params = {'step': step} if step is not None else None + return self._call(f'amixer/incr/{control}/', params=params) + + def amixer_decr(self, control: str, step: Optional[int] = None): + params = {'step': step} if step is not None else None + return self._call(f'amixer/decr/{control}/', params=params) + + def amixer_mute(self, control: str): + return self._call(f'amixer/mute/{control}/') + + def amixer_unmute(self, control: str): + return self._call(f'amixer/unmute/{control}/') + + def amixer_cap(self, control: str): + return self._call(f'amixer/cap/{control}/') + + def amixer_nocap(self, control: str): + return self._call(f'amixer/nocap/{control}/') + + +class CameraNodeClient(MediaNodeClient): + def capture(self, + save_to: str, + with_flash: bool = False): + return self._call('capture/', + {'with_flash': int(with_flash)}, + save_to=save_to) diff --git a/py_include/homekit/media/node_server.py b/py_include/homekit/media/node_server.py new file mode 100644 index 0000000..5d0803c --- /dev/null +++ b/py_include/homekit/media/node_server.py @@ -0,0 +1,86 @@ +from .. import http +from .record import Recorder +from .types import RecordStatus +from .storage import RecordStorage + + +class MediaNodeServer(http.HTTPServer): + recorder: Recorder + storage: RecordStorage + + def __init__(self, + recorder: Recorder, + storage: RecordStorage, + *args, **kwargs): + super().__init__(*args, **kwargs) + + self.recorder = recorder + self.storage = storage + + self.get('/record/', self.do_record) + self.get('/record/info/{id}/', self.record_info) + self.get('/record/forget/{id}/', self.record_forget) + self.get('/record/download/{id}/', self.record_download) + + self.get('/storage/list/', self.storage_list) + self.get('/storage/delete/', self.storage_delete) + self.get('/storage/download/', self.storage_download) + + async def do_record(self, request: http.Request): + duration = int(request.query['duration']) + max = Recorder.get_max_record_time()*15 + if not 0 < duration <= max: + raise ValueError(f'invalid duration: max duration is {max}') + + record_id = self.recorder.record(duration) + return http.ok({'id': record_id}) + + async def record_info(self, request: http.Request): + record_id = int(request.match_info['id']) + info = self.recorder.get_info(record_id) + return http.ok(info.as_dict()) + + async def record_forget(self, request: http.Request): + record_id = int(request.match_info['id']) + + info = self.recorder.get_info(record_id) + assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}" + + self.recorder.forget(record_id) + return http.ok() + + async def record_download(self, request: http.Request): + record_id = int(request.match_info['id']) + + info = self.recorder.get_info(record_id) + assert info.status == RecordStatus.FINISHED, f"record status is {info.status}" + + return http.FileResponse(info.file.path) + + async def storage_list(self, request: http.Request): + extended = 'extended' in request.query and int(request.query['extended']) == 1 + + files = self.storage.getfiles(as_objects=extended) + if extended: + files = list(map(lambda file: file.__dict__(), files)) + + return http.ok({ + 'files': files + }) + + async def storage_delete(self, request: http.Request): + file_id = request.query['file_id'] + file = self.storage.find(file_id) + if not file: + raise ValueError(f'file {file} not found') + + self.storage.delete(file) + return http.ok() + + async def storage_download(self, request): + file_id = request.query['file_id'] + file = self.storage.find(file_id) + if not file: + raise ValueError(f'file {file} not found') + + return http.FileResponse(file.path) diff --git a/py_include/homekit/media/record.py b/py_include/homekit/media/record.py new file mode 100644 index 0000000..cd7447a --- /dev/null +++ b/py_include/homekit/media/record.py @@ -0,0 +1,461 @@ +import os +import threading +import logging +import time +import subprocess +import signal + +from typing import Optional, List, Dict +from ..util import find_child_processes, Addr +from ..config import config +from .storage import RecordFile, RecordStorage +from .types import RecordStatus +from ..camera.types import CameraType + + +_history_item_timeout = 7200 +_history_cleanup_freq = 3600 + + +class RecordHistoryItem: + id: int + request_time: float + start_time: float + stop_time: float + relations: List[int] + status: RecordStatus + error: Optional[Exception] + file: Optional[RecordFile] + creation_time: float + + def __init__(self, id): + self.id = id + self.request_time = 0 + self.start_time = 0 + self.stop_time = 0 + self.relations = [] + self.status = RecordStatus.WAITING + self.file = None + self.error = None + self.creation_time = time.time() + + def add_relation(self, related_id: int): + self.relations.append(related_id) + + def mark_started(self, start_time: float): + self.start_time = start_time + self.status = RecordStatus.RECORDING + + def mark_finished(self, end_time: float, file: RecordFile): + self.stop_time = end_time + self.file = file + self.status = RecordStatus.FINISHED + + def mark_failed(self, error: Exception): + self.status = RecordStatus.ERROR + self.error = error + + def as_dict(self) -> dict: + data = { + 'id': self.id, + 'request_time': self.request_time, + 'status': self.status.value, + 'relations': self.relations, + 'start_time': self.start_time, + 'stop_time': self.stop_time, + } + if self.error: + data['error'] = str(self.error) + if self.file: + data['file'] = self.file.__dict__() + return data + + +class RecordingNotFoundError(Exception): + pass + + +class RecordHistory: + history: Dict[int, RecordHistoryItem] + + def __init__(self): + self.history = {} + self.logger = logging.getLogger(self.__class__.__name__) + + def add(self, record_id: int): + self.logger.debug(f'add: record_id={record_id}') + + r = RecordHistoryItem(record_id) + r.request_time = time.time() + + self.history[record_id] = r + + def delete(self, record_id: int): + self.logger.debug(f'delete: record_id={record_id}') + del self.history[record_id] + + def cleanup(self): + del_ids = [] + for rid, item in self.history.items(): + if item.creation_time < time.time()-_history_item_timeout: + del_ids.append(rid) + for rid in del_ids: + self.delete(rid) + + def __getitem__(self, key): + if key not in self.history: + raise RecordingNotFoundError() + + return self.history[key] + + def __setitem__(self, key, value): + raise NotImplementedError('setting history item this way is prohibited') + + def __contains__(self, key): + return key in self.history + + +class Recording: + RECORDER_PROGRAM = None + + start_time: float + stop_time: float + duration: int + record_id: int + recorder_program_pid: Optional[int] + process: Optional[subprocess.Popen] + + g_record_id = 1 + + def __init__(self): + if self.RECORDER_PROGRAM is None: + raise RuntimeError('this is abstract class') + + self.start_time = 0 + self.stop_time = 0 + self.duration = 0 + self.process = None + self.recorder_program_pid = None + self.record_id = Recording.next_id() + self.logger = logging.getLogger(self.__class__.__name__) + + def is_started(self) -> bool: + return self.start_time > 0 and self.stop_time > 0 + + def is_waiting(self): + return self.duration > 0 + + def ask_for(self, duration) -> int: + overtime = 0 + orig_duration = duration + + if self.is_started(): + already_passed = time.time() - self.start_time + max_duration = Recorder.get_max_record_time() - already_passed + self.logger.debug(f'ask_for({orig_duration}): recording is in progress, already passed {already_passed}s, max_duration set to {max_duration}') + else: + max_duration = Recorder.get_max_record_time() + + if duration > max_duration: + overtime = duration - max_duration + duration = max_duration + + self.logger.debug(f'ask_for({orig_duration}): requested duration ({orig_duration}) is greater than max ({max_duration}), overtime is {overtime}') + + self.duration += duration + if self.is_started(): + til_end = self.stop_time - time.time() + if til_end < 0: + til_end = 0 + + _prev_stop_time = self.stop_time + _to_add = duration - til_end + if _to_add < 0: + _to_add = 0 + + self.stop_time += _to_add + self.logger.debug(f'ask_for({orig_duration}): adding {_to_add} to stop_time (before: {_prev_stop_time}, after: {self.stop_time})') + + return overtime + + def start(self, output: str): + assert self.start_time == 0 and self.stop_time == 0, "already started?!" + assert self.process is None, "self.process is not None, what the hell?" + + cur = time.time() + self.start_time = cur + self.stop_time = cur + self.duration + + cmd = self.get_command(output) + self.logger.debug(f'start: running `{cmd}`') + self.process = subprocess.Popen(cmd, shell=True, stdin=None, stdout=None, stderr=None, close_fds=True) + + sh_pid = self.process.pid + self.logger.debug(f'start: started, pid of shell is {sh_pid}') + + pid = self.find_recorder_program_pid(sh_pid) + if pid is not None: + self.recorder_program_pid = pid + self.logger.debug(f'start: pid of {self.RECORDER_PROGRAM} is {pid}') + + def get_command(self, output: str) -> str: + pass + + def stop(self): + if self.process: + if self.recorder_program_pid is None: + self.recorder_program_pid = self.find_recorder_program_pid(self.process.pid) + + if self.recorder_program_pid is not None: + os.kill(self.recorder_program_pid, signal.SIGINT) + timeout = config['node']['process_wait_timeout'] + + self.logger.debug(f'stop: sent SIGINT to {self.recorder_program_pid}. now waiting up to {timeout} seconds...') + try: + self.process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + self.logger.warning(f'stop: wait({timeout}): timeout expired, killing it') + try: + os.kill(self.recorder_program_pid, signal.SIGKILL) + self.process.terminate() + except Exception as exc: + self.logger.exception(exc) + else: + self.logger.warning(f'stop: pid of {self.RECORDER_PROGRAM} is unknown, calling terminate()') + self.process.terminate() + + rc = self.process.returncode + self.logger.debug(f'stop: rc={rc}') + + self.process = None + self.recorder_program_pid = 0 + + self.duration = 0 + self.start_time = 0 + self.stop_time = 0 + + def find_recorder_program_pid(self, sh_pid: int): + try: + children = find_child_processes(sh_pid) + except OSError as exc: + self.logger.warning(f'failed to find child process of {sh_pid}: ' + str(exc)) + return None + + for child in children: + if self.RECORDER_PROGRAM in child.cmd: + return child.pid + + return None + + @staticmethod + def next_id() -> int: + cur_id = Recording.g_record_id + Recording.g_record_id += 1 + return cur_id + + def increment_id(self): + self.record_id = Recording.next_id() + + +class Recorder: + TEMP_NAME = None + + interrupted: bool + lock: threading.Lock + history_lock: threading.Lock + recording: Optional[Recording] + overtime: int + history: RecordHistory + next_history_cleanup_time: float + storage: RecordStorage + + def __init__(self, + storage: RecordStorage, + recording: Recording): + if self.TEMP_NAME is None: + raise RuntimeError('this is abstract class') + + self.storage = storage + self.recording = recording + self.interrupted = False + self.lock = threading.Lock() + self.history_lock = threading.Lock() + self.overtime = 0 + self.history = RecordHistory() + self.next_history_cleanup_time = 0 + self.logger = logging.getLogger(self.__class__.__name__) + + def start_thread(self): + t = threading.Thread(target=self.loop) + t.daemon = True + t.start() + + def loop(self) -> None: + tempname = os.path.join(self.storage.root, self.TEMP_NAME) + + while not self.interrupted: + cur = time.time() + stopped = False + cur_record_id = None + + if self.next_history_cleanup_time == 0: + self.next_history_cleanup_time = time.time() + _history_cleanup_freq + elif self.next_history_cleanup_time <= time.time(): + self.logger.debug('loop: calling history.cleanup()') + try: + self.history.cleanup() + except Exception as e: + self.logger.error('loop: error while history.cleanup(): ' + str(e)) + self.next_history_cleanup_time = time.time() + _history_cleanup_freq + + with self.lock: + cur_record_id = self.recording.record_id + # self.logger.debug(f'cur_record_id={cur_record_id}') + + if not self.recording.is_started(): + if self.recording.is_waiting(): + try: + if os.path.exists(tempname): + self.logger.warning(f'loop: going to start new recording, but {tempname} still exists, unlinking..') + try: + os.unlink(tempname) + except OSError as e: + self.logger.exception(e) + self.recording.start(tempname) + with self.history_lock: + self.history[cur_record_id].mark_started(self.recording.start_time) + except Exception as exc: + self.logger.exception(exc) + + # there should not be any errors, but still.. + try: + self.recording.stop() + except Exception as exc: + self.logger.exception(exc) + + with self.history_lock: + self.history[cur_record_id].mark_failed(exc) + + self.logger.debug(f'loop: start exc path: calling increment_id()') + self.recording.increment_id() + else: + if cur >= self.recording.stop_time: + try: + start_time = self.recording.start_time + stop_time = self.recording.stop_time + self.recording.stop() + + saved_name = self.storage.save(tempname, + record_id=cur_record_id, + start_time=int(start_time), + stop_time=int(stop_time)) + + with self.history_lock: + self.history[cur_record_id].mark_finished(stop_time, saved_name) + except Exception as exc: + self.logger.exception(exc) + with self.history_lock: + self.history[cur_record_id].mark_failed(exc) + finally: + self.logger.debug(f'loop: stop exc final path: calling increment_id()') + self.recording.increment_id() + + stopped = True + + if stopped and self.overtime > 0: + self.logger.info(f'recording {cur_record_id} is stopped, but we\'ve got overtime ({self.overtime})') + _overtime = self.overtime + self.overtime = 0 + + related_id = self.record(_overtime) + self.logger.info(f'enqueued another record with id {related_id}') + + if cur_record_id is not None: + with self.history_lock: + self.history[cur_record_id].add_relation(related_id) + + time.sleep(0.2) + + def record(self, duration: int) -> int: + self.logger.debug(f'record: duration={duration}') + with self.lock: + overtime = self.recording.ask_for(duration) + self.logger.debug(f'overtime={overtime}') + + if overtime > self.overtime: + self.overtime = overtime + + if not self.recording.is_started(): + with self.history_lock: + self.history.add(self.recording.record_id) + + return self.recording.record_id + + def stop(self): + self.interrupted = True + + def get_info(self, record_id: int) -> RecordHistoryItem: + with self.history_lock: + return self.history[record_id] + + def forget(self, record_id: int): + with self.history_lock: + self.logger.info(f'forget: removing record {record_id} from history') + self.history.delete(record_id) + + @staticmethod + def get_max_record_time() -> int: + return config['node']['record_max_time'] + + +class SoundRecorder(Recorder): + TEMP_NAME = 'temp.mp3' + + def __init__(self, *args, **kwargs): + super().__init__(recording=SoundRecording(), + *args, **kwargs) + + +class CameraRecorder(Recorder): + TEMP_NAME = 'temp.mp4' + + def __init__(self, + camera_type: CameraType, + *args, **kwargs): + if camera_type == CameraType.ESP32: + recording = ESP32CameraRecording(stream_addr=kwargs['stream_addr']) + del kwargs['stream_addr'] + else: + raise RuntimeError(f'unsupported camera type {camera_type}') + + super().__init__(recording=recording, + *args, **kwargs) + + +class SoundRecording(Recording): + RECORDER_PROGRAM = 'arecord' + + def get_command(self, output: str) -> str: + arecord = config['arecord']['bin'] + lame = config['lame']['bin'] + b = config['lame']['bitrate'] + + return f'{arecord} -f S16 -r 44100 -t raw 2>/dev/null | {lame} -r -s 44.1 -b {b} -m m - {output} >/dev/null 2>/dev/null' + + +class ESP32CameraRecording(Recording): + RECORDER_PROGRAM = 'esp32_capture.py' + + stream_addr: Addr + + def __init__(self, stream_addr: Addr): + super().__init__() + self.stream_addr = stream_addr + + def get_command(self, output: str) -> str: + bin = config['esp32_capture']['bin'] + return f'{bin} --addr {self.stream_addr[0]}:{self.stream_addr[1]} --output-directory {output} >/dev/null 2>/dev/null' + + def start(self, output: str): + output = os.path.dirname(output) + return super().start(output) \ No newline at end of file diff --git a/py_include/homekit/media/record_client.py b/py_include/homekit/media/record_client.py new file mode 100644 index 0000000..322495c --- /dev/null +++ b/py_include/homekit/media/record_client.py @@ -0,0 +1,166 @@ +import time +import logging +import threading +import os.path + +from tempfile import gettempdir +from .record import RecordStatus +from .node_client import SoundNodeClient, MediaNodeClient, CameraNodeClient +from ..util import Addr +from typing import Optional, Callable, Dict + + +class RecordClient: + DOWNLOAD_EXTENSION = None + + interrupted: bool + logger: logging.Logger + clients: Dict[str, MediaNodeClient] + awaiting: Dict[str, Dict[int, Optional[dict]]] + error_handler: Optional[Callable] + finished_handler: Optional[Callable] + download_on_finish: bool + + def __init__(self, + nodes: Dict[str, Addr], + error_handler: Optional[Callable] = None, + finished_handler: Optional[Callable] = None, + download_on_finish=False): + if self.DOWNLOAD_EXTENSION is None: + raise RuntimeError('this is abstract class') + + self.interrupted = False + self.logger = logging.getLogger(self.__class__.__name__) + self.clients = {} + self.awaiting = {} + + self.download_on_finish = download_on_finish + self.error_handler = error_handler + self.finished_handler = finished_handler + + self.awaiting_lock = threading.Lock() + + self.make_clients(nodes) + + try: + t = threading.Thread(target=self.loop) + t.daemon = True + t.start() + except (KeyboardInterrupt, SystemExit) as exc: + self.stop() + self.logger.exception(exc) + + def make_clients(self, nodes: Dict[str, Addr]): + pass + + def stop(self): + self.interrupted = True + + def loop(self): + while not self.interrupted: + for node in self.awaiting.keys(): + with self.awaiting_lock: + record_ids = list(self.awaiting[node].keys()) + if not record_ids: + continue + + self.logger.debug(f'loop: node `{node}` awaiting list: {record_ids}') + + cl = self.getclient(node) + del_ids = [] + for rid in record_ids: + info = cl.record_info(rid) + + if info['relations']: + for relid in info['relations']: + self.wait_for_record(node, relid, self.awaiting[node][rid], is_relative=True) + + status = RecordStatus(info['status']) + if status in (RecordStatus.FINISHED, RecordStatus.ERROR): + if status == RecordStatus.FINISHED: + if self.download_on_finish: + local_fn = self.download(node, rid, info['file']['fileid']) + else: + local_fn = None + self._report_finished(info, local_fn, self.awaiting[node][rid]) + else: + self._report_error(info, self.awaiting[node][rid]) + del_ids.append(rid) + self.logger.debug(f'record {rid}: status {status}') + + if del_ids: + self.logger.debug(f'deleting {del_ids} from {node}\'s awaiting list') + with self.awaiting_lock: + for del_id in del_ids: + del self.awaiting[node][del_id] + + time.sleep(5) + + self.logger.info('loop ended') + + def getclient(self, node: str): + return self.clients[node] + + def record(self, + node: str, + duration: int, + userdata: Optional[dict] = None) -> int: + self.logger.debug(f'record: node={node}, duration={duration}, userdata={userdata}') + + cl = self.getclient(node) + record_id = cl.record(duration)['id'] + self.logger.debug(f'record: request sent, record_id={record_id}') + + self.wait_for_record(node, record_id, userdata) + return record_id + + def wait_for_record(self, + node: str, + record_id: int, + userdata: Optional[dict] = None, + is_relative=False): + with self.awaiting_lock: + if record_id not in self.awaiting[node]: + msg = f'wait_for_record: adding {record_id} to {node}' + if is_relative: + msg += ' (by relation)' + self.logger.debug(msg) + + self.awaiting[node][record_id] = userdata + + def download(self, node: str, record_id: int, fileid: str): + dst = os.path.join(gettempdir(), f'{node}_{fileid}.{self.DOWNLOAD_EXTENSION}') + cl = self.getclient(node) + cl.record_download(record_id, dst) + return dst + + def forget(self, node: str, rid: int): + self.getclient(node).record_forget(rid) + + def _report_finished(self, *args): + if self.finished_handler: + self.finished_handler(*args) + + def _report_error(self, *args): + if self.error_handler: + self.error_handler(*args) + + +class SoundRecordClient(RecordClient): + DOWNLOAD_EXTENSION = 'mp3' + # clients: Dict[str, SoundNodeClient] + + def make_clients(self, nodes: Dict[str, Addr]): + for node, addr in nodes.items(): + self.clients[node] = SoundNodeClient(addr) + self.awaiting[node] = {} + + +class CameraRecordClient(RecordClient): + DOWNLOAD_EXTENSION = 'mp4' + # clients: Dict[str, CameraNodeClient] + + def make_clients(self, nodes: Dict[str, Addr]): + for node, addr in nodes.items(): + self.clients[node] = CameraNodeClient(addr) + self.awaiting[node] = {} \ No newline at end of file diff --git a/py_include/homekit/media/storage.py b/py_include/homekit/media/storage.py new file mode 100644 index 0000000..dd74ff8 --- /dev/null +++ b/py_include/homekit/media/storage.py @@ -0,0 +1,210 @@ +import os +import re +import shutil +import logging + +from typing import Optional, Union, List +from datetime import datetime +from ..util import strgen + +logger = logging.getLogger(__name__) + + +# record file +# ----------- + +class RecordFile: + EXTENSION = None + + start_time: Optional[datetime] + stop_time: Optional[datetime] + record_id: Optional[int] + name: str + file_id: Optional[str] + remote: bool + remote_filesize: int + storage_root: str + + human_date_dmt = '%d.%m.%y' + human_time_fmt = '%H:%M:%S' + + @staticmethod + def create(filename: str, *args, **kwargs): + if filename.endswith(f'.{SoundRecordFile.EXTENSION}'): + return SoundRecordFile(filename, *args, **kwargs) + elif filename.endswith(f'.{CameraRecordFile.EXTENSION}'): + return CameraRecordFile(filename, *args, **kwargs) + else: + raise RuntimeError(f'unsupported file extension: {filename}') + + def __init__(self, filename: str, remote=False, remote_filesize=None, storage_root='/'): + if self.EXTENSION is None: + raise RuntimeError('this is abstract class') + + self.name = filename + self.storage_root = storage_root + + self.remote = remote + self.remote_filesize = remote_filesize + + m = re.match(r'^(\d{6}-\d{6})_(\d{6}-\d{6})_id(\d+)(_\w+)?\.'+self.EXTENSION+'$', filename) + if m: + self.start_time = datetime.strptime(m.group(1), RecordStorage.time_fmt) + self.stop_time = datetime.strptime(m.group(2), RecordStorage.time_fmt) + self.record_id = int(m.group(3)) + self.file_id = (m.group(1) + '_' + m.group(2)).replace('-', '_') + else: + logger.warning(f'unexpected filename: {filename}') + self.start_time = None + self.stop_time = None + self.record_id = None + self.file_id = None + + @property + def path(self): + if self.remote: + return RuntimeError('remote recording, can\'t get real path') + + return os.path.realpath(os.path.join( + self.storage_root, self.name + )) + + @property + def start_humantime(self) -> str: + if self.start_time is None: + return '?' + fmt = f'{RecordFile.human_date_dmt} {RecordFile.human_time_fmt}' + return self.start_time.strftime(fmt) + + @property + def stop_humantime(self) -> str: + if self.stop_time is None: + return '?' + fmt = RecordFile.human_time_fmt + if self.start_time.date() != self.stop_time.date(): + fmt = f'{RecordFile.human_date_dmt} {fmt}' + return self.stop_time.strftime(fmt) + + @property + def start_unixtime(self) -> int: + if self.start_time is None: + return 0 + return int(self.start_time.timestamp()) + + @property + def stop_unixtime(self) -> int: + if self.stop_time is None: + return 0 + return int(self.stop_time.timestamp()) + + @property + def filesize(self): + if self.remote: + if self.remote_filesize is None: + raise RuntimeError('file is remote and remote_filesize is not set') + return self.remote_filesize + return os.path.getsize(self.path) + + def __dict__(self) -> dict: + return { + 'start_unixtime': self.start_unixtime, + 'stop_unixtime': self.stop_unixtime, + 'filename': self.name, + 'filesize': self.filesize, + 'fileid': self.file_id, + 'record_id': self.record_id or 0, + } + + +class PseudoRecordFile(RecordFile): + EXTENSION = 'null' + + def __init__(self): + super().__init__('pseudo.null') + + @property + def filesize(self): + return 0 + + +class SoundRecordFile(RecordFile): + EXTENSION = 'mp3' + + +class CameraRecordFile(RecordFile): + EXTENSION = 'mp4' + + +# record storage +# -------------- + +class RecordStorage: + EXTENSION = None + + time_fmt = '%d%m%y-%H%M%S' + + def __init__(self, root: str): + if self.EXTENSION is None: + raise RuntimeError('this is abstract class') + + self.root = root + + def getfiles(self, as_objects=False) -> Union[List[str], List[RecordFile]]: + files = [] + for name in os.listdir(self.root): + path = os.path.join(self.root, name) + if os.path.isfile(path) and name.endswith(f'.{self.EXTENSION}'): + files.append(name if not as_objects else RecordFile.create(name, storage_root=self.root)) + return files + + def find(self, file_id: str) -> Optional[RecordFile]: + for name in os.listdir(self.root): + if os.path.isfile(os.path.join(self.root, name)) and name.endswith(f'.{self.EXTENSION}'): + item = RecordFile.create(name, storage_root=self.root) + if item.file_id == file_id: + return item + return None + + def purge(self): + files = self.getfiles() + if files: + logger = logging.getLogger(self.__name__) + for f in files: + try: + path = os.path.join(self.root, f) + logger.debug(f'purge: deleting {path}') + os.unlink(path) + except OSError as exc: + logger.exception(exc) + + def delete(self, file: RecordFile): + os.unlink(file.path) + + def save(self, + fn: str, + record_id: int, + start_time: int, + stop_time: int) -> RecordFile: + + start_time_s = datetime.fromtimestamp(start_time).strftime(self.time_fmt) + stop_time_s = datetime.fromtimestamp(stop_time).strftime(self.time_fmt) + + dst_fn = f'{start_time_s}_{stop_time_s}_id{record_id}' + if os.path.exists(os.path.join(self.root, dst_fn)): + dst_fn += strgen(4) + dst_fn += f'.{self.EXTENSION}' + dst_path = os.path.join(self.root, dst_fn) + + shutil.move(fn, dst_path) + return RecordFile.create(dst_fn, storage_root=self.root) + + +class SoundRecordStorage(RecordStorage): + EXTENSION = 'mp3' + + +class ESP32CameraRecordStorage(RecordStorage): + EXTENSION = 'jpg' # not used anyway + + def save(self, *args, **kwargs): + return PseudoRecordFile() \ No newline at end of file diff --git a/py_include/homekit/media/types.py b/py_include/homekit/media/types.py new file mode 100644 index 0000000..acbc291 --- /dev/null +++ b/py_include/homekit/media/types.py @@ -0,0 +1,13 @@ +from enum import Enum, auto + + +class MediaNodeType(Enum): + SOUND = auto() + CAMERA = auto() + + +class RecordStatus(Enum): + WAITING = auto() + RECORDING = auto() + FINISHED = auto() + ERROR = auto() diff --git a/py_include/homekit/mqtt/__init__.py b/py_include/homekit/mqtt/__init__.py new file mode 100644 index 0000000..707d59c --- /dev/null +++ b/py_include/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/py_include/homekit/mqtt/_config.py b/py_include/homekit/mqtt/_config.py new file mode 100644 index 0000000..9ba9443 --- /dev/null +++ b/py_include/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/py_include/homekit/mqtt/_module.py b/py_include/homekit/mqtt/_module.py new file mode 100644 index 0000000..80f27bb --- /dev/null +++ b/py_include/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/py_include/homekit/mqtt/_mqtt.py b/py_include/homekit/mqtt/_mqtt.py new file mode 100644 index 0000000..fb35a24 --- /dev/null +++ b/py_include/homekit/mqtt/_mqtt.py @@ -0,0 +1,86 @@ +import os.path +import paho.mqtt.client as mqtt +import ssl +import logging + +from ._config import MqttCreds, MqttConfig +from typing import Optional + + +class Mqtt: + _connected: bool + _is_server: bool + _mqtt_config: MqttConfig + + def __init__(self, + clean_session=True, + client_id='', + creds: Optional[MqttCreds] = None, + is_server=False): + if not client_id: + raise ValueError('client_id must not be empty') + + self._client = mqtt.Client(client_id=client_id, + protocol=mqtt.MQTTv311, + clean_session=clean_session) + self._client.on_connect = self.on_connect + self._client.on_disconnect = self.on_disconnect + self._client.on_message = self.on_message + self._client.on_log = self.on_log + self._client.on_publish = self.on_publish + self._loop_started = False + self._connected = False + self._is_server = is_server + self._mqtt_config = MqttConfig() + self._logger = logging.getLogger(self.__class__.__name__) + + if not creds: + creds = self._mqtt_config.creds() if not is_server else self._mqtt_config.server_creds() + + self._client.username_pw_set(creds.username, creds.password) + + def _configure_tls(self): + ca_certs = os.path.realpath(os.path.join( + os.path.dirname(os.path.realpath(__file__)), + '..', + '..', + '..', + 'misc', + 'mqtt_ca.crt' + )) + self._client.tls_set(ca_certs=ca_certs, + cert_reqs=ssl.CERT_REQUIRED, + tls_version=ssl.PROTOCOL_TLSv1_2) + + def connect_and_loop(self, loop_forever=True): + self._configure_tls() + addr = self._mqtt_config.local_addr() if self._is_server else self._mqtt_config.remote_addr() + self._client.connect(addr.host, addr.port, 60) + if loop_forever: + self._client.loop_forever() + else: + self._client.loop_start() + self._loop_started = True + + def disconnect(self): + self._client.disconnect() + self._client.loop_write() + self._client.loop_stop() + + def on_connect(self, client: mqtt.Client, userdata, flags, rc): + self._logger.info("Connected with result code " + str(rc)) + self._connected = True + + def on_disconnect(self, client: mqtt.Client, userdata, rc): + self._logger.info("Disconnected with result code " + str(rc)) + self._connected = False + + def on_log(self, client: mqtt.Client, userdata, level, buf): + level = mqtt.LOGGING_LEVEL[level] if level in mqtt.LOGGING_LEVEL else logging.INFO + self._logger.log(level, f'MQTT: {buf}') + + def on_message(self, client: mqtt.Client, userdata, msg): + self._logger.debug(msg.topic + ": " + str(msg.payload)) + + def on_publish(self, client: mqtt.Client, userdata, mid): + self._logger.debug(f'publish done, mid={mid}') diff --git a/py_include/homekit/mqtt/_node.py b/py_include/homekit/mqtt/_node.py new file mode 100644 index 0000000..4e259a4 --- /dev/null +++ b/py_include/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/py_include/homekit/mqtt/_payload.py b/py_include/homekit/mqtt/_payload.py new file mode 100644 index 0000000..58eeae3 --- /dev/null +++ b/py_include/homekit/mqtt/_payload.py @@ -0,0 +1,145 @@ +import struct +import abc +import re + +from typing import Optional, Tuple + + +def pldstr(self) -> str: + attrs = [] + for field in self.__class__.__annotations__: + if hasattr(self, field): + attr = getattr(self, field) + attrs.append(f'{field}={attr}') + if attrs: + attrs_s = ' ' + attrs_s += ', '.join(attrs) + else: + attrs_s = '' + return f'<%s{attrs_s}>' % (self.__class__.__name__,) + + +class MqttPayload(abc.ABC): + FORMAT = '' + PACKER = {} + UNPACKER = {} + + def __init__(self, **kwargs): + for field in self.__class__.__annotations__: + setattr(self, field, kwargs[field]) + + def pack(self): + args = [] + bf_number = -1 + bf_arg = 0 + bf_progress = 0 + + for field, field_type in self.__class__.__annotations__.items(): + bfp = _bit_field_params(field_type) + if bfp: + n, s, b = bfp + if n != bf_number: + if bf_number != -1: + args.append(bf_arg) + bf_number = n + bf_progress = 0 + bf_arg = 0 + bf_arg |= (getattr(self, field) & (2 ** b - 1)) << bf_progress + bf_progress += b + + else: + if bf_number != -1: + args.append(bf_arg) + bf_number = -1 + bf_progress = 0 + bf_arg = 0 + + args.append(self._pack_field(field)) + + if bf_number != -1: + args.append(bf_arg) + + return struct.pack(self.FORMAT, *args) + + @classmethod + def unpack(cls, buf: bytes): + data = struct.unpack(cls.FORMAT, buf) + kwargs = {} + i = 0 + bf_number = -1 + bf_progress = 0 + + for field, field_type in cls.__annotations__.items(): + bfp = _bit_field_params(field_type) + if bfp: + n, s, b = bfp + if n != bf_number: + bf_number = n + bf_progress = 0 + kwargs[field] = (data[i] >> bf_progress) & (2 ** b - 1) + bf_progress += b + continue # don't increment i + + if bf_number != -1: + bf_number = -1 + i += 1 + + if issubclass(field_type, MqttPayloadCustomField): + kwargs[field] = field_type.unpack(data[i]) + else: + kwargs[field] = cls._unpack_field(field, data[i]) + i += 1 + + return cls(**kwargs) + + def _pack_field(self, name): + val = getattr(self, name) + if self.PACKER and name in self.PACKER: + return self.PACKER[name](val) + else: + return val + + @classmethod + def _unpack_field(cls, name, val): + if isinstance(val, MqttPayloadCustomField): + return + if cls.UNPACKER and name in cls.UNPACKER: + return cls.UNPACKER[name](val) + else: + return val + + def __str__(self): + return pldstr(self) + + +class MqttPayloadCustomField(abc.ABC): + def __init__(self, **kwargs): + for field in self.__class__.__annotations__: + setattr(self, field, kwargs[field]) + + @abc.abstractmethod + def __index__(self): + pass + + @classmethod + @abc.abstractmethod + def unpack(cls, *args, **kwargs): + pass + + def __str__(self): + return pldstr(self) + + +def bit_field(seq_no: int, total_bits: int, bits: int): + return type(f'MQTTPayloadBitField_{seq_no}_{total_bits}_{bits}', (object,), { + 'seq_no': seq_no, + 'total_bits': total_bits, + 'bits': bits + }) + + +def _bit_field_params(cl) -> Optional[Tuple[int, ...]]: + match = re.match(r'MQTTPayloadBitField_(\d+)_(\d+)_(\d)$', cl.__name__) + if match is not None: + return tuple([int(match.group(i)) for i in range(1, 4)]) + return None \ No newline at end of file diff --git a/py_include/homekit/mqtt/_util.py b/py_include/homekit/mqtt/_util.py new file mode 100644 index 0000000..390d463 --- /dev/null +++ b/py_include/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/py_include/homekit/mqtt/_wrapper.py b/py_include/homekit/mqtt/_wrapper.py new file mode 100644 index 0000000..3c2774c --- /dev/null +++ b/py_include/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/py_include/homekit/mqtt/module/diagnostics.py b/py_include/homekit/mqtt/module/diagnostics.py new file mode 100644 index 0000000..5db5e99 --- /dev/null +++ b/py_include/homekit/mqtt/module/diagnostics.py @@ -0,0 +1,64 @@ +from .._payload import MqttPayload, MqttPayloadCustomField +from .._node import MqttNode, MqttModule +from typing import Optional + +MODULE_NAME = 'MqttDiagnosticsModule' + + +class DiagnosticsFlags(MqttPayloadCustomField): + state: bool + config_changed_value_present: bool + config_changed: bool + + @staticmethod + def unpack(flags: int): + # _logger.debug(f'StatFlags.unpack: flags={flags}') + state = flags & 0x1 + ccvp = (flags >> 1) & 0x1 + cc = (flags >> 2) & 0x1 + # _logger.debug(f'StatFlags.unpack: state={state}') + return DiagnosticsFlags(state=(state == 1), + config_changed_value_present=(ccvp == 1), + config_changed=(cc == 1)) + + def __index__(self): + bits = 0 + bits |= (int(self.state) & 0x1) + bits |= (int(self.config_changed_value_present) & 0x1) << 1 + bits |= (int(self.config_changed) & 0x1) << 2 + return bits + + +class InitialDiagnosticsPayload(MqttPayload): + FORMAT = '=IBbIB' + + ip: int + fw_version: int + rssi: int + free_heap: int + flags: DiagnosticsFlags + + +class DiagnosticsPayload(MqttPayload): + FORMAT = '=bIB' + + rssi: int + free_heap: int + flags: DiagnosticsFlags + + +class MqttDiagnosticsModule(MqttModule): + def on_connect(self, mqtt: MqttNode): + super().on_connect(mqtt) + for topic in ('diag', 'd1ag', 'stat', 'stat1'): + mqtt.subscribe_module(topic, self) + + def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: + message = None + if topic in ('stat', 'diag'): + message = DiagnosticsPayload.unpack(payload) + elif topic in ('stat1', 'd1ag'): + message = InitialDiagnosticsPayload.unpack(payload) + if message: + self._logger.debug(message) + return message diff --git a/py_include/homekit/mqtt/module/inverter.py b/py_include/homekit/mqtt/module/inverter.py new file mode 100644 index 0000000..29bde0a --- /dev/null +++ b/py_include/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/py_include/homekit/mqtt/module/ota.py b/py_include/homekit/mqtt/module/ota.py new file mode 100644 index 0000000..cd34332 --- /dev/null +++ b/py_include/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/py_include/homekit/mqtt/module/relay.py b/py_include/homekit/mqtt/module/relay.py new file mode 100644 index 0000000..e968031 --- /dev/null +++ b/py_include/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/py_include/homekit/mqtt/module/temphum.py b/py_include/homekit/mqtt/module/temphum.py new file mode 100644 index 0000000..fd02cca --- /dev/null +++ b/py_include/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/py_include/homekit/pio/__init__.py b/py_include/homekit/pio/__init__.py new file mode 100644 index 0000000..7216bc4 --- /dev/null +++ b/py_include/homekit/pio/__init__.py @@ -0,0 +1 @@ +from .products import get_products, platformio_ini \ No newline at end of file diff --git a/py_include/homekit/pio/exceptions.py b/py_include/homekit/pio/exceptions.py new file mode 100644 index 0000000..a6afd20 --- /dev/null +++ b/py_include/homekit/pio/exceptions.py @@ -0,0 +1,2 @@ +class ProductConfigNotFoundError(Exception): + pass diff --git a/py_include/homekit/pio/products.py b/py_include/homekit/pio/products.py new file mode 100644 index 0000000..388da03 --- /dev/null +++ b/py_include/homekit/pio/products.py @@ -0,0 +1,113 @@ +import os +import logging + +from io import StringIO +from collections import OrderedDict + + +_logger = logging.getLogger(__name__) +_products_dir = os.path.join( + os.path.dirname(__file__), + '..', '..', '..', + 'platformio' +) + + +def get_products(): + products = [] + for f in os.listdir(_products_dir): + if f in ('common',): + continue + + if os.path.isdir(os.path.join(_products_dir, f)): + products.append(f) + + return products + + +def platformio_ini(product_config: dict, + target: str, + # node_id: str, + build_specific_defines: dict, + build_specific_defines_enums: list[str], + platform: str, + framework: str = 'arduino', + upload_port: str = '/dev/ttyUSB0', + monitor_speed: int = 115200, + debug=False, + debug_network=False) -> str: + node_id = build_specific_defines['CONFIG_NODE_ID'] + + # defines + defines = { + **product_config['common_defines'], + 'CONFIG_NODE_ID': node_id, + 'CONFIG_WIFI_AP_SSID': ('HK_'+node_id)[:31] + } + try: + defines.update(product_config['target_defines'][target]) + except KeyError: + pass + defines['CONFIG_NODE_SECRET_SIZE'] = len(defines['CONFIG_NODE_SECRET']) + defines['CONFIG_MQTT_CLIENT_ID'] = node_id + + build_type = 'release' + if debug: + defines['DEBUG'] = True + build_type = 'debug' + if debug_network: + defines['DEBUG'] = True + defines['DEBUG_ESP_SSL'] = True + defines['DEBUG_ESP_PORT'] = 'Serial' + build_type = 'debug' + if build_specific_defines: + for k, v in build_specific_defines.items(): + defines[k] = v + defines = OrderedDict(sorted(defines.items(), key=lambda t: t[0])) + + # libs + libs = [] + if 'common_libs' in product_config: + libs.extend(product_config['common_libs']) + if 'target_libs' in product_config and target in product_config['target_libs']: + libs.extend(product_config['target_libs'][target]) + libs = list(set(libs)) + libs.sort() + + try: + target_real_name = product_config['target_board_names'][target] + except KeyError: + target_real_name = target + + buf = StringIO() + + buf.write('; Generated by pio_ini.py\n\n') + buf.write(f'[env:{target_real_name}]\n') + buf.write(f'platform = {platform}\n') + buf.write(f'board = {target_real_name}\n') + buf.write(f'framework = {framework}\n') + buf.write(f'upload_port = {upload_port}\n') + buf.write(f'monitor_speed = {monitor_speed}\n') + if libs: + buf.write(f'lib_deps =') + for lib in libs: + buf.write(f' {lib}\n') + buf.write(f'build_flags =\n') + if defines: + for name, value in defines.items(): + buf.write(f' -D{name}') + is_enum = name in build_specific_defines_enums + if type(value) is not bool: + buf.write('=') + if type(value) is str: + if not is_enum: + buf.write('"\\"') + value = value.replace('"', '\\"') + buf.write(f'{value}') + if type(value) is str and not is_enum: + buf.write('"\\"') + buf.write('\n') + buf.write(f' -I../common/include') + buf.write(f'\nbuild_type = {build_type}') + + return buf.getvalue() diff --git a/py_include/homekit/relay/__init__.py b/py_include/homekit/relay/__init__.py new file mode 100644 index 0000000..406403d --- /dev/null +++ b/py_include/homekit/relay/__init__.py @@ -0,0 +1,16 @@ +import importlib + +__all__ = ['RelayClient', 'RelayServer'] + + +def __getattr__(name): + _map = { + 'RelayClient': '.sunxi_h3_client', + 'RelayServer': '.sunxi_h3_server' + } + + if name in __all__: + module = importlib.import_module(_map[name], __name__) + return getattr(module, name) + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/py_include/homekit/relay/__init__.pyi b/py_include/homekit/relay/__init__.pyi new file mode 100644 index 0000000..7a4a2f4 --- /dev/null +++ b/py_include/homekit/relay/__init__.pyi @@ -0,0 +1,2 @@ +from .sunxi_h3_client import RelayClient as RelayClient +from .sunxi_h3_server import RelayServer as RelayServer diff --git a/py_include/homekit/relay/sunxi_h3_client.py b/py_include/homekit/relay/sunxi_h3_client.py new file mode 100644 index 0000000..8c8d6c4 --- /dev/null +++ b/py_include/homekit/relay/sunxi_h3_client.py @@ -0,0 +1,39 @@ +import socket + + +class RelayClient: + def __init__(self, port=8307, host='127.0.0.1'): + self._host = host + self._port = port + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + def __del__(self): + self.sock.close() + + def connect(self): + self.sock.connect((self._host, self._port)) + + def _write(self, line): + self.sock.sendall((line+'\r\n').encode()) + + def _read(self): + buf = bytearray() + while True: + buf.extend(self.sock.recv(256)) + if b'\r\n' in buf: + break + + response = buf.decode().strip() + return response + + def on(self): + self._write('on') + return self._read() + + def off(self): + self._write('off') + return self._read() + + def status(self): + self._write('get') + return self._read() diff --git a/py_include/homekit/relay/sunxi_h3_server.py b/py_include/homekit/relay/sunxi_h3_server.py new file mode 100644 index 0000000..1f33969 --- /dev/null +++ b/py_include/homekit/relay/sunxi_h3_server.py @@ -0,0 +1,82 @@ +import asyncio +import logging + +from pyA20.gpio import gpio +from pyA20.gpio import port as gpioport +from ..util import Addr + +logger = logging.getLogger(__name__) + + +class RelayServer: + OFF = 1 + ON = 0 + + def __init__(self, + pinname: str, + addr: Addr): + if not hasattr(gpioport, pinname): + raise ValueError(f'invalid pin {pinname}') + + self.pin = getattr(gpioport, pinname) + self.addr = addr + + gpio.init() + gpio.setcfg(self.pin, gpio.OUTPUT) + + self.lock = asyncio.Lock() + + def run(self): + asyncio.run(self.run_server()) + + async def relay_set(self, value): + async with self.lock: + gpio.output(self.pin, value) + + async def relay_get(self): + async with self.lock: + return int(gpio.input(self.pin)) == RelayServer.ON + + async def handle_client(self, reader, writer): + request = None + while request != 'quit': + try: + request = await reader.read(255) + if request == b'\x04': + break + request = request.decode('utf-8').strip() + except Exception: + break + + data = 'unknown' + if request == 'on': + await self.relay_set(RelayServer.ON) + logger.debug('set on') + data = 'ok' + + elif request == 'off': + await self.relay_set(RelayServer.OFF) + logger.debug('set off') + data = 'ok' + + elif request == 'get': + status = await self.relay_get() + data = 'on' if status is True else 'off' + + writer.write((data + '\r\n').encode('utf-8')) + try: + await writer.drain() + except ConnectionError: + break + + try: + writer.close() + except ConnectionError: + pass + + async def run_server(self): + host, port = self.addr + server = await asyncio.start_server(self.handle_client, host, port) + async with server: + logger.info('Server started.') + await server.serve_forever() diff --git a/py_include/homekit/soundsensor/__init__.py b/py_include/homekit/soundsensor/__init__.py new file mode 100644 index 0000000..30052f8 --- /dev/null +++ b/py_include/homekit/soundsensor/__init__.py @@ -0,0 +1,22 @@ +import importlib + +__all__ = [ + 'SoundSensorNode', + 'SoundSensorHitHandler', + 'SoundSensorServer', + 'SoundSensorServerGuardClient' +] + + +def __getattr__(name): + if name in __all__: + if name == 'SoundSensorNode': + file = 'node' + elif name == 'SoundSensorServerGuardClient': + file = 'server_client' + else: + file = 'server' + module = importlib.import_module(f'.{file}', __name__) + return getattr(module, name) + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/py_include/homekit/soundsensor/__init__.pyi b/py_include/homekit/soundsensor/__init__.pyi new file mode 100644 index 0000000..cb34972 --- /dev/null +++ b/py_include/homekit/soundsensor/__init__.pyi @@ -0,0 +1,8 @@ +from .server import ( + SoundSensorHitHandler as SoundSensorHitHandler, + SoundSensorServer as SoundSensorServer, +) +from .server_client import ( + SoundSensorServerGuardClient as SoundSensorServerGuardClient +) +from .node import SoundSensorNode as SoundSensorNode diff --git a/py_include/homekit/soundsensor/node.py b/py_include/homekit/soundsensor/node.py new file mode 100644 index 0000000..292452f --- /dev/null +++ b/py_include/homekit/soundsensor/node.py @@ -0,0 +1,75 @@ +import logging +import threading + +from typing import Optional +from time import sleep +from ..util import stringify, send_datagram, Addr + +from pyA20.gpio import gpio +from pyA20.gpio import port as gpioport + +logger = logging.getLogger(__name__) + + +class SoundSensorNode: + def __init__(self, + name: str, + pinname: str, + server_addr: Optional[Addr], + threshold: int = 1, + delay=0.005): + + if not hasattr(gpioport, pinname): + raise ValueError(f'invalid pin {pinname}') + + self.pin = getattr(gpioport, pinname) + self.name = name + self.delay = delay + self.threshold = threshold + + self.server_addr = server_addr + + self.hits = 0 + self.hitlock = threading.Lock() + + self.interrupted = False + + def run(self): + try: + t = threading.Thread(target=self.sensor_reader) + t.daemon = True + t.start() + + while True: + with self.hitlock: + hits = self.hits + self.hits = 0 + + if hits >= self.threshold: + try: + if self.server_addr is not None: + send_datagram(stringify([self.name, hits]), self.server_addr) + else: + logger.debug(f'server reporting disabled, skipping reporting {hits} hits') + except OSError as exc: + logger.exception(exc) + + sleep(1) + + except (KeyboardInterrupt, SystemExit) as e: + self.interrupted = True + logger.info(str(e)) + + def sensor_reader(self): + gpio.init() + gpio.setcfg(self.pin, gpio.INPUT) + gpio.pullup(self.pin, gpio.PULLUP) + + while not self.interrupted: + state = gpio.input(self.pin) + sleep(self.delay) + + if not state: + with self.hitlock: + logger.debug('got a hit') + self.hits += 1 diff --git a/py_include/homekit/soundsensor/server.py b/py_include/homekit/soundsensor/server.py new file mode 100644 index 0000000..a627390 --- /dev/null +++ b/py_include/homekit/soundsensor/server.py @@ -0,0 +1,128 @@ +import asyncio +import json +import logging +import threading + +from ..database.sqlite import SQLiteBase +from ..config import config +from .. import http + +from typing import Type +from ..util import Addr + +logger = logging.getLogger(__name__) + + +class SoundSensorHitHandler(asyncio.DatagramProtocol): + def datagram_received(self, data, addr): + try: + data = json.loads(data) + except json.JSONDecodeError as e: + logger.error('failed to parse json datagram') + logger.exception(e) + return + + try: + name, hits = data + except (ValueError, IndexError) as e: + logger.error('failed to unpack data') + logger.exception(e) + return + + self.handler(name, hits) + + def handler(self, name: str, hits: int): + pass + + +class Database(SQLiteBase): + SCHEMA = 1 + + def __init__(self): + super().__init__(dbname='sound_sensor_server') + + def schema_init(self, version: int) -> None: + cursor = self.cursor() + + if version < 1: + cursor.execute("CREATE TABLE IF NOT EXISTS status (guard_enabled INTEGER NOT NULL)") + cursor.execute("INSERT INTO status (guard_enabled) VALUES (-1)") + + self.commit() + + def get_guard_enabled(self) -> int: + cur = self.cursor() + cur.execute("SELECT guard_enabled FROM status LIMIT 1") + return int(cur.fetchone()[0]) + + def set_guard_enabled(self, enabled: bool) -> None: + cur = self.cursor() + cur.execute("UPDATE status SET guard_enabled=?", (int(enabled),)) + self.commit() + + +class SoundSensorServer: + def __init__(self, + addr: Addr, + handler_impl: Type[SoundSensorHitHandler]): + self.addr = addr + self.impl = handler_impl + self.db = Database() + + self._recording_lock = threading.Lock() + self._recording_enabled = True + + if self.guard_control_enabled(): + current_status = self.db.get_guard_enabled() + if current_status == -1: + self.set_recording(config['server']['guard_recording_default'] + if 'guard_recording_default' in config['server'] + else False, + update=False) + else: + self.set_recording(bool(current_status), update=False) + + @staticmethod + def guard_control_enabled() -> bool: + return 'guard_control' in config['server'] and config['server']['guard_control'] is True + + def set_recording(self, enabled: bool, update=True): + with self._recording_lock: + self._recording_enabled = enabled + if update: + self.db.set_guard_enabled(enabled) + + def is_recording_enabled(self) -> bool: + with self._recording_lock: + return self._recording_enabled + + def run(self): + if self.guard_control_enabled(): + t = threading.Thread(target=self.run_guard_server) + t.daemon = True + t.start() + + loop = asyncio.get_event_loop() + t = loop.create_datagram_endpoint(self.impl, local_addr=self.addr) + loop.run_until_complete(t) + loop.run_forever() + + def run_guard_server(self): + routes = http.routes() + + @routes.post('/guard/enable') + async def guard_enable(request): + self.set_recording(True) + return http.ok() + + @routes.post('/guard/disable') + async def guard_disable(request): + self.set_recording(False) + return http.ok() + + @routes.get('/guard/status') + async def guard_status(request): + return http.ok({'enabled': self.is_recording_enabled()}) + + asyncio.set_event_loop(asyncio.new_event_loop()) # need to create new event loop in new thread + http.serve(self.addr, routes, handle_signals=False) # handle_signals=True doesn't work in separate thread diff --git a/py_include/homekit/soundsensor/server_client.py b/py_include/homekit/soundsensor/server_client.py new file mode 100644 index 0000000..7eef996 --- /dev/null +++ b/py_include/homekit/soundsensor/server_client.py @@ -0,0 +1,38 @@ +import requests +import logging + +from ..util import Addr +from ..api.errors import ApiResponseError + + +class SoundSensorServerGuardClient: + def __init__(self, addr: Addr): + self.endpoint = f'http://{addr[0]}:{addr[1]}' + self.logger = logging.getLogger(self.__class__.__name__) + + def guard_enable(self): + return self._call('guard/enable', is_post=True) + + def guard_disable(self): + return self._call('guard/disable', is_post=True) + + def guard_status(self): + return self._call('guard/status') + + def _call(self, + method: str, + is_post=False): + + url = f'{self.endpoint}/{method}' + self.logger.debug(f'calling {url}') + + r = requests.get(url) if not is_post else requests.post(url) + + if r.status_code != 200: + response = r.json() + raise ApiResponseError(status_code=r.status_code, + error_type=response['error'], + error_message=response['message'] or None, + error_stacktrace=response['stacktrace'] if 'stacktrace' in response else None) + + return r.json()['response'] diff --git a/py_include/homekit/telegram/__init__.py b/py_include/homekit/telegram/__init__.py new file mode 100644 index 0000000..a68dae1 --- /dev/null +++ b/py_include/homekit/telegram/__init__.py @@ -0,0 +1 @@ +from .telegram import send_message, send_photo diff --git a/py_include/homekit/telegram/_botcontext.py b/py_include/homekit/telegram/_botcontext.py new file mode 100644 index 0000000..a143bfe --- /dev/null +++ b/py_include/homekit/telegram/_botcontext.py @@ -0,0 +1,86 @@ +from typing import Optional, List + +from telegram import Update, User, CallbackQuery +from telegram.constants import ParseMode +from telegram.ext import CallbackContext + +from ._botdb import BotDatabase +from ._botlang import lang +from ._botutil import IgnoreMarkup, exc2text + + +class Context: + _update: Optional[Update] + _callback_context: Optional[CallbackContext] + _markup_getter: callable + db: Optional[BotDatabase] + _user_lang: Optional[str] + + def __init__(self, + update: Optional[Update], + callback_context: Optional[CallbackContext], + markup_getter: callable, + store: Optional[BotDatabase]): + self._update = update + self._callback_context = callback_context + self._markup_getter = markup_getter + self._store = store + self._user_lang = None + + async def reply(self, text, markup=None): + if markup is None: + markup = self._markup_getter(self) + kwargs = dict(parse_mode=ParseMode.HTML) + if not isinstance(markup, IgnoreMarkup): + kwargs['reply_markup'] = markup + return await self._update.message.reply_text(text, **kwargs) + + async def reply_exc(self, e: Exception) -> None: + await self.reply(exc2text(e), markup=IgnoreMarkup()) + + async def answer(self, text: str = None): + await self.callback_query.answer(text) + + async def edit(self, text, markup=None): + kwargs = dict(parse_mode=ParseMode.HTML) + if not isinstance(markup, IgnoreMarkup): + kwargs['reply_markup'] = markup + await self.callback_query.edit_message_text(text, **kwargs) + + @property + def text(self) -> str: + return self._update.message.text + + @property + def callback_query(self) -> CallbackQuery: + return self._update.callback_query + + @property + def args(self) -> Optional[List[str]]: + return self._callback_context.args + + @property + def user_id(self) -> int: + return self.user.id + + @property + def user_data(self): + return self._callback_context.user_data + + @property + def user(self) -> User: + return self._update.effective_user + + @property + def user_lang(self) -> str: + if self._user_lang is None: + self._user_lang = self._store.get_user_lang(self.user_id) + return self._user_lang + + def lang(self, key: str, *args) -> str: + return lang.get(key, self.user_lang, *args) + + def is_callback_context(self) -> bool: + return self._update.callback_query \ + and self._update.callback_query.data \ + and self._update.callback_query.data != '' diff --git a/py_include/homekit/telegram/_botdb.py b/py_include/homekit/telegram/_botdb.py new file mode 100644 index 0000000..4e1aec0 --- /dev/null +++ b/py_include/homekit/telegram/_botdb.py @@ -0,0 +1,32 @@ +from homekit.database.sqlite import SQLiteBase + + +class BotDatabase(SQLiteBase): + def __init__(self): + super().__init__() + + def schema_init(self, version: int) -> None: + if version < 1: + cursor = self.cursor() + cursor.execute("""CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + lang TEXT NOT NULL + )""") + self.commit() + + def get_user_lang(self, user_id: int, default: str = 'en') -> str: + cursor = self.cursor() + cursor.execute('SELECT lang FROM users WHERE id=?', (user_id,)) + row = cursor.fetchone() + + if row is None: + cursor.execute('INSERT INTO users (id, lang) VALUES (?, ?)', (user_id, default)) + self.commit() + return default + else: + return row[0] + + def set_user_lang(self, user_id: int, lang: str) -> None: + cursor = self.cursor() + cursor.execute('UPDATE users SET lang=? WHERE id=?', (lang, user_id)) + self.commit() diff --git a/py_include/homekit/telegram/_botlang.py b/py_include/homekit/telegram/_botlang.py new file mode 100644 index 0000000..f5f85bb --- /dev/null +++ b/py_include/homekit/telegram/_botlang.py @@ -0,0 +1,120 @@ +import logging + +from typing import Optional, Dict, List, Union + +_logger = logging.getLogger(__name__) + + +class LangStrings(dict): + _lang: Optional[str] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._lang = None + + def setlang(self, lang: str): + self._lang = lang + + def __missing__(self, key): + _logger.warning(f'key {key} is missing in language {self._lang}') + return '{%s}' % key + + def __setitem__(self, key, value): + raise NotImplementedError(f'setting translation strings this way is prohibited (was trying to set {key}={value})') + + +class LangPack: + strings: Dict[str, LangStrings[str, str]] + default_lang: str + + def __init__(self): + self.strings = {} + self.default_lang = 'en' + + def ru(self, **kwargs) -> None: + self.set(kwargs, 'ru') + + def en(self, **kwargs) -> None: + self.set(kwargs, 'en') + + def set(self, + strings: Union[LangStrings, dict], + lang: str) -> None: + + if isinstance(strings, dict) and not isinstance(strings, LangStrings): + strings = LangStrings(**strings) + strings.setlang(lang) + + if lang not in self.strings: + self.strings[lang] = strings + else: + self.strings[lang].update(strings) + + def all(self, key): + result = [] + for strings in self.strings.values(): + result.append(strings[key]) + return result + + @property + def languages(self) -> List[str]: + return list(self.strings.keys()) + + def get(self, key: str, lang: str, *args) -> str: + if args: + return self.strings[lang][key] % args + else: + return self.strings[lang][key] + + def get_langpack(self, _lang: str) -> dict: + return self.strings[_lang] + + def __call__(self, *args, **kwargs): + return self.strings[self.default_lang][args[0]] + + def __getitem__(self, key): + return self.strings[self.default_lang][key] + + def __setitem__(self, key, value): + raise NotImplementedError('setting translation strings this way is prohibited') + + def __contains__(self, key): + return key in self.strings[self.default_lang] + + @staticmethod + def pfx(prefix: str, l: list) -> list: + return list(map(lambda s: f'{prefix}{s}', l)) + + + +languages = { + 'en': 'English', + 'ru': 'Русский' +} + + +lang = LangPack() +lang.en( + en='English', + ru='Russian', + start_message="Select command on the keyboard.", + unknown_message="Unknown message", + cancel="🚫 Cancel", + back='🔙 Back', + select_language="Select language on the keyboard.", + invalid_language="Invalid language. Please try again.", + saved='Saved.', + please_wait="⏳ Please wait..." +) +lang.ru( + en='Английский', + ru='Русский', + start_message="Выберите команду на клавиатуре.", + unknown_message="Неизвестная команда", + cancel="🚫 Отмена", + back='🔙 Назад', + select_language="Выберите язык на клавиатуре.", + invalid_language="Неверный язык. Пожалуйста, попробуйте снова", + saved="Настройки сохранены.", + please_wait="⏳ Ожидайте..." +) \ No newline at end of file diff --git a/py_include/homekit/telegram/_botutil.py b/py_include/homekit/telegram/_botutil.py new file mode 100644 index 0000000..111a704 --- /dev/null +++ b/py_include/homekit/telegram/_botutil.py @@ -0,0 +1,47 @@ +import logging +import traceback + +from html import escape +from telegram import User +from homekit.api import WebApiClient as APIClient +from homekit.api.types import BotType +from homekit.api.errors import ApiResponseError + +_logger = logging.getLogger(__name__) + + +def user_any_name(user: User) -> str: + name = [user.first_name, user.last_name] + name = list(filter(lambda s: s is not None, name)) + name = ' '.join(name).strip() + + if not name: + name = user.username + + if not name: + name = str(user.id) + + return name + + +class ReportingHelper: + def __init__(self, client: APIClient, bot_type: BotType): + self.client = client + self.bot_type = bot_type + + def report(self, message, text: str = None) -> None: + if text is None: + text = message.text + try: + self.client.log_bot_request(self.bot_type, message.chat_id, text) + except ApiResponseError as error: + _logger.exception(error) + + +def exc2text(e: Exception) -> str: + tb = ''.join(traceback.format_tb(e.__traceback__)) + return f'{e.__class__.__name__}: ' + escape(str(e)) + "\n\n" + escape(tb) + + +class IgnoreMarkup: + pass diff --git a/py_include/homekit/telegram/aio.py b/py_include/homekit/telegram/aio.py new file mode 100644 index 0000000..fc87c1c --- /dev/null +++ b/py_include/homekit/telegram/aio.py @@ -0,0 +1,18 @@ +import functools +import asyncio + +from .telegram import ( + send_message as _send_message_sync, + send_photo as _send_photo_sync +) + + +async def send_message(*args, **kwargs): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, functools.partial(_send_message_sync, *args, **kwargs)) + + +async def send_photo(*args, **kwargs): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, functools.partial(_send_photo_sync, *args, **kwargs)) + diff --git a/py_include/homekit/telegram/bot.py b/py_include/homekit/telegram/bot.py new file mode 100644 index 0000000..2e33bea --- /dev/null +++ b/py_include/homekit/telegram/bot.py @@ -0,0 +1,583 @@ +from __future__ import annotations + +import logging +import itertools + +from enum import Enum, auto +from functools import wraps +from typing import Optional, Union, Tuple, Coroutine + +from telegram import Update, ReplyKeyboardMarkup +from telegram.ext import ( + Application, + filters, + CommandHandler, + MessageHandler, + CallbackQueryHandler, + CallbackContext, + ConversationHandler +) +from telegram.ext.filters import BaseFilter +from telegram.error import TimedOut + +from homekit.config import config +from homekit.api import WebApiClient +from homekit.api.types import BotType + +from ._botlang import lang, languages +from ._botdb import BotDatabase +from ._botutil import ReportingHelper, exc2text, IgnoreMarkup, user_any_name +from ._botcontext import Context + + +db: Optional[BotDatabase] = None + +_user_filter: Optional[BaseFilter] = None +_cancel_filter = filters.Text(lang.all('cancel')) +_back_filter = filters.Text(lang.all('back')) +_cancel_and_back_filter = filters.Text(lang.all('back') + lang.all('cancel')) + +_logger = logging.getLogger(__name__) +_application: Optional[Application] = None +_reporting: Optional[ReportingHelper] = None +_exception_handler: Optional[Coroutine] = None +_dispatcher = None +_markup_getter: Optional[callable] = None +_start_handler_ref: Optional[Coroutine] = None + + +def text_filter(*args): + if not _user_filter: + raise RuntimeError('user_filter is not initialized') + return filters.Text(args[0] if isinstance(args[0], list) else [*args]) & _user_filter + + +async def _handler_of_handler(*args, **kwargs): + self = None + context = None + update = None + + _args = list(args) + while len(_args): + v = _args[0] + if isinstance(v, conversation): + self = v + _args.pop(0) + elif isinstance(v, Update): + update = v + _args.pop(0) + elif isinstance(v, CallbackContext): + context = v + _args.pop(0) + break + + ctx = Context(update, + callback_context=context, + markup_getter=lambda _ctx: None if not _markup_getter else _markup_getter(_ctx), + store=db) + try: + _args.insert(0, ctx) + + f = kwargs['f'] + del kwargs['f'] + + if 'return_with_context' in kwargs: + return_with_context = True + del kwargs['return_with_context'] + else: + return_with_context = False + + if 'argument' in kwargs and kwargs['argument'] == 'message_key': + del kwargs['argument'] + mkey = None + for k, v in lang.get_langpack(ctx.user_lang).items(): + if ctx.text == v: + mkey = k + break + _args.insert(0, mkey) + + if self: + _args.insert(0, self) + + result = await f(*_args, **kwargs) + return result if not return_with_context else (result, ctx) + + except Exception as e: + if _exception_handler: + if not _exception_handler(e, ctx) and not isinstance(e, TimedOut): + _logger.exception(e) + if not ctx.is_callback_context(): + await ctx.reply_exc(e) + else: + notify_user(ctx.user_id, exc2text(e)) + else: + _logger.exception(e) + + +def handler(**kwargs): + def inner(f): + @wraps(f) + async def _handler(*args, **inner_kwargs): + if 'argument' in kwargs and kwargs['argument'] == 'message_key': + inner_kwargs['argument'] = 'message_key' + return await _handler_of_handler(f=f, *args, **inner_kwargs) + + messages = [] + texts = [] + + if 'messages' in kwargs: + messages += kwargs['messages'] + if 'message' in kwargs: + messages.append(kwargs['message']) + + if 'text' in kwargs: + texts.append(kwargs['text']) + if 'texts' in kwargs: + texts += kwargs['texts'] + + if messages or texts: + new_messages = list(itertools.chain.from_iterable([lang.all(m) for m in messages])) + texts += new_messages + texts = list(set(texts)) + _application.add_handler( + MessageHandler(text_filter(*texts), _handler), + group=0 + ) + + if 'command' in kwargs: + _application.add_handler(CommandHandler(kwargs['command'], _handler), group=0) + + if 'callback' in kwargs: + _application.add_handler(CallbackQueryHandler(_handler, pattern=kwargs['callback']), group=0) + + return _handler + + return inner + + +def simplehandler(f: Coroutine): + @wraps(f) + async def _handler(*args, **kwargs): + return await _handler_of_handler(f=f, *args, **kwargs) + return _handler + + +def callbackhandler(*args, **kwargs): + def inner(f): + @wraps(f) + async def _handler(*args, **kwargs): + return await _handler_of_handler(f=f, *args, **kwargs) + pattern_kwargs = {} + if kwargs['callback'] != '*': + pattern_kwargs['pattern'] = kwargs['callback'] + _application.add_handler(CallbackQueryHandler(_handler, **pattern_kwargs), group=0) + return _handler + return inner + + +async def exceptionhandler(f: callable): + global _exception_handler + if _exception_handler: + _logger.warning('exception handler already set, we will overwrite it') + _exception_handler = f + + +def defaultreplymarkup(f: callable): + global _markup_getter + _markup_getter = f + + +def convinput(state, is_enter=False, **kwargs): + def inner(f): + f.__dict__['_conv_data'] = dict( + orig_f=f, + enter=is_enter, + type=ConversationMethodType.ENTRY if is_enter and state == 0 else ConversationMethodType.STATE_HANDLER, + state=state, + **kwargs + ) + + @wraps(f) + async def _impl(*args, **kwargs): + result, ctx = await _handler_of_handler(f=f, *args, **kwargs, return_with_context=True) + if result == conversation.END: + await start(ctx) + return result + + return _impl + + return inner + + +def conventer(state, **kwargs): + return convinput(state, is_enter=True, **kwargs) + + +class ConversationMethodType(Enum): + ENTRY = auto() + STATE_HANDLER = auto() + + +class conversation: + END = ConversationHandler.END + STATE_SEQS = [] + + def __init__(self, enable_back=False): + self._logger = logging.getLogger(self.__class__.__name__) + self._user_state_cache = {} + self._back_enabled = enable_back + + def make_handlers(self, f: callable, **kwargs) -> list: + messages = {} + handlers = [] + + if 'messages' in kwargs: + if isinstance(kwargs['messages'], dict): + messages = kwargs['messages'] + else: + for m in kwargs['messages']: + messages[m] = None + + if 'message' in kwargs: + if isinstance(kwargs['message'], str): + messages[kwargs['message']] = None + else: + AttributeError('invalid message type: ' + type(kwargs['message'])) + + if messages: + for message, target_state in messages.items(): + if not target_state: + handlers.append(MessageHandler(text_filter(lang.all(message) if 'messages_lang_completed' not in kwargs else message), f)) + else: + handlers.append(MessageHandler(text_filter(lang.all(message) if 'messages_lang_completed' not in kwargs else message), self.make_invoker(target_state))) + + if 'regex' in kwargs: + handlers.append(MessageHandler(filters.Regex(kwargs['regex']) & _user_filter, f)) + + if 'command' in kwargs: + handlers.append(CommandHandler(kwargs['command'], f, _user_filter)) + + return handlers + + def make_invoker(self, state): + def _invoke(update: Update, context: CallbackContext): + ctx = Context(update, + callback_context=context, + markup_getter=lambda _ctx: None if not _markup_getter else _markup_getter(_ctx), + store=db) + return self.invoke(state, ctx) + return _invoke + + def invoke(self, state, ctx: Context): + self._logger.debug(f'invoke, state={state}') + for item in dir(self): + f = getattr(self, item) + if not callable(f) or item.startswith('_') or '_conv_data' not in f.__dict__: + continue + cd = f.__dict__['_conv_data'] + if cd['enter'] and cd['state'] == state: + return cd['orig_f'](self, ctx) + + raise RuntimeError(f'invoke: failed to find method for state {state}') + + def get_handler(self) -> ConversationHandler: + entry_points = [] + states = {} + + l_cancel_filter = _cancel_filter if not self._back_enabled else _cancel_and_back_filter + + for item in dir(self): + f = getattr(self, item) + if not callable(f) or item.startswith('_') or '_conv_data' not in f.__dict__: + continue + + cd = f.__dict__['_conv_data'] + + if cd['type'] == ConversationMethodType.ENTRY: + entry_points = self.make_handlers(f, **cd) + elif cd['type'] == ConversationMethodType.STATE_HANDLER: + states[cd['state']] = self.make_handlers(f, **cd) + states[cd['state']].append( + MessageHandler(_user_filter & ~l_cancel_filter, conversation.invalid) + ) + + fallbacks = [MessageHandler(_user_filter & _cancel_filter, self.cancel)] + if self._back_enabled: + fallbacks.append(MessageHandler(_user_filter & _back_filter, self.back)) + + return ConversationHandler( + entry_points=entry_points, + states=states, + fallbacks=fallbacks + ) + + def get_user_state(self, user_id: int) -> Optional[int]: + if user_id not in self._user_state_cache: + return None + return self._user_state_cache[user_id] + + # TODO store in ctx.user_state + def set_user_state(self, user_id: int, state: Union[int, None]): + if not self._back_enabled: + return + if state is not None: + self._user_state_cache[user_id] = state + else: + del self._user_state_cache[user_id] + + @staticmethod + @simplehandler + async def invalid(ctx: Context): + await ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup()) + # return 0 # FIXME is this needed + + @simplehandler + async def cancel(self, ctx: Context): + await start(ctx) + self.set_user_state(ctx.user_id, None) + return conversation.END + + @simplehandler + async def back(self, ctx: Context): + cur_state = self.get_user_state(ctx.user_id) + if cur_state is None: + await start(ctx) + self.set_user_state(ctx.user_id, None) + return conversation.END + + new_state = None + for seq in self.STATE_SEQS: + if cur_state in seq: + idx = seq.index(cur_state) + if idx > 0: + return self.invoke(seq[idx-1], ctx) + + if new_state is None: + raise RuntimeError('failed to determine state to go back to') + + @classmethod + def add_cancel_button(cls, ctx: Context, buttons): + buttons.append([ctx.lang('cancel')]) + + @classmethod + def add_back_button(cls, ctx: Context, buttons): + # buttons.insert(0, [ctx.lang('back')]) + buttons.append([ctx.lang('back')]) + + def reply(self, + ctx: Context, + state: Union[int, Enum], + text: str, + buttons: Optional[list], + with_cancel=False, + with_back=False, + buttons_lang_completed=False): + + if buttons: + new_buttons = [] + if not buttons_lang_completed: + for item in buttons: + if isinstance(item, list): + item = map(lambda s: ctx.lang(s), item) + new_buttons.append(list(item)) + elif isinstance(item, str): + new_buttons.append([ctx.lang(item)]) + else: + raise ValueError('invalid type: ' + type(item)) + else: + new_buttons = list(buttons) + + buttons = None + else: + if with_cancel or with_back: + new_buttons = [] + else: + new_buttons = None + + if with_cancel: + self.add_cancel_button(ctx, new_buttons) + if with_back: + if not self._back_enabled: + raise AttributeError(f'back is not enabled for this conversation ({self.__class__.__name__})') + self.add_back_button(ctx, new_buttons) + + markup = ReplyKeyboardMarkup(new_buttons, one_time_keyboard=True) if new_buttons else IgnoreMarkup() + ctx.reply(text, markup=markup) + self.set_user_state(ctx.user_id, state) + return state + + +class LangConversation(conversation): + START, = range(1) + + @conventer(START, command='lang') + async def entry(self, ctx: Context): + self._logger.debug(f'current language: {ctx.user_lang}') + + buttons = [] + for name in languages.values(): + buttons.append(name) + markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False) + + await ctx.reply(ctx.lang('select_language'), markup=markup) + return self.START + + @convinput(START, messages=lang.languages) + async def input(self, ctx: Context): + selected_lang = None + for key, value in languages.items(): + if value == ctx.text: + selected_lang = key + break + + if selected_lang is None: + raise ValueError('could not find the language') + + db.set_user_lang(ctx.user_id, selected_lang) + await ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup()) + + return self.END + + +def initialize(): + global _user_filter + global _application + # global _updater + global _dispatcher + + # init user_filter + _user_ids = config.app_config.get_user_ids() + if len(_user_ids) > 0: + _logger.info('allowed users: ' + str(_user_ids)) + _user_filter = filters.User(_user_ids) + else: + _user_filter = filters.ALL # not sure if this is correct + + _application = Application.builder()\ + .token(config.app_config.get('bot.token'))\ + .connect_timeout(7)\ + .read_timeout(6)\ + .build() + + # transparently log all messages + # _application.dispatcher.add_handler(MessageHandler(filters.ALL & _user_filter, _logging_message_handler), group=10) + # _application.dispatcher.add_handler(CallbackQueryHandler(_logging_callback_handler), group=10) + + +def run(start_handler=None, any_handler=None): + global db + global _start_handler_ref + + if not start_handler: + start_handler = _default_start_handler + if not any_handler: + any_handler = _default_any_handler + if not db: + db = BotDatabase() + + _start_handler_ref = start_handler + + _application.add_handler(LangConversation().get_handler(), group=0) + _application.add_handler(CommandHandler('start', + callback=simplehandler(start_handler), + filters=_user_filter)) + _application.add_handler(MessageHandler(filters.ALL & _user_filter, any_handler)) + + _application.run_polling() + + +def add_conversation(conv: conversation) -> None: + _application.add_handler(conv.get_handler(), group=0) + + +def add_handler(h): + _application.add_handler(h, group=0) + + +async def start(ctx: Context): + return await _start_handler_ref(ctx) + + +async def _default_start_handler(ctx: Context): + if 'start_message' not in lang: + return await ctx.reply('Please define start_message or override start()') + await ctx.reply(ctx.lang('start_message')) + + +@simplehandler +async def _default_any_handler(ctx: Context): + if 'invalid_command' not in lang: + return await ctx.reply('Please define invalid_command or override any()') + await ctx.reply(ctx.lang('invalid_command')) + + +def _logging_message_handler(update: Update, context: CallbackContext): + if _reporting: + _reporting.report(update.message) + + +def _logging_callback_handler(update: Update, context: CallbackContext): + if _reporting: + _reporting.report(update.callback_query.message, text=update.callback_query.data) + + +def enable_logging(bot_type: BotType): + api = WebApiClient(timeout=3) + api.enable_async() + + global _reporting + _reporting = ReportingHelper(api, bot_type) + + +def notify_all(text_getter: callable, + exclude: Tuple[int] = ()) -> None: + if 'notify_users' not in config['bot']: + _logger.error('notify_all() called but no notify_users directive found in the config') + return + + for user_id in config['bot']['notify_users']: + if user_id in exclude: + continue + + text = text_getter(db.get_user_lang(user_id)) + _application.bot.send_message(chat_id=user_id, + text=text, + parse_mode='HTML') + + +def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> None: + if isinstance(text, Exception): + text = exc2text(text) + _application.bot.send_message(chat_id=user_id, + text=text, + parse_mode='HTML', + **kwargs) + + +def send_photo(user_id, **kwargs): + _application.bot.send_photo(chat_id=user_id, **kwargs) + + +def send_audio(user_id, **kwargs): + _application.bot.send_audio(chat_id=user_id, **kwargs) + + +def send_file(user_id, **kwargs): + _application.bot.send_document(chat_id=user_id, **kwargs) + + +def edit_message_text(user_id, message_id, *args, **kwargs): + _application.bot.edit_message_text(chat_id=user_id, + message_id=message_id, + parse_mode='HTML', + *args, **kwargs) + + +def delete_message(user_id, message_id): + _application.bot.delete_message(chat_id=user_id, message_id=message_id) + + +def set_database(_db: BotDatabase): + global db + db = _db + diff --git a/py_include/homekit/telegram/config.py b/py_include/homekit/telegram/config.py new file mode 100644 index 0000000..4c7d74b --- /dev/null +++ b/py_include/homekit/telegram/config.py @@ -0,0 +1,75 @@ +from ..config import ConfigUnit +from typing import Optional, Union +from abc import ABC +from enum import Enum + + +class TelegramUserListType(Enum): + USERS = 'users' + NOTIFY = 'notify_users' + + +class TelegramUserIdsConfig(ConfigUnit): + NAME = 'telegram_user_ids' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'roottype': 'dict', + 'type': 'integer' + } + + +_user_ids_config = TelegramUserIdsConfig() + + +def _user_id_mapper(user: Union[str, int]) -> int: + if isinstance(user, int): + return user + return _user_ids_config[user] + + +class TelegramChatsConfig(ConfigUnit): + NAME = 'telegram_chats' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'type': 'dict', + 'schema': { + 'id': {'type': 'string', 'required': True}, + 'token': {'type': 'string', 'required': True}, + } + } + + +class TelegramBotConfig(ConfigUnit, ABC): + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'bot': { + 'type': 'dict', + 'schema': { + 'token': {'type': 'string', 'required': True}, + TelegramUserListType.USERS: {**TelegramBotConfig._userlist_schema(), 'required': True}, + TelegramUserListType.NOTIFY: TelegramBotConfig._userlist_schema(), + } + } + } + + @staticmethod + def _userlist_schema() -> dict: + return {'type': 'list', 'schema': {'type': ['string', 'int']}} + + @staticmethod + def custom_validator(data): + for ult in TelegramUserListType: + users = data['bot'][ult.value] + for user in users: + if isinstance(user, str): + if user not in _user_ids_config: + raise ValueError(f'user {user} not found in {TelegramUserIdsConfig.NAME}') + + def get_user_ids(self, + ult: TelegramUserListType = TelegramUserListType.USERS) -> list[int]: + return list(map(_user_id_mapper, self['bot'][ult.value])) \ No newline at end of file diff --git a/py_include/homekit/telegram/telegram.py b/py_include/homekit/telegram/telegram.py new file mode 100644 index 0000000..f42363e --- /dev/null +++ b/py_include/homekit/telegram/telegram.py @@ -0,0 +1,49 @@ +import requests +import logging + +from typing import Tuple +from .config import TelegramChatsConfig + +_chats = TelegramChatsConfig() +_logger = logging.getLogger(__name__) + + +def send_message(text: str, + chat: str, + parse_mode: str = 'HTML', + disable_web_page_preview: bool = False,): + data, token = _send_telegram_data(text, chat, parse_mode, disable_web_page_preview) + req = requests.post('https://api.telegram.org/bot%s/sendMessage' % token, data=data) + return req.json() + + +def send_photo(filename: str, chat: str): + chat_data = _chats[chat] + data = { + 'chat_id': chat_data['id'], + } + token = chat_data['token'] + + url = f'https://api.telegram.org/bot{token}/sendPhoto' + with open(filename, "rb") as fd: + req = requests.post(url, data=data, files={"photo": fd}) + return req.json() + + +def _send_telegram_data(text: str, + chat: str, + parse_mode: str = None, + disable_web_page_preview: bool = False) -> Tuple[dict, str]: + chat_data = _chats[chat] + data = { + 'chat_id': chat_data['id'], + 'text': text + } + + if parse_mode is not None: + data['parse_mode'] = parse_mode + + if disable_web_page_preview: + data['disable_web_page_preview'] = 1 + + return data, chat_data['token'] diff --git a/py_include/homekit/temphum/__init__.py b/py_include/homekit/temphum/__init__.py new file mode 100644 index 0000000..46d14e6 --- /dev/null +++ b/py_include/homekit/temphum/__init__.py @@ -0,0 +1 @@ +from .base import SensorType, BaseSensor diff --git a/py_include/homekit/temphum/base.py b/py_include/homekit/temphum/base.py new file mode 100644 index 0000000..602cab7 --- /dev/null +++ b/py_include/homekit/temphum/base.py @@ -0,0 +1,19 @@ +from abc import ABC +from enum import Enum + + +class BaseSensor(ABC): + def __init__(self, bus: int): + super().__init__() + self.bus = smbus.SMBus(bus) + + def humidity(self) -> float: + pass + + def temperature(self) -> float: + pass + + +class SensorType(Enum): + Si7021 = 'si7021' + DHT12 = 'dht12' \ No newline at end of file diff --git a/py_include/homekit/temphum/i2c.py b/py_include/homekit/temphum/i2c.py new file mode 100644 index 0000000..7d8e2e3 --- /dev/null +++ b/py_include/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/py_include/homekit/util.py b/py_include/homekit/util.py new file mode 100644 index 0000000..11e7116 --- /dev/null +++ b/py_include/homekit/util.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import json +import socket +import time +import subprocess +import traceback +import logging +import string +import random +import re + +from enum import Enum +from datetime import datetime +from typing import Optional, List +from zlib import adler32 + +logger = logging.getLogger(__name__) + + +def validate_ipv4_or_hostname(address: str, raise_exception: bool = False) -> bool: + if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', address): + parts = address.split('.') + if all(0 <= int(part) < 256 for part in parts): + return True + else: + if raise_exception: + raise ValueError(f"invalid IPv4 address: {address}") + return False + + if re.match(r'^[a-zA-Z0-9.-]+$', address): + return True + else: + if raise_exception: + raise ValueError(f"invalid hostname: {address}") + return False + + +def validate_mac_address(mac_address: str) -> bool: + mac_pattern = r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$' + if re.match(mac_pattern, mac_address): + return True + else: + return False + + +class Addr: + host: str + port: Optional[int] + + def __init__(self, host: str, port: Optional[int] = None): + self.host = host + self.port = port + + @staticmethod + def fromstring(addr: str) -> Addr: + colons = addr.count(':') + if colons != 1: + raise ValueError('invalid host:port format') + + if not colons: + host = addr + port= None + else: + host, port = addr.split(':') + + validate_ipv4_or_hostname(host, raise_exception=True) + + if port is not None: + port = int(port) + if not 0 <= port <= 65535: + raise ValueError(f'invalid port {port}') + + return Addr(host, port) + + def __str__(self): + buf = self.host + if self.port is not None: + buf += ':'+str(self.port) + return buf + + def __iter__(self): + yield self.host + yield self.port + + +# https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks +def chunks(lst, n): + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(lst), n): + yield lst[i:i + n] + + +def json_serial(obj): + """JSON serializer for datetime objects""" + if isinstance(obj, datetime): + return obj.timestamp() + if isinstance(obj, Enum): + return obj.value + raise TypeError("Type %s not serializable" % type(obj)) + + +def stringify(v) -> str: + return json.dumps(v, separators=(',', ':'), default=json_serial) + + +def ipv4_valid(ip: str) -> bool: + try: + socket.inet_aton(ip) + return True + except socket.error: + return False + + +def strgen(n: int): + return ''.join(random.choices(string.ascii_letters + string.digits, k=n)) + + +class MySimpleSocketClient: + host: str + port: int + + def __init__(self, host: str, port: int): + self.host = host + self.port = port + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.host, self.port)) + self.sock.settimeout(5) + + def __del__(self): + self.sock.close() + + def write(self, line: str) -> None: + self.sock.sendall((line + '\r\n').encode()) + + def read(self) -> str: + buf = bytearray() + while True: + buf.extend(self.sock.recv(256)) + if b'\r\n' in buf: + break + + response = buf.decode().strip() + return response + + +def send_datagram(message: str, addr: Addr) -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.sendto(message.encode(), addr) + + +def format_tb(exc) -> Optional[List[str]]: + tb = traceback.format_tb(exc.__traceback__) + if not tb: + return None + + tb = list(map(lambda s: s.strip(), tb)) + tb.reverse() + if tb[0][-1:] == ':': + tb[0] = tb[0][:-1] + + return tb + + +class ChildProcessInfo: + pid: int + cmd: str + + def __init__(self, + pid: int, + cmd: str): + self.pid = pid + self.cmd = cmd + + +def find_child_processes(ppid: int) -> List[ChildProcessInfo]: + p = subprocess.run(['pgrep', '-P', str(ppid), '--list-full'], capture_output=True) + if p.returncode != 0: + raise OSError(f'pgrep returned {p.returncode}') + + children = [] + + lines = p.stdout.decode().strip().split('\n') + for line in lines: + try: + space_idx = line.index(' ') + except ValueError as exc: + logger.exception(exc) + continue + + pid = int(line[0:space_idx]) + cmd = line[space_idx+1:] + + children.append(ChildProcessInfo(pid, cmd)) + + return children + + +class Stopwatch: + elapsed: float + time_started: Optional[float] + + def __init__(self): + self.elapsed = 0 + self.time_started = None + + def go(self): + if self.time_started is not None: + raise StopwatchError('stopwatch was already started') + + self.time_started = time.time() + + def pause(self): + if self.time_started is None: + raise StopwatchError('stopwatch was paused') + + self.elapsed += time.time() - self.time_started + self.time_started = None + + def get_elapsed_time(self): + elapsed = self.elapsed + if self.time_started is not None: + elapsed += time.time() - self.time_started + return elapsed + + def reset(self): + self.time_started = None + self.elapsed = 0 + + def is_paused(self): + return self.time_started is None + + +class StopwatchError(RuntimeError): + pass + + +def filesize_fmt(num, suffix="B") -> str: + for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: + if abs(num) < 1024.0: + return f"{num:3.1f} {unit}{suffix}" + num /= 1024.0 + return f"{num:.1f} Yi{suffix}" + + +class HashableEnum(Enum): + def hash(self) -> int: + return adler32(self.name.encode()) + + +def next_tick_gen(freq): + t = time.time() + while True: + t += freq + yield max(t - time.time(), 0) \ No newline at end of file diff --git a/py_include/pyA20/__init__.pyi b/py_include/pyA20/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/py_include/pyA20/gpio/connector.pyi b/py_include/pyA20/gpio/connector.pyi new file mode 100644 index 0000000..12b2b6e --- /dev/null +++ b/py_include/pyA20/gpio/connector.pyi @@ -0,0 +1,2 @@ +gpio1 = 0 +LED = 0 \ No newline at end of file diff --git a/py_include/pyA20/gpio/gpio.pyi b/py_include/pyA20/gpio/gpio.pyi new file mode 100644 index 0000000..225fcbe --- /dev/null +++ b/py_include/pyA20/gpio/gpio.pyi @@ -0,0 +1,24 @@ +HIGH = 1 +LOW = 0 +INPUT = 0 +OUTPUT = 0 +PULLUP = 0 +PULLDOWN = 0 + +def init(): + pass + +def setcfg(gpio: int, cfg: int): + pass + +def getcfg(gpio: int): + pass + +def output(gpio: int, value: int): + pass + +def pullup(gpio: int, pull: int): + pass + +def input(gpio: int): + pass \ No newline at end of file diff --git a/py_include/pyA20/gpio/port.pyi b/py_include/pyA20/gpio/port.pyi new file mode 100644 index 0000000..17f69fe --- /dev/null +++ b/py_include/pyA20/gpio/port.pyi @@ -0,0 +1,36 @@ +# these are not real values, just placeholders + +PA12 = 0 +PA11 = 0 +PA6 = 0 + +PA1 = 0 +PA0 = 0 + +PA3 = 0 +PC0 = 0 +PC1 = 0 +PC2 = 0 +PA19 = 0 +PA7 = 0 +PA8 = 0 +PA9 = 0 +PA10 = 0 +PA20 = 0 + +PA13 = 0 +PA14 = 0 +PD14 = 0 +PC4 = 0 +PC7 = 0 +PA2 = 0 +PC3 = 0 +PA21 = 0 +PA18 = 0 +PG8 = 0 +PG9 = 0 +PG6 = 0 +PG7 = 0 + +POWER_LED = 0 +STATUS_LED = 0 \ No newline at end of file diff --git a/py_include/pyA20/port.pyi b/py_include/pyA20/port.pyi new file mode 100644 index 0000000..e69de29 diff --git a/py_include/syncleo/__init__.py b/py_include/syncleo/__init__.py new file mode 100644 index 0000000..32563a5 --- /dev/null +++ b/py_include/syncleo/__init__.py @@ -0,0 +1,12 @@ +# Polaris PWK 1725CGLD "smart" kettle python library +# -------------------------------------------------- +# Copyright (C) Evgeny Zinoviev, 2022 +# License: BSD-3c + +from .kettle import Kettle, DeviceListener +from .protocol import ( + PowerType, + IncomingMessageListener, + ConnectionStatusListener, + ConnectionStatus +) diff --git a/py_include/syncleo/kettle.py b/py_include/syncleo/kettle.py new file mode 100644 index 0000000..d6e0dd6 --- /dev/null +++ b/py_include/syncleo/kettle.py @@ -0,0 +1,243 @@ +# Polaris PWK 1725CGLD smart kettle python library +# ------------------------------------------------ +# Copyright (C) Evgeny Zinoviev, 2022 +# License: BSD-3c + +from __future__ import annotations + +import threading +import logging +import zeroconf + +from abc import abstractmethod +from ipaddress import ip_address, IPv4Address, IPv6Address +from typing import Optional, List, Union + +from .protocol import ( + UDPConnection, + ModeMessage, + TargetTemperatureMessage, + PowerType, + ConnectionStatus, + ConnectionStatusListener, + WrappedMessage +) + + +class DeviceDiscover(threading.Thread, zeroconf.ServiceListener): + si: Optional[zeroconf.ServiceInfo] + _mac: str + _sb: Optional[zeroconf.ServiceBrowser] + _zc: Optional[zeroconf.Zeroconf] + _listeners: List[DeviceListener] + _valid_addresses: List[Union[IPv4Address, IPv6Address]] + _only_ipv4: bool + + def __init__(self, mac: str, + listener: Optional[DeviceListener] = None, + only_ipv4=True): + super().__init__() + self.si = None + self._mac = mac + self._zc = None + self._sb = None + self._only_ipv4 = only_ipv4 + self._valid_addresses = [] + self._listeners = [] + if isinstance(listener, DeviceListener): + self._listeners.append(listener) + self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__}') + + def add_listener(self, listener: DeviceListener): + if listener not in self._listeners: + self._listeners.append(listener) + else: + self._logger.warning(f'add_listener: listener {listener} already in the listeners list') + + def set_info(self, info: zeroconf.ServiceInfo): + valid_addresses = self._get_valid_addresses(info) + if not valid_addresses: + raise ValueError('no valid addresses') + self._valid_addresses = valid_addresses + self.si = info + for f in self._listeners: + try: + f.device_updated() + except Exception as exc: + self._logger.error(f'set_info: error while calling device_updated on {f}') + self._logger.exception(exc) + + def add_service(self, zc: zeroconf.Zeroconf, type_: str, name: str) -> None: + self._add_update_service('add_service', zc, type_, name) + + def update_service(self, zc: zeroconf.Zeroconf, type_: str, name: str) -> None: + self._add_update_service('update_service', zc, type_, name) + + def _add_update_service(self, method: str, zc: zeroconf.Zeroconf, type_: str, name: str) -> None: + info = zc.get_service_info(type_, name) + if name.startswith(f'{self._mac}.'): + self._logger.info(f'{method}: type={type_} name={name}') + try: + self.set_info(info) + except ValueError as exc: + self._logger.error(f'{method}: rejected: {str(exc)}') + else: + self._logger.debug(f'{method}: mac not matched: {info}') + + def remove_service(self, zc: zeroconf.Zeroconf, type_: str, name: str) -> None: + if name.startswith(f'{self._mac}.'): + self._logger.info(f'remove_service: type={type_} name={name}') + # TODO what to do here?! + + def run(self): + self._logger.debug('starting zeroconf service browser') + ip_version = zeroconf.IPVersion.V4Only if self._only_ipv4 else zeroconf.IPVersion.All + self._zc = zeroconf.Zeroconf(ip_version=ip_version) + self._sb = zeroconf.ServiceBrowser(self._zc, "_syncleo._udp.local.", self) + self._sb.join() + + def stop(self): + if self._sb: + try: + self._sb.cancel() + except RuntimeError: + pass + self._sb = None + self._zc.close() + self._zc = None + + def _get_valid_addresses(self, si: zeroconf.ServiceInfo) -> List[Union[IPv4Address, IPv6Address]]: + valid = [] + for addr in map(ip_address, si.addresses): + if self._only_ipv4 and not isinstance(addr, IPv4Address): + continue + if isinstance(addr, IPv4Address) and str(addr).startswith('169.254.'): + continue + valid.append(addr) + return valid + + @property + def pubkey(self) -> bytes: + return bytes.fromhex(self.si.properties[b'public'].decode()) + + @property + def curve(self) -> int: + return int(self.si.properties[b'curve'].decode()) + + @property + def addr(self) -> Union[IPv4Address, IPv6Address]: + return self._valid_addresses[0] + + @property + def port(self) -> int: + return int(self.si.port) + + @property + def protocol(self) -> int: + return int(self.si.properties[b'protocol'].decode()) + + +class DeviceListener: + @abstractmethod + def device_updated(self): + pass + + +class Kettle(DeviceListener, ConnectionStatusListener): + mac: str + device: Optional[DeviceDiscover] + device_token: str + conn: Optional[UDPConnection] + conn_status: Optional[ConnectionStatus] + _read_timeout: Optional[int] + _logger: logging.Logger + _find_evt: threading.Event + + def __init__(self, mac: str, device_token: str, read_timeout: Optional[int] = None): + super().__init__() + self.mac = mac + self.device = None + self.device_token = device_token + self.conn = None + self.conn_status = None + self._read_timeout = read_timeout + self._find_evt = threading.Event() + self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__}') + + def device_updated(self): + self._find_evt.set() + self._logger.info(f'device updated, service info: {self.device.si}') + + def connection_status_updated(self, status: ConnectionStatus): + self.conn_status = status + + def discover(self, wait=True, timeout=None, listener=None) -> Optional[zeroconf.ServiceInfo]: + do_start = False + if not self.device: + self.device = DeviceDiscover(self.mac, listener=self, only_ipv4=True) + do_start = True + self._logger.debug('discover: started device discovery') + else: + self._logger.warning('discover: already started') + + if listener is not None: + self.device.add_listener(listener) + + if do_start: + self.device.start() + + if wait: + self._find_evt.clear() + try: + self._find_evt.wait(timeout=timeout) + except KeyboardInterrupt: + self.device.stop() + return None + return self.device.si + + def start_server_if_needed(self, + incoming_message_listener=None, + connection_status_listener=None): + if self.conn: + self._logger.warning('start_server_if_needed: server is already started!') + self.conn.set_address(self.device.addr, self.device.port) + self.conn.set_device_pubkey(self.device.pubkey) + return + + assert self.device.curve == 29, f'curve type {self.device.curve} is not implemented' + assert self.device.protocol == 2, f'protocol {self.device.protocol} is not supported' + + kw = {} + if self._read_timeout is not None: + kw['read_timeout'] = self._read_timeout + self.conn = UDPConnection(addr=self.device.addr, + port=self.device.port, + device_pubkey=self.device.pubkey, + device_token=bytes.fromhex(self.device_token), **kw) + if incoming_message_listener: + self.conn.add_incoming_message_listener(incoming_message_listener) + + self.conn.add_connection_status_listener(self) + if connection_status_listener: + self.conn.add_connection_status_listener(connection_status_listener) + + self.conn.start() + + def stop_all(self): + # when we stop server, we should also stop device discovering service + if self.conn: + self.conn.interrupted = True + self.conn = None + self.device.stop() + self.device = None + + def is_connected(self) -> bool: + return self.conn is not None and self.conn_status == ConnectionStatus.CONNECTED + + def set_power(self, power_type: PowerType, callback: callable): + message = ModeMessage(power_type) + self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True)) + + def set_target_temperature(self, temp: int, callback: callable): + message = TargetTemperatureMessage(temp) + self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True)) diff --git a/py_include/syncleo/protocol.py b/py_include/syncleo/protocol.py new file mode 100644 index 0000000..36a1a8f --- /dev/null +++ b/py_include/syncleo/protocol.py @@ -0,0 +1,1169 @@ +# Polaris PWK 1725CGLD "smart" kettle python library +# -------------------------------------------------- +# Copyright (C) Evgeny Zinoviev, 2022 +# License: BSD-3c + +from __future__ import annotations + +import logging +import socket +import random +import struct +import threading +import time + +from abc import abstractmethod, ABC +from enum import Enum, auto +from typing import Union, Optional, Dict, Tuple, List +from ipaddress import IPv4Address, IPv6Address + +import cryptography.hazmat.primitives._serialization as srlz + +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey +from cryptography.hazmat.primitives import ciphers, padding, hashes +from cryptography.hazmat.primitives.ciphers import algorithms, modes + +ReprDict = Dict[str, Union[str, int, float, bool]] +_logger = logging.getLogger(__name__) + +PING_FREQUENCY = 3 +RESEND_ATTEMPTS = 5 +ERROR_TIMEOUT = 15 +MESSAGE_QUEUE_REMOVE_DELAY = 13 # after what time to delete (and pass False to handlers, if needed) messages with phase=DONE from queue +DISCONNECT_TIMEOUT = 15 + + +def safe_callback_call(f: callable, + *args, + logger: logging.Logger = None, + error_message: str = None): + try: + return f(*args) + except Exception as exc: + logger.error(f'{error_message}, see exception below:') + logger.exception(exc) + return None + + +# drop-in replacement for java.lang.System.arraycopy +# TODO: rewrite +def arraycopy(src, src_pos, dest, dest_pos, length): + for i in range(length): + dest[i + dest_pos] = src[i + src_pos] + + +# "convert" unsigned byte to signed +def u8_to_s8(b: int) -> int: + return struct.unpack('b', bytes([b]))[0] + + +class PowerType(Enum): + OFF = 0 # turn off + ON = 1 # turn on, set target temperature to 100 + CUSTOM = 3 # turn on, allows custom target temperature + # MYSTERY_MODE = 2 # don't know what 2 means, needs testing + # update: if I set it to '2', it just resets to '0' + + +# low-level protocol structures +# ----------------------------- + +class FrameType(Enum): + ACK = 0 + CMD = 1 + AUX = 2 + NAK = 3 + + +class FrameHead: + seq: Optional[int] # u8 + type: FrameType # u8 + length: int # u16. This is the length of FrameItem's payload + + @staticmethod + def from_bytes(buf: bytes) -> FrameHead: + seq, ft, length = struct.unpack(' bytes: + assert self.length != 0, "FrameHead.length has not been set" + assert self.seq is not None, "FrameHead.seq has not been set" + return struct.pack(' bytes: + ba = bytearray(self.head.pack()) + ba.extend(self.payload) + return bytes(ba) + + +# high-level wrappers around FrameItem +# ------------------------------------ + +class MessagePhase(Enum): + WAITING = 0 + SENT = 1 + DONE = 2 + + +class Message: + frame: Optional[FrameItem] + id: int + + _global_id = 0 + + def __init__(self): + self.frame = None + + # global internal message id, only useful for debugging purposes + self.id = self.next_id() + + def __repr__(self): + return f'<{self.__class__.__name__} id={self.id} seq={self.frame.head.seq}>' + + @staticmethod + def next_id(): + _id = Message._global_id + Message._global_id = (Message._global_id + 1) % 100000 + return _id + + @staticmethod + def from_encrypted(buf: bytes, inkey: bytes, outkey: bytes) -> Message: + _logger.debug(f'Message:from_encrypted: buf={buf.hex()}') + + assert len(buf) >= 4, 'invalid size' + head = FrameHead.from_bytes(buf[:4]) + + assert len(buf) == head.length + 4, f'invalid buf size ({len(buf)} != {head.length})' + payload = buf[4:] + b = head.seq + + j = b & 0xF + k = b >> 4 & 0xF + + key = bytearray(len(inkey)) + arraycopy(inkey, j, key, 0, len(inkey) - j) + arraycopy(inkey, 0, key, len(inkey) - j, j) + + iv = bytearray(len(outkey)) + arraycopy(outkey, k, iv, 0, len(outkey) - k) + arraycopy(outkey, 0, iv, len(outkey) - k, k) + + cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + decrypted_data = decryptor.update(payload) + decryptor.finalize() + + unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() + decrypted_data = unpadder.update(decrypted_data) + decrypted_data += unpadder.finalize() + + assert len(decrypted_data) != 0, 'decrypted data is null' + assert head.seq == decrypted_data[0], f'decrypted seq mismatch {head.seq} != {decrypted_data[0]}' + + # _logger.debug('Message.from_encrypted: plaintext: '+decrypted_data.hex()) + + if head.type == FrameType.ACK: + return AckMessage(head.seq) + + elif head.type == FrameType.NAK: + return NakMessage(head.seq) + + elif head.type == FrameType.AUX: + # TODO implement AUX + raise NotImplementedError('FrameType AUX is not yet implemented') + + elif head.type == FrameType.CMD: + type = decrypted_data[1] + data = decrypted_data[2:] + + cl = UnknownMessage + + subclasses = [cl for cl in CmdIncomingMessage.__subclasses__() if cl is not SimpleBooleanMessage] + subclasses.extend(SimpleBooleanMessage.__subclasses__()) + + for _cl in subclasses: + # `UnknownMessage` is a special class that holds a packed command that we don't recognize. + # It will be used anyway if we don't find a match, so skip it here + if _cl == UnknownMessage: + continue + + if _cl.TYPE == type: + cl = _cl + break + + m = cl.from_packed_data(data, seq=head.seq) + if isinstance(m, UnknownMessage): + m.set_type(type) + return m + + else: + raise NotImplementedError(f'Unexpected frame type: {head.type}') + + def pack_data(self) -> bytes: + return b'' + + @property + def seq(self) -> Union[int, None]: + try: + return self.frame.head.seq + except: + return None + + @seq.setter + def seq(self, seq: int): + self.frame.head.seq = seq + + def encrypt(self, outkey: bytes, inkey: bytes, token: bytes, pubkey: bytes): + assert self.frame is not None + + data = self._get_data_to_encrypt() + assert data is not None + + b = self.frame.head.seq + i = b & 0xf + j = b >> 4 & 0xf + + outkey = bytearray(outkey) + + l = len(outkey) + key = bytearray(l) + + arraycopy(outkey, i, key, 0, l-i) + arraycopy(outkey, 0, key, l-i, i) + + inkey = bytearray(inkey) + + l = len(inkey) + iv = bytearray(l) + + arraycopy(inkey, j, iv, 0, l-j) + arraycopy(inkey, 0, iv, l-j, j) + + cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv)) + encryptor = cipher.encryptor() + + newdata = bytearray(len(data)+1) + newdata[0] = b + + arraycopy(data, 0, newdata, 1, len(data)) + + newdata = bytes(newdata) + _logger.debug('frame payload to be encrypted: ' + newdata.hex()) + + padder = padding.PKCS7(algorithms.AES.block_size).padder() + ciphertext = bytearray() + ciphertext.extend(encryptor.update(padder.update(newdata) + padder.finalize())) + ciphertext.extend(encryptor.finalize()) + + self.frame.setpayload(ciphertext) + + def _get_data_to_encrypt(self) -> bytes: + return self.pack_data() + + +class AckMessage(Message, ABC): + def __init__(self, seq: Optional[int] = None): + super().__init__() + self.frame = FrameItem(FrameHead(seq, FrameType.ACK, None)) + + +class NakMessage(Message, ABC): + def __init__(self, seq: Optional[int] = None): + super().__init__() + self.frame = FrameItem(FrameHead(seq, FrameType.NAK, None)) + + +class CmdMessage(Message): + type: Optional[int] + data: bytes + + TYPE = None + + def _get_data_to_encrypt(self) -> bytes: + buf = bytearray() + buf.append(self.get_type()) + buf.extend(self.pack_data()) + return bytes(buf) + + def __init__(self, seq: Optional[int] = None): + super().__init__() + self.frame = FrameItem(FrameHead(seq, FrameType.CMD)) + self.data = b'' + + def _repr_fields(self) -> ReprDict: + return { + 'cmd': self.get_type() + } + + def __repr__(self): + params = [ + __name__+'.'+self.__class__.__name__, + f'id={self.id}', + f'seq={self.seq}' + ] + fields = self._repr_fields() + if fields: + for k, v in fields.items(): + params.append(f'{k}={v}') + elif self.data: + params.append(f'data={self.data.hex()}') + return '<'+' '.join(params)+'>' + + def get_type(self) -> int: + return self.__class__.TYPE + + +class CmdIncomingMessage(CmdMessage): + @staticmethod + @abstractmethod + def from_packed_data(cls, data: bytes, seq: Optional[int] = None): + pass + + @abstractmethod + def _repr_fields(self) -> ReprDict: + pass + + +class CmdOutgoingMessage(CmdMessage): + @abstractmethod + def pack_data(self) -> bytes: + return b'' + + +class ModeMessage(CmdOutgoingMessage, CmdIncomingMessage): + TYPE = 1 + + pt: PowerType + + def __init__(self, power_type: PowerType, seq: Optional[int] = None): + super().__init__(seq) + self.pt = power_type + + @classmethod + def from_packed_data(cls, data: bytes, seq=0) -> ModeMessage: + assert len(data) == 1, 'data size expected to be 1' + mode, = struct.unpack('B', data) + return ModeMessage(PowerType(mode), seq=seq) + + def pack_data(self) -> bytes: + return self.pt.value.to_bytes(1, byteorder='little') + + def _repr_fields(self) -> ReprDict: + return {'mode': self.pt.name} + + +class TargetTemperatureMessage(CmdOutgoingMessage, CmdIncomingMessage): + temperature: int + + TYPE = 2 + + def __init__(self, temp: int, seq: Optional[int] = None): + super().__init__(seq) + self.temperature = temp + + @classmethod + def from_packed_data(cls, data: bytes, seq=0) -> TargetTemperatureMessage: + assert len(data) == 2, 'data size expected to be 2' + nat, frac = struct.unpack('BB', data) + temp = int(nat + (frac / 100)) + return TargetTemperatureMessage(temp, seq=seq) + + def pack_data(self) -> bytes: + return bytes([self.temperature, 0]) + + def _repr_fields(self) -> ReprDict: + return {'temperature': self.temperature} + + +class PingMessage(CmdIncomingMessage, CmdOutgoingMessage): + TYPE = 255 + + @classmethod + def from_packed_data(cls, data: bytes, seq=0) -> PingMessage: + assert len(data) == 0, 'no data expected' + return PingMessage(seq=seq) + + def pack_data(self) -> bytes: + return b'' + + def _repr_fields(self) -> ReprDict: + return {} + + +# This is the first protocol message. Sent by a client. +# Kettle usually ACKs this, but sometimes i don't get any ACK and the very next message is HandshakeResponseMessage. +class HandshakeMessage(CmdMessage): + TYPE = 0 + + def encrypt(self, + outkey: bytes, + inkey: bytes, + token: bytes, + pubkey: bytes): + cipher = ciphers.Cipher(algorithms.AES(outkey), modes.CBC(inkey)) + encryptor = cipher.encryptor() + + ciphertext = bytearray() + ciphertext.extend(encryptor.update(token)) + ciphertext.extend(encryptor.finalize()) + + pld = bytearray() + pld.append(0) + pld.extend(pubkey) + pld.extend(ciphertext) + + self.frame.setpayload(pld) + + +# Kettle either sends this right after the handshake, of first it ACKs the handshake then sends this. +class HandshakeResponseMessage(CmdIncomingMessage): + TYPE = 0 + + protocol: int + fw_major: int + fw_minor: int + mode: int + token: bytes + + def __init__(self, + protocol: int, + fw_major: int, + fw_minor: int, + mode: int, + token: bytes, + seq: Optional[int] = None): + super().__init__(seq) + self.protocol = protocol + self.fw_major = fw_major + self.fw_minor = fw_minor + self.mode = mode + self.token = token + + @classmethod + def from_packed_data(cls, data: bytes, seq=0) -> HandshakeResponseMessage: + protocol, fw_major, fw_minor, mode = struct.unpack(' ReprDict: + return { + 'protocol': self.protocol, + 'fw': f'{self.fw_major}.{self.fw_minor}', + 'mode': self.mode, + 'token': self.token.hex() + } + + +# Apparently, some hardware info. +# On the other hand, if you look at com.syncleiot.iottransport.commands.CmdHardware, its mqtt topic says "mcu_firmware". +# My device returns 1.1.1. The kettle uses on ESP8266 ESP-12F MCU under the hood (or, more precisely, under a piece of +# cheap plastic), so maybe 1.1.1 is some MCU ROM version. +class DeviceHardwareMessage(CmdIncomingMessage): + TYPE = 143 # -113 + + hw: List[int] + + def __init__(self, hw: List[int], seq: Optional[int] = None): + super().__init__(seq) + self.hw = hw + + @classmethod + def from_packed_data(cls, data: bytes, seq=0) -> DeviceHardwareMessage: + assert len(data) == 3, 'invalid data size, expected 3' + hw = list(struct.unpack(' ReprDict: + return {'device_hardware': '.'.join(map(str, self.hw))} + + +# This message is sent by kettle right after the HandshakeMessageResponse. +# The diagnostic data is supposed to be sent to vendor, which we, obviously, not going to do. +# So just ACK and skip it. +class DeviceDiagnosticMessage(CmdIncomingMessage): + TYPE = 145 # -111 + + diag_data: bytes + + def __init__(self, diag_data: bytes, seq: Optional[int] = None): + super().__init__(seq) + self.diag_data = diag_data + + @classmethod + def from_packed_data(cls, data: bytes, seq=0) -> DeviceDiagnosticMessage: + return DeviceDiagnosticMessage(diag_data=data, seq=seq) + + def _repr_fields(self) -> ReprDict: + return {'diag_data': self.diag_data.hex()} + + +class SimpleBooleanMessage(ABC, CmdIncomingMessage): + value: bool + + def __init__(self, value: bool, seq: Optional[int] = None): + super().__init__(seq) + self.value = value + + @classmethod + def from_packed_data(cls, data: bytes, seq: Optional[int] = None): + assert len(data) == 1, 'invalid data size, expected 1' + enabled, = struct.unpack(' ReprDict: + pass + + +class AccessControlMessage(SimpleBooleanMessage): + TYPE = 133 # -123 + + def _repr_fields(self) -> ReprDict: + return {'acl_enabled': self.value} + + +class ErrorMessage(SimpleBooleanMessage): + TYPE = 7 + + def _repr_fields(self) -> ReprDict: + return {'error': self.value} + + +class ChildLockMessage(SimpleBooleanMessage): + TYPE = 30 + + def _repr_fields(self) -> ReprDict: + return {'child_lock': self.value} + + +class VolumeMessage(SimpleBooleanMessage): + TYPE = 9 + + def _repr_fields(self) -> ReprDict: + return {'volume': self.value} + + +class BacklightMessage(SimpleBooleanMessage): + TYPE = 28 + + def _repr_fields(self) -> ReprDict: + return {'backlight': self.value} + + +class CurrentTemperatureMessage(CmdIncomingMessage): + TYPE = 20 + + current_temperature: int + + def __init__(self, temp: int, seq: Optional[int] = None): + super().__init__(seq) + self.current_temperature = temp + + @classmethod + def from_packed_data(cls, data: bytes, seq=0) -> CurrentTemperatureMessage: + assert len(data) == 2, 'data size expected to be 2' + nat, frac = struct.unpack('BB', data) + temp = int(nat + (frac / 100)) + return CurrentTemperatureMessage(temp, seq=seq) + + def pack_data(self) -> bytes: + return bytes([self.current_temperature, 0]) + + def _repr_fields(self) -> ReprDict: + return {'current_temperature': self.current_temperature} + + +class UnknownMessage(CmdIncomingMessage): + type: Optional[int] + data: bytes + + def __init__(self, data: bytes, **kwargs): + super().__init__(**kwargs) + self.type = None + self.data = data + + @classmethod + def from_packed_data(cls, data: bytes, seq=0) -> UnknownMessage: + return UnknownMessage(data, seq=seq) + + def set_type(self, type: int): + self.type = type + + def get_type(self) -> int: + return self.type + + def _repr_fields(self) -> ReprDict: + return { + 'type': self.type, + 'data': self.data.hex() + } + + +class WrappedMessage: + _message: Message + _handler: Optional[callable] + _validator: Optional[callable] + _logger: Optional[logging.Logger] + _phase: MessagePhase + _phase_update_time: float + + def __init__(self, + message: Message, + handler: Optional[callable] = None, + validator: Optional[callable] = None, + ack=False): + self._message = message + self._handler = handler + self._validator = validator + self._logger = None + self._phase = MessagePhase.WAITING + self._phase_update_time = 0 + if not validator and ack: + self._validator = lambda m: isinstance(m, AckMessage) + + def setlogger(self, logger: logging.Logger): + self._logger = logger + + def validate(self, message: Message): + if not self._validator: + return True + return self._validator(message) + + def call(self, *args, error_message: str = None) -> None: + if not self._handler: + return + try: + self._handler(*args) + except Exception as exc: + logger = self._logger or logging.getLogger(self.__class__.__name__) + logger.error(f'{error_message}, see exception below:') + logger.exception(exc) + + @property + def phase(self) -> MessagePhase: + return self._phase + + @phase.setter + def phase(self, phase: MessagePhase): + self._phase = phase + self._phase_update_time = 0 if phase == MessagePhase.WAITING else time.time() + + @property + def phase_update_time(self) -> float: + return self._phase_update_time + + @property + def message(self) -> Message: + return self._message + + @property + def id(self) -> int: + return self._message.id + + @property + def seq(self) -> int: + return self._message.seq + + @seq.setter + def seq(self, seq: int): + self._message.seq = seq + + def __repr__(self): + return f'<{__name__}.{self.__class__.__name__} message={self._message.__repr__()}>' + + +# Connection stuff +# Well, strictly speaking, as it's UDP, there's no connection, but who cares. +# --------------------------------------------------------------------------- + +class IncomingMessageListener: + @abstractmethod + def incoming_message(self, message: Message) -> Optional[Message]: + pass + + +class ConnectionStatus(Enum): + NOT_CONNECTED = auto() + CONNECTING = auto() + CONNECTED = auto() + RECONNECTING = auto() + DISCONNECTED = auto() + + +class ConnectionStatusListener: + @abstractmethod + def connection_status_updated(self, status: ConnectionStatus): + pass + + +class UDPConnection(threading.Thread, ConnectionStatusListener): + inseq: int + outseq: int + source_port: int + device_addr: str + device_port: int + device_token: bytes + device_pubkey: bytes + interrupted: bool + response_handlers: Dict[int, WrappedMessage] + outgoing_queue: List[WrappedMessage] + pubkey: Optional[bytes] + encinkey: Optional[bytes] + encoutkey: Optional[bytes] + inc_listeners: List[IncomingMessageListener] + conn_listeners: List[ConnectionStatusListener] + outgoing_time: float + outgoing_time_1st: float + incoming_time: float + status: ConnectionStatus + reconnect_tries: int + read_timeout: int + + _addr_lock: threading.Lock + _iml_lock: threading.Lock + _csl_lock: threading.Lock + _st_lock: threading.Lock + + def __init__(self, + addr: Union[IPv4Address, IPv6Address], + port: int, + device_pubkey: bytes, + device_token: bytes, + read_timeout: int = 1): + super().__init__() + self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__} <{hex(id(self))}>') + self.setName(self.__class__.__name__) + + self.inseq = 0 + self.outseq = 0 + self.source_port = random.randint(1024, 65535) + self.device_addr = str(addr) + self.device_port = port + self.device_token = device_token + self.device_pubkey = device_pubkey + self.outgoing_queue = [] + self.response_handlers = {} + self.interrupted = False + self.outgoing_time = 0 + self.outgoing_time_1st = 0 + self.incoming_time = 0 + self.inc_listeners = [] + self.conn_listeners = [self] + self.status = ConnectionStatus.NOT_CONNECTED + self.reconnect_tries = 0 + self.read_timeout = read_timeout + + self._iml_lock = threading.Lock() + self._csl_lock = threading.Lock() + self._addr_lock = threading.Lock() + self._st_lock = threading.Lock() + + self.pubkey = None + self.encinkey = None + self.encoutkey = None + + def connection_status_updated(self, status: ConnectionStatus): + # self._logger.info(f'connection_status_updated: status = {status}') + with self._st_lock: + # self._logger.debug(f'connection_status_updated: lock acquired') + self.status = status + if status == ConnectionStatus.RECONNECTING: + self.reconnect_tries += 1 + if status in (ConnectionStatus.CONNECTED, ConnectionStatus.NOT_CONNECTED, ConnectionStatus.DISCONNECTED): + self.reconnect_tries = 0 + + def _cleanup(self): + # erase outgoing queue + for wm in self.outgoing_queue: + wm.call(False, + error_message=f'_cleanup: exception while calling cb(False) on message {wm.message}') + self.outgoing_queue = [] + self.response_handlers = {} + + # reset timestamps + self.incoming_time = 0 + self.outgoing_time = 0 + self.outgoing_time_1st = 0 + + self._logger.debug('_cleanup: done') + + def set_address(self, addr: Union[IPv4Address, IPv6Address], port: int): + with self._addr_lock: + if self.device_addr != str(addr) or self.device_port != port: + self.device_addr = str(addr) + self.device_port = port + self._logger.info(f'updated device network address: {self.device_addr}:{self.device_port}') + + def set_device_pubkey(self, pubkey: bytes): + if self.device_pubkey.hex() != pubkey.hex(): + self._logger.info(f'device pubkey has changed (old={self.device_pubkey.hex()}, new={pubkey.hex()})') + self.device_pubkey = pubkey + self._notify_cs(ConnectionStatus.RECONNECTING) + + def get_address(self) -> Tuple[str, int]: + with self._addr_lock: + return self.device_addr, self.device_port + + def add_incoming_message_listener(self, listener: IncomingMessageListener): + with self._iml_lock: + if listener not in self.inc_listeners: + self.inc_listeners.append(listener) + + def add_connection_status_listener(self, listener: ConnectionStatusListener): + with self._csl_lock: + if listener not in self.conn_listeners: + self.conn_listeners.append(listener) + + def _notify_cs(self, status: ConnectionStatus): + # self._logger.debug(f'_notify_cs: status={status}') + with self._csl_lock: + for obj in self.conn_listeners: + # self._logger.debug(f'_notify_cs: notifying {obj}') + obj.connection_status_updated(status) + + def _prepare_keys(self): + # generate key pair + privkey = X25519PrivateKey.generate() + + self.pubkey = bytes(reversed(privkey.public_key().public_bytes(encoding=srlz.Encoding.Raw, + format=srlz.PublicFormat.Raw))) + + # generate shared key + device_pubkey = X25519PublicKey.from_public_bytes( + bytes(reversed(self.device_pubkey)) + ) + shared_key = bytes(reversed( + privkey.exchange(device_pubkey) + )) + + # in/out encryption keys + digest = hashes.Hash(hashes.SHA256()) + digest.update(shared_key) + + shared_sha256 = digest.finalize() + + self.encinkey = shared_sha256[:16] + self.encoutkey = shared_sha256[16:] + + self._logger.info('encryption keys have been created') + + def _handshake_callback(self, r: MessageResponse): + # if got error for our HandshakeMessage, reset everything and try again + if r is False: + # self._logger.debug('_handshake_callback: set status=RECONNETING') + self._notify_cs(ConnectionStatus.RECONNECTING) + else: + # self._logger.debug('_handshake_callback: set status=CONNECTED') + self._notify_cs(ConnectionStatus.CONNECTED) + + def run(self): + self._logger.info('starting server loop') + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(('0.0.0.0', self.source_port)) + sock.settimeout(self.read_timeout) + + while not self.interrupted: + with self._st_lock: + status = self.status + + if status in (ConnectionStatus.DISCONNECTED, ConnectionStatus.RECONNECTING): + self._cleanup() + if status == ConnectionStatus.DISCONNECTED: + break + + # no activity for some time means connection is broken + fail = False + fail_path = 0 + if self.incoming_time > 0 and time.time() - self.incoming_time >= DISCONNECT_TIMEOUT: + fail = True + fail_path = 1 + elif self.outgoing_time_1st > 0 and self.incoming_time == 0 and time.time() - self.outgoing_time_1st >= DISCONNECT_TIMEOUT: + fail = True + fail_path = 2 + + if fail: + self._logger.debug(f'run: setting status=RECONNECTING because of long inactivity, fail_path={fail_path}') + self._notify_cs(ConnectionStatus.RECONNECTING) + + # establishing a connection + if status in (ConnectionStatus.RECONNECTING, ConnectionStatus.NOT_CONNECTED): + if status == ConnectionStatus.RECONNECTING and self.reconnect_tries >= 3: + self._notify_cs(ConnectionStatus.DISCONNECTED) + continue + + self._reset_outseq() + self._prepare_keys() + + # shake the imaginary kettle's hand + wrapped = WrappedMessage(HandshakeMessage(), + handler=self._handshake_callback, + validator=lambda m: isinstance(m, (AckMessage, HandshakeResponseMessage))) + self.enqueue_message(wrapped, prepend=True) + self._notify_cs(ConnectionStatus.CONNECTING) + + # pick next (wrapped) message to send + wm = self._get_next_message() # wm means "wrapped message" + if wm: + one_shot = isinstance(wm.message, (AckMessage, NakMessage)) + + if not isinstance(wm.message, (AckMessage, NakMessage)): + old_seq = wm.seq + wm.seq = self.outseq + self._set_response_handler(wm, old_seq=old_seq) + elif wm.seq is None: + # ack/nak is a response to some incoming message (and it must have the same seqno that incoming + # message had) + raise RuntimeError(f'run: seq must be set for {wm.__class__.__name__}') + + self._logger.debug(f'run: sending message: {wm.message}, one_shot={one_shot}, phase={wm.phase}') + encrypted = False + try: + wm.message.encrypt(outkey=self.encoutkey, inkey=self.encinkey, + token=self.device_token, pubkey=self.pubkey) + encrypted = True + except ValueError as exc: + # handle "ValueError: Invalid padding bytes." + self._logger.error('run: failed to encrypt the message.') + self._logger.exception(exc) + + if encrypted: + buf = wm.message.frame.pack() + # self._logger.debug(f'run: raw data to be sent: {buf.hex()}') + + # sending the first time + if wm.phase == MessagePhase.WAITING: + sock.sendto(buf, self.get_address()) + # resending + elif wm.phase == MessagePhase.SENT: + left = RESEND_ATTEMPTS + while left > 0: + sock.sendto(buf, self.get_address()) + left -= 1 + if left > 0: + time.sleep(0.05) + + if one_shot or wm.phase == MessagePhase.SENT: + wm.phase = MessagePhase.DONE + else: + wm.phase = MessagePhase.SENT + + now = time.time() + self.outgoing_time = now + if not self.outgoing_time_1st: + self.outgoing_time_1st = now + + # receiving data + try: + data = sock.recv(4096) + self._handle_incoming(data) + except (TimeoutError, socket.timeout): + pass + + self._logger.info('bye...') + + def _get_next_message(self) -> Optional[WrappedMessage]: + message = None + lpfx = '_get_next_message:' + remove_list = [] + for wm in self.outgoing_queue: + if wm.phase == MessagePhase.DONE: + if isinstance(wm.message, (AckMessage, NakMessage, PingMessage)) or time.time() - wm.phase_update_time >= MESSAGE_QUEUE_REMOVE_DELAY: + remove_list.append(wm) + continue + message = wm + break + + for wm in remove_list: + self._logger.debug(f'{lpfx} rm path: removing id={wm.id} seq={wm.seq}') + + # clear message handler + if wm.seq in self.response_handlers: + self.response_handlers[wm.seq].call( + False, error_message=f'{lpfx} rm path: error while calling callback for seq={wm.seq}') + del self.response_handlers[wm.seq] + + # remove from queue + try: + self.outgoing_queue.remove(wm) + except ValueError as exc: + self._logger.error(f'{lpfx} rm path: removing from outgoing_queue raised an exception: {str(exc)}') + + # ping pong + if not message and self.outgoing_time_1st != 0 and self.status == ConnectionStatus.CONNECTED: + now = time.time() + out_delta = now - self.outgoing_time + in_delta = now - self.incoming_time + if max(out_delta, in_delta) > PING_FREQUENCY: + self._logger.debug(f'{lpfx} no activity: in for {in_delta:.2f}s, out for {out_delta:.2f}s, time to ping the damn thing') + message = WrappedMessage(PingMessage(), ack=True) + # add it to outgoing_queue in order to be aggressively resent in future (if needed) + self.outgoing_queue.insert(0, message) + + return message + + def _handle_incoming(self, buf: bytes): + try: + incoming_message = Message.from_encrypted(buf, inkey=self.encinkey, outkey=self.encoutkey) + except ValueError as exc: + # handle "ValueError: Invalid padding bytes." + self._logger.error('_handle_incoming: failed to decrypt incoming frame:') + self._logger.exception(exc) + return + + self.incoming_time = time.time() + seq = incoming_message.seq + + lpfx = f'handle_incoming({incoming_message.id}):' + self._logger.debug(f'{lpfx} received: {incoming_message}') + + if isinstance(incoming_message, (AckMessage, NakMessage)): + seq_max = self.outseq + seq_name = 'outseq' + else: + seq_max = self.inseq + seq_name = 'inseq' + self.inseq = seq + + if seq < seq_max < 0xfd: + self._logger.debug(f'{lpfx} dropping: seq={seq}, {seq_name}={seq_max}') + return + + if seq not in self.response_handlers: + self._handle_incoming_cmd(incoming_message) + return + + callback_value = None # None means don't call a callback + handler = self.response_handlers[seq] + + if handler.validate(incoming_message): + self._logger.debug(f'{lpfx} response OK') + handler.phase = MessagePhase.DONE + callback_value = incoming_message + self._incr_outseq() + else: + self._logger.warning(f'{lpfx} response is INVALID') + + # It seems that we've received an incoming CmdMessage or PingMessage with the same seqno that our outgoing + # message had. Bad, but what can I say, this is quick-and-dirty made UDP based protocol and this sort of + # shit just happens. + + # (To be fair, maybe my implementation is not perfect either. But hey, what did you expect from a + # reverse-engineered re-implementation of custom UDP-based protocol that some noname vendor uses for their + # cheap IoT devices? I think _that_ is _the_ definition of shit. At least my implementation is FOSS, which + # is more than you'll ever be able to say about them.) + + # All this crapload of code below might not be needed at all, 'cause the protocol uses separate frame seq + # numbers for IN and OUT frames and this situation is not highly likely, as Theresa May could argue. + # After a handshake, a kettle sends us 10 or so CmdMessages, and then either we continuously ping it every + # 3 seconds, or kettle pings us. This in any case widens the gap between inseq and outseq. + + # But! the seqno is only 1 byte in size and once it reaches 0xff, it circles back to zero. And that (plus, + # perhaps, some bad luck) gives a chance for a collision. + + if handler.phase == MessagePhase.DONE or isinstance(handler.message, HandshakeMessage): + # no more attempts left, returning error back to user + # as to handshake, it cannot fail. + callback_value = False + + # else: + # # try resending the message + # handler.phase_reset() + # max_seq = self.outseq + # wait_remap = {} + # for m in self.outgoing_queue: + # if m.seq in self.waiting_for_response: + # wait_remap[m.seq] = (m.seq+1) % 256 + # m.set_seq((m.seq+1) % 256) + # if m.seq > max_seq: + # max_seq = m.seq + # if max_seq > self.outseq: + # self.outseq = max_seq % 256 + # if wait_remap: + # waiting_new = {} + # for old_seq, new_seq in wait_remap.items(): + # waiting_new[new_seq] = self.waiting_for_response[old_seq] + # self.waiting_for_response = waiting_new + + if isinstance(incoming_message, (PingMessage, CmdIncomingMessage)): + # handle incoming message as usual, as we need to ack/nak it anyway + self._handle_incoming_cmd(incoming_message) + + if callback_value is not None: + handler.call(callback_value, + error_message=f'{lpfx} error while calling callback for msg id={handler.message.id} seq={seq}') + del self.response_handlers[seq] + + def _handle_incoming_cmd(self, incoming_message: Message): + if isinstance(incoming_message, (AckMessage, NakMessage)): + self._logger.debug(f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): it\'s {incoming_message.__class__.__name__}, ignoring') + return + + replied = False + with self._iml_lock: + for f in self.inc_listeners: + retval = safe_callback_call(f.incoming_message, incoming_message, + logger=self._logger, + error_message=f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): error while calling message listener') + if isinstance(retval, Message): + if isinstance(retval, (AckMessage, NakMessage)): + retval.seq = incoming_message.seq + self.enqueue_message(WrappedMessage(retval), prepend=True) + replied = True + break + else: + raise RuntimeError('are you sure your response is correct? only ack/nak are allowed') + + if not replied: + self.enqueue_message(WrappedMessage(AckMessage(incoming_message.seq)), prepend=True) + + def enqueue_message(self, wrapped: WrappedMessage, prepend=False): + self._logger.debug(f'enqueue_message: {wrapped.message}') + if not prepend: + self.outgoing_queue.append(wrapped) + else: + self.outgoing_queue.insert(0, wrapped) + + def _set_response_handler(self, wm: WrappedMessage, old_seq=None): + if old_seq in self.response_handlers: + del self.response_handlers[old_seq] + + seq = wm.seq + assert seq is not None, 'seq is not set' + + if seq in self.response_handlers: + self._logger.debug(f'_set_response_handler(seq={seq}): handler is already set, cancelling it') + self.response_handlers[seq].call(False, + error_message=f'_set_response_handler({seq}): error while calling old callback') + self.response_handlers[seq] = wm + + def _incr_outseq(self) -> None: + self.outseq = (self.outseq + 1) % 256 + + def _reset_outseq(self): + self.outseq = 0 + self._logger.debug(f'_reset_outseq: set 0') + + +MessageResponse = Union[Message, bool] diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/camera_node.py b/src/camera_node.py deleted file mode 100755 index 3f2c5a4..0000000 --- a/src/camera_node.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import time - -from home.config import config -from home.media import MediaNodeServer, ESP32CameraRecordStorage, CameraRecorder -from home.camera import CameraType, esp32 -from home.util import Addr -from home import http - - -# Implements HTTP API for a camera. -# --------------------------------- - -class ESP32CameraNodeServer(MediaNodeServer): - def __init__(self, web_addr: Addr, *args, **kwargs): - super().__init__(*args, **kwargs) - self.last_settings_sync = 0 - - self.web = esp32.WebClient(web_addr) - self.get('/capture/', self.capture) - - async def capture(self, req: http.Request): - await self.sync_settings_if_needed() - - try: - with_flash = int(req.query['with_flash']) - except KeyError: - with_flash = 0 - - if with_flash: - await self.web.setflash(True) - await asyncio.sleep(0.5) - - bytes = (await self.web.capture()).read() - - if with_flash: - await asyncio.sleep(0.5) - await self.web.setflash(False) - - res = http.StreamResponse() - res.content_type = 'image/jpeg' - res.content_length = len(bytes) - - await res.prepare(req) - await res.write(bytes) - await res.write_eof() - - return res - - async def do_record(self, request: http.Request): - await self.sync_settings_if_needed() - - # sync settings - return await super().do_record(request) - - async def sync_settings_if_needed(self): - if self.last_settings_sync != 0 and time.time() - self.last_settings_sync < 300: - return - changed = await self.web.syncsettings(config['camera']['settings']) - if changed: - self.logger.debug('sync_settings_if_needed: some settings were changed, sleeping for 0.4 sec') - await asyncio.sleep(0.4) - self.last_settings_sync = time.time() - - -if __name__ == '__main__': - config.load_app('camera_node') - - recorder_kwargs = {} - camera_type = CameraType(config['camera']['type']) - if camera_type == CameraType.ESP32: - recorder_kwargs['stream_addr'] = config.get_addr('camera.web_addr') # this is not a mistake, we don't use stream_addr for esp32-cam anymore - storage = ESP32CameraRecordStorage(config['node']['storage']) - else: - raise RuntimeError(f'unsupported camera type {camera_type}') - - recorder = CameraRecorder(storage=storage, - camera_type=camera_type, - **recorder_kwargs) - recorder.start_thread() - - server = ESP32CameraNodeServer( - recorder=recorder, - storage=storage, - web_addr=config.get_addr('camera.web_addr'), - addr=config.get_addr('node.listen')) - server.run() diff --git a/src/electricity_calc.py b/src/electricity_calc.py deleted file mode 100755 index 8ea5a1c..0000000 --- a/src/electricity_calc.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import sys -import inspect - -from home.config import config # do not remove this import! -from datetime import datetime, timedelta -from logging import Logger -from home.database import InverterDatabase -from argparse import ArgumentParser, ArgumentError -from typing import Optional - -_logger: Optional[Logger] = None -_progname = os.path.basename(__file__) -_is_verbose = False - -fmt_time = '%Y-%m-%d %H:%M:%S' -fmt_date = '%Y-%m-%d' - - -def method_usage() -> str: - # https://stackoverflow.com/questions/2654113/how-to-get-the-callers-method-name-in-the-called-method - curframe = inspect.currentframe() - calframe = inspect.getouterframes(curframe, 2) - return f'{_progname} {calframe[1][3]} [ARGS]' - - -def fmt_escape(s: str): - return s.replace('%', '%%') - - -def setup_logging(verbose: bool): - global _is_verbose - - logging_level = logging.INFO if not verbose else logging.DEBUG - logging.basicConfig(level=logging_level) - - _is_verbose = verbose - - -class SubParser: - def __init__(self, description: str, usage: str): - self.parser = ArgumentParser( - description=description, - usage=usage - ) - - def add_argument(self, *args, **kwargs): - self.parser.add_argument(*args, **kwargs) - - def parse_args(self): - self.add_argument('--verbose', '-V', action='store_true', - help='enable debug logs') - - args = self.parser.parse_args(sys.argv[2:]) - setup_logging(args.verbose) - - return args - - -def strptime_auto(s: str) -> datetime: - e = None - for fmt in (fmt_time, fmt_date): - try: - return datetime.strptime(s, fmt) - except ValueError as _e: - e = _e - raise e - - -def get_dt_from_to_arguments(parser): - parser.add_argument('--from', type=str, dest='date_from', required=True, - help=f'From date, format: {fmt_escape(fmt_time)} or {fmt_escape(fmt_date)}') - parser.add_argument('--to', type=str, dest='date_to', default='now', - help=f'To date, format: {fmt_escape(fmt_time)}, {fmt_escape(fmt_date)}, \'now\' or \'24h\'') - arg = parser.parse_args() - - dt_from = strptime_auto(arg.date_from) - - if arg.date_to == 'now': - dt_to = datetime.now() - elif arg.date_to == '24h': - dt_to = dt_from + timedelta(days=1) - else: - dt_to = strptime_auto(arg.date_to) - - return dt_from, dt_to - - -def print_intervals(intervals): - for interval in intervals: - start, end = interval - buf = f'{start.strftime(fmt_time)} .. ' - if end: - buf += f'{end.strftime(fmt_time)}' - else: - buf += 'now' - - print(buf) - - -class Electricity(): - def __init__(self): - global _logger - - methods = [func.replace('_', '-') - for func in dir(Electricity) - if callable(getattr(Electricity, func)) and not func.startswith('_') and func != 'query'] - - parser = ArgumentParser( - usage=f'{_progname} METHOD [ARGS]' - ) - parser.add_argument('method', choices=methods, - help='Method to run') - parser.add_argument('--verbose', '-V', action='store_true', - help='enable debug logs') - - argv = sys.argv[1:2] - for arg in ('-V', '--verbose'): - if arg in sys.argv: - argv.append(arg) - args = parser.parse_args(argv) - - setup_logging(args.verbose) - self.db = InverterDatabase() - - method = args.method.replace('-', '_') - getattr(self, method)() - - def get_grid_connected_intervals(self): - parser = SubParser('Returns datetime intervals when grid was connected', method_usage()) - dt_from, dt_to = get_dt_from_to_arguments(parser) - - intervals = self.db.get_grid_connected_intervals(dt_from, dt_to) - print_intervals(intervals) - - def get_grid_used_intervals(self): - parser = SubParser('Returns datetime intervals when power grid was actually used', method_usage()) - dt_from, dt_to = get_dt_from_to_arguments(parser) - - intervals = self.db.get_grid_used_intervals(dt_from, dt_to) - print_intervals(intervals) - - def get_grid_consumed_energy(self): - parser = SubParser('Returns sum of energy consumed from util grid', method_usage()) - dt_from, dt_to = get_dt_from_to_arguments(parser) - - wh = self.db.get_grid_consumed_energy(dt_from, dt_to) - print('%.2f' % wh,) - - def get_consumed_energy(self): - parser = SubParser('Returns total consumed energy', method_usage()) - dt_from, dt_to = get_dt_from_to_arguments(parser) - - wh = self.db.get_consumed_energy(dt_from, dt_to) - print('%.2f' % wh,) - - -if __name__ == '__main__': - try: - Electricity() - except Exception as e: - _logger.exception(e) - sys.exit(1) diff --git a/src/esp32_capture.py b/src/esp32_capture.py deleted file mode 100755 index 0441565..0000000 --- a/src/esp32_capture.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import logging -import os.path - -from argparse import ArgumentParser -from home.camera.esp32 import WebClient -from home.util import Addr -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from datetime import datetime -from typing import Optional - -logger = logging.getLogger(__name__) -cam: Optional[WebClient] = None - - -class ESP32Capture: - def __init__(self, addr: Addr, interval: float, output_directory: str): - self.logger = logging.getLogger(self.__class__.__name__) - self.client = WebClient(addr) - self.output_directory = output_directory - self.interval = interval - - self.scheduler = AsyncIOScheduler() - self.scheduler.add_job(self.capture, 'interval', seconds=arg.interval) - self.scheduler.start() - - async def capture(self): - self.logger.debug('capture: start') - now = datetime.now() - filename = os.path.join( - self.output_directory, - now.strftime('%Y-%m-%d-%H:%M:%S.%f.jpg') - ) - if not await self.client.capture(filename): - self.logger.error('failed to capture') - self.logger.debug('capture: done') - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('--addr', type=str, required=True) - parser.add_argument('--output-directory', type=str, required=True) - parser.add_argument('--interval', type=float, default=0.5) - parser.add_argument('--verbose', action='store_true') - arg = parser.parse_args() - - if arg.verbose: - logging.basicConfig(level=logging.DEBUG) - - loop = asyncio.get_event_loop() - - ESP32Capture(Addr.fromstring(arg.addr), arg.interval, arg.output_directory) - try: - loop.run_forever() - except KeyboardInterrupt: - pass diff --git a/src/esp32cam_capture_diff_node.py b/src/esp32cam_capture_diff_node.py deleted file mode 100755 index 59482f7..0000000 --- a/src/esp32cam_capture_diff_node.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import logging -import os.path -import tempfile -import home.telegram.aio as telegram - -from home.config import config -from home.camera.esp32 import WebClient -from home.util import Addr, send_datagram, stringify -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from typing import Optional - -logger = logging.getLogger(__name__) -cam: Optional[WebClient] = None - - -async def pyssim(fn1: str, fn2: str) -> float: - args = [config['pyssim']['bin']] - if 'width' in config['pyssim']: - args.extend(['--width', str(config['pyssim']['width'])]) - if 'height' in config['pyssim']: - args.extend(['--height', str(config['pyssim']['height'])]) - args.extend([fn1, fn2]) - proc = await asyncio.create_subprocess_exec(*args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE) - stdout, stderr = await proc.communicate() - if proc.returncode != 0: - logger.error(f'pyssim({fn1}, {fn2}): pyssim returned {proc.returncode}, stderr: {stderr.decode().strip()}') - - return float(stdout.decode().strip()) - - -class ESP32CamCaptureDiffNode: - def __init__(self): - self.client = WebClient(Addr.fromstring(config['esp32cam_web_addr'])) - self.directory = tempfile.gettempdir() - self.nextpic = 1 - self.first = True - self.server_addr = Addr.fromstring(config['node']['server_addr']) - - self.scheduler = AsyncIOScheduler() - self.scheduler.add_job(self.capture, 'interval', seconds=config['node']['interval']) - self.scheduler.start() - - async def capture(self): - logger.debug('capture: start') - - filename = self.getfilename() - if not await self.client.capture(os.path.join(self.directory, filename)): - logger.error('failed to capture') - return - - self.nextpic = 1 if self.nextpic == 2 else 2 - if not self.first: - second_filename = os.path.join(self.directory, self.getfilename()) - score = await pyssim(filename, second_filename) - logger.debug(f'pyssim: score={score}') - if score < config['pyssim']['threshold']: - logger.info(f'score = {score}, informing central server') - send_datagram(stringify([config['node']['name'], 2]), self.server_addr) - - # send to telegram - if 'telegram' in config: - await telegram.send_message(f'pyssim: score={score}') - await telegram.send_photo(filename) - await telegram.send_photo(second_filename) - - self.first = False - - logger.debug('capture: done') - - def getfilename(self): - return os.path.join(self.directory, f'{self.nextpic}.jpg') - - -if __name__ == '__main__': - config.load_app('esp32cam_capture_diff_node') - - loop = asyncio.get_event_loop() - ESP32CamCaptureDiffNode() - try: - loop.run_forever() - except KeyboardInterrupt: - pass diff --git a/src/gpiorelayd.py b/src/gpiorelayd.py deleted file mode 100755 index f1a9e57..0000000 --- a/src/gpiorelayd.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import sys - -from home.config import config -from home.relay.sunxi_h3_server import RelayServer - -logger = logging.getLogger(__name__) - - -if __name__ == '__main__': - if not os.getegid() == 0: - sys.exit('Must be run as root.') - - config.load_app() - - try: - s = RelayServer(pinname=config.get('relayd.pin'), - addr=config.get_addr('relayd.listen')) - s.run() - except KeyboardInterrupt: - logger.info('Exiting...') diff --git a/src/home/__init__.py b/src/home/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/home/api/__init__.py b/src/home/api/__init__.py deleted file mode 100644 index d641f62..0000000 --- a/src/home/api/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -import importlib - -__all__ = [ - # web_api_client.py - 'WebApiClient', - 'RequestParams', - - # config.py - 'WebApiConfig' -] - - -def __getattr__(name): - if name in __all__: - file = 'config' if name == 'WebApiConfig' else 'web_api_client' - module = importlib.import_module(f'.{file}', __name__) - return getattr(module, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/api/__init__.pyi b/src/home/api/__init__.pyi deleted file mode 100644 index 5b98161..0000000 --- a/src/home/api/__init__.pyi +++ /dev/null @@ -1,5 +0,0 @@ -from .web_api_client import ( - RequestParams as RequestParams, - WebApiClient as WebApiClient -) -from .config import WebApiConfig as WebApiConfig diff --git a/src/home/api/config.py b/src/home/api/config.py deleted file mode 100644 index 00c1097..0000000 --- a/src/home/api/config.py +++ /dev/null @@ -1,15 +0,0 @@ -from ..config import ConfigUnit -from typing import Optional, Union - - -class WebApiConfig(ConfigUnit): - NAME = 'web_api' - - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'listen_addr': cls._addr_schema(required=True), - 'host': cls._addr_schema(required=True), - 'token': dict(type='string', required=True), - 'recordings_dir': dict(type='string', required=True) - } \ No newline at end of file diff --git a/src/home/api/errors/__init__.py b/src/home/api/errors/__init__.py deleted file mode 100644 index efb06aa..0000000 --- a/src/home/api/errors/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .api_response_error import ApiResponseError diff --git a/src/home/api/errors/api_response_error.py b/src/home/api/errors/api_response_error.py deleted file mode 100644 index 85d788b..0000000 --- a/src/home/api/errors/api_response_error.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Optional, List - - -class ApiResponseError(Exception): - def __init__(self, - status_code: int, - error_type: str, - error_message: str, - error_stacktrace: Optional[List[str]] = None): - super().__init__() - self.status_code = status_code - self.error_message = error_message - self.error_type = error_type - self.error_stacktrace = error_stacktrace - - def __str__(self): - def st_formatter(line: str): - return f'Remote| {line}' - - s = f'{self.error_type}: {self.error_message} (HTTP {self.status_code})' - if self.error_stacktrace is not None: - st = [] - for st_line in self.error_stacktrace: - st.append('\n'.join(st_formatter(st_subline) for st_subline in st_line.split('\n'))) - s += '\nRemote stacktrace:\n' - s += '\n'.join(st) - - return s diff --git a/src/home/api/types/__init__.py b/src/home/api/types/__init__.py deleted file mode 100644 index 9f27ff6..0000000 --- a/src/home/api/types/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .types import ( - BotType, - TemperatureSensorDataType, - TemperatureSensorLocation, - SoundSensorLocation -) diff --git a/src/home/api/types/types.py b/src/home/api/types/types.py deleted file mode 100644 index 981e798..0000000 --- a/src/home/api/types/types.py +++ /dev/null @@ -1,33 +0,0 @@ -from enum import Enum, auto - - -class BotType(Enum): - INVERTER = auto() - PUMP = auto() - SENSORS = auto() - ADMIN = auto() - SOUND = auto() - POLARIS_KETTLE = auto() - PUMP_MQTT = auto() - RELAY_MQTT = auto() - - -class TemperatureSensorLocation(Enum): - BIG_HOUSE_1 = auto() - BIG_HOUSE_2 = auto() - BIG_HOUSE_ROOM = auto() - STREET = auto() - DIANA = auto() - SPB1 = auto() - - -class TemperatureSensorDataType(Enum): - TEMPERATURE = auto() - RELATIVE_HUMIDITY = auto() - - -class SoundSensorLocation(Enum): - DIANA = auto() - BIG_HOUSE = auto() - SPB1 = auto() - diff --git a/src/home/api/web_api_client.py b/src/home/api/web_api_client.py deleted file mode 100644 index 15c1915..0000000 --- a/src/home/api/web_api_client.py +++ /dev/null @@ -1,227 +0,0 @@ -import requests -import json -import threading -import logging - -from collections import namedtuple -from datetime import datetime -from enum import Enum, auto -from typing import Optional, Callable, Union, List, Tuple, Dict -from requests.auth import HTTPBasicAuth - -from .config import WebApiConfig -from .errors import ApiResponseError -from .types import * -from ..config import config -from ..util import stringify -from ..media import RecordFile, MediaNodeClient - -_logger = logging.getLogger(__name__) -_config = WebApiConfig() - - -RequestParams = namedtuple('RequestParams', 'params, files, method') - - -class HTTPMethod(Enum): - GET = auto() - POST = auto() - - -class WebApiClient: - token: str - timeout: Union[float, Tuple[float, float]] - basic_auth: Optional[HTTPBasicAuth] - do_async: bool - async_error_handler: Optional[Callable] - async_success_handler: Optional[Callable] - - def __init__(self, timeout: Union[float, Tuple[float, float]] = 5): - self.token = config['token'] - self.timeout = timeout - self.basic_auth = None - self.do_async = False - self.async_error_handler = None - self.async_success_handler = None - - # if 'basic_auth' in config['api']: - # ba = config['api']['basic_auth'] - # col = ba.index(':') - # - # user = ba[:col] - # pw = ba[col+1:] - # - # _logger.debug(f'enabling basic auth: {user}:{pw}') - # self.basic_auth = HTTPBasicAuth(user, pw) - - # api methods - # ----------- - - def log_bot_request(self, - bot: BotType, - user_id: int, - message: str): - return self._post('log/bot_request/', { - 'bot': bot.value, - 'user_id': str(user_id), - 'message': message - }) - - def log_openwrt(self, - lines: List[Tuple[int, str]], - access_point: int): - return self._post('log/openwrt/', { - 'logs': stringify(lines), - 'ap': access_point - }) - - def get_sensors_data(self, - sensor: TemperatureSensorLocation, - hours: int): - data = self._get('sensors/data/', { - 'sensor': sensor.value, - 'hours': hours - }) - return [(datetime.fromtimestamp(date), temp, hum) for date, temp, hum in data] - - def add_sound_sensor_hits(self, - hits: List[Tuple[str, int]]): - return self._post('sound_sensors/hits/', { - 'hits': stringify(hits) - }) - - def get_sound_sensor_hits(self, - location: SoundSensorLocation, - after: datetime) -> List[dict]: - return self._process_sound_sensor_hits_data(self._get('sound_sensors/hits/', { - 'after': int(after.timestamp()), - 'location': location.value - })) - - def get_last_sound_sensor_hits(self, location: SoundSensorLocation, last: int): - return self._process_sound_sensor_hits_data(self._get('sound_sensors/hits/', { - 'last': last, - 'location': location.value - })) - - def recordings_list(self, extended=False, as_objects=False) -> Union[List[str], List[dict], List[RecordFile]]: - files = self._get('recordings/list/', {'extended': int(extended)})['data'] - if as_objects: - return MediaNodeClient.record_list_from_serialized(files) - return files - - def inverter_get_consumed_energy(self, s_from: str, s_to: str): - return self._get('inverter/consumed_energy/', { - 'from': s_from, - 'to': s_to - }) - - def inverter_get_grid_consumed_energy(self, s_from: str, s_to: str): - return self._get('inverter/grid_consumed_energy/', { - 'from': s_from, - 'to': s_to - }) - - @staticmethod - def _process_sound_sensor_hits_data(data: List[dict]) -> List[dict]: - for item in data: - item['time'] = datetime.fromtimestamp(item['time']) - return data - - # internal methods - # ---------------- - - def _get(self, *args, **kwargs): - return self._call(method=HTTPMethod.GET, *args, **kwargs) - - def _post(self, *args, **kwargs): - return self._call(method=HTTPMethod.POST, *args, **kwargs) - - def _call(self, - name: str, - params: dict, - method: HTTPMethod, - files: Optional[Dict[str, str]] = None): - if not self.do_async: - return self._make_request(name, params, method, files) - else: - t = threading.Thread(target=self._make_request_in_thread, args=(name, params, method, files)) - t.start() - return None - - def _make_request(self, - name: str, - params: dict, - method: HTTPMethod = HTTPMethod.GET, - files: Optional[Dict[str, str]] = None) -> Optional[any]: - domain = config['host'] - kwargs = {} - - if self.basic_auth is not None: - kwargs['auth'] = self.basic_auth - - if method == HTTPMethod.GET: - if files: - raise RuntimeError('can\'t upload files using GET, please use me properly') - kwargs['params'] = params - f = requests.get - else: - kwargs['data'] = params - f = requests.post - - fd = {} - if files: - for fname, fpath in files.items(): - fd[fname] = open(fpath, 'rb') - kwargs['files'] = fd - - try: - r = f(f'https://{domain}/{name}', - headers={'X-Token': self.token}, - timeout=self.timeout, - **kwargs) - - if not r.headers['content-type'].startswith('application/json'): - raise ApiResponseError(r.status_code, 'TypeError', 'content-type is not application/json') - - data = json.loads(r.text) - if r.status_code != 200: - raise ApiResponseError(r.status_code, - data['error'], - data['message'], - data['stacktrace'] if 'stacktrace' in data['error'] else None) - - return data['response'] if 'response' in data else True - finally: - for fname, f in fd.items(): - # logger.debug(f'closing file {fname} (fd={f})') - try: - f.close() - except Exception as exc: - _logger.exception(exc) - pass - - def _make_request_in_thread(self, name, params, method, files): - try: - result = self._make_request(name, params, method, files) - self._report_async_success(result, name, RequestParams(params=params, method=method, files=files)) - except Exception as e: - _logger.exception(e) - self._report_async_error(e, name, RequestParams(params=params, method=method, files=files)) - - def enable_async(self, - success_handler: Optional[Callable] = None, - error_handler: Optional[Callable] = None): - self.do_async = True - if error_handler: - self.async_error_handler = error_handler - if success_handler: - self.async_success_handler = success_handler - - def _report_async_error(self, *args): - if self.async_error_handler: - self.async_error_handler(*args) - - def _report_async_success(self, *args): - if self.async_success_handler: - self.async_success_handler(*args) \ No newline at end of file diff --git a/src/home/audio/__init__.py b/src/home/audio/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/home/audio/amixer.py b/src/home/audio/amixer.py deleted file mode 100644 index 5133c97..0000000 --- a/src/home/audio/amixer.py +++ /dev/null @@ -1,91 +0,0 @@ -import subprocess - -from ..config import app_config as config -from threading import Lock -from typing import Union, List - - -_lock = Lock() -_default_step = 5 - - -def has_control(s: str) -> bool: - for control in config['amixer']['controls']: - if control['name'] == s: - return True - return False - - -def get_caps(s: str) -> List[str]: - for control in config['amixer']['controls']: - if control['name'] == s: - return control['caps'] - raise KeyError(f'control {s} not found') - - -def get_all() -> list: - controls = [] - for control in config['amixer']['controls']: - controls.append({ - 'name': control['name'], - 'info': get(control['name']), - 'caps': control['caps'] - }) - return controls - - -def get(control: str): - return call('get', control) - - -def mute(control): - return call('set', control, 'mute') - - -def unmute(control): - return call('set', control, 'unmute') - - -def cap(control): - return call('set', control, 'cap') - - -def nocap(control): - return call('set', control, 'nocap') - - -def _get_default_step() -> int: - if 'step' in config['amixer']: - return int(config['amixer']['step']) - - return _default_step - - -def incr(control, step=None): - if step is None: - step = _get_default_step() - return call('set', control, f'{step}%+') - - -def decr(control, step=None): - if step is None: - step = _get_default_step() - return call('set', control, f'{step}%-') - - -def call(*args, return_code=False) -> Union[int, str]: - with _lock: - result = subprocess.run([config['amixer']['bin'], *args], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - if return_code: - return result.returncode - - if result.returncode != 0: - raise AmixerError(result.stderr.decode().strip()) - - return result.stdout.decode().strip() - - -class AmixerError(OSError): - pass diff --git a/src/home/camera/__init__.py b/src/home/camera/__init__.py deleted file mode 100644 index 626930b..0000000 --- a/src/home/camera/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .types import CameraType \ No newline at end of file diff --git a/src/home/camera/esp32.py b/src/home/camera/esp32.py deleted file mode 100644 index fe6de0e..0000000 --- a/src/home/camera/esp32.py +++ /dev/null @@ -1,226 +0,0 @@ -import logging -import requests -import json -import asyncio -import aioshutil - -from io import BytesIO -from functools import partial -from typing import Union, Optional -from enum import Enum -from ..api.errors import ApiResponseError -from ..util import Addr - - -class FrameSize(Enum): - UXGA_1600x1200 = 13 - SXGA_1280x1024 = 12 - HD_1280x720 = 11 - XGA_1024x768 = 10 - SVGA_800x600 = 9 - VGA_640x480 = 8 - HVGA_480x320 = 7 - CIF_400x296 = 6 - QVGA_320x240 = 5 - N_240x240 = 4 - HQVGA_240x176 = 3 - QCIF_176x144 = 2 - QQVGA_160x120 = 1 - N_96x96 = 0 - - -class WBMode(Enum): - AUTO = 0 - SUNNY = 1 - CLOUDY = 2 - OFFICE = 3 - HOME = 4 - - -def _assert_bounds(n: int, min: int, max: int): - if not min <= n <= max: - raise ValueError(f'value must be between {min} and {max}') - - -class WebClient: - def __init__(self, - addr: Addr): - self.endpoint = f'http://{addr[0]}:{addr[1]}' - self.logger = logging.getLogger(self.__class__.__name__) - self.delay = 0 - self.isfirstrequest = True - - async def syncsettings(self, settings) -> bool: - status = await self.getstatus() - self.logger.debug(f'syncsettings: status={status}') - - changed_anything = False - - for name, value in settings.items(): - server_name = name - if name == 'aec_dsp': - server_name = 'aec2' - - if server_name not in status: - # legacy compatibility - if server_name != 'vflip': - self.logger.warning(f'syncsettings: field `{server_name}` not found in camera status') - continue - - try: - # server returns 0 or 1 for bool values - if type(value) is bool: - value = int(value) - - if status[server_name] == value: - continue - except KeyError as exc: - if name != 'vflip': - self.logger.error(exc) - - try: - # fix for cases like when field is called raw_gma, but method is setrawgma() - name = name.replace('_', '') - - func = getattr(self, f'set{name}') - self.logger.debug(f'syncsettings: calling set{name}({value})') - - await func(value) - - changed_anything = True - except AttributeError as exc: - self.logger.exception(exc) - self.logger.error(f'syncsettings: method set{name}() not found') - - return changed_anything - - def setdelay(self, delay: int): - self.delay = delay - - async def capture(self, output: Optional[str] = None) -> Union[BytesIO, bool]: - kw = {} - if output: - kw['save_to'] = output - else: - kw['as_bytes'] = True - return await self._call('capture', **kw) - - async def getstatus(self): - return json.loads(await self._call('status')) - - async def setflash(self, enable: bool): - await self._control('flash', int(enable)) - - async def setframesize(self, fs: Union[int, FrameSize]): - if type(fs) is int: - fs = FrameSize(fs) - await self._control('framesize', fs.value) - - async def sethmirror(self, enable: bool): - await self._control('hmirror', int(enable)) - - async def setvflip(self, enable: bool): - await self._control('vflip', int(enable)) - - async def setawb(self, enable: bool): - await self._control('awb', int(enable)) - - async def setawbgain(self, enable: bool): - await self._control('awb_gain', int(enable)) - - async def setwbmode(self, mode: WBMode): - await self._control('wb_mode', mode.value) - - async def setaecsensor(self, enable: bool): - await self._control('aec', int(enable)) - - async def setaecdsp(self, enable: bool): - await self._control('aec2', int(enable)) - - async def setagc(self, enable: bool): - await self._control('agc', int(enable)) - - async def setagcgain(self, gain: int): - _assert_bounds(gain, 1, 31) - await self._control('agc_gain', gain) - - async def setgainceiling(self, gainceiling: int): - _assert_bounds(gainceiling, 2, 128) - await self._control('gainceiling', gainceiling) - - async def setbpc(self, enable: bool): - await self._control('bpc', int(enable)) - - async def setwpc(self, enable: bool): - await self._control('wpc', int(enable)) - - async def setrawgma(self, enable: bool): - await self._control('raw_gma', int(enable)) - - async def setlenscorrection(self, enable: bool): - await self._control('lenc', int(enable)) - - async def setdcw(self, enable: bool): - await self._control('dcw', int(enable)) - - async def setcolorbar(self, enable: bool): - await self._control('colorbar', int(enable)) - - async def setquality(self, q: int): - _assert_bounds(q, 4, 63) - await self._control('quality', q) - - async def setbrightness(self, brightness: int): - _assert_bounds(brightness, -2, -2) - await self._control('brightness', brightness) - - async def setcontrast(self, contrast: int): - _assert_bounds(contrast, -2, 2) - await self._control('contrast', contrast) - - async def setsaturation(self, saturation: int): - _assert_bounds(saturation, -2, 2) - await self._control('saturation', saturation) - - async def _control(self, var: str, value: Union[int, str]): - return await self._call('control', params={'var': var, 'val': value}) - - async def _call(self, - method: str, - params: Optional[dict] = None, - save_to: Optional[str] = None, - as_bytes=False) -> Union[str, bool, BytesIO]: - loop = asyncio.get_event_loop() - - if not self.isfirstrequest and self.delay > 0: - sleeptime = self.delay / 1000 - self.logger.debug(f'sleeping for {sleeptime}') - - await asyncio.sleep(sleeptime) - - self.isfirstrequest = False - - url = f'{self.endpoint}/{method}' - self.logger.debug(f'calling {url}, params: {params}') - - kwargs = {} - if params: - kwargs['params'] = params - if save_to: - kwargs['stream'] = True - - r = await loop.run_in_executor(None, - partial(requests.get, url, **kwargs)) - if r.status_code != 200: - raise ApiResponseError(status_code=r.status_code) - - if as_bytes: - return BytesIO(r.content) - - if save_to: - r.raise_for_status() - with open(save_to, 'wb') as f: - await aioshutil.copyfileobj(r.raw, f) - return True - - return r.text diff --git a/src/home/camera/types.py b/src/home/camera/types.py deleted file mode 100644 index de59022..0000000 --- a/src/home/camera/types.py +++ /dev/null @@ -1,5 +0,0 @@ -from enum import Enum - - -class CameraType(Enum): - ESP32 = 'esp32' diff --git a/src/home/camera/util.py b/src/home/camera/util.py deleted file mode 100644 index 97f35aa..0000000 --- a/src/home/camera/util.py +++ /dev/null @@ -1,107 +0,0 @@ -import asyncio -import os.path -import logging -import psutil - -from typing import List, Tuple -from ..util import chunks -from ..config import config - -_logger = logging.getLogger(__name__) -_temporary_fixing = '.temporary_fixing.mp4' - - -def _get_ffmpeg_path() -> str: - return 'ffmpeg' if 'ffmpeg' not in config else config['ffmpeg']['path'] - - -def time2seconds(time: str) -> int: - time, frac = time.split('.') - frac = int(frac) - - h, m, s = [int(i) for i in time.split(':')] - - return round(s + m*60 + h*3600 + frac/1000) - - -async def ffmpeg_recreate(filename: str): - filedir = os.path.dirname(filename) - tempname = os.path.join(filedir, _temporary_fixing) - mtime = os.path.getmtime(filename) - - args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname] - proc = await asyncio.create_subprocess_exec(*args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE) - stdout, stderr = await proc.communicate() - if proc.returncode != 0: - _logger.error(f'fix_timestamps({filename}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}') - - if os.path.isfile(tempname): - os.unlink(filename) - os.rename(tempname, filename) - os.utime(filename, (mtime, mtime)) - _logger.info(f'fix_timestamps({filename}): OK') - else: - _logger.error(f'fix_timestamps({filename}): temp file \'{tempname}\' does not exists, fix failed') - - -async def ffmpeg_cut(input: str, - output: str, - start_pos: int, - duration: int): - args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', input, - '-ss', str(start_pos), '-t', str(duration), - '-c', 'copy', '-y', output] - proc = await asyncio.create_subprocess_exec(*args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE) - stdout, stderr = await proc.communicate() - if proc.returncode != 0: - _logger.error(f'ffmpeg_cut({input}, start_pos={start_pos}, duration={duration}): ffmpeg returned {proc.returncode}, stderr: {stderr.decode().strip()}') - else: - _logger.info(f'ffmpeg_cut({input}): OK') - - -def dvr_scan_timecodes(timecodes: str) -> List[Tuple[int, int]]: - tc_backup = timecodes - - timecodes = timecodes.split(',') - if len(timecodes) % 2 != 0: - raise DVRScanInvalidTimecodes(f'invalid number of timecodes. input: {tc_backup}') - - timecodes = list(map(time2seconds, timecodes)) - timecodes = list(chunks(timecodes, 2)) - - # sort out invalid fragments (dvr-scan returns them sometimes, idk why...) - timecodes = list(filter(lambda f: f[0] < f[1], timecodes)) - if not timecodes: - raise DVRScanInvalidTimecodes(f'no valid timecodes. input: {tc_backup}') - - # https://stackoverflow.com/a/43600953 - timecodes.sort(key=lambda interval: interval[0]) - merged = [timecodes[0]] - for current in timecodes: - previous = merged[-1] - if current[0] <= previous[1]: - previous[1] = max(previous[1], current[1]) - else: - merged.append(current) - - return merged - - -class DVRScanInvalidTimecodes(Exception): - pass - - -def has_handle(fpath): - for proc in psutil.process_iter(): - try: - for item in proc.open_files(): - if fpath == item.path: - return True - except Exception: - pass - - return False \ No newline at end of file diff --git a/src/home/config/__init__.py b/src/home/config/__init__.py deleted file mode 100644 index 2fa5214..0000000 --- a/src/home/config/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .config import ( - Config, - ConfigUnit, - AppConfigUnit, - Translation, - config, - is_development_mode, - setup_logging -) -from ._configs import ( - LinuxBoardsConfig, - ServicesListConfig -) \ No newline at end of file diff --git a/src/home/config/_configs.py b/src/home/config/_configs.py deleted file mode 100644 index 1628cba..0000000 --- a/src/home/config/_configs.py +++ /dev/null @@ -1,55 +0,0 @@ -from .config import ConfigUnit -from typing import Optional - - -class ServicesListConfig(ConfigUnit): - NAME = 'services_list' - - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'type': 'list', - 'empty': False, - 'schema': { - 'type': 'string' - } - } - - -class LinuxBoardsConfig(ConfigUnit): - NAME = 'linux_boards' - - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'type': 'dict', - 'schema': { - 'mdns': {'type': 'string', 'required': True}, - 'board': {'type': 'string', 'required': True}, - 'network': { - 'type': 'list', - 'required': True, - 'empty': False, - 'allowed': ['wifi', 'ethernet'] - }, - 'ram': {'type': 'integer', 'required': True}, - 'online': {'type': 'boolean', 'required': True}, - - # optional - 'services': { - 'type': 'list', - 'empty': False, - 'allowed': ServicesListConfig().get() - }, - 'ext_hdd': { - 'type': 'list', - 'schema': { - 'type': 'dict', - 'schema': { - 'mountpoint': {'type': 'string', 'required': True}, - 'size': {'type': 'integer', 'required': True} - } - }, - }, - } - } diff --git a/src/home/config/config.py b/src/home/config/config.py deleted file mode 100644 index 7344386..0000000 --- a/src/home/config/config.py +++ /dev/null @@ -1,387 +0,0 @@ -import yaml -import logging -import os -import cerberus -import cerberus.errors - -from abc import ABC -from typing import Optional, Any, MutableMapping, Union -from argparse import ArgumentParser -from enum import Enum, auto -from os.path import join, isdir, isfile -from ..util import Addr - - -class MyValidator(cerberus.Validator): - def _normalize_coerce_addr(self, value): - return Addr.fromstring(value) - - -MyValidator.types_mapping['addr'] = cerberus.TypeDefinition('Addr', (Addr,), ()) - - -CONFIG_DIRECTORIES = ( - join(os.environ['HOME'], '.config', 'homekit'), - '/etc/homekit' -) - - -class RootSchemaType(Enum): - DEFAULT = auto() - DICT = auto() - LIST = auto() - - -class BaseConfigUnit(ABC): - _data: MutableMapping[str, Any] - _logger: logging.Logger - - def __init__(self): - self._data = {} - self._logger = logging.getLogger(self.__class__.__name__) - - def __getitem__(self, key): - return self._data[key] - - def __setitem__(self, key, value): - raise NotImplementedError('overwriting config values is prohibited') - - def __contains__(self, key): - return key in self._data - - def load_from(self, path: str): - with open(path, 'r') as fd: - self._data = yaml.safe_load(fd) - - def get(self, - key: Optional[str] = None, - default=None): - if key is None: - return self._data - - cur = self._data - pts = key.split('.') - for i in range(len(pts)): - k = pts[i] - if i < len(pts)-1: - if k not in cur: - raise KeyError(f'key {k} not found') - else: - return cur[k] if k in cur else default - cur = self._data[k] - - raise KeyError(f'option {key} not found') - - -class ConfigUnit(BaseConfigUnit): - NAME = 'dumb' - - def __init__(self, name=None, load=True): - super().__init__() - - self._data = {} - self._logger = logging.getLogger(self.__class__.__name__) - - if self.NAME != 'dumb' and load: - self.load_from(self.get_config_path()) - self.validate() - - elif name is not None: - self.NAME = name - - @classmethod - def get_config_path(cls, name=None) -> str: - if name is None: - name = cls.NAME - if name is None: - raise ValueError('get_config_path: name is none') - - for dirname in CONFIG_DIRECTORIES: - if isdir(dirname): - filename = join(dirname, f'{name}.yaml') - if isfile(filename): - return filename - - raise IOError(f'\'{name}.yaml\' not found') - - @classmethod - def schema(cls) -> Optional[dict]: - return None - - @classmethod - def _addr_schema(cls, required=False, **kwargs): - return { - 'type': 'addr', - 'coerce': Addr.fromstring, - 'required': required, - **kwargs - } - - def validate(self): - schema = self.schema() - if not schema: - self._logger.warning('validate: no schema') - return - - if isinstance(self, AppConfigUnit): - schema['logging'] = { - 'type': 'dict', - 'schema': { - 'logging': {'type': 'boolean'} - } - } - - rst = RootSchemaType.DEFAULT - try: - if schema['type'] == 'dict': - rst = RootSchemaType.DICT - elif schema['type'] == 'list': - rst = RootSchemaType.LIST - elif schema['roottype'] == 'dict': - del schema['roottype'] - rst = RootSchemaType.DICT - except KeyError: - pass - - v = MyValidator() - - if rst == RootSchemaType.DICT: - normalized = v.validated({'document': self._data}, - {'document': { - 'type': 'dict', - 'keysrules': {'type': 'string'}, - 'valuesrules': schema - }})['document'] - elif rst == RootSchemaType.LIST: - v = MyValidator() - normalized = v.validated({'document': self._data}, {'document': schema})['document'] - else: - normalized = v.validated(self._data, schema) - - self._data = normalized - - try: - self.custom_validator(self._data) - except Exception as e: - raise cerberus.DocumentError(f'{self.__class__.__name__}: {str(e)}') - - @staticmethod - def custom_validator(data): - pass - - def get_addr(self, key: str): - return Addr.fromstring(self.get(key)) - - -class AppConfigUnit(ConfigUnit): - _logging_verbose: bool - _logging_fmt: Optional[str] - _logging_file: Optional[str] - - def __init__(self, *args, **kwargs): - super().__init__(load=False, *args, **kwargs) - self._logging_verbose = False - self._logging_fmt = None - self._logging_file = None - - def logging_set_fmt(self, fmt: str) -> None: - self._logging_fmt = fmt - - def logging_get_fmt(self) -> Optional[str]: - try: - return self['logging']['default_fmt'] - except KeyError: - return self._logging_fmt - - def logging_set_file(self, file: str) -> None: - self._logging_file = file - - def logging_get_file(self) -> Optional[str]: - try: - return self['logging']['file'] - except KeyError: - return self._logging_file - - def logging_set_verbose(self): - self._logging_verbose = True - - def logging_is_verbose(self) -> bool: - try: - return bool(self['logging']['verbose']) - except KeyError: - return self._logging_verbose - - -class TranslationUnit(BaseConfigUnit): - pass - - -class Translation: - LANGUAGES = ('en', 'ru') - _langs: dict[str, TranslationUnit] - - def __init__(self, name: str): - super().__init__() - self._langs = {} - for lang in self.LANGUAGES: - for dirname in CONFIG_DIRECTORIES: - if isdir(dirname): - filename = join(dirname, f'i18n-{lang}', f'{name}.yaml') - if lang in self._langs: - raise RuntimeError(f'{name}: translation unit for lang \'{lang}\' already loaded') - self._langs[lang] = TranslationUnit() - self._langs[lang].load_from(filename) - diff = set() - for data in self._langs.values(): - diff ^= data.get().keys() - if len(diff) > 0: - raise RuntimeError(f'{name}: translation units have difference in keys: ' + ', '.join(diff)) - - def get(self, lang: str) -> TranslationUnit: - return self._langs[lang] - - -class Config: - app_name: Optional[str] - app_config: AppConfigUnit - - def __init__(self): - self.app_name = None - self.app_config = AppConfigUnit() - - def load_app(self, - name: Optional[Union[str, AppConfigUnit, bool]] = None, - use_cli=True, - parser: ArgumentParser = None, - no_config=False): - global app_config - - if not no_config \ - and not isinstance(name, str) \ - and not isinstance(name, bool) \ - and issubclass(name, AppConfigUnit) or name == AppConfigUnit: - self.app_name = name.NAME - self.app_config = name() - app_config = self.app_config - else: - self.app_name = name if isinstance(name, str) else None - - if self.app_name is None and not use_cli: - raise RuntimeError('either config name must be none or use_cli must be True') - - no_config = name is False or no_config - path = None - - if use_cli: - if parser is None: - parser = ArgumentParser() - if not no_config: - parser.add_argument('-c', '--config', type=str, required=name is None, - help='Path to the config in TOML or YAML format') - parser.add_argument('-V', '--verbose', action='store_true') - parser.add_argument('--log-file', type=str) - parser.add_argument('--log-default-fmt', action='store_true') - args = parser.parse_args() - - if not no_config and args.config: - path = args.config - - if args.verbose: - self.app_config.logging_set_verbose() - if args.log_file: - self.app_config.logging_set_file(args.log_file) - if args.log_default_fmt: - self.app_config.logging_set_fmt(args.log_default_fmt) - - if not isinstance(name, ConfigUnit): - if not no_config and path is None: - path = ConfigUnit.get_config_path(name=self.app_name) - - if not no_config: - self.app_config.load_from(path) - self.app_config.validate() - - setup_logging(self.app_config.logging_is_verbose(), - self.app_config.logging_get_file(), - self.app_config.logging_get_fmt()) - - if use_cli: - return args - - -config = Config() - - -def is_development_mode() -> bool: - if 'HK_MODE' in os.environ and os.environ['HK_MODE'] == 'dev': - return True - - return ('logging' in config.app_config) and ('verbose' in config.app_config['logging']) and (config.app_config['logging']['verbose'] is True) - - -def setup_logging(verbose=False, log_file=None, default_fmt=None): - logging_level = logging.INFO - if is_development_mode() or verbose: - logging_level = logging.DEBUG - _add_logging_level('TRACE', logging.DEBUG-5) - - log_config = {'level': logging_level} - if not default_fmt: - log_config['format'] = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - - if log_file is not None: - log_config['filename'] = log_file - log_config['encoding'] = 'utf-8' - - logging.basicConfig(**log_config) - - -# https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945 -def _add_logging_level(levelName, levelNum, methodName=None): - """ - Comprehensively adds a new logging level to the `logging` module and the - currently configured logging class. - - `levelName` becomes an attribute of the `logging` module with the value - `levelNum`. `methodName` becomes a convenience method for both `logging` - itself and the class returned by `logging.getLoggerClass()` (usually just - `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is - used. - - To avoid accidental clobberings of existing attributes, this method will - raise an `AttributeError` if the level name is already an attribute of the - `logging` module or if the method name is already present - - Example - ------- - >>> addLoggingLevel('TRACE', logging.DEBUG - 5) - >>> logging.getLogger(__name__).setLevel("TRACE") - >>> logging.getLogger(__name__).trace('that worked') - >>> logging.trace('so did this') - >>> logging.TRACE - 5 - - """ - if not methodName: - methodName = levelName.lower() - - if hasattr(logging, levelName): - raise AttributeError('{} already defined in logging module'.format(levelName)) - if hasattr(logging, methodName): - raise AttributeError('{} already defined in logging module'.format(methodName)) - if hasattr(logging.getLoggerClass(), methodName): - raise AttributeError('{} already defined in logger class'.format(methodName)) - - # This method was inspired by the answers to Stack Overflow post - # http://stackoverflow.com/q/2183233/2988730, especially - # http://stackoverflow.com/a/13638084/2988730 - def logForLevel(self, message, *args, **kwargs): - if self.isEnabledFor(levelNum): - self._log(levelNum, message, args, **kwargs) - def logToRoot(message, *args, **kwargs): - logging.log(levelNum, message, *args, **kwargs) - - logging.addLevelName(levelNum, levelName) - setattr(logging, levelName, levelNum) - setattr(logging.getLoggerClass(), methodName, logForLevel) - setattr(logging, methodName, logToRoot) \ No newline at end of file diff --git a/src/home/database/__init__.py b/src/home/database/__init__.py deleted file mode 100644 index b50cbce..0000000 --- a/src/home/database/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -import importlib - -__all__ = [ - 'get_mysql', - 'mysql_now', - 'get_clickhouse', - 'SimpleState', - - 'SensorsDatabase', - 'InverterDatabase', - 'BotsDatabase' -] - - -def __getattr__(name: str): - if name in __all__: - if name.endswith('Database'): - file = name[:-8].lower() - elif 'mysql' in name: - file = 'mysql' - elif 'clickhouse' in name: - file = 'clickhouse' - else: - file = 'simple_state' - - module = importlib.import_module(f'.{file}', __name__) - return getattr(module, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/database/__init__.pyi b/src/home/database/__init__.pyi deleted file mode 100644 index 31aae5d..0000000 --- a/src/home/database/__init__.pyi +++ /dev/null @@ -1,11 +0,0 @@ -from .mysql import ( - get_mysql as get_mysql, - mysql_now as mysql_now -) -from .clickhouse import get_clickhouse as get_clickhouse - -from simple_state import SimpleState as SimpleState - -from .sensors import SensorsDatabase as SensorsDatabase -from .inverter import InverterDatabase as InverterDatabase -from .bots import BotsDatabase as BotsDatabase diff --git a/src/home/database/_base.py b/src/home/database/_base.py deleted file mode 100644 index c01e62b..0000000 --- a/src/home/database/_base.py +++ /dev/null @@ -1,9 +0,0 @@ -import os - - -def get_data_root_directory(name: str) -> str: - return os.path.join( - os.environ['HOME'], - '.config', - 'homekit', - 'data') \ No newline at end of file diff --git a/src/home/database/bots.py b/src/home/database/bots.py deleted file mode 100644 index cde48b9..0000000 --- a/src/home/database/bots.py +++ /dev/null @@ -1,106 +0,0 @@ -import pytz - -from .mysql import mysql_now, MySQLDatabase, datetime_fmt -from ..api.types import ( - BotType, - SoundSensorLocation -) -from typing import Optional, List, Tuple -from datetime import datetime -from html import escape - - -class OpenwrtLogRecord: - id: int - log_time: datetime - received_time: datetime - text: str - - def __init__(self, id, text, log_time, received_time): - self.id = id - self.text = text - self.log_time = log_time - self.received_time = received_time - - def __repr__(self): - return f"{self.log_time.strftime('%H:%M:%S')} {escape(self.text)}" - - -class BotsDatabase(MySQLDatabase): - def add_request(self, - bot: BotType, - user_id: int, - message: str): - with self.cursor() as cursor: - cursor.execute("INSERT INTO requests_log (user_id, message, bot, time) VALUES (%s, %s, %s, %s)", - (user_id, message, bot.name.lower(), mysql_now())) - self.commit() - - def add_openwrt_logs(self, - lines: List[Tuple[datetime, str]], - access_point: int): - now = datetime.now() - with self.cursor() as cursor: - for line in lines: - time, text = line - cursor.execute("INSERT INTO openwrt (log_time, received_time, text, ap) VALUES (%s, %s, %s, %s)", - (time.strftime(datetime_fmt), now.strftime(datetime_fmt), text, access_point)) - self.commit() - - def add_sound_hits(self, - hits: List[Tuple[SoundSensorLocation, int]], - time: datetime): - with self.cursor() as cursor: - for loc, count in hits: - cursor.execute("INSERT INTO sound_hits (location, `time`, hits) VALUES (%s, %s, %s)", - (loc.name.lower(), time.strftime(datetime_fmt), count)) - self.commit() - - def get_sound_hits(self, - location: SoundSensorLocation, - after: Optional[datetime] = None, - last: Optional[int] = None) -> List[dict]: - with self.cursor(dictionary=True) as cursor: - sql = "SELECT `time`, hits FROM sound_hits WHERE location=%s" - args = [location.name.lower()] - - if after: - sql += ' AND `time` >= %s ORDER BY time DESC' - args.append(after) - elif last: - sql += ' ORDER BY time DESC LIMIT 0, %s' - args.append(last) - else: - raise ValueError('no `after`, no `last`, what do you expect?') - - cursor.execute(sql, tuple(args)) - data = [] - for row in cursor.fetchall(): - data.append({ - 'time': row['time'], - 'hits': row['hits'] - }) - return data - - def get_openwrt_logs(self, - filter_text: str, - min_id: int, - access_point: int, - limit: int = None) -> List[OpenwrtLogRecord]: - tz = pytz.timezone('Europe/Moscow') - with self.cursor(dictionary=True) as cursor: - sql = "SELECT * FROM openwrt WHERE ap=%s AND text LIKE %s AND id > %s" - if limit is not None: - sql += f" LIMIT {limit}" - - cursor.execute(sql, (access_point, f'%{filter_text}%', min_id)) - data = [] - for row in cursor.fetchall(): - data.append(OpenwrtLogRecord( - id=int(row['id']), - text=row['text'], - log_time=row['log_time'].astimezone(tz), - received_time=row['received_time'].astimezone(tz) - )) - - return data diff --git a/src/home/database/clickhouse.py b/src/home/database/clickhouse.py deleted file mode 100644 index d0ec283..0000000 --- a/src/home/database/clickhouse.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging - -from zoneinfo import ZoneInfo -from datetime import datetime -from clickhouse_driver import Client as ClickhouseClient -from ..config import is_development_mode - -_links = {} - - -def get_clickhouse(db: str) -> ClickhouseClient: - if db not in _links: - _links[db] = ClickhouseClient.from_url(f'clickhouse://localhost/{db}') - - return _links[db] - - -class ClickhouseDatabase: - def __init__(self, db: str): - self.db = get_clickhouse(db) - - self.server_timezone = self.db.execute('SELECT timezone()')[0][0] - self.logger = logging.getLogger(self.__class__.__name__) - - def query(self, *args, **kwargs): - settings = {'use_client_time_zone': True} - kwargs['settings'] = settings - - if 'no_tz_fix' not in kwargs and len(args) > 1 and isinstance(args[1], dict): - for k, v in args[1].items(): - if isinstance(v, datetime): - args[1][k] = v.astimezone(tz=ZoneInfo(self.server_timezone)) - - result = self.db.execute(*args, **kwargs) - - if is_development_mode(): - self.logger.debug(args[0] if len(args) == 1 else args[0] % args[1]) - - return result diff --git a/src/home/database/inverter.py b/src/home/database/inverter.py deleted file mode 100644 index fc3f74f..0000000 --- a/src/home/database/inverter.py +++ /dev/null @@ -1,212 +0,0 @@ -from time import time -from datetime import datetime, timedelta -from typing import Optional -from collections import namedtuple - -from .clickhouse import ClickhouseDatabase - - -IntervalList = list[list[Optional[datetime]]] - - -class InverterDatabase(ClickhouseDatabase): - def __init__(self): - super().__init__('solarmon') - - def add_generation(self, home_id: int, client_time: int, watts: int) -> None: - self.db.execute( - 'INSERT INTO generation (ClientTime, ReceivedTime, HomeID, Watts) VALUES', - [[client_time, round(time()), home_id, watts]] - ) - - def add_status(self, home_id: int, - client_time: int, - grid_voltage: int, - grid_freq: int, - ac_output_voltage: int, - ac_output_freq: int, - ac_output_apparent_power: int, - ac_output_active_power: int, - output_load_percent: int, - battery_voltage: int, - battery_voltage_scc: int, - battery_voltage_scc2: int, - battery_discharge_current: int, - battery_charge_current: int, - battery_capacity: int, - inverter_heat_sink_temp: int, - mppt1_charger_temp: int, - mppt2_charger_temp: int, - pv1_input_power: int, - pv2_input_power: int, - pv1_input_voltage: int, - pv2_input_voltage: int, - mppt1_charger_status: int, - mppt2_charger_status: int, - battery_power_direction: int, - dc_ac_power_direction: int, - line_power_direction: int, - load_connected: int) -> None: - self.db.execute("""INSERT INTO status ( - ClientTime, - ReceivedTime, - HomeID, - GridVoltage, - GridFrequency, - ACOutputVoltage, - ACOutputFrequency, - ACOutputApparentPower, - ACOutputActivePower, - OutputLoadPercent, - BatteryVoltage, - BatteryVoltageSCC, - BatteryVoltageSCC2, - BatteryDischargingCurrent, - BatteryChargingCurrent, - BatteryCapacity, - HeatSinkTemp, - MPPT1ChargerTemp, - MPPT2ChargerTemp, - PV1InputPower, - PV2InputPower, - PV1InputVoltage, - PV2InputVoltage, - MPPT1ChargerStatus, - MPPT2ChargerStatus, - BatteryPowerDirection, - DCACPowerDirection, - LinePowerDirection, - LoadConnected) VALUES""", [[ - client_time, - round(time()), - home_id, - grid_voltage, - grid_freq, - ac_output_voltage, - ac_output_freq, - ac_output_apparent_power, - ac_output_active_power, - output_load_percent, - battery_voltage, - battery_voltage_scc, - battery_voltage_scc2, - battery_discharge_current, - battery_charge_current, - battery_capacity, - inverter_heat_sink_temp, - mppt1_charger_temp, - mppt2_charger_temp, - pv1_input_power, - pv2_input_power, - pv1_input_voltage, - pv2_input_voltage, - mppt1_charger_status, - mppt2_charger_status, - battery_power_direction, - dc_ac_power_direction, - line_power_direction, - load_connected - ]]) - - def get_consumed_energy(self, dt_from: datetime, dt_to: datetime) -> float: - rows = self.query('SELECT ClientTime, ACOutputActivePower FROM status' - ' WHERE ClientTime >= %(from)s AND ClientTime <= %(to)s' - ' ORDER BY ClientTime', {'from': dt_from, 'to': dt_to}) - prev_time = None - prev_wh = 0 - - ws = 0 # watt-seconds - for t, wh in rows: - if prev_time is not None: - n = (t - prev_time).total_seconds() - ws += prev_wh * n - - prev_time = t - prev_wh = wh - - return ws / 3600 # convert to watt-hours - - def get_intervals_by_condition(self, - dt_from: datetime, - dt_to: datetime, - cond_start: str, - cond_end: str) -> IntervalList: - rows = None - ranges = [[None, None]] - - while rows is None or len(rows) > 0: - if ranges[len(ranges)-1][0] is None: - condition = cond_start - range_idx = 0 - else: - condition = cond_end - range_idx = 1 - - rows = self.query('SELECT ClientTime FROM status ' - f'WHERE ClientTime > %(from)s AND ClientTime <= %(to)s AND {condition}' - ' ORDER BY ClientTime LIMIT 1', - {'from': dt_from, 'to': dt_to}) - if not rows: - break - - row = rows[0] - - ranges[len(ranges) - 1][range_idx] = row[0] - if range_idx == 1: - ranges.append([None, None]) - - dt_from = row[0] - - if ranges[len(ranges)-1][0] is None: - ranges.pop() - elif ranges[len(ranges)-1][1] is None: - ranges[len(ranges)-1][1] = dt_to - timedelta(seconds=1) - - return ranges - - def get_grid_connected_intervals(self, dt_from: datetime, dt_to: datetime) -> IntervalList: - return self.get_intervals_by_condition(dt_from, dt_to, 'GridFrequency > 0', 'GridFrequency = 0') - - def get_grid_used_intervals(self, dt_from: datetime, dt_to: datetime) -> IntervalList: - return self.get_intervals_by_condition(dt_from, - dt_to, - "LinePowerDirection = 'Input'", - "LinePowerDirection != 'Input'") - - def get_grid_consumed_energy(self, dt_from: datetime, dt_to: datetime) -> float: - PrevData = namedtuple('PrevData', 'time, pd, bat_chg, bat_dis, wh') - - ws = 0 # watt-seconds - amps = 0 # amper-seconds - - intervals = self.get_grid_used_intervals(dt_from, dt_to) - for dt_start, dt_end in intervals: - fields = ', '.join([ - 'ClientTime', - 'DCACPowerDirection', - 'BatteryChargingCurrent', - 'BatteryDischargingCurrent', - 'ACOutputActivePower' - ]) - rows = self.query(f'SELECT {fields} FROM status' - ' WHERE ClientTime >= %(from)s AND ClientTime < %(to)s ORDER BY ClientTime', - {'from': dt_start, 'to': dt_end}) - - prev = PrevData(time=None, pd=None, bat_chg=None, bat_dis=None, wh=None) - for ct, pd, bat_chg, bat_dis, wh in rows: - if prev.time is not None: - n = (ct-prev.time).total_seconds() - ws += prev.wh * n - - if pd == 'DC/AC': - amps -= prev.bat_dis * n - elif pd == 'AC/DC': - amps += prev.bat_chg * n - - prev = PrevData(time=ct, pd=pd, bat_chg=bat_chg, bat_dis=bat_dis, wh=wh) - - amps /= 3600 - wh = ws / 3600 - wh += amps*48 - - return wh diff --git a/src/home/database/inverter_time_formats.py b/src/home/database/inverter_time_formats.py deleted file mode 100644 index 7c37d30..0000000 --- a/src/home/database/inverter_time_formats.py +++ /dev/null @@ -1,2 +0,0 @@ -FormatTime = '%Y-%m-%d %H:%M:%S' -FormatDate = '%Y-%m-%d' diff --git a/src/home/database/mysql.py b/src/home/database/mysql.py deleted file mode 100644 index fe97cd4..0000000 --- a/src/home/database/mysql.py +++ /dev/null @@ -1,47 +0,0 @@ -import time -import logging - -from mysql.connector import connect, MySQLConnection, Error -from typing import Optional -from ..config import config - -link: Optional[MySQLConnection] = None -logger = logging.getLogger(__name__) - -datetime_fmt = '%Y-%m-%d %H:%M:%S' - - -def get_mysql() -> MySQLConnection: - global link - - if link is not None: - return link - - link = connect( - host=config['mysql']['host'], - user=config['mysql']['user'], - password=config['mysql']['password'], - database=config['mysql']['database'], - ) - link.time_zone = '+01:00' - return link - - -def mysql_now() -> str: - return time.strftime('%Y-%m-%d %H:%M:%S') - - -class MySQLDatabase: - def __init__(self): - self.db = get_mysql() - - def cursor(self, **kwargs): - try: - self.db.ping(reconnect=True, attempts=2) - except Error as e: - logger.exception(e) - self.db = get_mysql() - return self.db.cursor(**kwargs) - - def commit(self): - self.db.commit() diff --git a/src/home/database/sensors.py b/src/home/database/sensors.py deleted file mode 100644 index 8155108..0000000 --- a/src/home/database/sensors.py +++ /dev/null @@ -1,69 +0,0 @@ -from time import time -from datetime import datetime -from typing import Tuple, List -from .clickhouse import ClickhouseDatabase -from ..api.types import TemperatureSensorLocation - - -def get_temperature_table(sensor: TemperatureSensorLocation) -> str: - if sensor == TemperatureSensorLocation.DIANA: - return 'temp_diana' - - elif sensor == TemperatureSensorLocation.STREET: - return 'temp_street' - - elif sensor == TemperatureSensorLocation.BIG_HOUSE_1: - return 'temp' - - elif sensor == TemperatureSensorLocation.BIG_HOUSE_2: - return 'temp_roof' - - elif sensor == TemperatureSensorLocation.BIG_HOUSE_ROOM: - return 'temp_room' - - elif sensor == TemperatureSensorLocation.SPB1: - return 'temp_spb1' - - -class SensorsDatabase(ClickhouseDatabase): - def __init__(self): - super().__init__('home') - - def add_temperature(self, - home_id: int, - client_time: int, - sensor: TemperatureSensorLocation, - temp: int, - rh: int): - table = get_temperature_table(sensor) - sql = """INSERT INTO """ + table + """ ( - ClientTime, - ReceivedTime, - HomeID, - Temperature, - RelativeHumidity - ) VALUES""" - self.db.execute(sql, [[ - client_time, - int(time()), - home_id, - temp, - rh - ]]) - - def get_temperature_recordings(self, - sensor: TemperatureSensorLocation, - time_range: Tuple[datetime, datetime], - home_id=1) -> List[tuple]: - table = get_temperature_table(sensor) - sql = f"""SELECT ClientTime, Temperature, RelativeHumidity - FROM {table} - WHERE ClientTime >= %(from)s AND ClientTime <= %(to)s - ORDER BY ClientTime""" - dt_from, dt_to = time_range - - data = self.query(sql, { - 'from': dt_from, - 'to': dt_to - }) - return [(date, temp/100, humidity/100) for date, temp, humidity in data] diff --git a/src/home/database/simple_state.py b/src/home/database/simple_state.py deleted file mode 100644 index 2b8ebe7..0000000 --- a/src/home/database/simple_state.py +++ /dev/null @@ -1,48 +0,0 @@ -import os -import json -import atexit - -from ._base import get_data_root_directory - - -class SimpleState: - def __init__(self, - name: str, - default: dict = None): - if default is None: - default = {} - elif type(default) is not dict: - raise TypeError('default must be dictionary') - - path = os.path.join(get_data_root_directory(), name) - if not os.path.exists(path): - self._data = default - else: - with open(path, 'r') as f: - self._data = json.loads(f.read()) - - self._file = path - atexit.register(self.__cleanup) - - def __cleanup(self): - if hasattr(self, '_file'): - with open(self._file, 'w') as f: - f.write(json.dumps(self._data)) - atexit.unregister(self.__cleanup) - - def __del__(self): - if 'open' in __builtins__: - self.__cleanup() - - def __getitem__(self, key): - return self._data[key] - - def __setitem__(self, key, value): - self._data[key] = value - - def __contains__(self, key): - return key in self._data - - def __delitem__(self, key): - if key in self._data: - del self._data[key] diff --git a/src/home/database/sqlite.py b/src/home/database/sqlite.py deleted file mode 100644 index 0af1f54..0000000 --- a/src/home/database/sqlite.py +++ /dev/null @@ -1,67 +0,0 @@ -import sqlite3 -import os.path -import logging - -from ._base import get_data_root_directory -from ..config import config, is_development_mode - - -def _get_database_path(name: str) -> str: - return os.path.join( - get_data_root_directory(), - f'{name}.db') - - -class SQLiteBase: - SCHEMA = 1 - - def __init__(self, name=None, check_same_thread=False): - if name is None: - name = config.app_config['database_name'] - database_path = _get_database_path(name) - if not os.path.exists(os.path.dirname(database_path)): - os.makedirs(os.path.dirname(database_path)) - - self.logger = logging.getLogger(self.__class__.__name__) - self.sqlite = sqlite3.connect(database_path, check_same_thread=check_same_thread) - - if is_development_mode(): - self.sql_logger = logging.getLogger(self.__class__.__name__) - self.sql_logger.setLevel('TRACE') - self.sqlite.set_trace_callback(self.sql_logger.trace) - - sqlite_version = self._get_sqlite_version() - self.logger.debug(f'SQLite version: {sqlite_version}') - - schema_version = self.schema_get_version() - self.logger.debug(f'Schema version: {schema_version}') - - self.schema_init(schema_version) - self.schema_set_version(self.SCHEMA) - - def __del__(self): - if self.sqlite: - self.sqlite.commit() - self.sqlite.close() - - def _get_sqlite_version(self) -> str: - cursor = self.sqlite.cursor() - cursor.execute("SELECT sqlite_version()") - return cursor.fetchone()[0] - - def schema_get_version(self) -> int: - cursor = self.sqlite.execute('PRAGMA user_version') - return int(cursor.fetchone()[0]) - - def schema_set_version(self, v) -> None: - self.sqlite.execute('PRAGMA user_version={:d}'.format(v)) - self.logger.info(f'Schema set to {v}') - - def cursor(self) -> sqlite3.Cursor: - return self.sqlite.cursor() - - def commit(self) -> None: - return self.sqlite.commit() - - def schema_init(self, version: int) -> None: - raise ValueError(f'{self.__class__.__name__}: must override schema_init') diff --git a/src/home/http/__init__.py b/src/home/http/__init__.py deleted file mode 100644 index 6030e95..0000000 --- a/src/home/http/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .http import serve, ok, routes, HTTPServer -from aiohttp.web import FileResponse, StreamResponse, Request, Response diff --git a/src/home/http/http.py b/src/home/http/http.py deleted file mode 100644 index 3e70751..0000000 --- a/src/home/http/http.py +++ /dev/null @@ -1,106 +0,0 @@ -import logging -import asyncio - -from aiohttp import web -from aiohttp.web import Response -from aiohttp.web_exceptions import HTTPNotFound - -from ..util import stringify, format_tb, Addr - - -_logger = logging.getLogger(__name__) - - -@web.middleware -async def errors_handler_middleware(request, handler): - try: - response = await handler(request) - return response - - except HTTPNotFound: - return web.json_response({'error': 'not found'}, status=404) - - except Exception as exc: - _logger.exception(exc) - data = { - 'error': exc.__class__.__name__, - 'message': exc.message if hasattr(exc, 'message') else str(exc) - } - tb = format_tb(exc) - if tb: - data['stacktrace'] = tb - - return web.json_response(data, status=500) - - -def serve(addr: Addr, route_table: web.RouteTableDef, handle_signals: bool = True): - app = web.Application() - app.add_routes(route_table) - app.middlewares.append(errors_handler_middleware) - - host, port = addr - - web.run_app(app, - host=host, - port=port, - handle_signals=handle_signals) - - -def routes() -> web.RouteTableDef: - return web.RouteTableDef() - - -def ok(data=None): - if data is None: - data = 1 - response = {'response': data} - return web.json_response(response, dumps=stringify) - - -class HTTPServer: - def __init__(self, addr: Addr, handle_errors=True): - self.addr = addr - self.app = web.Application() - self.logger = logging.getLogger(self.__class__.__name__) - - if handle_errors: - self.app.middlewares.append(errors_handler_middleware) - - def _add_route(self, - method: str, - path: str, - handler: callable): - self.app.router.add_routes([getattr(web, method)(path, handler)]) - - def get(self, path, handler): - self._add_route('get', path, handler) - - def post(self, path, handler): - self._add_route('post', path, handler) - - def put(self, path, handler): - self._add_route('put', path, handler) - - def delete(self, path, handler): - self._add_route('delete', path, handler) - - def run(self, event_loop=None, handle_signals=True): - if not event_loop: - event_loop = asyncio.get_event_loop() - - runner = web.AppRunner(self.app, handle_signals=handle_signals) - event_loop.run_until_complete(runner.setup()) - - host, port = self.addr - site = web.TCPSite(runner, host=host, port=port) - event_loop.run_until_complete(site.start()) - - self.logger.info(f'Server started at http://{host}:{port}') - - event_loop.run_forever() - - def ok(self, data=None): - return ok(data) - - def plain(self, text: str): - return Response(text=text, content_type='text/plain') diff --git a/src/home/inverter/__init__.py b/src/home/inverter/__init__.py deleted file mode 100644 index 8831ef3..0000000 --- a/src/home/inverter/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .monitor import InverterMonitor -from .inverter_wrapper import wrapper_instance -from .util import beautify_table diff --git a/src/home/inverter/config.py b/src/home/inverter/config.py deleted file mode 100644 index e284dfe..0000000 --- a/src/home/inverter/config.py +++ /dev/null @@ -1,13 +0,0 @@ -from ..config import ConfigUnit -from typing import Optional - - -class InverterdConfig(ConfigUnit): - NAME = 'inverterd' - - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'remote_addr': {'type': 'string'}, - 'local_addr': {'type': 'string'}, - } \ No newline at end of file diff --git a/src/home/inverter/emulator.py b/src/home/inverter/emulator.py deleted file mode 100644 index e86b8bb..0000000 --- a/src/home/inverter/emulator.py +++ /dev/null @@ -1,556 +0,0 @@ -import asyncio -import logging - -from inverterd import Format - -from typing import Union -from enum import Enum -from ..util import Addr, stringify - - -class InverterEnum(Enum): - def as_text(self) -> str: - raise RuntimeError('abstract method') - - -class BatteryType(InverterEnum): - AGM = 0 - Flooded = 1 - User = 2 - - def as_text(self) -> str: - return ('AGM', 'Flooded', 'User')[self.value] - - -class InputVoltageRange(InverterEnum): - Appliance = 0 - USP = 1 - - def as_text(self) -> str: - return ('Appliance', 'USP')[self.value] - - -class OutputSourcePriority(InverterEnum): - SolarUtilityBattery = 0 - SolarBatteryUtility = 1 - - def as_text(self) -> str: - return ('Solar-Utility-Battery', 'Solar-Battery-Utility')[self.value] - - -class ChargeSourcePriority(InverterEnum): - SolarFirst = 0 - SolarAndUtility = 1 - SolarOnly = 2 - - def as_text(self) -> str: - return ('Solar-First', 'Solar-and-Utility', 'Solar-only')[self.value] - - -class MachineType(InverterEnum): - OffGridTie = 0 - GridTie = 1 - - def as_text(self) -> str: - return ('Off-Grid-Tie', 'Grid-Tie')[self.value] - - -class Topology(InverterEnum): - TransformerLess = 0 - Transformer = 1 - - def as_text(self) -> str: - return ('Transformer-less', 'Transformer')[self.value] - - -class OutputMode(InverterEnum): - SingleOutput = 0 - ParallelOutput = 1 - Phase_1_of_3 = 2 - Phase_2_of_3 = 3 - Phase_3_of_3 = 4 - - def as_text(self) -> str: - return ( - 'Single output', - 'Parallel output', - 'Phase 1 of 3-phase output', - 'Phase 2 of 3-phase output', - 'Phase 3 of 3-phase' - )[self.value] - - -class SolarPowerPriority(InverterEnum): - BatteryLoadUtility = 0 - LoadBatteryUtility = 1 - - def as_text(self) -> str: - return ('Battery-Load-Utility', 'Load-Battery-Utility')[self.value] - - -class MPPTChargerStatus(InverterEnum): - Abnormal = 0 - NotCharging = 1 - Charging = 2 - - def as_text(self) -> str: - return ('Abnormal', 'Not charging', 'Charging')[self.value] - - -class BatteryPowerDirection(InverterEnum): - DoNothing = 0 - Charge = 1 - Discharge = 2 - - def as_text(self) -> str: - return ('Do nothing', 'Charge', 'Discharge')[self.value] - - -class DC_AC_PowerDirection(InverterEnum): - DoNothing = 0 - AC_DC = 1 - DC_AC = 2 - - def as_text(self) -> str: - return ('Do nothing', 'AC/DC', 'DC/AC')[self.value] - - -class LinePowerDirection(InverterEnum): - DoNothing = 0 - Input = 1 - Output = 2 - - def as_text(self) -> str: - return ('Do nothing', 'Input', 'Output')[self.value] - - -class WorkingMode(InverterEnum): - PowerOnMode = 0 - StandbyMode = 1 - BypassMode = 2 - BatteryMode = 3 - FaultMode = 4 - HybridMode = 5 - - def as_text(self) -> str: - return ( - 'Power on mode', - 'Standby mode', - 'Bypass mode', - 'Battery mode', - 'Fault mode', - 'Hybrid mode' - )[self.value] - - -class ParallelConnectionStatus(InverterEnum): - NotExistent = 0 - Existent = 1 - - def as_text(self) -> str: - return ('Non-existent', 'Existent')[self.value] - - -class LoadConnectionStatus(InverterEnum): - Disconnected = 0 - Connected = 1 - - def as_text(self) -> str: - return ('Disconnected', 'Connected')[self.value] - - -class ConfigurationStatus(InverterEnum): - Default = 0 - Changed = 1 - - def as_text(self) -> str: - return ('Default', 'Changed')[self.value] - - -_g_human_readable = {"grid_voltage": "Grid voltage", - "grid_freq": "Grid frequency", - "ac_output_voltage": "AC output voltage", - "ac_output_freq": "AC output frequency", - "ac_output_apparent_power": "AC output apparent power", - "ac_output_active_power": "AC output active power", - "output_load_percent": "Output load percent", - "battery_voltage": "Battery voltage", - "battery_voltage_scc": "Battery voltage from SCC", - "battery_voltage_scc2": "Battery voltage from SCC2", - "battery_discharge_current": "Battery discharge current", - "battery_charge_current": "Battery charge current", - "battery_capacity": "Battery capacity", - "inverter_heat_sink_temp": "Inverter heat sink temperature", - "mppt1_charger_temp": "MPPT1 charger temperature", - "mppt2_charger_temp": "MPPT2 charger temperature", - "pv1_input_power": "PV1 input power", - "pv2_input_power": "PV2 input power", - "pv1_input_voltage": "PV1 input voltage", - "pv2_input_voltage": "PV2 input voltage", - "configuration_status": "Configuration state", - "mppt1_charger_status": "MPPT1 charger status", - "mppt2_charger_status": "MPPT2 charger status", - "load_connected": "Load connection", - "battery_power_direction": "Battery power direction", - "dc_ac_power_direction": "DC/AC power direction", - "line_power_direction": "Line power direction", - "local_parallel_id": "Local parallel ID", - "ac_input_rating_voltage": "AC input rating voltage", - "ac_input_rating_current": "AC input rating current", - "ac_output_rating_voltage": "AC output rating voltage", - "ac_output_rating_freq": "AC output rating frequency", - "ac_output_rating_current": "AC output rating current", - "ac_output_rating_apparent_power": "AC output rating apparent power", - "ac_output_rating_active_power": "AC output rating active power", - "battery_rating_voltage": "Battery rating voltage", - "battery_recharge_voltage": "Battery re-charge voltage", - "battery_redischarge_voltage": "Battery re-discharge voltage", - "battery_under_voltage": "Battery under voltage", - "battery_bulk_voltage": "Battery bulk voltage", - "battery_float_voltage": "Battery float voltage", - "battery_type": "Battery type", - "max_charge_current": "Max charge current", - "max_ac_charge_current": "Max AC charge current", - "input_voltage_range": "Input voltage range", - "output_source_priority": "Output source priority", - "charge_source_priority": "Charge source priority", - "parallel_max_num": "Parallel max num", - "machine_type": "Machine type", - "topology": "Topology", - "output_mode": "Output mode", - "solar_power_priority": "Solar power priority", - "mppt": "MPPT string", - "fault_code": "Fault code", - "line_fail": "Line fail", - "output_circuit_short": "Output circuit short", - "inverter_over_temperature": "Inverter over temperature", - "fan_lock": "Fan lock", - "battery_voltage_high": "Battery voltage high", - "battery_low": "Battery low", - "battery_under": "Battery under", - "over_load": "Over load", - "eeprom_fail": "EEPROM fail", - "power_limit": "Power limit", - "pv1_voltage_high": "PV1 voltage high", - "pv2_voltage_high": "PV2 voltage high", - "mppt1_overload_warning": "MPPT1 overload warning", - "mppt2_overload_warning": "MPPT2 overload warning", - "battery_too_low_to_charge_for_scc1": "Battery too low to charge for SCC1", - "battery_too_low_to_charge_for_scc2": "Battery too low to charge for SCC2", - "buzzer": "Buzzer", - "overload_bypass": "Overload bypass function", - "escape_to_default_screen_after_1min_timeout": "Escape to default screen after 1min timeout", - "overload_restart": "Overload restart", - "over_temp_restart": "Over temperature restart", - "backlight_on": "Backlight on", - "alarm_on_on_primary_source_interrupt": "Alarm on on primary source interrupt", - "fault_code_record": "Fault code record", - "wh": "Wh"} - - -class InverterEmulator: - def __init__(self, addr: Addr, wait=True): - self.status = {"grid_voltage": {"unit": "V", "value": 236.3}, - "grid_freq": {"unit": "Hz", "value": 50.0}, - "ac_output_voltage": {"unit": "V", "value": 229.9}, - "ac_output_freq": {"unit": "Hz", "value": 50.0}, - "ac_output_apparent_power": {"unit": "VA", "value": 207}, - "ac_output_active_power": {"unit": "Wh", "value": 146}, - "output_load_percent": {"unit": "%", "value": 4}, - "battery_voltage": {"unit": "V", "value": 49.1}, - "battery_voltage_scc": {"unit": "V", "value": 0.0}, - "battery_voltage_scc2": {"unit": "V", "value": 0.0}, - "battery_discharge_current": {"unit": "A", "value": 3}, - "battery_charge_current": {"unit": "A", "value": 0}, - "battery_capacity": {"unit": "%", "value": 69}, - "inverter_heat_sink_temp": {"unit": "°C", "value": 17}, - "mppt1_charger_temp": {"unit": "°C", "value": 0}, - "mppt2_charger_temp": {"unit": "°C", "value": 0}, - "pv1_input_power": {"unit": "Wh", "value": 0}, - "pv2_input_power": {"unit": "Wh", "value": 0}, - "pv1_input_voltage": {"unit": "V", "value": 0.0}, - "pv2_input_voltage": {"unit": "V", "value": 0.0}, - "configuration_status": ConfigurationStatus.Default, - "mppt1_charger_status": MPPTChargerStatus.Abnormal, - "mppt2_charger_status": MPPTChargerStatus.Abnormal, - "load_connected": LoadConnectionStatus.Connected, - "battery_power_direction": BatteryPowerDirection.Discharge, - "dc_ac_power_direction": DC_AC_PowerDirection.DC_AC, - "line_power_direction": LinePowerDirection.DoNothing, - "local_parallel_id": 0} - - self.rated = {"ac_input_rating_voltage": {"unit": "V", "value": 230.0}, - "ac_input_rating_current": {"unit": "A", "value": 21.7}, - "ac_output_rating_voltage": {"unit": "V", "value": 230.0}, - "ac_output_rating_freq": {"unit": "Hz", "value": 50.0}, - "ac_output_rating_current": {"unit": "A", "value": 21.7}, - "ac_output_rating_apparent_power": {"unit": "VA", "value": 5000}, - "ac_output_rating_active_power": {"unit": "Wh", "value": 5000}, - "battery_rating_voltage": {"unit": "V", "value": 48.0}, - "battery_recharge_voltage": {"unit": "V", "value": 48.0}, - "battery_redischarge_voltage": {"unit": "V", "value": 55.0}, - "battery_under_voltage": {"unit": "V", "value": 42.0}, - "battery_bulk_voltage": {"unit": "V", "value": 57.6}, - "battery_float_voltage": {"unit": "V", "value": 54.0}, - "battery_type": BatteryType.User, - "max_charge_current": {"unit": "A", "value": 60}, - "max_ac_charge_current": {"unit": "A", "value": 30}, - "input_voltage_range": InputVoltageRange.Appliance, - "output_source_priority": OutputSourcePriority.SolarBatteryUtility, - "charge_source_priority": ChargeSourcePriority.SolarAndUtility, - "parallel_max_num": 6, - "machine_type": MachineType.OffGridTie, - "topology": Topology.TransformerLess, - "output_mode": OutputMode.SingleOutput, - "solar_power_priority": SolarPowerPriority.LoadBatteryUtility, - "mppt": "2"} - - self.errors = {"fault_code": 0, - "line_fail": False, - "output_circuit_short": False, - "inverter_over_temperature": False, - "fan_lock": False, - "battery_voltage_high": False, - "battery_low": False, - "battery_under": False, - "over_load": False, - "eeprom_fail": False, - "power_limit": False, - "pv1_voltage_high": False, - "pv2_voltage_high": False, - "mppt1_overload_warning": False, - "mppt2_overload_warning": False, - "battery_too_low_to_charge_for_scc1": False, - "battery_too_low_to_charge_for_scc2": False} - - self.flags = {"buzzer": False, - "overload_bypass": True, - "escape_to_default_screen_after_1min_timeout": False, - "overload_restart": True, - "over_temp_restart": True, - "backlight_on": False, - "alarm_on_on_primary_source_interrupt": True, - "fault_code_record": False} - - self.day_generated = 1000 - - self.logger = logging.getLogger(self.__class__.__name__) - - host, port = addr - asyncio.run(self.run_server(host, port, wait)) - # self.max_ac_charge_current = 30 - # self.max_charge_current = 60 - # self.charge_thresholds = [48, 54] - - async def run_server(self, host, port, wait: bool): - server = await asyncio.start_server(self.client_handler, host, port) - async with server: - self.logger.info(f'listening on {host}:{port}') - if wait: - await server.serve_forever() - else: - asyncio.ensure_future(server.serve_forever()) - - async def client_handler(self, reader, writer): - client_fmt = Format.JSON - - def w(s: str): - writer.write(s.encode('utf-8')) - - def return_error(message=None): - w('err\r\n') - if message: - if client_fmt in (Format.JSON, Format.SIMPLE_JSON): - w(stringify({ - 'result': 'error', - 'message': message - })) - elif client_fmt in (Format.TABLE, Format.SIMPLE_TABLE): - w(f'error: {message}') - w('\r\n') - w('\r\n') - - def return_ok(data=None): - w('ok\r\n') - if client_fmt in (Format.JSON, Format.SIMPLE_JSON): - jdata = { - 'result': 'ok' - } - if data: - jdata['data'] = data - w(stringify(jdata)) - w('\r\n') - elif data: - w(data) - w('\r\n') - w('\r\n') - - request = None - while request != 'quit': - try: - request = await reader.read(255) - if request == b'\x04': - break - request = request.decode('utf-8').strip() - except Exception: - break - - if request.startswith('format '): - requested_format = request[7:] - try: - client_fmt = Format(requested_format) - except ValueError: - return_error('invalid format') - - return_ok() - - elif request.startswith('exec '): - buf = request[5:].split(' ') - command = buf[0] - args = buf[1:] - - try: - return_ok(self.process_command(client_fmt, command, *args)) - except ValueError as e: - return_error(str(e)) - - else: - return_error(f'invalid token: {request}') - - try: - await writer.drain() - except ConnectionResetError as e: - # self.logger.exception(e) - pass - - writer.close() - - def process_command(self, fmt: Format, c: str, *args) -> Union[dict, str, list[int], None]: - ac_charge_currents = [2, 10, 20, 30, 40, 50, 60] - - if c == 'get-status': - return self.format_dict(self.status, fmt) - - elif c == 'get-rated': - return self.format_dict(self.rated, fmt) - - elif c == 'get-errors': - return self.format_dict(self.errors, fmt) - - elif c == 'get-flags': - return self.format_dict(self.flags, fmt) - - elif c == 'get-day-generated': - return self.format_dict({'wh': 1000}, fmt) - - elif c == 'get-allowed-ac-charge-currents': - return self.format_list(ac_charge_currents, fmt) - - elif c == 'set-max-ac-charge-current': - if int(args[0]) != 0: - raise ValueError(f'invalid machine id: {args[0]}') - amps = int(args[1]) - if amps not in ac_charge_currents: - raise ValueError(f'invalid value: {amps}') - self.rated['max_ac_charge_current']['value'] = amps - - elif c == 'set-charge-thresholds': - self.rated['battery_recharge_voltage']['value'] = float(args[0]) - self.rated['battery_redischarge_voltage']['value'] = float(args[1]) - - elif c == 'set-output-source-priority': - self.rated['output_source_priority'] = OutputSourcePriority.SolarBatteryUtility if args[0] == 'SBU' else OutputSourcePriority.SolarUtilityBattery - - elif c == 'set-battery-cutoff-voltage': - self.rated['battery_under_voltage']['value'] = float(args[0]) - - elif c == 'set-flag': - flag = args[0] - val = bool(int(args[1])) - - if flag == 'BUZZ': - k = 'buzzer' - elif flag == 'OLBP': - k = 'overload_bypass' - elif flag == 'LCDE': - k = 'escape_to_default_screen_after_1min_timeout' - elif flag == 'OLRS': - k = 'overload_restart' - elif flag == 'OTRS': - k = 'over_temp_restart' - elif flag == 'BLON': - k = 'backlight_on' - elif flag == 'ALRM': - k = 'alarm_on_on_primary_source_interrupt' - elif flag == 'FTCR': - k = 'fault_code_record' - else: - raise ValueError('invalid flag') - - self.flags[k] = val - - else: - raise ValueError(f'{c}: unsupported command') - - @staticmethod - def format_list(values: list, fmt: Format) -> Union[str, list]: - if fmt in (Format.JSON, Format.SIMPLE_JSON): - return values - return '\n'.join(map(lambda v: str(v), values)) - - @staticmethod - def format_dict(data: dict, fmt: Format) -> Union[str, dict]: - new_data = {} - for k, v in data.items(): - new_val = None - if fmt in (Format.JSON, Format.TABLE, Format.SIMPLE_TABLE): - if isinstance(v, dict): - new_val = v - elif isinstance(v, InverterEnum): - new_val = v.as_text() - else: - new_val = v - elif fmt == Format.SIMPLE_JSON: - if isinstance(v, dict): - new_val = v['value'] - elif isinstance(v, InverterEnum): - new_val = v.value - else: - new_val = str(v) - new_data[k] = new_val - - if fmt in (Format.JSON, Format.SIMPLE_JSON): - return new_data - - lines = [] - - if fmt == Format.SIMPLE_TABLE: - for k, v in new_data.items(): - buf = k - if isinstance(v, dict): - buf += ' ' + str(v['value']) + ' ' + v['unit'] - elif isinstance(v, InverterEnum): - buf += ' ' + v.as_text() - else: - buf += ' ' + str(v) - lines.append(buf) - - elif fmt == Format.TABLE: - max_k_len = 0 - for k in new_data.keys(): - if len(_g_human_readable[k]) > max_k_len: - max_k_len = len(_g_human_readable[k]) - for k, v in new_data.items(): - buf = _g_human_readable[k] + ':' - buf += ' ' * (max_k_len - len(_g_human_readable[k]) + 1) - if isinstance(v, dict): - buf += str(v['value']) + ' ' + v['unit'] - elif isinstance(v, InverterEnum): - buf += v.as_text() - elif isinstance(v, bool): - buf += str(int(v)) - else: - buf += str(v) - lines.append(buf) - - return '\n'.join(lines) diff --git a/src/home/inverter/inverter_wrapper.py b/src/home/inverter/inverter_wrapper.py deleted file mode 100644 index df2c2fc..0000000 --- a/src/home/inverter/inverter_wrapper.py +++ /dev/null @@ -1,48 +0,0 @@ -import json - -from threading import Lock -from inverterd import ( - Format, - Client as InverterClient, - InverterError -) - -_lock = Lock() - - -class InverterClientWrapper: - def __init__(self): - self._inverter = None - self._host = None - self._port = None - - def init(self, host: str, port: int): - self._host = host - self._port = port - self.create() - - def create(self): - self._inverter = InverterClient(host=self._host, port=self._port) - self._inverter.connect() - - def exec(self, command: str, arguments: tuple = (), format=Format.JSON): - with _lock: - try: - self._inverter.format(format) - response = self._inverter.exec(command, arguments) - if format == Format.JSON: - response = json.loads(response) - return response - except InverterError as e: - raise e - except Exception as e: - # silently try to reconnect - try: - self.create() - except Exception: - pass - raise e - - -wrapper_instance = InverterClientWrapper() - diff --git a/src/home/inverter/monitor.py b/src/home/inverter/monitor.py deleted file mode 100644 index 86f75ac..0000000 --- a/src/home/inverter/monitor.py +++ /dev/null @@ -1,499 +0,0 @@ -import logging -import time - -from .types import * -from threading import Thread -from typing import Callable, Optional -from .inverter_wrapper import wrapper_instance as inverter -from inverterd import InverterError -from ..util import Stopwatch, StopwatchError -from ..config import config - -logger = logging.getLogger(__name__) - - -def _pd_from_string(pd: str) -> BatteryPowerDirection: - if pd == 'Discharge': - return BatteryPowerDirection.DISCHARGING - elif pd == 'Charge': - return BatteryPowerDirection.CHARGING - elif pd == 'Do nothing': - return BatteryPowerDirection.DO_NOTHING - else: - raise ValueError(f'invalid power direction: {pd}') - - -class MonitorConfig: - def __getattr__(self, item): - return config['monitor'][item] - - -cfg = MonitorConfig() - - -""" -TODO: -- поддержать возможность ручного (через бота) переключения тока заряда вверх и вниз -- поддержать возможность бесшовного перезапуска бота, когда монитор понимает, что зарядка уже идет, и он - не запускает программу с начала, а продолжает с уже существующей позиции. Уведомления при этом можно не - присылать совсем, либо прислать какое-то одно приложение, в духе "программа была перезапущена" -""" - - -class InverterMonitor(Thread): - charging_event_handler: Optional[Callable] - battery_event_handler: Optional[Callable] - util_event_handler: Optional[Callable] - error_handler: Optional[Callable] - osp_change_cb: Optional[Callable] - osp: Optional[OutputSourcePriority] - - def __init__(self): - super().__init__() - self.setName('InverterMonitor') - - self.interrupted = False - self.min_allowed_current = 0 - self.ac_mode = None - self.osp = None - - # Event handlers for the bot. - self.charging_event_handler = None - self.battery_event_handler = None - self.util_event_handler = None - self.error_handler = None - self.osp_change_cb = None - - # Currents list, defined in the bot config. - self.currents = cfg.gen_currents - self.currents.sort() - - # We start charging at lowest possible current, then increase it once per minute (or so) to the maximum level. - # This is done so that the load on the generator increases smoothly, not abruptly. Generator will thank us. - self.current_change_direction = CurrentChangeDirection.UP - self.next_current_enter_time = 0 - self.active_current_idx = -1 - - self.battery_state = BatteryState.NORMAL - self.charging_state = ChargingState.NOT_CHARGING - - # 'Mostly-charged' means that we've already lowered the charging current to the level - # at which batteries are charging pretty slow. So instead of burning gasoline and shaking the air, - # we can just turn the generator off at this point. - self.mostly_charged = False - - # The stopwatch is used to measure how long does the battery voltage exceeds the float voltage level. - # We don't want to damage our batteries, right? - self.floating_stopwatch = Stopwatch() - - # State variables for utilities charging program - self.util_ac_present = None - self.util_pd = None - self.util_solar = None - - @property - def active_current(self) -> Optional[int]: - try: - if self.active_current_idx < 0: - return None - return self.currents[self.active_current_idx] - except IndexError: - return None - - def run(self): - # Check allowed currents and validate the config. - allowed_currents = list(inverter.exec('get-allowed-ac-charge-currents')['data']) - allowed_currents.sort() - - for a in self.currents: - if a not in allowed_currents: - raise ValueError(f'invalid value {a} in gen_currents list') - - self.min_allowed_current = min(allowed_currents) - - # Reading rated configuration - rated = inverter.exec('get-rated')['data'] - self.osp = OutputSourcePriority.from_text(rated['output_source_priority']) - - # Read data and run implemented programs every 2 seconds. - while not self.interrupted: - try: - response = inverter.exec('get-status') - if response['result'] != 'ok': - logger.error('get-status failed:', response) - else: - gs = response['data'] - - ac = gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0 - solar = gs['pv1_input_voltage']['value'] > 0 or gs['pv2_input_voltage']['value'] > 0 - solar_input = gs['pv1_input_power']['value'] - v = float(gs['battery_voltage']['value']) - load_watts = int(gs['ac_output_active_power']['value']) - pd = _pd_from_string(gs['battery_power_direction']) - - logger.debug(f'got status: ac={ac}, solar={solar}, v={v}, pd={pd}') - - if self.ac_mode == ACMode.GENERATOR: - self.gen_charging_program(ac, solar, v, pd) - - elif self.ac_mode == ACMode.UTILITIES: - self.utilities_monitoring_program(ac, solar, v, load_watts, solar_input, pd) - - if not ac or pd != BatteryPowerDirection.CHARGING: - # if AC is disconnected or not charging, run the low voltage checking program - self.low_voltage_program(v, load_watts) - - elif self.battery_state != BatteryState.NORMAL: - # AC is connected and the battery is charging, assume battery level is normal - self.battery_state = BatteryState.NORMAL - - except InverterError as e: - logger.exception(e) - - time.sleep(2) - - def utilities_monitoring_program(self, - ac: bool, # whether AC is connected - solar: bool, # whether MPPT is active - v: float, # battery voltage - load_watts: int, # load, wh - solar_input: int, # input from solar panels, wh - pd: BatteryPowerDirection # current power direction - ): - pd_event_send = False - if self.util_solar is None or solar != self.util_solar: - self.util_solar = solar - if solar and self.util_ac_present and self.util_pd == BatteryPowerDirection.CHARGING: - self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STOPPED_SOLAR) - pd_event_send = True - - if solar: - if v <= 48 and self.osp == OutputSourcePriority.SolarBatteryUtility: - self.osp_change_cb(OutputSourcePriority.SolarUtilityBattery, solar_input=solar_input, v=v) - self.osp = OutputSourcePriority.SolarUtilityBattery - - if self.osp == OutputSourcePriority.SolarUtilityBattery and solar_input >= 900: - self.osp_change_cb(OutputSourcePriority.SolarBatteryUtility, solar_input=solar_input, v=v) - self.osp = OutputSourcePriority.SolarBatteryUtility - - if self.util_ac_present is None or ac != self.util_ac_present: - self.util_event_handler(ACPresentEvent.CONNECTED if ac else ACPresentEvent.DISCONNECTED) - self.util_ac_present = ac - - if self.util_pd is None or self.util_pd != pd: - self.util_pd = pd - if not pd_event_send and not solar: - if pd == BatteryPowerDirection.CHARGING: - self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STARTED) - - elif pd == BatteryPowerDirection.DISCHARGING: - self.charging_event_handler(ChargingEvent.UTIL_CHARGING_STOPPED) - - def gen_charging_program(self, - ac: bool, # whether AC is connected - solar: bool, # whether MPPT is active - v: float, # current battery voltage - pd: BatteryPowerDirection # current power direction - ): - if self.charging_state == ChargingState.NOT_CHARGING: - if ac and solar: - # Not charging because MPPT is active (solar line is connected). - # Notify users about it and change the current state. - self.charging_state = ChargingState.AC_BUT_SOLAR - self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR) - logger.info('entering AC_BUT_SOLAR state') - elif ac: - # Not charging, but AC is connected and ready to use. - # Start the charging program. - self.gen_start(pd) - - elif self.charging_state == ChargingState.AC_BUT_SOLAR: - if not ac: - # AC charger has been disconnected. Since the state is AC_BUT_SOLAR, - # charging probably never even started. Stop the charging program. - self.gen_stop(ChargingState.NOT_CHARGING) - elif not solar: - # MPPT has been disconnected, and, since AC is still connected, we can - # try to start the charging program. - self.gen_start(pd) - - elif self.charging_state in (ChargingState.AC_OK, ChargingState.AC_WAITING): - if not ac: - # Charging was in progress, but AC has been suddenly disconnected. - # Sad, but what can we do? Stop the charging program and return. - self.gen_stop(ChargingState.NOT_CHARGING) - return - - if solar: - # Charging was in progress, but MPPT has been detected. Inverter doesn't charge - # batteries from AC when MPPT is active, so we have to pause our program. - self.charging_state = ChargingState.AC_BUT_SOLAR - self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR) - try: - self.floating_stopwatch.pause() - except StopwatchError: - msg = 'gen_charging_program: floating_stopwatch.pause() failed at (1)' - logger.warning(msg) - # self.error_handler(msg) - logger.info('solar power connected during charging, entering AC_BUT_SOLAR state') - return - - # No surprises at this point, just check the values and make decisions based on them. - # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # We've reached the 'mostly-charged' point, the voltage level is not float, - # but inverter decided to stop charging (or somebody used a kettle, lol). - # Anyway, assume that charging is complete, stop the program, notify users and return. - if self.mostly_charged and v > (cfg.gen_floating_v - 1) and pd != BatteryPowerDirection.CHARGING: - self.gen_stop(ChargingState.AC_DONE) - return - - # Monitor inverter power direction and notify users when it changes. - state = ChargingState.AC_OK if pd == BatteryPowerDirection.CHARGING else ChargingState.AC_WAITING - if state != self.charging_state: - self.charging_state = state - - evt = ChargingEvent.AC_CHARGING_STARTED if state == ChargingState.AC_OK else ChargingEvent.AC_NOT_CHARGING - self.charging_event_handler(evt) - - if self.floating_stopwatch.get_elapsed_time() >= cfg.gen_floating_time_max: - # We've been at a bulk voltage level too long, so we have to stop charging. - # Set the minimum current possible. - - if self.current_change_direction == CurrentChangeDirection.UP: - # This shouldn't happen, obviously an error. - msg = 'gen_charging_program:' - msg += ' been at bulk voltage level too long, but current change direction is still \'up\'!' - msg += ' This is obviously an error, please fix it' - logger.warning(msg) - self.error_handler(msg) - - self.gen_next_current(current=self.min_allowed_current) - - elif self.active_current is not None: - # If voltage is greater than float voltage, keep the stopwatch ticking - if v > cfg.gen_floating_v and self.floating_stopwatch.is_paused(): - try: - self.floating_stopwatch.go() - except StopwatchError: - msg = 'gen_charging_program: floating_stopwatch.go() failed at (2)' - logger.warning(msg) - self.error_handler(msg) - # Otherwise, pause it - elif v <= cfg.gen_floating_v and not self.floating_stopwatch.is_paused(): - try: - self.floating_stopwatch.pause() - except StopwatchError: - msg = 'gen_charging_program: floating_stopwatch.pause() failed at (3)' - logger.warning(msg) - self.error_handler(msg) - - # Charging current monitoring - if self.current_change_direction == CurrentChangeDirection.UP: - # Generator is warming up in this code path - - if self.next_current_enter_time != 0 and pd != BatteryPowerDirection.CHARGING: - # Generator was warming up and charging, but stopped (pd has changed). - # Resetting to the minimum possible current - logger.info(f'gen_charging_program (warming path): was charging but power direction suddeny changed. resetting to minimum current') - self.next_current_enter_time = 0 - self.gen_next_current(current=self.min_allowed_current) - - elif self.next_current_enter_time == 0 and pd == BatteryPowerDirection.CHARGING: - self.next_current_enter_time = time.time() + cfg.gen_raise_intervals[self.active_current_idx] - logger.info(f'gen_charging_program (warming path): set next_current_enter_time to {self.next_current_enter_time}') - - elif self.next_current_enter_time != 0 and time.time() >= self.next_current_enter_time: - logger.info('gen_charging_program (warming path): hit next_current_enter_time, calling gen_next_current()') - self.gen_next_current() - else: - # Gradually lower the current level, based on how close - # battery voltage has come to the bulk level. - if self.active_current >= 30: - upper_bound = cfg.gen_cur30_v_limit - elif self.active_current == 20: - upper_bound = cfg.gen_cur20_v_limit - else: - upper_bound = cfg.gen_cur10_v_limit - - # Voltage is high enough already and it's close to bulk level; we hit the upper bound, - # so let's lower the current - if v >= upper_bound: - self.gen_next_current() - - elif self.charging_state == ChargingState.AC_DONE: - # We've already finished charging, but AC was connected. Not that it's disconnected, - # set the appropriate state and notify users. - if not ac: - self.gen_stop(ChargingState.NOT_CHARGING) - - def gen_start(self, pd: BatteryPowerDirection): - if pd == BatteryPowerDirection.CHARGING: - self.charging_state = ChargingState.AC_OK - self.charging_event_handler(ChargingEvent.AC_CHARGING_STARTED) - logger.info('AC line connected and charging, entering AC_OK state') - - # Continue the stopwatch, if needed - try: - self.floating_stopwatch.go() - except StopwatchError: - msg = 'floating_stopwatch.go() failed at ac_charging_start(), AC_OK path' - logger.warning(msg) - self.error_handler(msg) - else: - self.charging_state = ChargingState.AC_WAITING - self.charging_event_handler(ChargingEvent.AC_NOT_CHARGING) - logger.info('AC line connected but not charging yet, entering AC_WAITING state') - - # Pause the stopwatch, if needed - try: - if not self.floating_stopwatch.is_paused(): - self.floating_stopwatch.pause() - except StopwatchError: - msg = 'floating_stopwatch.pause() failed at ac_charging_start(), AC_WAITING path' - logger.warning(msg) - self.error_handler(msg) - - # idx == -1 means haven't started our program yet. - if self.active_current_idx == -1: - self.gen_next_current() - # self.set_hw_charging_current(self.min_allowed_current) - - def gen_stop(self, reason: ChargingState): - self.charging_state = reason - - if reason == ChargingState.AC_DONE: - event = ChargingEvent.AC_CHARGING_FINISHED - elif reason == ChargingState.NOT_CHARGING: - event = ChargingEvent.AC_DISCONNECTED - else: - raise ValueError(f'ac_charging_stop: unexpected reason {reason}') - - logger.info(f'charging is finished, entering {reason} state') - self.charging_event_handler(event) - - self.next_current_enter_time = 0 - self.mostly_charged = False - self.active_current_idx = -1 - self.floating_stopwatch.reset() - self.current_change_direction = CurrentChangeDirection.UP - - self.set_hw_charging_current(self.min_allowed_current) - - def gen_next_current(self, current=None): - if current is None: - try: - current = self._next_current() - logger.debug(f'gen_next_current: ready to change charging current to {current} A') - except IndexError: - logger.debug('gen_next_current: was going to change charging current, but no currents left; finishing charging program') - self.gen_stop(ChargingState.AC_DONE) - return - - else: - try: - idx = self.currents.index(current) - except ValueError: - msg = f'gen_next_current: got current={current} but it\'s not in the currents list' - logger.error(msg) - self.error_handler(msg) - return - self.active_current_idx = idx - - if self.current_change_direction == CurrentChangeDirection.DOWN: - if current == self.currents[0]: - self.mostly_charged = True - self.gen_stop(ChargingState.AC_DONE) - - elif current == self.currents[1] and not self.mostly_charged: - self.mostly_charged = True - self.charging_event_handler(ChargingEvent.AC_MOSTLY_CHARGED) - - self.set_hw_charging_current(current) - - def set_hw_charging_current(self, current: int): - try: - response = inverter.exec('set-max-ac-charge-current', (0, current)) - if response['result'] != 'ok': - logger.error(f'failed to change AC charging current to {current} A') - raise InverterError('set-max-ac-charge-current: inverterd reported error') - else: - self.charging_event_handler(ChargingEvent.AC_CURRENT_CHANGED, current=current) - logger.info(f'changed AC charging current to {current} A') - except InverterError as e: - self.error_handler(f'failed to set charging current to {current} A (caught InverterError)') - logger.exception(e) - - def _next_current(self): - if self.current_change_direction == CurrentChangeDirection.UP: - self.active_current_idx += 1 - if self.active_current_idx == len(self.currents)-1: - logger.info('_next_current: charging current power direction to DOWN') - self.current_change_direction = CurrentChangeDirection.DOWN - self.next_current_enter_time = 0 - else: - if self.active_current_idx == 0: - raise IndexError('can\'t go lower') - self.active_current_idx -= 1 - - logger.info(f'_next_current: active_current_idx set to {self.active_current_idx}, returning current of {self.currents[self.active_current_idx]} A') - return self.currents[self.active_current_idx] - - def low_voltage_program(self, v: float, load_watts: int): - crit_level = cfg.vcrit - low_level = cfg.vlow - - if v <= crit_level: - state = BatteryState.CRITICAL - elif v <= low_level: - state = BatteryState.LOW - else: - state = BatteryState.NORMAL - - if state != self.battery_state: - self.battery_state = state - self.battery_event_handler(state, v, load_watts) - - def set_charging_event_handler(self, handler: Callable): - self.charging_event_handler = handler - - def set_battery_event_handler(self, handler: Callable): - self.battery_event_handler = handler - - def set_util_event_handler(self, handler: Callable): - self.util_event_handler = handler - - def set_error_handler(self, handler: Callable): - self.error_handler = handler - - def set_osp_need_change_callback(self, cb: Callable): - self.osp_change_cb = cb - - def set_ac_mode(self, mode: ACMode): - self.ac_mode = mode - - def notify_osp(self, osp: OutputSourcePriority): - self.osp = osp - - def stop(self): - self.interrupted = True - - def dump_status(self) -> dict: - return { - 'interrupted': self.interrupted, - 'currents': self.currents, - 'active_current': self.active_current, - 'current_change_direction': self.current_change_direction.name, - 'battery_state': self.battery_state.name, - 'charging_state': self.charging_state.name, - 'mostly_charged': self.mostly_charged, - 'floating_stopwatch_paused': self.floating_stopwatch.is_paused(), - 'floating_stopwatch_elapsed': self.floating_stopwatch.get_elapsed_time(), - 'time_now': time.time(), - 'next_current_enter_time': self.next_current_enter_time, - 'ac_mode': self.ac_mode, - 'osp': self.osp, - 'util_ac_present': self.util_ac_present, - 'util_pd': self.util_pd.name, - 'util_solar': self.util_solar - } diff --git a/src/home/inverter/types.py b/src/home/inverter/types.py deleted file mode 100644 index 57021f1..0000000 --- a/src/home/inverter/types.py +++ /dev/null @@ -1,64 +0,0 @@ -from enum import Enum, auto - - -class BatteryPowerDirection(Enum): - DISCHARGING = auto() - CHARGING = auto() - DO_NOTHING = auto() - - -class ChargingEvent(Enum): - AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR = auto() - AC_NOT_CHARGING = auto() - AC_CHARGING_STARTED = auto() - AC_DISCONNECTED = auto() - AC_CURRENT_CHANGED = auto() - AC_MOSTLY_CHARGED = auto() - AC_CHARGING_FINISHED = auto() - - UTIL_CHARGING_STARTED = auto() - UTIL_CHARGING_STOPPED = auto() - UTIL_CHARGING_STOPPED_SOLAR = auto() - - -class ACPresentEvent(Enum): - CONNECTED = auto() - DISCONNECTED = auto() - - -class ChargingState(Enum): - NOT_CHARGING = auto() - AC_BUT_SOLAR = auto() - AC_WAITING = auto() - AC_OK = auto() - AC_DONE = auto() - - -class CurrentChangeDirection(Enum): - UP = auto() - DOWN = auto() - - -class BatteryState(Enum): - NORMAL = auto() - LOW = auto() - CRITICAL = auto() - - -class ACMode(Enum): - GENERATOR = 'generator' - UTILITIES = 'utilities' - - -class OutputSourcePriority(Enum): - SolarUtilityBattery = 'SUB' - SolarBatteryUtility = 'SBU' - - @classmethod - def from_text(cls, s: str): - if s == 'Solar-Battery-Utility': - return cls.SolarBatteryUtility - elif s == 'Solar-Utility-Battery': - return cls.SolarUtilityBattery - else: - raise ValueError(f'unknown value: {s}') \ No newline at end of file diff --git a/src/home/inverter/util.py b/src/home/inverter/util.py deleted file mode 100644 index a577e6a..0000000 --- a/src/home/inverter/util.py +++ /dev/null @@ -1,8 +0,0 @@ -import re - - -def beautify_table(s): - lines = s.split('\n') - lines = list(map(lambda line: re.sub(r'\s+', ' ', line), lines)) - lines = list(map(lambda line: re.sub(r'(.*?): (.*)', r'\1: \2', line), lines)) - return '\n'.join(lines) diff --git a/src/home/media/__init__.py b/src/home/media/__init__.py deleted file mode 100644 index 6923105..0000000 --- a/src/home/media/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -import importlib -import itertools - -__map__ = { - 'types': ['MediaNodeType'], - 'record_client': ['SoundRecordClient', 'CameraRecordClient', 'RecordClient'], - 'node_server': ['MediaNodeServer'], - 'node_client': ['SoundNodeClient', 'CameraNodeClient', 'MediaNodeClient'], - 'storage': ['SoundRecordStorage', 'ESP32CameraRecordStorage', 'SoundRecordFile', 'CameraRecordFile', 'RecordFile'], - 'record': ['SoundRecorder', 'CameraRecorder'] -} - -__all__ = list(itertools.chain(*__map__.values())) - - -def __getattr__(name): - if name in __all__: - for file, names in __map__.items(): - if name in names: - module = importlib.import_module(f'.{file}', __name__) - return getattr(module, name) - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/media/__init__.pyi b/src/home/media/__init__.pyi deleted file mode 100644 index 77c2176..0000000 --- a/src/home/media/__init__.pyi +++ /dev/null @@ -1,27 +0,0 @@ -from .types import ( - MediaNodeType as MediaNodeType -) -from .record_client import ( - SoundRecordClient as SoundRecordClient, - CameraRecordClient as CameraRecordClient, - RecordClient as RecordClient -) -from .node_server import ( - MediaNodeServer as MediaNodeServer -) -from .node_client import ( - SoundNodeClient as SoundNodeClient, - CameraNodeClient as CameraNodeClient, - MediaNodeClient as MediaNodeClient -) -from .storage import ( - SoundRecordStorage as SoundRecordStorage, - ESP32CameraRecordStorage as ESP32CameraRecordStorage, - SoundRecordFile as SoundRecordFile, - CameraRecordFile as CameraRecordFile, - RecordFile as RecordFile -) -from .record import ( - SoundRecorder as SoundRecorder, - CameraRecorder as CameraRecorder -) \ No newline at end of file diff --git a/src/home/media/node_client.py b/src/home/media/node_client.py deleted file mode 100644 index eb39898..0000000 --- a/src/home/media/node_client.py +++ /dev/null @@ -1,119 +0,0 @@ -import requests -import shutil -import logging - -from typing import Optional, Union, List -from .storage import RecordFile -from ..util import Addr -from ..api.errors import ApiResponseError - - -class MediaNodeClient: - def __init__(self, addr: Addr): - self.endpoint = f'http://{addr[0]}:{addr[1]}' - self.logger = logging.getLogger(self.__class__.__name__) - - def record(self, duration: int): - return self._call('record/', params={"duration": duration}) - - def record_info(self, record_id: int): - return self._call(f'record/info/{record_id}/') - - def record_forget(self, record_id: int): - return self._call(f'record/forget/{record_id}/') - - def record_download(self, record_id: int, output: str): - return self._call(f'record/download/{record_id}/', save_to=output) - - def storage_list(self, extended=False, as_objects=False) -> Union[List[str], List[dict], List[RecordFile]]: - r = self._call('storage/list/', params={'extended': int(extended)}) - files = r['files'] - if as_objects: - return self.record_list_from_serialized(files) - return files - - @staticmethod - def record_list_from_serialized(files: Union[List[str], List[dict]]): - new_files = [] - for f in files: - kwargs = {'remote': True} - if isinstance(f, dict): - name = f['filename'] - kwargs['remote_filesize'] = f['filesize'] - else: - name = f - item = RecordFile.create(name, **kwargs) - new_files.append(item) - return new_files - - def storage_delete(self, file_id: str): - return self._call('storage/delete/', params={'file_id': file_id}) - - def storage_download(self, file_id: str, output: str): - return self._call('storage/download/', params={'file_id': file_id}, save_to=output) - - def _call(self, - method: str, - params: dict = None, - save_to: Optional[str] = None): - kwargs = {} - if isinstance(params, dict): - kwargs['params'] = params - if save_to: - kwargs['stream'] = True - - url = f'{self.endpoint}/{method}' - self.logger.debug(f'calling {url}, kwargs: {kwargs}') - - r = requests.get(url, **kwargs) - if r.status_code != 200: - response = r.json() - raise ApiResponseError(status_code=r.status_code, - error_type=response['error'], - error_message=response['message'] or None, - error_stacktrace=response['stacktrace'] if 'stacktrace' in response else None) - - if save_to: - r.raise_for_status() - with open(save_to, 'wb') as f: - shutil.copyfileobj(r.raw, f) - return True - - return r.json()['response'] - - -class SoundNodeClient(MediaNodeClient): - def amixer_get_all(self): - return self._call('amixer/get-all/') - - def amixer_get(self, control: str): - return self._call(f'amixer/get/{control}/') - - def amixer_incr(self, control: str, step: Optional[int] = None): - params = {'step': step} if step is not None else None - return self._call(f'amixer/incr/{control}/', params=params) - - def amixer_decr(self, control: str, step: Optional[int] = None): - params = {'step': step} if step is not None else None - return self._call(f'amixer/decr/{control}/', params=params) - - def amixer_mute(self, control: str): - return self._call(f'amixer/mute/{control}/') - - def amixer_unmute(self, control: str): - return self._call(f'amixer/unmute/{control}/') - - def amixer_cap(self, control: str): - return self._call(f'amixer/cap/{control}/') - - def amixer_nocap(self, control: str): - return self._call(f'amixer/nocap/{control}/') - - -class CameraNodeClient(MediaNodeClient): - def capture(self, - save_to: str, - with_flash: bool = False): - return self._call('capture/', - {'with_flash': int(with_flash)}, - save_to=save_to) diff --git a/src/home/media/node_server.py b/src/home/media/node_server.py deleted file mode 100644 index 5d0803c..0000000 --- a/src/home/media/node_server.py +++ /dev/null @@ -1,86 +0,0 @@ -from .. import http -from .record import Recorder -from .types import RecordStatus -from .storage import RecordStorage - - -class MediaNodeServer(http.HTTPServer): - recorder: Recorder - storage: RecordStorage - - def __init__(self, - recorder: Recorder, - storage: RecordStorage, - *args, **kwargs): - super().__init__(*args, **kwargs) - - self.recorder = recorder - self.storage = storage - - self.get('/record/', self.do_record) - self.get('/record/info/{id}/', self.record_info) - self.get('/record/forget/{id}/', self.record_forget) - self.get('/record/download/{id}/', self.record_download) - - self.get('/storage/list/', self.storage_list) - self.get('/storage/delete/', self.storage_delete) - self.get('/storage/download/', self.storage_download) - - async def do_record(self, request: http.Request): - duration = int(request.query['duration']) - max = Recorder.get_max_record_time()*15 - if not 0 < duration <= max: - raise ValueError(f'invalid duration: max duration is {max}') - - record_id = self.recorder.record(duration) - return http.ok({'id': record_id}) - - async def record_info(self, request: http.Request): - record_id = int(request.match_info['id']) - info = self.recorder.get_info(record_id) - return http.ok(info.as_dict()) - - async def record_forget(self, request: http.Request): - record_id = int(request.match_info['id']) - - info = self.recorder.get_info(record_id) - assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}" - - self.recorder.forget(record_id) - return http.ok() - - async def record_download(self, request: http.Request): - record_id = int(request.match_info['id']) - - info = self.recorder.get_info(record_id) - assert info.status == RecordStatus.FINISHED, f"record status is {info.status}" - - return http.FileResponse(info.file.path) - - async def storage_list(self, request: http.Request): - extended = 'extended' in request.query and int(request.query['extended']) == 1 - - files = self.storage.getfiles(as_objects=extended) - if extended: - files = list(map(lambda file: file.__dict__(), files)) - - return http.ok({ - 'files': files - }) - - async def storage_delete(self, request: http.Request): - file_id = request.query['file_id'] - file = self.storage.find(file_id) - if not file: - raise ValueError(f'file {file} not found') - - self.storage.delete(file) - return http.ok() - - async def storage_download(self, request): - file_id = request.query['file_id'] - file = self.storage.find(file_id) - if not file: - raise ValueError(f'file {file} not found') - - return http.FileResponse(file.path) diff --git a/src/home/media/record.py b/src/home/media/record.py deleted file mode 100644 index cd7447a..0000000 --- a/src/home/media/record.py +++ /dev/null @@ -1,461 +0,0 @@ -import os -import threading -import logging -import time -import subprocess -import signal - -from typing import Optional, List, Dict -from ..util import find_child_processes, Addr -from ..config import config -from .storage import RecordFile, RecordStorage -from .types import RecordStatus -from ..camera.types import CameraType - - -_history_item_timeout = 7200 -_history_cleanup_freq = 3600 - - -class RecordHistoryItem: - id: int - request_time: float - start_time: float - stop_time: float - relations: List[int] - status: RecordStatus - error: Optional[Exception] - file: Optional[RecordFile] - creation_time: float - - def __init__(self, id): - self.id = id - self.request_time = 0 - self.start_time = 0 - self.stop_time = 0 - self.relations = [] - self.status = RecordStatus.WAITING - self.file = None - self.error = None - self.creation_time = time.time() - - def add_relation(self, related_id: int): - self.relations.append(related_id) - - def mark_started(self, start_time: float): - self.start_time = start_time - self.status = RecordStatus.RECORDING - - def mark_finished(self, end_time: float, file: RecordFile): - self.stop_time = end_time - self.file = file - self.status = RecordStatus.FINISHED - - def mark_failed(self, error: Exception): - self.status = RecordStatus.ERROR - self.error = error - - def as_dict(self) -> dict: - data = { - 'id': self.id, - 'request_time': self.request_time, - 'status': self.status.value, - 'relations': self.relations, - 'start_time': self.start_time, - 'stop_time': self.stop_time, - } - if self.error: - data['error'] = str(self.error) - if self.file: - data['file'] = self.file.__dict__() - return data - - -class RecordingNotFoundError(Exception): - pass - - -class RecordHistory: - history: Dict[int, RecordHistoryItem] - - def __init__(self): - self.history = {} - self.logger = logging.getLogger(self.__class__.__name__) - - def add(self, record_id: int): - self.logger.debug(f'add: record_id={record_id}') - - r = RecordHistoryItem(record_id) - r.request_time = time.time() - - self.history[record_id] = r - - def delete(self, record_id: int): - self.logger.debug(f'delete: record_id={record_id}') - del self.history[record_id] - - def cleanup(self): - del_ids = [] - for rid, item in self.history.items(): - if item.creation_time < time.time()-_history_item_timeout: - del_ids.append(rid) - for rid in del_ids: - self.delete(rid) - - def __getitem__(self, key): - if key not in self.history: - raise RecordingNotFoundError() - - return self.history[key] - - def __setitem__(self, key, value): - raise NotImplementedError('setting history item this way is prohibited') - - def __contains__(self, key): - return key in self.history - - -class Recording: - RECORDER_PROGRAM = None - - start_time: float - stop_time: float - duration: int - record_id: int - recorder_program_pid: Optional[int] - process: Optional[subprocess.Popen] - - g_record_id = 1 - - def __init__(self): - if self.RECORDER_PROGRAM is None: - raise RuntimeError('this is abstract class') - - self.start_time = 0 - self.stop_time = 0 - self.duration = 0 - self.process = None - self.recorder_program_pid = None - self.record_id = Recording.next_id() - self.logger = logging.getLogger(self.__class__.__name__) - - def is_started(self) -> bool: - return self.start_time > 0 and self.stop_time > 0 - - def is_waiting(self): - return self.duration > 0 - - def ask_for(self, duration) -> int: - overtime = 0 - orig_duration = duration - - if self.is_started(): - already_passed = time.time() - self.start_time - max_duration = Recorder.get_max_record_time() - already_passed - self.logger.debug(f'ask_for({orig_duration}): recording is in progress, already passed {already_passed}s, max_duration set to {max_duration}') - else: - max_duration = Recorder.get_max_record_time() - - if duration > max_duration: - overtime = duration - max_duration - duration = max_duration - - self.logger.debug(f'ask_for({orig_duration}): requested duration ({orig_duration}) is greater than max ({max_duration}), overtime is {overtime}') - - self.duration += duration - if self.is_started(): - til_end = self.stop_time - time.time() - if til_end < 0: - til_end = 0 - - _prev_stop_time = self.stop_time - _to_add = duration - til_end - if _to_add < 0: - _to_add = 0 - - self.stop_time += _to_add - self.logger.debug(f'ask_for({orig_duration}): adding {_to_add} to stop_time (before: {_prev_stop_time}, after: {self.stop_time})') - - return overtime - - def start(self, output: str): - assert self.start_time == 0 and self.stop_time == 0, "already started?!" - assert self.process is None, "self.process is not None, what the hell?" - - cur = time.time() - self.start_time = cur - self.stop_time = cur + self.duration - - cmd = self.get_command(output) - self.logger.debug(f'start: running `{cmd}`') - self.process = subprocess.Popen(cmd, shell=True, stdin=None, stdout=None, stderr=None, close_fds=True) - - sh_pid = self.process.pid - self.logger.debug(f'start: started, pid of shell is {sh_pid}') - - pid = self.find_recorder_program_pid(sh_pid) - if pid is not None: - self.recorder_program_pid = pid - self.logger.debug(f'start: pid of {self.RECORDER_PROGRAM} is {pid}') - - def get_command(self, output: str) -> str: - pass - - def stop(self): - if self.process: - if self.recorder_program_pid is None: - self.recorder_program_pid = self.find_recorder_program_pid(self.process.pid) - - if self.recorder_program_pid is not None: - os.kill(self.recorder_program_pid, signal.SIGINT) - timeout = config['node']['process_wait_timeout'] - - self.logger.debug(f'stop: sent SIGINT to {self.recorder_program_pid}. now waiting up to {timeout} seconds...') - try: - self.process.wait(timeout=timeout) - except subprocess.TimeoutExpired: - self.logger.warning(f'stop: wait({timeout}): timeout expired, killing it') - try: - os.kill(self.recorder_program_pid, signal.SIGKILL) - self.process.terminate() - except Exception as exc: - self.logger.exception(exc) - else: - self.logger.warning(f'stop: pid of {self.RECORDER_PROGRAM} is unknown, calling terminate()') - self.process.terminate() - - rc = self.process.returncode - self.logger.debug(f'stop: rc={rc}') - - self.process = None - self.recorder_program_pid = 0 - - self.duration = 0 - self.start_time = 0 - self.stop_time = 0 - - def find_recorder_program_pid(self, sh_pid: int): - try: - children = find_child_processes(sh_pid) - except OSError as exc: - self.logger.warning(f'failed to find child process of {sh_pid}: ' + str(exc)) - return None - - for child in children: - if self.RECORDER_PROGRAM in child.cmd: - return child.pid - - return None - - @staticmethod - def next_id() -> int: - cur_id = Recording.g_record_id - Recording.g_record_id += 1 - return cur_id - - def increment_id(self): - self.record_id = Recording.next_id() - - -class Recorder: - TEMP_NAME = None - - interrupted: bool - lock: threading.Lock - history_lock: threading.Lock - recording: Optional[Recording] - overtime: int - history: RecordHistory - next_history_cleanup_time: float - storage: RecordStorage - - def __init__(self, - storage: RecordStorage, - recording: Recording): - if self.TEMP_NAME is None: - raise RuntimeError('this is abstract class') - - self.storage = storage - self.recording = recording - self.interrupted = False - self.lock = threading.Lock() - self.history_lock = threading.Lock() - self.overtime = 0 - self.history = RecordHistory() - self.next_history_cleanup_time = 0 - self.logger = logging.getLogger(self.__class__.__name__) - - def start_thread(self): - t = threading.Thread(target=self.loop) - t.daemon = True - t.start() - - def loop(self) -> None: - tempname = os.path.join(self.storage.root, self.TEMP_NAME) - - while not self.interrupted: - cur = time.time() - stopped = False - cur_record_id = None - - if self.next_history_cleanup_time == 0: - self.next_history_cleanup_time = time.time() + _history_cleanup_freq - elif self.next_history_cleanup_time <= time.time(): - self.logger.debug('loop: calling history.cleanup()') - try: - self.history.cleanup() - except Exception as e: - self.logger.error('loop: error while history.cleanup(): ' + str(e)) - self.next_history_cleanup_time = time.time() + _history_cleanup_freq - - with self.lock: - cur_record_id = self.recording.record_id - # self.logger.debug(f'cur_record_id={cur_record_id}') - - if not self.recording.is_started(): - if self.recording.is_waiting(): - try: - if os.path.exists(tempname): - self.logger.warning(f'loop: going to start new recording, but {tempname} still exists, unlinking..') - try: - os.unlink(tempname) - except OSError as e: - self.logger.exception(e) - self.recording.start(tempname) - with self.history_lock: - self.history[cur_record_id].mark_started(self.recording.start_time) - except Exception as exc: - self.logger.exception(exc) - - # there should not be any errors, but still.. - try: - self.recording.stop() - except Exception as exc: - self.logger.exception(exc) - - with self.history_lock: - self.history[cur_record_id].mark_failed(exc) - - self.logger.debug(f'loop: start exc path: calling increment_id()') - self.recording.increment_id() - else: - if cur >= self.recording.stop_time: - try: - start_time = self.recording.start_time - stop_time = self.recording.stop_time - self.recording.stop() - - saved_name = self.storage.save(tempname, - record_id=cur_record_id, - start_time=int(start_time), - stop_time=int(stop_time)) - - with self.history_lock: - self.history[cur_record_id].mark_finished(stop_time, saved_name) - except Exception as exc: - self.logger.exception(exc) - with self.history_lock: - self.history[cur_record_id].mark_failed(exc) - finally: - self.logger.debug(f'loop: stop exc final path: calling increment_id()') - self.recording.increment_id() - - stopped = True - - if stopped and self.overtime > 0: - self.logger.info(f'recording {cur_record_id} is stopped, but we\'ve got overtime ({self.overtime})') - _overtime = self.overtime - self.overtime = 0 - - related_id = self.record(_overtime) - self.logger.info(f'enqueued another record with id {related_id}') - - if cur_record_id is not None: - with self.history_lock: - self.history[cur_record_id].add_relation(related_id) - - time.sleep(0.2) - - def record(self, duration: int) -> int: - self.logger.debug(f'record: duration={duration}') - with self.lock: - overtime = self.recording.ask_for(duration) - self.logger.debug(f'overtime={overtime}') - - if overtime > self.overtime: - self.overtime = overtime - - if not self.recording.is_started(): - with self.history_lock: - self.history.add(self.recording.record_id) - - return self.recording.record_id - - def stop(self): - self.interrupted = True - - def get_info(self, record_id: int) -> RecordHistoryItem: - with self.history_lock: - return self.history[record_id] - - def forget(self, record_id: int): - with self.history_lock: - self.logger.info(f'forget: removing record {record_id} from history') - self.history.delete(record_id) - - @staticmethod - def get_max_record_time() -> int: - return config['node']['record_max_time'] - - -class SoundRecorder(Recorder): - TEMP_NAME = 'temp.mp3' - - def __init__(self, *args, **kwargs): - super().__init__(recording=SoundRecording(), - *args, **kwargs) - - -class CameraRecorder(Recorder): - TEMP_NAME = 'temp.mp4' - - def __init__(self, - camera_type: CameraType, - *args, **kwargs): - if camera_type == CameraType.ESP32: - recording = ESP32CameraRecording(stream_addr=kwargs['stream_addr']) - del kwargs['stream_addr'] - else: - raise RuntimeError(f'unsupported camera type {camera_type}') - - super().__init__(recording=recording, - *args, **kwargs) - - -class SoundRecording(Recording): - RECORDER_PROGRAM = 'arecord' - - def get_command(self, output: str) -> str: - arecord = config['arecord']['bin'] - lame = config['lame']['bin'] - b = config['lame']['bitrate'] - - return f'{arecord} -f S16 -r 44100 -t raw 2>/dev/null | {lame} -r -s 44.1 -b {b} -m m - {output} >/dev/null 2>/dev/null' - - -class ESP32CameraRecording(Recording): - RECORDER_PROGRAM = 'esp32_capture.py' - - stream_addr: Addr - - def __init__(self, stream_addr: Addr): - super().__init__() - self.stream_addr = stream_addr - - def get_command(self, output: str) -> str: - bin = config['esp32_capture']['bin'] - return f'{bin} --addr {self.stream_addr[0]}:{self.stream_addr[1]} --output-directory {output} >/dev/null 2>/dev/null' - - def start(self, output: str): - output = os.path.dirname(output) - return super().start(output) \ No newline at end of file diff --git a/src/home/media/record_client.py b/src/home/media/record_client.py deleted file mode 100644 index 322495c..0000000 --- a/src/home/media/record_client.py +++ /dev/null @@ -1,166 +0,0 @@ -import time -import logging -import threading -import os.path - -from tempfile import gettempdir -from .record import RecordStatus -from .node_client import SoundNodeClient, MediaNodeClient, CameraNodeClient -from ..util import Addr -from typing import Optional, Callable, Dict - - -class RecordClient: - DOWNLOAD_EXTENSION = None - - interrupted: bool - logger: logging.Logger - clients: Dict[str, MediaNodeClient] - awaiting: Dict[str, Dict[int, Optional[dict]]] - error_handler: Optional[Callable] - finished_handler: Optional[Callable] - download_on_finish: bool - - def __init__(self, - nodes: Dict[str, Addr], - error_handler: Optional[Callable] = None, - finished_handler: Optional[Callable] = None, - download_on_finish=False): - if self.DOWNLOAD_EXTENSION is None: - raise RuntimeError('this is abstract class') - - self.interrupted = False - self.logger = logging.getLogger(self.__class__.__name__) - self.clients = {} - self.awaiting = {} - - self.download_on_finish = download_on_finish - self.error_handler = error_handler - self.finished_handler = finished_handler - - self.awaiting_lock = threading.Lock() - - self.make_clients(nodes) - - try: - t = threading.Thread(target=self.loop) - t.daemon = True - t.start() - except (KeyboardInterrupt, SystemExit) as exc: - self.stop() - self.logger.exception(exc) - - def make_clients(self, nodes: Dict[str, Addr]): - pass - - def stop(self): - self.interrupted = True - - def loop(self): - while not self.interrupted: - for node in self.awaiting.keys(): - with self.awaiting_lock: - record_ids = list(self.awaiting[node].keys()) - if not record_ids: - continue - - self.logger.debug(f'loop: node `{node}` awaiting list: {record_ids}') - - cl = self.getclient(node) - del_ids = [] - for rid in record_ids: - info = cl.record_info(rid) - - if info['relations']: - for relid in info['relations']: - self.wait_for_record(node, relid, self.awaiting[node][rid], is_relative=True) - - status = RecordStatus(info['status']) - if status in (RecordStatus.FINISHED, RecordStatus.ERROR): - if status == RecordStatus.FINISHED: - if self.download_on_finish: - local_fn = self.download(node, rid, info['file']['fileid']) - else: - local_fn = None - self._report_finished(info, local_fn, self.awaiting[node][rid]) - else: - self._report_error(info, self.awaiting[node][rid]) - del_ids.append(rid) - self.logger.debug(f'record {rid}: status {status}') - - if del_ids: - self.logger.debug(f'deleting {del_ids} from {node}\'s awaiting list') - with self.awaiting_lock: - for del_id in del_ids: - del self.awaiting[node][del_id] - - time.sleep(5) - - self.logger.info('loop ended') - - def getclient(self, node: str): - return self.clients[node] - - def record(self, - node: str, - duration: int, - userdata: Optional[dict] = None) -> int: - self.logger.debug(f'record: node={node}, duration={duration}, userdata={userdata}') - - cl = self.getclient(node) - record_id = cl.record(duration)['id'] - self.logger.debug(f'record: request sent, record_id={record_id}') - - self.wait_for_record(node, record_id, userdata) - return record_id - - def wait_for_record(self, - node: str, - record_id: int, - userdata: Optional[dict] = None, - is_relative=False): - with self.awaiting_lock: - if record_id not in self.awaiting[node]: - msg = f'wait_for_record: adding {record_id} to {node}' - if is_relative: - msg += ' (by relation)' - self.logger.debug(msg) - - self.awaiting[node][record_id] = userdata - - def download(self, node: str, record_id: int, fileid: str): - dst = os.path.join(gettempdir(), f'{node}_{fileid}.{self.DOWNLOAD_EXTENSION}') - cl = self.getclient(node) - cl.record_download(record_id, dst) - return dst - - def forget(self, node: str, rid: int): - self.getclient(node).record_forget(rid) - - def _report_finished(self, *args): - if self.finished_handler: - self.finished_handler(*args) - - def _report_error(self, *args): - if self.error_handler: - self.error_handler(*args) - - -class SoundRecordClient(RecordClient): - DOWNLOAD_EXTENSION = 'mp3' - # clients: Dict[str, SoundNodeClient] - - def make_clients(self, nodes: Dict[str, Addr]): - for node, addr in nodes.items(): - self.clients[node] = SoundNodeClient(addr) - self.awaiting[node] = {} - - -class CameraRecordClient(RecordClient): - DOWNLOAD_EXTENSION = 'mp4' - # clients: Dict[str, CameraNodeClient] - - def make_clients(self, nodes: Dict[str, Addr]): - for node, addr in nodes.items(): - self.clients[node] = CameraNodeClient(addr) - self.awaiting[node] = {} \ No newline at end of file diff --git a/src/home/media/storage.py b/src/home/media/storage.py deleted file mode 100644 index dd74ff8..0000000 --- a/src/home/media/storage.py +++ /dev/null @@ -1,210 +0,0 @@ -import os -import re -import shutil -import logging - -from typing import Optional, Union, List -from datetime import datetime -from ..util import strgen - -logger = logging.getLogger(__name__) - - -# record file -# ----------- - -class RecordFile: - EXTENSION = None - - start_time: Optional[datetime] - stop_time: Optional[datetime] - record_id: Optional[int] - name: str - file_id: Optional[str] - remote: bool - remote_filesize: int - storage_root: str - - human_date_dmt = '%d.%m.%y' - human_time_fmt = '%H:%M:%S' - - @staticmethod - def create(filename: str, *args, **kwargs): - if filename.endswith(f'.{SoundRecordFile.EXTENSION}'): - return SoundRecordFile(filename, *args, **kwargs) - elif filename.endswith(f'.{CameraRecordFile.EXTENSION}'): - return CameraRecordFile(filename, *args, **kwargs) - else: - raise RuntimeError(f'unsupported file extension: {filename}') - - def __init__(self, filename: str, remote=False, remote_filesize=None, storage_root='/'): - if self.EXTENSION is None: - raise RuntimeError('this is abstract class') - - self.name = filename - self.storage_root = storage_root - - self.remote = remote - self.remote_filesize = remote_filesize - - m = re.match(r'^(\d{6}-\d{6})_(\d{6}-\d{6})_id(\d+)(_\w+)?\.'+self.EXTENSION+'$', filename) - if m: - self.start_time = datetime.strptime(m.group(1), RecordStorage.time_fmt) - self.stop_time = datetime.strptime(m.group(2), RecordStorage.time_fmt) - self.record_id = int(m.group(3)) - self.file_id = (m.group(1) + '_' + m.group(2)).replace('-', '_') - else: - logger.warning(f'unexpected filename: {filename}') - self.start_time = None - self.stop_time = None - self.record_id = None - self.file_id = None - - @property - def path(self): - if self.remote: - return RuntimeError('remote recording, can\'t get real path') - - return os.path.realpath(os.path.join( - self.storage_root, self.name - )) - - @property - def start_humantime(self) -> str: - if self.start_time is None: - return '?' - fmt = f'{RecordFile.human_date_dmt} {RecordFile.human_time_fmt}' - return self.start_time.strftime(fmt) - - @property - def stop_humantime(self) -> str: - if self.stop_time is None: - return '?' - fmt = RecordFile.human_time_fmt - if self.start_time.date() != self.stop_time.date(): - fmt = f'{RecordFile.human_date_dmt} {fmt}' - return self.stop_time.strftime(fmt) - - @property - def start_unixtime(self) -> int: - if self.start_time is None: - return 0 - return int(self.start_time.timestamp()) - - @property - def stop_unixtime(self) -> int: - if self.stop_time is None: - return 0 - return int(self.stop_time.timestamp()) - - @property - def filesize(self): - if self.remote: - if self.remote_filesize is None: - raise RuntimeError('file is remote and remote_filesize is not set') - return self.remote_filesize - return os.path.getsize(self.path) - - def __dict__(self) -> dict: - return { - 'start_unixtime': self.start_unixtime, - 'stop_unixtime': self.stop_unixtime, - 'filename': self.name, - 'filesize': self.filesize, - 'fileid': self.file_id, - 'record_id': self.record_id or 0, - } - - -class PseudoRecordFile(RecordFile): - EXTENSION = 'null' - - def __init__(self): - super().__init__('pseudo.null') - - @property - def filesize(self): - return 0 - - -class SoundRecordFile(RecordFile): - EXTENSION = 'mp3' - - -class CameraRecordFile(RecordFile): - EXTENSION = 'mp4' - - -# record storage -# -------------- - -class RecordStorage: - EXTENSION = None - - time_fmt = '%d%m%y-%H%M%S' - - def __init__(self, root: str): - if self.EXTENSION is None: - raise RuntimeError('this is abstract class') - - self.root = root - - def getfiles(self, as_objects=False) -> Union[List[str], List[RecordFile]]: - files = [] - for name in os.listdir(self.root): - path = os.path.join(self.root, name) - if os.path.isfile(path) and name.endswith(f'.{self.EXTENSION}'): - files.append(name if not as_objects else RecordFile.create(name, storage_root=self.root)) - return files - - def find(self, file_id: str) -> Optional[RecordFile]: - for name in os.listdir(self.root): - if os.path.isfile(os.path.join(self.root, name)) and name.endswith(f'.{self.EXTENSION}'): - item = RecordFile.create(name, storage_root=self.root) - if item.file_id == file_id: - return item - return None - - def purge(self): - files = self.getfiles() - if files: - logger = logging.getLogger(self.__name__) - for f in files: - try: - path = os.path.join(self.root, f) - logger.debug(f'purge: deleting {path}') - os.unlink(path) - except OSError as exc: - logger.exception(exc) - - def delete(self, file: RecordFile): - os.unlink(file.path) - - def save(self, - fn: str, - record_id: int, - start_time: int, - stop_time: int) -> RecordFile: - - start_time_s = datetime.fromtimestamp(start_time).strftime(self.time_fmt) - stop_time_s = datetime.fromtimestamp(stop_time).strftime(self.time_fmt) - - dst_fn = f'{start_time_s}_{stop_time_s}_id{record_id}' - if os.path.exists(os.path.join(self.root, dst_fn)): - dst_fn += strgen(4) - dst_fn += f'.{self.EXTENSION}' - dst_path = os.path.join(self.root, dst_fn) - - shutil.move(fn, dst_path) - return RecordFile.create(dst_fn, storage_root=self.root) - - -class SoundRecordStorage(RecordStorage): - EXTENSION = 'mp3' - - -class ESP32CameraRecordStorage(RecordStorage): - EXTENSION = 'jpg' # not used anyway - - def save(self, *args, **kwargs): - return PseudoRecordFile() \ No newline at end of file diff --git a/src/home/media/types.py b/src/home/media/types.py deleted file mode 100644 index acbc291..0000000 --- a/src/home/media/types.py +++ /dev/null @@ -1,13 +0,0 @@ -from enum import Enum, auto - - -class MediaNodeType(Enum): - SOUND = auto() - CAMERA = auto() - - -class RecordStatus(Enum): - WAITING = auto() - RECORDING = auto() - FINISHED = auto() - ERROR = auto() diff --git a/src/home/mqtt/__init__.py b/src/home/mqtt/__init__.py deleted file mode 100644 index 707d59c..0000000 --- a/src/home/mqtt/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from ._mqtt import Mqtt -from ._node import MqttNode -from ._module import MqttModule -from ._wrapper import MqttWrapper -from ._config import MqttConfig, MqttCreds, MqttNodesConfig -from ._payload import MqttPayload, MqttPayloadCustomField -from ._util import get_modules as get_mqtt_modules \ No newline at end of file diff --git a/src/home/mqtt/_config.py b/src/home/mqtt/_config.py deleted file mode 100644 index 9ba9443..0000000 --- a/src/home/mqtt/_config.py +++ /dev/null @@ -1,165 +0,0 @@ -from ..config import ConfigUnit -from typing import Optional, Union -from ..util import Addr -from collections import namedtuple - -MqttCreds = namedtuple('MqttCreds', 'username, password') - - -class MqttConfig(ConfigUnit): - NAME = 'mqtt' - - @classmethod - def schema(cls) -> Optional[dict]: - addr_schema = { - 'type': 'dict', - 'required': True, - 'schema': { - 'host': {'type': 'string', 'required': True}, - 'port': {'type': 'integer', 'required': True} - } - } - - schema = {} - for key in ('local', 'remote'): - schema[f'{key}_addr'] = addr_schema - - schema['creds'] = { - 'type': 'dict', - 'required': True, - 'keysrules': {'type': 'string'}, - 'valuesrules': { - 'type': 'dict', - 'schema': { - 'username': {'type': 'string', 'required': True}, - 'password': {'type': 'string', 'required': True}, - } - } - } - - for key in ('client', 'server'): - schema[f'default_{key}_creds'] = {'type': 'string', 'required': True} - - return schema - - def remote_addr(self) -> Addr: - return Addr(host=self['remote_addr']['host'], - port=self['remote_addr']['port']) - - def local_addr(self) -> Addr: - return Addr(host=self['local_addr']['host'], - port=self['local_addr']['port']) - - def creds_by_name(self, name: str) -> MqttCreds: - return MqttCreds(username=self['creds'][name]['username'], - password=self['creds'][name]['password']) - - def creds(self) -> MqttCreds: - return self.creds_by_name(self['default_client_creds']) - - def server_creds(self) -> MqttCreds: - return self.creds_by_name(self['default_server_creds']) - - -class MqttNodesConfig(ConfigUnit): - NAME = 'mqtt_nodes' - - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'common': { - 'type': 'dict', - 'schema': { - 'temphum': { - 'type': 'dict', - 'schema': { - 'interval': {'type': 'integer'} - } - }, - 'password': {'type': 'string'} - } - }, - 'nodes': { - 'type': 'dict', - 'required': True, - 'keysrules': {'type': 'string'}, - 'valuesrules': { - 'type': 'dict', - 'schema': { - 'type': {'type': 'string', 'required': True, 'allowed': ['esp8266', 'linux', 'none'],}, - 'board': {'type': 'string', 'allowed': ['nodemcu', 'd1_mini_lite', 'esp12e']}, - 'temphum': { - 'type': 'dict', - 'schema': { - 'module': {'type': 'string', 'required': True, 'allowed': ['si7021', 'dht12']}, - 'interval': {'type': 'integer'}, - 'i2c_bus': {'type': 'integer'}, - 'tcpserver': { - 'type': 'dict', - 'schema': { - 'port': {'type': 'integer', 'required': True} - } - } - } - }, - 'relay': { - 'type': 'dict', - 'schema': { - 'device_type': {'type': 'string', 'allowed': ['lamp', 'pump', 'solenoid'], 'required': True}, - 'legacy_topics': {'type': 'boolean'} - } - }, - 'password': {'type': 'string'} - } - } - } - } - - @staticmethod - def custom_validator(data): - for name, node in data['nodes'].items(): - if 'temphum' in node: - if node['type'] == 'linux': - if 'i2c_bus' not in node['temphum']: - raise KeyError(f'nodes.{name}.temphum: i2c_bus is missing but required for type=linux') - if node['type'] in ('esp8266',) and 'board' not in node: - raise KeyError(f'nodes.{name}: board is missing but required for type={node["type"]}') - - def get_node(self, name: str) -> dict: - node = self['nodes'][name] - if node['type'] == 'none': - return node - - try: - if 'password' not in node: - node['password'] = self['common']['password'] - except KeyError: - pass - - try: - if 'temphum' in node: - for ckey, cval in self['common']['temphum'].items(): - if ckey not in node['temphum']: - node['temphum'][ckey] = cval - except KeyError: - pass - - return node - - def get_nodes(self, - filters: Optional[Union[list[str], tuple[str]]] = None, - only_names=False) -> Union[dict, list[str]]: - if filters: - for f in filters: - if f not in ('temphum', 'relay'): - raise ValueError(f'{self.__class__.__name__}::get_node(): invalid filter {f}') - reslist = [] - resdict = {} - for name in self['nodes'].keys(): - node = self.get_node(name) - if (not filters) or ('temphum' in filters and 'temphum' in node) or ('relay' in filters and 'relay' in node): - if only_names: - reslist.append(name) - else: - resdict[name] = node - return reslist if only_names else resdict diff --git a/src/home/mqtt/_module.py b/src/home/mqtt/_module.py deleted file mode 100644 index 80f27bb..0000000 --- a/src/home/mqtt/_module.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import abc -import logging -import threading - -from time import sleep -from ..util import next_tick_gen - -from typing import TYPE_CHECKING, Optional -if TYPE_CHECKING: - from ._node import MqttNode - from ._payload import MqttPayload - - -class MqttModule(abc.ABC): - _tick_interval: int - _initialized: bool - _connected: bool - _ticker: Optional[threading.Thread] - _mqtt_node_ref: Optional[MqttNode] - - def __init__(self, tick_interval=0): - self._tick_interval = tick_interval - self._initialized = False - self._ticker = None - self._logger = logging.getLogger(self.__class__.__name__) - self._connected = False - self._mqtt_node_ref = None - - def on_connect(self, mqtt: MqttNode): - self._connected = True - self._mqtt_node_ref = mqtt - if self._tick_interval: - self._start_ticker() - - def on_disconnect(self, mqtt: MqttNode): - self._connected = False - self._mqtt_node_ref = None - - def is_initialized(self): - return self._initialized - - def set_initialized(self): - self._initialized = True - - def unset_initialized(self): - self._initialized = False - - def tick(self): - pass - - def _tick(self): - g = next_tick_gen(self._tick_interval) - while self._connected: - sleep(next(g)) - if not self._connected: - break - self.tick() - - def _start_ticker(self): - if not self._ticker or not self._ticker.is_alive(): - name_part = f'{self._mqtt_node_ref.id}/' if self._mqtt_node_ref else '' - self._ticker = None - self._ticker = threading.Thread(target=self._tick, - name=f'mqtt:{self.__class__.__name__}/{name_part}ticker') - self._ticker.start() - - def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: - pass diff --git a/src/home/mqtt/_mqtt.py b/src/home/mqtt/_mqtt.py deleted file mode 100644 index 746ae2e..0000000 --- a/src/home/mqtt/_mqtt.py +++ /dev/null @@ -1,86 +0,0 @@ -import os.path -import paho.mqtt.client as mqtt -import ssl -import logging - -from ._config import MqttCreds, MqttConfig -from typing import Optional - - -class Mqtt: - _connected: bool - _is_server: bool - _mqtt_config: MqttConfig - - def __init__(self, - clean_session=True, - client_id='', - creds: Optional[MqttCreds] = None, - is_server=False): - if not client_id: - raise ValueError('client_id must not be empty') - - self._client = mqtt.Client(client_id=client_id, - protocol=mqtt.MQTTv311, - clean_session=clean_session) - self._client.on_connect = self.on_connect - self._client.on_disconnect = self.on_disconnect - self._client.on_message = self.on_message - self._client.on_log = self.on_log - self._client.on_publish = self.on_publish - self._loop_started = False - self._connected = False - self._is_server = is_server - self._mqtt_config = MqttConfig() - self._logger = logging.getLogger(self.__class__.__name__) - - if not creds: - creds = self._mqtt_config.creds() if not is_server else self._mqtt_config.server_creds() - - self._client.username_pw_set(creds.username, creds.password) - - def _configure_tls(self): - ca_certs = os.path.realpath(os.path.join( - os.path.dirname(os.path.realpath(__file__)), - '..', - '..', - '..', - 'assets', - 'mqtt_ca.crt' - )) - self._client.tls_set(ca_certs=ca_certs, - cert_reqs=ssl.CERT_REQUIRED, - tls_version=ssl.PROTOCOL_TLSv1_2) - - def connect_and_loop(self, loop_forever=True): - self._configure_tls() - addr = self._mqtt_config.local_addr() if self._is_server else self._mqtt_config.remote_addr() - self._client.connect(addr.host, addr.port, 60) - if loop_forever: - self._client.loop_forever() - else: - self._client.loop_start() - self._loop_started = True - - def disconnect(self): - self._client.disconnect() - self._client.loop_write() - self._client.loop_stop() - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - self._logger.info("Connected with result code " + str(rc)) - self._connected = True - - def on_disconnect(self, client: mqtt.Client, userdata, rc): - self._logger.info("Disconnected with result code " + str(rc)) - self._connected = False - - def on_log(self, client: mqtt.Client, userdata, level, buf): - level = mqtt.LOGGING_LEVEL[level] if level in mqtt.LOGGING_LEVEL else logging.INFO - self._logger.log(level, f'MQTT: {buf}') - - def on_message(self, client: mqtt.Client, userdata, msg): - self._logger.debug(msg.topic + ": " + str(msg.payload)) - - def on_publish(self, client: mqtt.Client, userdata, mid): - self._logger.debug(f'publish done, mid={mid}') diff --git a/src/home/mqtt/_node.py b/src/home/mqtt/_node.py deleted file mode 100644 index 4e259a4..0000000 --- a/src/home/mqtt/_node.py +++ /dev/null @@ -1,92 +0,0 @@ -import logging -import importlib - -from typing import List, TYPE_CHECKING, Optional -from ._payload import MqttPayload -from ._module import MqttModule -if TYPE_CHECKING: - from ._wrapper import MqttWrapper -else: - MqttWrapper = None - - -class MqttNode: - _modules: List[MqttModule] - _module_subscriptions: dict[str, MqttModule] - _node_id: str - _node_secret: str - _payload_callbacks: list[callable] - _wrapper: Optional[MqttWrapper] - - def __init__(self, - node_id: str, - node_secret: Optional[str] = None): - self._modules = [] - self._module_subscriptions = {} - self._node_id = node_id - self._node_secret = node_secret - self._payload_callbacks = [] - self._logger = logging.getLogger(self.__class__.__name__) - self._wrapper = None - - def on_connect(self, wrapper: MqttWrapper): - self._wrapper = wrapper - for module in self._modules: - if not module.is_initialized(): - module.on_connect(self) - module.set_initialized() - - def on_disconnect(self): - self._wrapper = None - for module in self._modules: - module.unset_initialized() - - def on_message(self, topic, payload): - if topic in self._module_subscriptions: - payload = self._module_subscriptions[topic].handle_payload(self, topic, payload) - if isinstance(payload, MqttPayload): - for f in self._payload_callbacks: - f(self, payload) - - def load_module(self, module_name: str, *args, **kwargs) -> MqttModule: - module = importlib.import_module(f'..module.{module_name}', __name__) - if not hasattr(module, 'MODULE_NAME'): - raise RuntimeError(f'MODULE_NAME not found in module {module}') - cl = getattr(module, getattr(module, 'MODULE_NAME')) - instance = cl(*args, **kwargs) - self.add_module(instance) - return instance - - def add_module(self, module: MqttModule): - self._modules.append(module) - if self._wrapper and self._wrapper._connected: - module.on_connect(self) - module.set_initialized() - - def subscribe_module(self, topic: str, module: MqttModule, qos: int = 1): - if not self._wrapper or not self._wrapper._connected: - raise RuntimeError('not connected') - - self._module_subscriptions[topic] = module - self._wrapper.subscribe(self.id, topic, qos) - - def publish(self, - topic: str, - payload: bytes, - qos: int = 1): - self._wrapper.publish(self.id, topic, payload, qos) - - def add_payload_callback(self, callback: callable): - self._payload_callbacks.append(callback) - - @property - def id(self) -> str: - return self._node_id - - @property - def secret(self) -> str: - return self._node_secret - - @secret.setter - def secret(self, secret: str) -> None: - self._node_secret = secret diff --git a/src/home/mqtt/_payload.py b/src/home/mqtt/_payload.py deleted file mode 100644 index 58eeae3..0000000 --- a/src/home/mqtt/_payload.py +++ /dev/null @@ -1,145 +0,0 @@ -import struct -import abc -import re - -from typing import Optional, Tuple - - -def pldstr(self) -> str: - attrs = [] - for field in self.__class__.__annotations__: - if hasattr(self, field): - attr = getattr(self, field) - attrs.append(f'{field}={attr}') - if attrs: - attrs_s = ' ' - attrs_s += ', '.join(attrs) - else: - attrs_s = '' - return f'<%s{attrs_s}>' % (self.__class__.__name__,) - - -class MqttPayload(abc.ABC): - FORMAT = '' - PACKER = {} - UNPACKER = {} - - def __init__(self, **kwargs): - for field in self.__class__.__annotations__: - setattr(self, field, kwargs[field]) - - def pack(self): - args = [] - bf_number = -1 - bf_arg = 0 - bf_progress = 0 - - for field, field_type in self.__class__.__annotations__.items(): - bfp = _bit_field_params(field_type) - if bfp: - n, s, b = bfp - if n != bf_number: - if bf_number != -1: - args.append(bf_arg) - bf_number = n - bf_progress = 0 - bf_arg = 0 - bf_arg |= (getattr(self, field) & (2 ** b - 1)) << bf_progress - bf_progress += b - - else: - if bf_number != -1: - args.append(bf_arg) - bf_number = -1 - bf_progress = 0 - bf_arg = 0 - - args.append(self._pack_field(field)) - - if bf_number != -1: - args.append(bf_arg) - - return struct.pack(self.FORMAT, *args) - - @classmethod - def unpack(cls, buf: bytes): - data = struct.unpack(cls.FORMAT, buf) - kwargs = {} - i = 0 - bf_number = -1 - bf_progress = 0 - - for field, field_type in cls.__annotations__.items(): - bfp = _bit_field_params(field_type) - if bfp: - n, s, b = bfp - if n != bf_number: - bf_number = n - bf_progress = 0 - kwargs[field] = (data[i] >> bf_progress) & (2 ** b - 1) - bf_progress += b - continue # don't increment i - - if bf_number != -1: - bf_number = -1 - i += 1 - - if issubclass(field_type, MqttPayloadCustomField): - kwargs[field] = field_type.unpack(data[i]) - else: - kwargs[field] = cls._unpack_field(field, data[i]) - i += 1 - - return cls(**kwargs) - - def _pack_field(self, name): - val = getattr(self, name) - if self.PACKER and name in self.PACKER: - return self.PACKER[name](val) - else: - return val - - @classmethod - def _unpack_field(cls, name, val): - if isinstance(val, MqttPayloadCustomField): - return - if cls.UNPACKER and name in cls.UNPACKER: - return cls.UNPACKER[name](val) - else: - return val - - def __str__(self): - return pldstr(self) - - -class MqttPayloadCustomField(abc.ABC): - def __init__(self, **kwargs): - for field in self.__class__.__annotations__: - setattr(self, field, kwargs[field]) - - @abc.abstractmethod - def __index__(self): - pass - - @classmethod - @abc.abstractmethod - def unpack(cls, *args, **kwargs): - pass - - def __str__(self): - return pldstr(self) - - -def bit_field(seq_no: int, total_bits: int, bits: int): - return type(f'MQTTPayloadBitField_{seq_no}_{total_bits}_{bits}', (object,), { - 'seq_no': seq_no, - 'total_bits': total_bits, - 'bits': bits - }) - - -def _bit_field_params(cl) -> Optional[Tuple[int, ...]]: - match = re.match(r'MQTTPayloadBitField_(\d+)_(\d+)_(\d)$', cl.__name__) - if match is not None: - return tuple([int(match.group(i)) for i in range(1, 4)]) - return None \ No newline at end of file diff --git a/src/home/mqtt/_util.py b/src/home/mqtt/_util.py deleted file mode 100644 index 390d463..0000000 --- a/src/home/mqtt/_util.py +++ /dev/null @@ -1,15 +0,0 @@ -import os -import re - -from typing import List - - -def get_modules() -> List[str]: - modules = [] - modules_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'module') - for name in os.listdir(modules_dir): - if os.path.isdir(os.path.join(modules_dir, name)): - continue - name = re.sub(r'\.py$', '', name) - modules.append(name) - return modules diff --git a/src/home/mqtt/_wrapper.py b/src/home/mqtt/_wrapper.py deleted file mode 100644 index 3c2774c..0000000 --- a/src/home/mqtt/_wrapper.py +++ /dev/null @@ -1,60 +0,0 @@ -import paho.mqtt.client as mqtt - -from ._mqtt import Mqtt -from ._node import MqttNode -from ..util import strgen - - -class MqttWrapper(Mqtt): - _nodes: list[MqttNode] - - def __init__(self, - client_id: str, - topic_prefix='hk', - randomize_client_id=False, - clean_session=True): - if randomize_client_id: - client_id += '_'+strgen(6) - super().__init__(clean_session=clean_session, - client_id=client_id) - self._nodes = [] - self._topic_prefix = topic_prefix - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - for node in self._nodes: - node.on_connect(self) - - def on_disconnect(self, client: mqtt.Client, userdata, rc): - super().on_disconnect(client, userdata, rc) - for node in self._nodes: - node.on_disconnect() - - def on_message(self, client: mqtt.Client, userdata, msg): - try: - topic = msg.topic - topic_node = topic[len(self._topic_prefix)+1:topic.find('/', len(self._topic_prefix)+1)] - for node in self._nodes: - if node.id in ('+', topic_node): - node.on_message(topic[len(f'{self._topic_prefix}/{node.id}/'):], msg.payload) - except Exception as e: - self._logger.exception(str(e)) - - def add_node(self, node: MqttNode): - self._nodes.append(node) - if self._connected: - node.on_connect(self) - - def subscribe(self, - node_id: str, - topic: str, - qos: int): - self._client.subscribe(f'{self._topic_prefix}/{node_id}/{topic}', qos) - - def publish(self, - node_id: str, - topic: str, - payload: bytes, - qos: int): - self._client.publish(f'{self._topic_prefix}/{node_id}/{topic}', payload, qos) - self._client.loop_write() diff --git a/src/home/mqtt/module/diagnostics.py b/src/home/mqtt/module/diagnostics.py deleted file mode 100644 index 5db5e99..0000000 --- a/src/home/mqtt/module/diagnostics.py +++ /dev/null @@ -1,64 +0,0 @@ -from .._payload import MqttPayload, MqttPayloadCustomField -from .._node import MqttNode, MqttModule -from typing import Optional - -MODULE_NAME = 'MqttDiagnosticsModule' - - -class DiagnosticsFlags(MqttPayloadCustomField): - state: bool - config_changed_value_present: bool - config_changed: bool - - @staticmethod - def unpack(flags: int): - # _logger.debug(f'StatFlags.unpack: flags={flags}') - state = flags & 0x1 - ccvp = (flags >> 1) & 0x1 - cc = (flags >> 2) & 0x1 - # _logger.debug(f'StatFlags.unpack: state={state}') - return DiagnosticsFlags(state=(state == 1), - config_changed_value_present=(ccvp == 1), - config_changed=(cc == 1)) - - def __index__(self): - bits = 0 - bits |= (int(self.state) & 0x1) - bits |= (int(self.config_changed_value_present) & 0x1) << 1 - bits |= (int(self.config_changed) & 0x1) << 2 - return bits - - -class InitialDiagnosticsPayload(MqttPayload): - FORMAT = '=IBbIB' - - ip: int - fw_version: int - rssi: int - free_heap: int - flags: DiagnosticsFlags - - -class DiagnosticsPayload(MqttPayload): - FORMAT = '=bIB' - - rssi: int - free_heap: int - flags: DiagnosticsFlags - - -class MqttDiagnosticsModule(MqttModule): - def on_connect(self, mqtt: MqttNode): - super().on_connect(mqtt) - for topic in ('diag', 'd1ag', 'stat', 'stat1'): - mqtt.subscribe_module(topic, self) - - def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: - message = None - if topic in ('stat', 'diag'): - message = DiagnosticsPayload.unpack(payload) - elif topic in ('stat1', 'd1ag'): - message = InitialDiagnosticsPayload.unpack(payload) - if message: - self._logger.debug(message) - return message diff --git a/src/home/mqtt/module/inverter.py b/src/home/mqtt/module/inverter.py deleted file mode 100644 index d927a06..0000000 --- a/src/home/mqtt/module/inverter.py +++ /dev/null @@ -1,195 +0,0 @@ -import time -import json -import datetime -try: - import inverterd -except: - pass - -from typing import Optional -from .._module import MqttModule -from .._node import MqttNode -from .._payload import MqttPayload, bit_field -try: - from home.database import InverterDatabase -except: - pass - -_mult_10 = lambda n: int(n*10) -_div_10 = lambda n: n/10 - - -MODULE_NAME = 'MqttInverterModule' - -STATUS_TOPIC = 'status' -GENERATION_TOPIC = 'generation' - - -class MqttInverterStatusPayload(MqttPayload): - # 46 bytes - FORMAT = 'IHHHHHHBHHHHHBHHHHHHHH' - - PACKER = { - 'grid_voltage': _mult_10, - 'grid_freq': _mult_10, - 'ac_output_voltage': _mult_10, - 'ac_output_freq': _mult_10, - 'battery_voltage': _mult_10, - 'battery_voltage_scc': _mult_10, - 'battery_voltage_scc2': _mult_10, - 'pv1_input_voltage': _mult_10, - 'pv2_input_voltage': _mult_10 - } - UNPACKER = { - 'grid_voltage': _div_10, - 'grid_freq': _div_10, - 'ac_output_voltage': _div_10, - 'ac_output_freq': _div_10, - 'battery_voltage': _div_10, - 'battery_voltage_scc': _div_10, - 'battery_voltage_scc2': _div_10, - 'pv1_input_voltage': _div_10, - 'pv2_input_voltage': _div_10 - } - - time: int - grid_voltage: float - grid_freq: float - ac_output_voltage: float - ac_output_freq: float - ac_output_apparent_power: int - ac_output_active_power: int - output_load_percent: int - battery_voltage: float - battery_voltage_scc: float - battery_voltage_scc2: float - battery_discharge_current: int - battery_charge_current: int - battery_capacity: int - inverter_heat_sink_temp: int - mppt1_charger_temp: int - mppt2_charger_temp: int - pv1_input_power: int - pv2_input_power: int - pv1_input_voltage: float - pv2_input_voltage: float - - # H - mppt1_charger_status: bit_field(0, 16, 2) - mppt2_charger_status: bit_field(0, 16, 2) - battery_power_direction: bit_field(0, 16, 2) - dc_ac_power_direction: bit_field(0, 16, 2) - line_power_direction: bit_field(0, 16, 2) - load_connected: bit_field(0, 16, 1) - - -class MqttInverterGenerationPayload(MqttPayload): - # 8 bytes - FORMAT = 'II' - - time: int - wh: int - - -class MqttInverterModule(MqttModule): - _status_poll_freq: int - _generation_poll_freq: int - _inverter: Optional[inverterd.Client] - _database: Optional[InverterDatabase] - _gen_prev: float - - def __init__(self, status_poll_freq=0, generation_poll_freq=0): - super().__init__(tick_interval=status_poll_freq) - self._status_poll_freq = status_poll_freq - self._generation_poll_freq = generation_poll_freq - - # this defines whether this is a publisher or a subscriber - if status_poll_freq > 0: - self._inverter = inverterd.Client() - self._inverter.connect() - self._inverter.format(inverterd.Format.SIMPLE_JSON) - self._database = None - else: - self._inverter = None - self._database = InverterDatabase() - - self._gen_prev = 0 - - def on_connect(self, mqtt: MqttNode): - super().on_connect(mqtt) - if not self._inverter: - mqtt.subscribe_module(STATUS_TOPIC, self) - mqtt.subscribe_module(GENERATION_TOPIC, self) - - def tick(self): - if not self._inverter: - return - - # read status - now = time.time() - try: - raw = self._inverter.exec('get-status') - except inverterd.InverterError as e: - self._logger.error(f'inverter error: {str(e)}') - # TODO send to server - return - - data = json.loads(raw)['data'] - status = MqttInverterStatusPayload(time=round(now), **data) - self._mqtt_node_ref.publish(STATUS_TOPIC, status.pack()) - - # read today's generation stat - now = time.time() - if self._gen_prev == 0 or now - self._gen_prev >= self._generation_poll_freq: - self._gen_prev = now - today = datetime.date.today() - try: - raw = self._inverter.exec('get-day-generated', (today.year, today.month, today.day)) - except inverterd.InverterError as e: - self._logger.error(f'inverter error: {str(e)}') - # TODO send to server - return - - data = json.loads(raw)['data'] - gen = MqttInverterGenerationPayload(time=round(now), wh=data['wh']) - self._mqtt_node_ref.publish(GENERATION_TOPIC, gen.pack()) - - def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: - home_id = 1 # legacy compat - - if topic == STATUS_TOPIC: - s = MqttInverterStatusPayload.unpack(payload) - self._database.add_status(home_id=home_id, - client_time=s.time, - grid_voltage=int(s.grid_voltage*10), - grid_freq=int(s.grid_freq * 10), - ac_output_voltage=int(s.ac_output_voltage * 10), - ac_output_freq=int(s.ac_output_freq * 10), - ac_output_apparent_power=s.ac_output_apparent_power, - ac_output_active_power=s.ac_output_active_power, - output_load_percent=s.output_load_percent, - battery_voltage=int(s.battery_voltage * 10), - battery_voltage_scc=int(s.battery_voltage_scc * 10), - battery_voltage_scc2=int(s.battery_voltage_scc2 * 10), - battery_discharge_current=s.battery_discharge_current, - battery_charge_current=s.battery_charge_current, - battery_capacity=s.battery_capacity, - inverter_heat_sink_temp=s.inverter_heat_sink_temp, - mppt1_charger_temp=s.mppt1_charger_temp, - mppt2_charger_temp=s.mppt2_charger_temp, - pv1_input_power=s.pv1_input_power, - pv2_input_power=s.pv2_input_power, - pv1_input_voltage=int(s.pv1_input_voltage * 10), - pv2_input_voltage=int(s.pv2_input_voltage * 10), - mppt1_charger_status=s.mppt1_charger_status, - mppt2_charger_status=s.mppt2_charger_status, - battery_power_direction=s.battery_power_direction, - dc_ac_power_direction=s.dc_ac_power_direction, - line_power_direction=s.line_power_direction, - load_connected=s.load_connected) - return s - - elif topic == GENERATION_TOPIC: - gen = MqttInverterGenerationPayload.unpack(payload) - self._database.add_generation(home_id, gen.time, gen.wh) - return gen diff --git a/src/home/mqtt/module/ota.py b/src/home/mqtt/module/ota.py deleted file mode 100644 index cd34332..0000000 --- a/src/home/mqtt/module/ota.py +++ /dev/null @@ -1,77 +0,0 @@ -import hashlib - -from typing import Optional -from .._payload import MqttPayload -from .._node import MqttModule, MqttNode - -MODULE_NAME = 'MqttOtaModule' - - -class OtaResultPayload(MqttPayload): - FORMAT = '=BB' - result: int - error_code: int - - -class OtaPayload(MqttPayload): - secret: str - filename: str - - # structure of returned data: - # - # uint8_t[len(secret)] secret; - # uint8_t[16] md5; - # *uint8_t data - - def pack(self): - buf = bytearray(self.secret.encode()) - m = hashlib.md5() - with open(self.filename, 'rb') as fd: - content = fd.read() - m.update(content) - buf.extend(m.digest()) - buf.extend(content) - return buf - - def unpack(cls, buf: bytes): - raise RuntimeError(f'{cls.__class__.__name__}.unpack: not implemented') - # secret = buf[:12].decode() - # filename = buf[12:].decode() - # return OTAPayload(secret=secret, filename=filename) - - -class MqttOtaModule(MqttModule): - _ota_request: Optional[tuple[str, int]] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._ota_request = None - - def on_connect(self, mqtt: MqttNode): - super().on_connect(mqtt) - mqtt.subscribe_module("otares", self) - - if self._ota_request is not None: - filename, qos = self._ota_request - self._ota_request = None - self.do_push_ota(self._mqtt_node_ref.secret, filename, qos) - - def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: - if topic == 'otares': - message = OtaResultPayload.unpack(payload) - self._logger.debug(message) - return message - - def do_push_ota(self, secret: str, filename: str, qos: int): - payload = OtaPayload(secret=secret, filename=filename) - self._mqtt_node_ref.publish('ota', - payload=payload.pack(), - qos=qos) - - def push_ota(self, - filename: str, - qos: int): - if not self._initialized: - self._ota_request = (filename, qos) - else: - self.do_push_ota(filename, qos) diff --git a/src/home/mqtt/module/relay.py b/src/home/mqtt/module/relay.py deleted file mode 100644 index e968031..0000000 --- a/src/home/mqtt/module/relay.py +++ /dev/null @@ -1,92 +0,0 @@ -import datetime - -from typing import Optional -from .. import MqttModule, MqttPayload, MqttNode - -MODULE_NAME = 'MqttRelayModule' - - -class MqttPowerSwitchPayload(MqttPayload): - FORMAT = '=12sB' - PACKER = { - 'state': lambda n: int(n), - 'secret': lambda s: s.encode('utf-8') - } - UNPACKER = { - 'state': lambda n: bool(n), - 'secret': lambda s: s.decode('utf-8') - } - - secret: str - state: bool - - -class MqttPowerStatusPayload(MqttPayload): - FORMAT = '=B' - PACKER = { - 'opened': lambda n: int(n), - } - UNPACKER = { - 'opened': lambda n: bool(n), - } - - opened: bool - - -class MqttRelayState: - enabled: bool - update_time: datetime.datetime - rssi: int - fw_version: int - ever_updated: bool - - def __init__(self): - self.ever_updated = False - self.enabled = False - self.rssi = 0 - - def update(self, - enabled: bool, - rssi: int, - fw_version=None): - self.ever_updated = True - self.enabled = enabled - self.rssi = rssi - self.update_time = datetime.datetime.now() - if fw_version: - self.fw_version = fw_version - - -class MqttRelayModule(MqttModule): - _legacy_topics: bool - - def __init__(self, legacy_topics=False, *args, **kwargs): - super().__init__(*args, **kwargs) - self._legacy_topics = legacy_topics - - def on_connect(self, mqtt: MqttNode): - super().on_connect(mqtt) - mqtt.subscribe_module(self._get_switch_topic(), self) - mqtt.subscribe_module('relay/status', self) - - def switchpower(self, - enable: bool): - payload = MqttPowerSwitchPayload(secret=self._mqtt_node_ref.secret, - state=enable) - self._mqtt_node_ref.publish(self._get_switch_topic(), - payload=payload.pack()) - - def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: - message = None - - if topic == self._get_switch_topic(): - message = MqttPowerSwitchPayload.unpack(payload) - elif topic == 'relay/status': - message = MqttPowerStatusPayload.unpack(payload) - - if message is not None: - self._logger.debug(message) - return message - - def _get_switch_topic(self) -> str: - return 'relay/power' if self._legacy_topics else 'relay/switch' diff --git a/src/home/mqtt/module/temphum.py b/src/home/mqtt/module/temphum.py deleted file mode 100644 index fd02cca..0000000 --- a/src/home/mqtt/module/temphum.py +++ /dev/null @@ -1,82 +0,0 @@ -from .._node import MqttNode -from .._module import MqttModule -from .._payload import MqttPayload -from typing import Optional -from ...temphum import BaseSensor - -two_digits_precision = lambda x: round(x, 2) - -MODULE_NAME = 'MqttTempHumModule' -DATA_TOPIC = 'temphum/data' - - -class MqttTemphumDataPayload(MqttPayload): - FORMAT = '=ddb' - UNPACKER = { - 'temp': two_digits_precision, - 'rh': two_digits_precision - } - - temp: float - rh: float - error: int - - -# class MqttTempHumNodes(HashableEnum): -# KBN_SH_HALL = auto() -# KBN_SH_BATHROOM = auto() -# KBN_SH_LIVINGROOM = auto() -# KBN_SH_BEDROOM = auto() -# -# KBN_BH_2FL = auto() -# KBN_BH_2FL_STREET = auto() -# KBN_BH_1FL_LIVINGROOM = auto() -# KBN_BH_1FL_BEDROOM = auto() -# KBN_BH_1FL_BATHROOM = auto() -# -# KBN_NH_1FL_INV = auto() -# KBN_NH_1FL_CENTER = auto() -# KBN_NH_1LF_KT = auto() -# KBN_NH_1FL_DS = auto() -# KBN_NH_1FS_EZ = auto() -# -# SPB_FLAT120_CABINET = auto() - - -class MqttTempHumModule(MqttModule): - def __init__(self, - sensor: Optional[BaseSensor] = None, - write_to_database=False, - *args, **kwargs): - if sensor is not None: - kwargs['tick_interval'] = 10 - super().__init__(*args, **kwargs) - self._sensor = sensor - - def on_connect(self, mqtt: MqttNode): - super().on_connect(mqtt) - mqtt.subscribe_module(DATA_TOPIC, self) - - def tick(self): - if not self._sensor: - return - - error = 0 - temp = 0 - rh = 0 - try: - temp = self._sensor.temperature() - rh = self._sensor.humidity() - except: - error = 1 - pld = MqttTemphumDataPayload(temp=temp, rh=rh, error=error) - self._mqtt_node_ref.publish(DATA_TOPIC, pld.pack()) - - def handle_payload(self, - mqtt: MqttNode, - topic: str, - payload: bytes) -> Optional[MqttPayload]: - if topic == DATA_TOPIC: - message = MqttTemphumDataPayload.unpack(payload) - self._logger.debug(message) - return message diff --git a/src/home/pio/__init__.py b/src/home/pio/__init__.py deleted file mode 100644 index 7216bc4..0000000 --- a/src/home/pio/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .products import get_products, platformio_ini \ No newline at end of file diff --git a/src/home/pio/exceptions.py b/src/home/pio/exceptions.py deleted file mode 100644 index a6afd20..0000000 --- a/src/home/pio/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class ProductConfigNotFoundError(Exception): - pass diff --git a/src/home/pio/products.py b/src/home/pio/products.py deleted file mode 100644 index 388da03..0000000 --- a/src/home/pio/products.py +++ /dev/null @@ -1,113 +0,0 @@ -import os -import logging - -from io import StringIO -from collections import OrderedDict - - -_logger = logging.getLogger(__name__) -_products_dir = os.path.join( - os.path.dirname(__file__), - '..', '..', '..', - 'platformio' -) - - -def get_products(): - products = [] - for f in os.listdir(_products_dir): - if f in ('common',): - continue - - if os.path.isdir(os.path.join(_products_dir, f)): - products.append(f) - - return products - - -def platformio_ini(product_config: dict, - target: str, - # node_id: str, - build_specific_defines: dict, - build_specific_defines_enums: list[str], - platform: str, - framework: str = 'arduino', - upload_port: str = '/dev/ttyUSB0', - monitor_speed: int = 115200, - debug=False, - debug_network=False) -> str: - node_id = build_specific_defines['CONFIG_NODE_ID'] - - # defines - defines = { - **product_config['common_defines'], - 'CONFIG_NODE_ID': node_id, - 'CONFIG_WIFI_AP_SSID': ('HK_'+node_id)[:31] - } - try: - defines.update(product_config['target_defines'][target]) - except KeyError: - pass - defines['CONFIG_NODE_SECRET_SIZE'] = len(defines['CONFIG_NODE_SECRET']) - defines['CONFIG_MQTT_CLIENT_ID'] = node_id - - build_type = 'release' - if debug: - defines['DEBUG'] = True - build_type = 'debug' - if debug_network: - defines['DEBUG'] = True - defines['DEBUG_ESP_SSL'] = True - defines['DEBUG_ESP_PORT'] = 'Serial' - build_type = 'debug' - if build_specific_defines: - for k, v in build_specific_defines.items(): - defines[k] = v - defines = OrderedDict(sorted(defines.items(), key=lambda t: t[0])) - - # libs - libs = [] - if 'common_libs' in product_config: - libs.extend(product_config['common_libs']) - if 'target_libs' in product_config and target in product_config['target_libs']: - libs.extend(product_config['target_libs'][target]) - libs = list(set(libs)) - libs.sort() - - try: - target_real_name = product_config['target_board_names'][target] - except KeyError: - target_real_name = target - - buf = StringIO() - - buf.write('; Generated by pio_ini.py\n\n') - buf.write(f'[env:{target_real_name}]\n') - buf.write(f'platform = {platform}\n') - buf.write(f'board = {target_real_name}\n') - buf.write(f'framework = {framework}\n') - buf.write(f'upload_port = {upload_port}\n') - buf.write(f'monitor_speed = {monitor_speed}\n') - if libs: - buf.write(f'lib_deps =') - for lib in libs: - buf.write(f' {lib}\n') - buf.write(f'build_flags =\n') - if defines: - for name, value in defines.items(): - buf.write(f' -D{name}') - is_enum = name in build_specific_defines_enums - if type(value) is not bool: - buf.write('=') - if type(value) is str: - if not is_enum: - buf.write('"\\"') - value = value.replace('"', '\\"') - buf.write(f'{value}') - if type(value) is str and not is_enum: - buf.write('"\\"') - buf.write('\n') - buf.write(f' -I../common/include') - buf.write(f'\nbuild_type = {build_type}') - - return buf.getvalue() diff --git a/src/home/relay/__init__.py b/src/home/relay/__init__.py deleted file mode 100644 index 406403d..0000000 --- a/src/home/relay/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -import importlib - -__all__ = ['RelayClient', 'RelayServer'] - - -def __getattr__(name): - _map = { - 'RelayClient': '.sunxi_h3_client', - 'RelayServer': '.sunxi_h3_server' - } - - if name in __all__: - module = importlib.import_module(_map[name], __name__) - return getattr(module, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/relay/__init__.pyi b/src/home/relay/__init__.pyi deleted file mode 100644 index 7a4a2f4..0000000 --- a/src/home/relay/__init__.pyi +++ /dev/null @@ -1,2 +0,0 @@ -from .sunxi_h3_client import RelayClient as RelayClient -from .sunxi_h3_server import RelayServer as RelayServer diff --git a/src/home/relay/sunxi_h3_client.py b/src/home/relay/sunxi_h3_client.py deleted file mode 100644 index 8c8d6c4..0000000 --- a/src/home/relay/sunxi_h3_client.py +++ /dev/null @@ -1,39 +0,0 @@ -import socket - - -class RelayClient: - def __init__(self, port=8307, host='127.0.0.1'): - self._host = host - self._port = port - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - - def __del__(self): - self.sock.close() - - def connect(self): - self.sock.connect((self._host, self._port)) - - def _write(self, line): - self.sock.sendall((line+'\r\n').encode()) - - def _read(self): - buf = bytearray() - while True: - buf.extend(self.sock.recv(256)) - if b'\r\n' in buf: - break - - response = buf.decode().strip() - return response - - def on(self): - self._write('on') - return self._read() - - def off(self): - self._write('off') - return self._read() - - def status(self): - self._write('get') - return self._read() diff --git a/src/home/relay/sunxi_h3_server.py b/src/home/relay/sunxi_h3_server.py deleted file mode 100644 index 1f33969..0000000 --- a/src/home/relay/sunxi_h3_server.py +++ /dev/null @@ -1,82 +0,0 @@ -import asyncio -import logging - -from pyA20.gpio import gpio -from pyA20.gpio import port as gpioport -from ..util import Addr - -logger = logging.getLogger(__name__) - - -class RelayServer: - OFF = 1 - ON = 0 - - def __init__(self, - pinname: str, - addr: Addr): - if not hasattr(gpioport, pinname): - raise ValueError(f'invalid pin {pinname}') - - self.pin = getattr(gpioport, pinname) - self.addr = addr - - gpio.init() - gpio.setcfg(self.pin, gpio.OUTPUT) - - self.lock = asyncio.Lock() - - def run(self): - asyncio.run(self.run_server()) - - async def relay_set(self, value): - async with self.lock: - gpio.output(self.pin, value) - - async def relay_get(self): - async with self.lock: - return int(gpio.input(self.pin)) == RelayServer.ON - - async def handle_client(self, reader, writer): - request = None - while request != 'quit': - try: - request = await reader.read(255) - if request == b'\x04': - break - request = request.decode('utf-8').strip() - except Exception: - break - - data = 'unknown' - if request == 'on': - await self.relay_set(RelayServer.ON) - logger.debug('set on') - data = 'ok' - - elif request == 'off': - await self.relay_set(RelayServer.OFF) - logger.debug('set off') - data = 'ok' - - elif request == 'get': - status = await self.relay_get() - data = 'on' if status is True else 'off' - - writer.write((data + '\r\n').encode('utf-8')) - try: - await writer.drain() - except ConnectionError: - break - - try: - writer.close() - except ConnectionError: - pass - - async def run_server(self): - host, port = self.addr - server = await asyncio.start_server(self.handle_client, host, port) - async with server: - logger.info('Server started.') - await server.serve_forever() diff --git a/src/home/soundsensor/__init__.py b/src/home/soundsensor/__init__.py deleted file mode 100644 index 30052f8..0000000 --- a/src/home/soundsensor/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -import importlib - -__all__ = [ - 'SoundSensorNode', - 'SoundSensorHitHandler', - 'SoundSensorServer', - 'SoundSensorServerGuardClient' -] - - -def __getattr__(name): - if name in __all__: - if name == 'SoundSensorNode': - file = 'node' - elif name == 'SoundSensorServerGuardClient': - file = 'server_client' - else: - file = 'server' - module = importlib.import_module(f'.{file}', __name__) - return getattr(module, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/home/soundsensor/__init__.pyi b/src/home/soundsensor/__init__.pyi deleted file mode 100644 index cb34972..0000000 --- a/src/home/soundsensor/__init__.pyi +++ /dev/null @@ -1,8 +0,0 @@ -from .server import ( - SoundSensorHitHandler as SoundSensorHitHandler, - SoundSensorServer as SoundSensorServer, -) -from .server_client import ( - SoundSensorServerGuardClient as SoundSensorServerGuardClient -) -from .node import SoundSensorNode as SoundSensorNode diff --git a/src/home/soundsensor/node.py b/src/home/soundsensor/node.py deleted file mode 100644 index 292452f..0000000 --- a/src/home/soundsensor/node.py +++ /dev/null @@ -1,75 +0,0 @@ -import logging -import threading - -from typing import Optional -from time import sleep -from ..util import stringify, send_datagram, Addr - -from pyA20.gpio import gpio -from pyA20.gpio import port as gpioport - -logger = logging.getLogger(__name__) - - -class SoundSensorNode: - def __init__(self, - name: str, - pinname: str, - server_addr: Optional[Addr], - threshold: int = 1, - delay=0.005): - - if not hasattr(gpioport, pinname): - raise ValueError(f'invalid pin {pinname}') - - self.pin = getattr(gpioport, pinname) - self.name = name - self.delay = delay - self.threshold = threshold - - self.server_addr = server_addr - - self.hits = 0 - self.hitlock = threading.Lock() - - self.interrupted = False - - def run(self): - try: - t = threading.Thread(target=self.sensor_reader) - t.daemon = True - t.start() - - while True: - with self.hitlock: - hits = self.hits - self.hits = 0 - - if hits >= self.threshold: - try: - if self.server_addr is not None: - send_datagram(stringify([self.name, hits]), self.server_addr) - else: - logger.debug(f'server reporting disabled, skipping reporting {hits} hits') - except OSError as exc: - logger.exception(exc) - - sleep(1) - - except (KeyboardInterrupt, SystemExit) as e: - self.interrupted = True - logger.info(str(e)) - - def sensor_reader(self): - gpio.init() - gpio.setcfg(self.pin, gpio.INPUT) - gpio.pullup(self.pin, gpio.PULLUP) - - while not self.interrupted: - state = gpio.input(self.pin) - sleep(self.delay) - - if not state: - with self.hitlock: - logger.debug('got a hit') - self.hits += 1 diff --git a/src/home/soundsensor/server.py b/src/home/soundsensor/server.py deleted file mode 100644 index a627390..0000000 --- a/src/home/soundsensor/server.py +++ /dev/null @@ -1,128 +0,0 @@ -import asyncio -import json -import logging -import threading - -from ..database.sqlite import SQLiteBase -from ..config import config -from .. import http - -from typing import Type -from ..util import Addr - -logger = logging.getLogger(__name__) - - -class SoundSensorHitHandler(asyncio.DatagramProtocol): - def datagram_received(self, data, addr): - try: - data = json.loads(data) - except json.JSONDecodeError as e: - logger.error('failed to parse json datagram') - logger.exception(e) - return - - try: - name, hits = data - except (ValueError, IndexError) as e: - logger.error('failed to unpack data') - logger.exception(e) - return - - self.handler(name, hits) - - def handler(self, name: str, hits: int): - pass - - -class Database(SQLiteBase): - SCHEMA = 1 - - def __init__(self): - super().__init__(dbname='sound_sensor_server') - - def schema_init(self, version: int) -> None: - cursor = self.cursor() - - if version < 1: - cursor.execute("CREATE TABLE IF NOT EXISTS status (guard_enabled INTEGER NOT NULL)") - cursor.execute("INSERT INTO status (guard_enabled) VALUES (-1)") - - self.commit() - - def get_guard_enabled(self) -> int: - cur = self.cursor() - cur.execute("SELECT guard_enabled FROM status LIMIT 1") - return int(cur.fetchone()[0]) - - def set_guard_enabled(self, enabled: bool) -> None: - cur = self.cursor() - cur.execute("UPDATE status SET guard_enabled=?", (int(enabled),)) - self.commit() - - -class SoundSensorServer: - def __init__(self, - addr: Addr, - handler_impl: Type[SoundSensorHitHandler]): - self.addr = addr - self.impl = handler_impl - self.db = Database() - - self._recording_lock = threading.Lock() - self._recording_enabled = True - - if self.guard_control_enabled(): - current_status = self.db.get_guard_enabled() - if current_status == -1: - self.set_recording(config['server']['guard_recording_default'] - if 'guard_recording_default' in config['server'] - else False, - update=False) - else: - self.set_recording(bool(current_status), update=False) - - @staticmethod - def guard_control_enabled() -> bool: - return 'guard_control' in config['server'] and config['server']['guard_control'] is True - - def set_recording(self, enabled: bool, update=True): - with self._recording_lock: - self._recording_enabled = enabled - if update: - self.db.set_guard_enabled(enabled) - - def is_recording_enabled(self) -> bool: - with self._recording_lock: - return self._recording_enabled - - def run(self): - if self.guard_control_enabled(): - t = threading.Thread(target=self.run_guard_server) - t.daemon = True - t.start() - - loop = asyncio.get_event_loop() - t = loop.create_datagram_endpoint(self.impl, local_addr=self.addr) - loop.run_until_complete(t) - loop.run_forever() - - def run_guard_server(self): - routes = http.routes() - - @routes.post('/guard/enable') - async def guard_enable(request): - self.set_recording(True) - return http.ok() - - @routes.post('/guard/disable') - async def guard_disable(request): - self.set_recording(False) - return http.ok() - - @routes.get('/guard/status') - async def guard_status(request): - return http.ok({'enabled': self.is_recording_enabled()}) - - asyncio.set_event_loop(asyncio.new_event_loop()) # need to create new event loop in new thread - http.serve(self.addr, routes, handle_signals=False) # handle_signals=True doesn't work in separate thread diff --git a/src/home/soundsensor/server_client.py b/src/home/soundsensor/server_client.py deleted file mode 100644 index 7eef996..0000000 --- a/src/home/soundsensor/server_client.py +++ /dev/null @@ -1,38 +0,0 @@ -import requests -import logging - -from ..util import Addr -from ..api.errors import ApiResponseError - - -class SoundSensorServerGuardClient: - def __init__(self, addr: Addr): - self.endpoint = f'http://{addr[0]}:{addr[1]}' - self.logger = logging.getLogger(self.__class__.__name__) - - def guard_enable(self): - return self._call('guard/enable', is_post=True) - - def guard_disable(self): - return self._call('guard/disable', is_post=True) - - def guard_status(self): - return self._call('guard/status') - - def _call(self, - method: str, - is_post=False): - - url = f'{self.endpoint}/{method}' - self.logger.debug(f'calling {url}') - - r = requests.get(url) if not is_post else requests.post(url) - - if r.status_code != 200: - response = r.json() - raise ApiResponseError(status_code=r.status_code, - error_type=response['error'], - error_message=response['message'] or None, - error_stacktrace=response['stacktrace'] if 'stacktrace' in response else None) - - return r.json()['response'] diff --git a/src/home/telegram/__init__.py b/src/home/telegram/__init__.py deleted file mode 100644 index a68dae1..0000000 --- a/src/home/telegram/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .telegram import send_message, send_photo diff --git a/src/home/telegram/_botcontext.py b/src/home/telegram/_botcontext.py deleted file mode 100644 index a143bfe..0000000 --- a/src/home/telegram/_botcontext.py +++ /dev/null @@ -1,86 +0,0 @@ -from typing import Optional, List - -from telegram import Update, User, CallbackQuery -from telegram.constants import ParseMode -from telegram.ext import CallbackContext - -from ._botdb import BotDatabase -from ._botlang import lang -from ._botutil import IgnoreMarkup, exc2text - - -class Context: - _update: Optional[Update] - _callback_context: Optional[CallbackContext] - _markup_getter: callable - db: Optional[BotDatabase] - _user_lang: Optional[str] - - def __init__(self, - update: Optional[Update], - callback_context: Optional[CallbackContext], - markup_getter: callable, - store: Optional[BotDatabase]): - self._update = update - self._callback_context = callback_context - self._markup_getter = markup_getter - self._store = store - self._user_lang = None - - async def reply(self, text, markup=None): - if markup is None: - markup = self._markup_getter(self) - kwargs = dict(parse_mode=ParseMode.HTML) - if not isinstance(markup, IgnoreMarkup): - kwargs['reply_markup'] = markup - return await self._update.message.reply_text(text, **kwargs) - - async def reply_exc(self, e: Exception) -> None: - await self.reply(exc2text(e), markup=IgnoreMarkup()) - - async def answer(self, text: str = None): - await self.callback_query.answer(text) - - async def edit(self, text, markup=None): - kwargs = dict(parse_mode=ParseMode.HTML) - if not isinstance(markup, IgnoreMarkup): - kwargs['reply_markup'] = markup - await self.callback_query.edit_message_text(text, **kwargs) - - @property - def text(self) -> str: - return self._update.message.text - - @property - def callback_query(self) -> CallbackQuery: - return self._update.callback_query - - @property - def args(self) -> Optional[List[str]]: - return self._callback_context.args - - @property - def user_id(self) -> int: - return self.user.id - - @property - def user_data(self): - return self._callback_context.user_data - - @property - def user(self) -> User: - return self._update.effective_user - - @property - def user_lang(self) -> str: - if self._user_lang is None: - self._user_lang = self._store.get_user_lang(self.user_id) - return self._user_lang - - def lang(self, key: str, *args) -> str: - return lang.get(key, self.user_lang, *args) - - def is_callback_context(self) -> bool: - return self._update.callback_query \ - and self._update.callback_query.data \ - and self._update.callback_query.data != '' diff --git a/src/home/telegram/_botdb.py b/src/home/telegram/_botdb.py deleted file mode 100644 index 9e9cf94..0000000 --- a/src/home/telegram/_botdb.py +++ /dev/null @@ -1,32 +0,0 @@ -from home.database.sqlite import SQLiteBase - - -class BotDatabase(SQLiteBase): - def __init__(self): - super().__init__() - - def schema_init(self, version: int) -> None: - if version < 1: - cursor = self.cursor() - cursor.execute("""CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY, - lang TEXT NOT NULL - )""") - self.commit() - - def get_user_lang(self, user_id: int, default: str = 'en') -> str: - cursor = self.cursor() - cursor.execute('SELECT lang FROM users WHERE id=?', (user_id,)) - row = cursor.fetchone() - - if row is None: - cursor.execute('INSERT INTO users (id, lang) VALUES (?, ?)', (user_id, default)) - self.commit() - return default - else: - return row[0] - - def set_user_lang(self, user_id: int, lang: str) -> None: - cursor = self.cursor() - cursor.execute('UPDATE users SET lang=? WHERE id=?', (lang, user_id)) - self.commit() diff --git a/src/home/telegram/_botlang.py b/src/home/telegram/_botlang.py deleted file mode 100644 index f5f85bb..0000000 --- a/src/home/telegram/_botlang.py +++ /dev/null @@ -1,120 +0,0 @@ -import logging - -from typing import Optional, Dict, List, Union - -_logger = logging.getLogger(__name__) - - -class LangStrings(dict): - _lang: Optional[str] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._lang = None - - def setlang(self, lang: str): - self._lang = lang - - def __missing__(self, key): - _logger.warning(f'key {key} is missing in language {self._lang}') - return '{%s}' % key - - def __setitem__(self, key, value): - raise NotImplementedError(f'setting translation strings this way is prohibited (was trying to set {key}={value})') - - -class LangPack: - strings: Dict[str, LangStrings[str, str]] - default_lang: str - - def __init__(self): - self.strings = {} - self.default_lang = 'en' - - def ru(self, **kwargs) -> None: - self.set(kwargs, 'ru') - - def en(self, **kwargs) -> None: - self.set(kwargs, 'en') - - def set(self, - strings: Union[LangStrings, dict], - lang: str) -> None: - - if isinstance(strings, dict) and not isinstance(strings, LangStrings): - strings = LangStrings(**strings) - strings.setlang(lang) - - if lang not in self.strings: - self.strings[lang] = strings - else: - self.strings[lang].update(strings) - - def all(self, key): - result = [] - for strings in self.strings.values(): - result.append(strings[key]) - return result - - @property - def languages(self) -> List[str]: - return list(self.strings.keys()) - - def get(self, key: str, lang: str, *args) -> str: - if args: - return self.strings[lang][key] % args - else: - return self.strings[lang][key] - - def get_langpack(self, _lang: str) -> dict: - return self.strings[_lang] - - def __call__(self, *args, **kwargs): - return self.strings[self.default_lang][args[0]] - - def __getitem__(self, key): - return self.strings[self.default_lang][key] - - def __setitem__(self, key, value): - raise NotImplementedError('setting translation strings this way is prohibited') - - def __contains__(self, key): - return key in self.strings[self.default_lang] - - @staticmethod - def pfx(prefix: str, l: list) -> list: - return list(map(lambda s: f'{prefix}{s}', l)) - - - -languages = { - 'en': 'English', - 'ru': 'Русский' -} - - -lang = LangPack() -lang.en( - en='English', - ru='Russian', - start_message="Select command on the keyboard.", - unknown_message="Unknown message", - cancel="🚫 Cancel", - back='🔙 Back', - select_language="Select language on the keyboard.", - invalid_language="Invalid language. Please try again.", - saved='Saved.', - please_wait="⏳ Please wait..." -) -lang.ru( - en='Английский', - ru='Русский', - start_message="Выберите команду на клавиатуре.", - unknown_message="Неизвестная команда", - cancel="🚫 Отмена", - back='🔙 Назад', - select_language="Выберите язык на клавиатуре.", - invalid_language="Неверный язык. Пожалуйста, попробуйте снова", - saved="Настройки сохранены.", - please_wait="⏳ Ожидайте..." -) \ No newline at end of file diff --git a/src/home/telegram/_botutil.py b/src/home/telegram/_botutil.py deleted file mode 100644 index b551a55..0000000 --- a/src/home/telegram/_botutil.py +++ /dev/null @@ -1,47 +0,0 @@ -import logging -import traceback - -from html import escape -from telegram import User -from home.api import WebApiClient as APIClient -from home.api.types import BotType -from home.api.errors import ApiResponseError - -_logger = logging.getLogger(__name__) - - -def user_any_name(user: User) -> str: - name = [user.first_name, user.last_name] - name = list(filter(lambda s: s is not None, name)) - name = ' '.join(name).strip() - - if not name: - name = user.username - - if not name: - name = str(user.id) - - return name - - -class ReportingHelper: - def __init__(self, client: APIClient, bot_type: BotType): - self.client = client - self.bot_type = bot_type - - def report(self, message, text: str = None) -> None: - if text is None: - text = message.text - try: - self.client.log_bot_request(self.bot_type, message.chat_id, text) - except ApiResponseError as error: - _logger.exception(error) - - -def exc2text(e: Exception) -> str: - tb = ''.join(traceback.format_tb(e.__traceback__)) - return f'{e.__class__.__name__}: ' + escape(str(e)) + "\n\n" + escape(tb) - - -class IgnoreMarkup: - pass diff --git a/src/home/telegram/aio.py b/src/home/telegram/aio.py deleted file mode 100644 index fc87c1c..0000000 --- a/src/home/telegram/aio.py +++ /dev/null @@ -1,18 +0,0 @@ -import functools -import asyncio - -from .telegram import ( - send_message as _send_message_sync, - send_photo as _send_photo_sync -) - - -async def send_message(*args, **kwargs): - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, functools.partial(_send_message_sync, *args, **kwargs)) - - -async def send_photo(*args, **kwargs): - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, functools.partial(_send_photo_sync, *args, **kwargs)) - diff --git a/src/home/telegram/bot.py b/src/home/telegram/bot.py deleted file mode 100644 index e6ebc6e..0000000 --- a/src/home/telegram/bot.py +++ /dev/null @@ -1,583 +0,0 @@ -from __future__ import annotations - -import logging -import itertools - -from enum import Enum, auto -from functools import wraps -from typing import Optional, Union, Tuple, Coroutine - -from telegram import Update, ReplyKeyboardMarkup -from telegram.ext import ( - Application, - filters, - CommandHandler, - MessageHandler, - CallbackQueryHandler, - CallbackContext, - ConversationHandler -) -from telegram.ext.filters import BaseFilter -from telegram.error import TimedOut - -from home.config import config -from home.api import WebApiClient -from home.api.types import BotType - -from ._botlang import lang, languages -from ._botdb import BotDatabase -from ._botutil import ReportingHelper, exc2text, IgnoreMarkup, user_any_name -from ._botcontext import Context - - -db: Optional[BotDatabase] = None - -_user_filter: Optional[BaseFilter] = None -_cancel_filter = filters.Text(lang.all('cancel')) -_back_filter = filters.Text(lang.all('back')) -_cancel_and_back_filter = filters.Text(lang.all('back') + lang.all('cancel')) - -_logger = logging.getLogger(__name__) -_application: Optional[Application] = None -_reporting: Optional[ReportingHelper] = None -_exception_handler: Optional[Coroutine] = None -_dispatcher = None -_markup_getter: Optional[callable] = None -_start_handler_ref: Optional[Coroutine] = None - - -def text_filter(*args): - if not _user_filter: - raise RuntimeError('user_filter is not initialized') - return filters.Text(args[0] if isinstance(args[0], list) else [*args]) & _user_filter - - -async def _handler_of_handler(*args, **kwargs): - self = None - context = None - update = None - - _args = list(args) - while len(_args): - v = _args[0] - if isinstance(v, conversation): - self = v - _args.pop(0) - elif isinstance(v, Update): - update = v - _args.pop(0) - elif isinstance(v, CallbackContext): - context = v - _args.pop(0) - break - - ctx = Context(update, - callback_context=context, - markup_getter=lambda _ctx: None if not _markup_getter else _markup_getter(_ctx), - store=db) - try: - _args.insert(0, ctx) - - f = kwargs['f'] - del kwargs['f'] - - if 'return_with_context' in kwargs: - return_with_context = True - del kwargs['return_with_context'] - else: - return_with_context = False - - if 'argument' in kwargs and kwargs['argument'] == 'message_key': - del kwargs['argument'] - mkey = None - for k, v in lang.get_langpack(ctx.user_lang).items(): - if ctx.text == v: - mkey = k - break - _args.insert(0, mkey) - - if self: - _args.insert(0, self) - - result = await f(*_args, **kwargs) - return result if not return_with_context else (result, ctx) - - except Exception as e: - if _exception_handler: - if not _exception_handler(e, ctx) and not isinstance(e, TimedOut): - _logger.exception(e) - if not ctx.is_callback_context(): - await ctx.reply_exc(e) - else: - notify_user(ctx.user_id, exc2text(e)) - else: - _logger.exception(e) - - -def handler(**kwargs): - def inner(f): - @wraps(f) - async def _handler(*args, **inner_kwargs): - if 'argument' in kwargs and kwargs['argument'] == 'message_key': - inner_kwargs['argument'] = 'message_key' - return await _handler_of_handler(f=f, *args, **inner_kwargs) - - messages = [] - texts = [] - - if 'messages' in kwargs: - messages += kwargs['messages'] - if 'message' in kwargs: - messages.append(kwargs['message']) - - if 'text' in kwargs: - texts.append(kwargs['text']) - if 'texts' in kwargs: - texts += kwargs['texts'] - - if messages or texts: - new_messages = list(itertools.chain.from_iterable([lang.all(m) for m in messages])) - texts += new_messages - texts = list(set(texts)) - _application.add_handler( - MessageHandler(text_filter(*texts), _handler), - group=0 - ) - - if 'command' in kwargs: - _application.add_handler(CommandHandler(kwargs['command'], _handler), group=0) - - if 'callback' in kwargs: - _application.add_handler(CallbackQueryHandler(_handler, pattern=kwargs['callback']), group=0) - - return _handler - - return inner - - -def simplehandler(f: Coroutine): - @wraps(f) - async def _handler(*args, **kwargs): - return await _handler_of_handler(f=f, *args, **kwargs) - return _handler - - -def callbackhandler(*args, **kwargs): - def inner(f): - @wraps(f) - async def _handler(*args, **kwargs): - return await _handler_of_handler(f=f, *args, **kwargs) - pattern_kwargs = {} - if kwargs['callback'] != '*': - pattern_kwargs['pattern'] = kwargs['callback'] - _application.add_handler(CallbackQueryHandler(_handler, **pattern_kwargs), group=0) - return _handler - return inner - - -async def exceptionhandler(f: callable): - global _exception_handler - if _exception_handler: - _logger.warning('exception handler already set, we will overwrite it') - _exception_handler = f - - -def defaultreplymarkup(f: callable): - global _markup_getter - _markup_getter = f - - -def convinput(state, is_enter=False, **kwargs): - def inner(f): - f.__dict__['_conv_data'] = dict( - orig_f=f, - enter=is_enter, - type=ConversationMethodType.ENTRY if is_enter and state == 0 else ConversationMethodType.STATE_HANDLER, - state=state, - **kwargs - ) - - @wraps(f) - async def _impl(*args, **kwargs): - result, ctx = await _handler_of_handler(f=f, *args, **kwargs, return_with_context=True) - if result == conversation.END: - await start(ctx) - return result - - return _impl - - return inner - - -def conventer(state, **kwargs): - return convinput(state, is_enter=True, **kwargs) - - -class ConversationMethodType(Enum): - ENTRY = auto() - STATE_HANDLER = auto() - - -class conversation: - END = ConversationHandler.END - STATE_SEQS = [] - - def __init__(self, enable_back=False): - self._logger = logging.getLogger(self.__class__.__name__) - self._user_state_cache = {} - self._back_enabled = enable_back - - def make_handlers(self, f: callable, **kwargs) -> list: - messages = {} - handlers = [] - - if 'messages' in kwargs: - if isinstance(kwargs['messages'], dict): - messages = kwargs['messages'] - else: - for m in kwargs['messages']: - messages[m] = None - - if 'message' in kwargs: - if isinstance(kwargs['message'], str): - messages[kwargs['message']] = None - else: - AttributeError('invalid message type: ' + type(kwargs['message'])) - - if messages: - for message, target_state in messages.items(): - if not target_state: - handlers.append(MessageHandler(text_filter(lang.all(message) if 'messages_lang_completed' not in kwargs else message), f)) - else: - handlers.append(MessageHandler(text_filter(lang.all(message) if 'messages_lang_completed' not in kwargs else message), self.make_invoker(target_state))) - - if 'regex' in kwargs: - handlers.append(MessageHandler(filters.Regex(kwargs['regex']) & _user_filter, f)) - - if 'command' in kwargs: - handlers.append(CommandHandler(kwargs['command'], f, _user_filter)) - - return handlers - - def make_invoker(self, state): - def _invoke(update: Update, context: CallbackContext): - ctx = Context(update, - callback_context=context, - markup_getter=lambda _ctx: None if not _markup_getter else _markup_getter(_ctx), - store=db) - return self.invoke(state, ctx) - return _invoke - - def invoke(self, state, ctx: Context): - self._logger.debug(f'invoke, state={state}') - for item in dir(self): - f = getattr(self, item) - if not callable(f) or item.startswith('_') or '_conv_data' not in f.__dict__: - continue - cd = f.__dict__['_conv_data'] - if cd['enter'] and cd['state'] == state: - return cd['orig_f'](self, ctx) - - raise RuntimeError(f'invoke: failed to find method for state {state}') - - def get_handler(self) -> ConversationHandler: - entry_points = [] - states = {} - - l_cancel_filter = _cancel_filter if not self._back_enabled else _cancel_and_back_filter - - for item in dir(self): - f = getattr(self, item) - if not callable(f) or item.startswith('_') or '_conv_data' not in f.__dict__: - continue - - cd = f.__dict__['_conv_data'] - - if cd['type'] == ConversationMethodType.ENTRY: - entry_points = self.make_handlers(f, **cd) - elif cd['type'] == ConversationMethodType.STATE_HANDLER: - states[cd['state']] = self.make_handlers(f, **cd) - states[cd['state']].append( - MessageHandler(_user_filter & ~l_cancel_filter, conversation.invalid) - ) - - fallbacks = [MessageHandler(_user_filter & _cancel_filter, self.cancel)] - if self._back_enabled: - fallbacks.append(MessageHandler(_user_filter & _back_filter, self.back)) - - return ConversationHandler( - entry_points=entry_points, - states=states, - fallbacks=fallbacks - ) - - def get_user_state(self, user_id: int) -> Optional[int]: - if user_id not in self._user_state_cache: - return None - return self._user_state_cache[user_id] - - # TODO store in ctx.user_state - def set_user_state(self, user_id: int, state: Union[int, None]): - if not self._back_enabled: - return - if state is not None: - self._user_state_cache[user_id] = state - else: - del self._user_state_cache[user_id] - - @staticmethod - @simplehandler - async def invalid(ctx: Context): - await ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup()) - # return 0 # FIXME is this needed - - @simplehandler - async def cancel(self, ctx: Context): - await start(ctx) - self.set_user_state(ctx.user_id, None) - return conversation.END - - @simplehandler - async def back(self, ctx: Context): - cur_state = self.get_user_state(ctx.user_id) - if cur_state is None: - await start(ctx) - self.set_user_state(ctx.user_id, None) - return conversation.END - - new_state = None - for seq in self.STATE_SEQS: - if cur_state in seq: - idx = seq.index(cur_state) - if idx > 0: - return self.invoke(seq[idx-1], ctx) - - if new_state is None: - raise RuntimeError('failed to determine state to go back to') - - @classmethod - def add_cancel_button(cls, ctx: Context, buttons): - buttons.append([ctx.lang('cancel')]) - - @classmethod - def add_back_button(cls, ctx: Context, buttons): - # buttons.insert(0, [ctx.lang('back')]) - buttons.append([ctx.lang('back')]) - - def reply(self, - ctx: Context, - state: Union[int, Enum], - text: str, - buttons: Optional[list], - with_cancel=False, - with_back=False, - buttons_lang_completed=False): - - if buttons: - new_buttons = [] - if not buttons_lang_completed: - for item in buttons: - if isinstance(item, list): - item = map(lambda s: ctx.lang(s), item) - new_buttons.append(list(item)) - elif isinstance(item, str): - new_buttons.append([ctx.lang(item)]) - else: - raise ValueError('invalid type: ' + type(item)) - else: - new_buttons = list(buttons) - - buttons = None - else: - if with_cancel or with_back: - new_buttons = [] - else: - new_buttons = None - - if with_cancel: - self.add_cancel_button(ctx, new_buttons) - if with_back: - if not self._back_enabled: - raise AttributeError(f'back is not enabled for this conversation ({self.__class__.__name__})') - self.add_back_button(ctx, new_buttons) - - markup = ReplyKeyboardMarkup(new_buttons, one_time_keyboard=True) if new_buttons else IgnoreMarkup() - ctx.reply(text, markup=markup) - self.set_user_state(ctx.user_id, state) - return state - - -class LangConversation(conversation): - START, = range(1) - - @conventer(START, command='lang') - async def entry(self, ctx: Context): - self._logger.debug(f'current language: {ctx.user_lang}') - - buttons = [] - for name in languages.values(): - buttons.append(name) - markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False) - - await ctx.reply(ctx.lang('select_language'), markup=markup) - return self.START - - @convinput(START, messages=lang.languages) - async def input(self, ctx: Context): - selected_lang = None - for key, value in languages.items(): - if value == ctx.text: - selected_lang = key - break - - if selected_lang is None: - raise ValueError('could not find the language') - - db.set_user_lang(ctx.user_id, selected_lang) - await ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup()) - - return self.END - - -def initialize(): - global _user_filter - global _application - # global _updater - global _dispatcher - - # init user_filter - _user_ids = config.app_config.get_user_ids() - if len(_user_ids) > 0: - _logger.info('allowed users: ' + str(_user_ids)) - _user_filter = filters.User(_user_ids) - else: - _user_filter = filters.ALL # not sure if this is correct - - _application = Application.builder()\ - .token(config.app_config.get('bot.token'))\ - .connect_timeout(7)\ - .read_timeout(6)\ - .build() - - # transparently log all messages - # _application.dispatcher.add_handler(MessageHandler(filters.ALL & _user_filter, _logging_message_handler), group=10) - # _application.dispatcher.add_handler(CallbackQueryHandler(_logging_callback_handler), group=10) - - -def run(start_handler=None, any_handler=None): - global db - global _start_handler_ref - - if not start_handler: - start_handler = _default_start_handler - if not any_handler: - any_handler = _default_any_handler - if not db: - db = BotDatabase() - - _start_handler_ref = start_handler - - _application.add_handler(LangConversation().get_handler(), group=0) - _application.add_handler(CommandHandler('start', - callback=simplehandler(start_handler), - filters=_user_filter)) - _application.add_handler(MessageHandler(filters.ALL & _user_filter, any_handler)) - - _application.run_polling() - - -def add_conversation(conv: conversation) -> None: - _application.add_handler(conv.get_handler(), group=0) - - -def add_handler(h): - _application.add_handler(h, group=0) - - -async def start(ctx: Context): - return await _start_handler_ref(ctx) - - -async def _default_start_handler(ctx: Context): - if 'start_message' not in lang: - return await ctx.reply('Please define start_message or override start()') - await ctx.reply(ctx.lang('start_message')) - - -@simplehandler -async def _default_any_handler(ctx: Context): - if 'invalid_command' not in lang: - return await ctx.reply('Please define invalid_command or override any()') - await ctx.reply(ctx.lang('invalid_command')) - - -def _logging_message_handler(update: Update, context: CallbackContext): - if _reporting: - _reporting.report(update.message) - - -def _logging_callback_handler(update: Update, context: CallbackContext): - if _reporting: - _reporting.report(update.callback_query.message, text=update.callback_query.data) - - -def enable_logging(bot_type: BotType): - api = WebApiClient(timeout=3) - api.enable_async() - - global _reporting - _reporting = ReportingHelper(api, bot_type) - - -def notify_all(text_getter: callable, - exclude: Tuple[int] = ()) -> None: - if 'notify_users' not in config['bot']: - _logger.error('notify_all() called but no notify_users directive found in the config') - return - - for user_id in config['bot']['notify_users']: - if user_id in exclude: - continue - - text = text_getter(db.get_user_lang(user_id)) - _application.bot.send_message(chat_id=user_id, - text=text, - parse_mode='HTML') - - -def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> None: - if isinstance(text, Exception): - text = exc2text(text) - _application.bot.send_message(chat_id=user_id, - text=text, - parse_mode='HTML', - **kwargs) - - -def send_photo(user_id, **kwargs): - _application.bot.send_photo(chat_id=user_id, **kwargs) - - -def send_audio(user_id, **kwargs): - _application.bot.send_audio(chat_id=user_id, **kwargs) - - -def send_file(user_id, **kwargs): - _application.bot.send_document(chat_id=user_id, **kwargs) - - -def edit_message_text(user_id, message_id, *args, **kwargs): - _application.bot.edit_message_text(chat_id=user_id, - message_id=message_id, - parse_mode='HTML', - *args, **kwargs) - - -def delete_message(user_id, message_id): - _application.bot.delete_message(chat_id=user_id, message_id=message_id) - - -def set_database(_db: BotDatabase): - global db - db = _db - diff --git a/src/home/telegram/config.py b/src/home/telegram/config.py deleted file mode 100644 index 4c7d74b..0000000 --- a/src/home/telegram/config.py +++ /dev/null @@ -1,75 +0,0 @@ -from ..config import ConfigUnit -from typing import Optional, Union -from abc import ABC -from enum import Enum - - -class TelegramUserListType(Enum): - USERS = 'users' - NOTIFY = 'notify_users' - - -class TelegramUserIdsConfig(ConfigUnit): - NAME = 'telegram_user_ids' - - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'roottype': 'dict', - 'type': 'integer' - } - - -_user_ids_config = TelegramUserIdsConfig() - - -def _user_id_mapper(user: Union[str, int]) -> int: - if isinstance(user, int): - return user - return _user_ids_config[user] - - -class TelegramChatsConfig(ConfigUnit): - NAME = 'telegram_chats' - - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'type': 'dict', - 'schema': { - 'id': {'type': 'string', 'required': True}, - 'token': {'type': 'string', 'required': True}, - } - } - - -class TelegramBotConfig(ConfigUnit, ABC): - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'bot': { - 'type': 'dict', - 'schema': { - 'token': {'type': 'string', 'required': True}, - TelegramUserListType.USERS: {**TelegramBotConfig._userlist_schema(), 'required': True}, - TelegramUserListType.NOTIFY: TelegramBotConfig._userlist_schema(), - } - } - } - - @staticmethod - def _userlist_schema() -> dict: - return {'type': 'list', 'schema': {'type': ['string', 'int']}} - - @staticmethod - def custom_validator(data): - for ult in TelegramUserListType: - users = data['bot'][ult.value] - for user in users: - if isinstance(user, str): - if user not in _user_ids_config: - raise ValueError(f'user {user} not found in {TelegramUserIdsConfig.NAME}') - - def get_user_ids(self, - ult: TelegramUserListType = TelegramUserListType.USERS) -> list[int]: - return list(map(_user_id_mapper, self['bot'][ult.value])) \ No newline at end of file diff --git a/src/home/telegram/telegram.py b/src/home/telegram/telegram.py deleted file mode 100644 index f42363e..0000000 --- a/src/home/telegram/telegram.py +++ /dev/null @@ -1,49 +0,0 @@ -import requests -import logging - -from typing import Tuple -from .config import TelegramChatsConfig - -_chats = TelegramChatsConfig() -_logger = logging.getLogger(__name__) - - -def send_message(text: str, - chat: str, - parse_mode: str = 'HTML', - disable_web_page_preview: bool = False,): - data, token = _send_telegram_data(text, chat, parse_mode, disable_web_page_preview) - req = requests.post('https://api.telegram.org/bot%s/sendMessage' % token, data=data) - return req.json() - - -def send_photo(filename: str, chat: str): - chat_data = _chats[chat] - data = { - 'chat_id': chat_data['id'], - } - token = chat_data['token'] - - url = f'https://api.telegram.org/bot{token}/sendPhoto' - with open(filename, "rb") as fd: - req = requests.post(url, data=data, files={"photo": fd}) - return req.json() - - -def _send_telegram_data(text: str, - chat: str, - parse_mode: str = None, - disable_web_page_preview: bool = False) -> Tuple[dict, str]: - chat_data = _chats[chat] - data = { - 'chat_id': chat_data['id'], - 'text': text - } - - if parse_mode is not None: - data['parse_mode'] = parse_mode - - if disable_web_page_preview: - data['disable_web_page_preview'] = 1 - - return data, chat_data['token'] diff --git a/src/home/temphum/__init__.py b/src/home/temphum/__init__.py deleted file mode 100644 index 46d14e6..0000000 --- a/src/home/temphum/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .base import SensorType, BaseSensor diff --git a/src/home/temphum/base.py b/src/home/temphum/base.py deleted file mode 100644 index 602cab7..0000000 --- a/src/home/temphum/base.py +++ /dev/null @@ -1,19 +0,0 @@ -from abc import ABC -from enum import Enum - - -class BaseSensor(ABC): - def __init__(self, bus: int): - super().__init__() - self.bus = smbus.SMBus(bus) - - def humidity(self) -> float: - pass - - def temperature(self) -> float: - pass - - -class SensorType(Enum): - Si7021 = 'si7021' - DHT12 = 'dht12' \ No newline at end of file diff --git a/src/home/temphum/i2c.py b/src/home/temphum/i2c.py deleted file mode 100644 index 7d8e2e3..0000000 --- a/src/home/temphum/i2c.py +++ /dev/null @@ -1,52 +0,0 @@ -import abc -import smbus - -from .base import BaseSensor, SensorType - - -class I2CSensor(BaseSensor, abc.ABC): - def __init__(self, bus: int): - super().__init__() - self.bus = smbus.SMBus(bus) - - -class DHT12(I2CSensor): - i2c_addr = 0x5C - - def _measure(self): - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0, 5) - if (raw[0] + raw[1] + raw[2] + raw[3]) & 0xff != raw[4]: - raise ValueError("checksum error") - return raw - - def temperature(self) -> float: - raw = self._measure() - temp = raw[2] + (raw[3] & 0x7f) * 0.1 - if raw[3] & 0x80: - temp *= -1 - return temp - - def humidity(self) -> float: - raw = self._measure() - return raw[0] + raw[1] * 0.1 - - -class Si7021(I2CSensor): - i2c_addr = 0x40 - - def temperature(self) -> float: - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE3, 2) - return 175.72 * (raw[0] << 8 | raw[1]) / 65536.0 - 46.85 - - def humidity(self) -> float: - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE5, 2) - return 125.0 * (raw[0] << 8 | raw[1]) / 65536.0 - 6.0 - - -def create_sensor(type: SensorType, bus: int) -> BaseSensor: - if type == SensorType.Si7021: - return Si7021(bus) - elif type == SensorType.DHT12: - return DHT12(bus) - else: - raise ValueError('unexpected sensor type') diff --git a/src/home/util.py b/src/home/util.py deleted file mode 100644 index 11e7116..0000000 --- a/src/home/util.py +++ /dev/null @@ -1,255 +0,0 @@ -from __future__ import annotations - -import json -import socket -import time -import subprocess -import traceback -import logging -import string -import random -import re - -from enum import Enum -from datetime import datetime -from typing import Optional, List -from zlib import adler32 - -logger = logging.getLogger(__name__) - - -def validate_ipv4_or_hostname(address: str, raise_exception: bool = False) -> bool: - if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', address): - parts = address.split('.') - if all(0 <= int(part) < 256 for part in parts): - return True - else: - if raise_exception: - raise ValueError(f"invalid IPv4 address: {address}") - return False - - if re.match(r'^[a-zA-Z0-9.-]+$', address): - return True - else: - if raise_exception: - raise ValueError(f"invalid hostname: {address}") - return False - - -def validate_mac_address(mac_address: str) -> bool: - mac_pattern = r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$' - if re.match(mac_pattern, mac_address): - return True - else: - return False - - -class Addr: - host: str - port: Optional[int] - - def __init__(self, host: str, port: Optional[int] = None): - self.host = host - self.port = port - - @staticmethod - def fromstring(addr: str) -> Addr: - colons = addr.count(':') - if colons != 1: - raise ValueError('invalid host:port format') - - if not colons: - host = addr - port= None - else: - host, port = addr.split(':') - - validate_ipv4_or_hostname(host, raise_exception=True) - - if port is not None: - port = int(port) - if not 0 <= port <= 65535: - raise ValueError(f'invalid port {port}') - - return Addr(host, port) - - def __str__(self): - buf = self.host - if self.port is not None: - buf += ':'+str(self.port) - return buf - - def __iter__(self): - yield self.host - yield self.port - - -# https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks -def chunks(lst, n): - """Yield successive n-sized chunks from lst.""" - for i in range(0, len(lst), n): - yield lst[i:i + n] - - -def json_serial(obj): - """JSON serializer for datetime objects""" - if isinstance(obj, datetime): - return obj.timestamp() - if isinstance(obj, Enum): - return obj.value - raise TypeError("Type %s not serializable" % type(obj)) - - -def stringify(v) -> str: - return json.dumps(v, separators=(',', ':'), default=json_serial) - - -def ipv4_valid(ip: str) -> bool: - try: - socket.inet_aton(ip) - return True - except socket.error: - return False - - -def strgen(n: int): - return ''.join(random.choices(string.ascii_letters + string.digits, k=n)) - - -class MySimpleSocketClient: - host: str - port: int - - def __init__(self, host: str, port: int): - self.host = host - self.port = port - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.connect((self.host, self.port)) - self.sock.settimeout(5) - - def __del__(self): - self.sock.close() - - def write(self, line: str) -> None: - self.sock.sendall((line + '\r\n').encode()) - - def read(self) -> str: - buf = bytearray() - while True: - buf.extend(self.sock.recv(256)) - if b'\r\n' in buf: - break - - response = buf.decode().strip() - return response - - -def send_datagram(message: str, addr: Addr) -> None: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.sendto(message.encode(), addr) - - -def format_tb(exc) -> Optional[List[str]]: - tb = traceback.format_tb(exc.__traceback__) - if not tb: - return None - - tb = list(map(lambda s: s.strip(), tb)) - tb.reverse() - if tb[0][-1:] == ':': - tb[0] = tb[0][:-1] - - return tb - - -class ChildProcessInfo: - pid: int - cmd: str - - def __init__(self, - pid: int, - cmd: str): - self.pid = pid - self.cmd = cmd - - -def find_child_processes(ppid: int) -> List[ChildProcessInfo]: - p = subprocess.run(['pgrep', '-P', str(ppid), '--list-full'], capture_output=True) - if p.returncode != 0: - raise OSError(f'pgrep returned {p.returncode}') - - children = [] - - lines = p.stdout.decode().strip().split('\n') - for line in lines: - try: - space_idx = line.index(' ') - except ValueError as exc: - logger.exception(exc) - continue - - pid = int(line[0:space_idx]) - cmd = line[space_idx+1:] - - children.append(ChildProcessInfo(pid, cmd)) - - return children - - -class Stopwatch: - elapsed: float - time_started: Optional[float] - - def __init__(self): - self.elapsed = 0 - self.time_started = None - - def go(self): - if self.time_started is not None: - raise StopwatchError('stopwatch was already started') - - self.time_started = time.time() - - def pause(self): - if self.time_started is None: - raise StopwatchError('stopwatch was paused') - - self.elapsed += time.time() - self.time_started - self.time_started = None - - def get_elapsed_time(self): - elapsed = self.elapsed - if self.time_started is not None: - elapsed += time.time() - self.time_started - return elapsed - - def reset(self): - self.time_started = None - self.elapsed = 0 - - def is_paused(self): - return self.time_started is None - - -class StopwatchError(RuntimeError): - pass - - -def filesize_fmt(num, suffix="B") -> str: - for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: - if abs(num) < 1024.0: - return f"{num:3.1f} {unit}{suffix}" - num /= 1024.0 - return f"{num:.1f} Yi{suffix}" - - -class HashableEnum(Enum): - def hash(self) -> int: - return adler32(self.name.encode()) - - -def next_tick_gen(freq): - t = time.time() - while True: - t += freq - yield max(t - time.time(), 0) \ No newline at end of file diff --git a/src/inverter_bot.py b/src/inverter_bot.py deleted file mode 100755 index 1dd167e..0000000 --- a/src/inverter_bot.py +++ /dev/null @@ -1,943 +0,0 @@ -#!/usr/bin/env python3 -import logging -import re -import datetime -import json -import itertools -import sys - -from inverterd import Format, InverterError -from html import escape -from typing import Optional, Tuple, Union - -from home.util import chunks -from home.config import config, AppConfigUnit -from home.telegram import bot -from home.telegram.config import TelegramBotConfig, TelegramUserListType -from home.inverter import ( - wrapper_instance as inverter, - beautify_table, - InverterMonitor, -) -from home.inverter.types import ( - ChargingEvent, - ACPresentEvent, - BatteryState, - ACMode, - OutputSourcePriority -) -from home.database.inverter_time_formats import FormatDate -from home.api.types import BotType -from home.api import WebApiClient -from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton - - -if __name__ != '__main__': - print(f'this script can not be imported as module', file=sys.stderr) - sys.exit(1) - - -db = None -LT = escape('<=') -flags_map = { - 'buzzer': 'BUZZ', - 'overload_bypass': 'OLBP', - 'escape_to_default_screen_after_1min_timeout': 'LCDE', - 'overload_restart': 'OLRS', - 'over_temp_restart': 'OTRS', - 'backlight_on': 'BLON', - 'alarm_on_on_primary_source_interrupt': 'ALRM', - 'fault_code_record': 'FTCR', -} -logger = logging.getLogger(__name__) - - -class InverterBotConfig(AppConfigUnit, TelegramBotConfig): - NAME = 'inverter_bot' - - @classmethod - def schema(cls) -> Optional[dict]: - acmode_item_schema = { - 'thresholds': { - 'type': 'list', - 'required': True, - 'schema': { - 'type': 'list', - 'min': 40, - 'max': 60 - }, - }, - 'initial_current': {'type': 'integer'} - } - - return { - **super(TelegramBotConfig).schema(), - 'ac_mode': { - 'type': 'dict', - 'required': True, - 'schema': { - 'generator': acmode_item_schema, - 'utilities': acmode_item_schema - } - }, - 'monitor': { - 'type': 'dict', - 'required': True, - 'schema': { - 'vlow': {'type': 'integer', 'required': True}, - 'vcrit': {'type': 'integer', 'required': True}, - 'gen_currents': {'type': 'list', 'schema': {'type': 'integer'}, 'required': True}, - 'gen_raise_intervals': {'type': 'list', 'schema': {'type': 'integer'}, 'required': True}, - 'gen_cur30_v_limit': {'type': 'float', 'required': True}, - 'gen_cur20_v_limit': {'type': 'float', 'required': True}, - 'gen_cur10_v_limit': {'type': 'float', 'required': True}, - 'gen_floating_v': {'type': 'integer', 'required': True}, - 'gen_floating_time_max': {'type': 'integer', 'required': True} - } - } - } - - -config.load_app(InverterBotConfig) - -bot.initialize() -bot.lang.ru( - socket="В розетке", - status='Статус', - generation='Генерация', - priority='Приоритет', - battery="АКБ", - load="Нагрузка", - generator="Генератор", - utilities="Столб", - consumption="Статистика потребления", - settings="Настройки", - done="Готово", - unexpected_callback_data="Ошибка: неверные данные", - invalid_input="Неверное значение", - invalid_mode="Invalid mode", - - flags_press_button='Нажмите кнопку для переключения настройки', - flags_fail='Не удалось установить настройку', - flags_invalid='Неизвестная настройка', - - # generation - gen_input_power='Зарядная мощность', - - # settings - settings_msg="Что вы хотите настроить?", - settings_osp='Приоритет питания нагрузки', - settings_ac_preset="Применить шаблон режима AC", - settings_bat_thresholds="Пороги заряда АКБ от AC", - settings_bat_cut_off_voltage="Порог отключения АКБ", - settings_ac_max_charging_current="Максимальный ток заряда от AC", - - settings_osp_msg="Установите приоритет:", - settings_osp_sub='Solar-Utility-Battery', - settings_osp_sbu='Solar-Battery-Utility', - - settings_select_bottom_threshold="Выберите нижний порог:", - settings_select_upper_threshold="Выберите верхний порог:", - settings_select_max_current='Выберите максимальный ток:', - settings_enter_cutoff_voltage=f'Введите напряжение V, где 40.0 {LT} V {LT} 48.0', - - # time and date - today='Сегодня', - yday1='Вчера', - yday2='Позавчера', - for_7days='За 7 дней', - for_30days='За 30 дней', - # to_select_interval='Выбрать интервал', - - # consumption - consumption_msg="Выберите тип:", - consumption_total="Домашние приборы", - consumption_grid="Со столба", - consumption_select_interval='Выберите период:', - consumption_request_sent="⏳ Запрос отправлен...", - - # status - charging_at=', ', - pd_charging='заряжается', - pd_discharging='разряжается', - pd_nothing='не используется', - - # flags - flag_buzzer='Звуковой сигнал', - flag_overload_bypass='Разрешить перегрузку', - flag_escape_to_default_screen_after_1min_timeout='Возврат на главный экран через 1 минуту', - flag_overload_restart='Перезапуск при перегрузке', - flag_over_temp_restart='Перезапуск при перегреве', - flag_backlight_on='Подсветка экрана', - flag_alarm_on_on_primary_source_interrupt='Сигнал при разрыве основного источника питания', - flag_fault_code_record='Запись кодов ошибок', - - # commands - setbatuv_v=f'напряжение, 40.0 {LT} V {LT} 48.0', - setgenct_cv=f'напряжение включения заряда, 44 {LT} CV {LT} 51', - setgenct_dv=f'напряжение отключения заряда, 48 {LT} DV {LT} 58', - setgencc_a='максимальный ток заряда, допустимые значения: %s', - - # monitor - chrg_evt_started='✅ Начали заряжать от генератора.', - chrg_evt_finished='✅ Зарядили. Генератор пора выключать.', - chrg_evt_disconnected='ℹ️ Генератор отключен.', - chrg_evt_current_changed='ℹ️ Ток заряда от генератора установлен в %d A.', - chrg_evt_not_charging='ℹ️ Генератор подключен, но не заряжает.', - chrg_evt_na_solar='⛔️ Генератор подключен, но аккумуляторы не заряжаются из-за подключенных панелей.', - chrg_evt_mostly_charged='✅ Аккумуляторы более-менее заряжены, генератор пора выключать.', - battery_level_changed='Уровень заряда АКБ: %s %s (%0.1f V при нагрузке %d W)', - error_message='Ошибка: %s.', - - util_chrg_evt_started='✅ Начали заряжать от столба.', - util_chrg_evt_stopped='ℹ️ Перестали заряжать от столба.', - util_chrg_evt_stopped_solar='ℹ️ Перестали заряжать от столба из-за подключения панелей.', - - util_connected='✅️ Столб подключён.', - util_disconnected='‼️ Столб отключён.', - - # other notifications - ac_mode_changed_notification='Пользователь %s установил режим AC: %s.', - osp_changed_notification='Пользователь %s установил приоритет источника питания нагрузки: %s.', - osp_auto_changed_notification='ℹ️ Бот установил приоритет источника питания нагрузки: %s. Причины: напряжение АКБ %.1f V, мощность заряда с панелей %d W.', - - bat_state_normal='Нормальный', - bat_state_low='Низкий', - bat_state_critical='Критический', -) - -bot.lang.en( - socket='AC output', - status='Status', - generation='Generation', - priority='Priority', - battery="Battery", - load="Load", - generator="Generator", - utilities="Utilities", - consumption="Consumption statistics", - settings="Settings", - done="Done", - unexpected_callback_data="Unexpected callback data", - select_priortiy="Select priority:", - invalid_input="Invalid input", - invalid_mode="Invalid mode", - - flags_press_button='Press a button to toggle a flag.', - flags_fail='Failed to toggle flag', - flags_invalid='Invalid flag', - - # settings - settings_msg='What do you want to configure?', - settings_osp='Output source priority', - settings_ac_preset="AC preset", - settings_bat_thresholds="Battery charging thresholds", - settings_bat_cut_off_voltage="Battery cut-off voltage", - settings_ac_max_charging_current="Max AC charging current", - - settings_osp_msg="Select priority:", - settings_osp_sub='Solar-Utility-Battery', - settings_osp_sbu='Solar-Battery-Utility', - - settings_select_bottom_threshold="Select bottom (lower) threshold:", - settings_select_upper_threshold="Select top (upper) threshold:", - settings_select_max_current='Select max current:', - settings_enter_cutoff_voltage=f'Enter voltage V (40.0 {LT} V {LT} 48.0):', - - # generation - gen_input_power='Input power', - - # time and date - today='Today', - yday1='Yesterday', - yday2='The day before yesterday', - for_7days='7 days', - for_30days='30 days', - # to_select_interval='Select interval', - - # consumption - consumption_msg="Select type:", - consumption_total="Home appliances", - consumption_grid="Consumed from grid", - consumption_select_interval='Select period:', - consumption_request_sent="⏳ Request sent...", - - # status - charging_at=' @ ', - pd_charging='charging', - pd_discharging='discharging', - pd_nothing='not used', - - # flags - flag_buzzer='Buzzer', - flag_overload_bypass='Overload bypass', - flag_escape_to_default_screen_after_1min_timeout='Reset to default LCD page after 1min timeout', - flag_overload_restart='Restart on overload', - flag_over_temp_restart='Restart on overtemp', - flag_backlight_on='LCD backlight', - flag_alarm_on_on_primary_source_interrupt='Beep on primary source interruption', - flag_fault_code_record='Fault code recording', - - # commands - setbatuv_v=f'floating point number, 40.0 {LT} V {LT} 48.0', - setgenct_cv=f'charging voltage, 44 {LT} CV {LT} 51', - setgenct_dv=f'discharging voltage, 48 {LT} DV {LT} 58', - setgencc_a='max charging current, allowed values: %s', - - # monitor - chrg_evt_started='✅ Started charging from AC.', - chrg_evt_finished='✅ Finished charging, it\'s time to stop the generator.', - chrg_evt_disconnected='ℹ️ AC disconnected.', - chrg_evt_current_changed='ℹ️ AC charging current set to %d A.', - chrg_evt_not_charging='ℹ️ AC connected but not charging.', - chrg_evt_na_solar='⛔️ AC connected, but battery won\'t be charged due to active solar power line.', - chrg_evt_mostly_charged='✅ The battery is mostly charged now. The generator can be turned off.', - battery_level_changed='Battery level: %s (%0.1f V under %d W load)', - error_message='Error: %s.', - - util_chrg_evt_started='✅ Started charging from utilities.', - util_chrg_evt_stopped='ℹ️ Stopped charging from utilities.', - util_chrg_evt_stopped_solar='ℹ️ Stopped charging from utilities because solar panels were connected.', - - util_connected='✅️ Utilities connected.', - util_disconnected='‼️ Utilities disconnected.', - - # other notifications - ac_mode_changed_notification='User %s set AC mode to %s.', - osp_changed_notification='User %s set output source priority: %s.', - osp_auto_changed_notification='Bot changed output source priority to %s. Reasons: battery voltage is %.1f V, solar input is %d W.', - - bat_state_normal='Normal', - bat_state_low='Low', - bat_state_critical='Critical', -) - - -def monitor_charging(event: ChargingEvent, **kwargs) -> None: - args = [] - is_util = False - if event == ChargingEvent.AC_CHARGING_STARTED: - key = 'started' - elif event == ChargingEvent.AC_CHARGING_FINISHED: - key = 'finished' - elif event == ChargingEvent.AC_DISCONNECTED: - key = 'disconnected' - elif event == ChargingEvent.AC_NOT_CHARGING: - key = 'not_charging' - elif event == ChargingEvent.AC_CURRENT_CHANGED: - key = 'current_changed' - args.append(kwargs['current']) - elif event == ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR: - key = 'na_solar' - elif event == ChargingEvent.AC_MOSTLY_CHARGED: - key = 'mostly_charged' - elif event == ChargingEvent.UTIL_CHARGING_STARTED: - key = 'started' - is_util = True - elif event == ChargingEvent.UTIL_CHARGING_STOPPED: - key = 'stopped' - is_util = True - elif event == ChargingEvent.UTIL_CHARGING_STOPPED_SOLAR: - key = 'stopped_solar' - is_util = True - else: - logger.error('unknown charging event:', event) - return - - key = f'chrg_evt_{key}' - if is_util: - key = f'util_{key}' - bot.notify_all( - lambda lang: bot.lang.get(key, lang, *args) - ) - - -def monitor_battery(state: BatteryState, v: float, load_watts: int) -> None: - if state == BatteryState.NORMAL: - emoji = '✅' - elif state == BatteryState.LOW: - emoji = '⚠️' - elif state == BatteryState.CRITICAL: - emoji = '‼️' - else: - logger.error('unknown battery state:', state) - return - - bot.notify_all( - lambda lang: bot.lang.get('battery_level_changed', lang, - emoji, bot.lang.get(f'bat_state_{state.name.lower()}', lang), v, load_watts) - ) - - -def monitor_util(event: ACPresentEvent): - if event == ACPresentEvent.CONNECTED: - key = 'connected' - else: - key = 'disconnected' - key = f'util_{key}' - bot.notify_all( - lambda lang: bot.lang.get(key, lang) - ) - - -def monitor_error(error: str) -> None: - bot.notify_all( - lambda lang: bot.lang.get('error_message', lang, error) - ) - - -def osp_change_cb(new_osp: OutputSourcePriority, - solar_input: int, - v: float): - - setosp(new_osp) - - bot.notify_all( - lambda lang: bot.lang.get('osp_auto_changed_notification', lang, - bot.lang.get(f'settings_osp_{new_osp.value.lower()}', lang), v, solar_input), - ) - - -@bot.handler(command='status') -def full_status(ctx: bot.Context) -> None: - status = inverter.exec('get-status', format=Format.TABLE) - ctx.reply(beautify_table(status)) - - -@bot.handler(command='config') -def full_rated(ctx: bot.Context) -> None: - rated = inverter.exec('get-rated', format=Format.TABLE) - ctx.reply(beautify_table(rated)) - - -@bot.handler(command='errors') -def full_errors(ctx: bot.Context) -> None: - errors = inverter.exec('get-errors', format=Format.TABLE) - ctx.reply(beautify_table(errors)) - - -@bot.handler(command='flags') -def flags_handler(ctx: bot.Context) -> None: - flags = inverter.exec('get-flags')['data'] - text, markup = build_flags_keyboard(flags, ctx) - ctx.reply(text, markup=markup) - - -def build_flags_keyboard(flags: dict, ctx: bot.Context) -> Tuple[str, InlineKeyboardMarkup]: - keyboard = [] - for k, v in flags.items(): - label = ('✅' if v else '❌') + ' ' + ctx.lang(f'flag_{k}') - proto_flag = flags_map[k] - keyboard.append([InlineKeyboardButton(label, callback_data=f'flag_{proto_flag}')]) - - return ctx.lang('flags_press_button'), InlineKeyboardMarkup(keyboard) - - -def getacmode() -> ACMode: - return ACMode(bot.db.get_param('ac_mode', default=ACMode.GENERATOR)) - - -def setacmode(mode: ACMode): - monitor.set_ac_mode(mode) - - cv, dv = config['ac_mode'][str(mode.value)]['thresholds'] - a = config['ac_mode'][str(mode.value)]['initial_current'] - - logger.debug(f'setacmode: mode={mode}, cv={cv}, dv={dv}, a={a}') - - inverter.exec('set-charge-thresholds', (cv, dv)) - inverter.exec('set-max-ac-charge-current', (0, a)) - - -def setosp(sp: OutputSourcePriority): - logger.debug(f'setosp: sp={sp}') - inverter.exec('set-output-source-priority', (sp.value,)) - monitor.notify_osp(sp) - - -class SettingsConversation(bot.conversation): - START, OSP, AC_PRESET, BAT_THRESHOLDS_1, BAT_THRESHOLDS_2, BAT_CUT_OFF_VOLTAGE, AC_MAX_CHARGING_CURRENT = range(7) - STATE_SEQS = [ - [START, OSP], - [START, AC_PRESET], - [START, BAT_THRESHOLDS_1, BAT_THRESHOLDS_2], - [START, BAT_CUT_OFF_VOLTAGE], - [START, AC_MAX_CHARGING_CURRENT] - ] - - START_BUTTONS = bot.lang.pfx('settings_', ['ac_preset', - 'ac_max_charging_current', - 'bat_thresholds', - 'bat_cut_off_voltage', - 'osp']) - OSP_BUTTONS = bot.lang.pfx('settings_osp_', [sp.value.lower() for sp in OutputSourcePriority]) - AC_PRESET_BUTTONS = [mode.value for mode in ACMode] - - RECHARGE_VOLTAGES = [44, 45, 46, 47, 48, 49, 50, 51] - REDISCHARGE_VOLTAGES = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58] - - @bot.conventer(START, message='settings') - def start_enter(self, ctx: bot.Context): - buttons = list(chunks(list(self.START_BUTTONS), 2)) - buttons.reverse() - return self.reply(ctx, self.START, ctx.lang('settings_msg'), buttons, - with_cancel=True) - - @bot.convinput(START, messages={ - 'settings_osp': OSP, - 'settings_ac_preset': AC_PRESET, - 'settings_bat_thresholds': BAT_THRESHOLDS_1, - 'settings_bat_cut_off_voltage': BAT_CUT_OFF_VOLTAGE, - 'settings_ac_max_charging_current': AC_MAX_CHARGING_CURRENT - }) - def start_input(self, ctx: bot.Context): - pass - - @bot.conventer(OSP) - def osp_enter(self, ctx: bot.Context): - return self.reply(ctx, self.OSP, ctx.lang('settings_osp_msg'), self.OSP_BUTTONS, - with_back=True) - - @bot.convinput(OSP, messages=OSP_BUTTONS) - def osp_input(self, ctx: bot.Context): - selected_sp = None - for sp in OutputSourcePriority: - if ctx.text == ctx.lang(f'settings_osp_{sp.value.lower()}'): - selected_sp = sp - break - - if selected_sp is None: - raise ValueError('invalid sp') - - # apply the mode - setosp(selected_sp) - - # reply to user - ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()) - - # notify other users - bot.notify_all( - lambda lang: bot.lang.get('osp_changed_notification', lang, - ctx.user.id, ctx.user.name, - bot.lang.get(f'settings_osp_{selected_sp.value.lower()}', lang)), - exclude=(ctx.user_id,) - ) - return self.END - - @bot.conventer(AC_PRESET) - def acpreset_enter(self, ctx: bot.Context): - return self.reply(ctx, self.AC_PRESET, ctx.lang('settings_ac_preset_msg'), self.AC_PRESET_BUTTONS, - with_back=True) - - @bot.convinput(AC_PRESET, messages=AC_PRESET_BUTTONS) - def acpreset_input(self, ctx: bot.Context): - if monitor.active_current is not None: - raise RuntimeError('generator charging program is active') - - if ctx.text == ctx.lang('utilities'): - newmode = ACMode.UTILITIES - elif ctx.text == ctx.lang('generator'): - newmode = ACMode.GENERATOR - else: - raise ValueError('invalid mode') - - # apply the mode - setacmode(newmode) - - # save - bot.db.set_param('ac_mode', str(newmode.value)) - - # reply to user - ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()) - - # notify other users - bot.notify_all( - lambda lang: bot.lang.get('ac_mode_changed_notification', lang, - ctx.user.id, ctx.user.name, - bot.lang.get(str(newmode.value), lang)), - exclude=(ctx.user_id,) - ) - return self.END - - @bot.conventer(BAT_THRESHOLDS_1) - def thresholds1_enter(self, ctx: bot.Context): - buttons = list(map(lambda v: f'{v} V', self.RECHARGE_VOLTAGES)) - buttons = chunks(buttons, 4) - return self.reply(ctx, self.BAT_THRESHOLDS_1, ctx.lang('settings_select_bottom_threshold'), buttons, - with_back=True, buttons_lang_completed=True) - - @bot.convinput(BAT_THRESHOLDS_1, - messages=list(map(lambda n: f'{n} V', RECHARGE_VOLTAGES)), - messages_lang_completed=True) - def thresholds1_input(self, ctx: bot.Context): - v = self._parse_voltage(ctx.text) - ctx.user_data['bat_thrsh_v1'] = v - return self.invoke(self.BAT_THRESHOLDS_2, ctx) - - @bot.conventer(BAT_THRESHOLDS_2) - def thresholds2_enter(self, ctx: bot.Context): - buttons = list(map(lambda v: f'{v} V', self.REDISCHARGE_VOLTAGES)) - buttons = chunks(buttons, 4) - return self.reply(ctx, self.BAT_THRESHOLDS_2, ctx.lang('settings_select_upper_threshold'), buttons, - with_back=True, buttons_lang_completed=True) - - @bot.convinput(BAT_THRESHOLDS_2, - messages=list(map(lambda n: f'{n} V', REDISCHARGE_VOLTAGES)), - messages_lang_completed=True) - def thresholds2_input(self, ctx: bot.Context): - v2 = v = self._parse_voltage(ctx.text) - v1 = ctx.user_data['bat_thrsh_v1'] - del ctx.user_data['bat_thrsh_v1'] - - response = inverter.exec('set-charge-thresholds', (v1, v2)) - ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', - markup=bot.IgnoreMarkup()) - return self.END - - @bot.conventer(AC_MAX_CHARGING_CURRENT) - def ac_max_enter(self, ctx: bot.Context): - buttons = self._get_allowed_ac_charge_amps() - buttons = map(lambda n: f'{n} A', buttons) - buttons = [list(buttons)] - return self.reply(ctx, self.AC_MAX_CHARGING_CURRENT, ctx.lang('settings_select_max_current'), buttons, - with_back=True, buttons_lang_completed=True) - - @bot.convinput(AC_MAX_CHARGING_CURRENT, regex=r'^\d+ A$') - def ac_max_input(self, ctx: bot.Context): - a = self._parse_amps(ctx.text) - allowed = self._get_allowed_ac_charge_amps() - if a not in allowed: - raise ValueError('input is not allowed') - - response = inverter.exec('set-max-ac-charge-current', (0, a)) - ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', - markup=bot.IgnoreMarkup()) - return self.END - - @bot.conventer(BAT_CUT_OFF_VOLTAGE) - def cutoff_enter(self, ctx: bot.Context): - return self.reply(ctx, self.BAT_CUT_OFF_VOLTAGE, ctx.lang('settings_enter_cutoff_voltage'), None, - with_back=True) - - @bot.convinput(BAT_CUT_OFF_VOLTAGE, regex=r'^(\d{2}(\.\d{1})?)$') - def cutoff_input(self, ctx: bot.Context): - v = float(ctx.text) - if 40.0 <= v <= 48.0: - response = inverter.exec('set-battery-cutoff-voltage', (v,)) - ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', - markup=bot.IgnoreMarkup()) - else: - raise ValueError('invalid voltage') - - return self.END - - def _get_allowed_ac_charge_amps(self) -> list[int]: - l = inverter.exec('get-allowed-ac-charge-currents')['data'] - l = filter(lambda n: n <= 40, l) - return list(l) - - def _parse_voltage(self, s: str) -> int: - return int(re.match(r'^(\d{2}) V$', s).group(1)) - - def _parse_amps(self, s: str) -> int: - return int(re.match(r'^(\d{1,2}) A$', s).group(1)) - - -class ConsumptionConversation(bot.conversation): - START, TOTAL, GRID = range(3) - STATE_SEQS = [ - [START, TOTAL], - [START, GRID] - ] - - START_BUTTONS = bot.lang.pfx('consumption_', ['total', 'grid']) - INTERVAL_BUTTONS = [ - ['today'], - ['yday1'], - ['for_7days', 'for_30days'], - # ['to_select_interval'] - ] - INTERVAL_BUTTONS_FLAT = list(itertools.chain.from_iterable(INTERVAL_BUTTONS)) - - @bot.conventer(START, message='consumption') - def start_enter(self, ctx: bot.Context): - return self.reply(ctx, self.START, ctx.lang('consumption_msg'), [self.START_BUTTONS], - with_cancel=True) - - @bot.convinput(START, messages={ - 'consumption_total': TOTAL, - 'consumption_grid': GRID - }) - def start_input(self, ctx: bot.Context): - pass - - @bot.conventer(TOTAL) - def total_enter(self, ctx: bot.Context): - return self._render_interval_btns(ctx, self.TOTAL) - - @bot.conventer(GRID) - def grid_enter(self, ctx: bot.Context): - return self._render_interval_btns(ctx, self.GRID) - - def _render_interval_btns(self, ctx: bot.Context, state): - return self.reply(ctx, state, ctx.lang('consumption_select_interval'), self.INTERVAL_BUTTONS, - with_back=True) - - @bot.convinput(TOTAL, messages=INTERVAL_BUTTONS_FLAT) - def total_input(self, ctx: bot.Context): - return self._render_interval_results(ctx, self.TOTAL) - - @bot.convinput(GRID, messages=INTERVAL_BUTTONS_FLAT) - def grid_input(self, ctx: bot.Context): - return self._render_interval_results(ctx, self.GRID) - - def _render_interval_results(self, ctx: bot.Context, state): - # if ctx.text == ctx.lang('to_select_interval'): - # TODO - # pass - # - # else: - - now = datetime.datetime.now() - s_to = now.strftime(FormatDate) - - if ctx.text == ctx.lang('today'): - s_from = now.strftime(FormatDate) - s_to = 'now' - elif ctx.text == ctx.lang('yday1'): - s_from = (now - datetime.timedelta(days=1)).strftime(FormatDate) - elif ctx.text == ctx.lang('for_7days'): - s_from = (now - datetime.timedelta(days=7)).strftime(FormatDate) - elif ctx.text == ctx.lang('for_30days'): - s_from = (now - datetime.timedelta(days=30)).strftime(FormatDate) - - # markup = InlineKeyboardMarkup([ - # [InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait')] - # ]) - - message = ctx.reply(ctx.lang('consumption_request_sent'), - markup=bot.IgnoreMarkup()) - - api = WebApiClient(timeout=60) - method = 'inverter_get_consumed_energy' if state == self.TOTAL else 'inverter_get_grid_consumed_energy' - - try: - wh = getattr(api, method)(s_from, s_to) - bot.delete_message(message.chat_id, message.message_id) - ctx.reply('%.2f Wh' % (wh,), - markup=bot.IgnoreMarkup()) - return self.END - except Exception as e: - bot.delete_message(message.chat_id, message.message_id) - ctx.reply_exc(e) - -# other -# ----- - -@bot.handler(command='monstatus') -def monstatus_handler(ctx: bot.Context) -> None: - msg = '' - st = monitor.dump_status() - for k, v in st.items(): - msg += k + ': ' + str(v) + '\n' - ctx.reply(msg) - - -@bot.handler(command='monsetcur') -def monsetcur_handler(ctx: bot.Context) -> None: - ctx.reply('not implemented yet') - - -@bot.callbackhandler -def button_callback(ctx: bot.Context) -> None: - query = ctx.callback_query - - if query.data.startswith('flag_'): - flag = query.data[5:] - found = False - json_key = None - for k, v in flags_map.items(): - if v == flag: - found = True - json_key = k - break - if not found: - query.answer(ctx.lang('flags_invalid')) - return - - flags = inverter.exec('get-flags')['data'] - cur_flag_value = flags[json_key] - target_flag_value = '0' if cur_flag_value else '1' - - # set flag - response = inverter.exec('set-flag', (flag, target_flag_value)) - - # notify user - query.answer(ctx.lang('done') if response['result'] == 'ok' else ctx.lang('flags_fail')) - - # edit message - flags[json_key] = not cur_flag_value - text, markup = build_flags_keyboard(flags, ctx) - query.edit_message_text(text, reply_markup=markup) - - else: - query.answer(ctx.lang('unexpected_callback_data')) - - -@bot.exceptionhandler -def exception_handler(e: Exception, ctx: bot.Context) -> Optional[bool]: - if isinstance(e, InverterError): - try: - err = json.loads(str(e))['message'] - except json.decoder.JSONDecodeError: - err = str(e) - err = re.sub(r'((?:.*)?error:) (.*)', r'\1 \2', err) - ctx.reply(err, - markup=bot.IgnoreMarkup()) - return True - - -@bot.handler(message='status') -def status_handler(ctx: bot.Context) -> None: - gs = inverter.exec('get-status')['data'] - rated = inverter.exec('get-rated')['data'] - - # render response - power_direction = gs['battery_power_direction'].lower() - power_direction = re.sub(r'ge$', 'ging', power_direction) - - charging_rate = '' - chrg_at = ctx.lang('charging_at') - - if power_direction == 'charging': - charging_rate = f'{chrg_at}%s %s' % ( - gs['battery_charge_current']['value'], gs['battery_charge_current']['unit']) - pd_label = ctx.lang('pd_charging') - elif power_direction == 'discharging': - charging_rate = f'{chrg_at}%s %s' % ( - gs['battery_discharge_current']['value'], gs['battery_discharge_current']['unit']) - pd_label = ctx.lang('pd_discharging') - else: - pd_label = ctx.lang('pd_nothing') - - html = f'{ctx.lang("battery")}: %s %s' % (gs['battery_voltage']['value'], gs['battery_voltage']['unit']) - html += ' (%s%s)' % (pd_label, charging_rate) - - html += f'\n{ctx.lang("load")}: %s %s' % (gs['ac_output_active_power']['value'], gs['ac_output_active_power']['unit']) - html += ' (%s%%)' % (gs['output_load_percent']['value']) - - if gs['pv1_input_power']['value'] > 0: - html += f'\n{ctx.lang("gen_input_power")}: %s %s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit']) - - if gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0: - ac_mode = getacmode() - html += f'\n{ctx.lang(ac_mode.value)}: %s %s' % (gs['grid_voltage']['value'], gs['grid_voltage']['unit']) - html += ', %s %s' % (gs['grid_freq']['value'], gs['grid_freq']['unit']) - - html += f'\n{ctx.lang("socket")}: %s %s, %s %s' % ( - gs['ac_output_voltage']['value'], gs['ac_output_voltage']['unit'], - gs['ac_output_freq']['value'], gs['ac_output_freq']['unit'] - ) - - html += f'\n{ctx.lang("priority")}: {rated["output_source_priority"]}' - - # send response - ctx.reply(html) - - -@bot.handler(message='generation') -def generation_handler(ctx: bot.Context) -> None: - today = datetime.date.today() - yday = today - datetime.timedelta(days=1) - yday2 = today - datetime.timedelta(days=2) - - gs = inverter.exec('get-status')['data'] - - gen_today = inverter.exec('get-day-generated', (today.year, today.month, today.day))['data'] - gen_yday = None - gen_yday2 = None - - if yday.month == today.month: - gen_yday = inverter.exec('get-day-generated', (yday.year, yday.month, yday.day))['data'] - - if yday2.month == today.month: - gen_yday2 = inverter.exec('get-day-generated', (yday2.year, yday2.month, yday2.day))['data'] - - # render response - html = f'{ctx.lang("gen_input_power")}: %s %s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit']) - html += ' (%s %s)' % (gs['pv1_input_voltage']['value'], gs['pv1_input_voltage']['unit']) - - html += f'\n{ctx.lang("today")}: %s Wh' % (gen_today['wh']) - - if gen_yday is not None: - html += f'\n{ctx.lang("yday1")}: %s Wh' % (gen_yday['wh']) - - if gen_yday2 is not None: - html += f'\n{ctx.lang("yday2")}: %s Wh' % (gen_yday2['wh']) - - # send response - ctx.reply(html) - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - button = [ - [ctx.lang('status'), ctx.lang('generation')], - [ctx.lang('consumption')], - [ctx.lang('settings')] - ] - return ReplyKeyboardMarkup(button, one_time_keyboard=False) - - -class InverterStore(bot.BotDatabase): - SCHEMA = 2 - - def schema_init(self, version: int) -> None: - super().schema_init(version) - - if version < 2: - cursor = self.cursor() - cursor.execute("""CREATE TABLE IF NOT EXISTS params ( - id TEXT NOT NULL PRIMARY KEY, - value TEXT NOT NULL - )""") - cursor.execute("CREATE INDEX param_id_idx ON params (id)") - self.commit() - - def get_param(self, key: str, default=None): - cursor = self.cursor() - cursor.execute('SELECT value FROM params WHERE id=?', (key,)) - row = cursor.fetchone() - - return default if row is None else row[0] - - def set_param(self, key: str, value: Union[str, int, float]): - cursor = self.cursor() - cursor.execute('REPLACE INTO params (id, value) VALUES (?, ?)', (key, str(value))) - self.commit() - - -inverter.init(host=config['inverter']['ip'], port=config['inverter']['port']) - -bot.set_database(InverterStore()) -bot.enable_logging(BotType.INVERTER) - -bot.add_conversation(SettingsConversation(enable_back=True)) -bot.add_conversation(ConsumptionConversation(enable_back=True)) - -monitor = InverterMonitor() -monitor.set_charging_event_handler(monitor_charging) -monitor.set_battery_event_handler(monitor_battery) -monitor.set_util_event_handler(monitor_util) -monitor.set_error_handler(monitor_error) -monitor.set_osp_need_change_callback(osp_change_cb) - -setacmode(getacmode()) - -if not config.get('monitor.disabled'): - logging.info('starting monitor') - monitor.start() - -bot.run() - -monitor.stop() diff --git a/src/inverter_mqtt_util.py b/src/inverter_mqtt_util.py deleted file mode 100755 index 791bf80..0000000 --- a/src/inverter_mqtt_util.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python3 -from argparse import ArgumentParser -from home.config import config, app_config -from home.mqtt import MqttWrapper, MqttNode - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('mode', type=str, choices=('sender', 'receiver'), nargs=1) - - config.load_app('inverter_mqtt_util', parser=parser) - arg = parser.parse_args() - mode = arg.mode[0] - - mqtt = MqttWrapper(client_id=f'inverter_mqtt_{mode}', - clean_session=mode != 'receiver') - node = MqttNode(node_id='inverter') - module_kwargs = {} - if mode == 'sender': - module_kwargs['status_poll_freq'] = int(app_config['poll_freq']) - module_kwargs['generation_poll_freq'] = int(app_config['generation_poll_freq']) - node.load_module('inverter', **module_kwargs) - mqtt.add_node(node) - - mqtt.connect_and_loop() diff --git a/src/inverterd_emulator.py b/src/inverterd_emulator.py deleted file mode 100755 index 8c4d0bd..0000000 --- a/src/inverterd_emulator.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env python3 -import logging - -from home.inverter.emulator import InverterEmulator - - -if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG) - InverterEmulator(addr=('127.0.0.1', 8305)) diff --git a/src/ipcam_server.py b/src/ipcam_server.py deleted file mode 100755 index a54cd35..0000000 --- a/src/ipcam_server.py +++ /dev/null @@ -1,579 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import re -import asyncio -import time -import shutil -import home.telegram.aio as telegram - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from asyncio import Lock - -from home.config import config -from home import http -from home.database.sqlite import SQLiteBase -from home.camera import util as camutil - -from enum import Enum -from typing import Optional, Union, List, Tuple -from datetime import datetime, timedelta -from functools import cmp_to_key - - -class TimeFilterType(Enum): - FIX = 'fix' - MOTION = 'motion' - MOTION_START = 'motion_start' - - -class TelegramLinkType(Enum): - FRAGMENT = 'fragment' - ORIGINAL_FILE = 'original_file' - - -def valid_recording_name(filename: str) -> bool: - return filename.startswith('record_') and filename.endswith('.mp4') - - -def filename_to_datetime(filename: str) -> datetime: - filename = os.path.basename(filename).replace('record_', '').replace('.mp4', '') - return datetime.strptime(filename, datetime_format) - - -def get_all_cams() -> list: - return [cam for cam in config['camera'].keys()] - - -# ipcam database -# -------------- - -class IPCamServerDatabase(SQLiteBase): - SCHEMA = 4 - - def __init__(self): - super().__init__() - - def schema_init(self, version: int) -> None: - cursor = self.cursor() - - if version < 1: - # timestamps - cursor.execute("""CREATE TABLE IF NOT EXISTS timestamps ( - camera INTEGER PRIMARY KEY, - fix_time INTEGER NOT NULL, - motion_time INTEGER NOT NULL - )""") - for cam in config['camera'].keys(): - self.add_camera(cam) - - if version < 2: - # motion_failures - cursor.execute("""CREATE TABLE IF NOT EXISTS motion_failures ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - camera INTEGER NOT NULL, - filename TEXT NOT NULL - )""") - - if version < 3: - cursor.execute("ALTER TABLE motion_failures ADD COLUMN message TEXT NOT NULL DEFAULT ''") - - if version < 4: - cursor.execute("ALTER TABLE timestamps ADD COLUMN motion_start_time INTEGER NOT NULL DEFAULT 0") - cursor.execute("UPDATE timestamps SET motion_start_time=motion_time") - - self.commit() - - def add_camera(self, camera: int): - self.cursor().execute("INSERT INTO timestamps (camera, fix_time, motion_time) VALUES (?, ?, ?)", - (camera, 0, 0)) - self.commit() - - def add_motion_failure(self, - camera: int, - filename: str, - message: Optional[str]): - self.cursor().execute("INSERT INTO motion_failures (camera, filename, message) VALUES (?, ?, ?)", - (camera, filename, message or '')) - self.commit() - - def get_all_timestamps(self): - cur = self.cursor() - data = {} - - cur.execute("SELECT camera, fix_time, motion_time, motion_start_time FROM timestamps") - for cam, fix_time, motion_time, motion_start_time in cur.fetchall(): - data[int(cam)] = { - 'fix': int(fix_time), - 'motion': int(motion_time), - 'motion_start': int(motion_start_time) - } - - return data - - def set_timestamp(self, - camera: int, - time_type: TimeFilterType, - time: Union[int, datetime]): - cur = self.cursor() - if isinstance(time, datetime): - time = int(time.timestamp()) - cur.execute(f"UPDATE timestamps SET {time_type.value}_time=? WHERE camera=?", (time, camera)) - self.commit() - - def get_timestamp(self, - camera: int, - time_type: TimeFilterType) -> int: - cur = self.cursor() - cur.execute(f"SELECT {time_type.value}_time FROM timestamps WHERE camera=?", (camera,)) - return int(cur.fetchone()[0]) - - -# ipcam web api -# ------------- - -class IPCamWebServer(http.HTTPServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.get('/api/recordings', self.get_motion_queue) - self.get('/api/recordings/{name}', self.get_camera_recordings) - self.get('/api/recordings/{name}/download/{file}', self.download_recording) - self.get('/api/camera/list', self.camlist) - self.get('/api/timestamp/{name}/{type}', self.get_timestamp) - self.get('/api/timestamp/all', self.get_all_timestamps) - - self.post('/api/debug/migrate-mtimes', self.debug_migrate_mtimes) - self.post('/api/debug/fix', self.debug_fix) - self.post('/api/debug/cleanup', self.debug_cleanup) - self.post('/api/timestamp/{name}/{type}', self.set_timestamp) - - self.post('/api/motion/done/{name}', self.submit_motion) - self.post('/api/motion/fail/{name}', self.submit_motion_failure) - - self.get('/api/motion/params/{name}', self.get_motion_params) - self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params) - - self.queue_lock = Lock() - - async def get_camera_recordings(self, req): - camera = int(req.match_info['name']) - try: - filter = TimeFilterType(req.query['filter']) - except KeyError: - filter = None - - try: - limit = int(req.query['limit']) - except KeyError: - limit = 0 - - files = get_recordings_files(camera, filter, limit) - if files: - time = filename_to_datetime(files[len(files)-1]['name']) - db.set_timestamp(camera, TimeFilterType.MOTION_START, time) - return self.ok({'files': files}) - - async def get_motion_queue(self, req): - try: - limit = int(req.query['limit']) - except KeyError: - limit = 0 - - async with self.queue_lock: - files = get_recordings_files(None, TimeFilterType.MOTION_START, limit) - if files: - times_by_cam = {} - for file in files: - time = filename_to_datetime(file['name']) - if file['cam'] not in times_by_cam or times_by_cam[file['cam']] < time: - times_by_cam[file['cam']] = time - for cam, time in times_by_cam.items(): - db.set_timestamp(cam, TimeFilterType.MOTION_START, time) - - return self.ok({'files': files}) - - async def download_recording(self, req: http.Request): - cam = int(req.match_info['name']) - file = req.match_info['file'] - - fullpath = os.path.join(config['camera'][cam]['recordings_path'], file) - if not os.path.isfile(fullpath): - raise ValueError(f'file "{fullpath}" does not exists') - - return http.FileResponse(fullpath) - - async def camlist(self, req: http.Request): - return self.ok(config['camera']) - - async def submit_motion(self, req: http.Request): - data = await req.post() - - camera = int(req.match_info['name']) - timecodes = data['timecodes'] - filename = data['filename'] - - time = filename_to_datetime(filename) - - try: - if timecodes != '': - fragments = camutil.dvr_scan_timecodes(timecodes) - asyncio.ensure_future(process_fragments(camera, filename, fragments)) - - db.set_timestamp(camera, TimeFilterType.MOTION, time) - return self.ok() - - except camutil.DVRScanInvalidTimecodes as e: - db.add_motion_failure(camera, filename, str(e)) - db.set_timestamp(camera, TimeFilterType.MOTION, time) - return self.ok('invalid timecodes') - - async def submit_motion_failure(self, req: http.Request): - camera = int(req.match_info['name']) - - data = await req.post() - filename = data['filename'] - message = data['message'] - - db.add_motion_failure(camera, filename, message) - db.set_timestamp(camera, TimeFilterType.MOTION, filename_to_datetime(filename)) - - return self.ok() - - async def debug_migrate_mtimes(self, req: http.Request): - written = {} - for cam in config['camera'].keys(): - confdir = os.path.join(os.getenv('HOME'), '.config', f'video-util-{cam}') - for time_type in TimeFilterType: - txt_file = os.path.join(confdir, f'{time_type.value}_mtime') - if os.path.isfile(txt_file): - with open(txt_file, 'r') as fd: - data = fd.read() - db.set_timestamp(cam, time_type, int(data.strip())) - - if cam not in written: - written[cam] = [] - written[cam].append(time_type) - - return self.ok({'written': written}) - - async def debug_fix(self, req: http.Request): - asyncio.ensure_future(fix_job()) - return self.ok() - - async def debug_cleanup(self, req: http.Request): - asyncio.ensure_future(cleanup_job()) - return self.ok() - - async def set_timestamp(self, req: http.Request): - cam, time_type, time = self._getset_timestamp_params(req, need_time=True) - db.set_timestamp(cam, time_type, time) - return self.ok() - - async def get_timestamp(self, req: http.Request): - cam, time_type = self._getset_timestamp_params(req) - return self.ok(db.get_timestamp(cam, time_type)) - - async def get_all_timestamps(self, req: http.Request): - return self.ok(db.get_all_timestamps()) - - async def get_motion_params(self, req: http.Request): - data = config['motion_params'][int(req.match_info['name'])] - lines = [ - f'threshold={data["threshold"]}', - f'min_event_length=3s', - f'frame_skip=2', - f'downscale_factor=3', - ] - return self.plain('\n'.join(lines)+'\n') - - async def get_motion_roi_params(self, req: http.Request): - data = config['motion_params'][int(req.match_info['name'])] - return self.plain('\n'.join(data['roi'])+'\n') - - @staticmethod - def _getset_timestamp_params(req: http.Request, need_time=False): - values = [] - - cam = int(req.match_info['name']) - assert cam in config['camera'], 'invalid camera' - - values.append(cam) - values.append(TimeFilterType(req.match_info['type'])) - - if need_time: - time = req.query['time'] - if time.startswith('record_'): - time = filename_to_datetime(time) - elif time.isnumeric(): - time = int(time) - else: - raise ValueError('invalid time') - values.append(time) - - return values - - -# other global stuff -# ------------------ - -def open_database(): - global db - db = IPCamServerDatabase() - - # update cams list in database, if needed - cams = db.get_all_timestamps().keys() - for cam in config['camera']: - if cam not in cams: - db.add_camera(cam) - - -def get_recordings_path(cam: int) -> str: - return config['camera'][cam]['recordings_path'] - - -def get_motion_path(cam: int) -> str: - return config['camera'][cam]['motion_path'] - - -def get_recordings_files(cam: Optional[int] = None, - time_filter_type: Optional[TimeFilterType] = None, - limit=0) -> List[dict]: - from_time = 0 - to_time = int(time.time()) - - cams = [cam] if cam is not None else get_all_cams() - files = [] - for cam in cams: - if time_filter_type: - from_time = db.get_timestamp(cam, time_filter_type) - if time_filter_type in (TimeFilterType.MOTION, TimeFilterType.MOTION_START): - to_time = db.get_timestamp(cam, TimeFilterType.FIX) - - from_time = datetime.fromtimestamp(from_time) - to_time = datetime.fromtimestamp(to_time) - - recdir = get_recordings_path(cam) - cam_files = [{ - 'cam': cam, - 'name': file, - 'size': os.path.getsize(os.path.join(recdir, file))} - for file in os.listdir(recdir) - if valid_recording_name(file) and from_time < filename_to_datetime(file) <= to_time] - cam_files.sort(key=lambda file: file['name']) - - if cam_files: - last = cam_files[len(cam_files)-1] - fullpath = os.path.join(recdir, last['name']) - if camutil.has_handle(fullpath): - logger.debug(f'get_recordings_files: file {fullpath} has opened handle, ignoring it') - cam_files.pop() - files.extend(cam_files) - - if limit > 0: - files = files[:limit] - - return files - - -async def process_fragments(camera: int, - filename: str, - fragments: List[Tuple[int, int]]) -> None: - time = filename_to_datetime(filename) - - rec_dir = get_recordings_path(camera) - motion_dir = get_motion_path(camera) - if not os.path.exists(motion_dir): - os.mkdir(motion_dir) - - for fragment in fragments: - start, end = fragment - - start -= config['motion']['padding'] - end += config['motion']['padding'] - - if start < 0: - start = 0 - - duration = end - start - - dt1 = (time + timedelta(seconds=start)).strftime(datetime_format) - dt2 = (time + timedelta(seconds=end)).strftime(datetime_format) - - await camutil.ffmpeg_cut(input=os.path.join(rec_dir, filename), - output=os.path.join(motion_dir, f'{dt1}__{dt2}.mp4'), - start_pos=start, - duration=duration) - - if fragments and 'telegram' in config['motion'] and config['motion']['telegram']: - asyncio.ensure_future(motion_notify_tg(camera, filename, fragments)) - - -async def motion_notify_tg(camera: int, - filename: str, - fragments: List[Tuple[int, int]]): - dt_file = filename_to_datetime(filename) - fmt = '%H:%M:%S' - - text = f'Camera: {camera}\n' - text += f'Original file: {filename} ' - text += _tg_links(TelegramLinkType.ORIGINAL_FILE, camera, filename) - - for start, end in fragments: - start -= config['motion']['padding'] - end += config['motion']['padding'] - - if start < 0: - start = 0 - - duration = end - start - if duration < 0: - duration = 0 - - dt1 = dt_file + timedelta(seconds=start) - dt2 = dt_file + timedelta(seconds=end) - - text += f'\nFragment: {duration}s, {dt1.strftime(fmt)}-{dt2.strftime(fmt)} ' - text += _tg_links(TelegramLinkType.FRAGMENT, camera, f'{dt1.strftime(datetime_format)}__{dt2.strftime(datetime_format)}.mp4') - - await telegram.send_message(text) - - -def _tg_links(link_type: TelegramLinkType, - camera: int, - file: str) -> str: - links = [] - for link_name, link_template in config['telegram'][f'{link_type.value}_url_templates']: - link = link_template.replace('{camera}', str(camera)).replace('{file}', file) - links.append(f'{link_name}') - return ' '.join(links) - - -async def fix_job() -> None: - global fix_job_running - logger.debug('fix_job: starting') - - if fix_job_running: - logger.error('fix_job: already running') - return - - try: - fix_job_running = True - for cam in config['camera'].keys(): - files = get_recordings_files(cam, TimeFilterType.FIX) - if not files: - logger.debug(f'fix_job: no files for camera {cam}') - continue - - logger.debug(f'fix_job: got %d files for camera {cam}' % (len(files),)) - - for file in files: - fullpath = os.path.join(get_recordings_path(cam), file['name']) - await camutil.ffmpeg_recreate(fullpath) - timestamp = filename_to_datetime(file['name']) - if timestamp: - db.set_timestamp(cam, TimeFilterType.FIX, timestamp) - - finally: - fix_job_running = False - - -async def cleanup_job() -> None: - def fn2dt(name: str) -> datetime: - name = os.path.basename(name) - - if name.startswith('record_'): - return datetime.strptime(re.match(r'record_(.*?)\.mp4', name).group(1), datetime_format) - - m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.mp4', name) - if m: - return datetime.strptime(m.group(1), datetime_format) - - raise ValueError(f'unrecognized filename format: {name}') - - def compare(i1: str, i2: str) -> int: - dt1 = fn2dt(i1) - dt2 = fn2dt(i2) - - if dt1 < dt2: - return -1 - elif dt1 > dt2: - return 1 - else: - return 0 - - global cleanup_job_running - logger.debug('cleanup_job: starting') - - if cleanup_job_running: - logger.error('cleanup_job: already running') - return - - try: - cleanup_job_running = True - - gb = float(1 << 30) - for storage in config['storages']: - if os.path.exists(storage['mountpoint']): - total, used, free = shutil.disk_usage(storage['mountpoint']) - free_gb = free // gb - if free_gb < config['cleanup_min_gb']: - # print(f"{storage['mountpoint']}: free={free}, free_gb={free_gb}") - cleaned = 0 - files = [] - for cam in storage['cams']: - for _dir in (config['camera'][cam]['recordings_path'], config['camera'][cam]['motion_path']): - files += list(map(lambda file: os.path.join(_dir, file), os.listdir(_dir))) - files = list(filter(lambda path: os.path.isfile(path) and path.endswith('.mp4'), files)) - files.sort(key=cmp_to_key(compare)) - - for file in files: - size = os.stat(file).st_size - try: - os.unlink(file) - cleaned += size - except OSError as e: - logger.exception(e) - if (free + cleaned) // gb >= config['cleanup_min_gb']: - break - else: - logger.error(f"cleanup_job: {storage['mountpoint']} not found") - finally: - cleanup_job_running = False - - -fix_job_running = False -cleanup_job_running = False - -datetime_format = '%Y-%m-%d-%H.%M.%S' -datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}' -db: Optional[IPCamServerDatabase] = None -server: Optional[IPCamWebServer] = None -logger = logging.getLogger(__name__) - - -# start of the program -# -------------------- - -if __name__ == '__main__': - config.load_app('ipcam_server') - - open_database() - - loop = asyncio.get_event_loop() - - try: - scheduler = AsyncIOScheduler(event_loop=loop) - if config['fix_enabled']: - scheduler.add_job(fix_job, 'interval', seconds=config['fix_interval'], misfire_grace_time=None) - - scheduler.add_job(cleanup_job, 'interval', seconds=config['cleanup_interval'], misfire_grace_time=None) - scheduler.start() - except KeyError: - pass - - asyncio.ensure_future(fix_job()) - asyncio.ensure_future(cleanup_job()) - - server = IPCamWebServer(config.get_addr('server.listen')) - server.run() diff --git a/src/mqtt_node_util.py b/src/mqtt_node_util.py deleted file mode 100755 index ce954ae..0000000 --- a/src/mqtt_node_util.py +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 -import os.path - -from time import sleep -from typing import Optional -from argparse import ArgumentParser, ArgumentError - -from home.config import config -from home.mqtt import MqttNode, MqttWrapper, get_mqtt_modules -from home.mqtt import MqttNodesConfig - -mqtt_node: Optional[MqttNode] = None -mqtt: Optional[MqttWrapper] = None - - -if __name__ == '__main__': - nodes_config = MqttNodesConfig() - - parser = ArgumentParser() - parser.add_argument('--node-id', type=str, required=True, choices=nodes_config.get_nodes(only_names=True)) - parser.add_argument('--modules', type=str, choices=get_mqtt_modules(), nargs='*', - help='mqtt modules to include') - parser.add_argument('--switch-relay', choices=[0, 1], type=int, - help='send relay state') - parser.add_argument('--push-ota', type=str, metavar='OTA_FILENAME', - help='push OTA, receives path to firmware.bin') - - config.load_app(parser=parser, no_config=True) - arg = parser.parse_args() - - if arg.switch_relay is not None and 'relay' not in arg.modules: - raise ArgumentError(None, '--relay is only allowed when \'relay\' module included in --modules') - - mqtt = MqttWrapper(randomize_client_id=True, - client_id='mqtt_node_util') - mqtt_node = MqttNode(node_id=arg.node_id, - node_secret=nodes_config.get_node(arg.node_id)['password']) - - mqtt.add_node(mqtt_node) - - # must-have modules - ota_module = mqtt_node.load_module('ota') - mqtt_node.load_module('diagnostics') - - if arg.modules: - for m in arg.modules: - module_instance = mqtt_node.load_module(m) - if m == 'relay' and arg.switch_relay is not None: - module_instance.switchpower(arg.switch_relay == 1) - - try: - mqtt.connect_and_loop(loop_forever=False) - - if arg.push_ota: - if not os.path.exists(arg.push_ota): - raise OSError(f'--push-ota: file \"{arg.push_ota}\" does not exists') - ota_module.push_ota(arg.push_ota, 1) - - while True: - sleep(0.1) - - except KeyboardInterrupt: - mqtt.disconnect() diff --git a/src/openwrt_log_analyzer.py b/src/openwrt_log_analyzer.py deleted file mode 100755 index 96023cd..0000000 --- a/src/openwrt_log_analyzer.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -import home.telegram as telegram - -from home.telegram.config import TelegramChatsConfig -from home.util import validate_mac_address -from typing import Optional -from home.config import config, AppConfigUnit -from home.database import BotsDatabase, SimpleState - - -class OpenwrtLogAnalyzerConfig(AppConfigUnit): - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'database_name': {'type': 'string', 'required': True}, - 'devices': { - 'type': 'dict', - 'keysrules': {'type': 'string'}, - 'valuesrules': { - 'type': 'string', - 'check_with': validate_mac_address - } - }, - 'limit': {'type': 'integer'}, - 'telegram_chat': {'type': 'string'}, - 'aps': { - 'type': 'list', - 'schema': {'type': 'integer'} - } - } - - @staticmethod - def custom_validator(data): - chats = TelegramChatsConfig() - if data['telegram_chat'] not in chats: - return ValueError(f'unknown telegram chat {data["telegram_chat"]}') - - -def main(mac: str, - title: str, - ap: int) -> int: - db = BotsDatabase() - - data = db.get_openwrt_logs(filter_text=mac, - min_id=state['last_id'], - access_point=ap, - limit=config['openwrt_log_analyzer']['limit']) - if not data: - return 0 - - max_id = 0 - for log in data: - if log.id > max_id: - max_id = log.id - - text = '\n'.join(map(lambda s: str(s), data)) - telegram.send_message(f'{title} (AP #{ap})\n\n' + text, config.app_config['telegram_chat']) - - return max_id - - -if __name__ == '__main__': - config.load_app(OpenwrtLogAnalyzerConfig) - for ap in config.app_config['aps']: - dbname = config.app_config['database_name'] - dbname = dbname.replace('.txt', f'-{ap}.txt') - - state = SimpleState(name=dbname, - default={'last_id': 0}) - - max_last_id = 0 - for name, mac in config['devices'].items(): - last_id = main(mac, title=name, ap=ap) - if last_id > max_last_id: - max_last_id = last_id - - if max_last_id: - state['last_id'] = max_last_id diff --git a/src/openwrt_logger.py b/src/openwrt_logger.py deleted file mode 100755 index 82f11ac..0000000 --- a/src/openwrt_logger.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -import os - -from datetime import datetime -from typing import Tuple, List, Optional -from argparse import ArgumentParser -from home.config import config, AppConfigUnit -from home.database import SimpleState -from home.api import WebApiClient - - -class OpenwrtLoggerConfig(AppConfigUnit): - @classmethod - def schema(cls) -> Optional[dict]: - return dict( - database_name_template=dict(type='string', required=True) - ) - - -def parse_line(line: str) -> Tuple[int, str]: - space_pos = line.index(' ') - - date = line[:space_pos] - rest = line[space_pos+1:] - - return ( - int(datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z").timestamp()), - rest - ) - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('--file', type=str, required=True, - help='openwrt log file') - parser.add_argument('--access-point', type=int, required=True, - help='access point number') - - arg = config.load_app(OpenwrtLoggerConfig, parser=parser) - - state = SimpleState(name=config.app_config['database_name_template'].replace('{ap}', str(arg.access_point)), - default=dict(seek=0, size=0)) - fsize = os.path.getsize(arg.file) - if fsize < state['size']: - state['seek'] = 0 - - with open(arg.file, 'r') as f: - if state['seek']: - # jump to the latest read position - f.seek(state['seek']) - - # read till the end of the file - content = f.read() - - # save new position - state['seek'] = f.tell() - state['size'] = fsize - - lines: List[Tuple[int, str]] = [] - - if content != '': - for line in content.strip().split('\n'): - if not line: - continue - - try: - lines.append(parse_line(line)) - except ValueError: - lines.append((0, line)) - - api = WebApiClient() - api.log_openwrt(lines, arg.access_point) diff --git a/src/pio_build.py b/src/pio_build.py deleted file mode 100644 index 1916e5e..0000000 --- a/src/pio_build.py +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env python3 - -if __name__ == '__main__': - print('TODO') \ No newline at end of file diff --git a/src/pio_ini.py b/src/pio_ini.py deleted file mode 100755 index 920c3e5..0000000 --- a/src/pio_ini.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 -import os -import yaml -import re - -from pprint import pprint -from argparse import ArgumentParser, ArgumentError -from home.pio import get_products, platformio_ini -from home.pio.exceptions import ProductConfigNotFoundError - - -def get_config(product: str) -> dict: - config_path = os.path.join( - os.getenv('HOME'), '.config', - 'homekit_pio', f'{product}.yaml' - ) - if not os.path.exists(config_path): - raise ProductConfigNotFoundError(f'{config_path}: product config not found') - - with open(config_path, 'r') as f: - return yaml.safe_load(f) - - -def bsd_walk(product_config: dict, - f: callable): - try: - for define_name, define_extra_params in product_config['build_specific_defines'].items(): - define_name = re.sub(r'^CONFIG_', '', define_name) - kwargs = {} - if isinstance(define_extra_params, dict): - kwargs = define_extra_params - f(define_name, **kwargs) - except KeyError: - pass - - -# 'bsd' means 'build_specific_defines' -def bsd_parser(product_config: dict, - parser: ArgumentParser): - def f(define_name, **kwargs): - arg_kwargs = {} - define_name = define_name.lower().replace('_', '-') - - if 'type' in kwargs: - if kwargs['type'] in ('str', 'enum'): - arg_kwargs['type'] = str - if kwargs['type'] == 'enum' and 'list_config_key' in kwargs: - if not isinstance(product_config[kwargs['list_config_key']], list): - raise TypeError(f'product_config[{kwargs["list_config_key"]}] enum is not list') - if not product_config[kwargs['list_config_key']]: - raise ValueError(f'product_config[{kwargs["list_config_key"]}] enum cannot be empty') - arg_kwargs['choices'] = product_config[kwargs['list_config_key']] - if isinstance(product_config[kwargs['list_config_key']][0], int): - arg_kwargs['type'] = int - elif kwargs['type'] == 'int': - arg_kwargs['type'] = int - elif kwargs['type'] == 'bool': - arg_kwargs['action'] = 'store_true' - arg_kwargs['required'] = False - else: - raise TypeError(f'unsupported type {kwargs["type"]} for define {define_name}') - else: - arg_kwargs['action'] = 'store_true' - - if 'required' not in arg_kwargs: - arg_kwargs['required'] = True - parser.add_argument(f'--{define_name}', **arg_kwargs) - - bsd_walk(product_config, f) - - -def bsd_get(product_config: dict, - arg: object): - defines = {} - enums = [] - def f(define_name, **kwargs): - attr_name = define_name.lower() - attr_value = getattr(arg, attr_name) - if 'type' in kwargs: - if kwargs['type'] == 'enum': - enums.append(f'CONFIG_{define_name}') - defines[f'CONFIG_{define_name}'] = f'HOMEKIT_{attr_value.upper()}' - return - if kwargs['type'] == 'bool': - defines[f'CONFIG_{define_name}'] = True - return - defines[f'CONFIG_{define_name}'] = str(attr_value) - bsd_walk(product_config, f) - return defines, enums - - -if __name__ == '__main__': - products = get_products() - - # first, get the product - product_parser = ArgumentParser(add_help=False) - product_parser.add_argument('--product', type=str, choices=products, required=True, - help='PIO product name') - arg, _ = product_parser.parse_known_args() - if not arg.product: - product = os.path.basename(os.path.realpath(os.getcwd())) - if product not in products: - raise ArgumentError(None, 'invalid product') - else: - product = arg.product - - product_config = get_config(product) - - # then everythingm else - parser = ArgumentParser(parents=[product_parser]) - parser.add_argument('--target', type=str, required=True, choices=product_config['targets'], - help='PIO build target') - parser.add_argument('--platform', default='espressif8266', type=str) - parser.add_argument('--framework', default='arduino', type=str) - parser.add_argument('--upload-port', default='/dev/ttyUSB0', type=str) - parser.add_argument('--monitor-speed', default=115200) - parser.add_argument('--debug', action='store_true') - parser.add_argument('--debug-network', action='store_true') - bsd_parser(product_config, parser) - arg = parser.parse_args() - - if arg.target not in product_config['targets']: - raise ArgumentError(None, f'target {arg.target} not found for product {product}') - - bsd, bsd_enums = bsd_get(product_config, arg) - ini = platformio_ini(product_config=product_config, - target=arg.target, - build_specific_defines=bsd, - build_specific_defines_enums=bsd_enums, - platform=arg.platform, - framework=arg.framework, - upload_port=arg.upload_port, - monitor_speed=arg.monitor_speed, - debug=arg.debug, - debug_network=arg.debug_network) - print(ini) diff --git a/src/polaris_kettle_bot.py b/src/polaris_kettle_bot.py deleted file mode 100755 index 80baef3..0000000 --- a/src/polaris_kettle_bot.py +++ /dev/null @@ -1,746 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import logging -import locale -import queue -import time -import threading -import paho.mqtt.client as mqtt - -from home.telegram import bot -from home.api.types import BotType -from home.mqtt import Mqtt -from home.config import config -from home.util import chunks -from syncleo import ( - Kettle, - PowerType, - DeviceListener, - IncomingMessageListener, - ConnectionStatusListener, - ConnectionStatus -) -import syncleo.protocol as kettle_proto -from typing import Optional, Tuple, List, Union -from collections import namedtuple -from functools import partial -from datetime import datetime -from abc import abstractmethod -from telegram.error import TelegramError -from telegram import ( - ReplyKeyboardMarkup, - InlineKeyboardMarkup, - InlineKeyboardButton, - Message -) -from telegram.ext import ( - CallbackQueryHandler, - MessageHandler, - CommandHandler -) - -logger = logging.getLogger(__name__) -config.load_app('polaris_kettle_bot') - -primary_choices = (70, 80, 90, 100) -all_choices = range( - config['kettle']['temp_min'], - config['kettle']['temp_max']+1, - config['kettle']['temp_step']) - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре:", - invalid_command="Неизвестная команда", - unexpected_callback_data="Ошибка: неверные данные", - disable="❌ Выключить", - server_error="Ошибка сервера", - back="🔙 Назад", - smth_went_wrong="😱 Что-то пошло не так", - - # /status - status_not_connected="😟 Связь с чайником не установлена", - status_on="🟢 Чайник включён (до %d °C)", - status_off="🔴 Чайник выключен", - status_current_temp="Сейчас: %d °C", - status_update_time="Обновлено %s", - status_update_time_fmt="%d %b в %H:%M:%S", - - # /temp - select_temperature="Выберите температуру:", - - # enable/disable - enabling="💤 Чайник включается...", - disabling="💤 Чайник выключается...", - enabled="🟢 Чайник включён.", - enabled_target="%s Цель: %d °C", - enabled_reached="✅ Готово! Чайник вскипел, температура %d °C.", - disabled="✅ Чайник выключен.", - please_wait="⏳ Ожидайте..." -) -bot.lang.en( - start_message="Select command on the keyboard:", - invalid_command="Unknown command", - unexpected_callback_data="Unexpected callback data", - disable="❌ Turn OFF", - server_error="Server error", - back="🔙 Back", - smth_went_wrong="😱 Something went wrong", - - # /status - status_not_connected="😟 No connection", - status_on="🟢 Turned ON! Target: %d °C", - status_off="🔴 Turned OFF", - status_current_temp="Now: %d °C", - status_update_time="Updated on %s", - status_update_time_fmt="%b %d, %Y at %H:%M:%S", - - # /temp - select_temperature="Select a temperature:", - - # enable/disable - enabling="💤 Turning on...", - disabling="💤 Turning off...", - enabled="🟢 The kettle is turned ON.", - enabled_target="%s Target: %d °C", - enabled_reached="✅ Done! The kettle has boiled, the temperature is %d °C.", - disabled="✅ The kettle is turned OFF.", - please_wait="⏳ Please wait..." -) - -kc: Optional[KettleController] = None -RenderedContent = Tuple[str, Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]]] -tasks_lock = threading.Lock() - - -def run_tasks(tasks: queue.SimpleQueue, done: callable): - def next_task(r: Optional[kettle_proto.MessageResponse]): - if r is not None: - try: - assert r is not False, 'server error' - except AssertionError as exc: - logger.exception(exc) - tasks_lock.release() - return done(False) - - if not tasks.empty(): - task = tasks.get() - args = task[1:] - args.append(next_task) - f = getattr(kc.kettle, task[0]) - f(*args) - else: - tasks_lock.release() - return done(True) - - tasks_lock.acquire() - next_task(None) - - -def temperature_emoji(temp: int) -> str: - if temp > 90: - return '🔥' - elif temp >= 40: - return '♨️' - elif temp >= 35: - return '🌡' - else: - return '❄️' - - -class KettleInfoListener: - @abstractmethod - def info_updated(self, field: str): - pass - - -# class that holds data coming from the kettle over mqtt -class KettleInfo: - update_time: int - _mode: Optional[PowerType] - _temperature: Optional[int] - _target_temperature: Optional[int] - _update_listener: KettleInfoListener - - def __init__(self, update_listener: KettleInfoListener): - self.update_time = 0 - self._mode = None - self._temperature = None - self._target_temperature = None - self._update_listener = update_listener - - def _update(self, field: str): - self.update_time = int(time.time()) - if self._update_listener: - self._update_listener.info_updated(field) - - @property - def temperature(self) -> int: - return self._temperature - - @temperature.setter - def temperature(self, value: int): - self._temperature = value - self._update('temperature') - - @property - def mode(self) -> PowerType: - return self._mode - - @mode.setter - def mode(self, value: PowerType): - self._mode = value - self._update('mode') - - @property - def target_temperature(self) -> int: - return self._target_temperature - - @target_temperature.setter - def target_temperature(self, value: int): - self._target_temperature = value - self._update('target_temperature') - - -class KettleController(threading.Thread, - Mqtt, - DeviceListener, - IncomingMessageListener, - KettleInfoListener, - ConnectionStatusListener): - kettle: Kettle - info: KettleInfo - - _logger: logging.Logger - _stopped: bool - _restart_server_at: int - _lock: threading.Lock - _info_lock: threading.Lock - _accumulated_updates: dict - _info_flushed_time: float - _mqtt_root_topic: str - _muts: List[MessageUpdatingTarget] - - def __init__(self): - # basic setup - Mqtt.__init__(self, clean_session=False) - threading.Thread.__init__(self) - - self._logger = logging.getLogger(self.__class__.__name__) - - self.kettle = Kettle(mac=config['kettle']['mac'], - device_token=config['kettle']['token'], - read_timeout=config['kettle']['read_timeout']) - self.kettle_reconnect() - - # info - self.info = KettleInfo(update_listener=self) - self._accumulated_updates = {} - self._info_flushed_time = 0 - - # mqtt - self._mqtt_root_topic = '/polaris/6/'+config['kettle']['token']+'/#' - self.connect_and_loop(loop_forever=False) - - # thread loop related - self._stopped = False - # self._lock = threading.Lock() - self._info_lock = threading.Lock() - self._restart_server_at = 0 - - # bot - self._muts = [] - self._muts_lock = threading.Lock() - - self.start() - - def kettle_reconnect(self): - self.kettle.discover(wait=False, listener=self) - - def stop_all(self): - self.kettle.stop_all() - self._stopped = True - - def add_updating_message(self, mut: MessageUpdatingTarget): - with self._muts_lock: - for m in self._muts: - if m.user_id == m.user_id and m.user_did_turn_on() or m.user_did_turn_on() != mut.user_did_turn_on(): - m.delete() - self._muts.append(mut) - - # --------------------- - # threading.Thread impl - - def run(self): - while not self._stopped: - updates = [] - deletions = [] - forget = [] - - with self._muts_lock and self._info_lock: - if self._muts and self._accumulated_updates and (self._info_flushed_time == 0 or time.time() - self._info_flushed_time >= 1): - deletions = [] - - for mut in self._muts: - upd = mut.update( - mode=self.info.mode, - current_temp=self.info.temperature, - target_temp=self.info.target_temperature) - - if upd.finished or upd.delete: - forget.append(mut) - - if upd.delete: - deletions.append((mut, upd)) - - elif upd.changed: - updates.append((mut, upd)) - - self._info_flushed_time = time.time() - self._accumulated_updates = {} - - # edit messages - for mut, upd in updates: - self._logger.debug(f'loop: got update: {upd}') - try: - do_edit = True - if upd.finished: - # try to delete the old message and send a new one, to notify user more effectively - try: - bot.delete_message(upd.user_id, upd.message_id) - do_edit = False - except TelegramError as exc: - self._logger.error(f'loop: failed to delete old message (in order to send a new one)') - self._logger.exception(exc) - - if do_edit: - bot.edit_message_text(upd.user_id, upd.message_id, - text=upd.html, - reply_markup=upd.markup) - else: - bot.notify_user(upd.user_id, upd.html, reply_markup=upd.markup) - except TelegramError as exc: - if "Message can't be edited" in exc.message: - self._logger.warning("message can't be edited, adding it to forget list") - forget.append(upd) - - self._logger.error(f'loop: edit_message_text failed for update: {upd}') - self._logger.exception(exc) - - # delete messages - for mut, upd in deletions: - self._logger.debug(f'loop: got deletion: {upd}') - try: - bot.delete_message(upd.user_id, upd.message_id) - except TelegramError as exc: - self._logger.error(f'loop: delete_message failed for update: {upd}') - self._logger.exception(exc) - - # delete muts, if needed - if forget: - with self._muts_lock: - for mut in forget: - self._logger.debug(f'loop: removing mut {mut}') - self._muts.remove(mut) - - time.sleep(0.5) - - # ------------------- - # DeviceListener impl - - def device_updated(self): - self._logger.info(f'device updated: {self.kettle.device.si}') - self.kettle.start_server_if_needed(incoming_message_listener=self, - connection_status_listener=self) - - # ----------------------- - # KettleInfoListener impl - - def info_updated(self, field: str): - with self._info_lock: - newval = getattr(self.info, field) - self._logger.debug(f'info_updated: updated {field}, new value is {newval}') - self._accumulated_updates[field] = newval - - # ---------------------------- - # IncomingMessageListener impl - - def incoming_message(self, message: kettle_proto.Message) -> Optional[kettle_proto.Message]: - self._logger.info(f'incoming message: {message}') - - if isinstance(message, kettle_proto.ModeMessage): - self.info.mode = message.pt - elif isinstance(message, kettle_proto.CurrentTemperatureMessage): - self.info.temperature = message.current_temperature - elif isinstance(message, kettle_proto.TargetTemperatureMessage): - self.info.target_temperature = message.temperature - - return kettle_proto.AckMessage() - - # ----------------------------- - # ConnectionStatusListener impl - - def connection_status_updated(self, status: ConnectionStatus): - self._logger.info(f'connection status updated: {status}') - if status == ConnectionStatus.DISCONNECTED: - self.kettle.stop_all() - self.kettle_reconnect() - - # ------------- - # MQTTBase impl - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - client.subscribe(self._mqtt_root_topic, qos=1) - self._logger.info(f'subscribed to {self._mqtt_root_topic}') - - def on_message(self, client: mqtt.Client, userdata, msg): - try: - topic = msg.topic[len(self._mqtt_root_topic)-2:] - pld = msg.payload.decode() - - self._logger.debug(f'mqtt: on message: topic={topic} pld={pld}') - - if topic == 'state/sensor/temperature': - self.info.temperature = int(float(pld)) - elif topic == 'state/mode': - self.info.mode = PowerType(int(pld)) - elif topic == 'state/temperature': - self.info.target_temperature = int(float(pld)) - - except Exception as e: - self._logger.exception(str(e)) - - -class Renderer: - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'{ctx.lang("settings")}\n\n' - html += ctx.lang('select_place') - return html, None - - @classmethod - def status(cls, ctx: bot.Context, - connected: bool, - mode: PowerType, - current_temp: int, - target_temp: int, - update_time: int) -> RenderedContent: - if not connected: - return cls.not_connected(ctx) - else: - # power status - if mode != PowerType.OFF: - html = ctx.lang('status_on', target_temp) - else: - html = ctx.lang('status_off') - - # current temperature - html += '\n' - html += ctx.lang('status_current_temp', current_temp) - - # updated on - html += '\n' - html += cls.updated(ctx, update_time) - - return html, None - - @classmethod - def temp(cls, ctx: bot.Context, choices) -> RenderedContent: - buttons = [] - for chunk in chunks(choices, 5): - buttons.append([f'{temperature_emoji(n)} {n}' for n in chunk]) - buttons.append([ctx.lang('back')]) - return ctx.lang('select_temperature'), ReplyKeyboardMarkup(buttons) - - @classmethod - def turned_on(cls, ctx: bot.Context, - target_temp: int, - current_temp: int, - mode: PowerType, - update_time: Optional[int] = None, - reached=False, - no_keyboard=False) -> RenderedContent: - if mode == PowerType.OFF and not reached: - html = ctx.lang('enabling') - else: - if not reached: - html = ctx.lang('enabled') - - # target temperature - html += '\n' - html += ctx.lang('enabled_target', temperature_emoji(target_temp), target_temp) - - # current temperature - html += '\n' - html += temperature_emoji(current_temp) + ' ' - html += ctx.lang('status_current_temp', current_temp) - else: - html = ctx.lang('enabled_reached', current_temp) - - # updated on - if not reached and update_time is not None: - html += '\n' - html += cls.updated(ctx, update_time) - - return html, None if no_keyboard else cls.wait_buttons(ctx) - - @classmethod - def turned_off(cls, ctx: bot.Context, - mode: PowerType, - update_time: Optional[int] = None, - reached=False, - no_keyboard=False) -> RenderedContent: - if mode != PowerType.OFF: - html = ctx.lang('disabling') - else: - html = ctx.lang('disabled') - - # updated on - if not reached and update_time is not None: - html += '\n' - html += cls.updated(ctx, update_time) - - return html, None if no_keyboard else cls.wait_buttons(ctx) - - @classmethod - def not_connected(cls, ctx: bot.Context) -> RenderedContent: - return ctx.lang('status_not_connected'), None - - @classmethod - def smth_went_wrong(cls, ctx: bot.Context) -> RenderedContent: - html = ctx.lang('smth_went_wrong') - return html, None - - @classmethod - def updated(cls, ctx: bot.Context, update_time: int): - locale_bak = locale.getlocale(locale.LC_TIME) - locale.setlocale(locale.LC_TIME, 'ru_RU.UTF-8' if ctx.user_lang == 'ru' else 'en_US.UTF-8') - dt = datetime.fromtimestamp(update_time) - html = ctx.lang('status_update_time', dt.strftime(ctx.lang('status_update_time_fmt'))) - locale.setlocale(locale.LC_TIME, locale_bak) - return html - - @classmethod - def wait_buttons(cls, ctx: bot.Context): - return InlineKeyboardMarkup([ - [ - InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait') - ] - ]) - - -MUTUpdate = namedtuple('MUTUpdate', 'message_id, user_id, finished, changed, delete, html, markup') - - -class MessageUpdatingTarget: - ctx: bot.Context - message: Message - user_target_temp: Optional[int] - user_enabled_power_mode: PowerType - initial_power_mode: PowerType - need_to_delete: bool - rendered_content: Optional[RenderedContent] - - def __init__(self, - ctx: bot.Context, - message: Message, - user_enabled_power_mode: PowerType, - initial_power_mode: PowerType, - user_target_temp: Optional[int] = None): - self.ctx = ctx - self.message = message - self.initial_power_mode = initial_power_mode - self.user_enabled_power_mode = user_enabled_power_mode - self.ignore_pm = initial_power_mode is PowerType.OFF and self.user_did_turn_on() - self.user_target_temp = user_target_temp - self.need_to_delete = False - self.rendered_content = None - self.last_reported_temp = None - - def set_rendered_content(self, content: RenderedContent): - self.rendered_content = content - - def rendered_content_changed(self, content: RenderedContent) -> bool: - return content != self.rendered_content - - def update(self, - mode: PowerType, - current_temp: int, - target_temp: int) -> MUTUpdate: - - # determine whether status updating is finished - finished = False - reached = False - if self.ignore_pm: - if mode != PowerType.OFF: - self.ignore_pm = False - elif mode == PowerType.OFF: - reached = True - if self.user_did_turn_on(): - # when target is 100 degrees, this kettle sometimes turns off at 91, sometimes at 95, sometimes at 98. - # it's totally unpredictable, so in this case, we keep updating the message until it reaches at least 97 - # degrees, or if temperature started dropping. - if self.user_target_temp < 100 \ - or current_temp >= self.user_target_temp - 3 \ - or current_temp < self.last_reported_temp: - finished = True - else: - finished = True - - self.last_reported_temp = current_temp - - # render message - if self.user_did_turn_on(): - rc = Renderer.turned_on(self.ctx, - target_temp=target_temp, - current_temp=current_temp, - mode=mode, - reached=reached, - no_keyboard=finished) - else: - rc = Renderer.turned_off(self.ctx, - mode=mode, - reached=reached, - no_keyboard=finished) - - changed = self.rendered_content_changed(rc) - update = MUTUpdate(message_id=self.message.message_id, - user_id=self.ctx.user_id, - finished=finished, - changed=changed, - delete=self.need_to_delete, - html=rc[0], - markup=rc[1]) - if changed: - self.set_rendered_content(rc) - return update - - def user_did_turn_on(self) -> bool: - return self.user_enabled_power_mode in (PowerType.ON, PowerType.CUSTOM) - - def delete(self): - self.need_to_delete = True - - @property - def user_id(self) -> int: - return self.ctx.user_id - - -@bot.handler(command='status') -def status(ctx: bot.Context) -> None: - text, markup = Renderer.status(ctx, - connected=kc.kettle.is_connected(), - mode=kc.info.mode, - current_temp=kc.info.temperature, - target_temp=kc.info.target_temperature, - update_time=kc.info.update_time) - ctx.reply(text, markup=markup) - - -@bot.handler(command='temp') -def temp(ctx: bot.Context) -> None: - text, markup = Renderer.temp( - ctx, choices=all_choices) - ctx.reply(text, markup=markup) - - -def enable(temp: int, ctx: bot.Context) -> None: - if not kc.kettle.is_connected(): - text, markup = Renderer.not_connected(ctx) - ctx.reply(text, markup=markup) - return - - tasks = queue.SimpleQueue() - if temp == 100: - power_mode = PowerType.ON - else: - power_mode = PowerType.CUSTOM - tasks.put(['set_target_temperature', temp]) - tasks.put(['set_power', power_mode]) - - def done(ok: bool): - if not ok: - html, markup = Renderer.smth_went_wrong(ctx) - else: - html, markup = Renderer.turned_on(ctx, - target_temp=temp, - current_temp=kc.info.temperature, - mode=kc.info.mode) - message = ctx.reply(html, markup=markup) - logger.debug(f'ctx.reply returned message: {message}') - - if ok: - mut = MessageUpdatingTarget(ctx, message, - initial_power_mode=kc.info.mode, - user_enabled_power_mode=power_mode, - user_target_temp=temp) - mut.set_rendered_content((html, markup)) - kc.add_updating_message(mut) - - run_tasks(tasks, done) - - -@bot.handler(message='disable') -def disable(ctx: bot.Context): - if not kc.kettle.is_connected(): - text, markup = Renderer.not_connected(ctx) - ctx.reply(text, markup=markup) - return - - def done(ok: bool): - mode = kc.info.mode - if not ok: - html, markup = Renderer.smth_went_wrong(ctx) - else: - kw = {} - if mode == PowerType.OFF: - kw['reached'] = True - kw['no_keyboard'] = True - html, markup = Renderer.turned_off(ctx, mode=mode, **kw) - message = ctx.reply(html, markup=markup) - logger.debug(f'ctx.reply returned message: {message}') - - if ok and mode != PowerType.OFF: - mut = MessageUpdatingTarget(ctx, message, - initial_power_mode=mode, - user_enabled_power_mode=PowerType.OFF) - mut.set_rendered_content((html, markup)) - kc.add_updating_message(mut) - - tasks = queue.SimpleQueue() - tasks.put(['set_power', PowerType.OFF]) - run_tasks(tasks, done) - - -@bot.handler(message='back') -def back(ctx: bot.Context): - bot.start(ctx) - - -@bot.defaultreplymarkup -def defaultmarkup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [ - [f'{temperature_emoji(n)} {n}' for n in primary_choices], - [ctx.lang('disable')] - ] - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - for temp in primary_choices: - bot.handler(text=f'{temperature_emoji(temp)} {temp}')(partial(enable, temp)) - - for temp in all_choices: - bot.handler(text=f'{temperature_emoji(temp)} {temp}')(partial(enable, temp)) - - kc = KettleController() - - if 'api' in config: - bot.enable_logging(BotType.POLARIS_KETTLE) - - bot.run() - - # bot library handles signals, so when sigterm or something like that happens, we should stop all other threads here - kc.stop_all() diff --git a/src/polaris_kettle_util.py b/src/polaris_kettle_util.py deleted file mode 100755 index 12c4388..0000000 --- a/src/polaris_kettle_util.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: BSD-3-Clause - -import logging -import sys -import paho.mqtt.client as mqtt - -from typing import Optional -from argparse import ArgumentParser -from queue import SimpleQueue -from home.mqtt import Mqtt -from home.config import config -from syncleo import ( - Kettle, - PowerType, - protocol as kettle_proto -) - -k: Optional[Kettle] = None -logger = logging.getLogger(__name__) -control_tasks = SimpleQueue() - - -class MqttServer(Mqtt): - def __init__(self): - super().__init__(clean_session=False) - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - logger.info("subscribing to #") - client.subscribe('#', qos=1) - - def on_message(self, client: mqtt.Client, userdata, msg): - try: - print(msg.topic, msg.payload) - - except Exception as e: - logger.exception(str(e)) - - -def kettle_connection_established(response: kettle_proto.MessageResponse): - try: - assert isinstance(response, kettle_proto.AckMessage), f'ACK expected, but received: {response}' - except AssertionError: - k.stop_all() - return - - def next_task(response: kettle_proto.MessageResponse): - try: - assert response is not False, 'server error' - except AssertionError: - k.stop_all() - return - - if not control_tasks.empty(): - task = control_tasks.get() - f, args = task(k) - args.append(next_task) - f(*args) - else: - k.stop_all() - - next_task(response) - - -def main(): - tempmin = 30 - tempmax = 100 - tempstep = 5 - - parser = ArgumentParser() - parser.add_argument('-m', dest='mode', required=True, type=str, choices=('mqtt', 'control')) - parser.add_argument('--on', action='store_true') - parser.add_argument('--off', action='store_true') - parser.add_argument('-t', '--temperature', dest='temp', type=int, default=tempmax, - choices=range(tempmin, tempmax+tempstep, tempstep)) - - arg = config.load_app('polaris_kettle_util', use_cli=True, parser=parser) - - if arg.mode == 'mqtt': - server = MqttServer() - try: - server.connect_and_loop(loop_forever=True) - except KeyboardInterrupt: - pass - - elif arg.mode == 'control': - if arg.on and arg.off: - raise RuntimeError('--on and --off are mutually exclusive') - - if arg.off: - control_tasks.put(lambda k: (k.set_power, [PowerType.OFF])) - else: - if arg.temp == tempmax: - control_tasks.put(lambda k: (k.set_power, [PowerType.ON])) - else: - control_tasks.put(lambda k: (k.set_target_temperature, [arg.temp])) - control_tasks.put(lambda k: (k.set_power, [PowerType.CUSTOM])) - - k = Kettle(mac=config['kettle']['mac'], device_token=config['kettle']['token']) - info = k.discover() - if not info: - print('no device found.') - return 1 - - print('found service:', info) - k.start_server_if_needed(kettle_connection_established) - - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/src/pump_bot.py b/src/pump_bot.py deleted file mode 100755 index 25f06fd..0000000 --- a/src/pump_bot.py +++ /dev/null @@ -1,255 +0,0 @@ -#!/usr/bin/env python3 -from enum import Enum -from typing import Optional -from telegram import ReplyKeyboardMarkup, User -from time import time -from datetime import datetime - -from home.config import config, is_development_mode -from home.telegram import bot -from home.telegram._botutil import user_any_name -from home.relay.sunxi_h3_client import RelayClient -from home.api.types import BotType -from home.mqtt import MqttNode, MqttWrapper, MqttPayload -from home.mqtt.module.relay import MqttPowerStatusPayload, MqttRelayModule -from home.mqtt.module.temphum import MqttTemphumDataPayload -from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload - - -config.load_app('pump_bot') - -mqtt: Optional[MqttWrapper] = None -mqtt_node: Optional[MqttNode] = None -mqtt_relay_module: Optional[MqttRelayModule] = None -time_format = '%d.%m.%Y, %H:%M:%S' - -watering_mcu_status = { - 'last_time': 0, - 'last_boot_time': 0, - 'relay_opened': False, - 'ambient_temp': 0.0, - 'ambient_rh': 0.0, -} - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - unknown_command="Неизвестная команда", - - enable="Включить", - enable_silently="Включить тихо", - enabled="Насос включен ✅", - - disable="Выключить", - disable_silently="Выключить тихо", - disabled="Насос выключен ❌", - - start_watering="Включить полив", - stop_watering="Отключить полив", - - status="Статус насоса", - watering_status="Статус полива", - - done="Готово 👌", - sent="Команда отправлена", - - user_action_notification='Пользователь %s %s насос.', - user_watering_notification='Пользователь %s %s полив.', - 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 %s turned the pump %s.', - user_watering_notification='User %s %s the watering.', - user_action_on="ON", - user_action_off="OFF", - user_action_watering_on="started", - user_action_watering_off="stopped", -) - - -class UserAction(Enum): - ON = 'on' - OFF = 'off' - WATERING_ON = 'watering_on' - WATERING_OFF = 'watering_off' - - -def get_relay() -> RelayClient: - relay = RelayClient(host=config['relay']['ip'], port=config['relay']['port']) - relay.connect() - return relay - - -def on(ctx: bot.Context, silent=False) -> None: - get_relay().on() - ctx.reply(ctx.lang('done')) - if not silent: - notify(ctx.user, UserAction.ON) - - -def off(ctx: bot.Context, silent=False) -> None: - get_relay().off() - ctx.reply(ctx.lang('done')) - if not silent: - notify(ctx.user, UserAction.OFF) - - -def watering_on(ctx: bot.Context) -> None: - mqtt_relay_module.switchpower(True, config.get('mqtt_water_relay.secret')) - ctx.reply(ctx.lang('sent')) - notify(ctx.user, UserAction.WATERING_ON) - - -def watering_off(ctx: bot.Context) -> None: - mqtt_relay_module.switchpower(False, config.get('mqtt_water_relay.secret')) - ctx.reply(ctx.lang('sent')) - notify(ctx.user, UserAction.WATERING_OFF) - - -def notify(user: User, action: UserAction) -> None: - notification_key = 'user_watering_notification' if action in (UserAction.WATERING_ON, UserAction.WATERING_OFF) else 'user_action_notification' - def text_getter(lang: str): - action_name = bot.lang.get(f'user_action_{action.value}', lang) - user_name = user_any_name(user) - return 'ℹ ' + bot.lang.get(notification_key, lang, - user.id, user_name, action_name) - - bot.notify_all(text_getter, exclude=(user.id,)) - - -@bot.handler(message='enable') -def enable_handler(ctx: bot.Context) -> None: - on(ctx) - - -@bot.handler(message='enable_silently') -def enable_s_handler(ctx: bot.Context) -> None: - on(ctx, True) - - -@bot.handler(message='disable') -def disable_handler(ctx: bot.Context) -> None: - off(ctx) - - -@bot.handler(message='start_watering') -def start_watering(ctx: bot.Context) -> None: - watering_on(ctx) - - -@bot.handler(message='stop_watering') -def stop_watering(ctx: bot.Context) -> None: - watering_off(ctx) - - -@bot.handler(message='disable_silently') -def disable_s_handler(ctx: bot.Context) -> None: - off(ctx, True) - - -@bot.handler(message='status') -def status(ctx: bot.Context) -> None: - ctx.reply( - ctx.lang('enabled') if get_relay().status() == 'on' else ctx.lang('disabled') - ) - - -def _get_timestamp_as_string(timestamp: int) -> str: - if timestamp != 0: - return datetime.fromtimestamp(timestamp).strftime(time_format) - else: - return 'unknown' - - -@bot.handler(message='watering_status') -def watering_status(ctx: bot.Context) -> None: - buf = '' - if 0 < watering_mcu_status["last_time"] < time()-1800: - buf += 'WARNING! long time no reports from mcu! maybe something\'s wrong\n' - buf += f'last report time: {_get_timestamp_as_string(watering_mcu_status["last_time"])}\n' - if watering_mcu_status["last_boot_time"] != 0: - buf += f'boot time: {_get_timestamp_as_string(watering_mcu_status["last_boot_time"])}\n' - buf += 'relay opened: ' + ('yes' if watering_mcu_status['relay_opened'] else 'no') + '\n' - buf += f'ambient temp & humidity: {watering_mcu_status["ambient_temp"]} °C, {watering_mcu_status["ambient_rh"]}%' - ctx.reply(buf) - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [] - if ctx.user_id in config['bot']['silent_users']: - buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')]) - buttons.append([ctx.lang('enable'), ctx.lang('disable'), ctx.lang('status')],) - buttons.append([ctx.lang('start_watering'), ctx.lang('stop_watering'), ctx.lang('watering_status')]) - - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -def mqtt_payload_callback(mqtt_node: MqttNode, payload: MqttPayload): - global watering_mcu_status - - types_the_node_can_send = ( - InitialDiagnosticsPayload, - DiagnosticsPayload, - MqttTemphumDataPayload, - MqttPowerStatusPayload - ) - for cl in types_the_node_can_send: - if isinstance(payload, cl): - watering_mcu_status['last_time'] = int(time()) - break - - if isinstance(payload, InitialDiagnosticsPayload): - watering_mcu_status['last_boot_time'] = int(time()) - - elif isinstance(payload, MqttTemphumDataPayload): - watering_mcu_status['ambient_temp'] = payload.temp - watering_mcu_status['ambient_rh'] = payload.rh - - elif isinstance(payload, MqttPowerStatusPayload): - watering_mcu_status['relay_opened'] = payload.opened - - -if __name__ == '__main__': - mqtt = MqttWrapper() - mqtt_node = MqttNode(node_id=config.get('mqtt_water_relay.node_id')) - if is_development_mode(): - mqtt_node.load_module('diagnostics') - - mqtt_node.load_module('temphum') - mqtt_relay_module = mqtt_node.load_module('relay') - - mqtt_node.add_payload_callback(mqtt_payload_callback) - - mqtt.connect_and_loop(loop_forever=False) - - bot.enable_logging(BotType.PUMP) - bot.run() - - try: - mqtt.disconnect() - except: - pass diff --git a/src/pump_mqtt_bot.py b/src/pump_mqtt_bot.py deleted file mode 100755 index 4036d3a..0000000 --- a/src/pump_mqtt_bot.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python3 -import datetime - -from enum import Enum -from typing import Optional -from telegram import ReplyKeyboardMarkup, User - -from home.config import config -from home.telegram import bot -from home.telegram._botutil import user_any_name -from home.mqtt import MqttNode, MqttPayload -from home.mqtt.module.relay import MqttRelayState -from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload - - -config.load_app('pump_mqtt_bot') - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - start_message_no_access="Доступ запрещён. Вы можете отправить заявку на получение доступа.", - unknown_command="Неизвестная команда", - send_access_request="Отправить заявку", - management="Админка", - - enable="Включить", - enabled="Включен ✅", - - disable="Выключить", - disabled="Выключен ❌", - - status="Статус", - status_updated=' (обновлено %s)', - - done="Готово 👌", - user_action_notification='Пользователь %s %s насос.', - 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 %s turned the pump %s.', - user_action_on="ON", - user_action_off="OFF", - - date_yday="yesterday", - date_yyday="the day before yesterday", - date_at="at" -) - - -mqtt: Optional[MqttNode] = None -relay_state = MqttRelayState() - - -class UserAction(Enum): - ON = 'on' - OFF = 'off' - - -def on_mqtt_message(home_id, message: MqttPayload): - if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): - kwargs = dict(rssi=message.rssi, enabled=message.flags.state) - if isinstance(message, InitialDiagnosticsPayload): - kwargs['fw_version'] = message.fw_version - relay_state.update(**kwargs) - - -def notify(user: User, action: UserAction) -> None: - def text_getter(lang: str): - action_name = bot.lang.get(f'user_action_{action.value}', lang) - user_name = user_any_name(user) - return 'ℹ ' + bot.lang.get('user_action_notification', lang, - user.id, user_name, action_name) - - bot.notify_all(text_getter, exclude=(user.id,)) - - -@bot.handler(message='enable') -def enable_handler(ctx: bot.Context) -> None: - mqtt.set_power(config['mqtt']['home_id'], True) - ctx.reply(ctx.lang('done')) - notify(ctx.user, UserAction.ON) - - -@bot.handler(message='disable') -def disable_handler(ctx: bot.Context) -> None: - mqtt.set_power(config['mqtt']['home_id'], False) - ctx.reply(ctx.lang('done')) - notify(ctx.user, UserAction.OFF) - - -@bot.handler(message='status') -def status(ctx: bot.Context) -> None: - label = ctx.lang('enabled') if relay_state.enabled else ctx.lang('disabled') - if relay_state.ever_updated: - date_label = '' - today = datetime.date.today() - if today != relay_state.update_time.date(): - yday = today - datetime.timedelta(days=1) - yyday = today - datetime.timedelta(days=2) - if yday == relay_state.update_time.date(): - date_label = ctx.lang('date_yday') - elif yyday == relay_state.update_time.date(): - date_label = ctx.lang('date_yyday') - else: - date_label = relay_state.update_time.strftime('%d.%m.%Y') - date_label += ' ' - date_label += ctx.lang('date_at') + ' ' - date_label += relay_state.update_time.strftime('%H:%M') - label += ctx.lang('status_updated', date_label) - ctx.reply(label) - - -def start(ctx: bot.Context) -> None: - if ctx.user_id in config['bot']['users'] or ctx.user_id in config['bot']['admin_users']: - ctx.reply(ctx.lang('start_message')) - else: - buttons = [ - [ctx.lang('send_access_request')] - ] - ctx.reply(ctx.lang('start_message_no_access'), markup=ReplyKeyboardMarkup(buttons, one_time_keyboard=False)) - - -@bot.exceptionhandler -def exception_handler(e: Exception, ctx: bot.Context) -> bool: - return False - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [[ctx.lang('enable'), ctx.lang('disable')], [ctx.lang('status')]] - if ctx.user_id in config['bot']['admin_users']: - buttons.append([ctx.lang('management')]) - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - mqtt = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'], - secret=config['mqtt']['home_secret'])) - mqtt.set_message_callback(on_mqtt_message) - mqtt.connect_and_loop(loop_forever=False) - - # bot.enable_logging(BotType.PUMP_MQTT) - bot.run(start_handler=start) - - mqtt.disconnect() diff --git a/src/relay_mqtt_bot.py b/src/relay_mqtt_bot.py deleted file mode 100755 index 020dc08..0000000 --- a/src/relay_mqtt_bot.py +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env python3 -import sys - -from enum import Enum -from typing import Optional, Union -from telegram import ReplyKeyboardMarkup -from functools import partial - -from home.config import config, AppConfigUnit, Translation -from home.telegram import bot -from home.telegram.config import TelegramBotConfig -from home.mqtt import MqttPayload, MqttNode, MqttWrapper, MqttModule -from home.mqtt import MqttNodesConfig -from home.mqtt.module.relay import MqttRelayModule, MqttRelayState -from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload - - -if __name__ != '__main__': - print(f'this script can not be imported as module', file=sys.stderr) - sys.exit(1) - - -mqtt_nodes_config = MqttNodesConfig() - - -class RelayMqttBotConfig(AppConfigUnit, TelegramBotConfig): - NAME = 'relay_mqtt_bot' - - _strings: Translation - - def __init__(self): - super().__init__() - self._strings = Translation('mqtt_nodes') - - @classmethod - def schema(cls) -> Optional[dict]: - return { - **super(TelegramBotConfig).schema(), - 'relay_nodes': { - 'type': 'list', - 'required': True, - 'schema': { - 'type': 'string' - } - }, - } - - @staticmethod - def custom_validator(data): - relay_node_names = mqtt_nodes_config.get_nodes(filters=('relay',), only_names=True) - for node in data['relay_nodes']: - if node not in relay_node_names: - raise ValueError(f'unknown relay node "{node}"') - - def get_relay_name_translated(self, lang: str, relay_name: str) -> str: - return self._strings.get(lang)[relay_name]['relay'] - - -config.load_app(RelayMqttBotConfig) - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - unknown_command="Неизвестная команда", - done="Готово 👌", -) -bot.lang.en( - start_message="Select command on the keyboard", - unknown_command="Unknown command", - done="Done 👌", -) - - -type_emojis = { - 'lamp': '💡' -} -status_emoji = { - 'on': '✅', - 'off': '❌' -} - - -mqtt: MqttWrapper -relay_nodes: dict[str, Union[MqttRelayModule, MqttModule]] = {} -relay_states: dict[str, MqttRelayState] = {} - - -class UserAction(Enum): - ON = 'on' - OFF = 'off' - - -def on_mqtt_message(node: MqttNode, - message: MqttPayload): - if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): - kwargs = dict(rssi=message.rssi, enabled=message.flags.state) - if isinstance(message, InitialDiagnosticsPayload): - kwargs['fw_version'] = message.fw_version - if node.id not in relay_states: - relay_states[node.id] = MqttRelayState() - relay_states[node.id].update(**kwargs) - - -async def enable_handler(node_id: str, ctx: bot.Context) -> None: - relay_nodes[node_id].switchpower(True) - await ctx.reply(ctx.lang('done')) - - -async def disable_handler(node_id: str, ctx: bot.Context) -> None: - relay_nodes[node_id].switchpower(False) - await ctx.reply(ctx.lang('done')) - - -async def start(ctx: bot.Context) -> None: - await ctx.reply(ctx.lang('start_message')) - - -@bot.exceptionhandler -async def exception_handler(e: Exception, ctx: bot.Context) -> bool: - return False - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [] - for node_id in config.app_config['relay_nodes']: - node_data = mqtt_nodes_config.get_node(node_id) - type_emoji = type_emojis[node_data['relay']['device_type']] - row = [f'{type_emoji}{status_emoji[i.value]} {config.app_config.get_relay_name_translated(ctx.user_lang, node_id)}' - for i in UserAction] - buttons.append(row) - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -devices = [] -mqtt = MqttWrapper(client_id='relay_mqtt_bot') -for node_id in config.app_config['relay_nodes']: - node_data = mqtt_nodes_config.get_node(node_id) - mqtt_node = MqttNode(node_id=node_id, - node_secret=node_data['password']) - module_kwargs = {} - try: - if node_data['relay']['legacy_topics']: - module_kwargs['legacy_topics'] = True - except KeyError: - pass - relay_nodes[node_id] = mqtt_node.load_module('relay', **module_kwargs) - mqtt_node.add_payload_callback(on_mqtt_message) - mqtt.add_node(mqtt_node) - - type_emoji = type_emojis[node_data['relay']['device_type']] - - for action in UserAction: - messages = [] - for _lang in Translation.LANGUAGES: - _label = config.app_config.get_relay_name_translated(_lang, node_id) - messages.append(f'{type_emoji}{status_emoji[action.value]} {_label}') - bot.handler(texts=messages)(partial(enable_handler if action == UserAction.ON else disable_handler, node_id)) - -mqtt.connect_and_loop(loop_forever=False) - -bot.run(start_handler=start) - -mqtt.disconnect() diff --git a/src/relay_mqtt_http_proxy.py b/src/relay_mqtt_http_proxy.py deleted file mode 100755 index e13c04a..0000000 --- a/src/relay_mqtt_http_proxy.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -import logging - -from home import http -from home.config import config, AppConfigUnit -from home.mqtt import MqttPayload, MqttWrapper, MqttNode, MqttModule, MqttNodesConfig -from home.mqtt.module.relay import MqttRelayState, MqttRelayModule, MqttPowerStatusPayload -from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload -from typing import Optional, Union - - -logger = logging.getLogger(__name__) -mqtt: Optional[MqttWrapper] = None -mqtt_nodes: dict[str, MqttNode] = {} -relay_modules: dict[str, Union[MqttRelayModule, MqttModule]] = {} -relay_states: dict[str, MqttRelayState] = {} - -mqtt_nodes_config = MqttNodesConfig() - - -class RelayMqttHttpProxyConfig(AppConfigUnit): - NAME = 'relay_mqtt_http_proxy' - - @classmethod - def schema(cls) -> Optional[dict]: - return { - 'relay_nodes': { - 'type': 'list', - 'required': True, - 'schema': { - 'type': 'string' - } - }, - 'listen_addr': cls._addr_schema(required=True) - } - - @staticmethod - def custom_validator(data): - relay_node_names = mqtt_nodes_config.get_nodes(filters=('relay',), only_names=True) - for node in data['relay_nodes']: - if node not in relay_node_names: - raise ValueError(f'unknown relay node "{node}"') - - -def on_mqtt_message(node: MqttNode, - message: MqttPayload): - try: - is_legacy = mqtt_nodes_config[node.id]['relay']['legacy_topics'] - logger.debug(f'on_mqtt_message: relay {node.id} uses legacy topic names') - except KeyError: - is_legacy = False - kwargs = {} - - if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): - kwargs['rssi'] = message.rssi - if is_legacy: - kwargs['enabled'] = message.flags.state - - if not is_legacy and isinstance(message, MqttPowerStatusPayload): - kwargs['enabled'] = message.opened - - if len(kwargs): - logger.debug(f'on_mqtt_message: {node.id}: going to update relay state: {str(kwargs)}') - if node.id not in relay_states: - relay_states[node.id] = MqttRelayState() - relay_states[node.id].update(**kwargs) - - -class RelayMqttHttpProxy(http.HTTPServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.get('/relay/{id}/on', self.relay_on) - self.get('/relay/{id}/off', self.relay_off) - self.get('/relay/{id}/toggle', self.relay_toggle) - - async def _relay_on_off(self, - enable: Optional[bool], - req: http.Request): - node_id = req.match_info['id'] - node_secret = req.query['secret'] - - node = mqtt_nodes[node_id] - relay_module = relay_modules[node_id] - - if enable is None: - if node_id in relay_states and relay_states[node_id].ever_updated: - cur_state = relay_states[node_id].enabled - else: - cur_state = False - enable = not cur_state - - node.secret = node_secret - relay_module.switchpower(enable) - return self.ok() - - async def relay_on(self, req: http.Request): - return await self._relay_on_off(True, req) - - async def relay_off(self, req: http.Request): - return await self._relay_on_off(False, req) - - async def relay_toggle(self, req: http.Request): - return await self._relay_on_off(None, req) - - -if __name__ == '__main__': - config.load_app(RelayMqttHttpProxyConfig) - - mqtt = MqttWrapper(client_id='relay_mqtt_http_proxy', - randomize_client_id=True) - for node_id in config.app_config['relay_nodes']: - node_data = mqtt_nodes_config.get_node(node_id) - mqtt_node = MqttNode(node_id=node_id) - module_kwargs = {} - try: - if node_data['relay']['legacy_topics']: - module_kwargs['legacy_topics'] = True - except KeyError: - pass - relay_modules[node_id] = mqtt_node.load_module('relay', **module_kwargs) - if 'legacy_topics' in module_kwargs: - mqtt_node.load_module('diagnostics') - mqtt_node.add_payload_callback(on_mqtt_message) - mqtt.add_node(mqtt_node) - mqtt_nodes[node_id] = mqtt_node - - mqtt.connect_and_loop(loop_forever=False) - - proxy = RelayMqttHttpProxy(config.app_config['listen_addr']) - try: - proxy.run() - except KeyboardInterrupt: - mqtt.disconnect() diff --git a/src/sensors_bot.py b/src/sensors_bot.py deleted file mode 100755 index 441c212..0000000 --- a/src/sensors_bot.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -import json -import socket -import logging -import re -import gc - -from io import BytesIO -from typing import Optional - -import matplotlib.pyplot as plt -import matplotlib.dates as mdates -import matplotlib.ticker as mticker - -from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton - -from home.config import config -from home.telegram import bot -from home.util import chunks, MySimpleSocketClient -from home.api import WebApiClient -from home.api.types import ( - BotType, - TemperatureSensorLocation -) - -config.load_app('sensors_bot') -bot.initialize() - -bot.lang.ru( - start_message="Выберите датчик на клавиатуре", - unknown_command="Неизвестная команда", - temperature="Температура", - humidity="Влажность", - plot_3h="График за 3 часа", - plot_6h="График за 6 часов", - plot_12h="График за 12 часов", - plot_24h="График за 24 часа", - unexpected_callback_data="Ошибка: неверные данные", - loading="Загрузка...", - n_hrs="график за %d ч." -) -bot.lang.en( - start_message="Select the sensor on the keyboard", - unknown_command="Unknown command", - temperature="Temperature", - humidity="Relative humidity", - plot_3h="Graph for 3 hours", - plot_6h="Graph for 6 hours", - plot_12h="Graph for 12 hours", - plot_24h="Graph for 24 hours", - unexpected_callback_data="Unexpected callback data", - loading="Loading...", - n_hrs="graph for %d hours" -) - -plt.rcParams['font.size'] = 7 -logger = logging.getLogger(__name__) -plot_hours = [3, 6, 12, 24] - - -_sensor_names = [] -for k, v in config['sensors'].items(): - _sensor_names.append(k) - bot.lang.set({k: v['label_ru']}, 'ru') - bot.lang.set({k: v['label_en']}, 'en') - - -@bot.handler(messages=_sensor_names, argument='message_key') -def read_sensor(sensor: str, ctx: bot.Context) -> None: - host = config['sensors'][sensor]['ip'] - port = config['sensors'][sensor]['port'] - - try: - client = MySimpleSocketClient(host, port) - client.write('read') - data = json.loads(client.read()) - except (socket.timeout, socket.error) as error: - return ctx.reply_exc(error) - - temp = round(data['temp'], 2) - humidity = round(data['humidity'], 2) - - text = ctx.lang('temperature') + f': {temp} °C\n' - text += ctx.lang('humidity') + f': {humidity}%' - - buttons = list(map( - lambda h: InlineKeyboardButton(ctx.lang(f'plot_{h}h'), callback_data=f'plot/{sensor}/{h}'), - plot_hours - )) - ctx.reply(text, markup=InlineKeyboardMarkup(chunks(buttons, 2))) - - -@bot.callbackhandler(callback='*') -def callback_handler(ctx: bot.Context) -> None: - query = ctx.callback_query - - sensors_variants = '|'.join(config['sensors'].keys()) - hour_variants = '|'.join(list(map( - lambda n: str(n), - plot_hours - ))) - - match = re.match(rf'plot/({sensors_variants})/({hour_variants})', query.data) - if not match: - query.answer(ctx.lang('unexpected_callback_data')) - return - - query.answer(ctx.lang('loading')) - - # retrieve data - sensor = TemperatureSensorLocation[match.group(1).upper()] - hours = int(match.group(2)) - - api = WebApiClient(timeout=20) - data = api.get_sensors_data(sensor, hours) - - title = ctx.lang(sensor.name.lower()) + ' (' + ctx.lang('n_hrs', hours) + ')' - plot = draw_plot(data, title, - ctx.lang('temperature'), - ctx.lang('humidity')) - bot.send_photo(ctx.user_id, photo=plot) - - gc.collect() - - -def draw_plot(data, - title: str, - label_temp: str, - label_hum: str) -> BytesIO: - tempval = [] - humval = [] - dates = [] - for date, temp, humidity in data: - dates.append(date) - tempval.append(temp) - humval.append(humidity) - - fig, axs = plt.subplots(2, 1) - df = mdates.DateFormatter('%H:%M') - - axs[0].set_title(label_temp) - axs[0].plot(dates, tempval) - axs[0].xaxis.set_major_formatter(df) - axs[0].yaxis.set_major_formatter(mticker.FormatStrFormatter('%2.2f °C')) - - fig.suptitle(title, fontsize=10) - - axs[1].set_title(label_hum) - axs[1].plot(dates, humval) - axs[1].xaxis.set_major_formatter(df) - axs[1].yaxis.set_major_formatter(mticker.FormatStrFormatter('%2.1f %%')) - - fig.autofmt_xdate() - - # should be called after all axes have been added - fig.tight_layout() - - buf = BytesIO() - fig.savefig(buf, format='png', dpi=160) - buf.seek(0) - - plt.clf() - plt.close('all') - - return buf - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [] - for k in config['sensors'].keys(): - buttons.append(ctx.lang(k)) - buttons = chunks(buttons, 2) - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -if __name__ == '__main__': - if 'api' in config: - bot.enable_logging(BotType.SENSORS) - - bot.run() diff --git a/src/sound_bot.py b/src/sound_bot.py deleted file mode 100755 index bc9edce..0000000 --- a/src/sound_bot.py +++ /dev/null @@ -1,889 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import tempfile - -from enum import Enum -from datetime import datetime, timedelta -from html import escape -from typing import Optional, List, Dict, Tuple - -from home.config import config -from home.api import WebApiClient -from home.api.types import SoundSensorLocation, BotType -from home.api.errors import ApiResponseError -from home.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient -from home.soundsensor import SoundSensorServerGuardClient -from home.util import Addr, chunks, filesize_fmt - -from home.telegram import bot - -from telegram.error import TelegramError -from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton, User - -from PIL import Image - -config.load_app('sound_bot') - -nodes = {} -for nodename, nodecfg in config['nodes'].items(): - nodes[nodename] = Addr.fromstring(nodecfg['addr']) - -bot.initialize() -bot.lang.ru( - start_message="Выберите команду на клавиатуре", - unknown_command="Неизвестная команда", - unexpected_callback_data="Ошибка: неверные данные", - settings="Настройки микшера", - record="Запись", - loading="Загрузка...", - select_place="Выберите место:", - invalid_location="Неверное место", - invalid_interval="Неверная длительность", - unsupported_action="Неподдерживаемое действие", - # select_control="Выберите контрол для изменения настроек:", - control_state="Состояние контрола %s", - incr="громкость +", - decr="громкость -", - back="◀️ Назад", - n_min="%d мин.", - n_sec="%d сек.", - select_interval="Выберите длительность:", - place="Место", - beginning="Начало", - end="Конец", - record_result="Результат записи", - record_started='Запись запущена!', - record_error="Ошибка записи", - files="Локальные файлы", - remote_files="Файлы на сервере", - file_line="— Запись с %s до %s (%s)", - access_denied="Доступ запрещён", - - guard_disable="Снять с охраны", - guard_enable="Поставить на охрану", - guard_status="Статус охраны", - guard_user_action_notification='Пользователь %s %s.', - guard_user_action_enable="включил охрану ✅", - guard_user_action_disable="выключил охрану ❌", - guard_status_enabled="Включена ✅", - guard_status_disabled="Выключена ❌", - - done="Готово 👌", - - sound_sensors="Датчики звука", - sound_sensors_info="Здесь можно получить информацию о последних срабатываниях датчиков звука.", - sound_sensors_no_24h_data="За последние 24 часа данных нет.", - sound_sensors_show_anything="Показать, что есть", - - cameras="Камеры", - select_option="Выберите опцию", - w_flash="Со вспышкой", - wo_flash="Без вспышки", -) - -bot.lang.en( - start_message="Select command on the keyboard", - unknown_command="Unknown command", - settings="Mixer settings", - record="Record", - unexpected_callback_data="Unexpected callback data", - loading="Loading...", - select_place="Select place:", - invalid_location="Invalid place", - invalid_interval="Invalid duration", - unsupported_action="Unsupported action", - # select_control="Select control to adjust its parameters:", - control_state="%s control state", - incr="vol +", - decr="vol -", - back="◀️ Back", - n_min="%d min.", - n_sec="%d s.", - select_interval="Select duration:", - place="Place", - beginning="Started", - end="Ended", - record_result="Result", - record_started='Recording started!', - record_error="Recording error", - files="Local files", - remote_files="Remote files", - file_line="— From %s to %s (%s)", - access_denied="Access denied", - - guard_disable="Disable guard", - guard_enable="Enable guard", - guard_status="Guard status", - guard_user_action_notification='User %s %s.', - guard_user_action_enable="turned the guard ON ✅", - guard_user_action_disable="turn the guard OFF ❌", - guard_status_enabled="Active ✅", - guard_status_disabled="Disabled ❌", - done="Done 👌", - - sound_sensors="Sound sensors", - sound_sensors_info="Here you can get information about last sound sensors hits.", - sound_sensors_no_24h_data="No data for the last 24 hours.", - sound_sensors_show_anything="Show me at least something", - - cameras="Cameras", - select_option="Select option", - w_flash="With flash", - wo_flash="Without flash", -) - -logger = logging.getLogger(__name__) -RenderedContent = Tuple[str, Optional[InlineKeyboardMarkup]] -record_client: Optional[SoundRecordClient] = None -node_client_links: Dict[str, SoundNodeClient] = {} -cam_client_links: Dict[str, CameraNodeClient] = {} - - -def node_client(node: str) -> SoundNodeClient: - if node not in node_client_links: - node_client_links[node] = SoundNodeClient(Addr.fromstring(config['nodes'][node]['addr'])) - return node_client_links[node] - - -def camera_client(cam: str) -> CameraNodeClient: - if cam not in node_client_links: - cam_client_links[cam] = CameraNodeClient(Addr.fromstring(config['cameras'][cam]['addr'])) - return cam_client_links[cam] - - -def node_exists(node: str) -> bool: - return node in config['nodes'] - - -def camera_exists(name: str) -> bool: - return name in config['cameras'] - - -def camera_settings(name: str) -> Optional[dict]: - try: - return config['cameras'][name]['settings'] - except KeyError: - return None - - -def have_cameras() -> bool: - return 'cameras' in config and config['cameras'] - - -def sound_sensor_exists(node: str) -> bool: - return node in config['sound_sensors'] - - -def interval_defined(interval: int) -> bool: - return interval in config['bot']['record_intervals'] - - -def callback_unpack(ctx: bot.Context) -> List[str]: - return ctx.callback_query.data[3:].split('/') - - -def manual_recording_allowed(user_id: int) -> bool: - return 'manual_record_allowlist' not in config['bot'] or user_id in config['bot']['manual_record_allowlist'] - - -def guard_client() -> SoundSensorServerGuardClient: - return SoundSensorServerGuardClient(Addr.fromstring(config['bot']['guard_server'])) - - -# message renderers -# ----------------- - -class Renderer: - @classmethod - def places_markup(cls, ctx: bot.Context, callback_prefix: str) -> InlineKeyboardMarkup: - buttons = [] - for node, nodeconfig in config['nodes'].items(): - buttons.append([InlineKeyboardButton(nodeconfig['label'][ctx.user_lang], callback_data=f'{callback_prefix}/{node}')]) - return InlineKeyboardMarkup(buttons) - - @classmethod - def back_button(cls, - ctx: bot.Context, - buttons: list, - callback_data: str): - buttons.append([ - InlineKeyboardButton(ctx.lang('back'), callback_data=callback_data) - ]) - - -class SettingsRenderer(Renderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'{ctx.lang("settings")}\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='s0') - - @classmethod - def node(cls, ctx: bot.Context, - controls: List[dict]) -> RenderedContent: - node, = callback_unpack(ctx) - - html = [] - buttons = [] - for control in controls: - html.append(f'{control["name"]}\n{escape(control["info"])}') - buttons.append([ - InlineKeyboardButton(control['name'], callback_data=f's1/{node}/{control["name"]}') - ]) - - html = "\n\n".join(html) - cls.back_button(ctx, buttons, callback_data='s0') - - return html, InlineKeyboardMarkup(buttons) - - @classmethod - def control(cls, ctx: bot.Context, data) -> RenderedContent: - node, control, *rest = callback_unpack(ctx) - - html = '' + ctx.lang('control_state', control) + '\n\n' - html += escape(data['info']) - buttons = [] - callback_prefix = f's2/{node}/{control}' - for cap in data['caps']: - if cap == 'mute': - muted = 'dB] [off]' in data['info'] - act = 'unmute' if muted else 'mute' - buttons.append([InlineKeyboardButton(act, callback_data=f'{callback_prefix}/{act}')]) - - elif cap == 'cap': - cap_dis = 'Capture [off]' in data['info'] - act = 'cap' if cap_dis else 'nocap' - buttons.append([InlineKeyboardButton(act, callback_data=f'{callback_prefix}/{act}')]) - - elif cap == 'volume': - buttons.append( - list(map(lambda s: InlineKeyboardButton(ctx.lang(s), callback_data=f'{callback_prefix}/{s}'), - ['decr', 'incr'])) - ) - - cls.back_button(ctx, buttons, callback_data=f's0/{node}') - - return html, InlineKeyboardMarkup(buttons) - - -class RecordRenderer(Renderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'{ctx.lang("record")}\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='r0') - - @classmethod - def node(cls, ctx: bot.Context, durations: List[int]) -> RenderedContent: - node, = callback_unpack(ctx) - - html = ctx.lang('select_interval') - - buttons = [] - for s in durations: - if s >= 60: - m = int(s / 60) - label = ctx.lang('n_min', m) - else: - label = ctx.lang('n_sec', s) - buttons.append(InlineKeyboardButton(label, callback_data=f'r1/{node}/{s}')) - buttons = list(chunks(buttons, 3)) - cls.back_button(ctx, buttons, callback_data=f'r0') - - return html, InlineKeyboardMarkup(buttons) - - @classmethod - def record_started(cls, ctx: bot.Context, rid: int) -> RenderedContent: - node, *rest = callback_unpack(ctx) - - place = config['nodes'][node]['label'][ctx.user_lang] - - html = f'{ctx.lang("record_started")} ({place}, id={rid})' - return html, None - - @classmethod - def record_done(cls, info: dict, node: str, uid: int) -> str: - ulang = bot.db.get_user_lang(uid) - - def lang(key, *args): - return bot.lang.get(key, ulang, *args) - - rid = info['id'] - fmt = '%d.%m.%y %H:%M:%S' - start_time = datetime.fromtimestamp(int(info['start_time'])).strftime(fmt) - stop_time = datetime.fromtimestamp(int(info['stop_time'])).strftime(fmt) - - place = config['nodes'][node]['label'][ulang] - - html = f'{lang("record_result")} ({place}, id={rid})\n\n' - html += f'{lang("beginning")}: {start_time}\n' - html += f'{lang("end")}: {stop_time}' - - return html - - @classmethod - def record_error(cls, info: dict, node: str, uid: int) -> str: - ulang = bot.db.get_user_lang(uid) - - def lang(key, *args): - return bot.lang.get(key, ulang, *args) - - place = config['nodes'][node]['label'][ulang] - rid = info['id'] - - html = f'{lang("record_error")} ({place}, id={rid})' - if 'error' in info: - html += '\n'+str(info['error']) - - return html - - -class FilesRenderer(Renderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'{ctx.lang("files")}\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='f0') - - @classmethod - def filelist(cls, ctx: bot.Context, files: List[SoundRecordFile]) -> RenderedContent: - node, = callback_unpack(ctx) - - html_files = map(lambda file: cls.file(ctx, file, node), files) - html = '\n\n'.join(html_files) - - buttons = [] - cls.back_button(ctx, buttons, callback_data='f0') - - return html, InlineKeyboardMarkup(buttons) - - @classmethod - def file(cls, ctx: bot.Context, file: SoundRecordFile, node: str) -> str: - html = ctx.lang('file_line', file.start_humantime, file.stop_humantime, filesize_fmt(file.filesize)) - if file.file_id is not None: - html += f'/audio_{node}_{file.file_id}' - return html - - -class RemoteFilesRenderer(FilesRenderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'{ctx.lang("remote_files")}\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='g0') - - -class SoundSensorRenderer(Renderer): - @classmethod - def places_markup(cls, ctx: bot.Context, callback_prefix: str) -> InlineKeyboardMarkup: - buttons = [] - for sensor, sensor_label in config['sound_sensors'].items(): - buttons.append( - [InlineKeyboardButton(sensor_label[ctx.user_lang], callback_data=f'{callback_prefix}/{sensor}')]) - return InlineKeyboardMarkup(buttons) - - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'{ctx.lang("sound_sensors_info")}\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='S0') - - @classmethod - def hits(cls, ctx: bot.Context, data, is_last=False) -> RenderedContent: - node, = callback_unpack(ctx) - buttons = [] - - if not data: - html = ctx.lang('sound_sensors_no_24h_data') - if not is_last: - buttons.append([InlineKeyboardButton(ctx.lang('sound_sensors_show_anything'), callback_data=f'S1/{node}')]) - else: - html = '' - prev_date = None - for item in data: - item_date = item['time'].strftime('%d.%m.%y') - if prev_date is None or prev_date != item_date: - if html != '': - html += '\n\n' - html += f'{item_date}' - prev_date = item_date - html += '\n' + item['time'].strftime('%H:%M:%S') + f' (+{item["hits"]})' - cls.back_button(ctx, buttons, callback_data='S0') - return html, InlineKeyboardMarkup(buttons) - - @classmethod - def hits_plain(cls, ctx: bot.Context, data, is_last=False) -> bytes: - node, = callback_unpack(ctx) - - text = '' - prev_date = None - for item in data: - item_date = item['time'].strftime('%d.%m.%y') - if prev_date is None or prev_date != item_date: - if text != '': - text += '\n\n' - text += item_date - prev_date = item_date - text += '\n' + item['time'].strftime('%H:%M:%S') + f' (+{item["hits"]})' - - return text.encode() - - -class CamerasRenderer(Renderer): - @classmethod - def index(cls, ctx: bot.Context) -> RenderedContent: - html = f'{ctx.lang("cameras")}\n\n' - html += ctx.lang('select_place') - return html, cls.places_markup(ctx, callback_prefix='c0') - - @classmethod - def places_markup(cls, ctx: bot.Context, callback_prefix: str) -> InlineKeyboardMarkup: - buttons = [] - for camera_name, camera_data in config['cameras'].items(): - buttons.append( - [InlineKeyboardButton(camera_data['label'][ctx.user_lang], callback_data=f'{callback_prefix}/{camera_name}')]) - return InlineKeyboardMarkup(buttons) - - @classmethod - def camera(cls, ctx: bot.Context, flash_available: bool) -> RenderedContent: - node, = callback_unpack(ctx) - - html = ctx.lang('select_option') - - buttons = [] - if flash_available: - buttons.append(InlineKeyboardButton(ctx.lang('w_flash'), callback_data=f'c1/{node}/1')) - buttons.append(InlineKeyboardButton(ctx.lang('wo_flash'), callback_data=f'c1/{node}/0')) - - cls.back_button(ctx, [buttons], callback_data=f'c0') - - return html, InlineKeyboardMarkup([buttons]) - # - # @classmethod - # def record_started(cls, ctx: bot.Context, rid: int) -> RenderedContent: - # node, *rest = callback_unpack(ctx) - # - # place = config['nodes'][node]['label'][ctx.user_lang] - # - # html = f'{ctx.lang("record_started")} ({place}, id={rid})' - # return html, None - # - # @classmethod - # def record_done(cls, info: dict, node: str, uid: int) -> str: - # ulang = bot.db.get_user_lang(uid) - # - # def lang(key, *args): - # return bot.lang.get(key, ulang, *args) - # - # rid = info['id'] - # fmt = '%d.%m.%y %H:%M:%S' - # start_time = datetime.fromtimestamp(int(info['start_time'])).strftime(fmt) - # stop_time = datetime.fromtimestamp(int(info['stop_time'])).strftime(fmt) - # - # place = config['nodes'][node]['label'][ulang] - # - # html = f'{lang("record_result")} ({place}, id={rid})\n\n' - # html += f'{lang("beginning")}: {start_time}\n' - # html += f'{lang("end")}: {stop_time}' - # - # return html - # - # @classmethod - # def record_error(cls, info: dict, node: str, uid: int) -> str: - # ulang = bot.db.get_user_lang(uid) - # - # def lang(key, *args): - # return bot.lang.get(key, ulang, *args) - # - # place = config['nodes'][node]['label'][ulang] - # rid = info['id'] - # - # html = f'{lang("record_error")} ({place}, id={rid})' - # if 'error' in info: - # html += '\n'+str(info['error']) - # - # return html - - -# cameras handlers -# ---------------- - -@bot.handler(message='cameras', callback=r'^c0$') -def cameras(ctx: bot.Context): - """ List of cameras """ - - text, markup = CamerasRenderer.index(ctx) - if not ctx.is_callback_context(): - return ctx.reply(text, markup=markup) - else: - ctx.answer() - return ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^c0/.*') -def camera_options(ctx: bot.Context) -> None: - """ List of options (with/without flash etc) """ - - cam, = callback_unpack(ctx) - if not camera_exists(cam): - ctx.answer(ctx.lang('invalid_location')) - return - - ctx.answer() - flash_available = 'flash_available' in config['cameras'][cam] and config['cameras'][cam]['flash_available'] is True - - text, markup = CamerasRenderer.camera(ctx, flash_available) - ctx.edit(text, markup) - - -@bot.callbackhandler(callback=r'^c1/.*') -def camera_capture(ctx: bot.Context) -> None: - """ Cheese """ - - cam, flash = callback_unpack(ctx) - flash = int(flash) - if not camera_exists(cam): - ctx.answer(ctx.lang('invalid_location')) - return - - ctx.answer() - - client = camera_client(cam) - fd = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') - fd.close() - - client.capture(fd.name, with_flash=bool(flash)) - logger.debug(f'captured photo ({cam}), saved to {fd.name}') - - camera_config = config['cameras'][cam] - if 'rotate' in camera_config: - im = Image.open(fd.name) - im.rotate(camera_config['rotate'], expand=True) - # im.show() - im.save(fd.name) - logger.debug(f"rotated image {camera_config['rotate']} degrees") - - try: - with open(fd.name, 'rb') as f: - bot.send_photo(ctx.user_id, photo=f) - except TelegramError as exc: - logger.exception(exc) - - try: - os.unlink(fd.name) - except OSError as exc: - logger.exception(exc) - - -# settings handlers -# ----------------- - -@bot.handler(message='settings', callback=r'^s0$') -def settings(ctx: bot.Context): - """ List of nodes """ - - text, markup = SettingsRenderer.index(ctx) - if not ctx.is_callback_context(): - return ctx.reply(text, markup=markup) - else: - ctx.answer() - return ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^s0/.*') -def settings_place(ctx: bot.Context): - """ List of controls """ - - node, = callback_unpack(ctx) - if not node_exists(node): - ctx.answer(ctx.lang('invalid_location')) - return - - cl = node_client(node) - controls = cl.amixer_get_all() - - ctx.answer() - - text, markup = SettingsRenderer.node(ctx, controls) - ctx.edit(text, markup) - - -@bot.callbackhandler(callback=r'^s1/.*') -def settings_place_control(ctx: bot.Context): - """ List of available tunes for control """ - - node, control = callback_unpack(ctx) - if not node_exists(node): - ctx.answer(ctx.lang('invalid_location')) - return - - cl = node_client(node) - control_data = cl.amixer_get(control) - - ctx.answer() - - text, markup = SettingsRenderer.control(ctx, control_data) - ctx.edit(text, markup) - - -@bot.callbackhandler(callback=r'^s2/.*') -def settings_place_control_action(ctx: bot.Context): - """ Tuning """ - - node, control, action = callback_unpack(ctx) - if not node_exists(node): - return - - cl = node_client(node) - if not hasattr(cl, f'amixer_{action}'): - ctx.answer(ctx.lang('invalid_action')) - return - - func = getattr(cl, f'amixer_{action}') - control_data = func(control) - - ctx.answer() - - text, markup = SettingsRenderer.control(ctx, control_data) - ctx.edit(text, markup) - - -# recording handlers -# ------------------ - -@bot.handler(message='record', callback=r'^r0$') -def record(ctx: bot.Context): - """ List of nodes """ - - if not manual_recording_allowed(ctx.user_id): - return ctx.reply(ctx.lang('access_denied')) - - text, markup = RecordRenderer.index(ctx) - if not ctx.is_callback_context(): - return ctx.reply(text, markup=markup) - else: - ctx.answer() - return ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^r0/.*') -def record_place(ctx: bot.Context): - """ List of available intervals """ - - node, = callback_unpack(ctx) - if not node_exists(node): - ctx.answer(ctx.lang('invalid_location')) - return - - ctx.answer() - - text, markup = RecordRenderer.node(ctx, config['bot']['record_intervals']) - ctx.edit(text, markup) - - -@bot.callbackhandler(callback=r'^r1/.*') -def record_place_interval(ctx: bot.Context): - """ Do record! """ - - node, interval = callback_unpack(ctx) - interval = int(interval) - if not node_exists(node): - ctx.answer(ctx.lang('invalid_location')) - return - if not interval_defined(interval): - ctx.answer(ctx.lang('invalid_interval')) - return - - try: - record_id = record_client.record(node, interval, {'user_id': ctx.user_id, 'node': node}) - except ApiResponseError as e: - ctx.answer(e.error_message) - logger.error(e) - return - - ctx.answer() - - html, markup = RecordRenderer.record_started(ctx, record_id) - ctx.edit(html, markup) - - -# sound sensor handlers -# --------------------- - -@bot.handler(message='sound_sensors', callback=r'^S0$') -def sound_sensors(ctx: bot.Context): - """ List of places """ - - text, markup = SoundSensorRenderer.index(ctx) - if not ctx.is_callback_context(): - return ctx.reply(text, markup=markup) - else: - ctx.answer() - return ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^S0/.*') -def sound_sensors_last_24h(ctx: bot.Context): - """ Last 24h log """ - - node, = callback_unpack(ctx) - if not sound_sensor_exists(node): - ctx.answer(ctx.lang('invalid location')) - return - - ctx.answer() - - cl = WebApiClient() - data = cl.get_sound_sensor_hits(location=SoundSensorLocation[node.upper()], - after=datetime.now() - timedelta(hours=24)) - - text, markup = SoundSensorRenderer.hits(ctx, data) - if len(text) > 4096: - plain = SoundSensorRenderer.hits_plain(ctx, data) - bot.send_file(ctx.user_id, document=plain, filename='data.txt') - else: - ctx.edit(text, markup=markup) - - -@bot.callbackhandler(callback=r'^S1/.*') -def sound_sensors_last_anything(ctx: bot.Context): - """ Last _something_ """ - - node, = callback_unpack(ctx) - if not sound_sensor_exists(node): - ctx.answer(ctx.lang('invalid location')) - return - - ctx.answer() - - cl = WebApiClient() - data = cl.get_last_sound_sensor_hits(location=SoundSensorLocation[node.upper()], - last=20) - - text, markup = SoundSensorRenderer.hits(ctx, data, is_last=True) - if len(text) > 4096: - plain = SoundSensorRenderer.hits_plain(ctx, data) - bot.send_file(ctx.user_id, document=plain, filename='data.txt') - else: - ctx.edit(text, markup=markup) - - -# guard enable/disable handlers -# ----------------------------- - -class GuardUserAction(Enum): - ENABLE = 'enable' - DISABLE = 'disable' - - -if 'guard_server' in config['bot']: - @bot.handler(message='guard_status') - def guard_status(ctx: bot.Context): - guard = guard_client() - resp = guard.guard_status() - - key = 'enabled' if resp['enabled'] is True else 'disabled' - ctx.reply(ctx.lang(f'guard_status_{key}')) - - - @bot.handler(message='guard_enable') - def guard_enable(ctx: bot.Context): - guard = guard_client() - guard.guard_enable() - ctx.reply(ctx.lang('done')) - - _guard_notify(ctx.user, GuardUserAction.ENABLE) - - - @bot.handler(message='guard_disable') - def guard_disable(ctx: bot.Context): - guard = guard_client() - guard.guard_disable() - ctx.reply(ctx.lang('done')) - - _guard_notify(ctx.user, GuardUserAction.DISABLE) - - - def _guard_notify(user: User, action: GuardUserAction): - def text_getter(lang: str): - action_name = bot.lang.get(f'guard_user_action_{action.value}', lang) - user_name = bot.user_any_name(user) - return 'ℹ ' + bot.lang.get('guard_user_action_notification', lang, - user.id, user_name, action_name) - - bot.notify_all(text_getter, exclude=(user.id,)) - - -@bot.defaultreplymarkup -def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [ - [ctx.lang('record'), ctx.lang('settings')], - # [ctx.lang('files'), ctx.lang('remote_files')], - ] - if 'guard_server' in config['bot']: - buttons.append([ - ctx.lang('guard_enable'), ctx.lang('guard_disable'), ctx.lang('guard_status') - ]) - buttons.append([ctx.lang('sound_sensors')]) - if have_cameras(): - buttons.append([ctx.lang('cameras')]) - return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) - - -# record client callbacks -# ----------------------- - -def record_onerror(info: dict, userdata: dict): - uid = userdata['user_id'] - node = userdata['node'] - - html = RecordRenderer.record_error(info, node, uid) - try: - bot.notify_user(userdata['user_id'], html) - except TelegramError as exc: - logger.exception(exc) - finally: - record_client.forget(node, info['id']) - - -def record_onfinished(info: dict, fn: str, userdata: dict): - logger.info('record finished: ' + str(info)) - - uid = userdata['user_id'] - node = userdata['node'] - - html = RecordRenderer.record_done(info, node, uid) - bot.notify_user(uid, html) - - try: - # sending audiofile to telegram - with open(fn, 'rb') as f: - bot.send_audio(uid, audio=f, filename='audio.mp3') - - # deleting temp file - try: - os.unlink(fn) - except OSError as exc: - logger.exception(exc) - bot.notify_user(uid, exc) - - # remove the recording from sound_node's history - record_client.forget(node, info['id']) - - # remove file from storage - # node_client(node).storage_delete(info['file']['fileid']) - except Exception as e: - logger.exception(e) - - -if __name__ == '__main__': - record_client = SoundRecordClient(nodes, - error_handler=record_onerror, - finished_handler=record_onfinished, - download_on_finish=True) - - if 'api' in config: - bot.enable_logging(BotType.SOUND) - bot.run() - record_client.stop() diff --git a/src/sound_node.py b/src/sound_node.py deleted file mode 100755 index b0b4a67..0000000 --- a/src/sound_node.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -import os - -from typing import Optional - -from home.config import config -from home.audio import amixer -from home.media import MediaNodeServer, SoundRecordStorage, SoundRecorder -from home import http - - -# This script must be run as root as it runs arecord. -# Implements HTTP API for amixer and arecord. -# ------------------------------------------- - -def _amixer_control_response(control): - info = amixer.get(control) - caps = amixer.get_caps(control) - return http.ok({ - 'caps': caps, - 'info': info - }) - - -class SoundNodeServer(MediaNodeServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.get('/amixer/get-all/', self.amixer_get_all) - self.get('/amixer/get/{control}/', self.amixer_get) - self.get('/amixer/{op:mute|unmute|cap|nocap}/{control}/', self.amixer_set) - self.get('/amixer/{op:incr|decr}/{control}/', self.amixer_volume) - - async def amixer_get_all(self, request: http.Request): - controls_info = amixer.get_all() - return self.ok(controls_info) - - async def amixer_get(self, request: http.Request): - control = request.match_info['control'] - if not amixer.has_control(control): - raise ValueError(f'invalid control: {control}') - - return _amixer_control_response(control) - - async def amixer_set(self, request: http.Request): - op = request.match_info['op'] - control = request.match_info['control'] - if not amixer.has_control(control): - raise ValueError(f'invalid control: {control}') - - f = getattr(amixer, op) - f(control) - - return _amixer_control_response(control) - - async def amixer_volume(self, request: http.Request): - op = request.match_info['op'] - control = request.match_info['control'] - if not amixer.has_control(control): - raise ValueError(f'invalid control: {control}') - - def get_step() -> Optional[int]: - if 'step' in request.query: - step = int(request.query['step']) - if not 1 <= step <= 50: - raise ValueError('invalid step value') - return step - return None - - f = getattr(amixer, op) - f(control, step=get_step()) - - return _amixer_control_response(control) - - -if __name__ == '__main__': - if not os.getegid() == 0: - raise RuntimeError("Must be run as root.") - - config.load_app('sound_node') - - storage = SoundRecordStorage(config['node']['storage']) - - recorder = SoundRecorder(storage=storage) - recorder.start_thread() - - server = SoundNodeServer(recorder=recorder, - storage=storage, - addr=config.get_addr('node.listen')) - server.run() diff --git a/src/sound_sensor_node.py b/src/sound_sensor_node.py deleted file mode 100755 index 404fdf4..0000000 --- a/src/sound_sensor_node.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -import logging -import os -import sys - -from home.config import config -from home.util import Addr -from home.soundsensor import SoundSensorNode - -logger = logging.getLogger(__name__) - - -if __name__ == '__main__': - if not os.getegid() == 0: - sys.exit('Must be run as root.') - - config.load_app('sound_sensor_node') - - kwargs = {} - if 'delay' in config['node']: - kwargs['delay'] = config['node']['delay'] - - if 'server_addr' in config['node']: - server_addr = Addr.fromstring(config['node']['server_addr']) - else: - server_addr = None - - node = SoundSensorNode(name=config['node']['name'], - pinname=config['node']['pin'], - threshold=config['node']['threshold'] if 'threshold' in config['node'] else 1, - server_addr=server_addr, - **kwargs) - node.run() diff --git a/src/sound_sensor_server.py b/src/sound_sensor_server.py deleted file mode 100755 index 3446b80..0000000 --- a/src/sound_sensor_server.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -import logging -import threading - -from time import sleep -from typing import Optional, List, Dict, Tuple -from functools import partial -from home.config import config -from home.util import Addr -from home.api import WebApiClient, RequestParams -from home.api.types import SoundSensorLocation -from home.soundsensor import SoundSensorServer, SoundSensorHitHandler -from home.media import MediaNodeType, SoundRecordClient, CameraRecordClient, RecordClient - -interrupted = False -logger = logging.getLogger(__name__) -server: SoundSensorServer - - -def get_related_nodes(node_type: MediaNodeType, - sensor_name: str) -> List[str]: - try: - if sensor_name not in config[f'sensor_to_{node_type.name.lower()}_nodes_relations']: - raise ValueError(f'unexpected sensor name {sensor_name}') - return config[f'sensor_to_{node_type.name.lower()}_nodes_relations'][sensor_name] - except KeyError: - return [] - - -def get_node_config(node_type: MediaNodeType, - name: str) -> Optional[dict]: - if name in config[f'{node_type.name.lower()}_nodes']: - cfg = config[f'{node_type.name.lower()}_nodes'][name] - if 'min_hits' not in cfg: - cfg['min_hits'] = 1 - return cfg - else: - return None - - -class HitCounter: - def __init__(self): - self.sensors = {} - self.lock = threading.Lock() - self._reset_sensors() - - def _reset_sensors(self): - for loc in SoundSensorLocation: - self.sensors[loc.name.lower()] = 0 - - def add(self, name: str, hits: int): - if name not in self.sensors: - raise ValueError(f'sensor {name} not found') - - with self.lock: - self.sensors[name] += hits - - def get_all(self) -> List[Tuple[str, int]]: - vals = [] - with self.lock: - for name, hits in self.sensors.items(): - if hits > 0: - vals.append((name, hits)) - self._reset_sensors() - return vals - - -class HitHandler(SoundSensorHitHandler): - def handler(self, name: str, hits: int): - if not hasattr(SoundSensorLocation, name.upper()): - logger.error(f'invalid sensor name: {name}') - return - - should_continue = False - for node_type in MediaNodeType: - try: - nodes = get_related_nodes(node_type, name) - except ValueError: - logger.error(f'config for {node_type.name.lower()} node {name} not found') - return - - for node in nodes: - node_config = get_node_config(node_type, node) - if node_config is None: - logger.error(f'config for {node_type.name.lower()} node {node} not found') - continue - if hits < node_config['min_hits']: - continue - should_continue = True - - if not should_continue: - return - - hc.add(name, hits) - - if not server.is_recording_enabled(): - return - for node_type in MediaNodeType: - try: - nodes = get_related_nodes(node_type, name) - for node in nodes: - node_config = get_node_config(node_type, node) - if node_config is None: - logger.error(f'node config for {node_type.name.lower()} node {node} not found') - continue - - durations = node_config['durations'] - dur = durations[1] if hits > node_config['min_hits'] else durations[0] - record_clients[node_type].record(node, dur*60, {'node': node}) - - except ValueError as exc: - logger.exception(exc) - - -def hits_sender(): - while not interrupted: - all_hits = hc.get_all() - if all_hits: - api.add_sound_sensor_hits(all_hits) - sleep(5) - - -api: Optional[WebApiClient] = None -hc: Optional[HitCounter] = None -record_clients: Dict[MediaNodeType, RecordClient] = {} - - -# record callbacks -# ---------------- - -def record_error(type: MediaNodeType, - info: dict, - userdata: dict): - node = userdata['node'] - logger.error('recording ' + str(dict) + f' from {type.name.lower()} node ' + node + ' failed') - - record_clients[type].forget(node, info['id']) - - -def record_finished(type: MediaNodeType, - info: dict, - fn: str, - userdata: dict): - logger.debug(f'{type.name.lower()} record finished: ' + str(info)) - - # audio could have been requested by other user (telegram bot, for example) - # so we shouldn't 'forget' it here - - # node = userdata['node'] - # record.forget(node, info['id']) - - -# api client callbacks -# -------------------- - -def api_error_handler(exc, name, req: RequestParams): - logger.error(f'api call ({name}, params={req.params}) failed, exception below') - logger.exception(exc) - - -if __name__ == '__main__': - config.load_app('sound_sensor_server') - - hc = HitCounter() - api = WebApiClient(timeout=(10, 60)) - api.enable_async(error_handler=api_error_handler) - - t = threading.Thread(target=hits_sender) - t.daemon = True - t.start() - - sound_nodes = {} - if 'sound_nodes' in config: - for nodename, nodecfg in config['sound_nodes'].items(): - sound_nodes[nodename] = Addr.fromstring(nodecfg['addr']) - - camera_nodes = {} - if 'camera_nodes' in config: - for nodename, nodecfg in config['camera_nodes'].items(): - camera_nodes[nodename] = Addr.fromstring(nodecfg['addr']) - - if sound_nodes: - record_clients[MediaNodeType.SOUND] = SoundRecordClient(sound_nodes, - error_handler=partial(record_error, MediaNodeType.SOUND), - finished_handler=partial(record_finished, MediaNodeType.SOUND)) - - if camera_nodes: - record_clients[MediaNodeType.CAMERA] = CameraRecordClient(camera_nodes, - error_handler=partial(record_error, MediaNodeType.CAMERA), - finished_handler=partial(record_finished, MediaNodeType.CAMERA)) - - try: - server = SoundSensorServer(config.get_addr('server.listen'), HitHandler) - server.run() - except KeyboardInterrupt: - interrupted = True - for c in record_clients.values(): - c.stop() - logging.info('keyboard interrupt, exiting...') diff --git a/src/ssh_tunnels_config_util.py b/src/ssh_tunnels_config_util.py deleted file mode 100755 index 963c01b..0000000 --- a/src/ssh_tunnels_config_util.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 - -from home.config import config - -if __name__ == '__main__': - config.load_app('ssh_tunnels_config_util') - - network_prefix = config['network'] - hostnames = [] - - for k, v in config.app_config.get().items(): - if type(v) is not dict: - continue - hostnames.append(k) - - for host in hostnames: - buf = [] - i = 0 - for tun_host in hostnames: - http_bind_port = config['http_bind_base'] + config[host]['bind_slot'] * 10 + i - ssh_bind_port = config['ssh_bind_base'] + config[host]['bind_slot'] * 10 + i - - if tun_host == host: - target_host = '127.0.0.1' - else: - target_host = f'{network_prefix}.{config[tun_host]["ipv4"]}' - - buf.append(f'-R 127.0.0.1:{http_bind_port}:{target_host}:{config[tun_host]["http_port"]}') - buf.append(f'-R 127.0.0.1:{ssh_bind_port}:{target_host}:{config[tun_host]["ssh_port"]}') - - i += 1 - - print(host) - print(' '.join(buf)) - print() diff --git a/src/syncleo/__init__.py b/src/syncleo/__init__.py deleted file mode 100644 index 32563a5..0000000 --- a/src/syncleo/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# Polaris PWK 1725CGLD "smart" kettle python library -# -------------------------------------------------- -# Copyright (C) Evgeny Zinoviev, 2022 -# License: BSD-3c - -from .kettle import Kettle, DeviceListener -from .protocol import ( - PowerType, - IncomingMessageListener, - ConnectionStatusListener, - ConnectionStatus -) diff --git a/src/syncleo/kettle.py b/src/syncleo/kettle.py deleted file mode 100644 index d6e0dd6..0000000 --- a/src/syncleo/kettle.py +++ /dev/null @@ -1,243 +0,0 @@ -# Polaris PWK 1725CGLD smart kettle python library -# ------------------------------------------------ -# Copyright (C) Evgeny Zinoviev, 2022 -# License: BSD-3c - -from __future__ import annotations - -import threading -import logging -import zeroconf - -from abc import abstractmethod -from ipaddress import ip_address, IPv4Address, IPv6Address -from typing import Optional, List, Union - -from .protocol import ( - UDPConnection, - ModeMessage, - TargetTemperatureMessage, - PowerType, - ConnectionStatus, - ConnectionStatusListener, - WrappedMessage -) - - -class DeviceDiscover(threading.Thread, zeroconf.ServiceListener): - si: Optional[zeroconf.ServiceInfo] - _mac: str - _sb: Optional[zeroconf.ServiceBrowser] - _zc: Optional[zeroconf.Zeroconf] - _listeners: List[DeviceListener] - _valid_addresses: List[Union[IPv4Address, IPv6Address]] - _only_ipv4: bool - - def __init__(self, mac: str, - listener: Optional[DeviceListener] = None, - only_ipv4=True): - super().__init__() - self.si = None - self._mac = mac - self._zc = None - self._sb = None - self._only_ipv4 = only_ipv4 - self._valid_addresses = [] - self._listeners = [] - if isinstance(listener, DeviceListener): - self._listeners.append(listener) - self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__}') - - def add_listener(self, listener: DeviceListener): - if listener not in self._listeners: - self._listeners.append(listener) - else: - self._logger.warning(f'add_listener: listener {listener} already in the listeners list') - - def set_info(self, info: zeroconf.ServiceInfo): - valid_addresses = self._get_valid_addresses(info) - if not valid_addresses: - raise ValueError('no valid addresses') - self._valid_addresses = valid_addresses - self.si = info - for f in self._listeners: - try: - f.device_updated() - except Exception as exc: - self._logger.error(f'set_info: error while calling device_updated on {f}') - self._logger.exception(exc) - - def add_service(self, zc: zeroconf.Zeroconf, type_: str, name: str) -> None: - self._add_update_service('add_service', zc, type_, name) - - def update_service(self, zc: zeroconf.Zeroconf, type_: str, name: str) -> None: - self._add_update_service('update_service', zc, type_, name) - - def _add_update_service(self, method: str, zc: zeroconf.Zeroconf, type_: str, name: str) -> None: - info = zc.get_service_info(type_, name) - if name.startswith(f'{self._mac}.'): - self._logger.info(f'{method}: type={type_} name={name}') - try: - self.set_info(info) - except ValueError as exc: - self._logger.error(f'{method}: rejected: {str(exc)}') - else: - self._logger.debug(f'{method}: mac not matched: {info}') - - def remove_service(self, zc: zeroconf.Zeroconf, type_: str, name: str) -> None: - if name.startswith(f'{self._mac}.'): - self._logger.info(f'remove_service: type={type_} name={name}') - # TODO what to do here?! - - def run(self): - self._logger.debug('starting zeroconf service browser') - ip_version = zeroconf.IPVersion.V4Only if self._only_ipv4 else zeroconf.IPVersion.All - self._zc = zeroconf.Zeroconf(ip_version=ip_version) - self._sb = zeroconf.ServiceBrowser(self._zc, "_syncleo._udp.local.", self) - self._sb.join() - - def stop(self): - if self._sb: - try: - self._sb.cancel() - except RuntimeError: - pass - self._sb = None - self._zc.close() - self._zc = None - - def _get_valid_addresses(self, si: zeroconf.ServiceInfo) -> List[Union[IPv4Address, IPv6Address]]: - valid = [] - for addr in map(ip_address, si.addresses): - if self._only_ipv4 and not isinstance(addr, IPv4Address): - continue - if isinstance(addr, IPv4Address) and str(addr).startswith('169.254.'): - continue - valid.append(addr) - return valid - - @property - def pubkey(self) -> bytes: - return bytes.fromhex(self.si.properties[b'public'].decode()) - - @property - def curve(self) -> int: - return int(self.si.properties[b'curve'].decode()) - - @property - def addr(self) -> Union[IPv4Address, IPv6Address]: - return self._valid_addresses[0] - - @property - def port(self) -> int: - return int(self.si.port) - - @property - def protocol(self) -> int: - return int(self.si.properties[b'protocol'].decode()) - - -class DeviceListener: - @abstractmethod - def device_updated(self): - pass - - -class Kettle(DeviceListener, ConnectionStatusListener): - mac: str - device: Optional[DeviceDiscover] - device_token: str - conn: Optional[UDPConnection] - conn_status: Optional[ConnectionStatus] - _read_timeout: Optional[int] - _logger: logging.Logger - _find_evt: threading.Event - - def __init__(self, mac: str, device_token: str, read_timeout: Optional[int] = None): - super().__init__() - self.mac = mac - self.device = None - self.device_token = device_token - self.conn = None - self.conn_status = None - self._read_timeout = read_timeout - self._find_evt = threading.Event() - self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__}') - - def device_updated(self): - self._find_evt.set() - self._logger.info(f'device updated, service info: {self.device.si}') - - def connection_status_updated(self, status: ConnectionStatus): - self.conn_status = status - - def discover(self, wait=True, timeout=None, listener=None) -> Optional[zeroconf.ServiceInfo]: - do_start = False - if not self.device: - self.device = DeviceDiscover(self.mac, listener=self, only_ipv4=True) - do_start = True - self._logger.debug('discover: started device discovery') - else: - self._logger.warning('discover: already started') - - if listener is not None: - self.device.add_listener(listener) - - if do_start: - self.device.start() - - if wait: - self._find_evt.clear() - try: - self._find_evt.wait(timeout=timeout) - except KeyboardInterrupt: - self.device.stop() - return None - return self.device.si - - def start_server_if_needed(self, - incoming_message_listener=None, - connection_status_listener=None): - if self.conn: - self._logger.warning('start_server_if_needed: server is already started!') - self.conn.set_address(self.device.addr, self.device.port) - self.conn.set_device_pubkey(self.device.pubkey) - return - - assert self.device.curve == 29, f'curve type {self.device.curve} is not implemented' - assert self.device.protocol == 2, f'protocol {self.device.protocol} is not supported' - - kw = {} - if self._read_timeout is not None: - kw['read_timeout'] = self._read_timeout - self.conn = UDPConnection(addr=self.device.addr, - port=self.device.port, - device_pubkey=self.device.pubkey, - device_token=bytes.fromhex(self.device_token), **kw) - if incoming_message_listener: - self.conn.add_incoming_message_listener(incoming_message_listener) - - self.conn.add_connection_status_listener(self) - if connection_status_listener: - self.conn.add_connection_status_listener(connection_status_listener) - - self.conn.start() - - def stop_all(self): - # when we stop server, we should also stop device discovering service - if self.conn: - self.conn.interrupted = True - self.conn = None - self.device.stop() - self.device = None - - def is_connected(self) -> bool: - return self.conn is not None and self.conn_status == ConnectionStatus.CONNECTED - - def set_power(self, power_type: PowerType, callback: callable): - message = ModeMessage(power_type) - self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True)) - - def set_target_temperature(self, temp: int, callback: callable): - message = TargetTemperatureMessage(temp) - self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True)) diff --git a/src/syncleo/protocol.py b/src/syncleo/protocol.py deleted file mode 100644 index 36a1a8f..0000000 --- a/src/syncleo/protocol.py +++ /dev/null @@ -1,1169 +0,0 @@ -# Polaris PWK 1725CGLD "smart" kettle python library -# -------------------------------------------------- -# Copyright (C) Evgeny Zinoviev, 2022 -# License: BSD-3c - -from __future__ import annotations - -import logging -import socket -import random -import struct -import threading -import time - -from abc import abstractmethod, ABC -from enum import Enum, auto -from typing import Union, Optional, Dict, Tuple, List -from ipaddress import IPv4Address, IPv6Address - -import cryptography.hazmat.primitives._serialization as srlz - -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey -from cryptography.hazmat.primitives import ciphers, padding, hashes -from cryptography.hazmat.primitives.ciphers import algorithms, modes - -ReprDict = Dict[str, Union[str, int, float, bool]] -_logger = logging.getLogger(__name__) - -PING_FREQUENCY = 3 -RESEND_ATTEMPTS = 5 -ERROR_TIMEOUT = 15 -MESSAGE_QUEUE_REMOVE_DELAY = 13 # after what time to delete (and pass False to handlers, if needed) messages with phase=DONE from queue -DISCONNECT_TIMEOUT = 15 - - -def safe_callback_call(f: callable, - *args, - logger: logging.Logger = None, - error_message: str = None): - try: - return f(*args) - except Exception as exc: - logger.error(f'{error_message}, see exception below:') - logger.exception(exc) - return None - - -# drop-in replacement for java.lang.System.arraycopy -# TODO: rewrite -def arraycopy(src, src_pos, dest, dest_pos, length): - for i in range(length): - dest[i + dest_pos] = src[i + src_pos] - - -# "convert" unsigned byte to signed -def u8_to_s8(b: int) -> int: - return struct.unpack('b', bytes([b]))[0] - - -class PowerType(Enum): - OFF = 0 # turn off - ON = 1 # turn on, set target temperature to 100 - CUSTOM = 3 # turn on, allows custom target temperature - # MYSTERY_MODE = 2 # don't know what 2 means, needs testing - # update: if I set it to '2', it just resets to '0' - - -# low-level protocol structures -# ----------------------------- - -class FrameType(Enum): - ACK = 0 - CMD = 1 - AUX = 2 - NAK = 3 - - -class FrameHead: - seq: Optional[int] # u8 - type: FrameType # u8 - length: int # u16. This is the length of FrameItem's payload - - @staticmethod - def from_bytes(buf: bytes) -> FrameHead: - seq, ft, length = struct.unpack(' bytes: - assert self.length != 0, "FrameHead.length has not been set" - assert self.seq is not None, "FrameHead.seq has not been set" - return struct.pack(' bytes: - ba = bytearray(self.head.pack()) - ba.extend(self.payload) - return bytes(ba) - - -# high-level wrappers around FrameItem -# ------------------------------------ - -class MessagePhase(Enum): - WAITING = 0 - SENT = 1 - DONE = 2 - - -class Message: - frame: Optional[FrameItem] - id: int - - _global_id = 0 - - def __init__(self): - self.frame = None - - # global internal message id, only useful for debugging purposes - self.id = self.next_id() - - def __repr__(self): - return f'<{self.__class__.__name__} id={self.id} seq={self.frame.head.seq}>' - - @staticmethod - def next_id(): - _id = Message._global_id - Message._global_id = (Message._global_id + 1) % 100000 - return _id - - @staticmethod - def from_encrypted(buf: bytes, inkey: bytes, outkey: bytes) -> Message: - _logger.debug(f'Message:from_encrypted: buf={buf.hex()}') - - assert len(buf) >= 4, 'invalid size' - head = FrameHead.from_bytes(buf[:4]) - - assert len(buf) == head.length + 4, f'invalid buf size ({len(buf)} != {head.length})' - payload = buf[4:] - b = head.seq - - j = b & 0xF - k = b >> 4 & 0xF - - key = bytearray(len(inkey)) - arraycopy(inkey, j, key, 0, len(inkey) - j) - arraycopy(inkey, 0, key, len(inkey) - j, j) - - iv = bytearray(len(outkey)) - arraycopy(outkey, k, iv, 0, len(outkey) - k) - arraycopy(outkey, 0, iv, len(outkey) - k, k) - - cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv)) - decryptor = cipher.decryptor() - decrypted_data = decryptor.update(payload) + decryptor.finalize() - - unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() - decrypted_data = unpadder.update(decrypted_data) - decrypted_data += unpadder.finalize() - - assert len(decrypted_data) != 0, 'decrypted data is null' - assert head.seq == decrypted_data[0], f'decrypted seq mismatch {head.seq} != {decrypted_data[0]}' - - # _logger.debug('Message.from_encrypted: plaintext: '+decrypted_data.hex()) - - if head.type == FrameType.ACK: - return AckMessage(head.seq) - - elif head.type == FrameType.NAK: - return NakMessage(head.seq) - - elif head.type == FrameType.AUX: - # TODO implement AUX - raise NotImplementedError('FrameType AUX is not yet implemented') - - elif head.type == FrameType.CMD: - type = decrypted_data[1] - data = decrypted_data[2:] - - cl = UnknownMessage - - subclasses = [cl for cl in CmdIncomingMessage.__subclasses__() if cl is not SimpleBooleanMessage] - subclasses.extend(SimpleBooleanMessage.__subclasses__()) - - for _cl in subclasses: - # `UnknownMessage` is a special class that holds a packed command that we don't recognize. - # It will be used anyway if we don't find a match, so skip it here - if _cl == UnknownMessage: - continue - - if _cl.TYPE == type: - cl = _cl - break - - m = cl.from_packed_data(data, seq=head.seq) - if isinstance(m, UnknownMessage): - m.set_type(type) - return m - - else: - raise NotImplementedError(f'Unexpected frame type: {head.type}') - - def pack_data(self) -> bytes: - return b'' - - @property - def seq(self) -> Union[int, None]: - try: - return self.frame.head.seq - except: - return None - - @seq.setter - def seq(self, seq: int): - self.frame.head.seq = seq - - def encrypt(self, outkey: bytes, inkey: bytes, token: bytes, pubkey: bytes): - assert self.frame is not None - - data = self._get_data_to_encrypt() - assert data is not None - - b = self.frame.head.seq - i = b & 0xf - j = b >> 4 & 0xf - - outkey = bytearray(outkey) - - l = len(outkey) - key = bytearray(l) - - arraycopy(outkey, i, key, 0, l-i) - arraycopy(outkey, 0, key, l-i, i) - - inkey = bytearray(inkey) - - l = len(inkey) - iv = bytearray(l) - - arraycopy(inkey, j, iv, 0, l-j) - arraycopy(inkey, 0, iv, l-j, j) - - cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv)) - encryptor = cipher.encryptor() - - newdata = bytearray(len(data)+1) - newdata[0] = b - - arraycopy(data, 0, newdata, 1, len(data)) - - newdata = bytes(newdata) - _logger.debug('frame payload to be encrypted: ' + newdata.hex()) - - padder = padding.PKCS7(algorithms.AES.block_size).padder() - ciphertext = bytearray() - ciphertext.extend(encryptor.update(padder.update(newdata) + padder.finalize())) - ciphertext.extend(encryptor.finalize()) - - self.frame.setpayload(ciphertext) - - def _get_data_to_encrypt(self) -> bytes: - return self.pack_data() - - -class AckMessage(Message, ABC): - def __init__(self, seq: Optional[int] = None): - super().__init__() - self.frame = FrameItem(FrameHead(seq, FrameType.ACK, None)) - - -class NakMessage(Message, ABC): - def __init__(self, seq: Optional[int] = None): - super().__init__() - self.frame = FrameItem(FrameHead(seq, FrameType.NAK, None)) - - -class CmdMessage(Message): - type: Optional[int] - data: bytes - - TYPE = None - - def _get_data_to_encrypt(self) -> bytes: - buf = bytearray() - buf.append(self.get_type()) - buf.extend(self.pack_data()) - return bytes(buf) - - def __init__(self, seq: Optional[int] = None): - super().__init__() - self.frame = FrameItem(FrameHead(seq, FrameType.CMD)) - self.data = b'' - - def _repr_fields(self) -> ReprDict: - return { - 'cmd': self.get_type() - } - - def __repr__(self): - params = [ - __name__+'.'+self.__class__.__name__, - f'id={self.id}', - f'seq={self.seq}' - ] - fields = self._repr_fields() - if fields: - for k, v in fields.items(): - params.append(f'{k}={v}') - elif self.data: - params.append(f'data={self.data.hex()}') - return '<'+' '.join(params)+'>' - - def get_type(self) -> int: - return self.__class__.TYPE - - -class CmdIncomingMessage(CmdMessage): - @staticmethod - @abstractmethod - def from_packed_data(cls, data: bytes, seq: Optional[int] = None): - pass - - @abstractmethod - def _repr_fields(self) -> ReprDict: - pass - - -class CmdOutgoingMessage(CmdMessage): - @abstractmethod - def pack_data(self) -> bytes: - return b'' - - -class ModeMessage(CmdOutgoingMessage, CmdIncomingMessage): - TYPE = 1 - - pt: PowerType - - def __init__(self, power_type: PowerType, seq: Optional[int] = None): - super().__init__(seq) - self.pt = power_type - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> ModeMessage: - assert len(data) == 1, 'data size expected to be 1' - mode, = struct.unpack('B', data) - return ModeMessage(PowerType(mode), seq=seq) - - def pack_data(self) -> bytes: - return self.pt.value.to_bytes(1, byteorder='little') - - def _repr_fields(self) -> ReprDict: - return {'mode': self.pt.name} - - -class TargetTemperatureMessage(CmdOutgoingMessage, CmdIncomingMessage): - temperature: int - - TYPE = 2 - - def __init__(self, temp: int, seq: Optional[int] = None): - super().__init__(seq) - self.temperature = temp - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> TargetTemperatureMessage: - assert len(data) == 2, 'data size expected to be 2' - nat, frac = struct.unpack('BB', data) - temp = int(nat + (frac / 100)) - return TargetTemperatureMessage(temp, seq=seq) - - def pack_data(self) -> bytes: - return bytes([self.temperature, 0]) - - def _repr_fields(self) -> ReprDict: - return {'temperature': self.temperature} - - -class PingMessage(CmdIncomingMessage, CmdOutgoingMessage): - TYPE = 255 - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> PingMessage: - assert len(data) == 0, 'no data expected' - return PingMessage(seq=seq) - - def pack_data(self) -> bytes: - return b'' - - def _repr_fields(self) -> ReprDict: - return {} - - -# This is the first protocol message. Sent by a client. -# Kettle usually ACKs this, but sometimes i don't get any ACK and the very next message is HandshakeResponseMessage. -class HandshakeMessage(CmdMessage): - TYPE = 0 - - def encrypt(self, - outkey: bytes, - inkey: bytes, - token: bytes, - pubkey: bytes): - cipher = ciphers.Cipher(algorithms.AES(outkey), modes.CBC(inkey)) - encryptor = cipher.encryptor() - - ciphertext = bytearray() - ciphertext.extend(encryptor.update(token)) - ciphertext.extend(encryptor.finalize()) - - pld = bytearray() - pld.append(0) - pld.extend(pubkey) - pld.extend(ciphertext) - - self.frame.setpayload(pld) - - -# Kettle either sends this right after the handshake, of first it ACKs the handshake then sends this. -class HandshakeResponseMessage(CmdIncomingMessage): - TYPE = 0 - - protocol: int - fw_major: int - fw_minor: int - mode: int - token: bytes - - def __init__(self, - protocol: int, - fw_major: int, - fw_minor: int, - mode: int, - token: bytes, - seq: Optional[int] = None): - super().__init__(seq) - self.protocol = protocol - self.fw_major = fw_major - self.fw_minor = fw_minor - self.mode = mode - self.token = token - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> HandshakeResponseMessage: - protocol, fw_major, fw_minor, mode = struct.unpack(' ReprDict: - return { - 'protocol': self.protocol, - 'fw': f'{self.fw_major}.{self.fw_minor}', - 'mode': self.mode, - 'token': self.token.hex() - } - - -# Apparently, some hardware info. -# On the other hand, if you look at com.syncleiot.iottransport.commands.CmdHardware, its mqtt topic says "mcu_firmware". -# My device returns 1.1.1. The kettle uses on ESP8266 ESP-12F MCU under the hood (or, more precisely, under a piece of -# cheap plastic), so maybe 1.1.1 is some MCU ROM version. -class DeviceHardwareMessage(CmdIncomingMessage): - TYPE = 143 # -113 - - hw: List[int] - - def __init__(self, hw: List[int], seq: Optional[int] = None): - super().__init__(seq) - self.hw = hw - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> DeviceHardwareMessage: - assert len(data) == 3, 'invalid data size, expected 3' - hw = list(struct.unpack(' ReprDict: - return {'device_hardware': '.'.join(map(str, self.hw))} - - -# This message is sent by kettle right after the HandshakeMessageResponse. -# The diagnostic data is supposed to be sent to vendor, which we, obviously, not going to do. -# So just ACK and skip it. -class DeviceDiagnosticMessage(CmdIncomingMessage): - TYPE = 145 # -111 - - diag_data: bytes - - def __init__(self, diag_data: bytes, seq: Optional[int] = None): - super().__init__(seq) - self.diag_data = diag_data - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> DeviceDiagnosticMessage: - return DeviceDiagnosticMessage(diag_data=data, seq=seq) - - def _repr_fields(self) -> ReprDict: - return {'diag_data': self.diag_data.hex()} - - -class SimpleBooleanMessage(ABC, CmdIncomingMessage): - value: bool - - def __init__(self, value: bool, seq: Optional[int] = None): - super().__init__(seq) - self.value = value - - @classmethod - def from_packed_data(cls, data: bytes, seq: Optional[int] = None): - assert len(data) == 1, 'invalid data size, expected 1' - enabled, = struct.unpack(' ReprDict: - pass - - -class AccessControlMessage(SimpleBooleanMessage): - TYPE = 133 # -123 - - def _repr_fields(self) -> ReprDict: - return {'acl_enabled': self.value} - - -class ErrorMessage(SimpleBooleanMessage): - TYPE = 7 - - def _repr_fields(self) -> ReprDict: - return {'error': self.value} - - -class ChildLockMessage(SimpleBooleanMessage): - TYPE = 30 - - def _repr_fields(self) -> ReprDict: - return {'child_lock': self.value} - - -class VolumeMessage(SimpleBooleanMessage): - TYPE = 9 - - def _repr_fields(self) -> ReprDict: - return {'volume': self.value} - - -class BacklightMessage(SimpleBooleanMessage): - TYPE = 28 - - def _repr_fields(self) -> ReprDict: - return {'backlight': self.value} - - -class CurrentTemperatureMessage(CmdIncomingMessage): - TYPE = 20 - - current_temperature: int - - def __init__(self, temp: int, seq: Optional[int] = None): - super().__init__(seq) - self.current_temperature = temp - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> CurrentTemperatureMessage: - assert len(data) == 2, 'data size expected to be 2' - nat, frac = struct.unpack('BB', data) - temp = int(nat + (frac / 100)) - return CurrentTemperatureMessage(temp, seq=seq) - - def pack_data(self) -> bytes: - return bytes([self.current_temperature, 0]) - - def _repr_fields(self) -> ReprDict: - return {'current_temperature': self.current_temperature} - - -class UnknownMessage(CmdIncomingMessage): - type: Optional[int] - data: bytes - - def __init__(self, data: bytes, **kwargs): - super().__init__(**kwargs) - self.type = None - self.data = data - - @classmethod - def from_packed_data(cls, data: bytes, seq=0) -> UnknownMessage: - return UnknownMessage(data, seq=seq) - - def set_type(self, type: int): - self.type = type - - def get_type(self) -> int: - return self.type - - def _repr_fields(self) -> ReprDict: - return { - 'type': self.type, - 'data': self.data.hex() - } - - -class WrappedMessage: - _message: Message - _handler: Optional[callable] - _validator: Optional[callable] - _logger: Optional[logging.Logger] - _phase: MessagePhase - _phase_update_time: float - - def __init__(self, - message: Message, - handler: Optional[callable] = None, - validator: Optional[callable] = None, - ack=False): - self._message = message - self._handler = handler - self._validator = validator - self._logger = None - self._phase = MessagePhase.WAITING - self._phase_update_time = 0 - if not validator and ack: - self._validator = lambda m: isinstance(m, AckMessage) - - def setlogger(self, logger: logging.Logger): - self._logger = logger - - def validate(self, message: Message): - if not self._validator: - return True - return self._validator(message) - - def call(self, *args, error_message: str = None) -> None: - if not self._handler: - return - try: - self._handler(*args) - except Exception as exc: - logger = self._logger or logging.getLogger(self.__class__.__name__) - logger.error(f'{error_message}, see exception below:') - logger.exception(exc) - - @property - def phase(self) -> MessagePhase: - return self._phase - - @phase.setter - def phase(self, phase: MessagePhase): - self._phase = phase - self._phase_update_time = 0 if phase == MessagePhase.WAITING else time.time() - - @property - def phase_update_time(self) -> float: - return self._phase_update_time - - @property - def message(self) -> Message: - return self._message - - @property - def id(self) -> int: - return self._message.id - - @property - def seq(self) -> int: - return self._message.seq - - @seq.setter - def seq(self, seq: int): - self._message.seq = seq - - def __repr__(self): - return f'<{__name__}.{self.__class__.__name__} message={self._message.__repr__()}>' - - -# Connection stuff -# Well, strictly speaking, as it's UDP, there's no connection, but who cares. -# --------------------------------------------------------------------------- - -class IncomingMessageListener: - @abstractmethod - def incoming_message(self, message: Message) -> Optional[Message]: - pass - - -class ConnectionStatus(Enum): - NOT_CONNECTED = auto() - CONNECTING = auto() - CONNECTED = auto() - RECONNECTING = auto() - DISCONNECTED = auto() - - -class ConnectionStatusListener: - @abstractmethod - def connection_status_updated(self, status: ConnectionStatus): - pass - - -class UDPConnection(threading.Thread, ConnectionStatusListener): - inseq: int - outseq: int - source_port: int - device_addr: str - device_port: int - device_token: bytes - device_pubkey: bytes - interrupted: bool - response_handlers: Dict[int, WrappedMessage] - outgoing_queue: List[WrappedMessage] - pubkey: Optional[bytes] - encinkey: Optional[bytes] - encoutkey: Optional[bytes] - inc_listeners: List[IncomingMessageListener] - conn_listeners: List[ConnectionStatusListener] - outgoing_time: float - outgoing_time_1st: float - incoming_time: float - status: ConnectionStatus - reconnect_tries: int - read_timeout: int - - _addr_lock: threading.Lock - _iml_lock: threading.Lock - _csl_lock: threading.Lock - _st_lock: threading.Lock - - def __init__(self, - addr: Union[IPv4Address, IPv6Address], - port: int, - device_pubkey: bytes, - device_token: bytes, - read_timeout: int = 1): - super().__init__() - self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__} <{hex(id(self))}>') - self.setName(self.__class__.__name__) - - self.inseq = 0 - self.outseq = 0 - self.source_port = random.randint(1024, 65535) - self.device_addr = str(addr) - self.device_port = port - self.device_token = device_token - self.device_pubkey = device_pubkey - self.outgoing_queue = [] - self.response_handlers = {} - self.interrupted = False - self.outgoing_time = 0 - self.outgoing_time_1st = 0 - self.incoming_time = 0 - self.inc_listeners = [] - self.conn_listeners = [self] - self.status = ConnectionStatus.NOT_CONNECTED - self.reconnect_tries = 0 - self.read_timeout = read_timeout - - self._iml_lock = threading.Lock() - self._csl_lock = threading.Lock() - self._addr_lock = threading.Lock() - self._st_lock = threading.Lock() - - self.pubkey = None - self.encinkey = None - self.encoutkey = None - - def connection_status_updated(self, status: ConnectionStatus): - # self._logger.info(f'connection_status_updated: status = {status}') - with self._st_lock: - # self._logger.debug(f'connection_status_updated: lock acquired') - self.status = status - if status == ConnectionStatus.RECONNECTING: - self.reconnect_tries += 1 - if status in (ConnectionStatus.CONNECTED, ConnectionStatus.NOT_CONNECTED, ConnectionStatus.DISCONNECTED): - self.reconnect_tries = 0 - - def _cleanup(self): - # erase outgoing queue - for wm in self.outgoing_queue: - wm.call(False, - error_message=f'_cleanup: exception while calling cb(False) on message {wm.message}') - self.outgoing_queue = [] - self.response_handlers = {} - - # reset timestamps - self.incoming_time = 0 - self.outgoing_time = 0 - self.outgoing_time_1st = 0 - - self._logger.debug('_cleanup: done') - - def set_address(self, addr: Union[IPv4Address, IPv6Address], port: int): - with self._addr_lock: - if self.device_addr != str(addr) or self.device_port != port: - self.device_addr = str(addr) - self.device_port = port - self._logger.info(f'updated device network address: {self.device_addr}:{self.device_port}') - - def set_device_pubkey(self, pubkey: bytes): - if self.device_pubkey.hex() != pubkey.hex(): - self._logger.info(f'device pubkey has changed (old={self.device_pubkey.hex()}, new={pubkey.hex()})') - self.device_pubkey = pubkey - self._notify_cs(ConnectionStatus.RECONNECTING) - - def get_address(self) -> Tuple[str, int]: - with self._addr_lock: - return self.device_addr, self.device_port - - def add_incoming_message_listener(self, listener: IncomingMessageListener): - with self._iml_lock: - if listener not in self.inc_listeners: - self.inc_listeners.append(listener) - - def add_connection_status_listener(self, listener: ConnectionStatusListener): - with self._csl_lock: - if listener not in self.conn_listeners: - self.conn_listeners.append(listener) - - def _notify_cs(self, status: ConnectionStatus): - # self._logger.debug(f'_notify_cs: status={status}') - with self._csl_lock: - for obj in self.conn_listeners: - # self._logger.debug(f'_notify_cs: notifying {obj}') - obj.connection_status_updated(status) - - def _prepare_keys(self): - # generate key pair - privkey = X25519PrivateKey.generate() - - self.pubkey = bytes(reversed(privkey.public_key().public_bytes(encoding=srlz.Encoding.Raw, - format=srlz.PublicFormat.Raw))) - - # generate shared key - device_pubkey = X25519PublicKey.from_public_bytes( - bytes(reversed(self.device_pubkey)) - ) - shared_key = bytes(reversed( - privkey.exchange(device_pubkey) - )) - - # in/out encryption keys - digest = hashes.Hash(hashes.SHA256()) - digest.update(shared_key) - - shared_sha256 = digest.finalize() - - self.encinkey = shared_sha256[:16] - self.encoutkey = shared_sha256[16:] - - self._logger.info('encryption keys have been created') - - def _handshake_callback(self, r: MessageResponse): - # if got error for our HandshakeMessage, reset everything and try again - if r is False: - # self._logger.debug('_handshake_callback: set status=RECONNETING') - self._notify_cs(ConnectionStatus.RECONNECTING) - else: - # self._logger.debug('_handshake_callback: set status=CONNECTED') - self._notify_cs(ConnectionStatus.CONNECTED) - - def run(self): - self._logger.info('starting server loop') - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(('0.0.0.0', self.source_port)) - sock.settimeout(self.read_timeout) - - while not self.interrupted: - with self._st_lock: - status = self.status - - if status in (ConnectionStatus.DISCONNECTED, ConnectionStatus.RECONNECTING): - self._cleanup() - if status == ConnectionStatus.DISCONNECTED: - break - - # no activity for some time means connection is broken - fail = False - fail_path = 0 - if self.incoming_time > 0 and time.time() - self.incoming_time >= DISCONNECT_TIMEOUT: - fail = True - fail_path = 1 - elif self.outgoing_time_1st > 0 and self.incoming_time == 0 and time.time() - self.outgoing_time_1st >= DISCONNECT_TIMEOUT: - fail = True - fail_path = 2 - - if fail: - self._logger.debug(f'run: setting status=RECONNECTING because of long inactivity, fail_path={fail_path}') - self._notify_cs(ConnectionStatus.RECONNECTING) - - # establishing a connection - if status in (ConnectionStatus.RECONNECTING, ConnectionStatus.NOT_CONNECTED): - if status == ConnectionStatus.RECONNECTING and self.reconnect_tries >= 3: - self._notify_cs(ConnectionStatus.DISCONNECTED) - continue - - self._reset_outseq() - self._prepare_keys() - - # shake the imaginary kettle's hand - wrapped = WrappedMessage(HandshakeMessage(), - handler=self._handshake_callback, - validator=lambda m: isinstance(m, (AckMessage, HandshakeResponseMessage))) - self.enqueue_message(wrapped, prepend=True) - self._notify_cs(ConnectionStatus.CONNECTING) - - # pick next (wrapped) message to send - wm = self._get_next_message() # wm means "wrapped message" - if wm: - one_shot = isinstance(wm.message, (AckMessage, NakMessage)) - - if not isinstance(wm.message, (AckMessage, NakMessage)): - old_seq = wm.seq - wm.seq = self.outseq - self._set_response_handler(wm, old_seq=old_seq) - elif wm.seq is None: - # ack/nak is a response to some incoming message (and it must have the same seqno that incoming - # message had) - raise RuntimeError(f'run: seq must be set for {wm.__class__.__name__}') - - self._logger.debug(f'run: sending message: {wm.message}, one_shot={one_shot}, phase={wm.phase}') - encrypted = False - try: - wm.message.encrypt(outkey=self.encoutkey, inkey=self.encinkey, - token=self.device_token, pubkey=self.pubkey) - encrypted = True - except ValueError as exc: - # handle "ValueError: Invalid padding bytes." - self._logger.error('run: failed to encrypt the message.') - self._logger.exception(exc) - - if encrypted: - buf = wm.message.frame.pack() - # self._logger.debug(f'run: raw data to be sent: {buf.hex()}') - - # sending the first time - if wm.phase == MessagePhase.WAITING: - sock.sendto(buf, self.get_address()) - # resending - elif wm.phase == MessagePhase.SENT: - left = RESEND_ATTEMPTS - while left > 0: - sock.sendto(buf, self.get_address()) - left -= 1 - if left > 0: - time.sleep(0.05) - - if one_shot or wm.phase == MessagePhase.SENT: - wm.phase = MessagePhase.DONE - else: - wm.phase = MessagePhase.SENT - - now = time.time() - self.outgoing_time = now - if not self.outgoing_time_1st: - self.outgoing_time_1st = now - - # receiving data - try: - data = sock.recv(4096) - self._handle_incoming(data) - except (TimeoutError, socket.timeout): - pass - - self._logger.info('bye...') - - def _get_next_message(self) -> Optional[WrappedMessage]: - message = None - lpfx = '_get_next_message:' - remove_list = [] - for wm in self.outgoing_queue: - if wm.phase == MessagePhase.DONE: - if isinstance(wm.message, (AckMessage, NakMessage, PingMessage)) or time.time() - wm.phase_update_time >= MESSAGE_QUEUE_REMOVE_DELAY: - remove_list.append(wm) - continue - message = wm - break - - for wm in remove_list: - self._logger.debug(f'{lpfx} rm path: removing id={wm.id} seq={wm.seq}') - - # clear message handler - if wm.seq in self.response_handlers: - self.response_handlers[wm.seq].call( - False, error_message=f'{lpfx} rm path: error while calling callback for seq={wm.seq}') - del self.response_handlers[wm.seq] - - # remove from queue - try: - self.outgoing_queue.remove(wm) - except ValueError as exc: - self._logger.error(f'{lpfx} rm path: removing from outgoing_queue raised an exception: {str(exc)}') - - # ping pong - if not message and self.outgoing_time_1st != 0 and self.status == ConnectionStatus.CONNECTED: - now = time.time() - out_delta = now - self.outgoing_time - in_delta = now - self.incoming_time - if max(out_delta, in_delta) > PING_FREQUENCY: - self._logger.debug(f'{lpfx} no activity: in for {in_delta:.2f}s, out for {out_delta:.2f}s, time to ping the damn thing') - message = WrappedMessage(PingMessage(), ack=True) - # add it to outgoing_queue in order to be aggressively resent in future (if needed) - self.outgoing_queue.insert(0, message) - - return message - - def _handle_incoming(self, buf: bytes): - try: - incoming_message = Message.from_encrypted(buf, inkey=self.encinkey, outkey=self.encoutkey) - except ValueError as exc: - # handle "ValueError: Invalid padding bytes." - self._logger.error('_handle_incoming: failed to decrypt incoming frame:') - self._logger.exception(exc) - return - - self.incoming_time = time.time() - seq = incoming_message.seq - - lpfx = f'handle_incoming({incoming_message.id}):' - self._logger.debug(f'{lpfx} received: {incoming_message}') - - if isinstance(incoming_message, (AckMessage, NakMessage)): - seq_max = self.outseq - seq_name = 'outseq' - else: - seq_max = self.inseq - seq_name = 'inseq' - self.inseq = seq - - if seq < seq_max < 0xfd: - self._logger.debug(f'{lpfx} dropping: seq={seq}, {seq_name}={seq_max}') - return - - if seq not in self.response_handlers: - self._handle_incoming_cmd(incoming_message) - return - - callback_value = None # None means don't call a callback - handler = self.response_handlers[seq] - - if handler.validate(incoming_message): - self._logger.debug(f'{lpfx} response OK') - handler.phase = MessagePhase.DONE - callback_value = incoming_message - self._incr_outseq() - else: - self._logger.warning(f'{lpfx} response is INVALID') - - # It seems that we've received an incoming CmdMessage or PingMessage with the same seqno that our outgoing - # message had. Bad, but what can I say, this is quick-and-dirty made UDP based protocol and this sort of - # shit just happens. - - # (To be fair, maybe my implementation is not perfect either. But hey, what did you expect from a - # reverse-engineered re-implementation of custom UDP-based protocol that some noname vendor uses for their - # cheap IoT devices? I think _that_ is _the_ definition of shit. At least my implementation is FOSS, which - # is more than you'll ever be able to say about them.) - - # All this crapload of code below might not be needed at all, 'cause the protocol uses separate frame seq - # numbers for IN and OUT frames and this situation is not highly likely, as Theresa May could argue. - # After a handshake, a kettle sends us 10 or so CmdMessages, and then either we continuously ping it every - # 3 seconds, or kettle pings us. This in any case widens the gap between inseq and outseq. - - # But! the seqno is only 1 byte in size and once it reaches 0xff, it circles back to zero. And that (plus, - # perhaps, some bad luck) gives a chance for a collision. - - if handler.phase == MessagePhase.DONE or isinstance(handler.message, HandshakeMessage): - # no more attempts left, returning error back to user - # as to handshake, it cannot fail. - callback_value = False - - # else: - # # try resending the message - # handler.phase_reset() - # max_seq = self.outseq - # wait_remap = {} - # for m in self.outgoing_queue: - # if m.seq in self.waiting_for_response: - # wait_remap[m.seq] = (m.seq+1) % 256 - # m.set_seq((m.seq+1) % 256) - # if m.seq > max_seq: - # max_seq = m.seq - # if max_seq > self.outseq: - # self.outseq = max_seq % 256 - # if wait_remap: - # waiting_new = {} - # for old_seq, new_seq in wait_remap.items(): - # waiting_new[new_seq] = self.waiting_for_response[old_seq] - # self.waiting_for_response = waiting_new - - if isinstance(incoming_message, (PingMessage, CmdIncomingMessage)): - # handle incoming message as usual, as we need to ack/nak it anyway - self._handle_incoming_cmd(incoming_message) - - if callback_value is not None: - handler.call(callback_value, - error_message=f'{lpfx} error while calling callback for msg id={handler.message.id} seq={seq}') - del self.response_handlers[seq] - - def _handle_incoming_cmd(self, incoming_message: Message): - if isinstance(incoming_message, (AckMessage, NakMessage)): - self._logger.debug(f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): it\'s {incoming_message.__class__.__name__}, ignoring') - return - - replied = False - with self._iml_lock: - for f in self.inc_listeners: - retval = safe_callback_call(f.incoming_message, incoming_message, - logger=self._logger, - error_message=f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): error while calling message listener') - if isinstance(retval, Message): - if isinstance(retval, (AckMessage, NakMessage)): - retval.seq = incoming_message.seq - self.enqueue_message(WrappedMessage(retval), prepend=True) - replied = True - break - else: - raise RuntimeError('are you sure your response is correct? only ack/nak are allowed') - - if not replied: - self.enqueue_message(WrappedMessage(AckMessage(incoming_message.seq)), prepend=True) - - def enqueue_message(self, wrapped: WrappedMessage, prepend=False): - self._logger.debug(f'enqueue_message: {wrapped.message}') - if not prepend: - self.outgoing_queue.append(wrapped) - else: - self.outgoing_queue.insert(0, wrapped) - - def _set_response_handler(self, wm: WrappedMessage, old_seq=None): - if old_seq in self.response_handlers: - del self.response_handlers[old_seq] - - seq = wm.seq - assert seq is not None, 'seq is not set' - - if seq in self.response_handlers: - self._logger.debug(f'_set_response_handler(seq={seq}): handler is already set, cancelling it') - self.response_handlers[seq].call(False, - error_message=f'_set_response_handler({seq}): error while calling old callback') - self.response_handlers[seq] = wm - - def _incr_outseq(self) -> None: - self.outseq = (self.outseq + 1) % 256 - - def _reset_outseq(self): - self.outseq = 0 - self._logger.debug(f'_reset_outseq: set 0') - - -MessageResponse = Union[Message, bool] diff --git a/src/temphum_mqtt_node.py b/src/temphum_mqtt_node.py deleted file mode 100755 index c3d1975..0000000 --- a/src/temphum_mqtt_node.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import json -import logging - -from typing import Optional - -from home.config import config -from home.temphum import SensorType, BaseSensor -from home.temphum.i2c import create_sensor - -logger = logging.getLogger(__name__) -sensor: Optional[BaseSensor] = None -lock = asyncio.Lock() -delay = 0.01 - - -async def get_measurements(): - async with lock: - await asyncio.sleep(delay) - - temp = sensor.temperature() - rh = sensor.humidity() - - return rh, temp - - -async def handle_client(reader, writer): - request = None - while request != 'quit': - try: - request = await reader.read(255) - if request == b'\x04': - break - request = request.decode('utf-8').strip() - except Exception: - break - - if request == 'read': - try: - rh, temp = await asyncio.wait_for(get_measurements(), timeout=3) - data = dict(humidity=rh, temp=temp) - except asyncio.TimeoutError as e: - logger.exception(e) - data = dict(error='i2c call timed out') - else: - data = dict(error='invalid request') - - writer.write((json.dumps(data) + '\r\n').encode('utf-8')) - try: - await writer.drain() - except ConnectionResetError: - pass - - writer.close() - - -async def run_server(host, port): - server = await asyncio.start_server(handle_client, host, port) - async with server: - logger.info('Server started.') - await server.serve_forever() - - -if __name__ == '__main__': - config.load_app() - - if 'measure_delay' in config['sensor']: - delay = float(config['sensor']['measure_delay']) - - sensor = create_sensor(SensorType(config['sensor']['type']), - int(config['sensor']['bus'])) - - try: - host, port = config.get_addr('server.listen') - asyncio.run(run_server(host, port)) - except KeyboardInterrupt: - logging.info('Exiting...') diff --git a/src/temphum_mqtt_receiver.py b/src/temphum_mqtt_receiver.py deleted file mode 100755 index 2b30800..0000000 --- a/src/temphum_mqtt_receiver.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -import paho.mqtt.client as mqtt -import re - -from home.config import config -from home.mqtt import MqttWrapper, MqttNode - - -class MqttServer(Mqtt): - def __init__(self): - super().__init__(clean_session=False) - self.database = SensorsDatabase() - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - self._logger.info("subscribing to hk/#") - client.subscribe('hk/#', qos=1) - - def on_message(self, client: mqtt.Client, userdata, msg): - super().on_message(client, userdata, msg) - try: - variants = '|'.join([s.name.lower() for s in TemperatureSensorLocation]) - match = re.match(rf'hk/(\d+)/si7021/({variants})', msg.topic) - if not match: - return - - # FIXME string home_id must be supported - home_id = int(match.group(1)) - sensor = get_sensor_type(match.group(2)) - - payload = Temperature.unpack(msg.payload) - self.database.add_temperature(home_id, payload.time, sensor, - temp=int(payload.temp*100), - rh=int(payload.rh*100)) - except Exception as e: - self._logger.exception(str(e)) - - -if __name__ == '__main__': - config.load_app('temphum_mqtt_receiver') - - mqtt = MqttWrapper(clean_session=False) - node = MqttNode(node_id='+') - node.load_module('temphum', write_to_database=True) - mqtt.add_node(node) - - mqtt.connect_and_loop() \ No newline at end of file diff --git a/src/temphum_nodes_util.py b/src/temphum_nodes_util.py deleted file mode 100755 index c700ca8..0000000 --- a/src/temphum_nodes_util.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 -from home.mqtt.temphum import MqttTempHumNodes - -if __name__ == '__main__': - max_name_len = 0 - for node in MqttTempHumNodes: - if len(node.name) > max_name_len: - max_name_len = len(node.name) - - values = [] - for node in MqttTempHumNodes: - hash = node.hash() - if hash in values: - raise ValueError(f'collision detected: {hash}') - values.append(values) - print(' '*(max_name_len-len(node.name)), end='') - print(f'{node.name}: {hash}') diff --git a/src/temphum_smbus_util.py b/src/temphum_smbus_util.py deleted file mode 100755 index c06bacd..0000000 --- a/src/temphum_smbus_util.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -from argparse import ArgumentParser -from home.temphum import SensorType -from home.temphum.i2c import create_sensor - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('-t', '--type', choices=[item.value for item in SensorType], - required=True, - help='Sensor type') - parser.add_argument('-b', '--bus', type=int, default=0, - help='I2C bus number') - arg = parser.parse_args() - - sensor = create_sensor(SensorType(arg.type), arg.bus) - temp = sensor.temperature() - hum = sensor.humidity() - - print(f'temperature: {temp}') - print(f'rel. humidity: {hum}') diff --git a/src/temphumd.py b/src/temphumd.py deleted file mode 100755 index c3d1975..0000000 --- a/src/temphumd.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import json -import logging - -from typing import Optional - -from home.config import config -from home.temphum import SensorType, BaseSensor -from home.temphum.i2c import create_sensor - -logger = logging.getLogger(__name__) -sensor: Optional[BaseSensor] = None -lock = asyncio.Lock() -delay = 0.01 - - -async def get_measurements(): - async with lock: - await asyncio.sleep(delay) - - temp = sensor.temperature() - rh = sensor.humidity() - - return rh, temp - - -async def handle_client(reader, writer): - request = None - while request != 'quit': - try: - request = await reader.read(255) - if request == b'\x04': - break - request = request.decode('utf-8').strip() - except Exception: - break - - if request == 'read': - try: - rh, temp = await asyncio.wait_for(get_measurements(), timeout=3) - data = dict(humidity=rh, temp=temp) - except asyncio.TimeoutError as e: - logger.exception(e) - data = dict(error='i2c call timed out') - else: - data = dict(error='invalid request') - - writer.write((json.dumps(data) + '\r\n').encode('utf-8')) - try: - await writer.drain() - except ConnectionResetError: - pass - - writer.close() - - -async def run_server(host, port): - server = await asyncio.start_server(handle_client, host, port) - async with server: - logger.info('Server started.') - await server.serve_forever() - - -if __name__ == '__main__': - config.load_app() - - if 'measure_delay' in config['sensor']: - delay = float(config['sensor']['measure_delay']) - - sensor = create_sensor(SensorType(config['sensor']['type']), - int(config['sensor']['bus'])) - - try: - host, port = config.get_addr('server.listen') - asyncio.run(run_server(host, port)) - except KeyboardInterrupt: - logging.info('Exiting...') diff --git a/src/web_api.py b/src/web_api.py deleted file mode 100755 index 0aa994a..0000000 --- a/src/web_api.py +++ /dev/null @@ -1,239 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import json -import os - -from datetime import datetime, timedelta - -from aiohttp import web -from home import http -from home.config import config, is_development_mode -from home.database import BotsDatabase, SensorsDatabase, InverterDatabase -from home.database.inverter_time_formats import * -from home.api.types import BotType, TemperatureSensorLocation, SoundSensorLocation -from home.media import SoundRecordStorage - - -def strptime_auto(s: str) -> datetime: - e = None - for fmt in (FormatTime, FormatDate): - try: - return datetime.strptime(s, fmt) - except ValueError as _e: - e = _e - raise e - - -class AuthError(Exception): - def __init__(self, message: str): - super().__init__() - self.message = message - - -class WebAPIServer(http.HTTPServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.app.middlewares.append(self.validate_auth) - - self.get('/', self.get_index) - self.get('/sensors/data/', self.GET_sensors_data) - self.get('/sound_sensors/hits/', self.GET_sound_sensors_hits) - self.post('/sound_sensors/hits/', self.POST_sound_sensors_hits) - - self.post('/log/bot_request/', self.POST_bot_request_log) - self.post('/log/openwrt/', self.POST_openwrt_log) - - self.get('/inverter/consumed_energy/', self.GET_consumed_energy) - self.get('/inverter/grid_consumed_energy/', self.GET_grid_consumed_energy) - - self.get('/recordings/list/', self.GET_recordings_list) - - @staticmethod - @web.middleware - async def validate_auth(req: http.Request, handler): - def get_token() -> str: - name = 'X-Token' - if name in req.headers: - return req.headers[name] - - return req.query['token'] - - try: - token = get_token() - except KeyError: - raise AuthError('no token') - - if token != config['api']['token']: - raise AuthError('invalid token') - - return await handler(req) - - @staticmethod - async def get_index(req: http.Request): - message = "nothing here, keep lurking" - if is_development_mode(): - message += ' (dev mode)' - return http.Response(text=message, content_type='text/plain') - - async def GET_sensors_data(self, req: http.Request): - try: - hours = int(req.query['hours']) - if hours < 1 or hours > 24: - raise ValueError('invalid hours value') - except KeyError: - hours = 1 - - sensor = TemperatureSensorLocation(int(req.query['sensor'])) - - dt_to = datetime.now() - dt_from = dt_to - timedelta(hours=hours) - - db = SensorsDatabase() - data = db.get_temperature_recordings(sensor, (dt_from, dt_to)) - return self.ok(data) - - async def GET_sound_sensors_hits(self, req: http.Request): - location = SoundSensorLocation(int(req.query['location'])) - - after = int(req.query['after']) - kwargs = {} - if after is None: - last = int(req.query['last']) - if last is None: - raise ValueError('you must pass `after` or `last` params') - else: - if not 0 < last < 100: - raise ValueError('invalid last value: must be between 0 and 100') - kwargs['last'] = last - else: - kwargs['after'] = datetime.fromtimestamp(after) - - data = BotsDatabase().get_sound_hits(location, **kwargs) - return self.ok(data) - - async def POST_sound_sensors_hits(self, req: http.Request): - hits = [] - data = await req.post() - for hit, count in json.loads(data['hits']): - if not hasattr(SoundSensorLocation, hit.upper()): - raise ValueError('invalid sensor location') - if count < 1: - raise ValueError(f'invalid count: {count}') - hits.append((SoundSensorLocation[hit.upper()], count)) - - BotsDatabase().add_sound_hits(hits, datetime.now()) - return self.ok() - - async def POST_bot_request_log(self, req: http.Request): - data = await req.post() - - try: - user_id = int(data['user_id']) - except KeyError: - user_id = 0 - - try: - message = data['message'] - except KeyError: - message = '' - - bot = BotType(int(data['bot'])) - - # validate message - if message.strip() == '': - raise ValueError('message can\'t be empty') - - # add record to the database - BotsDatabase().add_request(bot, user_id, message) - - return self.ok() - - async def POST_openwrt_log(self, req: http.Request): - data = await req.post() - - try: - logs = data['logs'] - ap = int(data['ap']) - except KeyError: - logs = '' - ap = 0 - - # validate it - logs = json.loads(logs) - assert type(logs) is list, "invalid json data (list expected)" - - lines = [] - for line in logs: - assert type(line) is list, "invalid line type (list expected)" - assert len(line) == 2, f"expected 2 items in line, got {len(line)}" - assert type(line[0]) is int, "invalid line[0] type (int expected)" - assert type(line[1]) is str, "invalid line[1] type (str expected)" - - lines.append(( - datetime.fromtimestamp(line[0]), - line[1] - )) - - BotsDatabase().add_openwrt_logs(lines, ap) - return self.ok() - - async def GET_recordings_list(self, req: http.Request): - data = await req.post() - - try: - extended = bool(int(data['extended'])) - except KeyError: - extended = False - - node = data['node'] - - root = os.path.join(config['recordings']['directory'], node) - if not os.path.isdir(root): - raise ValueError(f'invalid node {node}: no such directory') - - storage = SoundRecordStorage(root) - files = storage.getfiles(as_objects=extended) - if extended: - files = list(map(lambda file: file.__dict__(), files)) - - return self.ok(files) - - @staticmethod - def _get_inverter_from_to(req: http.Request): - s_from = req.query['from'] - s_to = req.query['to'] - - dt_from = strptime_auto(s_from) - - if s_to == 'now': - dt_to = datetime.now() - else: - dt_to = strptime_auto(s_to) - - return dt_from, dt_to - - async def GET_consumed_energy(self, req: http.Request): - dt_from, dt_to = self._get_inverter_from_to(req) - wh = InverterDatabase().get_consumed_energy(dt_from, dt_to) - return self.ok(wh) - - async def GET_grid_consumed_energy(self, req: http.Request): - dt_from, dt_to = self._get_inverter_from_to(req) - wh = InverterDatabase().get_grid_consumed_energy(dt_from, dt_to) - return self.ok(wh) - - -# start of the program -# -------------------- - -if __name__ == '__main__': - _app_name = 'web_api' - if is_development_mode(): - _app_name += '_dev' - config.load_app(_app_name) - - loop = asyncio.get_event_loop() - - server = WebAPIServer(config.get_addr('server.listen')) - server.run() 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..a3a8356 100644 --- a/systemd/gpiorelayd@.service +++ b/systemd/gpiorelayd@.service @@ -6,7 +6,7 @@ After=network-online.target User=root Group=root Restart=on-failure -ExecStart=/home/user/homekit/src/gpiorelayd.py -c /etc/gpiorelayd.conf.d/%i.toml +ExecStart=/home/user/homekit/bin/gpiorelayd.py -c /etc/gpiorelayd.conf.d/%i.toml 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 index fedf11f..88f9169 100644 --- a/systemd/inverter_mqtt_receiver.service +++ b/systemd/inverter_mqtt_receiver.service @@ -6,7 +6,7 @@ After=clickhouse-server.service User=user Group=user Restart=on-failure -ExecStart=/home/user/homekit/src/inverter_mqtt_util.py receiver +ExecStart=/home/user/homekit/bin/inverter_mqtt_util.py receiver WorkingDirectory=/home/user [Install] diff --git a/systemd/inverter_mqtt_sender.service b/systemd/inverter_mqtt_sender.service index 34272bb..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_util.py sender +ExecStart=/home/user/homekit/bin/inverter_mqtt_util.py sender WorkingDirectory=/home/user [Install] diff --git a/systemd/ipcam_server.service b/systemd/ipcam_server.service index 07ac95f..e6f8918 100644 --- a/systemd/ipcam_server.service +++ b/systemd/ipcam_server.service @@ -7,7 +7,7 @@ User=user Group=user Restart=always RestartSec=10 -ExecStart=/home/user/homekit/src/ipcam_server.py +ExecStart=/home/user/homekit/bin/ipcam_server.py 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/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 diff --git a/test/test.py b/test/test.py index 7ea37e6..413c25c 100755 --- a/test/test.py +++ b/test/test.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -from home.relay import RelayClient +from homekit.relay import RelayClient if __name__ == '__main__': diff --git a/test/test_stopwatch.py b/test/test_stopwatch.py index 6ff2c0e..9dd7762 100755 --- a/test/test_stopwatch.py +++ b/test/test_stopwatch.py @@ -1,4 +1,4 @@ -from home.util import Stopwatch, StopwatchError +from homekit.util import Stopwatch, StopwatchError from time import sleep -- cgit v1.2.3