From f29e139cbb7e4a4d539cba6e894ef4a6acd312d6 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Wed, 31 May 2023 09:22:00 +0300 Subject: WIP: big refactoring --- .gitignore | 1 + misc/openwrt/etc/rc.local | 2 +- platformio/common/libs/main/homekit/main.cpp | 21 +- platformio/common/libs/main/homekit/main.h | 4 + platformio/common/libs/main/library.json | 2 +- .../common/libs/mqtt/homekit/mqtt/module.cpp | 2 +- platformio/common/libs/mqtt/homekit/mqtt/module.h | 15 +- platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp | 29 +- platformio/common/libs/mqtt/library.json | 2 +- .../homekit/mqtt/module/diagnostics.cpp | 11 +- .../homekit/mqtt/module/diagnostics.h | 5 +- .../libs/mqtt_module_diagnostics/library.json | 4 +- .../mqtt_module_ota/homekit/mqtt/module/ota.cpp | 12 +- .../libs/mqtt_module_ota/homekit/mqtt/module/ota.h | 7 +- .../common/libs/mqtt_module_ota/library.json | 4 +- .../homekit/mqtt/module/relay.cpp | 27 +- .../mqtt_module_relay/homekit/mqtt/module/relay.h | 10 +- .../common/libs/mqtt_module_relay/library.json | 2 +- .../homekit/mqtt/module/temphum.cpp | 2 +- .../homekit/mqtt/module/temphum.h | 2 +- .../common/libs/mqtt_module_temphum/library.json | 4 +- platformio/temphum_relayctl/src/main.cpp | 1 + requirements.txt | 21 +- src/camera_node.py | 2 +- src/esp32_capture.py | 4 +- src/esp32cam_capture_diff_node.py | 8 +- src/esp_mqtt_util.py | 42 --- src/gpiorelayd.py | 2 +- src/home/audio/amixer.py | 2 +- src/home/config/__init__.py | 14 +- src/home/config/_configs.py | 55 ++++ src/home/config/config.py | 329 +++++++++++++++------ src/home/database/clickhouse.py | 2 +- src/home/database/sqlite.py | 25 +- src/home/inverter/config.py | 13 + src/home/media/__init__.py | 1 + src/home/mqtt/__init__.py | 11 +- 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 | 59 ++++ src/home/mqtt/esp.py | 106 ------- 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/mqtt/mqtt.py | 76 ----- src/home/mqtt/payload/__init__.py | 1 - src/home/mqtt/payload/base_payload.py | 145 --------- src/home/mqtt/payload/esp.py | 78 ----- src/home/mqtt/payload/inverter.py | 73 ----- src/home/mqtt/payload/relay.py | 22 -- src/home/mqtt/payload/sensors.py | 20 -- src/home/mqtt/payload/temphum.py | 15 - src/home/mqtt/relay.py | 71 ----- src/home/mqtt/temphum.py | 54 ---- src/home/mqtt/util.py | 8 - src/home/pio/products.py | 4 - src/home/telegram/_botcontext.py | 19 +- src/home/telegram/bot.py | 149 +++++----- src/home/telegram/config.py | 75 +++++ src/home/temphum/__init__.py | 19 +- src/home/temphum/base.py | 20 +- src/home/temphum/dht12.py | 22 -- src/home/temphum/i2c.py | 52 ++++ src/home/temphum/si7021.py | 13 - src/home/util.py | 70 +++-- src/inverter_bot.py | 99 +++++-- src/inverter_mqtt_receiver.py | 74 ----- src/inverter_mqtt_sender.py | 72 ----- src/inverter_mqtt_util.py | 25 ++ src/ipcam_server.py | 2 +- src/mqtt_node_util.py | 63 ++++ src/openwrt_log_analyzer.py | 2 +- src/openwrt_logger.py | 2 +- src/pio_ini.py | 10 +- src/polaris_kettle_bot.py | 8 +- src/polaris_kettle_util.py | 6 +- src/pump_bot.py | 154 +++++++++- src/pump_mqtt_bot.py | 26 +- src/relay_mqtt_bot.py | 154 ++++++---- src/relay_mqtt_http_proxy.py | 49 +-- src/sensors_bot.py | 2 +- src/sensors_mqtt_receiver.py | 53 ---- src/sensors_mqtt_sender.py | 58 ---- src/sound_bot.py | 12 +- src/sound_node.py | 2 +- src/sound_sensor_node.py | 6 +- src/sound_sensor_server.py | 8 +- src/ssh_tunnels_config_util.py | 4 +- src/temphum_mqtt_node.py | 78 +++++ src/temphum_mqtt_receiver.py | 47 +++ src/temphum_smbus_util.py | 3 +- src/temphumd.py | 7 +- src/test_new_config.py | 12 + src/web_api.py | 2 +- systemd/inverter_mqtt_receiver.service | 13 + systemd/inverter_mqtt_sender.service | 2 +- systemd/ipcam_rtsp2hls@.service | 2 + systemd/sensors_mqtt_receiver.service | 4 +- systemd/sensors_mqtt_sender.service | 13 - test/mqtt_relay_server_util.py | 17 ++ test/mqtt_relay_util.py | 38 +++ test/test_amixer.py | 2 +- test/test_api.py | 2 +- test/test_esp32_cam.py | 6 +- test/test_inverter_monitor.py | 2 +- test/test_ipcam_server_cleanup.py | 2 +- test/test_record_upload.py | 6 +- test/test_send_fake_sound_hit.py | 4 +- test/test_sound_server_api.py | 2 +- test/test_telegram_aio_send_photo.py | 2 +- tools/mcuota.py | 98 ------ tools/mcuota.sh | 14 - 118 files changed, 2614 insertions(+), 1609 deletions(-) delete mode 100755 src/esp_mqtt_util.py create mode 100644 src/home/config/_configs.py create mode 100644 src/home/inverter/config.py create mode 100644 src/home/mqtt/_config.py create mode 100644 src/home/mqtt/_module.py create mode 100644 src/home/mqtt/_mqtt.py create mode 100644 src/home/mqtt/_node.py create mode 100644 src/home/mqtt/_payload.py create mode 100644 src/home/mqtt/_util.py create mode 100644 src/home/mqtt/_wrapper.py delete mode 100644 src/home/mqtt/esp.py create mode 100644 src/home/mqtt/module/diagnostics.py create mode 100644 src/home/mqtt/module/inverter.py create mode 100644 src/home/mqtt/module/ota.py create mode 100644 src/home/mqtt/module/relay.py create mode 100644 src/home/mqtt/module/temphum.py delete mode 100644 src/home/mqtt/mqtt.py delete mode 100644 src/home/mqtt/payload/__init__.py delete mode 100644 src/home/mqtt/payload/base_payload.py delete mode 100644 src/home/mqtt/payload/esp.py delete mode 100644 src/home/mqtt/payload/inverter.py delete mode 100644 src/home/mqtt/payload/relay.py delete mode 100644 src/home/mqtt/payload/sensors.py delete mode 100644 src/home/mqtt/payload/temphum.py delete mode 100644 src/home/mqtt/relay.py delete mode 100644 src/home/mqtt/temphum.py delete mode 100644 src/home/mqtt/util.py create mode 100644 src/home/telegram/config.py delete mode 100644 src/home/temphum/dht12.py create mode 100644 src/home/temphum/i2c.py delete mode 100644 src/home/temphum/si7021.py delete mode 100755 src/inverter_mqtt_receiver.py delete mode 100755 src/inverter_mqtt_sender.py create mode 100755 src/inverter_mqtt_util.py create mode 100755 src/mqtt_node_util.py delete mode 100755 src/sensors_mqtt_receiver.py delete mode 100755 src/sensors_mqtt_sender.py create mode 100755 src/temphum_mqtt_node.py create mode 100755 src/temphum_mqtt_receiver.py create mode 100755 src/test_new_config.py create mode 100644 systemd/inverter_mqtt_receiver.service delete mode 100644 systemd/sensors_mqtt_sender.service create mode 100755 test/mqtt_relay_server_util.py create mode 100755 test/mqtt_relay_util.py delete mode 100755 tools/mcuota.py delete mode 100755 tools/mcuota.sh diff --git a/.gitignore b/.gitignore index 5f65bca..4ffc1b1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ platformio.ini CMakeListsPrivate.txt /platformio/*/CMakeLists.txt /platformio/*/CMakeListsPrivate.txt +/platformio/*/.gitignore *.swp /localwebsite/vendor diff --git a/misc/openwrt/etc/rc.local b/misc/openwrt/etc/rc.local index 407d1eb..32b1227 100644 --- a/misc/openwrt/etc/rc.local +++ b/misc/openwrt/etc/rc.local @@ -17,7 +17,7 @@ done sleep 0.1 # block internet access for untrusted cameras -iptables -I FORWARD 1 -m set --match-set ipcam src ! -d 192.168.5.0 -j REJECT +iptables -I FORWARD 1 -m set --match-set ipcam src ! -d 192.168.5.0/24 -j REJECT # add some default routing rules ipset add mts-azov 192.168.5.0/24 # everybody diff --git a/platformio/common/libs/main/homekit/main.cpp b/platformio/common/libs/main/homekit/main.cpp index fd08925..816c764 100644 --- a/platformio/common/libs/main/homekit/main.cpp +++ b/platformio/common/libs/main/homekit/main.cpp @@ -6,7 +6,12 @@ namespace homekit::main { +#ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY enum WorkingMode working_mode = WorkingMode::NORMAL; +#endif +#endif + static const uint16_t recovery_boot_detection_ms = 2000; static const uint8_t recovery_boot_delay_ms = 100; @@ -22,8 +27,10 @@ static StopWatch blinkStopWatch; #endif #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY static DNSServer* dnsServer = nullptr; #endif +#endif static void onWifiConnected(const WiFiEventStationModeGotIP& event); static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event); @@ -45,6 +52,7 @@ static void wifiConnect() { } #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY static void wifiHotspot() { led::mcu_led->on(); @@ -71,13 +79,16 @@ static void waitForRecoveryPress() { } } #endif +#endif void setup() { WiFi.disconnect(); +#ifndef CONFIG_NO_RECOVERY #ifndef CONFIG_TARGET_ESP01 homekit::main::waitForRecoveryPress(); #endif +#endif #ifdef DEBUG Serial.begin(115200); @@ -95,25 +106,31 @@ void setup() { } #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY switch (working_mode) { case WorkingMode::RECOVERY: wifiHotspot(); break; case WorkingMode::NORMAL: +#endif #endif wifiConnectHandler = WiFi.onStationModeGotIP(onWifiConnected); wifiDisconnectHandler = WiFi.onStationModeDisconnected(onWifiDisconnected); wifiConnect(); +#ifndef CONFIG_NO_RECOVERY #ifndef CONFIG_TARGET_ESP01 break; } #endif +#endif } void loop(LoopConfig* config) { +#ifndef CONFIG_NO_RECOVERY #ifndef CONFIG_TARGET_ESP01 if (working_mode == WorkingMode::NORMAL) { +#endif #endif if (wifi_state == WiFiConnectionState::WAITING) { PRINT("."); @@ -166,6 +183,7 @@ void loop(LoopConfig* config) { } #endif } +#ifndef CONFIG_NO_RECOVERY #ifndef CONFIG_TARGET_ESP01 } else { if (dnsServer != nullptr) @@ -176,6 +194,7 @@ void loop(LoopConfig* config) { httpServer->loop(); } #endif +#endif } static void onWifiConnected(const WiFiEventStationModeGotIP& event) { @@ -191,4 +210,4 @@ static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event) { wifiTimer.once(2, wifiConnect); } -} \ No newline at end of file +} diff --git a/platformio/common/libs/main/homekit/main.h b/platformio/common/libs/main/homekit/main.h index a503dd0..78a0695 100644 --- a/platformio/common/libs/main/homekit/main.h +++ b/platformio/common/libs/main/homekit/main.h @@ -10,8 +10,10 @@ #include #include #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY #include #endif +#endif #include #include @@ -20,6 +22,7 @@ namespace homekit::main { #ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY enum class WorkingMode { RECOVERY, // AP mode, http server with configuration NORMAL, // MQTT client @@ -27,6 +30,7 @@ enum class WorkingMode { extern enum WorkingMode working_mode; #endif +#endif enum class WiFiConnectionState { WAITING = 0, diff --git a/platformio/common/libs/main/library.json b/platformio/common/libs/main/library.json index 04eedab..728d4f8 100644 --- a/platformio/common/libs/main/library.json +++ b/platformio/common/libs/main/library.json @@ -1,6 +1,6 @@ { "name": "homekit_main", - "version": "1.0.8", + "version": "1.0.10", "build": { "flags": "-I../../include" }, diff --git a/platformio/common/libs/mqtt/homekit/mqtt/module.cpp b/platformio/common/libs/mqtt/homekit/mqtt/module.cpp index e78ff12..0ac7637 100644 --- a/platformio/common/libs/mqtt/homekit/mqtt/module.cpp +++ b/platformio/common/libs/mqtt/homekit/mqtt/module.cpp @@ -21,6 +21,6 @@ void MqttModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, con void MqttModule::handleOnPublish(uint16_t packetId) {} -void MqttModule::handleOnDisconnect(espMqttClientTypes::DisconnectReason reason) {} +void MqttModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) {} } diff --git a/platformio/common/libs/mqtt/homekit/mqtt/module.h b/platformio/common/libs/mqtt/homekit/mqtt/module.h index e4a01f8..0a328f3 100644 --- a/platformio/common/libs/mqtt/homekit/mqtt/module.h +++ b/platformio/common/libs/mqtt/homekit/mqtt/module.h @@ -28,20 +28,25 @@ public: , receiveOnPublish(_receiveOnPublish) , receiveOnDisconnect(_receiveOnDisconnect) {} - virtual void init(Mqtt& mqtt) = 0; virtual void tick(Mqtt& mqtt) = 0; + virtual void onConnect(Mqtt& mqtt) = 0; + virtual void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason); + virtual void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total); virtual void handleOnPublish(uint16_t packetId); - virtual void handleOnDisconnect(espMqttClientTypes::DisconnectReason reason); inline void setInitialized() { initialized = true; } - inline short getTickInterval() { - return tickInterval; - } + inline void unsetInitialized() { + initialized = false; + } + + inline short getTickInterval() const { + return tickInterval; + } friend class Mqtt; }; diff --git a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp b/platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp index cb2cea7..aa769a5 100644 --- a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp +++ b/platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp @@ -34,7 +34,7 @@ Mqtt::Mqtt() { for (auto* module: modules) { if (!module->initialized) { - module->init(*this); + module->onConnect(*this); module->setInitialized(); } } @@ -50,18 +50,13 @@ Mqtt::Mqtt() { #endif for (auto* module: modules) { - if (module->receiveOnDisconnect) { - module->handleOnDisconnect(reason); - } + module->onDisconnect(*this, reason); + module->unsetInitialized(); } -// if (ota.readyToRestart) { -// restartTimer.once(1, restart); -// } else { - reconnectTimer.once(2, [&]() { - reconnect(); - }); -// } + reconnectTimer.once(2, [&]() { + reconnect(); + }); }); client.onSubscribe([&](uint16_t packetId, const SubscribeReturncode* returncodes, size_t len) { @@ -79,7 +74,7 @@ Mqtt::Mqtt() { PRINTF("mqtt: message received, topic=%s, qos=%d, dup=%d, retain=%d, len=%ul, index=%ul, total=%ul\n", topic, properties.qos, (int)properties.dup, (int)properties.retain, len, index, total); - const char *ptr = topic + nodeId.length() + 10; + const char *ptr = topic + nodeId.length() + 4; String relevantTopic(ptr); auto it = moduleSubscriptions.find(relevantTopic); @@ -87,7 +82,7 @@ Mqtt::Mqtt() { auto module = it->second; module->handlePayload(*this, relevantTopic, properties.packetId, payload, len, index, total); } else { - PRINTF("error: module subscription for topic %s not found\n", topic); + PRINTF("error: module subscription for topic %s not found\n", relevantTopic.c_str()); } }); @@ -130,8 +125,8 @@ void Mqtt::disconnect() { void Mqtt::loop() { client.loop(); for (auto& module: modules) { - if (module->getTickInterval() != 0) - module->tick(*this); + if (module->getTickInterval() != 0) + module->tick(*this); } } @@ -154,14 +149,14 @@ uint16_t Mqtt::subscribe(const String& topic, uint8_t qos) { void Mqtt::addModule(MqttModule* module) { modules.emplace_back(module); if (connected) { - module->init(*this); + module->onConnect(*this); module->setInitialized(); } } void Mqtt::subscribeModule(String& topic, MqttModule* module, uint8_t qos) { moduleSubscriptions[topic] = module; - subscribe(topic, qos); + subscribe(topic, qos); } } diff --git a/platformio/common/libs/mqtt/library.json b/platformio/common/libs/mqtt/library.json index d1ad420..f3f2504 100644 --- a/platformio/common/libs/mqtt/library.json +++ b/platformio/common/libs/mqtt/library.json @@ -1,6 +1,6 @@ { "name": "homekit_mqtt", - "version": "1.0.9", + "version": "1.0.11", "build": { "flags": "-I../../include" } diff --git a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp b/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp index d36a7e9..e0f797e 100644 --- a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp +++ b/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp @@ -7,12 +7,21 @@ namespace homekit::mqtt { static const char TOPIC_DIAGNOSTICS[] = "diag"; static const char TOPIC_INITIAL_DIAGNOSTICS[] = "d1ag"; -void MqttDiagnosticsModule::init(Mqtt& mqtt) {} +void MqttDiagnosticsModule::onConnect(Mqtt &mqtt) { + sendDiagnostics(mqtt); +} + +void MqttDiagnosticsModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) { + initialSent = false; +} void MqttDiagnosticsModule::tick(Mqtt& mqtt) { if (!tickElapsed()) return; + sendDiagnostics(mqtt); +} +void MqttDiagnosticsModule::sendDiagnostics(Mqtt& mqtt) { auto cfg = config::read(); if (!initialSent) { diff --git a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h b/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h index 055c179..bb7a81a 100644 --- a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h +++ b/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h @@ -32,12 +32,15 @@ class MqttDiagnosticsModule: public MqttModule { private: bool initialSent; + void sendDiagnostics(Mqtt& mqtt); + public: MqttDiagnosticsModule() : MqttModule(30) , initialSent(false) {} - void init(Mqtt& mqtt) override; + void onConnect(Mqtt& mqtt) override; + void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; void tick(Mqtt& mqtt) override; }; diff --git a/platformio/common/libs/mqtt_module_diagnostics/library.json b/platformio/common/libs/mqtt_module_diagnostics/library.json index 8df306d..a3d3244 100644 --- a/platformio/common/libs/mqtt_module_diagnostics/library.json +++ b/platformio/common/libs/mqtt_module_diagnostics/library.json @@ -1,10 +1,10 @@ { "name": "homekit_mqtt_module_diagnostics", - "version": "1.0.1", + "version": "1.0.2", "build": { "flags": "-I../../include" }, "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt" + "homekit_mqtt": "file://../common/libs/mqtt" } } diff --git a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp b/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp index 2f5f814..4e976cd 100644 --- a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp +++ b/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp @@ -12,7 +12,7 @@ using homekit::led::mcu_led; static const char TOPIC_OTA[] = "ota"; static const char TOPIC_OTA_RESPONSE[] = "otares"; -void MqttOtaModule::init(Mqtt& mqtt) { +void MqttOtaModule::onConnect(Mqtt& mqtt) { String topic(TOPIC_OTA); mqtt.subscribeModule(topic, this); } @@ -140,17 +140,15 @@ uint16_t MqttOtaModule::sendResponse(Mqtt& mqtt, OtaResult status, uint8_t error return mqtt.publish(TOPIC_OTA_RESPONSE, reinterpret_cast(&resp), sizeof(resp)); } -void MqttOtaModule::handleOnDisconnect(espMqttClientTypes::DisconnectReason reason) { - if (ota.started()) { +void MqttOtaModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) { + if (ota.readyToRestart) { + restartTimer.once(1, restart); + } else if (ota.started()) { PRINTLN("mqtt: update was in progress, canceling.."); ota.clean(); Update.end(); Update.clearError(); } - - if (ota.readyToRestart) { - restartTimer.once(1, restart); - } } void MqttOtaModule::handleOnPublish(uint16_t packetId) { diff --git a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h b/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h index 53613c3..df4f7ce 100644 --- a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h +++ b/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h @@ -57,11 +57,14 @@ private: public: MqttOtaModule() : MqttModule(0, true, true) {} - void init(Mqtt& mqtt) override; + void onConnect(Mqtt& mqtt) override; + void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; + void tick(Mqtt& mqtt) override; + void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; void handleOnPublish(uint16_t packetId) override; - void handleOnDisconnect(espMqttClientTypes::DisconnectReason reason) override; + inline bool isReadyToRestart() const { return ota.readyToRestart; } diff --git a/platformio/common/libs/mqtt_module_ota/library.json b/platformio/common/libs/mqtt_module_ota/library.json index 30db7d2..4f40a47 100644 --- a/platformio/common/libs/mqtt_module_ota/library.json +++ b/platformio/common/libs/mqtt_module_ota/library.json @@ -1,11 +1,11 @@ { "name": "homekit_mqtt_module_ota", - "version": "1.0.2", + "version": "1.0.5", "build": { "flags": "-I../../include" }, "dependencies": { "homekit_led": "file://../common/libs/led", - "homekit_mqtt": "file://../common/libs/mqtt" + "homekit_mqtt": "file://../common/libs/mqtt" } } diff --git a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp b/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp index ab40727..90c57f9 100644 --- a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp +++ b/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp @@ -5,19 +5,28 @@ namespace homekit::mqtt { static const char TOPIC_RELAY_SWITCH[] = "relay/switch"; +static const char TOPIC_RELAY_STATUS[] = "relay/status"; -void MqttRelayModule::init(Mqtt &mqtt) { - String topic(TOPIC_RELAY_SWITCH); - mqtt.subscribeModule(topic, this, 1); +void MqttRelayModule::onConnect(Mqtt &mqtt) { + String topic(TOPIC_RELAY_SWITCH); + mqtt.subscribeModule(topic, this, 1); +} + +void MqttRelayModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) { +#ifdef CONFIG_RELAY_OFF_ON_DISCONNECT + if (relay::state()) { + relay::off(); + } +#endif } void MqttRelayModule::tick(homekit::mqtt::Mqtt& mqtt) {} void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) { - if (topic != TOPIC_RELAY_SWITCH) - return; + if (topic != TOPIC_RELAY_SWITCH) + return; - if (length != sizeof(MqttRelaySwitchPayload)) { + if (length != sizeof(MqttRelaySwitchPayload)) { PRINTF("error: size of payload (%ul) does not match expected (%ul)\n", length, sizeof(MqttRelaySwitchPayload)); return; @@ -29,6 +38,8 @@ void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId return; } + MqttRelayStatusPayload resp{}; + if (pd->state == 1) { PRINTLN("mqtt: turning relay on"); relay::on(); @@ -38,6 +49,10 @@ void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId } else { PRINTLN("error: unexpected state value"); } + + resp.opened = relay::state(); + mqtt.publish(TOPIC_RELAY_STATUS, reinterpret_cast(&resp), sizeof(resp)); } } + diff --git a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h b/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h index 6420de1..e245527 100644 --- a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h +++ b/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h @@ -10,14 +10,20 @@ struct MqttRelaySwitchPayload { uint8_t state; } __attribute__((packed)); +struct MqttRelayStatusPayload { + uint8_t opened; +} __attribute__((packed)); + class MqttRelayModule : public MqttModule { public: MqttRelayModule() : MqttModule(0) {} - void init(Mqtt& mqtt) override; + void onConnect(Mqtt& mqtt) override; + void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; void tick(Mqtt& mqtt) override; - void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; + void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; }; } #endif //HOMEKIT_LIB_MQTT_MODULE_RELAY_H + diff --git a/platformio/common/libs/mqtt_module_relay/library.json b/platformio/common/libs/mqtt_module_relay/library.json index e71cf95..6cbbfb0 100644 --- a/platformio/common/libs/mqtt_module_relay/library.json +++ b/platformio/common/libs/mqtt_module_relay/library.json @@ -1,6 +1,6 @@ { "name": "homekit_mqtt_module_relay", - "version": "1.0.3", + "version": "1.0.5", "build": { "flags": "-I../../include" }, diff --git a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp b/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp index 82f1d74..409f38f 100644 --- a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp +++ b/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp @@ -4,7 +4,7 @@ namespace homekit::mqtt { static const char TOPIC_TEMPHUM_DATA[] = "temphum/data"; -void MqttTemphumModule::init(Mqtt &mqtt) {} +void MqttTemphumModule::onConnect(Mqtt &mqtt) {} void MqttTemphumModule::tick(homekit::mqtt::Mqtt& mqtt) { if (!tickElapsed()) diff --git a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h b/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h index 5c41cef..7b28afc 100644 --- a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h +++ b/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h @@ -19,7 +19,7 @@ private: public: MqttTemphumModule(temphum::Sensor* _sensor) : MqttModule(10), sensor(_sensor) {} - void init(Mqtt& mqtt) override; + void onConnect(Mqtt& mqtt) override; void tick(Mqtt& mqtt) override; }; diff --git a/platformio/common/libs/mqtt_module_temphum/library.json b/platformio/common/libs/mqtt_module_temphum/library.json index 9bb8cf1..068debd 100644 --- a/platformio/common/libs/mqtt_module_temphum/library.json +++ b/platformio/common/libs/mqtt_module_temphum/library.json @@ -1,11 +1,11 @@ { "name": "homekit_mqtt_module_temphum", - "version": "1.0.9", + "version": "1.0.10", "build": { "flags": "-I../../include" }, "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt", + "homekit_mqtt": "file://../common/libs/mqtt", "homekit_temphum": "file://../common/libs/temphum" } } diff --git a/platformio/temphum_relayctl/src/main.cpp b/platformio/temphum_relayctl/src/main.cpp index 0b05316..7f0945e 100644 --- a/platformio/temphum_relayctl/src/main.cpp +++ b/platformio/temphum_relayctl/src/main.cpp @@ -27,6 +27,7 @@ void setup() { main::setup(); relay::init(); + relay::off(); #if CONFIG_MODULE == HOMEKIT_SI7021 sensor = new temphum::Si7021(); diff --git a/requirements.txt b/requirements.txt index 46f9b8c..4595dea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,24 @@ paho-mqtt==1.6.1 inverterd~=1.0.3 clickhouse-driver~=0.2.0 -toml~=0.10.2 mysql-connector-python~=8.0.27 -Werkzeug==2.2.2 +Werkzeug==2.3.6 uwsgi~=2.0.20 -python-telegram-bot==13.15 -requests==2.28.1 +python-telegram-bot==20.3 +requests==2.31.0 aiohttp~=3.8.1 -pytz==2022.6 +pytz==2023.3 PyYAML~=6.0 -apscheduler~=3.9.1 +apscheduler==3.10.1 psutil~=5.9.1 aioshutil~=1.1 -scikit-image~=0.19.3 - +scikit-image==0.21.0 +cerberus~=1.3.4 # following can be installed from debian repositories # matplotlib~=3.5.0 -Pillow~=9.1.1 +Pillow==9.5.0 # for polaris kettle protocol implementation -cryptography==38.0.4 -zeroconf==0.39.4 \ No newline at end of file +cryptography==41.0.1 +zeroconf==0.64.1 \ No newline at end of file diff --git a/src/camera_node.py b/src/camera_node.py index d175e17..3f2c5a4 100755 --- a/src/camera_node.py +++ b/src/camera_node.py @@ -65,7 +65,7 @@ class ESP32CameraNodeServer(MediaNodeServer): if __name__ == '__main__': - config.load('camera_node') + config.load_app('camera_node') recorder_kwargs = {} camera_type = CameraType(config['camera']['type']) diff --git a/src/esp32_capture.py b/src/esp32_capture.py index 4a9ce10..0441565 100755 --- a/src/esp32_capture.py +++ b/src/esp32_capture.py @@ -5,7 +5,7 @@ import os.path from argparse import ArgumentParser from home.camera.esp32 import WebClient -from home.util import parse_addr, Addr +from home.util import Addr from apscheduler.schedulers.asyncio import AsyncIOScheduler from datetime import datetime from typing import Optional @@ -50,7 +50,7 @@ if __name__ == '__main__': loop = asyncio.get_event_loop() - ESP32Capture(parse_addr(arg.addr), arg.interval, arg.output_directory) + ESP32Capture(Addr.fromstring(arg.addr), arg.interval, arg.output_directory) try: loop.run_forever() except KeyboardInterrupt: diff --git a/src/esp32cam_capture_diff_node.py b/src/esp32cam_capture_diff_node.py index 4363e9e..59482f7 100755 --- a/src/esp32cam_capture_diff_node.py +++ b/src/esp32cam_capture_diff_node.py @@ -7,7 +7,7 @@ import home.telegram.aio as telegram from home.config import config from home.camera.esp32 import WebClient -from home.util import parse_addr, send_datagram, stringify +from home.util import Addr, send_datagram, stringify from apscheduler.schedulers.asyncio import AsyncIOScheduler from typing import Optional @@ -34,11 +34,11 @@ async def pyssim(fn1: str, fn2: str) -> float: class ESP32CamCaptureDiffNode: def __init__(self): - self.client = WebClient(parse_addr(config['esp32cam_web_addr'])) + self.client = WebClient(Addr.fromstring(config['esp32cam_web_addr'])) self.directory = tempfile.gettempdir() self.nextpic = 1 self.first = True - self.server_addr = parse_addr(config['node']['server_addr']) + self.server_addr = Addr.fromstring(config['node']['server_addr']) self.scheduler = AsyncIOScheduler() self.scheduler.add_job(self.capture, 'interval', seconds=config['node']['interval']) @@ -76,7 +76,7 @@ class ESP32CamCaptureDiffNode: if __name__ == '__main__': - config.load('esp32cam_capture_diff_node') + config.load_app('esp32cam_capture_diff_node') loop = asyncio.get_event_loop() ESP32CamCaptureDiffNode() diff --git a/src/esp_mqtt_util.py b/src/esp_mqtt_util.py deleted file mode 100755 index 263128c..0000000 --- a/src/esp_mqtt_util.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -from typing import Optional -from argparse import ArgumentParser -from enum import Enum - -from home.config import config -from home.mqtt import MqttRelay -from home.mqtt.esp import MqttEspBase -from home.mqtt.temphum import MqttTempHum -from home.mqtt.esp import MqttEspDevice - -mqtt_client: Optional[MqttEspBase] = None - - -class NodeType(Enum): - RELAY = 'relay' - TEMPHUM = 'temphum' - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('--device-id', type=str, required=True) - parser.add_argument('--type', type=str, required=True, - choices=[i.name.lower() for i in NodeType]) - - config.load('mqtt_util', parser=parser) - arg = parser.parse_args() - - mqtt_node_type = NodeType(arg.type) - devices = MqttEspDevice(id=arg.device_id) - - if mqtt_node_type == NodeType.RELAY: - mqtt_client = MqttRelay(devices=devices) - elif mqtt_node_type == NodeType.TEMPHUM: - mqtt_client = MqttTempHum(devices=devices) - - mqtt_client.set_message_callback(lambda device_id, payload: print(payload)) - mqtt_client.configure_tls() - try: - mqtt_client.connect_and_loop() - except KeyboardInterrupt: - mqtt_client.disconnect() diff --git a/src/gpiorelayd.py b/src/gpiorelayd.py index 85015a7..f1a9e57 100755 --- a/src/gpiorelayd.py +++ b/src/gpiorelayd.py @@ -13,7 +13,7 @@ if __name__ == '__main__': if not os.getegid() == 0: sys.exit('Must be run as root.') - config.load() + config.load_app() try: s = RelayServer(pinname=config.get('relayd.pin'), diff --git a/src/home/audio/amixer.py b/src/home/audio/amixer.py index 53e6bce..5133c97 100644 --- a/src/home/audio/amixer.py +++ b/src/home/audio/amixer.py @@ -1,6 +1,6 @@ import subprocess -from ..config import config +from ..config import app_config as config from threading import Lock from typing import Union, List diff --git a/src/home/config/__init__.py b/src/home/config/__init__.py index cc9c091..2fa5214 100644 --- a/src/home/config/__init__.py +++ b/src/home/config/__init__.py @@ -1 +1,13 @@ -from .config import ConfigStore, config, is_development_mode, setup_logging +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 new file mode 100644 index 0000000..3a1aae5 --- /dev/null +++ b/src/home/config/_configs.py @@ -0,0 +1,55 @@ +from .config import ConfigUnit +from typing import Optional + + +class ServicesListConfig(ConfigUnit): + NAME = 'services_list' + + @staticmethod + def schema() -> Optional[dict]: + return { + 'type': 'list', + 'empty': False, + 'schema': { + 'type': 'string' + } + } + + +class LinuxBoardsConfig(ConfigUnit): + NAME = 'linux_boards' + + @staticmethod + def schema() -> 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 index 4681685..aef9ee7 100644 --- a/src/home/config/config.py +++ b/src/home/config/config.py @@ -1,58 +1,256 @@ -import toml import yaml import logging import os +import pprint -from os.path import join, isdir, isfile -from typing import Optional, Any, MutableMapping +from abc import ABC +from cerberus import Validator, DocumentError +from typing import Optional, Any, MutableMapping, Union from argparse import ArgumentParser -from ..util import parse_addr +from enum import Enum, auto +from os.path import join, isdir, isfile +from ..util import 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 _get_config_path(name: str) -> str: - formats = ['toml', 'yaml'] + def __contains__(self, key): + return key in self._data - dirname = join(os.environ['HOME'], '.config', name) + def load_from(self, path: str): + with open(path, 'r') as fd: + self._data = yaml.safe_load(fd) - if isdir(dirname): - for fmt in formats: - filename = join(dirname, f'config.{fmt}') - if isfile(filename): - return filename + def get(self, + key: Optional[str] = None, + default=None): + if key is None: + return self._data - raise IOError(f'config not found in {dirname}') + 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] - else: - filenames = [join(os.environ['HOME'], '.config', f'{name}.{format}') for format in formats] - for file in filenames: - if isfile(file): - return file + raise KeyError(f'option {key} not found') - raise IOError(f'config 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') + + @staticmethod + def schema() -> Optional[dict]: + return None + + 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': 'bool'} + } + } + + 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 + + if rst == RootSchemaType.DICT: + v = Validator({'document': { + 'type': 'dict', + 'keysrules': {'type': 'string'}, + 'valuesrules': schema + }}) + result = v.validate({'document': self._data}) + elif rst == RootSchemaType.LIST: + v = Validator({'document': schema}) + result = v.validate({'document': self._data}) + else: + v = Validator(schema) + result = v.validate(self._data) + # pprint.pprint(self._data) + if not result: + # pprint.pprint(v.errors) + raise DocumentError(f'{self.__class__.__name__}: failed to validate data:\n{pprint.pformat(v.errors)}') + try: + self.custom_validator(self._data) + except Exception as e: + raise DocumentError(f'{self.__class__.__name__}: {str(e)}') + + @staticmethod + def custom_validator(data): + pass -class ConfigStore: - data: MutableMapping[str, Any] + 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.data = {} 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 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 - def load(self, name: Optional[str] = None, - use_cli=True, - parser: ArgumentParser = None): - self.app_name = name - - if (name is None) and (not use_cli): + if self.app_name is None and not use_cli: raise RuntimeError('either config name must be none or use_cli must be True') - log_default_fmt = False - log_file = None - log_verbose = False - no_config = name is False - + no_config = name is False or no_config path = None + if use_cli: if parser is None: parser = ArgumentParser() @@ -68,75 +266,38 @@ class ConfigStore: path = args.config if args.verbose: - log_verbose = True + self.app_config.logging_set_verbose() if args.log_file: - log_file = args.log_file + self.app_config.logging_set_file(args.log_file) if args.log_default_fmt: - log_default_fmt = args.log_default_fmt + self.app_config.logging_set_fmt(args.log_default_fmt) - if not no_config and path is None: - path = _get_config_path(name) + if not isinstance(name, ConfigUnit): + if not no_config and path is None: + path = ConfigUnit.get_config_path(name=self.app_name) - if no_config: - self.data = {} - else: - if path.endswith('.toml'): - self.data = toml.load(path) - elif path.endswith('.yaml'): - with open(path, 'r') as fd: - self.data = yaml.safe_load(fd) - - if 'logging' in self: - if not log_file and 'file' in self['logging']: - log_file = self['logging']['file'] - if log_default_fmt and 'default_fmt' in self['logging']: - log_default_fmt = self['logging']['default_fmt'] + if not no_config: + self.app_config.load_from(path) - setup_logging(log_verbose, log_file, log_default_fmt) + 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 - def __getitem__(self, key): - return self.data[key] - - def __setitem__(self, key, value): - raise NotImplementedError('overwriting config values is prohibited') - - def __contains__(self, key): - return key in self.data - - def get(self, key: str, default=None): - cur = self.data - pts = key.split('.') - for i in range(len(pts)): - k = pts[i] - if i < len(pts)-1: - if k not in cur: - raise KeyError(f'key {k} not found') - else: - return cur[k] if k in cur else default - cur = self.data[k] - raise KeyError(f'option {key} not found') - - def get_addr(self, key: str): - return parse_addr(self.get(key)) - - def items(self): - return self.data.items() - -config = ConfigStore() +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) and ('verbose' in config['logging']) and (config['logging']['verbose'] is 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=False): +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 diff --git a/src/home/database/clickhouse.py b/src/home/database/clickhouse.py index ca81628..d0ec283 100644 --- a/src/home/database/clickhouse.py +++ b/src/home/database/clickhouse.py @@ -1,7 +1,7 @@ import logging from zoneinfo import ZoneInfo -from datetime import datetime, timedelta +from datetime import datetime from clickhouse_driver import Client as ClickhouseClient from ..config import is_development_mode diff --git a/src/home/database/sqlite.py b/src/home/database/sqlite.py index bfba929..8c6145c 100644 --- a/src/home/database/sqlite.py +++ b/src/home/database/sqlite.py @@ -5,24 +5,27 @@ import logging from ..config import config, is_development_mode -def _get_database_path(name: str, dbname: str) -> str: - return os.path.join(os.environ['HOME'], '.config', name, f'{dbname}.db') +def _get_database_path(name: str) -> str: + return os.path.join( + os.environ['HOME'], + '.config', + 'homekit', + 'data', + f'{name}.db') class SQLiteBase: SCHEMA = 1 - def __init__(self, name=None, dbname='bot', check_same_thread=False): - db_path = config.get('db_path', default=None) - if db_path is None: - if not name: - name = config.app_name - if not dbname: - dbname = name - db_path = _get_database_path(name, dbname) + 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(db_path, check_same_thread=check_same_thread) + self.sqlite = sqlite3.connect(database_path, check_same_thread=check_same_thread) if is_development_mode(): self.sql_logger = logging.getLogger(self.__class__.__name__) diff --git a/src/home/inverter/config.py b/src/home/inverter/config.py new file mode 100644 index 0000000..62b8859 --- /dev/null +++ b/src/home/inverter/config.py @@ -0,0 +1,13 @@ +from ..config import ConfigUnit +from typing import Optional + + +class InverterdConfig(ConfigUnit): + NAME = 'inverterd' + + @staticmethod + def schema() -> Optional[dict]: + return { + 'remote_addr': {'type': 'string'}, + 'local_addr': {'type': 'string'}, + } \ No newline at end of file diff --git a/src/home/media/__init__.py b/src/home/media/__init__.py index 976c990..6923105 100644 --- a/src/home/media/__init__.py +++ b/src/home/media/__init__.py @@ -12,6 +12,7 @@ __map__ = { __all__ = list(itertools.chain(*__map__.values())) + def __getattr__(name): if name in __all__: for file, names in __map__.items(): diff --git a/src/home/mqtt/__init__.py b/src/home/mqtt/__init__.py index 982e2b6..707d59c 100644 --- a/src/home/mqtt/__init__.py +++ b/src/home/mqtt/__init__.py @@ -1,4 +1,7 @@ -from .mqtt import MqttBase -from .util import poll_tick -from .relay import MqttRelay, MqttRelayState -from .temphum import MqttTempHum \ No newline at end of file +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 new file mode 100644 index 0000000..f9047b4 --- /dev/null +++ b/src/home/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' + + @staticmethod + def schema() -> 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' + + @staticmethod + def schema() -> 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 new file mode 100644 index 0000000..80f27bb --- /dev/null +++ b/src/home/mqtt/_module.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import abc +import logging +import threading + +from time import sleep +from ..util import next_tick_gen + +from typing import TYPE_CHECKING, Optional +if TYPE_CHECKING: + from ._node import MqttNode + from ._payload import MqttPayload + + +class MqttModule(abc.ABC): + _tick_interval: int + _initialized: bool + _connected: bool + _ticker: Optional[threading.Thread] + _mqtt_node_ref: Optional[MqttNode] + + def __init__(self, tick_interval=0): + self._tick_interval = tick_interval + self._initialized = False + self._ticker = None + self._logger = logging.getLogger(self.__class__.__name__) + self._connected = False + self._mqtt_node_ref = None + + def on_connect(self, mqtt: MqttNode): + self._connected = True + self._mqtt_node_ref = mqtt + if self._tick_interval: + self._start_ticker() + + def on_disconnect(self, mqtt: MqttNode): + self._connected = False + self._mqtt_node_ref = None + + def is_initialized(self): + return self._initialized + + def set_initialized(self): + self._initialized = True + + def unset_initialized(self): + self._initialized = False + + def tick(self): + pass + + def _tick(self): + g = next_tick_gen(self._tick_interval) + while self._connected: + sleep(next(g)) + if not self._connected: + break + self.tick() + + def _start_ticker(self): + if not self._ticker or not self._ticker.is_alive(): + name_part = f'{self._mqtt_node_ref.id}/' if self._mqtt_node_ref else '' + self._ticker = None + self._ticker = threading.Thread(target=self._tick, + name=f'mqtt:{self.__class__.__name__}/{name_part}ticker') + self._ticker.start() + + def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: + pass diff --git a/src/home/mqtt/_mqtt.py b/src/home/mqtt/_mqtt.py new file mode 100644 index 0000000..746ae2e --- /dev/null +++ b/src/home/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__)), + '..', + '..', + '..', + '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 new file mode 100644 index 0000000..4e259a4 --- /dev/null +++ b/src/home/mqtt/_node.py @@ -0,0 +1,92 @@ +import logging +import importlib + +from typing import List, TYPE_CHECKING, Optional +from ._payload import MqttPayload +from ._module import MqttModule +if TYPE_CHECKING: + from ._wrapper import MqttWrapper +else: + MqttWrapper = None + + +class MqttNode: + _modules: List[MqttModule] + _module_subscriptions: dict[str, MqttModule] + _node_id: str + _node_secret: str + _payload_callbacks: list[callable] + _wrapper: Optional[MqttWrapper] + + def __init__(self, + node_id: str, + node_secret: Optional[str] = None): + self._modules = [] + self._module_subscriptions = {} + self._node_id = node_id + self._node_secret = node_secret + self._payload_callbacks = [] + self._logger = logging.getLogger(self.__class__.__name__) + self._wrapper = None + + def on_connect(self, wrapper: MqttWrapper): + self._wrapper = wrapper + for module in self._modules: + if not module.is_initialized(): + module.on_connect(self) + module.set_initialized() + + def on_disconnect(self): + self._wrapper = None + for module in self._modules: + module.unset_initialized() + + def on_message(self, topic, payload): + if topic in self._module_subscriptions: + payload = self._module_subscriptions[topic].handle_payload(self, topic, payload) + if isinstance(payload, MqttPayload): + for f in self._payload_callbacks: + f(self, payload) + + def load_module(self, module_name: str, *args, **kwargs) -> MqttModule: + module = importlib.import_module(f'..module.{module_name}', __name__) + if not hasattr(module, 'MODULE_NAME'): + raise RuntimeError(f'MODULE_NAME not found in module {module}') + cl = getattr(module, getattr(module, 'MODULE_NAME')) + instance = cl(*args, **kwargs) + self.add_module(instance) + return instance + + def add_module(self, module: MqttModule): + self._modules.append(module) + if self._wrapper and self._wrapper._connected: + module.on_connect(self) + module.set_initialized() + + def subscribe_module(self, topic: str, module: MqttModule, qos: int = 1): + if not self._wrapper or not self._wrapper._connected: + raise RuntimeError('not connected') + + self._module_subscriptions[topic] = module + self._wrapper.subscribe(self.id, topic, qos) + + def publish(self, + topic: str, + payload: bytes, + qos: int = 1): + self._wrapper.publish(self.id, topic, payload, qos) + + def add_payload_callback(self, callback: callable): + self._payload_callbacks.append(callback) + + @property + def id(self) -> str: + return self._node_id + + @property + def secret(self) -> str: + return self._node_secret + + @secret.setter + def secret(self, secret: str) -> None: + self._node_secret = secret diff --git a/src/home/mqtt/_payload.py b/src/home/mqtt/_payload.py new file mode 100644 index 0000000..58eeae3 --- /dev/null +++ b/src/home/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/src/home/mqtt/_util.py b/src/home/mqtt/_util.py new file mode 100644 index 0000000..390d463 --- /dev/null +++ b/src/home/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/src/home/mqtt/_wrapper.py b/src/home/mqtt/_wrapper.py new file mode 100644 index 0000000..f858f88 --- /dev/null +++ b/src/home/mqtt/_wrapper.py @@ -0,0 +1,59 @@ +import paho.mqtt.client as mqtt + +from ._mqtt import Mqtt +from ._node import MqttNode +from ..config import config +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 + for node in self._nodes: + 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/esp.py b/src/home/mqtt/esp.py deleted file mode 100644 index 56ced83..0000000 --- a/src/home/mqtt/esp.py +++ /dev/null @@ -1,106 +0,0 @@ -import re -import paho.mqtt.client as mqtt - -from .mqtt import MqttBase -from typing import Optional, Union -from .payload.esp import ( - OTAPayload, - OTAResultPayload, - DiagnosticsPayload, - InitialDiagnosticsPayload -) - - -class MqttEspDevice: - id: str - secret: Optional[str] - - def __init__(self, id: str, secret: Optional[str] = None): - self.id = id - self.secret = secret - - -class MqttEspBase(MqttBase): - _devices: list[MqttEspDevice] - _message_callback: Optional[callable] - _ota_publish_callback: Optional[callable] - - TOPIC_LEAF = 'esp' - - def __init__(self, - devices: Union[MqttEspDevice, list[MqttEspDevice]], - subscribe_to_updates=True): - super().__init__(clean_session=True) - if not isinstance(devices, list): - devices = [devices] - self._devices = devices - self._message_callback = None - self._ota_publish_callback = None - self._subscribe_to_updates = subscribe_to_updates - self._ota_mid = None - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - - if self._subscribe_to_updates: - for device in self._devices: - topic = f'hk/{device.id}/{self.TOPIC_LEAF}/#' - self._logger.debug(f"subscribing to {topic}") - client.subscribe(topic, qos=1) - - def on_publish(self, client: mqtt.Client, userdata, mid): - if self._ota_mid is not None and mid == self._ota_mid and self._ota_publish_callback: - self._ota_publish_callback() - - def set_message_callback(self, callback: callable): - self._message_callback = callback - - def on_message(self, client: mqtt.Client, userdata, msg): - try: - match = re.match(self.get_mqtt_topics(), msg.topic) - self._logger.debug(f'topic: {msg.topic}') - if not match: - return - - device_id = match.group(1) - subtopic = match.group(2) - - # try: - next(d for d in self._devices if d.id == device_id) - # except StopIteration:h - # return - - message = None - if subtopic == 'stat': - message = DiagnosticsPayload.unpack(msg.payload) - elif subtopic == 'stat1': - message = InitialDiagnosticsPayload.unpack(msg.payload) - elif subtopic == 'otares': - message = OTAResultPayload.unpack(msg.payload) - - if message and self._message_callback: - self._message_callback(device_id, message) - return True - - except Exception as e: - self._logger.exception(str(e)) - - def push_ota(self, - device_id, - filename: str, - publish_callback: callable, - qos: int): - device = next(d for d in self._devices if d.id == device_id) - assert device.secret is not None, 'device secret not specified' - - self._ota_publish_callback = publish_callback - payload = OTAPayload(secret=device.secret, filename=filename) - publish_result = self._client.publish(f'hk/{device.id}/{self.TOPIC_LEAF}/admin/ota', - payload=payload.pack(), - qos=qos) - self._ota_mid = publish_result.mid - self._client.loop_write() - - @classmethod - def get_mqtt_topics(cls, additional_topics: Optional[list[str]] = None): - return rf'^hk/(.*?)/{cls.TOPIC_LEAF}/(stat|stat1|otares'+('|'+('|'.join(additional_topics)) if additional_topics else '')+')$' \ No newline at end of file diff --git a/src/home/mqtt/module/diagnostics.py b/src/home/mqtt/module/diagnostics.py new file mode 100644 index 0000000..5db5e99 --- /dev/null +++ b/src/home/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/src/home/mqtt/module/inverter.py b/src/home/mqtt/module/inverter.py new file mode 100644 index 0000000..d927a06 --- /dev/null +++ b/src/home/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 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 new file mode 100644 index 0000000..cd34332 --- /dev/null +++ b/src/home/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/src/home/mqtt/module/relay.py b/src/home/mqtt/module/relay.py new file mode 100644 index 0000000..e968031 --- /dev/null +++ b/src/home/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/src/home/mqtt/module/temphum.py b/src/home/mqtt/module/temphum.py new file mode 100644 index 0000000..fd02cca --- /dev/null +++ b/src/home/mqtt/module/temphum.py @@ -0,0 +1,82 @@ +from .._node import MqttNode +from .._module import MqttModule +from .._payload import MqttPayload +from typing import Optional +from ...temphum import BaseSensor + +two_digits_precision = lambda x: round(x, 2) + +MODULE_NAME = 'MqttTempHumModule' +DATA_TOPIC = 'temphum/data' + + +class MqttTemphumDataPayload(MqttPayload): + FORMAT = '=ddb' + UNPACKER = { + 'temp': two_digits_precision, + 'rh': two_digits_precision + } + + temp: float + rh: float + error: int + + +# class MqttTempHumNodes(HashableEnum): +# KBN_SH_HALL = auto() +# KBN_SH_BATHROOM = auto() +# KBN_SH_LIVINGROOM = auto() +# KBN_SH_BEDROOM = auto() +# +# KBN_BH_2FL = auto() +# KBN_BH_2FL_STREET = auto() +# KBN_BH_1FL_LIVINGROOM = auto() +# KBN_BH_1FL_BEDROOM = auto() +# KBN_BH_1FL_BATHROOM = auto() +# +# KBN_NH_1FL_INV = auto() +# KBN_NH_1FL_CENTER = auto() +# KBN_NH_1LF_KT = auto() +# KBN_NH_1FL_DS = auto() +# KBN_NH_1FS_EZ = auto() +# +# SPB_FLAT120_CABINET = auto() + + +class MqttTempHumModule(MqttModule): + def __init__(self, + sensor: Optional[BaseSensor] = None, + write_to_database=False, + *args, **kwargs): + if sensor is not None: + kwargs['tick_interval'] = 10 + super().__init__(*args, **kwargs) + self._sensor = sensor + + def on_connect(self, mqtt: MqttNode): + super().on_connect(mqtt) + mqtt.subscribe_module(DATA_TOPIC, self) + + def tick(self): + if not self._sensor: + return + + error = 0 + temp = 0 + rh = 0 + try: + temp = self._sensor.temperature() + rh = self._sensor.humidity() + except: + error = 1 + pld = MqttTemphumDataPayload(temp=temp, rh=rh, error=error) + self._mqtt_node_ref.publish(DATA_TOPIC, pld.pack()) + + def handle_payload(self, + mqtt: MqttNode, + topic: str, + payload: bytes) -> Optional[MqttPayload]: + if topic == DATA_TOPIC: + message = MqttTemphumDataPayload.unpack(payload) + self._logger.debug(message) + return message diff --git a/src/home/mqtt/mqtt.py b/src/home/mqtt/mqtt.py deleted file mode 100644 index 4acd4f6..0000000 --- a/src/home/mqtt/mqtt.py +++ /dev/null @@ -1,76 +0,0 @@ -import os.path -import paho.mqtt.client as mqtt -import ssl -import logging - -from typing import Tuple -from ..config import config - - -def username_and_password() -> Tuple[str, str]: - username = config['mqtt']['username'] if 'username' in config['mqtt'] else None - password = config['mqtt']['password'] if 'password' in config['mqtt'] else None - return username, password - - -class MqttBase: - def __init__(self, clean_session=True): - self._client = mqtt.Client(client_id=config['mqtt']['client_id'], - protocol=mqtt.MQTTv311, - clean_session=clean_session) - self._client.on_connect = self.on_connect - self._client.on_disconnect = self.on_disconnect - self._client.on_message = self.on_message - self._client.on_log = self.on_log - self._client.on_publish = self.on_publish - self._loop_started = False - - self._logger = logging.getLogger(self.__class__.__name__) - - username, password = username_and_password() - if username and password: - self._logger.debug(f'username={username} password={password}') - self._client.username_pw_set(username, password) - - def configure_tls(self): - ca_certs = os.path.realpath(os.path.join( - os.path.dirname(os.path.realpath(__file__)), - '..', - '..', - '..', - 'assets', - 'mqtt_ca.crt' - )) - self._client.tls_set(ca_certs=ca_certs, cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLSv1_2) - - def connect_and_loop(self, loop_forever=True): - host = config['mqtt']['host'] - port = config['mqtt']['port'] - - self._client.connect(host, port, 60) - if loop_forever: - self._client.loop_forever() - else: - self._client.loop_start() - self._loop_started = True - - def disconnect(self): - self._client.disconnect() - self._client.loop_write() - self._client.loop_stop() - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - self._logger.info("Connected with result code " + str(rc)) - - def on_disconnect(self, client: mqtt.Client, userdata, rc): - self._logger.info("Disconnected with result code " + str(rc)) - - def on_log(self, client: mqtt.Client, userdata, level, buf): - level = mqtt.LOGGING_LEVEL[level] if level in mqtt.LOGGING_LEVEL else logging.INFO - self._logger.log(level, f'MQTT: {buf}') - - def on_message(self, client: mqtt.Client, userdata, msg): - self._logger.debug(msg.topic + ": " + str(msg.payload)) - - def on_publish(self, client: mqtt.Client, userdata, mid): - self._logger.debug(f'publish done, mid={mid}') \ No newline at end of file diff --git a/src/home/mqtt/payload/__init__.py b/src/home/mqtt/payload/__init__.py deleted file mode 100644 index eee6709..0000000 --- a/src/home/mqtt/payload/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .base_payload import MqttPayload \ No newline at end of file diff --git a/src/home/mqtt/payload/base_payload.py b/src/home/mqtt/payload/base_payload.py deleted file mode 100644 index 1abd898..0000000 --- a/src/home/mqtt/payload/base_payload.py +++ /dev/null @@ -1,145 +0,0 @@ -import abc -import struct -import re - -from typing import Optional, Tuple - - -def pldstr(self) -> str: - attrs = [] - for field in self.__class__.__annotations__: - if hasattr(self, field): - attr = getattr(self, field) - attrs.append(f'{field}={attr}') - if attrs: - attrs_s = ' ' - attrs_s += ', '.join(attrs) - else: - attrs_s = '' - return f'<%s{attrs_s}>' % (self.__class__.__name__,) - - -class MqttPayload(abc.ABC): - FORMAT = '' - PACKER = {} - UNPACKER = {} - - def __init__(self, **kwargs): - for field in self.__class__.__annotations__: - setattr(self, field, kwargs[field]) - - def pack(self): - args = [] - bf_number = -1 - bf_arg = 0 - bf_progress = 0 - - for field, field_type in self.__class__.__annotations__.items(): - bfp = _bit_field_params(field_type) - if bfp: - n, s, b = bfp - if n != bf_number: - if bf_number != -1: - args.append(bf_arg) - bf_number = n - bf_progress = 0 - bf_arg = 0 - bf_arg |= (getattr(self, field) & (2 ** b - 1)) << bf_progress - bf_progress += b - - else: - if bf_number != -1: - args.append(bf_arg) - bf_number = -1 - bf_progress = 0 - bf_arg = 0 - - args.append(self._pack_field(field)) - - if bf_number != -1: - args.append(bf_arg) - - return struct.pack(self.FORMAT, *args) - - @classmethod - def unpack(cls, buf: bytes): - data = struct.unpack(cls.FORMAT, buf) - kwargs = {} - i = 0 - bf_number = -1 - bf_progress = 0 - - for field, field_type in cls.__annotations__.items(): - bfp = _bit_field_params(field_type) - if bfp: - n, s, b = bfp - if n != bf_number: - bf_number = n - bf_progress = 0 - kwargs[field] = (data[i] >> bf_progress) & (2 ** b - 1) - bf_progress += b - continue # don't increment i - - if bf_number != -1: - bf_number = -1 - i += 1 - - if issubclass(field_type, MqttPayloadCustomField): - kwargs[field] = field_type.unpack(data[i]) - else: - kwargs[field] = cls._unpack_field(field, data[i]) - i += 1 - - return cls(**kwargs) - - def _pack_field(self, name): - val = getattr(self, name) - if self.PACKER and name in self.PACKER: - return self.PACKER[name](val) - else: - return val - - @classmethod - def _unpack_field(cls, name, val): - if isinstance(val, MqttPayloadCustomField): - return - if cls.UNPACKER and name in cls.UNPACKER: - return cls.UNPACKER[name](val) - else: - return val - - def __str__(self): - return pldstr(self) - - -class MqttPayloadCustomField(abc.ABC): - def __init__(self, **kwargs): - for field in self.__class__.__annotations__: - setattr(self, field, kwargs[field]) - - @abc.abstractmethod - def __index__(self): - pass - - @classmethod - @abc.abstractmethod - def unpack(cls, *args, **kwargs): - pass - - def __str__(self): - return pldstr(self) - - -def bit_field(seq_no: int, total_bits: int, bits: int): - return type(f'MQTTPayloadBitField_{seq_no}_{total_bits}_{bits}', (object,), { - 'seq_no': seq_no, - 'total_bits': total_bits, - 'bits': bits - }) - - -def _bit_field_params(cl) -> Optional[Tuple[int, ...]]: - match = re.match(r'MQTTPayloadBitField_(\d+)_(\d+)_(\d)$', cl.__name__) - if match is not None: - return tuple([int(match.group(i)) for i in range(1, 4)]) - return None diff --git a/src/home/mqtt/payload/esp.py b/src/home/mqtt/payload/esp.py deleted file mode 100644 index 171cdb9..0000000 --- a/src/home/mqtt/payload/esp.py +++ /dev/null @@ -1,78 +0,0 @@ -import hashlib - -from .base_payload import MqttPayload, MqttPayloadCustomField - - -class OTAResultPayload(MqttPayload): - FORMAT = '=BB' - result: int - error_code: int - - -class OTAPayload(MqttPayload): - secret: str - filename: str - - # structure of returned data: - # - # uint8_t[len(secret)] secret; - # uint8_t[16] md5; - # *uint8_t data - - def pack(self): - buf = bytearray(self.secret.encode()) - m = hashlib.md5() - with open(self.filename, 'rb') as fd: - content = fd.read() - m.update(content) - buf.extend(m.digest()) - buf.extend(content) - return buf - - def unpack(cls, buf: bytes): - raise RuntimeError(f'{cls.__class__.__name__}.unpack: not implemented') - # secret = buf[:12].decode() - # filename = buf[12:].decode() - # return OTAPayload(secret=secret, filename=filename) - - -class DiagnosticsFlags(MqttPayloadCustomField): - state: bool - config_changed_value_present: bool - config_changed: bool - - @staticmethod - def unpack(flags: int): - # _logger.debug(f'StatFlags.unpack: flags={flags}') - state = flags & 0x1 - ccvp = (flags >> 1) & 0x1 - cc = (flags >> 2) & 0x1 - # _logger.debug(f'StatFlags.unpack: state={state}') - return DiagnosticsFlags(state=(state == 1), - config_changed_value_present=(ccvp == 1), - config_changed=(cc == 1)) - - def __index__(self): - bits = 0 - bits |= (int(self.state) & 0x1) - bits |= (int(self.config_changed_value_present) & 0x1) << 1 - bits |= (int(self.config_changed) & 0x1) << 2 - return bits - - -class InitialDiagnosticsPayload(MqttPayload): - FORMAT = '=IBbIB' - - ip: int - fw_version: int - rssi: int - free_heap: int - flags: DiagnosticsFlags - - -class DiagnosticsPayload(MqttPayload): - FORMAT = '=bIB' - - rssi: int - free_heap: int - flags: DiagnosticsFlags diff --git a/src/home/mqtt/payload/inverter.py b/src/home/mqtt/payload/inverter.py deleted file mode 100644 index 09388df..0000000 --- a/src/home/mqtt/payload/inverter.py +++ /dev/null @@ -1,73 +0,0 @@ -import struct - -from .base_payload import MqttPayload, bit_field -from typing import Tuple - -_mult_10 = lambda n: int(n*10) -_div_10 = lambda n: n/10 - - -class Status(MqttPayload): - # 46 bytes - FORMAT = 'IHHHHHHBHHHHHBHHHHHHHH' - - PACKER = { - 'grid_voltage': _mult_10, - 'grid_freq': _mult_10, - 'ac_output_voltage': _mult_10, - 'ac_output_freq': _mult_10, - 'battery_voltage': _mult_10, - 'battery_voltage_scc': _mult_10, - 'battery_voltage_scc2': _mult_10, - 'pv1_input_voltage': _mult_10, - 'pv2_input_voltage': _mult_10 - } - UNPACKER = { - 'grid_voltage': _div_10, - 'grid_freq': _div_10, - 'ac_output_voltage': _div_10, - 'ac_output_freq': _div_10, - 'battery_voltage': _div_10, - 'battery_voltage_scc': _div_10, - 'battery_voltage_scc2': _div_10, - 'pv1_input_voltage': _div_10, - 'pv2_input_voltage': _div_10 - } - - time: int - grid_voltage: float - grid_freq: float - ac_output_voltage: float - ac_output_freq: float - ac_output_apparent_power: int - ac_output_active_power: int - output_load_percent: int - battery_voltage: float - battery_voltage_scc: float - battery_voltage_scc2: float - battery_discharge_current: int - battery_charge_current: int - battery_capacity: int - inverter_heat_sink_temp: int - mppt1_charger_temp: int - mppt2_charger_temp: int - pv1_input_power: int - pv2_input_power: int - pv1_input_voltage: float - pv2_input_voltage: float - - # H - mppt1_charger_status: bit_field(0, 16, 2) - mppt2_charger_status: bit_field(0, 16, 2) - battery_power_direction: bit_field(0, 16, 2) - dc_ac_power_direction: bit_field(0, 16, 2) - line_power_direction: bit_field(0, 16, 2) - load_connected: bit_field(0, 16, 1) - - -class Generation(MqttPayload): - # 8 bytes - FORMAT = 'II' - - time: int - wh: int diff --git a/src/home/mqtt/payload/relay.py b/src/home/mqtt/payload/relay.py deleted file mode 100644 index 4902991..0000000 --- a/src/home/mqtt/payload/relay.py +++ /dev/null @@ -1,22 +0,0 @@ -from .base_payload import MqttPayload -from .esp import ( - OTAResultPayload, - OTAPayload, - InitialDiagnosticsPayload, - DiagnosticsPayload -) - - -class PowerPayload(MqttPayload): - FORMAT = '=12sB' - PACKER = { - 'state': lambda n: int(n), - 'secret': lambda s: s.encode('utf-8') - } - UNPACKER = { - 'state': lambda n: bool(n), - 'secret': lambda s: s.decode('utf-8') - } - - secret: str - state: bool diff --git a/src/home/mqtt/payload/sensors.py b/src/home/mqtt/payload/sensors.py deleted file mode 100644 index f99b307..0000000 --- a/src/home/mqtt/payload/sensors.py +++ /dev/null @@ -1,20 +0,0 @@ -from .base_payload import MqttPayload - -_mult_100 = lambda n: int(n*100) -_div_100 = lambda n: n/100 - - -class Temperature(MqttPayload): - FORMAT = 'IhH' - PACKER = { - 'temp': _mult_100, - 'rh': _mult_100, - } - UNPACKER = { - 'temp': _div_100, - 'rh': _div_100, - } - - time: int - temp: float - rh: float diff --git a/src/home/mqtt/payload/temphum.py b/src/home/mqtt/payload/temphum.py deleted file mode 100644 index c0b744e..0000000 --- a/src/home/mqtt/payload/temphum.py +++ /dev/null @@ -1,15 +0,0 @@ -from .base_payload import MqttPayload - -two_digits_precision = lambda x: round(x, 2) - - -class TempHumDataPayload(MqttPayload): - FORMAT = '=ddb' - UNPACKER = { - 'temp': two_digits_precision, - 'rh': two_digits_precision - } - - temp: float - rh: float - error: int diff --git a/src/home/mqtt/relay.py b/src/home/mqtt/relay.py deleted file mode 100644 index a90f19c..0000000 --- a/src/home/mqtt/relay.py +++ /dev/null @@ -1,71 +0,0 @@ -import paho.mqtt.client as mqtt -import re -import datetime - -from .payload.relay import ( - PowerPayload, -) -from .esp import MqttEspBase - - -class MqttRelay(MqttEspBase): - TOPIC_LEAF = 'relay' - - def set_power(self, device_id, enable: bool, secret=None): - device = next(d for d in self._devices if d.id == device_id) - secret = secret if secret else device.secret - - assert secret is not None, 'device secret not specified' - - payload = PowerPayload(secret=secret, - state=enable) - self._client.publish(f'hk/{device.id}/{self.TOPIC_LEAF}/power', - payload=payload.pack(), - qos=1) - self._client.loop_write() - - def on_message(self, client: mqtt.Client, userdata, msg): - if super().on_message(client, userdata, msg): - return - - try: - match = re.match(self.get_mqtt_topics(['power']), msg.topic) - if not match: - return - - device_id = match.group(1) - subtopic = match.group(2) - - message = None - if subtopic == 'power': - message = PowerPayload.unpack(msg.payload) - - if message and self._message_callback: - self._message_callback(device_id, message) - - except Exception as e: - self._logger.exception(str(e)) - - -class MqttRelayState: - enabled: bool - update_time: datetime.datetime - rssi: int - fw_version: int - ever_updated: bool - - def __init__(self): - self.ever_updated = False - self.enabled = False - self.rssi = 0 - - def update(self, - enabled: bool, - rssi: int, - fw_version=None): - self.ever_updated = True - self.enabled = enabled - self.rssi = rssi - self.update_time = datetime.datetime.now() - if fw_version: - self.fw_version = fw_version diff --git a/src/home/mqtt/temphum.py b/src/home/mqtt/temphum.py deleted file mode 100644 index 44810ef..0000000 --- a/src/home/mqtt/temphum.py +++ /dev/null @@ -1,54 +0,0 @@ -import paho.mqtt.client as mqtt -import re - -from enum import auto -from .payload.temphum import TempHumDataPayload -from .esp import MqttEspBase -from ..util import HashableEnum - - -class MqttTempHumNodes(HashableEnum): - KBN_SH_HALL = auto() - KBN_SH_BATHROOM = auto() - KBN_SH_LIVINGROOM = auto() - KBN_SH_BEDROOM = auto() - - KBN_BH_2FL = auto() - KBN_BH_2FL_STREET = auto() - KBN_BH_1FL_LIVINGROOM = auto() - KBN_BH_1FL_BEDROOM = auto() - KBN_BH_1FL_BATHROOM = auto() - - KBN_NH_1FL_INV = auto() - KBN_NH_1FL_CENTER = auto() - KBN_NH_1LF_KT = auto() - KBN_NH_1FL_DS = auto() - KBN_NH_1FS_EZ = auto() - - SPB_FLAT120_CABINET = auto() - - -class MqttTempHum(MqttEspBase): - TOPIC_LEAF = 'temphum' - - def on_message(self, client: mqtt.Client, userdata, msg): - if super().on_message(client, userdata, msg): - return - - try: - match = re.match(self.get_mqtt_topics(['data']), msg.topic) - if not match: - return - - device_id = match.group(1) - subtopic = match.group(2) - - message = None - if subtopic == 'data': - message = TempHumDataPayload.unpack(msg.payload) - - if message and self._message_callback: - self._message_callback(device_id, message) - - except Exception as e: - self._logger.exception(str(e)) diff --git a/src/home/mqtt/util.py b/src/home/mqtt/util.py deleted file mode 100644 index f71ffd8..0000000 --- a/src/home/mqtt/util.py +++ /dev/null @@ -1,8 +0,0 @@ -import time - - -def poll_tick(freq): - t = time.time() - while True: - t += freq - yield max(t - time.time(), 0) diff --git a/src/home/pio/products.py b/src/home/pio/products.py index 7649078..388da03 100644 --- a/src/home/pio/products.py +++ b/src/home/pio/products.py @@ -16,10 +16,6 @@ _products_dir = os.path.join( def get_products(): products = [] for f in os.listdir(_products_dir): - # temp hack - if f.endswith('-esp01'): - continue - # skip the common dir if f in ('common',): continue diff --git a/src/home/telegram/_botcontext.py b/src/home/telegram/_botcontext.py index f343eeb..a143bfe 100644 --- a/src/home/telegram/_botcontext.py +++ b/src/home/telegram/_botcontext.py @@ -1,6 +1,7 @@ from typing import Optional, List -from telegram import Update, ParseMode, User, CallbackQuery +from telegram import Update, User, CallbackQuery +from telegram.constants import ParseMode from telegram.ext import CallbackContext from ._botdb import BotDatabase @@ -26,25 +27,25 @@ class Context: self._store = store self._user_lang = None - def reply(self, text, markup=None): + async def reply(self, text, markup=None): if markup is None: markup = self._markup_getter(self) kwargs = dict(parse_mode=ParseMode.HTML) if not isinstance(markup, IgnoreMarkup): kwargs['reply_markup'] = markup - return self._update.message.reply_text(text, **kwargs) + return await self._update.message.reply_text(text, **kwargs) - def reply_exc(self, e: Exception) -> None: - self.reply(exc2text(e), markup=IgnoreMarkup()) + async def reply_exc(self, e: Exception) -> None: + await self.reply(exc2text(e), markup=IgnoreMarkup()) - def answer(self, text: str = None): - self.callback_query.answer(text) + async def answer(self, text: str = None): + await self.callback_query.answer(text) - def edit(self, text, markup=None): + async def edit(self, text, markup=None): kwargs = dict(parse_mode=ParseMode.HTML) if not isinstance(markup, IgnoreMarkup): kwargs['reply_markup'] = markup - self.callback_query.edit_message_text(text, **kwargs) + await self.callback_query.edit_message_text(text, **kwargs) @property def text(self) -> str: diff --git a/src/home/telegram/bot.py b/src/home/telegram/bot.py index 10bfe06..7e22263 100644 --- a/src/home/telegram/bot.py +++ b/src/home/telegram/bot.py @@ -5,19 +5,19 @@ import itertools from enum import Enum, auto from functools import wraps -from typing import Optional, Union, Tuple +from typing import Optional, Union, Tuple, Coroutine from telegram import Update, ReplyKeyboardMarkup from telegram.ext import ( - Updater, - Filters, - BaseFilter, + Application, + filters, CommandHandler, MessageHandler, CallbackQueryHandler, CallbackContext, ConversationHandler ) +from telegram.ext.filters import BaseFilter from telegram.error import TimedOut from home.config import config @@ -33,26 +33,26 @@ 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')) +_cancel_filter = filters.Text(lang.all('cancel')) +_back_filter = filters.Text(lang.all('back')) +_cancel_and_back_filter = filters.Text(lang.all('back') + lang.all('cancel')) _logger = logging.getLogger(__name__) -_updater: Optional[Updater] = None +_application: Optional[Application] = None _reporting: Optional[ReportingHelper] = None -_exception_handler: Optional[callable] = None +_exception_handler: Optional[Coroutine] = None _dispatcher = None _markup_getter: Optional[callable] = None -_start_handler_ref: Optional[callable] = None +_start_handler_ref: Optional[Coroutine] = None def text_filter(*args): if not _user_filter: raise RuntimeError('user_filter is not initialized') - return Filters.text(args[0] if isinstance(args[0], list) else [*args]) & _user_filter + return filters.Text(args[0] if isinstance(args[0], list) else [*args]) & _user_filter -def _handler_of_handler(*args, **kwargs): +async def _handler_of_handler(*args, **kwargs): self = None context = None update = None @@ -99,7 +99,7 @@ def _handler_of_handler(*args, **kwargs): if self: _args.insert(0, self) - result = f(*_args, **kwargs) + result = await f(*_args, **kwargs) return result if not return_with_context else (result, ctx) except Exception as e: @@ -107,7 +107,7 @@ def _handler_of_handler(*args, **kwargs): if not _exception_handler(e, ctx) and not isinstance(e, TimedOut): _logger.exception(e) if not ctx.is_callback_context(): - ctx.reply_exc(e) + await ctx.reply_exc(e) else: notify_user(ctx.user_id, exc2text(e)) else: @@ -117,10 +117,10 @@ def _handler_of_handler(*args, **kwargs): def handler(**kwargs): def inner(f): @wraps(f) - def _handler(*args, **inner_kwargs): + async def _handler(*args, **inner_kwargs): if 'argument' in kwargs and kwargs['argument'] == 'message_key': inner_kwargs['argument'] = 'message_key' - return _handler_of_handler(f=f, *args, **inner_kwargs) + return await _handler_of_handler(f=f, *args, **inner_kwargs) messages = [] texts = [] @@ -139,43 +139,43 @@ def handler(**kwargs): new_messages = list(itertools.chain.from_iterable([lang.all(m) for m in messages])) texts += new_messages texts = list(set(texts)) - _updater.dispatcher.add_handler( + _application.add_handler( MessageHandler(text_filter(*texts), _handler), group=0 ) if 'command' in kwargs: - _updater.dispatcher.add_handler(CommandHandler(kwargs['command'], _handler), group=0) + _application.add_handler(CommandHandler(kwargs['command'], _handler), group=0) if 'callback' in kwargs: - _updater.dispatcher.add_handler(CallbackQueryHandler(_handler, pattern=kwargs['callback']), group=0) + _application.add_handler(CallbackQueryHandler(_handler, pattern=kwargs['callback']), group=0) return _handler return inner -def simplehandler(f: callable): +def simplehandler(f: Coroutine): @wraps(f) - def _handler(*args, **kwargs): - return _handler_of_handler(f=f, *args, **kwargs) + async def _handler(*args, **kwargs): + return await _handler_of_handler(f=f, *args, **kwargs) return _handler def callbackhandler(*args, **kwargs): def inner(f): @wraps(f) - def _handler(*args, **kwargs): - return _handler_of_handler(f=f, *args, **kwargs) + async def _handler(*args, **kwargs): + return await _handler_of_handler(f=f, *args, **kwargs) pattern_kwargs = {} if kwargs['callback'] != '*': pattern_kwargs['pattern'] = kwargs['callback'] - _updater.dispatcher.add_handler(CallbackQueryHandler(_handler, **pattern_kwargs), group=0) + _application.add_handler(CallbackQueryHandler(_handler, **pattern_kwargs), group=0) return _handler return inner -def exceptionhandler(f: callable): +async def exceptionhandler(f: callable): global _exception_handler if _exception_handler: _logger.warning('exception handler already set, we will overwrite it') @@ -198,10 +198,10 @@ def convinput(state, is_enter=False, **kwargs): ) @wraps(f) - def _impl(*args, **kwargs): - result, ctx = _handler_of_handler(f=f, *args, **kwargs, return_with_context=True) + async def _impl(*args, **kwargs): + result, ctx = await _handler_of_handler(f=f, *args, **kwargs, return_with_context=True) if result == conversation.END: - start(ctx) + await start(ctx) return result return _impl @@ -252,7 +252,7 @@ class conversation: handlers.append(MessageHandler(text_filter(lang.all(message) if 'messages_lang_completed' not in kwargs else message), self.make_invoker(target_state))) if 'regex' in kwargs: - handlers.append(MessageHandler(Filters.regex(kwargs['regex']) & _user_filter, f)) + handlers.append(MessageHandler(filters.Regex(kwargs['regex']) & _user_filter, f)) if 'command' in kwargs: handlers.append(CommandHandler(kwargs['command'], f, _user_filter)) @@ -327,21 +327,21 @@ class conversation: @staticmethod @simplehandler - def invalid(ctx: Context): - ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup()) + async def invalid(ctx: Context): + await ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup()) # return 0 # FIXME is this needed @simplehandler - def cancel(self, ctx: Context): - start(ctx) + async def cancel(self, ctx: Context): + await start(ctx) self.set_user_state(ctx.user_id, None) return conversation.END @simplehandler - def back(self, ctx: Context): + async def back(self, ctx: Context): cur_state = self.get_user_state(ctx.user_id) if cur_state is None: - start(ctx) + await start(ctx) self.set_user_state(ctx.user_id, None) return conversation.END @@ -411,7 +411,7 @@ class LangConversation(conversation): START, = range(1) @conventer(START, command='lang') - def entry(self, ctx: Context): + async def entry(self, ctx: Context): self._logger.debug(f'current language: {ctx.user_lang}') buttons = [] @@ -419,11 +419,11 @@ class LangConversation(conversation): buttons.append(name) markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False) - ctx.reply(ctx.lang('select_language'), markup=markup) + await ctx.reply(ctx.lang('select_language'), markup=markup) return self.START @convinput(START, messages=lang.languages) - def input(self, ctx: Context): + async def input(self, ctx: Context): selected_lang = None for key, value in languages.items(): if value == ctx.text: @@ -434,30 +434,34 @@ class LangConversation(conversation): raise ValueError('could not find the language') db.set_user_lang(ctx.user_id, selected_lang) - ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup()) + await ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup()) return self.END def initialize(): global _user_filter - global _updater + global _application + # global _updater global _dispatcher # init user_filter - if 'users' in config['bot']: - _logger.info('allowed users: ' + str(config['bot']['users'])) - _user_filter = Filters.user(config['bot']['users']) + _user_ids = config.app_config.get_user_ids() + if len(_user_ids) > 0: + _logger.info('allowed users: ' + str(_user_ids)) + _user_filter = filters.User(_user_ids) else: - _user_filter = Filters.all # not sure if this is correct + _user_filter = filters.ALL # not sure if this is correct - # init updater - _updater = Updater(config['bot']['token'], - request_kwargs={'read_timeout': 6, 'connect_timeout': 7}) + _application = Application.builder()\ + .token(config.app_config.get('bot.token'))\ + .connect_timeout(7)\ + .read_timeout(6)\ + .build() # transparently log all messages - _updater.dispatcher.add_handler(MessageHandler(Filters.all & _user_filter, _logging_message_handler), group=10) - _updater.dispatcher.add_handler(CallbackQueryHandler(_logging_callback_handler), group=10) + # _application.dispatcher.add_handler(MessageHandler(filters.ALL & _user_filter, _logging_message_handler), group=10) + # _application.dispatcher.add_handler(CallbackQueryHandler(_logging_callback_handler), group=10) def run(start_handler=None, any_handler=None): @@ -473,37 +477,38 @@ def run(start_handler=None, any_handler=None): _start_handler_ref = start_handler - _updater.dispatcher.add_handler(LangConversation().get_handler(), group=0) - _updater.dispatcher.add_handler(CommandHandler('start', simplehandler(start_handler), _user_filter)) - _updater.dispatcher.add_handler(MessageHandler(Filters.all & _user_filter, any_handler)) + _application.add_handler(LangConversation().get_handler(), group=0) + _application.add_handler(CommandHandler('start', + callback=simplehandler(start_handler), + filters=_user_filter)) + _application.add_handler(MessageHandler(filters.ALL & _user_filter, any_handler)) - _updater.start_polling() - _updater.idle() + _application.run_polling() def add_conversation(conv: conversation) -> None: - _updater.dispatcher.add_handler(conv.get_handler(), group=0) + _application.add_handler(conv.get_handler(), group=0) def add_handler(h): - _updater.dispatcher.add_handler(h, group=0) + _application.add_handler(h, group=0) -def start(ctx: Context): - return _start_handler_ref(ctx) +async def start(ctx: Context): + return await _start_handler_ref(ctx) -def _default_start_handler(ctx: Context): +async def _default_start_handler(ctx: Context): if 'start_message' not in lang: - return ctx.reply('Please define start_message or override start()') - ctx.reply(ctx.lang('start_message')) + return await ctx.reply('Please define start_message or override start()') + await ctx.reply(ctx.lang('start_message')) @simplehandler -def _default_any_handler(ctx: Context): +async def _default_any_handler(ctx: Context): if 'invalid_command' not in lang: - return ctx.reply('Please define invalid_command or override any()') - ctx.reply(ctx.lang('invalid_command')) + return await ctx.reply('Please define invalid_command or override any()') + await ctx.reply(ctx.lang('invalid_command')) def _logging_message_handler(update: Update, context: CallbackContext): @@ -535,7 +540,7 @@ def notify_all(text_getter: callable, continue text = text_getter(db.get_user_lang(user_id)) - _updater.bot.send_message(chat_id=user_id, + _application.bot.send_message(chat_id=user_id, text=text, parse_mode='HTML') @@ -543,33 +548,33 @@ def notify_all(text_getter: callable, def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> None: if isinstance(text, Exception): text = exc2text(text) - _updater.bot.send_message(chat_id=user_id, + _application.bot.send_message(chat_id=user_id, text=text, parse_mode='HTML', **kwargs) def send_photo(user_id, **kwargs): - _updater.bot.send_photo(chat_id=user_id, **kwargs) + _application.bot.send_photo(chat_id=user_id, **kwargs) def send_audio(user_id, **kwargs): - _updater.bot.send_audio(chat_id=user_id, **kwargs) + _application.bot.send_audio(chat_id=user_id, **kwargs) def send_file(user_id, **kwargs): - _updater.bot.send_document(chat_id=user_id, **kwargs) + _application.bot.send_document(chat_id=user_id, **kwargs) def edit_message_text(user_id, message_id, *args, **kwargs): - _updater.bot.edit_message_text(chat_id=user_id, + _application.bot.edit_message_text(chat_id=user_id, message_id=message_id, parse_mode='HTML', *args, **kwargs) def delete_message(user_id, message_id): - _updater.bot.delete_message(chat_id=user_id, message_id=message_id) + _application.bot.delete_message(chat_id=user_id, message_id=message_id) def set_database(_db: BotDatabase): diff --git a/src/home/telegram/config.py b/src/home/telegram/config.py new file mode 100644 index 0000000..7a46087 --- /dev/null +++ b/src/home/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' + + @staticmethod + def schema() -> 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' + + @staticmethod + def schema() -> Optional[dict]: + return { + 'type': 'dict', + 'schema': { + 'id': {'type': 'string', 'required': True}, + 'token': {'type': 'string', 'required': True}, + } + } + + +class TelegramBotConfig(ConfigUnit, ABC): + @staticmethod + def schema() -> 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/temphum/__init__.py b/src/home/temphum/__init__.py index 55a7e1f..46d14e6 100644 --- a/src/home/temphum/__init__.py +++ b/src/home/temphum/__init__.py @@ -1,18 +1 @@ -from .base import SensorType, TempHumSensor -from .si7021 import Si7021 -from .dht12 import DHT12 - -__all__ = [ - 'SensorType', - 'TempHumSensor', - 'create_sensor' -] - - -def create_sensor(type: SensorType, bus: int) -> TempHumSensor: - if type == SensorType.Si7021: - return Si7021(bus) - elif type == SensorType.DHT12: - return DHT12(bus) - else: - raise ValueError('unexpected sensor type') +from .base import SensorType, BaseSensor diff --git a/src/home/temphum/base.py b/src/home/temphum/base.py index e774433..602cab7 100644 --- a/src/home/temphum/base.py +++ b/src/home/temphum/base.py @@ -1,25 +1,19 @@ -import smbus - -from abc import abstractmethod, ABC +from abc import ABC from enum import Enum -class TempHumSensor: - @abstractmethod +class BaseSensor(ABC): + def __init__(self, bus: int): + super().__init__() + self.bus = smbus.SMBus(bus) + def humidity(self) -> float: pass - @abstractmethod def temperature(self) -> float: pass -class I2CTempHumSensor(TempHumSensor, ABC): - def __init__(self, bus: int): - super().__init__() - self.bus = smbus.SMBus(bus) - - class SensorType(Enum): Si7021 = 'si7021' - DHT12 = 'dht12' + DHT12 = 'dht12' \ No newline at end of file diff --git a/src/home/temphum/dht12.py b/src/home/temphum/dht12.py deleted file mode 100644 index d495766..0000000 --- a/src/home/temphum/dht12.py +++ /dev/null @@ -1,22 +0,0 @@ -from .base import I2CTempHumSensor - - -class DHT12(I2CTempHumSensor): - i2c_addr = 0x5C - - def _measure(self): - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0, 5) - if (raw[0] + raw[1] + raw[2] + raw[3]) & 0xff != raw[4]: - raise ValueError("checksum error") - return raw - - def temperature(self) -> float: - raw = self._measure() - temp = raw[2] + (raw[3] & 0x7f) * 0.1 - if raw[3] & 0x80: - temp *= -1 - return temp - - def humidity(self) -> float: - raw = self._measure() - return raw[0] + raw[1] * 0.1 diff --git a/src/home/temphum/i2c.py b/src/home/temphum/i2c.py new file mode 100644 index 0000000..7d8e2e3 --- /dev/null +++ b/src/home/temphum/i2c.py @@ -0,0 +1,52 @@ +import abc +import smbus + +from .base import BaseSensor, SensorType + + +class I2CSensor(BaseSensor, abc.ABC): + def __init__(self, bus: int): + super().__init__() + self.bus = smbus.SMBus(bus) + + +class DHT12(I2CSensor): + i2c_addr = 0x5C + + def _measure(self): + raw = self.bus.read_i2c_block_data(self.i2c_addr, 0, 5) + if (raw[0] + raw[1] + raw[2] + raw[3]) & 0xff != raw[4]: + raise ValueError("checksum error") + return raw + + def temperature(self) -> float: + raw = self._measure() + temp = raw[2] + (raw[3] & 0x7f) * 0.1 + if raw[3] & 0x80: + temp *= -1 + return temp + + def humidity(self) -> float: + raw = self._measure() + return raw[0] + raw[1] * 0.1 + + +class Si7021(I2CSensor): + i2c_addr = 0x40 + + def temperature(self) -> float: + raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE3, 2) + return 175.72 * (raw[0] << 8 | raw[1]) / 65536.0 - 46.85 + + def humidity(self) -> float: + raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE5, 2) + return 125.0 * (raw[0] << 8 | raw[1]) / 65536.0 - 6.0 + + +def create_sensor(type: SensorType, bus: int) -> BaseSensor: + if type == SensorType.Si7021: + return Si7021(bus) + elif type == SensorType.DHT12: + return DHT12(bus) + else: + raise ValueError('unexpected sensor type') diff --git a/src/home/temphum/si7021.py b/src/home/temphum/si7021.py deleted file mode 100644 index 6289e15..0000000 --- a/src/home/temphum/si7021.py +++ /dev/null @@ -1,13 +0,0 @@ -from .base import I2CTempHumSensor - - -class Si7021(I2CTempHumSensor): - i2c_addr = 0x40 - - def temperature(self) -> float: - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE3, 2) - return 175.72 * (raw[0] << 8 | raw[1]) / 65536.0 - 46.85 - - def humidity(self) -> float: - raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE5, 2) - return 125.0 * (raw[0] << 8 | raw[1]) / 65536.0 - 6.0 diff --git a/src/home/util.py b/src/home/util.py index 93a9d8f..35505bc 100644 --- a/src/home/util.py +++ b/src/home/util.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import socket import time @@ -6,17 +8,57 @@ import traceback import logging import string import random +import re from enum import Enum from datetime import datetime from typing import Tuple, Optional, List from zlib import adler32 -Addr = Tuple[str, int] # network address type (host, port) - logger = logging.getLogger(__name__) +def validate_ipv4_or_hostname(address: str, raise_exception: bool = False) -> bool: + if re.match(r'^(\d{1,3}\.){3}\d{1,3}$', address): + parts = address.split('.') + if all(0 <= int(part) < 256 for part in parts): + return True + else: + if raise_exception: + raise ValueError(f"invalid IPv4 address: {address}") + return False + + if re.match(r'^[a-zA-Z0-9.-]+$', address): + return True + else: + if raise_exception: + raise ValueError(f"invalid hostname: {address}") + return False + + +class Addr: + host: str + port: int + + def __init__(self, host: str, port: int): + self.host = host + self.port = port + + @staticmethod + def fromstring(addr: str) -> Addr: + if addr.count(':') != 1: + raise ValueError('invalid host:port format') + + host, port = addr.split(':') + validate_ipv4_or_hostname(host, raise_exception=True) + + port = int(port) + if not 0 <= port <= 65535: + raise ValueError(f'invalid port {port}') + + return Addr(host, port) + + # https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks def chunks(lst, n): """Yield successive n-sized chunks from lst.""" @@ -45,21 +87,6 @@ def ipv4_valid(ip: str) -> bool: return False -def parse_addr(addr: str) -> Addr: - if addr.count(':') != 1: - raise ValueError('invalid host:port format') - - host, port = addr.split(':') - if not ipv4_valid(host): - raise ValueError('invalid ipv4 address') - - port = int(port) - if not 0 <= port <= 65535: - raise ValueError('invalid port') - - return host, port - - def strgen(n: int): return ''.join(random.choices(string.ascii_letters + string.digits, k=n)) @@ -193,4 +220,11 @@ def filesize_fmt(num, suffix="B") -> str: class HashableEnum(Enum): def hash(self) -> int: - return adler32(self.name.encode()) \ No newline at end of file + return adler32(self.name.encode()) + + +def next_tick_gen(freq): + t = time.time() + while True: + t += freq + yield max(t - time.time(), 0) \ No newline at end of file diff --git a/src/inverter_bot.py b/src/inverter_bot.py index fd5acf3..ecf01fc 100755 --- a/src/inverter_bot.py +++ b/src/inverter_bot.py @@ -4,14 +4,16 @@ 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 +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, @@ -24,12 +26,17 @@ from home.inverter.types import ( ACMode, OutputSourcePriority ) -from home.database.inverter_time_formats import * +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 -monitor: Optional[InverterMonitor] = None + +if __name__ != '__main__': + print(f'this script can not be imported as module', file=sys.stderr) + sys.exit(1) + + db = None LT = escape('<=') flags_map = { @@ -42,9 +49,56 @@ flags_map = { 'alarm_on_on_primary_source_interrupt': 'ALRM', 'fault_code_record': 'FTCR', } - logger = logging.getLogger(__name__) -config.load('inverter_bot') + + +class InverterBotConfig(AppConfigUnit, TelegramBotConfig): + NAME = 'inverter_bot' + + @staticmethod + def schema() -> 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( @@ -863,28 +917,27 @@ class InverterStore(bot.BotDatabase): self.commit() -if __name__ == '__main__': - inverter.init(host=config['inverter']['ip'], port=config['inverter']['port']) +inverter.init(host=config['inverter']['ip'], port=config['inverter']['port']) - bot.set_database(InverterStore()) - bot.enable_logging(BotType.INVERTER) +bot.set_database(InverterStore()) +bot.enable_logging(BotType.INVERTER) - bot.add_conversation(SettingsConversation(enable_back=True)) - bot.add_conversation(ConsumptionConversation(enable_back=True)) +bot.add_conversation(SettingsConversation(enable_back=True)) +bot.add_conversation(ConsumptionConversation(enable_back=True)) - monitor = InverterMonitor() - monitor.set_charging_event_handler(monitor_charging) - monitor.set_battery_event_handler(monitor_battery) - monitor.set_util_event_handler(monitor_util) - monitor.set_error_handler(monitor_error) - monitor.set_osp_need_change_callback(osp_change_cb) +monitor = InverterMonitor() +monitor.set_charging_event_handler(monitor_charging) +monitor.set_battery_event_handler(monitor_battery) +monitor.set_util_event_handler(monitor_util) +monitor.set_error_handler(monitor_error) +monitor.set_osp_need_change_callback(osp_change_cb) - setacmode(getacmode()) +setacmode(getacmode()) - if not config.get('monitor.disabled'): - logging.info('starting monitor') - monitor.start() +if not config.get('monitor.disabled'): + logging.info('starting monitor') + monitor.start() - bot.run() +bot.run() - monitor.stop() +monitor.stop() diff --git a/src/inverter_mqtt_receiver.py b/src/inverter_mqtt_receiver.py deleted file mode 100755 index d40647e..0000000 --- a/src/inverter_mqtt_receiver.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -import paho.mqtt.client as mqtt -import re - -from home.mqtt import MqttBase -from home.mqtt.payload.inverter import Status, Generation -from home.database import InverterDatabase -from home.config import config - - -class MqttReceiver(MqttBase): - def __init__(self): - super().__init__(clean_session=False) - self.database = InverterDatabase() - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - self._logger.info("subscribing to hk/#") - client.subscribe('hk/#', qos=1) - - def on_message(self, client: mqtt.Client, userdata, msg): - super().on_message(client, userdata, msg) - try: - match = re.match(r'(?:home|hk)/(\d+)/(status|gen)', msg.topic) - if not match: - return - - # FIXME string home_id must be supported - home_id, what = int(match.group(1)), match.group(2) - if what == 'gen': - gen = Generation.unpack(msg.payload) - self.database.add_generation(home_id, gen.time, gen.wh) - - elif what == 'status': - s = Status.unpack(msg.payload) - self.database.add_status(home_id, - client_time=s.time, - grid_voltage=int(s.grid_voltage*10), - grid_freq=int(s.grid_freq * 10), - ac_output_voltage=int(s.ac_output_voltage * 10), - ac_output_freq=int(s.ac_output_freq * 10), - ac_output_apparent_power=s.ac_output_apparent_power, - ac_output_active_power=s.ac_output_active_power, - output_load_percent=s.output_load_percent, - battery_voltage=int(s.battery_voltage * 10), - battery_voltage_scc=int(s.battery_voltage_scc * 10), - battery_voltage_scc2=int(s.battery_voltage_scc2 * 10), - battery_discharge_current=s.battery_discharge_current, - battery_charge_current=s.battery_charge_current, - battery_capacity=s.battery_capacity, - inverter_heat_sink_temp=s.inverter_heat_sink_temp, - mppt1_charger_temp=s.mppt1_charger_temp, - mppt2_charger_temp=s.mppt2_charger_temp, - pv1_input_power=s.pv1_input_power, - pv2_input_power=s.pv2_input_power, - pv1_input_voltage=int(s.pv1_input_voltage * 10), - pv2_input_voltage=int(s.pv2_input_voltage * 10), - mppt1_charger_status=s.mppt1_charger_status, - mppt2_charger_status=s.mppt2_charger_status, - battery_power_direction=s.battery_power_direction, - dc_ac_power_direction=s.dc_ac_power_direction, - line_power_direction=s.line_power_direction, - load_connected=s.load_connected) - - except Exception as e: - self._logger.exception(str(e)) - - -if __name__ == '__main__': - config.load('inverter_mqtt_receiver') - - server = MqttReceiver() - server.connect_and_loop() - diff --git a/src/inverter_mqtt_sender.py b/src/inverter_mqtt_sender.py deleted file mode 100755 index fb2a2d8..0000000 --- a/src/inverter_mqtt_sender.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -import time -import datetime -import json -import inverterd - -from home.config import config -from home.mqtt import MqttBase, poll_tick -from home.mqtt.payload.inverter import Status, Generation - - -class MqttClient(MqttBase): - def __init__(self): - super().__init__() - - self._home_id = config['mqtt']['home_id'] - - self._inverter = inverterd.Client() - self._inverter.connect() - self._inverter.format(inverterd.Format.SIMPLE_JSON) - - def poll_inverter(self): - freq = int(config['mqtt']['inverter']['poll_freq']) - gen_freq = int(config['mqtt']['inverter']['generation_poll_freq']) - - g = poll_tick(freq) - gen_prev = 0 - while True: - time.sleep(next(g)) - - # read status - now = time.time() - try: - raw = self._inverter.exec('get-status') - except inverterd.InverterError as e: - self._logger.error(f'inverter error: {str(e)}') - # TODO send to server - continue - - data = json.loads(raw)['data'] - status = Status(time=round(now), **data) # FIXME this will crash with 99% probability - - self._client.publish(f'hk/{self._home_id}/status', - payload=status.pack(), - qos=1) - - # read today's generation stat - now = time.time() - if gen_prev == 0 or now - gen_prev >= gen_freq: - gen_prev = now - today = datetime.date.today() - try: - raw = self._inverter.exec('get-day-generated', (today.year, today.month, today.day)) - except inverterd.InverterError as e: - self._logger.error(f'inverter error: {str(e)}') - # TODO send to server - continue - - data = json.loads(raw)['data'] - gen = Generation(time=round(now), wh=data['wh']) - self._client.publish(f'hk/{self._home_id}/gen', - payload=gen.pack(), - qos=1) - - -if __name__ == '__main__': - config.load('inverter_mqtt_sender') - - client = MqttClient() - client.configure_tls() - client.connect_and_loop(loop_forever=False) - client.poll_inverter() \ No newline at end of file diff --git a/src/inverter_mqtt_util.py b/src/inverter_mqtt_util.py new file mode 100755 index 0000000..791bf80 --- /dev/null +++ b/src/inverter_mqtt_util.py @@ -0,0 +1,25 @@ +#!/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/ipcam_server.py b/src/ipcam_server.py index 2c4915d..a54cd35 100755 --- a/src/ipcam_server.py +++ b/src/ipcam_server.py @@ -556,7 +556,7 @@ logger = logging.getLogger(__name__) # -------------------- if __name__ == '__main__': - config.load('ipcam_server') + config.load_app('ipcam_server') open_database() diff --git a/src/mqtt_node_util.py b/src/mqtt_node_util.py new file mode 100755 index 0000000..ce954ae --- /dev/null +++ b/src/mqtt_node_util.py @@ -0,0 +1,63 @@ +#!/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 index d31c3bf..35b755f 100755 --- a/src/openwrt_log_analyzer.py +++ b/src/openwrt_log_analyzer.py @@ -54,7 +54,7 @@ def main(mac: str, if __name__ == '__main__': - config.load('openwrt_log_analyzer') + config.load_app('openwrt_log_analyzer') for ap in config['openwrt_log_analyzer']['aps']: state_file = config['simple_state']['file'] state_file = state_file.replace('.txt', f'-{ap}.txt') diff --git a/src/openwrt_logger.py b/src/openwrt_logger.py index 3b19de2..97fe7a9 100755 --- a/src/openwrt_logger.py +++ b/src/openwrt_logger.py @@ -46,7 +46,7 @@ if __name__ == '__main__': parser.add_argument('--access-point', type=int, required=True, help='access point number') - arg = config.load('openwrt_logger', parser=parser) + arg = config.load_app('openwrt_logger', parser=parser) state = SimpleState(file=config['simple_state']['file'].replace('{ap}', str(arg.access_point)), default={'seek': 0, 'size': 0}) diff --git a/src/pio_ini.py b/src/pio_ini.py index 19dd707..920c3e5 100755 --- a/src/pio_ini.py +++ b/src/pio_ini.py @@ -54,12 +54,17 @@ def bsd_parser(product_config: dict, arg_kwargs['type'] = int elif kwargs['type'] == 'int': arg_kwargs['type'] = int + elif kwargs['type'] == 'bool': + arg_kwargs['action'] = 'store_true' + arg_kwargs['required'] = False else: raise TypeError(f'unsupported type {kwargs["type"]} for define {define_name}') else: arg_kwargs['action'] = 'store_true' - parser.add_argument(f'--{define_name}', required=True, **arg_kwargs) + if 'required' not in arg_kwargs: + arg_kwargs['required'] = True + parser.add_argument(f'--{define_name}', **arg_kwargs) bsd_walk(product_config, f) @@ -76,6 +81,9 @@ def bsd_get(product_config: dict, enums.append(f'CONFIG_{define_name}') defines[f'CONFIG_{define_name}'] = f'HOMEKIT_{attr_value.upper()}' return + if kwargs['type'] == 'bool': + defines[f'CONFIG_{define_name}'] = True + return defines[f'CONFIG_{define_name}'] = str(attr_value) bsd_walk(product_config, f) return defines, enums diff --git a/src/polaris_kettle_bot.py b/src/polaris_kettle_bot.py index 088707d..80baef3 100755 --- a/src/polaris_kettle_bot.py +++ b/src/polaris_kettle_bot.py @@ -10,7 +10,7 @@ import paho.mqtt.client as mqtt from home.telegram import bot from home.api.types import BotType -from home.mqtt import MqttBase +from home.mqtt import Mqtt from home.config import config from home.util import chunks from syncleo import ( @@ -41,7 +41,7 @@ from telegram.ext import ( ) logger = logging.getLogger(__name__) -config.load('polaris_kettle_bot') +config.load_app('polaris_kettle_bot') primary_choices = (70, 80, 90, 100) all_choices = range( @@ -204,7 +204,7 @@ class KettleInfo: class KettleController(threading.Thread, - MqttBase, + Mqtt, DeviceListener, IncomingMessageListener, KettleInfoListener, @@ -224,7 +224,7 @@ class KettleController(threading.Thread, def __init__(self): # basic setup - MqttBase.__init__(self, clean_session=False) + Mqtt.__init__(self, clean_session=False) threading.Thread.__init__(self) self._logger = logging.getLogger(self.__class__.__name__) diff --git a/src/polaris_kettle_util.py b/src/polaris_kettle_util.py index 81326dd..12c4388 100755 --- a/src/polaris_kettle_util.py +++ b/src/polaris_kettle_util.py @@ -8,7 +8,7 @@ import paho.mqtt.client as mqtt from typing import Optional from argparse import ArgumentParser from queue import SimpleQueue -from home.mqtt import MqttBase +from home.mqtt import Mqtt from home.config import config from syncleo import ( Kettle, @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) control_tasks = SimpleQueue() -class MqttServer(MqttBase): +class MqttServer(Mqtt): def __init__(self): super().__init__(clean_session=False) @@ -75,7 +75,7 @@ def main(): parser.add_argument('-t', '--temperature', dest='temp', type=int, default=tempmax, choices=range(tempmin, tempmax+tempstep, tempstep)) - arg = config.load('polaris_kettle_util', use_cli=True, parser=parser) + arg = config.load_app('polaris_kettle_util', use_cli=True, parser=parser) if arg.mode == 'mqtt': server = MqttServer() diff --git a/src/pump_bot.py b/src/pump_bot.py index de925db..25f06fd 100755 --- a/src/pump_bot.py +++ b/src/pump_bot.py @@ -2,14 +2,34 @@ 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 +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('pump_bot') + +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( @@ -18,17 +38,27 @@ bot.lang.ru( enable="Включить", enable_silently="Включить тихо", - enabled="Включен ✅", + enabled="Насос включен ✅", disable="Выключить", disable_silently="Выключить тихо", - disabled="Выключен ❌", + disabled="Насос выключен ❌", + + start_watering="Включить полив", + stop_watering="Отключить полив", + + status="Статус насоса", + watering_status="Статус полива", - 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", @@ -36,23 +66,35 @@ bot.lang.en( enable="Turn ON", enable_silently="Turn ON silently", - enabled="Turned ON ✅", + enabled="The pump is turned ON ✅", disable="Turn OFF", disable_silently="Turn OFF silently", - disabled="Turned OFF ❌", + disabled="The pump is turned OFF ❌", + + start_watering="Start watering", + stop_watering="Stop watering", + + status="Pump status", + watering_status="Watering status", - status="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: @@ -75,11 +117,24 @@ def off(ctx: bot.Context, silent=False) -> None: 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('user_action_notification', lang, + return 'ℹ ' + bot.lang.get(notification_key, lang, user.id, user_name, action_name) bot.notify_all(text_getter, exclude=(user.id,)) @@ -100,6 +155,16 @@ 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) @@ -112,20 +177,79 @@ def status(ctx: bot.Context) -> None: ) +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 = [ - [ctx.lang('enable'), ctx.lang('disable')], - ] - + buttons = [] if ctx.user_id in config['bot']['silent_users']: buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')]) - - buttons.append([ctx.lang('status')]) + 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 index d3b6de4..4036d3a 100755 --- a/src/pump_mqtt_bot.py +++ b/src/pump_mqtt_bot.py @@ -8,13 +8,12 @@ from telegram import ReplyKeyboardMarkup, User from home.config import config from home.telegram import bot from home.telegram._botutil import user_any_name -from home.mqtt.esp import MqttEspDevice -from home.mqtt import MqttRelay, MqttRelayState -from home.mqtt.payload import MqttPayload -from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload +from home.mqtt import MqttNode, MqttPayload +from home.mqtt.module.relay import MqttRelayState +from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload -config.load('pump_mqtt_bot') +config.load_app('pump_mqtt_bot') bot.initialize() bot.lang.ru( @@ -70,7 +69,7 @@ bot.lang.en( ) -mqtt_relay: Optional[MqttRelay] = None +mqtt: Optional[MqttNode] = None relay_state = MqttRelayState() @@ -99,14 +98,14 @@ def notify(user: User, action: UserAction) -> None: @bot.handler(message='enable') def enable_handler(ctx: bot.Context) -> None: - mqtt_relay.set_power(config['mqtt']['home_id'], True) + mqtt.set_power(config['mqtt']['home_id'], True) ctx.reply(ctx.lang('done')) notify(ctx.user, UserAction.ON) @bot.handler(message='disable') def disable_handler(ctx: bot.Context) -> None: - mqtt_relay.set_power(config['mqtt']['home_id'], False) + mqtt.set_power(config['mqtt']['home_id'], False) ctx.reply(ctx.lang('done')) notify(ctx.user, UserAction.OFF) @@ -157,13 +156,12 @@ def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: if __name__ == '__main__': - mqtt_relay = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'], - secret=config['mqtt']['home_secret'])) - mqtt_relay.set_message_callback(on_mqtt_message) - mqtt_relay.configure_tls() - mqtt_relay.connect_and_loop(loop_forever=False) + mqtt = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'], + secret=config['mqtt']['home_secret'])) + mqtt.set_message_callback(on_mqtt_message) + mqtt.connect_and_loop(loop_forever=False) # bot.enable_logging(BotType.PUMP_MQTT) bot.run(start_handler=start) - mqtt_relay.disconnect() + mqtt.disconnect() diff --git a/src/relay_mqtt_bot.py b/src/relay_mqtt_bot.py index ebbff82..9de8c7e 100755 --- a/src/relay_mqtt_bot.py +++ b/src/relay_mqtt_bot.py @@ -1,18 +1,62 @@ #!/usr/bin/env python3 +import sys + from enum import Enum -from typing import Optional +from typing import Optional, Union from telegram import ReplyKeyboardMarkup from functools import partial -from home.config import config +from home.config import config, AppConfigUnit, Translation from home.telegram import bot -from home.mqtt import MqttRelay, MqttRelayState -from home.mqtt.esp import MqttEspDevice -from home.mqtt.payload import MqttPayload -from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload +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') + + @staticmethod + def schema() -> 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}"') -config.load('relay_mqtt_bot') + 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( @@ -34,7 +78,10 @@ status_emoji = { 'on': '✅', 'off': '❌' } -mqtt_relay: Optional[MqttRelay] = None + + +mqtt: MqttWrapper +relay_nodes: dict[str, Union[MqttRelayModule, MqttModule]] = {} relay_states: dict[str, MqttRelayState] = {} @@ -43,70 +90,75 @@ class UserAction(Enum): OFF = 'off' -def on_mqtt_message(home_id, message: MqttPayload): +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 home_id not in relay_states: - relay_states[home_id] = MqttRelayState() - relay_states[home_id].update(**kwargs) + if node.id not in relay_states: + relay_states[node.id] = MqttRelayState() + relay_states[node.id].update(**kwargs) -def enable_handler(home_id: str, ctx: bot.Context) -> None: - mqtt_relay.set_power(home_id, True) - ctx.reply(ctx.lang('done')) +async def enable_handler(node_id: str, ctx: bot.Context) -> None: + relay_nodes[node_id].switchpower(True) + await ctx.reply(ctx.lang('done')) -def disable_handler(home_id: str, ctx: bot.Context) -> None: - mqtt_relay.set_power(home_id, False) - ctx.reply(ctx.lang('done')) +async def disable_handler(node_id: str, ctx: bot.Context) -> None: + relay_nodes[node_id].switchpower(False) + await ctx.reply(ctx.lang('done')) -def start(ctx: bot.Context) -> None: - ctx.reply(ctx.lang('start_message')) +async def start(ctx: bot.Context) -> None: + await ctx.reply(ctx.lang('start_message')) @bot.exceptionhandler -def exception_handler(e: Exception, ctx: bot.Context) -> bool: +async def exception_handler(e: Exception, ctx: bot.Context) -> bool: return False @bot.defaultreplymarkup def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: buttons = [] - for device_id, data in config['relays'].items(): - labels = data['labels'] - type_emoji = type_emojis[data['type']] - row = [f'{type_emoji}{status_emoji[i.value]} {labels[ctx.user_lang]}' + for 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) -if __name__ == '__main__': - devices = [] - for device_id, data in config['relays'].items(): - devices.append(MqttEspDevice(id=device_id, - secret=data['secret'])) - labels = data['labels'] - bot.lang.ru(**{device_id: labels['ru']}) - bot.lang.en(**{device_id: labels['en']}) - - type_emoji = type_emojis[data['type']] - - for action in UserAction: - messages = [] - for _lang, _label in labels.items(): - messages.append(f'{type_emoji}{status_emoji[action.value]} {labels[_lang]}') - bot.handler(texts=messages)(partial(enable_handler if action == UserAction.ON else disable_handler, device_id)) - - mqtt_relay = MqttRelay(devices=devices) - mqtt_relay.set_message_callback(on_mqtt_message) - mqtt_relay.configure_tls() - mqtt_relay.connect_and_loop(loop_forever=False) - - # bot.enable_logging(BotType.RELAY_MQTT) - bot.run(start_handler=start) - - mqtt_relay.disconnect() +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 index 098facc..2bc2c4a 100755 --- a/src/relay_mqtt_http_proxy.py +++ b/src/relay_mqtt_http_proxy.py @@ -1,17 +1,19 @@ #!/usr/bin/env python3 from home import http from home.config import config -from home.mqtt import MqttRelay, MqttRelayState -from home.mqtt.esp import MqttEspDevice -from home.mqtt.payload import MqttPayload -from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload -from typing import Optional +from home.mqtt import MqttPayload, MqttWrapper, MqttNode, MqttModule +from home.mqtt.module.relay import MqttRelayState, MqttRelayModule +from home.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload +from typing import Optional, Union -mqtt_relay: Optional[MqttRelay] = None +mqtt: Optional[MqttWrapper] = None +mqtt_nodes: dict[str, MqttNode] = {} +relay_modules: dict[str, Union[MqttRelayModule, MqttModule]] = {} relay_states: dict[str, MqttRelayState] = {} -def on_mqtt_message(device_id, message: MqttPayload): +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 device_id not in relay_states: @@ -29,17 +31,22 @@ class RelayMqttHttpProxy(http.HTTPServer): async def _relay_on_off(self, enable: Optional[bool], req: http.Request): - device_id = req.match_info['id'] - device_secret = req.query['secret'] + 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 device_id in relay_states and relay_states[device_id].ever_updated: - cur_state = relay_states[device_id].enabled + 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 - mqtt_relay.set_power(device_id, enable, device_secret) + if not node.secret: + node.secret = node_secret + relay_module.switchpower(enable) return self.ok() async def relay_on(self, req: http.Request): @@ -53,15 +60,21 @@ class RelayMqttHttpProxy(http.HTTPServer): if __name__ == '__main__': - config.load('relay_mqtt_http_proxy') + config.load_app('relay_mqtt_http_proxy') + + mqtt = MqttWrapper() + for device_id, data in config['relays'].items(): + mqtt_node = MqttNode(node_id=device_id) + relay_modules[device_id] = mqtt_node.load_module('relay') + mqtt_nodes[device_id] = mqtt_node + mqtt_node.add_payload_callback(on_mqtt_message) + mqtt.add_node(mqtt_node) + mqtt_node.add_payload_callback(on_mqtt_message) - mqtt_relay = MqttRelay(devices=[MqttEspDevice(id=device_id) for device_id in config.get('relay.devices')]) - mqtt_relay.configure_tls() - mqtt_relay.set_message_callback(on_mqtt_message) - mqtt_relay.connect_and_loop(loop_forever=False) + mqtt.connect_and_loop(loop_forever=False) proxy = RelayMqttHttpProxy(config.get_addr('server.listen')) try: proxy.run() except KeyboardInterrupt: - mqtt_relay.disconnect() + mqtt.disconnect() diff --git a/src/sensors_bot.py b/src/sensors_bot.py index dc081b0..152dd24 100755 --- a/src/sensors_bot.py +++ b/src/sensors_bot.py @@ -23,7 +23,7 @@ from home.api.types import ( TemperatureSensorLocation ) -config.load('sensors_bot') +config.load_app('sensors_bot') bot.initialize() bot.lang.ru( diff --git a/src/sensors_mqtt_receiver.py b/src/sensors_mqtt_receiver.py deleted file mode 100755 index a377ddd..0000000 --- a/src/sensors_mqtt_receiver.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -import paho.mqtt.client as mqtt -import re - -from home.mqtt import MqttBase -from home.config import config -from home.mqtt.payload.sensors import Temperature -from home.api.types import TemperatureSensorLocation -from home.database import SensorsDatabase - - -def get_sensor_type(sensor: str) -> TemperatureSensorLocation: - for item in TemperatureSensorLocation: - if sensor == item.name.lower(): - return item - raise ValueError(f'unexpected sensor value: {sensor}') - - -class MqttServer(MqttBase): - def __init__(self): - super().__init__(clean_session=False) - self.database = SensorsDatabase() - - def on_connect(self, client: mqtt.Client, userdata, flags, rc): - super().on_connect(client, userdata, flags, rc) - self._logger.info("subscribing to hk/#") - client.subscribe('hk/#', qos=1) - - def on_message(self, client: mqtt.Client, userdata, msg): - super().on_message(client, userdata, msg) - try: - variants = '|'.join([s.name.lower() for s in TemperatureSensorLocation]) - match = re.match(rf'hk/(\d+)/si7021/({variants})', msg.topic) - if not match: - return - - # FIXME string home_id must be supported - home_id = int(match.group(1)) - sensor = get_sensor_type(match.group(2)) - - payload = Temperature.unpack(msg.payload) - self.database.add_temperature(home_id, payload.time, sensor, - temp=int(payload.temp*100), - rh=int(payload.rh*100)) - except Exception as e: - self._logger.exception(str(e)) - - -if __name__ == '__main__': - config.load('sensors_mqtt_receiver') - - server = MqttServer() - server.connect_and_loop() diff --git a/src/sensors_mqtt_sender.py b/src/sensors_mqtt_sender.py deleted file mode 100755 index 87a28ca..0000000 --- a/src/sensors_mqtt_sender.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -import time -import json - -from home.util import parse_addr, MySimpleSocketClient -from home.mqtt import MqttBase, poll_tick -from home.mqtt.payload.sensors import Temperature -from home.config import config - - -class MqttClient(MqttBase): - def __init__(self): - super().__init__(self) - self._home_id = config['mqtt']['home_id'] - - def poll(self): - freq = int(config['mqtt']['sensors']['poll_freq']) - self._logger.debug(f'freq={freq}') - - g = poll_tick(freq) - while True: - time.sleep(next(g)) - for k, v in config['mqtt']['sensors']['si7021'].items(): - host, port = parse_addr(v['addr']) - self.publish_si7021(host, port, k) - - def publish_si7021(self, host: str, port: int, name: str): - self._logger.debug(f"publish_si7021/{name}: {host}:{port}") - - try: - now = time.time() - socket = MySimpleSocketClient(host, port) - - socket.write('read') - response = json.loads(socket.read().strip()) - - temp = response['temp'] - humidity = response['humidity'] - - self._logger.debug(f'publish_si7021/{name}: temp={temp} humidity={humidity}') - - pld = Temperature(time=round(now), - temp=temp, - rh=humidity) - self._client.publish(f'hk/{self._home_id}/si7021/{name}', - payload=pld.pack(), - qos=1) - except Exception as e: - self._logger.exception(e) - - -if __name__ == '__main__': - config.load('sensors_mqtt_sender') - - client = MqttClient() - client.configure_tls() - client.connect_and_loop(loop_forever=False) - client.poll() diff --git a/src/sound_bot.py b/src/sound_bot.py index 186337a..32371bd 100755 --- a/src/sound_bot.py +++ b/src/sound_bot.py @@ -14,7 +14,7 @@ from home.api.types import SoundSensorLocation, BotType from home.api.errors import ApiResponseError from home.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient from home.soundsensor import SoundSensorServerGuardClient -from home.util import parse_addr, chunks, filesize_fmt +from home.util import Addr, chunks, filesize_fmt from home.telegram import bot @@ -23,11 +23,11 @@ from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardBu from PIL import Image -config.load('sound_bot') +config.load_app('sound_bot') nodes = {} for nodename, nodecfg in config['nodes'].items(): - nodes[nodename] = parse_addr(nodecfg['addr']) + nodes[nodename] = Addr.fromstring(nodecfg['addr']) bot.initialize() bot.lang.ru( @@ -142,13 +142,13 @@ cam_client_links: Dict[str, CameraNodeClient] = {} def node_client(node: str) -> SoundNodeClient: if node not in node_client_links: - node_client_links[node] = SoundNodeClient(parse_addr(config['nodes'][node]['addr'])) + node_client_links[node] = SoundNodeClient(Addr.fromstring(config['nodes'][node]['addr'])) return node_client_links[node] def camera_client(cam: str) -> CameraNodeClient: if cam not in node_client_links: - cam_client_links[cam] = CameraNodeClient(parse_addr(config['cameras'][cam]['addr'])) + cam_client_links[cam] = CameraNodeClient(Addr.fromstring(config['cameras'][cam]['addr'])) return cam_client_links[cam] @@ -188,7 +188,7 @@ def manual_recording_allowed(user_id: int) -> bool: def guard_client() -> SoundSensorServerGuardClient: - return SoundSensorServerGuardClient(parse_addr(config['bot']['guard_server'])) + return SoundSensorServerGuardClient(Addr.fromstring(config['bot']['guard_server'])) # message renderers diff --git a/src/sound_node.py b/src/sound_node.py index 9d53362..b0b4a67 100755 --- a/src/sound_node.py +++ b/src/sound_node.py @@ -77,7 +77,7 @@ if __name__ == '__main__': if not os.getegid() == 0: raise RuntimeError("Must be run as root.") - config.load('sound_node') + config.load_app('sound_node') storage = SoundRecordStorage(config['node']['storage']) diff --git a/src/sound_sensor_node.py b/src/sound_sensor_node.py index d9a8999..404fdf4 100755 --- a/src/sound_sensor_node.py +++ b/src/sound_sensor_node.py @@ -4,7 +4,7 @@ import os import sys from home.config import config -from home.util import parse_addr +from home.util import Addr from home.soundsensor import SoundSensorNode logger = logging.getLogger(__name__) @@ -14,14 +14,14 @@ if __name__ == '__main__': if not os.getegid() == 0: sys.exit('Must be run as root.') - config.load('sound_sensor_node') + config.load_app('sound_sensor_node') kwargs = {} if 'delay' in config['node']: kwargs['delay'] = config['node']['delay'] if 'server_addr' in config['node']: - server_addr = parse_addr(config['node']['server_addr']) + server_addr = Addr.fromstring(config['node']['server_addr']) else: server_addr = None diff --git a/src/sound_sensor_server.py b/src/sound_sensor_server.py index aa62608..b660210 100755 --- a/src/sound_sensor_server.py +++ b/src/sound_sensor_server.py @@ -6,7 +6,7 @@ from time import sleep from typing import Optional, List, Dict, Tuple from functools import partial from home.config import config -from home.util import parse_addr +from home.util import Addr from home.api import WebAPIClient, RequestParams from home.api.types import SoundSensorLocation from home.soundsensor import SoundSensorServer, SoundSensorHitHandler @@ -159,7 +159,7 @@ def api_error_handler(exc, name, req: RequestParams): if __name__ == '__main__': - config.load('sound_sensor_server') + config.load_app('sound_sensor_server') hc = HitCounter() api = WebAPIClient(timeout=(10, 60)) @@ -172,12 +172,12 @@ if __name__ == '__main__': sound_nodes = {} if 'sound_nodes' in config: for nodename, nodecfg in config['sound_nodes'].items(): - sound_nodes[nodename] = parse_addr(nodecfg['addr']) + sound_nodes[nodename] = Addr.fromstring(nodecfg['addr']) camera_nodes = {} if 'camera_nodes' in config: for nodename, nodecfg in config['camera_nodes'].items(): - camera_nodes[nodename] = parse_addr(nodecfg['addr']) + camera_nodes[nodename] = Addr.fromstring(nodecfg['addr']) if sound_nodes: record_clients[MediaNodeType.SOUND] = SoundRecordClient(sound_nodes, diff --git a/src/ssh_tunnels_config_util.py b/src/ssh_tunnels_config_util.py index 3b2ba6e..963c01b 100755 --- a/src/ssh_tunnels_config_util.py +++ b/src/ssh_tunnels_config_util.py @@ -3,12 +3,12 @@ from home.config import config if __name__ == '__main__': - config.load('ssh_tunnels_config_util') + config.load_app('ssh_tunnels_config_util') network_prefix = config['network'] hostnames = [] - for k, v in config.items(): + for k, v in config.app_config.get().items(): if type(v) is not dict: continue hostnames.append(k) diff --git a/src/temphum_mqtt_node.py b/src/temphum_mqtt_node.py new file mode 100755 index 0000000..c3d1975 --- /dev/null +++ b/src/temphum_mqtt_node.py @@ -0,0 +1,78 @@ +#!/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 new file mode 100755 index 0000000..2b30800 --- /dev/null +++ b/src/temphum_mqtt_receiver.py @@ -0,0 +1,47 @@ +#!/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_smbus_util.py b/src/temphum_smbus_util.py index 0f90835..c06bacd 100755 --- a/src/temphum_smbus_util.py +++ b/src/temphum_smbus_util.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from argparse import ArgumentParser -from home.temphum import SensorType, create_sensor +from home.temphum import SensorType +from home.temphum.i2c import create_sensor if __name__ == '__main__': diff --git a/src/temphumd.py b/src/temphumd.py index f4d1fca..c3d1975 100755 --- a/src/temphumd.py +++ b/src/temphumd.py @@ -6,10 +6,11 @@ import logging from typing import Optional from home.config import config -from home.temphum import SensorType, create_sensor, TempHumSensor +from home.temphum import SensorType, BaseSensor +from home.temphum.i2c import create_sensor logger = logging.getLogger(__name__) -sensor: Optional[TempHumSensor] = None +sensor: Optional[BaseSensor] = None lock = asyncio.Lock() delay = 0.01 @@ -62,7 +63,7 @@ async def run_server(host, port): if __name__ == '__main__': - config.load() + config.load_app() if 'measure_delay' in config['sensor']: delay = float(config['sensor']['measure_delay']) diff --git a/src/test_new_config.py b/src/test_new_config.py new file mode 100755 index 0000000..db9eae3 --- /dev/null +++ b/src/test_new_config.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +from home.config import config +from home.mqtt import MqttNodesConfig +from home.telegram.config import TelegramUserIdsConfig +from pprint import pprint + + +if __name__ == '__main__': + config.load_app(name=False) + + c = TelegramUserIdsConfig() + pprint(c.get()) \ No newline at end of file diff --git a/src/web_api.py b/src/web_api.py index 0ddc6bd..0aa994a 100755 --- a/src/web_api.py +++ b/src/web_api.py @@ -231,7 +231,7 @@ if __name__ == '__main__': _app_name = 'web_api' if is_development_mode(): _app_name += '_dev' - config.load(_app_name) + config.load_app(_app_name) loop = asyncio.get_event_loop() diff --git a/systemd/inverter_mqtt_receiver.service b/systemd/inverter_mqtt_receiver.service new file mode 100644 index 0000000..fedf11f --- /dev/null +++ b/systemd/inverter_mqtt_receiver.service @@ -0,0 +1,13 @@ +[Unit] +Description=Inverter MQTT receiver +After=clickhouse-server.service + +[Service] +User=user +Group=user +Restart=on-failure +ExecStart=/home/user/homekit/src/inverter_mqtt_util.py receiver +WorkingDirectory=/home/user + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/systemd/inverter_mqtt_sender.service b/systemd/inverter_mqtt_sender.service index e3925f6..34272bb 100644 --- a/systemd/inverter_mqtt_sender.service +++ b/systemd/inverter_mqtt_sender.service @@ -6,7 +6,7 @@ After=inverterd.service User=user Group=user Restart=on-failure -ExecStart=/home/user/homekit/src/inverter_mqtt_sender.py +ExecStart=/home/user/homekit/src/inverter_mqtt_util.py sender WorkingDirectory=/home/user [Install] diff --git a/systemd/ipcam_rtsp2hls@.service b/systemd/ipcam_rtsp2hls@.service index addd819..efcdd6a 100644 --- a/systemd/ipcam_rtsp2hls@.service +++ b/systemd/ipcam_rtsp2hls@.service @@ -9,6 +9,8 @@ User=user Group=user EnvironmentFile=/etc/ipcam_rtsp2hls.conf.d/%i.conf ExecStart=/home/user/homekit/tools/ipcam_rtsp2hls.sh --name %i --user $USER --password $PASSWORD --ip $IP --port $PORT $ARGS +Restart=on-failure +RestartSec=3 [Install] WantedBy=multi-user.target diff --git a/systemd/sensors_mqtt_receiver.service b/systemd/sensors_mqtt_receiver.service index e67c112..5b9ff6a 100644 --- a/systemd/sensors_mqtt_receiver.service +++ b/systemd/sensors_mqtt_receiver.service @@ -1,12 +1,12 @@ [Unit] -Description=sensors mqtt receiver +Description=temphum mqtt receiver After=network.target [Service] User=user Group=user Restart=on-failure -ExecStart=python3 /home/user/home/src/sensors_mqtt_receiver.py +ExecStart=python3 /home/user/home/src/temphum_mqtt_receiver.py WorkingDirectory=/home/user [Install] diff --git a/systemd/sensors_mqtt_sender.service b/systemd/sensors_mqtt_sender.service deleted file mode 100644 index a271d72..0000000 --- a/systemd/sensors_mqtt_sender.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Sensors MQTT sender -After=temphumd.service - -[Service] -User=user -Group=user -Restart=on-failure -ExecStart=/home/user/homekit/src/sensors_mqtt_sender.py -WorkingDirectory=/home/user - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/test/mqtt_relay_server_util.py b/test/mqtt_relay_server_util.py new file mode 100755 index 0000000..ac6a9ae --- /dev/null +++ b/test/mqtt_relay_server_util.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +import sys +import os.path +sys.path.extend([ + os.path.realpath( + os.path.join(os.path.dirname(os.path.join(__file__)), '..') + ) +]) + +from src.home.config import config +from src.home.mqtt.relay import MQTTRelayClient + + +if __name__ == '__main__': + config.load_app('test_mqtt_relay_server') + relay = MQTTRelayClient('test') + relay.connect_and_loop() diff --git a/test/mqtt_relay_util.py b/test/mqtt_relay_util.py new file mode 100755 index 0000000..0d8c764 --- /dev/null +++ b/test/mqtt_relay_util.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +import sys +import os.path +sys.path.extend([ + os.path.realpath( + os.path.join(os.path.dirname(os.path.join(__file__)), '..') + ) +]) + +from argparse import ArgumentParser +from src.home.config import config +from src.home.mqtt.relay import MQTTRelayController + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('--on', action='store_true') + parser.add_argument('--off', action='store_true') + parser.add_argument('--stat', action='store_true') + + config.load_app('test_mqtt_relay', parser=parser) + arg = parser.parse_args() + + relay = MQTTRelayController('test') + relay.connect_and_loop(loop_forever=False) + + if arg.on: + relay.set_power(True) + + elif arg.off: + relay.set_power(False) + + elif arg.stat: + relay.send_stat(dict( + state=False, + signal=-59, + fw_v=1.0 + )) \ No newline at end of file diff --git a/test/test_amixer.py b/test/test_amixer.py index c8bd546..464941e 100755 --- a/test/test_amixer.py +++ b/test/test_amixer.py @@ -28,7 +28,7 @@ if __name__ == '__main__': parser.add_argument('--decr', type=str) # parser.add_argument('--dump-config', action='store_true') - args = config.load('test_amixer', parser=parser) + args = config.load_app('test_amixer', parser=parser) # if args.dump_config: # print(config.data) diff --git a/test/test_api.py b/test/test_api.py index 1f6361c..e80eb4c 100755 --- a/test/test_api.py +++ b/test/test_api.py @@ -13,7 +13,7 @@ from src.home.config import config if __name__ == '__main__': - config.load('test_api') + config.load_app('test_api') api = WebAPIClient() print(api.log_bot_request(BotType.ADMIN, 1, "test_api.py")) diff --git a/test/test_esp32_cam.py b/test/test_esp32_cam.py index 27ce379..6a4ad25 100755 --- a/test/test_esp32_cam.py +++ b/test/test_esp32_cam.py @@ -10,7 +10,7 @@ sys.path.extend([ from pprint import pprint from argparse import ArgumentParser from time import sleep -from src.home.util import parse_addr +from src.home.util import Addr from src.home.camera import esp32 from src.home.config import config @@ -21,8 +21,8 @@ if __name__ == '__main__': parser.add_argument('--status', action='store_true', help='print status and exit') - arg = config.load(False, parser=parser) - cam = esp32.WebClient(addr=parse_addr(arg.addr)) + arg = config.load_app(False, parser=parser) + cam = esp32.WebClient(addr=Addr.fromstring(arg.addr)) if arg.status: status = cam.getstatus() diff --git a/test/test_inverter_monitor.py b/test/test_inverter_monitor.py index 3b1c6b0..621c0e9 100755 --- a/test/test_inverter_monitor.py +++ b/test/test_inverter_monitor.py @@ -372,5 +372,5 @@ def main(): if __name__ == '__main__': - config.load('test_inverter_monitor') + config.load_app('test_inverter_monitor') main() diff --git a/test/test_ipcam_server_cleanup.py b/test/test_ipcam_server_cleanup.py index b7eb23a..5f313a4 100644 --- a/test/test_ipcam_server_cleanup.py +++ b/test/test_ipcam_server_cleanup.py @@ -77,5 +77,5 @@ def cleanup_job(): if __name__ == '__main__': - config.load('ipcam_server') + config.load_app('ipcam_server') cleanup_job() diff --git a/test/test_record_upload.py b/test/test_record_upload.py index cbd3ca2..835504f 100755 --- a/test/test_record_upload.py +++ b/test/test_record_upload.py @@ -13,7 +13,7 @@ import time from src.home.api import WebAPIClient, RequestParams from src.home.config import config from src.home.media import SoundRecordClient -from src.home.util import parse_addr +from src.home.util import Addr logger = logging.getLogger(__name__) @@ -64,11 +64,11 @@ def api_success_handler(response, name, req: RequestParams): if __name__ == '__main__': - config.load('test_record_upload') + config.load_app('test_record_upload') nodes = {} for name, addr in config['nodes'].items(): - nodes[name] = parse_addr(addr) + nodes[name] = Addr(addr) record = SoundRecordClient(nodes, error_handler=record_error, finished_handler=record_finished, diff --git a/test/test_send_fake_sound_hit.py b/test/test_send_fake_sound_hit.py index 9660c45..61886cd 100755 --- a/test/test_send_fake_sound_hit.py +++ b/test/test_send_fake_sound_hit.py @@ -8,7 +8,7 @@ sys.path.extend([ ]) from argparse import ArgumentParser -from src.home.util import send_datagram, stringify, parse_addr +from src.home.util import send_datagram, stringify, Addr if __name__ == '__main__': @@ -22,4 +22,4 @@ if __name__ == '__main__': args = parser.parse_args() - send_datagram(stringify([args.name, args.hits]), parse_addr(args.server)) + send_datagram(stringify([args.name, args.hits]), Addr.fromstring(args.server)) diff --git a/test/test_sound_server_api.py b/test/test_sound_server_api.py index e68c6f8..5295a5d 100755 --- a/test/test_sound_server_api.py +++ b/test/test_sound_server_api.py @@ -56,7 +56,7 @@ def hits_sender(): if __name__ == '__main__': - config.load('test_api') + config.load_app('test_api') hc = HitCounter() api = WebAPIClient() diff --git a/test/test_telegram_aio_send_photo.py b/test/test_telegram_aio_send_photo.py index 705e534..4d05c03 100644 --- a/test/test_telegram_aio_send_photo.py +++ b/test/test_telegram_aio_send_photo.py @@ -20,7 +20,7 @@ async def main(): if __name__ == '__main__': - config.load('test_telegram_aio_send_photo') + config.load_app('test_telegram_aio_send_photo') loop = asyncio.get_event_loop() asyncio.ensure_future(main()) diff --git a/tools/mcuota.py b/tools/mcuota.py deleted file mode 100755 index 46968a8..0000000 --- a/tools/mcuota.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) - -from time import sleep -from argparse import ArgumentParser -from src.home.config import config -from src.home.mqtt import MqttRelay -from src.home.mqtt.esp import MqttEspDevice - - -def guess_filename(product: str, build_target: str): - return os.path.join( - products_dir, - product, - '.pio', - 'build', - build_target, - 'firmware.bin' - ) - - -def relayctl_publish_ota(filename: str, - device_id: str, - home_secret: str, - qos: int): - global stop - - def published(): - global stop - stop = True - - mqtt_relay = MqttRelay(devices=MqttEspDevice(id=device_id, secret=home_secret)) - mqtt_relay.configure_tls() - mqtt_relay.connect_and_loop(loop_forever=False) - mqtt_relay.push_ota(device_id, filename, published, qos) - while not stop: - sleep(0.1) - mqtt_relay.disconnect() - - -stop = False -products = { - 'relayctl': { - 'build_target': 'esp12e', - 'callback': relayctl_publish_ota - } -} - -products_dir = os.path.join( - os.path.dirname(__file__), - '..', - 'platformio' -) - - -def main(): - parser = ArgumentParser() - parser.add_argument('--filename', type=str) - parser.add_argument('--device-id', type=str, required=True) - parser.add_argument('--product', type=str, required=True) - parser.add_argument('--qos', type=int, default=1) - - config.load('mcuota_push', parser=parser) - arg = parser.parse_args() - - if arg.product not in products: - raise ValueError(f'invalid product: \'{arg.product}\' not found') - - if arg.device_id not in config['mqtt']['home_secrets']: - raise ValueError(f'home_secret for home {arg.device_id} not found in config!') - - filename = arg.filename if arg.filename else guess_filename(arg.product, products[arg.product]['build_target']) - if not os.path.exists(filename): - raise OSError(f'file \'{filename}\' does not exists') - - print('Please confirm following OTA params.') - print('') - print(f' Device ID: {arg.device_id}') - print(f' Product: {arg.product}') - print(f'Firmware file: {filename}') - print('') - input('Press any key to continue or Ctrl+C to abort.') - - products[arg.product]['callback'](filename, arg.device_id, config['mqtt']['home_secrets'][arg.device_id], qos=arg.qos) - - -if __name__ == '__main__': - try: - main() - except Exception as e: - print(str(e), file=sys.stderr) - sys.exit(1) diff --git a/tools/mcuota.sh b/tools/mcuota.sh deleted file mode 100755 index b2e7910..0000000 --- a/tools/mcuota.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - -. "$DIR/lib.bash" - -if [ -d "$DIR/../venv" ]; then - echoinfo "activating python venv" - . "$DIR/../venv/bin/activate" -else - echowarn "python venv not found" -fi - -"$DIR/mcuota.py" "$@" \ No newline at end of file -- cgit v1.2.3 From 327a5298359027099631c3c9967b7585928cd367 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sat, 10 Jun 2023 21:54:56 +0300 Subject: port relay_mqtt_http_proxy to new config scheme; config: support addr types & normalization --- src/home/config/_configs.py | 8 ++-- src/home/config/config.py | 61 +++++++++++++++++++----------- src/home/inverter/config.py | 4 +- src/home/mqtt/_config.py | 8 ++-- src/home/mqtt/_wrapper.py | 5 ++- src/home/telegram/config.py | 12 +++--- src/home/util.py | 33 ++++++++++++---- src/inverter_bot.py | 4 +- src/relay_mqtt_bot.py | 4 +- src/relay_mqtt_http_proxy.py | 89 +++++++++++++++++++++++++++++++++++--------- src/test_new_config.py | 12 ------ 11 files changed, 159 insertions(+), 81 deletions(-) delete mode 100755 src/test_new_config.py diff --git a/src/home/config/_configs.py b/src/home/config/_configs.py index 3a1aae5..1628cba 100644 --- a/src/home/config/_configs.py +++ b/src/home/config/_configs.py @@ -5,8 +5,8 @@ from typing import Optional class ServicesListConfig(ConfigUnit): NAME = 'services_list' - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: return { 'type': 'list', 'empty': False, @@ -19,8 +19,8 @@ class ServicesListConfig(ConfigUnit): class LinuxBoardsConfig(ConfigUnit): NAME = 'linux_boards' - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: return { 'type': 'dict', 'schema': { diff --git a/src/home/config/config.py b/src/home/config/config.py index aef9ee7..dc00d2e 100644 --- a/src/home/config/config.py +++ b/src/home/config/config.py @@ -1,10 +1,10 @@ import yaml import logging import os -import pprint +import cerberus +import cerberus.errors from abc import ABC -from cerberus import Validator, DocumentError from typing import Optional, Any, MutableMapping, Union from argparse import ArgumentParser from enum import Enum, auto @@ -12,11 +12,20 @@ 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() @@ -95,10 +104,19 @@ class ConfigUnit(BaseConfigUnit): raise IOError(f'\'{name}.yaml\' not found') - @staticmethod - def schema() -> Optional[dict]: + @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: @@ -109,7 +127,7 @@ class ConfigUnit(BaseConfigUnit): schema['logging'] = { 'type': 'dict', 'schema': { - 'logging': {'type': 'bool'} + 'logging': {'type': 'boolean'} } } @@ -125,27 +143,27 @@ class ConfigUnit(BaseConfigUnit): except KeyError: pass + v = MyValidator() + if rst == RootSchemaType.DICT: - v = Validator({'document': { - 'type': 'dict', - 'keysrules': {'type': 'string'}, - 'valuesrules': schema - }}) - result = v.validate({'document': self._data}) + normalized = v.validated({'document': self._data}, + {'document': { + 'type': 'dict', + 'keysrules': {'type': 'string'}, + 'valuesrules': schema + }})['document'] elif rst == RootSchemaType.LIST: - v = Validator({'document': schema}) - result = v.validate({'document': self._data}) + v = MyValidator() + normalized = v.validated({'document': self._data}, {'document': schema})['document'] else: - v = Validator(schema) - result = v.validate(self._data) - # pprint.pprint(self._data) - if not result: - # pprint.pprint(v.errors) - raise DocumentError(f'{self.__class__.__name__}: failed to validate data:\n{pprint.pformat(v.errors)}') + normalized = v.validated(self._data, schema) + + self._data = normalized + try: self.custom_validator(self._data) except Exception as e: - raise DocumentError(f'{self.__class__.__name__}: {str(e)}') + raise cerberus.DocumentError(f'{self.__class__.__name__}: {str(e)}') @staticmethod def custom_validator(data): @@ -238,7 +256,7 @@ class Config: no_config=False): global app_config - if issubclass(name, AppConfigUnit) or name == AppConfigUnit: + if 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 @@ -278,6 +296,7 @@ class Config: 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(), diff --git a/src/home/inverter/config.py b/src/home/inverter/config.py index 62b8859..e284dfe 100644 --- a/src/home/inverter/config.py +++ b/src/home/inverter/config.py @@ -5,8 +5,8 @@ from typing import Optional class InverterdConfig(ConfigUnit): NAME = 'inverterd' - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: return { 'remote_addr': {'type': 'string'}, 'local_addr': {'type': 'string'}, diff --git a/src/home/mqtt/_config.py b/src/home/mqtt/_config.py index f9047b4..9ba9443 100644 --- a/src/home/mqtt/_config.py +++ b/src/home/mqtt/_config.py @@ -9,8 +9,8 @@ MqttCreds = namedtuple('MqttCreds', 'username, password') class MqttConfig(ConfigUnit): NAME = 'mqtt' - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: addr_schema = { 'type': 'dict', 'required': True, @@ -64,8 +64,8 @@ class MqttConfig(ConfigUnit): class MqttNodesConfig(ConfigUnit): NAME = 'mqtt_nodes' - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: return { 'common': { 'type': 'dict', diff --git a/src/home/mqtt/_wrapper.py b/src/home/mqtt/_wrapper.py index f858f88..3c2774c 100644 --- a/src/home/mqtt/_wrapper.py +++ b/src/home/mqtt/_wrapper.py @@ -2,7 +2,6 @@ import paho.mqtt.client as mqtt from ._mqtt import Mqtt from ._node import MqttNode -from ..config import config from ..util import strgen @@ -34,8 +33,10 @@ class MqttWrapper(Mqtt): 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: - node.on_message(topic[len(f'{self._topic_prefix}/{node.id}/'):], msg.payload) + 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)) diff --git a/src/home/telegram/config.py b/src/home/telegram/config.py index 7a46087..4c7d74b 100644 --- a/src/home/telegram/config.py +++ b/src/home/telegram/config.py @@ -12,8 +12,8 @@ class TelegramUserListType(Enum): class TelegramUserIdsConfig(ConfigUnit): NAME = 'telegram_user_ids' - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: return { 'roottype': 'dict', 'type': 'integer' @@ -32,8 +32,8 @@ def _user_id_mapper(user: Union[str, int]) -> int: class TelegramChatsConfig(ConfigUnit): NAME = 'telegram_chats' - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: return { 'type': 'dict', 'schema': { @@ -44,8 +44,8 @@ class TelegramChatsConfig(ConfigUnit): class TelegramBotConfig(ConfigUnit, ABC): - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: return { 'bot': { 'type': 'dict', diff --git a/src/home/util.py b/src/home/util.py index 35505bc..1e12243 100644 --- a/src/home/util.py +++ b/src/home/util.py @@ -12,7 +12,7 @@ import re from enum import Enum from datetime import datetime -from typing import Tuple, Optional, List +from typing import Optional, List from zlib import adler32 logger = logging.getLogger(__name__) @@ -38,26 +38,43 @@ def validate_ipv4_or_hostname(address: str, raise_exception: bool = False) -> bo class Addr: host: str - port: int + port: Optional[int] - def __init__(self, host: str, port: int): + def __init__(self, host: str, port: Optional[int] = None): self.host = host self.port = port @staticmethod def fromstring(addr: str) -> Addr: - if addr.count(':') != 1: + colons = addr.count(':') + if colons != 1: raise ValueError('invalid host:port format') - host, port = addr.split(':') + if not colons: + host = addr + port= None + else: + host, port = addr.split(':') + validate_ipv4_or_hostname(host, raise_exception=True) - port = int(port) - if not 0 <= port <= 65535: - raise ValueError(f'invalid port {port}') + 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): diff --git a/src/inverter_bot.py b/src/inverter_bot.py index ecf01fc..d35e606 100755 --- a/src/inverter_bot.py +++ b/src/inverter_bot.py @@ -55,8 +55,8 @@ logger = logging.getLogger(__name__) class InverterBotConfig(AppConfigUnit, TelegramBotConfig): NAME = 'inverter_bot' - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: acmode_item_schema = { 'thresholds': { 'type': 'list', diff --git a/src/relay_mqtt_bot.py b/src/relay_mqtt_bot.py index 9de8c7e..020dc08 100755 --- a/src/relay_mqtt_bot.py +++ b/src/relay_mqtt_bot.py @@ -32,8 +32,8 @@ class RelayMqttBotConfig(AppConfigUnit, TelegramBotConfig): super().__init__() self._strings = Translation('mqtt_nodes') - @staticmethod - def schema() -> Optional[dict]: + @classmethod + def schema(cls) -> Optional[dict]: return { **super(TelegramBotConfig).schema(), 'relay_nodes': { diff --git a/src/relay_mqtt_http_proxy.py b/src/relay_mqtt_http_proxy.py index 2bc2c4a..e13c04a 100755 --- a/src/relay_mqtt_http_proxy.py +++ b/src/relay_mqtt_http_proxy.py @@ -1,24 +1,69 @@ #!/usr/bin/env python3 +import logging + from home import http -from home.config import config -from home.mqtt import MqttPayload, MqttWrapper, MqttNode, MqttModule -from home.mqtt.module.relay import MqttRelayState, MqttRelayModule +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 = dict(rssi=message.rssi, enabled=message.flags.state) - if device_id not in relay_states: - relay_states[device_id] = MqttRelayState() - relay_states[device_id].update(**kwargs) + 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): @@ -44,8 +89,7 @@ class RelayMqttHttpProxy(http.HTTPServer): cur_state = False enable = not cur_state - if not node.secret: - node.secret = node_secret + node.secret = node_secret relay_module.switchpower(enable) return self.ok() @@ -60,20 +104,29 @@ class RelayMqttHttpProxy(http.HTTPServer): if __name__ == '__main__': - config.load_app('relay_mqtt_http_proxy') - - mqtt = MqttWrapper() - for device_id, data in config['relays'].items(): - mqtt_node = MqttNode(node_id=device_id) - relay_modules[device_id] = mqtt_node.load_module('relay') - mqtt_nodes[device_id] = mqtt_node + 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_node.add_payload_callback(on_mqtt_message) + mqtt_nodes[node_id] = mqtt_node mqtt.connect_and_loop(loop_forever=False) - proxy = RelayMqttHttpProxy(config.get_addr('server.listen')) + proxy = RelayMqttHttpProxy(config.app_config['listen_addr']) try: proxy.run() except KeyboardInterrupt: diff --git a/src/test_new_config.py b/src/test_new_config.py deleted file mode 100755 index db9eae3..0000000 --- a/src/test_new_config.py +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python3 -from home.config import config -from home.mqtt import MqttNodesConfig -from home.telegram.config import TelegramUserIdsConfig -from pprint import pprint - - -if __name__ == '__main__': - config.load_app(name=False) - - c = TelegramUserIdsConfig() - pprint(c.get()) \ No newline at end of file -- cgit v1.2.3 From 2631c58961c2f5ec90be560a8f5152fe27339a90 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sat, 10 Jun 2023 22:11:41 +0300 Subject: fix mqtt_node_util --- src/electricity_calc.py | 1 - src/home/config/config.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/electricity_calc.py b/src/electricity_calc.py index c3cb233..8ea5a1c 100755 --- a/src/electricity_calc.py +++ b/src/electricity_calc.py @@ -3,7 +3,6 @@ import logging import os import sys import inspect -import zoneinfo from home.config import config # do not remove this import! from datetime import datetime, timedelta diff --git a/src/home/config/config.py b/src/home/config/config.py index dc00d2e..7344386 100644 --- a/src/home/config/config.py +++ b/src/home/config/config.py @@ -256,7 +256,10 @@ class Config: no_config=False): global app_config - if not isinstance(name, str) and not isinstance(name, bool) and issubclass(name, AppConfigUnit) or name == AppConfigUnit: + 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 -- cgit v1.2.3 From 3790c2205396cf860738f297e6ddc49cd2b2a03f Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sat, 10 Jun 2023 22:29:24 +0300 Subject: new config: port openwrt_logger and webapiclient --- doc/openwrt_logger.md | 28 ++++++++++++++++++++++++++++ src/home/api/__init__.py | 12 ++++++++++-- src/home/api/__init__.pyi | 3 ++- src/home/api/config.py | 15 +++++++++++++++ src/home/api/web_api_client.py | 32 +++++++++++++++++--------------- src/home/database/_base.py | 9 +++++++++ src/home/database/simple_state.py | 14 ++++++++------ src/home/database/sqlite.py | 6 ++---- src/home/telegram/_botutil.py | 2 +- src/home/telegram/bot.py | 4 ++-- src/inverter_bot.py | 4 ++-- src/openwrt_log_analyzer.py | 2 +- src/openwrt_logger.py | 37 +++++++++++++------------------------ src/sensors_bot.py | 4 ++-- src/sound_bot.py | 6 +++--- src/sound_sensor_server.py | 6 +++--- test/test_api.py | 4 ++-- test/test_record_upload.py | 4 ++-- test/test_sound_server_api.py | 4 ++-- 19 files changed, 124 insertions(+), 72 deletions(-) create mode 100644 doc/openwrt_logger.md create mode 100644 src/home/api/config.py create mode 100644 src/home/database/_base.py diff --git a/doc/openwrt_logger.md b/doc/openwrt_logger.md new file mode 100644 index 0000000..1179c8b --- /dev/null +++ b/doc/openwrt_logger.md @@ -0,0 +1,28 @@ +# openwrt_logger.py + +This script is supposed to be run by cron every 5 minutes or so. +It looks for new lines in log file and sends them to remote server. + +OpenWRT must have remote logging enabled (UDP; IP of host this script is launched on; port 514) + +`/etc/rsyslog.conf` contains following (assuming `192.168.1.1` is the router IP): + +``` +$ModLoad imudp +$UDPServerRun 514 +:fromhost-ip, isequal, "192.168.1.1" /var/log/openwrt.log +& ~ +``` + +Also comment out the following line: +``` +$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat +``` + +Cron line example: +``` +* * * * * /home/user/homekit/src/openwrt_logger.py --access-point 1 --file /var/wrtlogfs/openwrt-5.log >/dev/null +``` + +`/var/wrtlogfs` is recommended to be tmpfs, to avoid writes on mmc card, in case +you use arm sbcs as I do. \ No newline at end of file diff --git a/src/home/api/__init__.py b/src/home/api/__init__.py index 782a61e..d641f62 100644 --- a/src/home/api/__init__.py +++ b/src/home/api/__init__.py @@ -1,11 +1,19 @@ import importlib -__all__ = ['WebAPIClient', 'RequestParams'] +__all__ = [ + # web_api_client.py + 'WebApiClient', + 'RequestParams', + + # config.py + 'WebApiConfig' +] def __getattr__(name): if name in __all__: - module = importlib.import_module(f'.web_api_client', __name__) + 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 index 1b812d6..5b98161 100644 --- a/src/home/api/__init__.pyi +++ b/src/home/api/__init__.pyi @@ -1,4 +1,5 @@ from .web_api_client import ( RequestParams as RequestParams, - WebAPIClient as WebAPIClient + WebApiClient as WebApiClient ) +from .config import WebApiConfig as WebApiConfig diff --git a/src/home/api/config.py b/src/home/api/config.py new file mode 100644 index 0000000..00c1097 --- /dev/null +++ b/src/home/api/config.py @@ -0,0 +1,15 @@ +from ..config import ConfigUnit +from typing import Optional, Union + + +class WebApiConfig(ConfigUnit): + NAME = 'web_api' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'listen_addr': cls._addr_schema(required=True), + 'host': cls._addr_schema(required=True), + 'token': dict(type='string', required=True), + 'recordings_dir': dict(type='string', required=True) + } \ No newline at end of file diff --git a/src/home/api/web_api_client.py b/src/home/api/web_api_client.py index 6677182..15c1915 100644 --- a/src/home/api/web_api_client.py +++ b/src/home/api/web_api_client.py @@ -9,13 +9,15 @@ from enum import Enum, auto from typing import Optional, Callable, Union, List, Tuple, Dict from requests.auth import HTTPBasicAuth +from .config import WebApiConfig from .errors import ApiResponseError from .types import * from ..config import config from ..util import stringify from ..media import RecordFile, MediaNodeClient -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) +_config = WebApiConfig() RequestParams = namedtuple('RequestParams', 'params, files, method') @@ -26,7 +28,7 @@ class HTTPMethod(Enum): POST = auto() -class WebAPIClient: +class WebApiClient: token: str timeout: Union[float, Tuple[float, float]] basic_auth: Optional[HTTPBasicAuth] @@ -35,22 +37,22 @@ class WebAPIClient: async_success_handler: Optional[Callable] def __init__(self, timeout: Union[float, Tuple[float, float]] = 5): - self.token = config['api']['token'] + self.token = config['token'] self.timeout = timeout self.basic_auth = None self.do_async = False self.async_error_handler = None self.async_success_handler = None - if 'basic_auth' in config['api']: - ba = config['api']['basic_auth'] - col = ba.index(':') - - user = ba[:col] - pw = ba[col+1:] - - logger.debug(f'enabling basic auth: {user}:{pw}') - self.basic_auth = HTTPBasicAuth(user, pw) + # if 'basic_auth' in config['api']: + # ba = config['api']['basic_auth'] + # col = ba.index(':') + # + # user = ba[:col] + # pw = ba[col+1:] + # + # _logger.debug(f'enabling basic auth: {user}:{pw}') + # self.basic_auth = HTTPBasicAuth(user, pw) # api methods # ----------- @@ -152,7 +154,7 @@ class WebAPIClient: params: dict, method: HTTPMethod = HTTPMethod.GET, files: Optional[Dict[str, str]] = None) -> Optional[any]: - domain = config['api']['host'] + domain = config['host'] kwargs = {} if self.basic_auth is not None: @@ -196,7 +198,7 @@ class WebAPIClient: try: f.close() except Exception as exc: - logger.exception(exc) + _logger.exception(exc) pass def _make_request_in_thread(self, name, params, method, files): @@ -204,7 +206,7 @@ class WebAPIClient: result = self._make_request(name, params, method, files) self._report_async_success(result, name, RequestParams(params=params, method=method, files=files)) except Exception as e: - logger.exception(e) + _logger.exception(e) self._report_async_error(e, name, RequestParams(params=params, method=method, files=files)) def enable_async(self, diff --git a/src/home/database/_base.py b/src/home/database/_base.py new file mode 100644 index 0000000..c01e62b --- /dev/null +++ b/src/home/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/src/home/database/simple_state.py b/src/home/database/simple_state.py index cada9c8..2b8ebe7 100644 --- a/src/home/database/simple_state.py +++ b/src/home/database/simple_state.py @@ -2,24 +2,26 @@ import os import json import atexit +from ._base import get_data_root_directory + class SimpleState: def __init__(self, - file: str, - default: dict = None, - **kwargs): + name: str, + default: dict = None): if default is None: default = {} elif type(default) is not dict: raise TypeError('default must be dictionary') - if not os.path.exists(file): + path = os.path.join(get_data_root_directory(), name) + if not os.path.exists(path): self._data = default else: - with open(file, 'r') as f: + with open(path, 'r') as f: self._data = json.loads(f.read()) - self._file = file + self._file = path atexit.register(self.__cleanup) def __cleanup(self): diff --git a/src/home/database/sqlite.py b/src/home/database/sqlite.py index 8c6145c..0af1f54 100644 --- a/src/home/database/sqlite.py +++ b/src/home/database/sqlite.py @@ -2,15 +2,13 @@ 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( - os.environ['HOME'], - '.config', - 'homekit', - 'data', + get_data_root_directory(), f'{name}.db') diff --git a/src/home/telegram/_botutil.py b/src/home/telegram/_botutil.py index 6d1ee8f..b551a55 100644 --- a/src/home/telegram/_botutil.py +++ b/src/home/telegram/_botutil.py @@ -3,7 +3,7 @@ import traceback from html import escape from telegram import User -from home.api import WebAPIClient as APIClient +from home.api import WebApiClient as APIClient from home.api.types import BotType from home.api.errors import ApiResponseError diff --git a/src/home/telegram/bot.py b/src/home/telegram/bot.py index 7e22263..e6ebc6e 100644 --- a/src/home/telegram/bot.py +++ b/src/home/telegram/bot.py @@ -21,7 +21,7 @@ from telegram.ext.filters import BaseFilter from telegram.error import TimedOut from home.config import config -from home.api import WebAPIClient +from home.api import WebApiClient from home.api.types import BotType from ._botlang import lang, languages @@ -522,7 +522,7 @@ def _logging_callback_handler(update: Update, context: CallbackContext): def enable_logging(bot_type: BotType): - api = WebAPIClient(timeout=3) + api = WebApiClient(timeout=3) api.enable_async() global _reporting diff --git a/src/inverter_bot.py b/src/inverter_bot.py index d35e606..1dd167e 100755 --- a/src/inverter_bot.py +++ b/src/inverter_bot.py @@ -28,7 +28,7 @@ from home.inverter.types import ( ) from home.database.inverter_time_formats import FormatDate from home.api.types import BotType -from home.api import WebAPIClient +from home.api import WebApiClient from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton @@ -718,7 +718,7 @@ class ConsumptionConversation(bot.conversation): message = ctx.reply(ctx.lang('consumption_request_sent'), markup=bot.IgnoreMarkup()) - api = WebAPIClient(timeout=60) + api = WebApiClient(timeout=60) method = 'inverter_get_consumed_energy' if state == self.TOTAL else 'inverter_get_grid_consumed_energy' try: diff --git a/src/openwrt_log_analyzer.py b/src/openwrt_log_analyzer.py index 35b755f..c1c4fbe 100755 --- a/src/openwrt_log_analyzer.py +++ b/src/openwrt_log_analyzer.py @@ -59,7 +59,7 @@ if __name__ == '__main__': state_file = config['simple_state']['file'] state_file = state_file.replace('.txt', f'-{ap}.txt') - state = SimpleState(file=state_file, + state = SimpleState(name=state_file, default={'last_id': 0}) max_last_id = 0 diff --git a/src/openwrt_logger.py b/src/openwrt_logger.py index 97fe7a9..82f11ac 100755 --- a/src/openwrt_logger.py +++ b/src/openwrt_logger.py @@ -2,29 +2,19 @@ import os from datetime import datetime -from typing import Tuple, List +from typing import Tuple, List, Optional from argparse import ArgumentParser -from home.config import config +from home.config import config, AppConfigUnit from home.database import SimpleState -from home.api import WebAPIClient +from home.api import WebApiClient -f""" -This script is supposed to be run by cron every 5 minutes or so. -It looks for new lines in log file and sends them to remote server. -OpenWRT must have remote logging enabled (UDP; IP of host this script is launched on; port 514) - -/etc/rsyslog.conf contains following (assuming 192.168.1.1 is the router IP): - -$ModLoad imudp -$UDPServerRun 514 -:fromhost-ip, isequal, "192.168.1.1" /var/log/openwrt.log -& ~ - -Also comment out the following line: -$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat - -""" +class OpenwrtLoggerConfig(AppConfigUnit): + @classmethod + def schema(cls) -> Optional[dict]: + return dict( + database_name_template=dict(type='string', required=True) + ) def parse_line(line: str) -> Tuple[int, str]: @@ -46,11 +36,10 @@ if __name__ == '__main__': parser.add_argument('--access-point', type=int, required=True, help='access point number') - arg = config.load_app('openwrt_logger', parser=parser) - - state = SimpleState(file=config['simple_state']['file'].replace('{ap}', str(arg.access_point)), - default={'seek': 0, 'size': 0}) + arg = config.load_app(OpenwrtLoggerConfig, parser=parser) + state = SimpleState(name=config.app_config['database_name_template'].replace('{ap}', str(arg.access_point)), + default=dict(seek=0, size=0)) fsize = os.path.getsize(arg.file) if fsize < state['size']: state['seek'] = 0 @@ -79,5 +68,5 @@ if __name__ == '__main__': except ValueError: lines.append((0, line)) - api = WebAPIClient() + api = WebApiClient() api.log_openwrt(lines, arg.access_point) diff --git a/src/sensors_bot.py b/src/sensors_bot.py index 152dd24..441c212 100755 --- a/src/sensors_bot.py +++ b/src/sensors_bot.py @@ -17,7 +17,7 @@ from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardBu from home.config import config from home.telegram import bot from home.util import chunks, MySimpleSocketClient -from home.api import WebAPIClient +from home.api import WebApiClient from home.api.types import ( BotType, TemperatureSensorLocation @@ -111,7 +111,7 @@ def callback_handler(ctx: bot.Context) -> None: sensor = TemperatureSensorLocation[match.group(1).upper()] hours = int(match.group(2)) - api = WebAPIClient(timeout=20) + api = WebApiClient(timeout=20) data = api.get_sensors_data(sensor, hours) title = ctx.lang(sensor.name.lower()) + ' (' + ctx.lang('n_hrs', hours) + ')' diff --git a/src/sound_bot.py b/src/sound_bot.py index 32371bd..bc9edce 100755 --- a/src/sound_bot.py +++ b/src/sound_bot.py @@ -9,7 +9,7 @@ from html import escape from typing import Optional, List, Dict, Tuple from home.config import config -from home.api import WebAPIClient +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 @@ -734,7 +734,7 @@ def sound_sensors_last_24h(ctx: bot.Context): ctx.answer() - cl = WebAPIClient() + cl = WebApiClient() data = cl.get_sound_sensor_hits(location=SoundSensorLocation[node.upper()], after=datetime.now() - timedelta(hours=24)) @@ -757,7 +757,7 @@ def sound_sensors_last_anything(ctx: bot.Context): ctx.answer() - cl = WebAPIClient() + cl = WebApiClient() data = cl.get_last_sound_sensor_hits(location=SoundSensorLocation[node.upper()], last=20) diff --git a/src/sound_sensor_server.py b/src/sound_sensor_server.py index b660210..3446b80 100755 --- a/src/sound_sensor_server.py +++ b/src/sound_sensor_server.py @@ -7,7 +7,7 @@ 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 import WebApiClient, RequestParams from home.api.types import SoundSensorLocation from home.soundsensor import SoundSensorServer, SoundSensorHitHandler from home.media import MediaNodeType, SoundRecordClient, CameraRecordClient, RecordClient @@ -120,7 +120,7 @@ def hits_sender(): sleep(5) -api: Optional[WebAPIClient] = None +api: Optional[WebApiClient] = None hc: Optional[HitCounter] = None record_clients: Dict[MediaNodeType, RecordClient] = {} @@ -162,7 +162,7 @@ if __name__ == '__main__': config.load_app('sound_sensor_server') hc = HitCounter() - api = WebAPIClient(timeout=(10, 60)) + api = WebApiClient(timeout=(10, 60)) api.enable_async(error_handler=api_error_handler) t = threading.Thread(target=hits_sender) diff --git a/test/test_api.py b/test/test_api.py index e80eb4c..ecf8764 100755 --- a/test/test_api.py +++ b/test/test_api.py @@ -7,7 +7,7 @@ sys.path.extend([ ) ]) -from src.home.api import WebAPIClient +from src.home.api import WebApiClient from src.home.api.types import BotType from src.home.config import config @@ -15,5 +15,5 @@ from src.home.config import config if __name__ == '__main__': config.load_app('test_api') - api = WebAPIClient() + api = WebApiClient() print(api.log_bot_request(BotType.ADMIN, 1, "test_api.py")) diff --git a/test/test_record_upload.py b/test/test_record_upload.py index 835504f..c0daceb 100755 --- a/test/test_record_upload.py +++ b/test/test_record_upload.py @@ -10,7 +10,7 @@ sys.path.extend([ import time -from src.home.api import WebAPIClient, RequestParams +from src.home.api import WebApiClient, RequestParams from src.home.config import config from src.home.media import SoundRecordClient from src.home.util import Addr @@ -74,7 +74,7 @@ if __name__ == '__main__': finished_handler=record_finished, download_on_finish=True) - api = WebAPIClient() + api = WebApiClient() api.enable_async(error_handler=api_error_handler, success_handler=api_success_handler) diff --git a/test/test_sound_server_api.py b/test/test_sound_server_api.py index 5295a5d..77fe1ba 100755 --- a/test/test_sound_server_api.py +++ b/test/test_sound_server_api.py @@ -10,7 +10,7 @@ import threading from time import sleep from src.home.config import config -from src.home.api import WebAPIClient +from src.home.api import WebApiClient from src.home.api.types import SoundSensorLocation from typing import List, Tuple @@ -59,7 +59,7 @@ if __name__ == '__main__': config.load_app('test_api') hc = HitCounter() - api = WebAPIClient() + api = WebApiClient() hc.add('spb1', 1) # hc.add('big_house', 123) -- cgit v1.2.3 From f3b9d50496257d87757802dfb472b5ffae11962c Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sat, 10 Jun 2023 22:44:31 +0300 Subject: new config: port openwrt_log_analyzer --- src/home/telegram/telegram.py | 28 +++++++++--------- src/home/util.py | 8 ++++++ src/openwrt_log_analyzer.py | 66 +++++++++++++++++++++++-------------------- 3 files changed, 59 insertions(+), 43 deletions(-) diff --git a/src/home/telegram/telegram.py b/src/home/telegram/telegram.py index 2f94f93..f42363e 100644 --- a/src/home/telegram/telegram.py +++ b/src/home/telegram/telegram.py @@ -2,25 +2,27 @@ import requests import logging from typing import Tuple -from ..config import config - +from .config import TelegramChatsConfig +_chats = TelegramChatsConfig() _logger = logging.getLogger(__name__) def send_message(text: str, - parse_mode: str = None, - disable_web_page_preview: bool = False): - data, token = _send_telegram_data(text, parse_mode, disable_web_page_preview) + chat: str, + parse_mode: str = 'HTML', + disable_web_page_preview: bool = False,): + data, token = _send_telegram_data(text, chat, parse_mode, disable_web_page_preview) req = requests.post('https://api.telegram.org/bot%s/sendMessage' % token, data=data) return req.json() -def send_photo(filename: str): +def send_photo(filename: str, chat: str): + chat_data = _chats[chat] data = { - 'chat_id': config['telegram']['chat_id'], + 'chat_id': chat_data['id'], } - token = config['telegram']['token'] + token = chat_data['token'] url = f'https://api.telegram.org/bot{token}/sendPhoto' with open(filename, "rb") as fd: @@ -29,19 +31,19 @@ def send_photo(filename: str): def _send_telegram_data(text: str, + chat: str, parse_mode: str = None, disable_web_page_preview: bool = False) -> Tuple[dict, str]: + chat_data = _chats[chat] data = { - 'chat_id': config['telegram']['chat_id'], + 'chat_id': chat_data['id'], 'text': text } if parse_mode is not None: data['parse_mode'] = parse_mode - elif 'parse_mode' in config['telegram']: - data['parse_mode'] = config['telegram']['parse_mode'] - if disable_web_page_preview or 'disable_web_page_preview' in config['telegram']: + if disable_web_page_preview: data['disable_web_page_preview'] = 1 - return data, config['telegram']['token'] + return data, chat_data['token'] diff --git a/src/home/util.py b/src/home/util.py index 1e12243..11e7116 100644 --- a/src/home/util.py +++ b/src/home/util.py @@ -36,6 +36,14 @@ def validate_ipv4_or_hostname(address: str, raise_exception: bool = False) -> bo 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] diff --git a/src/openwrt_log_analyzer.py b/src/openwrt_log_analyzer.py index c1c4fbe..96023cd 100755 --- a/src/openwrt_log_analyzer.py +++ b/src/openwrt_log_analyzer.py @@ -1,33 +1,39 @@ #!/usr/bin/env python3 import home.telegram as telegram -from home.config import config +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 -""" -config.toml example: -[simple_state] -file = "/home/user/.config/openwrt_log_analyzer/state.txt" - -[mysql] -host = "localhost" -database = ".." -user = ".." -password = ".." - -[devices] -Device1 = "00:00:00:00:00:00" -Device2 = "01:01:01:01:01:01" - -[telegram] -chat_id = ".." -token = ".." -parse_mode = "HTML" - -[openwrt_log_analyzer] -limit = 10 -""" +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, @@ -48,18 +54,18 @@ def main(mac: str, max_id = log.id text = '\n'.join(map(lambda s: str(s), data)) - telegram.send_message(f'{title} (AP #{ap})\n\n' + text) + telegram.send_message(f'{title} (AP #{ap})\n\n' + text, config.app_config['telegram_chat']) return max_id if __name__ == '__main__': - config.load_app('openwrt_log_analyzer') - for ap in config['openwrt_log_analyzer']['aps']: - state_file = config['simple_state']['file'] - state_file = state_file.replace('.txt', f'-{ap}.txt') + 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=state_file, + state = SimpleState(name=dbname, default={'last_id': 0}) max_last_id = 0 -- cgit v1.2.3 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 From a6d8ba93056c1a4e243d56da447e241b2504fae2 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sat, 10 Jun 2023 23:20:37 +0300 Subject: move files again --- .gitignore | 12 +- .../esp32-cam/CameraWebServer/CameraWebServer.ino | 165 ++ arduino/esp32-cam/CameraWebServer/app_httpd.cpp | 1287 ++++++++++++++++ arduino/esp32-cam/CameraWebServer/camera_index.h | 1570 ++++++++++++++++++++ arduino/esp32-cam/CameraWebServer/camera_pins.h | 273 ++++ .../esp32-cam/CameraWebServer/index_ov2640.html | 1160 +++++++++++++++ bin/__py_include.py | 2 +- esp32-cam/CameraWebServer/CameraWebServer.ino | 165 -- esp32-cam/CameraWebServer/app_httpd.cpp | 1287 ---------------- esp32-cam/CameraWebServer/camera_index.h | 1570 -------------------- esp32-cam/CameraWebServer/camera_pins.h | 273 ---- esp32-cam/CameraWebServer/index_ov2640.html | 1160 --------------- include/pio/include/homekit/logging.h | 20 + include/pio/include/homekit/macros.h | 1 + include/pio/include/homekit/stopwatch.h | 30 + include/pio/include/homekit/util.h | 13 + include/pio/libs/config/homekit/config.cpp | 84 ++ include/pio/libs/config/homekit/config.h | 37 + include/pio/libs/config/library.json | 8 + .../pio/libs/http_server/homekit/http_server.cpp | 282 ++++ include/pio/libs/http_server/homekit/http_server.h | 62 + include/pio/libs/http_server/library.json | 8 + include/pio/libs/led/homekit/led.cpp | 27 + include/pio/libs/led/homekit/led.h | 33 + include/pio/libs/led/library.json | 8 + include/pio/libs/main/homekit/main.cpp | 213 +++ include/pio/libs/main/homekit/main.h | 52 + include/pio/libs/main/library.json | 12 + include/pio/libs/mqtt/homekit/mqtt/module.cpp | 26 + include/pio/libs/mqtt/homekit/mqtt/module.h | 56 + include/pio/libs/mqtt/homekit/mqtt/mqtt.cpp | 162 ++ include/pio/libs/mqtt/homekit/mqtt/mqtt.h | 48 + include/pio/libs/mqtt/homekit/mqtt/payload.h | 15 + include/pio/libs/mqtt/library.json | 7 + .../homekit/mqtt/module/diagnostics.cpp | 56 + .../homekit/mqtt/module/diagnostics.h | 49 + .../pio/libs/mqtt_module_diagnostics/library.json | 10 + .../mqtt_module_ota/homekit/mqtt/module/ota.cpp | 160 ++ .../libs/mqtt_module_ota/homekit/mqtt/module/ota.h | 75 + include/pio/libs/mqtt_module_ota/library.json | 11 + .../homekit/mqtt/module/relay.cpp | 58 + .../mqtt_module_relay/homekit/mqtt/module/relay.h | 29 + include/pio/libs/mqtt_module_relay/library.json | 11 + .../homekit/mqtt/module/temphum.cpp | 23 + .../homekit/mqtt/module/temphum.h | 28 + include/pio/libs/mqtt_module_temphum/library.json | 11 + include/pio/libs/relay/homekit/relay.cpp | 22 + include/pio/libs/relay/homekit/relay.h | 13 + include/pio/libs/relay/library.json | 8 + include/pio/libs/static/homekit/static.cpp | 450 ++++++ include/pio/libs/static/homekit/static.h | 25 + include/pio/libs/static/library.json | 8 + include/pio/libs/temphum/homekit/temphum.cpp | 89 ++ include/pio/libs/temphum/homekit/temphum.h | 38 + include/pio/libs/temphum/library.json | 8 + include/pio/libs/wifi/homekit/wifi.cpp | 47 + include/pio/libs/wifi/homekit/wifi.h | 40 + include/pio/libs/wifi/library.json | 8 + include/pio/make_static.sh | 89 ++ include/pio/static/app.js | 246 +++ include/pio/static/favicon.ico | Bin 0 -> 7886 bytes include/pio/static/index.html | 63 + include/pio/static/md5.js | 615 ++++++++ include/pio/static/style.css | 85 ++ include/py/__init__.py | 0 include/py/homekit/__init__.py | 0 include/py/homekit/api/__init__.py | 19 + include/py/homekit/api/__init__.pyi | 5 + include/py/homekit/api/config.py | 15 + include/py/homekit/api/errors/__init__.py | 1 + .../py/homekit/api/errors/api_response_error.py | 28 + include/py/homekit/api/types/__init__.py | 6 + include/py/homekit/api/types/types.py | 33 + include/py/homekit/api/web_api_client.py | 227 +++ include/py/homekit/audio/__init__.py | 0 include/py/homekit/audio/amixer.py | 91 ++ include/py/homekit/camera/__init__.py | 1 + include/py/homekit/camera/esp32.py | 226 +++ include/py/homekit/camera/types.py | 5 + include/py/homekit/camera/util.py | 107 ++ include/py/homekit/config/__init__.py | 13 + include/py/homekit/config/_configs.py | 55 + include/py/homekit/config/config.py | 387 +++++ include/py/homekit/database/__init__.py | 29 + include/py/homekit/database/__init__.pyi | 11 + include/py/homekit/database/_base.py | 9 + include/py/homekit/database/bots.py | 106 ++ include/py/homekit/database/clickhouse.py | 39 + include/py/homekit/database/inverter.py | 212 +++ .../py/homekit/database/inverter_time_formats.py | 2 + include/py/homekit/database/mysql.py | 47 + include/py/homekit/database/sensors.py | 69 + include/py/homekit/database/simple_state.py | 48 + include/py/homekit/database/sqlite.py | 67 + include/py/homekit/http/__init__.py | 2 + include/py/homekit/http/http.py | 106 ++ include/py/homekit/inverter/__init__.py | 3 + include/py/homekit/inverter/config.py | 13 + include/py/homekit/inverter/emulator.py | 556 +++++++ include/py/homekit/inverter/inverter_wrapper.py | 48 + include/py/homekit/inverter/monitor.py | 499 +++++++ include/py/homekit/inverter/types.py | 64 + include/py/homekit/inverter/util.py | 8 + include/py/homekit/media/__init__.py | 22 + include/py/homekit/media/__init__.pyi | 27 + include/py/homekit/media/node_client.py | 119 ++ include/py/homekit/media/node_server.py | 86 ++ include/py/homekit/media/record.py | 461 ++++++ include/py/homekit/media/record_client.py | 166 +++ include/py/homekit/media/storage.py | 210 +++ include/py/homekit/media/types.py | 13 + include/py/homekit/mqtt/__init__.py | 7 + include/py/homekit/mqtt/_config.py | 165 ++ include/py/homekit/mqtt/_module.py | 70 + include/py/homekit/mqtt/_mqtt.py | 86 ++ include/py/homekit/mqtt/_node.py | 92 ++ include/py/homekit/mqtt/_payload.py | 145 ++ include/py/homekit/mqtt/_util.py | 15 + include/py/homekit/mqtt/_wrapper.py | 60 + include/py/homekit/mqtt/module/diagnostics.py | 64 + include/py/homekit/mqtt/module/inverter.py | 195 +++ include/py/homekit/mqtt/module/ota.py | 77 + include/py/homekit/mqtt/module/relay.py | 92 ++ include/py/homekit/mqtt/module/temphum.py | 82 + include/py/homekit/pio/__init__.py | 1 + include/py/homekit/pio/exceptions.py | 2 + include/py/homekit/pio/products.py | 113 ++ include/py/homekit/relay/__init__.py | 16 + include/py/homekit/relay/__init__.pyi | 2 + include/py/homekit/relay/sunxi_h3_client.py | 39 + include/py/homekit/relay/sunxi_h3_server.py | 82 + include/py/homekit/soundsensor/__init__.py | 22 + include/py/homekit/soundsensor/__init__.pyi | 8 + include/py/homekit/soundsensor/node.py | 75 + include/py/homekit/soundsensor/server.py | 128 ++ include/py/homekit/soundsensor/server_client.py | 38 + include/py/homekit/telegram/__init__.py | 1 + include/py/homekit/telegram/_botcontext.py | 86 ++ include/py/homekit/telegram/_botdb.py | 32 + include/py/homekit/telegram/_botlang.py | 120 ++ include/py/homekit/telegram/_botutil.py | 47 + include/py/homekit/telegram/aio.py | 18 + include/py/homekit/telegram/bot.py | 583 ++++++++ include/py/homekit/telegram/config.py | 75 + include/py/homekit/telegram/telegram.py | 49 + include/py/homekit/temphum/__init__.py | 1 + include/py/homekit/temphum/base.py | 19 + include/py/homekit/temphum/i2c.py | 52 + include/py/homekit/util.py | 255 ++++ include/py/pyA20/__init__.pyi | 0 include/py/pyA20/gpio/connector.pyi | 2 + include/py/pyA20/gpio/gpio.pyi | 24 + include/py/pyA20/gpio/port.pyi | 36 + include/py/pyA20/port.pyi | 0 include/py/syncleo/__init__.py | 12 + include/py/syncleo/kettle.py | 243 +++ include/py/syncleo/protocol.py | 1169 +++++++++++++++ pio/dumb_mqtt/src/main.cpp | 12 + pio/relayctl/src/main.cpp | 35 + pio/temphum/src/main.cpp | 42 + pio/temphum_relayctl/src/main.cpp | 51 + platformio/common/include/homekit/logging.h | 20 - platformio/common/include/homekit/macros.h | 1 - platformio/common/include/homekit/stopwatch.h | 30 - platformio/common/include/homekit/util.h | 13 - platformio/common/libs/config/homekit/config.cpp | 84 -- platformio/common/libs/config/homekit/config.h | 37 - platformio/common/libs/config/library.json | 8 - .../libs/http_server/homekit/http_server.cpp | 282 ---- .../common/libs/http_server/homekit/http_server.h | 62 - platformio/common/libs/http_server/library.json | 8 - platformio/common/libs/led/homekit/led.cpp | 27 - platformio/common/libs/led/homekit/led.h | 33 - platformio/common/libs/led/library.json | 8 - platformio/common/libs/main/homekit/main.cpp | 213 --- platformio/common/libs/main/homekit/main.h | 52 - platformio/common/libs/main/library.json | 12 - .../common/libs/mqtt/homekit/mqtt/module.cpp | 26 - platformio/common/libs/mqtt/homekit/mqtt/module.h | 56 - platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp | 162 -- platformio/common/libs/mqtt/homekit/mqtt/mqtt.h | 48 - platformio/common/libs/mqtt/homekit/mqtt/payload.h | 15 - platformio/common/libs/mqtt/library.json | 7 - .../homekit/mqtt/module/diagnostics.cpp | 56 - .../homekit/mqtt/module/diagnostics.h | 49 - .../libs/mqtt_module_diagnostics/library.json | 10 - .../mqtt_module_ota/homekit/mqtt/module/ota.cpp | 160 -- .../libs/mqtt_module_ota/homekit/mqtt/module/ota.h | 75 - .../common/libs/mqtt_module_ota/library.json | 11 - .../homekit/mqtt/module/relay.cpp | 58 - .../mqtt_module_relay/homekit/mqtt/module/relay.h | 29 - .../common/libs/mqtt_module_relay/library.json | 11 - .../homekit/mqtt/module/temphum.cpp | 23 - .../homekit/mqtt/module/temphum.h | 28 - .../common/libs/mqtt_module_temphum/library.json | 11 - platformio/common/libs/relay/homekit/relay.cpp | 22 - platformio/common/libs/relay/homekit/relay.h | 13 - platformio/common/libs/relay/library.json | 8 - platformio/common/libs/static/homekit/static.cpp | 450 ------ platformio/common/libs/static/homekit/static.h | 25 - platformio/common/libs/static/library.json | 8 - platformio/common/libs/temphum/homekit/temphum.cpp | 89 -- platformio/common/libs/temphum/homekit/temphum.h | 38 - platformio/common/libs/temphum/library.json | 8 - platformio/common/libs/wifi/homekit/wifi.cpp | 47 - platformio/common/libs/wifi/homekit/wifi.h | 40 - platformio/common/libs/wifi/library.json | 8 - platformio/common/make_static.sh | 89 -- platformio/common/static/app.js | 246 --- platformio/common/static/favicon.ico | Bin 7886 -> 0 bytes platformio/common/static/index.html | 63 - platformio/common/static/md5.js | 615 -------- platformio/common/static/style.css | 85 -- platformio/dumb_mqtt/.gitignore | 3 - platformio/dumb_mqtt/src/main.cpp | 12 - platformio/relayctl/.gitignore | 3 - platformio/relayctl/src/main.cpp | 35 - platformio/temphum/.gitignore | 3 - platformio/temphum/src/main.cpp | 42 - platformio/temphum_relayctl/src/main.cpp | 51 - 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 --------------- test/__py_include.py | 9 + test/mqtt_relay_server_util.py | 18 +- test/mqtt_relay_util.py | 12 +- test/test.py | 1 + test/test_amixer.py | 9 +- test/test_api.py | 14 +- test/test_esp32_cam.py | 14 +- test/test_inverter_monitor.py | 19 +- test/test_ipcam_server_cleanup.py | 14 +- test/test_polaris_stuff.py | 11 +- test/test_record_upload.py | 17 +- test/test_send_fake_sound_hit.py | 10 +- test/test_sensors_plot.py | 0 test/test_sound_node_client.py | 9 +- test/test_sound_server_api.py | 14 +- test/test_stopwatch.py | 1 + test/test_telegram_aio_send_photo.py | 13 +- 330 files changed, 17000 insertions(+), 17078 deletions(-) create mode 100644 arduino/esp32-cam/CameraWebServer/CameraWebServer.ino create mode 100644 arduino/esp32-cam/CameraWebServer/app_httpd.cpp create mode 100644 arduino/esp32-cam/CameraWebServer/camera_index.h create mode 100644 arduino/esp32-cam/CameraWebServer/camera_pins.h create mode 100644 arduino/esp32-cam/CameraWebServer/index_ov2640.html delete mode 100644 esp32-cam/CameraWebServer/CameraWebServer.ino delete mode 100644 esp32-cam/CameraWebServer/app_httpd.cpp delete mode 100644 esp32-cam/CameraWebServer/camera_index.h delete mode 100644 esp32-cam/CameraWebServer/camera_pins.h delete mode 100644 esp32-cam/CameraWebServer/index_ov2640.html create mode 100644 include/pio/include/homekit/logging.h create mode 100644 include/pio/include/homekit/macros.h create mode 100644 include/pio/include/homekit/stopwatch.h create mode 100644 include/pio/include/homekit/util.h create mode 100644 include/pio/libs/config/homekit/config.cpp create mode 100644 include/pio/libs/config/homekit/config.h create mode 100644 include/pio/libs/config/library.json create mode 100644 include/pio/libs/http_server/homekit/http_server.cpp create mode 100644 include/pio/libs/http_server/homekit/http_server.h create mode 100644 include/pio/libs/http_server/library.json create mode 100644 include/pio/libs/led/homekit/led.cpp create mode 100644 include/pio/libs/led/homekit/led.h create mode 100644 include/pio/libs/led/library.json create mode 100644 include/pio/libs/main/homekit/main.cpp create mode 100644 include/pio/libs/main/homekit/main.h create mode 100644 include/pio/libs/main/library.json create mode 100644 include/pio/libs/mqtt/homekit/mqtt/module.cpp create mode 100644 include/pio/libs/mqtt/homekit/mqtt/module.h create mode 100644 include/pio/libs/mqtt/homekit/mqtt/mqtt.cpp create mode 100644 include/pio/libs/mqtt/homekit/mqtt/mqtt.h create mode 100644 include/pio/libs/mqtt/homekit/mqtt/payload.h create mode 100644 include/pio/libs/mqtt/library.json create mode 100644 include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp create mode 100644 include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h create mode 100644 include/pio/libs/mqtt_module_diagnostics/library.json create mode 100644 include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp create mode 100644 include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.h create mode 100644 include/pio/libs/mqtt_module_ota/library.json create mode 100644 include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp create mode 100644 include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.h create mode 100644 include/pio/libs/mqtt_module_relay/library.json create mode 100644 include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp create mode 100644 include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h create mode 100644 include/pio/libs/mqtt_module_temphum/library.json create mode 100644 include/pio/libs/relay/homekit/relay.cpp create mode 100644 include/pio/libs/relay/homekit/relay.h create mode 100644 include/pio/libs/relay/library.json create mode 100644 include/pio/libs/static/homekit/static.cpp create mode 100644 include/pio/libs/static/homekit/static.h create mode 100644 include/pio/libs/static/library.json create mode 100644 include/pio/libs/temphum/homekit/temphum.cpp create mode 100644 include/pio/libs/temphum/homekit/temphum.h create mode 100644 include/pio/libs/temphum/library.json create mode 100644 include/pio/libs/wifi/homekit/wifi.cpp create mode 100644 include/pio/libs/wifi/homekit/wifi.h create mode 100644 include/pio/libs/wifi/library.json create mode 100755 include/pio/make_static.sh create mode 100644 include/pio/static/app.js create mode 100644 include/pio/static/favicon.ico create mode 100644 include/pio/static/index.html create mode 100644 include/pio/static/md5.js create mode 100644 include/pio/static/style.css create mode 100644 include/py/__init__.py create mode 100644 include/py/homekit/__init__.py create mode 100644 include/py/homekit/api/__init__.py create mode 100644 include/py/homekit/api/__init__.pyi create mode 100644 include/py/homekit/api/config.py create mode 100644 include/py/homekit/api/errors/__init__.py create mode 100644 include/py/homekit/api/errors/api_response_error.py create mode 100644 include/py/homekit/api/types/__init__.py create mode 100644 include/py/homekit/api/types/types.py create mode 100644 include/py/homekit/api/web_api_client.py create mode 100644 include/py/homekit/audio/__init__.py create mode 100644 include/py/homekit/audio/amixer.py create mode 100644 include/py/homekit/camera/__init__.py create mode 100644 include/py/homekit/camera/esp32.py create mode 100644 include/py/homekit/camera/types.py create mode 100644 include/py/homekit/camera/util.py create mode 100644 include/py/homekit/config/__init__.py create mode 100644 include/py/homekit/config/_configs.py create mode 100644 include/py/homekit/config/config.py create mode 100644 include/py/homekit/database/__init__.py create mode 100644 include/py/homekit/database/__init__.pyi create mode 100644 include/py/homekit/database/_base.py create mode 100644 include/py/homekit/database/bots.py create mode 100644 include/py/homekit/database/clickhouse.py create mode 100644 include/py/homekit/database/inverter.py create mode 100644 include/py/homekit/database/inverter_time_formats.py create mode 100644 include/py/homekit/database/mysql.py create mode 100644 include/py/homekit/database/sensors.py create mode 100644 include/py/homekit/database/simple_state.py create mode 100644 include/py/homekit/database/sqlite.py create mode 100644 include/py/homekit/http/__init__.py create mode 100644 include/py/homekit/http/http.py create mode 100644 include/py/homekit/inverter/__init__.py create mode 100644 include/py/homekit/inverter/config.py create mode 100644 include/py/homekit/inverter/emulator.py create mode 100644 include/py/homekit/inverter/inverter_wrapper.py create mode 100644 include/py/homekit/inverter/monitor.py create mode 100644 include/py/homekit/inverter/types.py create mode 100644 include/py/homekit/inverter/util.py create mode 100644 include/py/homekit/media/__init__.py create mode 100644 include/py/homekit/media/__init__.pyi create mode 100644 include/py/homekit/media/node_client.py create mode 100644 include/py/homekit/media/node_server.py create mode 100644 include/py/homekit/media/record.py create mode 100644 include/py/homekit/media/record_client.py create mode 100644 include/py/homekit/media/storage.py create mode 100644 include/py/homekit/media/types.py create mode 100644 include/py/homekit/mqtt/__init__.py create mode 100644 include/py/homekit/mqtt/_config.py create mode 100644 include/py/homekit/mqtt/_module.py create mode 100644 include/py/homekit/mqtt/_mqtt.py create mode 100644 include/py/homekit/mqtt/_node.py create mode 100644 include/py/homekit/mqtt/_payload.py create mode 100644 include/py/homekit/mqtt/_util.py create mode 100644 include/py/homekit/mqtt/_wrapper.py create mode 100644 include/py/homekit/mqtt/module/diagnostics.py create mode 100644 include/py/homekit/mqtt/module/inverter.py create mode 100644 include/py/homekit/mqtt/module/ota.py create mode 100644 include/py/homekit/mqtt/module/relay.py create mode 100644 include/py/homekit/mqtt/module/temphum.py create mode 100644 include/py/homekit/pio/__init__.py create mode 100644 include/py/homekit/pio/exceptions.py create mode 100644 include/py/homekit/pio/products.py create mode 100644 include/py/homekit/relay/__init__.py create mode 100644 include/py/homekit/relay/__init__.pyi create mode 100644 include/py/homekit/relay/sunxi_h3_client.py create mode 100644 include/py/homekit/relay/sunxi_h3_server.py create mode 100644 include/py/homekit/soundsensor/__init__.py create mode 100644 include/py/homekit/soundsensor/__init__.pyi create mode 100644 include/py/homekit/soundsensor/node.py create mode 100644 include/py/homekit/soundsensor/server.py create mode 100644 include/py/homekit/soundsensor/server_client.py create mode 100644 include/py/homekit/telegram/__init__.py create mode 100644 include/py/homekit/telegram/_botcontext.py create mode 100644 include/py/homekit/telegram/_botdb.py create mode 100644 include/py/homekit/telegram/_botlang.py create mode 100644 include/py/homekit/telegram/_botutil.py create mode 100644 include/py/homekit/telegram/aio.py create mode 100644 include/py/homekit/telegram/bot.py create mode 100644 include/py/homekit/telegram/config.py create mode 100644 include/py/homekit/telegram/telegram.py create mode 100644 include/py/homekit/temphum/__init__.py create mode 100644 include/py/homekit/temphum/base.py create mode 100644 include/py/homekit/temphum/i2c.py create mode 100644 include/py/homekit/util.py create mode 100644 include/py/pyA20/__init__.pyi create mode 100644 include/py/pyA20/gpio/connector.pyi create mode 100644 include/py/pyA20/gpio/gpio.pyi create mode 100644 include/py/pyA20/gpio/port.pyi create mode 100644 include/py/pyA20/port.pyi create mode 100644 include/py/syncleo/__init__.py create mode 100644 include/py/syncleo/kettle.py create mode 100644 include/py/syncleo/protocol.py create mode 100644 pio/dumb_mqtt/src/main.cpp create mode 100644 pio/relayctl/src/main.cpp create mode 100644 pio/temphum/src/main.cpp create mode 100644 pio/temphum_relayctl/src/main.cpp delete mode 100644 platformio/common/include/homekit/logging.h delete mode 100644 platformio/common/include/homekit/macros.h delete mode 100644 platformio/common/include/homekit/stopwatch.h delete mode 100644 platformio/common/include/homekit/util.h delete mode 100644 platformio/common/libs/config/homekit/config.cpp delete mode 100644 platformio/common/libs/config/homekit/config.h delete mode 100644 platformio/common/libs/config/library.json delete mode 100644 platformio/common/libs/http_server/homekit/http_server.cpp delete mode 100644 platformio/common/libs/http_server/homekit/http_server.h delete mode 100644 platformio/common/libs/http_server/library.json delete mode 100644 platformio/common/libs/led/homekit/led.cpp delete mode 100644 platformio/common/libs/led/homekit/led.h delete mode 100644 platformio/common/libs/led/library.json delete mode 100644 platformio/common/libs/main/homekit/main.cpp delete mode 100644 platformio/common/libs/main/homekit/main.h delete mode 100644 platformio/common/libs/main/library.json delete mode 100644 platformio/common/libs/mqtt/homekit/mqtt/module.cpp delete mode 100644 platformio/common/libs/mqtt/homekit/mqtt/module.h delete mode 100644 platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp delete mode 100644 platformio/common/libs/mqtt/homekit/mqtt/mqtt.h delete mode 100644 platformio/common/libs/mqtt/homekit/mqtt/payload.h delete mode 100644 platformio/common/libs/mqtt/library.json delete mode 100644 platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp delete mode 100644 platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h delete mode 100644 platformio/common/libs/mqtt_module_diagnostics/library.json delete mode 100644 platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp delete mode 100644 platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h delete mode 100644 platformio/common/libs/mqtt_module_ota/library.json delete mode 100644 platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp delete mode 100644 platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h delete mode 100644 platformio/common/libs/mqtt_module_relay/library.json delete mode 100644 platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp delete mode 100644 platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h delete mode 100644 platformio/common/libs/mqtt_module_temphum/library.json delete mode 100644 platformio/common/libs/relay/homekit/relay.cpp delete mode 100644 platformio/common/libs/relay/homekit/relay.h delete mode 100644 platformio/common/libs/relay/library.json delete mode 100644 platformio/common/libs/static/homekit/static.cpp delete mode 100644 platformio/common/libs/static/homekit/static.h delete mode 100644 platformio/common/libs/static/library.json delete mode 100644 platformio/common/libs/temphum/homekit/temphum.cpp delete mode 100644 platformio/common/libs/temphum/homekit/temphum.h delete mode 100644 platformio/common/libs/temphum/library.json delete mode 100644 platformio/common/libs/wifi/homekit/wifi.cpp delete mode 100644 platformio/common/libs/wifi/homekit/wifi.h delete mode 100644 platformio/common/libs/wifi/library.json delete mode 100755 platformio/common/make_static.sh delete mode 100644 platformio/common/static/app.js delete mode 100644 platformio/common/static/favicon.ico delete mode 100644 platformio/common/static/index.html delete mode 100644 platformio/common/static/md5.js delete mode 100644 platformio/common/static/style.css delete mode 100644 platformio/dumb_mqtt/.gitignore delete mode 100644 platformio/dumb_mqtt/src/main.cpp delete mode 100644 platformio/relayctl/.gitignore delete mode 100644 platformio/relayctl/src/main.cpp delete mode 100644 platformio/temphum/.gitignore delete mode 100644 platformio/temphum/src/main.cpp delete mode 100644 platformio/temphum_relayctl/src/main.cpp delete mode 100644 py_include/__init__.py delete mode 100644 py_include/homekit/__init__.py delete mode 100644 py_include/homekit/api/__init__.py delete mode 100644 py_include/homekit/api/__init__.pyi delete mode 100644 py_include/homekit/api/config.py delete mode 100644 py_include/homekit/api/errors/__init__.py delete mode 100644 py_include/homekit/api/errors/api_response_error.py delete mode 100644 py_include/homekit/api/types/__init__.py delete mode 100644 py_include/homekit/api/types/types.py delete mode 100644 py_include/homekit/api/web_api_client.py delete mode 100644 py_include/homekit/audio/__init__.py delete mode 100644 py_include/homekit/audio/amixer.py delete mode 100644 py_include/homekit/camera/__init__.py delete mode 100644 py_include/homekit/camera/esp32.py delete mode 100644 py_include/homekit/camera/types.py delete mode 100644 py_include/homekit/camera/util.py delete mode 100644 py_include/homekit/config/__init__.py delete mode 100644 py_include/homekit/config/_configs.py delete mode 100644 py_include/homekit/config/config.py delete mode 100644 py_include/homekit/database/__init__.py delete mode 100644 py_include/homekit/database/__init__.pyi delete mode 100644 py_include/homekit/database/_base.py delete mode 100644 py_include/homekit/database/bots.py delete mode 100644 py_include/homekit/database/clickhouse.py delete mode 100644 py_include/homekit/database/inverter.py delete mode 100644 py_include/homekit/database/inverter_time_formats.py delete mode 100644 py_include/homekit/database/mysql.py delete mode 100644 py_include/homekit/database/sensors.py delete mode 100644 py_include/homekit/database/simple_state.py delete mode 100644 py_include/homekit/database/sqlite.py delete mode 100644 py_include/homekit/http/__init__.py delete mode 100644 py_include/homekit/http/http.py delete mode 100644 py_include/homekit/inverter/__init__.py delete mode 100644 py_include/homekit/inverter/config.py delete mode 100644 py_include/homekit/inverter/emulator.py delete mode 100644 py_include/homekit/inverter/inverter_wrapper.py delete mode 100644 py_include/homekit/inverter/monitor.py delete mode 100644 py_include/homekit/inverter/types.py delete mode 100644 py_include/homekit/inverter/util.py delete mode 100644 py_include/homekit/media/__init__.py delete mode 100644 py_include/homekit/media/__init__.pyi delete mode 100644 py_include/homekit/media/node_client.py delete mode 100644 py_include/homekit/media/node_server.py delete mode 100644 py_include/homekit/media/record.py delete mode 100644 py_include/homekit/media/record_client.py delete mode 100644 py_include/homekit/media/storage.py delete mode 100644 py_include/homekit/media/types.py delete mode 100644 py_include/homekit/mqtt/__init__.py delete mode 100644 py_include/homekit/mqtt/_config.py delete mode 100644 py_include/homekit/mqtt/_module.py delete mode 100644 py_include/homekit/mqtt/_mqtt.py delete mode 100644 py_include/homekit/mqtt/_node.py delete mode 100644 py_include/homekit/mqtt/_payload.py delete mode 100644 py_include/homekit/mqtt/_util.py delete mode 100644 py_include/homekit/mqtt/_wrapper.py delete mode 100644 py_include/homekit/mqtt/module/diagnostics.py delete mode 100644 py_include/homekit/mqtt/module/inverter.py delete mode 100644 py_include/homekit/mqtt/module/ota.py delete mode 100644 py_include/homekit/mqtt/module/relay.py delete mode 100644 py_include/homekit/mqtt/module/temphum.py delete mode 100644 py_include/homekit/pio/__init__.py delete mode 100644 py_include/homekit/pio/exceptions.py delete mode 100644 py_include/homekit/pio/products.py delete mode 100644 py_include/homekit/relay/__init__.py delete mode 100644 py_include/homekit/relay/__init__.pyi delete mode 100644 py_include/homekit/relay/sunxi_h3_client.py delete mode 100644 py_include/homekit/relay/sunxi_h3_server.py delete mode 100644 py_include/homekit/soundsensor/__init__.py delete mode 100644 py_include/homekit/soundsensor/__init__.pyi delete mode 100644 py_include/homekit/soundsensor/node.py delete mode 100644 py_include/homekit/soundsensor/server.py delete mode 100644 py_include/homekit/soundsensor/server_client.py delete mode 100644 py_include/homekit/telegram/__init__.py delete mode 100644 py_include/homekit/telegram/_botcontext.py delete mode 100644 py_include/homekit/telegram/_botdb.py delete mode 100644 py_include/homekit/telegram/_botlang.py delete mode 100644 py_include/homekit/telegram/_botutil.py delete mode 100644 py_include/homekit/telegram/aio.py delete mode 100644 py_include/homekit/telegram/bot.py delete mode 100644 py_include/homekit/telegram/config.py delete mode 100644 py_include/homekit/telegram/telegram.py delete mode 100644 py_include/homekit/temphum/__init__.py delete mode 100644 py_include/homekit/temphum/base.py delete mode 100644 py_include/homekit/temphum/i2c.py delete mode 100644 py_include/homekit/util.py delete mode 100644 py_include/pyA20/__init__.pyi delete mode 100644 py_include/pyA20/gpio/connector.pyi delete mode 100644 py_include/pyA20/gpio/gpio.pyi delete mode 100644 py_include/pyA20/gpio/port.pyi delete mode 100644 py_include/pyA20/port.pyi delete mode 100644 py_include/syncleo/__init__.py delete mode 100644 py_include/syncleo/kettle.py delete mode 100644 py_include/syncleo/protocol.py create mode 100644 test/__py_include.py delete mode 100755 test/test_sensors_plot.py diff --git a/.gitignore b/.gitignore index 1280ea2..6de5e71 100644 --- a/.gitignore +++ b/.gitignore @@ -6,19 +6,19 @@ config.def.h __pycache__ .DS_Store -/py_include/test/test_inverter_monitor.log +/include/test/test_inverter_monitor.log /youtrack-certificate /cpp -/py_include/test.py +/include/test.py /bin/test.py -/esp32-cam/CameraWebServer/wifi_password.h +/arduino/esp32-cam/CameraWebServer/wifi_password.h cmake-build-* .pio platformio.ini CMakeListsPrivate.txt -/platformio/*/CMakeLists.txt -/platformio/*/CMakeListsPrivate.txt -/platformio/*/.gitignore +/pio/*/CMakeLists.txt +/pio/*/CMakeListsPrivate.txt +/pio/*/.gitignore *.swp /localwebsite/vendor diff --git a/arduino/esp32-cam/CameraWebServer/CameraWebServer.ino b/arduino/esp32-cam/CameraWebServer/CameraWebServer.ino new file mode 100644 index 0000000..ef589d9 --- /dev/null +++ b/arduino/esp32-cam/CameraWebServer/CameraWebServer.ino @@ -0,0 +1,165 @@ +#include "esp_camera.h" +#include + +// +// WARNING!!! PSRAM IC required for UXGA resolution and high JPEG quality +// Ensure ESP32 Wrover Module or other board with PSRAM is selected +// Partial images will be transmitted if image exceeds buffer size +// +// You must select partition scheme from the board menu that has at least 3MB APP space. +// Face Recognition is DISABLED for ESP32 and ESP32-S2, because it takes up from 15 +// seconds to process single frame. Face Detection is ENABLED if PSRAM is enabled as well + +// =================== +// Select camera model +// =================== +//#define CAMERA_MODEL_WROVER_KIT // Has PSRAM +//#define CAMERA_MODEL_ESP_EYE // Has PSRAM +//#define CAMERA_MODEL_ESP32S3_EYE // Has PSRAM +//#define CAMERA_MODEL_M5STACK_PSRAM // Has PSRAM +//#define CAMERA_MODEL_M5STACK_V2_PSRAM // M5Camera version B Has PSRAM +//#define CAMERA_MODEL_M5STACK_WIDE // Has PSRAM +//#define CAMERA_MODEL_M5STACK_ESP32CAM // No PSRAM +//#define CAMERA_MODEL_M5STACK_UNITCAM // No PSRAM +#define CAMERA_MODEL_AI_THINKER // Has PSRAM +//#define CAMERA_MODEL_TTGO_T_JOURNAL // No PSRAM +// ** Espressif Internal Boards ** +//#define CAMERA_MODEL_ESP32_CAM_BOARD +//#define CAMERA_MODEL_ESP32S2_CAM_BOARD +//#define CAMERA_MODEL_ESP32S3_CAM_LCD + +#include "camera_pins.h" + +// =========================== +// Enter your WiFi credentials +// =========================== +#include "wifi_password.h" + +volatile float disconnected_since = 0; + +void startCameraServer(); + +void onWiFiDisconnect(WiFiEvent_t event, WiFiEventInfo_t info) { + disconnected_since = millis(); + WiFi.reconnect(); +} + +void onWiFiConnect(WiFiEvent_t event, WiFiEventInfo_t info) { + disconnected_since = 0; +} + +void setup() { + Serial.begin(115200); + //Serial.setDebugOutput(true); + Serial.println(); + + camera_config_t config; + config.ledc_channel = LEDC_CHANNEL_0; + config.ledc_timer = LEDC_TIMER_0; + config.pin_d0 = Y2_GPIO_NUM; + config.pin_d1 = Y3_GPIO_NUM; + config.pin_d2 = Y4_GPIO_NUM; + config.pin_d3 = Y5_GPIO_NUM; + config.pin_d4 = Y6_GPIO_NUM; + config.pin_d5 = Y7_GPIO_NUM; + config.pin_d6 = Y8_GPIO_NUM; + config.pin_d7 = Y9_GPIO_NUM; + config.pin_xclk = XCLK_GPIO_NUM; + config.pin_pclk = PCLK_GPIO_NUM; + config.pin_vsync = VSYNC_GPIO_NUM; + config.pin_href = HREF_GPIO_NUM; + config.pin_sscb_sda = SIOD_GPIO_NUM; + config.pin_sscb_scl = SIOC_GPIO_NUM; + config.pin_pwdn = PWDN_GPIO_NUM; + config.pin_reset = RESET_GPIO_NUM; + config.xclk_freq_hz = 20000000; + config.frame_size = FRAMESIZE_UXGA; + config.pixel_format = PIXFORMAT_JPEG; // for streaming + //config.pixel_format = PIXFORMAT_RGB565; // for face detection/recognition + config.grab_mode = CAMERA_GRAB_WHEN_EMPTY; + config.fb_location = CAMERA_FB_IN_PSRAM; + config.jpeg_quality = 12; + config.fb_count = 1; + + // if PSRAM IC present, init with UXGA resolution and higher JPEG quality + // for larger pre-allocated frame buffer. + if(config.pixel_format == PIXFORMAT_JPEG){ + if(psramFound()){ + config.jpeg_quality = 10; + config.fb_count = 2; + config.grab_mode = CAMERA_GRAB_LATEST; + } else { + // Limit the frame size when PSRAM is not available + config.frame_size = FRAMESIZE_SVGA; + config.fb_location = CAMERA_FB_IN_DRAM; + } + } else { + // Best option for face detection/recognition + config.frame_size = FRAMESIZE_240X240; +#if CONFIG_IDF_TARGET_ESP32S3 + config.fb_count = 2; +#endif + } + +#if defined(CAMERA_MODEL_ESP_EYE) + pinMode(13, INPUT_PULLUP); + pinMode(14, INPUT_PULLUP); +#endif + + // camera init + esp_err_t err = esp_camera_init(&config); + if (err != ESP_OK) { + Serial.printf("Camera init failed with error 0x%x", err); + return; + } + + sensor_t * s = esp_camera_sensor_get(); + // initial sensors are flipped vertically and colors are a bit saturated + if (s->id.PID == OV3660_PID) { + s->set_vflip(s, 1); // flip it back + s->set_brightness(s, 1); // up the brightness just a bit + s->set_saturation(s, -2); // lower the saturation + } + // drop down frame size for higher initial frame rate + if(config.pixel_format == PIXFORMAT_JPEG){ + s->set_framesize(s, FRAMESIZE_QVGA); + } + +#if defined(CAMERA_MODEL_M5STACK_WIDE) || defined(CAMERA_MODEL_M5STACK_ESP32CAM) + s->set_vflip(s, 1); + s->set_hmirror(s, 1); +#endif + +#if defined(CAMERA_MODEL_ESP32S3_EYE) + s->set_vflip(s, 1); +#endif + + WiFi.onEvent(onWiFiDisconnect, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + WiFi.onEvent(onWiFiConnect, ARDUINO_EVENT_WIFI_STA_CONNECTED); + + WiFi.begin(ssid, password); + WiFi.setSleep(false); + + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.println("WiFi connected"); + + startCameraServer(); + + Serial.print("Camera Ready! Use 'http://"); + Serial.print(WiFi.localIP()); + Serial.println("' to connect"); +} + +void loop() { + if (disconnected_since != 0 && (millis() - disconnected_since) > 60000) { + ESP.restart(); + return; + } + + // Do nothing. Everything is done in another task by the web server + delay(10000); +} diff --git a/arduino/esp32-cam/CameraWebServer/app_httpd.cpp b/arduino/esp32-cam/CameraWebServer/app_httpd.cpp new file mode 100644 index 0000000..e397c70 --- /dev/null +++ b/arduino/esp32-cam/CameraWebServer/app_httpd.cpp @@ -0,0 +1,1287 @@ +// Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +#include + +#include "esp_http_server.h" +#include "esp_timer.h" +#include "esp_camera.h" +#include "img_converters.h" +#include "fb_gfx.h" +#include "driver/ledc.h" +#include "sdkconfig.h" +#include "camera_index.h" + +#if defined(ARDUINO_ARCH_ESP32) && defined(CONFIG_ARDUHAL_ESP_LOG) +#include "esp32-hal-log.h" +#define TAG "" +#else +#include "esp_log.h" +static const char *TAG = "camera_httpd"; +#endif + +// Face Detection will not work on boards without (or with disabled) PSRAM +#ifdef BOARD_HAS_PSRAM +#define CONFIG_ESP_FACE_DETECT_ENABLED 1 +// Face Recognition takes upward from 15 seconds per frame on chips other than ESP32S3 +// Makes no sense to have it enabled for them +#if CONFIG_IDF_TARGET_ESP32S3 +#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 1 +#else +#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 0 +#endif +#else +#define CONFIG_ESP_FACE_DETECT_ENABLED 0 +#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 0 +#endif + +#if CONFIG_ESP_FACE_DETECT_ENABLED + +#include +#include "human_face_detect_msr01.hpp" +#include "human_face_detect_mnp01.hpp" + +#define TWO_STAGE 1 /* very large firmware, very slow, reboots when streaming... + +#define FACE_ID_SAVE_NUMBER 7 +#endif + +#define FACE_COLOR_WHITE 0x00FFFFFF +#define FACE_COLOR_BLACK 0x00000000 +#define FACE_COLOR_RED 0x000000FF +#define FACE_COLOR_GREEN 0x0000FF00 +#define FACE_COLOR_BLUE 0x00FF0000 +#define FACE_COLOR_YELLOW (FACE_COLOR_RED | FACE_COLOR_GREEN) +#define FACE_COLOR_CYAN (FACE_COLOR_BLUE | FACE_COLOR_GREEN) +#define FACE_COLOR_PURPLE (FACE_COLOR_BLUE | FACE_COLOR_RED) +#endif + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED +int led_duty = 0; +bool isStreaming = false; +#ifdef CONFIG_LED_LEDC_LOW_SPEED_MODE +#define CONFIG_LED_LEDC_SPEED_MODE LEDC_LOW_SPEED_MODE +#else +#define CONFIG_LED_LEDC_SPEED_MODE LEDC_HIGH_SPEED_MODE +#endif +#endif + +typedef struct +{ + httpd_req_t *req; + size_t len; +} jpg_chunking_t; + +#define PART_BOUNDARY "123456789000000000000987654321" +static const char *_STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY; +static const char *_STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n"; +static const char *_STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\nX-Timestamp: %d.%06d\r\n\r\n"; + +httpd_handle_t stream_httpd = NULL; +httpd_handle_t camera_httpd = NULL; + +#if CONFIG_ESP_FACE_DETECT_ENABLED + +static int8_t detection_enabled = 0; + +// #if TWO_STAGE +// static HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); +// static HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); +// #else +// static HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); +// #endif + +static int8_t flash_enabled = 0; + +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED +static int8_t recognition_enabled = 0; +static int8_t is_enrolling = 0; + +#if QUANT_TYPE + // S16 model + FaceRecognition112V1S16 recognizer; +#else + // S8 model + FaceRecognition112V1S8 recognizer; +#endif +#endif + +#endif + +typedef struct +{ + size_t size; //number of values used for filtering + size_t index; //current value index + size_t count; //value count + int sum; + int *values; //array to be filled with values +} ra_filter_t; + +static ra_filter_t ra_filter; + +static ra_filter_t *ra_filter_init(ra_filter_t *filter, size_t sample_size) +{ + memset(filter, 0, sizeof(ra_filter_t)); + + filter->values = (int *)malloc(sample_size * sizeof(int)); + if (!filter->values) + { + return NULL; + } + memset(filter->values, 0, sample_size * sizeof(int)); + + filter->size = sample_size; + return filter; +} + +static int ra_filter_run(ra_filter_t *filter, int value) +{ + if (!filter->values) + { + return value; + } + filter->sum -= filter->values[filter->index]; + filter->values[filter->index] = value; + filter->sum += filter->values[filter->index]; + filter->index++; + filter->index = filter->index % filter->size; + if (filter->count < filter->size) + { + filter->count++; + } + return filter->sum / filter->count; +} + +#if CONFIG_ESP_FACE_DETECT_ENABLED +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED +static void rgb_print(fb_data_t *fb, uint32_t color, const char *str) +{ + fb_gfx_print(fb, (fb->width - (strlen(str) * 14)) / 2, 10, color, str); +} + +static int rgb_printf(fb_data_t *fb, uint32_t color, const char *format, ...) +{ + char loc_buf[64]; + char *temp = loc_buf; + int len; + va_list arg; + va_list copy; + va_start(arg, format); + va_copy(copy, arg); + len = vsnprintf(loc_buf, sizeof(loc_buf), format, arg); + va_end(copy); + if (len >= sizeof(loc_buf)) + { + temp = (char *)malloc(len + 1); + if (temp == NULL) + { + return 0; + } + } + vsnprintf(temp, len + 1, format, arg); + va_end(arg); + rgb_print(fb, color, temp); + if (len > 64) + { + free(temp); + } + return len; +} +#endif +static void draw_face_boxes(fb_data_t *fb, std::list *results, int face_id) +{ + int x, y, w, h; + uint32_t color = FACE_COLOR_YELLOW; + if (face_id < 0) + { + color = FACE_COLOR_RED; + } + else if (face_id > 0) + { + color = FACE_COLOR_GREEN; + } + if(fb->bytes_per_pixel == 2){ + //color = ((color >> 8) & 0xF800) | ((color >> 3) & 0x07E0) | (color & 0x001F); + color = ((color >> 16) & 0x001F) | ((color >> 3) & 0x07E0) | ((color << 8) & 0xF800); + } + int i = 0; + for (std::list::iterator prediction = results->begin(); prediction != results->end(); prediction++, i++) + { + // rectangle box + x = (int)prediction->box[0]; + y = (int)prediction->box[1]; + w = (int)prediction->box[2] - x + 1; + h = (int)prediction->box[3] - y + 1; + if((x + w) > fb->width){ + w = fb->width - x; + } + if((y + h) > fb->height){ + h = fb->height - y; + } + fb_gfx_drawFastHLine(fb, x, y, w, color); + fb_gfx_drawFastHLine(fb, x, y + h - 1, w, color); + fb_gfx_drawFastVLine(fb, x, y, h, color); + fb_gfx_drawFastVLine(fb, x + w - 1, y, h, color); +#if TWO_STAGE + // landmarks (left eye, mouth left, nose, right eye, mouth right) + int x0, y0, j; + for (j = 0; j < 10; j+=2) { + x0 = (int)prediction->keypoint[j]; + y0 = (int)prediction->keypoint[j+1]; + fb_gfx_fillRect(fb, x0, y0, 3, 3, color); + } +#endif + } +} + +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED +static int run_face_recognition(fb_data_t *fb, std::list *results) +{ + std::vector landmarks = results->front().keypoint; + int id = -1; + + Tensor tensor; + tensor.set_element((uint8_t *)fb->data).set_shape({fb->height, fb->width, 3}).set_auto_free(false); + + int enrolled_count = recognizer.get_enrolled_id_num(); + + if (enrolled_count < FACE_ID_SAVE_NUMBER && is_enrolling){ + id = recognizer.enroll_id(tensor, landmarks, "", true); + ESP_LOGI(TAG, "Enrolled ID: %d", id); + rgb_printf(fb, FACE_COLOR_CYAN, "ID[%u]", id); + } + + face_info_t recognize = recognizer.recognize(tensor, landmarks); + if(recognize.id >= 0){ + rgb_printf(fb, FACE_COLOR_GREEN, "ID[%u]: %.2f", recognize.id, recognize.similarity); + } else { + rgb_print(fb, FACE_COLOR_RED, "Intruder Alert!"); + } + return recognize.id; +} +#endif +#endif + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED +void enable_led(bool en) +{ // Turn LED On or Off + int duty = en ? led_duty : 0; + if (en && isStreaming && (led_duty > CONFIG_LED_MAX_INTENSITY)) + { + duty = CONFIG_LED_MAX_INTENSITY; + } + ledc_set_duty(CONFIG_LED_LEDC_SPEED_MODE, CONFIG_LED_LEDC_CHANNEL, duty); + ledc_update_duty(CONFIG_LED_LEDC_SPEED_MODE, CONFIG_LED_LEDC_CHANNEL); + ESP_LOGI(TAG, "Set LED intensity to %d", duty); +} +#endif + +static esp_err_t bmp_handler(httpd_req_t *req) +{ + camera_fb_t *fb = NULL; + esp_err_t res = ESP_OK; + uint64_t fr_start = esp_timer_get_time(); + fb = esp_camera_fb_get(); + if (!fb) + { + ESP_LOGE(TAG, "Camera capture failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "image/x-windows-bmp"); + httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=capture.bmp"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + + char ts[32]; + snprintf(ts, 32, "%ld.%06ld", fb->timestamp.tv_sec, fb->timestamp.tv_usec); + httpd_resp_set_hdr(req, "X-Timestamp", (const char *)ts); + + + uint8_t * buf = NULL; + size_t buf_len = 0; + bool converted = frame2bmp(fb, &buf, &buf_len); + esp_camera_fb_return(fb); + if(!converted){ + ESP_LOGE(TAG, "BMP Conversion failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + res = httpd_resp_send(req, (const char *)buf, buf_len); + free(buf); + uint64_t fr_end = esp_timer_get_time(); + ESP_LOGI(TAG, "BMP: %llums, %uB", (uint64_t)((fr_end - fr_start) / 1000), buf_len); + return res; +} + +static size_t jpg_encode_stream(void *arg, size_t index, const void *data, size_t len) +{ + jpg_chunking_t *j = (jpg_chunking_t *)arg; + if (!index) + { + j->len = 0; + } + if (httpd_resp_send_chunk(j->req, (const char *)data, len) != ESP_OK) + { + return 0; + } + j->len += len; + return len; +} + +static esp_err_t capture_handler(httpd_req_t *req) +{ + camera_fb_t *fb = NULL; + esp_err_t res = ESP_OK; + int64_t fr_start = esp_timer_get_time(); + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + enable_led(true); + vTaskDelay(150 / portTICK_PERIOD_MS); // The LED needs to be turned on ~150ms before the call to esp_camera_fb_get() + fb = esp_camera_fb_get(); // or it won't be visible in the frame. A better way to do this is needed. + enable_led(false); +#else + fb = esp_camera_fb_get(); +#endif + + if (!fb) + { + ESP_LOGE(TAG, "Camera capture failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "image/jpeg"); + httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=capture.jpg"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + + char ts[32]; + snprintf(ts, 32, "%ld.%06ld", fb->timestamp.tv_sec, fb->timestamp.tv_usec); + httpd_resp_set_hdr(req, "X-Timestamp", (const char *)ts); + +#if CONFIG_ESP_FACE_DETECT_ENABLED + size_t out_len, out_width, out_height; + uint8_t *out_buf; + bool s; + bool detected = false; + int face_id = 0; + if (!detection_enabled || fb->width > 400) + { +#endif + size_t fb_len = 0; + if (fb->format == PIXFORMAT_JPEG) + { + fb_len = fb->len; + res = httpd_resp_send(req, (const char *)fb->buf, fb->len); + } + else + { + jpg_chunking_t jchunk = {req, 0}; + res = frame2jpg_cb(fb, 80, jpg_encode_stream, &jchunk) ? ESP_OK : ESP_FAIL; + httpd_resp_send_chunk(req, NULL, 0); + fb_len = jchunk.len; + } + esp_camera_fb_return(fb); + int64_t fr_end = esp_timer_get_time(); + ESP_LOGI(TAG, "JPG: %uB %ums", (uint32_t)(fb_len), (uint32_t)((fr_end - fr_start) / 1000)); + return res; +#if CONFIG_ESP_FACE_DETECT_ENABLED + } + + jpg_chunking_t jchunk = {req, 0}; + + if (fb->format == PIXFORMAT_RGB565 +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + && !recognition_enabled +#endif + ){ +#if TWO_STAGE + HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); + HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); + std::list &candidates = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); + std::list &results = s2.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}, candidates); +#else + HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); + std::list &results = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); +#endif + if (results.size() > 0) { + fb_data_t rfb; + rfb.width = fb->width; + rfb.height = fb->height; + rfb.data = fb->buf; + rfb.bytes_per_pixel = 2; + rfb.format = FB_RGB565; + detected = true; + draw_face_boxes(&rfb, &results, face_id); + } + s = fmt2jpg_cb(fb->buf, fb->len, fb->width, fb->height, PIXFORMAT_RGB565, 90, jpg_encode_stream, &jchunk); + esp_camera_fb_return(fb); + } else + { + out_len = fb->width * fb->height * 3; + out_width = fb->width; + out_height = fb->height; + out_buf = (uint8_t*)malloc(out_len); + if (!out_buf) { + ESP_LOGE(TAG, "out_buf malloc failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + s = fmt2rgb888(fb->buf, fb->len, fb->format, out_buf); + esp_camera_fb_return(fb); + if (!s) { + free(out_buf); + ESP_LOGE(TAG, "to rgb888 failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + + fb_data_t rfb; + rfb.width = out_width; + rfb.height = out_height; + rfb.data = out_buf; + rfb.bytes_per_pixel = 3; + rfb.format = FB_BGR888; + +#if TWO_STAGE + HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); + HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); + std::list &candidates = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); + std::list &results = s2.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}, candidates); +#else + HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); + std::list &results = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); +#endif + + if (results.size() > 0) { + detected = true; +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + if (recognition_enabled) { + face_id = run_face_recognition(&rfb, &results); + } +#endif + draw_face_boxes(&rfb, &results, face_id); + } + + s = fmt2jpg_cb(out_buf, out_len, out_width, out_height, PIXFORMAT_RGB888, 90, jpg_encode_stream, &jchunk); + free(out_buf); + } + + if (!s) { + ESP_LOGE(TAG, "JPEG compression failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + + int64_t fr_end = esp_timer_get_time(); + ESP_LOGI(TAG, "FACE: %uB %ums %s%d", (uint32_t)(jchunk.len), (uint32_t)((fr_end - fr_start) / 1000), detected ? "DETECTED " : "", face_id); + return res; +#endif +} + +static esp_err_t stream_handler(httpd_req_t *req) +{ + camera_fb_t *fb = NULL; + struct timeval _timestamp; + esp_err_t res = ESP_OK; + size_t _jpg_buf_len = 0; + uint8_t *_jpg_buf = NULL; + char *part_buf[128]; +#if CONFIG_ESP_FACE_DETECT_ENABLED + bool detected = false; + int face_id = 0; + int64_t fr_start = 0; + int64_t fr_ready = 0; + int64_t fr_face = 0; + int64_t fr_recognize = 0; + int64_t fr_encode = 0; + + size_t out_len = 0, out_width = 0, out_height = 0; + uint8_t *out_buf = NULL; + bool s = false; +#if TWO_STAGE + HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); + HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); +#else + HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); +#endif +#endif + + static int64_t last_frame = 0; + if (!last_frame) + { + last_frame = esp_timer_get_time(); + } + + res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE); + if (res != ESP_OK) + { + return res; + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_set_hdr(req, "X-Framerate", "60"); + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + enable_led(true); + isStreaming = true; +#endif + + while (true) + { +#if CONFIG_ESP_FACE_DETECT_ENABLED + detected = false; + face_id = 0; +#endif + + fb = esp_camera_fb_get(); + if (!fb) + { + ESP_LOGE(TAG, "Camera capture failed"); + res = ESP_FAIL; + } + else + { + _timestamp.tv_sec = fb->timestamp.tv_sec; + _timestamp.tv_usec = fb->timestamp.tv_usec; +#if CONFIG_ESP_FACE_DETECT_ENABLED + fr_start = esp_timer_get_time(); + fr_ready = fr_start; + fr_face = fr_start; + fr_encode = fr_start; + fr_recognize = fr_start; + if (!detection_enabled || fb->width > 400) + { +#endif + if (fb->format != PIXFORMAT_JPEG) + { + bool jpeg_converted = frame2jpg(fb, 80, &_jpg_buf, &_jpg_buf_len); + esp_camera_fb_return(fb); + fb = NULL; + if (!jpeg_converted) + { + ESP_LOGE(TAG, "JPEG compression failed"); + res = ESP_FAIL; + } + } + else + { + _jpg_buf_len = fb->len; + _jpg_buf = fb->buf; + } +#if CONFIG_ESP_FACE_DETECT_ENABLED + } + else + { + if (fb->format == PIXFORMAT_RGB565 +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + && !recognition_enabled +#endif + ){ + fr_ready = esp_timer_get_time(); +#if TWO_STAGE + std::list &candidates = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); + std::list &results = s2.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}, candidates); +#else + std::list &results = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); +#endif + fr_face = esp_timer_get_time(); + fr_recognize = fr_face; + if (results.size() > 0) { + fb_data_t rfb; + rfb.width = fb->width; + rfb.height = fb->height; + rfb.data = fb->buf; + rfb.bytes_per_pixel = 2; + rfb.format = FB_RGB565; + detected = true; + draw_face_boxes(&rfb, &results, face_id); + } + s = fmt2jpg(fb->buf, fb->len, fb->width, fb->height, PIXFORMAT_RGB565, 80, &_jpg_buf, &_jpg_buf_len); + esp_camera_fb_return(fb); + fb = NULL; + if (!s) { + ESP_LOGE(TAG, "fmt2jpg failed"); + res = ESP_FAIL; + } + fr_encode = esp_timer_get_time(); + } else + { + out_len = fb->width * fb->height * 3; + out_width = fb->width; + out_height = fb->height; + out_buf = (uint8_t*)malloc(out_len); + if (!out_buf) { + ESP_LOGE(TAG, "out_buf malloc failed"); + res = ESP_FAIL; + } else { + s = fmt2rgb888(fb->buf, fb->len, fb->format, out_buf); + esp_camera_fb_return(fb); + fb = NULL; + if (!s) { + free(out_buf); + ESP_LOGE(TAG, "to rgb888 failed"); + res = ESP_FAIL; + } else { + fr_ready = esp_timer_get_time(); + + fb_data_t rfb; + rfb.width = out_width; + rfb.height = out_height; + rfb.data = out_buf; + rfb.bytes_per_pixel = 3; + rfb.format = FB_BGR888; + +#if TWO_STAGE + std::list &candidates = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); + std::list &results = s2.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}, candidates); +#else + std::list &results = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); +#endif + + fr_face = esp_timer_get_time(); + fr_recognize = fr_face; + + if (results.size() > 0) { + detected = true; +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + if (recognition_enabled) { + face_id = run_face_recognition(&rfb, &results); + fr_recognize = esp_timer_get_time(); + } +#endif + draw_face_boxes(&rfb, &results, face_id); + } + s = fmt2jpg(out_buf, out_len, out_width, out_height, PIXFORMAT_RGB888, 90, &_jpg_buf, &_jpg_buf_len); + free(out_buf); + if (!s) { + ESP_LOGE(TAG, "fmt2jpg failed"); + res = ESP_FAIL; + } + fr_encode = esp_timer_get_time(); + } + } + } + } +#endif + } + if (res == ESP_OK) + { + res = httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY)); + } + if (res == ESP_OK) + { + size_t hlen = snprintf((char *)part_buf, 128, _STREAM_PART, _jpg_buf_len, _timestamp.tv_sec, _timestamp.tv_usec); + res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen); + } + if (res == ESP_OK) + { + res = httpd_resp_send_chunk(req, (const char *)_jpg_buf, _jpg_buf_len); + } + if (fb) + { + esp_camera_fb_return(fb); + fb = NULL; + _jpg_buf = NULL; + } + else if (_jpg_buf) + { + free(_jpg_buf); + _jpg_buf = NULL; + } + if (res != ESP_OK) + { + ESP_LOGE(TAG, "send frame failed failed"); + break; + } + int64_t fr_end = esp_timer_get_time(); + +#if CONFIG_ESP_FACE_DETECT_ENABLED + int64_t ready_time = (fr_ready - fr_start) / 1000; + int64_t face_time = (fr_face - fr_ready) / 1000; + int64_t recognize_time = (fr_recognize - fr_face) / 1000; + int64_t encode_time = (fr_encode - fr_recognize) / 1000; + int64_t process_time = (fr_encode - fr_start) / 1000; +#endif + + int64_t frame_time = fr_end - last_frame; + last_frame = fr_end; + frame_time /= 1000; + uint32_t avg_frame_time = ra_filter_run(&ra_filter, frame_time); + ESP_LOGI(TAG, "MJPG: %uB %ums (%.1ffps), AVG: %ums (%.1ffps)" +#if CONFIG_ESP_FACE_DETECT_ENABLED + ", %u+%u+%u+%u=%u %s%d" +#endif + , + (uint32_t)(_jpg_buf_len), + (uint32_t)frame_time, 1000.0 / (uint32_t)frame_time, + avg_frame_time, 1000.0 / avg_frame_time +#if CONFIG_ESP_FACE_DETECT_ENABLED + , + (uint32_t)ready_time, (uint32_t)face_time, (uint32_t)recognize_time, (uint32_t)encode_time, (uint32_t)process_time, + (detected) ? "DETECTED " : "", face_id +#endif + ); + } + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + isStreaming = false; + enable_led(false); +#endif + + last_frame = 0; + return res; +} + +static esp_err_t parse_get(httpd_req_t *req, char **obuf) +{ + char *buf = NULL; + size_t buf_len = 0; + + buf_len = httpd_req_get_url_query_len(req) + 1; + if (buf_len > 1) { + buf = (char *)malloc(buf_len); + if (!buf) { + httpd_resp_send_500(req); + return ESP_FAIL; + } + if (httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) { + *obuf = buf; + return ESP_OK; + } + free(buf); + } + httpd_resp_send_404(req); + return ESP_FAIL; +} + +static esp_err_t cmd_handler(httpd_req_t *req) +{ + char *buf = NULL; + char variable[32]; + char value[32]; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + if (httpd_query_key_value(buf, "var", variable, sizeof(variable)) != ESP_OK || + httpd_query_key_value(buf, "val", value, sizeof(value)) != ESP_OK) { + free(buf); + httpd_resp_send_404(req); + return ESP_FAIL; + } + free(buf); + + int val = atoi(value); + ESP_LOGI(TAG, "%s = %d", variable, val); + sensor_t *s = esp_camera_sensor_get(); + int res = 0; + + if (!strcmp(variable, "framesize")) { + if (s->pixformat == PIXFORMAT_JPEG) { + res = s->set_framesize(s, (framesize_t)val); + } + } + else if (!strcmp(variable, "quality")) + res = s->set_quality(s, val); + else if (!strcmp(variable, "contrast")) + res = s->set_contrast(s, val); + else if (!strcmp(variable, "brightness")) + res = s->set_brightness(s, val); + else if (!strcmp(variable, "saturation")) + res = s->set_saturation(s, val); + else if (!strcmp(variable, "gainceiling")) + res = s->set_gainceiling(s, (gainceiling_t)val); + else if (!strcmp(variable, "colorbar")) + res = s->set_colorbar(s, val); + else if (!strcmp(variable, "awb")) + res = s->set_whitebal(s, val); + else if (!strcmp(variable, "agc")) + res = s->set_gain_ctrl(s, val); + else if (!strcmp(variable, "aec")) + res = s->set_exposure_ctrl(s, val); + else if (!strcmp(variable, "hmirror")) + res = s->set_hmirror(s, val); + else if (!strcmp(variable, "vflip")) + res = s->set_vflip(s, val); + else if (!strcmp(variable, "awb_gain")) + res = s->set_awb_gain(s, val); + else if (!strcmp(variable, "agc_gain")) + res = s->set_agc_gain(s, val); + else if (!strcmp(variable, "aec_value")) + res = s->set_aec_value(s, val); + else if (!strcmp(variable, "aec2")) + res = s->set_aec2(s, val); + else if (!strcmp(variable, "dcw")) + res = s->set_dcw(s, val); + else if (!strcmp(variable, "bpc")) + res = s->set_bpc(s, val); + else if (!strcmp(variable, "wpc")) + res = s->set_wpc(s, val); + else if (!strcmp(variable, "raw_gma")) + res = s->set_raw_gma(s, val); + else if (!strcmp(variable, "lenc")) + res = s->set_lenc(s, val); + else if (!strcmp(variable, "special_effect")) + res = s->set_special_effect(s, val); + else if (!strcmp(variable, "wb_mode")) + res = s->set_wb_mode(s, val); + else if (!strcmp(variable, "ae_level")) + res = s->set_ae_level(s, val); +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + else if (!strcmp(variable, "led_intensity")) { + led_duty = val; + if (isStreaming) + enable_led(true); + } +#endif + +#if CONFIG_ESP_FACE_DETECT_ENABLED + else if (!strcmp(variable, "face_detect")) { + detection_enabled = val; +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + if (!detection_enabled) { + recognition_enabled = 0; + } +#endif + } +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + else if (!strcmp(variable, "face_enroll")){ + is_enrolling = !is_enrolling; + ESP_LOGI(TAG, "Enrolling: %s", is_enrolling?"true":"false"); + } + else if (!strcmp(variable, "face_recognize")) { + recognition_enabled = val; + if (recognition_enabled) { + detection_enabled = val; + } + } +#endif +#endif + else if(!strcmp(variable, "flash") ) { + pinMode(4, OUTPUT); + digitalWrite(4, atoi(value)); + flash_enabled = atoi(value); + } + else { + ESP_LOGI(TAG, "Unknown command: %s", variable); + res = -1; + } + + if (res < 0) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static int print_reg(char * p, sensor_t * s, uint16_t reg, uint32_t mask){ + return sprintf(p, "\"0x%x\":%u,", reg, s->get_reg(s, reg, mask)); +} + +static esp_err_t status_handler(httpd_req_t *req) +{ + static char json_response[1024]; + + sensor_t *s = esp_camera_sensor_get(); + char *p = json_response; + *p++ = '{'; + + if(s->id.PID == OV5640_PID || s->id.PID == OV3660_PID){ + for(int reg = 0x3400; reg < 0x3406; reg+=2){ + p+=print_reg(p, s, reg, 0xFFF);//12 bit + } + p+=print_reg(p, s, 0x3406, 0xFF); + + p+=print_reg(p, s, 0x3500, 0xFFFF0);//16 bit + p+=print_reg(p, s, 0x3503, 0xFF); + p+=print_reg(p, s, 0x350a, 0x3FF);//10 bit + p+=print_reg(p, s, 0x350c, 0xFFFF);//16 bit + + for(int reg = 0x5480; reg <= 0x5490; reg++){ + p+=print_reg(p, s, reg, 0xFF); + } + + for(int reg = 0x5380; reg <= 0x538b; reg++){ + p+=print_reg(p, s, reg, 0xFF); + } + + for(int reg = 0x5580; reg < 0x558a; reg++){ + p+=print_reg(p, s, reg, 0xFF); + } + p+=print_reg(p, s, 0x558a, 0x1FF);//9 bit + } else if(s->id.PID == OV2640_PID){ + p+=print_reg(p, s, 0xd3, 0xFF); + p+=print_reg(p, s, 0x111, 0xFF); + p+=print_reg(p, s, 0x132, 0xFF); + } + + p += sprintf(p, "\"xclk\":%u,", s->xclk_freq_hz / 1000000); + p += sprintf(p, "\"pixformat\":%u,", s->pixformat); + p += sprintf(p, "\"framesize\":%u,", s->status.framesize); + p += sprintf(p, "\"quality\":%u,", s->status.quality); + p += sprintf(p, "\"brightness\":%d,", s->status.brightness); + p += sprintf(p, "\"contrast\":%d,", s->status.contrast); + p += sprintf(p, "\"saturation\":%d,", s->status.saturation); + p += sprintf(p, "\"sharpness\":%d,", s->status.sharpness); + p += sprintf(p, "\"special_effect\":%u,", s->status.special_effect); + p += sprintf(p, "\"wb_mode\":%u,", s->status.wb_mode); + p += sprintf(p, "\"awb\":%u,", s->status.awb); + p += sprintf(p, "\"awb_gain\":%u,", s->status.awb_gain); + p += sprintf(p, "\"aec\":%u,", s->status.aec); + p += sprintf(p, "\"aec2\":%u,", s->status.aec2); + p += sprintf(p, "\"ae_level\":%d,", s->status.ae_level); + p += sprintf(p, "\"aec_value\":%u,", s->status.aec_value); + p += sprintf(p, "\"agc\":%u,", s->status.agc); + p += sprintf(p, "\"agc_gain\":%u,", s->status.agc_gain); + p += sprintf(p, "\"gainceiling\":%u,", s->status.gainceiling); + p += sprintf(p, "\"bpc\":%u,", s->status.bpc); + p += sprintf(p, "\"wpc\":%u,", s->status.wpc); + p += sprintf(p, "\"raw_gma\":%u,", s->status.raw_gma); + p += sprintf(p, "\"lenc\":%u,", s->status.lenc); + p += sprintf(p, "\"hmirror\":%u,", s->status.hmirror); + p += sprintf(p, "\"vflip\":%u,", s->status.vflip); + p += sprintf(p, "\"dcw\":%u,", s->status.dcw); + p += sprintf(p, "\"colorbar\":%u", s->status.colorbar); +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + p += sprintf(p, ",\"led_intensity\":%u", led_duty); +#else + p += sprintf(p, ",\"led_intensity\":%d", -1); +#endif +#if CONFIG_ESP_FACE_DETECT_ENABLED + p += sprintf(p, ",\"face_detect\":%u", detection_enabled); +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + p += sprintf(p, ",\"face_enroll\":%u,", is_enrolling); + p += sprintf(p, ",\"face_recognize\":%u", recognition_enabled); +#endif +#endif + p += sprintf(p, ",\"flash\":%u", flash_enabled); + *p++ = '}'; + *p++ = 0; + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, json_response, strlen(json_response)); +} + +static esp_err_t xclk_handler(httpd_req_t *req) +{ + char *buf = NULL; + char _xclk[32]; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + if (httpd_query_key_value(buf, "xclk", _xclk, sizeof(_xclk)) != ESP_OK) { + free(buf); + httpd_resp_send_404(req); + return ESP_FAIL; + } + free(buf); + + int xclk = atoi(_xclk); + ESP_LOGI(TAG, "Set XCLK: %d MHz", xclk); + + sensor_t *s = esp_camera_sensor_get(); + int res = s->set_xclk(s, LEDC_TIMER_0, xclk); + if (res) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t reg_handler(httpd_req_t *req) +{ + char *buf = NULL; + char _reg[32]; + char _mask[32]; + char _val[32]; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + if (httpd_query_key_value(buf, "reg", _reg, sizeof(_reg)) != ESP_OK || + httpd_query_key_value(buf, "mask", _mask, sizeof(_mask)) != ESP_OK || + httpd_query_key_value(buf, "val", _val, sizeof(_val)) != ESP_OK) { + free(buf); + httpd_resp_send_404(req); + return ESP_FAIL; + } + free(buf); + + int reg = atoi(_reg); + int mask = atoi(_mask); + int val = atoi(_val); + ESP_LOGI(TAG, "Set Register: reg: 0x%02x, mask: 0x%02x, value: 0x%02x", reg, mask, val); + + sensor_t *s = esp_camera_sensor_get(); + int res = s->set_reg(s, reg, mask, val); + if (res) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t greg_handler(httpd_req_t *req) +{ + char *buf = NULL; + char _reg[32]; + char _mask[32]; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + if (httpd_query_key_value(buf, "reg", _reg, sizeof(_reg)) != ESP_OK || + httpd_query_key_value(buf, "mask", _mask, sizeof(_mask)) != ESP_OK) { + free(buf); + httpd_resp_send_404(req); + return ESP_FAIL; + } + free(buf); + + int reg = atoi(_reg); + int mask = atoi(_mask); + sensor_t *s = esp_camera_sensor_get(); + int res = s->get_reg(s, reg, mask); + if (res < 0) { + return httpd_resp_send_500(req); + } + ESP_LOGI(TAG, "Get Register: reg: 0x%02x, mask: 0x%02x, value: 0x%02x", reg, mask, res); + + char buffer[20]; + const char * val = itoa(res, buffer, 10); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, val, strlen(val)); +} + +static int parse_get_var(char *buf, const char * key, int def) +{ + char _int[16]; + if(httpd_query_key_value(buf, key, _int, sizeof(_int)) != ESP_OK){ + return def; + } + return atoi(_int); +} + +static esp_err_t pll_handler(httpd_req_t *req) +{ + char *buf = NULL; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + + int bypass = parse_get_var(buf, "bypass", 0); + int mul = parse_get_var(buf, "mul", 0); + int sys = parse_get_var(buf, "sys", 0); + int root = parse_get_var(buf, "root", 0); + int pre = parse_get_var(buf, "pre", 0); + int seld5 = parse_get_var(buf, "seld5", 0); + int pclken = parse_get_var(buf, "pclken", 0); + int pclk = parse_get_var(buf, "pclk", 0); + free(buf); + + ESP_LOGI(TAG, "Set Pll: bypass: %d, mul: %d, sys: %d, root: %d, pre: %d, seld5: %d, pclken: %d, pclk: %d", bypass, mul, sys, root, pre, seld5, pclken, pclk); + sensor_t *s = esp_camera_sensor_get(); + int res = s->set_pll(s, bypass, mul, sys, root, pre, seld5, pclken, pclk); + if (res) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t win_handler(httpd_req_t *req) +{ + char *buf = NULL; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + + int startX = parse_get_var(buf, "sx", 0); + int startY = parse_get_var(buf, "sy", 0); + int endX = parse_get_var(buf, "ex", 0); + int endY = parse_get_var(buf, "ey", 0); + int offsetX = parse_get_var(buf, "offx", 0); + int offsetY = parse_get_var(buf, "offy", 0); + int totalX = parse_get_var(buf, "tx", 0); + int totalY = parse_get_var(buf, "ty", 0); + int outputX = parse_get_var(buf, "ox", 0); + int outputY = parse_get_var(buf, "oy", 0); + bool scale = parse_get_var(buf, "scale", 0) == 1; + bool binning = parse_get_var(buf, "binning", 0) == 1; + free(buf); + + ESP_LOGI(TAG, "Set Window: Start: %d %d, End: %d %d, Offset: %d %d, Total: %d %d, Output: %d %d, Scale: %u, Binning: %u", startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); + sensor_t *s = esp_camera_sensor_get(); + int res = s->set_res_raw(s, startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); + if (res) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t uptime_handler(httpd_req_t *req) +{ + char buf[64]; + sprintf(buf, "{\"seconds\":%ld}", (long)(millis()/1000)); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, buf, strlen(buf)); +} + +static esp_err_t index_handler(httpd_req_t *req) +{ + httpd_resp_set_type(req, "text/html"); + httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + sensor_t *s = esp_camera_sensor_get(); + if (s != NULL) { + if (s->id.PID == OV3660_PID) { + return httpd_resp_send(req, (const char *)index_ov3660_html_gz, index_ov3660_html_gz_len); + } else if (s->id.PID == OV5640_PID) { + return httpd_resp_send(req, (const char *)index_ov5640_html_gz, index_ov5640_html_gz_len); + } else { + return httpd_resp_send(req, (const char *)index_ov2640_html_gz, index_ov2640_html_gz_len); + } + } else { + ESP_LOGE(TAG, "Camera sensor not found"); + return httpd_resp_send_500(req); + } +} + +void startCameraServer() +{ + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.max_uri_handlers = 16; + + httpd_uri_t index_uri = { + .uri = "/", + .method = HTTP_GET, + .handler = index_handler, + .user_ctx = NULL}; + + httpd_uri_t status_uri = { + .uri = "/status", + .method = HTTP_GET, + .handler = status_handler, + .user_ctx = NULL}; + + httpd_uri_t cmd_uri = { + .uri = "/control", + .method = HTTP_GET, + .handler = cmd_handler, + .user_ctx = NULL}; + + httpd_uri_t capture_uri = { + .uri = "/capture", + .method = HTTP_GET, + .handler = capture_handler, + .user_ctx = NULL}; + + httpd_uri_t stream_uri = { + .uri = "/stream", + .method = HTTP_GET, + .handler = stream_handler, + .user_ctx = NULL}; + + httpd_uri_t bmp_uri = { + .uri = "/bmp", + .method = HTTP_GET, + .handler = bmp_handler, + .user_ctx = NULL}; + + httpd_uri_t xclk_uri = { + .uri = "/xclk", + .method = HTTP_GET, + .handler = xclk_handler, + .user_ctx = NULL}; + + httpd_uri_t reg_uri = { + .uri = "/reg", + .method = HTTP_GET, + .handler = reg_handler, + .user_ctx = NULL}; + + httpd_uri_t greg_uri = { + .uri = "/greg", + .method = HTTP_GET, + .handler = greg_handler, + .user_ctx = NULL}; + + httpd_uri_t pll_uri = { + .uri = "/pll", + .method = HTTP_GET, + .handler = pll_handler, + .user_ctx = NULL}; + + httpd_uri_t win_uri = { + .uri = "/resolution", + .method = HTTP_GET, + .handler = win_handler, + .user_ctx = NULL}; + + httpd_uri_t uptime_uri = { + .uri = "/uptime", + .method = HTTP_GET, + .handler = uptime_handler, + .user_ctx = NULL}; + + ra_filter_init(&ra_filter, 20); + +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + recognizer.set_partition(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "fr"); + + // load ids from flash partition + recognizer.set_ids_from_flash(); +#endif + ESP_LOGI(TAG, "Starting web server on port: '%d'", config.server_port); + if (httpd_start(&camera_httpd, &config) == ESP_OK) + { + httpd_register_uri_handler(camera_httpd, &index_uri); + httpd_register_uri_handler(camera_httpd, &cmd_uri); + httpd_register_uri_handler(camera_httpd, &status_uri); + httpd_register_uri_handler(camera_httpd, &capture_uri); + httpd_register_uri_handler(camera_httpd, &bmp_uri); + + httpd_register_uri_handler(camera_httpd, &xclk_uri); + httpd_register_uri_handler(camera_httpd, ®_uri); + httpd_register_uri_handler(camera_httpd, &greg_uri); + httpd_register_uri_handler(camera_httpd, &pll_uri); + httpd_register_uri_handler(camera_httpd, &win_uri); + httpd_register_uri_handler(camera_httpd, &uptime_uri); + } + + config.server_port += 1; + config.ctrl_port += 1; + ESP_LOGI(TAG, "Starting stream server on port: '%d'", config.server_port); + if (httpd_start(&stream_httpd, &config) == ESP_OK) + { + httpd_register_uri_handler(stream_httpd, &stream_uri); + } +} diff --git a/arduino/esp32-cam/CameraWebServer/camera_index.h b/arduino/esp32-cam/CameraWebServer/camera_index.h new file mode 100644 index 0000000..5ca12e9 --- /dev/null +++ b/arduino/esp32-cam/CameraWebServer/camera_index.h @@ -0,0 +1,1570 @@ + + +//File: index_ov2640.html.gz, Size: 6787 +#define index_ov2640_html_gz_len 6787 +const uint8_t index_ov2640_html_gz[] = { +0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xED, 0x3D, 0x6B, 0x73, 0xDB, 0x46, +0x92, 0xDF, 0xFD, 0x2B, 0x60, 0x24, 0x6B, 0x92, 0x25, 0x92, 0x22, 0x29, 0x4A, 0x96, 0x15, 0x89, +0x3E, 0x5B, 0x96, 0x1F, 0xB5, 0x76, 0xE2, 0xB5, 0x12, 0xC7, 0x5B, 0xA9, 0x2D, 0x07, 0x24, 0x86, +0x24, 0x62, 0x10, 0xE0, 0x02, 0xA0, 0x48, 0x26, 0xA5, 0xDF, 0x71, 0x3F, 0xE8, 0xFE, 0xD8, 0x75, +0xCF, 0x03, 0x18, 0x00, 0x83, 0x17, 0x29, 0x91, 0x5E, 0xDF, 0xD1, 0xA9, 0x08, 0x8F, 0xE9, 0x9E, +0x7E, 0x77, 0xCF, 0x0C, 0x06, 0x38, 0x7F, 0x68, 0xBA, 0xA3, 0x60, 0x3D, 0x27, 0xDA, 0x34, 0x98, +0xD9, 0x83, 0x07, 0xE7, 0xEC, 0x8F, 0x06, 0xBF, 0xF3, 0x29, 0x31, 0x4C, 0x76, 0x48, 0x4F, 0x67, +0x24, 0x30, 0xB4, 0xD1, 0xD4, 0xF0, 0x7C, 0x12, 0x5C, 0xE8, 0x8B, 0x60, 0xDC, 0x3A, 0xD5, 0x93, +0xB7, 0x1D, 0x63, 0x46, 0x2E, 0xF4, 0x1B, 0x8B, 0x2C, 0xE7, 0xAE, 0x17, 0xE8, 0xDA, 0xC8, 0x75, +0x02, 0xE2, 0x40, 0xF3, 0xA5, 0x65, 0x06, 0xD3, 0x0B, 0x93, 0xDC, 0x58, 0x23, 0xD2, 0xA2, 0x27, +0x4D, 0xCB, 0xB1, 0x02, 0xCB, 0xB0, 0x5B, 0xFE, 0xC8, 0xB0, 0xC9, 0x45, 0x57, 0xC6, 0x15, 0x58, +0x81, 0x4D, 0x06, 0x57, 0xD7, 0xEF, 0x8F, 0x7A, 0xDA, 0x4F, 0x1F, 0x7B, 0xFD, 0x93, 0xCE, 0xF9, +0x21, 0xBB, 0x16, 0xB5, 0xF1, 0x83, 0xB5, 0x7C, 0x8E, 0xBF, 0xA1, 0x6B, 0xAE, 0xB5, 0xBF, 0x62, +0x97, 0xF0, 0x37, 0x06, 0x22, 0x5A, 0x63, 0x63, 0x66, 0xD9, 0xEB, 0x33, 0xED, 0x99, 0x07, 0x7D, +0x36, 0x5F, 0x13, 0xFB, 0x86, 0x04, 0xD6, 0xC8, 0x68, 0xFA, 0x86, 0xE3, 0xB7, 0x7C, 0xE2, 0x59, +0xE3, 0x1F, 0x52, 0x80, 0x43, 0x63, 0xF4, 0x65, 0xE2, 0xB9, 0x0B, 0xC7, 0x3C, 0xD3, 0xBE, 0xEB, +0x9E, 0xE2, 0xBF, 0x74, 0xA3, 0x91, 0x6B, 0xBB, 0x1E, 0xDC, 0xBF, 0x7A, 0x89, 0xFF, 0xD2, 0xF7, +0x69, 0xEF, 0xBE, 0xF5, 0x27, 0x39, 0xD3, 0xBA, 0x27, 0xF3, 0x55, 0xEC, 0xFE, 0xED, 0x83, 0xD8, +0xE9, 0xB4, 0x97, 0x45, 0x3D, 0x87, 0x3F, 0xCD, 0x87, 0xF7, 0xC9, 0x28, 0xB0, 0x5C, 0xA7, 0x3D, +0x33, 0x2C, 0x47, 0x81, 0xC9, 0xB4, 0xFC, 0xB9, 0x6D, 0x80, 0x0C, 0xC6, 0x36, 0xC9, 0xC5, 0xF3, +0xDD, 0x8C, 0x38, 0x8B, 0x66, 0x01, 0x36, 0x44, 0xD2, 0x32, 0x2D, 0x8F, 0xB5, 0x3A, 0x43, 0x39, +0x2C, 0x66, 0x4E, 0x21, 0xDA, 0x3C, 0xBA, 0x1C, 0xD7, 0x21, 0x0A, 0x01, 0x62, 0x47, 0x4B, 0xCF, +0x98, 0x63, 0x03, 0xFC, 0x9B, 0x6E, 0x32, 0xB3, 0x1C, 0x66, 0x54, 0x67, 0xDA, 0x51, 0xBF, 0x33, +0x5F, 0x15, 0xA8, 0xF2, 0xE8, 0x04, 0xFF, 0xA5, 0x1B, 0xCD, 0x0D, 0xD3, 0xB4, 0x9C, 0xC9, 0x99, +0x76, 0xAA, 0x44, 0xE1, 0x7A, 0x26, 0xF1, 0x5A, 0x9E, 0x61, 0x5A, 0x0B, 0xFF, 0x4C, 0xEB, 0xAB, +0xDA, 0xCC, 0x0C, 0x6F, 0x02, 0xB4, 0x04, 0x2E, 0x10, 0xDB, 0xEA, 0x2A, 0x29, 0xE1, 0x4D, 0x3C, +0x6B, 0x32, 0x0D, 0x40, 0xA5, 0xA9, 0x36, 0x49, 0xA1, 0x71, 0x17, 0x2A, 0xD2, 0x67, 0xAE, 0xDC, +0xD4, 0x52, 0x33, 0x6C, 0x6B, 0xE2, 0xB4, 0xAC, 0x80, 0xCC, 0x80, 0x1D, 0x3F, 0xF0, 0x48, 0x30, +0x9A, 0xE6, 0x91, 0x32, 0xB6, 0x26, 0x0B, 0x8F, 0x28, 0x08, 0x09, 0xE5, 0x96, 0xC3, 0x30, 0xDC, +0x4C, 0xDF, 0x6A, 0x2D, 0xC9, 0xF0, 0x8B, 0x15, 0xB4, 0xB8, 0x4C, 0x86, 0x64, 0xEC, 0x7A, 0x44, +0xD9, 0x52, 0xB4, 0xB0, 0xDD, 0xD1, 0x97, 0x96, 0x1F, 0x18, 0x5E, 0x50, 0x06, 0xA1, 0x31, 0x0E, +0x88, 0x57, 0x8C, 0x8F, 0xA0, 0x55, 0x14, 0x63, 0xCB, 0xEE, 0x96, 0x37, 0xB0, 0x1C, 0xDB, 0x72, +0x48, 0x79, 0xF2, 0xB2, 0xFA, 0x8D, 0xA3, 0x63, 0xAD, 0x4A, 0x28, 0xC6, 0x9A, 0x4D, 0xF2, 0xAC, +0x84, 0xF2, 0x9A, 0xEE, 0x8C, 0xFB, 0x4D, 0xB7, 0xD3, 0xF9, 0x5B, 0xFA, 0xE6, 0x94, 0x30, 0x33, +0x35, 0x16, 0x81, 0xBB, 0xBD, 0x47, 0xA4, 0xDC, 0x2A, 0xC1, 0xC7, 0x7F, 0xCD, 0x88, 0x69, 0x19, +0x5A, 0x5D, 0x72, 0xE7, 0xD3, 0x0E, 0xD8, 0x54, 0x43, 0x33, 0x1C, 0x53, 0xAB, 0xBB, 0x9E, 0x05, +0x8E, 0x60, 0xD0, 0x70, 0x63, 0xC3, 0x15, 0x48, 0x1C, 0x73, 0xD2, 0x50, 0xB0, 0x9C, 0xE3, 0x33, +0xB2, 0x44, 0xD4, 0x6E, 0x83, 0xBF, 0x12, 0x21, 0x07, 0x7F, 0x85, 0x0E, 0xA4, 0xE0, 0x91, 0xA2, +0xCF, 0xD3, 0x97, 0x4C, 0x61, 0x96, 0xCE, 0xF0, 0x37, 0x33, 0x56, 0xAD, 0x5C, 0xDD, 0x89, 0x46, +0x42, 0x87, 0x90, 0x66, 0x47, 0x75, 0x68, 0x7A, 0x33, 0xD5, 0x5A, 0x1A, 0x46, 0xC9, 0x86, 0x1A, +0x86, 0x23, 0x55, 0xAB, 0x1C, 0x7F, 0xB2, 0x51, 0x54, 0x60, 0x57, 0xCD, 0x6A, 0x14, 0x3B, 0xD8, +0x3F, 0x95, 0x0D, 0x31, 0x4E, 0x32, 0xA3, 0x08, 0xFE, 0xCA, 0x47, 0x92, 0x08, 0x59, 0x61, 0x34, +0x51, 0x20, 0xCE, 0x8E, 0x28, 0x29, 0xBC, 0x59, 0xDE, 0xAD, 0xC0, 0x9A, 0x4F, 0x42, 0xD9, 0xE8, +0xA2, 0x40, 0x9C, 0x47, 0x43, 0x61, 0x94, 0xC1, 0xDF, 0x6D, 0x89, 0x7A, 0xE3, 0xBB, 0xE1, 0x22, +0x08, 0x5C, 0xC7, 0xDF, 0x2A, 0x45, 0x65, 0xF9, 0xD9, 0x1F, 0x0B, 0x3F, 0xB0, 0xC6, 0xEB, 0x16, +0x77, 0x69, 0xF0, 0xB3, 0xB9, 0x01, 0x25, 0xE4, 0x90, 0x04, 0x4B, 0x42, 0xF2, 0xCB, 0x0D, 0xC7, +0xB8, 0x81, 0xB8, 0x33, 0x99, 0xD8, 0x2A, 0xDB, 0x1B, 0x2D, 0x3C, 0x1F, 0xEB, 0xB6, 0xB9, 0x6B, +0x01, 0x62, 0x2F, 0xDD, 0x71, 0xDC, 0x07, 0x4B, 0x76, 0xD4, 0x1A, 0x0D, 0x15, 0x7D, 0xB9, 0x8B, +0x00, 0x65, 0xAC, 0xD4, 0x84, 0x0B, 0xEC, 0x58, 0xC1, 0x5A, 0x79, 0x8F, 0x7B, 0xA2, 0xE2, 0x8E, +0x70, 0xC1, 0xDC, 0xB4, 0x10, 0xA7, 0xEB, 0x6C, 0x34, 0x25, 0xA3, 0x2F, 0xC4, 0x3C, 0x28, 0x2C, +0xC3, 0x8A, 0xCA, 0xC3, 0xB6, 0xE5, 0xCC, 0x17, 0x41, 0x0B, 0xCB, 0xA9, 0xF9, 0xBD, 0xE8, 0x9C, +0x1A, 0xA4, 0x60, 0xB1, 0xD7, 0xCB, 0x2B, 0x2A, 0x8E, 0xE7, 0xAB, 0x7C, 0x21, 0xC8, 0xC4, 0x0E, +0x6C, 0x63, 0x48, 0xEC, 0x3C, 0x92, 0xB9, 0x33, 0x64, 0x84, 0x5D, 0x1E, 0xAB, 0xB2, 0x6B, 0x37, +0x4A, 0x59, 0x94, 0xBC, 0xFA, 0x8F, 0xFF, 0x56, 0x5A, 0x8E, 0xF4, 0xB8, 0x19, 0xBB, 0xE4, 0x13, +0x1B, 0x1C, 0x2C, 0xAB, 0xF4, 0x86, 0x36, 0x4B, 0xA0, 0x21, 0xB7, 0x03, 0xCF, 0x70, 0x26, 0x04, +0x62, 0xC1, 0xAA, 0x29, 0x0E, 0xF3, 0x07, 0x06, 0xA5, 0xD8, 0xC7, 0x50, 0x7D, 0x9C, 0x3F, 0x10, +0x61, 0x01, 0xA1, 0xA9, 0xB5, 0xD9, 0xC1, 0x06, 0x55, 0x89, 0xA4, 0xDF, 0x5C, 0x42, 0xBA, 0x4A, +0xEB, 0x60, 0x85, 0x89, 0xD2, 0x73, 0xE2, 0xB6, 0xA5, 0x2C, 0xF4, 0x0B, 0x43, 0x83, 0x18, 0xF2, +0x8D, 0xC7, 0x45, 0x83, 0xC6, 0xF1, 0xF8, 0xA8, 0x73, 0xD4, 0x2F, 0xAC, 0x9C, 0x94, 0x5C, 0x26, +0x06, 0x8E, 0x8A, 0xD0, 0x11, 0x86, 0x95, 0x5C, 0x23, 0xF0, 0x8D, 0x1B, 0x65, 0xD1, 0xEE, 0xFA, +0x16, 0x1B, 0xB9, 0x19, 0x43, 0x1F, 0xC6, 0x6E, 0x81, 0x62, 0xE8, 0xC5, 0x0D, 0xBD, 0xA7, 0xA4, +0x8F, 0x96, 0x74, 0x4A, 0x17, 0x10, 0xE2, 0x55, 0x93, 0x1D, 0xD3, 0x80, 0xBA, 0x89, 0xA4, 0x60, +0x65, 0x51, 0x19, 0x90, 0x55, 0xD0, 0x32, 0xC9, 0xC8, 0xF5, 0x58, 0x35, 0x98, 0x31, 0x72, 0x4C, +0x28, 0xB2, 0xD8, 0x62, 0xCF, 0xA6, 0xEE, 0x0D, 0xF1, 0x14, 0xC2, 0x4A, 0x28, 0xB5, 0xFF, 0xA4, +0x6F, 0x96, 0xC0, 0x66, 0x40, 0x7A, 0x54, 0xCA, 0x3E, 0x8E, 0xAE, 0xD7, 0x1D, 0xF5, 0x72, 0xFD, +0x98, 0xA1, 0x6B, 0x83, 0xCF, 0x18, 0x43, 0x9B, 0x98, 0x39, 0xD9, 0xCC, 0x24, 0x63, 0x63, 0x61, +0x07, 0x05, 0x56, 0x69, 0x74, 0xF0, 0x5F, 0x5E, 0x8F, 0x34, 0x0C, 0xFD, 0x86, 0xF3, 0x42, 0x17, +0x34, 0x70, 0xFC, 0x4B, 0xD1, 0xA7, 0x28, 0x35, 0x8C, 0xF9, 0x9C, 0x18, 0xD0, 0x6A, 0x44, 0xB2, +0xF4, 0x50, 0x6A, 0x88, 0xA1, 0x8E, 0xF3, 0xA5, 0xC6, 0xED, 0x85, 0x0E, 0x1B, 0x16, 0x8F, 0x95, +0x78, 0x3E, 0x1B, 0xBB, 0xA3, 0x85, 0xAA, 0xAA, 0x29, 0xE7, 0x78, 0x69, 0x7C, 0x67, 0x42, 0x64, +0xBE, 0x6D, 0x51, 0xF7, 0x5F, 0x38, 0x0E, 0x6A, 0xB4, 0x15, 0x78, 0xC0, 0xA6, 0xA2, 0xA3, 0x72, +0x82, 0xDB, 0x28, 0x86, 0xC5, 0x04, 0x9B, 0x35, 0x77, 0x95, 0x08, 0x53, 0x8A, 0x70, 0x1A, 0x46, +0x5A, 0x0D, 0x62, 0x88, 0x65, 0x0A, 0x54, 0xDB, 0xC9, 0x25, 0x98, 0x2E, 0x66, 0xAA, 0x3A, 0x4A, +0x74, 0xD6, 0x85, 0xA4, 0xCF, 0xBA, 0xF3, 0x26, 0x43, 0xA3, 0xDE, 0x69, 0x76, 0x9A, 0x47, 0xF0, +0x3F, 0xC5, 0x78, 0x26, 0xDF, 0xB8, 0xB8, 0x78, 0x33, 0x2C, 0x2F, 0x11, 0xA2, 0x8B, 0xA7, 0x95, +0xB2, 0x82, 0x7D, 0xA1, 0x2E, 0xCA, 0x7B, 0x52, 0x7C, 0x7E, 0xA9, 0xDB, 0x2E, 0xC8, 0xC3, 0x19, +0x26, 0x5D, 0xDD, 0x10, 0x15, 0xD6, 0x52, 0x55, 0xC5, 0x33, 0xF7, 0xCF, 0x16, 0x2B, 0x42, 0xFE, +0xCF, 0x5B, 0xBB, 0x24, 0x8A, 0x6F, 0xDA, 0xD2, 0x2B, 0xCB, 0xC5, 0xDF, 0xB7, 0x6D, 0x74, 0xB2, +0xB5, 0xDE, 0xE2, 0x55, 0x1F, 0x50, 0xE8, 0xC0, 0x18, 0xD4, 0x83, 0xC1, 0x68, 0x66, 0x65, 0x28, +0xB5, 0xD9, 0x40, 0x06, 0x63, 0xCB, 0xB6, 0x5B, 0xB6, 0xBB, 0x2C, 0xAE, 0x44, 0xF2, 0x2D, 0x39, +0x65, 0xA7, 0xC5, 0x26, 0xBF, 0x29, 0xB5, 0x0B, 0x88, 0x5C, 0xFF, 0x11, 0xD4, 0x7E, 0xDB, 0x0E, +0x97, 0xEB, 0x1A, 0x9B, 0x25, 0x8A, 0x0D, 0xEC, 0x71, 0xBB, 0x8E, 0x4A, 0x99, 0x12, 0xAB, 0x04, +0xF3, 0x87, 0x3D, 0x4B, 0x2B, 0x18, 0x4D, 0x37, 0x18, 0x7A, 0x46, 0x03, 0x23, 0x8F, 0xD8, 0x06, +0x56, 0xF0, 0x1B, 0xCD, 0x50, 0x14, 0x0E, 0xDF, 0x64, 0xF0, 0x32, 0x9C, 0x50, 0xD1, 0x7D, 0x3D, +0xB3, 0x4B, 0x6D, 0x56, 0x3B, 0x64, 0xC7, 0x6A, 0xB5, 0x59, 0x17, 0x94, 0xFB, 0x71, 0xCF, 0x50, +0x37, 0xAA, 0x10, 0xD1, 0x45, 0xD0, 0x9E, 0x78, 0x64, 0x5D, 0x82, 0x99, 0x26, 0xFF, 0x7B, 0xC6, +0xE6, 0x8F, 0x37, 0x9F, 0x2A, 0xA1, 0x09, 0x80, 0x5B, 0x51, 0xBB, 0xEF, 0x97, 0xE8, 0x3A, 0xBB, +0xCB, 0x32, 0xF6, 0x18, 0xCE, 0x8E, 0xEA, 0x7A, 0x89, 0x70, 0x93, 0x93, 0x42, 0xD5, 0xA6, 0x2A, +0xB2, 0xAF, 0x7A, 0x3C, 0x4F, 0xC6, 0x41, 0xC6, 0xE2, 0x0F, 0xAD, 0x53, 0x8F, 0xF2, 0xA3, 0x5B, +0x4B, 0x9A, 0x4D, 0x29, 0x8C, 0x1C, 0xE1, 0x24, 0x66, 0xB6, 0xF5, 0x29, 0x31, 0x63, 0xF4, 0xAC, +0x8C, 0x3C, 0x5B, 0x25, 0xA2, 0x7C, 0xA6, 0x6A, 0x86, 0x36, 0x33, 0x9E, 0xF2, 0x41, 0x3D, 0xE4, +0x53, 0xBD, 0x77, 0xA2, 0x5C, 0x5B, 0xC9, 0x69, 0x9C, 0x47, 0x5A, 0xE6, 0x2C, 0x60, 0x3A, 0x65, +0x65, 0x0E, 0x90, 0xE5, 0x58, 0xA4, 0x54, 0x54, 0xBE, 0x57, 0xE6, 0x45, 0x98, 0xF4, 0x4C, 0x56, +0xAE, 0xB1, 0x5B, 0x33, 0x03, 0xCA, 0x5E, 0x34, 0x57, 0x03, 0x30, 0xAA, 0xF4, 0x57, 0xC6, 0xDC, +0xA5, 0x39, 0xD6, 0xEE, 0x49, 0xA7, 0xA0, 0xCB, 0x91, 0xED, 0xFA, 0x5B, 0x4E, 0x80, 0x65, 0xCF, +0x7F, 0x29, 0xEF, 0x94, 0x4A, 0xDD, 0xB9, 0x3E, 0x95, 0xEF, 0x8E, 0x09, 0x99, 0x77, 0x3B, 0xCA, +0x48, 0x9B, 0x3B, 0x4B, 0x49, 0x67, 0xD0, 0xE8, 0xFA, 0xE5, 0x99, 0x36, 0x22, 0xEA, 0x30, 0x1A, +0x9F, 0xA8, 0x2B, 0x33, 0x55, 0x9A, 0xAB, 0x87, 0xA9, 0x65, 0x9A, 0x24, 0x77, 0x2E, 0x18, 0xC7, +0xBC, 0x25, 0x8B, 0x07, 0xA4, 0x5F, 0x35, 0x29, 0x75, 0x2F, 0x4E, 0x91, 0xFB, 0x58, 0x43, 0xF7, +0xBE, 0x3D, 0x86, 0x27, 0x9A, 0xAC, 0x99, 0xF4, 0x78, 0x29, 0x92, 0x4B, 0xAA, 0xD2, 0xB9, 0xC3, +0xB9, 0x56, 0x14, 0x19, 0xC8, 0x01, 0x5B, 0xA5, 0xA3, 0x79, 0x82, 0x2A, 0xBA, 0x90, 0xD2, 0xE6, +0x6B, 0x4B, 0x7C, 0x19, 0xB0, 0x95, 0xB5, 0xBA, 0x72, 0x87, 0x4B, 0x6D, 0xD4, 0x02, 0x92, 0xFD, +0x66, 0x8A, 0x66, 0x4F, 0x95, 0x51, 0x0E, 0x91, 0x61, 0x8A, 0x11, 0x0F, 0x57, 0xC5, 0x5B, 0x6D, +0xEA, 0x1C, 0xE7, 0x87, 0xD2, 0xD3, 0x70, 0xE7, 0x87, 0xD1, 0x83, 0x7B, 0xE7, 0xF8, 0x48, 0x9C, +0xFC, 0xD0, 0x1C, 0xEF, 0x67, 0x64, 0x1B, 0xBE, 0x7F, 0xA1, 0xE3, 0xA3, 0x5D, 0x7A, 0xFC, 0x19, +0xBA, 0x73, 0xD3, 0xBA, 0xD1, 0x2C, 0xF3, 0x42, 0xB7, 0xDD, 0x89, 0x9B, 0xB8, 0x47, 0xEF, 0x33, +0x2D, 0x43, 0x1E, 0xBB, 0xD0, 0x63, 0xEB, 0x8B, 0x3A, 0x85, 0x8A, 0x2E, 0xE9, 0x83, 0x47, 0xDF, +0x3D, 0x79, 0xFC, 0xF8, 0xE4, 0x87, 0x47, 0xCE, 0xD0, 0x9F, 0xF3, 0xFF, 0xFF, 0xCC, 0x96, 0x63, +0x7F, 0xFA, 0xD8, 0x3B, 0xE9, 0xC3, 0x70, 0x8F, 0x04, 0x01, 0x98, 0x9E, 0x7F, 0x7E, 0x48, 0x91, +0x26, 0x08, 0x39, 0x04, 0x4A, 0x32, 0x68, 0xE3, 0xE5, 0x8E, 0x8A, 0x3C, 0xD1, 0xC4, 0x87, 0x0C, +0x3E, 0x34, 0x3C, 0x45, 0x13, 0xDA, 0x8C, 0x15, 0xD3, 0x34, 0x94, 0xE8, 0x54, 0x27, 0x43, 0x77, +0x95, 0xE4, 0x80, 0x32, 0xC5, 0x15, 0xC6, 0x5B, 0x11, 0x33, 0x0B, 0x21, 0x80, 0x51, 0x70, 0x5C, +0x5C, 0x85, 0x36, 0xCA, 0x46, 0x31, 0x15, 0x60, 0xE3, 0xD5, 0xC8, 0xFE, 0x22, 0x74, 0xAF, 0x0B, +0xA5, 0x38, 0x6E, 0xC0, 0x42, 0x65, 0x46, 0x57, 0x31, 0x56, 0x39, 0x8C, 0xB4, 0x6C, 0xC8, 0xB8, +0x00, 0xD1, 0xB6, 0x28, 0x76, 0x76, 0x2D, 0x1F, 0x13, 0xC5, 0x26, 0xE9, 0x55, 0x00, 0xEB, 0x83, +0x4F, 0x97, 0x6F, 0xFF, 0xAE, 0xBD, 0x7B, 0xFD, 0xA7, 0x52, 0x43, 0x45, 0x44, 0x61, 0x8C, 0x2E, +0xD1, 0x33, 0x05, 0x63, 0xFA, 0x10, 0x32, 0xD1, 0xB9, 0x66, 0x28, 0x06, 0xCC, 0xF6, 0x36, 0x71, +0x26, 0xC1, 0xF4, 0x42, 0xEF, 0xEA, 0xF8, 0x48, 0x8B, 0x38, 0xEB, 0xE9, 0x1A, 0xC6, 0x6F, 0x7A, +0x70, 0x63, 0xD8, 0x0B, 0x3C, 0xEA, 0x94, 0xE1, 0x35, 0x6D, 0x5A, 0xCA, 0x66, 0x3C, 0xB0, 0x84, +0x32, 0x96, 0x02, 0x71, 0x5C, 0xCA, 0xFA, 0xE0, 0x9A, 0x04, 0xE7, 0x87, 0xEC, 0x56, 0x81, 0xD6, +0xF2, 0xFB, 0x06, 0x4F, 0x66, 0xE6, 0x90, 0x67, 0x42, 0x79, 0x8A, 0x1F, 0x7B, 0xC6, 0x8C, 0xA0, +0x54, 0x4A, 0x69, 0x5E, 0xD6, 0x7A, 0x08, 0xA9, 0x0F, 0x3E, 0x10, 0x5A, 0x10, 0x01, 0x19, 0xA5, +0x14, 0x7F, 0xCE, 0x6B, 0xD4, 0x58, 0xFF, 0xA1, 0x3D, 0xF3, 0x35, 0xA9, 0x96, 0xC1, 0xCC, 0xBC, +0x84, 0xDC, 0x1F, 0xB6, 0x5A, 0x5A, 0xEF, 0xDD, 0x7B, 0xAD, 0xD5, 0x2A, 0xD1, 0xD8, 0x9D, 0x53, +0x77, 0xE2, 0xFA, 0xEF, 0x1E, 0xE9, 0x83, 0x5F, 0x3E, 0xBD, 0x7A, 0x56, 0x87, 0xBA, 0xB0, 0xB3, +0xEA, 0xF6, 0x3A, 0x9D, 0xC6, 0xF9, 0x21, 0x6B, 0x52, 0x1D, 0x57, 0x0F, 0xF4, 0x4A, 0x71, 0xF5, +0x4E, 0x01, 0x57, 0xA7, 0xD7, 0xDF, 0x02, 0x57, 0x57, 0x1F, 0xBC, 0x7E, 0xC1, 0x30, 0x3D, 0xEE, +0x6D, 0x43, 0x14, 0x18, 0x38, 0xA5, 0x09, 0xC8, 0x59, 0x3D, 0x3E, 0x39, 0xDD, 0x1C, 0xD3, 0x13, +0xE0, 0xEE, 0x23, 0x60, 0x3A, 0x05, 0x41, 0x9D, 0x6C, 0x23, 0xA7, 0x53, 0x7D, 0x80, 0x78, 0x20, +0xA2, 0xAF, 0xFA, 0xA7, 0x5B, 0xE0, 0x79, 0x0C, 0x22, 0x42, 0x44, 0x80, 0x64, 0x75, 0xB4, 0x8D, +0x8C, 0x4E, 0xF4, 0xC1, 0xE5, 0x9B, 0x97, 0xF5, 0x3E, 0x30, 0xD6, 0x7B, 0x72, 0xB2, 0x39, 0x9E, +0x63, 0x7D, 0xF0, 0x0F, 0x24, 0x08, 0x88, 0x59, 0xF5, 0xFA, 0x5B, 0x10, 0xD4, 0xD7, 0x07, 0x00, +0x8F, 0x38, 0x36, 0x46, 0x01, 0x76, 0xFD, 0x9A, 0x12, 0x83, 0x88, 0xBA, 0x8F, 0xB7, 0xE0, 0x0A, +0xAC, 0xFA, 0x1F, 0x28, 0x1E, 0x40, 0xB2, 0xEA, 0xF6, 0xB7, 0xB1, 0x69, 0x40, 0x44, 0x49, 0x02, +0x5F, 0x43, 0x57, 0xDB, 0x1C, 0x13, 0xD8, 0xF4, 0x93, 0x93, 0xD5, 0x93, 0x93, 0x72, 0x08, 0x30, +0x46, 0x62, 0xBC, 0xC9, 0x8B, 0xA2, 0xF9, 0x41, 0x36, 0x2F, 0x80, 0xFE, 0x7B, 0x01, 0xC3, 0xA2, +0x60, 0x5D, 0x39, 0x7C, 0x72, 0x38, 0x90, 0x09, 0x3B, 0x28, 0x17, 0x39, 0x25, 0x4A, 0xC2, 0x27, +0x74, 0xF4, 0x41, 0xBF, 0x44, 0x86, 0x8A, 0x95, 0x30, 0x14, 0x36, 0x46, 0x3F, 0x4D, 0x9B, 0x68, +0x79, 0x98, 0x30, 0xC1, 0x25, 0x8E, 0x74, 0x29, 0x82, 0x6C, 0x14, 0x9A, 0x15, 0xB4, 0x1A, 0x2B, +0x7D, 0x70, 0x72, 0x54, 0x98, 0xD2, 0x36, 0x57, 0xC6, 0x90, 0x0E, 0xC0, 0x1D, 0xE2, 0xFB, 0x95, +0xF5, 0x11, 0x81, 0xEA, 0x83, 0xE7, 0xE1, 0xF1, 0x36, 0x5A, 0x69, 0xF5, 0xB6, 0x50, 0x8B, 0x44, +0x0E, 0xD3, 0x4C, 0xAB, 0xC7, 0x55, 0x13, 0x15, 0x2F, 0x77, 0xAB, 0x98, 0x22, 0x6A, 0xB7, 0xD1, +0x0B, 0x16, 0xE0, 0x9E, 0xE1, 0x07, 0x95, 0xB5, 0x22, 0x00, 0x21, 0x42, 0xF3, 0xA3, 0xBD, 0x69, +0x24, 0x24, 0xE5, 0x1B, 0xD0, 0x87, 0x6F, 0x04, 0x0B, 0xF6, 0x2C, 0x54, 0x65, 0x8D, 0x44, 0xA0, +0x50, 0x0F, 0x84, 0xC7, 0x7B, 0xD3, 0x8A, 0x44, 0xCE, 0xB7, 0xA0, 0x97, 0x39, 0x19, 0x59, 0x86, +0xFD, 0x99, 0x8C, 0xC7, 0x90, 0xB0, 0xAA, 0xEB, 0x26, 0x06, 0x0E, 0xFA, 0x61, 0xE7, 0xDA, 0x15, +0x3D, 0xAF, 0x5C, 0x9B, 0x27, 0xD0, 0x6D, 0x5E, 0xA0, 0x27, 0xB3, 0x37, 0x9F, 0xA4, 0x26, 0x74, +0x48, 0xC4, 0x8E, 0xF4, 0xC1, 0x8F, 0x6E, 0x48, 0xE7, 0xE6, 0x05, 0xC6, 0x8F, 0x64, 0x42, 0xE7, +0x80, 0xB7, 0xA9, 0x76, 0x5E, 0x79, 0xC6, 0x9A, 0x6E, 0x32, 0xDC, 0xA6, 0xF8, 0xFA, 0x40, 0x4C, +0xED, 0x67, 0xCB, 0xD9, 0x9C, 0x99, 0x3E, 0x12, 0x42, 0x88, 0xB3, 0x1D, 0x16, 0x28, 0x49, 0x9F, +0xC3, 0xC1, 0x76, 0x48, 0x4E, 0x70, 0xBC, 0x3A, 0xB7, 0x8C, 0xAF, 0xA1, 0xDC, 0x32, 0x96, 0xC3, +0xCA, 0x6E, 0x01, 0x30, 0xFA, 0xE0, 0xD9, 0xAF, 0xCF, 0x2B, 0x07, 0x29, 0xB6, 0x92, 0x5A, 0xC6, +0xC2, 0xA3, 0xF9, 0x08, 0xEC, 0x2C, 0x35, 0x51, 0xA4, 0xF6, 0x9C, 0xB2, 0x93, 0x45, 0x0A, 0xBE, +0x04, 0x81, 0x74, 0xE1, 0x49, 0x97, 0xD8, 0x2C, 0xC7, 0xE3, 0xFD, 0x45, 0x30, 0x20, 0xE2, 0xF3, +0xC4, 0xB0, 0xAA, 0xE7, 0x15, 0x01, 0x48, 0x35, 0xA5, 0xBD, 0x82, 0xA3, 0x5D, 0xA9, 0x8B, 0x75, +0xBB, 0x37, 0x9D, 0x71, 0xAE, 0xF7, 0xAD, 0x38, 0x20, 0x64, 0xE6, 0x9A, 0xD5, 0xA7, 0x81, 0x38, +0x9C, 0x3E, 0x00, 0xAD, 0xBD, 0x83, 0x83, 0xCA, 0x59, 0x46, 0x20, 0xB8, 0xE7, 0xF4, 0xF2, 0x6C, +0x11, 0xB8, 0xDB, 0x64, 0x96, 0xEB, 0x85, 0xE3, 0xAC, 0xB7, 0x49, 0x2B, 0x97, 0xB6, 0xBB, 0x30, +0x37, 0xC7, 0x00, 0x39, 0xE5, 0xA7, 0xF1, 0xD8, 0x1A, 0x6D, 0x9E, 0x95, 0x20, 0xA3, 0xBC, 0x76, +0x67, 0x25, 0xE1, 0xEF, 0x39, 0x8A, 0x93, 0x51, 0xF5, 0x00, 0x41, 0x46, 0xA0, 0xC5, 0xAB, 0x4B, +0xED, 0xFA, 0xEA, 0xC7, 0xEB, 0x9F, 0x3E, 0xEC, 0x26, 0x3A, 0x40, 0x9F, 0x7B, 0x0A, 0x0C, 0xC8, +0xED, 0xBE, 0x63, 0x02, 0x10, 0xD1, 0xDB, 0x44, 0x4F, 0x3D, 0xA6, 0xA8, 0x17, 0xD7, 0xEF, 0x77, +0xA5, 0xA5, 0xDE, 0xFE, 0xD4, 0xD4, 0xFB, 0x1A, 0xF4, 0xF4, 0xD9, 0x26, 0x37, 0xC4, 0xDE, 0x40, +0x57, 0x0C, 0x10, 0xF5, 0xA5, 0xBD, 0xC5, 0xA3, 0xBD, 0x0D, 0xE4, 0x42, 0x52, 0xBE, 0x81, 0x61, +0x1C, 0x58, 0xC5, 0x67, 0x4A, 0xF4, 0x26, 0xCE, 0xC3, 0x20, 0xF5, 0xC1, 0xD5, 0x6A, 0xEE, 0xFA, +0x0B, 0xAF, 0x64, 0x42, 0x55, 0x6B, 0xA4, 0xB3, 0x95, 0x42, 0x04, 0x29, 0x4C, 0x23, 0x1D, 0xAE, +0x10, 0x5C, 0x24, 0x91, 0xD6, 0xCF, 0xFA, 0x77, 0xAA, 0x15, 0x44, 0x7E, 0x9F, 0x8A, 0x99, 0x6C, +0x90, 0x77, 0x26, 0x98, 0x77, 0x5E, 0x5D, 0xEE, 0x26, 0x94, 0x4D, 0xF6, 0x96, 0x70, 0x26, 0x7B, +0x4D, 0x38, 0x1A, 0x5F, 0xC3, 0x16, 0x52, 0xD8, 0x70, 0x10, 0xC1, 0x01, 0x61, 0xEC, 0xBC, 0xC9, +0x00, 0x42, 0xF2, 0x9C, 0xEE, 0x6A, 0x1B, 0xD7, 0x11, 0x64, 0xC4, 0x3D, 0xE7, 0x28, 0xF2, 0x9B, +0xE3, 0x3B, 0xF5, 0x9A, 0xA3, 0x42, 0x6A, 0xB7, 0x71, 0x1A, 0xE4, 0x64, 0x44, 0x2C, 0x1B, 0xB7, +0x32, 0x57, 0x55, 0x88, 0x04, 0xCB, 0x74, 0xA2, 0x5D, 0xB2, 0xB3, 0x6D, 0x74, 0xD3, 0xDB, 0x46, +0x37, 0x32, 0x45, 0x71, 0xF5, 0x9C, 0xDC, 0x53, 0xA6, 0xE9, 0xF6, 0x4E, 0xEF, 0x53, 0x3D, 0xC3, +0x79, 0xF5, 0x98, 0x06, 0x30, 0xFA, 0xE0, 0xF9, 0xFB, 0xDD, 0xC4, 0x34, 0xEC, 0xAC, 0x64, 0x4C, +0xDB, 0x2A, 0x82, 0x51, 0xA6, 0xF6, 0x5D, 0x8A, 0x2D, 0x37, 0xD0, 0xC6, 0x12, 0x09, 0xFF, 0x75, +0x47, 0xDA, 0x58, 0x96, 0xD7, 0xC6, 0x1D, 0x67, 0x98, 0xE5, 0xD7, 0xA0, 0x1F, 0xCF, 0x58, 0x7E, +0x9E, 0xCC, 0x8C, 0xCA, 0x3A, 0xE2, 0x70, 0xFA, 0xE0, 0x83, 0xB1, 0xD4, 0x5E, 0xBD, 0x7B, 0xB6, +0x13, 0x5D, 0x89, 0x4E, 0xF7, 0xA3, 0xAF, 0x90, 0xE5, 0x7D, 0xEB, 0xCC, 0x26, 0x4E, 0x75, 0xA7, +0x42, 0x20, 0x7D, 0xF0, 0x96, 0x38, 0xBE, 0x76, 0xE9, 0x7A, 0xFC, 0xB5, 0x73, 0x3B, 0xD1, 0x1A, +0xED, 0x79, 0x3F, 0x2A, 0x63, 0x4C, 0xEF, 0x5B, 0x5F, 0xD3, 0x99, 0xE5, 0x79, 0xAE, 0x57, 0x59, +0x65, 0x1C, 0x4E, 0x1F, 0xBC, 0x6E, 0xBD, 0xA3, 0x47, 0x3B, 0x51, 0x97, 0xE8, 0x75, 0x3F, 0x1A, +0x0B, 0x79, 0xDE, 0xB7, 0xD2, 0x6E, 0xC6, 0xB6, 0x35, 0xAF, 0xAC, 0x32, 0x0A, 0xA5, 0x0F, 0x3E, +0xB6, 0x5E, 0xC2, 0xDF, 0x9D, 0xA8, 0x8B, 0xF5, 0xB8, 0x1F, 0x65, 0x71, 0x6E, 0xF7, 0xAD, 0x2A, +0x73, 0xB4, 0xAC, 0xAC, 0x28, 0x80, 0xD1, 0x07, 0x2F, 0x2E, 0x7F, 0xD5, 0xEA, 0x2F, 0xDC, 0xA5, +0x83, 0x0F, 0x5C, 0x6A, 0x57, 0x3F, 0x36, 0x76, 0xA2, 0x31, 0xEC, 0x7A, 0x3F, 0xFA, 0xA2, 0x4C, +0xEF, 0x5B, 0x5B, 0x74, 0x5F, 0xCD, 0xD0, 0xA8, 0x1E, 0x0E, 0x05, 0x20, 0x3E, 0xFB, 0x02, 0x47, +0xDA, 0x73, 0x63, 0x37, 0x01, 0x31, 0xEC, 0x77, 0x17, 0x45, 0x7B, 0xC4, 0xE4, 0xBE, 0xF5, 0x64, +0x13, 0xB3, 0x84, 0x8A, 0xE2, 0x25, 0x86, 0xF9, 0x19, 0xB7, 0xA8, 0xE0, 0xD6, 0xCD, 0x35, 0xD4, +0x1A, 0x57, 0x2F, 0xB4, 0x37, 0xE2, 0xB4, 0x04, 0x37, 0x1B, 0xCF, 0xD9, 0x65, 0x0D, 0x6D, 0xE3, +0xF4, 0xC4, 0x07, 0xB7, 0xBD, 0xE3, 0xE3, 0xED, 0x86, 0xB7, 0x59, 0xD3, 0xA8, 0xC7, 0xC7, 0xF7, +0xA8, 0x93, 0xB1, 0x31, 0x22, 0x9F, 0x4D, 0x12, 0x6C, 0xF2, 0x30, 0x8C, 0x04, 0xAB, 0x0F, 0x5E, +0xC2, 0x89, 0xF6, 0x82, 0x9E, 0xEC, 0xAA, 0x0C, 0x94, 0xFB, 0xDF, 0x85, 0x27, 0xC5, 0xF8, 0xDD, +0xB7, 0x33, 0x51, 0x62, 0xA0, 0xE8, 0x76, 0x27, 0xCE, 0x46, 0x7B, 0x0B, 0x62, 0xE0, 0x5C, 0x7D, +0x1F, 0xD8, 0xF9, 0x6E, 0x15, 0x18, 0x11, 0xB1, 0x33, 0x1D, 0x4A, 0x7C, 0xEF, 0x5D, 0x8D, 0x70, +0x7D, 0x5A, 0x5D, 0x7B, 0x08, 0x05, 0x4A, 0xC3, 0x3F, 0xBB, 0xD1, 0x14, 0xED, 0x70, 0x27, 0x0A, +0x62, 0xAC, 0xDD, 0xA1, 0x5E, 0xB2, 0xEF, 0xCB, 0x3B, 0xBF, 0xF8, 0x2B, 0x53, 0x8B, 0x94, 0xC0, +0x77, 0x20, 0xD1, 0x79, 0x4E, 0x12, 0xB4, 0xFC, 0xC0, 0xB2, 0x6D, 0x7D, 0xF0, 0x8A, 0x04, 0xDA, +0x35, 0x1E, 0x96, 0xDC, 0x72, 0x24, 0x61, 0x11, 0xFB, 0x0D, 0x03, 0x8F, 0x18, 0x33, 0x7D, 0x70, +0x8D, 0x2F, 0x93, 0x05, 0x5C, 0x78, 0x56, 0x1D, 0x19, 0xB5, 0x6E, 0xE2, 0x78, 0x2E, 0x10, 0x15, +0x2A, 0x87, 0xBF, 0xA4, 0x4E, 0xD7, 0xC4, 0x91, 0x74, 0x6D, 0x70, 0x45, 0x1B, 0x6B, 0xE8, 0xFE, +0xC5, 0xDD, 0x95, 0xDE, 0x0B, 0x45, 0x37, 0x3F, 0xE2, 0x6E, 0xC6, 0xF8, 0xAB, 0xA6, 0x41, 0xAD, +0x6C, 0x6B, 0xF3, 0xE0, 0xDC, 0x9F, 0x1B, 0x8E, 0x68, 0x46, 0xF7, 0xFD, 0x2E, 0xF9, 0x46, 0xCE, +0xA1, 0x6B, 0x9B, 0xD0, 0xF0, 0x99, 0x79, 0x83, 0xAF, 0xDD, 0x32, 0xB5, 0xEB, 0x70, 0x4B, 0x22, +0x82, 0x80, 0x5D, 0x08, 0x0C, 0x05, 0xBA, 0x9F, 0x7A, 0x02, 0x3D, 0xDB, 0x3C, 0x8A, 0x2F, 0x2A, +0xCA, 0x51, 0x6E, 0xC6, 0x2E, 0x4A, 0x8F, 0x4C, 0x42, 0x41, 0xAA, 0x36, 0xD7, 0x2A, 0xF7, 0x54, +0x7E, 0x20, 0x13, 0xCB, 0x07, 0x1A, 0x35, 0x30, 0x8B, 0x43, 0xBA, 0x0F, 0x8D, 0xD9, 0x72, 0xB9, +0x3D, 0x8E, 0x72, 0x97, 0x7C, 0x87, 0xB6, 0x72, 0xE7, 0x6A, 0xA5, 0x52, 0x3E, 0xB9, 0xCF, 0x34, +0x8E, 0xB1, 0xC8, 0xE8, 0x1F, 0xB6, 0x5A, 0xD3, 0x3E, 0xEE, 0xA8, 0xD3, 0x04, 0x6B, 0xE7, 0x87, +0xD3, 0x7E, 0xD1, 0x66, 0xB0, 0xC2, 0xED, 0x90, 0xC0, 0xE9, 0xC6, 0xBB, 0x21, 0x51, 0x4A, 0x03, +0xA0, 0xA6, 0xA9, 0xBD, 0x33, 0xFC, 0x2F, 0x4D, 0xED, 0x23, 0x16, 0x5E, 0x3B, 0xDC, 0x14, 0x89, +0xB4, 0x1B, 0xA6, 0xE9, 0x65, 0x6E, 0x8C, 0xEC, 0xC7, 0x36, 0x46, 0x9E, 0x88, 0x8D, 0x91, 0xD2, +0x12, 0xC8, 0xAA, 0xDB, 0xED, 0x96, 0xE1, 0xBC, 0xE4, 0xDE, 0xC8, 0x3B, 0x61, 0x69, 0x06, 0xC2, +0x2C, 0xC9, 0x52, 0x5F, 0xB0, 0xD4, 0x97, 0x58, 0x3A, 0xBD, 0xCB, 0xDD, 0x9E, 0x77, 0xC2, 0x11, +0x5F, 0x60, 0xFF, 0x4A, 0x58, 0x2A, 0xB5, 0x81, 0x95, 0xDA, 0xF6, 0x5D, 0xED, 0x5F, 0xA5, 0x4D, +0x92, 0xC1, 0xF0, 0x38, 0x37, 0x16, 0x52, 0x10, 0xE6, 0xF3, 0xAF, 0xEE, 0xD2, 0xE7, 0x27, 0x5B, +0xF8, 0xFC, 0x24, 0xE5, 0xF3, 0x3B, 0x74, 0x76, 0x41, 0xF8, 0x37, 0xE6, 0xF0, 0x82, 0xAD, 0x0A, +0x4E, 0xAF, 0x64, 0x6B, 0xB7, 0x1E, 0x12, 0x5A, 0xC2, 0xAB, 0xBB, 0xF4, 0x90, 0x0C, 0xBB, 0xDD, +0xC8, 0x48, 0x79, 0xCC, 0x19, 0xEC, 0x26, 0x27, 0xD1, 0x4A, 0x4A, 0x56, 0x27, 0xEF, 0x1D, 0x77, +0x80, 0x1E, 0xF5, 0x79, 0xD9, 0x74, 0x17, 0xEA, 0x29, 0xBF, 0x51, 0x3E, 0xB3, 0xC9, 0xDD, 0x14, +0x65, 0xF8, 0xAA, 0x8A, 0xB9, 0x54, 0xE1, 0x96, 0x2E, 0xCC, 0x2E, 0xDF, 0xFE, 0xBD, 0x5A, 0x2D, +0x96, 0xEC, 0x69, 0x77, 0xF5, 0xD8, 0x66, 0xD6, 0x2A, 0x0B, 0x8C, 0xD3, 0x0E, 0x11, 0x07, 0x5F, +0x19, 0xD1, 0xFB, 0x14, 0x72, 0xAE, 0x18, 0x0A, 0x4A, 0x41, 0x21, 0x04, 0xCB, 0x1A, 0xEC, 0xD1, +0x40, 0xC8, 0x27, 0x93, 0xE1, 0x58, 0xC4, 0x35, 0xCD, 0x1D, 0x8F, 0xE9, 0x77, 0xC8, 0x1E, 0x63, +0xC0, 0xF0, 0xBF, 0xE0, 0xF5, 0x4E, 0x37, 0x24, 0x49, 0x35, 0xD4, 0x8B, 0x28, 0x0C, 0x69, 0xA3, +0x26, 0x56, 0x34, 0x6E, 0xAB, 0x2A, 0x82, 0x23, 0x26, 0x82, 0x17, 0x6F, 0x3E, 0xAA, 0x64, 0xC0, +0x7C, 0xAD, 0x93, 0x16, 0xC1, 0xD1, 0xE6, 0x2F, 0xBD, 0xE8, 0x96, 0x96, 0x56, 0x27, 0x92, 0xD6, +0xD1, 0x38, 0xDA, 0xBB, 0xBB, 0x4D, 0xC8, 0x52, 0x48, 0xE0, 0x98, 0x3D, 0x9D, 0xAF, 0xBD, 0x97, +0x3D, 0xA0, 0x94, 0x1D, 0x1C, 0x57, 0xB1, 0x03, 0xF3, 0x68, 0x0B, 0x33, 0x38, 0xCE, 0x30, 0x83, +0xBB, 0x92, 0x41, 0x5F, 0x1F, 0xBC, 0xDF, 0xC4, 0x0C, 0xFA, 0x25, 0xCD, 0xE0, 0x48, 0x98, 0x41, +0xB4, 0xB1, 0xBB, 0x5F, 0x56, 0x58, 0x92, 0x15, 0x3C, 0x1E, 0xE3, 0xF3, 0x4C, 0x8F, 0xCB, 0x79, +0xC2, 0xEE, 0x62, 0xEE, 0xD2, 0x72, 0xAA, 0xC7, 0xDB, 0x5F, 0x2D, 0xC7, 0x74, 0x97, 0xD5, 0x42, +0xAE, 0xDC, 0xD1, 0xD7, 0x1E, 0x6E, 0xAB, 0x8D, 0x5A, 0x71, 0x66, 0xA7, 0xB5, 0xC2, 0xCA, 0xDE, +0xF1, 0x5D, 0x4F, 0x4B, 0xBF, 0xD2, 0x25, 0xB6, 0x33, 0x54, 0xB4, 0x2E, 0x57, 0x04, 0xA4, 0xF7, +0xC2, 0xBC, 0x79, 0xA9, 0x6D, 0xF0, 0xBE, 0x0D, 0x05, 0xB2, 0x2E, 0x7B, 0x2B, 0x89, 0xB6, 0xC1, +0x6B, 0x49, 0x14, 0xD8, 0x32, 0xF6, 0x0E, 0xE1, 0x1B, 0x62, 0xB4, 0xCD, 0x5E, 0x11, 0x53, 0xB8, +0x8D, 0x86, 0xB5, 0xDA, 0x3C, 0xA5, 0x84, 0xE3, 0x32, 0xE6, 0xAD, 0x50, 0x63, 0x95, 0x7B, 0xF7, +0x01, 0xC5, 0x29, 0x19, 0x00, 0x07, 0x5F, 0xD1, 0x6D, 0x46, 0x3E, 0x29, 0xB7, 0x5B, 0x38, 0x49, +0x58, 0x85, 0xCA, 0xF0, 0xD3, 0x99, 0x14, 0xCC, 0xC2, 0xCE, 0x2B, 0x06, 0xB3, 0xA8, 0xCE, 0x07, +0x63, 0xDA, 0xFB, 0xE0, 0xE5, 0x9F, 0x0A, 0x96, 0xD6, 0x9B, 0xB3, 0x74, 0x74, 0x57, 0x2C, 0x6D, +0x91, 0xAA, 0x42, 0xEB, 0x0A, 0xDC, 0xC0, 0xB0, 0x37, 0x36, 0x2E, 0x06, 0x0D, 0xB6, 0xC5, 0x62, +0xAE, 0x76, 0x0D, 0xAC, 0xEE, 0xD4, 0xC0, 0x04, 0x01, 0xE5, 0x94, 0xD1, 0x4F, 0x2B, 0xE3, 0xF4, +0x6B, 0xB3, 0x2F, 0xC6, 0x51, 0x59, 0xF3, 0x52, 0x70, 0x74, 0xF2, 0x35, 0x99, 0x97, 0xBB, 0x08, +0xF0, 0xEA, 0xC6, 0xC1, 0x8B, 0x81, 0x63, 0xF0, 0xA2, 0x47, 0xBB, 0x37, 0xB0, 0x90, 0x82, 0x8D, +0xF5, 0x71, 0x74, 0xA7, 0xEF, 0xA2, 0xBB, 0x8B, 0x08, 0xC6, 0x58, 0xDA, 0xC2, 0xC4, 0x7A, 0xFD, +0x1D, 0x9A, 0x98, 0xB4, 0xD0, 0xC4, 0xF3, 0x20, 0x2F, 0x60, 0x74, 0xBE, 0x36, 0x10, 0x15, 0x34, +0x55, 0x56, 0x92, 0xD4, 0x59, 0xF9, 0xFC, 0x10, 0x8A, 0xC2, 0x34, 0x82, 0x0C, 0x3A, 0xCF, 0xD9, +0x27, 0x2D, 0xD5, 0x1D, 0x46, 0xAF, 0xA0, 0xA4, 0xCB, 0x6A, 0xD1, 0xCB, 0x8E, 0xC3, 0x42, 0x33, +0xF9, 0x12, 0xE4, 0xC2, 0xD7, 0x3D, 0x9E, 0x1B, 0xFC, 0xDD, 0x28, 0x37, 0x84, 0xAF, 0x01, 0x6A, +0x53, 0x8F, 0x8C, 0x2F, 0xF4, 0xEF, 0x42, 0x9C, 0x5C, 0x5A, 0xD8, 0x44, 0xD7, 0x20, 0x24, 0x3B, +0xB6, 0x6B, 0x60, 0xB1, 0x6A, 0xCC, 0x03, 0xA0, 0xB4, 0xFD, 0xC7, 0x1C, 0x27, 0x79, 0x0D, 0x7C, +0x91, 0x86, 0x51, 0x6E, 0xED, 0x98, 0xBE, 0x36, 0x99, 0x3F, 0xF1, 0x84, 0x87, 0xE1, 0x9A, 0xE1, +0xFF, 0xFC, 0x77, 0xD1, 0xD4, 0x0C, 0x7E, 0xD8, 0x34, 0x12, 0x00, 0x98, 0x91, 0x37, 0xBA, 0xD0, +0x81, 0x52, 0xCF, 0xF5, 0xA1, 0x14, 0xB5, 0x26, 0x56, 0x86, 0xAA, 0xB2, 0xA4, 0x7D, 0xA8, 0x12, +0x77, 0xA2, 0xB1, 0x62, 0x6C, 0x72, 0xEE, 0x8F, 0x3C, 0x6B, 0x0E, 0xA5, 0x9A, 0xE9, 0x8E, 0x16, +0x33, 0xE2, 0x04, 0x6D, 0xC3, 0x34, 0xAF, 0x6E, 0xE0, 0xE0, 0x2D, 0xCE, 0x30, 0x83, 0xE4, 0xEB, +0xB5, 0x17, 0x3F, 0xBD, 0xBB, 0x64, 0x2F, 0x13, 0x7D, 0x0B, 0xF2, 0x22, 0x66, 0xAD, 0xA9, 0x8D, +0x17, 0x0E, 0xAB, 0xDE, 0xEB, 0x04, 0xDB, 0xB2, 0x0F, 0xCC, 0xDE, 0x18, 0x9E, 0x36, 0x34, 0x7C, +0xF2, 0xDA, 0xF5, 0x03, 0xED, 0x42, 0x0B, 0x31, 0xDA, 0xEE, 0x88, 0xBE, 0xAC, 0xA6, 0xCD, 0xF8, +0xE2, 0x2D, 0x19, 0xE3, 0xBF, 0x78, 0x36, 0x34, 0x0D, 0xA1, 0x0E, 0xB4, 0xDA, 0xD9, 0x69, 0xB7, +0x86, 0xF6, 0x17, 0x76, 0x31, 0xC6, 0x4F, 0xC6, 0x42, 0xBB, 0xFA, 0xC2, 0xB3, 0x9B, 0xDA, 0x68, +0xD8, 0x60, 0x2F, 0x80, 0xA5, 0x97, 0xF1, 0x9A, 0x78, 0x33, 0x78, 0x3B, 0x98, 0x12, 0xA7, 0x1E, +0x51, 0x06, 0xCE, 0x30, 0x77, 0x1D, 0x3F, 0xF6, 0xF5, 0x5B, 0x6B, 0x1C, 0x5D, 0x6F, 0x43, 0x41, +0x1F, 0x2C, 0x7C, 0xED, 0xE1, 0xC5, 0x85, 0x86, 0x05, 0x6E, 0xEC, 0xC5, 0xB2, 0xA3, 0x61, 0xB2, +0x5D, 0x53, 0x4B, 0x5C, 0xF8, 0x19, 0x42, 0x83, 0xF4, 0x0A, 0xF3, 0x5B, 0x8D, 0xD8, 0x89, 0x77, +0x68, 0x87, 0x00, 0x18, 0x45, 0xEA, 0x8D, 0x38, 0x81, 0x75, 0xD3, 0x08, 0x8C, 0x46, 0xFC, 0x65, +0xB6, 0xD0, 0x2B, 0x50, 0xD2, 0xD4, 0xE8, 0x2D, 0xF9, 0xCD, 0xBA, 0xB7, 0x8D, 0x36, 0xC8, 0x10, +0xF8, 0x0D, 0xA1, 0x89, 0xE7, 0x25, 0x3F, 0xEB, 0x0B, 0xD0, 0xAD, 0x6E, 0x53, 0xC3, 0x3B, 0x71, +0x58, 0x89, 0xC8, 0x07, 0xE2, 0x9A, 0x10, 0x5A, 0x3E, 0x5A, 0x05, 0x4A, 0x86, 0xEE, 0x36, 0xA6, +0x22, 0x88, 0x3D, 0x1F, 0xC8, 0x04, 0x24, 0x36, 0x69, 0xF2, 0x01, 0x74, 0x93, 0x8E, 0x9E, 0x9B, +0x2C, 0x28, 0x4A, 0x5A, 0x3B, 0x3C, 0x04, 0x97, 0x86, 0xA0, 0x44, 0xC0, 0x2A, 0x26, 0xF5, 0x1A, +0x5F, 0xC0, 0x04, 0x8B, 0xAA, 0x75, 0x56, 0xB5, 0x03, 0x40, 0xD0, 0x0E, 0xDC, 0xEB, 0xC0, 0xB3, +0x9C, 0x09, 0x0C, 0x3D, 0x1A, 0x11, 0x36, 0x7A, 0x1B, 0x51, 0x26, 0xEE, 0xD3, 0xEB, 0xB4, 0x93, +0xE4, 0x8D, 0x3A, 0xBF, 0x7E, 0x50, 0x6B, 0xD4, 0x38, 0xF1, 0xF4, 0x1C, 0xCC, 0xAD, 0xCE, 0x0E, +0x1E, 0x51, 0x1A, 0x1B, 0xDA, 0xF9, 0x39, 0xEF, 0x86, 0xB5, 0xC2, 0x8B, 0xD0, 0x88, 0xFE, 0x49, +0xDC, 0x0A, 0x4D, 0xF1, 0xF7, 0xEF, 0xFF, 0x12, 0x36, 0x7B, 0x7B, 0x08, 0x54, 0x3F, 0xC5, 0x19, +0x84, 0xEF, 0xFF, 0x82, 0xFF, 0xDF, 0x3E, 0xA2, 0xD3, 0x06, 0xDF, 0xFF, 0x85, 0x7F, 0x6E, 0x1F, +0x41, 0x4F, 0x70, 0x4C, 0xFB, 0xBB, 0xFD, 0x9D, 0xCA, 0x21, 0x2D, 0xBD, 0x49, 0xA6, 0xF4, 0x42, +0xB1, 0x55, 0xA6, 0x69, 0x92, 0x43, 0xD4, 0xEF, 0x91, 0xFF, 0xD6, 0x47, 0xAE, 0x09, 0xEA, 0x09, +0xC0, 0x92, 0x85, 0xD2, 0x6D, 0x50, 0x89, 0x10, 0x54, 0xF8, 0x76, 0x67, 0x6B, 0x4C, 0x5B, 0x6A, +0xDC, 0x55, 0x22, 0x03, 0x11, 0x2D, 0xE7, 0x86, 0xE7, 0x93, 0x37, 0x4E, 0x50, 0x0F, 0x62, 0x4E, +0x91, 0x21, 0xF1, 0xC1, 0x20, 0xC6, 0x02, 0xFE, 0x00, 0x0E, 0xDA, 0xD5, 0xB8, 0xD2, 0x42, 0x63, +0x7B, 0x10, 0xDA, 0x61, 0x44, 0x29, 0xBB, 0x99, 0x61, 0x87, 0x9F, 0x46, 0xF6, 0x97, 0x3A, 0xBE, +0x6F, 0x36, 0x19, 0x2A, 0x52, 0x22, 0xC2, 0x46, 0x4F, 0xF1, 0x7F, 0x20, 0x17, 0xFC, 0x93, 0xA9, +0x1F, 0xC0, 0xCA, 0x4A, 0xF8, 0x3A, 0x9D, 0x02, 0xF8, 0xBC, 0x6A, 0x6A, 0xEC, 0x60, 0x0D, 0x9E, +0xE1, 0x98, 0x78, 0x8E, 0x7F, 0xD6, 0x42, 0x7B, 0x78, 0x81, 0x1F, 0xC1, 0x35, 0x5A, 0xB3, 0xE2, +0x25, 0x76, 0x80, 0xAD, 0x68, 0x8D, 0x41, 0x5B, 0xB1, 0x23, 0xB8, 0x86, 0xEF, 0x5E, 0x02, 0xDB, +0x6D, 0x6A, 0x43, 0xCB, 0x71, 0xE8, 0x41, 0x01, 0xF5, 0x51, 0xAA, 0x7F, 0xEA, 0xAF, 0x80, 0x03, +0x4E, 0xDA, 0xED, 0x23, 0x7F, 0x1D, 0x9E, 0xAD, 0x6F, 0x1F, 0x11, 0xBC, 0x47, 0x89, 0x84, 0xE3, +0x35, 0x3F, 0x86, 0xEB, 0x40, 0x1F, 0xDE, 0x11, 0x04, 0xD3, 0x0B, 0xEB, 0xE8, 0x02, 0xB4, 0x08, +0xF0, 0x3E, 0x27, 0x1E, 0xCE, 0xD6, 0xE1, 0x19, 0x42, 0x53, 0x58, 0xCE, 0x06, 0x9C, 0xAE, 0xA3, +0x53, 0xB8, 0x4B, 0xDF, 0x23, 0x85, 0x44, 0x30, 0x9E, 0x6E, 0x1F, 0x71, 0x9E, 0xE0, 0x12, 0x3F, +0x4A, 0x8A, 0x1A, 0x63, 0x42, 0xC0, 0xA3, 0xC8, 0x73, 0x96, 0xA4, 0xA5, 0xFC, 0x01, 0xFE, 0x71, +0x65, 0x13, 0x3C, 0x7C, 0xBE, 0x7E, 0x63, 0xD6, 0x6B, 0x7C, 0x41, 0xB6, 0x86, 0x31, 0x4C, 0x86, +0x69, 0xBB, 0xCE, 0xC8, 0xB6, 0x46, 0xE8, 0x28, 0xF5, 0x86, 0x76, 0x31, 0xE0, 0x71, 0x0C, 0x0D, +0x1A, 0x9A, 0xCB, 0x46, 0x9A, 0x89, 0x5A, 0x2C, 0x29, 0xD6, 0x1A, 0x6D, 0x6A, 0x87, 0xDC, 0xD6, +0x10, 0x05, 0x77, 0xC1, 0x72, 0x38, 0xB0, 0xB1, 0x02, 0x47, 0xCA, 0x5B, 0x72, 0x91, 0xD0, 0xD6, +0x12, 0x16, 0x8A, 0x46, 0x0E, 0xB5, 0x9D, 0x44, 0x94, 0xCD, 0xF1, 0x6A, 0xE1, 0xC0, 0x0F, 0x93, +0x0E, 0x0C, 0xAA, 0xF2, 0x82, 0x7A, 0xED, 0x0A, 0x37, 0x64, 0xFC, 0x56, 0x3B, 0xC0, 0x46, 0x07, +0xB5, 0x7F, 0x9D, 0x69, 0xB5, 0x03, 0xD9, 0x93, 0x6F, 0x93, 0x2E, 0xC7, 0x34, 0x36, 0x29, 0xA9, +0xB1, 0x89, 0xA4, 0xB1, 0xC9, 0xDD, 0x6A, 0x4C, 0x5E, 0x08, 0xDE, 0x46, 0x6B, 0xF2, 0xCA, 0x6B, +0x8E, 0xE6, 0x0A, 0xE1, 0xB9, 0xD2, 0xB8, 0xB6, 0x26, 0x2A, 0x6D, 0x6D, 0xA2, 0x26, 0x96, 0xE2, +0xC0, 0x7B, 0x88, 0xF7, 0xFA, 0xE7, 0x77, 0x6F, 0x31, 0x54, 0xAA, 0x55, 0x16, 0x6A, 0x2C, 0x59, +0x8E, 0x28, 0x30, 0x60, 0xEE, 0x8C, 0x05, 0xEE, 0x58, 0x0E, 0x3D, 0xA8, 0x69, 0x75, 0x8A, 0x12, +0x33, 0x68, 0x81, 0x21, 0xF0, 0xC0, 0x5B, 0xCE, 0x77, 0x31, 0xD8, 0x0A, 0xE7, 0x8D, 0xA0, 0x72, +0x6C, 0x01, 0x01, 0x4A, 0x29, 0x91, 0x61, 0x4E, 0x39, 0x8C, 0x94, 0x13, 0x76, 0xEE, 0x22, 0xD4, +0x5F, 0xFD, 0xB2, 0x41, 0x4D, 0xC4, 0xF4, 0x28, 0xB6, 0xF9, 0x85, 0xD2, 0xE1, 0x91, 0xBF, 0x94, +0x80, 0xF8, 0x1C, 0xB6, 0xC2, 0xC0, 0x45, 0x26, 0x28, 0x85, 0x46, 0x4C, 0x5D, 0x66, 0xE3, 0x59, +0x57, 0xC1, 0xB3, 0x56, 0xE0, 0xE1, 0x99, 0xA7, 0x14, 0x1A, 0x3E, 0xD1, 0x95, 0x89, 0xA5, 0x1C, +0x31, 0x7C, 0x72, 0x49, 0xC5, 0x13, 0xCF, 0x74, 0xE5, 0x78, 0xE2, 0x93, 0x22, 0xD9, 0x78, 0x4A, +0xCA, 0x86, 0xCF, 0x44, 0x28, 0xEC, 0x39, 0x59, 0x8D, 0x74, 0xC4, 0x7F, 0x9B, 0xD7, 0x1F, 0x63, +0x03, 0xA2, 0x45, 0xF4, 0x67, 0x2F, 0x5E, 0x32, 0xF9, 0x28, 0xAA, 0x45, 0x62, 0x2B, 0xB3, 0x01, +0xB1, 0xDB, 0x46, 0x00, 0xF1, 0x09, 0xC6, 0xF1, 0xC4, 0x6F, 0x63, 0x85, 0x1B, 0x8A, 0x31, 0x75, +0xAB, 0xED, 0x00, 0x01, 0x14, 0x61, 0xE3, 0xAC, 0x93, 0x34, 0xCF, 0x14, 0x2E, 0x76, 0x39, 0x0B, +0x1D, 0xBB, 0x9B, 0x81, 0x91, 0x67, 0x98, 0x38, 0x04, 0x5E, 0xCC, 0xC2, 0x46, 0x47, 0x31, 0x12, +0xAE, 0xDE, 0xF1, 0x71, 0x3A, 0xCF, 0xF0, 0x0E, 0xF8, 0x67, 0xED, 0x10, 0x03, 0xCE, 0x47, 0x45, +0x43, 0xB3, 0x11, 0x14, 0x82, 0x5A, 0x4D, 0x2C, 0xD9, 0xD5, 0xCE, 0x52, 0x15, 0x37, 0x40, 0xF0, +0x45, 0x38, 0xED, 0x29, 0xA3, 0x31, 0xF6, 0xB1, 0x96, 0x21, 0x0C, 0xBE, 0xC3, 0x6F, 0xB2, 0x31, +0x64, 0x74, 0x83, 0x4B, 0x88, 0x89, 0x5D, 0xC3, 0x81, 0x6B, 0xE2, 0x12, 0x5B, 0xDB, 0x69, 0xB9, +0x0E, 0x51, 0xF7, 0x1A, 0xAB, 0xDF, 0x79, 0x47, 0xFC, 0x8C, 0x3F, 0x94, 0x1E, 0x81, 0x79, 0x24, +0x58, 0x78, 0x0E, 0xAF, 0xE7, 0xD3, 0xF5, 0x8D, 0x72, 0x28, 0xB9, 0x43, 0xDB, 0x3C, 0x3C, 0xD4, +0x9E, 0x05, 0x81, 0x01, 0x0A, 0xC0, 0x75, 0xCA, 0x29, 0xCA, 0x47, 0x33, 0xF8, 0xA4, 0x84, 0xEB, +0xA1, 0x51, 0xB2, 0x87, 0x8A, 0x09, 0xF3, 0x5B, 0xFC, 0x84, 0x9D, 0x70, 0x67, 0x8A, 0xAA, 0xFD, +0xEF, 0x05, 0xF1, 0xD6, 0xD7, 0x54, 0x60, 0xAE, 0xF7, 0xCC, 0xB6, 0xEB, 0xB5, 0x76, 0xB4, 0xEC, +0x5C, 0x63, 0x63, 0xF0, 0x36, 0xA0, 0xBA, 0x82, 0x3E, 0x40, 0xC7, 0x91, 0xCD, 0x33, 0x6E, 0x42, +0xBD, 0xC3, 0xB8, 0xEB, 0x82, 0x2B, 0x23, 0x39, 0xE8, 0x87, 0x16, 0xAE, 0xF3, 0x85, 0xAC, 0x17, +0x73, 0x10, 0x7F, 0x34, 0x8C, 0x4F, 0x4C, 0x2C, 0x70, 0xE9, 0x90, 0x36, 0xB4, 0xBC, 0xE4, 0x03, +0xB9, 0xEE, 0x91, 0xA2, 0x51, 0xA4, 0x02, 0x6A, 0x9D, 0xE8, 0x89, 0xE9, 0x2F, 0xFC, 0xDC, 0x3E, +0x50, 0x9F, 0x29, 0xA6, 0x40, 0x38, 0x81, 0x5C, 0x78, 0x22, 0x79, 0x25, 0x7A, 0x48, 0x4C, 0x4F, +0xDC, 0x36, 0x1E, 0x44, 0x91, 0x61, 0x31, 0x37, 0x8D, 0x80, 0xC4, 0x83, 0x43, 0x68, 0x0B, 0xE2, +0xE6, 0xCC, 0x0D, 0x48, 0x22, 0x62, 0x58, 0xB8, 0x69, 0xC6, 0xB0, 0x3F, 0x46, 0xD6, 0x78, 0xAF, +0xEE, 0xAF, 0xF0, 0xF1, 0x0A, 0xFE, 0x9F, 0x9A, 0x83, 0x28, 0x37, 0x6E, 0x4E, 0x59, 0x48, 0x18, +0x0F, 0x22, 0x2B, 0x91, 0xE5, 0x10, 0x0B, 0x0B, 0xFC, 0xBE, 0xE8, 0xE9, 0xE1, 0x43, 0x7A, 0xF4, +0x20, 0x54, 0x9A, 0x88, 0x1E, 0x17, 0x5A, 0x74, 0x23, 0xA1, 0xE0, 0x34, 0xEE, 0x04, 0x0E, 0x81, +0x5C, 0xC2, 0xC0, 0x7C, 0x2B, 0x54, 0xEF, 0x1C, 0xAA, 0x4D, 0xB4, 0x85, 0xFF, 0x8F, 0xFA, 0x5F, +0x51, 0xD4, 0xBF, 0xBF, 0x10, 0x9F, 0x63, 0xDB, 0x09, 0x0F, 0x60, 0x70, 0xEA, 0x69, 0xC1, 0x83, +0x1A, 0x54, 0x3B, 0xCA, 0x79, 0x3F, 0x1E, 0xBA, 0x23, 0xFB, 0x9A, 0x5A, 0x26, 0x23, 0x3A, 0xB2, +0x2C, 0x94, 0x11, 0x4E, 0xDF, 0xE3, 0x04, 0x37, 0xCE, 0x76, 0xD7, 0x6B, 0x6C, 0x6D, 0x81, 0xC6, +0xE3, 0xDB, 0xA8, 0x24, 0x99, 0xBA, 0xCB, 0x3C, 0x48, 0x0F, 0xA2, 0xCE, 0x0D, 0x49, 0x00, 0x87, +0xD0, 0x7C, 0x3B, 0x4F, 0x61, 0xD7, 0x62, 0xDB, 0x0F, 0x4F, 0x06, 0xD0, 0x40, 0x5C, 0x01, 0xD0, +0xC0, 0xA3, 0x5E, 0x23, 0xA1, 0x25, 0x4E, 0x11, 0x56, 0x41, 0x56, 0x2E, 0x62, 0x5A, 0xE6, 0xC5, +0x31, 0xB3, 0x50, 0x5A, 0x21, 0xC8, 0xCA, 0x97, 0x01, 0x22, 0x7E, 0x7A, 0xA1, 0x39, 0x0B, 0xDB, +0x06, 0x1B, 0x44, 0x16, 0xC0, 0x06, 0xE5, 0xBB, 0xCA, 0x10, 0xFD, 0x9F, 0x1B, 0xCF, 0x42, 0xCA, +0x63, 0x12, 0x78, 0xF4, 0x28, 0x8E, 0x0D, 0x17, 0x19, 0x58, 0x19, 0x1F, 0xF6, 0xC6, 0xDA, 0x5F, +0xBA, 0xCE, 0xD8, 0x9A, 0x44, 0x79, 0x96, 0x93, 0x04, 0xC9, 0xFA, 0x61, 0x4C, 0xF0, 0x52, 0x8D, +0x03, 0x84, 0x58, 0x26, 0x15, 0x10, 0x7D, 0x63, 0x6A, 0x6A, 0x36, 0xF6, 0x29, 0xB5, 0xFA, 0x3A, +0xE1, 0xEF, 0x52, 0x6C, 0x80, 0xFC, 0xD1, 0x98, 0xA3, 0x0B, 0xA2, 0xDE, 0x09, 0xBB, 0x92, 0x31, +0x4E, 0x62, 0x18, 0x91, 0xB1, 0x04, 0xDD, 0xF8, 0xA3, 0xF8, 0xF0, 0xAD, 0x62, 0xFC, 0xCD, 0x66, +0xF2, 0xD7, 0x59, 0x69, 0xE7, 0x80, 0x06, 0xDF, 0x7C, 0x26, 0xA5, 0xF6, 0x74, 0x6D, 0x40, 0x1B, +0x66, 0x20, 0xA1, 0x1D, 0xA4, 0x91, 0xE4, 0x52, 0x2E, 0x5E, 0x2B, 0xAD, 0x10, 0x08, 0x45, 0xB7, +0x1C, 0xA2, 0x28, 0x68, 0xAF, 0x70, 0x98, 0x87, 0x2A, 0xB1, 0x25, 0x55, 0x81, 0x90, 0x39, 0x62, +0x9D, 0x6D, 0xEC, 0x63, 0x43, 0x74, 0x44, 0xCE, 0x7D, 0x2C, 0x7E, 0x3D, 0xB3, 0x27, 0x2D, 0xB1, +0x7B, 0x3D, 0xD5, 0xCF, 0x40, 0x6B, 0x75, 0x05, 0xF5, 0xD0, 0xF4, 0x15, 0x3E, 0x25, 0x10, 0xF2, +0x10, 0x5E, 0x88, 0x97, 0xAF, 0xC2, 0xAD, 0xC3, 0xE9, 0x6B, 0xD9, 0xCE, 0x58, 0x92, 0x8D, 0x32, +0x6C, 0x64, 0xC8, 0x77, 0x94, 0x73, 0xBA, 0x9A, 0xFC, 0xE1, 0x47, 0x39, 0x33, 0x6C, 0x99, 0x6E, +0x72, 0x70, 0xB2, 0x65, 0xDD, 0x24, 0xD2, 0xC5, 0x70, 0x66, 0x05, 0x0A, 0x84, 0xB5, 0x6E, 0xAD, +0x4A, 0xE6, 0x92, 0xBD, 0x9C, 0x45, 0x4A, 0x5A, 0xD4, 0x03, 0xA2, 0xD8, 0x74, 0x3C, 0xFD, 0x50, +0x8A, 0x6B, 0x3F, 0xBD, 0x31, 0x3C, 0x9C, 0x64, 0x47, 0x05, 0x27, 0x16, 0x7F, 0x18, 0x0A, 0xB6, +0x6A, 0x49, 0x51, 0xC4, 0xD7, 0x2D, 0xC5, 0x5A, 0x61, 0x7C, 0x0C, 0x20, 0x2F, 0x96, 0xFD, 0xEE, +0x11, 0x80, 0xF3, 0x71, 0x52, 0x43, 0xFB, 0xFE, 0x2F, 0x8A, 0xE2, 0x56, 0x1B, 0x43, 0x94, 0xF1, +0xA7, 0xC4, 0xA4, 0x4B, 0x12, 0x01, 0x7E, 0x2B, 0x15, 0x17, 0x7C, 0x62, 0xEB, 0x94, 0xB7, 0xBF, +0x87, 0x16, 0x12, 0x26, 0xA9, 0xC2, 0x61, 0x0A, 0x5D, 0xCE, 0xCE, 0x1F, 0xA1, 0xB0, 0xC2, 0x5E, +0x31, 0x29, 0x85, 0x3F, 0x16, 0x81, 0xEC, 0x36, 0x54, 0x43, 0xD0, 0xCD, 0x8F, 0x50, 0xF7, 0x24, +0xCC, 0xB4, 0xC1, 0x07, 0x58, 0xA0, 0x01, 0x53, 0x84, 0x4A, 0xA6, 0x23, 0x1C, 0x46, 0x31, 0x31, +0xC5, 0x24, 0xCC, 0x98, 0xE1, 0xBC, 0x14, 0xAF, 0xF5, 0xF2, 0xFA, 0x21, 0x94, 0xC5, 0x1F, 0x3E, +0x0C, 0x8B, 0x1A, 0x0F, 0x42, 0x31, 0xA4, 0x71, 0x60, 0x07, 0x12, 0x82, 0x98, 0x88, 0xB2, 0xC4, +0x14, 0xDF, 0x66, 0x5D, 0x8B, 0x22, 0x55, 0xE6, 0xA8, 0x4E, 0x93, 0x33, 0x2D, 0x4D, 0xB3, 0xB4, +0xDF, 0xDF, 0xA8, 0xC9, 0xFC, 0x8B, 0xCF, 0xC0, 0x48, 0x11, 0xAF, 0x51, 0x85, 0x9C, 0xD4, 0xE0, +0xB2, 0x80, 0x94, 0x3B, 0x2C, 0xB3, 0xC5, 0x0F, 0xC2, 0x1B, 0xC5, 0x07, 0xC5, 0x6C, 0x6A, 0x78, +0x29, 0xD7, 0x82, 0x21, 0x83, 0xB1, 0xB3, 0xF8, 0x68, 0x4F, 0x12, 0x8F, 0xB2, 0x0E, 0xCC, 0x14, +0x17, 0xB3, 0x2E, 0xE6, 0xAE, 0x37, 0x16, 0x59, 0xE6, 0x4E, 0xBC, 0xD2, 0xE7, 0x10, 0xA8, 0xBC, +0x22, 0x80, 0xCB, 0xF0, 0xB9, 0x93, 0x42, 0xC8, 0xE8, 0x19, 0x15, 0x09, 0x07, 0x7D, 0x00, 0xA5, +0xDC, 0xBA, 0x08, 0x6D, 0x1A, 0x03, 0x45, 0xAC, 0xC5, 0xB0, 0xB1, 0x5D, 0xEA, 0x12, 0xBC, 0x9C, +0x7A, 0xF2, 0xE0, 0xA5, 0x8D, 0xE9, 0x12, 0x34, 0xF5, 0xFB, 0x62, 0x60, 0xF9, 0x69, 0x17, 0x99, +0x76, 0xE3, 0xA6, 0x04, 0x70, 0xF4, 0x88, 0x8E, 0x04, 0x2A, 0xB2, 0x59, 0x1E, 0x60, 0xF8, 0xB6, +0x9A, 0x9A, 0xA4, 0x5F, 0x3F, 0x70, 0xE7, 0x6C, 0x73, 0x7E, 0x22, 0x0C, 0x2D, 0xE9, 0x2C, 0x69, +0x1B, 0xEF, 0xD7, 0x79, 0x71, 0x25, 0xCB, 0x36, 0xBE, 0x14, 0x22, 0x6F, 0xF2, 0xAF, 0xC5, 0x2B, +0x63, 0x3A, 0xCD, 0xAA, 0xEC, 0x01, 0x2D, 0xA5, 0xED, 0x7B, 0x23, 0x96, 0x0C, 0xC2, 0x07, 0x5A, +0x30, 0x56, 0xE1, 0xE1, 0xEF, 0xAC, 0x4F, 0x4C, 0xDE, 0x31, 0x9B, 0x6A, 0x14, 0xD2, 0xE2, 0xCE, +0x93, 0xA4, 0x44, 0x53, 0x51, 0xCC, 0xBF, 0x7D, 0xCC, 0x02, 0xFC, 0x05, 0x09, 0x0F, 0x34, 0xD9, +0xDE, 0x32, 0x82, 0x72, 0x24, 0x26, 0x1E, 0x05, 0xE3, 0xC4, 0x4B, 0x99, 0x8C, 0x3D, 0x0D, 0xF5, +0xF4, 0xF3, 0x68, 0x08, 0xC9, 0xEB, 0x05, 0x38, 0x1F, 0x78, 0xFB, 0xB2, 0xDE, 0xB8, 0xCD, 0x63, +0x87, 0x89, 0x2B, 0xB2, 0x9D, 0xB2, 0x44, 0xD0, 0x34, 0xA1, 0xC6, 0x16, 0x93, 0x8F, 0x1A, 0x9D, +0xEC, 0x30, 0x57, 0x8E, 0x18, 0xDC, 0x64, 0x09, 0xF6, 0x22, 0x2D, 0x5A, 0x56, 0xDF, 0xC6, 0x10, +0x44, 0x09, 0x20, 0x45, 0x6C, 0xA2, 0x84, 0x95, 0xEC, 0x42, 0x34, 0x10, 0xB4, 0xCB, 0x3E, 0x98, +0x41, 0x7B, 0xBC, 0xFA, 0x4F, 0x94, 0x8B, 0x4C, 0x00, 0xA1, 0x33, 0x65, 0xA0, 0xC0, 0xA7, 0xA8, +0x46, 0x86, 0x73, 0x63, 0xF8, 0xB2, 0xD3, 0x8C, 0x80, 0xA0, 0x80, 0x70, 0xBF, 0xA9, 0xEB, 0xAC, +0x81, 0xCE, 0x1D, 0x80, 0x9D, 0xB5, 0xE9, 0xB6, 0x13, 0x1C, 0xC7, 0xA0, 0x0D, 0xD0, 0x93, 0xD8, +0x6D, 0xF6, 0x05, 0x6F, 0x71, 0x9F, 0x9D, 0xB1, 0x06, 0x61, 0x2F, 0xF8, 0xCD, 0xEC, 0xB6, 0x31, +0x9F, 0x13, 0xC7, 0xBC, 0x9C, 0x5A, 0xB6, 0x59, 0x67, 0xA0, 0xE1, 0x83, 0x36, 0x9E, 0x46, 0xBF, +0x3F, 0x4D, 0x1F, 0xEA, 0xE0, 0x58, 0xC1, 0x9B, 0x2F, 0xD9, 0xB5, 0x7A, 0xAD, 0x67, 0x8A, 0x67, +0x72, 0x78, 0xB3, 0xB6, 0xE9, 0x19, 0xCB, 0x37, 0xF8, 0xCC, 0x1F, 0x35, 0x87, 0x66, 0xA7, 0xD9, +0xE1, 0x0D, 0x02, 0x28, 0xB6, 0x84, 0xC8, 0x11, 0x2F, 0x3E, 0x1B, 0xF5, 0xCB, 0x87, 0xB7, 0x11, +0xDE, 0xC0, 0x7D, 0xC1, 0x2E, 0xD5, 0x6B, 0xF4, 0xA1, 0xC1, 0xC3, 0x3F, 0xE6, 0xB8, 0xD4, 0x2C, +0xD2, 0x8C, 0x24, 0x46, 0x7C, 0x1E, 0x10, 0x45, 0xC5, 0x9A, 0xFF, 0x20, 0x23, 0x85, 0xCB, 0x0E, +0xE4, 0x09, 0x34, 0xF7, 0xBA, 0x0A, 0x54, 0x3C, 0x2D, 0x88, 0xE0, 0xC8, 0xC9, 0x4B, 0x18, 0xDE, +0xFE, 0x93, 0x18, 0x1E, 0xE8, 0xE3, 0x40, 0xAB, 0xEB, 0x1D, 0xFD, 0xA0, 0x4E, 0xAF, 0xBF, 0x03, +0x76, 0xA6, 0xF5, 0xC6, 0x41, 0xB7, 0xD1, 0x68, 0xFB, 0xA0, 0x33, 0x52, 0x6F, 0xF5, 0x44, 0x13, +0xF8, 0x43, 0xDB, 0xB0, 0x4E, 0xB2, 0xEF, 0xBF, 0x76, 0x17, 0x9E, 0x9F, 0xD7, 0xE0, 0x9D, 0xE5, +0x60, 0x26, 0xCE, 0x6B, 0x72, 0x0D, 0x83, 0x17, 0xC7, 0x4C, 0x35, 0xD1, 0xE9, 0x43, 0x8E, 0x62, +0xA4, 0x49, 0x9F, 0xFD, 0x82, 0x92, 0x5F, 0x2A, 0xF6, 0x79, 0xBD, 0x49, 0x70, 0xA6, 0xBC, 0x2E, +0xD6, 0xAE, 0x6E, 0x65, 0xE3, 0x88, 0x2A, 0x3A, 0x3E, 0xD7, 0x90, 0xD2, 0x7F, 0x22, 0x60, 0xF1, +0x1A, 0x29, 0x35, 0x87, 0x5E, 0xA6, 0x0C, 0x55, 0xD6, 0x57, 0xB9, 0xF5, 0x68, 0x7C, 0xA2, 0x39, +0x39, 0xC6, 0x8E, 0xD7, 0x9E, 0x97, 0x0B, 0xF0, 0xF1, 0x99, 0x88, 0xA8, 0xEC, 0x1A, 0x0E, 0x38, +0xC3, 0xD0, 0x0F, 0x03, 0xD0, 0xBC, 0x7C, 0x04, 0xB7, 0xA5, 0x0C, 0xC6, 0x47, 0xAB, 0x05, 0x00, +0xD2, 0x3B, 0xDE, 0x25, 0x58, 0x69, 0x14, 0x9C, 0x5B, 0x2F, 0x24, 0xDF, 0x4A, 0x4E, 0x51, 0x00, +0xD6, 0x34, 0xE7, 0x8A, 0x28, 0x03, 0xED, 0x1A, 0x61, 0xD0, 0x43, 0x20, 0x3E, 0x6C, 0x93, 0x42, +0x5E, 0xC6, 0xB0, 0x3E, 0x3D, 0xA4, 0x4F, 0xC4, 0xC2, 0xAC, 0xA1, 0x7C, 0x7A, 0x18, 0x7F, 0x2B, +0x19, 0x88, 0xF8, 0xDA, 0x43, 0x24, 0x42, 0x92, 0x2F, 0x6F, 0x22, 0xCB, 0x5B, 0x4C, 0x67, 0x14, +0x40, 0xC8, 0x1F, 0xA5, 0x60, 0xE2, 0x22, 0x25, 0xC5, 0x45, 0xB8, 0xB8, 0x10, 0x20, 0x1A, 0xE1, +0x16, 0xCF, 0xAD, 0x84, 0xF6, 0xFF, 0xEB, 0xF3, 0x88, 0xB3, 0xE5, 0x30, 0x97, 0x4E, 0x3E, 0x77, +0x21, 0xB1, 0x97, 0x0F, 0x10, 0xFB, 0x72, 0x15, 0x63, 0x6B, 0x39, 0x2C, 0xC7, 0x96, 0x98, 0xFB, +0x40, 0x80, 0x88, 0x2D, 0xF5, 0x0C, 0x89, 0x60, 0x25, 0x7C, 0x8B, 0x9C, 0x66, 0x38, 0xA6, 0x16, +0x7E, 0xBF, 0x3C, 0x24, 0x96, 0xBD, 0x82, 0xAD, 0xB0, 0xDC, 0x64, 0xCD, 0x24, 0x26, 0xC3, 0x39, +0x96, 0x42, 0xD0, 0xB0, 0xA5, 0x04, 0x1D, 0xD2, 0x91, 0x0B, 0x2D, 0x1A, 0xB1, 0xA2, 0x31, 0x3C, +0x2D, 0x25, 0xAC, 0xB0, 0x75, 0xE4, 0x38, 0x11, 0x02, 0x31, 0x47, 0x73, 0x9C, 0x9C, 0xD3, 0x63, +0x83, 0x17, 0xC6, 0x6C, 0x62, 0x88, 0x22, 0x37, 0x08, 0x59, 0x8A, 0xB5, 0x09, 0x1D, 0x84, 0xC1, +0x67, 0x91, 0x59, 0x48, 0x0A, 0x5B, 0xF3, 0xD4, 0xDF, 0xDB, 0x04, 0xE7, 0x43, 0xF8, 0x3E, 0x46, +0xDC, 0x83, 0xE8, 0x7A, 0x9A, 0xED, 0x2E, 0x09, 0xAE, 0x5F, 0x8A, 0xC7, 0x4D, 0xB4, 0x21, 0x81, +0x98, 0x4A, 0xD8, 0xAC, 0x16, 0xC6, 0xA0, 0x60, 0x6A, 0xF9, 0x30, 0x08, 0xC7, 0xEF, 0x8C, 0x92, +0x87, 0x7A, 0x98, 0x10, 0x0B, 0xD9, 0x4B, 0xCF, 0xF6, 0xC7, 0xC4, 0xC9, 0x60, 0x22, 0x59, 0x3E, +0xE4, 0x3C, 0xA6, 0x02, 0x51, 0xDE, 0x3C, 0x5A, 0x05, 0x11, 0x86, 0xB7, 0xBF, 0x5A, 0x29, 0xAA, +0x19, 0x28, 0x14, 0x64, 0x08, 0x16, 0xC9, 0x32, 0xE2, 0x35, 0x25, 0x4D, 0xD5, 0x64, 0x65, 0x8E, +0x46, 0x71, 0xCE, 0x5E, 0x19, 0xE5, 0xB3, 0xB5, 0xC2, 0x24, 0xCE, 0x12, 0x2B, 0xFB, 0x9D, 0x1F, +0x8A, 0x9D, 0x05, 0xEC, 0x0C, 0x0B, 0xC6, 0xC1, 0x83, 0xF3, 0xC3, 0x69, 0x30, 0xB3, 0x07, 0x0F, +0xFE, 0x17, 0x8F, 0xA0, 0x18, 0x01, 0x53, 0xAD, 0x00, 0x00 +}; + + +//File: index_ov3660.html.gz, Size: 8887 +#define index_ov3660_html_gz_len 8887 +const uint8_t index_ov3660_html_gz[] = { + 0x1F, 0x8B, 0x08, 0x08, 0xA3, 0xFA, 0x69, 0x5E, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x5F, + 0x6F, 0x76, 0x33, 0x36, 0x36, 0x30, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xED, 0x3D, 0x69, 0x73, + 0xDB, 0x46, 0xB2, 0xDF, 0xFD, 0x2B, 0x60, 0x24, 0x6B, 0x51, 0x65, 0x91, 0xE2, 0xAD, 0x23, 0x12, + 0xFD, 0x6C, 0x59, 0xB1, 0x53, 0x1B, 0x67, 0xBD, 0x71, 0xE2, 0x24, 0xB5, 0xB5, 0xE5, 0x80, 0xC4, + 0x90, 0x44, 0x0C, 0x02, 0x5C, 0x00, 0xD4, 0x91, 0x94, 0x7E, 0xC7, 0xFB, 0x41, 0xEF, 0x8F, 0xBD, + 0xEE, 0x39, 0x70, 0x71, 0x00, 0x0C, 0x00, 0x11, 0x52, 0xF2, 0x1E, 0x5D, 0x65, 0xE1, 0x98, 0xEE, + 0xE9, 0x7B, 0x7A, 0x7A, 0x06, 0xC0, 0xD9, 0x53, 0xD3, 0x9D, 0x05, 0xB7, 0x6B, 0xA2, 0x2D, 0x83, + 0x95, 0x3D, 0x79, 0x72, 0xC6, 0xFE, 0x68, 0xF0, 0x3B, 0x5B, 0x12, 0xC3, 0x64, 0x87, 0xF4, 0x74, + 0x45, 0x02, 0x43, 0x9B, 0x2D, 0x0D, 0xCF, 0x27, 0xC1, 0xB9, 0xBE, 0x09, 0xE6, 0xED, 0x63, 0x3D, + 0x7D, 0xDB, 0x31, 0x56, 0xE4, 0x5C, 0xBF, 0xB2, 0xC8, 0xF5, 0xDA, 0xF5, 0x02, 0x5D, 0x9B, 0xB9, + 0x4E, 0x40, 0x1C, 0x68, 0x7E, 0x6D, 0x99, 0xC1, 0xF2, 0xDC, 0x24, 0x57, 0xD6, 0x8C, 0xB4, 0xE9, + 0xC9, 0x81, 0xE5, 0x58, 0x81, 0x65, 0xD8, 0x6D, 0x7F, 0x66, 0xD8, 0xE4, 0xBC, 0x17, 0xC7, 0x15, + 0x58, 0x81, 0x4D, 0x26, 0x97, 0x1F, 0xDE, 0x0F, 0xFA, 0xDA, 0x3F, 0x3E, 0x0E, 0xC6, 0xE3, 0xEE, + 0xD9, 0x21, 0xBB, 0x16, 0xB5, 0xF1, 0x83, 0xDB, 0xF8, 0x39, 0xFE, 0xA6, 0xAE, 0x79, 0xAB, 0xFD, + 0x91, 0xB8, 0x84, 0xBF, 0x39, 0x10, 0xD1, 0x9E, 0x1B, 0x2B, 0xCB, 0xBE, 0x3D, 0xD5, 0x5E, 0x7A, + 0xD0, 0xE7, 0xC1, 0x5B, 0x62, 0x5F, 0x91, 0xC0, 0x9A, 0x19, 0x07, 0xBE, 0xE1, 0xF8, 0x6D, 0x9F, + 0x78, 0xD6, 0xFC, 0xAB, 0x2D, 0xC0, 0xA9, 0x31, 0xFB, 0xBC, 0xF0, 0xDC, 0x8D, 0x63, 0x9E, 0x6A, + 0x5F, 0xF4, 0x8E, 0xF1, 0xDF, 0x76, 0xA3, 0x99, 0x6B, 0xBB, 0x1E, 0xDC, 0xBF, 0xFC, 0x1A, 0xFF, + 0x6D, 0xDF, 0xA7, 0xBD, 0xFB, 0xD6, 0xEF, 0xE4, 0x54, 0xEB, 0x8D, 0xD7, 0x37, 0x89, 0xFB, 0x77, + 0x4F, 0x12, 0xA7, 0xCB, 0x7E, 0x16, 0xF5, 0x1C, 0xFE, 0x38, 0x1F, 0xDE, 0x27, 0xB3, 0xC0, 0x72, + 0x9D, 0xCE, 0xCA, 0xB0, 0x1C, 0x09, 0x26, 0xD3, 0xF2, 0xD7, 0xB6, 0x01, 0x32, 0x98, 0xDB, 0x24, + 0x17, 0xCF, 0x17, 0x2B, 0xE2, 0x6C, 0x0E, 0x0A, 0xB0, 0x21, 0x92, 0xB6, 0x69, 0x79, 0xAC, 0xD5, + 0x29, 0xCA, 0x61, 0xB3, 0x72, 0x0A, 0xD1, 0xE6, 0xD1, 0xE5, 0xB8, 0x0E, 0x91, 0x08, 0x10, 0x3B, + 0xBA, 0xF6, 0x8C, 0x35, 0x36, 0xC0, 0xBF, 0xDB, 0x4D, 0x56, 0x96, 0xC3, 0x8C, 0xEA, 0x54, 0x1B, + 0x0C, 0xBB, 0xEB, 0x9B, 0x02, 0x55, 0x0E, 0xC6, 0xF8, 0x6F, 0xBB, 0xD1, 0xDA, 0x30, 0x4D, 0xCB, + 0x59, 0x9C, 0x6A, 0xC7, 0x52, 0x14, 0xAE, 0x67, 0x12, 0xAF, 0xED, 0x19, 0xA6, 0xB5, 0xF1, 0x4F, + 0xB5, 0xA1, 0xAC, 0xCD, 0xCA, 0xF0, 0x16, 0x40, 0x4B, 0xE0, 0x02, 0xB1, 0xED, 0x9E, 0x94, 0x12, + 0xDE, 0xC4, 0xB3, 0x16, 0xCB, 0x00, 0x54, 0xBA, 0xD5, 0x26, 0x2D, 0x34, 0xEE, 0x42, 0x45, 0xFA, + 0xCC, 0x95, 0x9B, 0x5C, 0x6A, 0x86, 0x6D, 0x2D, 0x9C, 0xB6, 0x15, 0x90, 0x15, 0xB0, 0xE3, 0x07, + 0x1E, 0x09, 0x66, 0xCB, 0x3C, 0x52, 0xE6, 0xD6, 0x62, 0xE3, 0x11, 0x09, 0x21, 0xA1, 0xDC, 0x72, + 0x18, 0x86, 0x9B, 0xDB, 0xB7, 0xDA, 0xD7, 0x64, 0xFA, 0xD9, 0x0A, 0xDA, 0x5C, 0x26, 0x53, 0x32, + 0x77, 0x3D, 0x22, 0x6D, 0x29, 0x5A, 0xD8, 0xEE, 0xEC, 0x73, 0xDB, 0x0F, 0x0C, 0x2F, 0x50, 0x41, + 0x68, 0xCC, 0x03, 0xE2, 0x15, 0xE3, 0x23, 0x68, 0x15, 0xC5, 0xD8, 0xB2, 0xBB, 0xE5, 0x0D, 0x2C, + 0xC7, 0xB6, 0x1C, 0xA2, 0x4E, 0x5E, 0x56, 0xBF, 0x49, 0x74, 0xAC, 0x95, 0x82, 0x62, 0xAC, 0xD5, + 0x22, 0xCF, 0x4A, 0x28, 0xAF, 0xDB, 0x9D, 0x71, 0xBF, 0xE9, 0x75, 0xBB, 0x7F, 0xDB, 0xBE, 0xB9, + 0x24, 0xCC, 0x4C, 0x8D, 0x4D, 0xE0, 0xD6, 0xF7, 0x88, 0x2D, 0xB7, 0x4A, 0xF1, 0xF1, 0x5F, 0x2B, + 0x62, 0x5A, 0x86, 0xD6, 0x8A, 0xB9, 0xF3, 0x71, 0x17, 0x6C, 0x6A, 0x5F, 0x33, 0x1C, 0x53, 0x6B, + 0xB9, 0x9E, 0x05, 0x8E, 0x60, 0xD0, 0x70, 0x63, 0xC3, 0x15, 0x18, 0x38, 0xD6, 0x64, 0x5F, 0xC2, + 0x72, 0x8E, 0xCF, 0xC4, 0x25, 0x22, 0x77, 0x1B, 0xFC, 0x29, 0x84, 0x1C, 0xFC, 0x15, 0x3A, 0x90, + 0x84, 0x47, 0x8A, 0x3E, 0x4F, 0x5F, 0x71, 0x0A, 0xB3, 0x74, 0x86, 0xBF, 0x95, 0x71, 0xD3, 0xCE, + 0xD5, 0x9D, 0x68, 0x24, 0x74, 0x08, 0xC3, 0xEC, 0xAC, 0x05, 0x4D, 0xAF, 0x96, 0x5A, 0x5B, 0xC3, + 0x28, 0xB9, 0x2F, 0x87, 0xE1, 0x48, 0xE5, 0x2A, 0xC7, 0x5F, 0xDC, 0x28, 0x4A, 0xB0, 0x2B, 0x67, + 0x35, 0x8A, 0x1D, 0xEC, 0x9F, 0xCC, 0x86, 0x18, 0x27, 0x99, 0x51, 0x04, 0x7F, 0xEA, 0x91, 0x24, + 0x42, 0x56, 0x18, 0x4D, 0x24, 0x88, 0xB3, 0x23, 0xCA, 0x16, 0xDE, 0x2C, 0xEF, 0x96, 0x60, 0xCD, + 0x27, 0x41, 0x35, 0xBA, 0x48, 0x10, 0xE7, 0xD1, 0x50, 0x18, 0x65, 0xF0, 0x77, 0xA7, 0x90, 0x6F, + 0x7C, 0x31, 0xDD, 0x04, 0x81, 0xEB, 0xF8, 0xB5, 0x86, 0xA8, 0x2C, 0x3F, 0xFB, 0x6D, 0xE3, 0x07, + 0xD6, 0xFC, 0xB6, 0xCD, 0x5D, 0x1A, 0xFC, 0x6C, 0x6D, 0x40, 0x0A, 0x39, 0x25, 0xC1, 0x35, 0x21, + 0xF9, 0xE9, 0x86, 0x63, 0x5C, 0x41, 0xDC, 0x59, 0x2C, 0x6C, 0x99, 0xED, 0xCD, 0x36, 0x9E, 0x8F, + 0x79, 0xDB, 0xDA, 0xB5, 0x00, 0xB1, 0xB7, 0xDD, 0x71, 0xD2, 0x07, 0x15, 0x3B, 0x6A, 0xCF, 0xA6, + 0x92, 0xBE, 0xDC, 0x4D, 0x80, 0x32, 0x96, 0x6A, 0xC2, 0x05, 0x76, 0xAC, 0xE0, 0x56, 0x7A, 0x8F, + 0x7B, 0xA2, 0xE4, 0x8E, 0x70, 0xC1, 0xDC, 0x61, 0x21, 0x49, 0xD7, 0xE9, 0x6C, 0x49, 0x66, 0x9F, + 0x89, 0xF9, 0xBC, 0x30, 0x0D, 0x2B, 0x4A, 0x0F, 0x3B, 0x96, 0xB3, 0xDE, 0x04, 0x6D, 0x4C, 0xA7, + 0xD6, 0x3B, 0xD1, 0x39, 0x35, 0x48, 0xC1, 0x62, 0xBF, 0x9F, 0x97, 0x54, 0x8C, 0xD6, 0x37, 0xF9, + 0x42, 0x88, 0x13, 0x3B, 0xB1, 0x8D, 0x29, 0xB1, 0xF3, 0x48, 0xE6, 0xCE, 0x90, 0x11, 0x76, 0x79, + 0xAC, 0xCA, 0xCE, 0xDD, 0x28, 0x65, 0xD1, 0xE0, 0x35, 0x3C, 0xFA, 0x9B, 0xB2, 0x1C, 0xE9, 0xF1, + 0x41, 0xE2, 0x92, 0x4F, 0x6C, 0x70, 0xB0, 0xAC, 0xD4, 0x1B, 0xDA, 0x5C, 0x03, 0x0D, 0xB9, 0x1D, + 0x78, 0x86, 0xB3, 0x20, 0x10, 0x0B, 0x6E, 0x0E, 0xC4, 0x61, 0xFE, 0xC4, 0x40, 0x89, 0x7D, 0x0C, + 0xD5, 0xA3, 0xFC, 0x89, 0x08, 0x0B, 0x08, 0x07, 0x5A, 0x87, 0x1D, 0x54, 0xC8, 0x4A, 0x62, 0xFA, + 0xCD, 0x25, 0xA4, 0x27, 0xB5, 0x0E, 0x96, 0x98, 0x48, 0x3D, 0x27, 0x69, 0x5B, 0xD2, 0x44, 0xBF, + 0x30, 0x34, 0x88, 0x29, 0xDF, 0x7C, 0x5E, 0x34, 0x69, 0x9C, 0xCF, 0x07, 0xDD, 0xC1, 0xB0, 0x30, + 0x73, 0x92, 0x72, 0x99, 0x9A, 0x38, 0x4A, 0x42, 0x47, 0x18, 0x56, 0x72, 0x8D, 0xC0, 0x37, 0xAE, + 0xA4, 0x49, 0xBB, 0xEB, 0x5B, 0x6C, 0xE6, 0x66, 0x4C, 0x7D, 0x98, 0xBB, 0x05, 0x92, 0xA9, 0x17, + 0x37, 0xF4, 0xBE, 0x94, 0x3E, 0x9A, 0xD2, 0x49, 0x5D, 0x40, 0x88, 0x57, 0x4E, 0x76, 0x42, 0x03, + 0xF2, 0x26, 0x31, 0x05, 0x4B, 0x93, 0xCA, 0x80, 0xDC, 0x04, 0x6D, 0x93, 0xCC, 0x5C, 0x8F, 0x65, + 0x83, 0x19, 0x33, 0xC7, 0x94, 0x22, 0x8B, 0x2D, 0xF6, 0x74, 0xE9, 0x5E, 0x11, 0x4F, 0x22, 0xAC, + 0x94, 0x52, 0x87, 0x27, 0x43, 0x53, 0x01, 0x9B, 0x01, 0xC3, 0xA3, 0x54, 0xF6, 0x49, 0x74, 0xFD, + 0xDE, 0xAC, 0x9F, 0xEB, 0xC7, 0x0C, 0x5D, 0x07, 0x7C, 0xC6, 0x98, 0xDA, 0xC4, 0xCC, 0x19, 0xCD, + 0x4C, 0x32, 0x37, 0x36, 0x76, 0x50, 0x60, 0x95, 0x46, 0x17, 0xFF, 0xE5, 0xF5, 0x48, 0xC3, 0xD0, + 0xBF, 0xB0, 0x2E, 0x74, 0x4E, 0x03, 0xC7, 0xBF, 0x25, 0x7D, 0x8A, 0x54, 0xC3, 0x58, 0xAF, 0x89, + 0x01, 0xAD, 0x66, 0x24, 0x4B, 0x0F, 0x4A, 0x53, 0x0C, 0x79, 0x9C, 0x57, 0x9A, 0xB7, 0x17, 0x3A, + 0x6C, 0x98, 0x3C, 0x96, 0xE2, 0xF9, 0x74, 0xEE, 0xCE, 0x36, 0xB2, 0xAC, 0x46, 0xCD, 0xF1, 0xB6, + 0xF1, 0x9D, 0x0A, 0x91, 0xF9, 0xB6, 0x45, 0xDD, 0x7F, 0xE3, 0x38, 0xA8, 0xD1, 0x76, 0xE0, 0x01, + 0x9B, 0x92, 0x8E, 0xD4, 0x04, 0x57, 0x29, 0x86, 0x25, 0x04, 0x9B, 0x55, 0xBB, 0x4A, 0x85, 0x29, + 0x49, 0x38, 0x0D, 0x23, 0xAD, 0x06, 0x31, 0xC4, 0x32, 0x05, 0xAA, 0x7A, 0x72, 0x09, 0x96, 0x9B, + 0x95, 0x2C, 0x8F, 0x12, 0x9D, 0xF5, 0x60, 0xD0, 0x67, 0xDD, 0x79, 0x8B, 0xA9, 0xD1, 0xEA, 0x1E, + 0x74, 0x0F, 0x06, 0xF0, 0x9F, 0x64, 0x3E, 0x93, 0x6F, 0x5C, 0x5C, 0xBC, 0x19, 0x96, 0x97, 0x0A, + 0xD1, 0xC5, 0x65, 0xA5, 0xAC, 0x60, 0x5F, 0xA8, 0x0B, 0x75, 0x4F, 0x4A, 0xD6, 0x97, 0x7A, 0x9D, + 0x82, 0x71, 0x38, 0xC3, 0xA4, 0xCB, 0x1B, 0xA2, 0xC4, 0x5A, 0xCA, 0xAA, 0x78, 0xE5, 0xFE, 0xDE, + 0x66, 0x49, 0xC8, 0xFF, 0x79, 0x6B, 0x8F, 0x89, 0xE2, 0x2F, 0x6D, 0xE9, 0xA5, 0xE5, 0xE2, 0x3F, + 0xB4, 0x6D, 0x74, 0xB3, 0xB5, 0xDE, 0xE6, 0x59, 0x1F, 0x50, 0xE8, 0xC0, 0x1C, 0xD4, 0x83, 0xC9, + 0x68, 0x66, 0x66, 0x18, 0x6B, 0x53, 0x41, 0x06, 0x73, 0xCB, 0xB6, 0xDB, 0xB6, 0x7B, 0x5D, 0x9C, + 0x89, 0xE4, 0x5B, 0xF2, 0x96, 0x9D, 0x16, 0x9B, 0x7C, 0x55, 0x6A, 0x37, 0x10, 0xB9, 0xFE, 0x14, + 0xD4, 0xFE, 0xB5, 0x1D, 0x2E, 0xD7, 0x35, 0xAA, 0x0D, 0x14, 0x15, 0xEC, 0xB1, 0x5E, 0x47, 0x4A, + 0xA6, 0xC4, 0x32, 0xC1, 0xFC, 0x69, 0xCF, 0xB5, 0x15, 0xCC, 0x96, 0x15, 0xA6, 0x9E, 0xD1, 0xC4, + 0xC8, 0x23, 0xB6, 0x81, 0x19, 0x7C, 0xA5, 0x0A, 0x45, 0xE1, 0xF4, 0x2D, 0x0E, 0xAE, 0xC2, 0x09, + 0x15, 0xDD, 0xE3, 0xA9, 0x2E, 0x75, 0x58, 0xEE, 0x90, 0x1D, 0xAB, 0xE5, 0x66, 0x5D, 0x90, 0xEE, + 0x27, 0x3D, 0x43, 0xDE, 0xA8, 0x44, 0x44, 0x17, 0x41, 0x7B, 0xE1, 0x91, 0x5B, 0x05, 0x66, 0x0E, + 0xF8, 0xDF, 0x53, 0x56, 0x3F, 0xAE, 0x5E, 0x2A, 0xA1, 0x03, 0x00, 0xB7, 0xA2, 0xCE, 0xD0, 0x57, + 0xE8, 0x3A, 0xBB, 0x4B, 0x15, 0x7B, 0x0C, 0xAB, 0xA3, 0xBA, 0xAE, 0x10, 0x6E, 0x72, 0x86, 0x50, + 0xB9, 0xA9, 0x8A, 0xD1, 0x57, 0x3E, 0x9F, 0x27, 0xF3, 0x20, 0x63, 0xF1, 0x87, 0xE6, 0xA9, 0x83, + 0xFC, 0xE8, 0xD6, 0x8E, 0x55, 0x53, 0x0A, 0x23, 0x47, 0x58, 0xC4, 0xCC, 0xB6, 0x3E, 0x29, 0x66, + 0x8C, 0x9E, 0xA5, 0x91, 0x67, 0xAB, 0x44, 0xA4, 0xCF, 0x54, 0xCD, 0xD0, 0x66, 0xC5, 0x87, 0x7C, + 0x50, 0x0F, 0xF9, 0xB9, 0xD5, 0x1F, 0x4B, 0xD7, 0x56, 0x72, 0x1A, 0xE7, 0x91, 0x96, 0x59, 0x05, + 0xDC, 0x1E, 0xB2, 0x32, 0x27, 0xC8, 0xF1, 0x58, 0x24, 0x55, 0x54, 0xBE, 0x57, 0xE6, 0x45, 0x98, + 0xED, 0x4A, 0x56, 0xAE, 0xB1, 0x5B, 0x2B, 0x03, 0xD2, 0x5E, 0x34, 0x57, 0x03, 0x30, 0xCA, 0xF4, + 0xA7, 0x62, 0xEE, 0xB1, 0x1A, 0x6B, 0x6F, 0xDC, 0x2D, 0xE8, 0x72, 0x66, 0xBB, 0x7E, 0xCD, 0x02, + 0x58, 0x76, 0xFD, 0x4B, 0x7A, 0x47, 0x69, 0xE8, 0xCE, 0xF5, 0xA9, 0x7C, 0x77, 0x4C, 0xC9, 0xBC, + 0xD7, 0x95, 0x46, 0xDA, 0xDC, 0x2A, 0x25, 0xAD, 0xA0, 0xD1, 0xF5, 0xCB, 0x53, 0x6D, 0x46, 0xE4, + 0x61, 0x34, 0x59, 0xA8, 0x53, 0x29, 0x95, 0xE6, 0xEA, 0x61, 0x69, 0x99, 0x26, 0xC9, 0xAD, 0x05, + 0xE3, 0x9C, 0x57, 0x31, 0x79, 0x40, 0xFA, 0x65, 0x45, 0xA9, 0x9D, 0x38, 0x45, 0xEE, 0xB6, 0x86, + 0xDE, 0xAE, 0x3D, 0x86, 0x0F, 0x34, 0x59, 0x95, 0xF4, 0x64, 0x2A, 0x92, 0x4B, 0xAA, 0xD4, 0xB9, + 0xC3, 0x5A, 0x2B, 0x8A, 0x0C, 0xE4, 0x80, 0xAD, 0xB6, 0xA3, 0x79, 0x8A, 0x2A, 0xBA, 0x90, 0xD2, + 0xE1, 0x6B, 0x4B, 0x7C, 0x19, 0xB0, 0x9D, 0xB5, 0xBA, 0x72, 0x8F, 0x4B, 0x6D, 0xD4, 0x02, 0xD2, + 0xFD, 0x66, 0x8A, 0xE6, 0x81, 0x32, 0xA3, 0x1C, 0x22, 0xC3, 0x21, 0x46, 0x6C, 0xAE, 0x4A, 0xB6, + 0x2A, 0xEB, 0x1C, 0xE1, 0xF9, 0xD9, 0x61, 0x6C, 0x3B, 0xDC, 0xD9, 0x61, 0xB4, 0x73, 0xEF, 0x0C, + 0xF7, 0xC4, 0xC5, 0x77, 0xCD, 0xF1, 0x8E, 0x66, 0xB6, 0xE1, 0xFB, 0xE7, 0x3A, 0xEE, 0xED, 0xD2, + 0x93, 0x9B, 0xE8, 0xCE, 0x4C, 0xEB, 0x4A, 0xB3, 0xCC, 0x73, 0xDD, 0x76, 0x17, 0x6E, 0xEA, 0x1E, + 0xBD, 0xCF, 0xD4, 0x0C, 0x03, 0xD9, 0xB9, 0x9E, 0x58, 0x60, 0xD4, 0x29, 0x54, 0x74, 0x49, 0x9F, + 0x3C, 0xFB, 0xE2, 0xE4, 0xE8, 0x68, 0xFC, 0xD5, 0x33, 0x67, 0xEA, 0xAF, 0xF9, 0xFF, 0x3F, 0xB0, + 0xF5, 0x58, 0xB6, 0xA9, 0x0F, 0xC6, 0xB6, 0x20, 0x00, 0xDB, 0xF3, 0xCF, 0x0E, 0x29, 0xD2, 0x14, + 0x21, 0x87, 0x40, 0x49, 0x06, 0x6D, 0x3C, 0xDF, 0x91, 0x91, 0x27, 0x9A, 0xF8, 0x30, 0x84, 0x4F, + 0x0D, 0x4F, 0xD2, 0x84, 0x36, 0x63, 0xD9, 0x34, 0x8D, 0x25, 0x3A, 0x55, 0xCA, 0xD4, 0xBD, 0x49, + 0x73, 0x40, 0x99, 0xE2, 0x1A, 0xE3, 0xAD, 0x88, 0x99, 0x85, 0x10, 0xC0, 0x28, 0x38, 0xAE, 0xAE, + 0x42, 0x1B, 0x69, 0xA3, 0x84, 0x0A, 0xB0, 0xF1, 0xCD, 0xCC, 0xFE, 0x2C, 0x94, 0xAF, 0x0B, 0xA5, + 0x38, 0x6E, 0xC0, 0x62, 0x65, 0x46, 0x57, 0x09, 0x56, 0x39, 0x4C, 0x6C, 0xDD, 0x90, 0x71, 0x01, + 0xA2, 0x6D, 0x53, 0xEC, 0xEC, 0x5A, 0x3E, 0x26, 0x8A, 0x2D, 0xA6, 0x57, 0x01, 0xAC, 0x4F, 0x7E, + 0xBE, 0xF8, 0xF6, 0xEF, 0xDA, 0xBB, 0xB7, 0xBF, 0x4B, 0x35, 0x54, 0x44, 0x14, 0x06, 0x69, 0x85, + 0x9E, 0x29, 0x18, 0xD3, 0x87, 0x90, 0x89, 0xCE, 0x35, 0x43, 0x31, 0xE0, 0x70, 0x6F, 0x13, 0x67, + 0x11, 0x2C, 0xCF, 0xF5, 0x9E, 0x8E, 0x7B, 0x5A, 0xC4, 0x59, 0x5F, 0xD7, 0x30, 0x80, 0xD3, 0x83, + 0x2B, 0xC3, 0xDE, 0xE0, 0x51, 0x57, 0x85, 0xD7, 0x6D, 0xD3, 0x92, 0x36, 0xE3, 0x91, 0x25, 0x94, + 0x71, 0x2C, 0x12, 0x27, 0xA5, 0xAC, 0x4F, 0x3E, 0x90, 0xE0, 0xEC, 0x90, 0xDD, 0x2A, 0xD0, 0x5A, + 0x7E, 0xDF, 0xE0, 0xC9, 0xCC, 0x1C, 0xF2, 0x4C, 0x28, 0x4F, 0xF1, 0x73, 0xCF, 0x58, 0x11, 0x94, + 0x8A, 0x92, 0xE6, 0xE3, 0x5A, 0x0F, 0x21, 0xF5, 0xC9, 0xF7, 0x84, 0x66, 0x44, 0x40, 0x86, 0x92, + 0xE2, 0xCF, 0x78, 0x92, 0x9A, 0xE8, 0x3F, 0xB4, 0x67, 0xBE, 0x28, 0xD5, 0x36, 0x98, 0x99, 0x2B, + 0xC8, 0xFD, 0x69, 0xBB, 0xAD, 0x0D, 0xDE, 0xBD, 0xD7, 0xDA, 0x6D, 0x85, 0xC6, 0xEE, 0x9A, 0xBA, + 0x13, 0xD7, 0x7F, 0xEF, 0x48, 0x9F, 0xFC, 0xF3, 0xE7, 0x37, 0x2F, 0x5B, 0xFD, 0xEE, 0xF0, 0xF8, + 0xA6, 0x37, 0x1A, 0x0F, 0xF7, 0xCF, 0x0E, 0x59, 0x93, 0xF2, 0xB8, 0xC6, 0xFA, 0xE4, 0x3D, 0x12, + 0xD2, 0x3A, 0x1E, 0x0F, 0xEB, 0xE2, 0x1A, 0x21, 0xAE, 0xB7, 0xAF, 0x5B, 0x47, 0xFD, 0xEE, 0x4D, + 0xAF, 0x7F, 0xDC, 0xAD, 0x81, 0x6A, 0xA8, 0x4F, 0xBE, 0x06, 0x4C, 0xBD, 0x13, 0x44, 0xD5, 0x2D, + 0x87, 0x0A, 0x45, 0xDB, 0xAF, 0x28, 0xDA, 0x81, 0x3E, 0xF9, 0x11, 0x45, 0x0B, 0x39, 0x37, 0xF2, + 0xD0, 0xAD, 0xC3, 0x43, 0x1F, 0x5C, 0x86, 0xE2, 0x02, 0x51, 0x00, 0x13, 0xFD, 0x3A, 0xA2, 0xED, + 0xE9, 0x13, 0x14, 0x07, 0x62, 0x02, 0xE9, 0xD6, 0x40, 0x04, 0xB1, 0x83, 0xD2, 0x04, 0xE4, 0xDC, + 0x1C, 0x8D, 0x8F, 0xAB, 0x63, 0x3A, 0x01, 0xEE, 0x3E, 0x02, 0xA6, 0x63, 0x10, 0xD4, 0xB8, 0x8E, + 0x9C, 0x8E, 0xF5, 0x09, 0xE2, 0x19, 0x0F, 0xBB, 0x37, 0xC3, 0x3A, 0x36, 0x03, 0x5E, 0xF1, 0x16, + 0x11, 0x01, 0x92, 0x9B, 0x41, 0x1D, 0x19, 0x81, 0x4B, 0x5C, 0x7C, 0xF3, 0x75, 0x6B, 0x08, 0x8C, + 0xF5, 0x4F, 0xC6, 0xD5, 0xF1, 0x80, 0x3B, 0xFC, 0x13, 0x09, 0x02, 0x62, 0x6E, 0xFA, 0xC3, 0x1A, + 0x04, 0x81, 0x33, 0x00, 0x3C, 0xE2, 0xA8, 0x8C, 0x02, 0xEC, 0xFA, 0x2D, 0x25, 0x06, 0x11, 0xF5, + 0x8E, 0x6A, 0x70, 0x05, 0x56, 0xFD, 0x4F, 0x14, 0x0F, 0x20, 0xB9, 0xE9, 0x0D, 0xEB, 0xD8, 0x34, + 0x20, 0xA2, 0x24, 0x81, 0xAF, 0xA1, 0xAB, 0x55, 0xC7, 0x04, 0x36, 0x7D, 0x32, 0xBE, 0x39, 0x19, + 0xAB, 0x21, 0xC0, 0xE1, 0x07, 0x43, 0x79, 0xDE, 0x00, 0x95, 0x3F, 0x7E, 0xE5, 0x8D, 0x4D, 0xFF, + 0xD9, 0xC0, 0x94, 0x33, 0xB8, 0x2D, 0x3D, 0x32, 0x71, 0x38, 0x90, 0x09, 0x3B, 0x50, 0x1B, 0x94, + 0x62, 0x94, 0x84, 0xBB, 0x9F, 0xF4, 0xC9, 0x50, 0x61, 0xF0, 0x4F, 0x64, 0x87, 0x14, 0x36, 0x41, + 0x3F, 0xCD, 0x48, 0xD0, 0xF2, 0x30, 0x17, 0x01, 0x97, 0x18, 0xE8, 0xB1, 0x08, 0x52, 0x69, 0xD4, + 0x93, 0xD0, 0x6A, 0xDC, 0xE8, 0x93, 0xF1, 0xA0, 0x30, 0x5B, 0xA8, 0xAE, 0x8C, 0x29, 0x2D, 0x6E, + 0x38, 0xC4, 0xF7, 0x4B, 0xEB, 0x23, 0x02, 0xD5, 0x27, 0xAF, 0xC2, 0xE3, 0x3A, 0x5A, 0x69, 0x17, + 0x71, 0x4A, 0x61, 0x33, 0xD4, 0x12, 0x23, 0x87, 0x69, 0xA6, 0x3D, 0xE0, 0xAA, 0x89, 0x34, 0x73, + 0xBF, 0x8A, 0xD9, 0xA5, 0x5E, 0x70, 0x6E, 0xE3, 0x19, 0x7E, 0x50, 0x5A, 0x2B, 0x02, 0x10, 0x22, + 0x34, 0x3F, 0x7A, 0x30, 0x8D, 0x84, 0xA4, 0xFC, 0x05, 0xF4, 0xE1, 0x1B, 0xC1, 0x86, 0xED, 0x33, + 0x2B, 0xAD, 0x91, 0x08, 0x14, 0xF2, 0x81, 0xF0, 0xB8, 0x96, 0x56, 0xEA, 0x84, 0xAF, 0x18, 0x39, + 0x5C, 0x2F, 0x22, 0x84, 0x0D, 0x77, 0xA4, 0x97, 0x22, 0x6A, 0x6B, 0xE9, 0x65, 0x69, 0x78, 0xEB, + 0x4A, 0xE1, 0x2B, 0x84, 0x04, 0xAD, 0x88, 0xC3, 0x07, 0x73, 0x95, 0x88, 0x98, 0xBF, 0x80, 0xAF, + 0x98, 0xC4, 0x71, 0x2D, 0xBF, 0xFC, 0xD4, 0x93, 0xC3, 0xE9, 0x93, 0xD7, 0xA4, 0xFD, 0x1D, 0x1E, + 0xD5, 0x51, 0xC7, 0xCB, 0x4D, 0xE0, 0xD6, 0x50, 0x88, 0xA0, 0x85, 0xA9, 0xA3, 0xCB, 0xB5, 0x71, + 0xBC, 0x23, 0x6D, 0x1C, 0xEF, 0x50, 0x1B, 0x06, 0xF9, 0x64, 0x93, 0x2B, 0x62, 0x97, 0x56, 0x87, + 0x00, 0xD4, 0x27, 0x97, 0x37, 0x6B, 0xD7, 0xC7, 0xA7, 0x77, 0xBE, 0xC5, 0xF3, 0x5A, 0x4E, 0x32, + 0xAA, 0xA1, 0x93, 0x90, 0x20, 0xEE, 0x23, 0x23, 0xAE, 0x95, 0xD1, 0x8E, 0xB4, 0x52, 0x44, 0x6B, + 0x1D, 0xAD, 0x2C, 0x0C, 0xCB, 0x99, 0x11, 0xCB, 0xC6, 0x27, 0x09, 0xCA, 0x2A, 0x26, 0x06, 0xAB, + 0x4F, 0xDE, 0x44, 0x27, 0x75, 0x14, 0xD3, 0xAD, 0xA1, 0x97, 0x38, 0x3D, 0x49, 0x7F, 0x19, 0xC1, + 0xAC, 0x7C, 0x47, 0xBA, 0xE9, 0xF5, 0x76, 0x39, 0xAA, 0xAC, 0xC9, 0xCC, 0x32, 0xEC, 0x4F, 0x64, + 0x3E, 0x87, 0x69, 0x50, 0xF9, 0xA1, 0x25, 0x01, 0x0E, 0xE3, 0x0B, 0x3B, 0xD7, 0x2E, 0xE9, 0x79, + 0xE9, 0x62, 0x5A, 0x0A, 0x5D, 0xF5, 0x8A, 0x5A, 0x7A, 0x4E, 0xC8, 0x97, 0x95, 0x09, 0xAD, 0x61, + 0xB2, 0x23, 0x7D, 0xF2, 0x9D, 0x1B, 0xD2, 0x59, 0x7D, 0xDA, 0xFA, 0x1D, 0x59, 0xD0, 0x55, 0xDB, + 0x3A, 0x73, 0xE8, 0x37, 0x9E, 0x71, 0x4B, 0x5F, 0x0B, 0x50, 0x67, 0x4A, 0xFF, 0x3D, 0x31, 0xB5, + 0x1F, 0x2C, 0xA7, 0x3A, 0x33, 0x43, 0x24, 0x84, 0x10, 0xA7, 0x1E, 0x96, 0x11, 0x4C, 0x91, 0xE0, + 0xA0, 0x1E, 0x92, 0x31, 0x16, 0x98, 0xD7, 0x96, 0xF1, 0x18, 0x26, 0xF1, 0xC6, 0xF5, 0xB4, 0xFC, + 0x80, 0x72, 0x3D, 0x85, 0x71, 0xF9, 0xA7, 0x57, 0xDA, 0x25, 0xDD, 0x67, 0x5C, 0x3A, 0x5C, 0xB1, + 0x2D, 0x50, 0x2A, 0x86, 0x1E, 0xAD, 0x23, 0x60, 0x9F, 0x5B, 0x0B, 0x3C, 0x72, 0x07, 0x52, 0x5D, + 0xE4, 0x91, 0xB0, 0x27, 0x08, 0xA4, 0x3B, 0x46, 0xF4, 0x18, 0xB7, 0x6A, 0x3C, 0xEE, 0x30, 0x15, + 0x9B, 0x5D, 0x97, 0x4F, 0xC3, 0x66, 0xD7, 0xA0, 0x26, 0xF3, 0x0A, 0xB7, 0xA0, 0x9B, 0x1A, 0xE8, + 0xAB, 0x11, 0x45, 0x61, 0xAF, 0x0F, 0xA3, 0x28, 0xCA, 0xEF, 0x43, 0x2B, 0x0A, 0xAC, 0xE5, 0x13, + 0x8E, 0xA3, 0x55, 0x9C, 0x8A, 0x02, 0xEA, 0x93, 0x77, 0x86, 0xB3, 0x81, 0x41, 0xA6, 0x29, 0x85, + 0x85, 0x1D, 0x3F, 0x98, 0x7B, 0x71, 0xBE, 0x1F, 0x5A, 0x75, 0x40, 0xC8, 0xCA, 0x35, 0xCB, 0x4F, + 0x77, 0x38, 0x1C, 0x0B, 0x89, 0xEF, 0xE0, 0xA8, 0x74, 0x62, 0x20, 0x30, 0xEC, 0x38, 0x23, 0x60, + 0x53, 0xA9, 0xEA, 0xC9, 0xC0, 0x87, 0x8D, 0xE3, 0xDC, 0xD6, 0xC9, 0x04, 0x2E, 0x6C, 0x77, 0x63, + 0x56, 0xC7, 0x00, 0x69, 0xC0, 0x3F, 0xE6, 0x73, 0x6B, 0x56, 0x3D, 0x91, 0x80, 0x24, 0xE0, 0xAD, + 0xBB, 0x52, 0x84, 0xDF, 0xF1, 0xC0, 0x4B, 0x66, 0x15, 0x66, 0x72, 0x33, 0xD0, 0xE2, 0xE5, 0x45, + 0xA3, 0x03, 0x2F, 0xF4, 0xF9, 0x40, 0x91, 0x01, 0xB9, 0x7D, 0xE8, 0xA0, 0x00, 0x44, 0x7C, 0xA2, + 0xC6, 0x53, 0x45, 0x59, 0x0C, 0x32, 0x8C, 0xE8, 0x62, 0xFA, 0xFD, 0x50, 0xF3, 0xBB, 0x88, 0xA2, + 0xE4, 0xEC, 0xAE, 0x37, 0x1A, 0x8C, 0xC3, 0xE9, 0xDD, 0xA0, 0x7F, 0xBF, 0x13, 0x3C, 0x44, 0xBE, + 0x5B, 0xFD, 0xF4, 0xAB, 0xA8, 0x06, 0xA2, 0xD1, 0x77, 0xB8, 0xCE, 0x50, 0x22, 0x60, 0xD7, 0x77, + 0xA4, 0xFE, 0xC3, 0x79, 0x52, 0xFF, 0x11, 0xB8, 0xD2, 0xA2, 0x42, 0xC4, 0x5B, 0x60, 0xC4, 0x7B, + 0x73, 0xD1, 0x8C, 0x86, 0x16, 0x0F, 0x16, 0xEA, 0x16, 0x0F, 0x1A, 0xEA, 0x34, 0xBE, 0x43, 0x4D, + 0x48, 0xA1, 0x62, 0x06, 0xCB, 0x01, 0x59, 0x2D, 0xAB, 0x4E, 0x90, 0xEB, 0xDD, 0xD4, 0x89, 0x72, + 0x82, 0x8C, 0x64, 0x90, 0x1B, 0x47, 0xAB, 0x22, 0xA3, 0xFB, 0x5D, 0xD6, 0x1D, 0x16, 0x51, 0x5B, + 0xC7, 0x69, 0x3C, 0xE3, 0xFA, 0xD3, 0x62, 0x65, 0x94, 0x56, 0x06, 0x87, 0x03, 0x5D, 0xBC, 0x7B, + 0xD9, 0x64, 0xBA, 0x20, 0xFA, 0x7D, 0x18, 0x3F, 0x0A, 0xB9, 0x7E, 0xE8, 0x58, 0x67, 0x13, 0xA7, + 0x7C, 0xB0, 0x43, 0x20, 0x7D, 0xF2, 0x2D, 0x71, 0x7C, 0xED, 0xC2, 0xF5, 0xF8, 0xBB, 0x18, 0x1B, + 0xD1, 0x1A, 0xED, 0xF9, 0x61, 0x54, 0xC6, 0x98, 0x7E, 0x68, 0x7D, 0x2D, 0x57, 0x96, 0xE7, 0xB9, + 0x5E, 0x69, 0x95, 0x71, 0x38, 0x98, 0x56, 0xB4, 0xDF, 0xD1, 0xA3, 0x46, 0xD4, 0x25, 0x7A, 0x7D, + 0x18, 0x8D, 0x85, 0x3C, 0x3F, 0xB4, 0xD2, 0xAE, 0xE6, 0xB6, 0xB5, 0x2E, 0xAD, 0x32, 0x0A, 0xA5, + 0x4F, 0x3E, 0xB6, 0xBF, 0x86, 0xBF, 0x8D, 0xA8, 0x8B, 0xF5, 0xF8, 0x30, 0xCA, 0xE2, 0xDC, 0x3E, + 0xB4, 0xAA, 0xA6, 0xEB, 0xF2, 0xE1, 0x10, 0x60, 0xF4, 0xC9, 0xAB, 0xF7, 0xCD, 0xE4, 0x7E, 0xD8, + 0x99, 0xA2, 0x86, 0x6A, 0xE9, 0x83, 0x32, 0xF5, 0xD0, 0xDA, 0xB8, 0xAE, 0xA0, 0x8D, 0x6B, 0x24, + 0xFC, 0xA7, 0x86, 0xB4, 0x71, 0xAD, 0xAE, 0x8D, 0x7B, 0xF6, 0x97, 0xEB, 0xC7, 0xA0, 0x1F, 0xFA, + 0xB0, 0xDF, 0xD4, 0x28, 0x3F, 0x1C, 0x09, 0x40, 0xDC, 0x34, 0x06, 0x47, 0xDA, 0x2B, 0xA3, 0x99, + 0x01, 0x29, 0xEC, 0xB7, 0x09, 0x17, 0x8A, 0x98, 0x7C, 0x68, 0x3D, 0xD9, 0xC4, 0xAC, 0x90, 0xE4, + 0x99, 0x9F, 0xF0, 0xC9, 0x39, 0x7C, 0xA2, 0xFC, 0x16, 0xB2, 0xBD, 0xCB, 0xD7, 0xDA, 0x37, 0xE2, + 0xF4, 0xA1, 0x0A, 0x43, 0x49, 0x9A, 0x92, 0xF3, 0xA6, 0xFE, 0x68, 0x57, 0xDB, 0x32, 0x00, 0xF3, + 0x0E, 0x75, 0x33, 0x37, 0x66, 0xE4, 0x93, 0x49, 0x82, 0x2A, 0xEB, 0xFE, 0x31, 0x58, 0x7D, 0xF2, + 0x35, 0x9C, 0x68, 0xAF, 0xE9, 0x49, 0x53, 0xE9, 0x78, 0xBC, 0xFF, 0x26, 0x3C, 0x2A, 0xC1, 0xEF, + 0x43, 0x3B, 0x15, 0x25, 0x06, 0x26, 0x3F, 0xEE, 0xC2, 0xA9, 0xF4, 0xDC, 0x53, 0x02, 0x9C, 0xAB, + 0xEF, 0x7B, 0x76, 0xDE, 0xAC, 0x02, 0x23, 0x22, 0x1A, 0xD3, 0x61, 0x8C, 0xEF, 0x26, 0xD4, 0x18, + 0x7F, 0xF8, 0x91, 0xBF, 0x36, 0xB8, 0x48, 0x53, 0xFC, 0x21, 0x3C, 0xBA, 0xDD, 0x88, 0x04, 0x6D, + 0x3F, 0xB0, 0x6C, 0x5B, 0x9F, 0xBC, 0x21, 0x81, 0xF6, 0x01, 0x0F, 0x15, 0x9F, 0xBA, 0x8B, 0x61, + 0x11, 0xCF, 0xDC, 0x06, 0x1E, 0x31, 0x56, 0xFA, 0xE4, 0x03, 0xBE, 0x50, 0x19, 0x70, 0xE1, 0x59, + 0x79, 0x64, 0x54, 0x88, 0xC4, 0xF1, 0x5C, 0x20, 0x2A, 0x54, 0x12, 0x7F, 0x51, 0xA3, 0xAE, 0x89, + 0xA3, 0xD8, 0xB5, 0xC9, 0x25, 0x6D, 0xAC, 0xA1, 0x95, 0x15, 0x77, 0x17, 0x7F, 0x1C, 0x30, 0xDF, + 0x39, 0xE8, 0x03, 0xC0, 0xF8, 0x44, 0x6F, 0xF2, 0x7D, 0xEB, 0xA0, 0x56, 0xF6, 0x7C, 0xFF, 0xE4, + 0xCC, 0x5F, 0x1B, 0x8E, 0x68, 0x46, 0x1F, 0x7E, 0xBF, 0xE6, 0x4F, 0x33, 0x4F, 0x5D, 0xDB, 0xFC, + 0x2A, 0xB6, 0xF0, 0xFF, 0x21, 0x7C, 0x2C, 0x17, 0x41, 0xC0, 0x2E, 0x04, 0x86, 0x02, 0xE5, 0x2E, + 0x3D, 0x81, 0x9E, 0x3D, 0x41, 0x8D, 0x6F, 0xEB, 0xCA, 0xD1, 0x6E, 0xC6, 0x93, 0xC4, 0x1E, 0x59, + 0x84, 0x92, 0x94, 0x3D, 0x61, 0x2E, 0x7D, 0xAE, 0xF8, 0x7B, 0xB2, 0xB0, 0x7C, 0xA0, 0x51, 0x03, + 0xBB, 0x38, 0xA4, 0xCF, 0x62, 0x32, 0x5B, 0x56, 0x7B, 0xCE, 0x37, 0xDE, 0x25, 0x7F, 0x4D, 0x81, + 0xF4, 0xF1, 0xED, 0x52, 0xA9, 0x63, 0xFA, 0x59, 0xEB, 0x24, 0xC6, 0x22, 0xAB, 0x7F, 0xDA, 0x6E, + 0x2F, 0x87, 0xF8, 0x54, 0xA9, 0x26, 0x58, 0x3B, 0x3B, 0x5C, 0x0E, 0x8B, 0x9E, 0xDA, 0x2B, 0x7C, + 0x24, 0x18, 0x38, 0xAD, 0xFC, 0x44, 0x30, 0x4A, 0x69, 0x02, 0xD4, 0x1C, 0x68, 0xEF, 0x0C, 0xFF, + 0xF3, 0x81, 0xF6, 0x11, 0x87, 0xF8, 0x06, 0x1F, 0x0C, 0x46, 0xDA, 0x0D, 0xD3, 0xF4, 0x32, 0x1F, + 0x0E, 0x1E, 0x26, 0x1E, 0x0E, 0x1E, 0x8B, 0x87, 0x83, 0xA3, 0x95, 0xAA, 0xEE, 0xCD, 0xA0, 0xDB, + 0x3D, 0x56, 0x61, 0x5D, 0xF1, 0x01, 0xE1, 0x7B, 0xE1, 0x69, 0x05, 0xD2, 0x54, 0xE4, 0x69, 0x28, + 0x78, 0x8A, 0x6D, 0xD8, 0xBF, 0x99, 0xCF, 0x1F, 0x1B, 0x47, 0x7C, 0xC9, 0xB0, 0x3A, 0x4B, 0xDD, + 0x7E, 0xD3, 0x4F, 0x71, 0x53, 0xE3, 0xBE, 0xAF, 0x87, 0xB8, 0x69, 0x93, 0x74, 0x34, 0x1C, 0xE5, + 0x06, 0x43, 0x0A, 0xC2, 0x9C, 0xFE, 0xCD, 0x7D, 0x3A, 0xFD, 0xA2, 0x86, 0xD3, 0x2F, 0xB6, 0x9C, + 0xBE, 0x41, 0x6F, 0x17, 0x84, 0xFF, 0xD5, 0x3C, 0x5E, 0xF0, 0x55, 0xC2, 0xEB, 0xA5, 0x7C, 0x75, + 0xBB, 0xF7, 0xEA, 0xF7, 0x85, 0x4E, 0x12, 0x1A, 0xC3, 0x9B, 0xFB, 0x74, 0x92, 0x0C, 0xD3, 0xAD, + 0x64, 0xA7, 0x3C, 0xEC, 0x4C, 0x9A, 0x19, 0x97, 0x68, 0x36, 0x15, 0x57, 0x28, 0xEF, 0x1D, 0x1F, + 0xD7, 0x1D, 0x0C, 0x79, 0xEA, 0x74, 0x1F, 0xEA, 0x51, 0x7F, 0x61, 0x44, 0x66, 0x93, 0xFB, 0x49, + 0xCC, 0xD6, 0xB1, 0x14, 0x57, 0x39, 0x31, 0x7B, 0xFF, 0xED, 0xB7, 0xE5, 0x72, 0xB1, 0x78, 0x2F, + 0x8F, 0x24, 0x17, 0xCB, 0x2D, 0x53, 0xDF, 0xAE, 0xE1, 0x06, 0x52, 0x5D, 0xC9, 0x74, 0x23, 0x70, + 0x7D, 0xF2, 0x8A, 0x1E, 0x6B, 0x31, 0x89, 0x95, 0x32, 0x5E, 0xE5, 0x59, 0x27, 0x05, 0x8C, 0xD5, + 0xB1, 0x23, 0x12, 0xD2, 0xBA, 0x51, 0xC4, 0x95, 0x53, 0xBB, 0x8E, 0xB1, 0xA7, 0xCE, 0x54, 0x6D, + 0x9F, 0xA0, 0x4D, 0x8A, 0x52, 0xE1, 0xD5, 0xC6, 0xAE, 0xAC, 0x36, 0x0E, 0xAB, 0x4F, 0xDE, 0xC1, + 0x64, 0xDC, 0x5A, 0xDB, 0x16, 0xCC, 0x3C, 0x5A, 0x5D, 0xAD, 0xAD, 0x0D, 0x7A, 0xFB, 0x0D, 0x8E, + 0x91, 0x82, 0x8C, 0x92, 0x6F, 0xCB, 0xE9, 0x45, 0x0F, 0xB3, 0x0D, 0xEE, 0xE9, 0x75, 0x39, 0x75, + 0x15, 0xE2, 0xB9, 0x6E, 0x50, 0x59, 0x1B, 0x02, 0x18, 0x12, 0x15, 0x38, 0xD2, 0x22, 0x9D, 0xA8, + 0xAB, 0x22, 0xB6, 0xB5, 0x36, 0xC2, 0xA6, 0xA6, 0x0E, 0xA5, 0x8D, 0xB4, 0xB8, 0x3F, 0x45, 0x75, + 0x07, 0xAA, 0x04, 0x6B, 0x4F, 0x9F, 0xF4, 0x4B, 0x60, 0x28, 0xDE, 0x87, 0xCA, 0x5A, 0xD5, 0x77, + 0x22, 0xFF, 0xB6, 0x7A, 0xEC, 0xE3, 0xB0, 0x90, 0x76, 0xDF, 0x42, 0xAA, 0xBB, 0xD2, 0x5E, 0x43, + 0x5F, 0xD4, 0x89, 0x7A, 0xA3, 0x26, 0x9D, 0x48, 0x90, 0x51, 0xDD, 0x89, 0x7A, 0x8F, 0xC3, 0x87, + 0x50, 0x1F, 0x6B, 0x8F, 0x54, 0xD6, 0x07, 0x87, 0xD5, 0x27, 0xEF, 0x3D, 0x82, 0xCA, 0xA8, 0xE4, + 0x3D, 0x21, 0x92, 0x6A, 0xCE, 0x73, 0x0F, 0x8E, 0xD2, 0xEB, 0x8C, 0xEA, 0xE1, 0xE8, 0x97, 0x73, + 0x36, 0x09, 0x86, 0x81, 0x3C, 0x08, 0x0C, 0x1E, 0xA7, 0x0B, 0x13, 0xDB, 0x1C, 0x55, 0x77, 0x62, + 0x01, 0x8D, 0xB3, 0x67, 0x38, 0xAC, 0x6C, 0x38, 0x31, 0x44, 0x8F, 0x2A, 0xEE, 0xD6, 0xC4, 0x70, + 0x1F, 0xC6, 0x34, 0xE9, 0x97, 0x32, 0xE9, 0x66, 0x4C, 0x67, 0x8D, 0x2F, 0x17, 0x24, 0x6A, 0x7B, + 0x3E, 0x29, 0xB2, 0x78, 0xA4, 0x61, 0xB0, 0x10, 0x69, 0xE8, 0xEB, 0x05, 0xE9, 0x5E, 0xF7, 0x46, + 0x73, 0x5E, 0x41, 0xC0, 0xF6, 0x32, 0x4B, 0xF9, 0xAD, 0x01, 0x31, 0xE6, 0x64, 0x29, 0x70, 0xC8, + 0xEB, 0x63, 0xCB, 0x7F, 0x29, 0x61, 0x95, 0xC7, 0x0A, 0x0E, 0xCC, 0x55, 0x18, 0x0E, 0xDD, 0xCD, + 0xE6, 0xBF, 0x21, 0x15, 0x35, 0xC6, 0xEE, 0x06, 0x13, 0xE0, 0xD8, 0xB2, 0x11, 0x55, 0x00, 0x0B, + 0x9A, 0x01, 0x9B, 0xF1, 0x95, 0x58, 0x09, 0xCA, 0x6C, 0x72, 0x3F, 0xF3, 0xFC, 0x6B, 0xCB, 0x29, + 0x3F, 0xCF, 0xFF, 0xC9, 0x72, 0x4C, 0xF7, 0xBA, 0xDC, 0x54, 0x3F, 0xDE, 0xD1, 0x9F, 0x60, 0xAA, + 0x4F, 0x07, 0x4B, 0x5C, 0x2C, 0x6C, 0x7B, 0x44, 0xED, 0xA5, 0x33, 0x69, 0x21, 0x33, 0xE8, 0x1B, + 0x5C, 0x6A, 0x03, 0x14, 0xBE, 0x46, 0x97, 0x1E, 0x77, 0xED, 0x2F, 0x3F, 0x9F, 0xC6, 0x93, 0x5D, + 0x4E, 0x81, 0x9A, 0xC3, 0x0C, 0x25, 0x85, 0xC7, 0x07, 0xAF, 0xA5, 0xFE, 0xB2, 0xCD, 0xCF, 0xED, + 0x83, 0xF3, 0x73, 0x1F, 0x01, 0x99, 0x38, 0x66, 0x65, 0xCB, 0x42, 0xD8, 0xC8, 0xAE, 0x2E, 0x1D, + 0xB3, 0x51, 0xAB, 0x62, 0xBD, 0x57, 0xD6, 0x41, 0xBF, 0x7B, 0x74, 0xF2, 0xB8, 0xCC, 0x0A, 0x19, + 0xAA, 0x61, 0x54, 0xBD, 0xD1, 0xF0, 0xE8, 0xF1, 0xD8, 0x95, 0x3B, 0x9F, 0xB3, 0x15, 0xAE, 0x6A, + 0xA6, 0xC5, 0xC1, 0x6F, 0xE8, 0xA3, 0xB4, 0x3E, 0x69, 0x36, 0x5E, 0x85, 0x9D, 0xAB, 0xE9, 0x62, + 0x20, 0xD1, 0xC5, 0xF8, 0x71, 0x99, 0x16, 0xE7, 0x48, 0xD5, 0xBA, 0x24, 0x1C, 0xDD, 0x13, 0x43, + 0xF7, 0x61, 0x5A, 0x81, 0x1B, 0x18, 0x76, 0x65, 0xCB, 0x62, 0xD0, 0x60, 0x58, 0x3F, 0xE0, 0x81, + 0xF6, 0x01, 0xF8, 0x6C, 0xD4, 0xB8, 0x44, 0xFF, 0xD5, 0x03, 0xD7, 0xA0, 0xFB, 0xC8, 0xC6, 0x43, + 0xC6, 0x52, 0xAD, 0xD0, 0x35, 0x1E, 0x3E, 0x1E, 0xFB, 0x72, 0x37, 0x01, 0x5E, 0xAD, 0x1C, 0xBA, + 0x18, 0x38, 0x86, 0x2E, 0x7A, 0xD4, 0xBC, 0x89, 0x85, 0x14, 0xD4, 0x18, 0x1C, 0x87, 0x0F, 0xBF, + 0x7E, 0xFD, 0x8B, 0x84, 0xA7, 0x5A, 0x46, 0x36, 0x78, 0x2C, 0x41, 0x6C, 0x66, 0x28, 0xBF, 0x88, + 0x8D, 0x22, 0x8B, 0x67, 0xF3, 0x0C, 0x16, 0xE6, 0x70, 0xEC, 0xA0, 0xD1, 0x0A, 0x86, 0xE8, 0xFC, + 0xDE, 0x97, 0xEC, 0x42, 0xAE, 0x1E, 0x53, 0xBD, 0x62, 0x6A, 0x39, 0x4E, 0x55, 0x35, 0x71, 0x58, + 0x7D, 0xF2, 0x8A, 0x1D, 0x34, 0xBB, 0xB8, 0xCA, 0x3B, 0xBF, 0xFF, 0x95, 0x55, 0xC1, 0x55, 0xD3, + 0x6A, 0x4A, 0x15, 0x31, 0xBC, 0xF0, 0x4B, 0x11, 0x3A, 0xDF, 0xAD, 0x18, 0x7D, 0x39, 0xE2, 0xF1, + 0x94, 0x34, 0x16, 0xC6, 0x0A, 0x9F, 0x30, 0x2E, 0x5B, 0xD4, 0x78, 0x83, 0x60, 0xE5, 0x6A, 0x1A, + 0xC9, 0x9E, 0x1E, 0x77, 0x55, 0x63, 0x92, 0x7C, 0xB5, 0x24, 0x10, 0xDE, 0x9E, 0x5A, 0x86, 0x8F, + 0x4F, 0xE3, 0xC3, 0xB1, 0xF6, 0x0A, 0x8E, 0xB5, 0xF7, 0xF6, 0x26, 0x7C, 0x37, 0xAE, 0xCC, 0x21, + 0xE2, 0x3B, 0x9B, 0x22, 0x0C, 0x59, 0xDB, 0xD7, 0xE9, 0x86, 0x2E, 0xFE, 0x14, 0x16, 0x1C, 0xE3, + 0x3E, 0xA6, 0xD1, 0xF0, 0xB8, 0xAB, 0x6B, 0x2C, 0x2B, 0xE6, 0xCF, 0x90, 0xF8, 0x9F, 0xE9, 0x06, + 0xA7, 0x5E, 0x48, 0xA0, 0xCC, 0x01, 0xE2, 0xF4, 0x86, 0x04, 0x52, 0xFB, 0xAD, 0xB3, 0xEF, 0x68, + 0x5B, 0x22, 0x3D, 0x21, 0x8E, 0xAE, 0xD4, 0x10, 0x12, 0x2F, 0xC3, 0x64, 0xED, 0x55, 0x9E, 0x86, + 0x91, 0x0B, 0xA2, 0x27, 0x15, 0x04, 0xEE, 0xF3, 0xBA, 0x5F, 0x9E, 0xFA, 0x82, 0xA7, 0x9E, 0x1A, + 0x4F, 0xFD, 0x1A, 0x3C, 0xF5, 0x1B, 0xE2, 0x69, 0x20, 0x78, 0xEA, 0xAB, 0xF1, 0x34, 0xA8, 0xC1, + 0xD3, 0xA0, 0x21, 0x9E, 0x86, 0x82, 0xA7, 0x81, 0x1A, 0x4F, 0xC3, 0x1A, 0x3C, 0x0D, 0x1B, 0xE2, + 0x69, 0x24, 0x78, 0x1A, 0xAA, 0xF1, 0x34, 0xAA, 0xC1, 0xD3, 0xA8, 0x21, 0x9E, 0xC6, 0x82, 0xA7, + 0x91, 0x1A, 0x4F, 0xE3, 0x1A, 0x3C, 0x8D, 0x1B, 0xE2, 0xE9, 0x48, 0xF0, 0x34, 0x56, 0xE3, 0xE9, + 0xA8, 0x06, 0x4F, 0x47, 0x0D, 0xF1, 0x74, 0x2C, 0x78, 0x3A, 0x52, 0xE3, 0xE9, 0xB8, 0x06, 0x4F, + 0xC7, 0x0D, 0xF1, 0x74, 0x22, 0x78, 0x3A, 0x56, 0xE3, 0xE9, 0xA4, 0x06, 0x4F, 0x27, 0x0D, 0xF1, + 0x84, 0x8B, 0x72, 0x8C, 0xA9, 0x13, 0xC5, 0x41, 0xB7, 0x5B, 0x83, 0x2B, 0xA3, 0x29, 0xAE, 0xC2, + 0x54, 0xA2, 0xA7, 0x9A, 0x4B, 0xD4, 0x49, 0x26, 0xA6, 0x4D, 0xB1, 0x15, 0x65, 0x13, 0x8A, 0xE9, + 0x44, 0xAF, 0x4E, 0x3E, 0x31, 0x6B, 0x8A, 0xAD, 0x30, 0xA1, 0xE8, 0x29, 0x66, 0x14, 0xBD, 0x3A, + 0x29, 0x85, 0xD9, 0x14, 0x5B, 0x61, 0x4E, 0xD1, 0x53, 0x4C, 0x2A, 0x7A, 0x75, 0xB2, 0x0A, 0xD2, + 0x14, 0x5B, 0x61, 0x5A, 0xD1, 0x53, 0xCC, 0x2B, 0x7A, 0x75, 0x12, 0x8B, 0x79, 0x53, 0x6C, 0x85, + 0x99, 0x45, 0x4F, 0x31, 0xB5, 0xE8, 0xD5, 0xC8, 0x2D, 0x4E, 0xE4, 0x13, 0xB1, 0x7B, 0x65, 0x8B, + 0x04, 0x7C, 0x8A, 0x1C, 0x4D, 0xDA, 0x94, 0x1E, 0x3D, 0xE1, 0x40, 0xF8, 0x6C, 0x14, 0x13, 0xC8, + 0x85, 0xEB, 0xCC, 0xAD, 0x45, 0x58, 0x64, 0x78, 0x34, 0x4F, 0x49, 0xF8, 0xB1, 0xB7, 0xF2, 0x2A, + 0x17, 0x1A, 0x3E, 0xBC, 0xBE, 0x2C, 0x57, 0x66, 0x88, 0xF7, 0xF2, 0x27, 0x2A, 0x32, 0x00, 0xD9, + 0xFD, 0xF8, 0x27, 0x02, 0x94, 0xEA, 0x0A, 0x14, 0xA8, 0x4C, 0x45, 0x61, 0x14, 0xAF, 0x28, 0x8C, + 0x95, 0x2B, 0x0A, 0x8C, 0xB8, 0xDD, 0xD4, 0x12, 0x00, 0xF7, 0x80, 0x7D, 0xD7, 0x40, 0x9D, 0xE9, + 0x41, 0x75, 0xA6, 0x47, 0x65, 0x98, 0x1E, 0x54, 0x61, 0xBA, 0xC2, 0xD3, 0x8D, 0x8A, 0x72, 0x02, + 0x7A, 0xBF, 0xB6, 0x6E, 0x88, 0xA9, 0xFD, 0xA2, 0x2E, 0xAA, 0x5E, 0x75, 0x51, 0x1D, 0x95, 0x11, + 0x55, 0x6F, 0x87, 0xF6, 0x31, 0x12, 0x7C, 0xFF, 0xA8, 0xCE, 0xF7, 0xA8, 0x3A, 0xDF, 0x83, 0x32, + 0x7C, 0x8F, 0x76, 0xC8, 0xF7, 0x50, 0xF0, 0xFD, 0x51, 0x9D, 0xEF, 0x61, 0x75, 0xBE, 0x87, 0x65, + 0xF8, 0x1E, 0xEE, 0x90, 0xEF, 0x3E, 0x04, 0x9B, 0x1F, 0x3F, 0x6A, 0x3F, 0x2C, 0x3D, 0xE2, 0x2F, + 0x8B, 0x2B, 0x71, 0x0C, 0xA2, 0xEA, 0xD8, 0x3E, 0x6A, 0x60, 0xEE, 0x86, 0x14, 0x0E, 0xE2, 0x3C, + 0x15, 0xE6, 0xCD, 0x0C, 0x42, 0xE5, 0xC3, 0x41, 0x72, 0x9E, 0xE4, 0x33, 0xB7, 0x9E, 0x2A, 0x53, + 0xBB, 0x8B, 0x61, 0xC7, 0xFA, 0xE4, 0xED, 0xA6, 0xC4, 0xF8, 0x76, 0x5C, 0xDD, 0x9E, 0xD5, 0x2B, + 0xE6, 0x8C, 0xAE, 0x9D, 0xD9, 0xF3, 0x09, 0xE5, 0x19, 0xF2, 0x32, 0x5F, 0x41, 0xED, 0xD5, 0xAB, + 0x10, 0xA3, 0x06, 0xAA, 0xE4, 0x18, 0xE9, 0x8F, 0x18, 0x3B, 0x3F, 0x22, 0x43, 0x1A, 0x64, 0x2C, + 0x25, 0x06, 0xA3, 0xA3, 0x92, 0xDA, 0x3C, 0xAE, 0x18, 0x9D, 0x90, 0xC6, 0x9D, 0xA9, 0x13, 0xA7, + 0x1E, 0x28, 0x80, 0x8F, 0x15, 0x04, 0x30, 0xAE, 0x2E, 0x80, 0x52, 0x99, 0x0B, 0xD2, 0xB8, 0x3B, + 0x01, 0x74, 0x99, 0x00, 0x3E, 0x44, 0x6F, 0xA6, 0xCE, 0x31, 0xE8, 0x1A, 0x15, 0xA8, 0x51, 0x03, + 0x6B, 0x24, 0x18, 0x69, 0x7B, 0xC2, 0xA2, 0x81, 0xA3, 0x72, 0x0A, 0xED, 0x97, 0xCD, 0xAF, 0xE4, + 0xC5, 0x4F, 0x85, 0xFC, 0x7B, 0x97, 0x09, 0x56, 0xBF, 0x2B, 0x2C, 0xBA, 0xBC, 0x00, 0xBA, 0xD5, + 0x05, 0xD0, 0x2B, 0x25, 0x80, 0xEE, 0xE3, 0x4A, 0xC6, 0xC7, 0xDB, 0x1F, 0x13, 0x2E, 0x96, 0x56, + 0x59, 0xF7, 0x8F, 0x8D, 0x66, 0xFD, 0x32, 0xC2, 0xDA, 0xA9, 0xF7, 0x0F, 0x22, 0xCE, 0xB5, 0x5F, + 0xB4, 0xE4, 0xD6, 0xD7, 0xBC, 0x38, 0x50, 0xBD, 0x08, 0x38, 0x6A, 0x60, 0xBD, 0x0A, 0x29, 0x3C, + 0x91, 0x70, 0x56, 0x32, 0xC0, 0x9F, 0x54, 0x77, 0x87, 0x52, 0x1A, 0x46, 0x5A, 0x77, 0xA7, 0xE2, + 0x51, 0x42, 0x10, 0xEC, 0x43, 0xE6, 0x2A, 0x2A, 0xAE, 0x5E, 0x39, 0x1C, 0x35, 0xB0, 0xD4, 0x85, + 0x14, 0x1E, 0x4B, 0x38, 0x2B, 0xA9, 0xE2, 0xB2, 0x29, 0xE9, 0x71, 0xC5, 0xA9, 0x65, 0x6F, 0x97, + 0x39, 0x29, 0x56, 0xBB, 0x63, 0x82, 0x88, 0x7F, 0x65, 0x22, 0x4F, 0xC1, 0xD5, 0x2B, 0xDE, 0xA3, + 0x9A, 0xEB, 0xB3, 0xBB, 0x8B, 0xE4, 0x47, 0xB2, 0x4F, 0x90, 0x17, 0xDB, 0x41, 0xD9, 0x5C, 0xB6, + 0x5B, 0x71, 0xE0, 0xDB, 0x69, 0x2A, 0x0B, 0xBD, 0x43, 0xD6, 0xB3, 0xCD, 0x7D, 0x8E, 0x09, 0x54, + 0x5F, 0x79, 0x1B, 0x35, 0xB0, 0x3D, 0x04, 0x29, 0xEC, 0xEB, 0x93, 0x8F, 0x25, 0x99, 0xAA, 0x53, + 0x3F, 0xA8, 0xBC, 0x3F, 0xA4, 0xB9, 0xD2, 0xFB, 0x6C, 0x75, 0x53, 0xBE, 0xF4, 0x7E, 0xF1, 0xEE, + 0xE7, 0x72, 0xA5, 0xF7, 0x78, 0x2F, 0xCD, 0x95, 0xDE, 0xAB, 0xD9, 0x4C, 0xA9, 0x8D, 0xB2, 0xC0, + 0x18, 0xBE, 0x3F, 0x62, 0x66, 0xF9, 0xB4, 0x4B, 0x10, 0x8C, 0xF6, 0x5E, 0x9C, 0x86, 0x22, 0x8A, + 0x3D, 0xB1, 0x9F, 0x6C, 0x9F, 0x67, 0x3D, 0x83, 0x9C, 0xB0, 0xA0, 0xB6, 0x11, 0x76, 0xFB, 0x75, + 0x28, 0x9D, 0x31, 0xFF, 0x10, 0x57, 0x8D, 0x47, 0xEB, 0xB3, 0x5E, 0x1A, 0xD0, 0x39, 0x2A, 0x89, + 0x7B, 0xE7, 0x8F, 0xDC, 0x4F, 0x52, 0x8A, 0xEA, 0x51, 0xFD, 0xF4, 0xF0, 0x5C, 0xB9, 0x4E, 0x4E, + 0xC1, 0xCA, 0x44, 0xF3, 0x41, 0xBC, 0xD4, 0xA2, 0x1E, 0xCD, 0x19, 0x79, 0xBB, 0x89, 0xE6, 0x88, + 0x3B, 0xC1, 0x7B, 0x89, 0xAC, 0x86, 0xC1, 0x96, 0x13, 0x80, 0x7C, 0x13, 0x85, 0x82, 0x00, 0xB2, + 0x24, 0x70, 0x2F, 0x22, 0xE8, 0x53, 0x09, 0xF4, 0x53, 0xDA, 0xCF, 0x08, 0xFC, 0xB4, 0x7D, 0xD5, + 0xB8, 0x3F, 0x68, 0xA0, 0x36, 0x81, 0xE2, 0x4A, 0x70, 0x54, 0x52, 0xA7, 0xE5, 0x16, 0x07, 0x13, + 0x3A, 0x2D, 0x67, 0xD4, 0x3B, 0x5B, 0x1D, 0x04, 0xE4, 0x03, 0x2A, 0x80, 0x81, 0xB2, 0x4A, 0xAB, + 0x4F, 0x33, 0x07, 0x0D, 0xE4, 0x27, 0x28, 0xAD, 0x04, 0x47, 0x25, 0x55, 0x5A, 0x6E, 0xE9, 0x33, + 0xA1, 0x52, 0xF5, 0xF9, 0x25, 0x27, 0x72, 0x67, 0x2A, 0x1D, 0x52, 0x01, 0x0C, 0x95, 0x55, 0x5A, + 0x7D, 0xD6, 0x31, 0x68, 0x60, 0xF7, 0x2E, 0x4A, 0x2B, 0xC1, 0x51, 0x49, 0x95, 0x96, 0x5B, 0xB2, + 0x4B, 0xA8, 0x54, 0x7D, 0x3E, 0xC9, 0x89, 0xDC, 0x99, 0x4A, 0x47, 0x54, 0x00, 0x23, 0x65, 0x95, + 0x56, 0xAF, 0x14, 0x0C, 0x1A, 0x28, 0x06, 0xA1, 0xB4, 0x12, 0x1C, 0x95, 0x54, 0x69, 0xB9, 0xD5, + 0xE7, 0x84, 0x4A, 0xD5, 0xD7, 0x39, 0x38, 0x91, 0x3B, 0x53, 0xE9, 0x98, 0x0A, 0x60, 0xAC, 0xAC, + 0xD2, 0xEA, 0xFB, 0xAB, 0x06, 0x0D, 0xEC, 0xDD, 0x46, 0x69, 0x25, 0x38, 0x2A, 0xA9, 0xD2, 0x72, + 0xA5, 0xDB, 0x84, 0x4A, 0xD5, 0x57, 0x6E, 0x38, 0x91, 0x3B, 0x53, 0xE9, 0x11, 0x15, 0xC0, 0x91, + 0xB2, 0x4A, 0xAB, 0x6F, 0x5D, 0x1F, 0x34, 0x50, 0xCF, 0x43, 0x69, 0x25, 0x38, 0x2A, 0xA9, 0xD2, + 0x72, 0x15, 0x9C, 0x84, 0x4A, 0xD5, 0xF7, 0x4E, 0x71, 0x22, 0x77, 0xA6, 0xD2, 0x63, 0x2A, 0x80, + 0x63, 0x65, 0x95, 0x56, 0xDF, 0xB9, 0x3F, 0x68, 0x60, 0xE7, 0x3E, 0x4A, 0x2B, 0xC1, 0x51, 0x49, + 0x95, 0x96, 0xAB, 0xCD, 0x26, 0x54, 0xAA, 0xBE, 0xDD, 0x89, 0x13, 0xB9, 0x33, 0x95, 0x9E, 0x50, + 0x01, 0x9C, 0x28, 0xAB, 0xB4, 0xFA, 0x96, 0x81, 0x41, 0x03, 0x9B, 0x5F, 0x50, 0x5A, 0xDD, 0x38, + 0x47, 0x25, 0x55, 0x5A, 0x6E, 0x81, 0x71, 0x90, 0xB1, 0xF5, 0x45, 0x41, 0xA5, 0x59, 0x0B, 0x8C, + 0x8F, 0xA0, 0x7E, 0x67, 0x5C, 0x4F, 0x2B, 0x7C, 0xFA, 0xE5, 0xE5, 0x4F, 0xAF, 0xB2, 0x0B, 0xFB, + 0x99, 0x55, 0xBC, 0x44, 0x5F, 0x8F, 0xBD, 0x8C, 0x17, 0x97, 0x17, 0x12, 0x0E, 0x5A, 0x66, 0x2F, + 0x4D, 0xD4, 0xB6, 0x98, 0xCF, 0xB7, 0x34, 0x06, 0x5C, 0xC2, 0xD2, 0x06, 0xC3, 0xAE, 0x3C, 0x69, + 0x29, 0xB0, 0x34, 0x4E, 0xE5, 0x6E, 0x82, 0x07, 0x22, 0x87, 0xB9, 0x38, 0xF2, 0xFE, 0xBD, 0xD2, + 0x9A, 0x0E, 0x03, 0x48, 0x86, 0x8F, 0x61, 0xF7, 0x44, 0x31, 0x7E, 0x80, 0x0C, 0xB2, 0x36, 0xC6, + 0xDF, 0x63, 0x00, 0x41, 0x1A, 0x07, 0x8C, 0xA9, 0x37, 0xCA, 0x4C, 0xA5, 0xAB, 0x00, 0xA5, 0x98, + 0xCA, 0xAA, 0xEC, 0xDC, 0x33, 0x53, 0x43, 0xC6, 0x54, 0x8E, 0x93, 0xA6, 0x98, 0x4A, 0xCF, 0x83, + 0x4B, 0x31, 0x95, 0x35, 0x11, 0x8E, 0x98, 0x7A, 0x0C, 0x81, 0x8E, 0xCC, 0x8C, 0xC5, 0xAC, 0x42, + 0xA8, 0xBB, 0xBC, 0x38, 0x7C, 0xF9, 0xE6, 0x42, 0xA3, 0x4B, 0x9A, 0xAE, 0x5D, 0x32, 0xE2, 0x25, + 0x3B, 0xFD, 0x53, 0xC5, 0x3C, 0x4A, 0x7A, 0x2C, 0xEA, 0xBD, 0xB9, 0x50, 0x0D, 0x78, 0x1C, 0xB2, + 0x4C, 0xC8, 0x1B, 0x75, 0x07, 0x55, 0x2A, 0x84, 0x21, 0x91, 0x3B, 0x0A, 0x7A, 0x14, 0x7D, 0x3F, + 0x92, 0xC1, 0x65, 0x39, 0x19, 0x94, 0xAA, 0x92, 0x26, 0x65, 0x50, 0x22, 0xEC, 0x0B, 0x22, 0x77, + 0x29, 0x03, 0x8C, 0x92, 0x97, 0x17, 0xDA, 0xFB, 0xBF, 0x6B, 0x97, 0x37, 0x6B, 0xD7, 0xDF, 0x78, + 0xA4, 0x30, 0xAA, 0x70, 0xB8, 0x64, 0x5C, 0x19, 0x8F, 0x46, 0x03, 0xD5, 0xC0, 0x32, 0xCA, 0x1E, + 0x02, 0xE6, 0xDD, 0x7B, 0x8C, 0x97, 0x94, 0xD0, 0x61, 0xC8, 0xE0, 0xF7, 0x04, 0x34, 0xAD, 0x14, + 0x37, 0x39, 0x60, 0x92, 0xC3, 0x5E, 0x17, 0xB7, 0x57, 0x2B, 0x32, 0x28, 0xCF, 0x28, 0x07, 0xF7, + 0x3A, 0x1C, 0x50, 0x2A, 0x47, 0x21, 0x7B, 0x1F, 0x7F, 0xF8, 0xA0, 0xC6, 0x58, 0xBA, 0x8E, 0x56, + 0x4E, 0x75, 0x59, 0x8F, 0x8C, 0xDE, 0xD3, 0xA0, 0x20, 0xBD, 0x71, 0x76, 0x08, 0xA1, 0x77, 0x1B, + 0x26, 0x43, 0x92, 0x67, 0x73, 0x6B, 0x01, 0x76, 0x2C, 0xEF, 0x83, 0x8A, 0x96, 0xBD, 0xEC, 0x14, + 0xBF, 0x51, 0xD9, 0x9E, 0x41, 0xF4, 0x07, 0x93, 0x40, 0xA7, 0x13, 0x02, 0x5F, 0x19, 0x0B, 0x12, + 0x5D, 0xD7, 0x58, 0x6C, 0xCF, 0x8B, 0xD9, 0x06, 0x43, 0x68, 0x5C, 0x11, 0xFE, 0x41, 0x4D, 0x6D, + 0xE9, 0x91, 0xF9, 0xB9, 0xFE, 0x45, 0x88, 0x93, 0x3F, 0x95, 0x87, 0x4D, 0x74, 0xCD, 0x74, 0xAF, + 0x1D, 0xDB, 0x35, 0x70, 0x3C, 0x30, 0xD6, 0x01, 0x50, 0xDA, 0xF9, 0x6D, 0x8D, 0x2F, 0xBE, 0x32, + 0xF0, 0x21, 0x2E, 0x23, 0xA7, 0x9F, 0x98, 0x55, 0xCC, 0x6C, 0xD7, 0x17, 0xB3, 0x39, 0x3C, 0x0C, + 0x3F, 0xC0, 0xF9, 0x3F, 0xFF, 0x5D, 0xB4, 0x83, 0xC0, 0x5A, 0x2D, 0x62, 0x02, 0xD0, 0x35, 0xDF, + 0x9B, 0x9D, 0xEB, 0x40, 0xA9, 0xE7, 0xFA, 0xBE, 0xEB, 0x59, 0x0B, 0x2B, 0x43, 0x3B, 0x59, 0xD2, + 0x3E, 0x94, 0x89, 0x3B, 0xD5, 0x58, 0xA2, 0xF8, 0x33, 0x7F, 0xE6, 0x59, 0xEB, 0x60, 0xF2, 0xC4, + 0x74, 0x67, 0x9B, 0x15, 0x71, 0x82, 0x8E, 0x61, 0x9A, 0x97, 0x57, 0x70, 0xF0, 0x2D, 0x7E, 0xAC, + 0x0D, 0x24, 0xDF, 0xDA, 0x7B, 0xFD, 0x8F, 0x77, 0x38, 0x3A, 0xE3, 0x35, 0x90, 0x17, 0x31, 0xF7, + 0x0E, 0xB4, 0xF9, 0xC6, 0x61, 0x03, 0x64, 0x8B, 0x60, 0xDB, 0x7D, 0xED, 0x0F, 0xC0, 0x78, 0x65, + 0x78, 0xDA, 0xD4, 0xF0, 0xC9, 0x5B, 0xD7, 0x0F, 0xB4, 0x73, 0x2D, 0xC4, 0x68, 0xBB, 0x33, 0xBA, + 0x9D, 0xA3, 0xC3, 0xF8, 0xE2, 0x2D, 0x19, 0xE3, 0x3F, 0x7A, 0x36, 0x34, 0x0D, 0xA1, 0x9E, 0x6B, + 0x7B, 0xA7, 0xC7, 0xBD, 0x3D, 0xB4, 0xDD, 0xB0, 0x8B, 0x39, 0x81, 0xE8, 0x0F, 0xED, 0x5A, 0x1B, + 0xCF, 0x3E, 0xD0, 0x66, 0xD3, 0xFD, 0x3F, 0x28, 0xF5, 0xF4, 0x32, 0x5E, 0xDB, 0xE7, 0xCC, 0x74, + 0x82, 0x25, 0x71, 0x5A, 0x11, 0x65, 0x1E, 0xF1, 0xD7, 0xAE, 0xE3, 0x13, 0x46, 0x1C, 0xFB, 0x59, + 0xF3, 0xE8, 0x7A, 0xC7, 0x0F, 0x8C, 0x60, 0xE3, 0x6B, 0x4F, 0xCF, 0xCF, 0xB5, 0x7E, 0xB7, 0x1B, + 0x6F, 0xA6, 0x41, 0x37, 0xE9, 0x76, 0x07, 0x5A, 0xEA, 0xC2, 0x0F, 0xE4, 0x26, 0xD8, 0xFF, 0x2A, + 0x84, 0xB9, 0xD3, 0x88, 0xED, 0x93, 0x04, 0x92, 0x10, 0x00, 0x5F, 0x27, 0xD7, 0xDA, 0x4F, 0x12, + 0xD8, 0x32, 0x8D, 0xC0, 0xD8, 0xFF, 0x23, 0xA1, 0x2F, 0xE8, 0x15, 0x28, 0x39, 0xD0, 0xE8, 0xAD, + 0xAF, 0x62, 0xB7, 0xEE, 0xF6, 0x3B, 0x20, 0x43, 0xE0, 0x37, 0x84, 0x26, 0x9E, 0x97, 0xA4, 0x98, + 0x42, 0xB7, 0x7B, 0x07, 0x1A, 0xDE, 0x49, 0xC2, 0xC6, 0x88, 0x7C, 0x22, 0xAE, 0x09, 0xA1, 0xE5, + 0xA3, 0x95, 0xA0, 0x64, 0xE8, 0xEE, 0x12, 0x2A, 0x82, 0x38, 0xF4, 0x3D, 0x59, 0x80, 0xC4, 0x16, + 0x07, 0x3C, 0x2C, 0x1D, 0xD0, 0x98, 0x74, 0xC0, 0xC2, 0x59, 0x4C, 0x6B, 0xE0, 0xD0, 0xBE, 0x6B, + 0x13, 0xB0, 0x89, 0x45, 0x6B, 0x8F, 0x7F, 0x0A, 0x14, 0xEC, 0x69, 0xAF, 0x7B, 0xB3, 0xF7, 0x1C, + 0xC0, 0x3B, 0x81, 0xFB, 0x21, 0xF0, 0x2C, 0x67, 0xD1, 0xEA, 0x8D, 0xF7, 0x23, 0x5C, 0xF4, 0x36, + 0x22, 0x4C, 0xDD, 0xA7, 0xD7, 0x69, 0x17, 0xE9, 0x1B, 0x2D, 0x7E, 0xFD, 0xF9, 0xDE, 0xFE, 0x1E, + 0x27, 0x9D, 0x9E, 0x83, 0xB1, 0xB5, 0xD8, 0xC1, 0x33, 0x4A, 0xE1, 0xBE, 0x76, 0x76, 0xC6, 0xBB, + 0x61, 0xAD, 0xF0, 0x22, 0x34, 0xA2, 0x7F, 0x52, 0xB7, 0x42, 0x43, 0xFC, 0xF5, 0xCB, 0x3F, 0x84, + 0xC5, 0xDE, 0x1D, 0x02, 0xD5, 0x2F, 0x30, 0x2E, 0x7F, 0xF9, 0x07, 0xFC, 0x7F, 0xF7, 0x8C, 0x86, + 0xE2, 0x2F, 0xFF, 0xC0, 0x3F, 0x77, 0xCF, 0xA0, 0x27, 0x38, 0xA6, 0xFD, 0xDD, 0xFD, 0x4A, 0xA5, + 0xB0, 0x2D, 0xBB, 0x45, 0xA6, 0xEC, 0x42, 0xA1, 0x95, 0xA6, 0x69, 0x91, 0x43, 0xD4, 0xAF, 0x91, + 0xF7, 0xB6, 0x66, 0xAE, 0x09, 0xCA, 0x09, 0xC0, 0x8E, 0x85, 0xCA, 0x6D, 0x50, 0x89, 0x10, 0x54, + 0x57, 0xA8, 0xDC, 0x9A, 0xD3, 0x96, 0x1A, 0x77, 0x94, 0xC8, 0x3C, 0x44, 0xCB, 0xB5, 0xE1, 0xF9, + 0xE4, 0x1B, 0x27, 0x68, 0x05, 0x09, 0x97, 0xC8, 0x90, 0xF8, 0x64, 0x92, 0x60, 0x01, 0x7F, 0x00, + 0x07, 0xED, 0xF6, 0xB8, 0xD2, 0x42, 0x53, 0x7B, 0x12, 0x5A, 0x61, 0x44, 0x29, 0xBB, 0x99, 0x61, + 0x85, 0x3F, 0xCF, 0xEC, 0xCF, 0xAD, 0x1B, 0xF8, 0x2F, 0x1D, 0x28, 0xB6, 0x44, 0x84, 0x8D, 0x5E, + 0xE0, 0x7F, 0x20, 0x17, 0xFC, 0x93, 0xA9, 0x1F, 0xC0, 0xFA, 0xDE, 0xB6, 0x5B, 0xEC, 0xB3, 0x5F, + 0xA0, 0x9A, 0x0D, 0x04, 0x21, 0xFF, 0x16, 0xC3, 0x81, 0xEB, 0x06, 0x9F, 0x0E, 0xB4, 0xB5, 0x07, + 0x84, 0xD1, 0x2F, 0x7D, 0xC0, 0x31, 0x20, 0x22, 0x0E, 0xFB, 0x5B, 0x48, 0xC1, 0xDA, 0xB6, 0x5F, + 0x30, 0xAC, 0x40, 0x02, 0x3B, 0x00, 0x4D, 0x6D, 0xD0, 0x62, 0xE0, 0xFF, 0xBB, 0x67, 0xD0, 0x09, + 0x1C, 0xC2, 0xFF, 0x77, 0xCF, 0xB0, 0x2B, 0xD4, 0x25, 0xF6, 0x78, 0xF7, 0x0C, 0x7A, 0x84, 0x13, + 0xF8, 0x1F, 0xDA, 0x60, 0xBF, 0xD8, 0x0A, 0xFF, 0xC2, 0x1D, 0xDA, 0x3F, 0xDE, 0xA4, 0x07, 0xEC, + 0x02, 0x3F, 0xCD, 0x63, 0x90, 0xBD, 0xE9, 0xBE, 0x45, 0xDF, 0x3C, 0xFE, 0xE9, 0x06, 0xD8, 0xA1, + 0x07, 0xB7, 0xE0, 0xF8, 0x8E, 0x89, 0xE7, 0xF8, 0xE7, 0x56, 0x98, 0x27, 0x5E, 0xE0, 0x47, 0x70, + 0x8D, 0xBE, 0x9D, 0x15, 0x2F, 0xB1, 0x03, 0x6C, 0x45, 0xDF, 0xA5, 0x49, 0x5B, 0xB1, 0x23, 0xB8, + 0xC6, 0xDF, 0xC0, 0x78, 0xA0, 0xF1, 0x77, 0xFC, 0x15, 0x0A, 0x27, 0x7A, 0x07, 0xDF, 0x0B, 0xFF, + 0x06, 0x19, 0x64, 0xA4, 0xA1, 0x54, 0xC2, 0xB3, 0xDB, 0xBB, 0x67, 0x04, 0xEF, 0x51, 0x22, 0xE1, + 0xF8, 0x96, 0x1F, 0xC3, 0x75, 0xA0, 0x0F, 0xEF, 0x08, 0x82, 0xE9, 0x85, 0xDB, 0xE8, 0x02, 0xB4, + 0x08, 0xF0, 0x3E, 0x27, 0x1E, 0xCE, 0x6E, 0xC3, 0x33, 0x84, 0xA6, 0xB0, 0x9C, 0x0D, 0x38, 0xBD, + 0x8D, 0x4E, 0xE1, 0x2E, 0xF2, 0x82, 0x0A, 0xE0, 0x3C, 0xDD, 0x3D, 0xE3, 0x3C, 0xA1, 0x16, 0xD9, + 0x51, 0x5A, 0xD4, 0x18, 0xF4, 0x02, 0x1E, 0x24, 0x5F, 0xB1, 0x1C, 0x24, 0x36, 0x3C, 0x42, 0x00, + 0xB8, 0xB4, 0x09, 0x1E, 0xBE, 0xBA, 0xFD, 0xC6, 0x6C, 0xED, 0xF1, 0x4F, 0xB7, 0xEE, 0x61, 0x88, + 0x8E, 0xC3, 0x74, 0x5C, 0x67, 0x66, 0x5B, 0x33, 0x8C, 0x04, 0xAD, 0x7D, 0xED, 0x7C, 0xC2, 0xC3, + 0x34, 0x7A, 0x2C, 0x34, 0x8F, 0x7B, 0x61, 0x26, 0x6A, 0x8F, 0x7F, 0x7C, 0x74, 0x6F, 0xBF, 0x43, + 0x1D, 0x8D, 0x3B, 0x13, 0xA2, 0xE0, 0x31, 0x46, 0x0D, 0x07, 0x36, 0x96, 0xE0, 0xD8, 0x0A, 0x07, + 0xB9, 0x48, 0x68, 0xEB, 0x18, 0x16, 0x8A, 0x26, 0x3E, 0x92, 0x74, 0x53, 0x83, 0x48, 0x4E, 0xD8, + 0x12, 0x11, 0xEA, 0x69, 0x3A, 0x42, 0x81, 0xAA, 0xBC, 0xA0, 0xB5, 0x77, 0xE9, 0x79, 0xAE, 0xF7, + 0xAF, 0xBD, 0xE7, 0xD8, 0xE8, 0xF9, 0xDE, 0xBF, 0x4F, 0xB5, 0xBD, 0xE7, 0xF1, 0x50, 0x75, 0x97, + 0x8E, 0x29, 0x4C, 0x63, 0x0B, 0x45, 0x8D, 0x2D, 0x62, 0x1A, 0x5B, 0xDC, 0xAF, 0xC6, 0xE2, 0x9F, + 0x8C, 0xAD, 0xA3, 0xB5, 0xF8, 0x27, 0x5A, 0x73, 0x34, 0x57, 0x08, 0xCF, 0x95, 0xC6, 0xB5, 0xB5, + 0x90, 0x69, 0xAB, 0x8A, 0x9A, 0xD8, 0x18, 0x0E, 0xDE, 0x43, 0xBC, 0xB7, 0x3F, 0xBC, 0xFB, 0x16, + 0xC7, 0x02, 0xB9, 0xCA, 0x42, 0x8D, 0xA5, 0xB3, 0x2D, 0x09, 0x06, 0x4C, 0x0E, 0x12, 0x23, 0x53, + 0x22, 0x49, 0x78, 0xBE, 0xA7, 0xB5, 0x28, 0x4A, 0x4C, 0x11, 0x0A, 0x0C, 0x81, 0x8F, 0x2C, 0x6A, + 0xBE, 0x8B, 0xA3, 0x89, 0x70, 0xDE, 0x08, 0x2A, 0xC7, 0x16, 0x10, 0x40, 0x49, 0x89, 0x0C, 0xF3, + 0x96, 0xC3, 0xC4, 0x06, 0xBD, 0xC6, 0x5D, 0x84, 0xFA, 0xAB, 0xAF, 0x1A, 0xD4, 0x44, 0x4C, 0x8F, + 0x62, 0x9B, 0x5F, 0x28, 0x1D, 0x1E, 0xF9, 0x95, 0x04, 0xC4, 0x3F, 0x05, 0x22, 0x31, 0x70, 0x3E, + 0x62, 0x94, 0xC0, 0x72, 0x2B, 0xC1, 0x42, 0x47, 0x1A, 0x25, 0x1C, 0xF4, 0xF3, 0x11, 0x19, 0x18, + 0xD4, 0xA8, 0xA0, 0xDF, 0x6B, 0x90, 0x60, 0x10, 0x63, 0x9A, 0x12, 0x12, 0xF1, 0xAD, 0x81, 0x6C, + 0x3C, 0x6A, 0xC4, 0x88, 0x37, 0xFC, 0x4B, 0xF0, 0xF0, 0x31, 0x54, 0x09, 0x0D, 0x7F, 0x3B, 0x7D, + 0x26, 0x16, 0x35, 0x62, 0xF8, 0x0B, 0xE1, 0x65, 0x3C, 0xF1, 0x31, 0x5B, 0x8D, 0x27, 0xFE, 0x1E, + 0xF3, 0x6C, 0x3C, 0x8A, 0xB2, 0xE1, 0xEF, 0x0E, 0x97, 0x59, 0x1D, 0x4B, 0x11, 0x72, 0x1D, 0x83, + 0x35, 0x01, 0x60, 0x5E, 0x96, 0x7E, 0xD1, 0x3B, 0xED, 0x46, 0x18, 0x78, 0x46, 0x91, 0x87, 0x81, + 0x37, 0x49, 0x63, 0x10, 0xD1, 0xE1, 0x01, 0x72, 0xBB, 0x87, 0x88, 0x42, 0x90, 0xA3, 0xAB, 0x45, + 0x21, 0x48, 0xBB, 0x45, 0xF8, 0x09, 0x61, 0x32, 0xC2, 0x0F, 0x2D, 0x68, 0xB0, 0x2F, 0x18, 0xE7, + 0xC9, 0x3F, 0xFC, 0x20, 0xB0, 0x4C, 0x89, 0x88, 0x03, 0xD2, 0x79, 0x25, 0x4B, 0xE2, 0xDF, 0xBE, + 0x4D, 0x19, 0x12, 0x2D, 0x96, 0xDC, 0xFA, 0x6A, 0xA1, 0xEB, 0xD6, 0xCF, 0xC0, 0x40, 0xE7, 0x0E, + 0x6A, 0xB9, 0x19, 0xFF, 0x58, 0xAC, 0x04, 0x09, 0xCC, 0x39, 0x94, 0x50, 0xF0, 0x2F, 0x66, 0xCA, + 0x18, 0xA1, 0x1F, 0x54, 0x54, 0x62, 0x45, 0x7C, 0x3C, 0x51, 0x46, 0x07, 0x9D, 0xDE, 0xE4, 0x29, + 0x85, 0x7F, 0xA2, 0x2E, 0x4B, 0x23, 0x6B, 0xD5, 0x21, 0x57, 0x7C, 0x8E, 0x4D, 0x32, 0xEC, 0x56, + 0x9C, 0x15, 0x3E, 0xCC, 0x10, 0xBD, 0xF8, 0x28, 0xE6, 0xE2, 0xC4, 0x96, 0xA6, 0xA2, 0xC4, 0xEE, + 0x18, 0x01, 0x24, 0x47, 0xD3, 0x4D, 0x40, 0xFC, 0x0E, 0xD6, 0x0F, 0x42, 0xE1, 0x6C, 0xDD, 0xEA, + 0x38, 0x40, 0x00, 0x45, 0xB8, 0x1F, 0x8F, 0x55, 0x2C, 0x70, 0x6C, 0xE1, 0x62, 0x97, 0xB3, 0xD0, + 0xB1, 0xBB, 0x19, 0x18, 0x79, 0x7A, 0x9B, 0x84, 0xC0, 0x8B, 0x59, 0xD8, 0x68, 0x8D, 0x28, 0x86, + 0xAB, 0x3F, 0x1A, 0x6D, 0x27, 0xB9, 0xBC, 0x03, 0xB6, 0xAC, 0x84, 0x02, 0xE9, 0x60, 0x89, 0x3E, + 0x2A, 0x7B, 0xCD, 0x60, 0x16, 0xAA, 0xED, 0x89, 0x35, 0xA5, 0xBD, 0xD3, 0xAD, 0x7A, 0x06, 0x40, + 0x70, 0xAB, 0xD2, 0x5E, 0x30, 0x1A, 0x4F, 0xA3, 0x62, 0x89, 0xA6, 0x4D, 0x3D, 0x62, 0x7C, 0xFE, + 0x2A, 0x81, 0x8C, 0x56, 0xFF, 0x43, 0x4C, 0xEC, 0x1A, 0x16, 0x05, 0x53, 0x97, 0xD8, 0x13, 0x37, + 0x6D, 0xD7, 0x21, 0xF2, 0x5E, 0x13, 0xD5, 0x11, 0xDE, 0x11, 0x3F, 0x33, 0xC9, 0xDC, 0xD8, 0xD8, + 0x41, 0x04, 0xE6, 0x91, 0x60, 0xE3, 0x39, 0xBC, 0x5A, 0xB2, 0x3D, 0xB9, 0x92, 0x96, 0xE9, 0x1A, + 0xB4, 0xCD, 0xC3, 0x43, 0xED, 0x65, 0x10, 0x18, 0xA0, 0x00, 0x5C, 0x66, 0x5D, 0xA2, 0x7C, 0x34, + 0x83, 0x17, 0x7C, 0x5D, 0x0F, 0x8D, 0x12, 0xEB, 0xCF, 0x1E, 0x70, 0x4D, 0xBD, 0xD1, 0x07, 0x10, + 0xE1, 0xA4, 0x14, 0x55, 0xE7, 0x3F, 0x1B, 0xE2, 0xDD, 0x7E, 0xA0, 0x02, 0x73, 0xBD, 0x97, 0xE0, + 0x8B, 0x7B, 0x9D, 0x68, 0xA9, 0x64, 0x8F, 0xD5, 0x37, 0x3B, 0x80, 0xEA, 0x12, 0xFA, 0x00, 0x1D, + 0x47, 0x36, 0xCF, 0xB8, 0x09, 0xF5, 0xAE, 0x9D, 0x9F, 0x9F, 0x73, 0x65, 0xA4, 0x0B, 0xAA, 0xD0, + 0xC2, 0x75, 0x3E, 0x93, 0xDB, 0xCD, 0x1A, 0xC4, 0x1F, 0x95, 0x48, 0x53, 0x45, 0x5B, 0x2E, 0x1D, + 0xD2, 0x81, 0x96, 0x17, 0xBC, 0x4C, 0xD6, 0x1B, 0x48, 0x1A, 0x45, 0x2A, 0xA0, 0xD6, 0x89, 0x9E, + 0xF8, 0xD5, 0x56, 0xA3, 0xBB, 0x27, 0xF2, 0x33, 0x49, 0x79, 0x99, 0x13, 0xC8, 0x85, 0x27, 0x86, + 0xAE, 0x54, 0x0F, 0x4F, 0x92, 0xA8, 0xEE, 0xF6, 0x9F, 0x44, 0x91, 0x61, 0xB3, 0x36, 0x8D, 0x80, + 0x24, 0x83, 0x43, 0x68, 0x0B, 0xE2, 0xE6, 0xCA, 0x0D, 0x48, 0x2A, 0x62, 0x58, 0x8E, 0x15, 0x58, + 0x86, 0xFD, 0x31, 0xB2, 0xC6, 0x9D, 0xBA, 0xBF, 0xC4, 0xC7, 0x4B, 0xF8, 0xFF, 0x56, 0x85, 0x57, + 0xAD, 0x2A, 0xB9, 0x65, 0x21, 0x61, 0x3C, 0x88, 0xAC, 0x24, 0x2E, 0x87, 0x44, 0x58, 0xE0, 0xF7, + 0x45, 0x4F, 0x4F, 0x9F, 0xD2, 0xA3, 0x27, 0xA1, 0xD2, 0x44, 0xF4, 0x38, 0xD7, 0xA2, 0x1B, 0x29, + 0x05, 0x6F, 0xE3, 0x4E, 0xE1, 0x10, 0xC8, 0x63, 0x18, 0x98, 0x6F, 0x85, 0xEA, 0x5D, 0xC3, 0x54, + 0x17, 0x6D, 0xE1, 0xFF, 0xA3, 0xFE, 0x23, 0x8A, 0xFA, 0xBB, 0x0B, 0xF1, 0x39, 0xB6, 0x9D, 0xF2, + 0x00, 0x06, 0x27, 0x5F, 0x74, 0x79, 0xBE, 0x77, 0xA0, 0xC9, 0x57, 0x55, 0x52, 0x69, 0xC5, 0xD2, + 0x32, 0x19, 0xC9, 0x91, 0x5D, 0xA1, 0x84, 0x70, 0x61, 0x14, 0x97, 0x0E, 0x71, 0x1D, 0xB1, 0xB5, + 0xC7, 0x56, 0x6D, 0x69, 0x34, 0xBE, 0x8B, 0x12, 0x92, 0xA5, 0x7B, 0x9D, 0x07, 0xE9, 0x41, 0xCC, + 0xB9, 0x22, 0x29, 0xE0, 0x10, 0xDA, 0xB4, 0x7C, 0x63, 0x6A, 0x17, 0x77, 0xCD, 0xDB, 0x99, 0x7C, + 0x28, 0x80, 0x06, 0xE2, 0x0A, 0x80, 0x06, 0x1E, 0xF5, 0x99, 0x18, 0x5A, 0xE2, 0x14, 0x61, 0x15, + 0x64, 0xE5, 0x22, 0x9E, 0x1B, 0xE0, 0xC4, 0x49, 0xCC, 0x2C, 0x90, 0x96, 0x08, 0xB1, 0xF1, 0xCB, + 0x00, 0x91, 0x3C, 0x3D, 0xD7, 0x9C, 0x8D, 0x6D, 0x83, 0x05, 0x22, 0x0B, 0x60, 0x81, 0xF1, 0xBB, + 0xD2, 0x00, 0xFD, 0xE7, 0x8D, 0x66, 0x21, 0xE5, 0x09, 0x09, 0x3C, 0x7B, 0x96, 0xC4, 0x86, 0xCB, + 0xB7, 0x2C, 0x35, 0x0F, 0x7B, 0x63, 0xED, 0xD9, 0xDB, 0x74, 0xA3, 0x51, 0x96, 0x93, 0x04, 0x43, + 0xF5, 0xD3, 0x84, 0xE0, 0x63, 0x19, 0x0E, 0x10, 0x62, 0x99, 0x54, 0x40, 0xB8, 0x49, 0x43, 0xDF, + 0x5A, 0xE9, 0x7A, 0x41, 0xAD, 0xBE, 0x45, 0xF8, 0x1E, 0x9D, 0x7D, 0x90, 0x3F, 0x1A, 0x73, 0x74, + 0x41, 0x64, 0x3B, 0x61, 0x57, 0x71, 0x8C, 0x8B, 0x04, 0x46, 0x64, 0x2C, 0x45, 0x37, 0xFE, 0x68, + 0x07, 0xD0, 0x14, 0x77, 0xC8, 0xC4, 0x06, 0xEF, 0xED, 0xD1, 0x9F, 0x76, 0xBC, 0xDD, 0x30, 0x97, + 0x82, 0xEB, 0xE9, 0xA7, 0x05, 0x34, 0x97, 0x31, 0x46, 0xD1, 0x5D, 0x4F, 0x91, 0x25, 0x4A, 0x02, + 0x1C, 0xE6, 0xA1, 0x9A, 0x1B, 0x33, 0xF2, 0xC9, 0x23, 0x33, 0x77, 0xE1, 0x58, 0xBF, 0x13, 0x19, + 0x42, 0xE6, 0x50, 0x2D, 0xE2, 0x78, 0xAE, 0x98, 0x66, 0x23, 0x72, 0xEE, 0x2B, 0xC9, 0xEB, 0x99, + 0x3D, 0x69, 0x3A, 0xB8, 0xD5, 0x27, 0x0B, 0x37, 0x1F, 0xF8, 0x56, 0x70, 0xBB, 0xDD, 0xCF, 0x44, + 0x6B, 0xF7, 0x04, 0xF5, 0xD0, 0xF4, 0x0D, 0x6E, 0xD0, 0x09, 0x79, 0x08, 0x2F, 0x24, 0x93, 0x50, + 0xE1, 0x9E, 0xE1, 0x0A, 0x58, 0xDC, 0x5E, 0xD8, 0x50, 0x19, 0x8D, 0x93, 0x91, 0x41, 0xDE, 0xD3, + 0xC8, 0xD1, 0xC3, 0x61, 0x43, 0x1A, 0xDF, 0x6B, 0x0E, 0x1A, 0x39, 0x38, 0xD9, 0xC6, 0x97, 0x34, + 0xD2, 0xCD, 0x74, 0x65, 0x05, 0x12, 0x84, 0x7B, 0xBD, 0xBD, 0x32, 0xE3, 0x4F, 0xDC, 0x5B, 0x59, + 0xC4, 0xA3, 0xA9, 0x39, 0x20, 0x4A, 0xAC, 0xE8, 0xCD, 0xD8, 0x06, 0xCF, 0x17, 0x30, 0xFD, 0xC6, + 0x75, 0x3A, 0x54, 0x70, 0x6A, 0x81, 0x9C, 0xA1, 0x60, 0xFB, 0x3A, 0x28, 0x8A, 0xE4, 0xCE, 0x0E, + 0xB1, 0x9B, 0x22, 0x99, 0xC9, 0xC7, 0x37, 0x14, 0xFC, 0xEA, 0x11, 0x80, 0xF3, 0xB1, 0x9A, 0xA8, + 0x7D, 0xF9, 0x07, 0x45, 0x71, 0xA7, 0xCD, 0x21, 0x5A, 0xF8, 0x4B, 0x62, 0xD2, 0xCA, 0x57, 0xB0, + 0xF1, 0x4F, 0x35, 0x5C, 0x14, 0x4F, 0xEC, 0xE4, 0xB8, 0xFB, 0x35, 0xB4, 0x90, 0x70, 0xB0, 0x29, + 0x9C, 0x6C, 0xD0, 0x0D, 0x3F, 0xF9, 0xF3, 0x0C, 0x96, 0x9E, 0x4B, 0x0A, 0x4B, 0xF8, 0x63, 0x91, + 0xC4, 0xEE, 0x40, 0x4E, 0x03, 0xDD, 0x7C, 0x07, 0xD9, 0x4B, 0xCA, 0x4C, 0xF7, 0xF9, 0x34, 0x09, + 0x34, 0x60, 0x8A, 0x90, 0xC7, 0x74, 0x84, 0x93, 0x21, 0x26, 0xA6, 0x84, 0x84, 0x19, 0x33, 0x9C, + 0x97, 0xE2, 0xDD, 0x30, 0x3C, 0x0B, 0x08, 0x65, 0xF1, 0x9B, 0x0F, 0x93, 0x9B, 0xFD, 0x27, 0xA1, + 0x18, 0xB6, 0x71, 0x60, 0x07, 0x31, 0x04, 0x09, 0x11, 0x65, 0x89, 0x89, 0x1B, 0x4D, 0x72, 0x5E, + 0x96, 0x23, 0x33, 0xF6, 0x8B, 0x8D, 0x99, 0x74, 0xC0, 0xA4, 0x3D, 0xFF, 0x8B, 0x1A, 0xCD, 0xBF, + 0x0F, 0xD8, 0x20, 0x1B, 0x8B, 0x79, 0xFB, 0x65, 0x08, 0xDA, 0x9A, 0x24, 0x16, 0x12, 0x73, 0x6F, + 0xE9, 0xB2, 0xF8, 0x41, 0x80, 0xA3, 0xF8, 0x20, 0x29, 0xDD, 0x9A, 0x26, 0xC6, 0x73, 0xBA, 0x90, + 0x41, 0x89, 0x6C, 0x62, 0x73, 0x3C, 0x21, 0x1E, 0x69, 0x3E, 0x97, 0x29, 0x2E, 0x66, 0x5F, 0xCC, + 0x61, 0xAF, 0x2C, 0x72, 0x9D, 0x5B, 0x37, 0xA5, 0x7B, 0xB5, 0xA8, 0xBC, 0x22, 0x80, 0x8B, 0x70, + 0x6F, 0x5E, 0x21, 0x64, 0xB4, 0x8F, 0x2F, 0x86, 0x83, 0x6E, 0xD2, 0x53, 0x5B, 0x5C, 0xA5, 0x4D, + 0x13, 0xA0, 0x88, 0xB5, 0x18, 0x56, 0xEC, 0xDB, 0x4E, 0x93, 0x1F, 0x1F, 0x7C, 0xF2, 0xE0, 0xE9, + 0x28, 0xC7, 0x1A, 0xC7, 0xA0, 0xA9, 0xE7, 0x17, 0x03, 0xC7, 0x77, 0x04, 0xC6, 0x69, 0x37, 0xAE, + 0x14, 0x80, 0xA3, 0x6D, 0x8C, 0x31, 0x50, 0x31, 0x9E, 0xE5, 0x01, 0x42, 0x1B, 0xB6, 0x4D, 0x75, + 0x2F, 0xA6, 0x5F, 0x3F, 0x70, 0xD7, 0x1F, 0x28, 0x21, 0xA9, 0x40, 0x74, 0x4D, 0x17, 0x07, 0x3A, + 0x78, 0xBF, 0xC5, 0xD3, 0xA4, 0xB8, 0x6C, 0x93, 0xEB, 0xA9, 0x1F, 0x70, 0xE5, 0x40, 0x63, 0x78, + 0xF6, 0x92, 0x39, 0x2E, 0x5D, 0x54, 0x90, 0xF6, 0x80, 0x96, 0xD2, 0xF1, 0xBD, 0x19, 0x1B, 0x0E, + 0xC2, 0x4D, 0x7F, 0x18, 0xAD, 0xF0, 0xF0, 0x57, 0xD6, 0x27, 0x0E, 0xDF, 0x09, 0x9B, 0xDA, 0x2F, + 0xA4, 0xC5, 0x5D, 0xA7, 0x49, 0x89, 0x4A, 0x4A, 0xCC, 0xBF, 0x7D, 0x1C, 0x07, 0xD8, 0xD0, 0x87, + 0x11, 0x33, 0x66, 0x6F, 0x19, 0x61, 0x39, 0x12, 0x13, 0x8F, 0x83, 0x49, 0xE2, 0x63, 0x63, 0x19, + 0xDB, 0x31, 0xFA, 0xE2, 0xD3, 0x6C, 0x0A, 0xC3, 0xD7, 0x6B, 0x70, 0x3E, 0xF0, 0xF6, 0xEB, 0xD6, + 0xFE, 0x5D, 0x1E, 0x3B, 0x4C, 0x5C, 0x91, 0xED, 0xA8, 0x12, 0x41, 0x07, 0x0A, 0x39, 0xB6, 0x84, + 0x7C, 0xE4, 0xE8, 0xE2, 0x0E, 0x73, 0xE9, 0x88, 0x69, 0x4A, 0x96, 0x60, 0xCF, 0xB7, 0x45, 0xCB, + 0x32, 0xD5, 0x04, 0x82, 0x68, 0x08, 0xD8, 0x22, 0x36, 0x95, 0xA8, 0xC6, 0xEC, 0x42, 0x34, 0x10, + 0xB4, 0xC7, 0x7D, 0x30, 0x83, 0xF6, 0x64, 0x1E, 0x9F, 0x4A, 0x18, 0x99, 0x00, 0x42, 0x67, 0xCA, + 0x59, 0xC2, 0x99, 0x19, 0xCE, 0x95, 0x91, 0x58, 0xC2, 0x99, 0x01, 0x41, 0x01, 0xE1, 0x7E, 0xD3, + 0xD2, 0x59, 0x03, 0x9D, 0x3B, 0x00, 0x3B, 0xEB, 0xD0, 0x87, 0x61, 0x70, 0x46, 0x82, 0x36, 0x40, + 0x4F, 0x12, 0xB7, 0x97, 0x84, 0xBE, 0x80, 0x90, 0xDF, 0x67, 0x67, 0xAC, 0x41, 0xD8, 0xCB, 0xD4, + 0x35, 0x6F, 0x3B, 0xC6, 0x7A, 0x4D, 0x1C, 0xF3, 0x62, 0x69, 0xD9, 0x66, 0x8B, 0x81, 0xC6, 0xD6, + 0x31, 0x30, 0x2C, 0x12, 0xBA, 0xF5, 0x8D, 0x63, 0x05, 0x6F, 0xBE, 0x60, 0xD7, 0x5A, 0x7B, 0x7D, + 0x53, 0xEC, 0x5C, 0xE4, 0xCD, 0x3A, 0xA6, 0x67, 0x5C, 0x7F, 0x83, 0xFB, 0xA2, 0xA9, 0x39, 0x1C, + 0x74, 0x0F, 0xBA, 0xBC, 0x41, 0x00, 0xE9, 0x96, 0x10, 0x39, 0xE2, 0xC5, 0xFD, 0xA3, 0x3F, 0x7E, + 0xFF, 0x6D, 0x84, 0x37, 0x70, 0x5F, 0xB3, 0x4B, 0xAD, 0x3D, 0xBA, 0xB1, 0xFA, 0xF0, 0xB7, 0x35, + 0xEE, 0x57, 0x11, 0xC3, 0x4C, 0x4C, 0x8C, 0xB8, 0x67, 0x1A, 0x45, 0xC5, 0x9A, 0x7F, 0x15, 0x47, + 0x0A, 0x97, 0x1D, 0x18, 0x27, 0xD0, 0xDC, 0x5B, 0x32, 0x50, 0xB1, 0xA3, 0x1A, 0xC1, 0x91, 0x93, + 0xAF, 0x61, 0xA2, 0xFA, 0x0B, 0x31, 0x3C, 0xD0, 0xC7, 0x73, 0xAD, 0xA5, 0x77, 0xF5, 0xE7, 0x2D, + 0x7A, 0xFD, 0x1D, 0xB0, 0xB3, 0x6C, 0xED, 0x3F, 0xEF, 0xED, 0xEF, 0x77, 0x7C, 0xD0, 0x19, 0x69, + 0xB5, 0xFB, 0xA2, 0x09, 0xFC, 0xA1, 0x6D, 0x58, 0x27, 0xD9, 0xF7, 0xDF, 0xBA, 0x1B, 0xCF, 0xCF, + 0x6B, 0xF0, 0xCE, 0x72, 0x70, 0x24, 0xCE, 0x6B, 0xF2, 0x01, 0xA6, 0x2F, 0x8E, 0xB9, 0xD5, 0x44, + 0xA7, 0x1B, 0xC1, 0xC5, 0x9C, 0x91, 0xEE, 0x8F, 0x85, 0xA4, 0x3F, 0x96, 0xEE, 0xF3, 0x8C, 0x93, + 0x60, 0xC5, 0xBB, 0x25, 0x96, 0xA5, 0xEE, 0xE2, 0xC6, 0x11, 0xE5, 0x74, 0xBC, 0x6A, 0xB0, 0xA5, + 0xFF, 0x54, 0xC0, 0xE2, 0x59, 0xD2, 0x56, 0x2D, 0x5C, 0x25, 0x11, 0x95, 0x66, 0x58, 0xB9, 0x19, + 0x69, 0xB2, 0x60, 0x9C, 0x9E, 0x2D, 0x27, 0xB3, 0xCF, 0x8B, 0x0D, 0xF8, 0xF8, 0x4A, 0x44, 0x54, + 0x76, 0x0D, 0xA7, 0x9C, 0x61, 0xE8, 0x87, 0x29, 0x68, 0xDE, 0x78, 0x04, 0xB7, 0x63, 0x23, 0x18, + 0x9F, 0xAF, 0x16, 0x00, 0xD0, 0x49, 0x6A, 0x38, 0x8A, 0x69, 0x08, 0xB5, 0x4D, 0xB6, 0x24, 0x44, + 0x40, 0xBB, 0xFD, 0x30, 0x62, 0x21, 0x10, 0x9F, 0x75, 0x45, 0xBA, 0xDB, 0x9E, 0x5D, 0xA7, 0x03, + 0xD6, 0xD6, 0xAC, 0xFA, 0x2E, 0xA6, 0x2D, 0xF1, 0x70, 0x4E, 0xC4, 0x0F, 0xC9, 0x67, 0x9E, 0xC4, + 0x99, 0x17, 0x55, 0x82, 0x02, 0x88, 0x4F, 0x34, 0xC7, 0x8F, 0xB3, 0x4F, 0x14, 0xD9, 0x27, 0x9C, + 0x7D, 0x04, 0x88, 0x26, 0x9C, 0xC5, 0x25, 0x8B, 0xD0, 0x18, 0x7F, 0x7A, 0x15, 0x71, 0x76, 0x3D, + 0xCD, 0xA5, 0x93, 0x97, 0x12, 0x62, 0xEC, 0xE5, 0x03, 0x40, 0xFB, 0x15, 0xF8, 0x43, 0x9C, 0xAD, + 0xEB, 0xA9, 0x1A, 0x5B, 0xA2, 0x14, 0x81, 0x00, 0x11, 0x5B, 0xF2, 0x82, 0x85, 0x60, 0xE5, 0x35, + 0x09, 0xF8, 0x23, 0x7C, 0x86, 0x63, 0x6A, 0x73, 0xCF, 0x58, 0x11, 0xFC, 0x62, 0x7B, 0x48, 0xAC, + 0x49, 0xEF, 0x17, 0xE6, 0x7E, 0xAC, 0x59, 0x8C, 0xC9, 0xB0, 0xE4, 0x51, 0x08, 0x1A, 0xB6, 0x8C, + 0x41, 0x87, 0x74, 0xE4, 0x42, 0x8B, 0x46, 0x2C, 0x83, 0x0B, 0x4F, 0x95, 0x84, 0x15, 0xB6, 0x8E, + 0x1C, 0x21, 0x42, 0x20, 0x4A, 0x26, 0xA3, 0x74, 0xA9, 0x8C, 0xCD, 0x24, 0x18, 0xB3, 0xA9, 0xF9, + 0x42, 0xBC, 0x41, 0xC8, 0x52, 0xA2, 0x4D, 0xE8, 0x20, 0x0C, 0x3E, 0x8B, 0xCC, 0x42, 0x52, 0xD8, + 0x42, 0xA2, 0xFE, 0xDE, 0x26, 0x58, 0x9E, 0xE0, 0x2F, 0x65, 0xBC, 0xF8, 0xE6, 0x6B, 0xCD, 0xF5, + 0x34, 0xDB, 0xBD, 0x26, 0xB8, 0x28, 0x28, 0x36, 0x90, 0x69, 0x53, 0x02, 0x01, 0x8E, 0xB0, 0x22, + 0x13, 0xEE, 0x8F, 0x09, 0x96, 0x96, 0x0F, 0x73, 0x62, 0x7C, 0x75, 0x28, 0x79, 0xAA, 0x87, 0xA3, + 0x53, 0x21, 0x7B, 0xDB, 0x25, 0xF4, 0x84, 0x38, 0x19, 0x4C, 0x24, 0xCB, 0xA7, 0x9C, 0xC7, 0xAD, + 0xC0, 0x92, 0x57, 0xD6, 0x2A, 0x21, 0xC2, 0xF0, 0xF6, 0xA3, 0x95, 0xA2, 0x9C, 0x81, 0x42, 0x41, + 0x86, 0x60, 0x91, 0x2C, 0x23, 0x5E, 0xB7, 0xA4, 0x29, 0xAB, 0x1D, 0xE6, 0x68, 0x14, 0x4B, 0xE1, + 0xD2, 0x68, 0x9E, 0xAD, 0x15, 0x26, 0x71, 0x36, 0xCA, 0xB1, 0xDF, 0xD9, 0xA1, 0x78, 0x14, 0x8A, + 0x9D, 0x61, 0xF6, 0x36, 0x79, 0x72, 0x76, 0xB8, 0x0C, 0x56, 0xF6, 0xE4, 0xC9, 0xFF, 0x02, 0x49, + 0x60, 0xC8, 0xA8, 0x55, 0x0C, 0x01, 0x00 +}; + + +//File: index_ov5640.html.gz, Size: 9124 +#define index_ov5640_html_gz_len 9124 +const uint8_t index_ov5640_html_gz[] = { + 0x1F, 0x8B, 0x08, 0x08, 0xD9, 0x6C, 0x6A, 0x5E, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x5F, + 0x6F, 0x76, 0x35, 0x36, 0x34, 0x30, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xED, 0x3D, 0x6B, 0x77, + 0xDB, 0xB6, 0x92, 0xDF, 0xF3, 0x2B, 0x18, 0xF5, 0x6E, 0x24, 0x9F, 0x58, 0xB6, 0xA8, 0x97, 0x1F, + 0xB1, 0x95, 0x4D, 0x1C, 0x27, 0xE9, 0xB9, 0x4D, 0x6F, 0x1A, 0xA7, 0x69, 0x7B, 0xBA, 0x3D, 0x29, + 0x25, 0x41, 0x12, 0x1B, 0x8A, 0xD4, 0x25, 0x29, 0xCB, 0x6E, 0x8E, 0x7F, 0xC7, 0xFE, 0xA0, 0xFD, + 0x63, 0x3B, 0x03, 0x80, 0x24, 0x48, 0x81, 0x24, 0x48, 0x4A, 0xB2, 0xDB, 0x5D, 0xE5, 0x9C, 0x98, + 0x0F, 0xCC, 0x60, 0xDE, 0x18, 0x0C, 0x40, 0xF2, 0xEC, 0xF1, 0xD8, 0x19, 0xF9, 0xB7, 0x0B, 0xA2, + 0xCD, 0xFC, 0xB9, 0x35, 0x78, 0x74, 0xC6, 0xFE, 0x68, 0xF0, 0x3B, 0x9B, 0x11, 0x63, 0xCC, 0x0E, + 0xE9, 0xE9, 0x9C, 0xF8, 0x86, 0x36, 0x9A, 0x19, 0xAE, 0x47, 0xFC, 0xF3, 0xDA, 0xD2, 0x9F, 0x34, + 0x8F, 0x6B, 0xC9, 0xDB, 0xB6, 0x31, 0x27, 0xE7, 0xB5, 0x6B, 0x93, 0xAC, 0x16, 0x8E, 0xEB, 0xD7, + 0xB4, 0x91, 0x63, 0xFB, 0xC4, 0x86, 0xE6, 0x2B, 0x73, 0xEC, 0xCF, 0xCE, 0xC7, 0xE4, 0xDA, 0x1C, + 0x91, 0x26, 0x3D, 0xD9, 0x37, 0x6D, 0xD3, 0x37, 0x0D, 0xAB, 0xE9, 0x8D, 0x0C, 0x8B, 0x9C, 0xEB, + 0x22, 0x2E, 0xDF, 0xF4, 0x2D, 0x32, 0xB8, 0xBC, 0x7A, 0xDF, 0x69, 0x6B, 0xFF, 0xFA, 0xD4, 0xEB, + 0x77, 0x5B, 0x67, 0x87, 0xEC, 0x5A, 0xD4, 0xC6, 0xF3, 0x6F, 0xC5, 0x73, 0xFC, 0x0D, 0x9D, 0xF1, + 0xAD, 0xF6, 0x35, 0x76, 0x09, 0x7F, 0x13, 0x20, 0xA2, 0x39, 0x31, 0xE6, 0xA6, 0x75, 0x7B, 0xAA, + 0xBD, 0x70, 0xA1, 0xCF, 0xFD, 0xB7, 0xC4, 0xBA, 0x26, 0xBE, 0x39, 0x32, 0xF6, 0x3D, 0xC3, 0xF6, + 0x9A, 0x1E, 0x71, 0xCD, 0xC9, 0xB3, 0x35, 0xC0, 0xA1, 0x31, 0xFA, 0x32, 0x75, 0x9D, 0xA5, 0x3D, + 0x3E, 0xD5, 0xBE, 0xD1, 0x8F, 0xF1, 0xDF, 0x7A, 0xA3, 0x91, 0x63, 0x39, 0x2E, 0xDC, 0xBF, 0x7C, + 0x8D, 0xFF, 0xD6, 0xEF, 0xD3, 0xDE, 0x3D, 0xF3, 0x4F, 0x72, 0xAA, 0xE9, 0xFD, 0xC5, 0x4D, 0xEC, + 0xFE, 0xDD, 0xA3, 0xD8, 0xE9, 0xAC, 0x9D, 0x46, 0x3D, 0x87, 0x3F, 0xCE, 0x86, 0xF7, 0xC8, 0xC8, + 0x37, 0x1D, 0xFB, 0x60, 0x6E, 0x98, 0xB6, 0x04, 0xD3, 0xD8, 0xF4, 0x16, 0x96, 0x01, 0x32, 0x98, + 0x58, 0x24, 0x13, 0xCF, 0x37, 0x73, 0x62, 0x2F, 0xF7, 0x73, 0xB0, 0x21, 0x92, 0xE6, 0xD8, 0x74, + 0x59, 0xAB, 0x53, 0x94, 0xC3, 0x72, 0x6E, 0xE7, 0xA2, 0xCD, 0xA2, 0xCB, 0x76, 0x6C, 0x22, 0x11, + 0x20, 0x76, 0xB4, 0x72, 0x8D, 0x05, 0x36, 0xC0, 0xBF, 0xEB, 0x4D, 0xE6, 0xA6, 0xCD, 0x8C, 0xEA, + 0x54, 0xEB, 0x74, 0x5B, 0x8B, 0x9B, 0x1C, 0x55, 0x76, 0xFA, 0xF8, 0x6F, 0xBD, 0xD1, 0xC2, 0x18, + 0x8F, 0x4D, 0x7B, 0x7A, 0xAA, 0x1D, 0x4B, 0x51, 0x38, 0xEE, 0x98, 0xB8, 0x4D, 0xD7, 0x18, 0x9B, + 0x4B, 0xEF, 0x54, 0xEB, 0xCA, 0xDA, 0xCC, 0x0D, 0x77, 0x0A, 0xB4, 0xF8, 0x0E, 0x10, 0xDB, 0xD4, + 0xA5, 0x94, 0xF0, 0x26, 0xAE, 0x39, 0x9D, 0xF9, 0xA0, 0xD2, 0xB5, 0x36, 0x49, 0xA1, 0x71, 0x17, + 0xCA, 0xD3, 0x67, 0xA6, 0xDC, 0xE4, 0x52, 0x33, 0x2C, 0x73, 0x6A, 0x37, 0x4D, 0x9F, 0xCC, 0x81, + 0x1D, 0xCF, 0x77, 0x89, 0x3F, 0x9A, 0x65, 0x91, 0x32, 0x31, 0xA7, 0x4B, 0x97, 0x48, 0x08, 0x09, + 0xE5, 0x96, 0xC1, 0x30, 0xDC, 0x5C, 0xBF, 0xD5, 0x5C, 0x91, 0xE1, 0x17, 0xD3, 0x6F, 0x72, 0x99, + 0x0C, 0xC9, 0xC4, 0x71, 0x89, 0xB4, 0x65, 0xD0, 0xC2, 0x72, 0x46, 0x5F, 0x9A, 0x9E, 0x6F, 0xB8, + 0xBE, 0x0A, 0x42, 0x63, 0xE2, 0x13, 0x37, 0x1F, 0x1F, 0x41, 0xAB, 0xC8, 0xC7, 0x96, 0xDE, 0x2D, + 0x6F, 0x60, 0xDA, 0x96, 0x69, 0x13, 0x75, 0xF2, 0xD2, 0xFA, 0x8D, 0xA3, 0x63, 0xAD, 0x14, 0x14, + 0x63, 0xCE, 0xA7, 0x59, 0x56, 0x42, 0x79, 0x5D, 0xEF, 0x8C, 0xFB, 0x8D, 0xDE, 0x6A, 0xFD, 0xC7, + 0xFA, 0xCD, 0x19, 0x61, 0x66, 0x6A, 0x2C, 0x7D, 0xA7, 0xBA, 0x47, 0xAC, 0xB9, 0x55, 0x82, 0x8F, + 0xFF, 0x9C, 0x93, 0xB1, 0x69, 0x68, 0x0D, 0xC1, 0x9D, 0x8F, 0x5B, 0x60, 0x53, 0x7B, 0x9A, 0x61, + 0x8F, 0xB5, 0x86, 0xE3, 0x9A, 0xE0, 0x08, 0x06, 0x0D, 0x37, 0x16, 0x5C, 0x81, 0x81, 0x63, 0x41, + 0xF6, 0x24, 0x2C, 0x67, 0xF8, 0x8C, 0x28, 0x11, 0xB9, 0xDB, 0xE0, 0x4F, 0x21, 0xE4, 0xE0, 0x2F, + 0xD7, 0x81, 0x24, 0x3C, 0x52, 0xF4, 0x59, 0xFA, 0x12, 0x29, 0x4C, 0xD3, 0x19, 0xFE, 0xE6, 0xC6, + 0x4D, 0x33, 0x53, 0x77, 0x41, 0xA3, 0x40, 0x87, 0x30, 0xCC, 0x8E, 0x1A, 0xD0, 0xF4, 0x7A, 0xA6, + 0x35, 0x35, 0x8C, 0x92, 0x7B, 0x72, 0x18, 0x8E, 0x54, 0xAE, 0x72, 0xFC, 0x89, 0x46, 0x51, 0x80, + 0x5D, 0x39, 0xAB, 0x51, 0xEC, 0x60, 0xFF, 0x64, 0x36, 0xC4, 0x38, 0x49, 0x8D, 0x22, 0xF8, 0x53, + 0x8F, 0x24, 0x11, 0xB2, 0xDC, 0x68, 0x22, 0x41, 0x9C, 0x1E, 0x51, 0xD6, 0xF0, 0xA6, 0x79, 0xB7, + 0x04, 0x6B, 0x36, 0x09, 0xAA, 0xD1, 0x45, 0x82, 0x38, 0x8B, 0x86, 0xDC, 0x28, 0x83, 0xBF, 0x3B, + 0x85, 0x7C, 0xE3, 0x9B, 0xE1, 0xD2, 0xF7, 0x1D, 0xDB, 0xAB, 0x34, 0x44, 0xA5, 0xF9, 0xD9, 0x1F, + 0x4B, 0xCF, 0x37, 0x27, 0xB7, 0x4D, 0xEE, 0xD2, 0xE0, 0x67, 0x0B, 0x03, 0x52, 0xC8, 0x21, 0xF1, + 0x57, 0x84, 0x64, 0xA7, 0x1B, 0xB6, 0x71, 0x0D, 0x71, 0x67, 0x3A, 0xB5, 0x64, 0xB6, 0x37, 0x5A, + 0xBA, 0x1E, 0xE6, 0x6D, 0x0B, 0xC7, 0x04, 0xC4, 0xEE, 0x7A, 0xC7, 0x71, 0x1F, 0x54, 0xEC, 0xA8, + 0x39, 0x1A, 0x4A, 0xFA, 0x72, 0x96, 0x3E, 0xCA, 0x58, 0xAA, 0x09, 0x07, 0xD8, 0x31, 0xFD, 0x5B, + 0xE9, 0x3D, 0xEE, 0x89, 0x92, 0x3B, 0x81, 0x0B, 0x66, 0x0E, 0x0B, 0x71, 0xBA, 0x4E, 0x47, 0x33, + 0x32, 0xFA, 0x42, 0xC6, 0x4F, 0x73, 0xD3, 0xB0, 0xBC, 0xF4, 0xF0, 0xC0, 0xB4, 0x17, 0x4B, 0xBF, + 0x89, 0xE9, 0xD4, 0x62, 0x2B, 0x3A, 0xA7, 0x06, 0x19, 0xB0, 0xD8, 0x6E, 0x67, 0x25, 0x15, 0xBD, + 0xC5, 0x4D, 0xB6, 0x10, 0x44, 0x62, 0x07, 0x96, 0x31, 0x24, 0x56, 0x16, 0xC9, 0xDC, 0x19, 0x52, + 0xC2, 0x2E, 0x8F, 0x55, 0xE9, 0xB9, 0x1B, 0xA5, 0x2C, 0x1A, 0xBC, 0xBA, 0x47, 0xFF, 0xA1, 0x2C, + 0x47, 0x7A, 0xBC, 0x1F, 0xBB, 0xE4, 0x11, 0x0B, 0x1C, 0x2C, 0x71, 0x6D, 0x61, 0xA4, 0x26, 0xE3, + 0xD0, 0x62, 0x05, 0x54, 0x65, 0x76, 0xE9, 0x1A, 0xF6, 0x94, 0x40, 0x74, 0xB8, 0xD9, 0x0F, 0x0E, + 0xB3, 0xA7, 0x0A, 0x4A, 0x02, 0xC1, 0xE0, 0xDD, 0xCB, 0x9E, 0x9A, 0xB0, 0x10, 0xB1, 0xAF, 0x1D, + 0xB0, 0x83, 0x12, 0x79, 0x8A, 0xA0, 0xF1, 0x4C, 0x42, 0x74, 0xA9, 0xBD, 0xB0, 0x54, 0x45, 0xEA, + 0x4B, 0x71, 0x6B, 0x93, 0xA6, 0xFE, 0xB9, 0xC1, 0x22, 0x98, 0x04, 0x4E, 0x26, 0x79, 0xD3, 0xC8, + 0xC9, 0xA4, 0xD3, 0xEA, 0x74, 0x73, 0x73, 0x29, 0x29, 0x97, 0x89, 0xA9, 0xA4, 0x24, 0x98, 0x84, + 0x81, 0x26, 0x5F, 0x17, 0xA7, 0x33, 0xE7, 0x9A, 0xB8, 0x12, 0x45, 0x24, 0xC8, 0xED, 0x9E, 0x74, + 0xC7, 0x0A, 0xD8, 0x0C, 0x18, 0x0A, 0xAE, 0x65, 0x81, 0x36, 0x8E, 0xAE, 0xAD, 0x8F, 0xDA, 0x99, + 0x16, 0xCA, 0xD0, 0x1D, 0x80, 0x35, 0x18, 0x43, 0x8B, 0x8C, 0x33, 0x22, 0xF7, 0x98, 0x4C, 0x8C, + 0xA5, 0xE5, 0xE7, 0xC8, 0xDB, 0x68, 0xE1, 0xBF, 0xAC, 0x1E, 0xA9, 0x7B, 0xFD, 0x8A, 0x35, 0x90, + 0x73, 0xEA, 0x12, 0xBF, 0x49, 0xFA, 0x0C, 0x86, 0x55, 0x63, 0xB1, 0x20, 0x06, 0xB4, 0x1A, 0x91, + 0xB4, 0xD9, 0xAA, 0x52, 0x3A, 0x2D, 0x8F, 0x69, 0x4A, 0x73, 0xD4, 0x5C, 0x53, 0x0C, 0x13, 0xA5, + 0x42, 0x3C, 0x9F, 0x4E, 0x9C, 0xD1, 0x52, 0x36, 0x82, 0xAB, 0x99, 0xD4, 0x3A, 0xBE, 0xD3, 0x40, + 0x64, 0x9E, 0x65, 0x52, 0xC3, 0x5E, 0xDA, 0x36, 0x6A, 0xB4, 0xE9, 0xBB, 0xC0, 0xA6, 0xA4, 0x23, + 0x35, 0xC1, 0x95, 0xF2, 0xCE, 0x98, 0x60, 0xD3, 0xEA, 0x34, 0x09, 0x07, 0x94, 0x04, 0x8A, 0x30, + 0x86, 0x68, 0x9E, 0x03, 0x4C, 0x05, 0xA8, 0xAA, 0xC9, 0xC5, 0x9F, 0x2D, 0xE7, 0xB2, 0x9C, 0x21, + 0xE8, 0x4C, 0x87, 0x01, 0x8E, 0x75, 0xE7, 0x4E, 0x87, 0x46, 0xA3, 0xB5, 0xDF, 0xDA, 0xEF, 0xC0, + 0x7F, 0x92, 0xDC, 0x3D, 0xDB, 0xB8, 0xB8, 0x78, 0x53, 0x2C, 0x2F, 0x11, 0x7C, 0xF2, 0x4B, 0x28, + 0x69, 0x61, 0x2C, 0x57, 0x17, 0xEA, 0x9E, 0x14, 0xAF, 0xA5, 0xE8, 0x07, 0x39, 0x23, 0x4C, 0x8A, + 0x49, 0x17, 0x37, 0x44, 0x89, 0xB5, 0x14, 0x55, 0xF1, 0xDC, 0xF9, 0xB3, 0xC9, 0x86, 0xD7, 0xFF, + 0xF3, 0xD6, 0x2E, 0x88, 0xE2, 0x6F, 0x6D, 0xE9, 0x85, 0xE5, 0xE2, 0xDD, 0xB7, 0x6D, 0xB4, 0xD2, + 0xB5, 0xDE, 0xE4, 0xF9, 0x0C, 0x50, 0x68, 0x43, 0xC6, 0xE9, 0xC2, 0xC4, 0x2B, 0x35, 0xE7, 0x11, + 0xDA, 0x94, 0x90, 0xC1, 0xC4, 0xB4, 0xAC, 0xA6, 0xE5, 0xAC, 0xF2, 0x33, 0x91, 0x6C, 0x4B, 0x5E, + 0xB3, 0xD3, 0x7C, 0x93, 0x2F, 0x4B, 0xED, 0x12, 0x22, 0xD7, 0x5F, 0x82, 0xDA, 0xBF, 0xB7, 0xC3, + 0x65, 0xBA, 0x46, 0xB9, 0x81, 0xA2, 0x84, 0x3D, 0x56, 0xEB, 0x48, 0xC9, 0x94, 0x58, 0x26, 0x98, + 0x39, 0xAB, 0xF3, 0x56, 0xA6, 0x3F, 0x9A, 0x95, 0x98, 0x54, 0x2D, 0x1C, 0xCF, 0x64, 0xCB, 0x37, + 0x2E, 0xB1, 0x0C, 0xCC, 0xE0, 0x4B, 0xCD, 0xC6, 0x73, 0x27, 0x26, 0x22, 0xB8, 0x0A, 0x27, 0x54, + 0x74, 0x0F, 0xA7, 0x92, 0x72, 0xC0, 0x72, 0x87, 0xF4, 0x58, 0x2D, 0x37, 0xEB, 0x9C, 0x74, 0x3F, + 0xEE, 0x19, 0xF2, 0x46, 0x05, 0x22, 0x7A, 0x10, 0xB4, 0xA7, 0x2E, 0xB9, 0x55, 0x60, 0x66, 0x9F, + 0xFF, 0x3D, 0x65, 0xB5, 0xD2, 0xF2, 0x45, 0x00, 0x3A, 0x00, 0x70, 0x2B, 0x3A, 0xE8, 0x7A, 0x0A, + 0x5D, 0xA7, 0x77, 0xA9, 0x62, 0x8F, 0x61, 0x25, 0xB0, 0x56, 0x53, 0x08, 0x37, 0x19, 0x43, 0xA8, + 0xDC, 0x54, 0x83, 0xD1, 0x57, 0x7A, 0xD3, 0x22, 0x13, 0x3F, 0x65, 0xA1, 0x83, 0xE6, 0xA9, 0x9D, + 0xEC, 0xE8, 0xD6, 0x14, 0xEA, 0x04, 0xB9, 0x91, 0x23, 0x2C, 0xD8, 0xA5, 0x5B, 0x9F, 0x14, 0x33, + 0x46, 0xCF, 0xC2, 0xC8, 0xD3, 0x55, 0x12, 0xA4, 0xCF, 0x54, 0xCD, 0xD0, 0x66, 0xCE, 0x87, 0x7C, + 0x50, 0x0F, 0xF9, 0xB9, 0xD1, 0xEE, 0x4B, 0xD7, 0x11, 0x32, 0x1A, 0x67, 0x91, 0xC6, 0x2A, 0x5E, + 0x4A, 0x43, 0x56, 0xEA, 0x04, 0x59, 0x8C, 0x45, 0x52, 0x45, 0x65, 0x7B, 0x65, 0x56, 0x84, 0x59, + 0xAF, 0xD1, 0x64, 0x1A, 0xBB, 0x39, 0x37, 0x20, 0xED, 0x45, 0x73, 0x35, 0x00, 0xA3, 0x4C, 0x7F, + 0x2A, 0xE6, 0x2E, 0xD4, 0x13, 0xF5, 0x7E, 0x2B, 0xA7, 0xCB, 0x91, 0xE5, 0x78, 0xD9, 0x7E, 0x65, + 0x0C, 0x41, 0x7E, 0x4B, 0x5F, 0xD2, 0x11, 0xAF, 0x6A, 0x4A, 0x2B, 0x4F, 0xD4, 0xB8, 0xA5, 0x77, + 0x94, 0x86, 0xEE, 0x4C, 0x9F, 0xCA, 0x76, 0xC7, 0x84, 0xCC, 0xF5, 0x96, 0x34, 0xD2, 0x66, 0xD6, + 0xDF, 0x7C, 0x72, 0x03, 0xF3, 0x4D, 0x5C, 0xAB, 0x3B, 0xD5, 0x46, 0x44, 0x1E, 0x46, 0x63, 0x83, + 0x9C, 0xAE, 0x52, 0x04, 0xCC, 0xD4, 0xC3, 0xCC, 0x1C, 0x8F, 0x49, 0x66, 0x95, 0x13, 0xE7, 0xBC, + 0xD9, 0xA1, 0xD2, 0x90, 0x96, 0xD3, 0x0A, 0x68, 0xB2, 0x9D, 0xAE, 0xCA, 0xCC, 0xE1, 0x2A, 0x25, + 0xF4, 0xC5, 0x24, 0x24, 0x6D, 0x22, 0x54, 0x61, 0xE5, 0x21, 0x12, 0x15, 0x31, 0x26, 0x23, 0xC7, + 0x65, 0x8B, 0xB8, 0x29, 0x13, 0xFF, 0x72, 0x33, 0x2B, 0x44, 0x2E, 0x2B, 0xDD, 0x6D, 0x25, 0x74, + 0x64, 0x6E, 0x74, 0xD0, 0xB7, 0x1D, 0x57, 0xF8, 0x70, 0x9C, 0x56, 0x49, 0x8F, 0x27, 0x6C, 0x99, + 0xA4, 0x4A, 0x43, 0x60, 0xA8, 0x46, 0x14, 0x19, 0xC8, 0x01, 0x5B, 0xAD, 0x2B, 0x34, 0x41, 0x15, + 0x5D, 0x5A, 0x39, 0xE0, 0xAB, 0x4D, 0x7C, 0x61, 0xB0, 0x99, 0xB6, 0xDE, 0xB2, 0xC1, 0xC5, 0x37, + 0x6A, 0x01, 0xC9, 0x7E, 0x53, 0x45, 0x73, 0x4F, 0xF9, 0x63, 0x06, 0x91, 0xE1, 0x40, 0x1C, 0x6C, + 0xB7, 0x8A, 0xB7, 0x2A, 0x1B, 0x42, 0xCE, 0x0E, 0x85, 0xFD, 0x71, 0x67, 0x87, 0xD1, 0x56, 0xBE, + 0x33, 0xDC, 0x24, 0x27, 0x6E, 0xA3, 0xE3, 0xFD, 0x8C, 0x2C, 0xC3, 0xF3, 0xCE, 0x6B, 0xB8, 0xD9, + 0xAB, 0x16, 0xDF, 0x55, 0x77, 0x36, 0x36, 0xAF, 0x35, 0x73, 0x7C, 0x5E, 0xB3, 0x9C, 0xA9, 0x93, + 0xB8, 0x47, 0xEF, 0x33, 0x2D, 0xC3, 0x68, 0x7F, 0x5E, 0x8B, 0xAD, 0x38, 0xD6, 0x28, 0x54, 0x74, + 0xA9, 0x36, 0x78, 0xF2, 0xCD, 0xC9, 0xD1, 0x51, 0xFF, 0xD9, 0x13, 0x7B, 0xE8, 0x2D, 0xF8, 0xFF, + 0x1F, 0xD9, 0x02, 0xAD, 0x47, 0x7C, 0x1F, 0x6C, 0xCE, 0x3B, 0x3B, 0xA4, 0xD8, 0x12, 0x14, 0x1C, + 0x02, 0x09, 0x29, 0x44, 0xF1, 0x6C, 0x50, 0x46, 0x57, 0xD0, 0xC4, 0x83, 0x04, 0x67, 0x68, 0xB8, + 0x92, 0x26, 0xB4, 0x19, 0x9B, 0x6B, 0xD0, 0x18, 0x52, 0xA3, 0xCA, 0x18, 0x3A, 0x37, 0x49, 0xD2, + 0x29, 0x37, 0x5C, 0x53, 0xBC, 0x15, 0x19, 0xA7, 0x21, 0x04, 0x30, 0x0A, 0x8E, 0xEB, 0xAC, 0xD0, + 0x46, 0xDA, 0x28, 0x26, 0x7B, 0x6C, 0x7C, 0x33, 0xB2, 0xBE, 0x04, 0x4A, 0xAF, 0x05, 0xDA, 0xB0, + 0x1D, 0x9F, 0x8D, 0x24, 0x29, 0x5D, 0xC5, 0x58, 0xE5, 0x30, 0xC2, 0x6A, 0x21, 0xE3, 0x02, 0x44, + 0xDB, 0xA4, 0xD8, 0xD9, 0xB5, 0x6C, 0x4C, 0x14, 0x9B, 0xA0, 0xD0, 0x00, 0xB8, 0x36, 0xF8, 0xF9, + 0xE2, 0xBB, 0x7F, 0x6A, 0xEF, 0xDE, 0xFE, 0x29, 0xD5, 0x50, 0x1E, 0x51, 0x18, 0x9C, 0x15, 0x7A, + 0xA6, 0x60, 0x4C, 0x1F, 0x81, 0x4C, 0x6A, 0x5C, 0x33, 0x14, 0x03, 0x26, 0x43, 0x16, 0xB1, 0xA7, + 0xFE, 0xEC, 0xBC, 0xA6, 0xD7, 0x70, 0x77, 0x4B, 0x70, 0xD6, 0xAE, 0x69, 0x18, 0xB8, 0xE9, 0xC1, + 0xB5, 0x61, 0x2D, 0xF1, 0xA8, 0xA5, 0xC2, 0xEB, 0xBA, 0x69, 0x49, 0x9B, 0xF1, 0x88, 0x12, 0xCA, + 0x58, 0x88, 0xC0, 0x71, 0x29, 0xD7, 0x06, 0x57, 0xC4, 0x3F, 0x3B, 0x64, 0xB7, 0x72, 0xB4, 0x96, + 0xDD, 0x37, 0xB8, 0x30, 0x33, 0x87, 0x2C, 0x13, 0xCA, 0x52, 0xFC, 0xC4, 0x35, 0xE6, 0x04, 0xA5, + 0xA2, 0xA4, 0x79, 0x51, 0xEB, 0x21, 0x64, 0x6D, 0xF0, 0x81, 0xD0, 0x2C, 0x03, 0xC8, 0x50, 0x52, + 0xFC, 0x19, 0x4F, 0xE1, 0x63, 0xFD, 0x87, 0xF6, 0xCC, 0x97, 0xEC, 0x9A, 0x06, 0x33, 0x73, 0x05, + 0xB9, 0x3F, 0x6E, 0x36, 0xB5, 0xDE, 0xBB, 0xF7, 0x5A, 0xB3, 0xA9, 0xD0, 0xD8, 0x59, 0x50, 0x77, + 0x0A, 0xF4, 0x0F, 0x16, 0xC2, 0xA8, 0x21, 0x54, 0x3F, 0xEC, 0xA8, 0x36, 0xF8, 0xE1, 0xEA, 0xE7, + 0x37, 0x2F, 0x1A, 0xED, 0x5E, 0xBF, 0x75, 0xA3, 0x9F, 0xB4, 0x5B, 0x7B, 0x67, 0x87, 0x0C, 0xAE, + 0x78, 0x07, 0x60, 0x60, 0xEF, 0xB5, 0xD7, 0x6F, 0x5F, 0x35, 0xF4, 0xD6, 0x71, 0x55, 0x64, 0xFA, + 0x49, 0x6D, 0xF0, 0xD3, 0x0F, 0x11, 0x65, 0xFD, 0x56, 0x15, 0x64, 0xC7, 0xC0, 0x26, 0xD0, 0xC5, + 0x50, 0x75, 0xBB, 0x85, 0x50, 0xA1, 0xC8, 0x3B, 0xE5, 0x44, 0xAE, 0x1F, 0x41, 0xBF, 0x94, 0x87, + 0x56, 0xF7, 0xF8, 0x46, 0xEF, 0xF5, 0xBB, 0x15, 0x78, 0xE8, 0xA3, 0x74, 0x81, 0x90, 0xC6, 0x71, + 0xBF, 0x5B, 0x15, 0x57, 0x0F, 0x71, 0x81, 0x40, 0x8E, 0xDA, 0x20, 0x8F, 0xF6, 0x71, 0x15, 0xD1, + 0x76, 0x6B, 0x03, 0xAA, 0xF2, 0x13, 0x44, 0xD5, 0x2A, 0x86, 0x0A, 0x45, 0xDB, 0x2E, 0x29, 0xDA, + 0x4E, 0x6D, 0xF0, 0x23, 0x8A, 0x16, 0x2D, 0x03, 0x78, 0xA8, 0x64, 0x1E, 0x6D, 0x88, 0x52, 0x14, + 0x57, 0x1B, 0xED, 0xB6, 0xD5, 0xAE, 0x22, 0x5A, 0xBD, 0x36, 0x40, 0x71, 0x20, 0xA6, 0xA3, 0x4A, + 0x0E, 0x00, 0xDE, 0x44, 0x69, 0x02, 0x72, 0x6E, 0x8E, 0xFA, 0xC7, 0xE5, 0x31, 0x81, 0x27, 0x5D, + 0x7D, 0x02, 0x4C, 0xC7, 0x20, 0xA8, 0x4A, 0x6E, 0x04, 0x5E, 0x84, 0x78, 0xFA, 0xDD, 0xD6, 0x4D, + 0xB7, 0x8A, 0xCD, 0x80, 0x57, 0xBC, 0x45, 0x44, 0x80, 0xE4, 0xA6, 0x53, 0x45, 0x46, 0xE0, 0x12, + 0x17, 0xDF, 0xBE, 0x6E, 0x74, 0x81, 0xB1, 0xF6, 0x49, 0xBF, 0x3C, 0x1E, 0x70, 0x87, 0x1F, 0x90, + 0x20, 0x20, 0xE6, 0xA6, 0x5D, 0x2C, 0x3A, 0xC4, 0x11, 0x81, 0x33, 0x00, 0x3C, 0xE2, 0x28, 0x8D, + 0x02, 0xEC, 0xFA, 0x2D, 0x25, 0x06, 0x11, 0xE9, 0x47, 0x15, 0xB8, 0x02, 0xAB, 0xFE, 0x01, 0xC5, + 0x03, 0x48, 0x30, 0xE8, 0x55, 0x30, 0x45, 0x40, 0x44, 0x49, 0xD2, 0xFB, 0xD4, 0xD5, 0xCA, 0x63, + 0x02, 0x9B, 0x3E, 0xE9, 0xDF, 0x9C, 0xF4, 0xD5, 0x10, 0xE0, 0x88, 0x8F, 0xA3, 0x54, 0x56, 0x4E, + 0x90, 0x9D, 0x32, 0x64, 0xA5, 0x03, 0xFF, 0x5E, 0x1A, 0x16, 0xCC, 0x6F, 0x0A, 0x27, 0x03, 0x1C, + 0x0E, 0x64, 0xC2, 0x0E, 0xD4, 0xF2, 0x00, 0x81, 0x92, 0x70, 0xA3, 0x59, 0x6D, 0xD0, 0x55, 0xC8, + 0xB7, 0x62, 0x09, 0x39, 0x85, 0x8D, 0xD1, 0x4F, 0x93, 0x40, 0xB4, 0x3C, 0x4C, 0xFF, 0xC0, 0x25, + 0x3A, 0x35, 0x21, 0x82, 0x94, 0x4A, 0x34, 0x24, 0xB4, 0x1A, 0x37, 0xB5, 0x41, 0xBF, 0x93, 0x9B, + 0xA0, 0x95, 0x57, 0xC6, 0x90, 0xD6, 0x68, 0x6C, 0xE2, 0x79, 0x85, 0xF5, 0x11, 0x81, 0xD6, 0x06, + 0x2F, 0xC3, 0xE3, 0x2A, 0x5A, 0x69, 0xE6, 0x71, 0x4A, 0x61, 0x53, 0xD4, 0x22, 0x90, 0xC3, 0x34, + 0xD3, 0xEC, 0x70, 0xD5, 0x44, 0x9A, 0xD9, 0xAC, 0x62, 0xB6, 0xA9, 0x17, 0x9C, 0x4E, 0xBA, 0x86, + 0xE7, 0x17, 0xD6, 0x4A, 0x00, 0x08, 0x11, 0x9A, 0x1F, 0xDD, 0x9B, 0x46, 0x42, 0x52, 0xFE, 0x06, + 0xFA, 0xF0, 0x0C, 0x7F, 0xC9, 0xAA, 0x85, 0x85, 0x35, 0x12, 0x81, 0x42, 0x3E, 0x10, 0x1E, 0x57, + 0xD2, 0x4A, 0x95, 0xF0, 0x25, 0x90, 0xC3, 0xF5, 0x12, 0x84, 0xB0, 0xEE, 0x96, 0xF4, 0x92, 0x47, + 0x6D, 0x25, 0xBD, 0xCC, 0x0C, 0x77, 0x51, 0x2A, 0x7C, 0x85, 0x90, 0xA0, 0x95, 0xE0, 0xF0, 0xDE, + 0x5C, 0x25, 0x22, 0xE6, 0x6F, 0xE0, 0x2B, 0x63, 0x62, 0x3B, 0xA6, 0x57, 0x7C, 0xB6, 0xCF, 0xE1, + 0x6A, 0x83, 0x57, 0xA4, 0xF9, 0x3D, 0x1E, 0x55, 0x51, 0xC7, 0x8B, 0xA5, 0xEF, 0x54, 0x50, 0x48, + 0x40, 0x0B, 0x53, 0x47, 0x8B, 0x6B, 0xE3, 0x78, 0x4B, 0xDA, 0x38, 0xDE, 0xA2, 0x36, 0x0C, 0xF2, + 0xD9, 0x22, 0xD7, 0xC4, 0x2A, 0xAC, 0x8E, 0x00, 0xB0, 0x36, 0xB8, 0xBC, 0x59, 0x38, 0x1E, 0x3E, + 0x3A, 0xF5, 0x1D, 0x9E, 0x57, 0x72, 0x92, 0x5E, 0x05, 0x9D, 0x84, 0x04, 0x71, 0x1F, 0xE9, 0x71, + 0xAD, 0xF4, 0xB6, 0xA4, 0x95, 0x3C, 0x5A, 0xAB, 0x68, 0x65, 0x6A, 0x98, 0xF6, 0x88, 0x98, 0x16, + 0x3E, 0xC6, 0x51, 0x54, 0x31, 0x02, 0x6C, 0x6D, 0xF0, 0x26, 0x3A, 0xA9, 0xA2, 0x98, 0x56, 0x05, + 0xBD, 0x88, 0xF4, 0xC4, 0xFD, 0xA5, 0x07, 0xB3, 0xF2, 0x2D, 0xE9, 0x46, 0xD7, 0xB7, 0x39, 0xAA, + 0x2C, 0xC8, 0xC8, 0x34, 0xAC, 0xCF, 0x64, 0x32, 0x81, 0x69, 0x50, 0xF1, 0xA1, 0x25, 0x06, 0x0E, + 0xE3, 0x0B, 0x3B, 0xD7, 0x2E, 0xE9, 0x79, 0xE1, 0xFA, 0x65, 0x02, 0x5D, 0xF9, 0x22, 0x66, 0x72, + 0x4E, 0x28, 0x2D, 0x4B, 0x7E, 0xEF, 0x84, 0x74, 0x96, 0x9F, 0xB6, 0x7E, 0x4F, 0xA6, 0x74, 0x1B, + 0x41, 0x95, 0x39, 0xF4, 0x1B, 0xD7, 0xB8, 0xA5, 0xEF, 0x64, 0xA8, 0x32, 0xA5, 0xFF, 0x40, 0xC6, + 0xDA, 0x47, 0xD3, 0x2E, 0xCF, 0x4C, 0x17, 0x09, 0x21, 0xC4, 0xAE, 0x86, 0xA5, 0x07, 0x53, 0x24, + 0x38, 0xA8, 0x86, 0xA4, 0x8F, 0x35, 0xFD, 0x85, 0x69, 0x3C, 0x84, 0x49, 0xBC, 0xB1, 0x1A, 0x16, + 0x1F, 0x50, 0x56, 0x43, 0x18, 0x97, 0x7F, 0x7A, 0xA9, 0x5D, 0xD2, 0x8D, 0xEF, 0x85, 0xC3, 0x15, + 0xDB, 0x93, 0xA7, 0x62, 0xE8, 0xD1, 0xD2, 0x0D, 0xF6, 0xB9, 0xB6, 0xA6, 0x26, 0x77, 0x20, 0xD5, + 0x75, 0x35, 0x09, 0x7B, 0x01, 0x81, 0x74, 0x0B, 0x53, 0x4D, 0xE0, 0x56, 0x8D, 0xC7, 0x2D, 0xA6, + 0x62, 0xA3, 0x55, 0xF1, 0x34, 0x6C, 0xB4, 0x02, 0x35, 0x8D, 0xAF, 0xF1, 0x99, 0x88, 0xB1, 0x06, + 0xFA, 0xDA, 0x89, 0xA2, 0xB0, 0xD7, 0xFB, 0x51, 0x14, 0xE5, 0xF7, 0xBE, 0x15, 0x05, 0xD6, 0xF2, + 0x19, 0xC7, 0xD1, 0x32, 0x4E, 0x45, 0x01, 0x6B, 0x83, 0x77, 0x86, 0xBD, 0x84, 0x41, 0x66, 0x57, + 0x0A, 0x0B, 0x3B, 0xBE, 0x37, 0xF7, 0xE2, 0x7C, 0xDF, 0xB7, 0xEA, 0x80, 0x90, 0xB9, 0x33, 0x2E, + 0x3E, 0xDD, 0xE1, 0x70, 0x2C, 0x24, 0xBE, 0x83, 0xA3, 0xC2, 0x89, 0x41, 0x80, 0x61, 0xCB, 0x19, + 0x01, 0x9B, 0x4A, 0x95, 0x4F, 0x06, 0xAE, 0x96, 0xB6, 0x7D, 0x5B, 0x25, 0x13, 0xB8, 0xB0, 0x9C, + 0xE5, 0xB8, 0x3C, 0x06, 0x48, 0x03, 0xFE, 0x35, 0x99, 0x98, 0xA3, 0xF2, 0x89, 0x04, 0x24, 0x01, + 0x6F, 0x9D, 0xB9, 0x22, 0xFC, 0x96, 0x07, 0x5E, 0x32, 0x2A, 0x31, 0x93, 0x1B, 0x81, 0x16, 0x2F, + 0x2F, 0x76, 0x3A, 0xF0, 0x42, 0x9F, 0xF7, 0x14, 0x19, 0x90, 0xDB, 0xFB, 0x0E, 0x0A, 0x40, 0xC4, + 0x67, 0x6A, 0x3C, 0x65, 0x94, 0xC5, 0x20, 0xC3, 0x88, 0x1E, 0x4C, 0xBF, 0xEF, 0x6B, 0x7E, 0x17, + 0x51, 0x14, 0x9F, 0xDD, 0xE1, 0xD2, 0x73, 0x38, 0xBD, 0xEB, 0xB4, 0x37, 0x3B, 0xC1, 0x43, 0xE4, + 0xDB, 0xD5, 0x4F, 0xBB, 0x8C, 0x6A, 0x20, 0x1A, 0x7D, 0x8F, 0xEB, 0x0C, 0x05, 0x02, 0x76, 0x75, + 0x47, 0x6A, 0xDF, 0x9F, 0x27, 0xB5, 0x1F, 0x80, 0x2B, 0x4D, 0x4B, 0x44, 0xBC, 0x29, 0x46, 0xBC, + 0x37, 0x17, 0xBB, 0xD1, 0xD0, 0xF4, 0xDE, 0x42, 0xDD, 0xF4, 0x5E, 0x43, 0x9D, 0xC6, 0x37, 0x05, + 0x06, 0x52, 0x28, 0x99, 0xC1, 0x72, 0x40, 0x56, 0xCB, 0xAA, 0x12, 0xE4, 0xF4, 0x9B, 0x2A, 0x51, + 0x2E, 0x20, 0x23, 0x1E, 0xE4, 0xFA, 0xD1, 0xAA, 0x48, 0x6F, 0xB3, 0xCB, 0xBA, 0xDD, 0x3C, 0x6A, + 0xAB, 0x38, 0x8D, 0x6B, 0xAC, 0x3E, 0x4F, 0xE7, 0x46, 0x61, 0x65, 0x70, 0x38, 0xD0, 0xC5, 0xBB, + 0x17, 0xBB, 0x4C, 0x17, 0x82, 0x7E, 0xEF, 0xC7, 0x8F, 0x42, 0xAE, 0xEF, 0x3B, 0xD6, 0x59, 0xC4, + 0x2E, 0x1E, 0xEC, 0x10, 0xA8, 0x36, 0xF8, 0x8E, 0xD8, 0x9E, 0x76, 0xE1, 0xB8, 0xFC, 0x45, 0x98, + 0x3B, 0xD1, 0x1A, 0xED, 0xF9, 0x7E, 0x54, 0xC6, 0x98, 0xBE, 0x6F, 0x7D, 0xCD, 0xE6, 0xA6, 0xEB, + 0x3A, 0x6E, 0x61, 0x95, 0x71, 0x38, 0x98, 0x56, 0x34, 0xDF, 0xD1, 0xA3, 0x9D, 0xA8, 0x2B, 0xE8, + 0xF5, 0x7E, 0x34, 0x16, 0xF2, 0x7C, 0xDF, 0x4A, 0xBB, 0x9E, 0x58, 0xE6, 0xA2, 0xB0, 0xCA, 0x28, + 0x54, 0x6D, 0xF0, 0xA9, 0xF9, 0x1A, 0xFE, 0xEE, 0x44, 0x5D, 0xAC, 0xC7, 0xFB, 0x51, 0x16, 0xE7, + 0xF6, 0xBE, 0x55, 0x35, 0x5C, 0x14, 0x0F, 0x87, 0x00, 0x53, 0x1B, 0xBC, 0x7C, 0xBF, 0x9B, 0xDC, + 0x0F, 0x3B, 0x53, 0xD4, 0x50, 0x25, 0x7D, 0x50, 0xA6, 0xEE, 0x5B, 0x1B, 0xAB, 0x12, 0xDA, 0x58, + 0x21, 0xE1, 0x3F, 0xED, 0x48, 0x1B, 0x2B, 0x75, 0x6D, 0x6C, 0xD8, 0x5F, 0x56, 0x0F, 0x41, 0x3F, + 0xF4, 0xE9, 0xD3, 0xA1, 0x51, 0x7C, 0x38, 0x0A, 0x00, 0x71, 0xD3, 0x18, 0x1C, 0x69, 0x2F, 0x8D, + 0xDD, 0x0C, 0x48, 0x61, 0xBF, 0xBB, 0x70, 0xA1, 0x88, 0xC9, 0xFB, 0xD6, 0xD3, 0xC4, 0x18, 0x91, + 0xCF, 0x63, 0xE2, 0x97, 0x59, 0x5B, 0x16, 0x60, 0x6B, 0x83, 0xD7, 0x70, 0xA2, 0xBD, 0xA2, 0x27, + 0xBB, 0x4A, 0xF9, 0xC4, 0xFE, 0x77, 0xA1, 0xB5, 0x18, 0xBF, 0x0F, 0x42, 0x71, 0x90, 0x60, 0x3B, + 0x53, 0xBB, 0xD4, 0xE3, 0x4C, 0x31, 0x70, 0xAE, 0xBE, 0x0F, 0xEC, 0x7C, 0xB7, 0x0A, 0x8C, 0x88, + 0xD8, 0x99, 0x0E, 0x05, 0xBE, 0x37, 0xA8, 0x46, 0xC5, 0xA7, 0x1A, 0xF9, 0x9B, 0x81, 0xF3, 0x74, + 0xC5, 0x9F, 0xAE, 0xA3, 0x9B, 0x5A, 0x88, 0xDF, 0xF4, 0x7C, 0xD3, 0xB2, 0x60, 0x2A, 0x4C, 0x7C, + 0xED, 0x0A, 0x0F, 0x15, 0x1F, 0xA7, 0x13, 0xB0, 0x04, 0x0F, 0xD1, 0xFA, 0x2E, 0x31, 0xE6, 0xB5, + 0xC1, 0x15, 0xBE, 0x33, 0x19, 0x70, 0xE1, 0x59, 0x71, 0x64, 0x54, 0x8C, 0xC4, 0x76, 0x1D, 0x20, + 0x2A, 0x54, 0x13, 0x7F, 0x3F, 0x65, 0x4D, 0x0B, 0x8E, 0x84, 0x6B, 0x83, 0x4B, 0xDA, 0x58, 0x43, + 0x3B, 0xCB, 0xEF, 0x4E, 0xF9, 0x39, 0x3F, 0xFA, 0x44, 0x2F, 0x3E, 0xA2, 0x1B, 0x7F, 0xA3, 0x3A, + 0xE8, 0x95, 0xBD, 0xD5, 0x60, 0x70, 0x46, 0xDF, 0x0E, 0xCB, 0x9B, 0xD1, 0x87, 0xD9, 0x57, 0xFC, + 0xE9, 0xE4, 0xA1, 0x63, 0x8D, 0x9F, 0x09, 0xAB, 0xCB, 0x57, 0xE1, 0xE3, 0xB6, 0x08, 0x02, 0x86, + 0x11, 0x60, 0xC8, 0x51, 0xFE, 0xCC, 0x0D, 0xD0, 0xB3, 0x27, 0xA2, 0xF1, 0x1D, 0x65, 0x19, 0xCA, + 0x4D, 0x79, 0x34, 0xD8, 0x25, 0xD3, 0x50, 0x90, 0xB2, 0x27, 0xC6, 0xA5, 0x0F, 0x0A, 0x7F, 0x20, + 0x53, 0xD3, 0x03, 0x1A, 0x35, 0x30, 0x8B, 0x43, 0xFA, 0x8C, 0x25, 0x33, 0x66, 0xB5, 0xE7, 0x77, + 0xC5, 0x2E, 0xF9, 0xCB, 0x19, 0xA4, 0x8F, 0x63, 0x17, 0xCA, 0x4F, 0x92, 0x0F, 0x4F, 0xC7, 0x31, + 0xE6, 0x19, 0xFD, 0xE3, 0x66, 0x73, 0xD6, 0xC5, 0xA7, 0x45, 0xB5, 0x80, 0xB5, 0xB3, 0xC3, 0x59, + 0x37, 0xEF, 0xD1, 0xB0, 0xDC, 0x47, 0x7D, 0x81, 0xD3, 0xD2, 0x4F, 0xFA, 0xA2, 0x94, 0x06, 0x40, + 0xCD, 0xBE, 0xF6, 0xCE, 0xF0, 0xBE, 0xEC, 0x6B, 0x9F, 0xB0, 0x00, 0xB7, 0xC3, 0x07, 0x7E, 0x91, + 0x76, 0x63, 0x3C, 0x76, 0x53, 0x1F, 0xFA, 0xED, 0xC6, 0x1E, 0xFA, 0xED, 0x07, 0x0F, 0xFD, 0xF6, + 0xA3, 0xDD, 0x6E, 0x37, 0x9D, 0x56, 0xEB, 0x58, 0x85, 0x75, 0xC5, 0x07, 0x7F, 0x37, 0xC2, 0xD3, + 0x1C, 0xA4, 0xA9, 0xC8, 0x53, 0x37, 0xE0, 0x49, 0xD8, 0x15, 0x7E, 0x33, 0x99, 0x3C, 0x34, 0x8E, + 0xF8, 0xBA, 0x54, 0x79, 0x96, 0x5A, 0xED, 0x5D, 0x3F, 0x9D, 0x4D, 0x8D, 0x7B, 0x53, 0x0F, 0x67, + 0xD3, 0x26, 0xC9, 0x68, 0xD8, 0xCB, 0x0C, 0x86, 0x14, 0x84, 0x39, 0xFD, 0x9B, 0x4D, 0x3A, 0xFD, + 0xB4, 0x82, 0xD3, 0x4F, 0xD7, 0x9C, 0x7E, 0x87, 0xDE, 0x1E, 0x10, 0xFE, 0x77, 0xF3, 0xF8, 0x80, + 0xAF, 0x02, 0x5E, 0x2F, 0xE5, 0xAB, 0xD5, 0xDA, 0xA8, 0xDF, 0xE7, 0x3A, 0x49, 0x68, 0x0C, 0x6F, + 0x36, 0xE9, 0x24, 0x29, 0xA6, 0x5B, 0xCA, 0x4E, 0x79, 0xD8, 0x19, 0xEC, 0x66, 0x5C, 0xA2, 0xD9, + 0x94, 0xA8, 0x50, 0xDE, 0x3B, 0x3E, 0x13, 0xDA, 0xE9, 0xF2, 0xD4, 0x69, 0x13, 0xEA, 0x51, 0x7F, + 0x11, 0x44, 0x6A, 0x93, 0xCD, 0x24, 0x66, 0x0B, 0x21, 0xC3, 0x55, 0x4E, 0xCC, 0xDE, 0x7F, 0xF7, + 0x5D, 0xB1, 0x5C, 0x4C, 0xEC, 0xE5, 0x81, 0xE4, 0x62, 0x99, 0xB5, 0xD0, 0xDB, 0x05, 0xDC, 0x40, + 0xAA, 0x4B, 0x99, 0x6E, 0x04, 0x5E, 0x1B, 0xBC, 0xA4, 0xC7, 0x9A, 0x20, 0xB1, 0x42, 0xC6, 0xAB, + 0x3C, 0xED, 0xA4, 0x80, 0x42, 0xB1, 0x34, 0x22, 0x21, 0xA9, 0x1B, 0x45, 0x5C, 0x19, 0x05, 0x52, + 0x81, 0x3D, 0x75, 0xA6, 0x2A, 0xFB, 0x04, 0x6D, 0x92, 0x97, 0x0A, 0x2F, 0x5C, 0x52, 0x5A, 0x6D, + 0x1C, 0xB6, 0x36, 0x78, 0xEF, 0x12, 0xED, 0x95, 0x79, 0xAD, 0xCE, 0x9B, 0xB0, 0x51, 0x30, 0x44, + 0xA2, 0x26, 0xE5, 0xE4, 0x0E, 0x3E, 0xE9, 0xAE, 0x40, 0x5C, 0x6C, 0x57, 0xDD, 0x4E, 0x27, 0xC1, + 0x0A, 0x69, 0x57, 0xBB, 0x1A, 0x86, 0x4E, 0x6D, 0xD0, 0xA9, 0x86, 0xA1, 0x5B, 0x1B, 0x74, 0xAB, + 0x61, 0xE8, 0x81, 0x1C, 0x0E, 0x7A, 0xD5, 0x70, 0xF4, 0x6B, 0x83, 0x7E, 0x35, 0x0C, 0x47, 0x20, + 0xCB, 0xAA, 0x54, 0x40, 0xE6, 0x72, 0x5C, 0x00, 0x43, 0xFE, 0x26, 0x47, 0xD6, 0xAA, 0xBA, 0xF3, + 0xCC, 0x97, 0x56, 0x69, 0xE7, 0xE1, 0xB0, 0xB5, 0xC1, 0xBB, 0xA5, 0xE5, 0x9B, 0x0B, 0xCB, 0x84, + 0x69, 0x7B, 0xA3, 0xAB, 0x35, 0xB5, 0x76, 0xAF, 0xBD, 0xB7, 0xC3, 0x0C, 0x33, 0xA0, 0x43, 0xED, + 0x1D, 0x52, 0x9D, 0x20, 0x09, 0xD3, 0x8F, 0xC5, 0x77, 0x0A, 0x3C, 0x88, 0x70, 0xE6, 0x3A, 0x8E, + 0x5F, 0x5A, 0x1D, 0x01, 0x30, 0xA4, 0xF9, 0x70, 0x54, 0x3A, 0x9A, 0x45, 0x68, 0xCA, 0x18, 0x7A, + 0xCA, 0x26, 0xE7, 0x8A, 0xE1, 0x4C, 0x2F, 0x16, 0xCE, 0x76, 0xE7, 0x3E, 0xDE, 0x6D, 0xF9, 0x94, + 0x81, 0xC3, 0xC2, 0x6C, 0xF5, 0x16, 0x66, 0x88, 0x73, 0x54, 0x98, 0xD6, 0x68, 0x81, 0xFB, 0xE8, + 0xBD, 0x5D, 0x7A, 0x4F, 0x40, 0x46, 0xC1, 0x37, 0xB0, 0x89, 0xDE, 0xF3, 0x30, 0x9C, 0x87, 0xEA, + 0x83, 0x58, 0xE3, 0x5E, 0x79, 0x8D, 0x04, 0xD0, 0x90, 0x0F, 0xE0, 0x7B, 0xF0, 0x2A, 0xF9, 0x91, + 0x80, 0xAC, 0x9C, 0x23, 0x55, 0x77, 0x1A, 0xA9, 0x2B, 0x56, 0xCC, 0x0B, 0xDA, 0x95, 0x47, 0xF5, + 0xCE, 0x43, 0x1C, 0x0B, 0x17, 0xF8, 0xEA, 0x44, 0xA2, 0xB6, 0xBD, 0x92, 0x22, 0x13, 0x93, 0x48, + 0x06, 0xCB, 0x8D, 0x86, 0x6D, 0x2B, 0xDF, 0x69, 0xE6, 0x1F, 0x10, 0xB0, 0xBE, 0xDA, 0x54, 0x7C, + 0x15, 0x5E, 0x60, 0x4E, 0x36, 0x11, 0x08, 0x79, 0x7D, 0x70, 0xB3, 0x00, 0x24, 0xAC, 0xF4, 0x34, + 0x80, 0x03, 0x73, 0x15, 0x86, 0x91, 0xB8, 0xA3, 0x17, 0x88, 0xC4, 0xE2, 0x8C, 0x20, 0xC4, 0x57, + 0x72, 0xB4, 0xBB, 0xF7, 0xF4, 0xBF, 0x2B, 0x0F, 0x1E, 0x15, 0x5D, 0xBF, 0x60, 0x1A, 0x2C, 0xC1, + 0x80, 0xEF, 0x0B, 0xD4, 0x8B, 0x24, 0xF4, 0x9B, 0x0B, 0x1F, 0xC2, 0x5A, 0x21, 0x35, 0x38, 0x3A, + 0x74, 0x13, 0x9F, 0xCD, 0xF3, 0x0B, 0x2C, 0xFF, 0xA5, 0x36, 0xD9, 0x4C, 0x75, 0x67, 0x65, 0xDA, + 0xC5, 0xAB, 0x3B, 0x3F, 0x99, 0xF6, 0xD8, 0x59, 0x15, 0x2B, 0xF0, 0x88, 0x1D, 0xFD, 0x05, 0x0A, + 0x3C, 0x34, 0x3D, 0xC0, 0x15, 0xE2, 0xA6, 0x4B, 0xD4, 0xDE, 0x67, 0x93, 0x14, 0x32, 0x83, 0xBE, + 0xC1, 0x05, 0x56, 0x40, 0xE1, 0x69, 0x74, 0xBD, 0x79, 0xDB, 0x99, 0xDA, 0xCF, 0xA7, 0x62, 0xAE, + 0xC6, 0x29, 0x50, 0xCB, 0xD5, 0xBA, 0x92, 0x72, 0xF3, 0xBD, 0x57, 0xD0, 0x7F, 0x59, 0xE7, 0xE7, + 0xF6, 0xDE, 0xF9, 0xD9, 0xC4, 0x00, 0x44, 0xEC, 0x71, 0x69, 0xCB, 0x42, 0xD8, 0xC8, 0xAE, 0x2E, + 0xED, 0xF1, 0x4E, 0xAD, 0x8A, 0xF5, 0x5E, 0x5A, 0x07, 0xED, 0x7E, 0xBB, 0xF3, 0xB0, 0xCC, 0x0A, + 0x19, 0xAA, 0x60, 0x54, 0xFA, 0x49, 0xEF, 0x01, 0x4D, 0x69, 0x9C, 0xC9, 0x84, 0xAD, 0x6B, 0x96, + 0x33, 0x2D, 0x0E, 0x7E, 0x43, 0x9F, 0xD2, 0xF5, 0xC8, 0x6E, 0xE3, 0x55, 0xD8, 0x79, 0xC1, 0xD2, + 0x8C, 0xA0, 0x8B, 0xFE, 0xC3, 0x32, 0x2D, 0xCE, 0x91, 0xAA, 0x75, 0x49, 0x38, 0xEA, 0x3E, 0x1C, + 0xD3, 0xF2, 0x1D, 0xDF, 0xB0, 0x4A, 0x5B, 0x16, 0x83, 0x06, 0xC3, 0xFA, 0x88, 0x07, 0xDA, 0x15, + 0xF0, 0xB9, 0x53, 0xE3, 0x0A, 0xFA, 0x2F, 0x1F, 0xB8, 0x8E, 0xBB, 0x1B, 0x52, 0x46, 0x05, 0x96, + 0x7E, 0x59, 0x67, 0xA9, 0x52, 0xE8, 0xEA, 0x6F, 0x68, 0x91, 0x7C, 0x23, 0xA1, 0x6B, 0xE9, 0xE3, + 0xD5, 0xD2, 0xA1, 0x8B, 0x81, 0x63, 0xE8, 0xA2, 0x47, 0xBB, 0x37, 0xB1, 0x90, 0x82, 0xF2, 0x36, + 0xD6, 0x3B, 0xD9, 0xE4, 0x16, 0x98, 0x4D, 0x44, 0x30, 0xC6, 0x53, 0x25, 0x23, 0xDB, 0x94, 0xDF, + 0x54, 0x36, 0xB2, 0x91, 0xA1, 0xFC, 0x8E, 0x37, 0x8A, 0x4C, 0xCC, 0xE6, 0x19, 0x2C, 0xCC, 0xE1, + 0xD8, 0xC1, 0x4E, 0x2B, 0x36, 0x41, 0xE7, 0x1B, 0x5F, 0xA8, 0x0D, 0xB9, 0x7A, 0x48, 0xF5, 0x99, + 0xA1, 0x69, 0xDB, 0x65, 0xD5, 0xC4, 0x61, 0x6B, 0x83, 0x97, 0xEC, 0x60, 0xB7, 0x4B, 0xEA, 0xBC, + 0xF3, 0xCD, 0xAF, 0xA7, 0x07, 0x5C, 0xED, 0x5A, 0x4D, 0x89, 0x22, 0x86, 0x1B, 0x7E, 0xF7, 0xA3, + 0xC6, 0xF7, 0xA8, 0x46, 0xDF, 0x01, 0x79, 0x38, 0x25, 0x8D, 0xA9, 0x31, 0xC7, 0x87, 0x97, 0x8B, + 0x16, 0x35, 0xDE, 0x20, 0x58, 0xB1, 0x9A, 0x46, 0xBC, 0xA7, 0x87, 0x5D, 0xD5, 0x18, 0xC4, 0xDF, + 0x5A, 0x09, 0x84, 0x37, 0x87, 0xA6, 0xE1, 0xE1, 0x83, 0xFE, 0x70, 0xAC, 0xBD, 0x84, 0x63, 0xED, + 0xBD, 0xB5, 0x0C, 0x5F, 0xBB, 0x2B, 0x73, 0x08, 0x71, 0x3F, 0x5B, 0x84, 0x21, 0xED, 0xA9, 0x05, + 0xBA, 0x8D, 0x8F, 0x3F, 0xE0, 0x05, 0xC7, 0xB8, 0x7B, 0xAD, 0xD7, 0x3D, 0x6E, 0xD5, 0x34, 0x96, + 0x15, 0xF3, 0xC7, 0xFA, 0xBD, 0x2F, 0x74, 0x5B, 0x9B, 0x1E, 0x12, 0x28, 0x73, 0x00, 0x91, 0xDE, + 0x90, 0x40, 0x6A, 0xBF, 0x55, 0x76, 0x9B, 0xAD, 0x4B, 0x44, 0x0F, 0xC4, 0xD1, 0x92, 0x1A, 0x42, + 0xEC, 0x3D, 0x9B, 0xAC, 0x7D, 0xFC, 0x05, 0x05, 0xED, 0x9E, 0xEC, 0xFD, 0xA7, 0x72, 0x41, 0xE8, + 0x52, 0x41, 0xE0, 0xEE, 0xBE, 0xCD, 0xF2, 0xD4, 0x0E, 0x78, 0xD2, 0xD5, 0x78, 0x6A, 0x57, 0xE0, + 0xA9, 0xBD, 0x23, 0x9E, 0x3A, 0x01, 0x4F, 0x6D, 0x35, 0x9E, 0x3A, 0x15, 0x78, 0xEA, 0xEC, 0x88, + 0xA7, 0x6E, 0xC0, 0x53, 0x47, 0x8D, 0xA7, 0x6E, 0x05, 0x9E, 0xBA, 0x3B, 0xE2, 0xA9, 0x17, 0xF0, + 0xD4, 0x55, 0xE3, 0xA9, 0x57, 0x81, 0xA7, 0xDE, 0x8E, 0x78, 0xEA, 0x07, 0x3C, 0xF5, 0xD4, 0x78, + 0xEA, 0x57, 0xE0, 0xA9, 0xBF, 0x23, 0x9E, 0x8E, 0x02, 0x9E, 0xFA, 0x6A, 0x3C, 0x1D, 0x55, 0xE0, + 0xE9, 0x68, 0x47, 0x3C, 0x1D, 0x07, 0x3C, 0x1D, 0xA9, 0xF1, 0x74, 0x5C, 0x81, 0xA7, 0xE3, 0x1D, + 0xF1, 0x74, 0x12, 0xF0, 0x74, 0xAC, 0xC6, 0xD3, 0x49, 0x05, 0x9E, 0x4E, 0x76, 0xC4, 0x13, 0xEE, + 0xA6, 0x62, 0x4C, 0x9D, 0x28, 0x0E, 0xBA, 0xAD, 0x0A, 0x5C, 0x19, 0xBB, 0xE2, 0x2A, 0x4C, 0x25, + 0x74, 0xD5, 0x5C, 0xA2, 0x4A, 0x32, 0x31, 0xDC, 0x15, 0x5B, 0x51, 0x36, 0xA1, 0x98, 0x4E, 0xE8, + 0x55, 0xF2, 0x89, 0xD1, 0xAE, 0xD8, 0x0A, 0x13, 0x0A, 0x5D, 0x31, 0xA3, 0xD0, 0xAB, 0xA4, 0x14, + 0xE3, 0x5D, 0xB1, 0x15, 0xE6, 0x14, 0xBA, 0x62, 0x52, 0xA1, 0x57, 0xC9, 0x2A, 0xC8, 0xAE, 0xD8, + 0x0A, 0xD3, 0x0A, 0x5D, 0x31, 0xAF, 0xD0, 0xAB, 0x24, 0x16, 0x93, 0x5D, 0xB1, 0x15, 0x66, 0x16, + 0xBA, 0x62, 0x6A, 0xA1, 0x57, 0xC8, 0x2D, 0x4E, 0xE4, 0x13, 0xB1, 0x8D, 0xB2, 0x45, 0x7C, 0x3E, + 0x45, 0x8E, 0x26, 0x6D, 0x4A, 0x0F, 0x1C, 0x71, 0x20, 0x7C, 0x22, 0x8E, 0x09, 0xE4, 0xC2, 0xB1, + 0x27, 0xE6, 0x34, 0x2C, 0x32, 0x3C, 0x98, 0x67, 0x63, 0x3C, 0xE1, 0x85, 0xBF, 0xCA, 0x85, 0x86, + 0xAB, 0x57, 0x97, 0xC5, 0xCA, 0x0C, 0x62, 0x2F, 0x7F, 0xA1, 0x22, 0x03, 0x90, 0xDD, 0x16, 0xBF, + 0x3E, 0xA0, 0x54, 0x57, 0xA0, 0x40, 0x45, 0x2A, 0x0A, 0x3D, 0xB1, 0xA2, 0xD0, 0x57, 0xAE, 0x28, + 0x30, 0xE2, 0xB6, 0x53, 0x4B, 0x00, 0xDC, 0x1D, 0xF6, 0xC9, 0x04, 0x75, 0xA6, 0x3B, 0xE5, 0x99, + 0xEE, 0x15, 0x61, 0xBA, 0x53, 0x86, 0xE9, 0x12, 0xCF, 0xB4, 0x2A, 0xCA, 0x09, 0xE8, 0x7D, 0x6D, + 0xDE, 0x90, 0xB1, 0xF6, 0x8B, 0xBA, 0xA8, 0xF4, 0xF2, 0xA2, 0x3A, 0x2A, 0x22, 0x2A, 0x7D, 0x8B, + 0xF6, 0xD1, 0x0B, 0xF8, 0xFE, 0x51, 0x9D, 0xEF, 0x5E, 0x79, 0xBE, 0x3B, 0x45, 0xF8, 0xEE, 0x6D, + 0x91, 0xEF, 0x6E, 0xC0, 0xF7, 0x27, 0x75, 0xBE, 0xBB, 0xE5, 0xF9, 0xEE, 0x16, 0xE1, 0xBB, 0xBB, + 0x45, 0xBE, 0xDB, 0x10, 0x6C, 0x7E, 0xFC, 0xA4, 0x7D, 0x9C, 0xB9, 0xC4, 0x9B, 0xE5, 0x57, 0xE2, + 0x18, 0x44, 0xD9, 0xB1, 0xBD, 0xB7, 0x83, 0xB9, 0x1B, 0x52, 0xD8, 0x11, 0x79, 0xCA, 0xCD, 0x9B, + 0x19, 0x84, 0xCA, 0x37, 0x89, 0xE4, 0x3C, 0xC9, 0x67, 0x6E, 0xBA, 0x2A, 0x53, 0xDB, 0x8B, 0x61, + 0xC7, 0xB5, 0xC1, 0xDB, 0x65, 0x81, 0xF1, 0xED, 0xB8, 0xBC, 0x3D, 0xAB, 0x57, 0xCC, 0x19, 0x5D, + 0x5B, 0xB3, 0xE7, 0x13, 0xCA, 0x33, 0xE4, 0x65, 0x9E, 0x82, 0xDA, 0xCB, 0x57, 0x21, 0x7A, 0x3B, + 0xA8, 0x92, 0x63, 0xA4, 0x3F, 0x62, 0xEC, 0xFC, 0x88, 0x0C, 0x69, 0x90, 0xB1, 0x14, 0x18, 0x8C, + 0x8E, 0x0A, 0x6A, 0xF3, 0xB8, 0x64, 0x74, 0x42, 0x1A, 0xB7, 0xA6, 0x4E, 0x9C, 0x7A, 0xA0, 0x00, + 0x3E, 0x95, 0x10, 0x40, 0xBF, 0xBC, 0x00, 0x0A, 0x65, 0x2E, 0x48, 0xE3, 0xF6, 0x04, 0xD0, 0x62, + 0x02, 0xB8, 0x8A, 0x5E, 0x7A, 0x9D, 0x61, 0xD0, 0x15, 0x2A, 0x50, 0xBD, 0x1D, 0xAC, 0x91, 0x60, + 0xA4, 0xD5, 0x03, 0x8B, 0x06, 0x8E, 0x8A, 0x29, 0xB4, 0x5D, 0x34, 0xBF, 0x92, 0x17, 0x3F, 0x15, + 0xF2, 0xEF, 0x6D, 0x26, 0x58, 0xED, 0x56, 0x60, 0xD1, 0xC5, 0x05, 0xD0, 0x2A, 0x2F, 0x00, 0xBD, + 0x90, 0x00, 0x5A, 0x0F, 0x2B, 0x19, 0xEF, 0xAF, 0x7F, 0xA7, 0x38, 0x5F, 0x5A, 0x45, 0xDD, 0x5F, + 0x18, 0xCD, 0xDA, 0x45, 0x84, 0xB5, 0x55, 0xEF, 0xEF, 0x44, 0x9C, 0x6B, 0xBF, 0x68, 0xF1, 0xAD, + 0xAF, 0x59, 0x71, 0xA0, 0x7C, 0x11, 0xB0, 0xB7, 0x83, 0xF5, 0x2A, 0xA4, 0xF0, 0x44, 0xC2, 0x59, + 0xC1, 0x00, 0x7F, 0x52, 0xDE, 0x1D, 0x0A, 0x69, 0x18, 0x69, 0xDD, 0x9E, 0x8A, 0x7B, 0x31, 0x41, + 0xB0, 0x6F, 0xA4, 0xAB, 0xA8, 0xB8, 0x7C, 0xE5, 0xB0, 0xB7, 0x83, 0xA5, 0x2E, 0xA4, 0xF0, 0x58, + 0xC2, 0x59, 0x41, 0x15, 0x17, 0x4D, 0x49, 0x8F, 0x4B, 0x4E, 0x2D, 0xF5, 0x6D, 0xE6, 0xA4, 0x58, + 0xED, 0x16, 0x04, 0x21, 0x7E, 0xC0, 0x22, 0x4B, 0xC1, 0xE5, 0x2B, 0xDE, 0xBD, 0x8A, 0xEB, 0xB3, + 0xDB, 0x8B, 0xE4, 0x47, 0xB2, 0xAF, 0x9B, 0xE7, 0xDB, 0x41, 0xD1, 0x5C, 0xB6, 0x55, 0x72, 0xE0, + 0xDB, 0x6A, 0x2A, 0x0B, 0xBD, 0x43, 0xD6, 0xB3, 0xCE, 0x7D, 0x86, 0x09, 0x94, 0x5F, 0x79, 0xEB, + 0xED, 0x60, 0x7B, 0x08, 0x52, 0xD8, 0xAE, 0x0D, 0x3E, 0x15, 0x64, 0xAA, 0x4A, 0xFD, 0xA0, 0xF4, + 0xFE, 0x90, 0xDD, 0x95, 0xDE, 0x47, 0xF3, 0x9B, 0xE2, 0xA5, 0xF7, 0x8B, 0x77, 0x3F, 0x17, 0x2B, + 0xBD, 0x8B, 0xBD, 0xEC, 0xAE, 0xF4, 0x5E, 0xCE, 0x66, 0x0A, 0x6D, 0x94, 0x05, 0xC6, 0xF0, 0x55, + 0x48, 0x23, 0xD3, 0xA3, 0x5D, 0x82, 0x60, 0xB4, 0xF7, 0xC1, 0x69, 0x28, 0x22, 0xE1, 0x19, 0xE5, + 0x78, 0xFB, 0x2C, 0xEB, 0xE9, 0x64, 0x84, 0x85, 0x52, 0xCF, 0xF0, 0xE2, 0x0B, 0x75, 0xFA, 0xFC, + 0x1B, 0x5F, 0x15, 0x9E, 0x05, 0x4E, 0x7B, 0xD7, 0xC8, 0xC1, 0x51, 0x41, 0xDC, 0x5B, 0x7F, 0xC5, + 0xC0, 0x20, 0xA1, 0x28, 0x9D, 0xEA, 0x47, 0xC7, 0x73, 0xE5, 0x3A, 0x39, 0x05, 0x2B, 0x12, 0xCD, + 0x3B, 0x62, 0xA9, 0x45, 0x3D, 0x9A, 0x33, 0xF2, 0xB6, 0x13, 0xCD, 0x11, 0x77, 0x8C, 0xF7, 0x02, + 0x59, 0x0D, 0x83, 0x2D, 0x26, 0x00, 0xF9, 0x26, 0x0A, 0x05, 0x01, 0xA4, 0x49, 0x60, 0x23, 0x22, + 0x68, 0x53, 0x09, 0xB4, 0x13, 0xDA, 0x4F, 0x09, 0xFC, 0xB4, 0x7D, 0xD9, 0xB8, 0xDF, 0xD9, 0x41, + 0x6D, 0x02, 0xC5, 0x15, 0xE3, 0xA8, 0xA0, 0x4E, 0x8B, 0x2D, 0x0E, 0xC6, 0x74, 0x5A, 0xCC, 0xA8, + 0xB7, 0xB6, 0x3A, 0x08, 0xC8, 0x3B, 0x54, 0x00, 0x1D, 0x65, 0x95, 0x96, 0x9F, 0x66, 0x76, 0x76, + 0x90, 0x9F, 0xA0, 0xB4, 0x62, 0x1C, 0x15, 0x54, 0x69, 0xB1, 0xA5, 0xCF, 0x98, 0x4A, 0xD5, 0xE7, + 0x97, 0x9C, 0xC8, 0xAD, 0xA9, 0xB4, 0x4B, 0x05, 0xD0, 0x55, 0x56, 0x69, 0xF9, 0x59, 0x47, 0x67, + 0x07, 0xBB, 0x77, 0x51, 0x5A, 0x31, 0x8E, 0x0A, 0xAA, 0xB4, 0xD8, 0x92, 0x5D, 0x4C, 0xA5, 0xEA, + 0xF3, 0x49, 0x4E, 0xE4, 0xD6, 0x54, 0xDA, 0xA3, 0x02, 0xE8, 0x29, 0xAB, 0xB4, 0x7C, 0xA5, 0xA0, + 0xB3, 0x83, 0x62, 0x10, 0x4A, 0x2B, 0xC6, 0x51, 0x41, 0x95, 0x16, 0x5B, 0x7D, 0x8E, 0xA9, 0x54, + 0x7D, 0x9D, 0x83, 0x13, 0xB9, 0x35, 0x95, 0xF6, 0xA9, 0x00, 0xFA, 0xCA, 0x2A, 0x2D, 0xBF, 0xBF, + 0xAA, 0xB3, 0x83, 0xBD, 0xDB, 0x28, 0xAD, 0x18, 0x47, 0x05, 0x55, 0x5A, 0xAC, 0x74, 0x1B, 0x53, + 0xA9, 0xFA, 0xCA, 0x0D, 0x27, 0x72, 0x6B, 0x2A, 0x3D, 0xA2, 0x02, 0x38, 0x52, 0x56, 0x69, 0xF9, + 0xAD, 0xEB, 0x9D, 0x1D, 0xD4, 0xF3, 0x50, 0x5A, 0x31, 0x8E, 0x0A, 0xAA, 0xB4, 0x58, 0x05, 0x27, + 0xA6, 0x52, 0xF5, 0xBD, 0x53, 0x9C, 0xC8, 0xAD, 0xA9, 0xF4, 0x98, 0x0A, 0xE0, 0x58, 0x59, 0xA5, + 0xE5, 0x77, 0xEE, 0x77, 0x76, 0xB0, 0x73, 0x1F, 0xA5, 0x15, 0xE3, 0xA8, 0xA0, 0x4A, 0x8B, 0xD5, + 0x66, 0x63, 0x2A, 0x55, 0xDF, 0xEE, 0xC4, 0x89, 0xDC, 0x9A, 0x4A, 0x4F, 0xA8, 0x00, 0x4E, 0x94, + 0x55, 0x5A, 0x7E, 0xCB, 0x40, 0x67, 0x07, 0x9B, 0x5F, 0x50, 0x5A, 0x2D, 0x91, 0xA3, 0x82, 0x2A, + 0x2D, 0xB6, 0xC0, 0xD8, 0x49, 0xD9, 0xFA, 0xA2, 0xA0, 0xD2, 0xB4, 0x05, 0xC6, 0x07, 0x50, 0xBF, + 0x33, 0x56, 0xC3, 0x12, 0x1F, 0xFC, 0x79, 0xF1, 0xD3, 0xCB, 0xF4, 0xC2, 0x7E, 0x6A, 0x15, 0x2F, + 0xD6, 0xD7, 0x43, 0x2F, 0xE3, 0x89, 0xF2, 0x42, 0xC2, 0x41, 0xCB, 0xEC, 0x25, 0x91, 0xDA, 0x1A, + 0xF3, 0xD9, 0x96, 0xC6, 0x80, 0x0B, 0x58, 0x5A, 0xA7, 0xDB, 0x92, 0x27, 0x2D, 0x39, 0x96, 0xC6, + 0xA9, 0xDC, 0x4E, 0xF0, 0x40, 0xE4, 0x30, 0x17, 0x47, 0xDE, 0x3F, 0x28, 0xAD, 0xE9, 0x30, 0x80, + 0x78, 0xF8, 0xE8, 0xB6, 0x4E, 0x14, 0xE3, 0x07, 0xC8, 0x20, 0x6D, 0x63, 0xFC, 0x06, 0x03, 0x08, + 0xD2, 0xD8, 0x61, 0x4C, 0xBD, 0x51, 0x66, 0x2A, 0x59, 0x05, 0x28, 0xC4, 0x54, 0x5A, 0x65, 0x67, + 0xC3, 0x4C, 0x75, 0x19, 0x53, 0x19, 0x4E, 0x9A, 0x60, 0x2A, 0x39, 0x0F, 0x2E, 0xC4, 0x54, 0xDA, + 0x44, 0x38, 0x62, 0xEA, 0x21, 0x04, 0x3A, 0x32, 0x32, 0xA6, 0xA3, 0x12, 0xA1, 0xEE, 0xF2, 0xE2, + 0xF0, 0xC5, 0x9B, 0x0B, 0x8D, 0x2E, 0x69, 0x3A, 0x56, 0xC1, 0x88, 0x17, 0xEF, 0xF4, 0x2F, 0x15, + 0xF3, 0x28, 0xE9, 0x42, 0xD4, 0x7B, 0x73, 0xA1, 0x1A, 0xF0, 0x38, 0x64, 0x91, 0x90, 0xD7, 0x6B, + 0x75, 0xCA, 0x54, 0x08, 0x43, 0x22, 0xB7, 0x14, 0xF4, 0x28, 0xFA, 0x76, 0x24, 0x83, 0xCB, 0x62, + 0x32, 0x28, 0x54, 0x25, 0x8D, 0xCB, 0xA0, 0x40, 0xD8, 0x0F, 0x88, 0xDC, 0xA6, 0x0C, 0x30, 0x4A, + 0x5E, 0x5E, 0x68, 0xEF, 0xFF, 0xA9, 0x5D, 0xDE, 0x2C, 0x1C, 0x6F, 0xE9, 0x92, 0xDC, 0xA8, 0xC2, + 0xE1, 0xE2, 0x71, 0xA5, 0xDF, 0xEB, 0x75, 0x54, 0x03, 0x4B, 0x2F, 0x7D, 0x08, 0x98, 0xB4, 0x36, + 0x18, 0x2F, 0x29, 0xA1, 0xDD, 0x90, 0xC1, 0x0F, 0x04, 0x34, 0xAD, 0x14, 0x37, 0x39, 0x60, 0x9C, + 0x43, 0xBD, 0x85, 0xDB, 0xAB, 0x15, 0x19, 0x94, 0x67, 0x94, 0x9D, 0x8D, 0x0E, 0x07, 0x94, 0xCA, + 0x5E, 0xC8, 0xDE, 0xA7, 0x8F, 0x57, 0x6A, 0x8C, 0x25, 0xEB, 0x68, 0xC5, 0x54, 0x97, 0xF6, 0xC8, + 0x68, 0xC1, 0x41, 0x41, 0xDE, 0xE8, 0xEC, 0x10, 0x62, 0xEC, 0xBA, 0x6C, 0x52, 0x44, 0x76, 0x36, + 0x31, 0xA7, 0x60, 0xB0, 0x72, 0x59, 0x52, 0x19, 0xB2, 0xB7, 0x9A, 0xE2, 0x17, 0x48, 0x9B, 0x23, + 0x08, 0xF3, 0xA0, 0x7B, 0xF4, 0xAE, 0x40, 0xB2, 0x73, 0x63, 0x4A, 0xA2, 0xEB, 0x1A, 0x0B, 0xE2, + 0x59, 0xC1, 0xD9, 0x60, 0x08, 0x8D, 0x6B, 0xC2, 0x3F, 0x97, 0xAA, 0xCD, 0x5C, 0x32, 0x39, 0xAF, + 0x7D, 0x13, 0xE2, 0xE4, 0x8F, 0xDF, 0x61, 0x93, 0x9A, 0x36, 0x76, 0x56, 0xB6, 0xE5, 0x18, 0x18, + 0xF8, 0x8D, 0x85, 0x0F, 0x94, 0x1E, 0xFC, 0xB1, 0xC0, 0x37, 0x5C, 0x19, 0xF8, 0xB4, 0x96, 0x91, + 0xD1, 0x8F, 0xA0, 0xFE, 0x91, 0xE5, 0x78, 0xC1, 0xB4, 0x0D, 0x0F, 0xC3, 0xCF, 0xAB, 0xFE, 0xCF, + 0x7F, 0xE7, 0x6D, 0x15, 0x30, 0xE7, 0x53, 0x41, 0x00, 0x35, 0xCD, 0x73, 0x47, 0xE7, 0x35, 0xA0, + 0xD4, 0x75, 0x3C, 0xCF, 0x71, 0xCD, 0xA9, 0x99, 0x32, 0x36, 0xA7, 0x49, 0xFB, 0x50, 0x26, 0xEE, + 0x44, 0x63, 0xC9, 0xB0, 0x7F, 0xE6, 0x8D, 0x5C, 0x73, 0xE1, 0x0F, 0x1E, 0x8D, 0x9D, 0xD1, 0x72, + 0x4E, 0x6C, 0xFF, 0xC0, 0x18, 0x8F, 0x2F, 0xAF, 0xE1, 0xE0, 0x3B, 0xFC, 0x16, 0x1F, 0x48, 0xBE, + 0x51, 0x7F, 0xF5, 0xAF, 0x77, 0x38, 0x0C, 0xE3, 0x35, 0x90, 0x17, 0x19, 0xD7, 0xF7, 0xB5, 0xC9, + 0xD2, 0x66, 0x23, 0x61, 0x83, 0x60, 0xDB, 0x3D, 0xED, 0x2B, 0x60, 0xBC, 0x36, 0x5C, 0x6D, 0x68, + 0x78, 0xE4, 0xAD, 0xE3, 0xF9, 0xDA, 0xB9, 0x16, 0x62, 0xB4, 0x9C, 0x11, 0xDD, 0xB7, 0x71, 0xC0, + 0xF8, 0xE2, 0x2D, 0x19, 0xE3, 0x3F, 0xBA, 0x16, 0x34, 0x0D, 0xA1, 0x9E, 0x6A, 0xF5, 0xD3, 0x63, + 0xBD, 0x8E, 0xF6, 0x17, 0x76, 0x31, 0x21, 0x10, 0xE6, 0xA1, 0x5D, 0x63, 0xE9, 0x5A, 0xFB, 0xDA, + 0x68, 0xB8, 0xF7, 0x95, 0x52, 0x4F, 0x2F, 0xE3, 0xB5, 0x3D, 0xCE, 0xCC, 0x81, 0x3F, 0x23, 0x76, + 0x23, 0xA2, 0xCC, 0x25, 0xDE, 0xC2, 0xB1, 0x3D, 0xC2, 0x88, 0x63, 0x3F, 0x73, 0x12, 0x5D, 0x3F, + 0xF0, 0x7C, 0xC3, 0x5F, 0x7A, 0xDA, 0xE3, 0xF3, 0x73, 0xAD, 0xDD, 0x6A, 0x89, 0xCD, 0x34, 0xE8, + 0x26, 0xD9, 0x6E, 0x5F, 0x4B, 0x5C, 0xF8, 0x48, 0x6E, 0xFC, 0xBD, 0x67, 0x21, 0xCC, 0x9D, 0x46, + 0x2C, 0x8F, 0xC4, 0x90, 0x84, 0x00, 0xF8, 0xDE, 0xB8, 0xC6, 0x5E, 0x9C, 0xC0, 0xC6, 0xD8, 0xF0, + 0x8D, 0xBD, 0xAF, 0x31, 0x7D, 0x41, 0xAF, 0x40, 0xC9, 0xBE, 0x46, 0x6F, 0x3D, 0x13, 0x6E, 0xDD, + 0xED, 0x1D, 0x80, 0x0C, 0x81, 0xDF, 0x10, 0x9A, 0xB8, 0x6E, 0x9C, 0x62, 0x0A, 0xDD, 0xD4, 0xF7, + 0x35, 0xBC, 0x13, 0x87, 0x15, 0x88, 0x7C, 0x14, 0x5C, 0x0B, 0x84, 0x96, 0x8D, 0x56, 0x82, 0x92, + 0xA1, 0xBB, 0x8B, 0xA9, 0x08, 0x02, 0xCE, 0x07, 0x32, 0x05, 0x89, 0x4D, 0xF7, 0x79, 0xFC, 0xD9, + 0xA7, 0xC1, 0x67, 0x9F, 0xC5, 0x2D, 0x41, 0x6B, 0x87, 0x87, 0xE0, 0xD2, 0x9E, 0x63, 0x11, 0xB0, + 0x8A, 0x69, 0xA3, 0xCE, 0xBF, 0xF5, 0x0A, 0x16, 0x55, 0x6F, 0xDD, 0xD4, 0x9F, 0x02, 0x82, 0x03, + 0xDF, 0xB9, 0xF2, 0x5D, 0xD3, 0x9E, 0x36, 0xF4, 0xFE, 0x5E, 0x84, 0x8D, 0xDE, 0x46, 0x94, 0x89, + 0xFB, 0xF4, 0x3A, 0xED, 0x24, 0x79, 0xA3, 0xC1, 0xAF, 0x3F, 0xAD, 0xEF, 0xD5, 0x39, 0xF1, 0xF4, + 0x1C, 0xCC, 0xAD, 0xC1, 0x0E, 0x9E, 0x50, 0x1A, 0xF7, 0xB4, 0xB3, 0x33, 0xDE, 0x0D, 0x6B, 0x85, + 0x17, 0xA1, 0x11, 0xFD, 0x93, 0xB8, 0x15, 0x9A, 0xE2, 0xEF, 0xFF, 0xF8, 0x1A, 0xD8, 0xEC, 0xDD, + 0x21, 0x50, 0xFD, 0x1C, 0x43, 0xF0, 0x3F, 0xBE, 0xC2, 0xFF, 0x77, 0x4F, 0x68, 0xD4, 0xFD, 0xC7, + 0x57, 0xFC, 0x73, 0xF7, 0x04, 0x7A, 0x82, 0x63, 0xDA, 0xDF, 0xDD, 0xEF, 0x54, 0x0E, 0xEB, 0xD2, + 0x9B, 0xA6, 0x4A, 0x2F, 0x14, 0x5B, 0x61, 0x9A, 0xA6, 0x19, 0x44, 0xFD, 0x1E, 0xF9, 0x6F, 0x63, + 0xE4, 0x8C, 0x41, 0x3D, 0x3E, 0x58, 0x72, 0xA0, 0x74, 0x0B, 0x54, 0x12, 0x08, 0xAA, 0x15, 0x28, + 0xDD, 0x9C, 0xD0, 0x96, 0x1A, 0x77, 0x95, 0xC8, 0x40, 0x82, 0x96, 0x0B, 0xC3, 0xF5, 0xC8, 0xB7, + 0xB6, 0xDF, 0xF0, 0x63, 0x4E, 0x91, 0x22, 0xF1, 0xC1, 0x20, 0xC6, 0x02, 0xFE, 0x00, 0x0E, 0xDA, + 0xD5, 0xB9, 0xD2, 0x42, 0x63, 0xE3, 0x7F, 0x13, 0x66, 0xF3, 0xA6, 0x90, 0xD9, 0x34, 0xA8, 0xD8, + 0xC2, 0x3E, 0xF7, 0x8A, 0x98, 0x10, 0x90, 0x25, 0x18, 0x10, 0x75, 0x88, 0x48, 0x64, 0xEC, 0x62, + 0x8A, 0x43, 0xFC, 0x3C, 0xB2, 0xBE, 0x34, 0x6E, 0xE0, 0xBF, 0x64, 0xCC, 0x5A, 0xD3, 0x15, 0x36, + 0x7A, 0x8E, 0xFF, 0x81, 0x82, 0xF0, 0x4F, 0xAA, 0xA1, 0x00, 0xD6, 0xF7, 0x96, 0xD5, 0x60, 0x1F, + 0x98, 0x03, 0x1B, 0x59, 0x42, 0x3C, 0xF4, 0x6E, 0x31, 0x32, 0x39, 0x8E, 0xFF, 0x79, 0x5F, 0x5B, + 0xB8, 0x40, 0x18, 0xFD, 0x96, 0x0A, 0x1C, 0x03, 0x22, 0x62, 0xB3, 0xBF, 0xB9, 0x14, 0x2C, 0x2C, + 0xEB, 0x39, 0xC3, 0x0A, 0x24, 0xB0, 0x03, 0x30, 0x99, 0x25, 0x9A, 0x2E, 0xFC, 0x7F, 0xF7, 0x04, + 0x3A, 0x81, 0x43, 0xF8, 0xFF, 0xEE, 0x09, 0x76, 0x85, 0x46, 0x85, 0x3D, 0xDE, 0x3D, 0x81, 0x1E, + 0xE1, 0x04, 0xFE, 0x87, 0x36, 0xD8, 0x2F, 0xB6, 0xC2, 0xBF, 0x70, 0x87, 0xF6, 0x8F, 0x37, 0xE9, + 0x01, 0xBB, 0xC0, 0x4F, 0xB3, 0x18, 0x64, 0x6F, 0xD7, 0x6F, 0xD0, 0xB7, 0x9D, 0x7F, 0xBE, 0x01, + 0x76, 0xE8, 0xC1, 0x2D, 0xC4, 0x20, 0x7B, 0x8C, 0xE7, 0xF8, 0xE7, 0x36, 0x50, 0x30, 0x5E, 0xE0, + 0x47, 0x70, 0x8D, 0xBE, 0x11, 0x16, 0x2F, 0xB1, 0x03, 0x6C, 0x45, 0xDF, 0xDF, 0x49, 0x5B, 0xB1, + 0x23, 0xB8, 0xC6, 0xDF, 0xFA, 0xB8, 0xAF, 0xF1, 0xF7, 0x0A, 0xE6, 0x0A, 0x27, 0x7A, 0xEF, 0xDF, + 0x73, 0xEF, 0x06, 0x19, 0x64, 0xA4, 0xA1, 0x54, 0xC2, 0xB3, 0xDB, 0xBB, 0x27, 0x04, 0xEF, 0x51, + 0x22, 0xE1, 0xF8, 0x96, 0x1F, 0xC3, 0x75, 0xA0, 0x0F, 0xEF, 0x04, 0x04, 0xD3, 0x0B, 0xB7, 0xD1, + 0x05, 0x68, 0xE1, 0xE3, 0x7D, 0x4E, 0x3C, 0x9C, 0xDD, 0x86, 0x67, 0x08, 0x4D, 0x61, 0x39, 0x1B, + 0x70, 0x7A, 0x1B, 0x9D, 0xC2, 0x5D, 0xE4, 0x05, 0x15, 0xC0, 0x79, 0xBA, 0x7B, 0xC2, 0x79, 0x42, + 0x2D, 0xB2, 0xA3, 0xB8, 0xA8, 0xE1, 0x7F, 0xF4, 0x23, 0x9F, 0x07, 0xEC, 0x4F, 0x81, 0x77, 0x12, + 0x6B, 0x4F, 0x3B, 0x1F, 0xF0, 0xB8, 0x8F, 0x01, 0x00, 0x3C, 0x0A, 0xAE, 0x13, 0xEB, 0xC0, 0xF0, + 0xC1, 0x21, 0x20, 0x6F, 0x22, 0xDE, 0x01, 0x46, 0x94, 0xD0, 0xCD, 0xD7, 0x6E, 0x1D, 0xD8, 0xE0, + 0x16, 0x14, 0xE1, 0xDE, 0x29, 0x0F, 0x1B, 0x88, 0x88, 0x71, 0xB9, 0x86, 0x8B, 0x5D, 0x4E, 0x43, + 0xC7, 0xEE, 0xA6, 0x60, 0xE4, 0xA1, 0x30, 0x0E, 0x81, 0x17, 0xD3, 0xB0, 0xD1, 0x51, 0x43, 0xC0, + 0xD5, 0xEE, 0xF5, 0x22, 0x6C, 0x89, 0x48, 0xC7, 0xE6, 0x94, 0x28, 0x90, 0x03, 0xCC, 0xCF, 0xA3, + 0xA1, 0x70, 0x04, 0xE6, 0xA0, 0xD5, 0x83, 0x09, 0x65, 0xFD, 0x74, 0x2D, 0xC2, 0x01, 0x04, 0x2F, + 0x20, 0x68, 0xCF, 0x19, 0x8D, 0xA7, 0x51, 0xF8, 0xD4, 0xB4, 0x21, 0x24, 0x3B, 0x5F, 0x9E, 0xC5, + 0x90, 0xD1, 0xD4, 0x3F, 0xC4, 0xC4, 0xAE, 0x61, 0xA2, 0x90, 0xB8, 0xC4, 0xB6, 0xDB, 0x35, 0x1D, + 0x9B, 0xC8, 0x7B, 0x8D, 0xC5, 0x4B, 0xDE, 0x11, 0x3F, 0x1B, 0x93, 0x89, 0xB1, 0xB4, 0xFC, 0x08, + 0xCC, 0x25, 0x90, 0xE8, 0xDA, 0x3C, 0x6C, 0xB1, 0x24, 0x3F, 0x77, 0xE8, 0xCE, 0x18, 0x2A, 0x82, + 0x51, 0xE1, 0x71, 0x72, 0x54, 0x00, 0xAB, 0x74, 0xFD, 0x46, 0xFD, 0xD2, 0x75, 0x1D, 0xF7, 0xD7, + 0xFA, 0x53, 0x6C, 0xF4, 0xB4, 0xFE, 0xDB, 0xA9, 0x46, 0xE3, 0xE9, 0x5E, 0x3C, 0xB8, 0x0B, 0xE1, + 0xF3, 0xF0, 0x50, 0x7B, 0xE1, 0xFB, 0x06, 0x28, 0x00, 0x6B, 0x2C, 0x33, 0x94, 0x8F, 0x66, 0xF0, + 0x24, 0xD0, 0x71, 0xD1, 0x28, 0xD9, 0xF7, 0xEE, 0x41, 0x22, 0x98, 0x58, 0x7A, 0x00, 0x12, 0x24, + 0x99, 0x14, 0xD5, 0xC1, 0xBF, 0x97, 0xC4, 0xBD, 0xBD, 0xA2, 0x02, 0x73, 0xDC, 0x17, 0x10, 0x2A, + 0xEB, 0x07, 0xD1, 0x3C, 0xA9, 0xCE, 0x72, 0x9E, 0x03, 0x40, 0x75, 0x09, 0x7D, 0x80, 0x8E, 0x23, + 0x9B, 0x67, 0xDC, 0x84, 0x7A, 0x87, 0x71, 0xEE, 0x9C, 0x2B, 0x23, 0x99, 0x64, 0x41, 0x0B, 0xC7, + 0xFE, 0x42, 0x6E, 0x97, 0x0B, 0x10, 0x7F, 0x94, 0x36, 0x25, 0x12, 0x39, 0x2E, 0x1D, 0x72, 0x00, + 0x2D, 0x2F, 0xF8, 0xC0, 0xA9, 0x77, 0x24, 0x8D, 0x22, 0x15, 0x50, 0xEB, 0x44, 0x4F, 0x7C, 0xB6, + 0xD6, 0xE8, 0xEE, 0x91, 0xFC, 0x4C, 0x92, 0x72, 0x72, 0x02, 0xB9, 0xF0, 0xC0, 0xB5, 0xA9, 0x63, + 0x27, 0x7A, 0x48, 0xA4, 0x83, 0x90, 0x0C, 0x46, 0x91, 0x61, 0xB9, 0x80, 0xE4, 0x93, 0xC4, 0x83, + 0x43, 0x68, 0x0B, 0xC1, 0xCD, 0xB9, 0xE3, 0x93, 0x44, 0xC4, 0x30, 0x6D, 0xD3, 0x37, 0x0D, 0xEB, + 0x53, 0x64, 0x8D, 0x5B, 0x75, 0x7F, 0x89, 0x8F, 0x17, 0xF0, 0xFF, 0xB5, 0x9C, 0x4F, 0x2D, 0x4F, + 0x59, 0xB3, 0x90, 0x30, 0x1E, 0x44, 0x56, 0x22, 0xCA, 0x21, 0x16, 0x16, 0xF8, 0xFD, 0xA0, 0xA7, + 0xC7, 0x8F, 0xE9, 0xD1, 0xA3, 0x50, 0x69, 0x41, 0xF4, 0x38, 0xD7, 0xA2, 0x1B, 0x09, 0x05, 0xAF, + 0xE3, 0x4E, 0xE0, 0x08, 0x90, 0x0B, 0x18, 0x12, 0x81, 0x7F, 0x01, 0xE9, 0x0D, 0xDA, 0xC2, 0xFF, + 0x47, 0xFD, 0x07, 0x14, 0xF5, 0xB7, 0x17, 0xE2, 0x33, 0x6C, 0x3B, 0xE1, 0x01, 0x0C, 0x4E, 0x9E, + 0x4F, 0x3F, 0x85, 0x44, 0x5B, 0x9E, 0x24, 0x87, 0xA1, 0x3B, 0x9C, 0xEC, 0xC3, 0x64, 0xE6, 0x92, + 0x85, 0xE7, 0x97, 0xB7, 0xDF, 0x8E, 0x1B, 0xF5, 0xF0, 0x8D, 0x46, 0xF5, 0x3D, 0x8C, 0x4B, 0x96, + 0x39, 0xFA, 0x12, 0x86, 0xA5, 0xC8, 0xF2, 0x20, 0xA5, 0xC1, 0xEC, 0x1F, 0x27, 0xD6, 0xE6, 0x88, + 0x9B, 0xEA, 0xAB, 0x0F, 0x2F, 0xDE, 0x7D, 0x7E, 0xF1, 0xF1, 0xE3, 0x07, 0x6D, 0x09, 0x36, 0xAB, + 0xF7, 0x3F, 0x63, 0xDA, 0x02, 0x93, 0x00, 0xF7, 0x33, 0xD0, 0xE7, 0x7D, 0xA6, 0x48, 0x5B, 0xBF, + 0xFE, 0xF6, 0x6B, 0xFB, 0x37, 0x00, 0xFD, 0xFA, 0x5F, 0x76, 0x9D, 0x31, 0x82, 0xA8, 0x9E, 0x02, + 0x2E, 0x3C, 0xFE, 0x5A, 0x7F, 0x1A, 0x18, 0x7C, 0x23, 0x9D, 0xC2, 0xF0, 0xF5, 0xBA, 0xF5, 0x3D, + 0x60, 0xF5, 0x6E, 0x1F, 0x50, 0xB1, 0x74, 0x10, 0xC6, 0x9C, 0x06, 0x96, 0x2A, 0x4C, 0xE8, 0x40, + 0x7F, 0x06, 0x7F, 0xCE, 0x34, 0xFD, 0x08, 0xFE, 0x3E, 0x7D, 0x1A, 0x99, 0x48, 0xC9, 0xEE, 0xEA, + 0x4F, 0x4D, 0xDA, 0x19, 0xCC, 0x4E, 0x1A, 0xE6, 0x19, 0x48, 0xF2, 0x79, 0x7D, 0xBF, 0x7E, 0x5A, + 0xAF, 0xC3, 0xB5, 0xA0, 0xFB, 0xBB, 0x18, 0x3B, 0x77, 0xCF, 0x42, 0x0E, 0xD9, 0xE8, 0x0A, 0x37, + 0x22, 0xF1, 0x8B, 0x59, 0xDD, 0x4B, 0x56, 0xE5, 0x3A, 0x4F, 0xD7, 0x09, 0x7B, 0x9B, 0xF5, 0x94, + 0x0E, 0x88, 0x22, 0x4C, 0x86, 0x82, 0x58, 0x68, 0x08, 0x7D, 0x2D, 0x15, 0x35, 0x1D, 0x6E, 0xC7, + 0x63, 0x17, 0xB4, 0x4D, 0xAD, 0x65, 0x6F, 0xCD, 0x85, 0xD5, 0x70, 0x60, 0x63, 0x09, 0x8E, 0xB5, + 0xE9, 0x66, 0x26, 0x12, 0xDA, 0x5A, 0xC0, 0xB2, 0x96, 0xF0, 0xB4, 0xEE, 0x2F, 0xD7, 0x61, 0x1A, + 0x9B, 0x2A, 0x6A, 0x6C, 0x2A, 0x68, 0x6C, 0xBA, 0x59, 0x8D, 0x71, 0xD4, 0x95, 0xB5, 0x16, 0xE0, + 0xC9, 0xD1, 0x5C, 0x2E, 0x3C, 0x57, 0x1A, 0xD7, 0xD6, 0x54, 0xA6, 0xAD, 0x32, 0x6A, 0x62, 0xB1, + 0x0B, 0x26, 0x45, 0xC4, 0x7D, 0xFB, 0xF1, 0xDD, 0x77, 0x18, 0x6D, 0xE4, 0x2A, 0x0B, 0x35, 0x96, + 0x4C, 0xAE, 0x24, 0x18, 0x30, 0x28, 0xC6, 0x2A, 0x1F, 0x89, 0xB0, 0xA9, 0x45, 0x15, 0x84, 0x1C, + 0x43, 0xE0, 0x05, 0x03, 0x35, 0xDF, 0xC5, 0x22, 0x41, 0xE0, 0xBC, 0x11, 0x54, 0x86, 0x2D, 0x20, + 0x80, 0x92, 0x12, 0x19, 0xE6, 0x35, 0x87, 0x11, 0x6A, 0x19, 0x3B, 0x77, 0x11, 0xEA, 0xAF, 0x9E, + 0x6A, 0x50, 0x0B, 0xA6, 0xEA, 0x51, 0x6C, 0xF3, 0x72, 0xA5, 0xC3, 0x27, 0xF4, 0x4A, 0x02, 0xE2, + 0x5F, 0x95, 0x93, 0x18, 0x38, 0x2F, 0x04, 0x14, 0xC0, 0x72, 0x2B, 0xC1, 0x42, 0x0B, 0x08, 0x4A, + 0x38, 0xE8, 0x97, 0xC8, 0x52, 0x30, 0xA8, 0x51, 0x41, 0x3F, 0xFD, 0x25, 0xC1, 0x10, 0x94, 0x2A, + 0x94, 0x90, 0x04, 0x9F, 0xAD, 0x4A, 0xC7, 0xA3, 0x46, 0x4C, 0xF0, 0xB1, 0x28, 0x09, 0x1E, 0x5E, + 0x1A, 0x51, 0x42, 0xC3, 0x3F, 0x74, 0x94, 0x8A, 0x45, 0x8D, 0x18, 0xFE, 0x6D, 0x21, 0x19, 0x4F, + 0xBC, 0x14, 0xA3, 0xC6, 0x13, 0xFF, 0x24, 0x4E, 0x3A, 0x1E, 0x45, 0xD9, 0xF0, 0xCF, 0xD0, 0xC8, + 0xAC, 0x8E, 0x55, 0x7E, 0x32, 0x1D, 0x83, 0x35, 0x01, 0x60, 0x9E, 0xAA, 0x3E, 0xD7, 0xC5, 0xCC, + 0x9A, 0x17, 0x8A, 0xB2, 0x30, 0xF0, 0x26, 0x49, 0x0C, 0x41, 0x74, 0xB8, 0x87, 0x92, 0xDD, 0x7D, + 0x44, 0xA1, 0xF7, 0x96, 0xA5, 0x16, 0x85, 0x16, 0x96, 0x15, 0x84, 0x9F, 0x10, 0x26, 0x25, 0xFC, + 0xD0, 0x25, 0x33, 0x5A, 0x6F, 0xCD, 0x94, 0x3F, 0x6D, 0xC1, 0xF0, 0xAE, 0x2B, 0x11, 0x71, 0xCC, + 0x97, 0x96, 0x92, 0x25, 0x41, 0x3B, 0x8E, 0x46, 0x34, 0x24, 0xBA, 0x1C, 0x77, 0xEB, 0xA9, 0x85, + 0xAE, 0x5B, 0x2F, 0x05, 0x03, 0x2D, 0x09, 0xAB, 0xE5, 0x66, 0xD0, 0x32, 0x05, 0xC9, 0xC2, 0x55, + 0x4B, 0xEF, 0xA0, 0x5D, 0x1A, 0x23, 0x58, 0x7F, 0x56, 0x63, 0x25, 0xF8, 0xEA, 0xB8, 0x8C, 0x0E, + 0x5A, 0xB5, 0xCE, 0x52, 0x0A, 0xFF, 0xBA, 0x73, 0x9A, 0x46, 0x16, 0xAA, 0x43, 0x6E, 0xF0, 0xFD, + 0x63, 0xC9, 0xB0, 0x5B, 0xB2, 0xD8, 0xBF, 0x7B, 0xE7, 0x30, 0xAE, 0x89, 0x82, 0x6B, 0x84, 0x2B, + 0xF3, 0x3C, 0xBB, 0x8B, 0xC0, 0x32, 0xBC, 0x63, 0x64, 0xD8, 0xD7, 0x46, 0xCC, 0x3B, 0x46, 0x30, + 0xFD, 0xF5, 0x09, 0x47, 0xDD, 0xA8, 0xB1, 0x06, 0x35, 0x4E, 0x23, 0x3B, 0x3B, 0xA0, 0x5B, 0xD6, + 0xB0, 0x10, 0x62, 0x92, 0x15, 0x3B, 0x89, 0xDD, 0x9E, 0x11, 0xFA, 0x9A, 0x10, 0x7E, 0x9F, 0x9D, + 0xB1, 0x06, 0x61, 0x2F, 0x43, 0x67, 0x7C, 0x7B, 0x60, 0x2C, 0x16, 0x10, 0xBC, 0x2E, 0x66, 0xA6, + 0x35, 0x6E, 0x30, 0x50, 0xC1, 0x44, 0x70, 0x6F, 0x02, 0xA1, 0xAB, 0x56, 0x1C, 0x2B, 0x30, 0x7C, + 0xC1, 0xAE, 0x35, 0xEA, 0xED, 0x71, 0xB0, 0x66, 0xC4, 0x9B, 0x1D, 0x8C, 0x5D, 0x63, 0xF5, 0x2D, + 0x6E, 0x6A, 0x68, 0x60, 0xA7, 0xFB, 0xAD, 0xFD, 0x16, 0x6F, 0xE0, 0xBB, 0xB7, 0x61, 0x96, 0x89, + 0x78, 0x71, 0xF1, 0xF7, 0xC7, 0x0F, 0xDF, 0x45, 0x78, 0x7D, 0xE7, 0x15, 0xBB, 0xD4, 0xA8, 0xD3, + 0x5D, 0x11, 0x87, 0x7F, 0x2C, 0x70, 0x2A, 0x10, 0x28, 0x45, 0x10, 0x23, 0x6E, 0x78, 0x40, 0x51, + 0xB1, 0xE6, 0xCF, 0x44, 0xA4, 0x70, 0xD9, 0x26, 0x2B, 0x0D, 0x30, 0x91, 0x86, 0x0C, 0x34, 0xD8, + 0x0E, 0x81, 0xE0, 0xC8, 0xC9, 0xEB, 0xA5, 0x65, 0xFD, 0x42, 0x0C, 0x17, 0xF4, 0xF1, 0x54, 0x6B, + 0xD4, 0x5A, 0xB5, 0xA7, 0x0D, 0x7A, 0xFD, 0x1D, 0xB0, 0x33, 0x6B, 0xEC, 0x3D, 0xD5, 0xF7, 0xF6, + 0x0E, 0x3C, 0xD0, 0x19, 0x69, 0x34, 0xDB, 0x41, 0x13, 0xF8, 0x43, 0xDB, 0xB0, 0x4E, 0xD2, 0xEF, + 0xBF, 0x75, 0x96, 0xAE, 0x97, 0xD5, 0xE0, 0x9D, 0x69, 0x63, 0x11, 0x27, 0xAB, 0xC9, 0x15, 0x01, + 0xC1, 0x8E, 0xD7, 0x9A, 0xD4, 0xE8, 0x2E, 0x0E, 0x5E, 0xFE, 0xD0, 0xE8, 0xE2, 0xB6, 0xD6, 0x10, + 0x2B, 0x3A, 0x7C, 0x19, 0x91, 0xA0, 0xA1, 0x37, 0x02, 0x8F, 0xBF, 0x13, 0x8D, 0x03, 0x5C, 0x16, + 0x0C, 0xE0, 0x7B, 0xF0, 0x80, 0x03, 0x97, 0xCC, 0x9D, 0x6B, 0xB2, 0xA6, 0x7F, 0x6C, 0x1E, 0x1A, + 0xFF, 0xCC, 0x1C, 0xB3, 0x32, 0x4D, 0x64, 0xB7, 0x58, 0x15, 0xC2, 0x0D, 0x22, 0xB8, 0x85, 0x02, + 0xF7, 0x53, 0x34, 0xEA, 0x6C, 0xF7, 0x0A, 0x1D, 0x15, 0xEE, 0x22, 0xB7, 0x99, 0x39, 0xAB, 0x2C, + 0x48, 0xD6, 0x7B, 0x02, 0x38, 0x84, 0x1E, 0x9B, 0x9E, 0x31, 0xB4, 0xF2, 0xBB, 0xE6, 0xED, 0xC6, + 0xBC, 0xFC, 0x0D, 0x0D, 0x82, 0x2B, 0x00, 0xEA, 0xBB, 0xB4, 0x4E, 0x28, 0xA0, 0x25, 0x76, 0x1E, + 0xD6, 0x80, 0xAC, 0x4C, 0xC4, 0x13, 0x03, 0x26, 0x4F, 0x71, 0xCC, 0xAC, 0x78, 0x5C, 0xA0, 0xAC, + 0x2C, 0x5E, 0x06, 0x88, 0xF8, 0x29, 0x98, 0x33, 0x58, 0xA8, 0xF6, 0x9C, 0xB2, 0xA0, 0x9D, 0xC6, + 0xEE, 0x86, 0xB9, 0x8D, 0x58, 0x30, 0x7D, 0xC4, 0xE2, 0xDF, 0x5F, 0xB0, 0x82, 0x1B, 0x52, 0x1E, + 0x93, 0xC0, 0x93, 0x27, 0x71, 0x6C, 0xB8, 0x8D, 0x85, 0x0D, 0x20, 0x61, 0x6F, 0xAC, 0x3D, 0x7B, + 0x7D, 0x78, 0xB4, 0xB2, 0xC0, 0x49, 0x82, 0xA1, 0xE0, 0x71, 0x4C, 0xF0, 0xC2, 0x18, 0x01, 0x84, + 0x98, 0x63, 0x2A, 0x20, 0xDC, 0x95, 0x56, 0x5B, 0x5B, 0xEF, 0x7F, 0x4E, 0xAD, 0xBE, 0x41, 0xF8, + 0xA6, 0xC4, 0x3D, 0x90, 0x3F, 0x1A, 0x73, 0x74, 0x21, 0x3E, 0x87, 0x4E, 0x60, 0x9C, 0xC6, 0x30, + 0x22, 0x63, 0x09, 0xBA, 0xF1, 0x77, 0x78, 0x48, 0x31, 0x4E, 0x0D, 0xD3, 0xBE, 0x20, 0x26, 0xA6, + 0x7F, 0x7B, 0xC2, 0x5D, 0xDA, 0x3D, 0x20, 0xC2, 0x0D, 0x83, 0xC2, 0x72, 0xC6, 0xFA, 0x7A, 0xC8, + 0xE1, 0x21, 0x6D, 0x9A, 0x82, 0x86, 0x76, 0xB1, 0x8E, 0x26, 0x93, 0xFA, 0xD5, 0xF0, 0x33, 0x62, + 0x93, 0x09, 0x85, 0xA2, 0x5B, 0x0D, 0x51, 0x1C, 0xB4, 0x57, 0x38, 0xCC, 0x42, 0x35, 0x31, 0x46, + 0xE4, 0xB3, 0x0B, 0x71, 0x6C, 0x6A, 0x9B, 0x7F, 0x12, 0x19, 0x42, 0xE6, 0x8C, 0x0D, 0x62, 0xBB, + 0x4E, 0x90, 0x48, 0x22, 0x72, 0xEE, 0x67, 0xF1, 0xEB, 0xF1, 0x21, 0x3A, 0x70, 0xBD, 0x70, 0x69, + 0x5D, 0xB4, 0x05, 0x56, 0xFA, 0x8F, 0x26, 0xC0, 0x91, 0xB1, 0x6D, 0xA8, 0x12, 0xAE, 0x63, 0x19, + 0x3C, 0x6C, 0x22, 0xD6, 0xAB, 0x2B, 0x16, 0xC1, 0x33, 0x70, 0xB2, 0xCD, 0x7D, 0x49, 0xA4, 0xCB, + 0xE1, 0xDC, 0xF4, 0x25, 0x08, 0xEB, 0x7A, 0x5D, 0x8A, 0x2B, 0xA5, 0x9E, 0x2E, 0x7A, 0x22, 0x8B, + 0x66, 0x74, 0xA9, 0x11, 0x10, 0xC5, 0xB6, 0x0A, 0x8C, 0xD8, 0x6E, 0xF5, 0xE7, 0x30, 0xE2, 0xE2, + 0x06, 0x00, 0x54, 0x75, 0x62, 0x0B, 0x10, 0x43, 0xC1, 0xF6, 0xAE, 0x51, 0x14, 0xF1, 0xDD, 0x6B, + 0xC1, 0x8E, 0xB1, 0xF8, 0xCA, 0xA4, 0xB8, 0xF7, 0xE5, 0x77, 0x97, 0x00, 0x9C, 0x87, 0xF3, 0x59, + 0xED, 0x1F, 0x5F, 0x29, 0x8A, 0x3B, 0x6D, 0x02, 0x91, 0xC0, 0x9B, 0x91, 0x31, 0x9D, 0x7B, 0xF9, + 0x4B, 0xEF, 0x54, 0xC3, 0x6D, 0x3F, 0xB1, 0xDD, 0x6A, 0x77, 0xBF, 0x87, 0x16, 0x12, 0x0E, 0x24, + 0xD1, 0x7A, 0x2B, 0x67, 0x7B, 0x6D, 0xDD, 0xF5, 0x91, 0xC2, 0x0A, 0x2B, 0x87, 0x55, 0x5A, 0x65, + 0x95, 0x2D, 0x4E, 0x26, 0xA3, 0x14, 0xCF, 0x31, 0x1F, 0xA9, 0xF4, 0x4D, 0x77, 0x5D, 0xAA, 0x74, + 0x29, 0xC9, 0x2E, 0xF1, 0xC7, 0xC2, 0x98, 0x25, 0x8C, 0xF9, 0x09, 0x3F, 0xDA, 0xE3, 0x72, 0x02, + 0x13, 0x19, 0x07, 0xF1, 0x96, 0x19, 0x11, 0xAE, 0x3E, 0x33, 0x3D, 0xC6, 0x4C, 0x80, 0x49, 0x9B, + 0x0B, 0x3B, 0x7F, 0x4B, 0x22, 0x5F, 0x76, 0x09, 0x95, 0xF5, 0x87, 0x07, 0x79, 0x7B, 0x28, 0x02, + 0x19, 0x0E, 0xEC, 0x40, 0x40, 0x10, 0x13, 0x51, 0x21, 0x15, 0x65, 0xC8, 0x8C, 0xFD, 0x84, 0x01, + 0x9B, 0x8E, 0xD6, 0xB4, 0xE7, 0x5F, 0xA9, 0x55, 0xFF, 0xB6, 0xCF, 0x46, 0x78, 0x21, 0x68, 0xEE, + 0x15, 0x21, 0x68, 0x6D, 0x55, 0x3E, 0x97, 0x98, 0x8D, 0xAD, 0x4F, 0x06, 0x3F, 0x88, 0xC5, 0x14, + 0xDF, 0xB9, 0xD6, 0x5A, 0x5B, 0x97, 0x17, 0x17, 0xD1, 0x42, 0x06, 0x25, 0xB2, 0x11, 0x16, 0xD5, + 0x03, 0xF1, 0x48, 0x17, 0xD0, 0x52, 0xC5, 0xC5, 0xEC, 0x8B, 0x45, 0x14, 0x9C, 0x16, 0x64, 0xCE, + 0x9F, 0xE8, 0x86, 0x59, 0x2A, 0xAF, 0x08, 0xE0, 0x22, 0xDC, 0x20, 0x9D, 0x0B, 0x19, 0x6D, 0xA6, + 0x16, 0x70, 0xD0, 0xF9, 0x98, 0xDA, 0xFA, 0x03, 0x9F, 0xBA, 0x09, 0xA0, 0x88, 0x35, 0x1F, 0x36, + 0x78, 0x4A, 0x26, 0x49, 0xBE, 0x38, 0x7A, 0x65, 0xC1, 0xD3, 0x61, 0x92, 0x35, 0x16, 0xA0, 0xA9, + 0xE7, 0xE7, 0x03, 0x8B, 0xDB, 0xB2, 0xEB, 0x82, 0xB0, 0x3D, 0xDF, 0x59, 0x5C, 0xD1, 0xAB, 0x89, + 0xA8, 0xB0, 0xA2, 0xC5, 0xAC, 0x03, 0xBC, 0x1F, 0xCC, 0x93, 0x44, 0x46, 0xE3, 0xF5, 0xFF, 0x2B, + 0xAC, 0x74, 0x69, 0x0C, 0x4F, 0x3D, 0x9E, 0xED, 0xD2, 0x22, 0x98, 0xB4, 0x07, 0x3A, 0xE7, 0xF4, + 0xDC, 0x11, 0x1B, 0x3C, 0xC2, 0x6D, 0xD0, 0x18, 0x3A, 0xF0, 0xF0, 0x77, 0xD6, 0x27, 0xA6, 0x12, + 0x31, 0x05, 0xEF, 0xE5, 0xD2, 0xE2, 0x2C, 0x92, 0xA4, 0x44, 0x01, 0x9E, 0x39, 0x9B, 0x87, 0xA3, + 0x06, 0x1B, 0x28, 0x31, 0x7C, 0x09, 0xCA, 0x4F, 0x89, 0x91, 0x91, 0x98, 0x78, 0x50, 0x8A, 0x13, + 0x2F, 0x8C, 0x7C, 0x6C, 0x0F, 0xFD, 0xF3, 0xCF, 0xA3, 0x21, 0x0C, 0x76, 0x38, 0x01, 0x04, 0xD7, + 0x5B, 0x35, 0xF6, 0xEE, 0xB2, 0xD8, 0x61, 0xE2, 0x8A, 0x14, 0xA9, 0x4A, 0x04, 0x8D, 0xDA, 0x72, + 0x6C, 0x31, 0xF9, 0xC8, 0xD1, 0x89, 0xD6, 0x7B, 0x69, 0x07, 0x13, 0x96, 0x34, 0xC1, 0x9E, 0xAF, + 0x8B, 0x96, 0xE5, 0xAC, 0x31, 0x04, 0x51, 0x3C, 0x5E, 0x23, 0x36, 0x91, 0x94, 0x0A, 0x76, 0x11, + 0x34, 0x08, 0x68, 0x17, 0x1D, 0x22, 0x85, 0xF6, 0xF8, 0x58, 0x99, 0x48, 0xFF, 0x02, 0x95, 0x5F, + 0x2C, 0x81, 0x88, 0x79, 0xA0, 0x72, 0x76, 0x0D, 0x33, 0xDC, 0xD0, 0x36, 0x21, 0xE3, 0xCD, 0xF2, + 0x19, 0xB8, 0x2D, 0x38, 0x1A, 0x4F, 0x8F, 0x73, 0x00, 0x68, 0x4E, 0xCC, 0x1E, 0x84, 0xA1, 0xB0, + 0x6C, 0xC3, 0xAE, 0xAF, 0x09, 0x89, 0x77, 0x66, 0x7C, 0x81, 0x66, 0x23, 0xD6, 0x4C, 0x40, 0x02, + 0x78, 0xD7, 0xD3, 0x05, 0x89, 0x20, 0xA0, 0xDD, 0x5E, 0xA8, 0x17, 0x04, 0xE2, 0x99, 0x68, 0xA4, + 0x95, 0xF4, 0xD9, 0xC4, 0xFA, 0x4C, 0x22, 0xA1, 0xB0, 0xF4, 0x19, 0xC4, 0xFA, 0xEC, 0xE1, 0x4E, + 0xD0, 0x42, 0xF0, 0xC4, 0x56, 0x24, 0x48, 0x92, 0x2D, 0x75, 0x22, 0x4A, 0x3D, 0x98, 0x49, 0xE5, + 0x40, 0x7C, 0xA6, 0xA9, 0x88, 0x28, 0x32, 0xA2, 0x28, 0x32, 0xC2, 0x45, 0x86, 0x00, 0x51, 0xE2, + 0x9E, 0x3F, 0xAD, 0x0B, 0xE3, 0xCA, 0x4F, 0x2F, 0x23, 0xCE, 0x56, 0xC3, 0x4C, 0x3A, 0xF9, 0x94, + 0x49, 0x60, 0x2F, 0x1B, 0x00, 0xDA, 0xCF, 0x61, 0xDC, 0x16, 0xD9, 0x5A, 0x0D, 0xD5, 0xD8, 0x0A, + 0xA6, 0x5C, 0x08, 0x10, 0xB1, 0x25, 0x9F, 0x98, 0x05, 0xAC, 0xBC, 0x22, 0x3E, 0x7F, 0xAE, 0xD3, + 0xB0, 0xC7, 0xDA, 0xC4, 0x35, 0xE6, 0xC4, 0x83, 0x09, 0x59, 0x48, 0xEC, 0x98, 0xDE, 0xCF, 0x1D, + 0xA2, 0x58, 0x33, 0x81, 0xC9, 0x70, 0x6A, 0x97, 0x0B, 0x1A, 0xB6, 0x14, 0xA0, 0x43, 0x3A, 0x32, + 0xA1, 0x83, 0x46, 0x6C, 0x6C, 0x0B, 0x4F, 0x95, 0x84, 0x15, 0xB6, 0x8E, 0x9C, 0x27, 0x42, 0xC0, + 0x26, 0x4A, 0x03, 0xAD, 0x97, 0x2C, 0x27, 0xB0, 0x84, 0x87, 0x31, 0x9B, 0x48, 0x6B, 0xC4, 0x06, + 0x21, 0x4B, 0xB1, 0x36, 0xA1, 0x83, 0x30, 0xF8, 0x34, 0x32, 0x73, 0x49, 0x61, 0xE5, 0xEA, 0xDA, + 0x7B, 0x8B, 0xE0, 0x34, 0x8F, 0xBF, 0xA9, 0xF3, 0xE2, 0xDB, 0xD7, 0x9A, 0xE3, 0x6A, 0x96, 0xB3, + 0x22, 0xB8, 0x59, 0x34, 0x58, 0x0A, 0xD6, 0x86, 0x04, 0x12, 0x4B, 0xC2, 0x26, 0xD3, 0x18, 0x87, + 0xFC, 0x99, 0xE9, 0x41, 0xEA, 0x8E, 0xEF, 0x93, 0x25, 0x8F, 0x6B, 0x61, 0x31, 0x34, 0x97, 0xBD, + 0xF5, 0xAD, 0x55, 0x31, 0x71, 0x32, 0x98, 0x48, 0x96, 0x8F, 0x39, 0x8F, 0x6B, 0xC1, 0x28, 0x6B, + 0xFA, 0x5E, 0x40, 0x84, 0xE1, 0xED, 0x07, 0x2B, 0x45, 0x39, 0x03, 0xB9, 0x82, 0x0C, 0xC1, 0x22, + 0x59, 0x46, 0xBC, 0xAE, 0x49, 0x53, 0x56, 0x23, 0xC9, 0xD0, 0x28, 0x96, 0x0B, 0xA5, 0x71, 0x3E, + 0x5D, 0x2B, 0x4C, 0xE2, 0x2C, 0x55, 0x67, 0xBF, 0xB3, 0xC3, 0xE0, 0xB1, 0x39, 0x76, 0x86, 0x8B, + 0x05, 0x83, 0x47, 0x67, 0x87, 0x33, 0x7F, 0x6E, 0x0D, 0x1E, 0xFD, 0x2F, 0x5E, 0x72, 0x0B, 0x89, + 0x42, 0x10, 0x01, 0x00 +}; diff --git a/arduino/esp32-cam/CameraWebServer/camera_pins.h b/arduino/esp32-cam/CameraWebServer/camera_pins.h new file mode 100644 index 0000000..e1be287 --- /dev/null +++ b/arduino/esp32-cam/CameraWebServer/camera_pins.h @@ -0,0 +1,273 @@ + +#if defined(CAMERA_MODEL_WROVER_KIT) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 21 +#define SIOD_GPIO_NUM 26 +#define SIOC_GPIO_NUM 27 + +#define Y9_GPIO_NUM 35 +#define Y8_GPIO_NUM 34 +#define Y7_GPIO_NUM 39 +#define Y6_GPIO_NUM 36 +#define Y5_GPIO_NUM 19 +#define Y4_GPIO_NUM 18 +#define Y3_GPIO_NUM 5 +#define Y2_GPIO_NUM 4 +#define VSYNC_GPIO_NUM 25 +#define HREF_GPIO_NUM 23 +#define PCLK_GPIO_NUM 22 + +#elif defined(CAMERA_MODEL_ESP_EYE) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 4 +#define SIOD_GPIO_NUM 18 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 36 +#define Y8_GPIO_NUM 37 +#define Y7_GPIO_NUM 38 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 35 +#define Y4_GPIO_NUM 14 +#define Y3_GPIO_NUM 13 +#define Y2_GPIO_NUM 34 +#define VSYNC_GPIO_NUM 5 +#define HREF_GPIO_NUM 27 +#define PCLK_GPIO_NUM 25 + +#elif defined(CAMERA_MODEL_M5STACK_PSRAM) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 25 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 32 +#define VSYNC_GPIO_NUM 22 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_M5STACK_V2_PSRAM) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 22 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 32 +#define VSYNC_GPIO_NUM 25 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_M5STACK_WIDE) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 22 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 32 +#define VSYNC_GPIO_NUM 25 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_M5STACK_ESP32CAM) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 25 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 17 +#define VSYNC_GPIO_NUM 22 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_M5STACK_UNITCAM) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 25 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 32 +#define VSYNC_GPIO_NUM 22 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_AI_THINKER) +#define PWDN_GPIO_NUM 32 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 0 +#define SIOD_GPIO_NUM 26 +#define SIOC_GPIO_NUM 27 + +#define Y9_GPIO_NUM 35 +#define Y8_GPIO_NUM 34 +#define Y7_GPIO_NUM 39 +#define Y6_GPIO_NUM 36 +#define Y5_GPIO_NUM 21 +#define Y4_GPIO_NUM 19 +#define Y3_GPIO_NUM 18 +#define Y2_GPIO_NUM 5 +#define VSYNC_GPIO_NUM 25 +#define HREF_GPIO_NUM 23 +#define PCLK_GPIO_NUM 22 + +#elif defined(CAMERA_MODEL_TTGO_T_JOURNAL) +#define PWDN_GPIO_NUM 0 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 25 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 17 +#define VSYNC_GPIO_NUM 22 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + + +#elif defined(CAMERA_MODEL_ESP32_CAM_BOARD) +// The 18 pin header on the board has Y5 and Y3 swapped +#define USE_BOARD_HEADER 0 +#define PWDN_GPIO_NUM 32 +#define RESET_GPIO_NUM 33 +#define XCLK_GPIO_NUM 4 +#define SIOD_GPIO_NUM 18 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 36 +#define Y8_GPIO_NUM 19 +#define Y7_GPIO_NUM 21 +#define Y6_GPIO_NUM 39 +#if USE_BOARD_HEADER +#define Y5_GPIO_NUM 13 +#else +#define Y5_GPIO_NUM 35 +#endif +#define Y4_GPIO_NUM 14 +#if USE_BOARD_HEADER +#define Y3_GPIO_NUM 35 +#else +#define Y3_GPIO_NUM 13 +#endif +#define Y2_GPIO_NUM 34 +#define VSYNC_GPIO_NUM 5 +#define HREF_GPIO_NUM 27 +#define PCLK_GPIO_NUM 25 + +#elif defined(CAMERA_MODEL_ESP32S3_CAM_LCD) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 40 +#define SIOD_GPIO_NUM 17 +#define SIOC_GPIO_NUM 18 + +#define Y9_GPIO_NUM 39 +#define Y8_GPIO_NUM 41 +#define Y7_GPIO_NUM 42 +#define Y6_GPIO_NUM 12 +#define Y5_GPIO_NUM 3 +#define Y4_GPIO_NUM 14 +#define Y3_GPIO_NUM 47 +#define Y2_GPIO_NUM 13 +#define VSYNC_GPIO_NUM 21 +#define HREF_GPIO_NUM 38 +#define PCLK_GPIO_NUM 11 + +#elif defined(CAMERA_MODEL_ESP32S2_CAM_BOARD) +// The 18 pin header on the board has Y5 and Y3 swapped +#define USE_BOARD_HEADER 0 +#define PWDN_GPIO_NUM 1 +#define RESET_GPIO_NUM 2 +#define XCLK_GPIO_NUM 42 +#define SIOD_GPIO_NUM 41 +#define SIOC_GPIO_NUM 18 + +#define Y9_GPIO_NUM 16 +#define Y8_GPIO_NUM 39 +#define Y7_GPIO_NUM 40 +#define Y6_GPIO_NUM 15 +#if USE_BOARD_HEADER +#define Y5_GPIO_NUM 12 +#else +#define Y5_GPIO_NUM 13 +#endif +#define Y4_GPIO_NUM 5 +#if USE_BOARD_HEADER +#define Y3_GPIO_NUM 13 +#else +#define Y3_GPIO_NUM 12 +#endif +#define Y2_GPIO_NUM 14 +#define VSYNC_GPIO_NUM 38 +#define HREF_GPIO_NUM 4 +#define PCLK_GPIO_NUM 3 + +#elif defined(CAMERA_MODEL_ESP32S3_EYE) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 15 +#define SIOD_GPIO_NUM 4 +#define SIOC_GPIO_NUM 5 + +#define Y2_GPIO_NUM 11 +#define Y3_GPIO_NUM 9 +#define Y4_GPIO_NUM 8 +#define Y5_GPIO_NUM 10 +#define Y6_GPIO_NUM 12 +#define Y7_GPIO_NUM 18 +#define Y8_GPIO_NUM 17 +#define Y9_GPIO_NUM 16 + +#define VSYNC_GPIO_NUM 6 +#define HREF_GPIO_NUM 7 +#define PCLK_GPIO_NUM 13 + +#else +#error "Camera model not selected" +#endif diff --git a/arduino/esp32-cam/CameraWebServer/index_ov2640.html b/arduino/esp32-cam/CameraWebServer/index_ov2640.html new file mode 100644 index 0000000..4f3738c --- /dev/null +++ b/arduino/esp32-cam/CameraWebServer/index_ov2640.html @@ -0,0 +1,1160 @@ + + + + + + ESP32 OV2460 + + + +
+ +
+ +
+ +
+
+
+ + + diff --git a/bin/__py_include.py b/bin/__py_include.py index 7f95e28..8f98830 100644 --- a/bin/__py_include.py +++ b/bin/__py_include.py @@ -1,7 +1,7 @@ import sys import os.path -for _name in ('py_include',): +for _name in ('include/py',): sys.path.extend([ os.path.realpath( os.path.join(os.path.dirname(os.path.join(__file__)), '..', _name) diff --git a/esp32-cam/CameraWebServer/CameraWebServer.ino b/esp32-cam/CameraWebServer/CameraWebServer.ino deleted file mode 100644 index ef589d9..0000000 --- a/esp32-cam/CameraWebServer/CameraWebServer.ino +++ /dev/null @@ -1,165 +0,0 @@ -#include "esp_camera.h" -#include - -// -// WARNING!!! PSRAM IC required for UXGA resolution and high JPEG quality -// Ensure ESP32 Wrover Module or other board with PSRAM is selected -// Partial images will be transmitted if image exceeds buffer size -// -// You must select partition scheme from the board menu that has at least 3MB APP space. -// Face Recognition is DISABLED for ESP32 and ESP32-S2, because it takes up from 15 -// seconds to process single frame. Face Detection is ENABLED if PSRAM is enabled as well - -// =================== -// Select camera model -// =================== -//#define CAMERA_MODEL_WROVER_KIT // Has PSRAM -//#define CAMERA_MODEL_ESP_EYE // Has PSRAM -//#define CAMERA_MODEL_ESP32S3_EYE // Has PSRAM -//#define CAMERA_MODEL_M5STACK_PSRAM // Has PSRAM -//#define CAMERA_MODEL_M5STACK_V2_PSRAM // M5Camera version B Has PSRAM -//#define CAMERA_MODEL_M5STACK_WIDE // Has PSRAM -//#define CAMERA_MODEL_M5STACK_ESP32CAM // No PSRAM -//#define CAMERA_MODEL_M5STACK_UNITCAM // No PSRAM -#define CAMERA_MODEL_AI_THINKER // Has PSRAM -//#define CAMERA_MODEL_TTGO_T_JOURNAL // No PSRAM -// ** Espressif Internal Boards ** -//#define CAMERA_MODEL_ESP32_CAM_BOARD -//#define CAMERA_MODEL_ESP32S2_CAM_BOARD -//#define CAMERA_MODEL_ESP32S3_CAM_LCD - -#include "camera_pins.h" - -// =========================== -// Enter your WiFi credentials -// =========================== -#include "wifi_password.h" - -volatile float disconnected_since = 0; - -void startCameraServer(); - -void onWiFiDisconnect(WiFiEvent_t event, WiFiEventInfo_t info) { - disconnected_since = millis(); - WiFi.reconnect(); -} - -void onWiFiConnect(WiFiEvent_t event, WiFiEventInfo_t info) { - disconnected_since = 0; -} - -void setup() { - Serial.begin(115200); - //Serial.setDebugOutput(true); - Serial.println(); - - camera_config_t config; - config.ledc_channel = LEDC_CHANNEL_0; - config.ledc_timer = LEDC_TIMER_0; - config.pin_d0 = Y2_GPIO_NUM; - config.pin_d1 = Y3_GPIO_NUM; - config.pin_d2 = Y4_GPIO_NUM; - config.pin_d3 = Y5_GPIO_NUM; - config.pin_d4 = Y6_GPIO_NUM; - config.pin_d5 = Y7_GPIO_NUM; - config.pin_d6 = Y8_GPIO_NUM; - config.pin_d7 = Y9_GPIO_NUM; - config.pin_xclk = XCLK_GPIO_NUM; - config.pin_pclk = PCLK_GPIO_NUM; - config.pin_vsync = VSYNC_GPIO_NUM; - config.pin_href = HREF_GPIO_NUM; - config.pin_sscb_sda = SIOD_GPIO_NUM; - config.pin_sscb_scl = SIOC_GPIO_NUM; - config.pin_pwdn = PWDN_GPIO_NUM; - config.pin_reset = RESET_GPIO_NUM; - config.xclk_freq_hz = 20000000; - config.frame_size = FRAMESIZE_UXGA; - config.pixel_format = PIXFORMAT_JPEG; // for streaming - //config.pixel_format = PIXFORMAT_RGB565; // for face detection/recognition - config.grab_mode = CAMERA_GRAB_WHEN_EMPTY; - config.fb_location = CAMERA_FB_IN_PSRAM; - config.jpeg_quality = 12; - config.fb_count = 1; - - // if PSRAM IC present, init with UXGA resolution and higher JPEG quality - // for larger pre-allocated frame buffer. - if(config.pixel_format == PIXFORMAT_JPEG){ - if(psramFound()){ - config.jpeg_quality = 10; - config.fb_count = 2; - config.grab_mode = CAMERA_GRAB_LATEST; - } else { - // Limit the frame size when PSRAM is not available - config.frame_size = FRAMESIZE_SVGA; - config.fb_location = CAMERA_FB_IN_DRAM; - } - } else { - // Best option for face detection/recognition - config.frame_size = FRAMESIZE_240X240; -#if CONFIG_IDF_TARGET_ESP32S3 - config.fb_count = 2; -#endif - } - -#if defined(CAMERA_MODEL_ESP_EYE) - pinMode(13, INPUT_PULLUP); - pinMode(14, INPUT_PULLUP); -#endif - - // camera init - esp_err_t err = esp_camera_init(&config); - if (err != ESP_OK) { - Serial.printf("Camera init failed with error 0x%x", err); - return; - } - - sensor_t * s = esp_camera_sensor_get(); - // initial sensors are flipped vertically and colors are a bit saturated - if (s->id.PID == OV3660_PID) { - s->set_vflip(s, 1); // flip it back - s->set_brightness(s, 1); // up the brightness just a bit - s->set_saturation(s, -2); // lower the saturation - } - // drop down frame size for higher initial frame rate - if(config.pixel_format == PIXFORMAT_JPEG){ - s->set_framesize(s, FRAMESIZE_QVGA); - } - -#if defined(CAMERA_MODEL_M5STACK_WIDE) || defined(CAMERA_MODEL_M5STACK_ESP32CAM) - s->set_vflip(s, 1); - s->set_hmirror(s, 1); -#endif - -#if defined(CAMERA_MODEL_ESP32S3_EYE) - s->set_vflip(s, 1); -#endif - - WiFi.onEvent(onWiFiDisconnect, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); - WiFi.onEvent(onWiFiConnect, ARDUINO_EVENT_WIFI_STA_CONNECTED); - - WiFi.begin(ssid, password); - WiFi.setSleep(false); - - while (WiFi.status() != WL_CONNECTED) { - delay(500); - Serial.print("."); - } - Serial.println(""); - Serial.println("WiFi connected"); - - startCameraServer(); - - Serial.print("Camera Ready! Use 'http://"); - Serial.print(WiFi.localIP()); - Serial.println("' to connect"); -} - -void loop() { - if (disconnected_since != 0 && (millis() - disconnected_since) > 60000) { - ESP.restart(); - return; - } - - // Do nothing. Everything is done in another task by the web server - delay(10000); -} diff --git a/esp32-cam/CameraWebServer/app_httpd.cpp b/esp32-cam/CameraWebServer/app_httpd.cpp deleted file mode 100644 index e397c70..0000000 --- a/esp32-cam/CameraWebServer/app_httpd.cpp +++ /dev/null @@ -1,1287 +0,0 @@ -// Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -#include - -#include "esp_http_server.h" -#include "esp_timer.h" -#include "esp_camera.h" -#include "img_converters.h" -#include "fb_gfx.h" -#include "driver/ledc.h" -#include "sdkconfig.h" -#include "camera_index.h" - -#if defined(ARDUINO_ARCH_ESP32) && defined(CONFIG_ARDUHAL_ESP_LOG) -#include "esp32-hal-log.h" -#define TAG "" -#else -#include "esp_log.h" -static const char *TAG = "camera_httpd"; -#endif - -// Face Detection will not work on boards without (or with disabled) PSRAM -#ifdef BOARD_HAS_PSRAM -#define CONFIG_ESP_FACE_DETECT_ENABLED 1 -// Face Recognition takes upward from 15 seconds per frame on chips other than ESP32S3 -// Makes no sense to have it enabled for them -#if CONFIG_IDF_TARGET_ESP32S3 -#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 1 -#else -#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 0 -#endif -#else -#define CONFIG_ESP_FACE_DETECT_ENABLED 0 -#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 0 -#endif - -#if CONFIG_ESP_FACE_DETECT_ENABLED - -#include -#include "human_face_detect_msr01.hpp" -#include "human_face_detect_mnp01.hpp" - -#define TWO_STAGE 1 /* very large firmware, very slow, reboots when streaming... - -#define FACE_ID_SAVE_NUMBER 7 -#endif - -#define FACE_COLOR_WHITE 0x00FFFFFF -#define FACE_COLOR_BLACK 0x00000000 -#define FACE_COLOR_RED 0x000000FF -#define FACE_COLOR_GREEN 0x0000FF00 -#define FACE_COLOR_BLUE 0x00FF0000 -#define FACE_COLOR_YELLOW (FACE_COLOR_RED | FACE_COLOR_GREEN) -#define FACE_COLOR_CYAN (FACE_COLOR_BLUE | FACE_COLOR_GREEN) -#define FACE_COLOR_PURPLE (FACE_COLOR_BLUE | FACE_COLOR_RED) -#endif - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED -int led_duty = 0; -bool isStreaming = false; -#ifdef CONFIG_LED_LEDC_LOW_SPEED_MODE -#define CONFIG_LED_LEDC_SPEED_MODE LEDC_LOW_SPEED_MODE -#else -#define CONFIG_LED_LEDC_SPEED_MODE LEDC_HIGH_SPEED_MODE -#endif -#endif - -typedef struct -{ - httpd_req_t *req; - size_t len; -} jpg_chunking_t; - -#define PART_BOUNDARY "123456789000000000000987654321" -static const char *_STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY; -static const char *_STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n"; -static const char *_STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\nX-Timestamp: %d.%06d\r\n\r\n"; - -httpd_handle_t stream_httpd = NULL; -httpd_handle_t camera_httpd = NULL; - -#if CONFIG_ESP_FACE_DETECT_ENABLED - -static int8_t detection_enabled = 0; - -// #if TWO_STAGE -// static HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); -// static HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); -// #else -// static HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); -// #endif - -static int8_t flash_enabled = 0; - -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED -static int8_t recognition_enabled = 0; -static int8_t is_enrolling = 0; - -#if QUANT_TYPE - // S16 model - FaceRecognition112V1S16 recognizer; -#else - // S8 model - FaceRecognition112V1S8 recognizer; -#endif -#endif - -#endif - -typedef struct -{ - size_t size; //number of values used for filtering - size_t index; //current value index - size_t count; //value count - int sum; - int *values; //array to be filled with values -} ra_filter_t; - -static ra_filter_t ra_filter; - -static ra_filter_t *ra_filter_init(ra_filter_t *filter, size_t sample_size) -{ - memset(filter, 0, sizeof(ra_filter_t)); - - filter->values = (int *)malloc(sample_size * sizeof(int)); - if (!filter->values) - { - return NULL; - } - memset(filter->values, 0, sample_size * sizeof(int)); - - filter->size = sample_size; - return filter; -} - -static int ra_filter_run(ra_filter_t *filter, int value) -{ - if (!filter->values) - { - return value; - } - filter->sum -= filter->values[filter->index]; - filter->values[filter->index] = value; - filter->sum += filter->values[filter->index]; - filter->index++; - filter->index = filter->index % filter->size; - if (filter->count < filter->size) - { - filter->count++; - } - return filter->sum / filter->count; -} - -#if CONFIG_ESP_FACE_DETECT_ENABLED -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED -static void rgb_print(fb_data_t *fb, uint32_t color, const char *str) -{ - fb_gfx_print(fb, (fb->width - (strlen(str) * 14)) / 2, 10, color, str); -} - -static int rgb_printf(fb_data_t *fb, uint32_t color, const char *format, ...) -{ - char loc_buf[64]; - char *temp = loc_buf; - int len; - va_list arg; - va_list copy; - va_start(arg, format); - va_copy(copy, arg); - len = vsnprintf(loc_buf, sizeof(loc_buf), format, arg); - va_end(copy); - if (len >= sizeof(loc_buf)) - { - temp = (char *)malloc(len + 1); - if (temp == NULL) - { - return 0; - } - } - vsnprintf(temp, len + 1, format, arg); - va_end(arg); - rgb_print(fb, color, temp); - if (len > 64) - { - free(temp); - } - return len; -} -#endif -static void draw_face_boxes(fb_data_t *fb, std::list *results, int face_id) -{ - int x, y, w, h; - uint32_t color = FACE_COLOR_YELLOW; - if (face_id < 0) - { - color = FACE_COLOR_RED; - } - else if (face_id > 0) - { - color = FACE_COLOR_GREEN; - } - if(fb->bytes_per_pixel == 2){ - //color = ((color >> 8) & 0xF800) | ((color >> 3) & 0x07E0) | (color & 0x001F); - color = ((color >> 16) & 0x001F) | ((color >> 3) & 0x07E0) | ((color << 8) & 0xF800); - } - int i = 0; - for (std::list::iterator prediction = results->begin(); prediction != results->end(); prediction++, i++) - { - // rectangle box - x = (int)prediction->box[0]; - y = (int)prediction->box[1]; - w = (int)prediction->box[2] - x + 1; - h = (int)prediction->box[3] - y + 1; - if((x + w) > fb->width){ - w = fb->width - x; - } - if((y + h) > fb->height){ - h = fb->height - y; - } - fb_gfx_drawFastHLine(fb, x, y, w, color); - fb_gfx_drawFastHLine(fb, x, y + h - 1, w, color); - fb_gfx_drawFastVLine(fb, x, y, h, color); - fb_gfx_drawFastVLine(fb, x + w - 1, y, h, color); -#if TWO_STAGE - // landmarks (left eye, mouth left, nose, right eye, mouth right) - int x0, y0, j; - for (j = 0; j < 10; j+=2) { - x0 = (int)prediction->keypoint[j]; - y0 = (int)prediction->keypoint[j+1]; - fb_gfx_fillRect(fb, x0, y0, 3, 3, color); - } -#endif - } -} - -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED -static int run_face_recognition(fb_data_t *fb, std::list *results) -{ - std::vector landmarks = results->front().keypoint; - int id = -1; - - Tensor tensor; - tensor.set_element((uint8_t *)fb->data).set_shape({fb->height, fb->width, 3}).set_auto_free(false); - - int enrolled_count = recognizer.get_enrolled_id_num(); - - if (enrolled_count < FACE_ID_SAVE_NUMBER && is_enrolling){ - id = recognizer.enroll_id(tensor, landmarks, "", true); - ESP_LOGI(TAG, "Enrolled ID: %d", id); - rgb_printf(fb, FACE_COLOR_CYAN, "ID[%u]", id); - } - - face_info_t recognize = recognizer.recognize(tensor, landmarks); - if(recognize.id >= 0){ - rgb_printf(fb, FACE_COLOR_GREEN, "ID[%u]: %.2f", recognize.id, recognize.similarity); - } else { - rgb_print(fb, FACE_COLOR_RED, "Intruder Alert!"); - } - return recognize.id; -} -#endif -#endif - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED -void enable_led(bool en) -{ // Turn LED On or Off - int duty = en ? led_duty : 0; - if (en && isStreaming && (led_duty > CONFIG_LED_MAX_INTENSITY)) - { - duty = CONFIG_LED_MAX_INTENSITY; - } - ledc_set_duty(CONFIG_LED_LEDC_SPEED_MODE, CONFIG_LED_LEDC_CHANNEL, duty); - ledc_update_duty(CONFIG_LED_LEDC_SPEED_MODE, CONFIG_LED_LEDC_CHANNEL); - ESP_LOGI(TAG, "Set LED intensity to %d", duty); -} -#endif - -static esp_err_t bmp_handler(httpd_req_t *req) -{ - camera_fb_t *fb = NULL; - esp_err_t res = ESP_OK; - uint64_t fr_start = esp_timer_get_time(); - fb = esp_camera_fb_get(); - if (!fb) - { - ESP_LOGE(TAG, "Camera capture failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - - httpd_resp_set_type(req, "image/x-windows-bmp"); - httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=capture.bmp"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - - char ts[32]; - snprintf(ts, 32, "%ld.%06ld", fb->timestamp.tv_sec, fb->timestamp.tv_usec); - httpd_resp_set_hdr(req, "X-Timestamp", (const char *)ts); - - - uint8_t * buf = NULL; - size_t buf_len = 0; - bool converted = frame2bmp(fb, &buf, &buf_len); - esp_camera_fb_return(fb); - if(!converted){ - ESP_LOGE(TAG, "BMP Conversion failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - res = httpd_resp_send(req, (const char *)buf, buf_len); - free(buf); - uint64_t fr_end = esp_timer_get_time(); - ESP_LOGI(TAG, "BMP: %llums, %uB", (uint64_t)((fr_end - fr_start) / 1000), buf_len); - return res; -} - -static size_t jpg_encode_stream(void *arg, size_t index, const void *data, size_t len) -{ - jpg_chunking_t *j = (jpg_chunking_t *)arg; - if (!index) - { - j->len = 0; - } - if (httpd_resp_send_chunk(j->req, (const char *)data, len) != ESP_OK) - { - return 0; - } - j->len += len; - return len; -} - -static esp_err_t capture_handler(httpd_req_t *req) -{ - camera_fb_t *fb = NULL; - esp_err_t res = ESP_OK; - int64_t fr_start = esp_timer_get_time(); - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - enable_led(true); - vTaskDelay(150 / portTICK_PERIOD_MS); // The LED needs to be turned on ~150ms before the call to esp_camera_fb_get() - fb = esp_camera_fb_get(); // or it won't be visible in the frame. A better way to do this is needed. - enable_led(false); -#else - fb = esp_camera_fb_get(); -#endif - - if (!fb) - { - ESP_LOGE(TAG, "Camera capture failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - - httpd_resp_set_type(req, "image/jpeg"); - httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=capture.jpg"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - - char ts[32]; - snprintf(ts, 32, "%ld.%06ld", fb->timestamp.tv_sec, fb->timestamp.tv_usec); - httpd_resp_set_hdr(req, "X-Timestamp", (const char *)ts); - -#if CONFIG_ESP_FACE_DETECT_ENABLED - size_t out_len, out_width, out_height; - uint8_t *out_buf; - bool s; - bool detected = false; - int face_id = 0; - if (!detection_enabled || fb->width > 400) - { -#endif - size_t fb_len = 0; - if (fb->format == PIXFORMAT_JPEG) - { - fb_len = fb->len; - res = httpd_resp_send(req, (const char *)fb->buf, fb->len); - } - else - { - jpg_chunking_t jchunk = {req, 0}; - res = frame2jpg_cb(fb, 80, jpg_encode_stream, &jchunk) ? ESP_OK : ESP_FAIL; - httpd_resp_send_chunk(req, NULL, 0); - fb_len = jchunk.len; - } - esp_camera_fb_return(fb); - int64_t fr_end = esp_timer_get_time(); - ESP_LOGI(TAG, "JPG: %uB %ums", (uint32_t)(fb_len), (uint32_t)((fr_end - fr_start) / 1000)); - return res; -#if CONFIG_ESP_FACE_DETECT_ENABLED - } - - jpg_chunking_t jchunk = {req, 0}; - - if (fb->format == PIXFORMAT_RGB565 -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - && !recognition_enabled -#endif - ){ -#if TWO_STAGE - HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); - HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); - std::list &candidates = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); - std::list &results = s2.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}, candidates); -#else - HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); - std::list &results = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); -#endif - if (results.size() > 0) { - fb_data_t rfb; - rfb.width = fb->width; - rfb.height = fb->height; - rfb.data = fb->buf; - rfb.bytes_per_pixel = 2; - rfb.format = FB_RGB565; - detected = true; - draw_face_boxes(&rfb, &results, face_id); - } - s = fmt2jpg_cb(fb->buf, fb->len, fb->width, fb->height, PIXFORMAT_RGB565, 90, jpg_encode_stream, &jchunk); - esp_camera_fb_return(fb); - } else - { - out_len = fb->width * fb->height * 3; - out_width = fb->width; - out_height = fb->height; - out_buf = (uint8_t*)malloc(out_len); - if (!out_buf) { - ESP_LOGE(TAG, "out_buf malloc failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - s = fmt2rgb888(fb->buf, fb->len, fb->format, out_buf); - esp_camera_fb_return(fb); - if (!s) { - free(out_buf); - ESP_LOGE(TAG, "to rgb888 failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - - fb_data_t rfb; - rfb.width = out_width; - rfb.height = out_height; - rfb.data = out_buf; - rfb.bytes_per_pixel = 3; - rfb.format = FB_BGR888; - -#if TWO_STAGE - HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); - HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); - std::list &candidates = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); - std::list &results = s2.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}, candidates); -#else - HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); - std::list &results = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); -#endif - - if (results.size() > 0) { - detected = true; -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - if (recognition_enabled) { - face_id = run_face_recognition(&rfb, &results); - } -#endif - draw_face_boxes(&rfb, &results, face_id); - } - - s = fmt2jpg_cb(out_buf, out_len, out_width, out_height, PIXFORMAT_RGB888, 90, jpg_encode_stream, &jchunk); - free(out_buf); - } - - if (!s) { - ESP_LOGE(TAG, "JPEG compression failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - - int64_t fr_end = esp_timer_get_time(); - ESP_LOGI(TAG, "FACE: %uB %ums %s%d", (uint32_t)(jchunk.len), (uint32_t)((fr_end - fr_start) / 1000), detected ? "DETECTED " : "", face_id); - return res; -#endif -} - -static esp_err_t stream_handler(httpd_req_t *req) -{ - camera_fb_t *fb = NULL; - struct timeval _timestamp; - esp_err_t res = ESP_OK; - size_t _jpg_buf_len = 0; - uint8_t *_jpg_buf = NULL; - char *part_buf[128]; -#if CONFIG_ESP_FACE_DETECT_ENABLED - bool detected = false; - int face_id = 0; - int64_t fr_start = 0; - int64_t fr_ready = 0; - int64_t fr_face = 0; - int64_t fr_recognize = 0; - int64_t fr_encode = 0; - - size_t out_len = 0, out_width = 0, out_height = 0; - uint8_t *out_buf = NULL; - bool s = false; -#if TWO_STAGE - HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); - HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); -#else - HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); -#endif -#endif - - static int64_t last_frame = 0; - if (!last_frame) - { - last_frame = esp_timer_get_time(); - } - - res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE); - if (res != ESP_OK) - { - return res; - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - httpd_resp_set_hdr(req, "X-Framerate", "60"); - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - enable_led(true); - isStreaming = true; -#endif - - while (true) - { -#if CONFIG_ESP_FACE_DETECT_ENABLED - detected = false; - face_id = 0; -#endif - - fb = esp_camera_fb_get(); - if (!fb) - { - ESP_LOGE(TAG, "Camera capture failed"); - res = ESP_FAIL; - } - else - { - _timestamp.tv_sec = fb->timestamp.tv_sec; - _timestamp.tv_usec = fb->timestamp.tv_usec; -#if CONFIG_ESP_FACE_DETECT_ENABLED - fr_start = esp_timer_get_time(); - fr_ready = fr_start; - fr_face = fr_start; - fr_encode = fr_start; - fr_recognize = fr_start; - if (!detection_enabled || fb->width > 400) - { -#endif - if (fb->format != PIXFORMAT_JPEG) - { - bool jpeg_converted = frame2jpg(fb, 80, &_jpg_buf, &_jpg_buf_len); - esp_camera_fb_return(fb); - fb = NULL; - if (!jpeg_converted) - { - ESP_LOGE(TAG, "JPEG compression failed"); - res = ESP_FAIL; - } - } - else - { - _jpg_buf_len = fb->len; - _jpg_buf = fb->buf; - } -#if CONFIG_ESP_FACE_DETECT_ENABLED - } - else - { - if (fb->format == PIXFORMAT_RGB565 -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - && !recognition_enabled -#endif - ){ - fr_ready = esp_timer_get_time(); -#if TWO_STAGE - std::list &candidates = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); - std::list &results = s2.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}, candidates); -#else - std::list &results = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); -#endif - fr_face = esp_timer_get_time(); - fr_recognize = fr_face; - if (results.size() > 0) { - fb_data_t rfb; - rfb.width = fb->width; - rfb.height = fb->height; - rfb.data = fb->buf; - rfb.bytes_per_pixel = 2; - rfb.format = FB_RGB565; - detected = true; - draw_face_boxes(&rfb, &results, face_id); - } - s = fmt2jpg(fb->buf, fb->len, fb->width, fb->height, PIXFORMAT_RGB565, 80, &_jpg_buf, &_jpg_buf_len); - esp_camera_fb_return(fb); - fb = NULL; - if (!s) { - ESP_LOGE(TAG, "fmt2jpg failed"); - res = ESP_FAIL; - } - fr_encode = esp_timer_get_time(); - } else - { - out_len = fb->width * fb->height * 3; - out_width = fb->width; - out_height = fb->height; - out_buf = (uint8_t*)malloc(out_len); - if (!out_buf) { - ESP_LOGE(TAG, "out_buf malloc failed"); - res = ESP_FAIL; - } else { - s = fmt2rgb888(fb->buf, fb->len, fb->format, out_buf); - esp_camera_fb_return(fb); - fb = NULL; - if (!s) { - free(out_buf); - ESP_LOGE(TAG, "to rgb888 failed"); - res = ESP_FAIL; - } else { - fr_ready = esp_timer_get_time(); - - fb_data_t rfb; - rfb.width = out_width; - rfb.height = out_height; - rfb.data = out_buf; - rfb.bytes_per_pixel = 3; - rfb.format = FB_BGR888; - -#if TWO_STAGE - std::list &candidates = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); - std::list &results = s2.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}, candidates); -#else - std::list &results = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); -#endif - - fr_face = esp_timer_get_time(); - fr_recognize = fr_face; - - if (results.size() > 0) { - detected = true; -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - if (recognition_enabled) { - face_id = run_face_recognition(&rfb, &results); - fr_recognize = esp_timer_get_time(); - } -#endif - draw_face_boxes(&rfb, &results, face_id); - } - s = fmt2jpg(out_buf, out_len, out_width, out_height, PIXFORMAT_RGB888, 90, &_jpg_buf, &_jpg_buf_len); - free(out_buf); - if (!s) { - ESP_LOGE(TAG, "fmt2jpg failed"); - res = ESP_FAIL; - } - fr_encode = esp_timer_get_time(); - } - } - } - } -#endif - } - if (res == ESP_OK) - { - res = httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY)); - } - if (res == ESP_OK) - { - size_t hlen = snprintf((char *)part_buf, 128, _STREAM_PART, _jpg_buf_len, _timestamp.tv_sec, _timestamp.tv_usec); - res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen); - } - if (res == ESP_OK) - { - res = httpd_resp_send_chunk(req, (const char *)_jpg_buf, _jpg_buf_len); - } - if (fb) - { - esp_camera_fb_return(fb); - fb = NULL; - _jpg_buf = NULL; - } - else if (_jpg_buf) - { - free(_jpg_buf); - _jpg_buf = NULL; - } - if (res != ESP_OK) - { - ESP_LOGE(TAG, "send frame failed failed"); - break; - } - int64_t fr_end = esp_timer_get_time(); - -#if CONFIG_ESP_FACE_DETECT_ENABLED - int64_t ready_time = (fr_ready - fr_start) / 1000; - int64_t face_time = (fr_face - fr_ready) / 1000; - int64_t recognize_time = (fr_recognize - fr_face) / 1000; - int64_t encode_time = (fr_encode - fr_recognize) / 1000; - int64_t process_time = (fr_encode - fr_start) / 1000; -#endif - - int64_t frame_time = fr_end - last_frame; - last_frame = fr_end; - frame_time /= 1000; - uint32_t avg_frame_time = ra_filter_run(&ra_filter, frame_time); - ESP_LOGI(TAG, "MJPG: %uB %ums (%.1ffps), AVG: %ums (%.1ffps)" -#if CONFIG_ESP_FACE_DETECT_ENABLED - ", %u+%u+%u+%u=%u %s%d" -#endif - , - (uint32_t)(_jpg_buf_len), - (uint32_t)frame_time, 1000.0 / (uint32_t)frame_time, - avg_frame_time, 1000.0 / avg_frame_time -#if CONFIG_ESP_FACE_DETECT_ENABLED - , - (uint32_t)ready_time, (uint32_t)face_time, (uint32_t)recognize_time, (uint32_t)encode_time, (uint32_t)process_time, - (detected) ? "DETECTED " : "", face_id -#endif - ); - } - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - isStreaming = false; - enable_led(false); -#endif - - last_frame = 0; - return res; -} - -static esp_err_t parse_get(httpd_req_t *req, char **obuf) -{ - char *buf = NULL; - size_t buf_len = 0; - - buf_len = httpd_req_get_url_query_len(req) + 1; - if (buf_len > 1) { - buf = (char *)malloc(buf_len); - if (!buf) { - httpd_resp_send_500(req); - return ESP_FAIL; - } - if (httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) { - *obuf = buf; - return ESP_OK; - } - free(buf); - } - httpd_resp_send_404(req); - return ESP_FAIL; -} - -static esp_err_t cmd_handler(httpd_req_t *req) -{ - char *buf = NULL; - char variable[32]; - char value[32]; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - if (httpd_query_key_value(buf, "var", variable, sizeof(variable)) != ESP_OK || - httpd_query_key_value(buf, "val", value, sizeof(value)) != ESP_OK) { - free(buf); - httpd_resp_send_404(req); - return ESP_FAIL; - } - free(buf); - - int val = atoi(value); - ESP_LOGI(TAG, "%s = %d", variable, val); - sensor_t *s = esp_camera_sensor_get(); - int res = 0; - - if (!strcmp(variable, "framesize")) { - if (s->pixformat == PIXFORMAT_JPEG) { - res = s->set_framesize(s, (framesize_t)val); - } - } - else if (!strcmp(variable, "quality")) - res = s->set_quality(s, val); - else if (!strcmp(variable, "contrast")) - res = s->set_contrast(s, val); - else if (!strcmp(variable, "brightness")) - res = s->set_brightness(s, val); - else if (!strcmp(variable, "saturation")) - res = s->set_saturation(s, val); - else if (!strcmp(variable, "gainceiling")) - res = s->set_gainceiling(s, (gainceiling_t)val); - else if (!strcmp(variable, "colorbar")) - res = s->set_colorbar(s, val); - else if (!strcmp(variable, "awb")) - res = s->set_whitebal(s, val); - else if (!strcmp(variable, "agc")) - res = s->set_gain_ctrl(s, val); - else if (!strcmp(variable, "aec")) - res = s->set_exposure_ctrl(s, val); - else if (!strcmp(variable, "hmirror")) - res = s->set_hmirror(s, val); - else if (!strcmp(variable, "vflip")) - res = s->set_vflip(s, val); - else if (!strcmp(variable, "awb_gain")) - res = s->set_awb_gain(s, val); - else if (!strcmp(variable, "agc_gain")) - res = s->set_agc_gain(s, val); - else if (!strcmp(variable, "aec_value")) - res = s->set_aec_value(s, val); - else if (!strcmp(variable, "aec2")) - res = s->set_aec2(s, val); - else if (!strcmp(variable, "dcw")) - res = s->set_dcw(s, val); - else if (!strcmp(variable, "bpc")) - res = s->set_bpc(s, val); - else if (!strcmp(variable, "wpc")) - res = s->set_wpc(s, val); - else if (!strcmp(variable, "raw_gma")) - res = s->set_raw_gma(s, val); - else if (!strcmp(variable, "lenc")) - res = s->set_lenc(s, val); - else if (!strcmp(variable, "special_effect")) - res = s->set_special_effect(s, val); - else if (!strcmp(variable, "wb_mode")) - res = s->set_wb_mode(s, val); - else if (!strcmp(variable, "ae_level")) - res = s->set_ae_level(s, val); -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - else if (!strcmp(variable, "led_intensity")) { - led_duty = val; - if (isStreaming) - enable_led(true); - } -#endif - -#if CONFIG_ESP_FACE_DETECT_ENABLED - else if (!strcmp(variable, "face_detect")) { - detection_enabled = val; -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - if (!detection_enabled) { - recognition_enabled = 0; - } -#endif - } -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - else if (!strcmp(variable, "face_enroll")){ - is_enrolling = !is_enrolling; - ESP_LOGI(TAG, "Enrolling: %s", is_enrolling?"true":"false"); - } - else if (!strcmp(variable, "face_recognize")) { - recognition_enabled = val; - if (recognition_enabled) { - detection_enabled = val; - } - } -#endif -#endif - else if(!strcmp(variable, "flash") ) { - pinMode(4, OUTPUT); - digitalWrite(4, atoi(value)); - flash_enabled = atoi(value); - } - else { - ESP_LOGI(TAG, "Unknown command: %s", variable); - res = -1; - } - - if (res < 0) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static int print_reg(char * p, sensor_t * s, uint16_t reg, uint32_t mask){ - return sprintf(p, "\"0x%x\":%u,", reg, s->get_reg(s, reg, mask)); -} - -static esp_err_t status_handler(httpd_req_t *req) -{ - static char json_response[1024]; - - sensor_t *s = esp_camera_sensor_get(); - char *p = json_response; - *p++ = '{'; - - if(s->id.PID == OV5640_PID || s->id.PID == OV3660_PID){ - for(int reg = 0x3400; reg < 0x3406; reg+=2){ - p+=print_reg(p, s, reg, 0xFFF);//12 bit - } - p+=print_reg(p, s, 0x3406, 0xFF); - - p+=print_reg(p, s, 0x3500, 0xFFFF0);//16 bit - p+=print_reg(p, s, 0x3503, 0xFF); - p+=print_reg(p, s, 0x350a, 0x3FF);//10 bit - p+=print_reg(p, s, 0x350c, 0xFFFF);//16 bit - - for(int reg = 0x5480; reg <= 0x5490; reg++){ - p+=print_reg(p, s, reg, 0xFF); - } - - for(int reg = 0x5380; reg <= 0x538b; reg++){ - p+=print_reg(p, s, reg, 0xFF); - } - - for(int reg = 0x5580; reg < 0x558a; reg++){ - p+=print_reg(p, s, reg, 0xFF); - } - p+=print_reg(p, s, 0x558a, 0x1FF);//9 bit - } else if(s->id.PID == OV2640_PID){ - p+=print_reg(p, s, 0xd3, 0xFF); - p+=print_reg(p, s, 0x111, 0xFF); - p+=print_reg(p, s, 0x132, 0xFF); - } - - p += sprintf(p, "\"xclk\":%u,", s->xclk_freq_hz / 1000000); - p += sprintf(p, "\"pixformat\":%u,", s->pixformat); - p += sprintf(p, "\"framesize\":%u,", s->status.framesize); - p += sprintf(p, "\"quality\":%u,", s->status.quality); - p += sprintf(p, "\"brightness\":%d,", s->status.brightness); - p += sprintf(p, "\"contrast\":%d,", s->status.contrast); - p += sprintf(p, "\"saturation\":%d,", s->status.saturation); - p += sprintf(p, "\"sharpness\":%d,", s->status.sharpness); - p += sprintf(p, "\"special_effect\":%u,", s->status.special_effect); - p += sprintf(p, "\"wb_mode\":%u,", s->status.wb_mode); - p += sprintf(p, "\"awb\":%u,", s->status.awb); - p += sprintf(p, "\"awb_gain\":%u,", s->status.awb_gain); - p += sprintf(p, "\"aec\":%u,", s->status.aec); - p += sprintf(p, "\"aec2\":%u,", s->status.aec2); - p += sprintf(p, "\"ae_level\":%d,", s->status.ae_level); - p += sprintf(p, "\"aec_value\":%u,", s->status.aec_value); - p += sprintf(p, "\"agc\":%u,", s->status.agc); - p += sprintf(p, "\"agc_gain\":%u,", s->status.agc_gain); - p += sprintf(p, "\"gainceiling\":%u,", s->status.gainceiling); - p += sprintf(p, "\"bpc\":%u,", s->status.bpc); - p += sprintf(p, "\"wpc\":%u,", s->status.wpc); - p += sprintf(p, "\"raw_gma\":%u,", s->status.raw_gma); - p += sprintf(p, "\"lenc\":%u,", s->status.lenc); - p += sprintf(p, "\"hmirror\":%u,", s->status.hmirror); - p += sprintf(p, "\"vflip\":%u,", s->status.vflip); - p += sprintf(p, "\"dcw\":%u,", s->status.dcw); - p += sprintf(p, "\"colorbar\":%u", s->status.colorbar); -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - p += sprintf(p, ",\"led_intensity\":%u", led_duty); -#else - p += sprintf(p, ",\"led_intensity\":%d", -1); -#endif -#if CONFIG_ESP_FACE_DETECT_ENABLED - p += sprintf(p, ",\"face_detect\":%u", detection_enabled); -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - p += sprintf(p, ",\"face_enroll\":%u,", is_enrolling); - p += sprintf(p, ",\"face_recognize\":%u", recognition_enabled); -#endif -#endif - p += sprintf(p, ",\"flash\":%u", flash_enabled); - *p++ = '}'; - *p++ = 0; - httpd_resp_set_type(req, "application/json"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, json_response, strlen(json_response)); -} - -static esp_err_t xclk_handler(httpd_req_t *req) -{ - char *buf = NULL; - char _xclk[32]; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - if (httpd_query_key_value(buf, "xclk", _xclk, sizeof(_xclk)) != ESP_OK) { - free(buf); - httpd_resp_send_404(req); - return ESP_FAIL; - } - free(buf); - - int xclk = atoi(_xclk); - ESP_LOGI(TAG, "Set XCLK: %d MHz", xclk); - - sensor_t *s = esp_camera_sensor_get(); - int res = s->set_xclk(s, LEDC_TIMER_0, xclk); - if (res) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static esp_err_t reg_handler(httpd_req_t *req) -{ - char *buf = NULL; - char _reg[32]; - char _mask[32]; - char _val[32]; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - if (httpd_query_key_value(buf, "reg", _reg, sizeof(_reg)) != ESP_OK || - httpd_query_key_value(buf, "mask", _mask, sizeof(_mask)) != ESP_OK || - httpd_query_key_value(buf, "val", _val, sizeof(_val)) != ESP_OK) { - free(buf); - httpd_resp_send_404(req); - return ESP_FAIL; - } - free(buf); - - int reg = atoi(_reg); - int mask = atoi(_mask); - int val = atoi(_val); - ESP_LOGI(TAG, "Set Register: reg: 0x%02x, mask: 0x%02x, value: 0x%02x", reg, mask, val); - - sensor_t *s = esp_camera_sensor_get(); - int res = s->set_reg(s, reg, mask, val); - if (res) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static esp_err_t greg_handler(httpd_req_t *req) -{ - char *buf = NULL; - char _reg[32]; - char _mask[32]; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - if (httpd_query_key_value(buf, "reg", _reg, sizeof(_reg)) != ESP_OK || - httpd_query_key_value(buf, "mask", _mask, sizeof(_mask)) != ESP_OK) { - free(buf); - httpd_resp_send_404(req); - return ESP_FAIL; - } - free(buf); - - int reg = atoi(_reg); - int mask = atoi(_mask); - sensor_t *s = esp_camera_sensor_get(); - int res = s->get_reg(s, reg, mask); - if (res < 0) { - return httpd_resp_send_500(req); - } - ESP_LOGI(TAG, "Get Register: reg: 0x%02x, mask: 0x%02x, value: 0x%02x", reg, mask, res); - - char buffer[20]; - const char * val = itoa(res, buffer, 10); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, val, strlen(val)); -} - -static int parse_get_var(char *buf, const char * key, int def) -{ - char _int[16]; - if(httpd_query_key_value(buf, key, _int, sizeof(_int)) != ESP_OK){ - return def; - } - return atoi(_int); -} - -static esp_err_t pll_handler(httpd_req_t *req) -{ - char *buf = NULL; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - - int bypass = parse_get_var(buf, "bypass", 0); - int mul = parse_get_var(buf, "mul", 0); - int sys = parse_get_var(buf, "sys", 0); - int root = parse_get_var(buf, "root", 0); - int pre = parse_get_var(buf, "pre", 0); - int seld5 = parse_get_var(buf, "seld5", 0); - int pclken = parse_get_var(buf, "pclken", 0); - int pclk = parse_get_var(buf, "pclk", 0); - free(buf); - - ESP_LOGI(TAG, "Set Pll: bypass: %d, mul: %d, sys: %d, root: %d, pre: %d, seld5: %d, pclken: %d, pclk: %d", bypass, mul, sys, root, pre, seld5, pclken, pclk); - sensor_t *s = esp_camera_sensor_get(); - int res = s->set_pll(s, bypass, mul, sys, root, pre, seld5, pclken, pclk); - if (res) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static esp_err_t win_handler(httpd_req_t *req) -{ - char *buf = NULL; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - - int startX = parse_get_var(buf, "sx", 0); - int startY = parse_get_var(buf, "sy", 0); - int endX = parse_get_var(buf, "ex", 0); - int endY = parse_get_var(buf, "ey", 0); - int offsetX = parse_get_var(buf, "offx", 0); - int offsetY = parse_get_var(buf, "offy", 0); - int totalX = parse_get_var(buf, "tx", 0); - int totalY = parse_get_var(buf, "ty", 0); - int outputX = parse_get_var(buf, "ox", 0); - int outputY = parse_get_var(buf, "oy", 0); - bool scale = parse_get_var(buf, "scale", 0) == 1; - bool binning = parse_get_var(buf, "binning", 0) == 1; - free(buf); - - ESP_LOGI(TAG, "Set Window: Start: %d %d, End: %d %d, Offset: %d %d, Total: %d %d, Output: %d %d, Scale: %u, Binning: %u", startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); - sensor_t *s = esp_camera_sensor_get(); - int res = s->set_res_raw(s, startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); - if (res) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static esp_err_t uptime_handler(httpd_req_t *req) -{ - char buf[64]; - sprintf(buf, "{\"seconds\":%ld}", (long)(millis()/1000)); - httpd_resp_set_type(req, "application/json"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, buf, strlen(buf)); -} - -static esp_err_t index_handler(httpd_req_t *req) -{ - httpd_resp_set_type(req, "text/html"); - httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); - sensor_t *s = esp_camera_sensor_get(); - if (s != NULL) { - if (s->id.PID == OV3660_PID) { - return httpd_resp_send(req, (const char *)index_ov3660_html_gz, index_ov3660_html_gz_len); - } else if (s->id.PID == OV5640_PID) { - return httpd_resp_send(req, (const char *)index_ov5640_html_gz, index_ov5640_html_gz_len); - } else { - return httpd_resp_send(req, (const char *)index_ov2640_html_gz, index_ov2640_html_gz_len); - } - } else { - ESP_LOGE(TAG, "Camera sensor not found"); - return httpd_resp_send_500(req); - } -} - -void startCameraServer() -{ - httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.max_uri_handlers = 16; - - httpd_uri_t index_uri = { - .uri = "/", - .method = HTTP_GET, - .handler = index_handler, - .user_ctx = NULL}; - - httpd_uri_t status_uri = { - .uri = "/status", - .method = HTTP_GET, - .handler = status_handler, - .user_ctx = NULL}; - - httpd_uri_t cmd_uri = { - .uri = "/control", - .method = HTTP_GET, - .handler = cmd_handler, - .user_ctx = NULL}; - - httpd_uri_t capture_uri = { - .uri = "/capture", - .method = HTTP_GET, - .handler = capture_handler, - .user_ctx = NULL}; - - httpd_uri_t stream_uri = { - .uri = "/stream", - .method = HTTP_GET, - .handler = stream_handler, - .user_ctx = NULL}; - - httpd_uri_t bmp_uri = { - .uri = "/bmp", - .method = HTTP_GET, - .handler = bmp_handler, - .user_ctx = NULL}; - - httpd_uri_t xclk_uri = { - .uri = "/xclk", - .method = HTTP_GET, - .handler = xclk_handler, - .user_ctx = NULL}; - - httpd_uri_t reg_uri = { - .uri = "/reg", - .method = HTTP_GET, - .handler = reg_handler, - .user_ctx = NULL}; - - httpd_uri_t greg_uri = { - .uri = "/greg", - .method = HTTP_GET, - .handler = greg_handler, - .user_ctx = NULL}; - - httpd_uri_t pll_uri = { - .uri = "/pll", - .method = HTTP_GET, - .handler = pll_handler, - .user_ctx = NULL}; - - httpd_uri_t win_uri = { - .uri = "/resolution", - .method = HTTP_GET, - .handler = win_handler, - .user_ctx = NULL}; - - httpd_uri_t uptime_uri = { - .uri = "/uptime", - .method = HTTP_GET, - .handler = uptime_handler, - .user_ctx = NULL}; - - ra_filter_init(&ra_filter, 20); - -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - recognizer.set_partition(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "fr"); - - // load ids from flash partition - recognizer.set_ids_from_flash(); -#endif - ESP_LOGI(TAG, "Starting web server on port: '%d'", config.server_port); - if (httpd_start(&camera_httpd, &config) == ESP_OK) - { - httpd_register_uri_handler(camera_httpd, &index_uri); - httpd_register_uri_handler(camera_httpd, &cmd_uri); - httpd_register_uri_handler(camera_httpd, &status_uri); - httpd_register_uri_handler(camera_httpd, &capture_uri); - httpd_register_uri_handler(camera_httpd, &bmp_uri); - - httpd_register_uri_handler(camera_httpd, &xclk_uri); - httpd_register_uri_handler(camera_httpd, ®_uri); - httpd_register_uri_handler(camera_httpd, &greg_uri); - httpd_register_uri_handler(camera_httpd, &pll_uri); - httpd_register_uri_handler(camera_httpd, &win_uri); - httpd_register_uri_handler(camera_httpd, &uptime_uri); - } - - config.server_port += 1; - config.ctrl_port += 1; - ESP_LOGI(TAG, "Starting stream server on port: '%d'", config.server_port); - if (httpd_start(&stream_httpd, &config) == ESP_OK) - { - httpd_register_uri_handler(stream_httpd, &stream_uri); - } -} diff --git a/esp32-cam/CameraWebServer/camera_index.h b/esp32-cam/CameraWebServer/camera_index.h deleted file mode 100644 index 5ca12e9..0000000 --- a/esp32-cam/CameraWebServer/camera_index.h +++ /dev/null @@ -1,1570 +0,0 @@ - - -//File: index_ov2640.html.gz, Size: 6787 -#define index_ov2640_html_gz_len 6787 -const uint8_t index_ov2640_html_gz[] = { -0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xED, 0x3D, 0x6B, 0x73, 0xDB, 0x46, -0x92, 0xDF, 0xFD, 0x2B, 0x60, 0x24, 0x6B, 0x92, 0x25, 0x92, 0x22, 0x29, 0x4A, 0x96, 0x15, 0x89, -0x3E, 0x5B, 0x96, 0x1F, 0xB5, 0x76, 0xE2, 0xB5, 0x12, 0xC7, 0x5B, 0xA9, 0x2D, 0x07, 0x24, 0x86, -0x24, 0x62, 0x10, 0xE0, 0x02, 0xA0, 0x48, 0x26, 0xA5, 0xDF, 0x71, 0x3F, 0xE8, 0xFE, 0xD8, 0x75, -0xCF, 0x03, 0x18, 0x00, 0x83, 0x17, 0x29, 0x91, 0x5E, 0xDF, 0xD1, 0xA9, 0x08, 0x8F, 0xE9, 0x9E, -0x7E, 0x77, 0xCF, 0x0C, 0x06, 0x38, 0x7F, 0x68, 0xBA, 0xA3, 0x60, 0x3D, 0x27, 0xDA, 0x34, 0x98, -0xD9, 0x83, 0x07, 0xE7, 0xEC, 0x8F, 0x06, 0xBF, 0xF3, 0x29, 0x31, 0x4C, 0x76, 0x48, 0x4F, 0x67, -0x24, 0x30, 0xB4, 0xD1, 0xD4, 0xF0, 0x7C, 0x12, 0x5C, 0xE8, 0x8B, 0x60, 0xDC, 0x3A, 0xD5, 0x93, -0xB7, 0x1D, 0x63, 0x46, 0x2E, 0xF4, 0x1B, 0x8B, 0x2C, 0xE7, 0xAE, 0x17, 0xE8, 0xDA, 0xC8, 0x75, -0x02, 0xE2, 0x40, 0xF3, 0xA5, 0x65, 0x06, 0xD3, 0x0B, 0x93, 0xDC, 0x58, 0x23, 0xD2, 0xA2, 0x27, -0x4D, 0xCB, 0xB1, 0x02, 0xCB, 0xB0, 0x5B, 0xFE, 0xC8, 0xB0, 0xC9, 0x45, 0x57, 0xC6, 0x15, 0x58, -0x81, 0x4D, 0x06, 0x57, 0xD7, 0xEF, 0x8F, 0x7A, 0xDA, 0x4F, 0x1F, 0x7B, 0xFD, 0x93, 0xCE, 0xF9, -0x21, 0xBB, 0x16, 0xB5, 0xF1, 0x83, 0xB5, 0x7C, 0x8E, 0xBF, 0xA1, 0x6B, 0xAE, 0xB5, 0xBF, 0x62, -0x97, 0xF0, 0x37, 0x06, 0x22, 0x5A, 0x63, 0x63, 0x66, 0xD9, 0xEB, 0x33, 0xED, 0x99, 0x07, 0x7D, -0x36, 0x5F, 0x13, 0xFB, 0x86, 0x04, 0xD6, 0xC8, 0x68, 0xFA, 0x86, 0xE3, 0xB7, 0x7C, 0xE2, 0x59, -0xE3, 0x1F, 0x52, 0x80, 0x43, 0x63, 0xF4, 0x65, 0xE2, 0xB9, 0x0B, 0xC7, 0x3C, 0xD3, 0xBE, 0xEB, -0x9E, 0xE2, 0xBF, 0x74, 0xA3, 0x91, 0x6B, 0xBB, 0x1E, 0xDC, 0xBF, 0x7A, 0x89, 0xFF, 0xD2, 0xF7, -0x69, 0xEF, 0xBE, 0xF5, 0x27, 0x39, 0xD3, 0xBA, 0x27, 0xF3, 0x55, 0xEC, 0xFE, 0xED, 0x83, 0xD8, -0xE9, 0xB4, 0x97, 0x45, 0x3D, 0x87, 0x3F, 0xCD, 0x87, 0xF7, 0xC9, 0x28, 0xB0, 0x5C, 0xA7, 0x3D, -0x33, 0x2C, 0x47, 0x81, 0xC9, 0xB4, 0xFC, 0xB9, 0x6D, 0x80, 0x0C, 0xC6, 0x36, 0xC9, 0xC5, 0xF3, -0xDD, 0x8C, 0x38, 0x8B, 0x66, 0x01, 0x36, 0x44, 0xD2, 0x32, 0x2D, 0x8F, 0xB5, 0x3A, 0x43, 0x39, -0x2C, 0x66, 0x4E, 0x21, 0xDA, 0x3C, 0xBA, 0x1C, 0xD7, 0x21, 0x0A, 0x01, 0x62, 0x47, 0x4B, 0xCF, -0x98, 0x63, 0x03, 0xFC, 0x9B, 0x6E, 0x32, 0xB3, 0x1C, 0x66, 0x54, 0x67, 0xDA, 0x51, 0xBF, 0x33, -0x5F, 0x15, 0xA8, 0xF2, 0xE8, 0x04, 0xFF, 0xA5, 0x1B, 0xCD, 0x0D, 0xD3, 0xB4, 0x9C, 0xC9, 0x99, -0x76, 0xAA, 0x44, 0xE1, 0x7A, 0x26, 0xF1, 0x5A, 0x9E, 0x61, 0x5A, 0x0B, 0xFF, 0x4C, 0xEB, 0xAB, -0xDA, 0xCC, 0x0C, 0x6F, 0x02, 0xB4, 0x04, 0x2E, 0x10, 0xDB, 0xEA, 0x2A, 0x29, 0xE1, 0x4D, 0x3C, -0x6B, 0x32, 0x0D, 0x40, 0xA5, 0xA9, 0x36, 0x49, 0xA1, 0x71, 0x17, 0x2A, 0xD2, 0x67, 0xAE, 0xDC, -0xD4, 0x52, 0x33, 0x6C, 0x6B, 0xE2, 0xB4, 0xAC, 0x80, 0xCC, 0x80, 0x1D, 0x3F, 0xF0, 0x48, 0x30, -0x9A, 0xE6, 0x91, 0x32, 0xB6, 0x26, 0x0B, 0x8F, 0x28, 0x08, 0x09, 0xE5, 0x96, 0xC3, 0x30, 0xDC, -0x4C, 0xDF, 0x6A, 0x2D, 0xC9, 0xF0, 0x8B, 0x15, 0xB4, 0xB8, 0x4C, 0x86, 0x64, 0xEC, 0x7A, 0x44, -0xD9, 0x52, 0xB4, 0xB0, 0xDD, 0xD1, 0x97, 0x96, 0x1F, 0x18, 0x5E, 0x50, 0x06, 0xA1, 0x31, 0x0E, -0x88, 0x57, 0x8C, 0x8F, 0xA0, 0x55, 0x14, 0x63, 0xCB, 0xEE, 0x96, 0x37, 0xB0, 0x1C, 0xDB, 0x72, -0x48, 0x79, 0xF2, 0xB2, 0xFA, 0x8D, 0xA3, 0x63, 0xAD, 0x4A, 0x28, 0xC6, 0x9A, 0x4D, 0xF2, 0xAC, -0x84, 0xF2, 0x9A, 0xEE, 0x8C, 0xFB, 0x4D, 0xB7, 0xD3, 0xF9, 0x5B, 0xFA, 0xE6, 0x94, 0x30, 0x33, -0x35, 0x16, 0x81, 0xBB, 0xBD, 0x47, 0xA4, 0xDC, 0x2A, 0xC1, 0xC7, 0x7F, 0xCD, 0x88, 0x69, 0x19, -0x5A, 0x5D, 0x72, 0xE7, 0xD3, 0x0E, 0xD8, 0x54, 0x43, 0x33, 0x1C, 0x53, 0xAB, 0xBB, 0x9E, 0x05, -0x8E, 0x60, 0xD0, 0x70, 0x63, 0xC3, 0x15, 0x48, 0x1C, 0x73, 0xD2, 0x50, 0xB0, 0x9C, 0xE3, 0x33, -0xB2, 0x44, 0xD4, 0x6E, 0x83, 0xBF, 0x12, 0x21, 0x07, 0x7F, 0x85, 0x0E, 0xA4, 0xE0, 0x91, 0xA2, -0xCF, 0xD3, 0x97, 0x4C, 0x61, 0x96, 0xCE, 0xF0, 0x37, 0x33, 0x56, 0xAD, 0x5C, 0xDD, 0x89, 0x46, -0x42, 0x87, 0x90, 0x66, 0x47, 0x75, 0x68, 0x7A, 0x33, 0xD5, 0x5A, 0x1A, 0x46, 0xC9, 0x86, 0x1A, -0x86, 0x23, 0x55, 0xAB, 0x1C, 0x7F, 0xB2, 0x51, 0x54, 0x60, 0x57, 0xCD, 0x6A, 0x14, 0x3B, 0xD8, -0x3F, 0x95, 0x0D, 0x31, 0x4E, 0x32, 0xA3, 0x08, 0xFE, 0xCA, 0x47, 0x92, 0x08, 0x59, 0x61, 0x34, -0x51, 0x20, 0xCE, 0x8E, 0x28, 0x29, 0xBC, 0x59, 0xDE, 0xAD, 0xC0, 0x9A, 0x4F, 0x42, 0xD9, 0xE8, -0xA2, 0x40, 0x9C, 0x47, 0x43, 0x61, 0x94, 0xC1, 0xDF, 0x6D, 0x89, 0x7A, 0xE3, 0xBB, 0xE1, 0x22, -0x08, 0x5C, 0xC7, 0xDF, 0x2A, 0x45, 0x65, 0xF9, 0xD9, 0x1F, 0x0B, 0x3F, 0xB0, 0xC6, 0xEB, 0x16, -0x77, 0x69, 0xF0, 0xB3, 0xB9, 0x01, 0x25, 0xE4, 0x90, 0x04, 0x4B, 0x42, 0xF2, 0xCB, 0x0D, 0xC7, -0xB8, 0x81, 0xB8, 0x33, 0x99, 0xD8, 0x2A, 0xDB, 0x1B, 0x2D, 0x3C, 0x1F, 0xEB, 0xB6, 0xB9, 0x6B, -0x01, 0x62, 0x2F, 0xDD, 0x71, 0xDC, 0x07, 0x4B, 0x76, 0xD4, 0x1A, 0x0D, 0x15, 0x7D, 0xB9, 0x8B, -0x00, 0x65, 0xAC, 0xD4, 0x84, 0x0B, 0xEC, 0x58, 0xC1, 0x5A, 0x79, 0x8F, 0x7B, 0xA2, 0xE2, 0x8E, -0x70, 0xC1, 0xDC, 0xB4, 0x10, 0xA7, 0xEB, 0x6C, 0x34, 0x25, 0xA3, 0x2F, 0xC4, 0x3C, 0x28, 0x2C, -0xC3, 0x8A, 0xCA, 0xC3, 0xB6, 0xE5, 0xCC, 0x17, 0x41, 0x0B, 0xCB, 0xA9, 0xF9, 0xBD, 0xE8, 0x9C, -0x1A, 0xA4, 0x60, 0xB1, 0xD7, 0xCB, 0x2B, 0x2A, 0x8E, 0xE7, 0xAB, 0x7C, 0x21, 0xC8, 0xC4, 0x0E, -0x6C, 0x63, 0x48, 0xEC, 0x3C, 0x92, 0xB9, 0x33, 0x64, 0x84, 0x5D, 0x1E, 0xAB, 0xB2, 0x6B, 0x37, -0x4A, 0x59, 0x94, 0xBC, 0xFA, 0x8F, 0xFF, 0x56, 0x5A, 0x8E, 0xF4, 0xB8, 0x19, 0xBB, 0xE4, 0x13, -0x1B, 0x1C, 0x2C, 0xAB, 0xF4, 0x86, 0x36, 0x4B, 0xA0, 0x21, 0xB7, 0x03, 0xCF, 0x70, 0x26, 0x04, -0x62, 0xC1, 0xAA, 0x29, 0x0E, 0xF3, 0x07, 0x06, 0xA5, 0xD8, 0xC7, 0x50, 0x7D, 0x9C, 0x3F, 0x10, -0x61, 0x01, 0xA1, 0xA9, 0xB5, 0xD9, 0xC1, 0x06, 0x55, 0x89, 0xA4, 0xDF, 0x5C, 0x42, 0xBA, 0x4A, -0xEB, 0x60, 0x85, 0x89, 0xD2, 0x73, 0xE2, 0xB6, 0xA5, 0x2C, 0xF4, 0x0B, 0x43, 0x83, 0x18, 0xF2, -0x8D, 0xC7, 0x45, 0x83, 0xC6, 0xF1, 0xF8, 0xA8, 0x73, 0xD4, 0x2F, 0xAC, 0x9C, 0x94, 0x5C, 0x26, -0x06, 0x8E, 0x8A, 0xD0, 0x11, 0x86, 0x95, 0x5C, 0x23, 0xF0, 0x8D, 0x1B, 0x65, 0xD1, 0xEE, 0xFA, -0x16, 0x1B, 0xB9, 0x19, 0x43, 0x1F, 0xC6, 0x6E, 0x81, 0x62, 0xE8, 0xC5, 0x0D, 0xBD, 0xA7, 0xA4, -0x8F, 0x96, 0x74, 0x4A, 0x17, 0x10, 0xE2, 0x55, 0x93, 0x1D, 0xD3, 0x80, 0xBA, 0x89, 0xA4, 0x60, -0x65, 0x51, 0x19, 0x90, 0x55, 0xD0, 0x32, 0xC9, 0xC8, 0xF5, 0x58, 0x35, 0x98, 0x31, 0x72, 0x4C, -0x28, 0xB2, 0xD8, 0x62, 0xCF, 0xA6, 0xEE, 0x0D, 0xF1, 0x14, 0xC2, 0x4A, 0x28, 0xB5, 0xFF, 0xA4, -0x6F, 0x96, 0xC0, 0x66, 0x40, 0x7A, 0x54, 0xCA, 0x3E, 0x8E, 0xAE, 0xD7, 0x1D, 0xF5, 0x72, 0xFD, -0x98, 0xA1, 0x6B, 0x83, 0xCF, 0x18, 0x43, 0x9B, 0x98, 0x39, 0xD9, 0xCC, 0x24, 0x63, 0x63, 0x61, -0x07, 0x05, 0x56, 0x69, 0x74, 0xF0, 0x5F, 0x5E, 0x8F, 0x34, 0x0C, 0xFD, 0x86, 0xF3, 0x42, 0x17, -0x34, 0x70, 0xFC, 0x4B, 0xD1, 0xA7, 0x28, 0x35, 0x8C, 0xF9, 0x9C, 0x18, 0xD0, 0x6A, 0x44, 0xB2, -0xF4, 0x50, 0x6A, 0x88, 0xA1, 0x8E, 0xF3, 0xA5, 0xC6, 0xED, 0x85, 0x0E, 0x1B, 0x16, 0x8F, 0x95, -0x78, 0x3E, 0x1B, 0xBB, 0xA3, 0x85, 0xAA, 0xAA, 0x29, 0xE7, 0x78, 0x69, 0x7C, 0x67, 0x42, 0x64, -0xBE, 0x6D, 0x51, 0xF7, 0x5F, 0x38, 0x0E, 0x6A, 0xB4, 0x15, 0x78, 0xC0, 0xA6, 0xA2, 0xA3, 0x72, -0x82, 0xDB, 0x28, 0x86, 0xC5, 0x04, 0x9B, 0x35, 0x77, 0x95, 0x08, 0x53, 0x8A, 0x70, 0x1A, 0x46, -0x5A, 0x0D, 0x62, 0x88, 0x65, 0x0A, 0x54, 0xDB, 0xC9, 0x25, 0x98, 0x2E, 0x66, 0xAA, 0x3A, 0x4A, -0x74, 0xD6, 0x85, 0xA4, 0xCF, 0xBA, 0xF3, 0x26, 0x43, 0xA3, 0xDE, 0x69, 0x76, 0x9A, 0x47, 0xF0, -0x3F, 0xC5, 0x78, 0x26, 0xDF, 0xB8, 0xB8, 0x78, 0x33, 0x2C, 0x2F, 0x11, 0xA2, 0x8B, 0xA7, 0x95, -0xB2, 0x82, 0x7D, 0xA1, 0x2E, 0xCA, 0x7B, 0x52, 0x7C, 0x7E, 0xA9, 0xDB, 0x2E, 0xC8, 0xC3, 0x19, -0x26, 0x5D, 0xDD, 0x10, 0x15, 0xD6, 0x52, 0x55, 0xC5, 0x33, 0xF7, 0xCF, 0x16, 0x2B, 0x42, 0xFE, -0xCF, 0x5B, 0xBB, 0x24, 0x8A, 0x6F, 0xDA, 0xD2, 0x2B, 0xCB, 0xC5, 0xDF, 0xB7, 0x6D, 0x74, 0xB2, -0xB5, 0xDE, 0xE2, 0x55, 0x1F, 0x50, 0xE8, 0xC0, 0x18, 0xD4, 0x83, 0xC1, 0x68, 0x66, 0x65, 0x28, -0xB5, 0xD9, 0x40, 0x06, 0x63, 0xCB, 0xB6, 0x5B, 0xB6, 0xBB, 0x2C, 0xAE, 0x44, 0xF2, 0x2D, 0x39, -0x65, 0xA7, 0xC5, 0x26, 0xBF, 0x29, 0xB5, 0x0B, 0x88, 0x5C, 0xFF, 0x11, 0xD4, 0x7E, 0xDB, 0x0E, -0x97, 0xEB, 0x1A, 0x9B, 0x25, 0x8A, 0x0D, 0xEC, 0x71, 0xBB, 0x8E, 0x4A, 0x99, 0x12, 0xAB, 0x04, -0xF3, 0x87, 0x3D, 0x4B, 0x2B, 0x18, 0x4D, 0x37, 0x18, 0x7A, 0x46, 0x03, 0x23, 0x8F, 0xD8, 0x06, -0x56, 0xF0, 0x1B, 0xCD, 0x50, 0x14, 0x0E, 0xDF, 0x64, 0xF0, 0x32, 0x9C, 0x50, 0xD1, 0x7D, 0x3D, -0xB3, 0x4B, 0x6D, 0x56, 0x3B, 0x64, 0xC7, 0x6A, 0xB5, 0x59, 0x17, 0x94, 0xFB, 0x71, 0xCF, 0x50, -0x37, 0xAA, 0x10, 0xD1, 0x45, 0xD0, 0x9E, 0x78, 0x64, 0x5D, 0x82, 0x99, 0x26, 0xFF, 0x7B, 0xC6, -0xE6, 0x8F, 0x37, 0x9F, 0x2A, 0xA1, 0x09, 0x80, 0x5B, 0x51, 0xBB, 0xEF, 0x97, 0xE8, 0x3A, 0xBB, -0xCB, 0x32, 0xF6, 0x18, 0xCE, 0x8E, 0xEA, 0x7A, 0x89, 0x70, 0x93, 0x93, 0x42, 0xD5, 0xA6, 0x2A, -0xB2, 0xAF, 0x7A, 0x3C, 0x4F, 0xC6, 0x41, 0xC6, 0xE2, 0x0F, 0xAD, 0x53, 0x8F, 0xF2, 0xA3, 0x5B, -0x4B, 0x9A, 0x4D, 0x29, 0x8C, 0x1C, 0xE1, 0x24, 0x66, 0xB6, 0xF5, 0x29, 0x31, 0x63, 0xF4, 0xAC, -0x8C, 0x3C, 0x5B, 0x25, 0xA2, 0x7C, 0xA6, 0x6A, 0x86, 0x36, 0x33, 0x9E, 0xF2, 0x41, 0x3D, 0xE4, -0x53, 0xBD, 0x77, 0xA2, 0x5C, 0x5B, 0xC9, 0x69, 0x9C, 0x47, 0x5A, 0xE6, 0x2C, 0x60, 0x3A, 0x65, -0x65, 0x0E, 0x90, 0xE5, 0x58, 0xA4, 0x54, 0x54, 0xBE, 0x57, 0xE6, 0x45, 0x98, 0xF4, 0x4C, 0x56, -0xAE, 0xB1, 0x5B, 0x33, 0x03, 0xCA, 0x5E, 0x34, 0x57, 0x03, 0x30, 0xAA, 0xF4, 0x57, 0xC6, 0xDC, -0xA5, 0x39, 0xD6, 0xEE, 0x49, 0xA7, 0xA0, 0xCB, 0x91, 0xED, 0xFA, 0x5B, 0x4E, 0x80, 0x65, 0xCF, -0x7F, 0x29, 0xEF, 0x94, 0x4A, 0xDD, 0xB9, 0x3E, 0x95, 0xEF, 0x8E, 0x09, 0x99, 0x77, 0x3B, 0xCA, -0x48, 0x9B, 0x3B, 0x4B, 0x49, 0x67, 0xD0, 0xE8, 0xFA, 0xE5, 0x99, 0x36, 0x22, 0xEA, 0x30, 0x1A, -0x9F, 0xA8, 0x2B, 0x33, 0x55, 0x9A, 0xAB, 0x87, 0xA9, 0x65, 0x9A, 0x24, 0x77, 0x2E, 0x18, 0xC7, -0xBC, 0x25, 0x8B, 0x07, 0xA4, 0x5F, 0x35, 0x29, 0x75, 0x2F, 0x4E, 0x91, 0xFB, 0x58, 0x43, 0xF7, -0xBE, 0x3D, 0x86, 0x27, 0x9A, 0xAC, 0x99, 0xF4, 0x78, 0x29, 0x92, 0x4B, 0xAA, 0xD2, 0xB9, 0xC3, -0xB9, 0x56, 0x14, 0x19, 0xC8, 0x01, 0x5B, 0xA5, 0xA3, 0x79, 0x82, 0x2A, 0xBA, 0x90, 0xD2, 0xE6, -0x6B, 0x4B, 0x7C, 0x19, 0xB0, 0x95, 0xB5, 0xBA, 0x72, 0x87, 0x4B, 0x6D, 0xD4, 0x02, 0x92, 0xFD, -0x66, 0x8A, 0x66, 0x4F, 0x95, 0x51, 0x0E, 0x91, 0x61, 0x8A, 0x11, 0x0F, 0x57, 0xC5, 0x5B, 0x6D, -0xEA, 0x1C, 0xE7, 0x87, 0xD2, 0xD3, 0x70, 0xE7, 0x87, 0xD1, 0x83, 0x7B, 0xE7, 0xF8, 0x48, 0x9C, -0xFC, 0xD0, 0x1C, 0xEF, 0x67, 0x64, 0x1B, 0xBE, 0x7F, 0xA1, 0xE3, 0xA3, 0x5D, 0x7A, 0xFC, 0x19, -0xBA, 0x73, 0xD3, 0xBA, 0xD1, 0x2C, 0xF3, 0x42, 0xB7, 0xDD, 0x89, 0x9B, 0xB8, 0x47, 0xEF, 0x33, -0x2D, 0x43, 0x1E, 0xBB, 0xD0, 0x63, 0xEB, 0x8B, 0x3A, 0x85, 0x8A, 0x2E, 0xE9, 0x83, 0x47, 0xDF, -0x3D, 0x79, 0xFC, 0xF8, 0xE4, 0x87, 0x47, 0xCE, 0xD0, 0x9F, 0xF3, 0xFF, 0xFF, 0xCC, 0x96, 0x63, -0x7F, 0xFA, 0xD8, 0x3B, 0xE9, 0xC3, 0x70, 0x8F, 0x04, 0x01, 0x98, 0x9E, 0x7F, 0x7E, 0x48, 0x91, -0x26, 0x08, 0x39, 0x04, 0x4A, 0x32, 0x68, 0xE3, 0xE5, 0x8E, 0x8A, 0x3C, 0xD1, 0xC4, 0x87, 0x0C, -0x3E, 0x34, 0x3C, 0x45, 0x13, 0xDA, 0x8C, 0x15, 0xD3, 0x34, 0x94, 0xE8, 0x54, 0x27, 0x43, 0x77, -0x95, 0xE4, 0x80, 0x32, 0xC5, 0x15, 0xC6, 0x5B, 0x11, 0x33, 0x0B, 0x21, 0x80, 0x51, 0x70, 0x5C, -0x5C, 0x85, 0x36, 0xCA, 0x46, 0x31, 0x15, 0x60, 0xE3, 0xD5, 0xC8, 0xFE, 0x22, 0x74, 0xAF, 0x0B, -0xA5, 0x38, 0x6E, 0xC0, 0x42, 0x65, 0x46, 0x57, 0x31, 0x56, 0x39, 0x8C, 0xB4, 0x6C, 0xC8, 0xB8, -0x00, 0xD1, 0xB6, 0x28, 0x76, 0x76, 0x2D, 0x1F, 0x13, 0xC5, 0x26, 0xE9, 0x55, 0x00, 0xEB, 0x83, -0x4F, 0x97, 0x6F, 0xFF, 0xAE, 0xBD, 0x7B, 0xFD, 0xA7, 0x52, 0x43, 0x45, 0x44, 0x61, 0x8C, 0x2E, -0xD1, 0x33, 0x05, 0x63, 0xFA, 0x10, 0x32, 0xD1, 0xB9, 0x66, 0x28, 0x06, 0xCC, 0xF6, 0x36, 0x71, -0x26, 0xC1, 0xF4, 0x42, 0xEF, 0xEA, 0xF8, 0x48, 0x8B, 0x38, 0xEB, 0xE9, 0x1A, 0xC6, 0x6F, 0x7A, -0x70, 0x63, 0xD8, 0x0B, 0x3C, 0xEA, 0x94, 0xE1, 0x35, 0x6D, 0x5A, 0xCA, 0x66, 0x3C, 0xB0, 0x84, -0x32, 0x96, 0x02, 0x71, 0x5C, 0xCA, 0xFA, 0xE0, 0x9A, 0x04, 0xE7, 0x87, 0xEC, 0x56, 0x81, 0xD6, -0xF2, 0xFB, 0x06, 0x4F, 0x66, 0xE6, 0x90, 0x67, 0x42, 0x79, 0x8A, 0x1F, 0x7B, 0xC6, 0x8C, 0xA0, -0x54, 0x4A, 0x69, 0x5E, 0xD6, 0x7A, 0x08, 0xA9, 0x0F, 0x3E, 0x10, 0x5A, 0x10, 0x01, 0x19, 0xA5, -0x14, 0x7F, 0xCE, 0x6B, 0xD4, 0x58, 0xFF, 0xA1, 0x3D, 0xF3, 0x35, 0xA9, 0x96, 0xC1, 0xCC, 0xBC, -0x84, 0xDC, 0x1F, 0xB6, 0x5A, 0x5A, 0xEF, 0xDD, 0x7B, 0xAD, 0xD5, 0x2A, 0xD1, 0xD8, 0x9D, 0x53, -0x77, 0xE2, 0xFA, 0xEF, 0x1E, 0xE9, 0x83, 0x5F, 0x3E, 0xBD, 0x7A, 0x56, 0x87, 0xBA, 0xB0, 0xB3, -0xEA, 0xF6, 0x3A, 0x9D, 0xC6, 0xF9, 0x21, 0x6B, 0x52, 0x1D, 0x57, 0x0F, 0xF4, 0x4A, 0x71, 0xF5, -0x4E, 0x01, 0x57, 0xA7, 0xD7, 0xDF, 0x02, 0x57, 0x57, 0x1F, 0xBC, 0x7E, 0xC1, 0x30, 0x3D, 0xEE, -0x6D, 0x43, 0x14, 0x18, 0x38, 0xA5, 0x09, 0xC8, 0x59, 0x3D, 0x3E, 0x39, 0xDD, 0x1C, 0xD3, 0x13, -0xE0, 0xEE, 0x23, 0x60, 0x3A, 0x05, 0x41, 0x9D, 0x6C, 0x23, 0xA7, 0x53, 0x7D, 0x80, 0x78, 0x20, -0xA2, 0xAF, 0xFA, 0xA7, 0x5B, 0xE0, 0x79, 0x0C, 0x22, 0x42, 0x44, 0x80, 0x64, 0x75, 0xB4, 0x8D, -0x8C, 0x4E, 0xF4, 0xC1, 0xE5, 0x9B, 0x97, 0xF5, 0x3E, 0x30, 0xD6, 0x7B, 0x72, 0xB2, 0x39, 0x9E, -0x63, 0x7D, 0xF0, 0x0F, 0x24, 0x08, 0x88, 0x59, 0xF5, 0xFA, 0x5B, 0x10, 0xD4, 0xD7, 0x07, 0x00, -0x8F, 0x38, 0x36, 0x46, 0x01, 0x76, 0xFD, 0x9A, 0x12, 0x83, 0x88, 0xBA, 0x8F, 0xB7, 0xE0, 0x0A, -0xAC, 0xFA, 0x1F, 0x28, 0x1E, 0x40, 0xB2, 0xEA, 0xF6, 0xB7, 0xB1, 0x69, 0x40, 0x44, 0x49, 0x02, -0x5F, 0x43, 0x57, 0xDB, 0x1C, 0x13, 0xD8, 0xF4, 0x93, 0x93, 0xD5, 0x93, 0x93, 0x72, 0x08, 0x30, -0x46, 0x62, 0xBC, 0xC9, 0x8B, 0xA2, 0xF9, 0x41, 0x36, 0x2F, 0x80, 0xFE, 0x7B, 0x01, 0xC3, 0xA2, -0x60, 0x5D, 0x39, 0x7C, 0x72, 0x38, 0x90, 0x09, 0x3B, 0x28, 0x17, 0x39, 0x25, 0x4A, 0xC2, 0x27, -0x74, 0xF4, 0x41, 0xBF, 0x44, 0x86, 0x8A, 0x95, 0x30, 0x14, 0x36, 0x46, 0x3F, 0x4D, 0x9B, 0x68, -0x79, 0x98, 0x30, 0xC1, 0x25, 0x8E, 0x74, 0x29, 0x82, 0x6C, 0x14, 0x9A, 0x15, 0xB4, 0x1A, 0x2B, -0x7D, 0x70, 0x72, 0x54, 0x98, 0xD2, 0x36, 0x57, 0xC6, 0x90, 0x0E, 0xC0, 0x1D, 0xE2, 0xFB, 0x95, -0xF5, 0x11, 0x81, 0xEA, 0x83, 0xE7, 0xE1, 0xF1, 0x36, 0x5A, 0x69, 0xF5, 0xB6, 0x50, 0x8B, 0x44, -0x0E, 0xD3, 0x4C, 0xAB, 0xC7, 0x55, 0x13, 0x15, 0x2F, 0x77, 0xAB, 0x98, 0x22, 0x6A, 0xB7, 0xD1, -0x0B, 0x16, 0xE0, 0x9E, 0xE1, 0x07, 0x95, 0xB5, 0x22, 0x00, 0x21, 0x42, 0xF3, 0xA3, 0xBD, 0x69, -0x24, 0x24, 0xE5, 0x1B, 0xD0, 0x87, 0x6F, 0x04, 0x0B, 0xF6, 0x2C, 0x54, 0x65, 0x8D, 0x44, 0xA0, -0x50, 0x0F, 0x84, 0xC7, 0x7B, 0xD3, 0x8A, 0x44, 0xCE, 0xB7, 0xA0, 0x97, 0x39, 0x19, 0x59, 0x86, -0xFD, 0x99, 0x8C, 0xC7, 0x90, 0xB0, 0xAA, 0xEB, 0x26, 0x06, 0x0E, 0xFA, 0x61, 0xE7, 0xDA, 0x15, -0x3D, 0xAF, 0x5C, 0x9B, 0x27, 0xD0, 0x6D, 0x5E, 0xA0, 0x27, 0xB3, 0x37, 0x9F, 0xA4, 0x26, 0x74, -0x48, 0xC4, 0x8E, 0xF4, 0xC1, 0x8F, 0x6E, 0x48, 0xE7, 0xE6, 0x05, 0xC6, 0x8F, 0x64, 0x42, 0xE7, -0x80, 0xB7, 0xA9, 0x76, 0x5E, 0x79, 0xC6, 0x9A, 0x6E, 0x32, 0xDC, 0xA6, 0xF8, 0xFA, 0x40, 0x4C, -0xED, 0x67, 0xCB, 0xD9, 0x9C, 0x99, 0x3E, 0x12, 0x42, 0x88, 0xB3, 0x1D, 0x16, 0x28, 0x49, 0x9F, -0xC3, 0xC1, 0x76, 0x48, 0x4E, 0x70, 0xBC, 0x3A, 0xB7, 0x8C, 0xAF, 0xA1, 0xDC, 0x32, 0x96, 0xC3, -0xCA, 0x6E, 0x01, 0x30, 0xFA, 0xE0, 0xD9, 0xAF, 0xCF, 0x2B, 0x07, 0x29, 0xB6, 0x92, 0x5A, 0xC6, -0xC2, 0xA3, 0xF9, 0x08, 0xEC, 0x2C, 0x35, 0x51, 0xA4, 0xF6, 0x9C, 0xB2, 0x93, 0x45, 0x0A, 0xBE, -0x04, 0x81, 0x74, 0xE1, 0x49, 0x97, 0xD8, 0x2C, 0xC7, 0xE3, 0xFD, 0x45, 0x30, 0x20, 0xE2, 0xF3, -0xC4, 0xB0, 0xAA, 0xE7, 0x15, 0x01, 0x48, 0x35, 0xA5, 0xBD, 0x82, 0xA3, 0x5D, 0xA9, 0x8B, 0x75, -0xBB, 0x37, 0x9D, 0x71, 0xAE, 0xF7, 0xAD, 0x38, 0x20, 0x64, 0xE6, 0x9A, 0xD5, 0xA7, 0x81, 0x38, -0x9C, 0x3E, 0x00, 0xAD, 0xBD, 0x83, 0x83, 0xCA, 0x59, 0x46, 0x20, 0xB8, 0xE7, 0xF4, 0xF2, 0x6C, -0x11, 0xB8, 0xDB, 0x64, 0x96, 0xEB, 0x85, 0xE3, 0xAC, 0xB7, 0x49, 0x2B, 0x97, 0xB6, 0xBB, 0x30, -0x37, 0xC7, 0x00, 0x39, 0xE5, 0xA7, 0xF1, 0xD8, 0x1A, 0x6D, 0x9E, 0x95, 0x20, 0xA3, 0xBC, 0x76, -0x67, 0x25, 0xE1, 0xEF, 0x39, 0x8A, 0x93, 0x51, 0xF5, 0x00, 0x41, 0x46, 0xA0, 0xC5, 0xAB, 0x4B, -0xED, 0xFA, 0xEA, 0xC7, 0xEB, 0x9F, 0x3E, 0xEC, 0x26, 0x3A, 0x40, 0x9F, 0x7B, 0x0A, 0x0C, 0xC8, -0xED, 0xBE, 0x63, 0x02, 0x10, 0xD1, 0xDB, 0x44, 0x4F, 0x3D, 0xA6, 0xA8, 0x17, 0xD7, 0xEF, 0x77, -0xA5, 0xA5, 0xDE, 0xFE, 0xD4, 0xD4, 0xFB, 0x1A, 0xF4, 0xF4, 0xD9, 0x26, 0x37, 0xC4, 0xDE, 0x40, -0x57, 0x0C, 0x10, 0xF5, 0xA5, 0xBD, 0xC5, 0xA3, 0xBD, 0x0D, 0xE4, 0x42, 0x52, 0xBE, 0x81, 0x61, -0x1C, 0x58, 0xC5, 0x67, 0x4A, 0xF4, 0x26, 0xCE, 0xC3, 0x20, 0xF5, 0xC1, 0xD5, 0x6A, 0xEE, 0xFA, -0x0B, 0xAF, 0x64, 0x42, 0x55, 0x6B, 0xA4, 0xB3, 0x95, 0x42, 0x04, 0x29, 0x4C, 0x23, 0x1D, 0xAE, -0x10, 0x5C, 0x24, 0x91, 0xD6, 0xCF, 0xFA, 0x77, 0xAA, 0x15, 0x44, 0x7E, 0x9F, 0x8A, 0x99, 0x6C, -0x90, 0x77, 0x26, 0x98, 0x77, 0x5E, 0x5D, 0xEE, 0x26, 0x94, 0x4D, 0xF6, 0x96, 0x70, 0x26, 0x7B, -0x4D, 0x38, 0x1A, 0x5F, 0xC3, 0x16, 0x52, 0xD8, 0x70, 0x10, 0xC1, 0x01, 0x61, 0xEC, 0xBC, 0xC9, -0x00, 0x42, 0xF2, 0x9C, 0xEE, 0x6A, 0x1B, 0xD7, 0x11, 0x64, 0xC4, 0x3D, 0xE7, 0x28, 0xF2, 0x9B, -0xE3, 0x3B, 0xF5, 0x9A, 0xA3, 0x42, 0x6A, 0xB7, 0x71, 0x1A, 0xE4, 0x64, 0x44, 0x2C, 0x1B, 0xB7, -0x32, 0x57, 0x55, 0x88, 0x04, 0xCB, 0x74, 0xA2, 0x5D, 0xB2, 0xB3, 0x6D, 0x74, 0xD3, 0xDB, 0x46, -0x37, 0x32, 0x45, 0x71, 0xF5, 0x9C, 0xDC, 0x53, 0xA6, 0xE9, 0xF6, 0x4E, 0xEF, 0x53, 0x3D, 0xC3, -0x79, 0xF5, 0x98, 0x06, 0x30, 0xFA, 0xE0, 0xF9, 0xFB, 0xDD, 0xC4, 0x34, 0xEC, 0xAC, 0x64, 0x4C, -0xDB, 0x2A, 0x82, 0x51, 0xA6, 0xF6, 0x5D, 0x8A, 0x2D, 0x37, 0xD0, 0xC6, 0x12, 0x09, 0xFF, 0x75, -0x47, 0xDA, 0x58, 0x96, 0xD7, 0xC6, 0x1D, 0x67, 0x98, 0xE5, 0xD7, 0xA0, 0x1F, 0xCF, 0x58, 0x7E, -0x9E, 0xCC, 0x8C, 0xCA, 0x3A, 0xE2, 0x70, 0xFA, 0xE0, 0x83, 0xB1, 0xD4, 0x5E, 0xBD, 0x7B, 0xB6, -0x13, 0x5D, 0x89, 0x4E, 0xF7, 0xA3, 0xAF, 0x90, 0xE5, 0x7D, 0xEB, 0xCC, 0x26, 0x4E, 0x75, 0xA7, -0x42, 0x20, 0x7D, 0xF0, 0x96, 0x38, 0xBE, 0x76, 0xE9, 0x7A, 0xFC, 0xB5, 0x73, 0x3B, 0xD1, 0x1A, -0xED, 0x79, 0x3F, 0x2A, 0x63, 0x4C, 0xEF, 0x5B, 0x5F, 0xD3, 0x99, 0xE5, 0x79, 0xAE, 0x57, 0x59, -0x65, 0x1C, 0x4E, 0x1F, 0xBC, 0x6E, 0xBD, 0xA3, 0x47, 0x3B, 0x51, 0x97, 0xE8, 0x75, 0x3F, 0x1A, -0x0B, 0x79, 0xDE, 0xB7, 0xD2, 0x6E, 0xC6, 0xB6, 0x35, 0xAF, 0xAC, 0x32, 0x0A, 0xA5, 0x0F, 0x3E, -0xB6, 0x5E, 0xC2, 0xDF, 0x9D, 0xA8, 0x8B, 0xF5, 0xB8, 0x1F, 0x65, 0x71, 0x6E, 0xF7, 0xAD, 0x2A, -0x73, 0xB4, 0xAC, 0xAC, 0x28, 0x80, 0xD1, 0x07, 0x2F, 0x2E, 0x7F, 0xD5, 0xEA, 0x2F, 0xDC, 0xA5, -0x83, 0x0F, 0x5C, 0x6A, 0x57, 0x3F, 0x36, 0x76, 0xA2, 0x31, 0xEC, 0x7A, 0x3F, 0xFA, 0xA2, 0x4C, -0xEF, 0x5B, 0x5B, 0x74, 0x5F, 0xCD, 0xD0, 0xA8, 0x1E, 0x0E, 0x05, 0x20, 0x3E, 0xFB, 0x02, 0x47, -0xDA, 0x73, 0x63, 0x37, 0x01, 0x31, 0xEC, 0x77, 0x17, 0x45, 0x7B, 0xC4, 0xE4, 0xBE, 0xF5, 0x64, -0x13, 0xB3, 0x84, 0x8A, 0xE2, 0x25, 0x86, 0xF9, 0x19, 0xB7, 0xA8, 0xE0, 0xD6, 0xCD, 0x35, 0xD4, -0x1A, 0x57, 0x2F, 0xB4, 0x37, 0xE2, 0xB4, 0x04, 0x37, 0x1B, 0xCF, 0xD9, 0x65, 0x0D, 0x6D, 0xE3, -0xF4, 0xC4, 0x07, 0xB7, 0xBD, 0xE3, 0xE3, 0xED, 0x86, 0xB7, 0x59, 0xD3, 0xA8, 0xC7, 0xC7, 0xF7, -0xA8, 0x93, 0xB1, 0x31, 0x22, 0x9F, 0x4D, 0x12, 0x6C, 0xF2, 0x30, 0x8C, 0x04, 0xAB, 0x0F, 0x5E, -0xC2, 0x89, 0xF6, 0x82, 0x9E, 0xEC, 0xAA, 0x0C, 0x94, 0xFB, 0xDF, 0x85, 0x27, 0xC5, 0xF8, 0xDD, -0xB7, 0x33, 0x51, 0x62, 0xA0, 0xE8, 0x76, 0x27, 0xCE, 0x46, 0x7B, 0x0B, 0x62, 0xE0, 0x5C, 0x7D, -0x1F, 0xD8, 0xF9, 0x6E, 0x15, 0x18, 0x11, 0xB1, 0x33, 0x1D, 0x4A, 0x7C, 0xEF, 0x5D, 0x8D, 0x70, -0x7D, 0x5A, 0x5D, 0x7B, 0x08, 0x05, 0x4A, 0xC3, 0x3F, 0xBB, 0xD1, 0x14, 0xED, 0x70, 0x27, 0x0A, -0x62, 0xAC, 0xDD, 0xA1, 0x5E, 0xB2, 0xEF, 0xCB, 0x3B, 0xBF, 0xF8, 0x2B, 0x53, 0x8B, 0x94, 0xC0, -0x77, 0x20, 0xD1, 0x79, 0x4E, 0x12, 0xB4, 0xFC, 0xC0, 0xB2, 0x6D, 0x7D, 0xF0, 0x8A, 0x04, 0xDA, -0x35, 0x1E, 0x96, 0xDC, 0x72, 0x24, 0x61, 0x11, 0xFB, 0x0D, 0x03, 0x8F, 0x18, 0x33, 0x7D, 0x70, -0x8D, 0x2F, 0x93, 0x05, 0x5C, 0x78, 0x56, 0x1D, 0x19, 0xB5, 0x6E, 0xE2, 0x78, 0x2E, 0x10, 0x15, -0x2A, 0x87, 0xBF, 0xA4, 0x4E, 0xD7, 0xC4, 0x91, 0x74, 0x6D, 0x70, 0x45, 0x1B, 0x6B, 0xE8, 0xFE, -0xC5, 0xDD, 0x95, 0xDE, 0x0B, 0x45, 0x37, 0x3F, 0xE2, 0x6E, 0xC6, 0xF8, 0xAB, 0xA6, 0x41, 0xAD, -0x6C, 0x6B, 0xF3, 0xE0, 0xDC, 0x9F, 0x1B, 0x8E, 0x68, 0x46, 0xF7, 0xFD, 0x2E, 0xF9, 0x46, 0xCE, -0xA1, 0x6B, 0x9B, 0xD0, 0xF0, 0x99, 0x79, 0x83, 0xAF, 0xDD, 0x32, 0xB5, 0xEB, 0x70, 0x4B, 0x22, -0x82, 0x80, 0x5D, 0x08, 0x0C, 0x05, 0xBA, 0x9F, 0x7A, 0x02, 0x3D, 0xDB, 0x3C, 0x8A, 0x2F, 0x2A, -0xCA, 0x51, 0x6E, 0xC6, 0x2E, 0x4A, 0x8F, 0x4C, 0x42, 0x41, 0xAA, 0x36, 0xD7, 0x2A, 0xF7, 0x54, -0x7E, 0x20, 0x13, 0xCB, 0x07, 0x1A, 0x35, 0x30, 0x8B, 0x43, 0xBA, 0x0F, 0x8D, 0xD9, 0x72, 0xB9, -0x3D, 0x8E, 0x72, 0x97, 0x7C, 0x87, 0xB6, 0x72, 0xE7, 0x6A, 0xA5, 0x52, 0x3E, 0xB9, 0xCF, 0x34, -0x8E, 0xB1, 0xC8, 0xE8, 0x1F, 0xB6, 0x5A, 0xD3, 0x3E, 0xEE, 0xA8, 0xD3, 0x04, 0x6B, 0xE7, 0x87, -0xD3, 0x7E, 0xD1, 0x66, 0xB0, 0xC2, 0xED, 0x90, 0xC0, 0xE9, 0xC6, 0xBB, 0x21, 0x51, 0x4A, 0x03, -0xA0, 0xA6, 0xA9, 0xBD, 0x33, 0xFC, 0x2F, 0x4D, 0xED, 0x23, 0x16, 0x5E, 0x3B, 0xDC, 0x14, 0x89, -0xB4, 0x1B, 0xA6, 0xE9, 0x65, 0x6E, 0x8C, 0xEC, 0xC7, 0x36, 0x46, 0x9E, 0x88, 0x8D, 0x91, 0xD2, -0x12, 0xC8, 0xAA, 0xDB, 0xED, 0x96, 0xE1, 0xBC, 0xE4, 0xDE, 0xC8, 0x3B, 0x61, 0x69, 0x06, 0xC2, -0x2C, 0xC9, 0x52, 0x5F, 0xB0, 0xD4, 0x97, 0x58, 0x3A, 0xBD, 0xCB, 0xDD, 0x9E, 0x77, 0xC2, 0x11, -0x5F, 0x60, 0xFF, 0x4A, 0x58, 0x2A, 0xB5, 0x81, 0x95, 0xDA, 0xF6, 0x5D, 0xED, 0x5F, 0xA5, 0x4D, -0x92, 0xC1, 0xF0, 0x38, 0x37, 0x16, 0x52, 0x10, 0xE6, 0xF3, 0xAF, 0xEE, 0xD2, 0xE7, 0x27, 0x5B, -0xF8, 0xFC, 0x24, 0xE5, 0xF3, 0x3B, 0x74, 0x76, 0x41, 0xF8, 0x37, 0xE6, 0xF0, 0x82, 0xAD, 0x0A, -0x4E, 0xAF, 0x64, 0x6B, 0xB7, 0x1E, 0x12, 0x5A, 0xC2, 0xAB, 0xBB, 0xF4, 0x90, 0x0C, 0xBB, 0xDD, -0xC8, 0x48, 0x79, 0xCC, 0x19, 0xEC, 0x26, 0x27, 0xD1, 0x4A, 0x4A, 0x56, 0x27, 0xEF, 0x1D, 0x77, -0x80, 0x1E, 0xF5, 0x79, 0xD9, 0x74, 0x17, 0xEA, 0x29, 0xBF, 0x51, 0x3E, 0xB3, 0xC9, 0xDD, 0x14, -0x65, 0xF8, 0xAA, 0x8A, 0xB9, 0x54, 0xE1, 0x96, 0x2E, 0xCC, 0x2E, 0xDF, 0xFE, 0xBD, 0x5A, 0x2D, -0x96, 0xEC, 0x69, 0x77, 0xF5, 0xD8, 0x66, 0xD6, 0x2A, 0x0B, 0x8C, 0xD3, 0x0E, 0x11, 0x07, 0x5F, -0x19, 0xD1, 0xFB, 0x14, 0x72, 0xAE, 0x18, 0x0A, 0x4A, 0x41, 0x21, 0x04, 0xCB, 0x1A, 0xEC, 0xD1, -0x40, 0xC8, 0x27, 0x93, 0xE1, 0x58, 0xC4, 0x35, 0xCD, 0x1D, 0x8F, 0xE9, 0x77, 0xC8, 0x1E, 0x63, -0xC0, 0xF0, 0xBF, 0xE0, 0xF5, 0x4E, 0x37, 0x24, 0x49, 0x35, 0xD4, 0x8B, 0x28, 0x0C, 0x69, 0xA3, -0x26, 0x56, 0x34, 0x6E, 0xAB, 0x2A, 0x82, 0x23, 0x26, 0x82, 0x17, 0x6F, 0x3E, 0xAA, 0x64, 0xC0, -0x7C, 0xAD, 0x93, 0x16, 0xC1, 0xD1, 0xE6, 0x2F, 0xBD, 0xE8, 0x96, 0x96, 0x56, 0x27, 0x92, 0xD6, -0xD1, 0x38, 0xDA, 0xBB, 0xBB, 0x4D, 0xC8, 0x52, 0x48, 0xE0, 0x98, 0x3D, 0x9D, 0xAF, 0xBD, 0x97, -0x3D, 0xA0, 0x94, 0x1D, 0x1C, 0x57, 0xB1, 0x03, 0xF3, 0x68, 0x0B, 0x33, 0x38, 0xCE, 0x30, 0x83, -0xBB, 0x92, 0x41, 0x5F, 0x1F, 0xBC, 0xDF, 0xC4, 0x0C, 0xFA, 0x25, 0xCD, 0xE0, 0x48, 0x98, 0x41, -0xB4, 0xB1, 0xBB, 0x5F, 0x56, 0x58, 0x92, 0x15, 0x3C, 0x1E, 0xE3, 0xF3, 0x4C, 0x8F, 0xCB, 0x79, -0xC2, 0xEE, 0x62, 0xEE, 0xD2, 0x72, 0xAA, 0xC7, 0xDB, 0x5F, 0x2D, 0xC7, 0x74, 0x97, 0xD5, 0x42, -0xAE, 0xDC, 0xD1, 0xD7, 0x1E, 0x6E, 0xAB, 0x8D, 0x5A, 0x71, 0x66, 0xA7, 0xB5, 0xC2, 0xCA, 0xDE, -0xF1, 0x5D, 0x4F, 0x4B, 0xBF, 0xD2, 0x25, 0xB6, 0x33, 0x54, 0xB4, 0x2E, 0x57, 0x04, 0xA4, 0xF7, -0xC2, 0xBC, 0x79, 0xA9, 0x6D, 0xF0, 0xBE, 0x0D, 0x05, 0xB2, 0x2E, 0x7B, 0x2B, 0x89, 0xB6, 0xC1, -0x6B, 0x49, 0x14, 0xD8, 0x32, 0xF6, 0x0E, 0xE1, 0x1B, 0x62, 0xB4, 0xCD, 0x5E, 0x11, 0x53, 0xB8, -0x8D, 0x86, 0xB5, 0xDA, 0x3C, 0xA5, 0x84, 0xE3, 0x32, 0xE6, 0xAD, 0x50, 0x63, 0x95, 0x7B, 0xF7, -0x01, 0xC5, 0x29, 0x19, 0x00, 0x07, 0x5F, 0xD1, 0x6D, 0x46, 0x3E, 0x29, 0xB7, 0x5B, 0x38, 0x49, -0x58, 0x85, 0xCA, 0xF0, 0xD3, 0x99, 0x14, 0xCC, 0xC2, 0xCE, 0x2B, 0x06, 0xB3, 0xA8, 0xCE, 0x07, -0x63, 0xDA, 0xFB, 0xE0, 0xE5, 0x9F, 0x0A, 0x96, 0xD6, 0x9B, 0xB3, 0x74, 0x74, 0x57, 0x2C, 0x6D, -0x91, 0xAA, 0x42, 0xEB, 0x0A, 0xDC, 0xC0, 0xB0, 0x37, 0x36, 0x2E, 0x06, 0x0D, 0xB6, 0xC5, 0x62, -0xAE, 0x76, 0x0D, 0xAC, 0xEE, 0xD4, 0xC0, 0x04, 0x01, 0xE5, 0x94, 0xD1, 0x4F, 0x2B, 0xE3, 0xF4, -0x6B, 0xB3, 0x2F, 0xC6, 0x51, 0x59, 0xF3, 0x52, 0x70, 0x74, 0xF2, 0x35, 0x99, 0x97, 0xBB, 0x08, -0xF0, 0xEA, 0xC6, 0xC1, 0x8B, 0x81, 0x63, 0xF0, 0xA2, 0x47, 0xBB, 0x37, 0xB0, 0x90, 0x82, 0x8D, -0xF5, 0x71, 0x74, 0xA7, 0xEF, 0xA2, 0xBB, 0x8B, 0x08, 0xC6, 0x58, 0xDA, 0xC2, 0xC4, 0x7A, 0xFD, -0x1D, 0x9A, 0x98, 0xB4, 0xD0, 0xC4, 0xF3, 0x20, 0x2F, 0x60, 0x74, 0xBE, 0x36, 0x10, 0x15, 0x34, -0x55, 0x56, 0x92, 0xD4, 0x59, 0xF9, 0xFC, 0x10, 0x8A, 0xC2, 0x34, 0x82, 0x0C, 0x3A, 0xCF, 0xD9, -0x27, 0x2D, 0xD5, 0x1D, 0x46, 0xAF, 0xA0, 0xA4, 0xCB, 0x6A, 0xD1, 0xCB, 0x8E, 0xC3, 0x42, 0x33, -0xF9, 0x12, 0xE4, 0xC2, 0xD7, 0x3D, 0x9E, 0x1B, 0xFC, 0xDD, 0x28, 0x37, 0x84, 0xAF, 0x01, 0x6A, -0x53, 0x8F, 0x8C, 0x2F, 0xF4, 0xEF, 0x42, 0x9C, 0x5C, 0x5A, 0xD8, 0x44, 0xD7, 0x20, 0x24, 0x3B, -0xB6, 0x6B, 0x60, 0xB1, 0x6A, 0xCC, 0x03, 0xA0, 0xB4, 0xFD, 0xC7, 0x1C, 0x27, 0x79, 0x0D, 0x7C, -0x91, 0x86, 0x51, 0x6E, 0xED, 0x98, 0xBE, 0x36, 0x99, 0x3F, 0xF1, 0x84, 0x87, 0xE1, 0x9A, 0xE1, -0xFF, 0xFC, 0x77, 0xD1, 0xD4, 0x0C, 0x7E, 0xD8, 0x34, 0x12, 0x00, 0x98, 0x91, 0x37, 0xBA, 0xD0, -0x81, 0x52, 0xCF, 0xF5, 0xA1, 0x14, 0xB5, 0x26, 0x56, 0x86, 0xAA, 0xB2, 0xA4, 0x7D, 0xA8, 0x12, -0x77, 0xA2, 0xB1, 0x62, 0x6C, 0x72, 0xEE, 0x8F, 0x3C, 0x6B, 0x0E, 0xA5, 0x9A, 0xE9, 0x8E, 0x16, -0x33, 0xE2, 0x04, 0x6D, 0xC3, 0x34, 0xAF, 0x6E, 0xE0, 0xE0, 0x2D, 0xCE, 0x30, 0x83, 0xE4, 0xEB, -0xB5, 0x17, 0x3F, 0xBD, 0xBB, 0x64, 0x2F, 0x13, 0x7D, 0x0B, 0xF2, 0x22, 0x66, 0xAD, 0xA9, 0x8D, -0x17, 0x0E, 0xAB, 0xDE, 0xEB, 0x04, 0xDB, 0xB2, 0x0F, 0xCC, 0xDE, 0x18, 0x9E, 0x36, 0x34, 0x7C, -0xF2, 0xDA, 0xF5, 0x03, 0xED, 0x42, 0x0B, 0x31, 0xDA, 0xEE, 0x88, 0xBE, 0xAC, 0xA6, 0xCD, 0xF8, -0xE2, 0x2D, 0x19, 0xE3, 0xBF, 0x78, 0x36, 0x34, 0x0D, 0xA1, 0x0E, 0xB4, 0xDA, 0xD9, 0x69, 0xB7, -0x86, 0xF6, 0x17, 0x76, 0x31, 0xC6, 0x4F, 0xC6, 0x42, 0xBB, 0xFA, 0xC2, 0xB3, 0x9B, 0xDA, 0x68, -0xD8, 0x60, 0x2F, 0x80, 0xA5, 0x97, 0xF1, 0x9A, 0x78, 0x33, 0x78, 0x3B, 0x98, 0x12, 0xA7, 0x1E, -0x51, 0x06, 0xCE, 0x30, 0x77, 0x1D, 0x3F, 0xF6, 0xF5, 0x5B, 0x6B, 0x1C, 0x5D, 0x6F, 0x43, 0x41, -0x1F, 0x2C, 0x7C, 0xED, 0xE1, 0xC5, 0x85, 0x86, 0x05, 0x6E, 0xEC, 0xC5, 0xB2, 0xA3, 0x61, 0xB2, -0x5D, 0x53, 0x4B, 0x5C, 0xF8, 0x19, 0x42, 0x83, 0xF4, 0x0A, 0xF3, 0x5B, 0x8D, 0xD8, 0x89, 0x77, -0x68, 0x87, 0x00, 0x18, 0x45, 0xEA, 0x8D, 0x38, 0x81, 0x75, 0xD3, 0x08, 0x8C, 0x46, 0xFC, 0x65, -0xB6, 0xD0, 0x2B, 0x50, 0xD2, 0xD4, 0xE8, 0x2D, 0xF9, 0xCD, 0xBA, 0xB7, 0x8D, 0x36, 0xC8, 0x10, -0xF8, 0x0D, 0xA1, 0x89, 0xE7, 0x25, 0x3F, 0xEB, 0x0B, 0xD0, 0xAD, 0x6E, 0x53, 0xC3, 0x3B, 0x71, -0x58, 0x89, 0xC8, 0x07, 0xE2, 0x9A, 0x10, 0x5A, 0x3E, 0x5A, 0x05, 0x4A, 0x86, 0xEE, 0x36, 0xA6, -0x22, 0x88, 0x3D, 0x1F, 0xC8, 0x04, 0x24, 0x36, 0x69, 0xF2, 0x01, 0x74, 0x93, 0x8E, 0x9E, 0x9B, -0x2C, 0x28, 0x4A, 0x5A, 0x3B, 0x3C, 0x04, 0x97, 0x86, 0xA0, 0x44, 0xC0, 0x2A, 0x26, 0xF5, 0x1A, -0x5F, 0xC0, 0x04, 0x8B, 0xAA, 0x75, 0x56, 0xB5, 0x03, 0x40, 0xD0, 0x0E, 0xDC, 0xEB, 0xC0, 0xB3, -0x9C, 0x09, 0x0C, 0x3D, 0x1A, 0x11, 0x36, 0x7A, 0x1B, 0x51, 0x26, 0xEE, 0xD3, 0xEB, 0xB4, 0x93, -0xE4, 0x8D, 0x3A, 0xBF, 0x7E, 0x50, 0x6B, 0xD4, 0x38, 0xF1, 0xF4, 0x1C, 0xCC, 0xAD, 0xCE, 0x0E, -0x1E, 0x51, 0x1A, 0x1B, 0xDA, 0xF9, 0x39, 0xEF, 0x86, 0xB5, 0xC2, 0x8B, 0xD0, 0x88, 0xFE, 0x49, -0xDC, 0x0A, 0x4D, 0xF1, 0xF7, 0xEF, 0xFF, 0x12, 0x36, 0x7B, 0x7B, 0x08, 0x54, 0x3F, 0xC5, 0x19, -0x84, 0xEF, 0xFF, 0x82, 0xFF, 0xDF, 0x3E, 0xA2, 0xD3, 0x06, 0xDF, 0xFF, 0x85, 0x7F, 0x6E, 0x1F, -0x41, 0x4F, 0x70, 0x4C, 0xFB, 0xBB, 0xFD, 0x9D, 0xCA, 0x21, 0x2D, 0xBD, 0x49, 0xA6, 0xF4, 0x42, -0xB1, 0x55, 0xA6, 0x69, 0x92, 0x43, 0xD4, 0xEF, 0x91, 0xFF, 0xD6, 0x47, 0xAE, 0x09, 0xEA, 0x09, -0xC0, 0x92, 0x85, 0xD2, 0x6D, 0x50, 0x89, 0x10, 0x54, 0xF8, 0x76, 0x67, 0x6B, 0x4C, 0x5B, 0x6A, -0xDC, 0x55, 0x22, 0x03, 0x11, 0x2D, 0xE7, 0x86, 0xE7, 0x93, 0x37, 0x4E, 0x50, 0x0F, 0x62, 0x4E, -0x91, 0x21, 0xF1, 0xC1, 0x20, 0xC6, 0x02, 0xFE, 0x00, 0x0E, 0xDA, 0xD5, 0xB8, 0xD2, 0x42, 0x63, -0x7B, 0x10, 0xDA, 0x61, 0x44, 0x29, 0xBB, 0x99, 0x61, 0x87, 0x9F, 0x46, 0xF6, 0x97, 0x3A, 0xBE, -0x6F, 0x36, 0x19, 0x2A, 0x52, 0x22, 0xC2, 0x46, 0x4F, 0xF1, 0x7F, 0x20, 0x17, 0xFC, 0x93, 0xA9, -0x1F, 0xC0, 0xCA, 0x4A, 0xF8, 0x3A, 0x9D, 0x02, 0xF8, 0xBC, 0x6A, 0x6A, 0xEC, 0x60, 0x0D, 0x9E, -0xE1, 0x98, 0x78, 0x8E, 0x7F, 0xD6, 0x42, 0x7B, 0x78, 0x81, 0x1F, 0xC1, 0x35, 0x5A, 0xB3, 0xE2, -0x25, 0x76, 0x80, 0xAD, 0x68, 0x8D, 0x41, 0x5B, 0xB1, 0x23, 0xB8, 0x86, 0xEF, 0x5E, 0x02, 0xDB, -0x6D, 0x6A, 0x43, 0xCB, 0x71, 0xE8, 0x41, 0x01, 0xF5, 0x51, 0xAA, 0x7F, 0xEA, 0xAF, 0x80, 0x03, -0x4E, 0xDA, 0xED, 0x23, 0x7F, 0x1D, 0x9E, 0xAD, 0x6F, 0x1F, 0x11, 0xBC, 0x47, 0x89, 0x84, 0xE3, -0x35, 0x3F, 0x86, 0xEB, 0x40, 0x1F, 0xDE, 0x11, 0x04, 0xD3, 0x0B, 0xEB, 0xE8, 0x02, 0xB4, 0x08, -0xF0, 0x3E, 0x27, 0x1E, 0xCE, 0xD6, 0xE1, 0x19, 0x42, 0x53, 0x58, 0xCE, 0x06, 0x9C, 0xAE, 0xA3, -0x53, 0xB8, 0x4B, 0xDF, 0x23, 0x85, 0x44, 0x30, 0x9E, 0x6E, 0x1F, 0x71, 0x9E, 0xE0, 0x12, 0x3F, -0x4A, 0x8A, 0x1A, 0x63, 0x42, 0xC0, 0xA3, 0xC8, 0x73, 0x96, 0xA4, 0xA5, 0xFC, 0x01, 0xFE, 0x71, -0x65, 0x13, 0x3C, 0x7C, 0xBE, 0x7E, 0x63, 0xD6, 0x6B, 0x7C, 0x41, 0xB6, 0x86, 0x31, 0x4C, 0x86, -0x69, 0xBB, 0xCE, 0xC8, 0xB6, 0x46, 0xE8, 0x28, 0xF5, 0x86, 0x76, 0x31, 0xE0, 0x71, 0x0C, 0x0D, -0x1A, 0x9A, 0xCB, 0x46, 0x9A, 0x89, 0x5A, 0x2C, 0x29, 0xD6, 0x1A, 0x6D, 0x6A, 0x87, 0xDC, 0xD6, -0x10, 0x05, 0x77, 0xC1, 0x72, 0x38, 0xB0, 0xB1, 0x02, 0x47, 0xCA, 0x5B, 0x72, 0x91, 0xD0, 0xD6, -0x12, 0x16, 0x8A, 0x46, 0x0E, 0xB5, 0x9D, 0x44, 0x94, 0xCD, 0xF1, 0x6A, 0xE1, 0xC0, 0x0F, 0x93, -0x0E, 0x0C, 0xAA, 0xF2, 0x82, 0x7A, 0xED, 0x0A, 0x37, 0x64, 0xFC, 0x56, 0x3B, 0xC0, 0x46, 0x07, -0xB5, 0x7F, 0x9D, 0x69, 0xB5, 0x03, 0xD9, 0x93, 0x6F, 0x93, 0x2E, 0xC7, 0x34, 0x36, 0x29, 0xA9, -0xB1, 0x89, 0xA4, 0xB1, 0xC9, 0xDD, 0x6A, 0x4C, 0x5E, 0x08, 0xDE, 0x46, 0x6B, 0xF2, 0xCA, 0x6B, -0x8E, 0xE6, 0x0A, 0xE1, 0xB9, 0xD2, 0xB8, 0xB6, 0x26, 0x2A, 0x6D, 0x6D, 0xA2, 0x26, 0x96, 0xE2, -0xC0, 0x7B, 0x88, 0xF7, 0xFA, 0xE7, 0x77, 0x6F, 0x31, 0x54, 0xAA, 0x55, 0x16, 0x6A, 0x2C, 0x59, -0x8E, 0x28, 0x30, 0x60, 0xEE, 0x8C, 0x05, 0xEE, 0x58, 0x0E, 0x3D, 0xA8, 0x69, 0x75, 0x8A, 0x12, -0x33, 0x68, 0x81, 0x21, 0xF0, 0xC0, 0x5B, 0xCE, 0x77, 0x31, 0xD8, 0x0A, 0xE7, 0x8D, 0xA0, 0x72, -0x6C, 0x01, 0x01, 0x4A, 0x29, 0x91, 0x61, 0x4E, 0x39, 0x8C, 0x94, 0x13, 0x76, 0xEE, 0x22, 0xD4, -0x5F, 0xFD, 0xB2, 0x41, 0x4D, 0xC4, 0xF4, 0x28, 0xB6, 0xF9, 0x85, 0xD2, 0xE1, 0x91, 0xBF, 0x94, -0x80, 0xF8, 0x1C, 0xB6, 0xC2, 0xC0, 0x45, 0x26, 0x28, 0x85, 0x46, 0x4C, 0x5D, 0x66, 0xE3, 0x59, -0x57, 0xC1, 0xB3, 0x56, 0xE0, 0xE1, 0x99, 0xA7, 0x14, 0x1A, 0x3E, 0xD1, 0x95, 0x89, 0xA5, 0x1C, -0x31, 0x7C, 0x72, 0x49, 0xC5, 0x13, 0xCF, 0x74, 0xE5, 0x78, 0xE2, 0x93, 0x22, 0xD9, 0x78, 0x4A, -0xCA, 0x86, 0xCF, 0x44, 0x28, 0xEC, 0x39, 0x59, 0x8D, 0x74, 0xC4, 0x7F, 0x9B, 0xD7, 0x1F, 0x63, -0x03, 0xA2, 0x45, 0xF4, 0x67, 0x2F, 0x5E, 0x32, 0xF9, 0x28, 0xAA, 0x45, 0x62, 0x2B, 0xB3, 0x01, -0xB1, 0xDB, 0x46, 0x00, 0xF1, 0x09, 0xC6, 0xF1, 0xC4, 0x6F, 0x63, 0x85, 0x1B, 0x8A, 0x31, 0x75, -0xAB, 0xED, 0x00, 0x01, 0x14, 0x61, 0xE3, 0xAC, 0x93, 0x34, 0xCF, 0x14, 0x2E, 0x76, 0x39, 0x0B, -0x1D, 0xBB, 0x9B, 0x81, 0x91, 0x67, 0x98, 0x38, 0x04, 0x5E, 0xCC, 0xC2, 0x46, 0x47, 0x31, 0x12, -0xAE, 0xDE, 0xF1, 0x71, 0x3A, 0xCF, 0xF0, 0x0E, 0xF8, 0x67, 0xED, 0x10, 0x03, 0xCE, 0x47, 0x45, -0x43, 0xB3, 0x11, 0x14, 0x82, 0x5A, 0x4D, 0x2C, 0xD9, 0xD5, 0xCE, 0x52, 0x15, 0x37, 0x40, 0xF0, -0x45, 0x38, 0xED, 0x29, 0xA3, 0x31, 0xF6, 0xB1, 0x96, 0x21, 0x0C, 0xBE, 0xC3, 0x6F, 0xB2, 0x31, -0x64, 0x74, 0x83, 0x4B, 0x88, 0x89, 0x5D, 0xC3, 0x81, 0x6B, 0xE2, 0x12, 0x5B, 0xDB, 0x69, 0xB9, -0x0E, 0x51, 0xF7, 0x1A, 0xAB, 0xDF, 0x79, 0x47, 0xFC, 0x8C, 0x3F, 0x94, 0x1E, 0x81, 0x79, 0x24, -0x58, 0x78, 0x0E, 0xAF, 0xE7, 0xD3, 0xF5, 0x8D, 0x72, 0x28, 0xB9, 0x43, 0xDB, 0x3C, 0x3C, 0xD4, -0x9E, 0x05, 0x81, 0x01, 0x0A, 0xC0, 0x75, 0xCA, 0x29, 0xCA, 0x47, 0x33, 0xF8, 0xA4, 0x84, 0xEB, -0xA1, 0x51, 0xB2, 0x87, 0x8A, 0x09, 0xF3, 0x5B, 0xFC, 0x84, 0x9D, 0x70, 0x67, 0x8A, 0xAA, 0xFD, -0xEF, 0x05, 0xF1, 0xD6, 0xD7, 0x54, 0x60, 0xAE, 0xF7, 0xCC, 0xB6, 0xEB, 0xB5, 0x76, 0xB4, 0xEC, -0x5C, 0x63, 0x63, 0xF0, 0x36, 0xA0, 0xBA, 0x82, 0x3E, 0x40, 0xC7, 0x91, 0xCD, 0x33, 0x6E, 0x42, -0xBD, 0xC3, 0xB8, 0xEB, 0x82, 0x2B, 0x23, 0x39, 0xE8, 0x87, 0x16, 0xAE, 0xF3, 0x85, 0xAC, 0x17, -0x73, 0x10, 0x7F, 0x34, 0x8C, 0x4F, 0x4C, 0x2C, 0x70, 0xE9, 0x90, 0x36, 0xB4, 0xBC, 0xE4, 0x03, -0xB9, 0xEE, 0x91, 0xA2, 0x51, 0xA4, 0x02, 0x6A, 0x9D, 0xE8, 0x89, 0xE9, 0x2F, 0xFC, 0xDC, 0x3E, -0x50, 0x9F, 0x29, 0xA6, 0x40, 0x38, 0x81, 0x5C, 0x78, 0x22, 0x79, 0x25, 0x7A, 0x48, 0x4C, 0x4F, -0xDC, 0x36, 0x1E, 0x44, 0x91, 0x61, 0x31, 0x37, 0x8D, 0x80, 0xC4, 0x83, 0x43, 0x68, 0x0B, 0xE2, -0xE6, 0xCC, 0x0D, 0x48, 0x22, 0x62, 0x58, 0xB8, 0x69, 0xC6, 0xB0, 0x3F, 0x46, 0xD6, 0x78, 0xAF, -0xEE, 0xAF, 0xF0, 0xF1, 0x0A, 0xFE, 0x9F, 0x9A, 0x83, 0x28, 0x37, 0x6E, 0x4E, 0x59, 0x48, 0x18, -0x0F, 0x22, 0x2B, 0x91, 0xE5, 0x10, 0x0B, 0x0B, 0xFC, 0xBE, 0xE8, 0xE9, 0xE1, 0x43, 0x7A, 0xF4, -0x20, 0x54, 0x9A, 0x88, 0x1E, 0x17, 0x5A, 0x74, 0x23, 0xA1, 0xE0, 0x34, 0xEE, 0x04, 0x0E, 0x81, -0x5C, 0xC2, 0xC0, 0x7C, 0x2B, 0x54, 0xEF, 0x1C, 0xAA, 0x4D, 0xB4, 0x85, 0xFF, 0x8F, 0xFA, 0x5F, -0x51, 0xD4, 0xBF, 0xBF, 0x10, 0x9F, 0x63, 0xDB, 0x09, 0x0F, 0x60, 0x70, 0xEA, 0x69, 0xC1, 0x83, -0x1A, 0x54, 0x3B, 0xCA, 0x79, 0x3F, 0x1E, 0xBA, 0x23, 0xFB, 0x9A, 0x5A, 0x26, 0x23, 0x3A, 0xB2, -0x2C, 0x94, 0x11, 0x4E, 0xDF, 0xE3, 0x04, 0x37, 0xCE, 0x76, 0xD7, 0x6B, 0x6C, 0x6D, 0x81, 0xC6, -0xE3, 0xDB, 0xA8, 0x24, 0x99, 0xBA, 0xCB, 0x3C, 0x48, 0x0F, 0xA2, 0xCE, 0x0D, 0x49, 0x00, 0x87, -0xD0, 0x7C, 0x3B, 0x4F, 0x61, 0xD7, 0x62, 0xDB, 0x0F, 0x4F, 0x06, 0xD0, 0x40, 0x5C, 0x01, 0xD0, -0xC0, 0xA3, 0x5E, 0x23, 0xA1, 0x25, 0x4E, 0x11, 0x56, 0x41, 0x56, 0x2E, 0x62, 0x5A, 0xE6, 0xC5, -0x31, 0xB3, 0x50, 0x5A, 0x21, 0xC8, 0xCA, 0x97, 0x01, 0x22, 0x7E, 0x7A, 0xA1, 0x39, 0x0B, 0xDB, -0x06, 0x1B, 0x44, 0x16, 0xC0, 0x06, 0xE5, 0xBB, 0xCA, 0x10, 0xFD, 0x9F, 0x1B, 0xCF, 0x42, 0xCA, -0x63, 0x12, 0x78, 0xF4, 0x28, 0x8E, 0x0D, 0x17, 0x19, 0x58, 0x19, 0x1F, 0xF6, 0xC6, 0xDA, 0x5F, -0xBA, 0xCE, 0xD8, 0x9A, 0x44, 0x79, 0x96, 0x93, 0x04, 0xC9, 0xFA, 0x61, 0x4C, 0xF0, 0x52, 0x8D, -0x03, 0x84, 0x58, 0x26, 0x15, 0x10, 0x7D, 0x63, 0x6A, 0x6A, 0x36, 0xF6, 0x29, 0xB5, 0xFA, 0x3A, -0xE1, 0xEF, 0x52, 0x6C, 0x80, 0xFC, 0xD1, 0x98, 0xA3, 0x0B, 0xA2, 0xDE, 0x09, 0xBB, 0x92, 0x31, -0x4E, 0x62, 0x18, 0x91, 0xB1, 0x04, 0xDD, 0xF8, 0xA3, 0xF8, 0xF0, 0xAD, 0x62, 0xFC, 0xCD, 0x66, -0xF2, 0xD7, 0x59, 0x69, 0xE7, 0x80, 0x06, 0xDF, 0x7C, 0x26, 0xA5, 0xF6, 0x74, 0x6D, 0x40, 0x1B, -0x66, 0x20, 0xA1, 0x1D, 0xA4, 0x91, 0xE4, 0x52, 0x2E, 0x5E, 0x2B, 0xAD, 0x10, 0x08, 0x45, 0xB7, -0x1C, 0xA2, 0x28, 0x68, 0xAF, 0x70, 0x98, 0x87, 0x2A, 0xB1, 0x25, 0x55, 0x81, 0x90, 0x39, 0x62, -0x9D, 0x6D, 0xEC, 0x63, 0x43, 0x74, 0x44, 0xCE, 0x7D, 0x2C, 0x7E, 0x3D, 0xB3, 0x27, 0x2D, 0xB1, -0x7B, 0x3D, 0xD5, 0xCF, 0x40, 0x6B, 0x75, 0x05, 0xF5, 0xD0, 0xF4, 0x15, 0x3E, 0x25, 0x10, 0xF2, -0x10, 0x5E, 0x88, 0x97, 0xAF, 0xC2, 0xAD, 0xC3, 0xE9, 0x6B, 0xD9, 0xCE, 0x58, 0x92, 0x8D, 0x32, -0x6C, 0x64, 0xC8, 0x77, 0x94, 0x73, 0xBA, 0x9A, 0xFC, 0xE1, 0x47, 0x39, 0x33, 0x6C, 0x99, 0x6E, -0x72, 0x70, 0xB2, 0x65, 0xDD, 0x24, 0xD2, 0xC5, 0x70, 0x66, 0x05, 0x0A, 0x84, 0xB5, 0x6E, 0xAD, -0x4A, 0xE6, 0x92, 0xBD, 0x9C, 0x45, 0x4A, 0x5A, 0xD4, 0x03, 0xA2, 0xD8, 0x74, 0x3C, 0xFD, 0x50, -0x8A, 0x6B, 0x3F, 0xBD, 0x31, 0x3C, 0x9C, 0x64, 0x47, 0x05, 0x27, 0x16, 0x7F, 0x18, 0x0A, 0xB6, -0x6A, 0x49, 0x51, 0xC4, 0xD7, 0x2D, 0xC5, 0x5A, 0x61, 0x7C, 0x0C, 0x20, 0x2F, 0x96, 0xFD, 0xEE, -0x11, 0x80, 0xF3, 0x71, 0x52, 0x43, 0xFB, 0xFE, 0x2F, 0x8A, 0xE2, 0x56, 0x1B, 0x43, 0x94, 0xF1, -0xA7, 0xC4, 0xA4, 0x4B, 0x12, 0x01, 0x7E, 0x2B, 0x15, 0x17, 0x7C, 0x62, 0xEB, 0x94, 0xB7, 0xBF, -0x87, 0x16, 0x12, 0x26, 0xA9, 0xC2, 0x61, 0x0A, 0x5D, 0xCE, 0xCE, 0x1F, 0xA1, 0xB0, 0xC2, 0x5E, -0x31, 0x29, 0x85, 0x3F, 0x16, 0x81, 0xEC, 0x36, 0x54, 0x43, 0xD0, 0xCD, 0x8F, 0x50, 0xF7, 0x24, -0xCC, 0xB4, 0xC1, 0x07, 0x58, 0xA0, 0x01, 0x53, 0x84, 0x4A, 0xA6, 0x23, 0x1C, 0x46, 0x31, 0x31, -0xC5, 0x24, 0xCC, 0x98, 0xE1, 0xBC, 0x14, 0xAF, 0xF5, 0xF2, 0xFA, 0x21, 0x94, 0xC5, 0x1F, 0x3E, -0x0C, 0x8B, 0x1A, 0x0F, 0x42, 0x31, 0xA4, 0x71, 0x60, 0x07, 0x12, 0x82, 0x98, 0x88, 0xB2, 0xC4, -0x14, 0xDF, 0x66, 0x5D, 0x8B, 0x22, 0x55, 0xE6, 0xA8, 0x4E, 0x93, 0x33, 0x2D, 0x4D, 0xB3, 0xB4, -0xDF, 0xDF, 0xA8, 0xC9, 0xFC, 0x8B, 0xCF, 0xC0, 0x48, 0x11, 0xAF, 0x51, 0x85, 0x9C, 0xD4, 0xE0, -0xB2, 0x80, 0x94, 0x3B, 0x2C, 0xB3, 0xC5, 0x0F, 0xC2, 0x1B, 0xC5, 0x07, 0xC5, 0x6C, 0x6A, 0x78, -0x29, 0xD7, 0x82, 0x21, 0x83, 0xB1, 0xB3, 0xF8, 0x68, 0x4F, 0x12, 0x8F, 0xB2, 0x0E, 0xCC, 0x14, -0x17, 0xB3, 0x2E, 0xE6, 0xAE, 0x37, 0x16, 0x59, 0xE6, 0x4E, 0xBC, 0xD2, 0xE7, 0x10, 0xA8, 0xBC, -0x22, 0x80, 0xCB, 0xF0, 0xB9, 0x93, 0x42, 0xC8, 0xE8, 0x19, 0x15, 0x09, 0x07, 0x7D, 0x00, 0xA5, -0xDC, 0xBA, 0x08, 0x6D, 0x1A, 0x03, 0x45, 0xAC, 0xC5, 0xB0, 0xB1, 0x5D, 0xEA, 0x12, 0xBC, 0x9C, -0x7A, 0xF2, 0xE0, 0xA5, 0x8D, 0xE9, 0x12, 0x34, 0xF5, 0xFB, 0x62, 0x60, 0xF9, 0x69, 0x17, 0x99, -0x76, 0xE3, 0xA6, 0x04, 0x70, 0xF4, 0x88, 0x8E, 0x04, 0x2A, 0xB2, 0x59, 0x1E, 0x60, 0xF8, 0xB6, -0x9A, 0x9A, 0xA4, 0x5F, 0x3F, 0x70, 0xE7, 0x6C, 0x73, 0x7E, 0x22, 0x0C, 0x2D, 0xE9, 0x2C, 0x69, -0x1B, 0xEF, 0xD7, 0x79, 0x71, 0x25, 0xCB, 0x36, 0xBE, 0x14, 0x22, 0x6F, 0xF2, 0xAF, 0xC5, 0x2B, -0x63, 0x3A, 0xCD, 0xAA, 0xEC, 0x01, 0x2D, 0xA5, 0xED, 0x7B, 0x23, 0x96, 0x0C, 0xC2, 0x07, 0x5A, -0x30, 0x56, 0xE1, 0xE1, 0xEF, 0xAC, 0x4F, 0x4C, 0xDE, 0x31, 0x9B, 0x6A, 0x14, 0xD2, 0xE2, 0xCE, -0x93, 0xA4, 0x44, 0x53, 0x51, 0xCC, 0xBF, 0x7D, 0xCC, 0x02, 0xFC, 0x05, 0x09, 0x0F, 0x34, 0xD9, -0xDE, 0x32, 0x82, 0x72, 0x24, 0x26, 0x1E, 0x05, 0xE3, 0xC4, 0x4B, 0x99, 0x8C, 0x3D, 0x0D, 0xF5, -0xF4, 0xF3, 0x68, 0x08, 0xC9, 0xEB, 0x05, 0x38, 0x1F, 0x78, 0xFB, 0xB2, 0xDE, 0xB8, 0xCD, 0x63, -0x87, 0x89, 0x2B, 0xB2, 0x9D, 0xB2, 0x44, 0xD0, 0x34, 0xA1, 0xC6, 0x16, 0x93, 0x8F, 0x1A, 0x9D, -0xEC, 0x30, 0x57, 0x8E, 0x18, 0xDC, 0x64, 0x09, 0xF6, 0x22, 0x2D, 0x5A, 0x56, 0xDF, 0xC6, 0x10, -0x44, 0x09, 0x20, 0x45, 0x6C, 0xA2, 0x84, 0x95, 0xEC, 0x42, 0x34, 0x10, 0xB4, 0xCB, 0x3E, 0x98, -0x41, 0x7B, 0xBC, 0xFA, 0x4F, 0x94, 0x8B, 0x4C, 0x00, 0xA1, 0x33, 0x65, 0xA0, 0xC0, 0xA7, 0xA8, -0x46, 0x86, 0x73, 0x63, 0xF8, 0xB2, 0xD3, 0x8C, 0x80, 0xA0, 0x80, 0x70, 0xBF, 0xA9, 0xEB, 0xAC, -0x81, 0xCE, 0x1D, 0x80, 0x9D, 0xB5, 0xE9, 0xB6, 0x13, 0x1C, 0xC7, 0xA0, 0x0D, 0xD0, 0x93, 0xD8, -0x6D, 0xF6, 0x05, 0x6F, 0x71, 0x9F, 0x9D, 0xB1, 0x06, 0x61, 0x2F, 0xF8, 0xCD, 0xEC, 0xB6, 0x31, -0x9F, 0x13, 0xC7, 0xBC, 0x9C, 0x5A, 0xB6, 0x59, 0x67, 0xA0, 0xE1, 0x83, 0x36, 0x9E, 0x46, 0xBF, -0x3F, 0x4D, 0x1F, 0xEA, 0xE0, 0x58, 0xC1, 0x9B, 0x2F, 0xD9, 0xB5, 0x7A, 0xAD, 0x67, 0x8A, 0x67, -0x72, 0x78, 0xB3, 0xB6, 0xE9, 0x19, 0xCB, 0x37, 0xF8, 0xCC, 0x1F, 0x35, 0x87, 0x66, 0xA7, 0xD9, -0xE1, 0x0D, 0x02, 0x28, 0xB6, 0x84, 0xC8, 0x11, 0x2F, 0x3E, 0x1B, 0xF5, 0xCB, 0x87, 0xB7, 0x11, -0xDE, 0xC0, 0x7D, 0xC1, 0x2E, 0xD5, 0x6B, 0xF4, 0xA1, 0xC1, 0xC3, 0x3F, 0xE6, 0xB8, 0xD4, 0x2C, -0xD2, 0x8C, 0x24, 0x46, 0x7C, 0x1E, 0x10, 0x45, 0xC5, 0x9A, 0xFF, 0x20, 0x23, 0x85, 0xCB, 0x0E, -0xE4, 0x09, 0x34, 0xF7, 0xBA, 0x0A, 0x54, 0x3C, 0x2D, 0x88, 0xE0, 0xC8, 0xC9, 0x4B, 0x18, 0xDE, -0xFE, 0x93, 0x18, 0x1E, 0xE8, 0xE3, 0x40, 0xAB, 0xEB, 0x1D, 0xFD, 0xA0, 0x4E, 0xAF, 0xBF, 0x03, -0x76, 0xA6, 0xF5, 0xC6, 0x41, 0xB7, 0xD1, 0x68, 0xFB, 0xA0, 0x33, 0x52, 0x6F, 0xF5, 0x44, 0x13, -0xF8, 0x43, 0xDB, 0xB0, 0x4E, 0xB2, 0xEF, 0xBF, 0x76, 0x17, 0x9E, 0x9F, 0xD7, 0xE0, 0x9D, 0xE5, -0x60, 0x26, 0xCE, 0x6B, 0x72, 0x0D, 0x83, 0x17, 0xC7, 0x4C, 0x35, 0xD1, 0xE9, 0x43, 0x8E, 0x62, -0xA4, 0x49, 0x9F, 0xFD, 0x82, 0x92, 0x5F, 0x2A, 0xF6, 0x79, 0xBD, 0x49, 0x70, 0xA6, 0xBC, 0x2E, -0xD6, 0xAE, 0x6E, 0x65, 0xE3, 0x88, 0x2A, 0x3A, 0x3E, 0xD7, 0x90, 0xD2, 0x7F, 0x22, 0x60, 0xF1, -0x1A, 0x29, 0x35, 0x87, 0x5E, 0xA6, 0x0C, 0x55, 0xD6, 0x57, 0xB9, 0xF5, 0x68, 0x7C, 0xA2, 0x39, -0x39, 0xC6, 0x8E, 0xD7, 0x9E, 0x97, 0x0B, 0xF0, 0xF1, 0x99, 0x88, 0xA8, 0xEC, 0x1A, 0x0E, 0x38, -0xC3, 0xD0, 0x0F, 0x03, 0xD0, 0xBC, 0x7C, 0x04, 0xB7, 0xA5, 0x0C, 0xC6, 0x47, 0xAB, 0x05, 0x00, -0xD2, 0x3B, 0xDE, 0x25, 0x58, 0x69, 0x14, 0x9C, 0x5B, 0x2F, 0x24, 0xDF, 0x4A, 0x4E, 0x51, 0x00, -0xD6, 0x34, 0xE7, 0x8A, 0x28, 0x03, 0xED, 0x1A, 0x61, 0xD0, 0x43, 0x20, 0x3E, 0x6C, 0x93, 0x42, -0x5E, 0xC6, 0xB0, 0x3E, 0x3D, 0xA4, 0x4F, 0xC4, 0xC2, 0xAC, 0xA1, 0x7C, 0x7A, 0x18, 0x7F, 0x2B, -0x19, 0x88, 0xF8, 0xDA, 0x43, 0x24, 0x42, 0x92, 0x2F, 0x6F, 0x22, 0xCB, 0x5B, 0x4C, 0x67, 0x14, -0x40, 0xC8, 0x1F, 0xA5, 0x60, 0xE2, 0x22, 0x25, 0xC5, 0x45, 0xB8, 0xB8, 0x10, 0x20, 0x1A, 0xE1, -0x16, 0xCF, 0xAD, 0x84, 0xF6, 0xFF, 0xEB, 0xF3, 0x88, 0xB3, 0xE5, 0x30, 0x97, 0x4E, 0x3E, 0x77, -0x21, 0xB1, 0x97, 0x0F, 0x10, 0xFB, 0x72, 0x15, 0x63, 0x6B, 0x39, 0x2C, 0xC7, 0x96, 0x98, 0xFB, -0x40, 0x80, 0x88, 0x2D, 0xF5, 0x0C, 0x89, 0x60, 0x25, 0x7C, 0x8B, 0x9C, 0x66, 0x38, 0xA6, 0x16, -0x7E, 0xBF, 0x3C, 0x24, 0x96, 0xBD, 0x82, 0xAD, 0xB0, 0xDC, 0x64, 0xCD, 0x24, 0x26, 0xC3, 0x39, -0x96, 0x42, 0xD0, 0xB0, 0xA5, 0x04, 0x1D, 0xD2, 0x91, 0x0B, 0x2D, 0x1A, 0xB1, 0xA2, 0x31, 0x3C, -0x2D, 0x25, 0xAC, 0xB0, 0x75, 0xE4, 0x38, 0x11, 0x02, 0x31, 0x47, 0x73, 0x9C, 0x9C, 0xD3, 0x63, -0x83, 0x17, 0xC6, 0x6C, 0x62, 0x88, 0x22, 0x37, 0x08, 0x59, 0x8A, 0xB5, 0x09, 0x1D, 0x84, 0xC1, -0x67, 0x91, 0x59, 0x48, 0x0A, 0x5B, 0xF3, 0xD4, 0xDF, 0xDB, 0x04, 0xE7, 0x43, 0xF8, 0x3E, 0x46, -0xDC, 0x83, 0xE8, 0x7A, 0x9A, 0xED, 0x2E, 0x09, 0xAE, 0x5F, 0x8A, 0xC7, 0x4D, 0xB4, 0x21, 0x81, -0x98, 0x4A, 0xD8, 0xAC, 0x16, 0xC6, 0xA0, 0x60, 0x6A, 0xF9, 0x30, 0x08, 0xC7, 0xEF, 0x8C, 0x92, -0x87, 0x7A, 0x98, 0x10, 0x0B, 0xD9, 0x4B, 0xCF, 0xF6, 0xC7, 0xC4, 0xC9, 0x60, 0x22, 0x59, 0x3E, -0xE4, 0x3C, 0xA6, 0x02, 0x51, 0xDE, 0x3C, 0x5A, 0x05, 0x11, 0x86, 0xB7, 0xBF, 0x5A, 0x29, 0xAA, -0x19, 0x28, 0x14, 0x64, 0x08, 0x16, 0xC9, 0x32, 0xE2, 0x35, 0x25, 0x4D, 0xD5, 0x64, 0x65, 0x8E, -0x46, 0x71, 0xCE, 0x5E, 0x19, 0xE5, 0xB3, 0xB5, 0xC2, 0x24, 0xCE, 0x12, 0x2B, 0xFB, 0x9D, 0x1F, -0x8A, 0x9D, 0x05, 0xEC, 0x0C, 0x0B, 0xC6, 0xC1, 0x83, 0xF3, 0xC3, 0x69, 0x30, 0xB3, 0x07, 0x0F, -0xFE, 0x17, 0x8F, 0xA0, 0x18, 0x01, 0x53, 0xAD, 0x00, 0x00 -}; - - -//File: index_ov3660.html.gz, Size: 8887 -#define index_ov3660_html_gz_len 8887 -const uint8_t index_ov3660_html_gz[] = { - 0x1F, 0x8B, 0x08, 0x08, 0xA3, 0xFA, 0x69, 0x5E, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x5F, - 0x6F, 0x76, 0x33, 0x36, 0x36, 0x30, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xED, 0x3D, 0x69, 0x73, - 0xDB, 0x46, 0xB2, 0xDF, 0xFD, 0x2B, 0x60, 0x24, 0x6B, 0x51, 0x65, 0x91, 0xE2, 0xAD, 0x23, 0x12, - 0xFD, 0x6C, 0x59, 0xB1, 0x53, 0x1B, 0x67, 0xBD, 0x71, 0xE2, 0x24, 0xB5, 0xB5, 0xE5, 0x80, 0xC4, - 0x90, 0x44, 0x0C, 0x02, 0x5C, 0x00, 0xD4, 0x91, 0x94, 0x7E, 0xC7, 0xFB, 0x41, 0xEF, 0x8F, 0xBD, - 0xEE, 0x39, 0x70, 0x71, 0x00, 0x0C, 0x00, 0x11, 0x52, 0xF2, 0x1E, 0x5D, 0x65, 0xE1, 0x98, 0xEE, - 0xE9, 0x7B, 0x7A, 0x7A, 0x06, 0xC0, 0xD9, 0x53, 0xD3, 0x9D, 0x05, 0xB7, 0x6B, 0xA2, 0x2D, 0x83, - 0x95, 0x3D, 0x79, 0x72, 0xC6, 0xFE, 0x68, 0xF0, 0x3B, 0x5B, 0x12, 0xC3, 0x64, 0x87, 0xF4, 0x74, - 0x45, 0x02, 0x43, 0x9B, 0x2D, 0x0D, 0xCF, 0x27, 0xC1, 0xB9, 0xBE, 0x09, 0xE6, 0xED, 0x63, 0x3D, - 0x7D, 0xDB, 0x31, 0x56, 0xE4, 0x5C, 0xBF, 0xB2, 0xC8, 0xF5, 0xDA, 0xF5, 0x02, 0x5D, 0x9B, 0xB9, - 0x4E, 0x40, 0x1C, 0x68, 0x7E, 0x6D, 0x99, 0xC1, 0xF2, 0xDC, 0x24, 0x57, 0xD6, 0x8C, 0xB4, 0xE9, - 0xC9, 0x81, 0xE5, 0x58, 0x81, 0x65, 0xD8, 0x6D, 0x7F, 0x66, 0xD8, 0xE4, 0xBC, 0x17, 0xC7, 0x15, - 0x58, 0x81, 0x4D, 0x26, 0x97, 0x1F, 0xDE, 0x0F, 0xFA, 0xDA, 0x3F, 0x3E, 0x0E, 0xC6, 0xE3, 0xEE, - 0xD9, 0x21, 0xBB, 0x16, 0xB5, 0xF1, 0x83, 0xDB, 0xF8, 0x39, 0xFE, 0xA6, 0xAE, 0x79, 0xAB, 0xFD, - 0x91, 0xB8, 0x84, 0xBF, 0x39, 0x10, 0xD1, 0x9E, 0x1B, 0x2B, 0xCB, 0xBE, 0x3D, 0xD5, 0x5E, 0x7A, - 0xD0, 0xE7, 0xC1, 0x5B, 0x62, 0x5F, 0x91, 0xC0, 0x9A, 0x19, 0x07, 0xBE, 0xE1, 0xF8, 0x6D, 0x9F, - 0x78, 0xD6, 0xFC, 0xAB, 0x2D, 0xC0, 0xA9, 0x31, 0xFB, 0xBC, 0xF0, 0xDC, 0x8D, 0x63, 0x9E, 0x6A, - 0x5F, 0xF4, 0x8E, 0xF1, 0xDF, 0x76, 0xA3, 0x99, 0x6B, 0xBB, 0x1E, 0xDC, 0xBF, 0xFC, 0x1A, 0xFF, - 0x6D, 0xDF, 0xA7, 0xBD, 0xFB, 0xD6, 0xEF, 0xE4, 0x54, 0xEB, 0x8D, 0xD7, 0x37, 0x89, 0xFB, 0x77, - 0x4F, 0x12, 0xA7, 0xCB, 0x7E, 0x16, 0xF5, 0x1C, 0xFE, 0x38, 0x1F, 0xDE, 0x27, 0xB3, 0xC0, 0x72, - 0x9D, 0xCE, 0xCA, 0xB0, 0x1C, 0x09, 0x26, 0xD3, 0xF2, 0xD7, 0xB6, 0x01, 0x32, 0x98, 0xDB, 0x24, - 0x17, 0xCF, 0x17, 0x2B, 0xE2, 0x6C, 0x0E, 0x0A, 0xB0, 0x21, 0x92, 0xB6, 0x69, 0x79, 0xAC, 0xD5, - 0x29, 0xCA, 0x61, 0xB3, 0x72, 0x0A, 0xD1, 0xE6, 0xD1, 0xE5, 0xB8, 0x0E, 0x91, 0x08, 0x10, 0x3B, - 0xBA, 0xF6, 0x8C, 0x35, 0x36, 0xC0, 0xBF, 0xDB, 0x4D, 0x56, 0x96, 0xC3, 0x8C, 0xEA, 0x54, 0x1B, - 0x0C, 0xBB, 0xEB, 0x9B, 0x02, 0x55, 0x0E, 0xC6, 0xF8, 0x6F, 0xBB, 0xD1, 0xDA, 0x30, 0x4D, 0xCB, - 0x59, 0x9C, 0x6A, 0xC7, 0x52, 0x14, 0xAE, 0x67, 0x12, 0xAF, 0xED, 0x19, 0xA6, 0xB5, 0xF1, 0x4F, - 0xB5, 0xA1, 0xAC, 0xCD, 0xCA, 0xF0, 0x16, 0x40, 0x4B, 0xE0, 0x02, 0xB1, 0xED, 0x9E, 0x94, 0x12, - 0xDE, 0xC4, 0xB3, 0x16, 0xCB, 0x00, 0x54, 0xBA, 0xD5, 0x26, 0x2D, 0x34, 0xEE, 0x42, 0x45, 0xFA, - 0xCC, 0x95, 0x9B, 0x5C, 0x6A, 0x86, 0x6D, 0x2D, 0x9C, 0xB6, 0x15, 0x90, 0x15, 0xB0, 0xE3, 0x07, - 0x1E, 0x09, 0x66, 0xCB, 0x3C, 0x52, 0xE6, 0xD6, 0x62, 0xE3, 0x11, 0x09, 0x21, 0xA1, 0xDC, 0x72, - 0x18, 0x86, 0x9B, 0xDB, 0xB7, 0xDA, 0xD7, 0x64, 0xFA, 0xD9, 0x0A, 0xDA, 0x5C, 0x26, 0x53, 0x32, - 0x77, 0x3D, 0x22, 0x6D, 0x29, 0x5A, 0xD8, 0xEE, 0xEC, 0x73, 0xDB, 0x0F, 0x0C, 0x2F, 0x50, 0x41, - 0x68, 0xCC, 0x03, 0xE2, 0x15, 0xE3, 0x23, 0x68, 0x15, 0xC5, 0xD8, 0xB2, 0xBB, 0xE5, 0x0D, 0x2C, - 0xC7, 0xB6, 0x1C, 0xA2, 0x4E, 0x5E, 0x56, 0xBF, 0x49, 0x74, 0xAC, 0x95, 0x82, 0x62, 0xAC, 0xD5, - 0x22, 0xCF, 0x4A, 0x28, 0xAF, 0xDB, 0x9D, 0x71, 0xBF, 0xE9, 0x75, 0xBB, 0x7F, 0xDB, 0xBE, 0xB9, - 0x24, 0xCC, 0x4C, 0x8D, 0x4D, 0xE0, 0xD6, 0xF7, 0x88, 0x2D, 0xB7, 0x4A, 0xF1, 0xF1, 0x5F, 0x2B, - 0x62, 0x5A, 0x86, 0xD6, 0x8A, 0xB9, 0xF3, 0x71, 0x17, 0x6C, 0x6A, 0x5F, 0x33, 0x1C, 0x53, 0x6B, - 0xB9, 0x9E, 0x05, 0x8E, 0x60, 0xD0, 0x70, 0x63, 0xC3, 0x15, 0x18, 0x38, 0xD6, 0x64, 0x5F, 0xC2, - 0x72, 0x8E, 0xCF, 0xC4, 0x25, 0x22, 0x77, 0x1B, 0xFC, 0x29, 0x84, 0x1C, 0xFC, 0x15, 0x3A, 0x90, - 0x84, 0x47, 0x8A, 0x3E, 0x4F, 0x5F, 0x71, 0x0A, 0xB3, 0x74, 0x86, 0xBF, 0x95, 0x71, 0xD3, 0xCE, - 0xD5, 0x9D, 0x68, 0x24, 0x74, 0x08, 0xC3, 0xEC, 0xAC, 0x05, 0x4D, 0xAF, 0x96, 0x5A, 0x5B, 0xC3, - 0x28, 0xB9, 0x2F, 0x87, 0xE1, 0x48, 0xE5, 0x2A, 0xC7, 0x5F, 0xDC, 0x28, 0x4A, 0xB0, 0x2B, 0x67, - 0x35, 0x8A, 0x1D, 0xEC, 0x9F, 0xCC, 0x86, 0x18, 0x27, 0x99, 0x51, 0x04, 0x7F, 0xEA, 0x91, 0x24, - 0x42, 0x56, 0x18, 0x4D, 0x24, 0x88, 0xB3, 0x23, 0xCA, 0x16, 0xDE, 0x2C, 0xEF, 0x96, 0x60, 0xCD, - 0x27, 0x41, 0x35, 0xBA, 0x48, 0x10, 0xE7, 0xD1, 0x50, 0x18, 0x65, 0xF0, 0x77, 0xA7, 0x90, 0x6F, - 0x7C, 0x31, 0xDD, 0x04, 0x81, 0xEB, 0xF8, 0xB5, 0x86, 0xA8, 0x2C, 0x3F, 0xFB, 0x6D, 0xE3, 0x07, - 0xD6, 0xFC, 0xB6, 0xCD, 0x5D, 0x1A, 0xFC, 0x6C, 0x6D, 0x40, 0x0A, 0x39, 0x25, 0xC1, 0x35, 0x21, - 0xF9, 0xE9, 0x86, 0x63, 0x5C, 0x41, 0xDC, 0x59, 0x2C, 0x6C, 0x99, 0xED, 0xCD, 0x36, 0x9E, 0x8F, - 0x79, 0xDB, 0xDA, 0xB5, 0x00, 0xB1, 0xB7, 0xDD, 0x71, 0xD2, 0x07, 0x15, 0x3B, 0x6A, 0xCF, 0xA6, - 0x92, 0xBE, 0xDC, 0x4D, 0x80, 0x32, 0x96, 0x6A, 0xC2, 0x05, 0x76, 0xAC, 0xE0, 0x56, 0x7A, 0x8F, - 0x7B, 0xA2, 0xE4, 0x8E, 0x70, 0xC1, 0xDC, 0x61, 0x21, 0x49, 0xD7, 0xE9, 0x6C, 0x49, 0x66, 0x9F, - 0x89, 0xF9, 0xBC, 0x30, 0x0D, 0x2B, 0x4A, 0x0F, 0x3B, 0x96, 0xB3, 0xDE, 0x04, 0x6D, 0x4C, 0xA7, - 0xD6, 0x3B, 0xD1, 0x39, 0x35, 0x48, 0xC1, 0x62, 0xBF, 0x9F, 0x97, 0x54, 0x8C, 0xD6, 0x37, 0xF9, - 0x42, 0x88, 0x13, 0x3B, 0xB1, 0x8D, 0x29, 0xB1, 0xF3, 0x48, 0xE6, 0xCE, 0x90, 0x11, 0x76, 0x79, - 0xAC, 0xCA, 0xCE, 0xDD, 0x28, 0x65, 0xD1, 0xE0, 0x35, 0x3C, 0xFA, 0x9B, 0xB2, 0x1C, 0xE9, 0xF1, - 0x41, 0xE2, 0x92, 0x4F, 0x6C, 0x70, 0xB0, 0xAC, 0xD4, 0x1B, 0xDA, 0x5C, 0x03, 0x0D, 0xB9, 0x1D, - 0x78, 0x86, 0xB3, 0x20, 0x10, 0x0B, 0x6E, 0x0E, 0xC4, 0x61, 0xFE, 0xC4, 0x40, 0x89, 0x7D, 0x0C, - 0xD5, 0xA3, 0xFC, 0x89, 0x08, 0x0B, 0x08, 0x07, 0x5A, 0x87, 0x1D, 0x54, 0xC8, 0x4A, 0x62, 0xFA, - 0xCD, 0x25, 0xA4, 0x27, 0xB5, 0x0E, 0x96, 0x98, 0x48, 0x3D, 0x27, 0x69, 0x5B, 0xD2, 0x44, 0xBF, - 0x30, 0x34, 0x88, 0x29, 0xDF, 0x7C, 0x5E, 0x34, 0x69, 0x9C, 0xCF, 0x07, 0xDD, 0xC1, 0xB0, 0x30, - 0x73, 0x92, 0x72, 0x99, 0x9A, 0x38, 0x4A, 0x42, 0x47, 0x18, 0x56, 0x72, 0x8D, 0xC0, 0x37, 0xAE, - 0xA4, 0x49, 0xBB, 0xEB, 0x5B, 0x6C, 0xE6, 0x66, 0x4C, 0x7D, 0x98, 0xBB, 0x05, 0x92, 0xA9, 0x17, - 0x37, 0xF4, 0xBE, 0x94, 0x3E, 0x9A, 0xD2, 0x49, 0x5D, 0x40, 0x88, 0x57, 0x4E, 0x76, 0x42, 0x03, - 0xF2, 0x26, 0x31, 0x05, 0x4B, 0x93, 0xCA, 0x80, 0xDC, 0x04, 0x6D, 0x93, 0xCC, 0x5C, 0x8F, 0x65, - 0x83, 0x19, 0x33, 0xC7, 0x94, 0x22, 0x8B, 0x2D, 0xF6, 0x74, 0xE9, 0x5E, 0x11, 0x4F, 0x22, 0xAC, - 0x94, 0x52, 0x87, 0x27, 0x43, 0x53, 0x01, 0x9B, 0x01, 0xC3, 0xA3, 0x54, 0xF6, 0x49, 0x74, 0xFD, - 0xDE, 0xAC, 0x9F, 0xEB, 0xC7, 0x0C, 0x5D, 0x07, 0x7C, 0xC6, 0x98, 0xDA, 0xC4, 0xCC, 0x19, 0xCD, - 0x4C, 0x32, 0x37, 0x36, 0x76, 0x50, 0x60, 0x95, 0x46, 0x17, 0xFF, 0xE5, 0xF5, 0x48, 0xC3, 0xD0, - 0xBF, 0xB0, 0x2E, 0x74, 0x4E, 0x03, 0xC7, 0xBF, 0x25, 0x7D, 0x8A, 0x54, 0xC3, 0x58, 0xAF, 0x89, - 0x01, 0xAD, 0x66, 0x24, 0x4B, 0x0F, 0x4A, 0x53, 0x0C, 0x79, 0x9C, 0x57, 0x9A, 0xB7, 0x17, 0x3A, - 0x6C, 0x98, 0x3C, 0x96, 0xE2, 0xF9, 0x74, 0xEE, 0xCE, 0x36, 0xB2, 0xAC, 0x46, 0xCD, 0xF1, 0xB6, - 0xF1, 0x9D, 0x0A, 0x91, 0xF9, 0xB6, 0x45, 0xDD, 0x7F, 0xE3, 0x38, 0xA8, 0xD1, 0x76, 0xE0, 0x01, - 0x9B, 0x92, 0x8E, 0xD4, 0x04, 0x57, 0x29, 0x86, 0x25, 0x04, 0x9B, 0x55, 0xBB, 0x4A, 0x85, 0x29, - 0x49, 0x38, 0x0D, 0x23, 0xAD, 0x06, 0x31, 0xC4, 0x32, 0x05, 0xAA, 0x7A, 0x72, 0x09, 0x96, 0x9B, - 0x95, 0x2C, 0x8F, 0x12, 0x9D, 0xF5, 0x60, 0xD0, 0x67, 0xDD, 0x79, 0x8B, 0xA9, 0xD1, 0xEA, 0x1E, - 0x74, 0x0F, 0x06, 0xF0, 0x9F, 0x64, 0x3E, 0x93, 0x6F, 0x5C, 0x5C, 0xBC, 0x19, 0x96, 0x97, 0x0A, - 0xD1, 0xC5, 0x65, 0xA5, 0xAC, 0x60, 0x5F, 0xA8, 0x0B, 0x75, 0x4F, 0x4A, 0xD6, 0x97, 0x7A, 0x9D, - 0x82, 0x71, 0x38, 0xC3, 0xA4, 0xCB, 0x1B, 0xA2, 0xC4, 0x5A, 0xCA, 0xAA, 0x78, 0xE5, 0xFE, 0xDE, - 0x66, 0x49, 0xC8, 0xFF, 0x79, 0x6B, 0x8F, 0x89, 0xE2, 0x2F, 0x6D, 0xE9, 0xA5, 0xE5, 0xE2, 0x3F, - 0xB4, 0x6D, 0x74, 0xB3, 0xB5, 0xDE, 0xE6, 0x59, 0x1F, 0x50, 0xE8, 0xC0, 0x1C, 0xD4, 0x83, 0xC9, - 0x68, 0x66, 0x66, 0x18, 0x6B, 0x53, 0x41, 0x06, 0x73, 0xCB, 0xB6, 0xDB, 0xB6, 0x7B, 0x5D, 0x9C, - 0x89, 0xE4, 0x5B, 0xF2, 0x96, 0x9D, 0x16, 0x9B, 0x7C, 0x55, 0x6A, 0x37, 0x10, 0xB9, 0xFE, 0x14, - 0xD4, 0xFE, 0xB5, 0x1D, 0x2E, 0xD7, 0x35, 0xAA, 0x0D, 0x14, 0x15, 0xEC, 0xB1, 0x5E, 0x47, 0x4A, - 0xA6, 0xC4, 0x32, 0xC1, 0xFC, 0x69, 0xCF, 0xB5, 0x15, 0xCC, 0x96, 0x15, 0xA6, 0x9E, 0xD1, 0xC4, - 0xC8, 0x23, 0xB6, 0x81, 0x19, 0x7C, 0xA5, 0x0A, 0x45, 0xE1, 0xF4, 0x2D, 0x0E, 0xAE, 0xC2, 0x09, - 0x15, 0xDD, 0xE3, 0xA9, 0x2E, 0x75, 0x58, 0xEE, 0x90, 0x1D, 0xAB, 0xE5, 0x66, 0x5D, 0x90, 0xEE, - 0x27, 0x3D, 0x43, 0xDE, 0xA8, 0x44, 0x44, 0x17, 0x41, 0x7B, 0xE1, 0x91, 0x5B, 0x05, 0x66, 0x0E, - 0xF8, 0xDF, 0x53, 0x56, 0x3F, 0xAE, 0x5E, 0x2A, 0xA1, 0x03, 0x00, 0xB7, 0xA2, 0xCE, 0xD0, 0x57, - 0xE8, 0x3A, 0xBB, 0x4B, 0x15, 0x7B, 0x0C, 0xAB, 0xA3, 0xBA, 0xAE, 0x10, 0x6E, 0x72, 0x86, 0x50, - 0xB9, 0xA9, 0x8A, 0xD1, 0x57, 0x3E, 0x9F, 0x27, 0xF3, 0x20, 0x63, 0xF1, 0x87, 0xE6, 0xA9, 0x83, - 0xFC, 0xE8, 0xD6, 0x8E, 0x55, 0x53, 0x0A, 0x23, 0x47, 0x58, 0xC4, 0xCC, 0xB6, 0x3E, 0x29, 0x66, - 0x8C, 0x9E, 0xA5, 0x91, 0x67, 0xAB, 0x44, 0xA4, 0xCF, 0x54, 0xCD, 0xD0, 0x66, 0xC5, 0x87, 0x7C, - 0x50, 0x0F, 0xF9, 0xB9, 0xD5, 0x1F, 0x4B, 0xD7, 0x56, 0x72, 0x1A, 0xE7, 0x91, 0x96, 0x59, 0x05, - 0xDC, 0x1E, 0xB2, 0x32, 0x27, 0xC8, 0xF1, 0x58, 0x24, 0x55, 0x54, 0xBE, 0x57, 0xE6, 0x45, 0x98, - 0xED, 0x4A, 0x56, 0xAE, 0xB1, 0x5B, 0x2B, 0x03, 0xD2, 0x5E, 0x34, 0x57, 0x03, 0x30, 0xCA, 0xF4, - 0xA7, 0x62, 0xEE, 0xB1, 0x1A, 0x6B, 0x6F, 0xDC, 0x2D, 0xE8, 0x72, 0x66, 0xBB, 0x7E, 0xCD, 0x02, - 0x58, 0x76, 0xFD, 0x4B, 0x7A, 0x47, 0x69, 0xE8, 0xCE, 0xF5, 0xA9, 0x7C, 0x77, 0x4C, 0xC9, 0xBC, - 0xD7, 0x95, 0x46, 0xDA, 0xDC, 0x2A, 0x25, 0xAD, 0xA0, 0xD1, 0xF5, 0xCB, 0x53, 0x6D, 0x46, 0xE4, - 0x61, 0x34, 0x59, 0xA8, 0x53, 0x29, 0x95, 0xE6, 0xEA, 0x61, 0x69, 0x99, 0x26, 0xC9, 0xAD, 0x05, - 0xE3, 0x9C, 0x57, 0x31, 0x79, 0x40, 0xFA, 0x65, 0x45, 0xA9, 0x9D, 0x38, 0x45, 0xEE, 0xB6, 0x86, - 0xDE, 0xAE, 0x3D, 0x86, 0x0F, 0x34, 0x59, 0x95, 0xF4, 0x64, 0x2A, 0x92, 0x4B, 0xAA, 0xD4, 0xB9, - 0xC3, 0x5A, 0x2B, 0x8A, 0x0C, 0xE4, 0x80, 0xAD, 0xB6, 0xA3, 0x79, 0x8A, 0x2A, 0xBA, 0x90, 0xD2, - 0xE1, 0x6B, 0x4B, 0x7C, 0x19, 0xB0, 0x9D, 0xB5, 0xBA, 0x72, 0x8F, 0x4B, 0x6D, 0xD4, 0x02, 0xD2, - 0xFD, 0x66, 0x8A, 0xE6, 0x81, 0x32, 0xA3, 0x1C, 0x22, 0xC3, 0x21, 0x46, 0x6C, 0xAE, 0x4A, 0xB6, - 0x2A, 0xEB, 0x1C, 0xE1, 0xF9, 0xD9, 0x61, 0x6C, 0x3B, 0xDC, 0xD9, 0x61, 0xB4, 0x73, 0xEF, 0x0C, - 0xF7, 0xC4, 0xC5, 0x77, 0xCD, 0xF1, 0x8E, 0x66, 0xB6, 0xE1, 0xFB, 0xE7, 0x3A, 0xEE, 0xED, 0xD2, - 0x93, 0x9B, 0xE8, 0xCE, 0x4C, 0xEB, 0x4A, 0xB3, 0xCC, 0x73, 0xDD, 0x76, 0x17, 0x6E, 0xEA, 0x1E, - 0xBD, 0xCF, 0xD4, 0x0C, 0x03, 0xD9, 0xB9, 0x9E, 0x58, 0x60, 0xD4, 0x29, 0x54, 0x74, 0x49, 0x9F, - 0x3C, 0xFB, 0xE2, 0xE4, 0xE8, 0x68, 0xFC, 0xD5, 0x33, 0x67, 0xEA, 0xAF, 0xF9, 0xFF, 0x3F, 0xB0, - 0xF5, 0x58, 0xB6, 0xA9, 0x0F, 0xC6, 0xB6, 0x20, 0x00, 0xDB, 0xF3, 0xCF, 0x0E, 0x29, 0xD2, 0x14, - 0x21, 0x87, 0x40, 0x49, 0x06, 0x6D, 0x3C, 0xDF, 0x91, 0x91, 0x27, 0x9A, 0xF8, 0x30, 0x84, 0x4F, - 0x0D, 0x4F, 0xD2, 0x84, 0x36, 0x63, 0xD9, 0x34, 0x8D, 0x25, 0x3A, 0x55, 0xCA, 0xD4, 0xBD, 0x49, - 0x73, 0x40, 0x99, 0xE2, 0x1A, 0xE3, 0xAD, 0x88, 0x99, 0x85, 0x10, 0xC0, 0x28, 0x38, 0xAE, 0xAE, - 0x42, 0x1B, 0x69, 0xA3, 0x84, 0x0A, 0xB0, 0xF1, 0xCD, 0xCC, 0xFE, 0x2C, 0x94, 0xAF, 0x0B, 0xA5, - 0x38, 0x6E, 0xC0, 0x62, 0x65, 0x46, 0x57, 0x09, 0x56, 0x39, 0x4C, 0x6C, 0xDD, 0x90, 0x71, 0x01, - 0xA2, 0x6D, 0x53, 0xEC, 0xEC, 0x5A, 0x3E, 0x26, 0x8A, 0x2D, 0xA6, 0x57, 0x01, 0xAC, 0x4F, 0x7E, - 0xBE, 0xF8, 0xF6, 0xEF, 0xDA, 0xBB, 0xB7, 0xBF, 0x4B, 0x35, 0x54, 0x44, 0x14, 0x06, 0x69, 0x85, - 0x9E, 0x29, 0x18, 0xD3, 0x87, 0x90, 0x89, 0xCE, 0x35, 0x43, 0x31, 0xE0, 0x70, 0x6F, 0x13, 0x67, - 0x11, 0x2C, 0xCF, 0xF5, 0x9E, 0x8E, 0x7B, 0x5A, 0xC4, 0x59, 0x5F, 0xD7, 0x30, 0x80, 0xD3, 0x83, - 0x2B, 0xC3, 0xDE, 0xE0, 0x51, 0x57, 0x85, 0xD7, 0x6D, 0xD3, 0x92, 0x36, 0xE3, 0x91, 0x25, 0x94, - 0x71, 0x2C, 0x12, 0x27, 0xA5, 0xAC, 0x4F, 0x3E, 0x90, 0xE0, 0xEC, 0x90, 0xDD, 0x2A, 0xD0, 0x5A, - 0x7E, 0xDF, 0xE0, 0xC9, 0xCC, 0x1C, 0xF2, 0x4C, 0x28, 0x4F, 0xF1, 0x73, 0xCF, 0x58, 0x11, 0x94, - 0x8A, 0x92, 0xE6, 0xE3, 0x5A, 0x0F, 0x21, 0xF5, 0xC9, 0xF7, 0x84, 0x66, 0x44, 0x40, 0x86, 0x92, - 0xE2, 0xCF, 0x78, 0x92, 0x9A, 0xE8, 0x3F, 0xB4, 0x67, 0xBE, 0x28, 0xD5, 0x36, 0x98, 0x99, 0x2B, - 0xC8, 0xFD, 0x69, 0xBB, 0xAD, 0x0D, 0xDE, 0xBD, 0xD7, 0xDA, 0x6D, 0x85, 0xC6, 0xEE, 0x9A, 0xBA, - 0x13, 0xD7, 0x7F, 0xEF, 0x48, 0x9F, 0xFC, 0xF3, 0xE7, 0x37, 0x2F, 0x5B, 0xFD, 0xEE, 0xF0, 0xF8, - 0xA6, 0x37, 0x1A, 0x0F, 0xF7, 0xCF, 0x0E, 0x59, 0x93, 0xF2, 0xB8, 0xC6, 0xFA, 0xE4, 0x3D, 0x12, - 0xD2, 0x3A, 0x1E, 0x0F, 0xEB, 0xE2, 0x1A, 0x21, 0xAE, 0xB7, 0xAF, 0x5B, 0x47, 0xFD, 0xEE, 0x4D, - 0xAF, 0x7F, 0xDC, 0xAD, 0x81, 0x6A, 0xA8, 0x4F, 0xBE, 0x06, 0x4C, 0xBD, 0x13, 0x44, 0xD5, 0x2D, - 0x87, 0x0A, 0x45, 0xDB, 0xAF, 0x28, 0xDA, 0x81, 0x3E, 0xF9, 0x11, 0x45, 0x0B, 0x39, 0x37, 0xF2, - 0xD0, 0xAD, 0xC3, 0x43, 0x1F, 0x5C, 0x86, 0xE2, 0x02, 0x51, 0x00, 0x13, 0xFD, 0x3A, 0xA2, 0xED, - 0xE9, 0x13, 0x14, 0x07, 0x62, 0x02, 0xE9, 0xD6, 0x40, 0x04, 0xB1, 0x83, 0xD2, 0x04, 0xE4, 0xDC, - 0x1C, 0x8D, 0x8F, 0xAB, 0x63, 0x3A, 0x01, 0xEE, 0x3E, 0x02, 0xA6, 0x63, 0x10, 0xD4, 0xB8, 0x8E, - 0x9C, 0x8E, 0xF5, 0x09, 0xE2, 0x19, 0x0F, 0xBB, 0x37, 0xC3, 0x3A, 0x36, 0x03, 0x5E, 0xF1, 0x16, - 0x11, 0x01, 0x92, 0x9B, 0x41, 0x1D, 0x19, 0x81, 0x4B, 0x5C, 0x7C, 0xF3, 0x75, 0x6B, 0x08, 0x8C, - 0xF5, 0x4F, 0xC6, 0xD5, 0xF1, 0x80, 0x3B, 0xFC, 0x13, 0x09, 0x02, 0x62, 0x6E, 0xFA, 0xC3, 0x1A, - 0x04, 0x81, 0x33, 0x00, 0x3C, 0xE2, 0xA8, 0x8C, 0x02, 0xEC, 0xFA, 0x2D, 0x25, 0x06, 0x11, 0xF5, - 0x8E, 0x6A, 0x70, 0x05, 0x56, 0xFD, 0x4F, 0x14, 0x0F, 0x20, 0xB9, 0xE9, 0x0D, 0xEB, 0xD8, 0x34, - 0x20, 0xA2, 0x24, 0x81, 0xAF, 0xA1, 0xAB, 0x55, 0xC7, 0x04, 0x36, 0x7D, 0x32, 0xBE, 0x39, 0x19, - 0xAB, 0x21, 0xC0, 0xE1, 0x07, 0x43, 0x79, 0xDE, 0x00, 0x95, 0x3F, 0x7E, 0xE5, 0x8D, 0x4D, 0xFF, - 0xD9, 0xC0, 0x94, 0x33, 0xB8, 0x2D, 0x3D, 0x32, 0x71, 0x38, 0x90, 0x09, 0x3B, 0x50, 0x1B, 0x94, - 0x62, 0x94, 0x84, 0xBB, 0x9F, 0xF4, 0xC9, 0x50, 0x61, 0xF0, 0x4F, 0x64, 0x87, 0x14, 0x36, 0x41, - 0x3F, 0xCD, 0x48, 0xD0, 0xF2, 0x30, 0x17, 0x01, 0x97, 0x18, 0xE8, 0xB1, 0x08, 0x52, 0x69, 0xD4, - 0x93, 0xD0, 0x6A, 0xDC, 0xE8, 0x93, 0xF1, 0xA0, 0x30, 0x5B, 0xA8, 0xAE, 0x8C, 0x29, 0x2D, 0x6E, - 0x38, 0xC4, 0xF7, 0x4B, 0xEB, 0x23, 0x02, 0xD5, 0x27, 0xAF, 0xC2, 0xE3, 0x3A, 0x5A, 0x69, 0x17, - 0x71, 0x4A, 0x61, 0x33, 0xD4, 0x12, 0x23, 0x87, 0x69, 0xA6, 0x3D, 0xE0, 0xAA, 0x89, 0x34, 0x73, - 0xBF, 0x8A, 0xD9, 0xA5, 0x5E, 0x70, 0x6E, 0xE3, 0x19, 0x7E, 0x50, 0x5A, 0x2B, 0x02, 0x10, 0x22, - 0x34, 0x3F, 0x7A, 0x30, 0x8D, 0x84, 0xA4, 0xFC, 0x05, 0xF4, 0xE1, 0x1B, 0xC1, 0x86, 0xED, 0x33, - 0x2B, 0xAD, 0x91, 0x08, 0x14, 0xF2, 0x81, 0xF0, 0xB8, 0x96, 0x56, 0xEA, 0x84, 0xAF, 0x18, 0x39, - 0x5C, 0x2F, 0x22, 0x84, 0x0D, 0x77, 0xA4, 0x97, 0x22, 0x6A, 0x6B, 0xE9, 0x65, 0x69, 0x78, 0xEB, - 0x4A, 0xE1, 0x2B, 0x84, 0x04, 0xAD, 0x88, 0xC3, 0x07, 0x73, 0x95, 0x88, 0x98, 0xBF, 0x80, 0xAF, - 0x98, 0xC4, 0x71, 0x2D, 0xBF, 0xFC, 0xD4, 0x93, 0xC3, 0xE9, 0x93, 0xD7, 0xA4, 0xFD, 0x1D, 0x1E, - 0xD5, 0x51, 0xC7, 0xCB, 0x4D, 0xE0, 0xD6, 0x50, 0x88, 0xA0, 0x85, 0xA9, 0xA3, 0xCB, 0xB5, 0x71, - 0xBC, 0x23, 0x6D, 0x1C, 0xEF, 0x50, 0x1B, 0x06, 0xF9, 0x64, 0x93, 0x2B, 0x62, 0x97, 0x56, 0x87, - 0x00, 0xD4, 0x27, 0x97, 0x37, 0x6B, 0xD7, 0xC7, 0xA7, 0x77, 0xBE, 0xC5, 0xF3, 0x5A, 0x4E, 0x32, - 0xAA, 0xA1, 0x93, 0x90, 0x20, 0xEE, 0x23, 0x23, 0xAE, 0x95, 0xD1, 0x8E, 0xB4, 0x52, 0x44, 0x6B, - 0x1D, 0xAD, 0x2C, 0x0C, 0xCB, 0x99, 0x11, 0xCB, 0xC6, 0x27, 0x09, 0xCA, 0x2A, 0x26, 0x06, 0xAB, - 0x4F, 0xDE, 0x44, 0x27, 0x75, 0x14, 0xD3, 0xAD, 0xA1, 0x97, 0x38, 0x3D, 0x49, 0x7F, 0x19, 0xC1, - 0xAC, 0x7C, 0x47, 0xBA, 0xE9, 0xF5, 0x76, 0x39, 0xAA, 0xAC, 0xC9, 0xCC, 0x32, 0xEC, 0x4F, 0x64, - 0x3E, 0x87, 0x69, 0x50, 0xF9, 0xA1, 0x25, 0x01, 0x0E, 0xE3, 0x0B, 0x3B, 0xD7, 0x2E, 0xE9, 0x79, - 0xE9, 0x62, 0x5A, 0x0A, 0x5D, 0xF5, 0x8A, 0x5A, 0x7A, 0x4E, 0xC8, 0x97, 0x95, 0x09, 0xAD, 0x61, - 0xB2, 0x23, 0x7D, 0xF2, 0x9D, 0x1B, 0xD2, 0x59, 0x7D, 0xDA, 0xFA, 0x1D, 0x59, 0xD0, 0x55, 0xDB, - 0x3A, 0x73, 0xE8, 0x37, 0x9E, 0x71, 0x4B, 0x5F, 0x0B, 0x50, 0x67, 0x4A, 0xFF, 0x3D, 0x31, 0xB5, - 0x1F, 0x2C, 0xA7, 0x3A, 0x33, 0x43, 0x24, 0x84, 0x10, 0xA7, 0x1E, 0x96, 0x11, 0x4C, 0x91, 0xE0, - 0xA0, 0x1E, 0x92, 0x31, 0x16, 0x98, 0xD7, 0x96, 0xF1, 0x18, 0x26, 0xF1, 0xC6, 0xF5, 0xB4, 0xFC, - 0x80, 0x72, 0x3D, 0x85, 0x71, 0xF9, 0xA7, 0x57, 0xDA, 0x25, 0xDD, 0x67, 0x5C, 0x3A, 0x5C, 0xB1, - 0x2D, 0x50, 0x2A, 0x86, 0x1E, 0xAD, 0x23, 0x60, 0x9F, 0x5B, 0x0B, 0x3C, 0x72, 0x07, 0x52, 0x5D, - 0xE4, 0x91, 0xB0, 0x27, 0x08, 0xA4, 0x3B, 0x46, 0xF4, 0x18, 0xB7, 0x6A, 0x3C, 0xEE, 0x30, 0x15, - 0x9B, 0x5D, 0x97, 0x4F, 0xC3, 0x66, 0xD7, 0xA0, 0x26, 0xF3, 0x0A, 0xB7, 0xA0, 0x9B, 0x1A, 0xE8, - 0xAB, 0x11, 0x45, 0x61, 0xAF, 0x0F, 0xA3, 0x28, 0xCA, 0xEF, 0x43, 0x2B, 0x0A, 0xAC, 0xE5, 0x13, - 0x8E, 0xA3, 0x55, 0x9C, 0x8A, 0x02, 0xEA, 0x93, 0x77, 0x86, 0xB3, 0x81, 0x41, 0xA6, 0x29, 0x85, - 0x85, 0x1D, 0x3F, 0x98, 0x7B, 0x71, 0xBE, 0x1F, 0x5A, 0x75, 0x40, 0xC8, 0xCA, 0x35, 0xCB, 0x4F, - 0x77, 0x38, 0x1C, 0x0B, 0x89, 0xEF, 0xE0, 0xA8, 0x74, 0x62, 0x20, 0x30, 0xEC, 0x38, 0x23, 0x60, - 0x53, 0xA9, 0xEA, 0xC9, 0xC0, 0x87, 0x8D, 0xE3, 0xDC, 0xD6, 0xC9, 0x04, 0x2E, 0x6C, 0x77, 0x63, - 0x56, 0xC7, 0x00, 0x69, 0xC0, 0x3F, 0xE6, 0x73, 0x6B, 0x56, 0x3D, 0x91, 0x80, 0x24, 0xE0, 0xAD, - 0xBB, 0x52, 0x84, 0xDF, 0xF1, 0xC0, 0x4B, 0x66, 0x15, 0x66, 0x72, 0x33, 0xD0, 0xE2, 0xE5, 0x45, - 0xA3, 0x03, 0x2F, 0xF4, 0xF9, 0x40, 0x91, 0x01, 0xB9, 0x7D, 0xE8, 0xA0, 0x00, 0x44, 0x7C, 0xA2, - 0xC6, 0x53, 0x45, 0x59, 0x0C, 0x32, 0x8C, 0xE8, 0x62, 0xFA, 0xFD, 0x50, 0xF3, 0xBB, 0x88, 0xA2, - 0xE4, 0xEC, 0xAE, 0x37, 0x1A, 0x8C, 0xC3, 0xE9, 0xDD, 0xA0, 0x7F, 0xBF, 0x13, 0x3C, 0x44, 0xBE, - 0x5B, 0xFD, 0xF4, 0xAB, 0xA8, 0x06, 0xA2, 0xD1, 0x77, 0xB8, 0xCE, 0x50, 0x22, 0x60, 0xD7, 0x77, - 0xA4, 0xFE, 0xC3, 0x79, 0x52, 0xFF, 0x11, 0xB8, 0xD2, 0xA2, 0x42, 0xC4, 0x5B, 0x60, 0xC4, 0x7B, - 0x73, 0xD1, 0x8C, 0x86, 0x16, 0x0F, 0x16, 0xEA, 0x16, 0x0F, 0x1A, 0xEA, 0x34, 0xBE, 0x43, 0x4D, - 0x48, 0xA1, 0x62, 0x06, 0xCB, 0x01, 0x59, 0x2D, 0xAB, 0x4E, 0x90, 0xEB, 0xDD, 0xD4, 0x89, 0x72, - 0x82, 0x8C, 0x64, 0x90, 0x1B, 0x47, 0xAB, 0x22, 0xA3, 0xFB, 0x5D, 0xD6, 0x1D, 0x16, 0x51, 0x5B, - 0xC7, 0x69, 0x3C, 0xE3, 0xFA, 0xD3, 0x62, 0x65, 0x94, 0x56, 0x06, 0x87, 0x03, 0x5D, 0xBC, 0x7B, - 0xD9, 0x64, 0xBA, 0x20, 0xFA, 0x7D, 0x18, 0x3F, 0x0A, 0xB9, 0x7E, 0xE8, 0x58, 0x67, 0x13, 0xA7, - 0x7C, 0xB0, 0x43, 0x20, 0x7D, 0xF2, 0x2D, 0x71, 0x7C, 0xED, 0xC2, 0xF5, 0xF8, 0xBB, 0x18, 0x1B, - 0xD1, 0x1A, 0xED, 0xF9, 0x61, 0x54, 0xC6, 0x98, 0x7E, 0x68, 0x7D, 0x2D, 0x57, 0x96, 0xE7, 0xB9, - 0x5E, 0x69, 0x95, 0x71, 0x38, 0x98, 0x56, 0xB4, 0xDF, 0xD1, 0xA3, 0x46, 0xD4, 0x25, 0x7A, 0x7D, - 0x18, 0x8D, 0x85, 0x3C, 0x3F, 0xB4, 0xD2, 0xAE, 0xE6, 0xB6, 0xB5, 0x2E, 0xAD, 0x32, 0x0A, 0xA5, - 0x4F, 0x3E, 0xB6, 0xBF, 0x86, 0xBF, 0x8D, 0xA8, 0x8B, 0xF5, 0xF8, 0x30, 0xCA, 0xE2, 0xDC, 0x3E, - 0xB4, 0xAA, 0xA6, 0xEB, 0xF2, 0xE1, 0x10, 0x60, 0xF4, 0xC9, 0xAB, 0xF7, 0xCD, 0xE4, 0x7E, 0xD8, - 0x99, 0xA2, 0x86, 0x6A, 0xE9, 0x83, 0x32, 0xF5, 0xD0, 0xDA, 0xB8, 0xAE, 0xA0, 0x8D, 0x6B, 0x24, - 0xFC, 0xA7, 0x86, 0xB4, 0x71, 0xAD, 0xAE, 0x8D, 0x7B, 0xF6, 0x97, 0xEB, 0xC7, 0xA0, 0x1F, 0xFA, - 0xB0, 0xDF, 0xD4, 0x28, 0x3F, 0x1C, 0x09, 0x40, 0xDC, 0x34, 0x06, 0x47, 0xDA, 0x2B, 0xA3, 0x99, - 0x01, 0x29, 0xEC, 0xB7, 0x09, 0x17, 0x8A, 0x98, 0x7C, 0x68, 0x3D, 0xD9, 0xC4, 0xAC, 0x90, 0xE4, - 0x99, 0x9F, 0xF0, 0xC9, 0x39, 0x7C, 0xA2, 0xFC, 0x16, 0xB2, 0xBD, 0xCB, 0xD7, 0xDA, 0x37, 0xE2, - 0xF4, 0xA1, 0x0A, 0x43, 0x49, 0x9A, 0x92, 0xF3, 0xA6, 0xFE, 0x68, 0x57, 0xDB, 0x32, 0x00, 0xF3, - 0x0E, 0x75, 0x33, 0x37, 0x66, 0xE4, 0x93, 0x49, 0x82, 0x2A, 0xEB, 0xFE, 0x31, 0x58, 0x7D, 0xF2, - 0x35, 0x9C, 0x68, 0xAF, 0xE9, 0x49, 0x53, 0xE9, 0x78, 0xBC, 0xFF, 0x26, 0x3C, 0x2A, 0xC1, 0xEF, - 0x43, 0x3B, 0x15, 0x25, 0x06, 0x26, 0x3F, 0xEE, 0xC2, 0xA9, 0xF4, 0xDC, 0x53, 0x02, 0x9C, 0xAB, - 0xEF, 0x7B, 0x76, 0xDE, 0xAC, 0x02, 0x23, 0x22, 0x1A, 0xD3, 0x61, 0x8C, 0xEF, 0x26, 0xD4, 0x18, - 0x7F, 0xF8, 0x91, 0xBF, 0x36, 0xB8, 0x48, 0x53, 0xFC, 0x21, 0x3C, 0xBA, 0xDD, 0x88, 0x04, 0x6D, - 0x3F, 0xB0, 0x6C, 0x5B, 0x9F, 0xBC, 0x21, 0x81, 0xF6, 0x01, 0x0F, 0x15, 0x9F, 0xBA, 0x8B, 0x61, - 0x11, 0xCF, 0xDC, 0x06, 0x1E, 0x31, 0x56, 0xFA, 0xE4, 0x03, 0xBE, 0x50, 0x19, 0x70, 0xE1, 0x59, - 0x79, 0x64, 0x54, 0x88, 0xC4, 0xF1, 0x5C, 0x20, 0x2A, 0x54, 0x12, 0x7F, 0x51, 0xA3, 0xAE, 0x89, - 0xA3, 0xD8, 0xB5, 0xC9, 0x25, 0x6D, 0xAC, 0xA1, 0x95, 0x15, 0x77, 0x17, 0x7F, 0x1C, 0x30, 0xDF, - 0x39, 0xE8, 0x03, 0xC0, 0xF8, 0x44, 0x6F, 0xF2, 0x7D, 0xEB, 0xA0, 0x56, 0xF6, 0x7C, 0xFF, 0xE4, - 0xCC, 0x5F, 0x1B, 0x8E, 0x68, 0x46, 0x1F, 0x7E, 0xBF, 0xE6, 0x4F, 0x33, 0x4F, 0x5D, 0xDB, 0xFC, - 0x2A, 0xB6, 0xF0, 0xFF, 0x21, 0x7C, 0x2C, 0x17, 0x41, 0xC0, 0x2E, 0x04, 0x86, 0x02, 0xE5, 0x2E, - 0x3D, 0x81, 0x9E, 0x3D, 0x41, 0x8D, 0x6F, 0xEB, 0xCA, 0xD1, 0x6E, 0xC6, 0x93, 0xC4, 0x1E, 0x59, - 0x84, 0x92, 0x94, 0x3D, 0x61, 0x2E, 0x7D, 0xAE, 0xF8, 0x7B, 0xB2, 0xB0, 0x7C, 0xA0, 0x51, 0x03, - 0xBB, 0x38, 0xA4, 0xCF, 0x62, 0x32, 0x5B, 0x56, 0x7B, 0xCE, 0x37, 0xDE, 0x25, 0x7F, 0x4D, 0x81, - 0xF4, 0xF1, 0xED, 0x52, 0xA9, 0x63, 0xFA, 0x59, 0xEB, 0x24, 0xC6, 0x22, 0xAB, 0x7F, 0xDA, 0x6E, - 0x2F, 0x87, 0xF8, 0x54, 0xA9, 0x26, 0x58, 0x3B, 0x3B, 0x5C, 0x0E, 0x8B, 0x9E, 0xDA, 0x2B, 0x7C, - 0x24, 0x18, 0x38, 0xAD, 0xFC, 0x44, 0x30, 0x4A, 0x69, 0x02, 0xD4, 0x1C, 0x68, 0xEF, 0x0C, 0xFF, - 0xF3, 0x81, 0xF6, 0x11, 0x87, 0xF8, 0x06, 0x1F, 0x0C, 0x46, 0xDA, 0x0D, 0xD3, 0xF4, 0x32, 0x1F, - 0x0E, 0x1E, 0x26, 0x1E, 0x0E, 0x1E, 0x8B, 0x87, 0x83, 0xA3, 0x95, 0xAA, 0xEE, 0xCD, 0xA0, 0xDB, - 0x3D, 0x56, 0x61, 0x5D, 0xF1, 0x01, 0xE1, 0x7B, 0xE1, 0x69, 0x05, 0xD2, 0x54, 0xE4, 0x69, 0x28, - 0x78, 0x8A, 0x6D, 0xD8, 0xBF, 0x99, 0xCF, 0x1F, 0x1B, 0x47, 0x7C, 0xC9, 0xB0, 0x3A, 0x4B, 0xDD, - 0x7E, 0xD3, 0x4F, 0x71, 0x53, 0xE3, 0xBE, 0xAF, 0x87, 0xB8, 0x69, 0x93, 0x74, 0x34, 0x1C, 0xE5, - 0x06, 0x43, 0x0A, 0xC2, 0x9C, 0xFE, 0xCD, 0x7D, 0x3A, 0xFD, 0xA2, 0x86, 0xD3, 0x2F, 0xB6, 0x9C, - 0xBE, 0x41, 0x6F, 0x17, 0x84, 0xFF, 0xD5, 0x3C, 0x5E, 0xF0, 0x55, 0xC2, 0xEB, 0xA5, 0x7C, 0x75, - 0xBB, 0xF7, 0xEA, 0xF7, 0x85, 0x4E, 0x12, 0x1A, 0xC3, 0x9B, 0xFB, 0x74, 0x92, 0x0C, 0xD3, 0xAD, - 0x64, 0xA7, 0x3C, 0xEC, 0x4C, 0x9A, 0x19, 0x97, 0x68, 0x36, 0x15, 0x57, 0x28, 0xEF, 0x1D, 0x1F, - 0xD7, 0x1D, 0x0C, 0x79, 0xEA, 0x74, 0x1F, 0xEA, 0x51, 0x7F, 0x61, 0x44, 0x66, 0x93, 0xFB, 0x49, - 0xCC, 0xD6, 0xB1, 0x14, 0x57, 0x39, 0x31, 0x7B, 0xFF, 0xED, 0xB7, 0xE5, 0x72, 0xB1, 0x78, 0x2F, - 0x8F, 0x24, 0x17, 0xCB, 0x2D, 0x53, 0xDF, 0xAE, 0xE1, 0x06, 0x52, 0x5D, 0xC9, 0x74, 0x23, 0x70, - 0x7D, 0xF2, 0x8A, 0x1E, 0x6B, 0x31, 0x89, 0x95, 0x32, 0x5E, 0xE5, 0x59, 0x27, 0x05, 0x8C, 0xD5, - 0xB1, 0x23, 0x12, 0xD2, 0xBA, 0x51, 0xC4, 0x95, 0x53, 0xBB, 0x8E, 0xB1, 0xA7, 0xCE, 0x54, 0x6D, - 0x9F, 0xA0, 0x4D, 0x8A, 0x52, 0xE1, 0xD5, 0xC6, 0xAE, 0xAC, 0x36, 0x0E, 0xAB, 0x4F, 0xDE, 0xC1, - 0x64, 0xDC, 0x5A, 0xDB, 0x16, 0xCC, 0x3C, 0x5A, 0x5D, 0xAD, 0xAD, 0x0D, 0x7A, 0xFB, 0x0D, 0x8E, - 0x91, 0x82, 0x8C, 0x92, 0x6F, 0xCB, 0xE9, 0x45, 0x0F, 0xB3, 0x0D, 0xEE, 0xE9, 0x75, 0x39, 0x75, - 0x15, 0xE2, 0xB9, 0x6E, 0x50, 0x59, 0x1B, 0x02, 0x18, 0x12, 0x15, 0x38, 0xD2, 0x22, 0x9D, 0xA8, - 0xAB, 0x22, 0xB6, 0xB5, 0x36, 0xC2, 0xA6, 0xA6, 0x0E, 0xA5, 0x8D, 0xB4, 0xB8, 0x3F, 0x45, 0x75, - 0x07, 0xAA, 0x04, 0x6B, 0x4F, 0x9F, 0xF4, 0x4B, 0x60, 0x28, 0xDE, 0x87, 0xCA, 0x5A, 0xD5, 0x77, - 0x22, 0xFF, 0xB6, 0x7A, 0xEC, 0xE3, 0xB0, 0x90, 0x76, 0xDF, 0x42, 0xAA, 0xBB, 0xD2, 0x5E, 0x43, - 0x5F, 0xD4, 0x89, 0x7A, 0xA3, 0x26, 0x9D, 0x48, 0x90, 0x51, 0xDD, 0x89, 0x7A, 0x8F, 0xC3, 0x87, - 0x50, 0x1F, 0x6B, 0x8F, 0x54, 0xD6, 0x07, 0x87, 0xD5, 0x27, 0xEF, 0x3D, 0x82, 0xCA, 0xA8, 0xE4, - 0x3D, 0x21, 0x92, 0x6A, 0xCE, 0x73, 0x0F, 0x8E, 0xD2, 0xEB, 0x8C, 0xEA, 0xE1, 0xE8, 0x97, 0x73, - 0x36, 0x09, 0x86, 0x81, 0x3C, 0x08, 0x0C, 0x1E, 0xA7, 0x0B, 0x13, 0xDB, 0x1C, 0x55, 0x77, 0x62, - 0x01, 0x8D, 0xB3, 0x67, 0x38, 0xAC, 0x6C, 0x38, 0x31, 0x44, 0x8F, 0x2A, 0xEE, 0xD6, 0xC4, 0x70, - 0x1F, 0xC6, 0x34, 0xE9, 0x97, 0x32, 0xE9, 0x66, 0x4C, 0x67, 0x8D, 0x2F, 0x17, 0x24, 0x6A, 0x7B, - 0x3E, 0x29, 0xB2, 0x78, 0xA4, 0x61, 0xB0, 0x10, 0x69, 0xE8, 0xEB, 0x05, 0xE9, 0x5E, 0xF7, 0x46, - 0x73, 0x5E, 0x41, 0xC0, 0xF6, 0x32, 0x4B, 0xF9, 0xAD, 0x01, 0x31, 0xE6, 0x64, 0x29, 0x70, 0xC8, - 0xEB, 0x63, 0xCB, 0x7F, 0x29, 0x61, 0x95, 0xC7, 0x0A, 0x0E, 0xCC, 0x55, 0x18, 0x0E, 0xDD, 0xCD, - 0xE6, 0xBF, 0x21, 0x15, 0x35, 0xC6, 0xEE, 0x06, 0x13, 0xE0, 0xD8, 0xB2, 0x11, 0x55, 0x00, 0x0B, - 0x9A, 0x01, 0x9B, 0xF1, 0x95, 0x58, 0x09, 0xCA, 0x6C, 0x72, 0x3F, 0xF3, 0xFC, 0x6B, 0xCB, 0x29, - 0x3F, 0xCF, 0xFF, 0xC9, 0x72, 0x4C, 0xF7, 0xBA, 0xDC, 0x54, 0x3F, 0xDE, 0xD1, 0x9F, 0x60, 0xAA, - 0x4F, 0x07, 0x4B, 0x5C, 0x2C, 0x6C, 0x7B, 0x44, 0xED, 0xA5, 0x33, 0x69, 0x21, 0x33, 0xE8, 0x1B, - 0x5C, 0x6A, 0x03, 0x14, 0xBE, 0x46, 0x97, 0x1E, 0x77, 0xED, 0x2F, 0x3F, 0x9F, 0xC6, 0x93, 0x5D, - 0x4E, 0x81, 0x9A, 0xC3, 0x0C, 0x25, 0x85, 0xC7, 0x07, 0xAF, 0xA5, 0xFE, 0xB2, 0xCD, 0xCF, 0xED, - 0x83, 0xF3, 0x73, 0x1F, 0x01, 0x99, 0x38, 0x66, 0x65, 0xCB, 0x42, 0xD8, 0xC8, 0xAE, 0x2E, 0x1D, - 0xB3, 0x51, 0xAB, 0x62, 0xBD, 0x57, 0xD6, 0x41, 0xBF, 0x7B, 0x74, 0xF2, 0xB8, 0xCC, 0x0A, 0x19, - 0xAA, 0x61, 0x54, 0xBD, 0xD1, 0xF0, 0xE8, 0xF1, 0xD8, 0x95, 0x3B, 0x9F, 0xB3, 0x15, 0xAE, 0x6A, - 0xA6, 0xC5, 0xC1, 0x6F, 0xE8, 0xA3, 0xB4, 0x3E, 0x69, 0x36, 0x5E, 0x85, 0x9D, 0xAB, 0xE9, 0x62, - 0x20, 0xD1, 0xC5, 0xF8, 0x71, 0x99, 0x16, 0xE7, 0x48, 0xD5, 0xBA, 0x24, 0x1C, 0xDD, 0x13, 0x43, - 0xF7, 0x61, 0x5A, 0x81, 0x1B, 0x18, 0x76, 0x65, 0xCB, 0x62, 0xD0, 0x60, 0x58, 0x3F, 0xE0, 0x81, - 0xF6, 0x01, 0xF8, 0x6C, 0xD4, 0xB8, 0x44, 0xFF, 0xD5, 0x03, 0xD7, 0xA0, 0xFB, 0xC8, 0xC6, 0x43, - 0xC6, 0x52, 0xAD, 0xD0, 0x35, 0x1E, 0x3E, 0x1E, 0xFB, 0x72, 0x37, 0x01, 0x5E, 0xAD, 0x1C, 0xBA, - 0x18, 0x38, 0x86, 0x2E, 0x7A, 0xD4, 0xBC, 0x89, 0x85, 0x14, 0xD4, 0x18, 0x1C, 0x87, 0x0F, 0xBF, - 0x7E, 0xFD, 0x8B, 0x84, 0xA7, 0x5A, 0x46, 0x36, 0x78, 0x2C, 0x41, 0x6C, 0x66, 0x28, 0xBF, 0x88, - 0x8D, 0x22, 0x8B, 0x67, 0xF3, 0x0C, 0x16, 0xE6, 0x70, 0xEC, 0xA0, 0xD1, 0x0A, 0x86, 0xE8, 0xFC, - 0xDE, 0x97, 0xEC, 0x42, 0xAE, 0x1E, 0x53, 0xBD, 0x62, 0x6A, 0x39, 0x4E, 0x55, 0x35, 0x71, 0x58, - 0x7D, 0xF2, 0x8A, 0x1D, 0x34, 0xBB, 0xB8, 0xCA, 0x3B, 0xBF, 0xFF, 0x95, 0x55, 0xC1, 0x55, 0xD3, - 0x6A, 0x4A, 0x15, 0x31, 0xBC, 0xF0, 0x4B, 0x11, 0x3A, 0xDF, 0xAD, 0x18, 0x7D, 0x39, 0xE2, 0xF1, - 0x94, 0x34, 0x16, 0xC6, 0x0A, 0x9F, 0x30, 0x2E, 0x5B, 0xD4, 0x78, 0x83, 0x60, 0xE5, 0x6A, 0x1A, - 0xC9, 0x9E, 0x1E, 0x77, 0x55, 0x63, 0x92, 0x7C, 0xB5, 0x24, 0x10, 0xDE, 0x9E, 0x5A, 0x86, 0x8F, - 0x4F, 0xE3, 0xC3, 0xB1, 0xF6, 0x0A, 0x8E, 0xB5, 0xF7, 0xF6, 0x26, 0x7C, 0x37, 0xAE, 0xCC, 0x21, - 0xE2, 0x3B, 0x9B, 0x22, 0x0C, 0x59, 0xDB, 0xD7, 0xE9, 0x86, 0x2E, 0xFE, 0x14, 0x16, 0x1C, 0xE3, - 0x3E, 0xA6, 0xD1, 0xF0, 0xB8, 0xAB, 0x6B, 0x2C, 0x2B, 0xE6, 0xCF, 0x90, 0xF8, 0x9F, 0xE9, 0x06, - 0xA7, 0x5E, 0x48, 0xA0, 0xCC, 0x01, 0xE2, 0xF4, 0x86, 0x04, 0x52, 0xFB, 0xAD, 0xB3, 0xEF, 0x68, - 0x5B, 0x22, 0x3D, 0x21, 0x8E, 0xAE, 0xD4, 0x10, 0x12, 0x2F, 0xC3, 0x64, 0xED, 0x55, 0x9E, 0x86, - 0x91, 0x0B, 0xA2, 0x27, 0x15, 0x04, 0xEE, 0xF3, 0xBA, 0x5F, 0x9E, 0xFA, 0x82, 0xA7, 0x9E, 0x1A, - 0x4F, 0xFD, 0x1A, 0x3C, 0xF5, 0x1B, 0xE2, 0x69, 0x20, 0x78, 0xEA, 0xAB, 0xF1, 0x34, 0xA8, 0xC1, - 0xD3, 0xA0, 0x21, 0x9E, 0x86, 0x82, 0xA7, 0x81, 0x1A, 0x4F, 0xC3, 0x1A, 0x3C, 0x0D, 0x1B, 0xE2, - 0x69, 0x24, 0x78, 0x1A, 0xAA, 0xF1, 0x34, 0xAA, 0xC1, 0xD3, 0xA8, 0x21, 0x9E, 0xC6, 0x82, 0xA7, - 0x91, 0x1A, 0x4F, 0xE3, 0x1A, 0x3C, 0x8D, 0x1B, 0xE2, 0xE9, 0x48, 0xF0, 0x34, 0x56, 0xE3, 0xE9, - 0xA8, 0x06, 0x4F, 0x47, 0x0D, 0xF1, 0x74, 0x2C, 0x78, 0x3A, 0x52, 0xE3, 0xE9, 0xB8, 0x06, 0x4F, - 0xC7, 0x0D, 0xF1, 0x74, 0x22, 0x78, 0x3A, 0x56, 0xE3, 0xE9, 0xA4, 0x06, 0x4F, 0x27, 0x0D, 0xF1, - 0x84, 0x8B, 0x72, 0x8C, 0xA9, 0x13, 0xC5, 0x41, 0xB7, 0x5B, 0x83, 0x2B, 0xA3, 0x29, 0xAE, 0xC2, - 0x54, 0xA2, 0xA7, 0x9A, 0x4B, 0xD4, 0x49, 0x26, 0xA6, 0x4D, 0xB1, 0x15, 0x65, 0x13, 0x8A, 0xE9, - 0x44, 0xAF, 0x4E, 0x3E, 0x31, 0x6B, 0x8A, 0xAD, 0x30, 0xA1, 0xE8, 0x29, 0x66, 0x14, 0xBD, 0x3A, - 0x29, 0x85, 0xD9, 0x14, 0x5B, 0x61, 0x4E, 0xD1, 0x53, 0x4C, 0x2A, 0x7A, 0x75, 0xB2, 0x0A, 0xD2, - 0x14, 0x5B, 0x61, 0x5A, 0xD1, 0x53, 0xCC, 0x2B, 0x7A, 0x75, 0x12, 0x8B, 0x79, 0x53, 0x6C, 0x85, - 0x99, 0x45, 0x4F, 0x31, 0xB5, 0xE8, 0xD5, 0xC8, 0x2D, 0x4E, 0xE4, 0x13, 0xB1, 0x7B, 0x65, 0x8B, - 0x04, 0x7C, 0x8A, 0x1C, 0x4D, 0xDA, 0x94, 0x1E, 0x3D, 0xE1, 0x40, 0xF8, 0x6C, 0x14, 0x13, 0xC8, - 0x85, 0xEB, 0xCC, 0xAD, 0x45, 0x58, 0x64, 0x78, 0x34, 0x4F, 0x49, 0xF8, 0xB1, 0xB7, 0xF2, 0x2A, - 0x17, 0x1A, 0x3E, 0xBC, 0xBE, 0x2C, 0x57, 0x66, 0x88, 0xF7, 0xF2, 0x27, 0x2A, 0x32, 0x00, 0xD9, - 0xFD, 0xF8, 0x27, 0x02, 0x94, 0xEA, 0x0A, 0x14, 0xA8, 0x4C, 0x45, 0x61, 0x14, 0xAF, 0x28, 0x8C, - 0x95, 0x2B, 0x0A, 0x8C, 0xB8, 0xDD, 0xD4, 0x12, 0x00, 0xF7, 0x80, 0x7D, 0xD7, 0x40, 0x9D, 0xE9, - 0x41, 0x75, 0xA6, 0x47, 0x65, 0x98, 0x1E, 0x54, 0x61, 0xBA, 0xC2, 0xD3, 0x8D, 0x8A, 0x72, 0x02, - 0x7A, 0xBF, 0xB6, 0x6E, 0x88, 0xA9, 0xFD, 0xA2, 0x2E, 0xAA, 0x5E, 0x75, 0x51, 0x1D, 0x95, 0x11, - 0x55, 0x6F, 0x87, 0xF6, 0x31, 0x12, 0x7C, 0xFF, 0xA8, 0xCE, 0xF7, 0xA8, 0x3A, 0xDF, 0x83, 0x32, - 0x7C, 0x8F, 0x76, 0xC8, 0xF7, 0x50, 0xF0, 0xFD, 0x51, 0x9D, 0xEF, 0x61, 0x75, 0xBE, 0x87, 0x65, - 0xF8, 0x1E, 0xEE, 0x90, 0xEF, 0x3E, 0x04, 0x9B, 0x1F, 0x3F, 0x6A, 0x3F, 0x2C, 0x3D, 0xE2, 0x2F, - 0x8B, 0x2B, 0x71, 0x0C, 0xA2, 0xEA, 0xD8, 0x3E, 0x6A, 0x60, 0xEE, 0x86, 0x14, 0x0E, 0xE2, 0x3C, - 0x15, 0xE6, 0xCD, 0x0C, 0x42, 0xE5, 0xC3, 0x41, 0x72, 0x9E, 0xE4, 0x33, 0xB7, 0x9E, 0x2A, 0x53, - 0xBB, 0x8B, 0x61, 0xC7, 0xFA, 0xE4, 0xED, 0xA6, 0xC4, 0xF8, 0x76, 0x5C, 0xDD, 0x9E, 0xD5, 0x2B, - 0xE6, 0x8C, 0xAE, 0x9D, 0xD9, 0xF3, 0x09, 0xE5, 0x19, 0xF2, 0x32, 0x5F, 0x41, 0xED, 0xD5, 0xAB, - 0x10, 0xA3, 0x06, 0xAA, 0xE4, 0x18, 0xE9, 0x8F, 0x18, 0x3B, 0x3F, 0x22, 0x43, 0x1A, 0x64, 0x2C, - 0x25, 0x06, 0xA3, 0xA3, 0x92, 0xDA, 0x3C, 0xAE, 0x18, 0x9D, 0x90, 0xC6, 0x9D, 0xA9, 0x13, 0xA7, - 0x1E, 0x28, 0x80, 0x8F, 0x15, 0x04, 0x30, 0xAE, 0x2E, 0x80, 0x52, 0x99, 0x0B, 0xD2, 0xB8, 0x3B, - 0x01, 0x74, 0x99, 0x00, 0x3E, 0x44, 0x6F, 0xA6, 0xCE, 0x31, 0xE8, 0x1A, 0x15, 0xA8, 0x51, 0x03, - 0x6B, 0x24, 0x18, 0x69, 0x7B, 0xC2, 0xA2, 0x81, 0xA3, 0x72, 0x0A, 0xED, 0x97, 0xCD, 0xAF, 0xE4, - 0xC5, 0x4F, 0x85, 0xFC, 0x7B, 0x97, 0x09, 0x56, 0xBF, 0x2B, 0x2C, 0xBA, 0xBC, 0x00, 0xBA, 0xD5, - 0x05, 0xD0, 0x2B, 0x25, 0x80, 0xEE, 0xE3, 0x4A, 0xC6, 0xC7, 0xDB, 0x1F, 0x13, 0x2E, 0x96, 0x56, - 0x59, 0xF7, 0x8F, 0x8D, 0x66, 0xFD, 0x32, 0xC2, 0xDA, 0xA9, 0xF7, 0x0F, 0x22, 0xCE, 0xB5, 0x5F, - 0xB4, 0xE4, 0xD6, 0xD7, 0xBC, 0x38, 0x50, 0xBD, 0x08, 0x38, 0x6A, 0x60, 0xBD, 0x0A, 0x29, 0x3C, - 0x91, 0x70, 0x56, 0x32, 0xC0, 0x9F, 0x54, 0x77, 0x87, 0x52, 0x1A, 0x46, 0x5A, 0x77, 0xA7, 0xE2, - 0x51, 0x42, 0x10, 0xEC, 0x43, 0xE6, 0x2A, 0x2A, 0xAE, 0x5E, 0x39, 0x1C, 0x35, 0xB0, 0xD4, 0x85, - 0x14, 0x1E, 0x4B, 0x38, 0x2B, 0xA9, 0xE2, 0xB2, 0x29, 0xE9, 0x71, 0xC5, 0xA9, 0x65, 0x6F, 0x97, - 0x39, 0x29, 0x56, 0xBB, 0x63, 0x82, 0x88, 0x7F, 0x65, 0x22, 0x4F, 0xC1, 0xD5, 0x2B, 0xDE, 0xA3, - 0x9A, 0xEB, 0xB3, 0xBB, 0x8B, 0xE4, 0x47, 0xB2, 0x4F, 0x90, 0x17, 0xDB, 0x41, 0xD9, 0x5C, 0xB6, - 0x5B, 0x71, 0xE0, 0xDB, 0x69, 0x2A, 0x0B, 0xBD, 0x43, 0xD6, 0xB3, 0xCD, 0x7D, 0x8E, 0x09, 0x54, - 0x5F, 0x79, 0x1B, 0x35, 0xB0, 0x3D, 0x04, 0x29, 0xEC, 0xEB, 0x93, 0x8F, 0x25, 0x99, 0xAA, 0x53, - 0x3F, 0xA8, 0xBC, 0x3F, 0xA4, 0xB9, 0xD2, 0xFB, 0x6C, 0x75, 0x53, 0xBE, 0xF4, 0x7E, 0xF1, 0xEE, - 0xE7, 0x72, 0xA5, 0xF7, 0x78, 0x2F, 0xCD, 0x95, 0xDE, 0xAB, 0xD9, 0x4C, 0xA9, 0x8D, 0xB2, 0xC0, - 0x18, 0xBE, 0x3F, 0x62, 0x66, 0xF9, 0xB4, 0x4B, 0x10, 0x8C, 0xF6, 0x5E, 0x9C, 0x86, 0x22, 0x8A, - 0x3D, 0xB1, 0x9F, 0x6C, 0x9F, 0x67, 0x3D, 0x83, 0x9C, 0xB0, 0xA0, 0xB6, 0x11, 0x76, 0xFB, 0x75, - 0x28, 0x9D, 0x31, 0xFF, 0x10, 0x57, 0x8D, 0x47, 0xEB, 0xB3, 0x5E, 0x1A, 0xD0, 0x39, 0x2A, 0x89, - 0x7B, 0xE7, 0x8F, 0xDC, 0x4F, 0x52, 0x8A, 0xEA, 0x51, 0xFD, 0xF4, 0xF0, 0x5C, 0xB9, 0x4E, 0x4E, - 0xC1, 0xCA, 0x44, 0xF3, 0x41, 0xBC, 0xD4, 0xA2, 0x1E, 0xCD, 0x19, 0x79, 0xBB, 0x89, 0xE6, 0x88, - 0x3B, 0xC1, 0x7B, 0x89, 0xAC, 0x86, 0xC1, 0x96, 0x13, 0x80, 0x7C, 0x13, 0x85, 0x82, 0x00, 0xB2, - 0x24, 0x70, 0x2F, 0x22, 0xE8, 0x53, 0x09, 0xF4, 0x53, 0xDA, 0xCF, 0x08, 0xFC, 0xB4, 0x7D, 0xD5, - 0xB8, 0x3F, 0x68, 0xA0, 0x36, 0x81, 0xE2, 0x4A, 0x70, 0x54, 0x52, 0xA7, 0xE5, 0x16, 0x07, 0x13, - 0x3A, 0x2D, 0x67, 0xD4, 0x3B, 0x5B, 0x1D, 0x04, 0xE4, 0x03, 0x2A, 0x80, 0x81, 0xB2, 0x4A, 0xAB, - 0x4F, 0x33, 0x07, 0x0D, 0xE4, 0x27, 0x28, 0xAD, 0x04, 0x47, 0x25, 0x55, 0x5A, 0x6E, 0xE9, 0x33, - 0xA1, 0x52, 0xF5, 0xF9, 0x25, 0x27, 0x72, 0x67, 0x2A, 0x1D, 0x52, 0x01, 0x0C, 0x95, 0x55, 0x5A, - 0x7D, 0xD6, 0x31, 0x68, 0x60, 0xF7, 0x2E, 0x4A, 0x2B, 0xC1, 0x51, 0x49, 0x95, 0x96, 0x5B, 0xB2, - 0x4B, 0xA8, 0x54, 0x7D, 0x3E, 0xC9, 0x89, 0xDC, 0x99, 0x4A, 0x47, 0x54, 0x00, 0x23, 0x65, 0x95, - 0x56, 0xAF, 0x14, 0x0C, 0x1A, 0x28, 0x06, 0xA1, 0xB4, 0x12, 0x1C, 0x95, 0x54, 0x69, 0xB9, 0xD5, - 0xE7, 0x84, 0x4A, 0xD5, 0xD7, 0x39, 0x38, 0x91, 0x3B, 0x53, 0xE9, 0x98, 0x0A, 0x60, 0xAC, 0xAC, - 0xD2, 0xEA, 0xFB, 0xAB, 0x06, 0x0D, 0xEC, 0xDD, 0x46, 0x69, 0x25, 0x38, 0x2A, 0xA9, 0xD2, 0x72, - 0xA5, 0xDB, 0x84, 0x4A, 0xD5, 0x57, 0x6E, 0x38, 0x91, 0x3B, 0x53, 0xE9, 0x11, 0x15, 0xC0, 0x91, - 0xB2, 0x4A, 0xAB, 0x6F, 0x5D, 0x1F, 0x34, 0x50, 0xCF, 0x43, 0x69, 0x25, 0x38, 0x2A, 0xA9, 0xD2, - 0x72, 0x15, 0x9C, 0x84, 0x4A, 0xD5, 0xF7, 0x4E, 0x71, 0x22, 0x77, 0xA6, 0xD2, 0x63, 0x2A, 0x80, - 0x63, 0x65, 0x95, 0x56, 0xDF, 0xB9, 0x3F, 0x68, 0x60, 0xE7, 0x3E, 0x4A, 0x2B, 0xC1, 0x51, 0x49, - 0x95, 0x96, 0xAB, 0xCD, 0x26, 0x54, 0xAA, 0xBE, 0xDD, 0x89, 0x13, 0xB9, 0x33, 0x95, 0x9E, 0x50, - 0x01, 0x9C, 0x28, 0xAB, 0xB4, 0xFA, 0x96, 0x81, 0x41, 0x03, 0x9B, 0x5F, 0x50, 0x5A, 0xDD, 0x38, - 0x47, 0x25, 0x55, 0x5A, 0x6E, 0x81, 0x71, 0x90, 0xB1, 0xF5, 0x45, 0x41, 0xA5, 0x59, 0x0B, 0x8C, - 0x8F, 0xA0, 0x7E, 0x67, 0x5C, 0x4F, 0x2B, 0x7C, 0xFA, 0xE5, 0xE5, 0x4F, 0xAF, 0xB2, 0x0B, 0xFB, - 0x99, 0x55, 0xBC, 0x44, 0x5F, 0x8F, 0xBD, 0x8C, 0x17, 0x97, 0x17, 0x12, 0x0E, 0x5A, 0x66, 0x2F, - 0x4D, 0xD4, 0xB6, 0x98, 0xCF, 0xB7, 0x34, 0x06, 0x5C, 0xC2, 0xD2, 0x06, 0xC3, 0xAE, 0x3C, 0x69, - 0x29, 0xB0, 0x34, 0x4E, 0xE5, 0x6E, 0x82, 0x07, 0x22, 0x87, 0xB9, 0x38, 0xF2, 0xFE, 0xBD, 0xD2, - 0x9A, 0x0E, 0x03, 0x48, 0x86, 0x8F, 0x61, 0xF7, 0x44, 0x31, 0x7E, 0x80, 0x0C, 0xB2, 0x36, 0xC6, - 0xDF, 0x63, 0x00, 0x41, 0x1A, 0x07, 0x8C, 0xA9, 0x37, 0xCA, 0x4C, 0xA5, 0xAB, 0x00, 0xA5, 0x98, - 0xCA, 0xAA, 0xEC, 0xDC, 0x33, 0x53, 0x43, 0xC6, 0x54, 0x8E, 0x93, 0xA6, 0x98, 0x4A, 0xCF, 0x83, - 0x4B, 0x31, 0x95, 0x35, 0x11, 0x8E, 0x98, 0x7A, 0x0C, 0x81, 0x8E, 0xCC, 0x8C, 0xC5, 0xAC, 0x42, - 0xA8, 0xBB, 0xBC, 0x38, 0x7C, 0xF9, 0xE6, 0x42, 0xA3, 0x4B, 0x9A, 0xAE, 0x5D, 0x32, 0xE2, 0x25, - 0x3B, 0xFD, 0x53, 0xC5, 0x3C, 0x4A, 0x7A, 0x2C, 0xEA, 0xBD, 0xB9, 0x50, 0x0D, 0x78, 0x1C, 0xB2, - 0x4C, 0xC8, 0x1B, 0x75, 0x07, 0x55, 0x2A, 0x84, 0x21, 0x91, 0x3B, 0x0A, 0x7A, 0x14, 0x7D, 0x3F, - 0x92, 0xC1, 0x65, 0x39, 0x19, 0x94, 0xAA, 0x92, 0x26, 0x65, 0x50, 0x22, 0xEC, 0x0B, 0x22, 0x77, - 0x29, 0x03, 0x8C, 0x92, 0x97, 0x17, 0xDA, 0xFB, 0xBF, 0x6B, 0x97, 0x37, 0x6B, 0xD7, 0xDF, 0x78, - 0xA4, 0x30, 0xAA, 0x70, 0xB8, 0x64, 0x5C, 0x19, 0x8F, 0x46, 0x03, 0xD5, 0xC0, 0x32, 0xCA, 0x1E, - 0x02, 0xE6, 0xDD, 0x7B, 0x8C, 0x97, 0x94, 0xD0, 0x61, 0xC8, 0xE0, 0xF7, 0x04, 0x34, 0xAD, 0x14, - 0x37, 0x39, 0x60, 0x92, 0xC3, 0x5E, 0x17, 0xB7, 0x57, 0x2B, 0x32, 0x28, 0xCF, 0x28, 0x07, 0xF7, - 0x3A, 0x1C, 0x50, 0x2A, 0x47, 0x21, 0x7B, 0x1F, 0x7F, 0xF8, 0xA0, 0xC6, 0x58, 0xBA, 0x8E, 0x56, - 0x4E, 0x75, 0x59, 0x8F, 0x8C, 0xDE, 0xD3, 0xA0, 0x20, 0xBD, 0x71, 0x76, 0x08, 0xA1, 0x77, 0x1B, - 0x26, 0x43, 0x92, 0x67, 0x73, 0x6B, 0x01, 0x76, 0x2C, 0xEF, 0x83, 0x8A, 0x96, 0xBD, 0xEC, 0x14, - 0xBF, 0x51, 0xD9, 0x9E, 0x41, 0xF4, 0x07, 0x93, 0x40, 0xA7, 0x13, 0x02, 0x5F, 0x19, 0x0B, 0x12, - 0x5D, 0xD7, 0x58, 0x6C, 0xCF, 0x8B, 0xD9, 0x06, 0x43, 0x68, 0x5C, 0x11, 0xFE, 0x41, 0x4D, 0x6D, - 0xE9, 0x91, 0xF9, 0xB9, 0xFE, 0x45, 0x88, 0x93, 0x3F, 0x95, 0x87, 0x4D, 0x74, 0xCD, 0x74, 0xAF, - 0x1D, 0xDB, 0x35, 0x70, 0x3C, 0x30, 0xD6, 0x01, 0x50, 0xDA, 0xF9, 0x6D, 0x8D, 0x2F, 0xBE, 0x32, - 0xF0, 0x21, 0x2E, 0x23, 0xA7, 0x9F, 0x98, 0x55, 0xCC, 0x6C, 0xD7, 0x17, 0xB3, 0x39, 0x3C, 0x0C, - 0x3F, 0xC0, 0xF9, 0x3F, 0xFF, 0x5D, 0xB4, 0x83, 0xC0, 0x5A, 0x2D, 0x62, 0x02, 0xD0, 0x35, 0xDF, - 0x9B, 0x9D, 0xEB, 0x40, 0xA9, 0xE7, 0xFA, 0xBE, 0xEB, 0x59, 0x0B, 0x2B, 0x43, 0x3B, 0x59, 0xD2, - 0x3E, 0x94, 0x89, 0x3B, 0xD5, 0x58, 0xA2, 0xF8, 0x33, 0x7F, 0xE6, 0x59, 0xEB, 0x60, 0xF2, 0xC4, - 0x74, 0x67, 0x9B, 0x15, 0x71, 0x82, 0x8E, 0x61, 0x9A, 0x97, 0x57, 0x70, 0xF0, 0x2D, 0x7E, 0xAC, - 0x0D, 0x24, 0xDF, 0xDA, 0x7B, 0xFD, 0x8F, 0x77, 0x38, 0x3A, 0xE3, 0x35, 0x90, 0x17, 0x31, 0xF7, - 0x0E, 0xB4, 0xF9, 0xC6, 0x61, 0x03, 0x64, 0x8B, 0x60, 0xDB, 0x7D, 0xED, 0x0F, 0xC0, 0x78, 0x65, - 0x78, 0xDA, 0xD4, 0xF0, 0xC9, 0x5B, 0xD7, 0x0F, 0xB4, 0x73, 0x2D, 0xC4, 0x68, 0xBB, 0x33, 0xBA, - 0x9D, 0xA3, 0xC3, 0xF8, 0xE2, 0x2D, 0x19, 0xE3, 0x3F, 0x7A, 0x36, 0x34, 0x0D, 0xA1, 0x9E, 0x6B, - 0x7B, 0xA7, 0xC7, 0xBD, 0x3D, 0xB4, 0xDD, 0xB0, 0x8B, 0x39, 0x81, 0xE8, 0x0F, 0xED, 0x5A, 0x1B, - 0xCF, 0x3E, 0xD0, 0x66, 0xD3, 0xFD, 0x3F, 0x28, 0xF5, 0xF4, 0x32, 0x5E, 0xDB, 0xE7, 0xCC, 0x74, - 0x82, 0x25, 0x71, 0x5A, 0x11, 0x65, 0x1E, 0xF1, 0xD7, 0xAE, 0xE3, 0x13, 0x46, 0x1C, 0xFB, 0x59, - 0xF3, 0xE8, 0x7A, 0xC7, 0x0F, 0x8C, 0x60, 0xE3, 0x6B, 0x4F, 0xCF, 0xCF, 0xB5, 0x7E, 0xB7, 0x1B, - 0x6F, 0xA6, 0x41, 0x37, 0xE9, 0x76, 0x07, 0x5A, 0xEA, 0xC2, 0x0F, 0xE4, 0x26, 0xD8, 0xFF, 0x2A, - 0x84, 0xB9, 0xD3, 0x88, 0xED, 0x93, 0x04, 0x92, 0x10, 0x00, 0x5F, 0x27, 0xD7, 0xDA, 0x4F, 0x12, - 0xD8, 0x32, 0x8D, 0xC0, 0xD8, 0xFF, 0x23, 0xA1, 0x2F, 0xE8, 0x15, 0x28, 0x39, 0xD0, 0xE8, 0xAD, - 0xAF, 0x62, 0xB7, 0xEE, 0xF6, 0x3B, 0x20, 0x43, 0xE0, 0x37, 0x84, 0x26, 0x9E, 0x97, 0xA4, 0x98, - 0x42, 0xB7, 0x7B, 0x07, 0x1A, 0xDE, 0x49, 0xC2, 0xC6, 0x88, 0x7C, 0x22, 0xAE, 0x09, 0xA1, 0xE5, - 0xA3, 0x95, 0xA0, 0x64, 0xE8, 0xEE, 0x12, 0x2A, 0x82, 0x38, 0xF4, 0x3D, 0x59, 0x80, 0xC4, 0x16, - 0x07, 0x3C, 0x2C, 0x1D, 0xD0, 0x98, 0x74, 0xC0, 0xC2, 0x59, 0x4C, 0x6B, 0xE0, 0xD0, 0xBE, 0x6B, - 0x13, 0xB0, 0x89, 0x45, 0x6B, 0x8F, 0x7F, 0x0A, 0x14, 0xEC, 0x69, 0xAF, 0x7B, 0xB3, 0xF7, 0x1C, - 0xC0, 0x3B, 0x81, 0xFB, 0x21, 0xF0, 0x2C, 0x67, 0xD1, 0xEA, 0x8D, 0xF7, 0x23, 0x5C, 0xF4, 0x36, - 0x22, 0x4C, 0xDD, 0xA7, 0xD7, 0x69, 0x17, 0xE9, 0x1B, 0x2D, 0x7E, 0xFD, 0xF9, 0xDE, 0xFE, 0x1E, - 0x27, 0x9D, 0x9E, 0x83, 0xB1, 0xB5, 0xD8, 0xC1, 0x33, 0x4A, 0xE1, 0xBE, 0x76, 0x76, 0xC6, 0xBB, - 0x61, 0xAD, 0xF0, 0x22, 0x34, 0xA2, 0x7F, 0x52, 0xB7, 0x42, 0x43, 0xFC, 0xF5, 0xCB, 0x3F, 0x84, - 0xC5, 0xDE, 0x1D, 0x02, 0xD5, 0x2F, 0x30, 0x2E, 0x7F, 0xF9, 0x07, 0xFC, 0x7F, 0xF7, 0x8C, 0x86, - 0xE2, 0x2F, 0xFF, 0xC0, 0x3F, 0x77, 0xCF, 0xA0, 0x27, 0x38, 0xA6, 0xFD, 0xDD, 0xFD, 0x4A, 0xA5, - 0xB0, 0x2D, 0xBB, 0x45, 0xA6, 0xEC, 0x42, 0xA1, 0x95, 0xA6, 0x69, 0x91, 0x43, 0xD4, 0xAF, 0x91, - 0xF7, 0xB6, 0x66, 0xAE, 0x09, 0xCA, 0x09, 0xC0, 0x8E, 0x85, 0xCA, 0x6D, 0x50, 0x89, 0x10, 0x54, - 0x57, 0xA8, 0xDC, 0x9A, 0xD3, 0x96, 0x1A, 0x77, 0x94, 0xC8, 0x3C, 0x44, 0xCB, 0xB5, 0xE1, 0xF9, - 0xE4, 0x1B, 0x27, 0x68, 0x05, 0x09, 0x97, 0xC8, 0x90, 0xF8, 0x64, 0x92, 0x60, 0x01, 0x7F, 0x00, - 0x07, 0xED, 0xF6, 0xB8, 0xD2, 0x42, 0x53, 0x7B, 0x12, 0x5A, 0x61, 0x44, 0x29, 0xBB, 0x99, 0x61, - 0x85, 0x3F, 0xCF, 0xEC, 0xCF, 0xAD, 0x1B, 0xF8, 0x2F, 0x1D, 0x28, 0xB6, 0x44, 0x84, 0x8D, 0x5E, - 0xE0, 0x7F, 0x20, 0x17, 0xFC, 0x93, 0xA9, 0x1F, 0xC0, 0xFA, 0xDE, 0xB6, 0x5B, 0xEC, 0xB3, 0x5F, - 0xA0, 0x9A, 0x0D, 0x04, 0x21, 0xFF, 0x16, 0xC3, 0x81, 0xEB, 0x06, 0x9F, 0x0E, 0xB4, 0xB5, 0x07, - 0x84, 0xD1, 0x2F, 0x7D, 0xC0, 0x31, 0x20, 0x22, 0x0E, 0xFB, 0x5B, 0x48, 0xC1, 0xDA, 0xB6, 0x5F, - 0x30, 0xAC, 0x40, 0x02, 0x3B, 0x00, 0x4D, 0x6D, 0xD0, 0x62, 0xE0, 0xFF, 0xBB, 0x67, 0xD0, 0x09, - 0x1C, 0xC2, 0xFF, 0x77, 0xCF, 0xB0, 0x2B, 0xD4, 0x25, 0xF6, 0x78, 0xF7, 0x0C, 0x7A, 0x84, 0x13, - 0xF8, 0x1F, 0xDA, 0x60, 0xBF, 0xD8, 0x0A, 0xFF, 0xC2, 0x1D, 0xDA, 0x3F, 0xDE, 0xA4, 0x07, 0xEC, - 0x02, 0x3F, 0xCD, 0x63, 0x90, 0xBD, 0xE9, 0xBE, 0x45, 0xDF, 0x3C, 0xFE, 0xE9, 0x06, 0xD8, 0xA1, - 0x07, 0xB7, 0xE0, 0xF8, 0x8E, 0x89, 0xE7, 0xF8, 0xE7, 0x56, 0x98, 0x27, 0x5E, 0xE0, 0x47, 0x70, - 0x8D, 0xBE, 0x9D, 0x15, 0x2F, 0xB1, 0x03, 0x6C, 0x45, 0xDF, 0xA5, 0x49, 0x5B, 0xB1, 0x23, 0xB8, - 0xC6, 0xDF, 0xC0, 0x78, 0xA0, 0xF1, 0x77, 0xFC, 0x15, 0x0A, 0x27, 0x7A, 0x07, 0xDF, 0x0B, 0xFF, - 0x06, 0x19, 0x64, 0xA4, 0xA1, 0x54, 0xC2, 0xB3, 0xDB, 0xBB, 0x67, 0x04, 0xEF, 0x51, 0x22, 0xE1, - 0xF8, 0x96, 0x1F, 0xC3, 0x75, 0xA0, 0x0F, 0xEF, 0x08, 0x82, 0xE9, 0x85, 0xDB, 0xE8, 0x02, 0xB4, - 0x08, 0xF0, 0x3E, 0x27, 0x1E, 0xCE, 0x6E, 0xC3, 0x33, 0x84, 0xA6, 0xB0, 0x9C, 0x0D, 0x38, 0xBD, - 0x8D, 0x4E, 0xE1, 0x2E, 0xF2, 0x82, 0x0A, 0xE0, 0x3C, 0xDD, 0x3D, 0xE3, 0x3C, 0xA1, 0x16, 0xD9, - 0x51, 0x5A, 0xD4, 0x18, 0xF4, 0x02, 0x1E, 0x24, 0x5F, 0xB1, 0x1C, 0x24, 0x36, 0x3C, 0x42, 0x00, - 0xB8, 0xB4, 0x09, 0x1E, 0xBE, 0xBA, 0xFD, 0xC6, 0x6C, 0xED, 0xF1, 0x4F, 0xB7, 0xEE, 0x61, 0x88, - 0x8E, 0xC3, 0x74, 0x5C, 0x67, 0x66, 0x5B, 0x33, 0x8C, 0x04, 0xAD, 0x7D, 0xED, 0x7C, 0xC2, 0xC3, - 0x34, 0x7A, 0x2C, 0x34, 0x8F, 0x7B, 0x61, 0x26, 0x6A, 0x8F, 0x7F, 0x7C, 0x74, 0x6F, 0xBF, 0x43, - 0x1D, 0x8D, 0x3B, 0x13, 0xA2, 0xE0, 0x31, 0x46, 0x0D, 0x07, 0x36, 0x96, 0xE0, 0xD8, 0x0A, 0x07, - 0xB9, 0x48, 0x68, 0xEB, 0x18, 0x16, 0x8A, 0x26, 0x3E, 0x92, 0x74, 0x53, 0x83, 0x48, 0x4E, 0xD8, - 0x12, 0x11, 0xEA, 0x69, 0x3A, 0x42, 0x81, 0xAA, 0xBC, 0xA0, 0xB5, 0x77, 0xE9, 0x79, 0xAE, 0xF7, - 0xAF, 0xBD, 0xE7, 0xD8, 0xE8, 0xF9, 0xDE, 0xBF, 0x4F, 0xB5, 0xBD, 0xE7, 0xF1, 0x50, 0x75, 0x97, - 0x8E, 0x29, 0x4C, 0x63, 0x0B, 0x45, 0x8D, 0x2D, 0x62, 0x1A, 0x5B, 0xDC, 0xAF, 0xC6, 0xE2, 0x9F, - 0x8C, 0xAD, 0xA3, 0xB5, 0xF8, 0x27, 0x5A, 0x73, 0x34, 0x57, 0x08, 0xCF, 0x95, 0xC6, 0xB5, 0xB5, - 0x90, 0x69, 0xAB, 0x8A, 0x9A, 0xD8, 0x18, 0x0E, 0xDE, 0x43, 0xBC, 0xB7, 0x3F, 0xBC, 0xFB, 0x16, - 0xC7, 0x02, 0xB9, 0xCA, 0x42, 0x8D, 0xA5, 0xB3, 0x2D, 0x09, 0x06, 0x4C, 0x0E, 0x12, 0x23, 0x53, - 0x22, 0x49, 0x78, 0xBE, 0xA7, 0xB5, 0x28, 0x4A, 0x4C, 0x11, 0x0A, 0x0C, 0x81, 0x8F, 0x2C, 0x6A, - 0xBE, 0x8B, 0xA3, 0x89, 0x70, 0xDE, 0x08, 0x2A, 0xC7, 0x16, 0x10, 0x40, 0x49, 0x89, 0x0C, 0xF3, - 0x96, 0xC3, 0xC4, 0x06, 0xBD, 0xC6, 0x5D, 0x84, 0xFA, 0xAB, 0xAF, 0x1A, 0xD4, 0x44, 0x4C, 0x8F, - 0x62, 0x9B, 0x5F, 0x28, 0x1D, 0x1E, 0xF9, 0x95, 0x04, 0xC4, 0x3F, 0x05, 0x22, 0x31, 0x70, 0x3E, - 0x62, 0x94, 0xC0, 0x72, 0x2B, 0xC1, 0x42, 0x47, 0x1A, 0x25, 0x1C, 0xF4, 0xF3, 0x11, 0x19, 0x18, - 0xD4, 0xA8, 0xA0, 0xDF, 0x6B, 0x90, 0x60, 0x10, 0x63, 0x9A, 0x12, 0x12, 0xF1, 0xAD, 0x81, 0x6C, - 0x3C, 0x6A, 0xC4, 0x88, 0x37, 0xFC, 0x4B, 0xF0, 0xF0, 0x31, 0x54, 0x09, 0x0D, 0x7F, 0x3B, 0x7D, - 0x26, 0x16, 0x35, 0x62, 0xF8, 0x0B, 0xE1, 0x65, 0x3C, 0xF1, 0x31, 0x5B, 0x8D, 0x27, 0xFE, 0x1E, - 0xF3, 0x6C, 0x3C, 0x8A, 0xB2, 0xE1, 0xEF, 0x0E, 0x97, 0x59, 0x1D, 0x4B, 0x11, 0x72, 0x1D, 0x83, - 0x35, 0x01, 0x60, 0x5E, 0x96, 0x7E, 0xD1, 0x3B, 0xED, 0x46, 0x18, 0x78, 0x46, 0x91, 0x87, 0x81, - 0x37, 0x49, 0x63, 0x10, 0xD1, 0xE1, 0x01, 0x72, 0xBB, 0x87, 0x88, 0x42, 0x90, 0xA3, 0xAB, 0x45, - 0x21, 0x48, 0xBB, 0x45, 0xF8, 0x09, 0x61, 0x32, 0xC2, 0x0F, 0x2D, 0x68, 0xB0, 0x2F, 0x18, 0xE7, - 0xC9, 0x3F, 0xFC, 0x20, 0xB0, 0x4C, 0x89, 0x88, 0x03, 0xD2, 0x79, 0x25, 0x4B, 0xE2, 0xDF, 0xBE, - 0x4D, 0x19, 0x12, 0x2D, 0x96, 0xDC, 0xFA, 0x6A, 0xA1, 0xEB, 0xD6, 0xCF, 0xC0, 0x40, 0xE7, 0x0E, - 0x6A, 0xB9, 0x19, 0xFF, 0x58, 0xAC, 0x04, 0x09, 0xCC, 0x39, 0x94, 0x50, 0xF0, 0x2F, 0x66, 0xCA, - 0x18, 0xA1, 0x1F, 0x54, 0x54, 0x62, 0x45, 0x7C, 0x3C, 0x51, 0x46, 0x07, 0x9D, 0xDE, 0xE4, 0x29, - 0x85, 0x7F, 0xA2, 0x2E, 0x4B, 0x23, 0x6B, 0xD5, 0x21, 0x57, 0x7C, 0x8E, 0x4D, 0x32, 0xEC, 0x56, - 0x9C, 0x15, 0x3E, 0xCC, 0x10, 0xBD, 0xF8, 0x28, 0xE6, 0xE2, 0xC4, 0x96, 0xA6, 0xA2, 0xC4, 0xEE, - 0x18, 0x01, 0x24, 0x47, 0xD3, 0x4D, 0x40, 0xFC, 0x0E, 0xD6, 0x0F, 0x42, 0xE1, 0x6C, 0xDD, 0xEA, - 0x38, 0x40, 0x00, 0x45, 0xB8, 0x1F, 0x8F, 0x55, 0x2C, 0x70, 0x6C, 0xE1, 0x62, 0x97, 0xB3, 0xD0, - 0xB1, 0xBB, 0x19, 0x18, 0x79, 0x7A, 0x9B, 0x84, 0xC0, 0x8B, 0x59, 0xD8, 0x68, 0x8D, 0x28, 0x86, - 0xAB, 0x3F, 0x1A, 0x6D, 0x27, 0xB9, 0xBC, 0x03, 0xB6, 0xAC, 0x84, 0x02, 0xE9, 0x60, 0x89, 0x3E, - 0x2A, 0x7B, 0xCD, 0x60, 0x16, 0xAA, 0xED, 0x89, 0x35, 0xA5, 0xBD, 0xD3, 0xAD, 0x7A, 0x06, 0x40, - 0x70, 0xAB, 0xD2, 0x5E, 0x30, 0x1A, 0x4F, 0xA3, 0x62, 0x89, 0xA6, 0x4D, 0x3D, 0x62, 0x7C, 0xFE, - 0x2A, 0x81, 0x8C, 0x56, 0xFF, 0x43, 0x4C, 0xEC, 0x1A, 0x16, 0x05, 0x53, 0x97, 0xD8, 0x13, 0x37, - 0x6D, 0xD7, 0x21, 0xF2, 0x5E, 0x13, 0xD5, 0x11, 0xDE, 0x11, 0x3F, 0x33, 0xC9, 0xDC, 0xD8, 0xD8, - 0x41, 0x04, 0xE6, 0x91, 0x60, 0xE3, 0x39, 0xBC, 0x5A, 0xB2, 0x3D, 0xB9, 0x92, 0x96, 0xE9, 0x1A, - 0xB4, 0xCD, 0xC3, 0x43, 0xED, 0x65, 0x10, 0x18, 0xA0, 0x00, 0x5C, 0x66, 0x5D, 0xA2, 0x7C, 0x34, - 0x83, 0x17, 0x7C, 0x5D, 0x0F, 0x8D, 0x12, 0xEB, 0xCF, 0x1E, 0x70, 0x4D, 0xBD, 0xD1, 0x07, 0x10, - 0xE1, 0xA4, 0x14, 0x55, 0xE7, 0x3F, 0x1B, 0xE2, 0xDD, 0x7E, 0xA0, 0x02, 0x73, 0xBD, 0x97, 0xE0, - 0x8B, 0x7B, 0x9D, 0x68, 0xA9, 0x64, 0x8F, 0xD5, 0x37, 0x3B, 0x80, 0xEA, 0x12, 0xFA, 0x00, 0x1D, - 0x47, 0x36, 0xCF, 0xB8, 0x09, 0xF5, 0xAE, 0x9D, 0x9F, 0x9F, 0x73, 0x65, 0xA4, 0x0B, 0xAA, 0xD0, - 0xC2, 0x75, 0x3E, 0x93, 0xDB, 0xCD, 0x1A, 0xC4, 0x1F, 0x95, 0x48, 0x53, 0x45, 0x5B, 0x2E, 0x1D, - 0xD2, 0x81, 0x96, 0x17, 0xBC, 0x4C, 0xD6, 0x1B, 0x48, 0x1A, 0x45, 0x2A, 0xA0, 0xD6, 0x89, 0x9E, - 0xF8, 0xD5, 0x56, 0xA3, 0xBB, 0x27, 0xF2, 0x33, 0x49, 0x79, 0x99, 0x13, 0xC8, 0x85, 0x27, 0x86, - 0xAE, 0x54, 0x0F, 0x4F, 0x92, 0xA8, 0xEE, 0xF6, 0x9F, 0x44, 0x91, 0x61, 0xB3, 0x36, 0x8D, 0x80, - 0x24, 0x83, 0x43, 0x68, 0x0B, 0xE2, 0xE6, 0xCA, 0x0D, 0x48, 0x2A, 0x62, 0x58, 0x8E, 0x15, 0x58, - 0x86, 0xFD, 0x31, 0xB2, 0xC6, 0x9D, 0xBA, 0xBF, 0xC4, 0xC7, 0x4B, 0xF8, 0xFF, 0x56, 0x85, 0x57, - 0xAD, 0x2A, 0xB9, 0x65, 0x21, 0x61, 0x3C, 0x88, 0xAC, 0x24, 0x2E, 0x87, 0x44, 0x58, 0xE0, 0xF7, - 0x45, 0x4F, 0x4F, 0x9F, 0xD2, 0xA3, 0x27, 0xA1, 0xD2, 0x44, 0xF4, 0x38, 0xD7, 0xA2, 0x1B, 0x29, - 0x05, 0x6F, 0xE3, 0x4E, 0xE1, 0x10, 0xC8, 0x63, 0x18, 0x98, 0x6F, 0x85, 0xEA, 0x5D, 0xC3, 0x54, - 0x17, 0x6D, 0xE1, 0xFF, 0xA3, 0xFE, 0x23, 0x8A, 0xFA, 0xBB, 0x0B, 0xF1, 0x39, 0xB6, 0x9D, 0xF2, - 0x00, 0x06, 0x27, 0x5F, 0x74, 0x79, 0xBE, 0x77, 0xA0, 0xC9, 0x57, 0x55, 0x52, 0x69, 0xC5, 0xD2, - 0x32, 0x19, 0xC9, 0x91, 0x5D, 0xA1, 0x84, 0x70, 0x61, 0x14, 0x97, 0x0E, 0x71, 0x1D, 0xB1, 0xB5, - 0xC7, 0x56, 0x6D, 0x69, 0x34, 0xBE, 0x8B, 0x12, 0x92, 0xA5, 0x7B, 0x9D, 0x07, 0xE9, 0x41, 0xCC, - 0xB9, 0x22, 0x29, 0xE0, 0x10, 0xDA, 0xB4, 0x7C, 0x63, 0x6A, 0x17, 0x77, 0xCD, 0xDB, 0x99, 0x7C, - 0x28, 0x80, 0x06, 0xE2, 0x0A, 0x80, 0x06, 0x1E, 0xF5, 0x99, 0x18, 0x5A, 0xE2, 0x14, 0x61, 0x15, - 0x64, 0xE5, 0x22, 0x9E, 0x1B, 0xE0, 0xC4, 0x49, 0xCC, 0x2C, 0x90, 0x96, 0x08, 0xB1, 0xF1, 0xCB, - 0x00, 0x91, 0x3C, 0x3D, 0xD7, 0x9C, 0x8D, 0x6D, 0x83, 0x05, 0x22, 0x0B, 0x60, 0x81, 0xF1, 0xBB, - 0xD2, 0x00, 0xFD, 0xE7, 0x8D, 0x66, 0x21, 0xE5, 0x09, 0x09, 0x3C, 0x7B, 0x96, 0xC4, 0x86, 0xCB, - 0xB7, 0x2C, 0x35, 0x0F, 0x7B, 0x63, 0xED, 0xD9, 0xDB, 0x74, 0xA3, 0x51, 0x96, 0x93, 0x04, 0x43, - 0xF5, 0xD3, 0x84, 0xE0, 0x63, 0x19, 0x0E, 0x10, 0x62, 0x99, 0x54, 0x40, 0xB8, 0x49, 0x43, 0xDF, - 0x5A, 0xE9, 0x7A, 0x41, 0xAD, 0xBE, 0x45, 0xF8, 0x1E, 0x9D, 0x7D, 0x90, 0x3F, 0x1A, 0x73, 0x74, - 0x41, 0x64, 0x3B, 0x61, 0x57, 0x71, 0x8C, 0x8B, 0x04, 0x46, 0x64, 0x2C, 0x45, 0x37, 0xFE, 0x68, - 0x07, 0xD0, 0x14, 0x77, 0xC8, 0xC4, 0x06, 0xEF, 0xED, 0xD1, 0x9F, 0x76, 0xBC, 0xDD, 0x30, 0x97, - 0x82, 0xEB, 0xE9, 0xA7, 0x05, 0x34, 0x97, 0x31, 0x46, 0xD1, 0x5D, 0x4F, 0x91, 0x25, 0x4A, 0x02, - 0x1C, 0xE6, 0xA1, 0x9A, 0x1B, 0x33, 0xF2, 0xC9, 0x23, 0x33, 0x77, 0xE1, 0x58, 0xBF, 0x13, 0x19, - 0x42, 0xE6, 0x50, 0x2D, 0xE2, 0x78, 0xAE, 0x98, 0x66, 0x23, 0x72, 0xEE, 0x2B, 0xC9, 0xEB, 0x99, - 0x3D, 0x69, 0x3A, 0xB8, 0xD5, 0x27, 0x0B, 0x37, 0x1F, 0xF8, 0x56, 0x70, 0xBB, 0xDD, 0xCF, 0x44, - 0x6B, 0xF7, 0x04, 0xF5, 0xD0, 0xF4, 0x0D, 0x6E, 0xD0, 0x09, 0x79, 0x08, 0x2F, 0x24, 0x93, 0x50, - 0xE1, 0x9E, 0xE1, 0x0A, 0x58, 0xDC, 0x5E, 0xD8, 0x50, 0x19, 0x8D, 0x93, 0x91, 0x41, 0xDE, 0xD3, - 0xC8, 0xD1, 0xC3, 0x61, 0x43, 0x1A, 0xDF, 0x6B, 0x0E, 0x1A, 0x39, 0x38, 0xD9, 0xC6, 0x97, 0x34, - 0xD2, 0xCD, 0x74, 0x65, 0x05, 0x12, 0x84, 0x7B, 0xBD, 0xBD, 0x32, 0xE3, 0x4F, 0xDC, 0x5B, 0x59, - 0xC4, 0xA3, 0xA9, 0x39, 0x20, 0x4A, 0xAC, 0xE8, 0xCD, 0xD8, 0x06, 0xCF, 0x17, 0x30, 0xFD, 0xC6, - 0x75, 0x3A, 0x54, 0x70, 0x6A, 0x81, 0x9C, 0xA1, 0x60, 0xFB, 0x3A, 0x28, 0x8A, 0xE4, 0xCE, 0x0E, - 0xB1, 0x9B, 0x22, 0x99, 0xC9, 0xC7, 0x37, 0x14, 0xFC, 0xEA, 0x11, 0x80, 0xF3, 0xB1, 0x9A, 0xA8, - 0x7D, 0xF9, 0x07, 0x45, 0x71, 0xA7, 0xCD, 0x21, 0x5A, 0xF8, 0x4B, 0x62, 0xD2, 0xCA, 0x57, 0xB0, - 0xF1, 0x4F, 0x35, 0x5C, 0x14, 0x4F, 0xEC, 0xE4, 0xB8, 0xFB, 0x35, 0xB4, 0x90, 0x70, 0xB0, 0x29, - 0x9C, 0x6C, 0xD0, 0x0D, 0x3F, 0xF9, 0xF3, 0x0C, 0x96, 0x9E, 0x4B, 0x0A, 0x4B, 0xF8, 0x63, 0x91, - 0xC4, 0xEE, 0x40, 0x4E, 0x03, 0xDD, 0x7C, 0x07, 0xD9, 0x4B, 0xCA, 0x4C, 0xF7, 0xF9, 0x34, 0x09, - 0x34, 0x60, 0x8A, 0x90, 0xC7, 0x74, 0x84, 0x93, 0x21, 0x26, 0xA6, 0x84, 0x84, 0x19, 0x33, 0x9C, - 0x97, 0xE2, 0xDD, 0x30, 0x3C, 0x0B, 0x08, 0x65, 0xF1, 0x9B, 0x0F, 0x93, 0x9B, 0xFD, 0x27, 0xA1, - 0x18, 0xB6, 0x71, 0x60, 0x07, 0x31, 0x04, 0x09, 0x11, 0x65, 0x89, 0x89, 0x1B, 0x4D, 0x72, 0x5E, - 0x96, 0x23, 0x33, 0xF6, 0x8B, 0x8D, 0x99, 0x74, 0xC0, 0xA4, 0x3D, 0xFF, 0x8B, 0x1A, 0xCD, 0xBF, - 0x0F, 0xD8, 0x20, 0x1B, 0x8B, 0x79, 0xFB, 0x65, 0x08, 0xDA, 0x9A, 0x24, 0x16, 0x12, 0x73, 0x6F, - 0xE9, 0xB2, 0xF8, 0x41, 0x80, 0xA3, 0xF8, 0x20, 0x29, 0xDD, 0x9A, 0x26, 0xC6, 0x73, 0xBA, 0x90, - 0x41, 0x89, 0x6C, 0x62, 0x73, 0x3C, 0x21, 0x1E, 0x69, 0x3E, 0x97, 0x29, 0x2E, 0x66, 0x5F, 0xCC, - 0x61, 0xAF, 0x2C, 0x72, 0x9D, 0x5B, 0x37, 0xA5, 0x7B, 0xB5, 0xA8, 0xBC, 0x22, 0x80, 0x8B, 0x70, - 0x6F, 0x5E, 0x21, 0x64, 0xB4, 0x8F, 0x2F, 0x86, 0x83, 0x6E, 0xD2, 0x53, 0x5B, 0x5C, 0xA5, 0x4D, - 0x13, 0xA0, 0x88, 0xB5, 0x18, 0x56, 0xEC, 0xDB, 0x4E, 0x93, 0x1F, 0x1F, 0x7C, 0xF2, 0xE0, 0xE9, - 0x28, 0xC7, 0x1A, 0xC7, 0xA0, 0xA9, 0xE7, 0x17, 0x03, 0xC7, 0x77, 0x04, 0xC6, 0x69, 0x37, 0xAE, - 0x14, 0x80, 0xA3, 0x6D, 0x8C, 0x31, 0x50, 0x31, 0x9E, 0xE5, 0x01, 0x42, 0x1B, 0xB6, 0x4D, 0x75, - 0x2F, 0xA6, 0x5F, 0x3F, 0x70, 0xD7, 0x1F, 0x28, 0x21, 0xA9, 0x40, 0x74, 0x4D, 0x17, 0x07, 0x3A, - 0x78, 0xBF, 0xC5, 0xD3, 0xA4, 0xB8, 0x6C, 0x93, 0xEB, 0xA9, 0x1F, 0x70, 0xE5, 0x40, 0x63, 0x78, - 0xF6, 0x92, 0x39, 0x2E, 0x5D, 0x54, 0x90, 0xF6, 0x80, 0x96, 0xD2, 0xF1, 0xBD, 0x19, 0x1B, 0x0E, - 0xC2, 0x4D, 0x7F, 0x18, 0xAD, 0xF0, 0xF0, 0x57, 0xD6, 0x27, 0x0E, 0xDF, 0x09, 0x9B, 0xDA, 0x2F, - 0xA4, 0xC5, 0x5D, 0xA7, 0x49, 0x89, 0x4A, 0x4A, 0xCC, 0xBF, 0x7D, 0x1C, 0x07, 0xD8, 0xD0, 0x87, - 0x11, 0x33, 0x66, 0x6F, 0x19, 0x61, 0x39, 0x12, 0x13, 0x8F, 0x83, 0x49, 0xE2, 0x63, 0x63, 0x19, - 0xDB, 0x31, 0xFA, 0xE2, 0xD3, 0x6C, 0x0A, 0xC3, 0xD7, 0x6B, 0x70, 0x3E, 0xF0, 0xF6, 0xEB, 0xD6, - 0xFE, 0x5D, 0x1E, 0x3B, 0x4C, 0x5C, 0x91, 0xED, 0xA8, 0x12, 0x41, 0x07, 0x0A, 0x39, 0xB6, 0x84, - 0x7C, 0xE4, 0xE8, 0xE2, 0x0E, 0x73, 0xE9, 0x88, 0x69, 0x4A, 0x96, 0x60, 0xCF, 0xB7, 0x45, 0xCB, - 0x32, 0xD5, 0x04, 0x82, 0x68, 0x08, 0xD8, 0x22, 0x36, 0x95, 0xA8, 0xC6, 0xEC, 0x42, 0x34, 0x10, - 0xB4, 0xC7, 0x7D, 0x30, 0x83, 0xF6, 0x64, 0x1E, 0x9F, 0x4A, 0x18, 0x99, 0x00, 0x42, 0x67, 0xCA, - 0x59, 0xC2, 0x99, 0x19, 0xCE, 0x95, 0x91, 0x58, 0xC2, 0x99, 0x01, 0x41, 0x01, 0xE1, 0x7E, 0xD3, - 0xD2, 0x59, 0x03, 0x9D, 0x3B, 0x00, 0x3B, 0xEB, 0xD0, 0x87, 0x61, 0x70, 0x46, 0x82, 0x36, 0x40, - 0x4F, 0x12, 0xB7, 0x97, 0x84, 0xBE, 0x80, 0x90, 0xDF, 0x67, 0x67, 0xAC, 0x41, 0xD8, 0xCB, 0xD4, - 0x35, 0x6F, 0x3B, 0xC6, 0x7A, 0x4D, 0x1C, 0xF3, 0x62, 0x69, 0xD9, 0x66, 0x8B, 0x81, 0xC6, 0xD6, - 0x31, 0x30, 0x2C, 0x12, 0xBA, 0xF5, 0x8D, 0x63, 0x05, 0x6F, 0xBE, 0x60, 0xD7, 0x5A, 0x7B, 0x7D, - 0x53, 0xEC, 0x5C, 0xE4, 0xCD, 0x3A, 0xA6, 0x67, 0x5C, 0x7F, 0x83, 0xFB, 0xA2, 0xA9, 0x39, 0x1C, - 0x74, 0x0F, 0xBA, 0xBC, 0x41, 0x00, 0xE9, 0x96, 0x10, 0x39, 0xE2, 0xC5, 0xFD, 0xA3, 0x3F, 0x7E, - 0xFF, 0x6D, 0x84, 0x37, 0x70, 0x5F, 0xB3, 0x4B, 0xAD, 0x3D, 0xBA, 0xB1, 0xFA, 0xF0, 0xB7, 0x35, - 0xEE, 0x57, 0x11, 0xC3, 0x4C, 0x4C, 0x8C, 0xB8, 0x67, 0x1A, 0x45, 0xC5, 0x9A, 0x7F, 0x15, 0x47, - 0x0A, 0x97, 0x1D, 0x18, 0x27, 0xD0, 0xDC, 0x5B, 0x32, 0x50, 0xB1, 0xA3, 0x1A, 0xC1, 0x91, 0x93, - 0xAF, 0x61, 0xA2, 0xFA, 0x0B, 0x31, 0x3C, 0xD0, 0xC7, 0x73, 0xAD, 0xA5, 0x77, 0xF5, 0xE7, 0x2D, - 0x7A, 0xFD, 0x1D, 0xB0, 0xB3, 0x6C, 0xED, 0x3F, 0xEF, 0xED, 0xEF, 0x77, 0x7C, 0xD0, 0x19, 0x69, - 0xB5, 0xFB, 0xA2, 0x09, 0xFC, 0xA1, 0x6D, 0x58, 0x27, 0xD9, 0xF7, 0xDF, 0xBA, 0x1B, 0xCF, 0xCF, - 0x6B, 0xF0, 0xCE, 0x72, 0x70, 0x24, 0xCE, 0x6B, 0xF2, 0x01, 0xA6, 0x2F, 0x8E, 0xB9, 0xD5, 0x44, - 0xA7, 0x1B, 0xC1, 0xC5, 0x9C, 0x91, 0xEE, 0x8F, 0x85, 0xA4, 0x3F, 0x96, 0xEE, 0xF3, 0x8C, 0x93, - 0x60, 0xC5, 0xBB, 0x25, 0x96, 0xA5, 0xEE, 0xE2, 0xC6, 0x11, 0xE5, 0x74, 0xBC, 0x6A, 0xB0, 0xA5, - 0xFF, 0x54, 0xC0, 0xE2, 0x59, 0xD2, 0x56, 0x2D, 0x5C, 0x25, 0x11, 0x95, 0x66, 0x58, 0xB9, 0x19, - 0x69, 0xB2, 0x60, 0x9C, 0x9E, 0x2D, 0x27, 0xB3, 0xCF, 0x8B, 0x0D, 0xF8, 0xF8, 0x4A, 0x44, 0x54, - 0x76, 0x0D, 0xA7, 0x9C, 0x61, 0xE8, 0x87, 0x29, 0x68, 0xDE, 0x78, 0x04, 0xB7, 0x63, 0x23, 0x18, - 0x9F, 0xAF, 0x16, 0x00, 0xD0, 0x49, 0x6A, 0x38, 0x8A, 0x69, 0x08, 0xB5, 0x4D, 0xB6, 0x24, 0x44, - 0x40, 0xBB, 0xFD, 0x30, 0x62, 0x21, 0x10, 0x9F, 0x75, 0x45, 0xBA, 0xDB, 0x9E, 0x5D, 0xA7, 0x03, - 0xD6, 0xD6, 0xAC, 0xFA, 0x2E, 0xA6, 0x2D, 0xF1, 0x70, 0x4E, 0xC4, 0x0F, 0xC9, 0x67, 0x9E, 0xC4, - 0x99, 0x17, 0x55, 0x82, 0x02, 0x88, 0x4F, 0x34, 0xC7, 0x8F, 0xB3, 0x4F, 0x14, 0xD9, 0x27, 0x9C, - 0x7D, 0x04, 0x88, 0x26, 0x9C, 0xC5, 0x25, 0x8B, 0xD0, 0x18, 0x7F, 0x7A, 0x15, 0x71, 0x76, 0x3D, - 0xCD, 0xA5, 0x93, 0x97, 0x12, 0x62, 0xEC, 0xE5, 0x03, 0x40, 0xFB, 0x15, 0xF8, 0x43, 0x9C, 0xAD, - 0xEB, 0xA9, 0x1A, 0x5B, 0xA2, 0x14, 0x81, 0x00, 0x11, 0x5B, 0xF2, 0x82, 0x85, 0x60, 0xE5, 0x35, - 0x09, 0xF8, 0x23, 0x7C, 0x86, 0x63, 0x6A, 0x73, 0xCF, 0x58, 0x11, 0xFC, 0x62, 0x7B, 0x48, 0xAC, - 0x49, 0xEF, 0x17, 0xE6, 0x7E, 0xAC, 0x59, 0x8C, 0xC9, 0xB0, 0xE4, 0x51, 0x08, 0x1A, 0xB6, 0x8C, - 0x41, 0x87, 0x74, 0xE4, 0x42, 0x8B, 0x46, 0x2C, 0x83, 0x0B, 0x4F, 0x95, 0x84, 0x15, 0xB6, 0x8E, - 0x1C, 0x21, 0x42, 0x20, 0x4A, 0x26, 0xA3, 0x74, 0xA9, 0x8C, 0xCD, 0x24, 0x18, 0xB3, 0xA9, 0xF9, - 0x42, 0xBC, 0x41, 0xC8, 0x52, 0xA2, 0x4D, 0xE8, 0x20, 0x0C, 0x3E, 0x8B, 0xCC, 0x42, 0x52, 0xD8, - 0x42, 0xA2, 0xFE, 0xDE, 0x26, 0x58, 0x9E, 0xE0, 0x2F, 0x65, 0xBC, 0xF8, 0xE6, 0x6B, 0xCD, 0xF5, - 0x34, 0xDB, 0xBD, 0x26, 0xB8, 0x28, 0x28, 0x36, 0x90, 0x69, 0x53, 0x02, 0x01, 0x8E, 0xB0, 0x22, - 0x13, 0xEE, 0x8F, 0x09, 0x96, 0x96, 0x0F, 0x73, 0x62, 0x7C, 0x75, 0x28, 0x79, 0xAA, 0x87, 0xA3, - 0x53, 0x21, 0x7B, 0xDB, 0x25, 0xF4, 0x84, 0x38, 0x19, 0x4C, 0x24, 0xCB, 0xA7, 0x9C, 0xC7, 0xAD, - 0xC0, 0x92, 0x57, 0xD6, 0x2A, 0x21, 0xC2, 0xF0, 0xF6, 0xA3, 0x95, 0xA2, 0x9C, 0x81, 0x42, 0x41, - 0x86, 0x60, 0x91, 0x2C, 0x23, 0x5E, 0xB7, 0xA4, 0x29, 0xAB, 0x1D, 0xE6, 0x68, 0x14, 0x4B, 0xE1, - 0xD2, 0x68, 0x9E, 0xAD, 0x15, 0x26, 0x71, 0x36, 0xCA, 0xB1, 0xDF, 0xD9, 0xA1, 0x78, 0x14, 0x8A, - 0x9D, 0x61, 0xF6, 0x36, 0x79, 0x72, 0x76, 0xB8, 0x0C, 0x56, 0xF6, 0xE4, 0xC9, 0xFF, 0x02, 0x49, - 0x60, 0xC8, 0xA8, 0x55, 0x0C, 0x01, 0x00 -}; - - -//File: index_ov5640.html.gz, Size: 9124 -#define index_ov5640_html_gz_len 9124 -const uint8_t index_ov5640_html_gz[] = { - 0x1F, 0x8B, 0x08, 0x08, 0xD9, 0x6C, 0x6A, 0x5E, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x5F, - 0x6F, 0x76, 0x35, 0x36, 0x34, 0x30, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xED, 0x3D, 0x6B, 0x77, - 0xDB, 0xB6, 0x92, 0xDF, 0xF3, 0x2B, 0x18, 0xF5, 0x6E, 0x24, 0x9F, 0x58, 0xB6, 0xA8, 0x97, 0x1F, - 0xB1, 0x95, 0x4D, 0x1C, 0x27, 0xE9, 0xB9, 0x4D, 0x6F, 0x1A, 0xA7, 0x69, 0x7B, 0xBA, 0x3D, 0x29, - 0x25, 0x41, 0x12, 0x1B, 0x8A, 0xD4, 0x25, 0x29, 0xCB, 0x6E, 0x8E, 0x7F, 0xC7, 0xFE, 0xA0, 0xFD, - 0x63, 0x3B, 0x03, 0x80, 0x24, 0x48, 0x81, 0x24, 0x48, 0x4A, 0xB2, 0xDB, 0x5D, 0xE5, 0x9C, 0x98, - 0x0F, 0xCC, 0x60, 0xDE, 0x18, 0x0C, 0x40, 0xF2, 0xEC, 0xF1, 0xD8, 0x19, 0xF9, 0xB7, 0x0B, 0xA2, - 0xCD, 0xFC, 0xB9, 0x35, 0x78, 0x74, 0xC6, 0xFE, 0x68, 0xF0, 0x3B, 0x9B, 0x11, 0x63, 0xCC, 0x0E, - 0xE9, 0xE9, 0x9C, 0xF8, 0x86, 0x36, 0x9A, 0x19, 0xAE, 0x47, 0xFC, 0xF3, 0xDA, 0xD2, 0x9F, 0x34, - 0x8F, 0x6B, 0xC9, 0xDB, 0xB6, 0x31, 0x27, 0xE7, 0xB5, 0x6B, 0x93, 0xAC, 0x16, 0x8E, 0xEB, 0xD7, - 0xB4, 0x91, 0x63, 0xFB, 0xC4, 0x86, 0xE6, 0x2B, 0x73, 0xEC, 0xCF, 0xCE, 0xC7, 0xE4, 0xDA, 0x1C, - 0x91, 0x26, 0x3D, 0xD9, 0x37, 0x6D, 0xD3, 0x37, 0x0D, 0xAB, 0xE9, 0x8D, 0x0C, 0x8B, 0x9C, 0xEB, - 0x22, 0x2E, 0xDF, 0xF4, 0x2D, 0x32, 0xB8, 0xBC, 0x7A, 0xDF, 0x69, 0x6B, 0xFF, 0xFA, 0xD4, 0xEB, - 0x77, 0x5B, 0x67, 0x87, 0xEC, 0x5A, 0xD4, 0xC6, 0xF3, 0x6F, 0xC5, 0x73, 0xFC, 0x0D, 0x9D, 0xF1, - 0xAD, 0xF6, 0x35, 0x76, 0x09, 0x7F, 0x13, 0x20, 0xA2, 0x39, 0x31, 0xE6, 0xA6, 0x75, 0x7B, 0xAA, - 0xBD, 0x70, 0xA1, 0xCF, 0xFD, 0xB7, 0xC4, 0xBA, 0x26, 0xBE, 0x39, 0x32, 0xF6, 0x3D, 0xC3, 0xF6, - 0x9A, 0x1E, 0x71, 0xCD, 0xC9, 0xB3, 0x35, 0xC0, 0xA1, 0x31, 0xFA, 0x32, 0x75, 0x9D, 0xA5, 0x3D, - 0x3E, 0xD5, 0xBE, 0xD1, 0x8F, 0xF1, 0xDF, 0x7A, 0xA3, 0x91, 0x63, 0x39, 0x2E, 0xDC, 0xBF, 0x7C, - 0x8D, 0xFF, 0xD6, 0xEF, 0xD3, 0xDE, 0x3D, 0xF3, 0x4F, 0x72, 0xAA, 0xE9, 0xFD, 0xC5, 0x4D, 0xEC, - 0xFE, 0xDD, 0xA3, 0xD8, 0xE9, 0xAC, 0x9D, 0x46, 0x3D, 0x87, 0x3F, 0xCE, 0x86, 0xF7, 0xC8, 0xC8, - 0x37, 0x1D, 0xFB, 0x60, 0x6E, 0x98, 0xB6, 0x04, 0xD3, 0xD8, 0xF4, 0x16, 0x96, 0x01, 0x32, 0x98, - 0x58, 0x24, 0x13, 0xCF, 0x37, 0x73, 0x62, 0x2F, 0xF7, 0x73, 0xB0, 0x21, 0x92, 0xE6, 0xD8, 0x74, - 0x59, 0xAB, 0x53, 0x94, 0xC3, 0x72, 0x6E, 0xE7, 0xA2, 0xCD, 0xA2, 0xCB, 0x76, 0x6C, 0x22, 0x11, - 0x20, 0x76, 0xB4, 0x72, 0x8D, 0x05, 0x36, 0xC0, 0xBF, 0xEB, 0x4D, 0xE6, 0xA6, 0xCD, 0x8C, 0xEA, - 0x54, 0xEB, 0x74, 0x5B, 0x8B, 0x9B, 0x1C, 0x55, 0x76, 0xFA, 0xF8, 0x6F, 0xBD, 0xD1, 0xC2, 0x18, - 0x8F, 0x4D, 0x7B, 0x7A, 0xAA, 0x1D, 0x4B, 0x51, 0x38, 0xEE, 0x98, 0xB8, 0x4D, 0xD7, 0x18, 0x9B, - 0x4B, 0xEF, 0x54, 0xEB, 0xCA, 0xDA, 0xCC, 0x0D, 0x77, 0x0A, 0xB4, 0xF8, 0x0E, 0x10, 0xDB, 0xD4, - 0xA5, 0x94, 0xF0, 0x26, 0xAE, 0x39, 0x9D, 0xF9, 0xA0, 0xD2, 0xB5, 0x36, 0x49, 0xA1, 0x71, 0x17, - 0xCA, 0xD3, 0x67, 0xA6, 0xDC, 0xE4, 0x52, 0x33, 0x2C, 0x73, 0x6A, 0x37, 0x4D, 0x9F, 0xCC, 0x81, - 0x1D, 0xCF, 0x77, 0x89, 0x3F, 0x9A, 0x65, 0x91, 0x32, 0x31, 0xA7, 0x4B, 0x97, 0x48, 0x08, 0x09, - 0xE5, 0x96, 0xC1, 0x30, 0xDC, 0x5C, 0xBF, 0xD5, 0x5C, 0x91, 0xE1, 0x17, 0xD3, 0x6F, 0x72, 0x99, - 0x0C, 0xC9, 0xC4, 0x71, 0x89, 0xB4, 0x65, 0xD0, 0xC2, 0x72, 0x46, 0x5F, 0x9A, 0x9E, 0x6F, 0xB8, - 0xBE, 0x0A, 0x42, 0x63, 0xE2, 0x13, 0x37, 0x1F, 0x1F, 0x41, 0xAB, 0xC8, 0xC7, 0x96, 0xDE, 0x2D, - 0x6F, 0x60, 0xDA, 0x96, 0x69, 0x13, 0x75, 0xF2, 0xD2, 0xFA, 0x8D, 0xA3, 0x63, 0xAD, 0x14, 0x14, - 0x63, 0xCE, 0xA7, 0x59, 0x56, 0x42, 0x79, 0x5D, 0xEF, 0x8C, 0xFB, 0x8D, 0xDE, 0x6A, 0xFD, 0xC7, - 0xFA, 0xCD, 0x19, 0x61, 0x66, 0x6A, 0x2C, 0x7D, 0xA7, 0xBA, 0x47, 0xAC, 0xB9, 0x55, 0x82, 0x8F, - 0xFF, 0x9C, 0x93, 0xB1, 0x69, 0x68, 0x0D, 0xC1, 0x9D, 0x8F, 0x5B, 0x60, 0x53, 0x7B, 0x9A, 0x61, - 0x8F, 0xB5, 0x86, 0xE3, 0x9A, 0xE0, 0x08, 0x06, 0x0D, 0x37, 0x16, 0x5C, 0x81, 0x81, 0x63, 0x41, - 0xF6, 0x24, 0x2C, 0x67, 0xF8, 0x8C, 0x28, 0x11, 0xB9, 0xDB, 0xE0, 0x4F, 0x21, 0xE4, 0xE0, 0x2F, - 0xD7, 0x81, 0x24, 0x3C, 0x52, 0xF4, 0x59, 0xFA, 0x12, 0x29, 0x4C, 0xD3, 0x19, 0xFE, 0xE6, 0xC6, - 0x4D, 0x33, 0x53, 0x77, 0x41, 0xA3, 0x40, 0x87, 0x30, 0xCC, 0x8E, 0x1A, 0xD0, 0xF4, 0x7A, 0xA6, - 0x35, 0x35, 0x8C, 0x92, 0x7B, 0x72, 0x18, 0x8E, 0x54, 0xAE, 0x72, 0xFC, 0x89, 0x46, 0x51, 0x80, - 0x5D, 0x39, 0xAB, 0x51, 0xEC, 0x60, 0xFF, 0x64, 0x36, 0xC4, 0x38, 0x49, 0x8D, 0x22, 0xF8, 0x53, - 0x8F, 0x24, 0x11, 0xB2, 0xDC, 0x68, 0x22, 0x41, 0x9C, 0x1E, 0x51, 0xD6, 0xF0, 0xA6, 0x79, 0xB7, - 0x04, 0x6B, 0x36, 0x09, 0xAA, 0xD1, 0x45, 0x82, 0x38, 0x8B, 0x86, 0xDC, 0x28, 0x83, 0xBF, 0x3B, - 0x85, 0x7C, 0xE3, 0x9B, 0xE1, 0xD2, 0xF7, 0x1D, 0xDB, 0xAB, 0x34, 0x44, 0xA5, 0xF9, 0xD9, 0x1F, - 0x4B, 0xCF, 0x37, 0x27, 0xB7, 0x4D, 0xEE, 0xD2, 0xE0, 0x67, 0x0B, 0x03, 0x52, 0xC8, 0x21, 0xF1, - 0x57, 0x84, 0x64, 0xA7, 0x1B, 0xB6, 0x71, 0x0D, 0x71, 0x67, 0x3A, 0xB5, 0x64, 0xB6, 0x37, 0x5A, - 0xBA, 0x1E, 0xE6, 0x6D, 0x0B, 0xC7, 0x04, 0xC4, 0xEE, 0x7A, 0xC7, 0x71, 0x1F, 0x54, 0xEC, 0xA8, - 0x39, 0x1A, 0x4A, 0xFA, 0x72, 0x96, 0x3E, 0xCA, 0x58, 0xAA, 0x09, 0x07, 0xD8, 0x31, 0xFD, 0x5B, - 0xE9, 0x3D, 0xEE, 0x89, 0x92, 0x3B, 0x81, 0x0B, 0x66, 0x0E, 0x0B, 0x71, 0xBA, 0x4E, 0x47, 0x33, - 0x32, 0xFA, 0x42, 0xC6, 0x4F, 0x73, 0xD3, 0xB0, 0xBC, 0xF4, 0xF0, 0xC0, 0xB4, 0x17, 0x4B, 0xBF, - 0x89, 0xE9, 0xD4, 0x62, 0x2B, 0x3A, 0xA7, 0x06, 0x19, 0xB0, 0xD8, 0x6E, 0x67, 0x25, 0x15, 0xBD, - 0xC5, 0x4D, 0xB6, 0x10, 0x44, 0x62, 0x07, 0x96, 0x31, 0x24, 0x56, 0x16, 0xC9, 0xDC, 0x19, 0x52, - 0xC2, 0x2E, 0x8F, 0x55, 0xE9, 0xB9, 0x1B, 0xA5, 0x2C, 0x1A, 0xBC, 0xBA, 0x47, 0xFF, 0xA1, 0x2C, - 0x47, 0x7A, 0xBC, 0x1F, 0xBB, 0xE4, 0x11, 0x0B, 0x1C, 0x2C, 0x71, 0x6D, 0x61, 0xA4, 0x26, 0xE3, - 0xD0, 0x62, 0x05, 0x54, 0x65, 0x76, 0xE9, 0x1A, 0xF6, 0x94, 0x40, 0x74, 0xB8, 0xD9, 0x0F, 0x0E, - 0xB3, 0xA7, 0x0A, 0x4A, 0x02, 0xC1, 0xE0, 0xDD, 0xCB, 0x9E, 0x9A, 0xB0, 0x10, 0xB1, 0xAF, 0x1D, - 0xB0, 0x83, 0x12, 0x79, 0x8A, 0xA0, 0xF1, 0x4C, 0x42, 0x74, 0xA9, 0xBD, 0xB0, 0x54, 0x45, 0xEA, - 0x4B, 0x71, 0x6B, 0x93, 0xA6, 0xFE, 0xB9, 0xC1, 0x22, 0x98, 0x04, 0x4E, 0x26, 0x79, 0xD3, 0xC8, - 0xC9, 0xA4, 0xD3, 0xEA, 0x74, 0x73, 0x73, 0x29, 0x29, 0x97, 0x89, 0xA9, 0xA4, 0x24, 0x98, 0x84, - 0x81, 0x26, 0x5F, 0x17, 0xA7, 0x33, 0xE7, 0x9A, 0xB8, 0x12, 0x45, 0x24, 0xC8, 0xED, 0x9E, 0x74, - 0xC7, 0x0A, 0xD8, 0x0C, 0x18, 0x0A, 0xAE, 0x65, 0x81, 0x36, 0x8E, 0xAE, 0xAD, 0x8F, 0xDA, 0x99, - 0x16, 0xCA, 0xD0, 0x1D, 0x80, 0x35, 0x18, 0x43, 0x8B, 0x8C, 0x33, 0x22, 0xF7, 0x98, 0x4C, 0x8C, - 0xA5, 0xE5, 0xE7, 0xC8, 0xDB, 0x68, 0xE1, 0xBF, 0xAC, 0x1E, 0xA9, 0x7B, 0xFD, 0x8A, 0x35, 0x90, - 0x73, 0xEA, 0x12, 0xBF, 0x49, 0xFA, 0x0C, 0x86, 0x55, 0x63, 0xB1, 0x20, 0x06, 0xB4, 0x1A, 0x91, - 0xB4, 0xD9, 0xAA, 0x52, 0x3A, 0x2D, 0x8F, 0x69, 0x4A, 0x73, 0xD4, 0x5C, 0x53, 0x0C, 0x13, 0xA5, - 0x42, 0x3C, 0x9F, 0x4E, 0x9C, 0xD1, 0x52, 0x36, 0x82, 0xAB, 0x99, 0xD4, 0x3A, 0xBE, 0xD3, 0x40, - 0x64, 0x9E, 0x65, 0x52, 0xC3, 0x5E, 0xDA, 0x36, 0x6A, 0xB4, 0xE9, 0xBB, 0xC0, 0xA6, 0xA4, 0x23, - 0x35, 0xC1, 0x95, 0xF2, 0xCE, 0x98, 0x60, 0xD3, 0xEA, 0x34, 0x09, 0x07, 0x94, 0x04, 0x8A, 0x30, - 0x86, 0x68, 0x9E, 0x03, 0x4C, 0x05, 0xA8, 0xAA, 0xC9, 0xC5, 0x9F, 0x2D, 0xE7, 0xB2, 0x9C, 0x21, - 0xE8, 0x4C, 0x87, 0x01, 0x8E, 0x75, 0xE7, 0x4E, 0x87, 0x46, 0xA3, 0xB5, 0xDF, 0xDA, 0xEF, 0xC0, - 0x7F, 0x92, 0xDC, 0x3D, 0xDB, 0xB8, 0xB8, 0x78, 0x53, 0x2C, 0x2F, 0x11, 0x7C, 0xF2, 0x4B, 0x28, - 0x69, 0x61, 0x2C, 0x57, 0x17, 0xEA, 0x9E, 0x14, 0xAF, 0xA5, 0xE8, 0x07, 0x39, 0x23, 0x4C, 0x8A, - 0x49, 0x17, 0x37, 0x44, 0x89, 0xB5, 0x14, 0x55, 0xF1, 0xDC, 0xF9, 0xB3, 0xC9, 0x86, 0xD7, 0xFF, - 0xF3, 0xD6, 0x2E, 0x88, 0xE2, 0x6F, 0x6D, 0xE9, 0x85, 0xE5, 0xE2, 0xDD, 0xB7, 0x6D, 0xB4, 0xD2, - 0xB5, 0xDE, 0xE4, 0xF9, 0x0C, 0x50, 0x68, 0x43, 0xC6, 0xE9, 0xC2, 0xC4, 0x2B, 0x35, 0xE7, 0x11, - 0xDA, 0x94, 0x90, 0xC1, 0xC4, 0xB4, 0xAC, 0xA6, 0xE5, 0xAC, 0xF2, 0x33, 0x91, 0x6C, 0x4B, 0x5E, - 0xB3, 0xD3, 0x7C, 0x93, 0x2F, 0x4B, 0xED, 0x12, 0x22, 0xD7, 0x5F, 0x82, 0xDA, 0xBF, 0xB7, 0xC3, - 0x65, 0xBA, 0x46, 0xB9, 0x81, 0xA2, 0x84, 0x3D, 0x56, 0xEB, 0x48, 0xC9, 0x94, 0x58, 0x26, 0x98, - 0x39, 0xAB, 0xF3, 0x56, 0xA6, 0x3F, 0x9A, 0x95, 0x98, 0x54, 0x2D, 0x1C, 0xCF, 0x64, 0xCB, 0x37, - 0x2E, 0xB1, 0x0C, 0xCC, 0xE0, 0x4B, 0xCD, 0xC6, 0x73, 0x27, 0x26, 0x22, 0xB8, 0x0A, 0x27, 0x54, - 0x74, 0x0F, 0xA7, 0x92, 0x72, 0xC0, 0x72, 0x87, 0xF4, 0x58, 0x2D, 0x37, 0xEB, 0x9C, 0x74, 0x3F, - 0xEE, 0x19, 0xF2, 0x46, 0x05, 0x22, 0x7A, 0x10, 0xB4, 0xA7, 0x2E, 0xB9, 0x55, 0x60, 0x66, 0x9F, - 0xFF, 0x3D, 0x65, 0xB5, 0xD2, 0xF2, 0x45, 0x00, 0x3A, 0x00, 0x70, 0x2B, 0x3A, 0xE8, 0x7A, 0x0A, - 0x5D, 0xA7, 0x77, 0xA9, 0x62, 0x8F, 0x61, 0x25, 0xB0, 0x56, 0x53, 0x08, 0x37, 0x19, 0x43, 0xA8, - 0xDC, 0x54, 0x83, 0xD1, 0x57, 0x7A, 0xD3, 0x22, 0x13, 0x3F, 0x65, 0xA1, 0x83, 0xE6, 0xA9, 0x9D, - 0xEC, 0xE8, 0xD6, 0x14, 0xEA, 0x04, 0xB9, 0x91, 0x23, 0x2C, 0xD8, 0xA5, 0x5B, 0x9F, 0x14, 0x33, - 0x46, 0xCF, 0xC2, 0xC8, 0xD3, 0x55, 0x12, 0xA4, 0xCF, 0x54, 0xCD, 0xD0, 0x66, 0xCE, 0x87, 0x7C, - 0x50, 0x0F, 0xF9, 0xB9, 0xD1, 0xEE, 0x4B, 0xD7, 0x11, 0x32, 0x1A, 0x67, 0x91, 0xC6, 0x2A, 0x5E, - 0x4A, 0x43, 0x56, 0xEA, 0x04, 0x59, 0x8C, 0x45, 0x52, 0x45, 0x65, 0x7B, 0x65, 0x56, 0x84, 0x59, - 0xAF, 0xD1, 0x64, 0x1A, 0xBB, 0x39, 0x37, 0x20, 0xED, 0x45, 0x73, 0x35, 0x00, 0xA3, 0x4C, 0x7F, - 0x2A, 0xE6, 0x2E, 0xD4, 0x13, 0xF5, 0x7E, 0x2B, 0xA7, 0xCB, 0x91, 0xE5, 0x78, 0xD9, 0x7E, 0x65, - 0x0C, 0x41, 0x7E, 0x4B, 0x5F, 0xD2, 0x11, 0xAF, 0x6A, 0x4A, 0x2B, 0x4F, 0xD4, 0xB8, 0xA5, 0x77, - 0x94, 0x86, 0xEE, 0x4C, 0x9F, 0xCA, 0x76, 0xC7, 0x84, 0xCC, 0xF5, 0x96, 0x34, 0xD2, 0x66, 0xD6, - 0xDF, 0x7C, 0x72, 0x03, 0xF3, 0x4D, 0x5C, 0xAB, 0x3B, 0xD5, 0x46, 0x44, 0x1E, 0x46, 0x63, 0x83, - 0x9C, 0xAE, 0x52, 0x04, 0xCC, 0xD4, 0xC3, 0xCC, 0x1C, 0x8F, 0x49, 0x66, 0x95, 0x13, 0xE7, 0xBC, - 0xD9, 0xA1, 0xD2, 0x90, 0x96, 0xD3, 0x0A, 0x68, 0xB2, 0x9D, 0xAE, 0xCA, 0xCC, 0xE1, 0x2A, 0x25, - 0xF4, 0xC5, 0x24, 0x24, 0x6D, 0x22, 0x54, 0x61, 0xE5, 0x21, 0x12, 0x15, 0x31, 0x26, 0x23, 0xC7, - 0x65, 0x8B, 0xB8, 0x29, 0x13, 0xFF, 0x72, 0x33, 0x2B, 0x44, 0x2E, 0x2B, 0xDD, 0x6D, 0x25, 0x74, - 0x64, 0x6E, 0x74, 0xD0, 0xB7, 0x1D, 0x57, 0xF8, 0x70, 0x9C, 0x56, 0x49, 0x8F, 0x27, 0x6C, 0x99, - 0xA4, 0x4A, 0x43, 0x60, 0xA8, 0x46, 0x14, 0x19, 0xC8, 0x01, 0x5B, 0xAD, 0x2B, 0x34, 0x41, 0x15, - 0x5D, 0x5A, 0x39, 0xE0, 0xAB, 0x4D, 0x7C, 0x61, 0xB0, 0x99, 0xB6, 0xDE, 0xB2, 0xC1, 0xC5, 0x37, - 0x6A, 0x01, 0xC9, 0x7E, 0x53, 0x45, 0x73, 0x4F, 0xF9, 0x63, 0x06, 0x91, 0xE1, 0x40, 0x1C, 0x6C, - 0xB7, 0x8A, 0xB7, 0x2A, 0x1B, 0x42, 0xCE, 0x0E, 0x85, 0xFD, 0x71, 0x67, 0x87, 0xD1, 0x56, 0xBE, - 0x33, 0xDC, 0x24, 0x27, 0x6E, 0xA3, 0xE3, 0xFD, 0x8C, 0x2C, 0xC3, 0xF3, 0xCE, 0x6B, 0xB8, 0xD9, - 0xAB, 0x16, 0xDF, 0x55, 0x77, 0x36, 0x36, 0xAF, 0x35, 0x73, 0x7C, 0x5E, 0xB3, 0x9C, 0xA9, 0x93, - 0xB8, 0x47, 0xEF, 0x33, 0x2D, 0xC3, 0x68, 0x7F, 0x5E, 0x8B, 0xAD, 0x38, 0xD6, 0x28, 0x54, 0x74, - 0xA9, 0x36, 0x78, 0xF2, 0xCD, 0xC9, 0xD1, 0x51, 0xFF, 0xD9, 0x13, 0x7B, 0xE8, 0x2D, 0xF8, 0xFF, - 0x1F, 0xD9, 0x02, 0xAD, 0x47, 0x7C, 0x1F, 0x6C, 0xCE, 0x3B, 0x3B, 0xA4, 0xD8, 0x12, 0x14, 0x1C, - 0x02, 0x09, 0x29, 0x44, 0xF1, 0x6C, 0x50, 0x46, 0x57, 0xD0, 0xC4, 0x83, 0x04, 0x67, 0x68, 0xB8, - 0x92, 0x26, 0xB4, 0x19, 0x9B, 0x6B, 0xD0, 0x18, 0x52, 0xA3, 0xCA, 0x18, 0x3A, 0x37, 0x49, 0xD2, - 0x29, 0x37, 0x5C, 0x53, 0xBC, 0x15, 0x19, 0xA7, 0x21, 0x04, 0x30, 0x0A, 0x8E, 0xEB, 0xAC, 0xD0, - 0x46, 0xDA, 0x28, 0x26, 0x7B, 0x6C, 0x7C, 0x33, 0xB2, 0xBE, 0x04, 0x4A, 0xAF, 0x05, 0xDA, 0xB0, - 0x1D, 0x9F, 0x8D, 0x24, 0x29, 0x5D, 0xC5, 0x58, 0xE5, 0x30, 0xC2, 0x6A, 0x21, 0xE3, 0x02, 0x44, - 0xDB, 0xA4, 0xD8, 0xD9, 0xB5, 0x6C, 0x4C, 0x14, 0x9B, 0xA0, 0xD0, 0x00, 0xB8, 0x36, 0xF8, 0xF9, - 0xE2, 0xBB, 0x7F, 0x6A, 0xEF, 0xDE, 0xFE, 0x29, 0xD5, 0x50, 0x1E, 0x51, 0x18, 0x9C, 0x15, 0x7A, - 0xA6, 0x60, 0x4C, 0x1F, 0x81, 0x4C, 0x6A, 0x5C, 0x33, 0x14, 0x03, 0x26, 0x43, 0x16, 0xB1, 0xA7, - 0xFE, 0xEC, 0xBC, 0xA6, 0xD7, 0x70, 0x77, 0x4B, 0x70, 0xD6, 0xAE, 0x69, 0x18, 0xB8, 0xE9, 0xC1, - 0xB5, 0x61, 0x2D, 0xF1, 0xA8, 0xA5, 0xC2, 0xEB, 0xBA, 0x69, 0x49, 0x9B, 0xF1, 0x88, 0x12, 0xCA, - 0x58, 0x88, 0xC0, 0x71, 0x29, 0xD7, 0x06, 0x57, 0xC4, 0x3F, 0x3B, 0x64, 0xB7, 0x72, 0xB4, 0x96, - 0xDD, 0x37, 0xB8, 0x30, 0x33, 0x87, 0x2C, 0x13, 0xCA, 0x52, 0xFC, 0xC4, 0x35, 0xE6, 0x04, 0xA5, - 0xA2, 0xA4, 0x79, 0x51, 0xEB, 0x21, 0x64, 0x6D, 0xF0, 0x81, 0xD0, 0x2C, 0x03, 0xC8, 0x50, 0x52, - 0xFC, 0x19, 0x4F, 0xE1, 0x63, 0xFD, 0x87, 0xF6, 0xCC, 0x97, 0xEC, 0x9A, 0x06, 0x33, 0x73, 0x05, - 0xB9, 0x3F, 0x6E, 0x36, 0xB5, 0xDE, 0xBB, 0xF7, 0x5A, 0xB3, 0xA9, 0xD0, 0xD8, 0x59, 0x50, 0x77, - 0x0A, 0xF4, 0x0F, 0x16, 0xC2, 0xA8, 0x21, 0x54, 0x3F, 0xEC, 0xA8, 0x36, 0xF8, 0xE1, 0xEA, 0xE7, - 0x37, 0x2F, 0x1A, 0xED, 0x5E, 0xBF, 0x75, 0xA3, 0x9F, 0xB4, 0x5B, 0x7B, 0x67, 0x87, 0x0C, 0xAE, - 0x78, 0x07, 0x60, 0x60, 0xEF, 0xB5, 0xD7, 0x6F, 0x5F, 0x35, 0xF4, 0xD6, 0x71, 0x55, 0x64, 0xFA, - 0x49, 0x6D, 0xF0, 0xD3, 0x0F, 0x11, 0x65, 0xFD, 0x56, 0x15, 0x64, 0xC7, 0xC0, 0x26, 0xD0, 0xC5, - 0x50, 0x75, 0xBB, 0x85, 0x50, 0xA1, 0xC8, 0x3B, 0xE5, 0x44, 0xAE, 0x1F, 0x41, 0xBF, 0x94, 0x87, - 0x56, 0xF7, 0xF8, 0x46, 0xEF, 0xF5, 0xBB, 0x15, 0x78, 0xE8, 0xA3, 0x74, 0x81, 0x90, 0xC6, 0x71, - 0xBF, 0x5B, 0x15, 0x57, 0x0F, 0x71, 0x81, 0x40, 0x8E, 0xDA, 0x20, 0x8F, 0xF6, 0x71, 0x15, 0xD1, - 0x76, 0x6B, 0x03, 0xAA, 0xF2, 0x13, 0x44, 0xD5, 0x2A, 0x86, 0x0A, 0x45, 0xDB, 0x2E, 0x29, 0xDA, - 0x4E, 0x6D, 0xF0, 0x23, 0x8A, 0x16, 0x2D, 0x03, 0x78, 0xA8, 0x64, 0x1E, 0x6D, 0x88, 0x52, 0x14, - 0x57, 0x1B, 0xED, 0xB6, 0xD5, 0xAE, 0x22, 0x5A, 0xBD, 0x36, 0x40, 0x71, 0x20, 0xA6, 0xA3, 0x4A, - 0x0E, 0x00, 0xDE, 0x44, 0x69, 0x02, 0x72, 0x6E, 0x8E, 0xFA, 0xC7, 0xE5, 0x31, 0x81, 0x27, 0x5D, - 0x7D, 0x02, 0x4C, 0xC7, 0x20, 0xA8, 0x4A, 0x6E, 0x04, 0x5E, 0x84, 0x78, 0xFA, 0xDD, 0xD6, 0x4D, - 0xB7, 0x8A, 0xCD, 0x80, 0x57, 0xBC, 0x45, 0x44, 0x80, 0xE4, 0xA6, 0x53, 0x45, 0x46, 0xE0, 0x12, - 0x17, 0xDF, 0xBE, 0x6E, 0x74, 0x81, 0xB1, 0xF6, 0x49, 0xBF, 0x3C, 0x1E, 0x70, 0x87, 0x1F, 0x90, - 0x20, 0x20, 0xE6, 0xA6, 0x5D, 0x2C, 0x3A, 0xC4, 0x11, 0x81, 0x33, 0x00, 0x3C, 0xE2, 0x28, 0x8D, - 0x02, 0xEC, 0xFA, 0x2D, 0x25, 0x06, 0x11, 0xE9, 0x47, 0x15, 0xB8, 0x02, 0xAB, 0xFE, 0x01, 0xC5, - 0x03, 0x48, 0x30, 0xE8, 0x55, 0x30, 0x45, 0x40, 0x44, 0x49, 0xD2, 0xFB, 0xD4, 0xD5, 0xCA, 0x63, - 0x02, 0x9B, 0x3E, 0xE9, 0xDF, 0x9C, 0xF4, 0xD5, 0x10, 0xE0, 0x88, 0x8F, 0xA3, 0x54, 0x56, 0x4E, - 0x90, 0x9D, 0x32, 0x64, 0xA5, 0x03, 0xFF, 0x5E, 0x1A, 0x16, 0xCC, 0x6F, 0x0A, 0x27, 0x03, 0x1C, - 0x0E, 0x64, 0xC2, 0x0E, 0xD4, 0xF2, 0x00, 0x81, 0x92, 0x70, 0xA3, 0x59, 0x6D, 0xD0, 0x55, 0xC8, - 0xB7, 0x62, 0x09, 0x39, 0x85, 0x8D, 0xD1, 0x4F, 0x93, 0x40, 0xB4, 0x3C, 0x4C, 0xFF, 0xC0, 0x25, - 0x3A, 0x35, 0x21, 0x82, 0x94, 0x4A, 0x34, 0x24, 0xB4, 0x1A, 0x37, 0xB5, 0x41, 0xBF, 0x93, 0x9B, - 0xA0, 0x95, 0x57, 0xC6, 0x90, 0xD6, 0x68, 0x6C, 0xE2, 0x79, 0x85, 0xF5, 0x11, 0x81, 0xD6, 0x06, - 0x2F, 0xC3, 0xE3, 0x2A, 0x5A, 0x69, 0xE6, 0x71, 0x4A, 0x61, 0x53, 0xD4, 0x22, 0x90, 0xC3, 0x34, - 0xD3, 0xEC, 0x70, 0xD5, 0x44, 0x9A, 0xD9, 0xAC, 0x62, 0xB6, 0xA9, 0x17, 0x9C, 0x4E, 0xBA, 0x86, - 0xE7, 0x17, 0xD6, 0x4A, 0x00, 0x08, 0x11, 0x9A, 0x1F, 0xDD, 0x9B, 0x46, 0x42, 0x52, 0xFE, 0x06, - 0xFA, 0xF0, 0x0C, 0x7F, 0xC9, 0xAA, 0x85, 0x85, 0x35, 0x12, 0x81, 0x42, 0x3E, 0x10, 0x1E, 0x57, - 0xD2, 0x4A, 0x95, 0xF0, 0x25, 0x90, 0xC3, 0xF5, 0x12, 0x84, 0xB0, 0xEE, 0x96, 0xF4, 0x92, 0x47, - 0x6D, 0x25, 0xBD, 0xCC, 0x0C, 0x77, 0x51, 0x2A, 0x7C, 0x85, 0x90, 0xA0, 0x95, 0xE0, 0xF0, 0xDE, - 0x5C, 0x25, 0x22, 0xE6, 0x6F, 0xE0, 0x2B, 0x63, 0x62, 0x3B, 0xA6, 0x57, 0x7C, 0xB6, 0xCF, 0xE1, - 0x6A, 0x83, 0x57, 0xA4, 0xF9, 0x3D, 0x1E, 0x55, 0x51, 0xC7, 0x8B, 0xA5, 0xEF, 0x54, 0x50, 0x48, - 0x40, 0x0B, 0x53, 0x47, 0x8B, 0x6B, 0xE3, 0x78, 0x4B, 0xDA, 0x38, 0xDE, 0xA2, 0x36, 0x0C, 0xF2, - 0xD9, 0x22, 0xD7, 0xC4, 0x2A, 0xAC, 0x8E, 0x00, 0xB0, 0x36, 0xB8, 0xBC, 0x59, 0x38, 0x1E, 0x3E, - 0x3A, 0xF5, 0x1D, 0x9E, 0x57, 0x72, 0x92, 0x5E, 0x05, 0x9D, 0x84, 0x04, 0x71, 0x1F, 0xE9, 0x71, - 0xAD, 0xF4, 0xB6, 0xA4, 0x95, 0x3C, 0x5A, 0xAB, 0x68, 0x65, 0x6A, 0x98, 0xF6, 0x88, 0x98, 0x16, - 0x3E, 0xC6, 0x51, 0x54, 0x31, 0x02, 0x6C, 0x6D, 0xF0, 0x26, 0x3A, 0xA9, 0xA2, 0x98, 0x56, 0x05, - 0xBD, 0x88, 0xF4, 0xC4, 0xFD, 0xA5, 0x07, 0xB3, 0xF2, 0x2D, 0xE9, 0x46, 0xD7, 0xB7, 0x39, 0xAA, - 0x2C, 0xC8, 0xC8, 0x34, 0xAC, 0xCF, 0x64, 0x32, 0x81, 0x69, 0x50, 0xF1, 0xA1, 0x25, 0x06, 0x0E, - 0xE3, 0x0B, 0x3B, 0xD7, 0x2E, 0xE9, 0x79, 0xE1, 0xFA, 0x65, 0x02, 0x5D, 0xF9, 0x22, 0x66, 0x72, - 0x4E, 0x28, 0x2D, 0x4B, 0x7E, 0xEF, 0x84, 0x74, 0x96, 0x9F, 0xB6, 0x7E, 0x4F, 0xA6, 0x74, 0x1B, - 0x41, 0x95, 0x39, 0xF4, 0x1B, 0xD7, 0xB8, 0xA5, 0xEF, 0x64, 0xA8, 0x32, 0xA5, 0xFF, 0x40, 0xC6, - 0xDA, 0x47, 0xD3, 0x2E, 0xCF, 0x4C, 0x17, 0x09, 0x21, 0xC4, 0xAE, 0x86, 0xA5, 0x07, 0x53, 0x24, - 0x38, 0xA8, 0x86, 0xA4, 0x8F, 0x35, 0xFD, 0x85, 0x69, 0x3C, 0x84, 0x49, 0xBC, 0xB1, 0x1A, 0x16, - 0x1F, 0x50, 0x56, 0x43, 0x18, 0x97, 0x7F, 0x7A, 0xA9, 0x5D, 0xD2, 0x8D, 0xEF, 0x85, 0xC3, 0x15, - 0xDB, 0x93, 0xA7, 0x62, 0xE8, 0xD1, 0xD2, 0x0D, 0xF6, 0xB9, 0xB6, 0xA6, 0x26, 0x77, 0x20, 0xD5, - 0x75, 0x35, 0x09, 0x7B, 0x01, 0x81, 0x74, 0x0B, 0x53, 0x4D, 0xE0, 0x56, 0x8D, 0xC7, 0x2D, 0xA6, - 0x62, 0xA3, 0x55, 0xF1, 0x34, 0x6C, 0xB4, 0x02, 0x35, 0x8D, 0xAF, 0xF1, 0x99, 0x88, 0xB1, 0x06, - 0xFA, 0xDA, 0x89, 0xA2, 0xB0, 0xD7, 0xFB, 0x51, 0x14, 0xE5, 0xF7, 0xBE, 0x15, 0x05, 0xD6, 0xF2, - 0x19, 0xC7, 0xD1, 0x32, 0x4E, 0x45, 0x01, 0x6B, 0x83, 0x77, 0x86, 0xBD, 0x84, 0x41, 0x66, 0x57, - 0x0A, 0x0B, 0x3B, 0xBE, 0x37, 0xF7, 0xE2, 0x7C, 0xDF, 0xB7, 0xEA, 0x80, 0x90, 0xB9, 0x33, 0x2E, - 0x3E, 0xDD, 0xE1, 0x70, 0x2C, 0x24, 0xBE, 0x83, 0xA3, 0xC2, 0x89, 0x41, 0x80, 0x61, 0xCB, 0x19, - 0x01, 0x9B, 0x4A, 0x95, 0x4F, 0x06, 0xAE, 0x96, 0xB6, 0x7D, 0x5B, 0x25, 0x13, 0xB8, 0xB0, 0x9C, - 0xE5, 0xB8, 0x3C, 0x06, 0x48, 0x03, 0xFE, 0x35, 0x99, 0x98, 0xA3, 0xF2, 0x89, 0x04, 0x24, 0x01, - 0x6F, 0x9D, 0xB9, 0x22, 0xFC, 0x96, 0x07, 0x5E, 0x32, 0x2A, 0x31, 0x93, 0x1B, 0x81, 0x16, 0x2F, - 0x2F, 0x76, 0x3A, 0xF0, 0x42, 0x9F, 0xF7, 0x14, 0x19, 0x90, 0xDB, 0xFB, 0x0E, 0x0A, 0x40, 0xC4, - 0x67, 0x6A, 0x3C, 0x65, 0x94, 0xC5, 0x20, 0xC3, 0x88, 0x1E, 0x4C, 0xBF, 0xEF, 0x6B, 0x7E, 0x17, - 0x51, 0x14, 0x9F, 0xDD, 0xE1, 0xD2, 0x73, 0x38, 0xBD, 0xEB, 0xB4, 0x37, 0x3B, 0xC1, 0x43, 0xE4, - 0xDB, 0xD5, 0x4F, 0xBB, 0x8C, 0x6A, 0x20, 0x1A, 0x7D, 0x8F, 0xEB, 0x0C, 0x05, 0x02, 0x76, 0x75, - 0x47, 0x6A, 0xDF, 0x9F, 0x27, 0xB5, 0x1F, 0x80, 0x2B, 0x4D, 0x4B, 0x44, 0xBC, 0x29, 0x46, 0xBC, - 0x37, 0x17, 0xBB, 0xD1, 0xD0, 0xF4, 0xDE, 0x42, 0xDD, 0xF4, 0x5E, 0x43, 0x9D, 0xC6, 0x37, 0x05, - 0x06, 0x52, 0x28, 0x99, 0xC1, 0x72, 0x40, 0x56, 0xCB, 0xAA, 0x12, 0xE4, 0xF4, 0x9B, 0x2A, 0x51, - 0x2E, 0x20, 0x23, 0x1E, 0xE4, 0xFA, 0xD1, 0xAA, 0x48, 0x6F, 0xB3, 0xCB, 0xBA, 0xDD, 0x3C, 0x6A, - 0xAB, 0x38, 0x8D, 0x6B, 0xAC, 0x3E, 0x4F, 0xE7, 0x46, 0x61, 0x65, 0x70, 0x38, 0xD0, 0xC5, 0xBB, - 0x17, 0xBB, 0x4C, 0x17, 0x82, 0x7E, 0xEF, 0xC7, 0x8F, 0x42, 0xAE, 0xEF, 0x3B, 0xD6, 0x59, 0xC4, - 0x2E, 0x1E, 0xEC, 0x10, 0xA8, 0x36, 0xF8, 0x8E, 0xD8, 0x9E, 0x76, 0xE1, 0xB8, 0xFC, 0x45, 0x98, - 0x3B, 0xD1, 0x1A, 0xED, 0xF9, 0x7E, 0x54, 0xC6, 0x98, 0xBE, 0x6F, 0x7D, 0xCD, 0xE6, 0xA6, 0xEB, - 0x3A, 0x6E, 0x61, 0x95, 0x71, 0x38, 0x98, 0x56, 0x34, 0xDF, 0xD1, 0xA3, 0x9D, 0xA8, 0x2B, 0xE8, - 0xF5, 0x7E, 0x34, 0x16, 0xF2, 0x7C, 0xDF, 0x4A, 0xBB, 0x9E, 0x58, 0xE6, 0xA2, 0xB0, 0xCA, 0x28, - 0x54, 0x6D, 0xF0, 0xA9, 0xF9, 0x1A, 0xFE, 0xEE, 0x44, 0x5D, 0xAC, 0xC7, 0xFB, 0x51, 0x16, 0xE7, - 0xF6, 0xBE, 0x55, 0x35, 0x5C, 0x14, 0x0F, 0x87, 0x00, 0x53, 0x1B, 0xBC, 0x7C, 0xBF, 0x9B, 0xDC, - 0x0F, 0x3B, 0x53, 0xD4, 0x50, 0x25, 0x7D, 0x50, 0xA6, 0xEE, 0x5B, 0x1B, 0xAB, 0x12, 0xDA, 0x58, - 0x21, 0xE1, 0x3F, 0xED, 0x48, 0x1B, 0x2B, 0x75, 0x6D, 0x6C, 0xD8, 0x5F, 0x56, 0x0F, 0x41, 0x3F, - 0xF4, 0xE9, 0xD3, 0xA1, 0x51, 0x7C, 0x38, 0x0A, 0x00, 0x71, 0xD3, 0x18, 0x1C, 0x69, 0x2F, 0x8D, - 0xDD, 0x0C, 0x48, 0x61, 0xBF, 0xBB, 0x70, 0xA1, 0x88, 0xC9, 0xFB, 0xD6, 0xD3, 0xC4, 0x18, 0x91, - 0xCF, 0x63, 0xE2, 0x97, 0x59, 0x5B, 0x16, 0x60, 0x6B, 0x83, 0xD7, 0x70, 0xA2, 0xBD, 0xA2, 0x27, - 0xBB, 0x4A, 0xF9, 0xC4, 0xFE, 0x77, 0xA1, 0xB5, 0x18, 0xBF, 0x0F, 0x42, 0x71, 0x90, 0x60, 0x3B, - 0x53, 0xBB, 0xD4, 0xE3, 0x4C, 0x31, 0x70, 0xAE, 0xBE, 0x0F, 0xEC, 0x7C, 0xB7, 0x0A, 0x8C, 0x88, - 0xD8, 0x99, 0x0E, 0x05, 0xBE, 0x37, 0xA8, 0x46, 0xC5, 0xA7, 0x1A, 0xF9, 0x9B, 0x81, 0xF3, 0x74, - 0xC5, 0x9F, 0xAE, 0xA3, 0x9B, 0x5A, 0x88, 0xDF, 0xF4, 0x7C, 0xD3, 0xB2, 0x60, 0x2A, 0x4C, 0x7C, - 0xED, 0x0A, 0x0F, 0x15, 0x1F, 0xA7, 0x13, 0xB0, 0x04, 0x0F, 0xD1, 0xFA, 0x2E, 0x31, 0xE6, 0xB5, - 0xC1, 0x15, 0xBE, 0x33, 0x19, 0x70, 0xE1, 0x59, 0x71, 0x64, 0x54, 0x8C, 0xC4, 0x76, 0x1D, 0x20, - 0x2A, 0x54, 0x13, 0x7F, 0x3F, 0x65, 0x4D, 0x0B, 0x8E, 0x84, 0x6B, 0x83, 0x4B, 0xDA, 0x58, 0x43, - 0x3B, 0xCB, 0xEF, 0x4E, 0xF9, 0x39, 0x3F, 0xFA, 0x44, 0x2F, 0x3E, 0xA2, 0x1B, 0x7F, 0xA3, 0x3A, - 0xE8, 0x95, 0xBD, 0xD5, 0x60, 0x70, 0x46, 0xDF, 0x0E, 0xCB, 0x9B, 0xD1, 0x87, 0xD9, 0x57, 0xFC, - 0xE9, 0xE4, 0xA1, 0x63, 0x8D, 0x9F, 0x09, 0xAB, 0xCB, 0x57, 0xE1, 0xE3, 0xB6, 0x08, 0x02, 0x86, - 0x11, 0x60, 0xC8, 0x51, 0xFE, 0xCC, 0x0D, 0xD0, 0xB3, 0x27, 0xA2, 0xF1, 0x1D, 0x65, 0x19, 0xCA, - 0x4D, 0x79, 0x34, 0xD8, 0x25, 0xD3, 0x50, 0x90, 0xB2, 0x27, 0xC6, 0xA5, 0x0F, 0x0A, 0x7F, 0x20, - 0x53, 0xD3, 0x03, 0x1A, 0x35, 0x30, 0x8B, 0x43, 0xFA, 0x8C, 0x25, 0x33, 0x66, 0xB5, 0xE7, 0x77, - 0xC5, 0x2E, 0xF9, 0xCB, 0x19, 0xA4, 0x8F, 0x63, 0x17, 0xCA, 0x4F, 0x92, 0x0F, 0x4F, 0xC7, 0x31, - 0xE6, 0x19, 0xFD, 0xE3, 0x66, 0x73, 0xD6, 0xC5, 0xA7, 0x45, 0xB5, 0x80, 0xB5, 0xB3, 0xC3, 0x59, - 0x37, 0xEF, 0xD1, 0xB0, 0xDC, 0x47, 0x7D, 0x81, 0xD3, 0xD2, 0x4F, 0xFA, 0xA2, 0x94, 0x06, 0x40, - 0xCD, 0xBE, 0xF6, 0xCE, 0xF0, 0xBE, 0xEC, 0x6B, 0x9F, 0xB0, 0x00, 0xB7, 0xC3, 0x07, 0x7E, 0x91, - 0x76, 0x63, 0x3C, 0x76, 0x53, 0x1F, 0xFA, 0xED, 0xC6, 0x1E, 0xFA, 0xED, 0x07, 0x0F, 0xFD, 0xF6, - 0xA3, 0xDD, 0x6E, 0x37, 0x9D, 0x56, 0xEB, 0x58, 0x85, 0x75, 0xC5, 0x07, 0x7F, 0x37, 0xC2, 0xD3, - 0x1C, 0xA4, 0xA9, 0xC8, 0x53, 0x37, 0xE0, 0x49, 0xD8, 0x15, 0x7E, 0x33, 0x99, 0x3C, 0x34, 0x8E, - 0xF8, 0xBA, 0x54, 0x79, 0x96, 0x5A, 0xED, 0x5D, 0x3F, 0x9D, 0x4D, 0x8D, 0x7B, 0x53, 0x0F, 0x67, - 0xD3, 0x26, 0xC9, 0x68, 0xD8, 0xCB, 0x0C, 0x86, 0x14, 0x84, 0x39, 0xFD, 0x9B, 0x4D, 0x3A, 0xFD, - 0xB4, 0x82, 0xD3, 0x4F, 0xD7, 0x9C, 0x7E, 0x87, 0xDE, 0x1E, 0x10, 0xFE, 0x77, 0xF3, 0xF8, 0x80, - 0xAF, 0x02, 0x5E, 0x2F, 0xE5, 0xAB, 0xD5, 0xDA, 0xA8, 0xDF, 0xE7, 0x3A, 0x49, 0x68, 0x0C, 0x6F, - 0x36, 0xE9, 0x24, 0x29, 0xA6, 0x5B, 0xCA, 0x4E, 0x79, 0xD8, 0x19, 0xEC, 0x66, 0x5C, 0xA2, 0xD9, - 0x94, 0xA8, 0x50, 0xDE, 0x3B, 0x3E, 0x13, 0xDA, 0xE9, 0xF2, 0xD4, 0x69, 0x13, 0xEA, 0x51, 0x7F, - 0x11, 0x44, 0x6A, 0x93, 0xCD, 0x24, 0x66, 0x0B, 0x21, 0xC3, 0x55, 0x4E, 0xCC, 0xDE, 0x7F, 0xF7, - 0x5D, 0xB1, 0x5C, 0x4C, 0xEC, 0xE5, 0x81, 0xE4, 0x62, 0x99, 0xB5, 0xD0, 0xDB, 0x05, 0xDC, 0x40, - 0xAA, 0x4B, 0x99, 0x6E, 0x04, 0x5E, 0x1B, 0xBC, 0xA4, 0xC7, 0x9A, 0x20, 0xB1, 0x42, 0xC6, 0xAB, - 0x3C, 0xED, 0xA4, 0x80, 0x42, 0xB1, 0x34, 0x22, 0x21, 0xA9, 0x1B, 0x45, 0x5C, 0x19, 0x05, 0x52, - 0x81, 0x3D, 0x75, 0xA6, 0x2A, 0xFB, 0x04, 0x6D, 0x92, 0x97, 0x0A, 0x2F, 0x5C, 0x52, 0x5A, 0x6D, - 0x1C, 0xB6, 0x36, 0x78, 0xEF, 0x12, 0xED, 0x95, 0x79, 0xAD, 0xCE, 0x9B, 0xB0, 0x51, 0x30, 0x44, - 0xA2, 0x26, 0xE5, 0xE4, 0x0E, 0x3E, 0xE9, 0xAE, 0x40, 0x5C, 0x6C, 0x57, 0xDD, 0x4E, 0x27, 0xC1, - 0x0A, 0x69, 0x57, 0xBB, 0x1A, 0x86, 0x4E, 0x6D, 0xD0, 0xA9, 0x86, 0xA1, 0x5B, 0x1B, 0x74, 0xAB, - 0x61, 0xE8, 0x81, 0x1C, 0x0E, 0x7A, 0xD5, 0x70, 0xF4, 0x6B, 0x83, 0x7E, 0x35, 0x0C, 0x47, 0x20, - 0xCB, 0xAA, 0x54, 0x40, 0xE6, 0x72, 0x5C, 0x00, 0x43, 0xFE, 0x26, 0x47, 0xD6, 0xAA, 0xBA, 0xF3, - 0xCC, 0x97, 0x56, 0x69, 0xE7, 0xE1, 0xB0, 0xB5, 0xC1, 0xBB, 0xA5, 0xE5, 0x9B, 0x0B, 0xCB, 0x84, - 0x69, 0x7B, 0xA3, 0xAB, 0x35, 0xB5, 0x76, 0xAF, 0xBD, 0xB7, 0xC3, 0x0C, 0x33, 0xA0, 0x43, 0xED, - 0x1D, 0x52, 0x9D, 0x20, 0x09, 0xD3, 0x8F, 0xC5, 0x77, 0x0A, 0x3C, 0x88, 0x70, 0xE6, 0x3A, 0x8E, - 0x5F, 0x5A, 0x1D, 0x01, 0x30, 0xA4, 0xF9, 0x70, 0x54, 0x3A, 0x9A, 0x45, 0x68, 0xCA, 0x18, 0x7A, - 0xCA, 0x26, 0xE7, 0x8A, 0xE1, 0x4C, 0x2F, 0x16, 0xCE, 0x76, 0xE7, 0x3E, 0xDE, 0x6D, 0xF9, 0x94, - 0x81, 0xC3, 0xC2, 0x6C, 0xF5, 0x16, 0x66, 0x88, 0x73, 0x54, 0x98, 0xD6, 0x68, 0x81, 0xFB, 0xE8, - 0xBD, 0x5D, 0x7A, 0x4F, 0x40, 0x46, 0xC1, 0x37, 0xB0, 0x89, 0xDE, 0xF3, 0x30, 0x9C, 0x87, 0xEA, - 0x83, 0x58, 0xE3, 0x5E, 0x79, 0x8D, 0x04, 0xD0, 0x90, 0x0F, 0xE0, 0x7B, 0xF0, 0x2A, 0xF9, 0x91, - 0x80, 0xAC, 0x9C, 0x23, 0x55, 0x77, 0x1A, 0xA9, 0x2B, 0x56, 0xCC, 0x0B, 0xDA, 0x95, 0x47, 0xF5, - 0xCE, 0x43, 0x1C, 0x0B, 0x17, 0xF8, 0xEA, 0x44, 0xA2, 0xB6, 0xBD, 0x92, 0x22, 0x13, 0x93, 0x48, - 0x06, 0xCB, 0x8D, 0x86, 0x6D, 0x2B, 0xDF, 0x69, 0xE6, 0x1F, 0x10, 0xB0, 0xBE, 0xDA, 0x54, 0x7C, - 0x15, 0x5E, 0x60, 0x4E, 0x36, 0x11, 0x08, 0x79, 0x7D, 0x70, 0xB3, 0x00, 0x24, 0xAC, 0xF4, 0x34, - 0x80, 0x03, 0x73, 0x15, 0x86, 0x91, 0xB8, 0xA3, 0x17, 0x88, 0xC4, 0xE2, 0x8C, 0x20, 0xC4, 0x57, - 0x72, 0xB4, 0xBB, 0xF7, 0xF4, 0xBF, 0x2B, 0x0F, 0x1E, 0x15, 0x5D, 0xBF, 0x60, 0x1A, 0x2C, 0xC1, - 0x80, 0xEF, 0x0B, 0xD4, 0x8B, 0x24, 0xF4, 0x9B, 0x0B, 0x1F, 0xC2, 0x5A, 0x21, 0x35, 0x38, 0x3A, - 0x74, 0x13, 0x9F, 0xCD, 0xF3, 0x0B, 0x2C, 0xFF, 0xA5, 0x36, 0xD9, 0x4C, 0x75, 0x67, 0x65, 0xDA, - 0xC5, 0xAB, 0x3B, 0x3F, 0x99, 0xF6, 0xD8, 0x59, 0x15, 0x2B, 0xF0, 0x88, 0x1D, 0xFD, 0x05, 0x0A, - 0x3C, 0x34, 0x3D, 0xC0, 0x15, 0xE2, 0xA6, 0x4B, 0xD4, 0xDE, 0x67, 0x93, 0x14, 0x32, 0x83, 0xBE, - 0xC1, 0x05, 0x56, 0x40, 0xE1, 0x69, 0x74, 0xBD, 0x79, 0xDB, 0x99, 0xDA, 0xCF, 0xA7, 0x62, 0xAE, - 0xC6, 0x29, 0x50, 0xCB, 0xD5, 0xBA, 0x92, 0x72, 0xF3, 0xBD, 0x57, 0xD0, 0x7F, 0x59, 0xE7, 0xE7, - 0xF6, 0xDE, 0xF9, 0xD9, 0xC4, 0x00, 0x44, 0xEC, 0x71, 0x69, 0xCB, 0x42, 0xD8, 0xC8, 0xAE, 0x2E, - 0xED, 0xF1, 0x4E, 0xAD, 0x8A, 0xF5, 0x5E, 0x5A, 0x07, 0xED, 0x7E, 0xBB, 0xF3, 0xB0, 0xCC, 0x0A, - 0x19, 0xAA, 0x60, 0x54, 0xFA, 0x49, 0xEF, 0x01, 0x4D, 0x69, 0x9C, 0xC9, 0x84, 0xAD, 0x6B, 0x96, - 0x33, 0x2D, 0x0E, 0x7E, 0x43, 0x9F, 0xD2, 0xF5, 0xC8, 0x6E, 0xE3, 0x55, 0xD8, 0x79, 0xC1, 0xD2, - 0x8C, 0xA0, 0x8B, 0xFE, 0xC3, 0x32, 0x2D, 0xCE, 0x91, 0xAA, 0x75, 0x49, 0x38, 0xEA, 0x3E, 0x1C, - 0xD3, 0xF2, 0x1D, 0xDF, 0xB0, 0x4A, 0x5B, 0x16, 0x83, 0x06, 0xC3, 0xFA, 0x88, 0x07, 0xDA, 0x15, - 0xF0, 0xB9, 0x53, 0xE3, 0x0A, 0xFA, 0x2F, 0x1F, 0xB8, 0x8E, 0xBB, 0x1B, 0x52, 0x46, 0x05, 0x96, - 0x7E, 0x59, 0x67, 0xA9, 0x52, 0xE8, 0xEA, 0x6F, 0x68, 0x91, 0x7C, 0x23, 0xA1, 0x6B, 0xE9, 0xE3, - 0xD5, 0xD2, 0xA1, 0x8B, 0x81, 0x63, 0xE8, 0xA2, 0x47, 0xBB, 0x37, 0xB1, 0x90, 0x82, 0xF2, 0x36, - 0xD6, 0x3B, 0xD9, 0xE4, 0x16, 0x98, 0x4D, 0x44, 0x30, 0xC6, 0x53, 0x25, 0x23, 0xDB, 0x94, 0xDF, - 0x54, 0x36, 0xB2, 0x91, 0xA1, 0xFC, 0x8E, 0x37, 0x8A, 0x4C, 0xCC, 0xE6, 0x19, 0x2C, 0xCC, 0xE1, - 0xD8, 0xC1, 0x4E, 0x2B, 0x36, 0x41, 0xE7, 0x1B, 0x5F, 0xA8, 0x0D, 0xB9, 0x7A, 0x48, 0xF5, 0x99, - 0xA1, 0x69, 0xDB, 0x65, 0xD5, 0xC4, 0x61, 0x6B, 0x83, 0x97, 0xEC, 0x60, 0xB7, 0x4B, 0xEA, 0xBC, - 0xF3, 0xCD, 0xAF, 0xA7, 0x07, 0x5C, 0xED, 0x5A, 0x4D, 0x89, 0x22, 0x86, 0x1B, 0x7E, 0xF7, 0xA3, - 0xC6, 0xF7, 0xA8, 0x46, 0xDF, 0x01, 0x79, 0x38, 0x25, 0x8D, 0xA9, 0x31, 0xC7, 0x87, 0x97, 0x8B, - 0x16, 0x35, 0xDE, 0x20, 0x58, 0xB1, 0x9A, 0x46, 0xBC, 0xA7, 0x87, 0x5D, 0xD5, 0x18, 0xC4, 0xDF, - 0x5A, 0x09, 0x84, 0x37, 0x87, 0xA6, 0xE1, 0xE1, 0x83, 0xFE, 0x70, 0xAC, 0xBD, 0x84, 0x63, 0xED, - 0xBD, 0xB5, 0x0C, 0x5F, 0xBB, 0x2B, 0x73, 0x08, 0x71, 0x3F, 0x5B, 0x84, 0x21, 0xED, 0xA9, 0x05, - 0xBA, 0x8D, 0x8F, 0x3F, 0xE0, 0x05, 0xC7, 0xB8, 0x7B, 0xAD, 0xD7, 0x3D, 0x6E, 0xD5, 0x34, 0x96, - 0x15, 0xF3, 0xC7, 0xFA, 0xBD, 0x2F, 0x74, 0x5B, 0x9B, 0x1E, 0x12, 0x28, 0x73, 0x00, 0x91, 0xDE, - 0x90, 0x40, 0x6A, 0xBF, 0x55, 0x76, 0x9B, 0xAD, 0x4B, 0x44, 0x0F, 0xC4, 0xD1, 0x92, 0x1A, 0x42, - 0xEC, 0x3D, 0x9B, 0xAC, 0x7D, 0xFC, 0x05, 0x05, 0xED, 0x9E, 0xEC, 0xFD, 0xA7, 0x72, 0x41, 0xE8, - 0x52, 0x41, 0xE0, 0xEE, 0xBE, 0xCD, 0xF2, 0xD4, 0x0E, 0x78, 0xD2, 0xD5, 0x78, 0x6A, 0x57, 0xE0, - 0xA9, 0xBD, 0x23, 0x9E, 0x3A, 0x01, 0x4F, 0x6D, 0x35, 0x9E, 0x3A, 0x15, 0x78, 0xEA, 0xEC, 0x88, - 0xA7, 0x6E, 0xC0, 0x53, 0x47, 0x8D, 0xA7, 0x6E, 0x05, 0x9E, 0xBA, 0x3B, 0xE2, 0xA9, 0x17, 0xF0, - 0xD4, 0x55, 0xE3, 0xA9, 0x57, 0x81, 0xA7, 0xDE, 0x8E, 0x78, 0xEA, 0x07, 0x3C, 0xF5, 0xD4, 0x78, - 0xEA, 0x57, 0xE0, 0xA9, 0xBF, 0x23, 0x9E, 0x8E, 0x02, 0x9E, 0xFA, 0x6A, 0x3C, 0x1D, 0x55, 0xE0, - 0xE9, 0x68, 0x47, 0x3C, 0x1D, 0x07, 0x3C, 0x1D, 0xA9, 0xF1, 0x74, 0x5C, 0x81, 0xA7, 0xE3, 0x1D, - 0xF1, 0x74, 0x12, 0xF0, 0x74, 0xAC, 0xC6, 0xD3, 0x49, 0x05, 0x9E, 0x4E, 0x76, 0xC4, 0x13, 0xEE, - 0xA6, 0x62, 0x4C, 0x9D, 0x28, 0x0E, 0xBA, 0xAD, 0x0A, 0x5C, 0x19, 0xBB, 0xE2, 0x2A, 0x4C, 0x25, - 0x74, 0xD5, 0x5C, 0xA2, 0x4A, 0x32, 0x31, 0xDC, 0x15, 0x5B, 0x51, 0x36, 0xA1, 0x98, 0x4E, 0xE8, - 0x55, 0xF2, 0x89, 0xD1, 0xAE, 0xD8, 0x0A, 0x13, 0x0A, 0x5D, 0x31, 0xA3, 0xD0, 0xAB, 0xA4, 0x14, - 0xE3, 0x5D, 0xB1, 0x15, 0xE6, 0x14, 0xBA, 0x62, 0x52, 0xA1, 0x57, 0xC9, 0x2A, 0xC8, 0xAE, 0xD8, - 0x0A, 0xD3, 0x0A, 0x5D, 0x31, 0xAF, 0xD0, 0xAB, 0x24, 0x16, 0x93, 0x5D, 0xB1, 0x15, 0x66, 0x16, - 0xBA, 0x62, 0x6A, 0xA1, 0x57, 0xC8, 0x2D, 0x4E, 0xE4, 0x13, 0xB1, 0x8D, 0xB2, 0x45, 0x7C, 0x3E, - 0x45, 0x8E, 0x26, 0x6D, 0x4A, 0x0F, 0x1C, 0x71, 0x20, 0x7C, 0x22, 0x8E, 0x09, 0xE4, 0xC2, 0xB1, - 0x27, 0xE6, 0x34, 0x2C, 0x32, 0x3C, 0x98, 0x67, 0x63, 0x3C, 0xE1, 0x85, 0xBF, 0xCA, 0x85, 0x86, - 0xAB, 0x57, 0x97, 0xC5, 0xCA, 0x0C, 0x62, 0x2F, 0x7F, 0xA1, 0x22, 0x03, 0x90, 0xDD, 0x16, 0xBF, - 0x3E, 0xA0, 0x54, 0x57, 0xA0, 0x40, 0x45, 0x2A, 0x0A, 0x3D, 0xB1, 0xA2, 0xD0, 0x57, 0xAE, 0x28, - 0x30, 0xE2, 0xB6, 0x53, 0x4B, 0x00, 0xDC, 0x1D, 0xF6, 0xC9, 0x04, 0x75, 0xA6, 0x3B, 0xE5, 0x99, - 0xEE, 0x15, 0x61, 0xBA, 0x53, 0x86, 0xE9, 0x12, 0xCF, 0xB4, 0x2A, 0xCA, 0x09, 0xE8, 0x7D, 0x6D, - 0xDE, 0x90, 0xB1, 0xF6, 0x8B, 0xBA, 0xA8, 0xF4, 0xF2, 0xA2, 0x3A, 0x2A, 0x22, 0x2A, 0x7D, 0x8B, - 0xF6, 0xD1, 0x0B, 0xF8, 0xFE, 0x51, 0x9D, 0xEF, 0x5E, 0x79, 0xBE, 0x3B, 0x45, 0xF8, 0xEE, 0x6D, - 0x91, 0xEF, 0x6E, 0xC0, 0xF7, 0x27, 0x75, 0xBE, 0xBB, 0xE5, 0xF9, 0xEE, 0x16, 0xE1, 0xBB, 0xBB, - 0x45, 0xBE, 0xDB, 0x10, 0x6C, 0x7E, 0xFC, 0xA4, 0x7D, 0x9C, 0xB9, 0xC4, 0x9B, 0xE5, 0x57, 0xE2, - 0x18, 0x44, 0xD9, 0xB1, 0xBD, 0xB7, 0x83, 0xB9, 0x1B, 0x52, 0xD8, 0x11, 0x79, 0xCA, 0xCD, 0x9B, - 0x19, 0x84, 0xCA, 0x37, 0x89, 0xE4, 0x3C, 0xC9, 0x67, 0x6E, 0xBA, 0x2A, 0x53, 0xDB, 0x8B, 0x61, - 0xC7, 0xB5, 0xC1, 0xDB, 0x65, 0x81, 0xF1, 0xED, 0xB8, 0xBC, 0x3D, 0xAB, 0x57, 0xCC, 0x19, 0x5D, - 0x5B, 0xB3, 0xE7, 0x13, 0xCA, 0x33, 0xE4, 0x65, 0x9E, 0x82, 0xDA, 0xCB, 0x57, 0x21, 0x7A, 0x3B, - 0xA8, 0x92, 0x63, 0xA4, 0x3F, 0x62, 0xEC, 0xFC, 0x88, 0x0C, 0x69, 0x90, 0xB1, 0x14, 0x18, 0x8C, - 0x8E, 0x0A, 0x6A, 0xF3, 0xB8, 0x64, 0x74, 0x42, 0x1A, 0xB7, 0xA6, 0x4E, 0x9C, 0x7A, 0xA0, 0x00, - 0x3E, 0x95, 0x10, 0x40, 0xBF, 0xBC, 0x00, 0x0A, 0x65, 0x2E, 0x48, 0xE3, 0xF6, 0x04, 0xD0, 0x62, - 0x02, 0xB8, 0x8A, 0x5E, 0x7A, 0x9D, 0x61, 0xD0, 0x15, 0x2A, 0x50, 0xBD, 0x1D, 0xAC, 0x91, 0x60, - 0xA4, 0xD5, 0x03, 0x8B, 0x06, 0x8E, 0x8A, 0x29, 0xB4, 0x5D, 0x34, 0xBF, 0x92, 0x17, 0x3F, 0x15, - 0xF2, 0xEF, 0x6D, 0x26, 0x58, 0xED, 0x56, 0x60, 0xD1, 0xC5, 0x05, 0xD0, 0x2A, 0x2F, 0x00, 0xBD, - 0x90, 0x00, 0x5A, 0x0F, 0x2B, 0x19, 0xEF, 0xAF, 0x7F, 0xA7, 0x38, 0x5F, 0x5A, 0x45, 0xDD, 0x5F, - 0x18, 0xCD, 0xDA, 0x45, 0x84, 0xB5, 0x55, 0xEF, 0xEF, 0x44, 0x9C, 0x6B, 0xBF, 0x68, 0xF1, 0xAD, - 0xAF, 0x59, 0x71, 0xA0, 0x7C, 0x11, 0xB0, 0xB7, 0x83, 0xF5, 0x2A, 0xA4, 0xF0, 0x44, 0xC2, 0x59, - 0xC1, 0x00, 0x7F, 0x52, 0xDE, 0x1D, 0x0A, 0x69, 0x18, 0x69, 0xDD, 0x9E, 0x8A, 0x7B, 0x31, 0x41, - 0xB0, 0x6F, 0xA4, 0xAB, 0xA8, 0xB8, 0x7C, 0xE5, 0xB0, 0xB7, 0x83, 0xA5, 0x2E, 0xA4, 0xF0, 0x58, - 0xC2, 0x59, 0x41, 0x15, 0x17, 0x4D, 0x49, 0x8F, 0x4B, 0x4E, 0x2D, 0xF5, 0x6D, 0xE6, 0xA4, 0x58, - 0xED, 0x16, 0x04, 0x21, 0x7E, 0xC0, 0x22, 0x4B, 0xC1, 0xE5, 0x2B, 0xDE, 0xBD, 0x8A, 0xEB, 0xB3, - 0xDB, 0x8B, 0xE4, 0x47, 0xB2, 0xAF, 0x9B, 0xE7, 0xDB, 0x41, 0xD1, 0x5C, 0xB6, 0x55, 0x72, 0xE0, - 0xDB, 0x6A, 0x2A, 0x0B, 0xBD, 0x43, 0xD6, 0xB3, 0xCE, 0x7D, 0x86, 0x09, 0x94, 0x5F, 0x79, 0xEB, - 0xED, 0x60, 0x7B, 0x08, 0x52, 0xD8, 0xAE, 0x0D, 0x3E, 0x15, 0x64, 0xAA, 0x4A, 0xFD, 0xA0, 0xF4, - 0xFE, 0x90, 0xDD, 0x95, 0xDE, 0x47, 0xF3, 0x9B, 0xE2, 0xA5, 0xF7, 0x8B, 0x77, 0x3F, 0x17, 0x2B, - 0xBD, 0x8B, 0xBD, 0xEC, 0xAE, 0xF4, 0x5E, 0xCE, 0x66, 0x0A, 0x6D, 0x94, 0x05, 0xC6, 0xF0, 0x55, - 0x48, 0x23, 0xD3, 0xA3, 0x5D, 0x82, 0x60, 0xB4, 0xF7, 0xC1, 0x69, 0x28, 0x22, 0xE1, 0x19, 0xE5, - 0x78, 0xFB, 0x2C, 0xEB, 0xE9, 0x64, 0x84, 0x85, 0x52, 0xCF, 0xF0, 0xE2, 0x0B, 0x75, 0xFA, 0xFC, - 0x1B, 0x5F, 0x15, 0x9E, 0x05, 0x4E, 0x7B, 0xD7, 0xC8, 0xC1, 0x51, 0x41, 0xDC, 0x5B, 0x7F, 0xC5, - 0xC0, 0x20, 0xA1, 0x28, 0x9D, 0xEA, 0x47, 0xC7, 0x73, 0xE5, 0x3A, 0x39, 0x05, 0x2B, 0x12, 0xCD, - 0x3B, 0x62, 0xA9, 0x45, 0x3D, 0x9A, 0x33, 0xF2, 0xB6, 0x13, 0xCD, 0x11, 0x77, 0x8C, 0xF7, 0x02, - 0x59, 0x0D, 0x83, 0x2D, 0x26, 0x00, 0xF9, 0x26, 0x0A, 0x05, 0x01, 0xA4, 0x49, 0x60, 0x23, 0x22, - 0x68, 0x53, 0x09, 0xB4, 0x13, 0xDA, 0x4F, 0x09, 0xFC, 0xB4, 0x7D, 0xD9, 0xB8, 0xDF, 0xD9, 0x41, - 0x6D, 0x02, 0xC5, 0x15, 0xE3, 0xA8, 0xA0, 0x4E, 0x8B, 0x2D, 0x0E, 0xC6, 0x74, 0x5A, 0xCC, 0xA8, - 0xB7, 0xB6, 0x3A, 0x08, 0xC8, 0x3B, 0x54, 0x00, 0x1D, 0x65, 0x95, 0x96, 0x9F, 0x66, 0x76, 0x76, - 0x90, 0x9F, 0xA0, 0xB4, 0x62, 0x1C, 0x15, 0x54, 0x69, 0xB1, 0xA5, 0xCF, 0x98, 0x4A, 0xD5, 0xE7, - 0x97, 0x9C, 0xC8, 0xAD, 0xA9, 0xB4, 0x4B, 0x05, 0xD0, 0x55, 0x56, 0x69, 0xF9, 0x59, 0x47, 0x67, - 0x07, 0xBB, 0x77, 0x51, 0x5A, 0x31, 0x8E, 0x0A, 0xAA, 0xB4, 0xD8, 0x92, 0x5D, 0x4C, 0xA5, 0xEA, - 0xF3, 0x49, 0x4E, 0xE4, 0xD6, 0x54, 0xDA, 0xA3, 0x02, 0xE8, 0x29, 0xAB, 0xB4, 0x7C, 0xA5, 0xA0, - 0xB3, 0x83, 0x62, 0x10, 0x4A, 0x2B, 0xC6, 0x51, 0x41, 0x95, 0x16, 0x5B, 0x7D, 0x8E, 0xA9, 0x54, - 0x7D, 0x9D, 0x83, 0x13, 0xB9, 0x35, 0x95, 0xF6, 0xA9, 0x00, 0xFA, 0xCA, 0x2A, 0x2D, 0xBF, 0xBF, - 0xAA, 0xB3, 0x83, 0xBD, 0xDB, 0x28, 0xAD, 0x18, 0x47, 0x05, 0x55, 0x5A, 0xAC, 0x74, 0x1B, 0x53, - 0xA9, 0xFA, 0xCA, 0x0D, 0x27, 0x72, 0x6B, 0x2A, 0x3D, 0xA2, 0x02, 0x38, 0x52, 0x56, 0x69, 0xF9, - 0xAD, 0xEB, 0x9D, 0x1D, 0xD4, 0xF3, 0x50, 0x5A, 0x31, 0x8E, 0x0A, 0xAA, 0xB4, 0x58, 0x05, 0x27, - 0xA6, 0x52, 0xF5, 0xBD, 0x53, 0x9C, 0xC8, 0xAD, 0xA9, 0xF4, 0x98, 0x0A, 0xE0, 0x58, 0x59, 0xA5, - 0xE5, 0x77, 0xEE, 0x77, 0x76, 0xB0, 0x73, 0x1F, 0xA5, 0x15, 0xE3, 0xA8, 0xA0, 0x4A, 0x8B, 0xD5, - 0x66, 0x63, 0x2A, 0x55, 0xDF, 0xEE, 0xC4, 0x89, 0xDC, 0x9A, 0x4A, 0x4F, 0xA8, 0x00, 0x4E, 0x94, - 0x55, 0x5A, 0x7E, 0xCB, 0x40, 0x67, 0x07, 0x9B, 0x5F, 0x50, 0x5A, 0x2D, 0x91, 0xA3, 0x82, 0x2A, - 0x2D, 0xB6, 0xC0, 0xD8, 0x49, 0xD9, 0xFA, 0xA2, 0xA0, 0xD2, 0xB4, 0x05, 0xC6, 0x07, 0x50, 0xBF, - 0x33, 0x56, 0xC3, 0x12, 0x1F, 0xFC, 0x79, 0xF1, 0xD3, 0xCB, 0xF4, 0xC2, 0x7E, 0x6A, 0x15, 0x2F, - 0xD6, 0xD7, 0x43, 0x2F, 0xE3, 0x89, 0xF2, 0x42, 0xC2, 0x41, 0xCB, 0xEC, 0x25, 0x91, 0xDA, 0x1A, - 0xF3, 0xD9, 0x96, 0xC6, 0x80, 0x0B, 0x58, 0x5A, 0xA7, 0xDB, 0x92, 0x27, 0x2D, 0x39, 0x96, 0xC6, - 0xA9, 0xDC, 0x4E, 0xF0, 0x40, 0xE4, 0x30, 0x17, 0x47, 0xDE, 0x3F, 0x28, 0xAD, 0xE9, 0x30, 0x80, - 0x78, 0xF8, 0xE8, 0xB6, 0x4E, 0x14, 0xE3, 0x07, 0xC8, 0x20, 0x6D, 0x63, 0xFC, 0x06, 0x03, 0x08, - 0xD2, 0xD8, 0x61, 0x4C, 0xBD, 0x51, 0x66, 0x2A, 0x59, 0x05, 0x28, 0xC4, 0x54, 0x5A, 0x65, 0x67, - 0xC3, 0x4C, 0x75, 0x19, 0x53, 0x19, 0x4E, 0x9A, 0x60, 0x2A, 0x39, 0x0F, 0x2E, 0xC4, 0x54, 0xDA, - 0x44, 0x38, 0x62, 0xEA, 0x21, 0x04, 0x3A, 0x32, 0x32, 0xA6, 0xA3, 0x12, 0xA1, 0xEE, 0xF2, 0xE2, - 0xF0, 0xC5, 0x9B, 0x0B, 0x8D, 0x2E, 0x69, 0x3A, 0x56, 0xC1, 0x88, 0x17, 0xEF, 0xF4, 0x2F, 0x15, - 0xF3, 0x28, 0xE9, 0x42, 0xD4, 0x7B, 0x73, 0xA1, 0x1A, 0xF0, 0x38, 0x64, 0x91, 0x90, 0xD7, 0x6B, - 0x75, 0xCA, 0x54, 0x08, 0x43, 0x22, 0xB7, 0x14, 0xF4, 0x28, 0xFA, 0x76, 0x24, 0x83, 0xCB, 0x62, - 0x32, 0x28, 0x54, 0x25, 0x8D, 0xCB, 0xA0, 0x40, 0xD8, 0x0F, 0x88, 0xDC, 0xA6, 0x0C, 0x30, 0x4A, - 0x5E, 0x5E, 0x68, 0xEF, 0xFF, 0xA9, 0x5D, 0xDE, 0x2C, 0x1C, 0x6F, 0xE9, 0x92, 0xDC, 0xA8, 0xC2, - 0xE1, 0xE2, 0x71, 0xA5, 0xDF, 0xEB, 0x75, 0x54, 0x03, 0x4B, 0x2F, 0x7D, 0x08, 0x98, 0xB4, 0x36, - 0x18, 0x2F, 0x29, 0xA1, 0xDD, 0x90, 0xC1, 0x0F, 0x04, 0x34, 0xAD, 0x14, 0x37, 0x39, 0x60, 0x9C, - 0x43, 0xBD, 0x85, 0xDB, 0xAB, 0x15, 0x19, 0x94, 0x67, 0x94, 0x9D, 0x8D, 0x0E, 0x07, 0x94, 0xCA, - 0x5E, 0xC8, 0xDE, 0xA7, 0x8F, 0x57, 0x6A, 0x8C, 0x25, 0xEB, 0x68, 0xC5, 0x54, 0x97, 0xF6, 0xC8, - 0x68, 0xC1, 0x41, 0x41, 0xDE, 0xE8, 0xEC, 0x10, 0x62, 0xEC, 0xBA, 0x6C, 0x52, 0x44, 0x76, 0x36, - 0x31, 0xA7, 0x60, 0xB0, 0x72, 0x59, 0x52, 0x19, 0xB2, 0xB7, 0x9A, 0xE2, 0x17, 0x48, 0x9B, 0x23, - 0x08, 0xF3, 0xA0, 0x7B, 0xF4, 0xAE, 0x40, 0xB2, 0x73, 0x63, 0x4A, 0xA2, 0xEB, 0x1A, 0x0B, 0xE2, - 0x59, 0xC1, 0xD9, 0x60, 0x08, 0x8D, 0x6B, 0xC2, 0x3F, 0x97, 0xAA, 0xCD, 0x5C, 0x32, 0x39, 0xAF, - 0x7D, 0x13, 0xE2, 0xE4, 0x8F, 0xDF, 0x61, 0x93, 0x9A, 0x36, 0x76, 0x56, 0xB6, 0xE5, 0x18, 0x18, - 0xF8, 0x8D, 0x85, 0x0F, 0x94, 0x1E, 0xFC, 0xB1, 0xC0, 0x37, 0x5C, 0x19, 0xF8, 0xB4, 0x96, 0x91, - 0xD1, 0x8F, 0xA0, 0xFE, 0x91, 0xE5, 0x78, 0xC1, 0xB4, 0x0D, 0x0F, 0xC3, 0xCF, 0xAB, 0xFE, 0xCF, - 0x7F, 0xE7, 0x6D, 0x15, 0x30, 0xE7, 0x53, 0x41, 0x00, 0x35, 0xCD, 0x73, 0x47, 0xE7, 0x35, 0xA0, - 0xD4, 0x75, 0x3C, 0xCF, 0x71, 0xCD, 0xA9, 0x99, 0x32, 0x36, 0xA7, 0x49, 0xFB, 0x50, 0x26, 0xEE, - 0x44, 0x63, 0xC9, 0xB0, 0x7F, 0xE6, 0x8D, 0x5C, 0x73, 0xE1, 0x0F, 0x1E, 0x8D, 0x9D, 0xD1, 0x72, - 0x4E, 0x6C, 0xFF, 0xC0, 0x18, 0x8F, 0x2F, 0xAF, 0xE1, 0xE0, 0x3B, 0xFC, 0x16, 0x1F, 0x48, 0xBE, - 0x51, 0x7F, 0xF5, 0xAF, 0x77, 0x38, 0x0C, 0xE3, 0x35, 0x90, 0x17, 0x19, 0xD7, 0xF7, 0xB5, 0xC9, - 0xD2, 0x66, 0x23, 0x61, 0x83, 0x60, 0xDB, 0x3D, 0xED, 0x2B, 0x60, 0xBC, 0x36, 0x5C, 0x6D, 0x68, - 0x78, 0xE4, 0xAD, 0xE3, 0xF9, 0xDA, 0xB9, 0x16, 0x62, 0xB4, 0x9C, 0x11, 0xDD, 0xB7, 0x71, 0xC0, - 0xF8, 0xE2, 0x2D, 0x19, 0xE3, 0x3F, 0xBA, 0x16, 0x34, 0x0D, 0xA1, 0x9E, 0x6A, 0xF5, 0xD3, 0x63, - 0xBD, 0x8E, 0xF6, 0x17, 0x76, 0x31, 0x21, 0x10, 0xE6, 0xA1, 0x5D, 0x63, 0xE9, 0x5A, 0xFB, 0xDA, - 0x68, 0xB8, 0xF7, 0x95, 0x52, 0x4F, 0x2F, 0xE3, 0xB5, 0x3D, 0xCE, 0xCC, 0x81, 0x3F, 0x23, 0x76, - 0x23, 0xA2, 0xCC, 0x25, 0xDE, 0xC2, 0xB1, 0x3D, 0xC2, 0x88, 0x63, 0x3F, 0x73, 0x12, 0x5D, 0x3F, - 0xF0, 0x7C, 0xC3, 0x5F, 0x7A, 0xDA, 0xE3, 0xF3, 0x73, 0xAD, 0xDD, 0x6A, 0x89, 0xCD, 0x34, 0xE8, - 0x26, 0xD9, 0x6E, 0x5F, 0x4B, 0x5C, 0xF8, 0x48, 0x6E, 0xFC, 0xBD, 0x67, 0x21, 0xCC, 0x9D, 0x46, - 0x2C, 0x8F, 0xC4, 0x90, 0x84, 0x00, 0xF8, 0xDE, 0xB8, 0xC6, 0x5E, 0x9C, 0xC0, 0xC6, 0xD8, 0xF0, - 0x8D, 0xBD, 0xAF, 0x31, 0x7D, 0x41, 0xAF, 0x40, 0xC9, 0xBE, 0x46, 0x6F, 0x3D, 0x13, 0x6E, 0xDD, - 0xED, 0x1D, 0x80, 0x0C, 0x81, 0xDF, 0x10, 0x9A, 0xB8, 0x6E, 0x9C, 0x62, 0x0A, 0xDD, 0xD4, 0xF7, - 0x35, 0xBC, 0x13, 0x87, 0x15, 0x88, 0x7C, 0x14, 0x5C, 0x0B, 0x84, 0x96, 0x8D, 0x56, 0x82, 0x92, - 0xA1, 0xBB, 0x8B, 0xA9, 0x08, 0x02, 0xCE, 0x07, 0x32, 0x05, 0x89, 0x4D, 0xF7, 0x79, 0xFC, 0xD9, - 0xA7, 0xC1, 0x67, 0x9F, 0xC5, 0x2D, 0x41, 0x6B, 0x87, 0x87, 0xE0, 0xD2, 0x9E, 0x63, 0x11, 0xB0, - 0x8A, 0x69, 0xA3, 0xCE, 0xBF, 0xF5, 0x0A, 0x16, 0x55, 0x6F, 0xDD, 0xD4, 0x9F, 0x02, 0x82, 0x03, - 0xDF, 0xB9, 0xF2, 0x5D, 0xD3, 0x9E, 0x36, 0xF4, 0xFE, 0x5E, 0x84, 0x8D, 0xDE, 0x46, 0x94, 0x89, - 0xFB, 0xF4, 0x3A, 0xED, 0x24, 0x79, 0xA3, 0xC1, 0xAF, 0x3F, 0xAD, 0xEF, 0xD5, 0x39, 0xF1, 0xF4, - 0x1C, 0xCC, 0xAD, 0xC1, 0x0E, 0x9E, 0x50, 0x1A, 0xF7, 0xB4, 0xB3, 0x33, 0xDE, 0x0D, 0x6B, 0x85, - 0x17, 0xA1, 0x11, 0xFD, 0x93, 0xB8, 0x15, 0x9A, 0xE2, 0xEF, 0xFF, 0xF8, 0x1A, 0xD8, 0xEC, 0xDD, - 0x21, 0x50, 0xFD, 0x1C, 0x43, 0xF0, 0x3F, 0xBE, 0xC2, 0xFF, 0x77, 0x4F, 0x68, 0xD4, 0xFD, 0xC7, - 0x57, 0xFC, 0x73, 0xF7, 0x04, 0x7A, 0x82, 0x63, 0xDA, 0xDF, 0xDD, 0xEF, 0x54, 0x0E, 0xEB, 0xD2, - 0x9B, 0xA6, 0x4A, 0x2F, 0x14, 0x5B, 0x61, 0x9A, 0xA6, 0x19, 0x44, 0xFD, 0x1E, 0xF9, 0x6F, 0x63, - 0xE4, 0x8C, 0x41, 0x3D, 0x3E, 0x58, 0x72, 0xA0, 0x74, 0x0B, 0x54, 0x12, 0x08, 0xAA, 0x15, 0x28, - 0xDD, 0x9C, 0xD0, 0x96, 0x1A, 0x77, 0x95, 0xC8, 0x40, 0x82, 0x96, 0x0B, 0xC3, 0xF5, 0xC8, 0xB7, - 0xB6, 0xDF, 0xF0, 0x63, 0x4E, 0x91, 0x22, 0xF1, 0xC1, 0x20, 0xC6, 0x02, 0xFE, 0x00, 0x0E, 0xDA, - 0xD5, 0xB9, 0xD2, 0x42, 0x63, 0xE3, 0x7F, 0x13, 0x66, 0xF3, 0xA6, 0x90, 0xD9, 0x34, 0xA8, 0xD8, - 0xC2, 0x3E, 0xF7, 0x8A, 0x98, 0x10, 0x90, 0x25, 0x18, 0x10, 0x75, 0x88, 0x48, 0x64, 0xEC, 0x62, - 0x8A, 0x43, 0xFC, 0x3C, 0xB2, 0xBE, 0x34, 0x6E, 0xE0, 0xBF, 0x64, 0xCC, 0x5A, 0xD3, 0x15, 0x36, - 0x7A, 0x8E, 0xFF, 0x81, 0x82, 0xF0, 0x4F, 0xAA, 0xA1, 0x00, 0xD6, 0xF7, 0x96, 0xD5, 0x60, 0x1F, - 0x98, 0x03, 0x1B, 0x59, 0x42, 0x3C, 0xF4, 0x6E, 0x31, 0x32, 0x39, 0x8E, 0xFF, 0x79, 0x5F, 0x5B, - 0xB8, 0x40, 0x18, 0xFD, 0x96, 0x0A, 0x1C, 0x03, 0x22, 0x62, 0xB3, 0xBF, 0xB9, 0x14, 0x2C, 0x2C, - 0xEB, 0x39, 0xC3, 0x0A, 0x24, 0xB0, 0x03, 0x30, 0x99, 0x25, 0x9A, 0x2E, 0xFC, 0x7F, 0xF7, 0x04, - 0x3A, 0x81, 0x43, 0xF8, 0xFF, 0xEE, 0x09, 0x76, 0x85, 0x46, 0x85, 0x3D, 0xDE, 0x3D, 0x81, 0x1E, - 0xE1, 0x04, 0xFE, 0x87, 0x36, 0xD8, 0x2F, 0xB6, 0xC2, 0xBF, 0x70, 0x87, 0xF6, 0x8F, 0x37, 0xE9, - 0x01, 0xBB, 0xC0, 0x4F, 0xB3, 0x18, 0x64, 0x6F, 0xD7, 0x6F, 0xD0, 0xB7, 0x9D, 0x7F, 0xBE, 0x01, - 0x76, 0xE8, 0xC1, 0x2D, 0xC4, 0x20, 0x7B, 0x8C, 0xE7, 0xF8, 0xE7, 0x36, 0x50, 0x30, 0x5E, 0xE0, - 0x47, 0x70, 0x8D, 0xBE, 0x11, 0x16, 0x2F, 0xB1, 0x03, 0x6C, 0x45, 0xDF, 0xDF, 0x49, 0x5B, 0xB1, - 0x23, 0xB8, 0xC6, 0xDF, 0xFA, 0xB8, 0xAF, 0xF1, 0xF7, 0x0A, 0xE6, 0x0A, 0x27, 0x7A, 0xEF, 0xDF, - 0x73, 0xEF, 0x06, 0x19, 0x64, 0xA4, 0xA1, 0x54, 0xC2, 0xB3, 0xDB, 0xBB, 0x27, 0x04, 0xEF, 0x51, - 0x22, 0xE1, 0xF8, 0x96, 0x1F, 0xC3, 0x75, 0xA0, 0x0F, 0xEF, 0x04, 0x04, 0xD3, 0x0B, 0xB7, 0xD1, - 0x05, 0x68, 0xE1, 0xE3, 0x7D, 0x4E, 0x3C, 0x9C, 0xDD, 0x86, 0x67, 0x08, 0x4D, 0x61, 0x39, 0x1B, - 0x70, 0x7A, 0x1B, 0x9D, 0xC2, 0x5D, 0xE4, 0x05, 0x15, 0xC0, 0x79, 0xBA, 0x7B, 0xC2, 0x79, 0x42, - 0x2D, 0xB2, 0xA3, 0xB8, 0xA8, 0xE1, 0x7F, 0xF4, 0x23, 0x9F, 0x07, 0xEC, 0x4F, 0x81, 0x77, 0x12, - 0x6B, 0x4F, 0x3B, 0x1F, 0xF0, 0xB8, 0x8F, 0x01, 0x00, 0x3C, 0x0A, 0xAE, 0x13, 0xEB, 0xC0, 0xF0, - 0xC1, 0x21, 0x20, 0x6F, 0x22, 0xDE, 0x01, 0x46, 0x94, 0xD0, 0xCD, 0xD7, 0x6E, 0x1D, 0xD8, 0xE0, - 0x16, 0x14, 0xE1, 0xDE, 0x29, 0x0F, 0x1B, 0x88, 0x88, 0x71, 0xB9, 0x86, 0x8B, 0x5D, 0x4E, 0x43, - 0xC7, 0xEE, 0xA6, 0x60, 0xE4, 0xA1, 0x30, 0x0E, 0x81, 0x17, 0xD3, 0xB0, 0xD1, 0x51, 0x43, 0xC0, - 0xD5, 0xEE, 0xF5, 0x22, 0x6C, 0x89, 0x48, 0xC7, 0xE6, 0x94, 0x28, 0x90, 0x03, 0xCC, 0xCF, 0xA3, - 0xA1, 0x70, 0x04, 0xE6, 0xA0, 0xD5, 0x83, 0x09, 0x65, 0xFD, 0x74, 0x2D, 0xC2, 0x01, 0x04, 0x2F, - 0x20, 0x68, 0xCF, 0x19, 0x8D, 0xA7, 0x51, 0xF8, 0xD4, 0xB4, 0x21, 0x24, 0x3B, 0x5F, 0x9E, 0xC5, - 0x90, 0xD1, 0xD4, 0x3F, 0xC4, 0xC4, 0xAE, 0x61, 0xA2, 0x90, 0xB8, 0xC4, 0xB6, 0xDB, 0x35, 0x1D, - 0x9B, 0xC8, 0x7B, 0x8D, 0xC5, 0x4B, 0xDE, 0x11, 0x3F, 0x1B, 0x93, 0x89, 0xB1, 0xB4, 0xFC, 0x08, - 0xCC, 0x25, 0x90, 0xE8, 0xDA, 0x3C, 0x6C, 0xB1, 0x24, 0x3F, 0x77, 0xE8, 0xCE, 0x18, 0x2A, 0x82, - 0x51, 0xE1, 0x71, 0x72, 0x54, 0x00, 0xAB, 0x74, 0xFD, 0x46, 0xFD, 0xD2, 0x75, 0x1D, 0xF7, 0xD7, - 0xFA, 0x53, 0x6C, 0xF4, 0xB4, 0xFE, 0xDB, 0xA9, 0x46, 0xE3, 0xE9, 0x5E, 0x3C, 0xB8, 0x0B, 0xE1, - 0xF3, 0xF0, 0x50, 0x7B, 0xE1, 0xFB, 0x06, 0x28, 0x00, 0x6B, 0x2C, 0x33, 0x94, 0x8F, 0x66, 0xF0, - 0x24, 0xD0, 0x71, 0xD1, 0x28, 0xD9, 0xF7, 0xEE, 0x41, 0x22, 0x98, 0x58, 0x7A, 0x00, 0x12, 0x24, - 0x99, 0x14, 0xD5, 0xC1, 0xBF, 0x97, 0xC4, 0xBD, 0xBD, 0xA2, 0x02, 0x73, 0xDC, 0x17, 0x10, 0x2A, - 0xEB, 0x07, 0xD1, 0x3C, 0xA9, 0xCE, 0x72, 0x9E, 0x03, 0x40, 0x75, 0x09, 0x7D, 0x80, 0x8E, 0x23, - 0x9B, 0x67, 0xDC, 0x84, 0x7A, 0x87, 0x71, 0xEE, 0x9C, 0x2B, 0x23, 0x99, 0x64, 0x41, 0x0B, 0xC7, - 0xFE, 0x42, 0x6E, 0x97, 0x0B, 0x10, 0x7F, 0x94, 0x36, 0x25, 0x12, 0x39, 0x2E, 0x1D, 0x72, 0x00, - 0x2D, 0x2F, 0xF8, 0xC0, 0xA9, 0x77, 0x24, 0x8D, 0x22, 0x15, 0x50, 0xEB, 0x44, 0x4F, 0x7C, 0xB6, - 0xD6, 0xE8, 0xEE, 0x91, 0xFC, 0x4C, 0x92, 0x72, 0x72, 0x02, 0xB9, 0xF0, 0xC0, 0xB5, 0xA9, 0x63, - 0x27, 0x7A, 0x48, 0xA4, 0x83, 0x90, 0x0C, 0x46, 0x91, 0x61, 0xB9, 0x80, 0xE4, 0x93, 0xC4, 0x83, - 0x43, 0x68, 0x0B, 0xC1, 0xCD, 0xB9, 0xE3, 0x93, 0x44, 0xC4, 0x30, 0x6D, 0xD3, 0x37, 0x0D, 0xEB, - 0x53, 0x64, 0x8D, 0x5B, 0x75, 0x7F, 0x89, 0x8F, 0x17, 0xF0, 0xFF, 0xB5, 0x9C, 0x4F, 0x2D, 0x4F, - 0x59, 0xB3, 0x90, 0x30, 0x1E, 0x44, 0x56, 0x22, 0xCA, 0x21, 0x16, 0x16, 0xF8, 0xFD, 0xA0, 0xA7, - 0xC7, 0x8F, 0xE9, 0xD1, 0xA3, 0x50, 0x69, 0x41, 0xF4, 0x38, 0xD7, 0xA2, 0x1B, 0x09, 0x05, 0xAF, - 0xE3, 0x4E, 0xE0, 0x08, 0x90, 0x0B, 0x18, 0x12, 0x81, 0x7F, 0x01, 0xE9, 0x0D, 0xDA, 0xC2, 0xFF, - 0x47, 0xFD, 0x07, 0x14, 0xF5, 0xB7, 0x17, 0xE2, 0x33, 0x6C, 0x3B, 0xE1, 0x01, 0x0C, 0x4E, 0x9E, - 0x4F, 0x3F, 0x85, 0x44, 0x5B, 0x9E, 0x24, 0x87, 0xA1, 0x3B, 0x9C, 0xEC, 0xC3, 0x64, 0xE6, 0x92, - 0x85, 0xE7, 0x97, 0xB7, 0xDF, 0x8E, 0x1B, 0xF5, 0xF0, 0x8D, 0x46, 0xF5, 0x3D, 0x8C, 0x4B, 0x96, - 0x39, 0xFA, 0x12, 0x86, 0xA5, 0xC8, 0xF2, 0x20, 0xA5, 0xC1, 0xEC, 0x1F, 0x27, 0xD6, 0xE6, 0x88, - 0x9B, 0xEA, 0xAB, 0x0F, 0x2F, 0xDE, 0x7D, 0x7E, 0xF1, 0xF1, 0xE3, 0x07, 0x6D, 0x09, 0x36, 0xAB, - 0xF7, 0x3F, 0x63, 0xDA, 0x02, 0x93, 0x00, 0xF7, 0x33, 0xD0, 0xE7, 0x7D, 0xA6, 0x48, 0x5B, 0xBF, - 0xFE, 0xF6, 0x6B, 0xFB, 0x37, 0x00, 0xFD, 0xFA, 0x5F, 0x76, 0x9D, 0x31, 0x82, 0xA8, 0x9E, 0x02, - 0x2E, 0x3C, 0xFE, 0x5A, 0x7F, 0x1A, 0x18, 0x7C, 0x23, 0x9D, 0xC2, 0xF0, 0xF5, 0xBA, 0xF5, 0x3D, - 0x60, 0xF5, 0x6E, 0x1F, 0x50, 0xB1, 0x74, 0x10, 0xC6, 0x9C, 0x06, 0x96, 0x2A, 0x4C, 0xE8, 0x40, - 0x7F, 0x06, 0x7F, 0xCE, 0x34, 0xFD, 0x08, 0xFE, 0x3E, 0x7D, 0x1A, 0x99, 0x48, 0xC9, 0xEE, 0xEA, - 0x4F, 0x4D, 0xDA, 0x19, 0xCC, 0x4E, 0x1A, 0xE6, 0x19, 0x48, 0xF2, 0x79, 0x7D, 0xBF, 0x7E, 0x5A, - 0xAF, 0xC3, 0xB5, 0xA0, 0xFB, 0xBB, 0x18, 0x3B, 0x77, 0xCF, 0x42, 0x0E, 0xD9, 0xE8, 0x0A, 0x37, - 0x22, 0xF1, 0x8B, 0x59, 0xDD, 0x4B, 0x56, 0xE5, 0x3A, 0x4F, 0xD7, 0x09, 0x7B, 0x9B, 0xF5, 0x94, - 0x0E, 0x88, 0x22, 0x4C, 0x86, 0x82, 0x58, 0x68, 0x08, 0x7D, 0x2D, 0x15, 0x35, 0x1D, 0x6E, 0xC7, - 0x63, 0x17, 0xB4, 0x4D, 0xAD, 0x65, 0x6F, 0xCD, 0x85, 0xD5, 0x70, 0x60, 0x63, 0x09, 0x8E, 0xB5, - 0xE9, 0x66, 0x26, 0x12, 0xDA, 0x5A, 0xC0, 0xB2, 0x96, 0xF0, 0xB4, 0xEE, 0x2F, 0xD7, 0x61, 0x1A, - 0x9B, 0x2A, 0x6A, 0x6C, 0x2A, 0x68, 0x6C, 0xBA, 0x59, 0x8D, 0x71, 0xD4, 0x95, 0xB5, 0x16, 0xE0, - 0xC9, 0xD1, 0x5C, 0x2E, 0x3C, 0x57, 0x1A, 0xD7, 0xD6, 0x54, 0xA6, 0xAD, 0x32, 0x6A, 0x62, 0xB1, - 0x0B, 0x26, 0x45, 0xC4, 0x7D, 0xFB, 0xF1, 0xDD, 0x77, 0x18, 0x6D, 0xE4, 0x2A, 0x0B, 0x35, 0x96, - 0x4C, 0xAE, 0x24, 0x18, 0x30, 0x28, 0xC6, 0x2A, 0x1F, 0x89, 0xB0, 0xA9, 0x45, 0x15, 0x84, 0x1C, - 0x43, 0xE0, 0x05, 0x03, 0x35, 0xDF, 0xC5, 0x22, 0x41, 0xE0, 0xBC, 0x11, 0x54, 0x86, 0x2D, 0x20, - 0x80, 0x92, 0x12, 0x19, 0xE6, 0x35, 0x87, 0x11, 0x6A, 0x19, 0x3B, 0x77, 0x11, 0xEA, 0xAF, 0x9E, - 0x6A, 0x50, 0x0B, 0xA6, 0xEA, 0x51, 0x6C, 0xF3, 0x72, 0xA5, 0xC3, 0x27, 0xF4, 0x4A, 0x02, 0xE2, - 0x5F, 0x95, 0x93, 0x18, 0x38, 0x2F, 0x04, 0x14, 0xC0, 0x72, 0x2B, 0xC1, 0x42, 0x0B, 0x08, 0x4A, - 0x38, 0xE8, 0x97, 0xC8, 0x52, 0x30, 0xA8, 0x51, 0x41, 0x3F, 0xFD, 0x25, 0xC1, 0x10, 0x94, 0x2A, - 0x94, 0x90, 0x04, 0x9F, 0xAD, 0x4A, 0xC7, 0xA3, 0x46, 0x4C, 0xF0, 0xB1, 0x28, 0x09, 0x1E, 0x5E, - 0x1A, 0x51, 0x42, 0xC3, 0x3F, 0x74, 0x94, 0x8A, 0x45, 0x8D, 0x18, 0xFE, 0x6D, 0x21, 0x19, 0x4F, - 0xBC, 0x14, 0xA3, 0xC6, 0x13, 0xFF, 0x24, 0x4E, 0x3A, 0x1E, 0x45, 0xD9, 0xF0, 0xCF, 0xD0, 0xC8, - 0xAC, 0x8E, 0x55, 0x7E, 0x32, 0x1D, 0x83, 0x35, 0x01, 0x60, 0x9E, 0xAA, 0x3E, 0xD7, 0xC5, 0xCC, - 0x9A, 0x17, 0x8A, 0xB2, 0x30, 0xF0, 0x26, 0x49, 0x0C, 0x41, 0x74, 0xB8, 0x87, 0x92, 0xDD, 0x7D, - 0x44, 0xA1, 0xF7, 0x96, 0xA5, 0x16, 0x85, 0x16, 0x96, 0x15, 0x84, 0x9F, 0x10, 0x26, 0x25, 0xFC, - 0xD0, 0x25, 0x33, 0x5A, 0x6F, 0xCD, 0x94, 0x3F, 0x6D, 0xC1, 0xF0, 0xAE, 0x2B, 0x11, 0x71, 0xCC, - 0x97, 0x96, 0x92, 0x25, 0x41, 0x3B, 0x8E, 0x46, 0x34, 0x24, 0xBA, 0x1C, 0x77, 0xEB, 0xA9, 0x85, - 0xAE, 0x5B, 0x2F, 0x05, 0x03, 0x2D, 0x09, 0xAB, 0xE5, 0x66, 0xD0, 0x32, 0x05, 0xC9, 0xC2, 0x55, - 0x4B, 0xEF, 0xA0, 0x5D, 0x1A, 0x23, 0x58, 0x7F, 0x56, 0x63, 0x25, 0xF8, 0xEA, 0xB8, 0x8C, 0x0E, - 0x5A, 0xB5, 0xCE, 0x52, 0x0A, 0xFF, 0xBA, 0x73, 0x9A, 0x46, 0x16, 0xAA, 0x43, 0x6E, 0xF0, 0xFD, - 0x63, 0xC9, 0xB0, 0x5B, 0xB2, 0xD8, 0xBF, 0x7B, 0xE7, 0x30, 0xAE, 0x89, 0x82, 0x6B, 0x84, 0x2B, - 0xF3, 0x3C, 0xBB, 0x8B, 0xC0, 0x32, 0xBC, 0x63, 0x64, 0xD8, 0xD7, 0x46, 0xCC, 0x3B, 0x46, 0x30, - 0xFD, 0xF5, 0x09, 0x47, 0xDD, 0xA8, 0xB1, 0x06, 0x35, 0x4E, 0x23, 0x3B, 0x3B, 0xA0, 0x5B, 0xD6, - 0xB0, 0x10, 0x62, 0x92, 0x15, 0x3B, 0x89, 0xDD, 0x9E, 0x11, 0xFA, 0x9A, 0x10, 0x7E, 0x9F, 0x9D, - 0xB1, 0x06, 0x61, 0x2F, 0x43, 0x67, 0x7C, 0x7B, 0x60, 0x2C, 0x16, 0x10, 0xBC, 0x2E, 0x66, 0xA6, - 0x35, 0x6E, 0x30, 0x50, 0xC1, 0x44, 0x70, 0x6F, 0x02, 0xA1, 0xAB, 0x56, 0x1C, 0x2B, 0x30, 0x7C, - 0xC1, 0xAE, 0x35, 0xEA, 0xED, 0x71, 0xB0, 0x66, 0xC4, 0x9B, 0x1D, 0x8C, 0x5D, 0x63, 0xF5, 0x2D, - 0x6E, 0x6A, 0x68, 0x60, 0xA7, 0xFB, 0xAD, 0xFD, 0x16, 0x6F, 0xE0, 0xBB, 0xB7, 0x61, 0x96, 0x89, - 0x78, 0x71, 0xF1, 0xF7, 0xC7, 0x0F, 0xDF, 0x45, 0x78, 0x7D, 0xE7, 0x15, 0xBB, 0xD4, 0xA8, 0xD3, - 0x5D, 0x11, 0x87, 0x7F, 0x2C, 0x70, 0x2A, 0x10, 0x28, 0x45, 0x10, 0x23, 0x6E, 0x78, 0x40, 0x51, - 0xB1, 0xE6, 0xCF, 0x44, 0xA4, 0x70, 0xD9, 0x26, 0x2B, 0x0D, 0x30, 0x91, 0x86, 0x0C, 0x34, 0xD8, - 0x0E, 0x81, 0xE0, 0xC8, 0xC9, 0xEB, 0xA5, 0x65, 0xFD, 0x42, 0x0C, 0x17, 0xF4, 0xF1, 0x54, 0x6B, - 0xD4, 0x5A, 0xB5, 0xA7, 0x0D, 0x7A, 0xFD, 0x1D, 0xB0, 0x33, 0x6B, 0xEC, 0x3D, 0xD5, 0xF7, 0xF6, - 0x0E, 0x3C, 0xD0, 0x19, 0x69, 0x34, 0xDB, 0x41, 0x13, 0xF8, 0x43, 0xDB, 0xB0, 0x4E, 0xD2, 0xEF, - 0xBF, 0x75, 0x96, 0xAE, 0x97, 0xD5, 0xE0, 0x9D, 0x69, 0x63, 0x11, 0x27, 0xAB, 0xC9, 0x15, 0x01, - 0xC1, 0x8E, 0xD7, 0x9A, 0xD4, 0xE8, 0x2E, 0x0E, 0x5E, 0xFE, 0xD0, 0xE8, 0xE2, 0xB6, 0xD6, 0x10, - 0x2B, 0x3A, 0x7C, 0x19, 0x91, 0xA0, 0xA1, 0x37, 0x02, 0x8F, 0xBF, 0x13, 0x8D, 0x03, 0x5C, 0x16, - 0x0C, 0xE0, 0x7B, 0xF0, 0x80, 0x03, 0x97, 0xCC, 0x9D, 0x6B, 0xB2, 0xA6, 0x7F, 0x6C, 0x1E, 0x1A, - 0xFF, 0xCC, 0x1C, 0xB3, 0x32, 0x4D, 0x64, 0xB7, 0x58, 0x15, 0xC2, 0x0D, 0x22, 0xB8, 0x85, 0x02, - 0xF7, 0x53, 0x34, 0xEA, 0x6C, 0xF7, 0x0A, 0x1D, 0x15, 0xEE, 0x22, 0xB7, 0x99, 0x39, 0xAB, 0x2C, - 0x48, 0xD6, 0x7B, 0x02, 0x38, 0x84, 0x1E, 0x9B, 0x9E, 0x31, 0xB4, 0xF2, 0xBB, 0xE6, 0xED, 0xC6, - 0xBC, 0xFC, 0x0D, 0x0D, 0x82, 0x2B, 0x00, 0xEA, 0xBB, 0xB4, 0x4E, 0x28, 0xA0, 0x25, 0x76, 0x1E, - 0xD6, 0x80, 0xAC, 0x4C, 0xC4, 0x13, 0x03, 0x26, 0x4F, 0x71, 0xCC, 0xAC, 0x78, 0x5C, 0xA0, 0xAC, - 0x2C, 0x5E, 0x06, 0x88, 0xF8, 0x29, 0x98, 0x33, 0x58, 0xA8, 0xF6, 0x9C, 0xB2, 0xA0, 0x9D, 0xC6, - 0xEE, 0x86, 0xB9, 0x8D, 0x58, 0x30, 0x7D, 0xC4, 0xE2, 0xDF, 0x5F, 0xB0, 0x82, 0x1B, 0x52, 0x1E, - 0x93, 0xC0, 0x93, 0x27, 0x71, 0x6C, 0xB8, 0x8D, 0x85, 0x0D, 0x20, 0x61, 0x6F, 0xAC, 0x3D, 0x7B, - 0x7D, 0x78, 0xB4, 0xB2, 0xC0, 0x49, 0x82, 0xA1, 0xE0, 0x71, 0x4C, 0xF0, 0xC2, 0x18, 0x01, 0x84, - 0x98, 0x63, 0x2A, 0x20, 0xDC, 0x95, 0x56, 0x5B, 0x5B, 0xEF, 0x7F, 0x4E, 0xAD, 0xBE, 0x41, 0xF8, - 0xA6, 0xC4, 0x3D, 0x90, 0x3F, 0x1A, 0x73, 0x74, 0x21, 0x3E, 0x87, 0x4E, 0x60, 0x9C, 0xC6, 0x30, - 0x22, 0x63, 0x09, 0xBA, 0xF1, 0x77, 0x78, 0x48, 0x31, 0x4E, 0x0D, 0xD3, 0xBE, 0x20, 0x26, 0xA6, - 0x7F, 0x7B, 0xC2, 0x5D, 0xDA, 0x3D, 0x20, 0xC2, 0x0D, 0x83, 0xC2, 0x72, 0xC6, 0xFA, 0x7A, 0xC8, - 0xE1, 0x21, 0x6D, 0x9A, 0x82, 0x86, 0x76, 0xB1, 0x8E, 0x26, 0x93, 0xFA, 0xD5, 0xF0, 0x33, 0x62, - 0x93, 0x09, 0x85, 0xA2, 0x5B, 0x0D, 0x51, 0x1C, 0xB4, 0x57, 0x38, 0xCC, 0x42, 0x35, 0x31, 0x46, - 0xE4, 0xB3, 0x0B, 0x71, 0x6C, 0x6A, 0x9B, 0x7F, 0x12, 0x19, 0x42, 0xE6, 0x8C, 0x0D, 0x62, 0xBB, - 0x4E, 0x90, 0x48, 0x22, 0x72, 0xEE, 0x67, 0xF1, 0xEB, 0xF1, 0x21, 0x3A, 0x70, 0xBD, 0x70, 0x69, - 0x5D, 0xB4, 0x05, 0x56, 0xFA, 0x8F, 0x26, 0xC0, 0x91, 0xB1, 0x6D, 0xA8, 0x12, 0xAE, 0x63, 0x19, - 0x3C, 0x6C, 0x22, 0xD6, 0xAB, 0x2B, 0x16, 0xC1, 0x33, 0x70, 0xB2, 0xCD, 0x7D, 0x49, 0xA4, 0xCB, - 0xE1, 0xDC, 0xF4, 0x25, 0x08, 0xEB, 0x7A, 0x5D, 0x8A, 0x2B, 0xA5, 0x9E, 0x2E, 0x7A, 0x22, 0x8B, - 0x66, 0x74, 0xA9, 0x11, 0x10, 0xC5, 0xB6, 0x0A, 0x8C, 0xD8, 0x6E, 0xF5, 0xE7, 0x30, 0xE2, 0xE2, - 0x06, 0x00, 0x54, 0x75, 0x62, 0x0B, 0x10, 0x43, 0xC1, 0xF6, 0xAE, 0x51, 0x14, 0xF1, 0xDD, 0x6B, - 0xC1, 0x8E, 0xB1, 0xF8, 0xCA, 0xA4, 0xB8, 0xF7, 0xE5, 0x77, 0x97, 0x00, 0x9C, 0x87, 0xF3, 0x59, - 0xED, 0x1F, 0x5F, 0x29, 0x8A, 0x3B, 0x6D, 0x02, 0x91, 0xC0, 0x9B, 0x91, 0x31, 0x9D, 0x7B, 0xF9, - 0x4B, 0xEF, 0x54, 0xC3, 0x6D, 0x3F, 0xB1, 0xDD, 0x6A, 0x77, 0xBF, 0x87, 0x16, 0x12, 0x0E, 0x24, - 0xD1, 0x7A, 0x2B, 0x67, 0x7B, 0x6D, 0xDD, 0xF5, 0x91, 0xC2, 0x0A, 0x2B, 0x87, 0x55, 0x5A, 0x65, - 0x95, 0x2D, 0x4E, 0x26, 0xA3, 0x14, 0xCF, 0x31, 0x1F, 0xA9, 0xF4, 0x4D, 0x77, 0x5D, 0xAA, 0x74, - 0x29, 0xC9, 0x2E, 0xF1, 0xC7, 0xC2, 0x98, 0x25, 0x8C, 0xF9, 0x09, 0x3F, 0xDA, 0xE3, 0x72, 0x02, - 0x13, 0x19, 0x07, 0xF1, 0x96, 0x19, 0x11, 0xAE, 0x3E, 0x33, 0x3D, 0xC6, 0x4C, 0x80, 0x49, 0x9B, - 0x0B, 0x3B, 0x7F, 0x4B, 0x22, 0x5F, 0x76, 0x09, 0x95, 0xF5, 0x87, 0x07, 0x79, 0x7B, 0x28, 0x02, - 0x19, 0x0E, 0xEC, 0x40, 0x40, 0x10, 0x13, 0x51, 0x21, 0x15, 0x65, 0xC8, 0x8C, 0xFD, 0x84, 0x01, - 0x9B, 0x8E, 0xD6, 0xB4, 0xE7, 0x5F, 0xA9, 0x55, 0xFF, 0xB6, 0xCF, 0x46, 0x78, 0x21, 0x68, 0xEE, - 0x15, 0x21, 0x68, 0x6D, 0x55, 0x3E, 0x97, 0x98, 0x8D, 0xAD, 0x4F, 0x06, 0x3F, 0x88, 0xC5, 0x14, - 0xDF, 0xB9, 0xD6, 0x5A, 0x5B, 0x97, 0x17, 0x17, 0xD1, 0x42, 0x06, 0x25, 0xB2, 0x11, 0x16, 0xD5, - 0x03, 0xF1, 0x48, 0x17, 0xD0, 0x52, 0xC5, 0xC5, 0xEC, 0x8B, 0x45, 0x14, 0x9C, 0x16, 0x64, 0xCE, - 0x9F, 0xE8, 0x86, 0x59, 0x2A, 0xAF, 0x08, 0xE0, 0x22, 0xDC, 0x20, 0x9D, 0x0B, 0x19, 0x6D, 0xA6, - 0x16, 0x70, 0xD0, 0xF9, 0x98, 0xDA, 0xFA, 0x03, 0x9F, 0xBA, 0x09, 0xA0, 0x88, 0x35, 0x1F, 0x36, - 0x78, 0x4A, 0x26, 0x49, 0xBE, 0x38, 0x7A, 0x65, 0xC1, 0xD3, 0x61, 0x92, 0x35, 0x16, 0xA0, 0xA9, - 0xE7, 0xE7, 0x03, 0x8B, 0xDB, 0xB2, 0xEB, 0x82, 0xB0, 0x3D, 0xDF, 0x59, 0x5C, 0xD1, 0xAB, 0x89, - 0xA8, 0xB0, 0xA2, 0xC5, 0xAC, 0x03, 0xBC, 0x1F, 0xCC, 0x93, 0x44, 0x46, 0xE3, 0xF5, 0xFF, 0x2B, - 0xAC, 0x74, 0x69, 0x0C, 0x4F, 0x3D, 0x9E, 0xED, 0xD2, 0x22, 0x98, 0xB4, 0x07, 0x3A, 0xE7, 0xF4, - 0xDC, 0x11, 0x1B, 0x3C, 0xC2, 0x6D, 0xD0, 0x18, 0x3A, 0xF0, 0xF0, 0x77, 0xD6, 0x27, 0xA6, 0x12, - 0x31, 0x05, 0xEF, 0xE5, 0xD2, 0xE2, 0x2C, 0x92, 0xA4, 0x44, 0x01, 0x9E, 0x39, 0x9B, 0x87, 0xA3, - 0x06, 0x1B, 0x28, 0x31, 0x7C, 0x09, 0xCA, 0x4F, 0x89, 0x91, 0x91, 0x98, 0x78, 0x50, 0x8A, 0x13, - 0x2F, 0x8C, 0x7C, 0x6C, 0x0F, 0xFD, 0xF3, 0xCF, 0xA3, 0x21, 0x0C, 0x76, 0x38, 0x01, 0x04, 0xD7, - 0x5B, 0x35, 0xF6, 0xEE, 0xB2, 0xD8, 0x61, 0xE2, 0x8A, 0x14, 0xA9, 0x4A, 0x04, 0x8D, 0xDA, 0x72, - 0x6C, 0x31, 0xF9, 0xC8, 0xD1, 0x89, 0xD6, 0x7B, 0x69, 0x07, 0x13, 0x96, 0x34, 0xC1, 0x9E, 0xAF, - 0x8B, 0x96, 0xE5, 0xAC, 0x31, 0x04, 0x51, 0x3C, 0x5E, 0x23, 0x36, 0x91, 0x94, 0x0A, 0x76, 0x11, - 0x34, 0x08, 0x68, 0x17, 0x1D, 0x22, 0x85, 0xF6, 0xF8, 0x58, 0x99, 0x48, 0xFF, 0x02, 0x95, 0x5F, - 0x2C, 0x81, 0x88, 0x79, 0xA0, 0x72, 0x76, 0x0D, 0x33, 0xDC, 0xD0, 0x36, 0x21, 0xE3, 0xCD, 0xF2, - 0x19, 0xB8, 0x2D, 0x38, 0x1A, 0x4F, 0x8F, 0x73, 0x00, 0x68, 0x4E, 0xCC, 0x1E, 0x84, 0xA1, 0xB0, - 0x6C, 0xC3, 0xAE, 0xAF, 0x09, 0x89, 0x77, 0x66, 0x7C, 0x81, 0x66, 0x23, 0xD6, 0x4C, 0x40, 0x02, - 0x78, 0xD7, 0xD3, 0x05, 0x89, 0x20, 0xA0, 0xDD, 0x5E, 0xA8, 0x17, 0x04, 0xE2, 0x99, 0x68, 0xA4, - 0x95, 0xF4, 0xD9, 0xC4, 0xFA, 0x4C, 0x22, 0xA1, 0xB0, 0xF4, 0x19, 0xC4, 0xFA, 0xEC, 0xE1, 0x4E, - 0xD0, 0x42, 0xF0, 0xC4, 0x56, 0x24, 0x48, 0x92, 0x2D, 0x75, 0x22, 0x4A, 0x3D, 0x98, 0x49, 0xE5, - 0x40, 0x7C, 0xA6, 0xA9, 0x88, 0x28, 0x32, 0xA2, 0x28, 0x32, 0xC2, 0x45, 0x86, 0x00, 0x51, 0xE2, - 0x9E, 0x3F, 0xAD, 0x0B, 0xE3, 0xCA, 0x4F, 0x2F, 0x23, 0xCE, 0x56, 0xC3, 0x4C, 0x3A, 0xF9, 0x94, - 0x49, 0x60, 0x2F, 0x1B, 0x00, 0xDA, 0xCF, 0x61, 0xDC, 0x16, 0xD9, 0x5A, 0x0D, 0xD5, 0xD8, 0x0A, - 0xA6, 0x5C, 0x08, 0x10, 0xB1, 0x25, 0x9F, 0x98, 0x05, 0xAC, 0xBC, 0x22, 0x3E, 0x7F, 0xAE, 0xD3, - 0xB0, 0xC7, 0xDA, 0xC4, 0x35, 0xE6, 0xC4, 0x83, 0x09, 0x59, 0x48, 0xEC, 0x98, 0xDE, 0xCF, 0x1D, - 0xA2, 0x58, 0x33, 0x81, 0xC9, 0x70, 0x6A, 0x97, 0x0B, 0x1A, 0xB6, 0x14, 0xA0, 0x43, 0x3A, 0x32, - 0xA1, 0x83, 0x46, 0x6C, 0x6C, 0x0B, 0x4F, 0x95, 0x84, 0x15, 0xB6, 0x8E, 0x9C, 0x27, 0x42, 0xC0, - 0x26, 0x4A, 0x03, 0xAD, 0x97, 0x2C, 0x27, 0xB0, 0x84, 0x87, 0x31, 0x9B, 0x48, 0x6B, 0xC4, 0x06, - 0x21, 0x4B, 0xB1, 0x36, 0xA1, 0x83, 0x30, 0xF8, 0x34, 0x32, 0x73, 0x49, 0x61, 0xE5, 0xEA, 0xDA, - 0x7B, 0x8B, 0xE0, 0x34, 0x8F, 0xBF, 0xA9, 0xF3, 0xE2, 0xDB, 0xD7, 0x9A, 0xE3, 0x6A, 0x96, 0xB3, - 0x22, 0xB8, 0x59, 0x34, 0x58, 0x0A, 0xD6, 0x86, 0x04, 0x12, 0x4B, 0xC2, 0x26, 0xD3, 0x18, 0x87, - 0xFC, 0x99, 0xE9, 0x41, 0xEA, 0x8E, 0xEF, 0x93, 0x25, 0x8F, 0x6B, 0x61, 0x31, 0x34, 0x97, 0xBD, - 0xF5, 0xAD, 0x55, 0x31, 0x71, 0x32, 0x98, 0x48, 0x96, 0x8F, 0x39, 0x8F, 0x6B, 0xC1, 0x28, 0x6B, - 0xFA, 0x5E, 0x40, 0x84, 0xE1, 0xED, 0x07, 0x2B, 0x45, 0x39, 0x03, 0xB9, 0x82, 0x0C, 0xC1, 0x22, - 0x59, 0x46, 0xBC, 0xAE, 0x49, 0x53, 0x56, 0x23, 0xC9, 0xD0, 0x28, 0x96, 0x0B, 0xA5, 0x71, 0x3E, - 0x5D, 0x2B, 0x4C, 0xE2, 0x2C, 0x55, 0x67, 0xBF, 0xB3, 0xC3, 0xE0, 0xB1, 0x39, 0x76, 0x86, 0x8B, - 0x05, 0x83, 0x47, 0x67, 0x87, 0x33, 0x7F, 0x6E, 0x0D, 0x1E, 0xFD, 0x2F, 0x5E, 0x72, 0x0B, 0x89, - 0x42, 0x10, 0x01, 0x00 -}; diff --git a/esp32-cam/CameraWebServer/camera_pins.h b/esp32-cam/CameraWebServer/camera_pins.h deleted file mode 100644 index e1be287..0000000 --- a/esp32-cam/CameraWebServer/camera_pins.h +++ /dev/null @@ -1,273 +0,0 @@ - -#if defined(CAMERA_MODEL_WROVER_KIT) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 21 -#define SIOD_GPIO_NUM 26 -#define SIOC_GPIO_NUM 27 - -#define Y9_GPIO_NUM 35 -#define Y8_GPIO_NUM 34 -#define Y7_GPIO_NUM 39 -#define Y6_GPIO_NUM 36 -#define Y5_GPIO_NUM 19 -#define Y4_GPIO_NUM 18 -#define Y3_GPIO_NUM 5 -#define Y2_GPIO_NUM 4 -#define VSYNC_GPIO_NUM 25 -#define HREF_GPIO_NUM 23 -#define PCLK_GPIO_NUM 22 - -#elif defined(CAMERA_MODEL_ESP_EYE) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 4 -#define SIOD_GPIO_NUM 18 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 36 -#define Y8_GPIO_NUM 37 -#define Y7_GPIO_NUM 38 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 35 -#define Y4_GPIO_NUM 14 -#define Y3_GPIO_NUM 13 -#define Y2_GPIO_NUM 34 -#define VSYNC_GPIO_NUM 5 -#define HREF_GPIO_NUM 27 -#define PCLK_GPIO_NUM 25 - -#elif defined(CAMERA_MODEL_M5STACK_PSRAM) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 25 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 32 -#define VSYNC_GPIO_NUM 22 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_M5STACK_V2_PSRAM) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 22 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 32 -#define VSYNC_GPIO_NUM 25 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_M5STACK_WIDE) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 22 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 32 -#define VSYNC_GPIO_NUM 25 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_M5STACK_ESP32CAM) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 25 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 17 -#define VSYNC_GPIO_NUM 22 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_M5STACK_UNITCAM) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 25 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 32 -#define VSYNC_GPIO_NUM 22 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_AI_THINKER) -#define PWDN_GPIO_NUM 32 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 0 -#define SIOD_GPIO_NUM 26 -#define SIOC_GPIO_NUM 27 - -#define Y9_GPIO_NUM 35 -#define Y8_GPIO_NUM 34 -#define Y7_GPIO_NUM 39 -#define Y6_GPIO_NUM 36 -#define Y5_GPIO_NUM 21 -#define Y4_GPIO_NUM 19 -#define Y3_GPIO_NUM 18 -#define Y2_GPIO_NUM 5 -#define VSYNC_GPIO_NUM 25 -#define HREF_GPIO_NUM 23 -#define PCLK_GPIO_NUM 22 - -#elif defined(CAMERA_MODEL_TTGO_T_JOURNAL) -#define PWDN_GPIO_NUM 0 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 25 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 17 -#define VSYNC_GPIO_NUM 22 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - - -#elif defined(CAMERA_MODEL_ESP32_CAM_BOARD) -// The 18 pin header on the board has Y5 and Y3 swapped -#define USE_BOARD_HEADER 0 -#define PWDN_GPIO_NUM 32 -#define RESET_GPIO_NUM 33 -#define XCLK_GPIO_NUM 4 -#define SIOD_GPIO_NUM 18 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 36 -#define Y8_GPIO_NUM 19 -#define Y7_GPIO_NUM 21 -#define Y6_GPIO_NUM 39 -#if USE_BOARD_HEADER -#define Y5_GPIO_NUM 13 -#else -#define Y5_GPIO_NUM 35 -#endif -#define Y4_GPIO_NUM 14 -#if USE_BOARD_HEADER -#define Y3_GPIO_NUM 35 -#else -#define Y3_GPIO_NUM 13 -#endif -#define Y2_GPIO_NUM 34 -#define VSYNC_GPIO_NUM 5 -#define HREF_GPIO_NUM 27 -#define PCLK_GPIO_NUM 25 - -#elif defined(CAMERA_MODEL_ESP32S3_CAM_LCD) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 40 -#define SIOD_GPIO_NUM 17 -#define SIOC_GPIO_NUM 18 - -#define Y9_GPIO_NUM 39 -#define Y8_GPIO_NUM 41 -#define Y7_GPIO_NUM 42 -#define Y6_GPIO_NUM 12 -#define Y5_GPIO_NUM 3 -#define Y4_GPIO_NUM 14 -#define Y3_GPIO_NUM 47 -#define Y2_GPIO_NUM 13 -#define VSYNC_GPIO_NUM 21 -#define HREF_GPIO_NUM 38 -#define PCLK_GPIO_NUM 11 - -#elif defined(CAMERA_MODEL_ESP32S2_CAM_BOARD) -// The 18 pin header on the board has Y5 and Y3 swapped -#define USE_BOARD_HEADER 0 -#define PWDN_GPIO_NUM 1 -#define RESET_GPIO_NUM 2 -#define XCLK_GPIO_NUM 42 -#define SIOD_GPIO_NUM 41 -#define SIOC_GPIO_NUM 18 - -#define Y9_GPIO_NUM 16 -#define Y8_GPIO_NUM 39 -#define Y7_GPIO_NUM 40 -#define Y6_GPIO_NUM 15 -#if USE_BOARD_HEADER -#define Y5_GPIO_NUM 12 -#else -#define Y5_GPIO_NUM 13 -#endif -#define Y4_GPIO_NUM 5 -#if USE_BOARD_HEADER -#define Y3_GPIO_NUM 13 -#else -#define Y3_GPIO_NUM 12 -#endif -#define Y2_GPIO_NUM 14 -#define VSYNC_GPIO_NUM 38 -#define HREF_GPIO_NUM 4 -#define PCLK_GPIO_NUM 3 - -#elif defined(CAMERA_MODEL_ESP32S3_EYE) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 15 -#define SIOD_GPIO_NUM 4 -#define SIOC_GPIO_NUM 5 - -#define Y2_GPIO_NUM 11 -#define Y3_GPIO_NUM 9 -#define Y4_GPIO_NUM 8 -#define Y5_GPIO_NUM 10 -#define Y6_GPIO_NUM 12 -#define Y7_GPIO_NUM 18 -#define Y8_GPIO_NUM 17 -#define Y9_GPIO_NUM 16 - -#define VSYNC_GPIO_NUM 6 -#define HREF_GPIO_NUM 7 -#define PCLK_GPIO_NUM 13 - -#else -#error "Camera model not selected" -#endif diff --git a/esp32-cam/CameraWebServer/index_ov2640.html b/esp32-cam/CameraWebServer/index_ov2640.html deleted file mode 100644 index 4f3738c..0000000 --- a/esp32-cam/CameraWebServer/index_ov2640.html +++ /dev/null @@ -1,1160 +0,0 @@ - - - - - - ESP32 OV2460 - - - -
- -
- -
- -
-
-
- - - diff --git a/include/pio/include/homekit/logging.h b/include/pio/include/homekit/logging.h new file mode 100644 index 0000000..559ca33 --- /dev/null +++ b/include/pio/include/homekit/logging.h @@ -0,0 +1,20 @@ +#ifndef COMMON_HOMEKIT_LOGGING_H +#define COMMON_HOMEKIT_LOGGING_H + +#include + +#ifdef DEBUG + +#define PRINTLN(s) Serial.println(s) +#define PRINT(s) Serial.print(s) +#define PRINTF(fmt, ...) Serial.printf(fmt, ##__VA_ARGS__) + +#else + +#define PRINTLN(s) +#define PRINT(s) +#define PRINTF(...) + +#endif + +#endif //COMMON_HOMEKIT_LOGGING_H \ No newline at end of file diff --git a/include/pio/include/homekit/macros.h b/include/pio/include/homekit/macros.h new file mode 100644 index 0000000..7d3ad83 --- /dev/null +++ b/include/pio/include/homekit/macros.h @@ -0,0 +1 @@ +#define ARRAY_SIZE(X) sizeof((X))/sizeof((X)[0]) \ No newline at end of file diff --git a/include/pio/include/homekit/stopwatch.h b/include/pio/include/homekit/stopwatch.h new file mode 100644 index 0000000..bac2fcc --- /dev/null +++ b/include/pio/include/homekit/stopwatch.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +namespace homekit { + +class StopWatch { +private: + unsigned long time; + +public: + StopWatch() : time(0) {}; + + inline void save() { + time = millis(); + } + + inline bool elapsed(unsigned long ms) { + unsigned long now = millis(); + if (now < time) { + // rollover? + time = now; + } else if (now - time >= ms) { + return true; + } + return false; + } +}; + +} \ No newline at end of file diff --git a/include/pio/include/homekit/util.h b/include/pio/include/homekit/util.h new file mode 100644 index 0000000..e0780d8 --- /dev/null +++ b/include/pio/include/homekit/util.h @@ -0,0 +1,13 @@ +#pragma once + +namespace homekit { + +inline size_t otaGetMaxUpdateSize() { + return (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000; +} + +inline void restart() { + ESP.restart(); +} + +} \ No newline at end of file diff --git a/include/pio/libs/config/homekit/config.cpp b/include/pio/libs/config/homekit/config.cpp new file mode 100644 index 0000000..5bafcad --- /dev/null +++ b/include/pio/libs/config/homekit/config.cpp @@ -0,0 +1,84 @@ +#include +#include +#include "config.h" +#include + +#define GET_DATA_CRC(data) \ + eeprom_crc(reinterpret_cast(&(data))+4, sizeof(ConfigData)-4) + +namespace homekit::config { + +static const uint32_t magic = 0xdeadbeef; +static const uint32_t crc_table[16] PROGMEM = { + 0x00000000, 0x1db71064, 0x3b6e20c8, 0x26d930ac, + 0x76dc4190, 0x6b6b51f4, 0x4db26158, 0x5005713c, + 0xedb88320, 0xf00f9344, 0xd6d6a3e8, 0xcb61b38c, + 0x9b64c2b0, 0x86d3d2d4, 0xa00ae278, 0xbdbdf21c +}; + +static uint32_t eeprom_crc(const uint8_t* data, size_t len) { + uint32_t crc = ~0L; + for (size_t index = 0; index < len; index++) { + crc = pgm_read_word(&crc_table[(crc ^ data[index]) & 0x0f]) ^ (crc >> 4); + crc = pgm_read_word(&crc_table[(crc ^ (data[index] >> 4)) & 0x0f]) ^ (crc >> 4); + crc = ~crc; + } + return crc; +} + +ConfigData read() { + ConfigData data; + EEPROM.begin(sizeof(ConfigData)); + EEPROM.get(0, data); + EEPROM.end(); +#ifdef DEBUG + if (!isValid(data)) { + PRINTLN("config::read(): data is not valid!"); + } +#endif + return data; +} + +void write(ConfigData& data) { + EEPROM.begin(sizeof(ConfigData)); + data.magic = magic; + data.crc = GET_DATA_CRC(data); + EEPROM.put(0, data); + EEPROM.end(); +} + +void erase() { + ConfigData data; + erase(data); +} + +void erase(ConfigData& data) { + bzero(reinterpret_cast(&data), sizeof(data)); + write(data); +} + +bool isValid(ConfigData& data) { + return data.crc == GET_DATA_CRC(data); +} + +bool isDirty(ConfigData& data) { + return data.magic != magic; +} + +char* ConfigData::escapeHomeId(char* buf, size_t len) { + if (len < 32) + return nullptr; + size_t id_len = strlen(node_id); + char* c = node_id; + char* dst = buf; + for (size_t i = 0; i < id_len; i++) { + if (*c == '"') + *(dst++) = '\\'; + *(dst++) = *c; + c++; + } + *dst = '\0'; + return buf; +} + +} diff --git a/include/pio/libs/config/homekit/config.h b/include/pio/libs/config/homekit/config.h new file mode 100644 index 0000000..28f01fb --- /dev/null +++ b/include/pio/libs/config/homekit/config.h @@ -0,0 +1,37 @@ +#ifndef COMMON_HOMEKIT_CONFIG_H +#define COMMON_HOMEKIT_CONFIG_H + +#include + +namespace homekit::config { + +struct ConfigFlags { + uint8_t wifi_configured: 1; + uint8_t node_configured: 1; + uint8_t reserved: 6; +} __attribute__((packed)); + +struct ConfigData { + // helpers + uint32_t crc = 0; + uint32_t magic = 0; + char node_id[16] = {0}; + char wifi_ssid[32] = {0}; + char wifi_psk[63] = {0}; + ConfigFlags flags {0}; + + // helper methods + char* escapeHomeId(char* buf, size_t len); +} __attribute__((packed)); + + +ConfigData read(); +void write(ConfigData& data); +void erase(); +void erase(ConfigData& data); +bool isValid(ConfigData& data); +bool isDirty(ConfigData& data); + +} + +#endif //COMMON_HOMEKIT_CONFIG_H \ No newline at end of file diff --git a/include/pio/libs/config/library.json b/include/pio/libs/config/library.json new file mode 100644 index 0000000..720d093 --- /dev/null +++ b/include/pio/libs/config/library.json @@ -0,0 +1,8 @@ +{ + "name": "homekit_config", + "version": "1.0.2", + "build": { + "flags": "-I../../include" + } +} + diff --git a/include/pio/libs/http_server/homekit/http_server.cpp b/include/pio/libs/http_server/homekit/http_server.cpp new file mode 100644 index 0000000..ea81f5b --- /dev/null +++ b/include/pio/libs/http_server/homekit/http_server.cpp @@ -0,0 +1,282 @@ +#include "http_server.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace homekit { + +using files::StaticFile; + +static const char CONTENT_TYPE_HTML[] PROGMEM = "text/html; charset=utf-8"; +static const char CONTENT_TYPE_CSS[] PROGMEM = "text/css"; +static const char CONTENT_TYPE_JS[] PROGMEM = "application/javascript"; +static const char CONTENT_TYPE_JSON[] PROGMEM = "application/json"; +static const char CONTENT_TYPE_FAVICON[] PROGMEM = "image/x-icon"; + +static const char JSON_UPDATE_FMT[] PROGMEM = "{\"result\":%d}"; +static const char JSON_STATUS_FMT[] PROGMEM = "{\"node_id\":\"%s\"" +#ifdef DEBUG + ",\"configured\":%d" + ",\"crc\":%u" + ",\"fl_n\":%d" + ",\"fl_w\":%d" +#endif + "}"; +static const size_t JSON_BUF_SIZE = 192; + +static const char JSON_SCAN_FIRST_LIST[] PROGMEM = "{\"list\":["; + +static const char MSG_IS_INVALID[] PROGMEM = " is invalid"; +static const char MSG_IS_MISSING[] PROGMEM = " is missing"; + +static const char GZIP[] PROGMEM = "gzip"; +static const char CONTENT_ENCODING[] PROGMEM = "Content-Encoding"; +static const char NOT_FOUND[] PROGMEM = "Not Found"; + +static const char ROUTE_STYLE_CSS[] PROGMEM = "/style.css"; +static const char ROUTE_APP_JS[] PROGMEM = "/app.js"; +static const char ROUTE_MD5_JS[] PROGMEM = "/md5.js"; +static const char ROUTE_FAVICON_ICO[] PROGMEM = "/favicon.ico"; +static const char ROUTE_STATUS[] PROGMEM = "/status"; +static const char ROUTE_SCAN[] PROGMEM = "/scan"; +static const char ROUTE_RESET[] PROGMEM = "/reset"; +// #ifdef DEBUG +static const char ROUTE_HEAP[] PROGMEM = "/heap"; +// #endif +static const char ROUTE_UPDATE[] PROGMEM = "/update"; + +void HttpServer::start() { + server.on(FPSTR(ROUTE_STYLE_CSS), HTTP_GET, [&]() { sendGzip(files::style_css, CONTENT_TYPE_CSS); }); + server.on(FPSTR(ROUTE_APP_JS), HTTP_GET, [&]() { sendGzip(files::app_js, CONTENT_TYPE_JS); }); + server.on(FPSTR(ROUTE_MD5_JS), HTTP_GET, [&]() { sendGzip(files::md5_js, CONTENT_TYPE_JS); }); + server.on(FPSTR(ROUTE_FAVICON_ICO), HTTP_GET, [&]() { sendGzip(files::favicon_ico, CONTENT_TYPE_FAVICON); }); + + server.on("/", HTTP_GET, [&]() { sendGzip(files::index_html, CONTENT_TYPE_HTML); }); + server.on(FPSTR(ROUTE_STATUS), HTTP_GET, [&]() { + char json_buf[JSON_BUF_SIZE]; + auto cfg = config::read(); + + if (!isValid(cfg) || !cfg.flags.node_configured) { + sprintf_P(json_buf, JSON_STATUS_FMT + , CONFIG_NODE_ID +#ifdef DEBUG + , 0 + , cfg.crc + , cfg.flags.node_configured + , cfg.flags.wifi_configured +#endif + ); + } else { + char escaped_node_id[32]; + char *escaped_node_id_res = cfg.escapeHomeId(escaped_node_id, 32); + sprintf_P(json_buf, JSON_STATUS_FMT + , escaped_node_id_res == nullptr ? "?" : escaped_node_id +#ifdef DEBUG + , 1 + , cfg.crc + , cfg.flags.node_configured + , cfg.flags.wifi_configured +#endif + ); + } + server.send(200, FPSTR(CONTENT_TYPE_JSON), json_buf); + }); + server.on(FPSTR(ROUTE_STATUS), HTTP_POST, [&]() { + auto cfg = config::read(); + String s; + + if (!getInputParam("ssid", 32, s)) return; + strncpy(cfg.wifi_ssid, s.c_str(), 32); + PRINTF("saving ssid: %s\n", cfg.wifi_ssid); + + if (!getInputParam("psk", 63, s)) return; + strncpy(cfg.wifi_psk, s.c_str(), 63); + PRINTF("saving psk: %s\n", cfg.wifi_psk); + + if (!getInputParam("hid", 16, s)) return; + strcpy(cfg.node_id, s.c_str()); + PRINTF("saving home id: %s\n", cfg.node_id); + + cfg.flags.node_configured = 1; + cfg.flags.wifi_configured = 1; + + config::write(cfg); + + restartTimer.once(0, restart); + }); + + server.on(FPSTR(ROUTE_RESET), HTTP_POST, [&]() { + config::erase(); + restartTimer.once(1, restart); + }); + + server.on(FPSTR(ROUTE_HEAP), HTTP_GET, [&]() { + server.send(200, FPSTR(CONTENT_TYPE_HTML), String(ESP.getFreeHeap())); + }); + + server.on(FPSTR(ROUTE_SCAN), HTTP_GET, [&]() { + size_t i = 0; + size_t len; + const char* ssid; + bool enough = false; + + bzero(reinterpret_cast(scanBuf), scanBufSize); + char* cur = scanBuf; + + strncpy_P(cur, JSON_SCAN_FIRST_LIST, scanBufSize); + cur += 9; + + for (auto& res: *scanResults) { + ssid = res.ssid.c_str(); + len = res.ssid.length(); + + // new item (array with 2 items) + *cur++ = '['; + + // 1. ssid (string) + *cur++ = '"'; + for (size_t j = 0; j < len; j++) { + if (*(ssid+j) == '"') + *cur++ = '\\'; + *cur++ = *(ssid+j); + } + *cur++ = '"'; + *cur++ = ','; + + // 2. rssi (number) + cur += sprintf(cur, "%d", res.rssi); + + // close array + *cur++ = ']'; + + if ((size_t)(cur - scanBuf) >= (size_t) ARRAY_SIZE(scanBuf) - 40) + enough = true; + + if (i < scanResults->size() - 1 || enough) + *cur++ = ','; + + if (enough) + break; + + i++; + } + + *cur++ = ']'; + *cur++ = '}'; + *cur++ = '\0'; + + server.send(200, FPSTR(CONTENT_TYPE_JSON), scanBuf); + }); + + server.on(FPSTR(ROUTE_UPDATE), HTTP_POST, [&]() { + char json_buf[16]; + bool should_reboot = !Update.hasError() && !ota.invalidMd5; + Update.clearError(); + + sprintf_P(json_buf, JSON_UPDATE_FMT, should_reboot ? 1 : 0); + + server.send(200, FPSTR(CONTENT_TYPE_JSON), json_buf); + + if (should_reboot) + restartTimer.once(1, restart); + }, [&]() { + HTTPUpload& upload = server.upload(); + + if (upload.status == UPLOAD_FILE_START) { + ota.clean(); + + String s; + if (!getInputParam("md5", 0, s)) { + ota.invalidMd5 = true; + PRINTLN("http/ota: md5 not found"); + return; + } + + if (!Update.setMD5(s.c_str())) { + ota.invalidMd5 = true; + PRINTLN("http/ota: setMD5() failed"); + return; + } + + Serial.printf("http/ota: starting, filename=%s\n", upload.filename.c_str()); + if (!Update.begin(otaGetMaxUpdateSize())) { +#ifdef DEBUG + Update.printError(Serial); +#endif + } + } else if (upload.status == UPLOAD_FILE_WRITE) { + if (!Update.isRunning()) + return; + + PRINTF("http/ota: writing %ul\n", upload.currentSize); + ota_led(); + + if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { +#ifdef DEBUG + Update.printError(Serial); +#endif + } + } else if (upload.status == UPLOAD_FILE_END) { + if (!Update.isRunning()) + return; + + if (Update.end(true)) { + PRINTF("http/ota: ok, total size %ul\n", upload.totalSize); + } else { +#ifdef DEBUG + Update.printError(Serial); +#endif + } + } + }); + + server.onNotFound([&]() { + server.send(404, FPSTR(CONTENT_TYPE_HTML), NOT_FOUND); + }); + + server.begin(); +} + +void HttpServer::loop() { + server.handleClient(); +} + +void HttpServer::sendGzip(const StaticFile& file, PGM_P content_type) { + server.sendHeader(FPSTR(CONTENT_ENCODING), FPSTR(GZIP)); + server.send_P(200, content_type, (const char*)file.content, file.size); +} + +void HttpServer::sendError(const String& message) { + char buf[32]; + if (snprintf_P(buf, 32, PSTR("error: %s"), message.c_str()) == 32) + buf[31] = '\0'; + server.send(400, FPSTR(CONTENT_TYPE_HTML), buf); +} + +bool HttpServer::getInputParam(const char *field_name, + size_t max_len, + String& dst) { + if (!server.hasArg(field_name)) { + sendError(String(field_name) + String(MSG_IS_MISSING)); + return false; + } + + String field = server.arg(field_name); + if (!field.length() || (max_len != 0 && field.length() > max_len)) { + sendError(String(field_name) + String(MSG_IS_INVALID)); + return false; + } + + dst = field; + return true; +} + +void HttpServer::ota_led() const {} + +} diff --git a/include/pio/libs/http_server/homekit/http_server.h b/include/pio/libs/http_server/homekit/http_server.h new file mode 100644 index 0000000..8725a88 --- /dev/null +++ b/include/pio/libs/http_server/homekit/http_server.h @@ -0,0 +1,62 @@ +#ifndef COMMON_HOMEKIT_HTTP_SERVER_H +#define COMMON_HOMEKIT_HTTP_SERVER_H + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace homekit { + +struct OTAStatus { + bool invalidMd5; + + OTAStatus() : invalidMd5(false) {} + + inline void clean() { + invalidMd5 = false; + } +}; + +using files::StaticFile; + +class HttpServer { +private: + ESP8266WebServer server; + Ticker restartTimer; + std::shared_ptr> scanResults; + OTAStatus ota; + + char* scanBuf; + size_t scanBufSize; + + void sendGzip(const StaticFile& file, PGM_P content_type); + void sendError(const String& message); + + bool getInputParam(const char* field_name, size_t max_len, String& dst); + virtual void ota_led() const; + +public: + explicit HttpServer(std::shared_ptr> scanResults) + : server(80) + , scanResults(std::move(scanResults)) + , scanBufSize(512) { + scanBuf = new char[scanBufSize]; + }; + + ~HttpServer() { + delete[] scanBuf; + } + + void start(); + void loop(); +}; + +} + +#endif //COMMON_HOMEKIT_HTTP_SERVER_H diff --git a/include/pio/libs/http_server/library.json b/include/pio/libs/http_server/library.json new file mode 100644 index 0000000..ee2d369 --- /dev/null +++ b/include/pio/libs/http_server/library.json @@ -0,0 +1,8 @@ +{ + "name": "homekit_http_server", + "version": "1.0.3", + "build": { + "flags": "-I../../include" + } +} + diff --git a/include/pio/libs/led/homekit/led.cpp b/include/pio/libs/led/homekit/led.cpp new file mode 100644 index 0000000..ffefb04 --- /dev/null +++ b/include/pio/libs/led/homekit/led.cpp @@ -0,0 +1,27 @@ +#include "led.h" + +namespace homekit::led { + +void Led::on_off(uint16_t delay_ms, bool last_delay) const { + on(); + delay(delay_ms); + + off(); + if (last_delay) + delay(delay_ms); +} + +void Led::blink(uint8_t count, uint16_t delay_ms) const { + for (uint8_t i = 0; i < count; i++) { + on_off(delay_ms, i < count-1); + } +} + + +#ifdef CONFIG_TARGET_NODEMCU +const Led* board_led = new Led(CONFIG_BOARD_LED_GPIO); +#endif +const Led* mcu_led = new Led(CONFIG_MCU_LED_GPIO); + + +} diff --git a/include/pio/libs/led/homekit/led.h b/include/pio/libs/led/homekit/led.h new file mode 100644 index 0000000..775d2eb --- /dev/null +++ b/include/pio/libs/led/homekit/led.h @@ -0,0 +1,33 @@ +#ifndef HOMEKIT_LIB_LED_H +#define HOMEKIT_LIB_LED_H + +#include +#include + +namespace homekit::led { + +class Led { +private: + uint8_t _pin; + +public: + explicit Led(uint8_t pin) : _pin(pin) { + pinMode(_pin, OUTPUT); + off(); + } + + inline void off() const { digitalWrite(_pin, HIGH); } + inline void on() const { digitalWrite(_pin, LOW); } + + void on_off(uint16_t delay_ms, bool last_delay = false) const; + void blink(uint8_t count, uint16_t delay_ms) const; +}; + +#ifdef CONFIG_TARGET_NODEMCU +extern const Led* board_led; +#endif +extern const Led* mcu_led; + +} + +#endif //HOMEKIT_LIB_LED_H diff --git a/include/pio/libs/led/library.json b/include/pio/libs/led/library.json new file mode 100644 index 0000000..6785d42 --- /dev/null +++ b/include/pio/libs/led/library.json @@ -0,0 +1,8 @@ +{ + "name": "homekit_led", + "version": "1.0.8", + "build": { + "flags": "-I../../include" + } +} + diff --git a/include/pio/libs/main/homekit/main.cpp b/include/pio/libs/main/homekit/main.cpp new file mode 100644 index 0000000..816c764 --- /dev/null +++ b/include/pio/libs/main/homekit/main.cpp @@ -0,0 +1,213 @@ +#include "./main.h" +#include +#include +#include +#include + +namespace homekit::main { + +#ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY +enum WorkingMode working_mode = WorkingMode::NORMAL; +#endif +#endif + +static const uint16_t recovery_boot_detection_ms = 2000; +static const uint8_t recovery_boot_delay_ms = 100; + +static volatile enum WiFiConnectionState wifi_state = WiFiConnectionState::WAITING; +static void* service = nullptr; +static WiFiEventHandler wifiConnectHandler, wifiDisconnectHandler; +static Ticker wifiTimer; +static mqtt::MqttDiagnosticsModule* mqttDiagModule; +static mqtt::MqttOtaModule* mqttOtaModule; + +#if MQTT_BLINK +static StopWatch blinkStopWatch; +#endif + +#ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY +static DNSServer* dnsServer = nullptr; +#endif +#endif + +static void onWifiConnected(const WiFiEventStationModeGotIP& event); +static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event); + +static void wifiConnect() { + const char *ssid, *psk, *hostname; + auto cfg = config::read(); + wifi::getConfig(cfg, &ssid, &psk, &hostname); + + PRINTF("Wi-Fi STA creds: ssid=%s, psk=%s, hostname=%s\n", ssid, psk, hostname); + + wifi_state = WiFiConnectionState::WAITING; + + WiFi.mode(WIFI_STA); + WiFi.hostname(hostname); + WiFi.begin(ssid, psk); + + PRINT("connecting to wifi.."); +} + +#ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY +static void wifiHotspot() { + led::mcu_led->on(); + + auto scanResults = wifi::scan(); + + WiFi.mode(WIFI_AP); + WiFi.softAP(wifi::AP_SSID); + + dnsServer = new DNSServer(); + dnsServer->start(53, "*", WiFi.softAPIP()); + + service = new HttpServer(scanResults); + ((HttpServer*)service)->start(); +} + +static void waitForRecoveryPress() { + pinMode(CONFIG_FLASH_GPIO, INPUT_PULLUP); + for (uint16_t i = 0; i < recovery_boot_detection_ms; i += recovery_boot_delay_ms) { + delay(recovery_boot_delay_ms); + if (digitalRead(CONFIG_FLASH_GPIO) == LOW) { + working_mode = WorkingMode::RECOVERY; + break; + } + } +} +#endif +#endif + + +void setup() { + WiFi.disconnect(); +#ifndef CONFIG_NO_RECOVERY +#ifndef CONFIG_TARGET_ESP01 + homekit::main::waitForRecoveryPress(); +#endif +#endif + +#ifdef DEBUG + Serial.begin(115200); +#endif + + auto cfg = config::read(); + if (config::isDirty(cfg)) { + PRINTLN("config is dirty, erasing..."); + config::erase(cfg); +#ifdef CONFIG_TARGET_NODEMCU + led::board_led->blink(10, 50); +#else + led::mcu_led->blink(10, 50); +#endif + } + +#ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY + switch (working_mode) { + case WorkingMode::RECOVERY: + wifiHotspot(); + break; + + case WorkingMode::NORMAL: +#endif +#endif + wifiConnectHandler = WiFi.onStationModeGotIP(onWifiConnected); + wifiDisconnectHandler = WiFi.onStationModeDisconnected(onWifiDisconnected); + wifiConnect(); +#ifndef CONFIG_NO_RECOVERY +#ifndef CONFIG_TARGET_ESP01 + break; + } +#endif +#endif +} + +void loop(LoopConfig* config) { +#ifndef CONFIG_NO_RECOVERY +#ifndef CONFIG_TARGET_ESP01 + if (working_mode == WorkingMode::NORMAL) { +#endif +#endif + if (wifi_state == WiFiConnectionState::WAITING) { + PRINT("."); + led::mcu_led->blink(2, 50); + delay(1000); + return; + } + + if (wifi_state == WiFiConnectionState::JUST_CONNECTED) { +#ifdef CONFIG_TARGET_NODEMCU + led::board_led->blink(3, 300); +#else + led::mcu_led->blink(3, 300); +#endif + wifi_state = WiFiConnectionState::CONNECTED; + + if (service == nullptr) { + service = new mqtt::Mqtt(); + mqttDiagModule = new mqtt::MqttDiagnosticsModule(); + mqttOtaModule = new mqtt::MqttOtaModule(); + + ((mqtt::Mqtt*)service)->addModule(mqttDiagModule); + ((mqtt::Mqtt*)service)->addModule(mqttOtaModule); + + if (config != nullptr) + config->onMqttCreated(*(mqtt::Mqtt*)service); + } + + ((mqtt::Mqtt*)service)->connect(); +#if MQTT_BLINK + blinkStopWatch.save(); +#endif + } + + auto mqtt = (mqtt::Mqtt*)service; + if (static_cast(wifi_state) >= 1 && mqtt != nullptr) { + mqtt->loop(); + + if (mqttOtaModule != nullptr && mqttOtaModule->isReadyToRestart()) { + mqtt->disconnect(); + } + +#if MQTT_BLINK + // periodically blink board led + if (blinkStopWatch.elapsed(5000)) { +#ifdef CONFIG_TARGET_NODEMCU + board_led->blink(1, 10); +#endif + blinkStopWatch.save(); + } +#endif + } +#ifndef CONFIG_NO_RECOVERY +#ifndef CONFIG_TARGET_ESP01 + } else { + if (dnsServer != nullptr) + dnsServer->processNextRequest(); + + auto httpServer = (HttpServer*)service; + if (httpServer != nullptr) + httpServer->loop(); + } +#endif +#endif +} + +static void onWifiConnected(const WiFiEventStationModeGotIP& event) { + PRINTF("connected (%s)\n", WiFi.localIP().toString().c_str()); + wifi_state = WiFiConnectionState::JUST_CONNECTED; +} + +static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event) { + PRINTLN("disconnected from wi-fi"); + wifi_state = WiFiConnectionState::WAITING; + if (service != nullptr) + ((mqtt::Mqtt*)service)->disconnect(); + wifiTimer.once(2, wifiConnect); +} + +} diff --git a/include/pio/libs/main/homekit/main.h b/include/pio/libs/main/homekit/main.h new file mode 100644 index 0000000..78a0695 --- /dev/null +++ b/include/pio/libs/main/homekit/main.h @@ -0,0 +1,52 @@ +#ifndef HOMEKIT_LIB_MAIN_H +#define HOMEKIT_LIB_MAIN_H + +#include +#include +#include +#include +#include + +#include +#include +#ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY +#include +#endif +#endif +#include +#include + +#include + +namespace homekit::main { + +#ifndef CONFIG_TARGET_ESP01 +#ifndef CONFIG_NO_RECOVERY +enum class WorkingMode { + RECOVERY, // AP mode, http server with configuration + NORMAL, // MQTT client +}; + +extern enum WorkingMode working_mode; +#endif +#endif + +enum class WiFiConnectionState { + WAITING = 0, + JUST_CONNECTED = 1, + CONNECTED = 2 +}; + + +struct LoopConfig { + std::function onMqttCreated; +}; + + +void setup(); +void loop(LoopConfig* config); + +} + +#endif //HOMEKIT_LIB_MAIN_H diff --git a/include/pio/libs/main/library.json b/include/pio/libs/main/library.json new file mode 100644 index 0000000..728d4f8 --- /dev/null +++ b/include/pio/libs/main/library.json @@ -0,0 +1,12 @@ +{ + "name": "homekit_main", + "version": "1.0.10", + "build": { + "flags": "-I../../include" + }, + "dependencies": { + "homekit_mqtt_module_ota": "file://../common/libs/mqtt_module_ota", + "homekit_mqtt_module_diagnostics": "file://../common/libs/mqtt_module_diagnostics" + } +} + diff --git a/include/pio/libs/mqtt/homekit/mqtt/module.cpp b/include/pio/libs/mqtt/homekit/mqtt/module.cpp new file mode 100644 index 0000000..0ac7637 --- /dev/null +++ b/include/pio/libs/mqtt/homekit/mqtt/module.cpp @@ -0,0 +1,26 @@ +#include "./module.h" +#include + +namespace homekit::mqtt { + +bool MqttModule::tickElapsed() { + if (!tickSw.elapsed(tickInterval*1000)) + return false; + + tickSw.save(); + return true; +} + +void MqttModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t* payload, size_t length, + size_t index, size_t total) { + if (length != total) + PRINTLN("mqtt: received partial message, not supported"); + + // TODO +} + +void MqttModule::handleOnPublish(uint16_t packetId) {} + +void MqttModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) {} + +} diff --git a/include/pio/libs/mqtt/homekit/mqtt/module.h b/include/pio/libs/mqtt/homekit/mqtt/module.h new file mode 100644 index 0000000..0a328f3 --- /dev/null +++ b/include/pio/libs/mqtt/homekit/mqtt/module.h @@ -0,0 +1,56 @@ +#ifndef HOMEKIT_LIB_MQTT_MODULE_H +#define HOMEKIT_LIB_MQTT_MODULE_H + +#include "./mqtt.h" +#include "./payload.h" +#include + + +namespace homekit::mqtt { + +class Mqtt; + +class MqttModule { +protected: + bool initialized; + StopWatch tickSw; + short tickInterval; + + bool receiveOnPublish; + bool receiveOnDisconnect; + + bool tickElapsed(); + +public: + MqttModule(short _tickInterval, bool _receiveOnPublish = false, bool _receiveOnDisconnect = false) + : initialized(false) + , tickInterval(_tickInterval) + , receiveOnPublish(_receiveOnPublish) + , receiveOnDisconnect(_receiveOnDisconnect) {} + + virtual void tick(Mqtt& mqtt) = 0; + + virtual void onConnect(Mqtt& mqtt) = 0; + virtual void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason); + + virtual void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total); + virtual void handleOnPublish(uint16_t packetId); + + inline void setInitialized() { + initialized = true; + } + + inline void unsetInitialized() { + initialized = false; + } + + inline short getTickInterval() const { + return tickInterval; + } + + friend class Mqtt; +}; + +} + +#endif //HOMEKIT_LIB_MQTT_MODULE_H diff --git a/include/pio/libs/mqtt/homekit/mqtt/mqtt.cpp b/include/pio/libs/mqtt/homekit/mqtt/mqtt.cpp new file mode 100644 index 0000000..aa769a5 --- /dev/null +++ b/include/pio/libs/mqtt/homekit/mqtt/mqtt.cpp @@ -0,0 +1,162 @@ +#include "./mqtt.h" + +#include +#include +#include + +namespace homekit::mqtt { + +const uint8_t MQTT_CA_FINGERPRINT[] = { \ + 0x0e, 0xb6, 0x3a, 0x02, 0x1f, \ + 0x4e, 0x1e, 0xe1, 0x6a, 0x67, \ + 0x62, 0xec, 0x64, 0xd4, 0x84, \ + 0x8a, 0xb0, 0xc9, 0x9c, 0xbb \ +};; +const char MQTT_SERVER[] = "mqtt.solarmon.ru"; +const uint16_t MQTT_PORT = 8883; +const char MQTT_USERNAME[] = CONFIG_MQTT_USERNAME; +const char MQTT_PASSWORD[] = CONFIG_MQTT_PASSWORD; +const char MQTT_CLIENT_ID[] = CONFIG_MQTT_CLIENT_ID; +const char MQTT_SECRET[CONFIG_NODE_SECRET_SIZE+1] = CONFIG_NODE_SECRET; + +static const uint16_t MQTT_KEEPALIVE = 30; + +using namespace espMqttClientTypes; + +Mqtt::Mqtt() { + auto cfg = config::read(); + nodeId = String(cfg.flags.node_configured ? cfg.node_id : wifi::NODE_ID); + + randomSeed(micros()); + + client.onConnect([&](bool sessionPresent) { + PRINTLN("mqtt: connected"); + + for (auto* module: modules) { + if (!module->initialized) { + module->onConnect(*this); + module->setInitialized(); + } + } + + connected = true; + }); + + client.onDisconnect([&](DisconnectReason reason) { + PRINTF("mqtt: disconnected, reason=%d\n", static_cast(reason)); +#ifdef DEBUG + if (reason == DisconnectReason::TLS_BAD_FINGERPRINT) + PRINTLN("reason: bad fingerprint"); +#endif + + for (auto* module: modules) { + module->onDisconnect(*this, reason); + module->unsetInitialized(); + } + + reconnectTimer.once(2, [&]() { + reconnect(); + }); + }); + + client.onSubscribe([&](uint16_t packetId, const SubscribeReturncode* returncodes, size_t len) { + PRINTF("mqtt: subscribe ack, packet_id=%d\n", packetId); + for (size_t i = 0; i < len; i++) { + PRINTF(" return code: %u\n", static_cast(*(returncodes+i))); + } + }); + + client.onUnsubscribe([&](uint16_t packetId) { + PRINTF("mqtt: unsubscribe ack, packet_id=%d\n", packetId); + }); + + client.onMessage([&](const MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) { + PRINTF("mqtt: message received, topic=%s, qos=%d, dup=%d, retain=%d, len=%ul, index=%ul, total=%ul\n", + topic, properties.qos, (int)properties.dup, (int)properties.retain, len, index, total); + + const char *ptr = topic + nodeId.length() + 4; + String relevantTopic(ptr); + + auto it = moduleSubscriptions.find(relevantTopic); + if (it != moduleSubscriptions.end()) { + auto module = it->second; + module->handlePayload(*this, relevantTopic, properties.packetId, payload, len, index, total); + } else { + PRINTF("error: module subscription for topic %s not found\n", relevantTopic.c_str()); + } + }); + + client.onPublish([&](uint16_t packetId) { + PRINTF("mqtt: publish ack, packet_id=%d\n", packetId); + + for (auto* module: modules) { + if (module->receiveOnPublish) { + module->handleOnPublish(packetId); + } + } + }); + + client.setServer(MQTT_SERVER, MQTT_PORT); + client.setClientId(MQTT_CLIENT_ID); + client.setCredentials(MQTT_USERNAME, MQTT_PASSWORD); + client.setCleanSession(true); + client.setFingerprint(MQTT_CA_FINGERPRINT); + client.setKeepAlive(MQTT_KEEPALIVE); +} + +void Mqtt::connect() { + reconnect(); +} + +void Mqtt::reconnect() { + if (client.connected()) { + PRINTLN("warning: already connected"); + return; + } + client.connect(); +} + +void Mqtt::disconnect() { + // TODO test how this works??? + reconnectTimer.detach(); + client.disconnect(); +} + +void Mqtt::loop() { + client.loop(); + for (auto& module: modules) { + if (module->getTickInterval() != 0) + module->tick(*this); + } +} + +uint16_t Mqtt::publish(const String& topic, uint8_t* payload, size_t length) { + String fullTopic = "hk/" + nodeId + "/" + topic; + return client.publish(fullTopic.c_str(), 1, false, payload, length); +} + +uint16_t Mqtt::subscribe(const String& topic, uint8_t qos) { + String fullTopic = "hk/" + nodeId + "/" + topic; + PRINTF("mqtt: subscribing to %s...\n", fullTopic.c_str()); + + uint16_t packetId = client.subscribe(fullTopic.c_str(), qos); + if (!packetId) + PRINTF("error: failed to subscribe to %s\n", fullTopic.c_str()); + + return packetId; +} + +void Mqtt::addModule(MqttModule* module) { + modules.emplace_back(module); + if (connected) { + module->onConnect(*this); + module->setInitialized(); + } +} + +void Mqtt::subscribeModule(String& topic, MqttModule* module, uint8_t qos) { + moduleSubscriptions[topic] = module; + subscribe(topic, qos); +} + +} diff --git a/include/pio/libs/mqtt/homekit/mqtt/mqtt.h b/include/pio/libs/mqtt/homekit/mqtt/mqtt.h new file mode 100644 index 0000000..9e0c2be --- /dev/null +++ b/include/pio/libs/mqtt/homekit/mqtt/mqtt.h @@ -0,0 +1,48 @@ +#ifndef HOMEKIT_LIB_MQTT_H +#define HOMEKIT_LIB_MQTT_H + +#include +#include +#include +#include +#include +#include "./module.h" + +namespace homekit::mqtt { + +extern const uint8_t MQTT_CA_FINGERPRINT[]; +extern const char MQTT_SERVER[]; +extern const uint16_t MQTT_PORT; +extern const char MQTT_USERNAME[]; +extern const char MQTT_PASSWORD[]; +extern const char MQTT_CLIENT_ID[]; +extern const char MQTT_SECRET[CONFIG_NODE_SECRET_SIZE+1]; + +class MqttModule; + +class Mqtt { +private: + String nodeId; + WiFiClientSecure httpsSecureClient; + espMqttClientSecure client; + Ticker reconnectTimer; + std::vector modules; + std::map moduleSubscriptions; + bool connected; + + uint16_t subscribe(const String& topic, uint8_t qos = 0); + +public: + Mqtt(); + void connect(); + void disconnect(); + void reconnect(); + void loop(); + void addModule(MqttModule* module); + void subscribeModule(String& topic, MqttModule* module, uint8_t qos = 0); + uint16_t publish(const String& topic, uint8_t* payload, size_t length); +}; + +} + +#endif //HOMEKIT_LIB_MQTT_H diff --git a/include/pio/libs/mqtt/homekit/mqtt/payload.h b/include/pio/libs/mqtt/homekit/mqtt/payload.h new file mode 100644 index 0000000..3e0fe0c --- /dev/null +++ b/include/pio/libs/mqtt/homekit/mqtt/payload.h @@ -0,0 +1,15 @@ +#ifndef HOMEKIT_MQTT_PAYLOAD_H +#define HOMEKIT_MQTT_PAYLOAD_H + +#include + +namespace homekit::mqtt { + +struct MqttPayload { + virtual ~MqttPayload() = default; + virtual size_t size() const = 0; +}; + +} + +#endif \ No newline at end of file diff --git a/include/pio/libs/mqtt/library.json b/include/pio/libs/mqtt/library.json new file mode 100644 index 0000000..f3f2504 --- /dev/null +++ b/include/pio/libs/mqtt/library.json @@ -0,0 +1,7 @@ +{ + "name": "homekit_mqtt", + "version": "1.0.11", + "build": { + "flags": "-I../../include" + } +} diff --git a/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp b/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp new file mode 100644 index 0000000..e0f797e --- /dev/null +++ b/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp @@ -0,0 +1,56 @@ +#include "./diagnostics.h" +#include +#include + +namespace homekit::mqtt { + +static const char TOPIC_DIAGNOSTICS[] = "diag"; +static const char TOPIC_INITIAL_DIAGNOSTICS[] = "d1ag"; + +void MqttDiagnosticsModule::onConnect(Mqtt &mqtt) { + sendDiagnostics(mqtt); +} + +void MqttDiagnosticsModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) { + initialSent = false; +} + +void MqttDiagnosticsModule::tick(Mqtt& mqtt) { + if (!tickElapsed()) + return; + sendDiagnostics(mqtt); +} + +void MqttDiagnosticsModule::sendDiagnostics(Mqtt& mqtt) { + auto cfg = config::read(); + + if (!initialSent) { + MqttInitialDiagnosticsPayload stat{ + .ip = wifi::getIPAsInteger(), + .fw_version = CONFIG_FW_VERSION, + .rssi = wifi::getRSSI(), + .free_heap = ESP.getFreeHeap(), + .flags = DiagnosticsFlags{ + .state = 1, + .config_changed_value_present = 1, + .config_changed = static_cast(cfg.flags.node_configured || + cfg.flags.wifi_configured ? 1 : 0) + } + }; + mqtt.publish(TOPIC_INITIAL_DIAGNOSTICS, reinterpret_cast(&stat), sizeof(stat)); + initialSent = true; + } else { + MqttDiagnosticsPayload stat{ + .rssi = wifi::getRSSI(), + .free_heap = ESP.getFreeHeap(), + .flags = DiagnosticsFlags{ + .state = 1, + .config_changed_value_present = 0, + .config_changed = 0 + } + }; + mqtt.publish(TOPIC_DIAGNOSTICS, reinterpret_cast(&stat), sizeof(stat)); + } +} + +} diff --git a/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h b/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h new file mode 100644 index 0000000..bb7a81a --- /dev/null +++ b/include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h @@ -0,0 +1,49 @@ +#ifndef HOMEKIT_LIB_MQTT_MODULE_DIAGNOSTICS_H +#define HOMEKIT_LIB_MQTT_MODULE_DIAGNOSTICS_H + +#include +#include + +namespace homekit::mqtt { + +struct DiagnosticsFlags { + uint8_t state: 1; + uint8_t config_changed_value_present: 1; + uint8_t config_changed: 1; + uint8_t reserved: 5; +} __attribute__((packed)); + +struct MqttInitialDiagnosticsPayload { + uint32_t ip; + uint8_t fw_version; + int8_t rssi; + uint32_t free_heap; + DiagnosticsFlags flags; +} __attribute__((packed)); + +struct MqttDiagnosticsPayload { + int8_t rssi; + uint32_t free_heap; + DiagnosticsFlags flags; +} __attribute__((packed)); + + +class MqttDiagnosticsModule: public MqttModule { +private: + bool initialSent; + + void sendDiagnostics(Mqtt& mqtt); + +public: + MqttDiagnosticsModule() + : MqttModule(30) + , initialSent(false) {} + + void onConnect(Mqtt& mqtt) override; + void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; + void tick(Mqtt& mqtt) override; +}; + +} + +#endif //HOMEKIT_LIB_MQTT_MODULE_DIAGNOSTICS_H diff --git a/include/pio/libs/mqtt_module_diagnostics/library.json b/include/pio/libs/mqtt_module_diagnostics/library.json new file mode 100644 index 0000000..a3d3244 --- /dev/null +++ b/include/pio/libs/mqtt_module_diagnostics/library.json @@ -0,0 +1,10 @@ +{ + "name": "homekit_mqtt_module_diagnostics", + "version": "1.0.2", + "build": { + "flags": "-I../../include" + }, + "dependencies": { + "homekit_mqtt": "file://../common/libs/mqtt" + } +} diff --git a/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp b/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp new file mode 100644 index 0000000..4e976cd --- /dev/null +++ b/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp @@ -0,0 +1,160 @@ +#include "./ota.h" +#include +#include +#include + +namespace homekit::mqtt { + +using homekit::led::mcu_led; + +#define MD5_SIZE 16 + +static const char TOPIC_OTA[] = "ota"; +static const char TOPIC_OTA_RESPONSE[] = "otares"; + +void MqttOtaModule::onConnect(Mqtt& mqtt) { + String topic(TOPIC_OTA); + mqtt.subscribeModule(topic, this); +} + +void MqttOtaModule::tick(Mqtt& mqtt) { + if (!tickElapsed()) + return; +} + +void MqttOtaModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) { + char md5[33]; + char* md5Ptr = md5; + + if (index != 0 && ota.dataPacketId != packetId) { + PRINTLN("mqtt/ota: non-matching packet id"); + return; + } + + Update.runAsync(true); + + if (index == 0) { + if (length < CONFIG_NODE_SECRET_SIZE + MD5_SIZE) { + PRINTLN("mqtt/ota: failed to check secret, first packet size is too small"); + return; + } + + if (memcmp((const char*)payload, CONFIG_NODE_SECRET, CONFIG_NODE_SECRET_SIZE) != 0) { + PRINTLN("mqtt/ota: invalid secret"); + return; + } + + PRINTF("mqtt/ota: starting update, total=%ul\n", total-CONFIG_NODE_SECRET_SIZE); + for (int i = 0; i < MD5_SIZE; i++) { + md5Ptr += sprintf(md5Ptr, "%02x", *((unsigned char*)(payload+CONFIG_NODE_SECRET_SIZE+i))); + } + md5[32] = '\0'; + PRINTF("mqtt/ota: md5 is %s\n", md5); + PRINTF("mqtt/ota: first packet is %ul bytes length\n", length); + + md5[32] = '\0'; + + if (Update.isRunning()) { + Update.end(); + Update.clearError(); + } + + if (!Update.setMD5(md5)) { + PRINTLN("mqtt/ota: setMD5 failed"); + return; + } + + ota.dataPacketId = packetId; + + if (!Update.begin(total - CONFIG_NODE_SECRET_SIZE - MD5_SIZE)) { + ota.clean(); +#ifdef DEBUG + Update.printError(Serial); +#endif + sendResponse(mqtt, OtaResult::UPDATE_ERROR, Update.getError()); + } + + ota.written = Update.write(const_cast(payload)+CONFIG_NODE_SECRET_SIZE + MD5_SIZE, length-CONFIG_NODE_SECRET_SIZE - MD5_SIZE); + ota.written += CONFIG_NODE_SECRET_SIZE + MD5_SIZE; + + mcu_led->blink(1, 1); + PRINTF("mqtt/ota: updating %u/%u\n", ota.written, Update.size()); + + } else { + if (!Update.isRunning()) { + PRINTLN("mqtt/ota: update is not running"); + return; + } + + if (index == ota.written) { + size_t written; + if ((written = Update.write(const_cast(payload), length)) != length) { + PRINTF("mqtt/ota: error: tried to write %ul bytes, write() returned %ul\n", + length, written); + ota.clean(); + Update.end(); + Update.clearError(); + sendResponse(mqtt, OtaResult::WRITE_ERROR); + return; + } + ota.written += length; + + mcu_led->blink(1, 1); + PRINTF("mqtt/ota: updating %u/%u\n", + ota.written - CONFIG_NODE_SECRET_SIZE - MD5_SIZE, + Update.size()); + } else { + PRINTF("mqtt/ota: position is invalid, expected %ul, got %ul\n", ota.written, index); + ota.clean(); + Update.end(); + Update.clearError(); + } + } + + if (Update.isFinished()) { + ota.dataPacketId = 0; + + if (Update.end()) { + ota.finished = true; + ota.publishResultPacketId = sendResponse(mqtt, OtaResult::OK); + PRINTF("mqtt/ota: ok, otares packet_id=%d\n", ota.publishResultPacketId); + } else { + ota.clean(); + + PRINTF("mqtt/ota: error: %u\n", Update.getError()); +#ifdef DEBUG + Update.printError(Serial); +#endif + Update.clearError(); + + sendResponse(mqtt, OtaResult::UPDATE_ERROR, Update.getError()); + } + } +} + +uint16_t MqttOtaModule::sendResponse(Mqtt& mqtt, OtaResult status, uint8_t error_code) const { + MqttOtaResponsePayload resp{ + .status = status, + .error_code = error_code + }; + return mqtt.publish(TOPIC_OTA_RESPONSE, reinterpret_cast(&resp), sizeof(resp)); +} + +void MqttOtaModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) { + if (ota.readyToRestart) { + restartTimer.once(1, restart); + } else if (ota.started()) { + PRINTLN("mqtt: update was in progress, canceling.."); + ota.clean(); + Update.end(); + Update.clearError(); + } +} + +void MqttOtaModule::handleOnPublish(uint16_t packetId) { + if (ota.finished && packetId == ota.publishResultPacketId) { + ota.readyToRestart = true; + } +} + +} diff --git a/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.h b/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.h new file mode 100644 index 0000000..df4f7ce --- /dev/null +++ b/include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.h @@ -0,0 +1,75 @@ +#ifndef HOMEKIT_LIB_MQTT_MODULE_OTA_H +#define HOMEKIT_LIB_MQTT_MODULE_OTA_H + +#include +#include +#include + +namespace homekit::mqtt { + +enum class OtaResult: uint8_t { + OK = 0, + UPDATE_ERROR = 1, + WRITE_ERROR = 2, +}; + +struct OtaStatus { + uint16_t dataPacketId; + uint16_t publishResultPacketId; + bool finished; + bool readyToRestart; + size_t written; + + OtaStatus() + : dataPacketId(0) + , publishResultPacketId(0) + , finished(false) + , readyToRestart(false) + , written(0) + {} + + inline void clean() { + dataPacketId = 0; + publishResultPacketId = 0; + finished = false; + readyToRestart = false; + written = 0; + } + + inline bool started() const { + return dataPacketId != 0; + } +}; + +struct MqttOtaResponsePayload { + OtaResult status; + uint8_t error_code; +} __attribute__((packed)); + + +class MqttOtaModule: public MqttModule { +private: + OtaStatus ota; + Ticker restartTimer; + + uint16_t sendResponse(Mqtt& mqtt, OtaResult status, uint8_t error_code = 0) const; + +public: + MqttOtaModule() : MqttModule(0, true, true) {} + + void onConnect(Mqtt& mqtt) override; + void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; + + void tick(Mqtt& mqtt) override; + + void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; + void handleOnPublish(uint16_t packetId) override; + + inline bool isReadyToRestart() const { + return ota.readyToRestart; + } +}; + +} + +#endif //HOMEKIT_LIB_MQTT_MODULE_OTA_H diff --git a/include/pio/libs/mqtt_module_ota/library.json b/include/pio/libs/mqtt_module_ota/library.json new file mode 100644 index 0000000..4f40a47 --- /dev/null +++ b/include/pio/libs/mqtt_module_ota/library.json @@ -0,0 +1,11 @@ +{ + "name": "homekit_mqtt_module_ota", + "version": "1.0.5", + "build": { + "flags": "-I../../include" + }, + "dependencies": { + "homekit_led": "file://../common/libs/led", + "homekit_mqtt": "file://../common/libs/mqtt" + } +} diff --git a/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp b/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp new file mode 100644 index 0000000..90c57f9 --- /dev/null +++ b/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp @@ -0,0 +1,58 @@ +#include "./relay.h" +#include +#include + +namespace homekit::mqtt { + +static const char TOPIC_RELAY_SWITCH[] = "relay/switch"; +static const char TOPIC_RELAY_STATUS[] = "relay/status"; + +void MqttRelayModule::onConnect(Mqtt &mqtt) { + String topic(TOPIC_RELAY_SWITCH); + mqtt.subscribeModule(topic, this, 1); +} + +void MqttRelayModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) { +#ifdef CONFIG_RELAY_OFF_ON_DISCONNECT + if (relay::state()) { + relay::off(); + } +#endif +} + +void MqttRelayModule::tick(homekit::mqtt::Mqtt& mqtt) {} + +void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) { + if (topic != TOPIC_RELAY_SWITCH) + return; + + if (length != sizeof(MqttRelaySwitchPayload)) { + PRINTF("error: size of payload (%ul) does not match expected (%ul)\n", + length, sizeof(MqttRelaySwitchPayload)); + return; + } + + auto pd = reinterpret_cast(payload); + if (strncmp(pd->secret, MQTT_SECRET, sizeof(pd->secret)) != 0) { + PRINTLN("error: invalid secret"); + return; + } + + MqttRelayStatusPayload resp{}; + + if (pd->state == 1) { + PRINTLN("mqtt: turning relay on"); + relay::on(); + } else if (pd->state == 0) { + PRINTLN("mqtt: turning relay off"); + relay::off(); + } else { + PRINTLN("error: unexpected state value"); + } + + resp.opened = relay::state(); + mqtt.publish(TOPIC_RELAY_STATUS, reinterpret_cast(&resp), sizeof(resp)); +} + +} + diff --git a/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.h b/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.h new file mode 100644 index 0000000..e245527 --- /dev/null +++ b/include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.h @@ -0,0 +1,29 @@ +#ifndef HOMEKIT_LIB_MQTT_MODULE_RELAY_H +#define HOMEKIT_LIB_MQTT_MODULE_RELAY_H + +#include + +namespace homekit::mqtt { + +struct MqttRelaySwitchPayload { + char secret[12]; + uint8_t state; +} __attribute__((packed)); + +struct MqttRelayStatusPayload { + uint8_t opened; +} __attribute__((packed)); + +class MqttRelayModule : public MqttModule { +public: + MqttRelayModule() : MqttModule(0) {} + void onConnect(Mqtt& mqtt) override; + void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; + void tick(Mqtt& mqtt) override; + void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; +}; + +} + +#endif //HOMEKIT_LIB_MQTT_MODULE_RELAY_H + diff --git a/include/pio/libs/mqtt_module_relay/library.json b/include/pio/libs/mqtt_module_relay/library.json new file mode 100644 index 0000000..6cbbfb0 --- /dev/null +++ b/include/pio/libs/mqtt_module_relay/library.json @@ -0,0 +1,11 @@ +{ + "name": "homekit_mqtt_module_relay", + "version": "1.0.5", + "build": { + "flags": "-I../../include" + }, + "dependencies": { + "homekit_mqtt": "file://../common/libs/mqtt", + "homekit_relay": "file://../common/libs/relay" + } +} diff --git a/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp b/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp new file mode 100644 index 0000000..409f38f --- /dev/null +++ b/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp @@ -0,0 +1,23 @@ +#include "temphum.h" + +namespace homekit::mqtt { + +static const char TOPIC_TEMPHUM_DATA[] = "temphum/data"; + +void MqttTemphumModule::onConnect(Mqtt &mqtt) {} + +void MqttTemphumModule::tick(homekit::mqtt::Mqtt& mqtt) { + if (!tickElapsed()) + return; + + temphum::SensorData sd = sensor->read(); + MqttTemphumPayload payload { + .temp = sd.temp, + .rh = sd.rh, + .error = sd.error + }; + + mqtt.publish(TOPIC_TEMPHUM_DATA, reinterpret_cast(&payload), sizeof(payload)); +} + +} diff --git a/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h b/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h new file mode 100644 index 0000000..7b28afc --- /dev/null +++ b/include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h @@ -0,0 +1,28 @@ +#ifndef HOMEKIT_LIB_MQTT_MODULE_TEMPHUM_H +#define HOMEKIT_LIB_MQTT_MODULE_TEMPHUM_H + +#include +#include + +namespace homekit::mqtt { + +struct MqttTemphumPayload { + double temp = 0; + double rh = 0; + uint8_t error = 0; +} __attribute__((packed)); + + +class MqttTemphumModule : public MqttModule { +private: + temphum::Sensor* sensor; + +public: + MqttTemphumModule(temphum::Sensor* _sensor) : MqttModule(10), sensor(_sensor) {} + void onConnect(Mqtt& mqtt) override; + void tick(Mqtt& mqtt) override; +}; + +} + +#endif //HOMEKIT_LIB_MQTT_MODULE_TEMPHUM_H diff --git a/include/pio/libs/mqtt_module_temphum/library.json b/include/pio/libs/mqtt_module_temphum/library.json new file mode 100644 index 0000000..068debd --- /dev/null +++ b/include/pio/libs/mqtt_module_temphum/library.json @@ -0,0 +1,11 @@ +{ + "name": "homekit_mqtt_module_temphum", + "version": "1.0.10", + "build": { + "flags": "-I../../include" + }, + "dependencies": { + "homekit_mqtt": "file://../common/libs/mqtt", + "homekit_temphum": "file://../common/libs/temphum" + } +} diff --git a/include/pio/libs/relay/homekit/relay.cpp b/include/pio/libs/relay/homekit/relay.cpp new file mode 100644 index 0000000..b00a7a2 --- /dev/null +++ b/include/pio/libs/relay/homekit/relay.cpp @@ -0,0 +1,22 @@ +#include +#include "./relay.h" + +namespace homekit::relay { + +void init() { + pinMode(CONFIG_RELAY_GPIO, OUTPUT); +} + +bool state() { + return digitalRead(CONFIG_RELAY_GPIO) == HIGH; +} + +void on() { + digitalWrite(CONFIG_RELAY_GPIO, HIGH); +} + +void off() { + digitalWrite(CONFIG_RELAY_GPIO, LOW); +} + +} diff --git a/include/pio/libs/relay/homekit/relay.h b/include/pio/libs/relay/homekit/relay.h new file mode 100644 index 0000000..288cc05 --- /dev/null +++ b/include/pio/libs/relay/homekit/relay.h @@ -0,0 +1,13 @@ +#ifndef HOMEKIT_LIB_RELAY_H +#define HOMEKIT_LIB_RELAY_H + +namespace homekit::relay { + +void init(); +bool state(); +void on(); +void off(); + +} + +#endif //HOMEKIT_LIB_RELAY_H diff --git a/include/pio/libs/relay/library.json b/include/pio/libs/relay/library.json new file mode 100644 index 0000000..e878248 --- /dev/null +++ b/include/pio/libs/relay/library.json @@ -0,0 +1,8 @@ +{ + "name": "homekit_relay", + "version": "1.0.0", + "build": { + "flags": "-I../../include" + } +} + diff --git a/include/pio/libs/static/homekit/static.cpp b/include/pio/libs/static/homekit/static.cpp new file mode 100644 index 0000000..366a09f --- /dev/null +++ b/include/pio/libs/static/homekit/static.cpp @@ -0,0 +1,450 @@ +/** + * This file is autogenerated with make_static.sh script + */ + +#include "static.h" + +namespace homekit::files { + +static const uint8_t index_html_content[] PROGMEM = { + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x9d, 0x56, 0x4d, 0x6f, 0xdb, 0x38, + 0x10, 0xbd, 0xe7, 0x57, 0xb0, 0x3c, 0x14, 0x09, 0x10, 0x4b, 0x9b, 0x14, 0xcd, 0x16, 0xad, 0x24, + 0xa0, 0xd8, 0x76, 0xb1, 0x05, 0x7a, 0x08, 0x6a, 0x14, 0x0b, 0xec, 0xc5, 0xa0, 0xa8, 0x91, 0xc5, + 0x9a, 0x22, 0x59, 0x71, 0x24, 0xc7, 0xfd, 0xf5, 0x1d, 0x52, 0x92, 0x3f, 0xb2, 0x46, 0xfa, 0x71, + 0xb1, 0x34, 0xc3, 0x99, 0x37, 0x6f, 0x1e, 0x87, 0xa2, 0xb3, 0x67, 0x95, 0x95, 0xb8, 0x73, 0xc0, + 0x1a, 0x6c, 0x75, 0x71, 0x91, 0x85, 0x07, 0xd3, 0xc2, 0xac, 0x73, 0x0e, 0x86, 0x07, 0x07, 0x88, + 0x8a, 0x1e, 0x2d, 0xa0, 0xa0, 0x18, 0x74, 0x0b, 0xf8, 0xda, 0xab, 0x21, 0xe7, 0xd2, 0x1a, 0x04, + 0x83, 0x8b, 0x90, 0xcc, 0xd9, 0x64, 0xe5, 0x1c, 0xe1, 0x01, 0xd3, 0x00, 0xf2, 0x86, 0xc9, 0x46, + 0x74, 0x1e, 0x30, 0xef, 0xb1, 0x5e, 0xbc, 0xe2, 0x33, 0x86, 0x11, 0x2d, 0xe4, 0x7c, 0x50, 0xb0, + 0x75, 0xb6, 0xc3, 0xa3, 0xcc, 0xad, 0xaa, 0xb0, 0xc9, 0x2b, 0x18, 0x94, 0x84, 0x45, 0x34, 0xae, + 0x95, 0x51, 0xa8, 0x84, 0x5e, 0x78, 0x29, 0x34, 0xe4, 0x37, 0xd7, 0x2d, 0x39, 0xda, 0xbe, 0x3d, + 0xd8, 0xe2, 0xe1, 0xc4, 0xee, 0x3d, 0x74, 0xd1, 0x10, 0x25, 0xd9, 0xc6, 0x86, 0xa2, 0xa8, 0x50, + 0x43, 0xf1, 0x97, 0x35, 0xb5, 0x5a, 0xf7, 0x9d, 0x40, 0x65, 0x4d, 0x96, 0x8e, 0xce, 0x8b, 0x4c, + 0x2b, 0xb3, 0x61, 0x1d, 0xe8, 0x9c, 0xfb, 0x86, 0xd8, 0xc8, 0x1e, 0x99, 0x22, 0x42, 0x9c, 0x35, + 0x1d, 0xd4, 0x39, 0x4f, 0x6b, 0x31, 0x04, 0x3b, 0xa1, 0x1f, 0xce, 0x42, 0xa7, 0x39, 0x57, 0xad, + 0x58, 0x43, 0xfa, 0xb0, 0x88, 0x71, 0xa7, 0x10, 0xb8, 0xd3, 0xe0, 0x1b, 0x00, 0x9c, 0x63, 0xa3, + 0x18, 0xd2, 0xfb, 0x3d, 0x5e, 0x0c, 0x49, 0x82, 0x87, 0x32, 0xbd, 0xec, 0x94, 0x43, 0xe6, 0x3b, + 0x49, 0x2b, 0x6d, 0xf5, 0x32, 0xf9, 0x42, 0xee, 0x2c, 0x1d, 0xdd, 0x8f, 0xd7, 0x85, 0x73, 0x8f, + 0xd7, 0xd3, 0x69, 0x6b, 0x4a, 0x5b, 0xed, 0x98, 0x35, 0xda, 0x8a, 0x8a, 0xe8, 0x91, 0x64, 0x6f, + 0x9d, 0xbb, 0xbc, 0x0a, 0x15, 0x2a, 0x35, 0x30, 0xa9, 0x85, 0xf7, 0x44, 0x25, 0x74, 0xcc, 0x8b, + 0x25, 0x20, 0x2a, 0xb3, 0xf6, 0x2c, 0xf3, 0x4e, 0x18, 0xa6, 0x28, 0x23, 0xe4, 0x91, 0x6b, 0x45, + 0xa2, 0x81, 0xe6, 0xc5, 0xe5, 0x64, 0x27, 0x49, 0x72, 0x45, 0xc5, 0x28, 0x8a, 0x6a, 0x12, 0xd0, + 0x29, 0x5c, 0xa9, 0xad, 0xdc, 0x84, 0x12, 0xb5, 0xed, 0x5a, 0x46, 0x1b, 0xdb, 0x58, 0x82, 0x72, + 0xd6, 0x53, 0xef, 0x42, 0x06, 0x91, 0x63, 0xb7, 0x02, 0x7b, 0x6a, 0x7e, 0xdc, 0x72, 0x03, 0xb8, + 0xb5, 0xdd, 0x66, 0xe5, 0x27, 0x0a, 0x8f, 0x08, 0x06, 0xa0, 0x99, 0xc3, 0xbf, 0xea, 0x6f, 0xc5, + 0x96, 0xcb, 0x0f, 0xef, 0xce, 0x54, 0x8e, 0x71, 0xca, 0xb8, 0x1e, 0xa3, 0x86, 0xa0, 0x41, 0x62, + 0xec, 0xc3, 0x7b, 0x55, 0xad, 0x46, 0x7b, 0x2e, 0x19, 0x5c, 0x7c, 0x9f, 0xd8, 0x6b, 0x3d, 0xce, + 0x55, 0x48, 0xb4, 0x2e, 0x90, 0x64, 0x83, 0xd0, 0x3d, 0x05, 0xf2, 0xe2, 0xe3, 0xbe, 0xeb, 0x2c, + 0x1d, 0xd7, 0x82, 0xc2, 0x23, 0x5c, 0x78, 0x3b, 0xcf, 0xe3, 0x98, 0xef, 0x3d, 0xb9, 0xa9, 0xc1, + 0xea, 0x87, 0x9c, 0xe3, 0xcb, 0x34, 0x21, 0x6e, 0x4a, 0xe2, 0x7b, 0x26, 0x13, 0x75, 0xe7, 0x37, + 0xe7, 0x98, 0xc7, 0x4e, 0x6b, 0x5d, 0xad, 0xe2, 0x3a, 0xcd, 0xbf, 0x06, 0xb3, 0xa6, 0x63, 0xc3, + 0xef, 0x5e, 0x70, 0x56, 0x29, 0x1f, 0x06, 0xbf, 0x3a, 0x53, 0xdc, 0xf7, 0xe5, 0xc4, 0x95, 0x26, + 0x36, 0xbc, 0x30, 0x72, 0xc7, 0xa9, 0xdf, 0x46, 0xa8, 0xe2, 0x84, 0x95, 0x6c, 0x40, 0x6e, 0x4a, + 0xfb, 0xb0, 0xd7, 0x71, 0x0e, 0x1b, 0x85, 0xde, 0x27, 0xb1, 0xf0, 0xca, 0xdc, 0xbe, 0xf1, 0x88, + 0x7c, 0x50, 0xeb, 0x69, 0xd1, 0xfe, 0xb1, 0x2d, 0xb0, 0x9f, 0xd8, 0xe2, 0x63, 0x62, 0xe1, 0x40, + 0x1d, 0x49, 0x75, 0xd4, 0xff, 0xcd, 0xdd, 0x4c, 0xb6, 0x09, 0x7b, 0x3e, 0xcb, 0xd4, 0x9c, 0x1f, + 0x80, 0x63, 0xa9, 0xa6, 0xfa, 0x65, 0x8f, 0x48, 0x03, 0x31, 0xd6, 0x21, 0xb9, 0x5a, 0x85, 0x87, + 0xb0, 0x59, 0x87, 0xd1, 0x5d, 0x2c, 0xc5, 0x00, 0x4c, 0x98, 0x8a, 0x7d, 0x82, 0xd2, 0x5a, 0xcc, + 0xd2, 0x31, 0x39, 0x80, 0x05, 0xee, 0x67, 0x5b, 0x9f, 0x0e, 0xe0, 0x67, 0x57, 0x09, 0x04, 0x56, + 0xab, 0xae, 0xdd, 0x8a, 0x0e, 0xd8, 0x65, 0x52, 0x2a, 0x73, 0xf5, 0xbb, 0x27, 0xac, 0x8f, 0x68, + 0x9c, 0x81, 0x91, 0x23, 0xf1, 0xb6, 0xd7, 0xa8, 0x9c, 0xe8, 0x30, 0x12, 0x59, 0xd0, 0xaa, 0x98, + 0x75, 0x19, 0x63, 0x9f, 0x3c, 0x7e, 0x67, 0x35, 0xaf, 0x15, 0xf1, 0xa6, 0x92, 0x12, 0x1c, 0x7d, + 0xa5, 0x03, 0xdd, 0xeb, 0xf0, 0x93, 0xac, 0xbf, 0xcd, 0xc8, 0x31, 0xe2, 0x07, 0x4a, 0x9e, 0x08, + 0x78, 0x90, 0xff, 0xb3, 0x0b, 0x9f, 0x9b, 0x5f, 0x11, 0xf0, 0x13, 0x50, 0x07, 0x6c, 0xee, 0xe2, + 0x77, 0x85, 0xeb, 0x02, 0x0a, 0xff, 0x39, 0xb2, 0x13, 0xae, 0xf2, 0xab, 0x29, 0x2b, 0x52, 0xf8, + 0x15, 0xce, 0x1f, 0x4c, 0x6d, 0x9f, 0x60, 0xfa, 0x7e, 0x79, 0xff, 0xea, 0xf6, 0xee, 0x6e, 0x51, + 0x0a, 0x4f, 0xa3, 0x96, 0x95, 0x05, 0x5d, 0x27, 0x62, 0x27, 0x51, 0x53, 0x8d, 0xe2, 0xfa, 0x30, + 0x2b, 0xc3, 0x9f, 0x59, 0xd9, 0x15, 0x17, 0xf7, 0xb4, 0xbd, 0xcc, 0xd6, 0x2c, 0x13, 0xd3, 0xb5, + 0x12, 0xae, 0x65, 0xff, 0x3a, 0x4d, 0xd7, 0x0a, 0x13, 0xd9, 0xdc, 0xb8, 0x44, 0xd9, 0xb4, 0xa1, + 0xd3, 0xb5, 0x21, 0x9b, 0x7c, 0x29, 0x2f, 0x26, 0x2b, 0x4b, 0x45, 0xc1, 0xca, 0xdd, 0xff, 0x33, + 0xa7, 0x2c, 0x5e, 0xbc, 0x1f, 0xd6, 0x60, 0x76, 0xec, 0x3f, 0x65, 0x2c, 0x5d, 0xd1, 0x43, 0x4c, + 0x78, 0x2e, 0xad, 0xdb, 0xbd, 0x61, 0xb7, 0x7f, 0xdc, 0xde, 0x1e, 0x8e, 0x76, 0xb8, 0x74, 0xe2, + 0x1d, 0x14, 0xff, 0x36, 0x7c, 0x07, 0x90, 0xb9, 0x94, 0x17, 0x47, 0x08, 0x00, 0x00, +}; +const StaticFile index_html PROGMEM = {(sizeof(index_html_content)/sizeof(index_html_content[0])), index_html_content}; + +static const uint8_t app_js_content[] PROGMEM = { + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x9d, 0x57, 0x6d, 0x6f, 0xdb, 0x46, + 0x12, 0xfe, 0xde, 0x5f, 0x41, 0x2d, 0x70, 0x06, 0xf7, 0x44, 0xd3, 0x2f, 0xd7, 0x02, 0x85, 0x18, + 0x42, 0x48, 0xda, 0xe4, 0x92, 0x22, 0xa9, 0x8b, 0x24, 0x57, 0x1c, 0x60, 0xf8, 0x82, 0x95, 0x38, + 0xb2, 0x18, 0x53, 0xbb, 0xec, 0xee, 0xd2, 0xb2, 0x4f, 0x11, 0x70, 0x69, 0x0a, 0xb4, 0x40, 0x0b, + 0x04, 0xe8, 0xf7, 0xcb, 0xa7, 0xfe, 0x00, 0x37, 0x77, 0xbe, 0x4b, 0x2e, 0x4d, 0xfa, 0x17, 0xa8, + 0x7f, 0xd4, 0x99, 0x25, 0x29, 0xd1, 0xb2, 0x81, 0x06, 0xf7, 0xc1, 0x12, 0xb5, 0x9c, 0x9d, 0x9d, + 0x79, 0x66, 0x9e, 0x67, 0xc7, 0xa3, 0x42, 0x0e, 0x6d, 0xaa, 0xa4, 0x07, 0x3e, 0xf0, 0x99, 0x06, + 0x5b, 0x68, 0xc9, 0xf6, 0xd5, 0xe0, 0x31, 0x0c, 0xad, 0xb7, 0xe7, 0xbe, 0x0e, 0x58, 0x1c, 0xc7, + 0xd5, 0x63, 0x98, 0x6b, 0x65, 0x95, 0x3d, 0xcd, 0x21, 0xb4, 0xea, 0x81, 0xd5, 0xa9, 0x3c, 0x0c, + 0x87, 0x22, 0xcb, 0x70, 0xef, 0x7c, 0xd4, 0x78, 0xb2, 0x2b, 0x4f, 0x5e, 0xa2, 0x86, 0xc5, 0x04, + 0xa4, 0x0d, 0x0f, 0xc1, 0xde, 0xcc, 0x80, 0x1e, 0x6f, 0x9c, 0xde, 0x49, 0x2e, 0xd8, 0x4b, 0xb2, + 0x87, 0xd0, 0xd8, 0xd3, 0x0c, 0xc2, 0x24, 0x35, 0x79, 0x26, 0x4e, 0x63, 0x26, 0x95, 0x04, 0xb6, + 0x32, 0xd2, 0x2d, 0xa7, 0x80, 0x61, 0xc0, 0x31, 0xba, 0xfa, 0x14, 0x46, 0xa2, 0xc8, 0xec, 0xc6, + 0xc6, 0xfa, 0x8a, 0xcf, 0x03, 0x72, 0xa8, 0xf2, 0x2f, 0xb4, 0xca, 0xc5, 0xa1, 0x20, 0x17, 0x64, + 0xb5, 0xb6, 0xe4, 0xcc, 0x86, 0x42, 0x0e, 0x21, 0xbb, 0x51, 0x0c, 0x06, 0x19, 0xc4, 0x9d, 0x6d, + 0x5c, 0xa9, 0x8e, 0xf9, 0x52, 0x64, 0x05, 0x2e, 0xec, 0x04, 0x9d, 0x9d, 0x55, 0x18, 0x69, 0x3b, + 0x0c, 0x2f, 0x95, 0xc6, 0xd2, 0x6e, 0x35, 0xf2, 0x6e, 0x6a, 0xad, 0x74, 0x1f, 0xc2, 0x09, 0x18, + 0x23, 0x0e, 0xa1, 0x07, 0x5d, 0xd6, 0x8a, 0x5e, 0xd5, 0x29, 0x82, 0xbd, 0x6e, 0x11, 0xb5, 0x41, + 0x61, 0xc1, 0x67, 0x98, 0xaa, 0xc0, 0x33, 0x13, 0x16, 0xac, 0x1e, 0x5b, 0xb0, 0x88, 0x6a, 0x8f, + 0x86, 0x89, 0x3a, 0x86, 0xab, 0xb6, 0xb5, 0x6c, 0x8d, 0xcf, 0x67, 0xa3, 0x56, 0x25, 0x03, 0xcb, + 0x67, 0xb2, 0xc8, 0xb2, 0x4e, 0x1c, 0x23, 0x3a, 0x3e, 0x84, 0xc7, 0x2e, 0x19, 0xcb, 0x03, 0xf2, + 0x3a, 0x3f, 0x16, 0xda, 0x33, 0xf1, 0x76, 0xb4, 0xdc, 0x52, 0xe0, 0xfe, 0x5d, 0xac, 0x72, 0xb7, + 0x6b, 0x36, 0x36, 0xa4, 0x6f, 0x7d, 0x96, 0x29, 0x91, 0x60, 0x75, 0x1f, 0x65, 0x62, 0x00, 0x19, + 0xe3, 0xd5, 0x9e, 0x24, 0x5e, 0xd6, 0x73, 0xa4, 0xf4, 0xc4, 0x84, 0x12, 0xec, 0x54, 0xe9, 0xa3, + 0x47, 0x98, 0x98, 0x45, 0x6b, 0x13, 0x25, 0xa1, 0x48, 0x92, 0x9b, 0x54, 0x89, 0xbb, 0xa9, 0xb1, + 0x20, 0x41, 0xfb, 0xcc, 0x14, 0x83, 0x49, 0x6a, 0x59, 0xe0, 0x37, 0xc7, 0xb5, 0xfb, 0x23, 0x1c, + 0xa7, 0x49, 0x15, 0x5d, 0x88, 0x19, 0x4e, 0x7c, 0xde, 0x4f, 0xc2, 0xdc, 0x1c, 0xd5, 0x4b, 0x19, + 0xc8, 0x43, 0x3b, 0xbe, 0xf6, 0x71, 0xdf, 0x17, 0x19, 0x68, 0x0c, 0xab, 0xfc, 0x67, 0x79, 0x5e, + 0xbe, 0x2c, 0xcf, 0x17, 0xff, 0x28, 0xdf, 0x2e, 0xbe, 0x2f, 0x5f, 0x7b, 0xe5, 0xaf, 0xe5, 0x19, + 0xfe, 0x78, 0x57, 0xbe, 0x59, 0xfc, 0xe0, 0xf9, 0xe5, 0x2f, 0xe5, 0xab, 0xf2, 0x2d, 0xfe, 0xfd, + 0x52, 0x9e, 0xd1, 0x0a, 0x3e, 0x9f, 0x2d, 0x9e, 0x7b, 0xe5, 0xbf, 0xcb, 0x37, 0xee, 0xc5, 0x99, + 0xb7, 0xe9, 0x7d, 0xec, 0x2d, 0x9e, 0x3a, 0x8b, 0x97, 0xb4, 0x0b, 0xff, 0x5e, 0x72, 0xc6, 0x03, + 0xea, 0x2f, 0xde, 0xdb, 0xdc, 0x41, 0x10, 0x92, 0xd0, 0x18, 0x0c, 0xca, 0x40, 0x86, 0x1d, 0x0f, + 0xc9, 0x1d, 0x99, 0xc0, 0xc9, 0x85, 0x00, 0xbc, 0xf2, 0x25, 0x9e, 0xfd, 0x33, 0x1e, 0x7b, 0xe6, + 0x7c, 0x2e, 0xbe, 0x2e, 0xdf, 0x2d, 0xbe, 0x2d, 0xff, 0x87, 0x8f, 0x78, 0xd2, 0xbb, 0xc5, 0xd3, + 0xc5, 0xd7, 0x8b, 0x67, 0x14, 0xd8, 0xd2, 0xef, 0xb1, 0x4a, 0x13, 0x6c, 0x02, 0x74, 0xea, 0xb0, + 0xe0, 0xbd, 0xa5, 0xbb, 0x1f, 0x29, 0x1b, 0xdc, 0xf5, 0x0a, 0x9d, 0x9c, 0x7b, 0x63, 0x35, 0xc1, + 0xae, 0x4a, 0x9a, 0x7d, 0x73, 0xce, 0x03, 0xdc, 0x33, 0x56, 0xd3, 0x47, 0x84, 0xc9, 0x65, 0x68, + 0x87, 0x63, 0x21, 0x0f, 0x61, 0x0d, 0xda, 0x0a, 0xc0, 0x8b, 0xbd, 0x46, 0x94, 0x65, 0xd8, 0xda, + 0x56, 0x68, 0x64, 0x62, 0x38, 0x1c, 0xc3, 0xf0, 0x08, 0x92, 0x3e, 0xb3, 0x70, 0x62, 0x59, 0x8f, + 0xe5, 0xc2, 0x18, 0x2c, 0x24, 0xf5, 0x54, 0x75, 0x24, 0x01, 0xf0, 0x9e, 0xc7, 0x51, 0x63, 0xd8, + 0x78, 0xe9, 0xfa, 0x02, 0x6c, 0xd1, 0xe6, 0xce, 0xb2, 0x05, 0xeb, 0xf7, 0x2a, 0xa7, 0x8d, 0x66, + 0xdf, 0x1e, 0xe0, 0x6a, 0xab, 0xd6, 0x31, 0x63, 0x55, 0xc2, 0xe2, 0xb1, 0x38, 0x21, 0xb9, 0xf0, + 0xd9, 0x16, 0xb2, 0xcb, 0x16, 0x86, 0x05, 0xb3, 0x79, 0xeb, 0x48, 0x1b, 0x48, 0x3e, 0xb3, 0xfa, + 0x74, 0x96, 0x8e, 0x7c, 0xcb, 0xed, 0x58, 0xab, 0xa9, 0x67, 0x23, 0xf0, 0x5d, 0x2b, 0x05, 0x32, + 0x94, 0x2a, 0x81, 0x47, 0x69, 0xf2, 0xe4, 0x09, 0x11, 0x00, 0x09, 0x5e, 0x1d, 0x12, 0xac, 0x7e, + 0x55, 0x35, 0xa8, 0x17, 0xb0, 0xf3, 0xe7, 0x43, 0x61, 0x87, 0x63, 0xf4, 0x35, 0xab, 0x8a, 0x92, + 0xe2, 0x23, 0x9f, 0xaf, 0x87, 0x82, 0x32, 0xb1, 0x16, 0x88, 0x63, 0x59, 0x1d, 0x08, 0xd4, 0x81, + 0x40, 0x54, 0x83, 0x97, 0x4a, 0x44, 0xec, 0xf6, 0xc3, 0x7b, 0x77, 0x31, 0xaf, 0x08, 0xc9, 0xe2, + 0x13, 0x4c, 0x12, 0x39, 0x27, 0xaf, 0xd9, 0x30, 0x43, 0x44, 0xeb, 0xde, 0x8e, 0x64, 0xb7, 0x5b, + 0x61, 0xa8, 0xe3, 0xea, 0xc5, 0xbe, 0x3c, 0xd8, 0xdf, 0x3e, 0x08, 0x54, 0xeb, 0xe7, 0xce, 0x41, + 0xe3, 0x56, 0xe4, 0x39, 0xc8, 0xc4, 0x97, 0x30, 0xf5, 0xf6, 0x1c, 0x90, 0xbe, 0xee, 0x32, 0xcf, + 0x67, 0x5d, 0x85, 0x5f, 0xc9, 0x8d, 0x09, 0x67, 0x81, 0xc6, 0xe0, 0x85, 0x5f, 0xd9, 0xb7, 0xf3, + 0x83, 0x55, 0x7e, 0x50, 0xe5, 0x37, 0xbf, 0xc0, 0x7f, 0x8a, 0x01, 0xd6, 0x09, 0x5e, 0xe4, 0x89, + 0xb0, 0xb0, 0xe2, 0x37, 0xbc, 0x17, 0xbf, 0x11, 0x17, 0xc4, 0x44, 0xe3, 0x77, 0xd0, 0x81, 0x70, + 0x94, 0x66, 0xd5, 0x87, 0xa9, 0x73, 0xe6, 0x35, 0xf9, 0x1b, 0x0e, 0xfc, 0x84, 0x3c, 0x7a, 0x5d, + 0xbe, 0xf1, 0x90, 0x8b, 0x3f, 0x23, 0xa1, 0x90, 0x91, 0xc8, 0xcb, 0x73, 0xe2, 0x31, 0x71, 0xf7, + 0xed, 0x1a, 0xe1, 0x90, 0x1c, 0x9d, 0x9d, 0x08, 0x15, 0xb5, 0x21, 0x53, 0x54, 0x41, 0x4b, 0x98, + 0xfc, 0xf5, 0xde, 0xdd, 0xdb, 0xd6, 0xe6, 0xf7, 0xe1, 0xab, 0x02, 0x8c, 0x0d, 0x84, 0x5b, 0xbc, + 0x85, 0x99, 0x7c, 0x2a, 0xac, 0x88, 0x9a, 0x63, 0x1b, 0x14, 0x19, 0x05, 0x45, 0xac, 0x58, 0x45, + 0x88, 0xc8, 0x73, 0xec, 0xa1, 0x22, 0x27, 0xfd, 0xbb, 0x22, 0x57, 0xbc, 0xff, 0x0e, 0x35, 0x4a, + 0xfc, 0x5a, 0xb6, 0x2e, 0x82, 0x40, 0xc7, 0x6b, 0xae, 0x42, 0x93, 0xfe, 0x1d, 0x22, 0x49, 0x95, + 0x44, 0x77, 0x90, 0x5c, 0xd3, 0xfd, 0x7b, 0xc2, 0x8e, 0x43, 0xad, 0x0a, 0x3c, 0xbe, 0x59, 0xdd, + 0xd2, 0x7f, 0xdc, 0xd9, 0xde, 0xe6, 0x78, 0xa3, 0xde, 0x4a, 0x4f, 0x20, 0xf1, 0x77, 0x79, 0x0f, + 0x7f, 0x07, 0x4d, 0x7e, 0xad, 0x56, 0x92, 0x5d, 0xf6, 0x07, 0x46, 0x8d, 0x29, 0x43, 0x25, 0x35, + 0x88, 0xe4, 0x94, 0x18, 0x02, 0x15, 0x2d, 0xe3, 0x65, 0x40, 0x4d, 0x2d, 0x59, 0xf9, 0x62, 0x1d, + 0x50, 0xc2, 0xf2, 0xbf, 0x88, 0xa2, 0x93, 0xd0, 0xc5, 0x77, 0x6e, 0xf1, 0x5d, 0xe0, 0x2d, 0x9e, + 0x39, 0xd1, 0x22, 0x1d, 0x7d, 0x4d, 0x4f, 0xa4, 0x8d, 0x24, 0xad, 0xa4, 0xb3, 0xe7, 0x6e, 0xc3, + 0xbf, 0xd0, 0xfc, 0x59, 0xf9, 0x1f, 0x7c, 0x3a, 0x47, 0xc3, 0xa7, 0x8b, 0xe7, 0x2c, 0xc2, 0x12, + 0x7f, 0x88, 0x72, 0x29, 0x43, 0x17, 0xc9, 0x03, 0x8a, 0x84, 0x13, 0x1d, 0x48, 0x43, 0x3f, 0x7b, + 0xb0, 0xf7, 0x79, 0x98, 0x0b, 0x6d, 0xc0, 0xa7, 0xf7, 0x26, 0x47, 0xca, 0xc3, 0x43, 0x14, 0x1b, + 0x4e, 0xbf, 0xf0, 0x86, 0xee, 0x57, 0xc5, 0x07, 0xde, 0x6b, 0xba, 0xe0, 0x05, 0x86, 0xf3, 0x0a, + 0xe3, 0x75, 0x32, 0x7a, 0x45, 0x27, 0xb0, 0x4b, 0x44, 0xc5, 0x9b, 0x6c, 0xee, 0xa0, 0x00, 0xba, + 0x7c, 0xe3, 0xb6, 0x28, 0xb5, 0x3b, 0x9d, 0x4c, 0xb0, 0xde, 0x3e, 0xfb, 0x62, 0xef, 0xc1, 0x43, + 0x16, 0xd8, 0x46, 0x89, 0x84, 0xb3, 0x26, 0x30, 0x0d, 0x75, 0x83, 0xa0, 0xbe, 0x22, 0x70, 0xeb, + 0x22, 0xbe, 0x8f, 0xfc, 0x55, 0x8d, 0xbe, 0xf4, 0x78, 0xa1, 0xc9, 0x67, 0xab, 0xb6, 0xbc, 0x85, + 0xeb, 0xf7, 0x11, 0x24, 0xd0, 0x11, 0x85, 0x4b, 0x65, 0x5f, 0xaf, 0x96, 0x8d, 0xa7, 0xa9, 0x4c, + 0xd4, 0x34, 0x9c, 0x24, 0x1f, 0x55, 0x90, 0x21, 0x48, 0x3c, 0x5a, 0x9f, 0x19, 0xaa, 0x98, 0x71, + 0x62, 0xd8, 0xaa, 0x98, 0xd9, 0x47, 0xf3, 0x98, 0x75, 0xab, 0x8b, 0xbd, 0x21, 0xc4, 0x95, 0xa0, + 0x34, 0x98, 0x5c, 0x04, 0x7a, 0xf1, 0x2d, 0xdd, 0x3b, 0x35, 0xd5, 0x16, 0xdf, 0x54, 0x44, 0xa4, + 0x8b, 0x8b, 0x7c, 0x50, 0x5d, 0xaf, 0x9b, 0x1b, 0xa9, 0x14, 0xfa, 0xb4, 0x1a, 0xf6, 0xd6, 0x52, + 0x25, 0xb6, 0x38, 0x21, 0xe9, 0xb4, 0x8e, 0x69, 0xcd, 0x81, 0x28, 0xb2, 0x81, 0x0e, 0xd2, 0x4a, + 0x0d, 0x62, 0x5d, 0x49, 0x72, 0xc0, 0x1a, 0x0b, 0xd6, 0x89, 0xe9, 0x5e, 0xc2, 0xe9, 0x29, 0xad, + 0xe5, 0x93, 0xc0, 0x72, 0x93, 0x14, 0x82, 0x8d, 0x63, 0xe5, 0x40, 0x0c, 0x8f, 0xbc, 0x49, 0x61, + 0xac, 0x37, 0x00, 0x4f, 0x78, 0xcb, 0x7d, 0x9c, 0x7a, 0xaf, 0x23, 0x2f, 0x6f, 0x92, 0xca, 0x2b, + 0x74, 0xe6, 0x99, 0x1c, 0x86, 0xe9, 0x28, 0xa5, 0x19, 0x29, 0x32, 0xd3, 0xb4, 0x6e, 0x9a, 0xa1, + 0x30, 0xc0, 0xfe, 0x7c, 0xf3, 0x21, 0xeb, 0x91, 0x60, 0xfb, 0xa8, 0x93, 0x8d, 0x2e, 0x2b, 0x9c, + 0xe4, 0x3c, 0xcd, 0x75, 0x38, 0x16, 0x66, 0x6f, 0x2a, 0x69, 0x42, 0x44, 0xa8, 0x4e, 0x7d, 0xc5, + 0xf1, 0x8e, 0x92, 0xdd, 0xd8, 0x77, 0x73, 0x81, 0x44, 0x26, 0xe2, 0x9d, 0xb6, 0x37, 0xf2, 0x59, + 0x9f, 0xf1, 0x3e, 0x7e, 0xf4, 0xd8, 0x06, 0xe3, 0x5d, 0x90, 0x43, 0xbc, 0x72, 0xfe, 0x72, 0xff, + 0xce, 0x27, 0x6a, 0x82, 0x7d, 0x8e, 0x2d, 0x83, 0x1b, 0xbb, 0x0c, 0xcb, 0x72, 0xc5, 0x1b, 0xbd, + 0xaf, 0x0e, 0x38, 0x8f, 0x06, 0x88, 0xed, 0x51, 0xe4, 0x22, 0x72, 0x5d, 0xb9, 0x0c, 0xc9, 0x35, + 0x83, 0x88, 0xf7, 0x0f, 0xa2, 0xf7, 0x09, 0x4e, 0x84, 0x79, 0x61, 0x50, 0xda, 0xff, 0x8f, 0x08, + 0x74, 0x2c, 0xc2, 0xc7, 0x2a, 0x45, 0x5a, 0x60, 0x0e, 0xf3, 0x7a, 0x24, 0xbc, 0xac, 0xa1, 0x8d, + 0x68, 0x9a, 0x8a, 0x43, 0x74, 0xf9, 0x06, 0x55, 0xc8, 0xb1, 0xbb, 0xd8, 0x0d, 0x35, 0x68, 0x6d, + 0x7b, 0xdb, 0xb5, 0xb8, 0xcf, 0x3e, 0x51, 0x12, 0x19, 0x63, 0x37, 0xab, 0xa1, 0x83, 0xa1, 0xda, + 0x66, 0xe9, 0xd0, 0x0d, 0xdc, 0x5b, 0x27, 0x9b, 0xd3, 0xe9, 0x74, 0x93, 0xee, 0x96, 0x4d, 0x2c, + 0x54, 0x15, 0x1d, 0x8d, 0x3b, 0xe6, 0x77, 0x04, 0xad, 0xd6, 0x1a, 0xd3, 0xd6, 0x1a, 0x5a, 0x64, + 0xf5, 0x88, 0x80, 0x10, 0xe1, 0xe4, 0xda, 0xd9, 0xfa, 0xdb, 0xee, 0x93, 0x9d, 0xdd, 0xdd, 0x3f, + 0x6d, 0x85, 0x16, 0xe3, 0xf1, 0x31, 0x38, 0xf7, 0x9a, 0x5f, 0xee, 0x94, 0x31, 0xa6, 0xe8, 0xd1, + 0xe9, 0x1e, 0xeb, 0x2e, 0xcd, 0xa2, 0xd4, 0x77, 0x1d, 0xda, 0x52, 0x2f, 0x73, 0x51, 0xbd, 0x48, + 0x71, 0xcc, 0x95, 0x8a, 0x83, 0x5a, 0x53, 0x8d, 0x14, 0x64, 0xe0, 0x04, 0xc5, 0xf5, 0x1a, 0xa1, + 0xd4, 0xa7, 0xf5, 0x9e, 0xc6, 0x2c, 0xe7, 0x35, 0xc9, 0x69, 0xb0, 0x88, 0x67, 0xc8, 0xa3, 0x9e, + 0x0d, 0x07, 0x29, 0xdd, 0x05, 0x81, 0xb3, 0xe6, 0x41, 0xae, 0x4c, 0x7b, 0xd1, 0x21, 0x8d, 0x87, + 0xe2, 0x7f, 0x2a, 0xf5, 0xd6, 0x54, 0xa6, 0xf6, 0x7a, 0x9e, 0xb7, 0xc1, 0xc1, 0xe9, 0xdf, 0xdd, + 0xf3, 0xd1, 0x07, 0xbf, 0x01, 0xdd, 0x89, 0x77, 0x95, 0xce, 0x0d, 0x00, 0x00, +}; +const StaticFile app_js PROGMEM = {(sizeof(app_js_content)/sizeof(app_js_content[0])), app_js_content}; + +static const uint8_t md5_js_content[] PROGMEM = { + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xad, 0x59, 0x79, 0x73, 0x1b, 0xb7, + 0x15, 0xff, 0xbf, 0x9f, 0x42, 0xe2, 0x4c, 0x39, 0xbb, 0xb3, 0x2b, 0x05, 0xf7, 0x61, 0x72, 0xe5, + 0x89, 0x93, 0x1e, 0xe9, 0x95, 0xb6, 0x69, 0xd2, 0x83, 0x43, 0xcd, 0xd0, 0xd2, 0xd2, 0xbb, 0x89, + 0x42, 0xaa, 0x58, 0xd0, 0xb2, 0x62, 0xd2, 0x9f, 0xbd, 0x0f, 0xd8, 0x0b, 0x4b, 0x89, 0x3a, 0xac, + 0x8e, 0x2d, 0x2c, 0x08, 0xbc, 0xf7, 0x80, 0xdf, 0xbb, 0x70, 0x1d, 0x2f, 0x37, 0xab, 0x0b, 0x5b, + 0xae, 0x57, 0x51, 0xfc, 0x71, 0xb4, 0xa9, 0xf2, 0xa3, 0xca, 0x9a, 0xf2, 0xc2, 0x8e, 0x26, 0xef, + 0x17, 0xe6, 0xc8, 0xa6, 0x26, 0x1b, 0x95, 0xab, 0xeb, 0x8d, 0x3d, 0x2a, 0xab, 0xa3, 0x72, 0xf5, + 0x7e, 0x71, 0x55, 0x5e, 0x1e, 0xd9, 0xdb, 0xeb, 0x7c, 0x94, 0x96, 0xd9, 0xfb, 0x35, 0xfc, 0x40, + 0xc7, 0x59, 0x76, 0x53, 0xae, 0x2e, 0xd7, 0x37, 0xa7, 0x5f, 0x1a, 0xb3, 0xb8, 0x7d, 0xb3, 0x59, + 0x2e, 0x73, 0x93, 0x16, 0xd9, 0x08, 0x61, 0x42, 0x19, 0x17, 0x52, 0xe9, 0xc5, 0xdb, 0x8b, 0xcb, + 0x7c, 0x39, 0x3a, 0xad, 0xae, 0xaf, 0x4a, 0x1b, 0x8d, 0x46, 0x71, 0x5a, 0x65, 0x33, 0x4c, 0x54, + 0x4a, 0x89, 0x14, 0x2a, 0x55, 0x54, 0x29, 0x81, 0x54, 0x7a, 0x42, 0x30, 0x93, 0x4c, 0x51, 0xc1, + 0xd4, 0x3c, 0xcd, 0xb3, 0x19, 0x4a, 0x55, 0x8a, 0x45, 0x4a, 0xd8, 0x3c, 0x5d, 0x64, 0xb3, 0x51, + 0x91, 0x7f, 0x18, 0xa5, 0xa3, 0x85, 0x1b, 0x04, 0xbe, 0x97, 0xe5, 0xbb, 0xbc, 0xb2, 0x50, 0x79, + 0xeb, 0x07, 0x6c, 0x7b, 0xde, 0xb4, 0xbf, 0xde, 0x2e, 0xaa, 0x5c, 0xb0, 0xd1, 0x3c, 0x5d, 0x66, + 0xa3, 0x2f, 0xdf, 0x7c, 0xf5, 0xf5, 0x6f, 0x7e, 0xfb, 0xbb, 0xdf, 0x7f, 0xf3, 0x87, 0x3f, 0xfe, + 0xe9, 0xcf, 0x7f, 0xf9, 0xf6, 0xaf, 0x7f, 0xfb, 0xfb, 0x77, 0xff, 0xf8, 0xfe, 0x87, 0x7f, 0xfe, + 0xeb, 0xdf, 0xff, 0xa9, 0x67, 0xf6, 0xae, 0x28, 0x7f, 0xfc, 0xe9, 0xea, 0xe7, 0xd5, 0xfa, 0xfa, + 0xbf, 0xa6, 0xb2, 0x9b, 0xf7, 0x37, 0x1f, 0x6e, 0x7f, 0xe9, 0x67, 0x9f, 0x7c, 0x11, 0xce, 0x7c, + 0x95, 0xcd, 0xe6, 0x93, 0x72, 0x19, 0x95, 0xf1, 0x47, 0xa7, 0xa1, 0x75, 0xb6, 0xca, 0x6f, 0x8e, + 0x02, 0xe4, 0x91, 0x50, 0xf1, 0xc4, 0xfa, 0xd6, 0xef, 0xcb, 0x95, 0x55, 0xbe, 0x2b, 0x5a, 0x3b, + 0xc6, 0xb6, 0x8d, 0x92, 0xb6, 0x71, 0xe7, 0x2b, 0xa7, 0x65, 0xe5, 0xbf, 0xdb, 0x6d, 0x34, 0xf8, + 0x9d, 0x75, 0xa6, 0xb1, 0xf1, 0x47, 0x93, 0xdb, 0x8d, 0x59, 0x8d, 0x66, 0xeb, 0xb7, 0x3f, 0xe6, + 0x17, 0xb6, 0x1e, 0x71, 0x3e, 0xca, 0xb2, 0xec, 0x5b, 0xdf, 0x70, 0x7a, 0x6d, 0xd6, 0x76, 0xed, + 0x2c, 0x73, 0x6a, 0xd7, 0xdf, 0x81, 0x09, 0x57, 0xef, 0x4e, 0x2f, 0x16, 0x57, 0x57, 0xc0, 0xba, + 0x8b, 0xd3, 0x72, 0x3c, 0x3e, 0x0e, 0xe6, 0x08, 0x03, 0xfc, 0x50, 0xe6, 0x37, 0xe3, 0x71, 0x74, + 0xb7, 0xf1, 0xbe, 0x41, 0xeb, 0x31, 0x61, 0x30, 0x27, 0x7f, 0xbd, 0x3c, 0xb2, 0xe3, 0xb1, 0x3d, + 0xad, 0xf5, 0xde, 0xd7, 0x4e, 0x2f, 0xd6, 0x2b, 0xf0, 0x9d, 0xcd, 0x85, 0x5d, 0x1b, 0x98, 0x56, + 0x20, 0x79, 0x17, 0x7b, 0x67, 0xba, 0x0f, 0xcf, 0x51, 0xd7, 0x64, 0xba, 0x26, 0xa7, 0xa6, 0x4d, + 0x74, 0x8c, 0xe2, 0xd3, 0xcd, 0xf5, 0xe5, 0xc2, 0xe6, 0xd0, 0x35, 0xb3, 0xf3, 0x28, 0xde, 0xed, + 0x26, 0x2d, 0x35, 0xf4, 0x03, 0x3d, 0x58, 0xc1, 0xc4, 0xab, 0x19, 0x9a, 0x67, 0xab, 0x19, 0x16, + 0xbe, 0x74, 0x05, 0x71, 0x05, 0x75, 0x05, 0x73, 0x05, 0x77, 0x85, 0xef, 0x95, 0xae, 0x50, 0xae, + 0xd0, 0x9e, 0xb8, 0x66, 0xf4, 0x3c, 0xd8, 0x33, 0x61, 0xcf, 0x85, 0x3d, 0x1b, 0x06, 0x3e, 0x94, + 0xda, 0xa2, 0xac, 0x4e, 0xdf, 0x5e, 0xad, 0x2f, 0x7e, 0xaa, 0xb2, 0x55, 0xf3, 0xcb, 0x63, 0x52, + 0x99, 0x9d, 0xe4, 0x57, 0x10, 0x2e, 0xbd, 0x2b, 0x14, 0xf7, 0xbb, 0x42, 0xc8, 0xb3, 0xe7, 0x15, + 0x45, 0x3c, 0x1c, 0x60, 0xcf, 0x3f, 0x8a, 0x78, 0xe7, 0x87, 0x08, 0x69, 0x20, 0x28, 0x1e, 0xf9, + 0x37, 0xaf, 0x87, 0x2c, 0x50, 0x56, 0x7f, 0x71, 0xf3, 0x25, 0xcd, 0x97, 0xd6, 0xdf, 0xca, 0x2e, + 0x8c, 0xad, 0xab, 0x6f, 0x6f, 0x6d, 0x5e, 0x35, 0xbd, 0x6f, 0x7c, 0xbd, 0x01, 0xbe, 0x2c, 0x57, + 0x10, 0xf0, 0xbf, 0xe4, 0x97, 0x4d, 0xe7, 0xa2, 0x2a, 0xa0, 0x7e, 0x8c, 0xdb, 0x5e, 0x08, 0x96, + 0xec, 0x18, 0xed, 0x36, 0x81, 0xf3, 0xd5, 0x26, 0x1b, 0x58, 0x1a, 0x34, 0x74, 0x3c, 0x14, 0xd7, + 0xe8, 0x0b, 0xb2, 0x40, 0xeb, 0x50, 0x2e, 0xa2, 0x46, 0x95, 0x77, 0xdb, 0x11, 0x24, 0x94, 0xca, + 0x73, 0xb5, 0x7e, 0xe7, 0x1b, 0x6c, 0x61, 0xd6, 0x37, 0x47, 0xc6, 0x11, 0xae, 0x36, 0x57, 0x57, + 0xe0, 0x61, 0x36, 0x6c, 0x2b, 0x9d, 0x23, 0x1e, 0xf4, 0xc0, 0xf8, 0x4e, 0x40, 0xda, 0xb8, 0x33, + 0xdf, 0xf1, 0x30, 0xea, 0xa0, 0x6b, 0xbb, 0x05, 0x71, 0x77, 0x43, 0x03, 0x7a, 0xe2, 0x6e, 0xcc, + 0xc2, 0x21, 0x5f, 0xae, 0x4d, 0xe4, 0xa0, 0x2c, 0xd2, 0x25, 0xc4, 0x37, 0x4a, 0xd7, 0x99, 0x3d, + 0xbd, 0xca, 0x57, 0xef, 0x6c, 0x91, 0xde, 0x66, 0x81, 0xd9, 0xd2, 0x4d, 0x16, 0xfa, 0xc1, 0x64, + 0x35, 0x5d, 0x4f, 0x3c, 0xc4, 0x40, 0xaf, 0x10, 0x8d, 0x7b, 0x5a, 0xbe, 0x75, 0xae, 0x7d, 0xeb, + 0x5c, 0x3b, 0xf5, 0xa5, 0xab, 0xbb, 0x82, 0xb8, 0x82, 0xba, 0x82, 0xb9, 0x82, 0xbb, 0xc2, 0xf7, + 0x4a, 0x57, 0x28, 0x57, 0x68, 0x4f, 0x5c, 0xb3, 0x7b, 0x1e, 0xec, 0x99, 0xb0, 0xe7, 0xc2, 0x9e, + 0xcd, 0x3b, 0x78, 0x9c, 0x16, 0xb1, 0xf7, 0x60, 0x87, 0x64, 0x19, 0x78, 0x86, 0x9b, 0xe2, 0x78, + 0xbc, 0x9c, 0x0a, 0x36, 0x49, 0x92, 0x55, 0xbc, 0x99, 0x2d, 0x93, 0x64, 0x9e, 0xd9, 0xd9, 0x6a, + 0x5e, 0xeb, 0xed, 0x11, 0xfa, 0xdb, 0xd9, 0xf2, 0xec, 0x8c, 0xcc, 0xb7, 0x9e, 0x63, 0x3a, 0xcd, + 0x67, 0x74, 0xec, 0x04, 0x04, 0x21, 0xf3, 0x88, 0x80, 0x68, 0x01, 0xaa, 0xbc, 0x28, 0x16, 0xe6, + 0xab, 0xf5, 0x65, 0xfe, 0xa5, 0x8d, 0x56, 0x71, 0x3c, 0x85, 0x15, 0xe3, 0x75, 0x33, 0x91, 0xc5, + 0xab, 0xc5, 0x94, 0x20, 0xa6, 0x5e, 0x47, 0x4d, 0x03, 0xd6, 0x64, 0xbb, 0x38, 0x3b, 0x13, 0x69, + 0xfb, 0x9b, 0xa8, 0xad, 0xa0, 0xe3, 0x45, 0x0c, 0x84, 0x9c, 0x13, 0x2d, 0xb6, 0xd0, 0x9d, 0x71, + 0x49, 0x19, 0xeb, 0x78, 0x08, 0x61, 0x8e, 0x07, 0x93, 0x90, 0xc9, 0x09, 0x19, 0x0b, 0x7a, 0x57, + 0x0e, 0xcc, 0x48, 0x70, 0x4e, 0x45, 0x12, 0x45, 0x18, 0x11, 0xd7, 0x34, 0x9d, 0x62, 0xb4, 0xf5, + 0xf5, 0xc1, 0x54, 0xdd, 0xf4, 0xe3, 0x96, 0x9f, 0x30, 0xe4, 0xc7, 0x50, 0x7b, 0x63, 0x60, 0xb2, + 0x37, 0xc8, 0xa1, 0x71, 0x9f, 0xa4, 0xee, 0x43, 0xda, 0xea, 0xcc, 0xb0, 0xe8, 0x6d, 0xd0, 0x69, + 0xae, 0xeb, 0x8c, 0x5a, 0xe5, 0xc5, 0x3d, 0x55, 0x1a, 0xf4, 0xb6, 0x53, 0xe9, 0x7b, 0xef, 0xd5, + 0x6a, 0xcf, 0xd1, 0x29, 0xf6, 0xb0, 0xc0, 0x06, 0xee, 0x33, 0x46, 0x7c, 0xa6, 0xfe, 0x83, 0xd9, + 0xb4, 0x26, 0x78, 0x70, 0x36, 0xde, 0x20, 0xff, 0xc7, 0xf9, 0xd6, 0x59, 0xf8, 0x6a, 0x51, 0x59, + 0x97, 0x52, 0xbf, 0x59, 0x5d, 0xe6, 0x1f, 0xb2, 0x65, 0xda, 0xe7, 0xdb, 0x24, 0x5b, 0x9e, 0xf4, + 0xf6, 0x4c, 0x97, 0x67, 0x99, 0x00, 0x1d, 0x06, 0xa9, 0x79, 0x79, 0x22, 0x58, 0xda, 0x65, 0x84, + 0x28, 0x4e, 0x07, 0xd9, 0x01, 0xc5, 0xaf, 0x42, 0xda, 0x5d, 0xb3, 0x76, 0xf6, 0xf2, 0xcf, 0x18, + 0xd1, 0x4c, 0x0b, 0x49, 0x34, 0xef, 0x32, 0xcb, 0x9b, 0x7a, 0xe0, 0x9e, 0xe8, 0x8b, 0x8e, 0x48, + 0x4c, 0xa7, 0x28, 0xdd, 0x5f, 0x0d, 0x7c, 0xf5, 0xd7, 0x3d, 0x4d, 0x3d, 0x87, 0xdd, 0x2e, 0x0d, + 0x33, 0x7e, 0x9b, 0xd5, 0xb3, 0x60, 0x23, 0x79, 0x5f, 0xca, 0xdf, 0x5b, 0x51, 0x8e, 0x51, 0xbd, + 0xc3, 0x1c, 0xa4, 0x49, 0x93, 0xdd, 0xd5, 0xda, 0xc4, 0xce, 0x4c, 0xad, 0xe7, 0x0a, 0x74, 0x6b, + 0xe6, 0xa9, 0x01, 0x8f, 0x13, 0xc3, 0x6c, 0xb9, 0xdd, 0x0e, 0x14, 0xe5, 0xf2, 0xa6, 0xf5, 0x79, + 0xd3, 0x97, 0xae, 0xee, 0x0a, 0xe2, 0x0a, 0xea, 0x0a, 0xe6, 0x0a, 0xee, 0x0a, 0xdf, 0x2b, 0x5d, + 0xa1, 0x5c, 0xa1, 0x3d, 0x71, 0xcd, 0xee, 0x79, 0xb0, 0x67, 0xc2, 0x9e, 0x0b, 0x7b, 0xb6, 0x26, + 0x6f, 0x36, 0x3f, 0x3b, 0x35, 0x4d, 0xa7, 0x34, 0xad, 0x3b, 0x03, 0x5d, 0x43, 0xe3, 0x36, 0x30, + 0x09, 0xc0, 0xd0, 0xa1, 0x49, 0xf7, 0x34, 0xe9, 0x1a, 0x43, 0x2d, 0x36, 0x3b, 0xf0, 0xb4, 0x4c, + 0x61, 0xa9, 0x4c, 0x73, 0xd8, 0x04, 0x07, 0xba, 0x9a, 0xf4, 0x6b, 0xf0, 0x6b, 0x93, 0x45, 0x91, + 0xfb, 0xb3, 0xfe, 0x6f, 0x01, 0xf0, 0x4f, 0x84, 0x42, 0x4a, 0x0a, 0x4d, 0x25, 0xf8, 0xa4, 0xdc, + 0x5a, 0x37, 0x34, 0x8f, 0x4f, 0x88, 0xc4, 0x92, 0x52, 0x25, 0x35, 0x58, 0x3b, 0x3e, 0x8f, 0x4a, + 0xa0, 0x87, 0xbf, 0xbe, 0xf9, 0x3c, 0x2a, 0xa0, 0x09, 0xfe, 0x4e, 0xa0, 0x81, 0x70, 0xc5, 0xb0, + 0x66, 0xe7, 0x04, 0x21, 0x46, 0xb1, 0x42, 0x12, 0x8f, 0x6d, 0x9c, 0x2c, 0x40, 0x97, 0x27, 0x18, + 0x4b, 0x45, 0x91, 0x44, 0x2e, 0xa4, 0x30, 0xd9, 0x16, 0x4e, 0x3c, 0x8a, 0x13, 0xeb, 0xc4, 0x8e, + 0x43, 0x79, 0xb0, 0x62, 0x02, 0x07, 0x71, 0x1c, 0x44, 0x30, 0x60, 0x92, 0xdc, 0xb1, 0xc8, 0x6d, + 0x09, 0x2c, 0x98, 0xc7, 0x49, 0x51, 0xb3, 0x14, 0x0d, 0x25, 0x05, 0x4a, 0x8a, 0x05, 0xe1, 0x9a, + 0x20, 0x0d, 0x94, 0x84, 0x6c, 0x8d, 0xa3, 0x04, 0xe1, 0x25, 0x50, 0xbe, 0x8a, 0x1a, 0x7f, 0x29, + 0x50, 0xeb, 0x2b, 0x05, 0x86, 0x93, 0x48, 0xb3, 0xc3, 0x49, 0xbd, 0x22, 0x92, 0x56, 0x13, 0x89, + 0xc7, 0xd2, 0xec, 0x7a, 0xe2, 0x73, 0x33, 0x8e, 0xca, 0xf3, 0xc2, 0x0f, 0x13, 0x28, 0x48, 0x84, + 0x0a, 0x4a, 0x4c, 0xa0, 0x18, 0x60, 0x37, 0x8d, 0x42, 0xa0, 0x5a, 0x9e, 0xdb, 0x31, 0xfc, 0x2e, + 0xe3, 0x46, 0x05, 0x54, 0x69, 0x2e, 0x18, 0x57, 0xe2, 0x5e, 0x15, 0xd8, 0x73, 0xd3, 0x20, 0x4f, + 0x04, 0x12, 0x18, 0x71, 0x85, 0xf5, 0xa3, 0xc0, 0x11, 0x63, 0x9c, 0x70, 0x4a, 0xd1, 0x5d, 0xe0, + 0xf1, 0x5d, 0x68, 0x45, 0x08, 0x88, 0x01, 0xbb, 0x14, 0x0c, 0x2b, 0xa5, 0xe5, 0xe7, 0x00, 0xe2, + 0xf3, 0x04, 0x83, 0x9d, 0x91, 0x42, 0x8c, 0x3c, 0x8c, 0x48, 0xc0, 0x48, 0x0c, 0x9c, 0x83, 0x62, + 0xca, 0xf0, 0x83, 0x90, 0xe4, 0xfc, 0x84, 0x71, 0x89, 0xb8, 0x56, 0xf4, 0x2e, 0xa0, 0x47, 0xf0, + 0x28, 0x98, 0x8f, 0x94, 0x08, 0x51, 0xce, 0xf0, 0x67, 0x59, 0x48, 0xc3, 0x34, 0xb5, 0x73, 0x60, + 0xc6, 0xb0, 0x7c, 0x10, 0x10, 0x44, 0xfb, 0x09, 0x23, 0xc8, 0xe7, 0xf6, 0xc3, 0x60, 0xb0, 0xf3, + 0x7a, 0xad, 0x11, 0x43, 0x30, 0x21, 0xf2, 0x6c, 0x3c, 0x90, 0x46, 0x12, 0x08, 0x21, 0x26, 0x10, + 0x15, 0x8a, 0x7c, 0x96, 0xcb, 0x81, 0x87, 0x30, 0x04, 0x3a, 0xc7, 0x08, 0x3f, 0x8c, 0xc7, 0xf9, + 0x02, 0x47, 0x60, 0x4d, 0x42, 0x34, 0x7a, 0x18, 0x94, 0x37, 0x3b, 0x15, 0x9c, 0x72, 0x4a, 0xee, + 0x09, 0xb7, 0x1e, 0x54, 0x3b, 0x25, 0x98, 0xcd, 0xb8, 0x87, 0x08, 0xe8, 0x06, 0x31, 0x81, 0x05, + 0x97, 0x5a, 0x70, 0xec, 0x46, 0xe5, 0x35, 0x40, 0xd9, 0x02, 0xec, 0xbd, 0x07, 0x09, 0xcd, 0x11, + 0x16, 0xd4, 0xa9, 0x41, 0xd7, 0x28, 0x68, 0x83, 0xc2, 0x01, 0xee, 0x34, 0x61, 0x9d, 0x02, 0x03, + 0xfd, 0x27, 0x82, 0x51, 0x48, 0x2c, 0x12, 0x7b, 0x4b, 0xb1, 0x1a, 0x94, 0x6a, 0x40, 0xf5, 0x81, + 0x4d, 0x25, 0x84, 0xa6, 0xa4, 0xc8, 0x1b, 0x09, 0xd5, 0x78, 0xc8, 0x67, 0xe0, 0xe1, 0xf3, 0x13, + 0x89, 0x30, 0x87, 0x08, 0xd7, 0xf8, 0x20, 0x1e, 0x70, 0x9e, 0x84, 0x2a, 0x40, 0x83, 0xbc, 0x93, + 0x3f, 0x1d, 0x0e, 0x48, 0x17, 0x02, 0xb9, 0x8c, 0x48, 0xf9, 0x61, 0x38, 0xcc, 0xd9, 0x1c, 0x36, + 0x3d, 0x52, 0x31, 0xf5, 0x42, 0x38, 0x7a, 0x9e, 0x70, 0xa1, 0x18, 0x03, 0x25, 0xaa, 0xc3, 0x70, + 0x9c, 0xef, 0x20, 0xac, 0x15, 0xb8, 0xa9, 0xf7, 0x9d, 0x27, 0x03, 0x72, 0xf9, 0x4b, 0x49, 0x2a, + 0xa8, 0x16, 0xf8, 0x30, 0x1e, 0x17, 0xd6, 0x60, 0x79, 0x4e, 0xc1, 0x41, 0xf1, 0x0b, 0x01, 0xb9, + 0x80, 0x80, 0xe8, 0x66, 0x42, 0x61, 0x26, 0xe4, 0x41, 0x48, 0xb0, 0xf6, 0x70, 0x0c, 0x81, 0x03, + 0x2a, 0x7c, 0x0e, 0x1e, 0xe9, 0x12, 0x90, 0x0b, 0x0b, 0x05, 0xc9, 0xee, 0x30, 0x20, 0xec, 0x96, + 0x36, 0x4d, 0xc0, 0x94, 0x12, 0x36, 0xc1, 0x0f, 0x21, 0xca, 0xb3, 0x0e, 0x54, 0x54, 0x65, 0x0e, + 0xc4, 0x79, 0x87, 0xab, 0x02, 0x69, 0xb5, 0xc7, 0xc1, 0x34, 0xc1, 0xe3, 0x40, 0x0e, 0xab, 0xc1, + 0xa8, 0x06, 0x4c, 0xad, 0xbc, 0x13, 0x02, 0x31, 0xcd, 0x25, 0x60, 0xf6, 0x53, 0xc2, 0x35, 0x18, + 0xdc, 0x81, 0x09, 0xd2, 0x48, 0x0e, 0xf0, 0x9b, 0xb0, 0xc1, 0x8a, 0x6a, 0x44, 0x11, 0xf7, 0x69, + 0x0b, 0x8b, 0x1a, 0x87, 0x68, 0x70, 0xb4, 0x56, 0x07, 0xa8, 0x48, 0x73, 0xee, 0x72, 0x2d, 0xa1, + 0x1e, 0x82, 0xfe, 0x0c, 0x04, 0x2e, 0x07, 0x38, 0x41, 0xb0, 0x7a, 0x0b, 0x74, 0x3f, 0x0a, 0xe6, + 0x52, 0x8e, 0x24, 0x4a, 0x83, 0xe3, 0x3f, 0x15, 0x85, 0x74, 0x62, 0x39, 0xd3, 0xb2, 0x4e, 0x1a, + 0xf7, 0x62, 0x40, 0xce, 0x73, 0x35, 0x18, 0x0b, 0x09, 0x86, 0x5e, 0x88, 0x82, 0x42, 0xae, 0x51, + 0x30, 0x4b, 0x8d, 0x25, 0xbb, 0x1f, 0x85, 0xcb, 0x33, 0x5c, 0x41, 0x60, 0x12, 0x42, 0x9e, 0x08, + 0x02, 0xdc, 0x15, 0xa8, 0x39, 0xc1, 0x5a, 0xea, 0x03, 0x20, 0xc4, 0x3c, 0x91, 0x02, 0x11, 0x8d, + 0x95, 0x7e, 0x19, 0x02, 0x58, 0xfd, 0x40, 0x0b, 0x54, 0x30, 0xa6, 0xe4, 0xfd, 0x00, 0x9c, 0xe3, + 0x32, 0x82, 0x15, 0xe6, 0xaa, 0x4e, 0x40, 0x4f, 0x71, 0x26, 0x58, 0x2e, 0xc0, 0xba, 0x92, 0x01, + 0x0a, 0x74, 0x00, 0x02, 0x88, 0xd5, 0x1a, 0xf6, 0x30, 0x0a, 0x96, 0x81, 0x07, 0x30, 0xf4, 0xf1, + 0x1d, 0x46, 0x77, 0x64, 0xb6, 0x9f, 0xba, 0x3c, 0x0e, 0x59, 0x48, 0x50, 0x04, 0x19, 0x0b, 0xc4, + 0x88, 0x1a, 0x80, 0x68, 0x00, 0x6c, 0x3f, 0x95, 0x5d, 0x90, 0xc2, 0xbe, 0x52, 0x69, 0xcc, 0x30, + 0xaf, 0x8f, 0x83, 0x1e, 0x04, 0x69, 0x41, 0x84, 0xc1, 0x1d, 0x15, 0xdb, 0x4f, 0x41, 0x96, 0x63, + 0x2e, 0x0f, 0x31, 0x8d, 0x3c, 0x1f, 0xaf, 0x81, 0xc8, 0x06, 0xc8, 0xf6, 0x93, 0x6d, 0x97, 0x00, + 0x88, 0x36, 0xea, 0x52, 0xb0, 0xc3, 0x82, 0xeb, 0xd0, 0xc6, 0xcf, 0x05, 0xe3, 0x17, 0x7f, 0xd8, + 0xcc, 0x30, 0x88, 0x6d, 0x89, 0x0f, 0xc3, 0xf1, 0x39, 0x14, 0xce, 0x5a, 0x0a, 0xd2, 0x89, 0x78, + 0x06, 0x1c, 0xef, 0xfa, 0x1c, 0x73, 0x42, 0x1f, 0xc0, 0x82, 0x5d, 0xf6, 0x00, 0xc0, 0x04, 0xdc, + 0x5a, 0xbf, 0x04, 0x8d, 0xcb, 0xe1, 0x90, 0xe9, 0x61, 0xff, 0x47, 0xb9, 0x3e, 0x0c, 0xc6, 0xad, + 0x70, 0x10, 0x8b, 0x70, 0x52, 0xf0, 0x16, 0x7c, 0x22, 0x16, 0xb7, 0x3f, 0xe0, 0xc2, 0xad, 0x40, + 0xb0, 0xaa, 0x3e, 0x84, 0x06, 0x42, 0x14, 0x43, 0xa2, 0xc1, 0x1c, 0x0b, 0xf6, 0x22, 0x38, 0xde, + 0x17, 0x38, 0xe8, 0x0e, 0x8e, 0x33, 0x0f, 0xa0, 0xf1, 0x87, 0x1e, 0x48, 0xbf, 0x18, 0x56, 0x12, + 0xfd, 0x74, 0x3c, 0x60, 0x79, 0x09, 0xda, 0x52, 0x92, 0x78, 0x5d, 0x1d, 0x82, 0x03, 0xf1, 0x4a, + 0xc1, 0xcf, 0xc0, 0x3f, 0xea, 0xa8, 0xd9, 0x03, 0x13, 0x1c, 0xf5, 0xa2, 0xee, 0x72, 0x37, 0xe9, + 0x8e, 0x68, 0xb4, 0xa3, 0x29, 0x70, 0x66, 0x06, 0x87, 0xbc, 0xb4, 0xbd, 0xfb, 0x2d, 0x83, 0x13, + 0x5d, 0xdf, 0x4e, 0xb3, 0x22, 0x69, 0xc9, 0xd5, 0x70, 0xa4, 0xec, 0x18, 0xc7, 0xaf, 0xfa, 0xd1, + 0xea, 0xaf, 0xc7, 0xda, 0x0d, 0xd5, 0x7c, 0xbd, 0xa6, 0xba, 0x81, 0x9a, 0x6f, 0x30, 0xf1, 0xf6, + 0xc6, 0xb9, 0xa0, 0x35, 0xe8, 0xbd, 0x43, 0xaf, 0xbb, 0x17, 0xe9, 0xcf, 0xbc, 0x83, 0x5b, 0x82, + 0x28, 0x0e, 0xef, 0x08, 0x0e, 0x9c, 0xf9, 0xda, 0x9b, 0x6b, 0x3a, 0x69, 0xee, 0x40, 0x8a, 0x19, + 0x98, 0x90, 0x8d, 0x5d, 0xb6, 0x2a, 0xc0, 0x0d, 0xc7, 0xd6, 0x7d, 0xad, 0xbf, 0xe1, 0xa9, 0xdb, + 0xa0, 0xae, 0xfa, 0x2a, 0x41, 0x7d, 0x1d, 0x8b, 0xa0, 0x3d, 0xa4, 0x69, 0xc5, 0x99, 0x81, 0x64, + 0xd3, 0x34, 0x75, 0x92, 0x4d, 0x2f, 0xd9, 0x04, 0x92, 0x4d, 0x20, 0xd9, 0x04, 0x92, 0x4d, 0x20, + 0xb9, 0x1c, 0x48, 0x2e, 0x9b, 0xa6, 0x4e, 0x72, 0xd9, 0x4b, 0x2e, 0x03, 0xc9, 0x65, 0x20, 0xb9, + 0x0c, 0x24, 0x97, 0x81, 0xe4, 0x6a, 0x20, 0xb9, 0x6a, 0x9a, 0x3a, 0xc9, 0x55, 0x2f, 0xb9, 0x0a, + 0x24, 0x57, 0x81, 0xe4, 0x2a, 0x90, 0x5c, 0xb5, 0x92, 0x87, 0x66, 0x6c, 0x1f, 0x9d, 0xb2, 0x3d, + 0xdb, 0x0e, 0x88, 0xea, 0x37, 0xbb, 0x97, 0x99, 0xbb, 0xd8, 0x33, 0xf7, 0x8c, 0x70, 0x30, 0x71, + 0xea, 0x6d, 0x0a, 0xd5, 0xb4, 0xb6, 0x62, 0x53, 0x83, 0x99, 0xba, 0x9a, 0x23, 0x31, 0xa9, 0x69, + 0x49, 0x4c, 0x47, 0x62, 0x06, 0x24, 0x65, 0x5a, 0xb6, 0x24, 0x65, 0x47, 0x52, 0x0e, 0x48, 0x8a, + 0xb4, 0x68, 0x49, 0x8a, 0x8e, 0xa4, 0x68, 0x49, 0xf6, 0x74, 0xe2, 0x1f, 0x24, 0xb3, 0xbb, 0x0a, + 0xb8, 0x4b, 0x54, 0x3f, 0x33, 0x3c, 0xae, 0x98, 0xfd, 0x07, 0x26, 0x58, 0x7a, 0x41, 0x49, 0xfb, + 0xef, 0x46, 0x36, 0x6e, 0x43, 0xc1, 0xf8, 0x9b, 0xb0, 0x56, 0x9b, 0xfe, 0x16, 0xac, 0x51, 0xa8, + 0xf1, 0xb7, 0x61, 0xed, 0xb5, 0x89, 0xbf, 0x15, 0xab, 0xd5, 0x9a, 0xda, 0x21, 0x88, 0xfa, 0xc9, + 0x22, 0x3b, 0x30, 0xe5, 0x21, 0xa9, 0x7f, 0x72, 0x0d, 0x51, 0xb4, 0xef, 0x23, 0xcd, 0x2d, 0x56, + 0x36, 0x1a, 0xb5, 0xe1, 0xea, 0x65, 0x44, 0x71, 0x9a, 0x67, 0x68, 0x92, 0x43, 0x5e, 0x9c, 0xc4, + 0x36, 0xab, 0x66, 0xb9, 0xbb, 0x57, 0x35, 0x6d, 0xa5, 0x6c, 0x2b, 0x90, 0xbf, 0x97, 0x33, 0x9f, + 0x97, 0xe7, 0xc9, 0x72, 0x26, 0x28, 0x1c, 0x76, 0xdd, 0xa6, 0xc6, 0xe5, 0x48, 0x16, 0xb7, 0x4d, + 0x90, 0x85, 0x88, 0xcf, 0xae, 0xa2, 0x6d, 0x2a, 0xe7, 0xad, 0x1e, 0xbc, 0xec, 0x7d, 0x39, 0x4e, + 0xc6, 0x58, 0xc0, 0x42, 0x32, 0xca, 0xb2, 0xd1, 0x2e, 0x6d, 0x1e, 0xb1, 0x7f, 0xbe, 0xe4, 0x77, + 0xee, 0xe0, 0xb2, 0xdb, 0xc8, 0xbf, 0x3e, 0xc7, 0x13, 0x7b, 0x7a, 0x61, 0xf2, 0xc1, 0x03, 0xd7, + 0xf0, 0xd9, 0x72, 0x97, 0xda, 0x3b, 0x4f, 0x60, 0xfd, 0xcb, 0x66, 0xcb, 0x1d, 0x05, 0x4f, 0x9b, + 0xbb, 0x49, 0xab, 0x24, 0x03, 0xaa, 0x30, 0xd3, 0x45, 0xf3, 0x80, 0x34, 0x49, 0x12, 0x53, 0x0f, + 0x5f, 0x66, 0x8b, 0x99, 0x99, 0x4f, 0xec, 0xac, 0x9c, 0xc3, 0x44, 0xca, 0xb8, 0xbb, 0xeb, 0xdd, + 0x45, 0x31, 0xfc, 0x9f, 0xfc, 0xea, 0x7f, 0xf1, 0xc2, 0x99, 0x50, 0xc3, 0x1f, 0x00, 0x00, +}; +const StaticFile md5_js PROGMEM = {(sizeof(md5_js_content)/sizeof(md5_js_content[0])), md5_js_content}; + +static const uint8_t style_css_content[] PROGMEM = { + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x9d, 0x53, 0x5d, 0x6b, 0xdb, 0x30, + 0x14, 0x7d, 0xdf, 0xaf, 0x08, 0x94, 0x41, 0x0b, 0x76, 0xb0, 0x9b, 0x26, 0x59, 0x64, 0xf6, 0xb0, + 0x3d, 0x8c, 0xed, 0x61, 0x4f, 0x65, 0x4f, 0xa3, 0x14, 0x7d, 0x5c, 0xd9, 0x22, 0xb2, 0x25, 0xa4, + 0xeb, 0x26, 0x99, 0xf1, 0x7f, 0x9f, 0xfc, 0x11, 0x37, 0x69, 0x32, 0x28, 0xc3, 0x20, 0xb8, 0x1f, + 0xd2, 0x39, 0xf7, 0xdc, 0x63, 0x66, 0xc4, 0x21, 0x2a, 0xb0, 0xd4, 0x8d, 0xa5, 0x42, 0xa8, 0x2a, + 0x27, 0x49, 0x56, 0x52, 0x97, 0xab, 0x8a, 0x24, 0x2d, 0xeb, 0x8a, 0xac, 0x46, 0x34, 0x55, 0xa4, + 0x2a, 0x5b, 0xe3, 0x6f, 0x3c, 0x58, 0xf8, 0x6c, 0xa9, 0xf7, 0x3b, 0xe3, 0xc4, 0xd3, 0x69, 0x12, + 0x61, 0x8f, 0x4f, 0x8d, 0x34, 0x15, 0xc6, 0x5e, 0xfd, 0x01, 0x92, 0xae, 0xec, 0x3e, 0xeb, 0x43, + 0x49, 0x4b, 0xa5, 0x0f, 0x24, 0xa6, 0xd6, 0x6a, 0x88, 0xfd, 0xc1, 0x23, 0x94, 0xd1, 0x57, 0xad, + 0xaa, 0xed, 0x4f, 0xca, 0x1f, 0xfb, 0xf0, 0x5b, 0xe8, 0x8b, 0x1e, 0x21, 0x37, 0x30, 0xfb, 0xf5, + 0x23, 0xfa, 0x0e, 0xfa, 0x05, 0x50, 0x71, 0x1a, 0x7d, 0x71, 0x8a, 0xea, 0xc8, 0xd3, 0xca, 0xc7, + 0x1e, 0x9c, 0x92, 0xed, 0x1c, 0x15, 0x6a, 0x98, 0xb8, 0xa6, 0x89, 0xdd, 0xcf, 0xfa, 0x63, 0x42, + 0xdb, 0x81, 0xca, 0x0b, 0x24, 0xab, 0x24, 0xc9, 0x18, 0xe5, 0xdb, 0xdc, 0x99, 0xba, 0x12, 0x31, + 0x37, 0xda, 0x38, 0x72, 0x03, 0x52, 0xde, 0xcb, 0x65, 0xc6, 0x02, 0x79, 0x70, 0x31, 0x33, 0x61, + 0xb2, 0x92, 0xa4, 0xe1, 0xba, 0x37, 0x5a, 0x89, 0xd9, 0x8d, 0xd8, 0x40, 0x02, 0xeb, 0x6c, 0xec, + 0xbe, 0x5f, 0xaf, 0x80, 0x3d, 0x64, 0x27, 0x33, 0x2d, 0xed, 0xbe, 0x9d, 0x33, 0x6d, 0xf8, 0xf6, + 0x8c, 0x42, 0x3b, 0x97, 0xb5, 0xd6, 0xf1, 0x4e, 0x09, 0x2c, 0x9a, 0xfe, 0x0c, 0xe9, 0xe4, 0x63, + 0xc0, 0xd9, 0x77, 0x17, 0xbb, 0xb6, 0x09, 0xb2, 0x6b, 0x36, 0xae, 0x7c, 0xd6, 0x94, 0xc1, 0x89, + 0xe8, 0xb3, 0x64, 0xb6, 0xb8, 0x1c, 0x61, 0xec, 0xed, 0x65, 0x6e, 0x86, 0xb5, 0x4c, 0xac, 0x7b, + 0x2e, 0x7d, 0xd9, 0xd7, 0xec, 0xec, 0xb5, 0x18, 0x8d, 0x25, 0xe1, 0xb5, 0xf6, 0x5d, 0x3b, 0x8b, + 0x3c, 0x68, 0xe0, 0xd8, 0x8c, 0x0c, 0x1d, 0x15, 0xaa, 0xf6, 0xe4, 0x21, 0x90, 0x19, 0x32, 0xa7, + 0xfa, 0xf0, 0x0d, 0xe7, 0x5c, 0x66, 0x47, 0xd6, 0xeb, 0x50, 0xd9, 0x84, 0x46, 0x53, 0x63, 0x58, + 0x27, 0x04, 0xc7, 0x5c, 0x43, 0x24, 0xd2, 0xf0, 0xda, 0x5f, 0xe0, 0x8e, 0xe9, 0x01, 0x7d, 0x08, + 0x9a, 0x5e, 0xb0, 0x82, 0x0a, 0xb3, 0xeb, 0x15, 0xe9, 0x37, 0xeb, 0x72, 0x46, 0x6f, 0x93, 0xa8, + 0xfb, 0xe6, 0xe9, 0xf2, 0xae, 0x1d, 0xfc, 0x48, 0x84, 0xf2, 0x94, 0x69, 0x10, 0x57, 0x8d, 0x79, + 0xb5, 0x3a, 0xa0, 0x4e, 0x95, 0x11, 0xf8, 0x18, 0x37, 0x97, 0x6e, 0x91, 0x69, 0x70, 0xcb, 0xe2, + 0xe8, 0x96, 0xb3, 0xe4, 0xc8, 0xe2, 0x7f, 0x45, 0xeb, 0x96, 0xf7, 0xaa, 0xda, 0x89, 0x51, 0xc3, + 0xfb, 0x52, 0x1e, 0xfd, 0x97, 0x04, 0x0f, 0x5b, 0xe3, 0x15, 0xaa, 0x30, 0xaf, 0x03, 0x4d, 0x51, + 0xbd, 0x40, 0xd6, 0xdd, 0x89, 0x8b, 0xc1, 0x22, 0xe9, 0xa7, 0x2b, 0x9e, 0x19, 0x05, 0xaa, 0x0c, + 0xde, 0x4e, 0xd3, 0xdd, 0x91, 0xc2, 0xbc, 0x80, 0x7b, 0x8f, 0xc0, 0x19, 0xaf, 0x9d, 0x0f, 0xf0, + 0xd6, 0xa8, 0x0a, 0xc1, 0xbd, 0x19, 0x9f, 0x2d, 0x39, 0x87, 0xc5, 0xf9, 0x1f, 0xf2, 0x0f, 0x44, + 0xca, 0x3b, 0xbe, 0x4d, 0x67, 0xc6, 0xa0, 0xc6, 0xd8, 0x34, 0x57, 0xfe, 0xd9, 0x81, 0x07, 0x8c, + 0xde, 0xc4, 0xd7, 0xf9, 0x1e, 0xff, 0xdb, 0xd5, 0x62, 0x93, 0xae, 0xdb, 0x0f, 0x7f, 0x01, 0x37, + 0xdb, 0x6e, 0xf6, 0xae, 0x04, 0x00, 0x00, +}; +const StaticFile style_css PROGMEM = {(sizeof(style_css_content)/sizeof(style_css_content[0])), style_css_content}; + +static const uint8_t favicon_ico_content[] PROGMEM = { + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x99, 0x4b, 0x48, 0x15, 0x61, + 0x14, 0xc7, 0xcf, 0xc5, 0x17, 0x2e, 0x4a, 0x57, 0xe5, 0x63, 0xe1, 0x85, 0x42, 0x23, 0x8c, 0x8c, + 0x20, 0x4d, 0x45, 0xdb, 0x59, 0x14, 0x2e, 0x7a, 0xab, 0x68, 0x1b, 0x17, 0xae, 0x24, 0x41, 0xf1, + 0x41, 0xa0, 0x41, 0xa1, 0x11, 0x28, 0x1a, 0x2e, 0x12, 0x72, 0xe7, 0x03, 0x09, 0x74, 0x15, 0x54, + 0x1b, 0x97, 0xd9, 0x53, 0x23, 0x8a, 0x5a, 0x94, 0x25, 0x59, 0x91, 0x82, 0xa0, 0x81, 0x99, 0x39, + 0xfd, 0x8f, 0x73, 0x46, 0xbf, 0xc6, 0xb9, 0x73, 0x67, 0xee, 0x9d, 0xab, 0x41, 0x1e, 0xf8, 0x71, + 0xe7, 0x7e, 0xe7, 0xf1, 0x9f, 0xb9, 0xdf, 0x7c, 0x8f, 0x99, 0x4b, 0xe4, 0xa3, 0x28, 0x4a, 0x4c, + 0x24, 0x7c, 0xfa, 0xa9, 0x2a, 0x9a, 0xe8, 0x08, 0x11, 0x25, 0x25, 0xe9, 0xdf, 0xdb, 0xe2, 0x89, + 0x7a, 0xd1, 0xe6, 0xf7, 0xeb, 0xdf, 0x07, 0x11, 0x97, 0xbe, 0x93, 0x68, 0x1f, 0x62, 0x70, 0x88, + 0x16, 0xbd, 0x7d, 0xd5, 0x10, 0x37, 0x43, 0x3a, 0x0e, 0x2c, 0x06, 0xdc, 0x16, 0x62, 0x9c, 0xa5, + 0xfc, 0x65, 0xb1, 0x60, 0x58, 0x88, 0x75, 0x99, 0x9b, 0x06, 0x2a, 0xc1, 0x1b, 0xa1, 0x52, 0xda, + 0x82, 0x19, 0xae, 0x90, 0xca, 0xc0, 0x04, 0x58, 0x02, 0x9a, 0xb0, 0x24, 0x6d, 0xa5, 0x12, 0x13, + 0xc8, 0xd8, 0x3f, 0xab, 0xe4, 0x99, 0x99, 0x95, 0xfa, 0x56, 0xe6, 0x17, 0x8d, 0x40, 0xb9, 0x06, + 0x13, 0x12, 0x6b, 0xb6, 0x4b, 0xa6, 0x73, 0x0e, 0xc4, 0x92, 0xc4, 0x9a, 0xed, 0xba, 0x83, 0x5c, + 0x83, 0x6b, 0x16, 0xf9, 0x1d, 0x2e, 0xf2, 0x3b, 0x2c, 0xf2, 0xdb, 0x5d, 0xe4, 0xb7, 0x9b, 0x72, + 0xf9, 0xfe, 0xea, 0x76, 0x91, 0xcf, 0xb1, 0x6a, 0x3f, 0x9e, 0x03, 0x5f, 0x5c, 0xe4, 0x73, 0xec, + 0x59, 0x25, 0xbf, 0x00, 0xdc, 0x02, 0x5d, 0xe0, 0x2e, 0x58, 0x00, 0xf7, 0xe5, 0x7b, 0x97, 0x1c, + 0x2f, 0x88, 0xaf, 0x4b, 0x62, 0x0b, 0x4c, 0xd7, 0xe0, 0x93, 0xcf, 0x7c, 0xf0, 0x99, 0xf4, 0x7b, + 0xc9, 0xb0, 0x52, 0x69, 0xcb, 0x37, 0xc5, 0x5a, 0x59, 0x1e, 0x98, 0x06, 0x25, 0x4a, 0x5b, 0x89, + 0xb4, 0xe5, 0xd9, 0xe4, 0x19, 0x96, 0x09, 0x9e, 0x83, 0x22, 0xa5, 0xad, 0x48, 0xda, 0x32, 0xcd, + 0xc1, 0x8b, 0x18, 0x55, 0x93, 0x71, 0x44, 0xa3, 0x51, 0x44, 0x2d, 0x3e, 0x1d, 0xa7, 0xc6, 0x79, + 0x3c, 0xcf, 0x60, 0x2a, 0xa2, 0x2c, 0x52, 0xe6, 0x99, 0x78, 0x57, 0xf3, 0x8c, 0xd9, 0x52, 0xc1, + 0x43, 0xf0, 0x40, 0x8e, 0xbd, 0xb6, 0x1c, 0xf0, 0x5e, 0xc8, 0x89, 0x40, 0xfd, 0x6c, 0xf0, 0x41, + 0xc8, 0xf6, 0xb0, 0x2e, 0x8f, 0x91, 0xbd, 0xa0, 0x0a, 0x7c, 0x15, 0xf8, 0x78, 0x0f, 0xd9, 0xcf, + 0x69, 0xc1, 0x8c, 0x7b, 0x9c, 0xbb, 0xaf, 0x0d, 0x3c, 0x05, 0xf3, 0xb4, 0x3e, 0x4e, 0xe6, 0xa5, + 0xad, 0x15, 0x1c, 0x24, 0xfb, 0xfb, 0xd6, 0xca, 0xf8, 0xbc, 0x78, 0x6c, 0x3a, 0x99, 0x17, 0xc7, + 0x25, 0xd6, 0xe9, 0xb5, 0xf0, 0xb9, 0x9c, 0x27, 0x7d, 0x5c, 0x38, 0x1d, 0xf7, 0xd3, 0x92, 0xe3, + 0xe4, 0x3a, 0xf8, 0x37, 0x79, 0xe1, 0xa2, 0xb6, 0x7a, 0x1d, 0x59, 0x41, 0x6a, 0x73, 0x5f, 0xb6, + 0x85, 0x50, 0xdb, 0xe0, 0x06, 0xd9, 0xaf, 0xc9, 0xe9, 0xa4, 0xf7, 0x5b, 0xa8, 0xf5, 0x9f, 0x48, + 0x8d, 0x40, 0x76, 0x0c, 0xfc, 0x08, 0xa3, 0x3e, 0xcf, 0xa5, 0x85, 0x36, 0xf5, 0xcf, 0x84, 0x51, + 0xdb, 0xe0, 0xb4, 0x4d, 0xfd, 0x72, 0x0f, 0xea, 0x97, 0xdb, 0xd4, 0xaf, 0xf0, 0xa0, 0x7e, 0xc5, + 0x16, 0xd6, 0x2f, 0xf3, 0xa0, 0x7e, 0xa0, 0x7d, 0x12, 0xcf, 0x57, 0x9d, 0x1e, 0xd4, 0xef, 0x94, + 0x5a, 0xaa, 0x61, 0x15, 0xa3, 0x5e, 0xf0, 0xd3, 0x83, 0xfa, 0x5c, 0xe3, 0x8e, 0xd4, 0x34, 0x8c, + 0xe7, 0x8d, 0x26, 0xf0, 0x58, 0x18, 0x03, 0xcf, 0x48, 0x5f, 0xea, 0x38, 0x67, 0x4a, 0xf1, 0x19, + 0x4c, 0x89, 0x6f, 0x46, 0x62, 0xc7, 0x14, 0x5f, 0x13, 0x6d, 0x9c, 0x8b, 0x78, 0x5c, 0xef, 0x06, + 0x29, 0xa4, 0x2f, 0xad, 0xbc, 0xc4, 0x8e, 0x80, 0x15, 0xd2, 0xe7, 0xe1, 0x64, 0xf1, 0xa5, 0xc8, + 0x71, 0xab, 0xf8, 0x46, 0x68, 0x7d, 0x39, 0x4e, 0x91, 0x1a, 0x4e, 0xf6, 0xed, 0x09, 0xa0, 0x5f, + 0x6a, 0x34, 0x58, 0xf8, 0x1b, 0xc4, 0xd7, 0x2f, 0xb1, 0x6e, 0x8d, 0x1f, 0x43, 0x06, 0xa4, 0x46, + 0xa3, 0x85, 0xbf, 0x51, 0x7c, 0x03, 0x12, 0xeb, 0xd6, 0xf8, 0x9c, 0xfa, 0xa4, 0x46, 0xbd, 0x85, + 0xbf, 0x5e, 0x7c, 0x7d, 0x14, 0xda, 0xf9, 0x73, 0xff, 0x5f, 0x01, 0x9f, 0xc0, 0x49, 0x0b, 0x3f, + 0xb7, 0x4d, 0x49, 0x4c, 0x94, 0x85, 0x7f, 0xd5, 0xb4, 0xab, 0xda, 0x2a, 0x2b, 0xd4, 0x4c, 0x8b, + 0x78, 0x94, 0x99, 0xc3, 0xa9, 0x4c, 0x52, 0xdc, 0x2a, 0xa3, 0x48, 0x63, 0x5a, 0x70, 0x2b, 0x04, + 0xc2, 0x6c, 0xdc, 0xb6, 0x98, 0x94, 0xb6, 0x06, 0x77, 0xbc, 0x9f, 0xf4, 0x89, 0x79, 0x6d, 0x1f, + 0x96, 0xb8, 0x71, 0x1f, 0x76, 0xea, 0x72, 0x51, 0x48, 0x48, 0xf9, 0x77, 0xe0, 0x2d, 0x48, 0x0b, + 0xb5, 0x4e, 0x18, 0xfa, 0xd5, 0xb4, 0x3e, 0x36, 0xab, 0xb7, 0x40, 0xbf, 0x56, 0xd1, 0xaf, 0xfd, + 0x0f, 0xf5, 0xeb, 0x14, 0xfd, 0xba, 0x4d, 0xd2, 0xe4, 0xdb, 0x9a, 0x9f, 0x35, 0x5f, 0x82, 0xdf, + 0x8a, 0x3e, 0x1f, 0xf3, 0x9e, 0x92, 0x9f, 0x23, 0x33, 0x22, 0xa0, 0xcb, 0x73, 0xe7, 0x10, 0xe9, + 0xf3, 0x46, 0xb0, 0x75, 0x82, 0x63, 0x06, 0xc1, 0x2e, 0x8f, 0xb4, 0xf3, 0x68, 0x7d, 0x3d, 0x71, + 0xc3, 0x77, 0x90, 0x1b, 0xa6, 0x76, 0x21, 0xe9, 0x7b, 0xb3, 0x50, 0xd7, 0x4c, 0xce, 0x2d, 0x08, + 0xe3, 0x37, 0x77, 0xb3, 0x97, 0x0f, 0xc4, 0x37, 0x90, 0x1c, 0x82, 0xfe, 0x90, 0x07, 0xda, 0x06, + 0x83, 0x2e, 0xb5, 0xf7, 0x9b, 0xee, 0xef, 0x70, 0xe1, 0x5a, 0x07, 0x5c, 0xe8, 0xbb, 0x79, 0x77, + 0xe3, 0x94, 0x9b, 0x2e, 0xf4, 0x5f, 0x47, 0x40, 0xff, 0x95, 0x43, 0x6d, 0xde, 0x23, 0x39, 0x19, + 0xe7, 0xa1, 0xf4, 0x41, 0xb4, 0x03, 0xfd, 0xd4, 0x08, 0x68, 0x1b, 0xa4, 0x38, 0xd0, 0xcf, 0x88, + 0xa0, 0x7e, 0xd0, 0xb9, 0x79, 0x5b, 0x7f, 0x5b, 0x7f, 0xab, 0xf4, 0x61, 0x17, 0xc1, 0xbd, 0x08, + 0xea, 0x73, 0xed, 0x0b, 0x01, 0xb4, 0xe3, 0xc1, 0xaf, 0x08, 0x6a, 0x1b, 0xf0, 0xff, 0x05, 0xf1, + 0x16, 0xfa, 0xfc, 0x50, 0x31, 0xbe, 0x09, 0xfa, 0xfc, 0x0e, 0xcd, 0x67, 0xf3, 0x1b, 0x1c, 0x02, + 0x87, 0x4d, 0xd4, 0x28, 0xf9, 0xc3, 0x16, 0x7e, 0x83, 0x61, 0x25, 0xae, 0xc6, 0xc2, 0x7f, 0xc8, + 0xea, 0xda, 0x1d, 0xdc, 0x93, 0xc5, 0x4a, 0xdd, 0x1e, 0x9b, 0xb8, 0x1e, 0x25, 0xae, 0xd8, 0xad, + 0xce, 0xb6, 0xfe, 0x3f, 0xab, 0x7f, 0x42, 0xa9, 0xdb, 0x6d, 0x13, 0xa7, 0xfe, 0x67, 0x75, 0xdc, + 0x43, 0xfd, 0x1d, 0xa4, 0xbf, 0x27, 0xe2, 0x77, 0xfc, 0x47, 0x6d, 0xe2, 0x72, 0x49, 0xdf, 0xef, + 0x3e, 0xe2, 0x1c, 0x27, 0xb5, 0xb5, 0xe5, 0x42, 0x6d, 0x03, 0x93, 0x71, 0x9a, 0xd6, 0xe2, 0xd3, + 0x70, 0x11, 0x1a, 0x36, 0x42, 0xcd, 0xcb, 0x78, 0x0e, 0x60, 0x16, 0xf1, 0x9c, 0x6f, 0x30, 0x47, + 0x94, 0x60, 0xc7, 0x24, 0x51, 0xdc, 0x28, 0x51, 0x14, 0xc3, 0xc7, 0x5a, 0xeb, 0x47, 0xcd, 0x8a, + 0x3f, 0x05, 0x2f, 0x43, 0xb9, 0xce, 0x1e, 0x00, 0x00, +}; +const StaticFile favicon_ico PROGMEM = {(sizeof(favicon_ico_content)/sizeof(favicon_ico_content[0])), favicon_ico_content}; + +} diff --git a/include/pio/libs/static/homekit/static.h b/include/pio/libs/static/homekit/static.h new file mode 100644 index 0000000..c2617e9 --- /dev/null +++ b/include/pio/libs/static/homekit/static.h @@ -0,0 +1,25 @@ +/** + * This file is autogenerated with make_static.sh script + */ + +#ifndef COMMON_HOMEKIT_STATIC_H +#define COMMON_HOMEKIT_STATIC_H + +#include + +namespace homekit::files { + +typedef struct { + size_t size; + const uint8_t* content; +} StaticFile; + +extern const StaticFile index_html; +extern const StaticFile app_js; +extern const StaticFile md5_js; +extern const StaticFile style_css; +extern const StaticFile favicon_ico; + +} + +#endif //COMMON_HOMEKIT_STATIC_H \ No newline at end of file diff --git a/include/pio/libs/static/library.json b/include/pio/libs/static/library.json new file mode 100644 index 0000000..bc650d7 --- /dev/null +++ b/include/pio/libs/static/library.json @@ -0,0 +1,8 @@ +{ + "name": "homekit_static", + "version": "1.0.1", + "build": { + "flags": "-I../../include" + } +} + diff --git a/include/pio/libs/temphum/homekit/temphum.cpp b/include/pio/libs/temphum/homekit/temphum.cpp new file mode 100644 index 0000000..e69b3a5 --- /dev/null +++ b/include/pio/libs/temphum/homekit/temphum.cpp @@ -0,0 +1,89 @@ +#ifndef CONFIG_TARGET_ESP01 +#include +#endif +#include +#include "temphum.h" + +namespace homekit::temphum { + +void Sensor::setup() const { +#ifndef CONFIG_TARGET_ESP01 + pinMode(CONFIG_SDA_GPIO, OUTPUT); + pinMode(CONFIG_SCL_GPIO, OUTPUT); + + Wire.begin(CONFIG_SDA_GPIO, CONFIG_SCL_GPIO); +#else + Wire.begin(); +#endif +} + +void Sensor::writeCommand(int reg) const { + Wire.beginTransmission(dev_addr); + Wire.write(reg); + Wire.endTransmission(); + delay(500); // wait for the measurement to be ready +} + +SensorData Si7021::read() { + uint8_t error = 0; + writeCommand(0xf3); // command to measure temperature + Wire.requestFrom(dev_addr, 2); + if (Wire.available() < 2) { + PRINTLN("Si7021: 0xf3: could not read 2 bytes"); + error = 1; + } + uint16_t temp_raw = Wire.read() << 8 | Wire.read(); + double temperature = ((175.72 * temp_raw) / 65536.0) - 46.85; + + writeCommand(0xf5); // command to measure humidity + Wire.requestFrom(dev_addr, 2); + if (Wire.available() < 2) { + PRINTLN("Si7021: 0xf5: could not read 2 bytes"); + error = 1; + } + uint16_t hum_raw = Wire.read() << 8 | Wire.read(); + double humidity = ((125.0 * hum_raw) / 65536.0) - 6.0; + + return { + .error = error, + .temp = temperature, + .rh = humidity + }; +} + +SensorData DHT12::read() { + SensorData sd; + byte raw[5]; + sd.error = 1; + + writeCommand(0); + Wire.requestFrom(dev_addr, 5); + + if (Wire.available() < 5) { + PRINTLN("DHT12: could not read 5 bytes"); + goto end; + } + + // Parse the received data + for (uint8_t i = 0; i < 5; i++) + raw[i] = Wire.read(); + + if (((raw[0] + raw[1] + raw[2] + raw[3]) & 0xff) != raw[4]) { + PRINTLN("DHT12: checksum error"); + goto end; + } + + // Calculate temperature and humidity values + sd.temp = raw[2] + (raw[3] & 0x7f) * 0.1; + if (raw[3] & 0x80) + sd.temp *= -1; + + sd.rh = raw[0] + raw[1] * 0.1; + + sd.error = 0; + +end: + return sd; +} + +} diff --git a/include/pio/libs/temphum/homekit/temphum.h b/include/pio/libs/temphum/homekit/temphum.h new file mode 100644 index 0000000..1952ce0 --- /dev/null +++ b/include/pio/libs/temphum/homekit/temphum.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +namespace homekit::temphum { + +struct SensorData { + uint8_t error = 0; + double temp = 0; // celsius + double rh = 0; // relative humidity percentage +}; + + +class Sensor { +protected: + int dev_addr; +public: + explicit Sensor(int dev) : dev_addr(dev) {} + void setup() const; + void writeCommand(int reg) const; + virtual SensorData read() = 0; +}; + + +class Si7021 : public Sensor { +public: + SensorData read() override; + Si7021() : Sensor(0x40) {} +}; + + +class DHT12 : public Sensor { +public: + SensorData read() override; + DHT12() : Sensor(0x5c) {} +}; + +} \ No newline at end of file diff --git a/include/pio/libs/temphum/library.json b/include/pio/libs/temphum/library.json new file mode 100644 index 0000000..329b7ca --- /dev/null +++ b/include/pio/libs/temphum/library.json @@ -0,0 +1,8 @@ +{ + "name": "homekit_temphum", + "version": "1.0.3", + "build": { + "flags": "-I../../include" + } +} + diff --git a/include/pio/libs/wifi/homekit/wifi.cpp b/include/pio/libs/wifi/homekit/wifi.cpp new file mode 100644 index 0000000..3060dd6 --- /dev/null +++ b/include/pio/libs/wifi/homekit/wifi.cpp @@ -0,0 +1,47 @@ +#include +#include "wifi.h" +#include +#include + +namespace homekit::wifi { + +using namespace homekit; +using homekit::config::ConfigData; + +const char NODE_ID[] = CONFIG_NODE_ID; +const char AP_SSID[] = CONFIG_WIFI_AP_SSID; +const char STA_SSID[] = CONFIG_WIFI_STA_SSID; +const char STA_PSK[] = CONFIG_WIFI_STA_PSK; + +void getConfig(ConfigData &cfg, const char** ssid, const char** psk, const char** hostname) { + if (cfg.flags.wifi_configured) { + *ssid = cfg.wifi_ssid; + *psk = cfg.wifi_psk; + *hostname = cfg.node_id; + } else { + *ssid = STA_SSID; + *psk = STA_PSK; + *hostname = NODE_ID; + } +} + +std::shared_ptr> scan() { + if (WiFi.getMode() != WIFI_STA) { + PRINTLN("wifi::scan: switching mode to STA"); + WiFi.mode(WIFI_STA); + } + + std::shared_ptr> results(new std::list); + int count = WiFi.scanNetworks(); + for (int i = 0; i < count; i++) { + results->push_back(ScanResult { + .rssi = WiFi.RSSI(i), + .ssid = WiFi.SSID(i) + }); + } + + WiFi.scanDelete(); + return results; +} + +} \ No newline at end of file diff --git a/include/pio/libs/wifi/homekit/wifi.h b/include/pio/libs/wifi/homekit/wifi.h new file mode 100644 index 0000000..3fe77cb --- /dev/null +++ b/include/pio/libs/wifi/homekit/wifi.h @@ -0,0 +1,40 @@ +#ifndef HOMEKIT_TEPMHUM_WIFI_H +#define HOMEKIT_TEPMHUM_WIFI_H + +#include +#include +#include + +#include + +namespace homekit::wifi { + +using homekit::config::ConfigData; + +struct ScanResult { + int rssi; + String ssid; +}; + +void getConfig(ConfigData& cfg, const char** ssid, const char** psk, const char** hostname); + +std::shared_ptr> scan(); + +inline uint32_t getIPAsInteger() { + if (!WiFi.isConnected()) + return 0; + return WiFi.localIP().v4(); +} + +inline int8_t getRSSI() { + return WiFi.RSSI(); +} + +extern const char AP_SSID[]; +extern const char STA_SSID[]; +extern const char STA_PSK[]; +extern const char NODE_ID[]; + +} + +#endif //HOMEKIT_TEPMHUM_WIFI_H \ No newline at end of file diff --git a/include/pio/libs/wifi/library.json b/include/pio/libs/wifi/library.json new file mode 100644 index 0000000..c7faecd --- /dev/null +++ b/include/pio/libs/wifi/library.json @@ -0,0 +1,8 @@ +{ + "name": "homekit_wifi", + "version": "1.0.1", + "build": { + "flags": "-I../../include" + } +} + diff --git a/include/pio/make_static.sh b/include/pio/make_static.sh new file mode 100755 index 0000000..d207e57 --- /dev/null +++ b/include/pio/make_static.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +#set -x +#set -e + +COMMON_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)" +PROJECT_DIR="$(pwd)" + +fw_version="$(cat "$PROJECT_DIR/src/config.def.h" | grep "^#define FW_VERSION" | awk '{print $3}')" +header="$PROJECT_DIR/src/static.h" +source="$PROJECT_DIR/src/static.cpp" + +[ -f "$header" ] && rm "$header" +[ -f "$source" ] && rm "$source" + +is_minifyable() { + local ext="$1" + [ "$ext" = "html" ] || [ "$ext" = "css" ] || [ "$ext" = "js" ] +} + +minify() { + local ext="$1" + local bin="$(realpath "$COMMON_DIR"/../../tools/minify.js)" + "$bin" --type "$ext" +} + +# .h header +cat <> "$header" +/** + * This file is autogenerated with make_static.sh script + */ + +#pragma once + +#include + +namespace homekit::files { + +typedef struct { + size_t size; + const uint8_t* content; +} StaticFile; + +EOF + +cat <> "$source" +/** + * This file is autogenerated with make_static.sh script + */ + +#include "static.h" + +namespace homekit::files { + +EOF + +# loop over files +for ext in html js css ico; do + for f in "$COMMON_DIR"/static/*.$ext; do + filename="$(basename "$f")" + echo "processing ${filename}..." + filename="${filename/./_}" + + # write .h + echo "extern const StaticFile $filename;" >> "$header" + + # write .c + { + echo "static const uint8_t ${filename}_content[] PROGMEM = {" + + cat "$f" | + ( [ "$ext" = "html" ] && sed "s/{version}/$fw_version/" || cat ) | + ( is_minifyable "$ext" && minify "$ext" || cat ) | + gzip | + xxd -ps -c 16 | + sed 's/.\{2\}/0x&, /g' | + sed 's/^/ /' | + sed 's/[ \t]*$//' + + echo "};" + echo "const StaticFile $filename PROGMEM = {(sizeof(${filename}_content)/sizeof(${filename}_content[0])), ${filename}_content};" + echo "" + } >> "$source" + done +done + +# end of homekit::files +( echo ""; echo "}" ) >> "$header" +echo "}" >> "$source" diff --git a/include/pio/static/app.js b/include/pio/static/app.js new file mode 100644 index 0000000..299230c --- /dev/null +++ b/include/pio/static/app.js @@ -0,0 +1,246 @@ +function isObject(o) { + return Object.prototype.toString.call(o) === '[object Object]'; +} + +function ge(id) { + return document.getElementById(id) +} + +function hide(el) { + el.style.display = 'none' +} + +function cancelEvent(evt) { + if (evt.preventDefault) evt.preventDefault(); + if (evt.stopPropagation) evt.stopPropagation(); + + evt.cancelBubble = true; + evt.returnValue = false; + + return false; +} + +function errorText(e) { + return e instanceof Error ? e.message : e+'' +} + +(function() { + function request(method, url, data, callback) { + data = data || null; + + if (typeof callback != 'function') { + throw new Error('callback must be a function'); + } + + if (!url) + throw new Error('no url specified'); + + switch (method) { + case 'GET': + if (isObject(data)) { + for (var k in data) { + if (data.hasOwnProperty(k)) + url += (url.indexOf('?') === -1 ? '?' : '&')+encodeURIComponent(k)+'='+encodeURIComponent(data[k]) + } + } + break; + + case 'POST': + if (isObject(data)) { + var sdata = []; + for (var k in data) { + if (data.hasOwnProperty(k)) + sdata.push(encodeURIComponent(k)+'='+encodeURIComponent(data[k])); + } + data = sdata.join('&'); + } + break; + } + + var xhr = new XMLHttpRequest(); + xhr.open(method, url); + + if (method === 'POST') + xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); + + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if ('status' in xhr && !/^2|1223/.test(xhr.status)) + throw new Error('http code '+xhr.status) + callback(null, JSON.parse(xhr.responseText)); + } + }; + xhr.onerror = function(e) { + callback(e, null); + }; + + xhr.send(method === 'GET' ? null : data); + return xhr; + } + + window.ajax = { + get: request.bind(request, 'GET'), + post: request.bind(request, 'POST') + } +})(); + + +function lock(el) { + el.setAttribute('disabled', 'disabled'); +} + +function unlock(el) { + el.removeAttribute('disabled'); +} + +function initNetworkSettings() { + function setupField(el, value) { + if (value !== null) + el.value = value; + unlock(el); + } + + var doneRequestsCount = 0; + function onRequestDone() { + doneRequestsCount++; + if (doneRequestsCount === 2) { + hide(ge('loading_label')) + } + } + + var form = document.forms.network_settings; + form.addEventListener('submit', function(e) { + if (!form.hid.value.trim()) { + alert('Введите home id'); + return cancelEvent(e); + } + + if (form.psk.value.length < 8) { + alert('Неверный пароль (минимальная длина - 8 символов)'); + return cancelEvent(e); + } + + if (form.ssid.selectedIndex === -1) { + alert('Не выбрана точка доступа'); + return cancelEvent(e); + } + + lock(form.submit) + }) + form.show_psk.addEventListener('change', function(e) { + form.psk.setAttribute('type', e.target.checked ? 'text' : 'password'); + }); + form.ssid.addEventListener('change', function(e) { + var i = e.target.selectedIndex; + if (i !== -1) { + var opt = e.target.options[i]; + if (opt) + form.psk.value = ''; + } + }); + + ajax.get('/status', {}, function(error, response) { + try { + if (error) + throw error; + + setupField(form.hid, response.node_id || null); + setupField(form.psk, null); + setupField(form.submit, null); + + onRequestDone(); + } catch (error) { + alert(errorText(error)); + } + }); + + ajax.get('/scan', {}, function(error, response) { + try { + if (error) + throw error; + + form.ssid.innerHTML = ''; + for (var i = 0; i < response.list.length; i++) { + var ssid = response.list[i][0]; + var rssi = response.list[i][1]; + form.ssid.append(new Option(ssid + ' (' + rssi + ' dBm)', ssid)); + } + unlock(form.ssid); + + onRequestDone(); + } catch (error) { + alert(errorText(error)); + } + }); +} + +function initUpdateForm() { + var form = document.forms.update_settings; + form.addEventListener('submit', function(e) { + cancelEvent(e); + if (!form.file.files.length) { + alert('Файл обновления не выбран'); + return false; + } + + lock(form.submit); + + var xhr = new XMLHttpRequest(); + var fd = new FormData(); + fd.append('file', form.file.files[0]); + + xhr.upload.addEventListener('progress', function (e) { + var total = form.file.files[0].size; + var progress; + if (e.loaded < total) { + progress = Math.round(e.loaded / total * 100).toFixed(2); + } else { + progress = 100; + } + form.submit.innerHTML = progress + '%'; + }); + xhr.onreadystatechange = function() { + var errorMessage = 'Ошибка обновления'; + var successMessage = 'Обновление завершено, устройство перезагружается'; + if (xhr.readyState === 4) { + try { + var response = JSON.parse(xhr.responseText); + if (response.result === 1) { + alert(successMessage); + } else { + alert(errorMessage); + } + } catch (e) { + alert(successMessage); + } + } + }; + xhr.onerror = function(e) { + alert(errorText(e)); + }; + + xhr.open('POST', e.target.action); + xhr.send(fd); + + return false; + }); + form.file.addEventListener('change', function(e) { + if (e.target.files.length) { + var reader = new FileReader(); + reader.onload = function() { + var hash = window.md5(reader.result); + form.setAttribute('action', '/update?md5='+hash); + unlock(form.submit); + }; + reader.onerror = function() { + alert('Ошибка чтения файла'); + }; + reader.readAsBinaryString(e.target.files[0]); + } + }); +} + +window.initApp = function() { + initNetworkSettings(); + initUpdateForm(); +} \ No newline at end of file diff --git a/include/pio/static/favicon.ico b/include/pio/static/favicon.ico new file mode 100644 index 0000000..6940e4f Binary files /dev/null and b/include/pio/static/favicon.ico differ diff --git a/include/pio/static/index.html b/include/pio/static/index.html new file mode 100644 index 0000000..d4a8040 --- /dev/null +++ b/include/pio/static/index.html @@ -0,0 +1,63 @@ + + + + + + Configuration + + + + + + +
Settings (loading...)
+
+
+
WiFi SSID
+
+ +
+ +
WiFi Password
+
+ +
+ +
+
+ +
Home ID
+
+ +
+ + +
+
+ +
Update firmware (.bin)
+
+
+
+ +
+ +
+
+ +
Reset settings
+
+
+ +
+
+ +
Info
+
+ ESP8266-based relayctl, firmware v{version}
+ Part of homekit by Evgeny Zinoviev © 2022 +
+ + \ No newline at end of file diff --git a/include/pio/static/md5.js b/include/pio/static/md5.js new file mode 100644 index 0000000..b707a4e --- /dev/null +++ b/include/pio/static/md5.js @@ -0,0 +1,615 @@ +/** + * [js-md5]{@link https://github.com/emn178/js-md5} + * + * @namespace md5 + * @version 0.7.3 + * @author Chen, Yi-Cyuan [emn178@gmail.com] + * @copyright Chen, Yi-Cyuan 2014-2017 + * @license MIT + */ +(function () { + 'use strict'; + + var ERROR = 'input is invalid type'; + var ARRAY_BUFFER = typeof window.ArrayBuffer !== 'undefined'; + var HEX_CHARS = '0123456789abcdef'.split(''); + var EXTRA = [128, 32768, 8388608, -2147483648]; + var SHIFT = [0, 8, 16, 24]; + var OUTPUT_TYPES = ['hex', 'array', 'digest', 'buffer', 'arrayBuffer', 'base64']; + var BASE64_ENCODE_CHAR = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split(''); + + var blocks = [], buffer8; + if (ARRAY_BUFFER) { + var buffer = new ArrayBuffer(68); + buffer8 = new Uint8Array(buffer); + blocks = new Uint32Array(buffer); + } + + if (!Array.isArray) { + Array.isArray = function (obj) { + return Object.prototype.toString.call(obj) === '[object Array]'; + }; + } + + if (ARRAY_BUFFER && !ArrayBuffer.isView) { + ArrayBuffer.isView = function (obj) { + return typeof obj === 'object' && obj.buffer && obj.buffer.constructor === ArrayBuffer; + }; + } + + /** + * @method hex + * @memberof md5 + * @description Output hash as hex string + * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash + * @returns {String} Hex string + * @example + * md5.hex('The quick brown fox jumps over the lazy dog'); + * // equal to + * md5('The quick brown fox jumps over the lazy dog'); + */ + /** + * @method digest + * @memberof md5 + * @description Output hash as bytes array + * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash + * @returns {Array} Bytes array + * @example + * md5.digest('The quick brown fox jumps over the lazy dog'); + */ + /** + * @method array + * @memberof md5 + * @description Output hash as bytes array + * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash + * @returns {Array} Bytes array + * @example + * md5.array('The quick brown fox jumps over the lazy dog'); + */ + /** + * @method arrayBuffer + * @memberof md5 + * @description Output hash as ArrayBuffer + * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash + * @returns {ArrayBuffer} ArrayBuffer + * @example + * md5.arrayBuffer('The quick brown fox jumps over the lazy dog'); + */ + /** + * @method buffer + * @deprecated This maybe confuse with Buffer in node.js. Please use arrayBuffer instead. + * @memberof md5 + * @description Output hash as ArrayBuffer + * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash + * @returns {ArrayBuffer} ArrayBuffer + * @example + * md5.buffer('The quick brown fox jumps over the lazy dog'); + */ + /** + * @method base64 + * @memberof md5 + * @description Output hash as base64 string + * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash + * @returns {String} base64 string + * @example + * md5.base64('The quick brown fox jumps over the lazy dog'); + */ + var createOutputMethod = function (outputType) { + return function (message) { + return new Md5(true).update(message)[outputType](); + }; + }; + + /** + * @method create + * @memberof md5 + * @description Create Md5 object + * @returns {Md5} Md5 object. + * @example + * var hash = md5.create(); + */ + /** + * @method update + * @memberof md5 + * @description Create and update Md5 object + * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash + * @returns {Md5} Md5 object. + * @example + * var hash = md5.update('The quick brown fox jumps over the lazy dog'); + * // equal to + * var hash = md5.create(); + * hash.update('The quick brown fox jumps over the lazy dog'); + */ + var createMethod = function () { + var method = createOutputMethod('hex'); + method.create = function () { + return new Md5(); + }; + method.update = function (message) { + return method.create().update(message); + }; + for (var i = 0; i < OUTPUT_TYPES.length; ++i) { + var type = OUTPUT_TYPES[i]; + method[type] = createOutputMethod(type); + } + return method; + }; + + /** + * Md5 class + * @class Md5 + * @description This is internal class. + * @see {@link md5.create} + */ + function Md5(sharedMemory) { + if (sharedMemory) { + blocks[0] = blocks[16] = blocks[1] = blocks[2] = blocks[3] = + blocks[4] = blocks[5] = blocks[6] = blocks[7] = + blocks[8] = blocks[9] = blocks[10] = blocks[11] = + blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; + this.blocks = blocks; + this.buffer8 = buffer8; + } else { + if (ARRAY_BUFFER) { + var buffer = new ArrayBuffer(68); + this.buffer8 = new Uint8Array(buffer); + this.blocks = new Uint32Array(buffer); + } else { + this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + } + } + this.h0 = this.h1 = this.h2 = this.h3 = this.start = this.bytes = this.hBytes = 0; + this.finalized = this.hashed = false; + this.first = true; + } + + /** + * @method update + * @memberof Md5 + * @instance + * @description Update hash + * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash + * @returns {Md5} Md5 object. + * @see {@link md5.update} + */ + Md5.prototype.update = function (message) { + if (this.finalized) { + return; + } + + var notString, type = typeof message; + if (type !== 'string') { + if (type === 'object') { + if (message === null) { + throw ERROR; + } else if (ARRAY_BUFFER && message.constructor === ArrayBuffer) { + message = new Uint8Array(message); + } else if (!Array.isArray(message)) { + if (!ARRAY_BUFFER || !ArrayBuffer.isView(message)) { + throw ERROR; + } + } + } else { + throw ERROR; + } + notString = true; + } + var code, index = 0, i, length = message.length, blocks = this.blocks; + var buffer8 = this.buffer8; + + while (index < length) { + if (this.hashed) { + this.hashed = false; + blocks[0] = blocks[16]; + blocks[16] = blocks[1] = blocks[2] = blocks[3] = + blocks[4] = blocks[5] = blocks[6] = blocks[7] = + blocks[8] = blocks[9] = blocks[10] = blocks[11] = + blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; + } + + if (notString) { + if (ARRAY_BUFFER) { + for (i = this.start; index < length && i < 64; ++index) { + buffer8[i++] = message[index]; + } + } else { + for (i = this.start; index < length && i < 64; ++index) { + blocks[i >> 2] |= message[index] << SHIFT[i++ & 3]; + } + } + } else { + if (ARRAY_BUFFER) { + for (i = this.start; index < length && i < 64; ++index) { + code = message.charCodeAt(index); + if (code < 0x80) { + buffer8[i++] = code; + } else if (code < 0x800) { + buffer8[i++] = 0xc0 | (code >> 6); + buffer8[i++] = 0x80 | (code & 0x3f); + } else if (code < 0xd800 || code >= 0xe000) { + buffer8[i++] = 0xe0 | (code >> 12); + buffer8[i++] = 0x80 | ((code >> 6) & 0x3f); + buffer8[i++] = 0x80 | (code & 0x3f); + } else { + code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); + buffer8[i++] = 0xf0 | (code >> 18); + buffer8[i++] = 0x80 | ((code >> 12) & 0x3f); + buffer8[i++] = 0x80 | ((code >> 6) & 0x3f); + buffer8[i++] = 0x80 | (code & 0x3f); + } + } + } else { + for (i = this.start; index < length && i < 64; ++index) { + code = message.charCodeAt(index); + if (code < 0x80) { + blocks[i >> 2] |= code << SHIFT[i++ & 3]; + } else if (code < 0x800) { + blocks[i >> 2] |= (0xc0 | (code >> 6)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } else if (code < 0xd800 || code >= 0xe000) { + blocks[i >> 2] |= (0xe0 | (code >> 12)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } else { + code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); + blocks[i >> 2] |= (0xf0 | (code >> 18)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | ((code >> 12) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } + } + } + } + this.lastByteIndex = i; + this.bytes += i - this.start; + if (i >= 64) { + this.start = i - 64; + this.hash(); + this.hashed = true; + } else { + this.start = i; + } + } + if (this.bytes > 4294967295) { + this.hBytes += this.bytes / 4294967296 << 0; + this.bytes = this.bytes % 4294967296; + } + return this; + }; + + Md5.prototype.finalize = function () { + if (this.finalized) { + return; + } + this.finalized = true; + var blocks = this.blocks, i = this.lastByteIndex; + blocks[i >> 2] |= EXTRA[i & 3]; + if (i >= 56) { + if (!this.hashed) { + this.hash(); + } + blocks[0] = blocks[16]; + blocks[16] = blocks[1] = blocks[2] = blocks[3] = + blocks[4] = blocks[5] = blocks[6] = blocks[7] = + blocks[8] = blocks[9] = blocks[10] = blocks[11] = + blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; + } + blocks[14] = this.bytes << 3; + blocks[15] = this.hBytes << 3 | this.bytes >>> 29; + this.hash(); + }; + + Md5.prototype.hash = function () { + var a, b, c, d, bc, da, blocks = this.blocks; + + if (this.first) { + a = blocks[0] - 680876937; + a = (a << 7 | a >>> 25) - 271733879 << 0; + d = (-1732584194 ^ a & 2004318071) + blocks[1] - 117830708; + d = (d << 12 | d >>> 20) + a << 0; + c = (-271733879 ^ (d & (a ^ -271733879))) + blocks[2] - 1126478375; + c = (c << 17 | c >>> 15) + d << 0; + b = (a ^ (c & (d ^ a))) + blocks[3] - 1316259209; + b = (b << 22 | b >>> 10) + c << 0; + } else { + a = this.h0; + b = this.h1; + c = this.h2; + d = this.h3; + a += (d ^ (b & (c ^ d))) + blocks[0] - 680876936; + a = (a << 7 | a >>> 25) + b << 0; + d += (c ^ (a & (b ^ c))) + blocks[1] - 389564586; + d = (d << 12 | d >>> 20) + a << 0; + c += (b ^ (d & (a ^ b))) + blocks[2] + 606105819; + c = (c << 17 | c >>> 15) + d << 0; + b += (a ^ (c & (d ^ a))) + blocks[3] - 1044525330; + b = (b << 22 | b >>> 10) + c << 0; + } + + a += (d ^ (b & (c ^ d))) + blocks[4] - 176418897; + a = (a << 7 | a >>> 25) + b << 0; + d += (c ^ (a & (b ^ c))) + blocks[5] + 1200080426; + d = (d << 12 | d >>> 20) + a << 0; + c += (b ^ (d & (a ^ b))) + blocks[6] - 1473231341; + c = (c << 17 | c >>> 15) + d << 0; + b += (a ^ (c & (d ^ a))) + blocks[7] - 45705983; + b = (b << 22 | b >>> 10) + c << 0; + a += (d ^ (b & (c ^ d))) + blocks[8] + 1770035416; + a = (a << 7 | a >>> 25) + b << 0; + d += (c ^ (a & (b ^ c))) + blocks[9] - 1958414417; + d = (d << 12 | d >>> 20) + a << 0; + c += (b ^ (d & (a ^ b))) + blocks[10] - 42063; + c = (c << 17 | c >>> 15) + d << 0; + b += (a ^ (c & (d ^ a))) + blocks[11] - 1990404162; + b = (b << 22 | b >>> 10) + c << 0; + a += (d ^ (b & (c ^ d))) + blocks[12] + 1804603682; + a = (a << 7 | a >>> 25) + b << 0; + d += (c ^ (a & (b ^ c))) + blocks[13] - 40341101; + d = (d << 12 | d >>> 20) + a << 0; + c += (b ^ (d & (a ^ b))) + blocks[14] - 1502002290; + c = (c << 17 | c >>> 15) + d << 0; + b += (a ^ (c & (d ^ a))) + blocks[15] + 1236535329; + b = (b << 22 | b >>> 10) + c << 0; + a += (c ^ (d & (b ^ c))) + blocks[1] - 165796510; + a = (a << 5 | a >>> 27) + b << 0; + d += (b ^ (c & (a ^ b))) + blocks[6] - 1069501632; + d = (d << 9 | d >>> 23) + a << 0; + c += (a ^ (b & (d ^ a))) + blocks[11] + 643717713; + c = (c << 14 | c >>> 18) + d << 0; + b += (d ^ (a & (c ^ d))) + blocks[0] - 373897302; + b = (b << 20 | b >>> 12) + c << 0; + a += (c ^ (d & (b ^ c))) + blocks[5] - 701558691; + a = (a << 5 | a >>> 27) + b << 0; + d += (b ^ (c & (a ^ b))) + blocks[10] + 38016083; + d = (d << 9 | d >>> 23) + a << 0; + c += (a ^ (b & (d ^ a))) + blocks[15] - 660478335; + c = (c << 14 | c >>> 18) + d << 0; + b += (d ^ (a & (c ^ d))) + blocks[4] - 405537848; + b = (b << 20 | b >>> 12) + c << 0; + a += (c ^ (d & (b ^ c))) + blocks[9] + 568446438; + a = (a << 5 | a >>> 27) + b << 0; + d += (b ^ (c & (a ^ b))) + blocks[14] - 1019803690; + d = (d << 9 | d >>> 23) + a << 0; + c += (a ^ (b & (d ^ a))) + blocks[3] - 187363961; + c = (c << 14 | c >>> 18) + d << 0; + b += (d ^ (a & (c ^ d))) + blocks[8] + 1163531501; + b = (b << 20 | b >>> 12) + c << 0; + a += (c ^ (d & (b ^ c))) + blocks[13] - 1444681467; + a = (a << 5 | a >>> 27) + b << 0; + d += (b ^ (c & (a ^ b))) + blocks[2] - 51403784; + d = (d << 9 | d >>> 23) + a << 0; + c += (a ^ (b & (d ^ a))) + blocks[7] + 1735328473; + c = (c << 14 | c >>> 18) + d << 0; + b += (d ^ (a & (c ^ d))) + blocks[12] - 1926607734; + b = (b << 20 | b >>> 12) + c << 0; + bc = b ^ c; + a += (bc ^ d) + blocks[5] - 378558; + a = (a << 4 | a >>> 28) + b << 0; + d += (bc ^ a) + blocks[8] - 2022574463; + d = (d << 11 | d >>> 21) + a << 0; + da = d ^ a; + c += (da ^ b) + blocks[11] + 1839030562; + c = (c << 16 | c >>> 16) + d << 0; + b += (da ^ c) + blocks[14] - 35309556; + b = (b << 23 | b >>> 9) + c << 0; + bc = b ^ c; + a += (bc ^ d) + blocks[1] - 1530992060; + a = (a << 4 | a >>> 28) + b << 0; + d += (bc ^ a) + blocks[4] + 1272893353; + d = (d << 11 | d >>> 21) + a << 0; + da = d ^ a; + c += (da ^ b) + blocks[7] - 155497632; + c = (c << 16 | c >>> 16) + d << 0; + b += (da ^ c) + blocks[10] - 1094730640; + b = (b << 23 | b >>> 9) + c << 0; + bc = b ^ c; + a += (bc ^ d) + blocks[13] + 681279174; + a = (a << 4 | a >>> 28) + b << 0; + d += (bc ^ a) + blocks[0] - 358537222; + d = (d << 11 | d >>> 21) + a << 0; + da = d ^ a; + c += (da ^ b) + blocks[3] - 722521979; + c = (c << 16 | c >>> 16) + d << 0; + b += (da ^ c) + blocks[6] + 76029189; + b = (b << 23 | b >>> 9) + c << 0; + bc = b ^ c; + a += (bc ^ d) + blocks[9] - 640364487; + a = (a << 4 | a >>> 28) + b << 0; + d += (bc ^ a) + blocks[12] - 421815835; + d = (d << 11 | d >>> 21) + a << 0; + da = d ^ a; + c += (da ^ b) + blocks[15] + 530742520; + c = (c << 16 | c >>> 16) + d << 0; + b += (da ^ c) + blocks[2] - 995338651; + b = (b << 23 | b >>> 9) + c << 0; + a += (c ^ (b | ~d)) + blocks[0] - 198630844; + a = (a << 6 | a >>> 26) + b << 0; + d += (b ^ (a | ~c)) + blocks[7] + 1126891415; + d = (d << 10 | d >>> 22) + a << 0; + c += (a ^ (d | ~b)) + blocks[14] - 1416354905; + c = (c << 15 | c >>> 17) + d << 0; + b += (d ^ (c | ~a)) + blocks[5] - 57434055; + b = (b << 21 | b >>> 11) + c << 0; + a += (c ^ (b | ~d)) + blocks[12] + 1700485571; + a = (a << 6 | a >>> 26) + b << 0; + d += (b ^ (a | ~c)) + blocks[3] - 1894986606; + d = (d << 10 | d >>> 22) + a << 0; + c += (a ^ (d | ~b)) + blocks[10] - 1051523; + c = (c << 15 | c >>> 17) + d << 0; + b += (d ^ (c | ~a)) + blocks[1] - 2054922799; + b = (b << 21 | b >>> 11) + c << 0; + a += (c ^ (b | ~d)) + blocks[8] + 1873313359; + a = (a << 6 | a >>> 26) + b << 0; + d += (b ^ (a | ~c)) + blocks[15] - 30611744; + d = (d << 10 | d >>> 22) + a << 0; + c += (a ^ (d | ~b)) + blocks[6] - 1560198380; + c = (c << 15 | c >>> 17) + d << 0; + b += (d ^ (c | ~a)) + blocks[13] + 1309151649; + b = (b << 21 | b >>> 11) + c << 0; + a += (c ^ (b | ~d)) + blocks[4] - 145523070; + a = (a << 6 | a >>> 26) + b << 0; + d += (b ^ (a | ~c)) + blocks[11] - 1120210379; + d = (d << 10 | d >>> 22) + a << 0; + c += (a ^ (d | ~b)) + blocks[2] + 718787259; + c = (c << 15 | c >>> 17) + d << 0; + b += (d ^ (c | ~a)) + blocks[9] - 343485551; + b = (b << 21 | b >>> 11) + c << 0; + + if (this.first) { + this.h0 = a + 1732584193 << 0; + this.h1 = b - 271733879 << 0; + this.h2 = c - 1732584194 << 0; + this.h3 = d + 271733878 << 0; + this.first = false; + } else { + this.h0 = this.h0 + a << 0; + this.h1 = this.h1 + b << 0; + this.h2 = this.h2 + c << 0; + this.h3 = this.h3 + d << 0; + } + }; + + /** + * @method hex + * @memberof Md5 + * @instance + * @description Output hash as hex string + * @returns {String} Hex string + * @see {@link md5.hex} + * @example + * hash.hex(); + */ + Md5.prototype.hex = function () { + this.finalize(); + + var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3; + + return HEX_CHARS[(h0 >> 4) & 0x0F] + HEX_CHARS[h0 & 0x0F] + + HEX_CHARS[(h0 >> 12) & 0x0F] + HEX_CHARS[(h0 >> 8) & 0x0F] + + HEX_CHARS[(h0 >> 20) & 0x0F] + HEX_CHARS[(h0 >> 16) & 0x0F] + + HEX_CHARS[(h0 >> 28) & 0x0F] + HEX_CHARS[(h0 >> 24) & 0x0F] + + HEX_CHARS[(h1 >> 4) & 0x0F] + HEX_CHARS[h1 & 0x0F] + + HEX_CHARS[(h1 >> 12) & 0x0F] + HEX_CHARS[(h1 >> 8) & 0x0F] + + HEX_CHARS[(h1 >> 20) & 0x0F] + HEX_CHARS[(h1 >> 16) & 0x0F] + + HEX_CHARS[(h1 >> 28) & 0x0F] + HEX_CHARS[(h1 >> 24) & 0x0F] + + HEX_CHARS[(h2 >> 4) & 0x0F] + HEX_CHARS[h2 & 0x0F] + + HEX_CHARS[(h2 >> 12) & 0x0F] + HEX_CHARS[(h2 >> 8) & 0x0F] + + HEX_CHARS[(h2 >> 20) & 0x0F] + HEX_CHARS[(h2 >> 16) & 0x0F] + + HEX_CHARS[(h2 >> 28) & 0x0F] + HEX_CHARS[(h2 >> 24) & 0x0F] + + HEX_CHARS[(h3 >> 4) & 0x0F] + HEX_CHARS[h3 & 0x0F] + + HEX_CHARS[(h3 >> 12) & 0x0F] + HEX_CHARS[(h3 >> 8) & 0x0F] + + HEX_CHARS[(h3 >> 20) & 0x0F] + HEX_CHARS[(h3 >> 16) & 0x0F] + + HEX_CHARS[(h3 >> 28) & 0x0F] + HEX_CHARS[(h3 >> 24) & 0x0F]; + }; + + /** + * @method toString + * @memberof Md5 + * @instance + * @description Output hash as hex string + * @returns {String} Hex string + * @see {@link md5.hex} + * @example + * hash.toString(); + */ + Md5.prototype.toString = Md5.prototype.hex; + + /** + * @method digest + * @memberof Md5 + * @instance + * @description Output hash as bytes array + * @returns {Array} Bytes array + * @see {@link md5.digest} + * @example + * hash.digest(); + */ + Md5.prototype.digest = function () { + this.finalize(); + + var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3; + return [ + h0 & 0xFF, (h0 >> 8) & 0xFF, (h0 >> 16) & 0xFF, (h0 >> 24) & 0xFF, + h1 & 0xFF, (h1 >> 8) & 0xFF, (h1 >> 16) & 0xFF, (h1 >> 24) & 0xFF, + h2 & 0xFF, (h2 >> 8) & 0xFF, (h2 >> 16) & 0xFF, (h2 >> 24) & 0xFF, + h3 & 0xFF, (h3 >> 8) & 0xFF, (h3 >> 16) & 0xFF, (h3 >> 24) & 0xFF + ]; + }; + + /** + * @method array + * @memberof Md5 + * @instance + * @description Output hash as bytes array + * @returns {Array} Bytes array + * @see {@link md5.array} + * @example + * hash.array(); + */ + Md5.prototype.array = Md5.prototype.digest; + + /** + * @method arrayBuffer + * @memberof Md5 + * @instance + * @description Output hash as ArrayBuffer + * @returns {ArrayBuffer} ArrayBuffer + * @see {@link md5.arrayBuffer} + * @example + * hash.arrayBuffer(); + */ + Md5.prototype.arrayBuffer = function () { + this.finalize(); + + var buffer = new ArrayBuffer(16); + var blocks = new Uint32Array(buffer); + blocks[0] = this.h0; + blocks[1] = this.h1; + blocks[2] = this.h2; + blocks[3] = this.h3; + return buffer; + }; + + /** + * @method buffer + * @deprecated This maybe confuse with Buffer in node.js. Please use arrayBuffer instead. + * @memberof Md5 + * @instance + * @description Output hash as ArrayBuffer + * @returns {ArrayBuffer} ArrayBuffer + * @see {@link md5.buffer} + * @example + * hash.buffer(); + */ + Md5.prototype.buffer = Md5.prototype.arrayBuffer; + + /** + * @method base64 + * @memberof Md5 + * @instance + * @description Output hash as base64 string + * @returns {String} base64 string + * @see {@link md5.base64} + * @example + * hash.base64(); + */ + Md5.prototype.base64 = function () { + var v1, v2, v3, base64Str = '', bytes = this.array(); + for (var i = 0; i < 15;) { + v1 = bytes[i++]; + v2 = bytes[i++]; + v3 = bytes[i++]; + base64Str += BASE64_ENCODE_CHAR[v1 >>> 2] + + BASE64_ENCODE_CHAR[(v1 << 4 | v2 >>> 4) & 63] + + BASE64_ENCODE_CHAR[(v2 << 2 | v3 >>> 6) & 63] + + BASE64_ENCODE_CHAR[v3 & 63]; + } + v1 = bytes[i]; + base64Str += BASE64_ENCODE_CHAR[v1 >>> 2] + + BASE64_ENCODE_CHAR[(v1 << 4) & 63] + + '=='; + return base64Str; + }; + + window.md5 = createMethod(); +})(); diff --git a/include/pio/static/style.css b/include/pio/static/style.css new file mode 100644 index 0000000..32bd02c --- /dev/null +++ b/include/pio/static/style.css @@ -0,0 +1,85 @@ +body, html { + padding: 0; + margin: 0; +} +body, button, input[type="text"], input[type="password"] { + font-size: 16px; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; +} + +.title { + padding: 10px 10px 6px; + font-weight: 600; + background-color: #eff2f5; + border-bottom: 1px #d9e0e7 solid; + color: #276eb4; + font-size: 15px; +} +.block { + padding: 10px; +} +.full-width { + width: 100%; + box-sizing: border-box; +} + +.form_label { + padding: 0 0 3px; + font-weight: 600; +} +.form_input { + margin-bottom: 15px; +} +.form_sublabel { + padding-top: 3px; +} + +input[type="text"], +input[type="password"], +select { + border-radius: 4px; + border: 1px #c9cccf solid; + padding: 7px 9px; + outline: none; +} +input[type="text"]:focus, +input[type="password"]:focus, +select:focus { + box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); +} +input[type="text"]:disabled, +input[type="password"]:disabled, +select:disabled { + background-color: #f1f2f3; + border-color: #f1f2f3; +} + +button { + border-radius: 4px; + border: 1px #c9cccf solid; + padding: 7px 15px; + outline: none; + background: #fff; + color: #000; /* fix for iOS */ + position: relative; + line-height: 18px; + font-weight: 600; +} +button:disabled { + background-color: #f1f2f3; + border-color: #f1f2f3; +} +button:not(:disabled):hover { + box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); + cursor: pointer; + border-color: #b5cce3; + color: #276eb4; +} +button:not(:disabled):active { + top: 1px; +} + +button.is_reset, +button.is_reset:not(:disabled):hover { + color: #e63917; +} \ No newline at end of file diff --git a/include/py/__init__.py b/include/py/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/include/py/homekit/__init__.py b/include/py/homekit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/include/py/homekit/api/__init__.py b/include/py/homekit/api/__init__.py new file mode 100644 index 0000000..d641f62 --- /dev/null +++ b/include/py/homekit/api/__init__.py @@ -0,0 +1,19 @@ +import importlib + +__all__ = [ + # web_api_client.py + 'WebApiClient', + 'RequestParams', + + # config.py + 'WebApiConfig' +] + + +def __getattr__(name): + if name in __all__: + file = 'config' if name == 'WebApiConfig' else 'web_api_client' + module = importlib.import_module(f'.{file}', __name__) + return getattr(module, name) + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/include/py/homekit/api/__init__.pyi b/include/py/homekit/api/__init__.pyi new file mode 100644 index 0000000..5b98161 --- /dev/null +++ b/include/py/homekit/api/__init__.pyi @@ -0,0 +1,5 @@ +from .web_api_client import ( + RequestParams as RequestParams, + WebApiClient as WebApiClient +) +from .config import WebApiConfig as WebApiConfig diff --git a/include/py/homekit/api/config.py b/include/py/homekit/api/config.py new file mode 100644 index 0000000..00c1097 --- /dev/null +++ b/include/py/homekit/api/config.py @@ -0,0 +1,15 @@ +from ..config import ConfigUnit +from typing import Optional, Union + + +class WebApiConfig(ConfigUnit): + NAME = 'web_api' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'listen_addr': cls._addr_schema(required=True), + 'host': cls._addr_schema(required=True), + 'token': dict(type='string', required=True), + 'recordings_dir': dict(type='string', required=True) + } \ No newline at end of file diff --git a/include/py/homekit/api/errors/__init__.py b/include/py/homekit/api/errors/__init__.py new file mode 100644 index 0000000..efb06aa --- /dev/null +++ b/include/py/homekit/api/errors/__init__.py @@ -0,0 +1 @@ +from .api_response_error import ApiResponseError diff --git a/include/py/homekit/api/errors/api_response_error.py b/include/py/homekit/api/errors/api_response_error.py new file mode 100644 index 0000000..85d788b --- /dev/null +++ b/include/py/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/include/py/homekit/api/types/__init__.py b/include/py/homekit/api/types/__init__.py new file mode 100644 index 0000000..9f27ff6 --- /dev/null +++ b/include/py/homekit/api/types/__init__.py @@ -0,0 +1,6 @@ +from .types import ( + BotType, + TemperatureSensorDataType, + TemperatureSensorLocation, + SoundSensorLocation +) diff --git a/include/py/homekit/api/types/types.py b/include/py/homekit/api/types/types.py new file mode 100644 index 0000000..981e798 --- /dev/null +++ b/include/py/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/include/py/homekit/api/web_api_client.py b/include/py/homekit/api/web_api_client.py new file mode 100644 index 0000000..15c1915 --- /dev/null +++ b/include/py/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/include/py/homekit/audio/__init__.py b/include/py/homekit/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/include/py/homekit/audio/amixer.py b/include/py/homekit/audio/amixer.py new file mode 100644 index 0000000..5133c97 --- /dev/null +++ b/include/py/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/include/py/homekit/camera/__init__.py b/include/py/homekit/camera/__init__.py new file mode 100644 index 0000000..626930b --- /dev/null +++ b/include/py/homekit/camera/__init__.py @@ -0,0 +1 @@ +from .types import CameraType \ No newline at end of file diff --git a/include/py/homekit/camera/esp32.py b/include/py/homekit/camera/esp32.py new file mode 100644 index 0000000..fe6de0e --- /dev/null +++ b/include/py/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/include/py/homekit/camera/types.py b/include/py/homekit/camera/types.py new file mode 100644 index 0000000..de59022 --- /dev/null +++ b/include/py/homekit/camera/types.py @@ -0,0 +1,5 @@ +from enum import Enum + + +class CameraType(Enum): + ESP32 = 'esp32' diff --git a/include/py/homekit/camera/util.py b/include/py/homekit/camera/util.py new file mode 100644 index 0000000..97f35aa --- /dev/null +++ b/include/py/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/include/py/homekit/config/__init__.py b/include/py/homekit/config/__init__.py new file mode 100644 index 0000000..2fa5214 --- /dev/null +++ b/include/py/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/include/py/homekit/config/_configs.py b/include/py/homekit/config/_configs.py new file mode 100644 index 0000000..1628cba --- /dev/null +++ b/include/py/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/include/py/homekit/config/config.py b/include/py/homekit/config/config.py new file mode 100644 index 0000000..7344386 --- /dev/null +++ b/include/py/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/include/py/homekit/database/__init__.py b/include/py/homekit/database/__init__.py new file mode 100644 index 0000000..b50cbce --- /dev/null +++ b/include/py/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/include/py/homekit/database/__init__.pyi b/include/py/homekit/database/__init__.pyi new file mode 100644 index 0000000..31aae5d --- /dev/null +++ b/include/py/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/include/py/homekit/database/_base.py b/include/py/homekit/database/_base.py new file mode 100644 index 0000000..c01e62b --- /dev/null +++ b/include/py/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/include/py/homekit/database/bots.py b/include/py/homekit/database/bots.py new file mode 100644 index 0000000..cde48b9 --- /dev/null +++ b/include/py/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/include/py/homekit/database/clickhouse.py b/include/py/homekit/database/clickhouse.py new file mode 100644 index 0000000..d0ec283 --- /dev/null +++ b/include/py/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/include/py/homekit/database/inverter.py b/include/py/homekit/database/inverter.py new file mode 100644 index 0000000..fc3f74f --- /dev/null +++ b/include/py/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/include/py/homekit/database/inverter_time_formats.py b/include/py/homekit/database/inverter_time_formats.py new file mode 100644 index 0000000..7c37d30 --- /dev/null +++ b/include/py/homekit/database/inverter_time_formats.py @@ -0,0 +1,2 @@ +FormatTime = '%Y-%m-%d %H:%M:%S' +FormatDate = '%Y-%m-%d' diff --git a/include/py/homekit/database/mysql.py b/include/py/homekit/database/mysql.py new file mode 100644 index 0000000..fe97cd4 --- /dev/null +++ b/include/py/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/include/py/homekit/database/sensors.py b/include/py/homekit/database/sensors.py new file mode 100644 index 0000000..8155108 --- /dev/null +++ b/include/py/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/include/py/homekit/database/simple_state.py b/include/py/homekit/database/simple_state.py new file mode 100644 index 0000000..2b8ebe7 --- /dev/null +++ b/include/py/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/include/py/homekit/database/sqlite.py b/include/py/homekit/database/sqlite.py new file mode 100644 index 0000000..0af1f54 --- /dev/null +++ b/include/py/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/include/py/homekit/http/__init__.py b/include/py/homekit/http/__init__.py new file mode 100644 index 0000000..6030e95 --- /dev/null +++ b/include/py/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/include/py/homekit/http/http.py b/include/py/homekit/http/http.py new file mode 100644 index 0000000..3e70751 --- /dev/null +++ b/include/py/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/include/py/homekit/inverter/__init__.py b/include/py/homekit/inverter/__init__.py new file mode 100644 index 0000000..8831ef3 --- /dev/null +++ b/include/py/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/include/py/homekit/inverter/config.py b/include/py/homekit/inverter/config.py new file mode 100644 index 0000000..e284dfe --- /dev/null +++ b/include/py/homekit/inverter/config.py @@ -0,0 +1,13 @@ +from ..config import ConfigUnit +from typing import Optional + + +class InverterdConfig(ConfigUnit): + NAME = 'inverterd' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'remote_addr': {'type': 'string'}, + 'local_addr': {'type': 'string'}, + } \ No newline at end of file diff --git a/include/py/homekit/inverter/emulator.py b/include/py/homekit/inverter/emulator.py new file mode 100644 index 0000000..e86b8bb --- /dev/null +++ b/include/py/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/include/py/homekit/inverter/inverter_wrapper.py b/include/py/homekit/inverter/inverter_wrapper.py new file mode 100644 index 0000000..df2c2fc --- /dev/null +++ b/include/py/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/include/py/homekit/inverter/monitor.py b/include/py/homekit/inverter/monitor.py new file mode 100644 index 0000000..86f75ac --- /dev/null +++ b/include/py/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/include/py/homekit/inverter/types.py b/include/py/homekit/inverter/types.py new file mode 100644 index 0000000..57021f1 --- /dev/null +++ b/include/py/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/include/py/homekit/inverter/util.py b/include/py/homekit/inverter/util.py new file mode 100644 index 0000000..a577e6a --- /dev/null +++ b/include/py/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/include/py/homekit/media/__init__.py b/include/py/homekit/media/__init__.py new file mode 100644 index 0000000..6923105 --- /dev/null +++ b/include/py/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/include/py/homekit/media/__init__.pyi b/include/py/homekit/media/__init__.pyi new file mode 100644 index 0000000..77c2176 --- /dev/null +++ b/include/py/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/include/py/homekit/media/node_client.py b/include/py/homekit/media/node_client.py new file mode 100644 index 0000000..eb39898 --- /dev/null +++ b/include/py/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/include/py/homekit/media/node_server.py b/include/py/homekit/media/node_server.py new file mode 100644 index 0000000..5d0803c --- /dev/null +++ b/include/py/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/include/py/homekit/media/record.py b/include/py/homekit/media/record.py new file mode 100644 index 0000000..cd7447a --- /dev/null +++ b/include/py/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/include/py/homekit/media/record_client.py b/include/py/homekit/media/record_client.py new file mode 100644 index 0000000..322495c --- /dev/null +++ b/include/py/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/include/py/homekit/media/storage.py b/include/py/homekit/media/storage.py new file mode 100644 index 0000000..dd74ff8 --- /dev/null +++ b/include/py/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/include/py/homekit/media/types.py b/include/py/homekit/media/types.py new file mode 100644 index 0000000..acbc291 --- /dev/null +++ b/include/py/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/include/py/homekit/mqtt/__init__.py b/include/py/homekit/mqtt/__init__.py new file mode 100644 index 0000000..707d59c --- /dev/null +++ b/include/py/homekit/mqtt/__init__.py @@ -0,0 +1,7 @@ +from ._mqtt import Mqtt +from ._node import MqttNode +from ._module import MqttModule +from ._wrapper import MqttWrapper +from ._config import MqttConfig, MqttCreds, MqttNodesConfig +from ._payload import MqttPayload, MqttPayloadCustomField +from ._util import get_modules as get_mqtt_modules \ No newline at end of file diff --git a/include/py/homekit/mqtt/_config.py b/include/py/homekit/mqtt/_config.py new file mode 100644 index 0000000..9ba9443 --- /dev/null +++ b/include/py/homekit/mqtt/_config.py @@ -0,0 +1,165 @@ +from ..config import ConfigUnit +from typing import Optional, Union +from ..util import Addr +from collections import namedtuple + +MqttCreds = namedtuple('MqttCreds', 'username, password') + + +class MqttConfig(ConfigUnit): + NAME = 'mqtt' + + @classmethod + def schema(cls) -> Optional[dict]: + addr_schema = { + 'type': 'dict', + 'required': True, + 'schema': { + 'host': {'type': 'string', 'required': True}, + 'port': {'type': 'integer', 'required': True} + } + } + + schema = {} + for key in ('local', 'remote'): + schema[f'{key}_addr'] = addr_schema + + schema['creds'] = { + 'type': 'dict', + 'required': True, + 'keysrules': {'type': 'string'}, + 'valuesrules': { + 'type': 'dict', + 'schema': { + 'username': {'type': 'string', 'required': True}, + 'password': {'type': 'string', 'required': True}, + } + } + } + + for key in ('client', 'server'): + schema[f'default_{key}_creds'] = {'type': 'string', 'required': True} + + return schema + + def remote_addr(self) -> Addr: + return Addr(host=self['remote_addr']['host'], + port=self['remote_addr']['port']) + + def local_addr(self) -> Addr: + return Addr(host=self['local_addr']['host'], + port=self['local_addr']['port']) + + def creds_by_name(self, name: str) -> MqttCreds: + return MqttCreds(username=self['creds'][name]['username'], + password=self['creds'][name]['password']) + + def creds(self) -> MqttCreds: + return self.creds_by_name(self['default_client_creds']) + + def server_creds(self) -> MqttCreds: + return self.creds_by_name(self['default_server_creds']) + + +class MqttNodesConfig(ConfigUnit): + NAME = 'mqtt_nodes' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + 'common': { + 'type': 'dict', + 'schema': { + 'temphum': { + 'type': 'dict', + 'schema': { + 'interval': {'type': 'integer'} + } + }, + 'password': {'type': 'string'} + } + }, + 'nodes': { + 'type': 'dict', + 'required': True, + 'keysrules': {'type': 'string'}, + 'valuesrules': { + 'type': 'dict', + 'schema': { + 'type': {'type': 'string', 'required': True, 'allowed': ['esp8266', 'linux', 'none'],}, + 'board': {'type': 'string', 'allowed': ['nodemcu', 'd1_mini_lite', 'esp12e']}, + 'temphum': { + 'type': 'dict', + 'schema': { + 'module': {'type': 'string', 'required': True, 'allowed': ['si7021', 'dht12']}, + 'interval': {'type': 'integer'}, + 'i2c_bus': {'type': 'integer'}, + 'tcpserver': { + 'type': 'dict', + 'schema': { + 'port': {'type': 'integer', 'required': True} + } + } + } + }, + 'relay': { + 'type': 'dict', + 'schema': { + 'device_type': {'type': 'string', 'allowed': ['lamp', 'pump', 'solenoid'], 'required': True}, + 'legacy_topics': {'type': 'boolean'} + } + }, + 'password': {'type': 'string'} + } + } + } + } + + @staticmethod + def custom_validator(data): + for name, node in data['nodes'].items(): + if 'temphum' in node: + if node['type'] == 'linux': + if 'i2c_bus' not in node['temphum']: + raise KeyError(f'nodes.{name}.temphum: i2c_bus is missing but required for type=linux') + if node['type'] in ('esp8266',) and 'board' not in node: + raise KeyError(f'nodes.{name}: board is missing but required for type={node["type"]}') + + def get_node(self, name: str) -> dict: + node = self['nodes'][name] + if node['type'] == 'none': + return node + + try: + if 'password' not in node: + node['password'] = self['common']['password'] + except KeyError: + pass + + try: + if 'temphum' in node: + for ckey, cval in self['common']['temphum'].items(): + if ckey not in node['temphum']: + node['temphum'][ckey] = cval + except KeyError: + pass + + return node + + def get_nodes(self, + filters: Optional[Union[list[str], tuple[str]]] = None, + only_names=False) -> Union[dict, list[str]]: + if filters: + for f in filters: + if f not in ('temphum', 'relay'): + raise ValueError(f'{self.__class__.__name__}::get_node(): invalid filter {f}') + reslist = [] + resdict = {} + for name in self['nodes'].keys(): + node = self.get_node(name) + if (not filters) or ('temphum' in filters and 'temphum' in node) or ('relay' in filters and 'relay' in node): + if only_names: + reslist.append(name) + else: + resdict[name] = node + return reslist if only_names else resdict diff --git a/include/py/homekit/mqtt/_module.py b/include/py/homekit/mqtt/_module.py new file mode 100644 index 0000000..80f27bb --- /dev/null +++ b/include/py/homekit/mqtt/_module.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import abc +import logging +import threading + +from time import sleep +from ..util import next_tick_gen + +from typing import TYPE_CHECKING, Optional +if TYPE_CHECKING: + from ._node import MqttNode + from ._payload import MqttPayload + + +class MqttModule(abc.ABC): + _tick_interval: int + _initialized: bool + _connected: bool + _ticker: Optional[threading.Thread] + _mqtt_node_ref: Optional[MqttNode] + + def __init__(self, tick_interval=0): + self._tick_interval = tick_interval + self._initialized = False + self._ticker = None + self._logger = logging.getLogger(self.__class__.__name__) + self._connected = False + self._mqtt_node_ref = None + + def on_connect(self, mqtt: MqttNode): + self._connected = True + self._mqtt_node_ref = mqtt + if self._tick_interval: + self._start_ticker() + + def on_disconnect(self, mqtt: MqttNode): + self._connected = False + self._mqtt_node_ref = None + + def is_initialized(self): + return self._initialized + + def set_initialized(self): + self._initialized = True + + def unset_initialized(self): + self._initialized = False + + def tick(self): + pass + + def _tick(self): + g = next_tick_gen(self._tick_interval) + while self._connected: + sleep(next(g)) + if not self._connected: + break + self.tick() + + def _start_ticker(self): + if not self._ticker or not self._ticker.is_alive(): + name_part = f'{self._mqtt_node_ref.id}/' if self._mqtt_node_ref else '' + self._ticker = None + self._ticker = threading.Thread(target=self._tick, + name=f'mqtt:{self.__class__.__name__}/{name_part}ticker') + self._ticker.start() + + def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: + pass diff --git a/include/py/homekit/mqtt/_mqtt.py b/include/py/homekit/mqtt/_mqtt.py new file mode 100644 index 0000000..fb35a24 --- /dev/null +++ b/include/py/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/include/py/homekit/mqtt/_node.py b/include/py/homekit/mqtt/_node.py new file mode 100644 index 0000000..4e259a4 --- /dev/null +++ b/include/py/homekit/mqtt/_node.py @@ -0,0 +1,92 @@ +import logging +import importlib + +from typing import List, TYPE_CHECKING, Optional +from ._payload import MqttPayload +from ._module import MqttModule +if TYPE_CHECKING: + from ._wrapper import MqttWrapper +else: + MqttWrapper = None + + +class MqttNode: + _modules: List[MqttModule] + _module_subscriptions: dict[str, MqttModule] + _node_id: str + _node_secret: str + _payload_callbacks: list[callable] + _wrapper: Optional[MqttWrapper] + + def __init__(self, + node_id: str, + node_secret: Optional[str] = None): + self._modules = [] + self._module_subscriptions = {} + self._node_id = node_id + self._node_secret = node_secret + self._payload_callbacks = [] + self._logger = logging.getLogger(self.__class__.__name__) + self._wrapper = None + + def on_connect(self, wrapper: MqttWrapper): + self._wrapper = wrapper + for module in self._modules: + if not module.is_initialized(): + module.on_connect(self) + module.set_initialized() + + def on_disconnect(self): + self._wrapper = None + for module in self._modules: + module.unset_initialized() + + def on_message(self, topic, payload): + if topic in self._module_subscriptions: + payload = self._module_subscriptions[topic].handle_payload(self, topic, payload) + if isinstance(payload, MqttPayload): + for f in self._payload_callbacks: + f(self, payload) + + def load_module(self, module_name: str, *args, **kwargs) -> MqttModule: + module = importlib.import_module(f'..module.{module_name}', __name__) + if not hasattr(module, 'MODULE_NAME'): + raise RuntimeError(f'MODULE_NAME not found in module {module}') + cl = getattr(module, getattr(module, 'MODULE_NAME')) + instance = cl(*args, **kwargs) + self.add_module(instance) + return instance + + def add_module(self, module: MqttModule): + self._modules.append(module) + if self._wrapper and self._wrapper._connected: + module.on_connect(self) + module.set_initialized() + + def subscribe_module(self, topic: str, module: MqttModule, qos: int = 1): + if not self._wrapper or not self._wrapper._connected: + raise RuntimeError('not connected') + + self._module_subscriptions[topic] = module + self._wrapper.subscribe(self.id, topic, qos) + + def publish(self, + topic: str, + payload: bytes, + qos: int = 1): + self._wrapper.publish(self.id, topic, payload, qos) + + def add_payload_callback(self, callback: callable): + self._payload_callbacks.append(callback) + + @property + def id(self) -> str: + return self._node_id + + @property + def secret(self) -> str: + return self._node_secret + + @secret.setter + def secret(self, secret: str) -> None: + self._node_secret = secret diff --git a/include/py/homekit/mqtt/_payload.py b/include/py/homekit/mqtt/_payload.py new file mode 100644 index 0000000..58eeae3 --- /dev/null +++ b/include/py/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/include/py/homekit/mqtt/_util.py b/include/py/homekit/mqtt/_util.py new file mode 100644 index 0000000..390d463 --- /dev/null +++ b/include/py/homekit/mqtt/_util.py @@ -0,0 +1,15 @@ +import os +import re + +from typing import List + + +def get_modules() -> List[str]: + modules = [] + modules_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'module') + for name in os.listdir(modules_dir): + if os.path.isdir(os.path.join(modules_dir, name)): + continue + name = re.sub(r'\.py$', '', name) + modules.append(name) + return modules diff --git a/include/py/homekit/mqtt/_wrapper.py b/include/py/homekit/mqtt/_wrapper.py new file mode 100644 index 0000000..3c2774c --- /dev/null +++ b/include/py/homekit/mqtt/_wrapper.py @@ -0,0 +1,60 @@ +import paho.mqtt.client as mqtt + +from ._mqtt import Mqtt +from ._node import MqttNode +from ..util import strgen + + +class MqttWrapper(Mqtt): + _nodes: list[MqttNode] + + def __init__(self, + client_id: str, + topic_prefix='hk', + randomize_client_id=False, + clean_session=True): + if randomize_client_id: + client_id += '_'+strgen(6) + super().__init__(clean_session=clean_session, + client_id=client_id) + self._nodes = [] + self._topic_prefix = topic_prefix + + def on_connect(self, client: mqtt.Client, userdata, flags, rc): + super().on_connect(client, userdata, flags, rc) + for node in self._nodes: + node.on_connect(self) + + def on_disconnect(self, client: mqtt.Client, userdata, rc): + super().on_disconnect(client, userdata, rc) + for node in self._nodes: + node.on_disconnect() + + def on_message(self, client: mqtt.Client, userdata, msg): + try: + topic = msg.topic + topic_node = topic[len(self._topic_prefix)+1:topic.find('/', len(self._topic_prefix)+1)] + for node in self._nodes: + if node.id in ('+', topic_node): + node.on_message(topic[len(f'{self._topic_prefix}/{node.id}/'):], msg.payload) + except Exception as e: + self._logger.exception(str(e)) + + def add_node(self, node: MqttNode): + self._nodes.append(node) + if self._connected: + node.on_connect(self) + + def subscribe(self, + node_id: str, + topic: str, + qos: int): + self._client.subscribe(f'{self._topic_prefix}/{node_id}/{topic}', qos) + + def publish(self, + node_id: str, + topic: str, + payload: bytes, + qos: int): + self._client.publish(f'{self._topic_prefix}/{node_id}/{topic}', payload, qos) + self._client.loop_write() diff --git a/include/py/homekit/mqtt/module/diagnostics.py b/include/py/homekit/mqtt/module/diagnostics.py new file mode 100644 index 0000000..5db5e99 --- /dev/null +++ b/include/py/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/include/py/homekit/mqtt/module/inverter.py b/include/py/homekit/mqtt/module/inverter.py new file mode 100644 index 0000000..29bde0a --- /dev/null +++ b/include/py/homekit/mqtt/module/inverter.py @@ -0,0 +1,195 @@ +import time +import json +import datetime +try: + import inverterd +except: + pass + +from typing import Optional +from .._module import MqttModule +from .._node import MqttNode +from .._payload import MqttPayload, bit_field +try: + from homekit.database import InverterDatabase +except: + pass + +_mult_10 = lambda n: int(n*10) +_div_10 = lambda n: n/10 + + +MODULE_NAME = 'MqttInverterModule' + +STATUS_TOPIC = 'status' +GENERATION_TOPIC = 'generation' + + +class MqttInverterStatusPayload(MqttPayload): + # 46 bytes + FORMAT = 'IHHHHHHBHHHHHBHHHHHHHH' + + PACKER = { + 'grid_voltage': _mult_10, + 'grid_freq': _mult_10, + 'ac_output_voltage': _mult_10, + 'ac_output_freq': _mult_10, + 'battery_voltage': _mult_10, + 'battery_voltage_scc': _mult_10, + 'battery_voltage_scc2': _mult_10, + 'pv1_input_voltage': _mult_10, + 'pv2_input_voltage': _mult_10 + } + UNPACKER = { + 'grid_voltage': _div_10, + 'grid_freq': _div_10, + 'ac_output_voltage': _div_10, + 'ac_output_freq': _div_10, + 'battery_voltage': _div_10, + 'battery_voltage_scc': _div_10, + 'battery_voltage_scc2': _div_10, + 'pv1_input_voltage': _div_10, + 'pv2_input_voltage': _div_10 + } + + time: int + grid_voltage: float + grid_freq: float + ac_output_voltage: float + ac_output_freq: float + ac_output_apparent_power: int + ac_output_active_power: int + output_load_percent: int + battery_voltage: float + battery_voltage_scc: float + battery_voltage_scc2: float + battery_discharge_current: int + battery_charge_current: int + battery_capacity: int + inverter_heat_sink_temp: int + mppt1_charger_temp: int + mppt2_charger_temp: int + pv1_input_power: int + pv2_input_power: int + pv1_input_voltage: float + pv2_input_voltage: float + + # H + mppt1_charger_status: bit_field(0, 16, 2) + mppt2_charger_status: bit_field(0, 16, 2) + battery_power_direction: bit_field(0, 16, 2) + dc_ac_power_direction: bit_field(0, 16, 2) + line_power_direction: bit_field(0, 16, 2) + load_connected: bit_field(0, 16, 1) + + +class MqttInverterGenerationPayload(MqttPayload): + # 8 bytes + FORMAT = 'II' + + time: int + wh: int + + +class MqttInverterModule(MqttModule): + _status_poll_freq: int + _generation_poll_freq: int + _inverter: Optional[inverterd.Client] + _database: Optional[InverterDatabase] + _gen_prev: float + + def __init__(self, status_poll_freq=0, generation_poll_freq=0): + super().__init__(tick_interval=status_poll_freq) + self._status_poll_freq = status_poll_freq + self._generation_poll_freq = generation_poll_freq + + # this defines whether this is a publisher or a subscriber + if status_poll_freq > 0: + self._inverter = inverterd.Client() + self._inverter.connect() + self._inverter.format(inverterd.Format.SIMPLE_JSON) + self._database = None + else: + self._inverter = None + self._database = InverterDatabase() + + self._gen_prev = 0 + + def on_connect(self, mqtt: MqttNode): + super().on_connect(mqtt) + if not self._inverter: + mqtt.subscribe_module(STATUS_TOPIC, self) + mqtt.subscribe_module(GENERATION_TOPIC, self) + + def tick(self): + if not self._inverter: + return + + # read status + now = time.time() + try: + raw = self._inverter.exec('get-status') + except inverterd.InverterError as e: + self._logger.error(f'inverter error: {str(e)}') + # TODO send to server + return + + data = json.loads(raw)['data'] + status = MqttInverterStatusPayload(time=round(now), **data) + self._mqtt_node_ref.publish(STATUS_TOPIC, status.pack()) + + # read today's generation stat + now = time.time() + if self._gen_prev == 0 or now - self._gen_prev >= self._generation_poll_freq: + self._gen_prev = now + today = datetime.date.today() + try: + raw = self._inverter.exec('get-day-generated', (today.year, today.month, today.day)) + except inverterd.InverterError as e: + self._logger.error(f'inverter error: {str(e)}') + # TODO send to server + return + + data = json.loads(raw)['data'] + gen = MqttInverterGenerationPayload(time=round(now), wh=data['wh']) + self._mqtt_node_ref.publish(GENERATION_TOPIC, gen.pack()) + + def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: + home_id = 1 # legacy compat + + if topic == STATUS_TOPIC: + s = MqttInverterStatusPayload.unpack(payload) + self._database.add_status(home_id=home_id, + client_time=s.time, + grid_voltage=int(s.grid_voltage*10), + grid_freq=int(s.grid_freq * 10), + ac_output_voltage=int(s.ac_output_voltage * 10), + ac_output_freq=int(s.ac_output_freq * 10), + ac_output_apparent_power=s.ac_output_apparent_power, + ac_output_active_power=s.ac_output_active_power, + output_load_percent=s.output_load_percent, + battery_voltage=int(s.battery_voltage * 10), + battery_voltage_scc=int(s.battery_voltage_scc * 10), + battery_voltage_scc2=int(s.battery_voltage_scc2 * 10), + battery_discharge_current=s.battery_discharge_current, + battery_charge_current=s.battery_charge_current, + battery_capacity=s.battery_capacity, + inverter_heat_sink_temp=s.inverter_heat_sink_temp, + mppt1_charger_temp=s.mppt1_charger_temp, + mppt2_charger_temp=s.mppt2_charger_temp, + pv1_input_power=s.pv1_input_power, + pv2_input_power=s.pv2_input_power, + pv1_input_voltage=int(s.pv1_input_voltage * 10), + pv2_input_voltage=int(s.pv2_input_voltage * 10), + mppt1_charger_status=s.mppt1_charger_status, + mppt2_charger_status=s.mppt2_charger_status, + battery_power_direction=s.battery_power_direction, + dc_ac_power_direction=s.dc_ac_power_direction, + line_power_direction=s.line_power_direction, + load_connected=s.load_connected) + return s + + elif topic == GENERATION_TOPIC: + gen = MqttInverterGenerationPayload.unpack(payload) + self._database.add_generation(home_id, gen.time, gen.wh) + return gen diff --git a/include/py/homekit/mqtt/module/ota.py b/include/py/homekit/mqtt/module/ota.py new file mode 100644 index 0000000..cd34332 --- /dev/null +++ b/include/py/homekit/mqtt/module/ota.py @@ -0,0 +1,77 @@ +import hashlib + +from typing import Optional +from .._payload import MqttPayload +from .._node import MqttModule, MqttNode + +MODULE_NAME = 'MqttOtaModule' + + +class OtaResultPayload(MqttPayload): + FORMAT = '=BB' + result: int + error_code: int + + +class OtaPayload(MqttPayload): + secret: str + filename: str + + # structure of returned data: + # + # uint8_t[len(secret)] secret; + # uint8_t[16] md5; + # *uint8_t data + + def pack(self): + buf = bytearray(self.secret.encode()) + m = hashlib.md5() + with open(self.filename, 'rb') as fd: + content = fd.read() + m.update(content) + buf.extend(m.digest()) + buf.extend(content) + return buf + + def unpack(cls, buf: bytes): + raise RuntimeError(f'{cls.__class__.__name__}.unpack: not implemented') + # secret = buf[:12].decode() + # filename = buf[12:].decode() + # return OTAPayload(secret=secret, filename=filename) + + +class MqttOtaModule(MqttModule): + _ota_request: Optional[tuple[str, int]] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ota_request = None + + def on_connect(self, mqtt: MqttNode): + super().on_connect(mqtt) + mqtt.subscribe_module("otares", self) + + if self._ota_request is not None: + filename, qos = self._ota_request + self._ota_request = None + self.do_push_ota(self._mqtt_node_ref.secret, filename, qos) + + def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: + if topic == 'otares': + message = OtaResultPayload.unpack(payload) + self._logger.debug(message) + return message + + def do_push_ota(self, secret: str, filename: str, qos: int): + payload = OtaPayload(secret=secret, filename=filename) + self._mqtt_node_ref.publish('ota', + payload=payload.pack(), + qos=qos) + + def push_ota(self, + filename: str, + qos: int): + if not self._initialized: + self._ota_request = (filename, qos) + else: + self.do_push_ota(filename, qos) diff --git a/include/py/homekit/mqtt/module/relay.py b/include/py/homekit/mqtt/module/relay.py new file mode 100644 index 0000000..e968031 --- /dev/null +++ b/include/py/homekit/mqtt/module/relay.py @@ -0,0 +1,92 @@ +import datetime + +from typing import Optional +from .. import MqttModule, MqttPayload, MqttNode + +MODULE_NAME = 'MqttRelayModule' + + +class MqttPowerSwitchPayload(MqttPayload): + FORMAT = '=12sB' + PACKER = { + 'state': lambda n: int(n), + 'secret': lambda s: s.encode('utf-8') + } + UNPACKER = { + 'state': lambda n: bool(n), + 'secret': lambda s: s.decode('utf-8') + } + + secret: str + state: bool + + +class MqttPowerStatusPayload(MqttPayload): + FORMAT = '=B' + PACKER = { + 'opened': lambda n: int(n), + } + UNPACKER = { + 'opened': lambda n: bool(n), + } + + opened: bool + + +class MqttRelayState: + enabled: bool + update_time: datetime.datetime + rssi: int + fw_version: int + ever_updated: bool + + def __init__(self): + self.ever_updated = False + self.enabled = False + self.rssi = 0 + + def update(self, + enabled: bool, + rssi: int, + fw_version=None): + self.ever_updated = True + self.enabled = enabled + self.rssi = rssi + self.update_time = datetime.datetime.now() + if fw_version: + self.fw_version = fw_version + + +class MqttRelayModule(MqttModule): + _legacy_topics: bool + + def __init__(self, legacy_topics=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self._legacy_topics = legacy_topics + + def on_connect(self, mqtt: MqttNode): + super().on_connect(mqtt) + mqtt.subscribe_module(self._get_switch_topic(), self) + mqtt.subscribe_module('relay/status', self) + + def switchpower(self, + enable: bool): + payload = MqttPowerSwitchPayload(secret=self._mqtt_node_ref.secret, + state=enable) + self._mqtt_node_ref.publish(self._get_switch_topic(), + payload=payload.pack()) + + def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]: + message = None + + if topic == self._get_switch_topic(): + message = MqttPowerSwitchPayload.unpack(payload) + elif topic == 'relay/status': + message = MqttPowerStatusPayload.unpack(payload) + + if message is not None: + self._logger.debug(message) + return message + + def _get_switch_topic(self) -> str: + return 'relay/power' if self._legacy_topics else 'relay/switch' diff --git a/include/py/homekit/mqtt/module/temphum.py b/include/py/homekit/mqtt/module/temphum.py new file mode 100644 index 0000000..fd02cca --- /dev/null +++ b/include/py/homekit/mqtt/module/temphum.py @@ -0,0 +1,82 @@ +from .._node import MqttNode +from .._module import MqttModule +from .._payload import MqttPayload +from typing import Optional +from ...temphum import BaseSensor + +two_digits_precision = lambda x: round(x, 2) + +MODULE_NAME = 'MqttTempHumModule' +DATA_TOPIC = 'temphum/data' + + +class MqttTemphumDataPayload(MqttPayload): + FORMAT = '=ddb' + UNPACKER = { + 'temp': two_digits_precision, + 'rh': two_digits_precision + } + + temp: float + rh: float + error: int + + +# class MqttTempHumNodes(HashableEnum): +# KBN_SH_HALL = auto() +# KBN_SH_BATHROOM = auto() +# KBN_SH_LIVINGROOM = auto() +# KBN_SH_BEDROOM = auto() +# +# KBN_BH_2FL = auto() +# KBN_BH_2FL_STREET = auto() +# KBN_BH_1FL_LIVINGROOM = auto() +# KBN_BH_1FL_BEDROOM = auto() +# KBN_BH_1FL_BATHROOM = auto() +# +# KBN_NH_1FL_INV = auto() +# KBN_NH_1FL_CENTER = auto() +# KBN_NH_1LF_KT = auto() +# KBN_NH_1FL_DS = auto() +# KBN_NH_1FS_EZ = auto() +# +# SPB_FLAT120_CABINET = auto() + + +class MqttTempHumModule(MqttModule): + def __init__(self, + sensor: Optional[BaseSensor] = None, + write_to_database=False, + *args, **kwargs): + if sensor is not None: + kwargs['tick_interval'] = 10 + super().__init__(*args, **kwargs) + self._sensor = sensor + + def on_connect(self, mqtt: MqttNode): + super().on_connect(mqtt) + mqtt.subscribe_module(DATA_TOPIC, self) + + def tick(self): + if not self._sensor: + return + + error = 0 + temp = 0 + rh = 0 + try: + temp = self._sensor.temperature() + rh = self._sensor.humidity() + except: + error = 1 + pld = MqttTemphumDataPayload(temp=temp, rh=rh, error=error) + self._mqtt_node_ref.publish(DATA_TOPIC, pld.pack()) + + def handle_payload(self, + mqtt: MqttNode, + topic: str, + payload: bytes) -> Optional[MqttPayload]: + if topic == DATA_TOPIC: + message = MqttTemphumDataPayload.unpack(payload) + self._logger.debug(message) + return message diff --git a/include/py/homekit/pio/__init__.py b/include/py/homekit/pio/__init__.py new file mode 100644 index 0000000..7216bc4 --- /dev/null +++ b/include/py/homekit/pio/__init__.py @@ -0,0 +1 @@ +from .products import get_products, platformio_ini \ No newline at end of file diff --git a/include/py/homekit/pio/exceptions.py b/include/py/homekit/pio/exceptions.py new file mode 100644 index 0000000..a6afd20 --- /dev/null +++ b/include/py/homekit/pio/exceptions.py @@ -0,0 +1,2 @@ +class ProductConfigNotFoundError(Exception): + pass diff --git a/include/py/homekit/pio/products.py b/include/py/homekit/pio/products.py new file mode 100644 index 0000000..388da03 --- /dev/null +++ b/include/py/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/include/py/homekit/relay/__init__.py b/include/py/homekit/relay/__init__.py new file mode 100644 index 0000000..406403d --- /dev/null +++ b/include/py/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/include/py/homekit/relay/__init__.pyi b/include/py/homekit/relay/__init__.pyi new file mode 100644 index 0000000..7a4a2f4 --- /dev/null +++ b/include/py/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/include/py/homekit/relay/sunxi_h3_client.py b/include/py/homekit/relay/sunxi_h3_client.py new file mode 100644 index 0000000..8c8d6c4 --- /dev/null +++ b/include/py/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/include/py/homekit/relay/sunxi_h3_server.py b/include/py/homekit/relay/sunxi_h3_server.py new file mode 100644 index 0000000..1f33969 --- /dev/null +++ b/include/py/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/include/py/homekit/soundsensor/__init__.py b/include/py/homekit/soundsensor/__init__.py new file mode 100644 index 0000000..30052f8 --- /dev/null +++ b/include/py/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/include/py/homekit/soundsensor/__init__.pyi b/include/py/homekit/soundsensor/__init__.pyi new file mode 100644 index 0000000..cb34972 --- /dev/null +++ b/include/py/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/include/py/homekit/soundsensor/node.py b/include/py/homekit/soundsensor/node.py new file mode 100644 index 0000000..292452f --- /dev/null +++ b/include/py/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/include/py/homekit/soundsensor/server.py b/include/py/homekit/soundsensor/server.py new file mode 100644 index 0000000..a627390 --- /dev/null +++ b/include/py/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/include/py/homekit/soundsensor/server_client.py b/include/py/homekit/soundsensor/server_client.py new file mode 100644 index 0000000..7eef996 --- /dev/null +++ b/include/py/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/include/py/homekit/telegram/__init__.py b/include/py/homekit/telegram/__init__.py new file mode 100644 index 0000000..a68dae1 --- /dev/null +++ b/include/py/homekit/telegram/__init__.py @@ -0,0 +1 @@ +from .telegram import send_message, send_photo diff --git a/include/py/homekit/telegram/_botcontext.py b/include/py/homekit/telegram/_botcontext.py new file mode 100644 index 0000000..a143bfe --- /dev/null +++ b/include/py/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/include/py/homekit/telegram/_botdb.py b/include/py/homekit/telegram/_botdb.py new file mode 100644 index 0000000..4e1aec0 --- /dev/null +++ b/include/py/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/include/py/homekit/telegram/_botlang.py b/include/py/homekit/telegram/_botlang.py new file mode 100644 index 0000000..f5f85bb --- /dev/null +++ b/include/py/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/include/py/homekit/telegram/_botutil.py b/include/py/homekit/telegram/_botutil.py new file mode 100644 index 0000000..111a704 --- /dev/null +++ b/include/py/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/include/py/homekit/telegram/aio.py b/include/py/homekit/telegram/aio.py new file mode 100644 index 0000000..fc87c1c --- /dev/null +++ b/include/py/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/include/py/homekit/telegram/bot.py b/include/py/homekit/telegram/bot.py new file mode 100644 index 0000000..2e33bea --- /dev/null +++ b/include/py/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/include/py/homekit/telegram/config.py b/include/py/homekit/telegram/config.py new file mode 100644 index 0000000..4c7d74b --- /dev/null +++ b/include/py/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/include/py/homekit/telegram/telegram.py b/include/py/homekit/telegram/telegram.py new file mode 100644 index 0000000..f42363e --- /dev/null +++ b/include/py/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/include/py/homekit/temphum/__init__.py b/include/py/homekit/temphum/__init__.py new file mode 100644 index 0000000..46d14e6 --- /dev/null +++ b/include/py/homekit/temphum/__init__.py @@ -0,0 +1 @@ +from .base import SensorType, BaseSensor diff --git a/include/py/homekit/temphum/base.py b/include/py/homekit/temphum/base.py new file mode 100644 index 0000000..602cab7 --- /dev/null +++ b/include/py/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/include/py/homekit/temphum/i2c.py b/include/py/homekit/temphum/i2c.py new file mode 100644 index 0000000..7d8e2e3 --- /dev/null +++ b/include/py/homekit/temphum/i2c.py @@ -0,0 +1,52 @@ +import abc +import smbus + +from .base import BaseSensor, SensorType + + +class I2CSensor(BaseSensor, abc.ABC): + def __init__(self, bus: int): + super().__init__() + self.bus = smbus.SMBus(bus) + + +class DHT12(I2CSensor): + i2c_addr = 0x5C + + def _measure(self): + raw = self.bus.read_i2c_block_data(self.i2c_addr, 0, 5) + if (raw[0] + raw[1] + raw[2] + raw[3]) & 0xff != raw[4]: + raise ValueError("checksum error") + return raw + + def temperature(self) -> float: + raw = self._measure() + temp = raw[2] + (raw[3] & 0x7f) * 0.1 + if raw[3] & 0x80: + temp *= -1 + return temp + + def humidity(self) -> float: + raw = self._measure() + return raw[0] + raw[1] * 0.1 + + +class Si7021(I2CSensor): + i2c_addr = 0x40 + + def temperature(self) -> float: + raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE3, 2) + return 175.72 * (raw[0] << 8 | raw[1]) / 65536.0 - 46.85 + + def humidity(self) -> float: + raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE5, 2) + return 125.0 * (raw[0] << 8 | raw[1]) / 65536.0 - 6.0 + + +def create_sensor(type: SensorType, bus: int) -> BaseSensor: + if type == SensorType.Si7021: + return Si7021(bus) + elif type == SensorType.DHT12: + return DHT12(bus) + else: + raise ValueError('unexpected sensor type') diff --git a/include/py/homekit/util.py b/include/py/homekit/util.py new file mode 100644 index 0000000..11e7116 --- /dev/null +++ b/include/py/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/include/py/pyA20/__init__.pyi b/include/py/pyA20/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/include/py/pyA20/gpio/connector.pyi b/include/py/pyA20/gpio/connector.pyi new file mode 100644 index 0000000..12b2b6e --- /dev/null +++ b/include/py/pyA20/gpio/connector.pyi @@ -0,0 +1,2 @@ +gpio1 = 0 +LED = 0 \ No newline at end of file diff --git a/include/py/pyA20/gpio/gpio.pyi b/include/py/pyA20/gpio/gpio.pyi new file mode 100644 index 0000000..225fcbe --- /dev/null +++ b/include/py/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/include/py/pyA20/gpio/port.pyi b/include/py/pyA20/gpio/port.pyi new file mode 100644 index 0000000..17f69fe --- /dev/null +++ b/include/py/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/include/py/pyA20/port.pyi b/include/py/pyA20/port.pyi new file mode 100644 index 0000000..e69de29 diff --git a/include/py/syncleo/__init__.py b/include/py/syncleo/__init__.py new file mode 100644 index 0000000..32563a5 --- /dev/null +++ b/include/py/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/include/py/syncleo/kettle.py b/include/py/syncleo/kettle.py new file mode 100644 index 0000000..d6e0dd6 --- /dev/null +++ b/include/py/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/include/py/syncleo/protocol.py b/include/py/syncleo/protocol.py new file mode 100644 index 0000000..36a1a8f --- /dev/null +++ b/include/py/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/pio/dumb_mqtt/src/main.cpp b/pio/dumb_mqtt/src/main.cpp new file mode 100644 index 0000000..eefc165 --- /dev/null +++ b/pio/dumb_mqtt/src/main.cpp @@ -0,0 +1,12 @@ +#include +#include + +using namespace homekit; + +void setup() { + main::setup(); +} + +void loop() { + main::loop(nullptr); +} diff --git a/pio/relayctl/src/main.cpp b/pio/relayctl/src/main.cpp new file mode 100644 index 0000000..c399641 --- /dev/null +++ b/pio/relayctl/src/main.cpp @@ -0,0 +1,35 @@ +#include +#include +#include +#include +#include +#include + +using namespace homekit; +using main::LoopConfig; +using mqtt::Mqtt; +using mqtt::MqttRelayModule; + +MqttRelayModule* mqttRelayModule = nullptr; + +static void onMqttCreated(Mqtt& mqtt); + +LoopConfig loopConfig = { + .onMqttCreated = onMqttCreated +}; + +void setup() { + main::setup(); + relay::init(); +} + +void loop() { + main::loop(&loopConfig); +} + +static void onMqttCreated(Mqtt& mqtt) { + if (mqttRelayModule == nullptr) { + mqttRelayModule = new MqttRelayModule(); + mqtt.addModule(mqttRelayModule); + } +} \ No newline at end of file diff --git a/pio/temphum/src/main.cpp b/pio/temphum/src/main.cpp new file mode 100644 index 0000000..2df8638 --- /dev/null +++ b/pio/temphum/src/main.cpp @@ -0,0 +1,42 @@ +#include +#include +#include +#include +#include +#include + +using namespace homekit; +using main::LoopConfig; +using mqtt::Mqtt; +using mqtt::MqttTemphumModule; + +temphum::Sensor* sensor = nullptr; +MqttTemphumModule* mqttTemphumModule = nullptr; + +static void onMqttCreated(Mqtt& mqtt); + +LoopConfig loopConfig = { + .onMqttCreated = onMqttCreated +}; + +void setup() { + main::setup(); + +#if CONFIG_MODULE == HOMEKIT_SI7021 + sensor = new temphum::Si7021(); +#elif CONFIG_MODULE == HOMEKIT_DHT12 + sensor = new temphum::DHT12(); +#endif + sensor->setup(); +} + +void loop() { + main::loop(&loopConfig); +} + +static void onMqttCreated(Mqtt& mqtt) { + if (mqttTemphumModule == nullptr) { + mqttTemphumModule = new MqttTemphumModule(sensor); + mqtt.addModule(mqttTemphumModule); + } +} \ No newline at end of file diff --git a/pio/temphum_relayctl/src/main.cpp b/pio/temphum_relayctl/src/main.cpp new file mode 100644 index 0000000..7f0945e --- /dev/null +++ b/pio/temphum_relayctl/src/main.cpp @@ -0,0 +1,51 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace homekit; +using main::LoopConfig; +using mqtt::Mqtt; +using mqtt::MqttTemphumModule; +using mqtt::MqttRelayModule; + +temphum::Sensor* sensor = nullptr; +MqttTemphumModule* mqttTemphumModule = nullptr; +MqttRelayModule* mqttRelayModule = nullptr; + +static void onMqttCreated(Mqtt& mqtt); + +LoopConfig loopConfig = { + .onMqttCreated = onMqttCreated +}; + +void setup() { + main::setup(); + + relay::init(); + relay::off(); + +#if CONFIG_MODULE == HOMEKIT_SI7021 + sensor = new temphum::Si7021(); +#elif CONFIG_MODULE == HOMEKIT_DHT12 + sensor = new temphum::DHT12(); +#endif + sensor->setup(); +} + +void loop() { + main::loop(&loopConfig); +} + +static void onMqttCreated(Mqtt& mqtt) { + if (mqttTemphumModule == nullptr) { + mqttTemphumModule = new MqttTemphumModule(sensor); + mqttRelayModule = new MqttRelayModule(); + mqtt.addModule(mqttTemphumModule); + mqtt.addModule(mqttRelayModule); + } +} \ No newline at end of file diff --git a/platformio/common/include/homekit/logging.h b/platformio/common/include/homekit/logging.h deleted file mode 100644 index 559ca33..0000000 --- a/platformio/common/include/homekit/logging.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifndef COMMON_HOMEKIT_LOGGING_H -#define COMMON_HOMEKIT_LOGGING_H - -#include - -#ifdef DEBUG - -#define PRINTLN(s) Serial.println(s) -#define PRINT(s) Serial.print(s) -#define PRINTF(fmt, ...) Serial.printf(fmt, ##__VA_ARGS__) - -#else - -#define PRINTLN(s) -#define PRINT(s) -#define PRINTF(...) - -#endif - -#endif //COMMON_HOMEKIT_LOGGING_H \ No newline at end of file diff --git a/platformio/common/include/homekit/macros.h b/platformio/common/include/homekit/macros.h deleted file mode 100644 index 7d3ad83..0000000 --- a/platformio/common/include/homekit/macros.h +++ /dev/null @@ -1 +0,0 @@ -#define ARRAY_SIZE(X) sizeof((X))/sizeof((X)[0]) \ No newline at end of file diff --git a/platformio/common/include/homekit/stopwatch.h b/platformio/common/include/homekit/stopwatch.h deleted file mode 100644 index bac2fcc..0000000 --- a/platformio/common/include/homekit/stopwatch.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include - -namespace homekit { - -class StopWatch { -private: - unsigned long time; - -public: - StopWatch() : time(0) {}; - - inline void save() { - time = millis(); - } - - inline bool elapsed(unsigned long ms) { - unsigned long now = millis(); - if (now < time) { - // rollover? - time = now; - } else if (now - time >= ms) { - return true; - } - return false; - } -}; - -} \ No newline at end of file diff --git a/platformio/common/include/homekit/util.h b/platformio/common/include/homekit/util.h deleted file mode 100644 index e0780d8..0000000 --- a/platformio/common/include/homekit/util.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -namespace homekit { - -inline size_t otaGetMaxUpdateSize() { - return (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000; -} - -inline void restart() { - ESP.restart(); -} - -} \ No newline at end of file diff --git a/platformio/common/libs/config/homekit/config.cpp b/platformio/common/libs/config/homekit/config.cpp deleted file mode 100644 index 5bafcad..0000000 --- a/platformio/common/libs/config/homekit/config.cpp +++ /dev/null @@ -1,84 +0,0 @@ -#include -#include -#include "config.h" -#include - -#define GET_DATA_CRC(data) \ - eeprom_crc(reinterpret_cast(&(data))+4, sizeof(ConfigData)-4) - -namespace homekit::config { - -static const uint32_t magic = 0xdeadbeef; -static const uint32_t crc_table[16] PROGMEM = { - 0x00000000, 0x1db71064, 0x3b6e20c8, 0x26d930ac, - 0x76dc4190, 0x6b6b51f4, 0x4db26158, 0x5005713c, - 0xedb88320, 0xf00f9344, 0xd6d6a3e8, 0xcb61b38c, - 0x9b64c2b0, 0x86d3d2d4, 0xa00ae278, 0xbdbdf21c -}; - -static uint32_t eeprom_crc(const uint8_t* data, size_t len) { - uint32_t crc = ~0L; - for (size_t index = 0; index < len; index++) { - crc = pgm_read_word(&crc_table[(crc ^ data[index]) & 0x0f]) ^ (crc >> 4); - crc = pgm_read_word(&crc_table[(crc ^ (data[index] >> 4)) & 0x0f]) ^ (crc >> 4); - crc = ~crc; - } - return crc; -} - -ConfigData read() { - ConfigData data; - EEPROM.begin(sizeof(ConfigData)); - EEPROM.get(0, data); - EEPROM.end(); -#ifdef DEBUG - if (!isValid(data)) { - PRINTLN("config::read(): data is not valid!"); - } -#endif - return data; -} - -void write(ConfigData& data) { - EEPROM.begin(sizeof(ConfigData)); - data.magic = magic; - data.crc = GET_DATA_CRC(data); - EEPROM.put(0, data); - EEPROM.end(); -} - -void erase() { - ConfigData data; - erase(data); -} - -void erase(ConfigData& data) { - bzero(reinterpret_cast(&data), sizeof(data)); - write(data); -} - -bool isValid(ConfigData& data) { - return data.crc == GET_DATA_CRC(data); -} - -bool isDirty(ConfigData& data) { - return data.magic != magic; -} - -char* ConfigData::escapeHomeId(char* buf, size_t len) { - if (len < 32) - return nullptr; - size_t id_len = strlen(node_id); - char* c = node_id; - char* dst = buf; - for (size_t i = 0; i < id_len; i++) { - if (*c == '"') - *(dst++) = '\\'; - *(dst++) = *c; - c++; - } - *dst = '\0'; - return buf; -} - -} diff --git a/platformio/common/libs/config/homekit/config.h b/platformio/common/libs/config/homekit/config.h deleted file mode 100644 index 28f01fb..0000000 --- a/platformio/common/libs/config/homekit/config.h +++ /dev/null @@ -1,37 +0,0 @@ -#ifndef COMMON_HOMEKIT_CONFIG_H -#define COMMON_HOMEKIT_CONFIG_H - -#include - -namespace homekit::config { - -struct ConfigFlags { - uint8_t wifi_configured: 1; - uint8_t node_configured: 1; - uint8_t reserved: 6; -} __attribute__((packed)); - -struct ConfigData { - // helpers - uint32_t crc = 0; - uint32_t magic = 0; - char node_id[16] = {0}; - char wifi_ssid[32] = {0}; - char wifi_psk[63] = {0}; - ConfigFlags flags {0}; - - // helper methods - char* escapeHomeId(char* buf, size_t len); -} __attribute__((packed)); - - -ConfigData read(); -void write(ConfigData& data); -void erase(); -void erase(ConfigData& data); -bool isValid(ConfigData& data); -bool isDirty(ConfigData& data); - -} - -#endif //COMMON_HOMEKIT_CONFIG_H \ No newline at end of file diff --git a/platformio/common/libs/config/library.json b/platformio/common/libs/config/library.json deleted file mode 100644 index 720d093..0000000 --- a/platformio/common/libs/config/library.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "homekit_config", - "version": "1.0.2", - "build": { - "flags": "-I../../include" - } -} - diff --git a/platformio/common/libs/http_server/homekit/http_server.cpp b/platformio/common/libs/http_server/homekit/http_server.cpp deleted file mode 100644 index ea81f5b..0000000 --- a/platformio/common/libs/http_server/homekit/http_server.cpp +++ /dev/null @@ -1,282 +0,0 @@ -#include "http_server.h" - -#include -#include - -#include -#include -#include -#include -#include - -namespace homekit { - -using files::StaticFile; - -static const char CONTENT_TYPE_HTML[] PROGMEM = "text/html; charset=utf-8"; -static const char CONTENT_TYPE_CSS[] PROGMEM = "text/css"; -static const char CONTENT_TYPE_JS[] PROGMEM = "application/javascript"; -static const char CONTENT_TYPE_JSON[] PROGMEM = "application/json"; -static const char CONTENT_TYPE_FAVICON[] PROGMEM = "image/x-icon"; - -static const char JSON_UPDATE_FMT[] PROGMEM = "{\"result\":%d}"; -static const char JSON_STATUS_FMT[] PROGMEM = "{\"node_id\":\"%s\"" -#ifdef DEBUG - ",\"configured\":%d" - ",\"crc\":%u" - ",\"fl_n\":%d" - ",\"fl_w\":%d" -#endif - "}"; -static const size_t JSON_BUF_SIZE = 192; - -static const char JSON_SCAN_FIRST_LIST[] PROGMEM = "{\"list\":["; - -static const char MSG_IS_INVALID[] PROGMEM = " is invalid"; -static const char MSG_IS_MISSING[] PROGMEM = " is missing"; - -static const char GZIP[] PROGMEM = "gzip"; -static const char CONTENT_ENCODING[] PROGMEM = "Content-Encoding"; -static const char NOT_FOUND[] PROGMEM = "Not Found"; - -static const char ROUTE_STYLE_CSS[] PROGMEM = "/style.css"; -static const char ROUTE_APP_JS[] PROGMEM = "/app.js"; -static const char ROUTE_MD5_JS[] PROGMEM = "/md5.js"; -static const char ROUTE_FAVICON_ICO[] PROGMEM = "/favicon.ico"; -static const char ROUTE_STATUS[] PROGMEM = "/status"; -static const char ROUTE_SCAN[] PROGMEM = "/scan"; -static const char ROUTE_RESET[] PROGMEM = "/reset"; -// #ifdef DEBUG -static const char ROUTE_HEAP[] PROGMEM = "/heap"; -// #endif -static const char ROUTE_UPDATE[] PROGMEM = "/update"; - -void HttpServer::start() { - server.on(FPSTR(ROUTE_STYLE_CSS), HTTP_GET, [&]() { sendGzip(files::style_css, CONTENT_TYPE_CSS); }); - server.on(FPSTR(ROUTE_APP_JS), HTTP_GET, [&]() { sendGzip(files::app_js, CONTENT_TYPE_JS); }); - server.on(FPSTR(ROUTE_MD5_JS), HTTP_GET, [&]() { sendGzip(files::md5_js, CONTENT_TYPE_JS); }); - server.on(FPSTR(ROUTE_FAVICON_ICO), HTTP_GET, [&]() { sendGzip(files::favicon_ico, CONTENT_TYPE_FAVICON); }); - - server.on("/", HTTP_GET, [&]() { sendGzip(files::index_html, CONTENT_TYPE_HTML); }); - server.on(FPSTR(ROUTE_STATUS), HTTP_GET, [&]() { - char json_buf[JSON_BUF_SIZE]; - auto cfg = config::read(); - - if (!isValid(cfg) || !cfg.flags.node_configured) { - sprintf_P(json_buf, JSON_STATUS_FMT - , CONFIG_NODE_ID -#ifdef DEBUG - , 0 - , cfg.crc - , cfg.flags.node_configured - , cfg.flags.wifi_configured -#endif - ); - } else { - char escaped_node_id[32]; - char *escaped_node_id_res = cfg.escapeHomeId(escaped_node_id, 32); - sprintf_P(json_buf, JSON_STATUS_FMT - , escaped_node_id_res == nullptr ? "?" : escaped_node_id -#ifdef DEBUG - , 1 - , cfg.crc - , cfg.flags.node_configured - , cfg.flags.wifi_configured -#endif - ); - } - server.send(200, FPSTR(CONTENT_TYPE_JSON), json_buf); - }); - server.on(FPSTR(ROUTE_STATUS), HTTP_POST, [&]() { - auto cfg = config::read(); - String s; - - if (!getInputParam("ssid", 32, s)) return; - strncpy(cfg.wifi_ssid, s.c_str(), 32); - PRINTF("saving ssid: %s\n", cfg.wifi_ssid); - - if (!getInputParam("psk", 63, s)) return; - strncpy(cfg.wifi_psk, s.c_str(), 63); - PRINTF("saving psk: %s\n", cfg.wifi_psk); - - if (!getInputParam("hid", 16, s)) return; - strcpy(cfg.node_id, s.c_str()); - PRINTF("saving home id: %s\n", cfg.node_id); - - cfg.flags.node_configured = 1; - cfg.flags.wifi_configured = 1; - - config::write(cfg); - - restartTimer.once(0, restart); - }); - - server.on(FPSTR(ROUTE_RESET), HTTP_POST, [&]() { - config::erase(); - restartTimer.once(1, restart); - }); - - server.on(FPSTR(ROUTE_HEAP), HTTP_GET, [&]() { - server.send(200, FPSTR(CONTENT_TYPE_HTML), String(ESP.getFreeHeap())); - }); - - server.on(FPSTR(ROUTE_SCAN), HTTP_GET, [&]() { - size_t i = 0; - size_t len; - const char* ssid; - bool enough = false; - - bzero(reinterpret_cast(scanBuf), scanBufSize); - char* cur = scanBuf; - - strncpy_P(cur, JSON_SCAN_FIRST_LIST, scanBufSize); - cur += 9; - - for (auto& res: *scanResults) { - ssid = res.ssid.c_str(); - len = res.ssid.length(); - - // new item (array with 2 items) - *cur++ = '['; - - // 1. ssid (string) - *cur++ = '"'; - for (size_t j = 0; j < len; j++) { - if (*(ssid+j) == '"') - *cur++ = '\\'; - *cur++ = *(ssid+j); - } - *cur++ = '"'; - *cur++ = ','; - - // 2. rssi (number) - cur += sprintf(cur, "%d", res.rssi); - - // close array - *cur++ = ']'; - - if ((size_t)(cur - scanBuf) >= (size_t) ARRAY_SIZE(scanBuf) - 40) - enough = true; - - if (i < scanResults->size() - 1 || enough) - *cur++ = ','; - - if (enough) - break; - - i++; - } - - *cur++ = ']'; - *cur++ = '}'; - *cur++ = '\0'; - - server.send(200, FPSTR(CONTENT_TYPE_JSON), scanBuf); - }); - - server.on(FPSTR(ROUTE_UPDATE), HTTP_POST, [&]() { - char json_buf[16]; - bool should_reboot = !Update.hasError() && !ota.invalidMd5; - Update.clearError(); - - sprintf_P(json_buf, JSON_UPDATE_FMT, should_reboot ? 1 : 0); - - server.send(200, FPSTR(CONTENT_TYPE_JSON), json_buf); - - if (should_reboot) - restartTimer.once(1, restart); - }, [&]() { - HTTPUpload& upload = server.upload(); - - if (upload.status == UPLOAD_FILE_START) { - ota.clean(); - - String s; - if (!getInputParam("md5", 0, s)) { - ota.invalidMd5 = true; - PRINTLN("http/ota: md5 not found"); - return; - } - - if (!Update.setMD5(s.c_str())) { - ota.invalidMd5 = true; - PRINTLN("http/ota: setMD5() failed"); - return; - } - - Serial.printf("http/ota: starting, filename=%s\n", upload.filename.c_str()); - if (!Update.begin(otaGetMaxUpdateSize())) { -#ifdef DEBUG - Update.printError(Serial); -#endif - } - } else if (upload.status == UPLOAD_FILE_WRITE) { - if (!Update.isRunning()) - return; - - PRINTF("http/ota: writing %ul\n", upload.currentSize); - ota_led(); - - if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { -#ifdef DEBUG - Update.printError(Serial); -#endif - } - } else if (upload.status == UPLOAD_FILE_END) { - if (!Update.isRunning()) - return; - - if (Update.end(true)) { - PRINTF("http/ota: ok, total size %ul\n", upload.totalSize); - } else { -#ifdef DEBUG - Update.printError(Serial); -#endif - } - } - }); - - server.onNotFound([&]() { - server.send(404, FPSTR(CONTENT_TYPE_HTML), NOT_FOUND); - }); - - server.begin(); -} - -void HttpServer::loop() { - server.handleClient(); -} - -void HttpServer::sendGzip(const StaticFile& file, PGM_P content_type) { - server.sendHeader(FPSTR(CONTENT_ENCODING), FPSTR(GZIP)); - server.send_P(200, content_type, (const char*)file.content, file.size); -} - -void HttpServer::sendError(const String& message) { - char buf[32]; - if (snprintf_P(buf, 32, PSTR("error: %s"), message.c_str()) == 32) - buf[31] = '\0'; - server.send(400, FPSTR(CONTENT_TYPE_HTML), buf); -} - -bool HttpServer::getInputParam(const char *field_name, - size_t max_len, - String& dst) { - if (!server.hasArg(field_name)) { - sendError(String(field_name) + String(MSG_IS_MISSING)); - return false; - } - - String field = server.arg(field_name); - if (!field.length() || (max_len != 0 && field.length() > max_len)) { - sendError(String(field_name) + String(MSG_IS_INVALID)); - return false; - } - - dst = field; - return true; -} - -void HttpServer::ota_led() const {} - -} diff --git a/platformio/common/libs/http_server/homekit/http_server.h b/platformio/common/libs/http_server/homekit/http_server.h deleted file mode 100644 index 8725a88..0000000 --- a/platformio/common/libs/http_server/homekit/http_server.h +++ /dev/null @@ -1,62 +0,0 @@ -#ifndef COMMON_HOMEKIT_HTTP_SERVER_H -#define COMMON_HOMEKIT_HTTP_SERVER_H - -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace homekit { - -struct OTAStatus { - bool invalidMd5; - - OTAStatus() : invalidMd5(false) {} - - inline void clean() { - invalidMd5 = false; - } -}; - -using files::StaticFile; - -class HttpServer { -private: - ESP8266WebServer server; - Ticker restartTimer; - std::shared_ptr> scanResults; - OTAStatus ota; - - char* scanBuf; - size_t scanBufSize; - - void sendGzip(const StaticFile& file, PGM_P content_type); - void sendError(const String& message); - - bool getInputParam(const char* field_name, size_t max_len, String& dst); - virtual void ota_led() const; - -public: - explicit HttpServer(std::shared_ptr> scanResults) - : server(80) - , scanResults(std::move(scanResults)) - , scanBufSize(512) { - scanBuf = new char[scanBufSize]; - }; - - ~HttpServer() { - delete[] scanBuf; - } - - void start(); - void loop(); -}; - -} - -#endif //COMMON_HOMEKIT_HTTP_SERVER_H diff --git a/platformio/common/libs/http_server/library.json b/platformio/common/libs/http_server/library.json deleted file mode 100644 index ee2d369..0000000 --- a/platformio/common/libs/http_server/library.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "homekit_http_server", - "version": "1.0.3", - "build": { - "flags": "-I../../include" - } -} - diff --git a/platformio/common/libs/led/homekit/led.cpp b/platformio/common/libs/led/homekit/led.cpp deleted file mode 100644 index ffefb04..0000000 --- a/platformio/common/libs/led/homekit/led.cpp +++ /dev/null @@ -1,27 +0,0 @@ -#include "led.h" - -namespace homekit::led { - -void Led::on_off(uint16_t delay_ms, bool last_delay) const { - on(); - delay(delay_ms); - - off(); - if (last_delay) - delay(delay_ms); -} - -void Led::blink(uint8_t count, uint16_t delay_ms) const { - for (uint8_t i = 0; i < count; i++) { - on_off(delay_ms, i < count-1); - } -} - - -#ifdef CONFIG_TARGET_NODEMCU -const Led* board_led = new Led(CONFIG_BOARD_LED_GPIO); -#endif -const Led* mcu_led = new Led(CONFIG_MCU_LED_GPIO); - - -} diff --git a/platformio/common/libs/led/homekit/led.h b/platformio/common/libs/led/homekit/led.h deleted file mode 100644 index 775d2eb..0000000 --- a/platformio/common/libs/led/homekit/led.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef HOMEKIT_LIB_LED_H -#define HOMEKIT_LIB_LED_H - -#include -#include - -namespace homekit::led { - -class Led { -private: - uint8_t _pin; - -public: - explicit Led(uint8_t pin) : _pin(pin) { - pinMode(_pin, OUTPUT); - off(); - } - - inline void off() const { digitalWrite(_pin, HIGH); } - inline void on() const { digitalWrite(_pin, LOW); } - - void on_off(uint16_t delay_ms, bool last_delay = false) const; - void blink(uint8_t count, uint16_t delay_ms) const; -}; - -#ifdef CONFIG_TARGET_NODEMCU -extern const Led* board_led; -#endif -extern const Led* mcu_led; - -} - -#endif //HOMEKIT_LIB_LED_H diff --git a/platformio/common/libs/led/library.json b/platformio/common/libs/led/library.json deleted file mode 100644 index 6785d42..0000000 --- a/platformio/common/libs/led/library.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "homekit_led", - "version": "1.0.8", - "build": { - "flags": "-I../../include" - } -} - diff --git a/platformio/common/libs/main/homekit/main.cpp b/platformio/common/libs/main/homekit/main.cpp deleted file mode 100644 index 816c764..0000000 --- a/platformio/common/libs/main/homekit/main.cpp +++ /dev/null @@ -1,213 +0,0 @@ -#include "./main.h" -#include -#include -#include -#include - -namespace homekit::main { - -#ifndef CONFIG_TARGET_ESP01 -#ifndef CONFIG_NO_RECOVERY -enum WorkingMode working_mode = WorkingMode::NORMAL; -#endif -#endif - -static const uint16_t recovery_boot_detection_ms = 2000; -static const uint8_t recovery_boot_delay_ms = 100; - -static volatile enum WiFiConnectionState wifi_state = WiFiConnectionState::WAITING; -static void* service = nullptr; -static WiFiEventHandler wifiConnectHandler, wifiDisconnectHandler; -static Ticker wifiTimer; -static mqtt::MqttDiagnosticsModule* mqttDiagModule; -static mqtt::MqttOtaModule* mqttOtaModule; - -#if MQTT_BLINK -static StopWatch blinkStopWatch; -#endif - -#ifndef CONFIG_TARGET_ESP01 -#ifndef CONFIG_NO_RECOVERY -static DNSServer* dnsServer = nullptr; -#endif -#endif - -static void onWifiConnected(const WiFiEventStationModeGotIP& event); -static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event); - -static void wifiConnect() { - const char *ssid, *psk, *hostname; - auto cfg = config::read(); - wifi::getConfig(cfg, &ssid, &psk, &hostname); - - PRINTF("Wi-Fi STA creds: ssid=%s, psk=%s, hostname=%s\n", ssid, psk, hostname); - - wifi_state = WiFiConnectionState::WAITING; - - WiFi.mode(WIFI_STA); - WiFi.hostname(hostname); - WiFi.begin(ssid, psk); - - PRINT("connecting to wifi.."); -} - -#ifndef CONFIG_TARGET_ESP01 -#ifndef CONFIG_NO_RECOVERY -static void wifiHotspot() { - led::mcu_led->on(); - - auto scanResults = wifi::scan(); - - WiFi.mode(WIFI_AP); - WiFi.softAP(wifi::AP_SSID); - - dnsServer = new DNSServer(); - dnsServer->start(53, "*", WiFi.softAPIP()); - - service = new HttpServer(scanResults); - ((HttpServer*)service)->start(); -} - -static void waitForRecoveryPress() { - pinMode(CONFIG_FLASH_GPIO, INPUT_PULLUP); - for (uint16_t i = 0; i < recovery_boot_detection_ms; i += recovery_boot_delay_ms) { - delay(recovery_boot_delay_ms); - if (digitalRead(CONFIG_FLASH_GPIO) == LOW) { - working_mode = WorkingMode::RECOVERY; - break; - } - } -} -#endif -#endif - - -void setup() { - WiFi.disconnect(); -#ifndef CONFIG_NO_RECOVERY -#ifndef CONFIG_TARGET_ESP01 - homekit::main::waitForRecoveryPress(); -#endif -#endif - -#ifdef DEBUG - Serial.begin(115200); -#endif - - auto cfg = config::read(); - if (config::isDirty(cfg)) { - PRINTLN("config is dirty, erasing..."); - config::erase(cfg); -#ifdef CONFIG_TARGET_NODEMCU - led::board_led->blink(10, 50); -#else - led::mcu_led->blink(10, 50); -#endif - } - -#ifndef CONFIG_TARGET_ESP01 -#ifndef CONFIG_NO_RECOVERY - switch (working_mode) { - case WorkingMode::RECOVERY: - wifiHotspot(); - break; - - case WorkingMode::NORMAL: -#endif -#endif - wifiConnectHandler = WiFi.onStationModeGotIP(onWifiConnected); - wifiDisconnectHandler = WiFi.onStationModeDisconnected(onWifiDisconnected); - wifiConnect(); -#ifndef CONFIG_NO_RECOVERY -#ifndef CONFIG_TARGET_ESP01 - break; - } -#endif -#endif -} - -void loop(LoopConfig* config) { -#ifndef CONFIG_NO_RECOVERY -#ifndef CONFIG_TARGET_ESP01 - if (working_mode == WorkingMode::NORMAL) { -#endif -#endif - if (wifi_state == WiFiConnectionState::WAITING) { - PRINT("."); - led::mcu_led->blink(2, 50); - delay(1000); - return; - } - - if (wifi_state == WiFiConnectionState::JUST_CONNECTED) { -#ifdef CONFIG_TARGET_NODEMCU - led::board_led->blink(3, 300); -#else - led::mcu_led->blink(3, 300); -#endif - wifi_state = WiFiConnectionState::CONNECTED; - - if (service == nullptr) { - service = new mqtt::Mqtt(); - mqttDiagModule = new mqtt::MqttDiagnosticsModule(); - mqttOtaModule = new mqtt::MqttOtaModule(); - - ((mqtt::Mqtt*)service)->addModule(mqttDiagModule); - ((mqtt::Mqtt*)service)->addModule(mqttOtaModule); - - if (config != nullptr) - config->onMqttCreated(*(mqtt::Mqtt*)service); - } - - ((mqtt::Mqtt*)service)->connect(); -#if MQTT_BLINK - blinkStopWatch.save(); -#endif - } - - auto mqtt = (mqtt::Mqtt*)service; - if (static_cast(wifi_state) >= 1 && mqtt != nullptr) { - mqtt->loop(); - - if (mqttOtaModule != nullptr && mqttOtaModule->isReadyToRestart()) { - mqtt->disconnect(); - } - -#if MQTT_BLINK - // periodically blink board led - if (blinkStopWatch.elapsed(5000)) { -#ifdef CONFIG_TARGET_NODEMCU - board_led->blink(1, 10); -#endif - blinkStopWatch.save(); - } -#endif - } -#ifndef CONFIG_NO_RECOVERY -#ifndef CONFIG_TARGET_ESP01 - } else { - if (dnsServer != nullptr) - dnsServer->processNextRequest(); - - auto httpServer = (HttpServer*)service; - if (httpServer != nullptr) - httpServer->loop(); - } -#endif -#endif -} - -static void onWifiConnected(const WiFiEventStationModeGotIP& event) { - PRINTF("connected (%s)\n", WiFi.localIP().toString().c_str()); - wifi_state = WiFiConnectionState::JUST_CONNECTED; -} - -static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event) { - PRINTLN("disconnected from wi-fi"); - wifi_state = WiFiConnectionState::WAITING; - if (service != nullptr) - ((mqtt::Mqtt*)service)->disconnect(); - wifiTimer.once(2, wifiConnect); -} - -} diff --git a/platformio/common/libs/main/homekit/main.h b/platformio/common/libs/main/homekit/main.h deleted file mode 100644 index 78a0695..0000000 --- a/platformio/common/libs/main/homekit/main.h +++ /dev/null @@ -1,52 +0,0 @@ -#ifndef HOMEKIT_LIB_MAIN_H -#define HOMEKIT_LIB_MAIN_H - -#include -#include -#include -#include -#include - -#include -#include -#ifndef CONFIG_TARGET_ESP01 -#ifndef CONFIG_NO_RECOVERY -#include -#endif -#endif -#include -#include - -#include - -namespace homekit::main { - -#ifndef CONFIG_TARGET_ESP01 -#ifndef CONFIG_NO_RECOVERY -enum class WorkingMode { - RECOVERY, // AP mode, http server with configuration - NORMAL, // MQTT client -}; - -extern enum WorkingMode working_mode; -#endif -#endif - -enum class WiFiConnectionState { - WAITING = 0, - JUST_CONNECTED = 1, - CONNECTED = 2 -}; - - -struct LoopConfig { - std::function onMqttCreated; -}; - - -void setup(); -void loop(LoopConfig* config); - -} - -#endif //HOMEKIT_LIB_MAIN_H diff --git a/platformio/common/libs/main/library.json b/platformio/common/libs/main/library.json deleted file mode 100644 index 728d4f8..0000000 --- a/platformio/common/libs/main/library.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "homekit_main", - "version": "1.0.10", - "build": { - "flags": "-I../../include" - }, - "dependencies": { - "homekit_mqtt_module_ota": "file://../common/libs/mqtt_module_ota", - "homekit_mqtt_module_diagnostics": "file://../common/libs/mqtt_module_diagnostics" - } -} - diff --git a/platformio/common/libs/mqtt/homekit/mqtt/module.cpp b/platformio/common/libs/mqtt/homekit/mqtt/module.cpp deleted file mode 100644 index 0ac7637..0000000 --- a/platformio/common/libs/mqtt/homekit/mqtt/module.cpp +++ /dev/null @@ -1,26 +0,0 @@ -#include "./module.h" -#include - -namespace homekit::mqtt { - -bool MqttModule::tickElapsed() { - if (!tickSw.elapsed(tickInterval*1000)) - return false; - - tickSw.save(); - return true; -} - -void MqttModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t* payload, size_t length, - size_t index, size_t total) { - if (length != total) - PRINTLN("mqtt: received partial message, not supported"); - - // TODO -} - -void MqttModule::handleOnPublish(uint16_t packetId) {} - -void MqttModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) {} - -} diff --git a/platformio/common/libs/mqtt/homekit/mqtt/module.h b/platformio/common/libs/mqtt/homekit/mqtt/module.h deleted file mode 100644 index 0a328f3..0000000 --- a/platformio/common/libs/mqtt/homekit/mqtt/module.h +++ /dev/null @@ -1,56 +0,0 @@ -#ifndef HOMEKIT_LIB_MQTT_MODULE_H -#define HOMEKIT_LIB_MQTT_MODULE_H - -#include "./mqtt.h" -#include "./payload.h" -#include - - -namespace homekit::mqtt { - -class Mqtt; - -class MqttModule { -protected: - bool initialized; - StopWatch tickSw; - short tickInterval; - - bool receiveOnPublish; - bool receiveOnDisconnect; - - bool tickElapsed(); - -public: - MqttModule(short _tickInterval, bool _receiveOnPublish = false, bool _receiveOnDisconnect = false) - : initialized(false) - , tickInterval(_tickInterval) - , receiveOnPublish(_receiveOnPublish) - , receiveOnDisconnect(_receiveOnDisconnect) {} - - virtual void tick(Mqtt& mqtt) = 0; - - virtual void onConnect(Mqtt& mqtt) = 0; - virtual void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason); - - virtual void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total); - virtual void handleOnPublish(uint16_t packetId); - - inline void setInitialized() { - initialized = true; - } - - inline void unsetInitialized() { - initialized = false; - } - - inline short getTickInterval() const { - return tickInterval; - } - - friend class Mqtt; -}; - -} - -#endif //HOMEKIT_LIB_MQTT_MODULE_H diff --git a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp b/platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp deleted file mode 100644 index aa769a5..0000000 --- a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.cpp +++ /dev/null @@ -1,162 +0,0 @@ -#include "./mqtt.h" - -#include -#include -#include - -namespace homekit::mqtt { - -const uint8_t MQTT_CA_FINGERPRINT[] = { \ - 0x0e, 0xb6, 0x3a, 0x02, 0x1f, \ - 0x4e, 0x1e, 0xe1, 0x6a, 0x67, \ - 0x62, 0xec, 0x64, 0xd4, 0x84, \ - 0x8a, 0xb0, 0xc9, 0x9c, 0xbb \ -};; -const char MQTT_SERVER[] = "mqtt.solarmon.ru"; -const uint16_t MQTT_PORT = 8883; -const char MQTT_USERNAME[] = CONFIG_MQTT_USERNAME; -const char MQTT_PASSWORD[] = CONFIG_MQTT_PASSWORD; -const char MQTT_CLIENT_ID[] = CONFIG_MQTT_CLIENT_ID; -const char MQTT_SECRET[CONFIG_NODE_SECRET_SIZE+1] = CONFIG_NODE_SECRET; - -static const uint16_t MQTT_KEEPALIVE = 30; - -using namespace espMqttClientTypes; - -Mqtt::Mqtt() { - auto cfg = config::read(); - nodeId = String(cfg.flags.node_configured ? cfg.node_id : wifi::NODE_ID); - - randomSeed(micros()); - - client.onConnect([&](bool sessionPresent) { - PRINTLN("mqtt: connected"); - - for (auto* module: modules) { - if (!module->initialized) { - module->onConnect(*this); - module->setInitialized(); - } - } - - connected = true; - }); - - client.onDisconnect([&](DisconnectReason reason) { - PRINTF("mqtt: disconnected, reason=%d\n", static_cast(reason)); -#ifdef DEBUG - if (reason == DisconnectReason::TLS_BAD_FINGERPRINT) - PRINTLN("reason: bad fingerprint"); -#endif - - for (auto* module: modules) { - module->onDisconnect(*this, reason); - module->unsetInitialized(); - } - - reconnectTimer.once(2, [&]() { - reconnect(); - }); - }); - - client.onSubscribe([&](uint16_t packetId, const SubscribeReturncode* returncodes, size_t len) { - PRINTF("mqtt: subscribe ack, packet_id=%d\n", packetId); - for (size_t i = 0; i < len; i++) { - PRINTF(" return code: %u\n", static_cast(*(returncodes+i))); - } - }); - - client.onUnsubscribe([&](uint16_t packetId) { - PRINTF("mqtt: unsubscribe ack, packet_id=%d\n", packetId); - }); - - client.onMessage([&](const MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) { - PRINTF("mqtt: message received, topic=%s, qos=%d, dup=%d, retain=%d, len=%ul, index=%ul, total=%ul\n", - topic, properties.qos, (int)properties.dup, (int)properties.retain, len, index, total); - - const char *ptr = topic + nodeId.length() + 4; - String relevantTopic(ptr); - - auto it = moduleSubscriptions.find(relevantTopic); - if (it != moduleSubscriptions.end()) { - auto module = it->second; - module->handlePayload(*this, relevantTopic, properties.packetId, payload, len, index, total); - } else { - PRINTF("error: module subscription for topic %s not found\n", relevantTopic.c_str()); - } - }); - - client.onPublish([&](uint16_t packetId) { - PRINTF("mqtt: publish ack, packet_id=%d\n", packetId); - - for (auto* module: modules) { - if (module->receiveOnPublish) { - module->handleOnPublish(packetId); - } - } - }); - - client.setServer(MQTT_SERVER, MQTT_PORT); - client.setClientId(MQTT_CLIENT_ID); - client.setCredentials(MQTT_USERNAME, MQTT_PASSWORD); - client.setCleanSession(true); - client.setFingerprint(MQTT_CA_FINGERPRINT); - client.setKeepAlive(MQTT_KEEPALIVE); -} - -void Mqtt::connect() { - reconnect(); -} - -void Mqtt::reconnect() { - if (client.connected()) { - PRINTLN("warning: already connected"); - return; - } - client.connect(); -} - -void Mqtt::disconnect() { - // TODO test how this works??? - reconnectTimer.detach(); - client.disconnect(); -} - -void Mqtt::loop() { - client.loop(); - for (auto& module: modules) { - if (module->getTickInterval() != 0) - module->tick(*this); - } -} - -uint16_t Mqtt::publish(const String& topic, uint8_t* payload, size_t length) { - String fullTopic = "hk/" + nodeId + "/" + topic; - return client.publish(fullTopic.c_str(), 1, false, payload, length); -} - -uint16_t Mqtt::subscribe(const String& topic, uint8_t qos) { - String fullTopic = "hk/" + nodeId + "/" + topic; - PRINTF("mqtt: subscribing to %s...\n", fullTopic.c_str()); - - uint16_t packetId = client.subscribe(fullTopic.c_str(), qos); - if (!packetId) - PRINTF("error: failed to subscribe to %s\n", fullTopic.c_str()); - - return packetId; -} - -void Mqtt::addModule(MqttModule* module) { - modules.emplace_back(module); - if (connected) { - module->onConnect(*this); - module->setInitialized(); - } -} - -void Mqtt::subscribeModule(String& topic, MqttModule* module, uint8_t qos) { - moduleSubscriptions[topic] = module; - subscribe(topic, qos); -} - -} diff --git a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.h b/platformio/common/libs/mqtt/homekit/mqtt/mqtt.h deleted file mode 100644 index 9e0c2be..0000000 --- a/platformio/common/libs/mqtt/homekit/mqtt/mqtt.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifndef HOMEKIT_LIB_MQTT_H -#define HOMEKIT_LIB_MQTT_H - -#include -#include -#include -#include -#include -#include "./module.h" - -namespace homekit::mqtt { - -extern const uint8_t MQTT_CA_FINGERPRINT[]; -extern const char MQTT_SERVER[]; -extern const uint16_t MQTT_PORT; -extern const char MQTT_USERNAME[]; -extern const char MQTT_PASSWORD[]; -extern const char MQTT_CLIENT_ID[]; -extern const char MQTT_SECRET[CONFIG_NODE_SECRET_SIZE+1]; - -class MqttModule; - -class Mqtt { -private: - String nodeId; - WiFiClientSecure httpsSecureClient; - espMqttClientSecure client; - Ticker reconnectTimer; - std::vector modules; - std::map moduleSubscriptions; - bool connected; - - uint16_t subscribe(const String& topic, uint8_t qos = 0); - -public: - Mqtt(); - void connect(); - void disconnect(); - void reconnect(); - void loop(); - void addModule(MqttModule* module); - void subscribeModule(String& topic, MqttModule* module, uint8_t qos = 0); - uint16_t publish(const String& topic, uint8_t* payload, size_t length); -}; - -} - -#endif //HOMEKIT_LIB_MQTT_H diff --git a/platformio/common/libs/mqtt/homekit/mqtt/payload.h b/platformio/common/libs/mqtt/homekit/mqtt/payload.h deleted file mode 100644 index 3e0fe0c..0000000 --- a/platformio/common/libs/mqtt/homekit/mqtt/payload.h +++ /dev/null @@ -1,15 +0,0 @@ -#ifndef HOMEKIT_MQTT_PAYLOAD_H -#define HOMEKIT_MQTT_PAYLOAD_H - -#include - -namespace homekit::mqtt { - -struct MqttPayload { - virtual ~MqttPayload() = default; - virtual size_t size() const = 0; -}; - -} - -#endif \ No newline at end of file diff --git a/platformio/common/libs/mqtt/library.json b/platformio/common/libs/mqtt/library.json deleted file mode 100644 index f3f2504..0000000 --- a/platformio/common/libs/mqtt/library.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "homekit_mqtt", - "version": "1.0.11", - "build": { - "flags": "-I../../include" - } -} diff --git a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp b/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp deleted file mode 100644 index e0f797e..0000000 --- a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp +++ /dev/null @@ -1,56 +0,0 @@ -#include "./diagnostics.h" -#include -#include - -namespace homekit::mqtt { - -static const char TOPIC_DIAGNOSTICS[] = "diag"; -static const char TOPIC_INITIAL_DIAGNOSTICS[] = "d1ag"; - -void MqttDiagnosticsModule::onConnect(Mqtt &mqtt) { - sendDiagnostics(mqtt); -} - -void MqttDiagnosticsModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) { - initialSent = false; -} - -void MqttDiagnosticsModule::tick(Mqtt& mqtt) { - if (!tickElapsed()) - return; - sendDiagnostics(mqtt); -} - -void MqttDiagnosticsModule::sendDiagnostics(Mqtt& mqtt) { - auto cfg = config::read(); - - if (!initialSent) { - MqttInitialDiagnosticsPayload stat{ - .ip = wifi::getIPAsInteger(), - .fw_version = CONFIG_FW_VERSION, - .rssi = wifi::getRSSI(), - .free_heap = ESP.getFreeHeap(), - .flags = DiagnosticsFlags{ - .state = 1, - .config_changed_value_present = 1, - .config_changed = static_cast(cfg.flags.node_configured || - cfg.flags.wifi_configured ? 1 : 0) - } - }; - mqtt.publish(TOPIC_INITIAL_DIAGNOSTICS, reinterpret_cast(&stat), sizeof(stat)); - initialSent = true; - } else { - MqttDiagnosticsPayload stat{ - .rssi = wifi::getRSSI(), - .free_heap = ESP.getFreeHeap(), - .flags = DiagnosticsFlags{ - .state = 1, - .config_changed_value_present = 0, - .config_changed = 0 - } - }; - mqtt.publish(TOPIC_DIAGNOSTICS, reinterpret_cast(&stat), sizeof(stat)); - } -} - -} diff --git a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h b/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h deleted file mode 100644 index bb7a81a..0000000 --- a/platformio/common/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h +++ /dev/null @@ -1,49 +0,0 @@ -#ifndef HOMEKIT_LIB_MQTT_MODULE_DIAGNOSTICS_H -#define HOMEKIT_LIB_MQTT_MODULE_DIAGNOSTICS_H - -#include -#include - -namespace homekit::mqtt { - -struct DiagnosticsFlags { - uint8_t state: 1; - uint8_t config_changed_value_present: 1; - uint8_t config_changed: 1; - uint8_t reserved: 5; -} __attribute__((packed)); - -struct MqttInitialDiagnosticsPayload { - uint32_t ip; - uint8_t fw_version; - int8_t rssi; - uint32_t free_heap; - DiagnosticsFlags flags; -} __attribute__((packed)); - -struct MqttDiagnosticsPayload { - int8_t rssi; - uint32_t free_heap; - DiagnosticsFlags flags; -} __attribute__((packed)); - - -class MqttDiagnosticsModule: public MqttModule { -private: - bool initialSent; - - void sendDiagnostics(Mqtt& mqtt); - -public: - MqttDiagnosticsModule() - : MqttModule(30) - , initialSent(false) {} - - void onConnect(Mqtt& mqtt) override; - void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; - void tick(Mqtt& mqtt) override; -}; - -} - -#endif //HOMEKIT_LIB_MQTT_MODULE_DIAGNOSTICS_H diff --git a/platformio/common/libs/mqtt_module_diagnostics/library.json b/platformio/common/libs/mqtt_module_diagnostics/library.json deleted file mode 100644 index a3d3244..0000000 --- a/platformio/common/libs/mqtt_module_diagnostics/library.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "homekit_mqtt_module_diagnostics", - "version": "1.0.2", - "build": { - "flags": "-I../../include" - }, - "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt" - } -} diff --git a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp b/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp deleted file mode 100644 index 4e976cd..0000000 --- a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp +++ /dev/null @@ -1,160 +0,0 @@ -#include "./ota.h" -#include -#include -#include - -namespace homekit::mqtt { - -using homekit::led::mcu_led; - -#define MD5_SIZE 16 - -static const char TOPIC_OTA[] = "ota"; -static const char TOPIC_OTA_RESPONSE[] = "otares"; - -void MqttOtaModule::onConnect(Mqtt& mqtt) { - String topic(TOPIC_OTA); - mqtt.subscribeModule(topic, this); -} - -void MqttOtaModule::tick(Mqtt& mqtt) { - if (!tickElapsed()) - return; -} - -void MqttOtaModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) { - char md5[33]; - char* md5Ptr = md5; - - if (index != 0 && ota.dataPacketId != packetId) { - PRINTLN("mqtt/ota: non-matching packet id"); - return; - } - - Update.runAsync(true); - - if (index == 0) { - if (length < CONFIG_NODE_SECRET_SIZE + MD5_SIZE) { - PRINTLN("mqtt/ota: failed to check secret, first packet size is too small"); - return; - } - - if (memcmp((const char*)payload, CONFIG_NODE_SECRET, CONFIG_NODE_SECRET_SIZE) != 0) { - PRINTLN("mqtt/ota: invalid secret"); - return; - } - - PRINTF("mqtt/ota: starting update, total=%ul\n", total-CONFIG_NODE_SECRET_SIZE); - for (int i = 0; i < MD5_SIZE; i++) { - md5Ptr += sprintf(md5Ptr, "%02x", *((unsigned char*)(payload+CONFIG_NODE_SECRET_SIZE+i))); - } - md5[32] = '\0'; - PRINTF("mqtt/ota: md5 is %s\n", md5); - PRINTF("mqtt/ota: first packet is %ul bytes length\n", length); - - md5[32] = '\0'; - - if (Update.isRunning()) { - Update.end(); - Update.clearError(); - } - - if (!Update.setMD5(md5)) { - PRINTLN("mqtt/ota: setMD5 failed"); - return; - } - - ota.dataPacketId = packetId; - - if (!Update.begin(total - CONFIG_NODE_SECRET_SIZE - MD5_SIZE)) { - ota.clean(); -#ifdef DEBUG - Update.printError(Serial); -#endif - sendResponse(mqtt, OtaResult::UPDATE_ERROR, Update.getError()); - } - - ota.written = Update.write(const_cast(payload)+CONFIG_NODE_SECRET_SIZE + MD5_SIZE, length-CONFIG_NODE_SECRET_SIZE - MD5_SIZE); - ota.written += CONFIG_NODE_SECRET_SIZE + MD5_SIZE; - - mcu_led->blink(1, 1); - PRINTF("mqtt/ota: updating %u/%u\n", ota.written, Update.size()); - - } else { - if (!Update.isRunning()) { - PRINTLN("mqtt/ota: update is not running"); - return; - } - - if (index == ota.written) { - size_t written; - if ((written = Update.write(const_cast(payload), length)) != length) { - PRINTF("mqtt/ota: error: tried to write %ul bytes, write() returned %ul\n", - length, written); - ota.clean(); - Update.end(); - Update.clearError(); - sendResponse(mqtt, OtaResult::WRITE_ERROR); - return; - } - ota.written += length; - - mcu_led->blink(1, 1); - PRINTF("mqtt/ota: updating %u/%u\n", - ota.written - CONFIG_NODE_SECRET_SIZE - MD5_SIZE, - Update.size()); - } else { - PRINTF("mqtt/ota: position is invalid, expected %ul, got %ul\n", ota.written, index); - ota.clean(); - Update.end(); - Update.clearError(); - } - } - - if (Update.isFinished()) { - ota.dataPacketId = 0; - - if (Update.end()) { - ota.finished = true; - ota.publishResultPacketId = sendResponse(mqtt, OtaResult::OK); - PRINTF("mqtt/ota: ok, otares packet_id=%d\n", ota.publishResultPacketId); - } else { - ota.clean(); - - PRINTF("mqtt/ota: error: %u\n", Update.getError()); -#ifdef DEBUG - Update.printError(Serial); -#endif - Update.clearError(); - - sendResponse(mqtt, OtaResult::UPDATE_ERROR, Update.getError()); - } - } -} - -uint16_t MqttOtaModule::sendResponse(Mqtt& mqtt, OtaResult status, uint8_t error_code) const { - MqttOtaResponsePayload resp{ - .status = status, - .error_code = error_code - }; - return mqtt.publish(TOPIC_OTA_RESPONSE, reinterpret_cast(&resp), sizeof(resp)); -} - -void MqttOtaModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) { - if (ota.readyToRestart) { - restartTimer.once(1, restart); - } else if (ota.started()) { - PRINTLN("mqtt: update was in progress, canceling.."); - ota.clean(); - Update.end(); - Update.clearError(); - } -} - -void MqttOtaModule::handleOnPublish(uint16_t packetId) { - if (ota.finished && packetId == ota.publishResultPacketId) { - ota.readyToRestart = true; - } -} - -} diff --git a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h b/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h deleted file mode 100644 index df4f7ce..0000000 --- a/platformio/common/libs/mqtt_module_ota/homekit/mqtt/module/ota.h +++ /dev/null @@ -1,75 +0,0 @@ -#ifndef HOMEKIT_LIB_MQTT_MODULE_OTA_H -#define HOMEKIT_LIB_MQTT_MODULE_OTA_H - -#include -#include -#include - -namespace homekit::mqtt { - -enum class OtaResult: uint8_t { - OK = 0, - UPDATE_ERROR = 1, - WRITE_ERROR = 2, -}; - -struct OtaStatus { - uint16_t dataPacketId; - uint16_t publishResultPacketId; - bool finished; - bool readyToRestart; - size_t written; - - OtaStatus() - : dataPacketId(0) - , publishResultPacketId(0) - , finished(false) - , readyToRestart(false) - , written(0) - {} - - inline void clean() { - dataPacketId = 0; - publishResultPacketId = 0; - finished = false; - readyToRestart = false; - written = 0; - } - - inline bool started() const { - return dataPacketId != 0; - } -}; - -struct MqttOtaResponsePayload { - OtaResult status; - uint8_t error_code; -} __attribute__((packed)); - - -class MqttOtaModule: public MqttModule { -private: - OtaStatus ota; - Ticker restartTimer; - - uint16_t sendResponse(Mqtt& mqtt, OtaResult status, uint8_t error_code = 0) const; - -public: - MqttOtaModule() : MqttModule(0, true, true) {} - - void onConnect(Mqtt& mqtt) override; - void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; - - void tick(Mqtt& mqtt) override; - - void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; - void handleOnPublish(uint16_t packetId) override; - - inline bool isReadyToRestart() const { - return ota.readyToRestart; - } -}; - -} - -#endif //HOMEKIT_LIB_MQTT_MODULE_OTA_H diff --git a/platformio/common/libs/mqtt_module_ota/library.json b/platformio/common/libs/mqtt_module_ota/library.json deleted file mode 100644 index 4f40a47..0000000 --- a/platformio/common/libs/mqtt_module_ota/library.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "homekit_mqtt_module_ota", - "version": "1.0.5", - "build": { - "flags": "-I../../include" - }, - "dependencies": { - "homekit_led": "file://../common/libs/led", - "homekit_mqtt": "file://../common/libs/mqtt" - } -} diff --git a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp b/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp deleted file mode 100644 index 90c57f9..0000000 --- a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp +++ /dev/null @@ -1,58 +0,0 @@ -#include "./relay.h" -#include -#include - -namespace homekit::mqtt { - -static const char TOPIC_RELAY_SWITCH[] = "relay/switch"; -static const char TOPIC_RELAY_STATUS[] = "relay/status"; - -void MqttRelayModule::onConnect(Mqtt &mqtt) { - String topic(TOPIC_RELAY_SWITCH); - mqtt.subscribeModule(topic, this, 1); -} - -void MqttRelayModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) { -#ifdef CONFIG_RELAY_OFF_ON_DISCONNECT - if (relay::state()) { - relay::off(); - } -#endif -} - -void MqttRelayModule::tick(homekit::mqtt::Mqtt& mqtt) {} - -void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) { - if (topic != TOPIC_RELAY_SWITCH) - return; - - if (length != sizeof(MqttRelaySwitchPayload)) { - PRINTF("error: size of payload (%ul) does not match expected (%ul)\n", - length, sizeof(MqttRelaySwitchPayload)); - return; - } - - auto pd = reinterpret_cast(payload); - if (strncmp(pd->secret, MQTT_SECRET, sizeof(pd->secret)) != 0) { - PRINTLN("error: invalid secret"); - return; - } - - MqttRelayStatusPayload resp{}; - - if (pd->state == 1) { - PRINTLN("mqtt: turning relay on"); - relay::on(); - } else if (pd->state == 0) { - PRINTLN("mqtt: turning relay off"); - relay::off(); - } else { - PRINTLN("error: unexpected state value"); - } - - resp.opened = relay::state(); - mqtt.publish(TOPIC_RELAY_STATUS, reinterpret_cast(&resp), sizeof(resp)); -} - -} - diff --git a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h b/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h deleted file mode 100644 index e245527..0000000 --- a/platformio/common/libs/mqtt_module_relay/homekit/mqtt/module/relay.h +++ /dev/null @@ -1,29 +0,0 @@ -#ifndef HOMEKIT_LIB_MQTT_MODULE_RELAY_H -#define HOMEKIT_LIB_MQTT_MODULE_RELAY_H - -#include - -namespace homekit::mqtt { - -struct MqttRelaySwitchPayload { - char secret[12]; - uint8_t state; -} __attribute__((packed)); - -struct MqttRelayStatusPayload { - uint8_t opened; -} __attribute__((packed)); - -class MqttRelayModule : public MqttModule { -public: - MqttRelayModule() : MqttModule(0) {} - void onConnect(Mqtt& mqtt) override; - void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override; - void tick(Mqtt& mqtt) override; - void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override; -}; - -} - -#endif //HOMEKIT_LIB_MQTT_MODULE_RELAY_H - diff --git a/platformio/common/libs/mqtt_module_relay/library.json b/platformio/common/libs/mqtt_module_relay/library.json deleted file mode 100644 index 6cbbfb0..0000000 --- a/platformio/common/libs/mqtt_module_relay/library.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "homekit_mqtt_module_relay", - "version": "1.0.5", - "build": { - "flags": "-I../../include" - }, - "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt", - "homekit_relay": "file://../common/libs/relay" - } -} diff --git a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp b/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp deleted file mode 100644 index 409f38f..0000000 --- a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "temphum.h" - -namespace homekit::mqtt { - -static const char TOPIC_TEMPHUM_DATA[] = "temphum/data"; - -void MqttTemphumModule::onConnect(Mqtt &mqtt) {} - -void MqttTemphumModule::tick(homekit::mqtt::Mqtt& mqtt) { - if (!tickElapsed()) - return; - - temphum::SensorData sd = sensor->read(); - MqttTemphumPayload payload { - .temp = sd.temp, - .rh = sd.rh, - .error = sd.error - }; - - mqtt.publish(TOPIC_TEMPHUM_DATA, reinterpret_cast(&payload), sizeof(payload)); -} - -} diff --git a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h b/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h deleted file mode 100644 index 7b28afc..0000000 --- a/platformio/common/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifndef HOMEKIT_LIB_MQTT_MODULE_TEMPHUM_H -#define HOMEKIT_LIB_MQTT_MODULE_TEMPHUM_H - -#include -#include - -namespace homekit::mqtt { - -struct MqttTemphumPayload { - double temp = 0; - double rh = 0; - uint8_t error = 0; -} __attribute__((packed)); - - -class MqttTemphumModule : public MqttModule { -private: - temphum::Sensor* sensor; - -public: - MqttTemphumModule(temphum::Sensor* _sensor) : MqttModule(10), sensor(_sensor) {} - void onConnect(Mqtt& mqtt) override; - void tick(Mqtt& mqtt) override; -}; - -} - -#endif //HOMEKIT_LIB_MQTT_MODULE_TEMPHUM_H diff --git a/platformio/common/libs/mqtt_module_temphum/library.json b/platformio/common/libs/mqtt_module_temphum/library.json deleted file mode 100644 index 068debd..0000000 --- a/platformio/common/libs/mqtt_module_temphum/library.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "homekit_mqtt_module_temphum", - "version": "1.0.10", - "build": { - "flags": "-I../../include" - }, - "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt", - "homekit_temphum": "file://../common/libs/temphum" - } -} diff --git a/platformio/common/libs/relay/homekit/relay.cpp b/platformio/common/libs/relay/homekit/relay.cpp deleted file mode 100644 index b00a7a2..0000000 --- a/platformio/common/libs/relay/homekit/relay.cpp +++ /dev/null @@ -1,22 +0,0 @@ -#include -#include "./relay.h" - -namespace homekit::relay { - -void init() { - pinMode(CONFIG_RELAY_GPIO, OUTPUT); -} - -bool state() { - return digitalRead(CONFIG_RELAY_GPIO) == HIGH; -} - -void on() { - digitalWrite(CONFIG_RELAY_GPIO, HIGH); -} - -void off() { - digitalWrite(CONFIG_RELAY_GPIO, LOW); -} - -} diff --git a/platformio/common/libs/relay/homekit/relay.h b/platformio/common/libs/relay/homekit/relay.h deleted file mode 100644 index 288cc05..0000000 --- a/platformio/common/libs/relay/homekit/relay.h +++ /dev/null @@ -1,13 +0,0 @@ -#ifndef HOMEKIT_LIB_RELAY_H -#define HOMEKIT_LIB_RELAY_H - -namespace homekit::relay { - -void init(); -bool state(); -void on(); -void off(); - -} - -#endif //HOMEKIT_LIB_RELAY_H diff --git a/platformio/common/libs/relay/library.json b/platformio/common/libs/relay/library.json deleted file mode 100644 index e878248..0000000 --- a/platformio/common/libs/relay/library.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "homekit_relay", - "version": "1.0.0", - "build": { - "flags": "-I../../include" - } -} - diff --git a/platformio/common/libs/static/homekit/static.cpp b/platformio/common/libs/static/homekit/static.cpp deleted file mode 100644 index 366a09f..0000000 --- a/platformio/common/libs/static/homekit/static.cpp +++ /dev/null @@ -1,450 +0,0 @@ -/** - * This file is autogenerated with make_static.sh script - */ - -#include "static.h" - -namespace homekit::files { - -static const uint8_t index_html_content[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x9d, 0x56, 0x4d, 0x6f, 0xdb, 0x38, - 0x10, 0xbd, 0xe7, 0x57, 0xb0, 0x3c, 0x14, 0x09, 0x10, 0x4b, 0x9b, 0x14, 0xcd, 0x16, 0xad, 0x24, - 0xa0, 0xd8, 0x76, 0xb1, 0x05, 0x7a, 0x08, 0x6a, 0x14, 0x0b, 0xec, 0xc5, 0xa0, 0xa8, 0x91, 0xc5, - 0x9a, 0x22, 0x59, 0x71, 0x24, 0xc7, 0xfd, 0xf5, 0x1d, 0x52, 0x92, 0x3f, 0xb2, 0x46, 0xfa, 0x71, - 0xb1, 0x34, 0xc3, 0x99, 0x37, 0x6f, 0x1e, 0x87, 0xa2, 0xb3, 0x67, 0x95, 0x95, 0xb8, 0x73, 0xc0, - 0x1a, 0x6c, 0x75, 0x71, 0x91, 0x85, 0x07, 0xd3, 0xc2, 0xac, 0x73, 0x0e, 0x86, 0x07, 0x07, 0x88, - 0x8a, 0x1e, 0x2d, 0xa0, 0xa0, 0x18, 0x74, 0x0b, 0xf8, 0xda, 0xab, 0x21, 0xe7, 0xd2, 0x1a, 0x04, - 0x83, 0x8b, 0x90, 0xcc, 0xd9, 0x64, 0xe5, 0x1c, 0xe1, 0x01, 0xd3, 0x00, 0xf2, 0x86, 0xc9, 0x46, - 0x74, 0x1e, 0x30, 0xef, 0xb1, 0x5e, 0xbc, 0xe2, 0x33, 0x86, 0x11, 0x2d, 0xe4, 0x7c, 0x50, 0xb0, - 0x75, 0xb6, 0xc3, 0xa3, 0xcc, 0xad, 0xaa, 0xb0, 0xc9, 0x2b, 0x18, 0x94, 0x84, 0x45, 0x34, 0xae, - 0x95, 0x51, 0xa8, 0x84, 0x5e, 0x78, 0x29, 0x34, 0xe4, 0x37, 0xd7, 0x2d, 0x39, 0xda, 0xbe, 0x3d, - 0xd8, 0xe2, 0xe1, 0xc4, 0xee, 0x3d, 0x74, 0xd1, 0x10, 0x25, 0xd9, 0xc6, 0x86, 0xa2, 0xa8, 0x50, - 0x43, 0xf1, 0x97, 0x35, 0xb5, 0x5a, 0xf7, 0x9d, 0x40, 0x65, 0x4d, 0x96, 0x8e, 0xce, 0x8b, 0x4c, - 0x2b, 0xb3, 0x61, 0x1d, 0xe8, 0x9c, 0xfb, 0x86, 0xd8, 0xc8, 0x1e, 0x99, 0x22, 0x42, 0x9c, 0x35, - 0x1d, 0xd4, 0x39, 0x4f, 0x6b, 0x31, 0x04, 0x3b, 0xa1, 0x1f, 0xce, 0x42, 0xa7, 0x39, 0x57, 0xad, - 0x58, 0x43, 0xfa, 0xb0, 0x88, 0x71, 0xa7, 0x10, 0xb8, 0xd3, 0xe0, 0x1b, 0x00, 0x9c, 0x63, 0xa3, - 0x18, 0xd2, 0xfb, 0x3d, 0x5e, 0x0c, 0x49, 0x82, 0x87, 0x32, 0xbd, 0xec, 0x94, 0x43, 0xe6, 0x3b, - 0x49, 0x2b, 0x6d, 0xf5, 0x32, 0xf9, 0x42, 0xee, 0x2c, 0x1d, 0xdd, 0x8f, 0xd7, 0x85, 0x73, 0x8f, - 0xd7, 0xd3, 0x69, 0x6b, 0x4a, 0x5b, 0xed, 0x98, 0x35, 0xda, 0x8a, 0x8a, 0xe8, 0x91, 0x64, 0x6f, - 0x9d, 0xbb, 0xbc, 0x0a, 0x15, 0x2a, 0x35, 0x30, 0xa9, 0x85, 0xf7, 0x44, 0x25, 0x74, 0xcc, 0x8b, - 0x25, 0x20, 0x2a, 0xb3, 0xf6, 0x2c, 0xf3, 0x4e, 0x18, 0xa6, 0x28, 0x23, 0xe4, 0x91, 0x6b, 0x45, - 0xa2, 0x81, 0xe6, 0xc5, 0xe5, 0x64, 0x27, 0x49, 0x72, 0x45, 0xc5, 0x28, 0x8a, 0x6a, 0x12, 0xd0, - 0x29, 0x5c, 0xa9, 0xad, 0xdc, 0x84, 0x12, 0xb5, 0xed, 0x5a, 0x46, 0x1b, 0xdb, 0x58, 0x82, 0x72, - 0xd6, 0x53, 0xef, 0x42, 0x06, 0x91, 0x63, 0xb7, 0x02, 0x7b, 0x6a, 0x7e, 0xdc, 0x72, 0x03, 0xb8, - 0xb5, 0xdd, 0x66, 0xe5, 0x27, 0x0a, 0x8f, 0x08, 0x06, 0xa0, 0x99, 0xc3, 0xbf, 0xea, 0x6f, 0xc5, - 0x96, 0xcb, 0x0f, 0xef, 0xce, 0x54, 0x8e, 0x71, 0xca, 0xb8, 0x1e, 0xa3, 0x86, 0xa0, 0x41, 0x62, - 0xec, 0xc3, 0x7b, 0x55, 0xad, 0x46, 0x7b, 0x2e, 0x19, 0x5c, 0x7c, 0x9f, 0xd8, 0x6b, 0x3d, 0xce, - 0x55, 0x48, 0xb4, 0x2e, 0x90, 0x64, 0x83, 0xd0, 0x3d, 0x05, 0xf2, 0xe2, 0xe3, 0xbe, 0xeb, 0x2c, - 0x1d, 0xd7, 0x82, 0xc2, 0x23, 0x5c, 0x78, 0x3b, 0xcf, 0xe3, 0x98, 0xef, 0x3d, 0xb9, 0xa9, 0xc1, - 0xea, 0x87, 0x9c, 0xe3, 0xcb, 0x34, 0x21, 0x6e, 0x4a, 0xe2, 0x7b, 0x26, 0x13, 0x75, 0xe7, 0x37, - 0xe7, 0x98, 0xc7, 0x4e, 0x6b, 0x5d, 0xad, 0xe2, 0x3a, 0xcd, 0xbf, 0x06, 0xb3, 0xa6, 0x63, 0xc3, - 0xef, 0x5e, 0x70, 0x56, 0x29, 0x1f, 0x06, 0xbf, 0x3a, 0x53, 0xdc, 0xf7, 0xe5, 0xc4, 0x95, 0x26, - 0x36, 0xbc, 0x30, 0x72, 0xc7, 0xa9, 0xdf, 0x46, 0xa8, 0xe2, 0x84, 0x95, 0x6c, 0x40, 0x6e, 0x4a, - 0xfb, 0xb0, 0xd7, 0x71, 0x0e, 0x1b, 0x85, 0xde, 0x27, 0xb1, 0xf0, 0xca, 0xdc, 0xbe, 0xf1, 0x88, - 0x7c, 0x50, 0xeb, 0x69, 0xd1, 0xfe, 0xb1, 0x2d, 0xb0, 0x9f, 0xd8, 0xe2, 0x63, 0x62, 0xe1, 0x40, - 0x1d, 0x49, 0x75, 0xd4, 0xff, 0xcd, 0xdd, 0x4c, 0xb6, 0x09, 0x7b, 0x3e, 0xcb, 0xd4, 0x9c, 0x1f, - 0x80, 0x63, 0xa9, 0xa6, 0xfa, 0x65, 0x8f, 0x48, 0x03, 0x31, 0xd6, 0x21, 0xb9, 0x5a, 0x85, 0x87, - 0xb0, 0x59, 0x87, 0xd1, 0x5d, 0x2c, 0xc5, 0x00, 0x4c, 0x98, 0x8a, 0x7d, 0x82, 0xd2, 0x5a, 0xcc, - 0xd2, 0x31, 0x39, 0x80, 0x05, 0xee, 0x67, 0x5b, 0x9f, 0x0e, 0xe0, 0x67, 0x57, 0x09, 0x04, 0x56, - 0xab, 0xae, 0xdd, 0x8a, 0x0e, 0xd8, 0x65, 0x52, 0x2a, 0x73, 0xf5, 0xbb, 0x27, 0xac, 0x8f, 0x68, - 0x9c, 0x81, 0x91, 0x23, 0xf1, 0xb6, 0xd7, 0xa8, 0x9c, 0xe8, 0x30, 0x12, 0x59, 0xd0, 0xaa, 0x98, - 0x75, 0x19, 0x63, 0x9f, 0x3c, 0x7e, 0x67, 0x35, 0xaf, 0x15, 0xf1, 0xa6, 0x92, 0x12, 0x1c, 0x7d, - 0xa5, 0x03, 0xdd, 0xeb, 0xf0, 0x93, 0xac, 0xbf, 0xcd, 0xc8, 0x31, 0xe2, 0x07, 0x4a, 0x9e, 0x08, - 0x78, 0x90, 0xff, 0xb3, 0x0b, 0x9f, 0x9b, 0x5f, 0x11, 0xf0, 0x13, 0x50, 0x07, 0x6c, 0xee, 0xe2, - 0x77, 0x85, 0xeb, 0x02, 0x0a, 0xff, 0x39, 0xb2, 0x13, 0xae, 0xf2, 0xab, 0x29, 0x2b, 0x52, 0xf8, - 0x15, 0xce, 0x1f, 0x4c, 0x6d, 0x9f, 0x60, 0xfa, 0x7e, 0x79, 0xff, 0xea, 0xf6, 0xee, 0x6e, 0x51, - 0x0a, 0x4f, 0xa3, 0x96, 0x95, 0x05, 0x5d, 0x27, 0x62, 0x27, 0x51, 0x53, 0x8d, 0xe2, 0xfa, 0x30, - 0x2b, 0xc3, 0x9f, 0x59, 0xd9, 0x15, 0x17, 0xf7, 0xb4, 0xbd, 0xcc, 0xd6, 0x2c, 0x13, 0xd3, 0xb5, - 0x12, 0xae, 0x65, 0xff, 0x3a, 0x4d, 0xd7, 0x0a, 0x13, 0xd9, 0xdc, 0xb8, 0x44, 0xd9, 0xb4, 0xa1, - 0xd3, 0xb5, 0x21, 0x9b, 0x7c, 0x29, 0x2f, 0x26, 0x2b, 0x4b, 0x45, 0xc1, 0xca, 0xdd, 0xff, 0x33, - 0xa7, 0x2c, 0x5e, 0xbc, 0x1f, 0xd6, 0x60, 0x76, 0xec, 0x3f, 0x65, 0x2c, 0x5d, 0xd1, 0x43, 0x4c, - 0x78, 0x2e, 0xad, 0xdb, 0xbd, 0x61, 0xb7, 0x7f, 0xdc, 0xde, 0x1e, 0x8e, 0x76, 0xb8, 0x74, 0xe2, - 0x1d, 0x14, 0xff, 0x36, 0x7c, 0x07, 0x90, 0xb9, 0x94, 0x17, 0x47, 0x08, 0x00, 0x00, -}; -const StaticFile index_html PROGMEM = {(sizeof(index_html_content)/sizeof(index_html_content[0])), index_html_content}; - -static const uint8_t app_js_content[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x9d, 0x57, 0x6d, 0x6f, 0xdb, 0x46, - 0x12, 0xfe, 0xde, 0x5f, 0x41, 0x2d, 0x70, 0x06, 0xf7, 0x44, 0xd3, 0x2f, 0xd7, 0x02, 0x85, 0x18, - 0x42, 0x48, 0xda, 0xe4, 0x92, 0x22, 0xa9, 0x8b, 0x24, 0x57, 0x1c, 0x60, 0xf8, 0x82, 0x95, 0x38, - 0xb2, 0x18, 0x53, 0xbb, 0xec, 0xee, 0xd2, 0xb2, 0x4f, 0x11, 0x70, 0x69, 0x0a, 0xb4, 0x40, 0x0b, - 0x04, 0xe8, 0xf7, 0xcb, 0xa7, 0xfe, 0x00, 0x37, 0x77, 0xbe, 0x4b, 0x2e, 0x4d, 0xfa, 0x17, 0xa8, - 0x7f, 0xd4, 0x99, 0x25, 0x29, 0xd1, 0xb2, 0x81, 0x06, 0xf7, 0xc1, 0x12, 0xb5, 0x9c, 0x9d, 0x9d, - 0x79, 0x66, 0x9e, 0x67, 0xc7, 0xa3, 0x42, 0x0e, 0x6d, 0xaa, 0xa4, 0x07, 0x3e, 0xf0, 0x99, 0x06, - 0x5b, 0x68, 0xc9, 0xf6, 0xd5, 0xe0, 0x31, 0x0c, 0xad, 0xb7, 0xe7, 0xbe, 0x0e, 0x58, 0x1c, 0xc7, - 0xd5, 0x63, 0x98, 0x6b, 0x65, 0x95, 0x3d, 0xcd, 0x21, 0xb4, 0xea, 0x81, 0xd5, 0xa9, 0x3c, 0x0c, - 0x87, 0x22, 0xcb, 0x70, 0xef, 0x7c, 0xd4, 0x78, 0xb2, 0x2b, 0x4f, 0x5e, 0xa2, 0x86, 0xc5, 0x04, - 0xa4, 0x0d, 0x0f, 0xc1, 0xde, 0xcc, 0x80, 0x1e, 0x6f, 0x9c, 0xde, 0x49, 0x2e, 0xd8, 0x4b, 0xb2, - 0x87, 0xd0, 0xd8, 0xd3, 0x0c, 0xc2, 0x24, 0x35, 0x79, 0x26, 0x4e, 0x63, 0x26, 0x95, 0x04, 0xb6, - 0x32, 0xd2, 0x2d, 0xa7, 0x80, 0x61, 0xc0, 0x31, 0xba, 0xfa, 0x14, 0x46, 0xa2, 0xc8, 0xec, 0xc6, - 0xc6, 0xfa, 0x8a, 0xcf, 0x03, 0x72, 0xa8, 0xf2, 0x2f, 0xb4, 0xca, 0xc5, 0xa1, 0x20, 0x17, 0x64, - 0xb5, 0xb6, 0xe4, 0xcc, 0x86, 0x42, 0x0e, 0x21, 0xbb, 0x51, 0x0c, 0x06, 0x19, 0xc4, 0x9d, 0x6d, - 0x5c, 0xa9, 0x8e, 0xf9, 0x52, 0x64, 0x05, 0x2e, 0xec, 0x04, 0x9d, 0x9d, 0x55, 0x18, 0x69, 0x3b, - 0x0c, 0x2f, 0x95, 0xc6, 0xd2, 0x6e, 0x35, 0xf2, 0x6e, 0x6a, 0xad, 0x74, 0x1f, 0xc2, 0x09, 0x18, - 0x23, 0x0e, 0xa1, 0x07, 0x5d, 0xd6, 0x8a, 0x5e, 0xd5, 0x29, 0x82, 0xbd, 0x6e, 0x11, 0xb5, 0x41, - 0x61, 0xc1, 0x67, 0x98, 0xaa, 0xc0, 0x33, 0x13, 0x16, 0xac, 0x1e, 0x5b, 0xb0, 0x88, 0x6a, 0x8f, - 0x86, 0x89, 0x3a, 0x86, 0xab, 0xb6, 0xb5, 0x6c, 0x8d, 0xcf, 0x67, 0xa3, 0x56, 0x25, 0x03, 0xcb, - 0x67, 0xb2, 0xc8, 0xb2, 0x4e, 0x1c, 0x23, 0x3a, 0x3e, 0x84, 0xc7, 0x2e, 0x19, 0xcb, 0x03, 0xf2, - 0x3a, 0x3f, 0x16, 0xda, 0x33, 0xf1, 0x76, 0xb4, 0xdc, 0x52, 0xe0, 0xfe, 0x5d, 0xac, 0x72, 0xb7, - 0x6b, 0x36, 0x36, 0xa4, 0x6f, 0x7d, 0x96, 0x29, 0x91, 0x60, 0x75, 0x1f, 0x65, 0x62, 0x00, 0x19, - 0xe3, 0xd5, 0x9e, 0x24, 0x5e, 0xd6, 0x73, 0xa4, 0xf4, 0xc4, 0x84, 0x12, 0xec, 0x54, 0xe9, 0xa3, - 0x47, 0x98, 0x98, 0x45, 0x6b, 0x13, 0x25, 0xa1, 0x48, 0x92, 0x9b, 0x54, 0x89, 0xbb, 0xa9, 0xb1, - 0x20, 0x41, 0xfb, 0xcc, 0x14, 0x83, 0x49, 0x6a, 0x59, 0xe0, 0x37, 0xc7, 0xb5, 0xfb, 0x23, 0x1c, - 0xa7, 0x49, 0x15, 0x5d, 0x88, 0x19, 0x4e, 0x7c, 0xde, 0x4f, 0xc2, 0xdc, 0x1c, 0xd5, 0x4b, 0x19, - 0xc8, 0x43, 0x3b, 0xbe, 0xf6, 0x71, 0xdf, 0x17, 0x19, 0x68, 0x0c, 0xab, 0xfc, 0x67, 0x79, 0x5e, - 0xbe, 0x2c, 0xcf, 0x17, 0xff, 0x28, 0xdf, 0x2e, 0xbe, 0x2f, 0x5f, 0x7b, 0xe5, 0xaf, 0xe5, 0x19, - 0xfe, 0x78, 0x57, 0xbe, 0x59, 0xfc, 0xe0, 0xf9, 0xe5, 0x2f, 0xe5, 0xab, 0xf2, 0x2d, 0xfe, 0xfd, - 0x52, 0x9e, 0xd1, 0x0a, 0x3e, 0x9f, 0x2d, 0x9e, 0x7b, 0xe5, 0xbf, 0xcb, 0x37, 0xee, 0xc5, 0x99, - 0xb7, 0xe9, 0x7d, 0xec, 0x2d, 0x9e, 0x3a, 0x8b, 0x97, 0xb4, 0x0b, 0xff, 0x5e, 0x72, 0xc6, 0x03, - 0xea, 0x2f, 0xde, 0xdb, 0xdc, 0x41, 0x10, 0x92, 0xd0, 0x18, 0x0c, 0xca, 0x40, 0x86, 0x1d, 0x0f, - 0xc9, 0x1d, 0x99, 0xc0, 0xc9, 0x85, 0x00, 0xbc, 0xf2, 0x25, 0x9e, 0xfd, 0x33, 0x1e, 0x7b, 0xe6, - 0x7c, 0x2e, 0xbe, 0x2e, 0xdf, 0x2d, 0xbe, 0x2d, 0xff, 0x87, 0x8f, 0x78, 0xd2, 0xbb, 0xc5, 0xd3, - 0xc5, 0xd7, 0x8b, 0x67, 0x14, 0xd8, 0xd2, 0xef, 0xb1, 0x4a, 0x13, 0x6c, 0x02, 0x74, 0xea, 0xb0, - 0xe0, 0xbd, 0xa5, 0xbb, 0x1f, 0x29, 0x1b, 0xdc, 0xf5, 0x0a, 0x9d, 0x9c, 0x7b, 0x63, 0x35, 0xc1, - 0xae, 0x4a, 0x9a, 0x7d, 0x73, 0xce, 0x03, 0xdc, 0x33, 0x56, 0xd3, 0x47, 0x84, 0xc9, 0x65, 0x68, - 0x87, 0x63, 0x21, 0x0f, 0x61, 0x0d, 0xda, 0x0a, 0xc0, 0x8b, 0xbd, 0x46, 0x94, 0x65, 0xd8, 0xda, - 0x56, 0x68, 0x64, 0x62, 0x38, 0x1c, 0xc3, 0xf0, 0x08, 0x92, 0x3e, 0xb3, 0x70, 0x62, 0x59, 0x8f, - 0xe5, 0xc2, 0x18, 0x2c, 0x24, 0xf5, 0x54, 0x75, 0x24, 0x01, 0xf0, 0x9e, 0xc7, 0x51, 0x63, 0xd8, - 0x78, 0xe9, 0xfa, 0x02, 0x6c, 0xd1, 0xe6, 0xce, 0xb2, 0x05, 0xeb, 0xf7, 0x2a, 0xa7, 0x8d, 0x66, - 0xdf, 0x1e, 0xe0, 0x6a, 0xab, 0xd6, 0x31, 0x63, 0x55, 0xc2, 0xe2, 0xb1, 0x38, 0x21, 0xb9, 0xf0, - 0xd9, 0x16, 0xb2, 0xcb, 0x16, 0x86, 0x05, 0xb3, 0x79, 0xeb, 0x48, 0x1b, 0x48, 0x3e, 0xb3, 0xfa, - 0x74, 0x96, 0x8e, 0x7c, 0xcb, 0xed, 0x58, 0xab, 0xa9, 0x67, 0x23, 0xf0, 0x5d, 0x2b, 0x05, 0x32, - 0x94, 0x2a, 0x81, 0x47, 0x69, 0xf2, 0xe4, 0x09, 0x11, 0x00, 0x09, 0x5e, 0x1d, 0x12, 0xac, 0x7e, - 0x55, 0x35, 0xa8, 0x17, 0xb0, 0xf3, 0xe7, 0x43, 0x61, 0x87, 0x63, 0xf4, 0x35, 0xab, 0x8a, 0x92, - 0xe2, 0x23, 0x9f, 0xaf, 0x87, 0x82, 0x32, 0xb1, 0x16, 0x88, 0x63, 0x59, 0x1d, 0x08, 0xd4, 0x81, - 0x40, 0x54, 0x83, 0x97, 0x4a, 0x44, 0xec, 0xf6, 0xc3, 0x7b, 0x77, 0x31, 0xaf, 0x08, 0xc9, 0xe2, - 0x13, 0x4c, 0x12, 0x39, 0x27, 0xaf, 0xd9, 0x30, 0x43, 0x44, 0xeb, 0xde, 0x8e, 0x64, 0xb7, 0x5b, - 0x61, 0xa8, 0xe3, 0xea, 0xc5, 0xbe, 0x3c, 0xd8, 0xdf, 0x3e, 0x08, 0x54, 0xeb, 0xe7, 0xce, 0x41, - 0xe3, 0x56, 0xe4, 0x39, 0xc8, 0xc4, 0x97, 0x30, 0xf5, 0xf6, 0x1c, 0x90, 0xbe, 0xee, 0x32, 0xcf, - 0x67, 0x5d, 0x85, 0x5f, 0xc9, 0x8d, 0x09, 0x67, 0x81, 0xc6, 0xe0, 0x85, 0x5f, 0xd9, 0xb7, 0xf3, - 0x83, 0x55, 0x7e, 0x50, 0xe5, 0x37, 0xbf, 0xc0, 0x7f, 0x8a, 0x01, 0xd6, 0x09, 0x5e, 0xe4, 0x89, - 0xb0, 0xb0, 0xe2, 0x37, 0xbc, 0x17, 0xbf, 0x11, 0x17, 0xc4, 0x44, 0xe3, 0x77, 0xd0, 0x81, 0x70, - 0x94, 0x66, 0xd5, 0x87, 0xa9, 0x73, 0xe6, 0x35, 0xf9, 0x1b, 0x0e, 0xfc, 0x84, 0x3c, 0x7a, 0x5d, - 0xbe, 0xf1, 0x90, 0x8b, 0x3f, 0x23, 0xa1, 0x90, 0x91, 0xc8, 0xcb, 0x73, 0xe2, 0x31, 0x71, 0xf7, - 0xed, 0x1a, 0xe1, 0x90, 0x1c, 0x9d, 0x9d, 0x08, 0x15, 0xb5, 0x21, 0x53, 0x54, 0x41, 0x4b, 0x98, - 0xfc, 0xf5, 0xde, 0xdd, 0xdb, 0xd6, 0xe6, 0xf7, 0xe1, 0xab, 0x02, 0x8c, 0x0d, 0x84, 0x5b, 0xbc, - 0x85, 0x99, 0x7c, 0x2a, 0xac, 0x88, 0x9a, 0x63, 0x1b, 0x14, 0x19, 0x05, 0x45, 0xac, 0x58, 0x45, - 0x88, 0xc8, 0x73, 0xec, 0xa1, 0x22, 0x27, 0xfd, 0xbb, 0x22, 0x57, 0xbc, 0xff, 0x0e, 0x35, 0x4a, - 0xfc, 0x5a, 0xb6, 0x2e, 0x82, 0x40, 0xc7, 0x6b, 0xae, 0x42, 0x93, 0xfe, 0x1d, 0x22, 0x49, 0x95, - 0x44, 0x77, 0x90, 0x5c, 0xd3, 0xfd, 0x7b, 0xc2, 0x8e, 0x43, 0xad, 0x0a, 0x3c, 0xbe, 0x59, 0xdd, - 0xd2, 0x7f, 0xdc, 0xd9, 0xde, 0xe6, 0x78, 0xa3, 0xde, 0x4a, 0x4f, 0x20, 0xf1, 0x77, 0x79, 0x0f, - 0x7f, 0x07, 0x4d, 0x7e, 0xad, 0x56, 0x92, 0x5d, 0xf6, 0x07, 0x46, 0x8d, 0x29, 0x43, 0x25, 0x35, - 0x88, 0xe4, 0x94, 0x18, 0x02, 0x15, 0x2d, 0xe3, 0x65, 0x40, 0x4d, 0x2d, 0x59, 0xf9, 0x62, 0x1d, - 0x50, 0xc2, 0xf2, 0xbf, 0x88, 0xa2, 0x93, 0xd0, 0xc5, 0x77, 0x6e, 0xf1, 0x5d, 0xe0, 0x2d, 0x9e, - 0x39, 0xd1, 0x22, 0x1d, 0x7d, 0x4d, 0x4f, 0xa4, 0x8d, 0x24, 0xad, 0xa4, 0xb3, 0xe7, 0x6e, 0xc3, - 0xbf, 0xd0, 0xfc, 0x59, 0xf9, 0x1f, 0x7c, 0x3a, 0x47, 0xc3, 0xa7, 0x8b, 0xe7, 0x2c, 0xc2, 0x12, - 0x7f, 0x88, 0x72, 0x29, 0x43, 0x17, 0xc9, 0x03, 0x8a, 0x84, 0x13, 0x1d, 0x48, 0x43, 0x3f, 0x7b, - 0xb0, 0xf7, 0x79, 0x98, 0x0b, 0x6d, 0xc0, 0xa7, 0xf7, 0x26, 0x47, 0xca, 0xc3, 0x43, 0x14, 0x1b, - 0x4e, 0xbf, 0xf0, 0x86, 0xee, 0x57, 0xc5, 0x07, 0xde, 0x6b, 0xba, 0xe0, 0x05, 0x86, 0xf3, 0x0a, - 0xe3, 0x75, 0x32, 0x7a, 0x45, 0x27, 0xb0, 0x4b, 0x44, 0xc5, 0x9b, 0x6c, 0xee, 0xa0, 0x00, 0xba, - 0x7c, 0xe3, 0xb6, 0x28, 0xb5, 0x3b, 0x9d, 0x4c, 0xb0, 0xde, 0x3e, 0xfb, 0x62, 0xef, 0xc1, 0x43, - 0x16, 0xd8, 0x46, 0x89, 0x84, 0xb3, 0x26, 0x30, 0x0d, 0x75, 0x83, 0xa0, 0xbe, 0x22, 0x70, 0xeb, - 0x22, 0xbe, 0x8f, 0xfc, 0x55, 0x8d, 0xbe, 0xf4, 0x78, 0xa1, 0xc9, 0x67, 0xab, 0xb6, 0xbc, 0x85, - 0xeb, 0xf7, 0x11, 0x24, 0xd0, 0x11, 0x85, 0x4b, 0x65, 0x5f, 0xaf, 0x96, 0x8d, 0xa7, 0xa9, 0x4c, - 0xd4, 0x34, 0x9c, 0x24, 0x1f, 0x55, 0x90, 0x21, 0x48, 0x3c, 0x5a, 0x9f, 0x19, 0xaa, 0x98, 0x71, - 0x62, 0xd8, 0xaa, 0x98, 0xd9, 0x47, 0xf3, 0x98, 0x75, 0xab, 0x8b, 0xbd, 0x21, 0xc4, 0x95, 0xa0, - 0x34, 0x98, 0x5c, 0x04, 0x7a, 0xf1, 0x2d, 0xdd, 0x3b, 0x35, 0xd5, 0x16, 0xdf, 0x54, 0x44, 0xa4, - 0x8b, 0x8b, 0x7c, 0x50, 0x5d, 0xaf, 0x9b, 0x1b, 0xa9, 0x14, 0xfa, 0xb4, 0x1a, 0xf6, 0xd6, 0x52, - 0x25, 0xb6, 0x38, 0x21, 0xe9, 0xb4, 0x8e, 0x69, 0xcd, 0x81, 0x28, 0xb2, 0x81, 0x0e, 0xd2, 0x4a, - 0x0d, 0x62, 0x5d, 0x49, 0x72, 0xc0, 0x1a, 0x0b, 0xd6, 0x89, 0xe9, 0x5e, 0xc2, 0xe9, 0x29, 0xad, - 0xe5, 0x93, 0xc0, 0x72, 0x93, 0x14, 0x82, 0x8d, 0x63, 0xe5, 0x40, 0x0c, 0x8f, 0xbc, 0x49, 0x61, - 0xac, 0x37, 0x00, 0x4f, 0x78, 0xcb, 0x7d, 0x9c, 0x7a, 0xaf, 0x23, 0x2f, 0x6f, 0x92, 0xca, 0x2b, - 0x74, 0xe6, 0x99, 0x1c, 0x86, 0xe9, 0x28, 0xa5, 0x19, 0x29, 0x32, 0xd3, 0xb4, 0x6e, 0x9a, 0xa1, - 0x30, 0xc0, 0xfe, 0x7c, 0xf3, 0x21, 0xeb, 0x91, 0x60, 0xfb, 0xa8, 0x93, 0x8d, 0x2e, 0x2b, 0x9c, - 0xe4, 0x3c, 0xcd, 0x75, 0x38, 0x16, 0x66, 0x6f, 0x2a, 0x69, 0x42, 0x44, 0xa8, 0x4e, 0x7d, 0xc5, - 0xf1, 0x8e, 0x92, 0xdd, 0xd8, 0x77, 0x73, 0x81, 0x44, 0x26, 0xe2, 0x9d, 0xb6, 0x37, 0xf2, 0x59, - 0x9f, 0xf1, 0x3e, 0x7e, 0xf4, 0xd8, 0x06, 0xe3, 0x5d, 0x90, 0x43, 0xbc, 0x72, 0xfe, 0x72, 0xff, - 0xce, 0x27, 0x6a, 0x82, 0x7d, 0x8e, 0x2d, 0x83, 0x1b, 0xbb, 0x0c, 0xcb, 0x72, 0xc5, 0x1b, 0xbd, - 0xaf, 0x0e, 0x38, 0x8f, 0x06, 0x88, 0xed, 0x51, 0xe4, 0x22, 0x72, 0x5d, 0xb9, 0x0c, 0xc9, 0x35, - 0x83, 0x88, 0xf7, 0x0f, 0xa2, 0xf7, 0x09, 0x4e, 0x84, 0x79, 0x61, 0x50, 0xda, 0xff, 0x8f, 0x08, - 0x74, 0x2c, 0xc2, 0xc7, 0x2a, 0x45, 0x5a, 0x60, 0x0e, 0xf3, 0x7a, 0x24, 0xbc, 0xac, 0xa1, 0x8d, - 0x68, 0x9a, 0x8a, 0x43, 0x74, 0xf9, 0x06, 0x55, 0xc8, 0xb1, 0xbb, 0xd8, 0x0d, 0x35, 0x68, 0x6d, - 0x7b, 0xdb, 0xb5, 0xb8, 0xcf, 0x3e, 0x51, 0x12, 0x19, 0x63, 0x37, 0xab, 0xa1, 0x83, 0xa1, 0xda, - 0x66, 0xe9, 0xd0, 0x0d, 0xdc, 0x5b, 0x27, 0x9b, 0xd3, 0xe9, 0x74, 0x93, 0xee, 0x96, 0x4d, 0x2c, - 0x54, 0x15, 0x1d, 0x8d, 0x3b, 0xe6, 0x77, 0x04, 0xad, 0xd6, 0x1a, 0xd3, 0xd6, 0x1a, 0x5a, 0x64, - 0xf5, 0x88, 0x80, 0x10, 0xe1, 0xe4, 0xda, 0xd9, 0xfa, 0xdb, 0xee, 0x93, 0x9d, 0xdd, 0xdd, 0x3f, - 0x6d, 0x85, 0x16, 0xe3, 0xf1, 0x31, 0x38, 0xf7, 0x9a, 0x5f, 0xee, 0x94, 0x31, 0xa6, 0xe8, 0xd1, - 0xe9, 0x1e, 0xeb, 0x2e, 0xcd, 0xa2, 0xd4, 0x77, 0x1d, 0xda, 0x52, 0x2f, 0x73, 0x51, 0xbd, 0x48, - 0x71, 0xcc, 0x95, 0x8a, 0x83, 0x5a, 0x53, 0x8d, 0x14, 0x64, 0xe0, 0x04, 0xc5, 0xf5, 0x1a, 0xa1, - 0xd4, 0xa7, 0xf5, 0x9e, 0xc6, 0x2c, 0xe7, 0x35, 0xc9, 0x69, 0xb0, 0x88, 0x67, 0xc8, 0xa3, 0x9e, - 0x0d, 0x07, 0x29, 0xdd, 0x05, 0x81, 0xb3, 0xe6, 0x41, 0xae, 0x4c, 0x7b, 0xd1, 0x21, 0x8d, 0x87, - 0xe2, 0x7f, 0x2a, 0xf5, 0xd6, 0x54, 0xa6, 0xf6, 0x7a, 0x9e, 0xb7, 0xc1, 0xc1, 0xe9, 0xdf, 0xdd, - 0xf3, 0xd1, 0x07, 0xbf, 0x01, 0xdd, 0x89, 0x77, 0x95, 0xce, 0x0d, 0x00, 0x00, -}; -const StaticFile app_js PROGMEM = {(sizeof(app_js_content)/sizeof(app_js_content[0])), app_js_content}; - -static const uint8_t md5_js_content[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xad, 0x59, 0x79, 0x73, 0x1b, 0xb7, - 0x15, 0xff, 0xbf, 0x9f, 0x42, 0xe2, 0x4c, 0x39, 0xbb, 0xb3, 0x2b, 0x05, 0xf7, 0x61, 0x72, 0xe5, - 0x89, 0x93, 0x1e, 0xe9, 0x95, 0xb6, 0x69, 0xd2, 0x83, 0x43, 0xcd, 0xd0, 0xd2, 0xd2, 0xbb, 0x89, - 0x42, 0xaa, 0x58, 0xd0, 0xb2, 0x62, 0xd2, 0x9f, 0xbd, 0x0f, 0xd8, 0x0b, 0x4b, 0x89, 0x3a, 0xac, - 0x8e, 0x2d, 0x2c, 0x08, 0xbc, 0xf7, 0x80, 0xdf, 0xbb, 0x70, 0x1d, 0x2f, 0x37, 0xab, 0x0b, 0x5b, - 0xae, 0x57, 0x51, 0xfc, 0x71, 0xb4, 0xa9, 0xf2, 0xa3, 0xca, 0x9a, 0xf2, 0xc2, 0x8e, 0x26, 0xef, - 0x17, 0xe6, 0xc8, 0xa6, 0x26, 0x1b, 0x95, 0xab, 0xeb, 0x8d, 0x3d, 0x2a, 0xab, 0xa3, 0x72, 0xf5, - 0x7e, 0x71, 0x55, 0x5e, 0x1e, 0xd9, 0xdb, 0xeb, 0x7c, 0x94, 0x96, 0xd9, 0xfb, 0x35, 0xfc, 0x40, - 0xc7, 0x59, 0x76, 0x53, 0xae, 0x2e, 0xd7, 0x37, 0xa7, 0x5f, 0x1a, 0xb3, 0xb8, 0x7d, 0xb3, 0x59, - 0x2e, 0x73, 0x93, 0x16, 0xd9, 0x08, 0x61, 0x42, 0x19, 0x17, 0x52, 0xe9, 0xc5, 0xdb, 0x8b, 0xcb, - 0x7c, 0x39, 0x3a, 0xad, 0xae, 0xaf, 0x4a, 0x1b, 0x8d, 0x46, 0x71, 0x5a, 0x65, 0x33, 0x4c, 0x54, - 0x4a, 0x89, 0x14, 0x2a, 0x55, 0x54, 0x29, 0x81, 0x54, 0x7a, 0x42, 0x30, 0x93, 0x4c, 0x51, 0xc1, - 0xd4, 0x3c, 0xcd, 0xb3, 0x19, 0x4a, 0x55, 0x8a, 0x45, 0x4a, 0xd8, 0x3c, 0x5d, 0x64, 0xb3, 0x51, - 0x91, 0x7f, 0x18, 0xa5, 0xa3, 0x85, 0x1b, 0x04, 0xbe, 0x97, 0xe5, 0xbb, 0xbc, 0xb2, 0x50, 0x79, - 0xeb, 0x07, 0x6c, 0x7b, 0xde, 0xb4, 0xbf, 0xde, 0x2e, 0xaa, 0x5c, 0xb0, 0xd1, 0x3c, 0x5d, 0x66, - 0xa3, 0x2f, 0xdf, 0x7c, 0xf5, 0xf5, 0x6f, 0x7e, 0xfb, 0xbb, 0xdf, 0x7f, 0xf3, 0x87, 0x3f, 0xfe, - 0xe9, 0xcf, 0x7f, 0xf9, 0xf6, 0xaf, 0x7f, 0xfb, 0xfb, 0x77, 0xff, 0xf8, 0xfe, 0x87, 0x7f, 0xfe, - 0xeb, 0xdf, 0xff, 0xa9, 0x67, 0xf6, 0xae, 0x28, 0x7f, 0xfc, 0xe9, 0xea, 0xe7, 0xd5, 0xfa, 0xfa, - 0xbf, 0xa6, 0xb2, 0x9b, 0xf7, 0x37, 0x1f, 0x6e, 0x7f, 0xe9, 0x67, 0x9f, 0x7c, 0x11, 0xce, 0x7c, - 0x95, 0xcd, 0xe6, 0x93, 0x72, 0x19, 0x95, 0xf1, 0x47, 0xa7, 0xa1, 0x75, 0xb6, 0xca, 0x6f, 0x8e, - 0x02, 0xe4, 0x91, 0x50, 0xf1, 0xc4, 0xfa, 0xd6, 0xef, 0xcb, 0x95, 0x55, 0xbe, 0x2b, 0x5a, 0x3b, - 0xc6, 0xb6, 0x8d, 0x92, 0xb6, 0x71, 0xe7, 0x2b, 0xa7, 0x65, 0xe5, 0xbf, 0xdb, 0x6d, 0x34, 0xf8, - 0x9d, 0x75, 0xa6, 0xb1, 0xf1, 0x47, 0x93, 0xdb, 0x8d, 0x59, 0x8d, 0x66, 0xeb, 0xb7, 0x3f, 0xe6, - 0x17, 0xb6, 0x1e, 0x71, 0x3e, 0xca, 0xb2, 0xec, 0x5b, 0xdf, 0x70, 0x7a, 0x6d, 0xd6, 0x76, 0xed, - 0x2c, 0x73, 0x6a, 0xd7, 0xdf, 0x81, 0x09, 0x57, 0xef, 0x4e, 0x2f, 0x16, 0x57, 0x57, 0xc0, 0xba, - 0x8b, 0xd3, 0x72, 0x3c, 0x3e, 0x0e, 0xe6, 0x08, 0x03, 0xfc, 0x50, 0xe6, 0x37, 0xe3, 0x71, 0x74, - 0xb7, 0xf1, 0xbe, 0x41, 0xeb, 0x31, 0x61, 0x30, 0x27, 0x7f, 0xbd, 0x3c, 0xb2, 0xe3, 0xb1, 0x3d, - 0xad, 0xf5, 0xde, 0xd7, 0x4e, 0x2f, 0xd6, 0x2b, 0xf0, 0x9d, 0xcd, 0x85, 0x5d, 0x1b, 0x98, 0x56, - 0x20, 0x79, 0x17, 0x7b, 0x67, 0xba, 0x0f, 0xcf, 0x51, 0xd7, 0x64, 0xba, 0x26, 0xa7, 0xa6, 0x4d, - 0x74, 0x8c, 0xe2, 0xd3, 0xcd, 0xf5, 0xe5, 0xc2, 0xe6, 0xd0, 0x35, 0xb3, 0xf3, 0x28, 0xde, 0xed, - 0x26, 0x2d, 0x35, 0xf4, 0x03, 0x3d, 0x58, 0xc1, 0xc4, 0xab, 0x19, 0x9a, 0x67, 0xab, 0x19, 0x16, - 0xbe, 0x74, 0x05, 0x71, 0x05, 0x75, 0x05, 0x73, 0x05, 0x77, 0x85, 0xef, 0x95, 0xae, 0x50, 0xae, - 0xd0, 0x9e, 0xb8, 0x66, 0xf4, 0x3c, 0xd8, 0x33, 0x61, 0xcf, 0x85, 0x3d, 0x1b, 0x06, 0x3e, 0x94, - 0xda, 0xa2, 0xac, 0x4e, 0xdf, 0x5e, 0xad, 0x2f, 0x7e, 0xaa, 0xb2, 0x55, 0xf3, 0xcb, 0x63, 0x52, - 0x99, 0x9d, 0xe4, 0x57, 0x10, 0x2e, 0xbd, 0x2b, 0x14, 0xf7, 0xbb, 0x42, 0xc8, 0xb3, 0xe7, 0x15, - 0x45, 0x3c, 0x1c, 0x60, 0xcf, 0x3f, 0x8a, 0x78, 0xe7, 0x87, 0x08, 0x69, 0x20, 0x28, 0x1e, 0xf9, - 0x37, 0xaf, 0x87, 0x2c, 0x50, 0x56, 0x7f, 0x71, 0xf3, 0x25, 0xcd, 0x97, 0xd6, 0xdf, 0xca, 0x2e, - 0x8c, 0xad, 0xab, 0x6f, 0x6f, 0x6d, 0x5e, 0x35, 0xbd, 0x6f, 0x7c, 0xbd, 0x01, 0xbe, 0x2c, 0x57, - 0x10, 0xf0, 0xbf, 0xe4, 0x97, 0x4d, 0xe7, 0xa2, 0x2a, 0xa0, 0x7e, 0x8c, 0xdb, 0x5e, 0x08, 0x96, - 0xec, 0x18, 0xed, 0x36, 0x81, 0xf3, 0xd5, 0x26, 0x1b, 0x58, 0x1a, 0x34, 0x74, 0x3c, 0x14, 0xd7, - 0xe8, 0x0b, 0xb2, 0x40, 0xeb, 0x50, 0x2e, 0xa2, 0x46, 0x95, 0x77, 0xdb, 0x11, 0x24, 0x94, 0xca, - 0x73, 0xb5, 0x7e, 0xe7, 0x1b, 0x6c, 0x61, 0xd6, 0x37, 0x47, 0xc6, 0x11, 0xae, 0x36, 0x57, 0x57, - 0xe0, 0x61, 0x36, 0x6c, 0x2b, 0x9d, 0x23, 0x1e, 0xf4, 0xc0, 0xf8, 0x4e, 0x40, 0xda, 0xb8, 0x33, - 0xdf, 0xf1, 0x30, 0xea, 0xa0, 0x6b, 0xbb, 0x05, 0x71, 0x77, 0x43, 0x03, 0x7a, 0xe2, 0x6e, 0xcc, - 0xc2, 0x21, 0x5f, 0xae, 0x4d, 0xe4, 0xa0, 0x2c, 0xd2, 0x25, 0xc4, 0x37, 0x4a, 0xd7, 0x99, 0x3d, - 0xbd, 0xca, 0x57, 0xef, 0x6c, 0x91, 0xde, 0x66, 0x81, 0xd9, 0xd2, 0x4d, 0x16, 0xfa, 0xc1, 0x64, - 0x35, 0x5d, 0x4f, 0x3c, 0xc4, 0x40, 0xaf, 0x10, 0x8d, 0x7b, 0x5a, 0xbe, 0x75, 0xae, 0x7d, 0xeb, - 0x5c, 0x3b, 0xf5, 0xa5, 0xab, 0xbb, 0x82, 0xb8, 0x82, 0xba, 0x82, 0xb9, 0x82, 0xbb, 0xc2, 0xf7, - 0x4a, 0x57, 0x28, 0x57, 0x68, 0x4f, 0x5c, 0xb3, 0x7b, 0x1e, 0xec, 0x99, 0xb0, 0xe7, 0xc2, 0x9e, - 0xcd, 0x3b, 0x78, 0x9c, 0x16, 0xb1, 0xf7, 0x60, 0x87, 0x64, 0x19, 0x78, 0x86, 0x9b, 0xe2, 0x78, - 0xbc, 0x9c, 0x0a, 0x36, 0x49, 0x92, 0x55, 0xbc, 0x99, 0x2d, 0x93, 0x64, 0x9e, 0xd9, 0xd9, 0x6a, - 0x5e, 0xeb, 0xed, 0x11, 0xfa, 0xdb, 0xd9, 0xf2, 0xec, 0x8c, 0xcc, 0xb7, 0x9e, 0x63, 0x3a, 0xcd, - 0x67, 0x74, 0xec, 0x04, 0x04, 0x21, 0xf3, 0x88, 0x80, 0x68, 0x01, 0xaa, 0xbc, 0x28, 0x16, 0xe6, - 0xab, 0xf5, 0x65, 0xfe, 0xa5, 0x8d, 0x56, 0x71, 0x3c, 0x85, 0x15, 0xe3, 0x75, 0x33, 0x91, 0xc5, - 0xab, 0xc5, 0x94, 0x20, 0xa6, 0x5e, 0x47, 0x4d, 0x03, 0xd6, 0x64, 0xbb, 0x38, 0x3b, 0x13, 0x69, - 0xfb, 0x9b, 0xa8, 0xad, 0xa0, 0xe3, 0x45, 0x0c, 0x84, 0x9c, 0x13, 0x2d, 0xb6, 0xd0, 0x9d, 0x71, - 0x49, 0x19, 0xeb, 0x78, 0x08, 0x61, 0x8e, 0x07, 0x93, 0x90, 0xc9, 0x09, 0x19, 0x0b, 0x7a, 0x57, - 0x0e, 0xcc, 0x48, 0x70, 0x4e, 0x45, 0x12, 0x45, 0x18, 0x11, 0xd7, 0x34, 0x9d, 0x62, 0xb4, 0xf5, - 0xf5, 0xc1, 0x54, 0xdd, 0xf4, 0xe3, 0x96, 0x9f, 0x30, 0xe4, 0xc7, 0x50, 0x7b, 0x63, 0x60, 0xb2, - 0x37, 0xc8, 0xa1, 0x71, 0x9f, 0xa4, 0xee, 0x43, 0xda, 0xea, 0xcc, 0xb0, 0xe8, 0x6d, 0xd0, 0x69, - 0xae, 0xeb, 0x8c, 0x5a, 0xe5, 0xc5, 0x3d, 0x55, 0x1a, 0xf4, 0xb6, 0x53, 0xe9, 0x7b, 0xef, 0xd5, - 0x6a, 0xcf, 0xd1, 0x29, 0xf6, 0xb0, 0xc0, 0x06, 0xee, 0x33, 0x46, 0x7c, 0xa6, 0xfe, 0x83, 0xd9, - 0xb4, 0x26, 0x78, 0x70, 0x36, 0xde, 0x20, 0xff, 0xc7, 0xf9, 0xd6, 0x59, 0xf8, 0x6a, 0x51, 0x59, - 0x97, 0x52, 0xbf, 0x59, 0x5d, 0xe6, 0x1f, 0xb2, 0x65, 0xda, 0xe7, 0xdb, 0x24, 0x5b, 0x9e, 0xf4, - 0xf6, 0x4c, 0x97, 0x67, 0x99, 0x00, 0x1d, 0x06, 0xa9, 0x79, 0x79, 0x22, 0x58, 0xda, 0x65, 0x84, - 0x28, 0x4e, 0x07, 0xd9, 0x01, 0xc5, 0xaf, 0x42, 0xda, 0x5d, 0xb3, 0x76, 0xf6, 0xf2, 0xcf, 0x18, - 0xd1, 0x4c, 0x0b, 0x49, 0x34, 0xef, 0x32, 0xcb, 0x9b, 0x7a, 0xe0, 0x9e, 0xe8, 0x8b, 0x8e, 0x48, - 0x4c, 0xa7, 0x28, 0xdd, 0x5f, 0x0d, 0x7c, 0xf5, 0xd7, 0x3d, 0x4d, 0x3d, 0x87, 0xdd, 0x2e, 0x0d, - 0x33, 0x7e, 0x9b, 0xd5, 0xb3, 0x60, 0x23, 0x79, 0x5f, 0xca, 0xdf, 0x5b, 0x51, 0x8e, 0x51, 0xbd, - 0xc3, 0x1c, 0xa4, 0x49, 0x93, 0xdd, 0xd5, 0xda, 0xc4, 0xce, 0x4c, 0xad, 0xe7, 0x0a, 0x74, 0x6b, - 0xe6, 0xa9, 0x01, 0x8f, 0x13, 0xc3, 0x6c, 0xb9, 0xdd, 0x0e, 0x14, 0xe5, 0xf2, 0xa6, 0xf5, 0x79, - 0xd3, 0x97, 0xae, 0xee, 0x0a, 0xe2, 0x0a, 0xea, 0x0a, 0xe6, 0x0a, 0xee, 0x0a, 0xdf, 0x2b, 0x5d, - 0xa1, 0x5c, 0xa1, 0x3d, 0x71, 0xcd, 0xee, 0x79, 0xb0, 0x67, 0xc2, 0x9e, 0x0b, 0x7b, 0xb6, 0x26, - 0x6f, 0x36, 0x3f, 0x3b, 0x35, 0x4d, 0xa7, 0x34, 0xad, 0x3b, 0x03, 0x5d, 0x43, 0xe3, 0x36, 0x30, - 0x09, 0xc0, 0xd0, 0xa1, 0x49, 0xf7, 0x34, 0xe9, 0x1a, 0x43, 0x2d, 0x36, 0x3b, 0xf0, 0xb4, 0x4c, - 0x61, 0xa9, 0x4c, 0x73, 0xd8, 0x04, 0x07, 0xba, 0x9a, 0xf4, 0x6b, 0xf0, 0x6b, 0x93, 0x45, 0x91, - 0xfb, 0xb3, 0xfe, 0x6f, 0x01, 0xf0, 0x4f, 0x84, 0x42, 0x4a, 0x0a, 0x4d, 0x25, 0xf8, 0xa4, 0xdc, - 0x5a, 0x37, 0x34, 0x8f, 0x4f, 0x88, 0xc4, 0x92, 0x52, 0x25, 0x35, 0x58, 0x3b, 0x3e, 0x8f, 0x4a, - 0xa0, 0x87, 0xbf, 0xbe, 0xf9, 0x3c, 0x2a, 0xa0, 0x09, 0xfe, 0x4e, 0xa0, 0x81, 0x70, 0xc5, 0xb0, - 0x66, 0xe7, 0x04, 0x21, 0x46, 0xb1, 0x42, 0x12, 0x8f, 0x6d, 0x9c, 0x2c, 0x40, 0x97, 0x27, 0x18, - 0x4b, 0x45, 0x91, 0x44, 0x2e, 0xa4, 0x30, 0xd9, 0x16, 0x4e, 0x3c, 0x8a, 0x13, 0xeb, 0xc4, 0x8e, - 0x43, 0x79, 0xb0, 0x62, 0x02, 0x07, 0x71, 0x1c, 0x44, 0x30, 0x60, 0x92, 0xdc, 0xb1, 0xc8, 0x6d, - 0x09, 0x2c, 0x98, 0xc7, 0x49, 0x51, 0xb3, 0x14, 0x0d, 0x25, 0x05, 0x4a, 0x8a, 0x05, 0xe1, 0x9a, - 0x20, 0x0d, 0x94, 0x84, 0x6c, 0x8d, 0xa3, 0x04, 0xe1, 0x25, 0x50, 0xbe, 0x8a, 0x1a, 0x7f, 0x29, - 0x50, 0xeb, 0x2b, 0x05, 0x86, 0x93, 0x48, 0xb3, 0xc3, 0x49, 0xbd, 0x22, 0x92, 0x56, 0x13, 0x89, - 0xc7, 0xd2, 0xec, 0x7a, 0xe2, 0x73, 0x33, 0x8e, 0xca, 0xf3, 0xc2, 0x0f, 0x13, 0x28, 0x48, 0x84, - 0x0a, 0x4a, 0x4c, 0xa0, 0x18, 0x60, 0x37, 0x8d, 0x42, 0xa0, 0x5a, 0x9e, 0xdb, 0x31, 0xfc, 0x2e, - 0xe3, 0x46, 0x05, 0x54, 0x69, 0x2e, 0x18, 0x57, 0xe2, 0x5e, 0x15, 0xd8, 0x73, 0xd3, 0x20, 0x4f, - 0x04, 0x12, 0x18, 0x71, 0x85, 0xf5, 0xa3, 0xc0, 0x11, 0x63, 0x9c, 0x70, 0x4a, 0xd1, 0x5d, 0xe0, - 0xf1, 0x5d, 0x68, 0x45, 0x08, 0x88, 0x01, 0xbb, 0x14, 0x0c, 0x2b, 0xa5, 0xe5, 0xe7, 0x00, 0xe2, - 0xf3, 0x04, 0x83, 0x9d, 0x91, 0x42, 0x8c, 0x3c, 0x8c, 0x48, 0xc0, 0x48, 0x0c, 0x9c, 0x83, 0x62, - 0xca, 0xf0, 0x83, 0x90, 0xe4, 0xfc, 0x84, 0x71, 0x89, 0xb8, 0x56, 0xf4, 0x2e, 0xa0, 0x47, 0xf0, - 0x28, 0x98, 0x8f, 0x94, 0x08, 0x51, 0xce, 0xf0, 0x67, 0x59, 0x48, 0xc3, 0x34, 0xb5, 0x73, 0x60, - 0xc6, 0xb0, 0x7c, 0x10, 0x10, 0x44, 0xfb, 0x09, 0x23, 0xc8, 0xe7, 0xf6, 0xc3, 0x60, 0xb0, 0xf3, - 0x7a, 0xad, 0x11, 0x43, 0x30, 0x21, 0xf2, 0x6c, 0x3c, 0x90, 0x46, 0x12, 0x08, 0x21, 0x26, 0x10, - 0x15, 0x8a, 0x7c, 0x96, 0xcb, 0x81, 0x87, 0x30, 0x04, 0x3a, 0xc7, 0x08, 0x3f, 0x8c, 0xc7, 0xf9, - 0x02, 0x47, 0x60, 0x4d, 0x42, 0x34, 0x7a, 0x18, 0x94, 0x37, 0x3b, 0x15, 0x9c, 0x72, 0x4a, 0xee, - 0x09, 0xb7, 0x1e, 0x54, 0x3b, 0x25, 0x98, 0xcd, 0xb8, 0x87, 0x08, 0xe8, 0x06, 0x31, 0x81, 0x05, - 0x97, 0x5a, 0x70, 0xec, 0x46, 0xe5, 0x35, 0x40, 0xd9, 0x02, 0xec, 0xbd, 0x07, 0x09, 0xcd, 0x11, - 0x16, 0xd4, 0xa9, 0x41, 0xd7, 0x28, 0x68, 0x83, 0xc2, 0x01, 0xee, 0x34, 0x61, 0x9d, 0x02, 0x03, - 0xfd, 0x27, 0x82, 0x51, 0x48, 0x2c, 0x12, 0x7b, 0x4b, 0xb1, 0x1a, 0x94, 0x6a, 0x40, 0xf5, 0x81, - 0x4d, 0x25, 0x84, 0xa6, 0xa4, 0xc8, 0x1b, 0x09, 0xd5, 0x78, 0xc8, 0x67, 0xe0, 0xe1, 0xf3, 0x13, - 0x89, 0x30, 0x87, 0x08, 0xd7, 0xf8, 0x20, 0x1e, 0x70, 0x9e, 0x84, 0x2a, 0x40, 0x83, 0xbc, 0x93, - 0x3f, 0x1d, 0x0e, 0x48, 0x17, 0x02, 0xb9, 0x8c, 0x48, 0xf9, 0x61, 0x38, 0xcc, 0xd9, 0x1c, 0x36, - 0x3d, 0x52, 0x31, 0xf5, 0x42, 0x38, 0x7a, 0x9e, 0x70, 0xa1, 0x18, 0x03, 0x25, 0xaa, 0xc3, 0x70, - 0x9c, 0xef, 0x20, 0xac, 0x15, 0xb8, 0xa9, 0xf7, 0x9d, 0x27, 0x03, 0x72, 0xf9, 0x4b, 0x49, 0x2a, - 0xa8, 0x16, 0xf8, 0x30, 0x1e, 0x17, 0xd6, 0x60, 0x79, 0x4e, 0xc1, 0x41, 0xf1, 0x0b, 0x01, 0xb9, - 0x80, 0x80, 0xe8, 0x66, 0x42, 0x61, 0x26, 0xe4, 0x41, 0x48, 0xb0, 0xf6, 0x70, 0x0c, 0x81, 0x03, - 0x2a, 0x7c, 0x0e, 0x1e, 0xe9, 0x12, 0x90, 0x0b, 0x0b, 0x05, 0xc9, 0xee, 0x30, 0x20, 0xec, 0x96, - 0x36, 0x4d, 0xc0, 0x94, 0x12, 0x36, 0xc1, 0x0f, 0x21, 0xca, 0xb3, 0x0e, 0x54, 0x54, 0x65, 0x0e, - 0xc4, 0x79, 0x87, 0xab, 0x02, 0x69, 0xb5, 0xc7, 0xc1, 0x34, 0xc1, 0xe3, 0x40, 0x0e, 0xab, 0xc1, - 0xa8, 0x06, 0x4c, 0xad, 0xbc, 0x13, 0x02, 0x31, 0xcd, 0x25, 0x60, 0xf6, 0x53, 0xc2, 0x35, 0x18, - 0xdc, 0x81, 0x09, 0xd2, 0x48, 0x0e, 0xf0, 0x9b, 0xb0, 0xc1, 0x8a, 0x6a, 0x44, 0x11, 0xf7, 0x69, - 0x0b, 0x8b, 0x1a, 0x87, 0x68, 0x70, 0xb4, 0x56, 0x07, 0xa8, 0x48, 0x73, 0xee, 0x72, 0x2d, 0xa1, - 0x1e, 0x82, 0xfe, 0x0c, 0x04, 0x2e, 0x07, 0x38, 0x41, 0xb0, 0x7a, 0x0b, 0x74, 0x3f, 0x0a, 0xe6, - 0x52, 0x8e, 0x24, 0x4a, 0x83, 0xe3, 0x3f, 0x15, 0x85, 0x74, 0x62, 0x39, 0xd3, 0xb2, 0x4e, 0x1a, - 0xf7, 0x62, 0x40, 0xce, 0x73, 0x35, 0x18, 0x0b, 0x09, 0x86, 0x5e, 0x88, 0x82, 0x42, 0xae, 0x51, - 0x30, 0x4b, 0x8d, 0x25, 0xbb, 0x1f, 0x85, 0xcb, 0x33, 0x5c, 0x41, 0x60, 0x12, 0x42, 0x9e, 0x08, - 0x02, 0xdc, 0x15, 0xa8, 0x39, 0xc1, 0x5a, 0xea, 0x03, 0x20, 0xc4, 0x3c, 0x91, 0x02, 0x11, 0x8d, - 0x95, 0x7e, 0x19, 0x02, 0x58, 0xfd, 0x40, 0x0b, 0x54, 0x30, 0xa6, 0xe4, 0xfd, 0x00, 0x9c, 0xe3, - 0x32, 0x82, 0x15, 0xe6, 0xaa, 0x4e, 0x40, 0x4f, 0x71, 0x26, 0x58, 0x2e, 0xc0, 0xba, 0x92, 0x01, - 0x0a, 0x74, 0x00, 0x02, 0x88, 0xd5, 0x1a, 0xf6, 0x30, 0x0a, 0x96, 0x81, 0x07, 0x30, 0xf4, 0xf1, - 0x1d, 0x46, 0x77, 0x64, 0xb6, 0x9f, 0xba, 0x3c, 0x0e, 0x59, 0x48, 0x50, 0x04, 0x19, 0x0b, 0xc4, - 0x88, 0x1a, 0x80, 0x68, 0x00, 0x6c, 0x3f, 0x95, 0x5d, 0x90, 0xc2, 0xbe, 0x52, 0x69, 0xcc, 0x30, - 0xaf, 0x8f, 0x83, 0x1e, 0x04, 0x69, 0x41, 0x84, 0xc1, 0x1d, 0x15, 0xdb, 0x4f, 0x41, 0x96, 0x63, - 0x2e, 0x0f, 0x31, 0x8d, 0x3c, 0x1f, 0xaf, 0x81, 0xc8, 0x06, 0xc8, 0xf6, 0x93, 0x6d, 0x97, 0x00, - 0x88, 0x36, 0xea, 0x52, 0xb0, 0xc3, 0x82, 0xeb, 0xd0, 0xc6, 0xcf, 0x05, 0xe3, 0x17, 0x7f, 0xd8, - 0xcc, 0x30, 0x88, 0x6d, 0x89, 0x0f, 0xc3, 0xf1, 0x39, 0x14, 0xce, 0x5a, 0x0a, 0xd2, 0x89, 0x78, - 0x06, 0x1c, 0xef, 0xfa, 0x1c, 0x73, 0x42, 0x1f, 0xc0, 0x82, 0x5d, 0xf6, 0x00, 0xc0, 0x04, 0xdc, - 0x5a, 0xbf, 0x04, 0x8d, 0xcb, 0xe1, 0x90, 0xe9, 0x61, 0xff, 0x47, 0xb9, 0x3e, 0x0c, 0xc6, 0xad, - 0x70, 0x10, 0x8b, 0x70, 0x52, 0xf0, 0x16, 0x7c, 0x22, 0x16, 0xb7, 0x3f, 0xe0, 0xc2, 0xad, 0x40, - 0xb0, 0xaa, 0x3e, 0x84, 0x06, 0x42, 0x14, 0x43, 0xa2, 0xc1, 0x1c, 0x0b, 0xf6, 0x22, 0x38, 0xde, - 0x17, 0x38, 0xe8, 0x0e, 0x8e, 0x33, 0x0f, 0xa0, 0xf1, 0x87, 0x1e, 0x48, 0xbf, 0x18, 0x56, 0x12, - 0xfd, 0x74, 0x3c, 0x60, 0x79, 0x09, 0xda, 0x52, 0x92, 0x78, 0x5d, 0x1d, 0x82, 0x03, 0xf1, 0x4a, - 0xc1, 0xcf, 0xc0, 0x3f, 0xea, 0xa8, 0xd9, 0x03, 0x13, 0x1c, 0xf5, 0xa2, 0xee, 0x72, 0x37, 0xe9, - 0x8e, 0x68, 0xb4, 0xa3, 0x29, 0x70, 0x66, 0x06, 0x87, 0xbc, 0xb4, 0xbd, 0xfb, 0x2d, 0x83, 0x13, - 0x5d, 0xdf, 0x4e, 0xb3, 0x22, 0x69, 0xc9, 0xd5, 0x70, 0xa4, 0xec, 0x18, 0xc7, 0xaf, 0xfa, 0xd1, - 0xea, 0xaf, 0xc7, 0xda, 0x0d, 0xd5, 0x7c, 0xbd, 0xa6, 0xba, 0x81, 0x9a, 0x6f, 0x30, 0xf1, 0xf6, - 0xc6, 0xb9, 0xa0, 0x35, 0xe8, 0xbd, 0x43, 0xaf, 0xbb, 0x17, 0xe9, 0xcf, 0xbc, 0x83, 0x5b, 0x82, - 0x28, 0x0e, 0xef, 0x08, 0x0e, 0x9c, 0xf9, 0xda, 0x9b, 0x6b, 0x3a, 0x69, 0xee, 0x40, 0x8a, 0x19, - 0x98, 0x90, 0x8d, 0x5d, 0xb6, 0x2a, 0xc0, 0x0d, 0xc7, 0xd6, 0x7d, 0xad, 0xbf, 0xe1, 0xa9, 0xdb, - 0xa0, 0xae, 0xfa, 0x2a, 0x41, 0x7d, 0x1d, 0x8b, 0xa0, 0x3d, 0xa4, 0x69, 0xc5, 0x99, 0x81, 0x64, - 0xd3, 0x34, 0x75, 0x92, 0x4d, 0x2f, 0xd9, 0x04, 0x92, 0x4d, 0x20, 0xd9, 0x04, 0x92, 0x4d, 0x20, - 0xb9, 0x1c, 0x48, 0x2e, 0x9b, 0xa6, 0x4e, 0x72, 0xd9, 0x4b, 0x2e, 0x03, 0xc9, 0x65, 0x20, 0xb9, - 0x0c, 0x24, 0x97, 0x81, 0xe4, 0x6a, 0x20, 0xb9, 0x6a, 0x9a, 0x3a, 0xc9, 0x55, 0x2f, 0xb9, 0x0a, - 0x24, 0x57, 0x81, 0xe4, 0x2a, 0x90, 0x5c, 0xb5, 0x92, 0x87, 0x66, 0x6c, 0x1f, 0x9d, 0xb2, 0x3d, - 0xdb, 0x0e, 0x88, 0xea, 0x37, 0xbb, 0x97, 0x99, 0xbb, 0xd8, 0x33, 0xf7, 0x8c, 0x70, 0x30, 0x71, - 0xea, 0x6d, 0x0a, 0xd5, 0xb4, 0xb6, 0x62, 0x53, 0x83, 0x99, 0xba, 0x9a, 0x23, 0x31, 0xa9, 0x69, - 0x49, 0x4c, 0x47, 0x62, 0x06, 0x24, 0x65, 0x5a, 0xb6, 0x24, 0x65, 0x47, 0x52, 0x0e, 0x48, 0x8a, - 0xb4, 0x68, 0x49, 0x8a, 0x8e, 0xa4, 0x68, 0x49, 0xf6, 0x74, 0xe2, 0x1f, 0x24, 0xb3, 0xbb, 0x0a, - 0xb8, 0x4b, 0x54, 0x3f, 0x33, 0x3c, 0xae, 0x98, 0xfd, 0x07, 0x26, 0x58, 0x7a, 0x41, 0x49, 0xfb, - 0xef, 0x46, 0x36, 0x6e, 0x43, 0xc1, 0xf8, 0x9b, 0xb0, 0x56, 0x9b, 0xfe, 0x16, 0xac, 0x51, 0xa8, - 0xf1, 0xb7, 0x61, 0xed, 0xb5, 0x89, 0xbf, 0x15, 0xab, 0xd5, 0x9a, 0xda, 0x21, 0x88, 0xfa, 0xc9, - 0x22, 0x3b, 0x30, 0xe5, 0x21, 0xa9, 0x7f, 0x72, 0x0d, 0x51, 0xb4, 0xef, 0x23, 0xcd, 0x2d, 0x56, - 0x36, 0x1a, 0xb5, 0xe1, 0xea, 0x65, 0x44, 0x71, 0x9a, 0x67, 0x68, 0x92, 0x43, 0x5e, 0x9c, 0xc4, - 0x36, 0xab, 0x66, 0xb9, 0xbb, 0x57, 0x35, 0x6d, 0xa5, 0x6c, 0x2b, 0x90, 0xbf, 0x97, 0x33, 0x9f, - 0x97, 0xe7, 0xc9, 0x72, 0x26, 0x28, 0x1c, 0x76, 0xdd, 0xa6, 0xc6, 0xe5, 0x48, 0x16, 0xb7, 0x4d, - 0x90, 0x85, 0x88, 0xcf, 0xae, 0xa2, 0x6d, 0x2a, 0xe7, 0xad, 0x1e, 0xbc, 0xec, 0x7d, 0x39, 0x4e, - 0xc6, 0x58, 0xc0, 0x42, 0x32, 0xca, 0xb2, 0xd1, 0x2e, 0x6d, 0x1e, 0xb1, 0x7f, 0xbe, 0xe4, 0x77, - 0xee, 0xe0, 0xb2, 0xdb, 0xc8, 0xbf, 0x3e, 0xc7, 0x13, 0x7b, 0x7a, 0x61, 0xf2, 0xc1, 0x03, 0xd7, - 0xf0, 0xd9, 0x72, 0x97, 0xda, 0x3b, 0x4f, 0x60, 0xfd, 0xcb, 0x66, 0xcb, 0x1d, 0x05, 0x4f, 0x9b, - 0xbb, 0x49, 0xab, 0x24, 0x03, 0xaa, 0x30, 0xd3, 0x45, 0xf3, 0x80, 0x34, 0x49, 0x12, 0x53, 0x0f, - 0x5f, 0x66, 0x8b, 0x99, 0x99, 0x4f, 0xec, 0xac, 0x9c, 0xc3, 0x44, 0xca, 0xb8, 0xbb, 0xeb, 0xdd, - 0x45, 0x31, 0xfc, 0x9f, 0xfc, 0xea, 0x7f, 0xf1, 0xc2, 0x99, 0x50, 0xc3, 0x1f, 0x00, 0x00, -}; -const StaticFile md5_js PROGMEM = {(sizeof(md5_js_content)/sizeof(md5_js_content[0])), md5_js_content}; - -static const uint8_t style_css_content[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x9d, 0x53, 0x5d, 0x6b, 0xdb, 0x30, - 0x14, 0x7d, 0xdf, 0xaf, 0x08, 0x94, 0x41, 0x0b, 0x76, 0xb0, 0x9b, 0x26, 0x59, 0x64, 0xf6, 0xb0, - 0x3d, 0x8c, 0xed, 0x61, 0x4f, 0x65, 0x4f, 0xa3, 0x14, 0x7d, 0x5c, 0xd9, 0x22, 0xb2, 0x25, 0xa4, - 0xeb, 0x26, 0x99, 0xf1, 0x7f, 0x9f, 0xfc, 0x11, 0x37, 0x69, 0x32, 0x28, 0xc3, 0x20, 0xb8, 0x1f, - 0xd2, 0x39, 0xf7, 0xdc, 0x63, 0x66, 0xc4, 0x21, 0x2a, 0xb0, 0xd4, 0x8d, 0xa5, 0x42, 0xa8, 0x2a, - 0x27, 0x49, 0x56, 0x52, 0x97, 0xab, 0x8a, 0x24, 0x2d, 0xeb, 0x8a, 0xac, 0x46, 0x34, 0x55, 0xa4, - 0x2a, 0x5b, 0xe3, 0x6f, 0x3c, 0x58, 0xf8, 0x6c, 0xa9, 0xf7, 0x3b, 0xe3, 0xc4, 0xd3, 0x69, 0x12, - 0x61, 0x8f, 0x4f, 0x8d, 0x34, 0x15, 0xc6, 0x5e, 0xfd, 0x01, 0x92, 0xae, 0xec, 0x3e, 0xeb, 0x43, - 0x49, 0x4b, 0xa5, 0x0f, 0x24, 0xa6, 0xd6, 0x6a, 0x88, 0xfd, 0xc1, 0x23, 0x94, 0xd1, 0x57, 0xad, - 0xaa, 0xed, 0x4f, 0xca, 0x1f, 0xfb, 0xf0, 0x5b, 0xe8, 0x8b, 0x1e, 0x21, 0x37, 0x30, 0xfb, 0xf5, - 0x23, 0xfa, 0x0e, 0xfa, 0x05, 0x50, 0x71, 0x1a, 0x7d, 0x71, 0x8a, 0xea, 0xc8, 0xd3, 0xca, 0xc7, - 0x1e, 0x9c, 0x92, 0xed, 0x1c, 0x15, 0x6a, 0x98, 0xb8, 0xa6, 0x89, 0xdd, 0xcf, 0xfa, 0x63, 0x42, - 0xdb, 0x81, 0xca, 0x0b, 0x24, 0xab, 0x24, 0xc9, 0x18, 0xe5, 0xdb, 0xdc, 0x99, 0xba, 0x12, 0x31, - 0x37, 0xda, 0x38, 0x72, 0x03, 0x52, 0xde, 0xcb, 0x65, 0xc6, 0x02, 0x79, 0x70, 0x31, 0x33, 0x61, - 0xb2, 0x92, 0xa4, 0xe1, 0xba, 0x37, 0x5a, 0x89, 0xd9, 0x8d, 0xd8, 0x40, 0x02, 0xeb, 0x6c, 0xec, - 0xbe, 0x5f, 0xaf, 0x80, 0x3d, 0x64, 0x27, 0x33, 0x2d, 0xed, 0xbe, 0x9d, 0x33, 0x6d, 0xf8, 0xf6, - 0x8c, 0x42, 0x3b, 0x97, 0xb5, 0xd6, 0xf1, 0x4e, 0x09, 0x2c, 0x9a, 0xfe, 0x0c, 0xe9, 0xe4, 0x63, - 0xc0, 0xd9, 0x77, 0x17, 0xbb, 0xb6, 0x09, 0xb2, 0x6b, 0x36, 0xae, 0x7c, 0xd6, 0x94, 0xc1, 0x89, - 0xe8, 0xb3, 0x64, 0xb6, 0xb8, 0x1c, 0x61, 0xec, 0xed, 0x65, 0x6e, 0x86, 0xb5, 0x4c, 0xac, 0x7b, - 0x2e, 0x7d, 0xd9, 0xd7, 0xec, 0xec, 0xb5, 0x18, 0x8d, 0x25, 0xe1, 0xb5, 0xf6, 0x5d, 0x3b, 0x8b, - 0x3c, 0x68, 0xe0, 0xd8, 0x8c, 0x0c, 0x1d, 0x15, 0xaa, 0xf6, 0xe4, 0x21, 0x90, 0x19, 0x32, 0xa7, - 0xfa, 0xf0, 0x0d, 0xe7, 0x5c, 0x66, 0x47, 0xd6, 0xeb, 0x50, 0xd9, 0x84, 0x46, 0x53, 0x63, 0x58, - 0x27, 0x04, 0xc7, 0x5c, 0x43, 0x24, 0xd2, 0xf0, 0xda, 0x5f, 0xe0, 0x8e, 0xe9, 0x01, 0x7d, 0x08, - 0x9a, 0x5e, 0xb0, 0x82, 0x0a, 0xb3, 0xeb, 0x15, 0xe9, 0x37, 0xeb, 0x72, 0x46, 0x6f, 0x93, 0xa8, - 0xfb, 0xe6, 0xe9, 0xf2, 0xae, 0x1d, 0xfc, 0x48, 0x84, 0xf2, 0x94, 0x69, 0x10, 0x57, 0x8d, 0x79, - 0xb5, 0x3a, 0xa0, 0x4e, 0x95, 0x11, 0xf8, 0x18, 0x37, 0x97, 0x6e, 0x91, 0x69, 0x70, 0xcb, 0xe2, - 0xe8, 0x96, 0xb3, 0xe4, 0xc8, 0xe2, 0x7f, 0x45, 0xeb, 0x96, 0xf7, 0xaa, 0xda, 0x89, 0x51, 0xc3, - 0xfb, 0x52, 0x1e, 0xfd, 0x97, 0x04, 0x0f, 0x5b, 0xe3, 0x15, 0xaa, 0x30, 0xaf, 0x03, 0x4d, 0x51, - 0xbd, 0x40, 0xd6, 0xdd, 0x89, 0x8b, 0xc1, 0x22, 0xe9, 0xa7, 0x2b, 0x9e, 0x19, 0x05, 0xaa, 0x0c, - 0xde, 0x4e, 0xd3, 0xdd, 0x91, 0xc2, 0xbc, 0x80, 0x7b, 0x8f, 0xc0, 0x19, 0xaf, 0x9d, 0x0f, 0xf0, - 0xd6, 0xa8, 0x0a, 0xc1, 0xbd, 0x19, 0x9f, 0x2d, 0x39, 0x87, 0xc5, 0xf9, 0x1f, 0xf2, 0x0f, 0x44, - 0xca, 0x3b, 0xbe, 0x4d, 0x67, 0xc6, 0xa0, 0xc6, 0xd8, 0x34, 0x57, 0xfe, 0xd9, 0x81, 0x07, 0x8c, - 0xde, 0xc4, 0xd7, 0xf9, 0x1e, 0xff, 0xdb, 0xd5, 0x62, 0x93, 0xae, 0xdb, 0x0f, 0x7f, 0x01, 0x37, - 0xdb, 0x6e, 0xf6, 0xae, 0x04, 0x00, 0x00, -}; -const StaticFile style_css PROGMEM = {(sizeof(style_css_content)/sizeof(style_css_content[0])), style_css_content}; - -static const uint8_t favicon_ico_content[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x99, 0x4b, 0x48, 0x15, 0x61, - 0x14, 0xc7, 0xcf, 0xc5, 0x17, 0x2e, 0x4a, 0x57, 0xe5, 0x63, 0xe1, 0x85, 0x42, 0x23, 0x8c, 0x8c, - 0x20, 0x4d, 0x45, 0xdb, 0x59, 0x14, 0x2e, 0x7a, 0xab, 0x68, 0x1b, 0x17, 0xae, 0x24, 0x41, 0xf1, - 0x41, 0xa0, 0x41, 0xa1, 0x11, 0x28, 0x1a, 0x2e, 0x12, 0x72, 0xe7, 0x03, 0x09, 0x74, 0x15, 0x54, - 0x1b, 0x97, 0xd9, 0x53, 0x23, 0x8a, 0x5a, 0x94, 0x25, 0x59, 0x91, 0x82, 0xa0, 0x81, 0x99, 0x39, - 0xfd, 0x8f, 0x73, 0x46, 0xbf, 0xc6, 0xb9, 0x73, 0x67, 0xee, 0x9d, 0xab, 0x41, 0x1e, 0xf8, 0x71, - 0xe7, 0x7e, 0xe7, 0xf1, 0x9f, 0xb9, 0xdf, 0x7c, 0x8f, 0x99, 0x4b, 0xe4, 0xa3, 0x28, 0x4a, 0x4c, - 0x24, 0x7c, 0xfa, 0xa9, 0x2a, 0x9a, 0xe8, 0x08, 0x11, 0x25, 0x25, 0xe9, 0xdf, 0xdb, 0xe2, 0x89, - 0x7a, 0xd1, 0xe6, 0xf7, 0xeb, 0xdf, 0x07, 0x11, 0x97, 0xbe, 0x93, 0x68, 0x1f, 0x62, 0x70, 0x88, - 0x16, 0xbd, 0x7d, 0xd5, 0x10, 0x37, 0x43, 0x3a, 0x0e, 0x2c, 0x06, 0xdc, 0x16, 0x62, 0x9c, 0xa5, - 0xfc, 0x65, 0xb1, 0x60, 0x58, 0x88, 0x75, 0x99, 0x9b, 0x06, 0x2a, 0xc1, 0x1b, 0xa1, 0x52, 0xda, - 0x82, 0x19, 0xae, 0x90, 0xca, 0xc0, 0x04, 0x58, 0x02, 0x9a, 0xb0, 0x24, 0x6d, 0xa5, 0x12, 0x13, - 0xc8, 0xd8, 0x3f, 0xab, 0xe4, 0x99, 0x99, 0x95, 0xfa, 0x56, 0xe6, 0x17, 0x8d, 0x40, 0xb9, 0x06, - 0x13, 0x12, 0x6b, 0xb6, 0x4b, 0xa6, 0x73, 0x0e, 0xc4, 0x92, 0xc4, 0x9a, 0xed, 0xba, 0x83, 0x5c, - 0x83, 0x6b, 0x16, 0xf9, 0x1d, 0x2e, 0xf2, 0x3b, 0x2c, 0xf2, 0xdb, 0x5d, 0xe4, 0xb7, 0x9b, 0x72, - 0xf9, 0xfe, 0xea, 0x76, 0x91, 0xcf, 0xb1, 0x6a, 0x3f, 0x9e, 0x03, 0x5f, 0x5c, 0xe4, 0x73, 0xec, - 0x59, 0x25, 0xbf, 0x00, 0xdc, 0x02, 0x5d, 0xe0, 0x2e, 0x58, 0x00, 0xf7, 0xe5, 0x7b, 0x97, 0x1c, - 0x2f, 0x88, 0xaf, 0x4b, 0x62, 0x0b, 0x4c, 0xd7, 0xe0, 0x93, 0xcf, 0x7c, 0xf0, 0x99, 0xf4, 0x7b, - 0xc9, 0xb0, 0x52, 0x69, 0xcb, 0x37, 0xc5, 0x5a, 0x59, 0x1e, 0x98, 0x06, 0x25, 0x4a, 0x5b, 0x89, - 0xb4, 0xe5, 0xd9, 0xe4, 0x19, 0x96, 0x09, 0x9e, 0x83, 0x22, 0xa5, 0xad, 0x48, 0xda, 0x32, 0xcd, - 0xc1, 0x8b, 0x18, 0x55, 0x93, 0x71, 0x44, 0xa3, 0x51, 0x44, 0x2d, 0x3e, 0x1d, 0xa7, 0xc6, 0x79, - 0x3c, 0xcf, 0x60, 0x2a, 0xa2, 0x2c, 0x52, 0xe6, 0x99, 0x78, 0x57, 0xf3, 0x8c, 0xd9, 0x52, 0xc1, - 0x43, 0xf0, 0x40, 0x8e, 0xbd, 0xb6, 0x1c, 0xf0, 0x5e, 0xc8, 0x89, 0x40, 0xfd, 0x6c, 0xf0, 0x41, - 0xc8, 0xf6, 0xb0, 0x2e, 0x8f, 0x91, 0xbd, 0xa0, 0x0a, 0x7c, 0x15, 0xf8, 0x78, 0x0f, 0xd9, 0xcf, - 0x69, 0xc1, 0x8c, 0x7b, 0x9c, 0xbb, 0xaf, 0x0d, 0x3c, 0x05, 0xf3, 0xb4, 0x3e, 0x4e, 0xe6, 0xa5, - 0xad, 0x15, 0x1c, 0x24, 0xfb, 0xfb, 0xd6, 0xca, 0xf8, 0xbc, 0x78, 0x6c, 0x3a, 0x99, 0x17, 0xc7, - 0x25, 0xd6, 0xe9, 0xb5, 0xf0, 0xb9, 0x9c, 0x27, 0x7d, 0x5c, 0x38, 0x1d, 0xf7, 0xd3, 0x92, 0xe3, - 0xe4, 0x3a, 0xf8, 0x37, 0x79, 0xe1, 0xa2, 0xb6, 0x7a, 0x1d, 0x59, 0x41, 0x6a, 0x73, 0x5f, 0xb6, - 0x85, 0x50, 0xdb, 0xe0, 0x06, 0xd9, 0xaf, 0xc9, 0xe9, 0xa4, 0xf7, 0x5b, 0xa8, 0xf5, 0x9f, 0x48, - 0x8d, 0x40, 0x76, 0x0c, 0xfc, 0x08, 0xa3, 0x3e, 0xcf, 0xa5, 0x85, 0x36, 0xf5, 0xcf, 0x84, 0x51, - 0xdb, 0xe0, 0xb4, 0x4d, 0xfd, 0x72, 0x0f, 0xea, 0x97, 0xdb, 0xd4, 0xaf, 0xf0, 0xa0, 0x7e, 0xc5, - 0x16, 0xd6, 0x2f, 0xf3, 0xa0, 0x7e, 0xa0, 0x7d, 0x12, 0xcf, 0x57, 0x9d, 0x1e, 0xd4, 0xef, 0x94, - 0x5a, 0xaa, 0x61, 0x15, 0xa3, 0x5e, 0xf0, 0xd3, 0x83, 0xfa, 0x5c, 0xe3, 0x8e, 0xd4, 0x34, 0x8c, - 0xe7, 0x8d, 0x26, 0xf0, 0x58, 0x18, 0x03, 0xcf, 0x48, 0x5f, 0xea, 0x38, 0x67, 0x4a, 0xf1, 0x19, - 0x4c, 0x89, 0x6f, 0x46, 0x62, 0xc7, 0x14, 0x5f, 0x13, 0x6d, 0x9c, 0x8b, 0x78, 0x5c, 0xef, 0x06, - 0x29, 0xa4, 0x2f, 0xad, 0xbc, 0xc4, 0x8e, 0x80, 0x15, 0xd2, 0xe7, 0xe1, 0x64, 0xf1, 0xa5, 0xc8, - 0x71, 0xab, 0xf8, 0x46, 0x68, 0x7d, 0x39, 0x4e, 0x91, 0x1a, 0x4e, 0xf6, 0xed, 0x09, 0xa0, 0x5f, - 0x6a, 0x34, 0x58, 0xf8, 0x1b, 0xc4, 0xd7, 0x2f, 0xb1, 0x6e, 0x8d, 0x1f, 0x43, 0x06, 0xa4, 0x46, - 0xa3, 0x85, 0xbf, 0x51, 0x7c, 0x03, 0x12, 0xeb, 0xd6, 0xf8, 0x9c, 0xfa, 0xa4, 0x46, 0xbd, 0x85, - 0xbf, 0x5e, 0x7c, 0x7d, 0x14, 0xda, 0xf9, 0x73, 0xff, 0x5f, 0x01, 0x9f, 0xc0, 0x49, 0x0b, 0x3f, - 0xb7, 0x4d, 0x49, 0x4c, 0x94, 0x85, 0x7f, 0xd5, 0xb4, 0xab, 0xda, 0x2a, 0x2b, 0xd4, 0x4c, 0x8b, - 0x78, 0x94, 0x99, 0xc3, 0xa9, 0x4c, 0x52, 0xdc, 0x2a, 0xa3, 0x48, 0x63, 0x5a, 0x70, 0x2b, 0x04, - 0xc2, 0x6c, 0xdc, 0xb6, 0x98, 0x94, 0xb6, 0x06, 0x77, 0xbc, 0x9f, 0xf4, 0x89, 0x79, 0x6d, 0x1f, - 0x96, 0xb8, 0x71, 0x1f, 0x76, 0xea, 0x72, 0x51, 0x48, 0x48, 0xf9, 0x77, 0xe0, 0x2d, 0x48, 0x0b, - 0xb5, 0x4e, 0x18, 0xfa, 0xd5, 0xb4, 0x3e, 0x36, 0xab, 0xb7, 0x40, 0xbf, 0x56, 0xd1, 0xaf, 0xfd, - 0x0f, 0xf5, 0xeb, 0x14, 0xfd, 0xba, 0x4d, 0xd2, 0xe4, 0xdb, 0x9a, 0x9f, 0x35, 0x5f, 0x82, 0xdf, - 0x8a, 0x3e, 0x1f, 0xf3, 0x9e, 0x92, 0x9f, 0x23, 0x33, 0x22, 0xa0, 0xcb, 0x73, 0xe7, 0x10, 0xe9, - 0xf3, 0x46, 0xb0, 0x75, 0x82, 0x63, 0x06, 0xc1, 0x2e, 0x8f, 0xb4, 0xf3, 0x68, 0x7d, 0x3d, 0x71, - 0xc3, 0x77, 0x90, 0x1b, 0xa6, 0x76, 0x21, 0xe9, 0x7b, 0xb3, 0x50, 0xd7, 0x4c, 0xce, 0x2d, 0x08, - 0xe3, 0x37, 0x77, 0xb3, 0x97, 0x0f, 0xc4, 0x37, 0x90, 0x1c, 0x82, 0xfe, 0x90, 0x07, 0xda, 0x06, - 0x83, 0x2e, 0xb5, 0xf7, 0x9b, 0xee, 0xef, 0x70, 0xe1, 0x5a, 0x07, 0x5c, 0xe8, 0xbb, 0x79, 0x77, - 0xe3, 0x94, 0x9b, 0x2e, 0xf4, 0x5f, 0x47, 0x40, 0xff, 0x95, 0x43, 0x6d, 0xde, 0x23, 0x39, 0x19, - 0xe7, 0xa1, 0xf4, 0x41, 0xb4, 0x03, 0xfd, 0xd4, 0x08, 0x68, 0x1b, 0xa4, 0x38, 0xd0, 0xcf, 0x88, - 0xa0, 0x7e, 0xd0, 0xb9, 0x79, 0x5b, 0x7f, 0x5b, 0x7f, 0xab, 0xf4, 0x61, 0x17, 0xc1, 0xbd, 0x08, - 0xea, 0x73, 0xed, 0x0b, 0x01, 0xb4, 0xe3, 0xc1, 0xaf, 0x08, 0x6a, 0x1b, 0xf0, 0xff, 0x05, 0xf1, - 0x16, 0xfa, 0xfc, 0x50, 0x31, 0xbe, 0x09, 0xfa, 0xfc, 0x0e, 0xcd, 0x67, 0xf3, 0x1b, 0x1c, 0x02, - 0x87, 0x4d, 0xd4, 0x28, 0xf9, 0xc3, 0x16, 0x7e, 0x83, 0x61, 0x25, 0xae, 0xc6, 0xc2, 0x7f, 0xc8, - 0xea, 0xda, 0x1d, 0xdc, 0x93, 0xc5, 0x4a, 0xdd, 0x1e, 0x9b, 0xb8, 0x1e, 0x25, 0xae, 0xd8, 0xad, - 0xce, 0xb6, 0xfe, 0x3f, 0xab, 0x7f, 0x42, 0xa9, 0xdb, 0x6d, 0x13, 0xa7, 0xfe, 0x67, 0x75, 0xdc, - 0x43, 0xfd, 0x1d, 0xa4, 0xbf, 0x27, 0xe2, 0x77, 0xfc, 0x47, 0x6d, 0xe2, 0x72, 0x49, 0xdf, 0xef, - 0x3e, 0xe2, 0x1c, 0x27, 0xb5, 0xb5, 0xe5, 0x42, 0x6d, 0x03, 0x93, 0x71, 0x9a, 0xd6, 0xe2, 0xd3, - 0x70, 0x11, 0x1a, 0x36, 0x42, 0xcd, 0xcb, 0x78, 0x0e, 0x60, 0x16, 0xf1, 0x9c, 0x6f, 0x30, 0x47, - 0x94, 0x60, 0xc7, 0x24, 0x51, 0xdc, 0x28, 0x51, 0x14, 0xc3, 0xc7, 0x5a, 0xeb, 0x47, 0xcd, 0x8a, - 0x3f, 0x05, 0x2f, 0x43, 0xb9, 0xce, 0x1e, 0x00, 0x00, -}; -const StaticFile favicon_ico PROGMEM = {(sizeof(favicon_ico_content)/sizeof(favicon_ico_content[0])), favicon_ico_content}; - -} diff --git a/platformio/common/libs/static/homekit/static.h b/platformio/common/libs/static/homekit/static.h deleted file mode 100644 index c2617e9..0000000 --- a/platformio/common/libs/static/homekit/static.h +++ /dev/null @@ -1,25 +0,0 @@ -/** - * This file is autogenerated with make_static.sh script - */ - -#ifndef COMMON_HOMEKIT_STATIC_H -#define COMMON_HOMEKIT_STATIC_H - -#include - -namespace homekit::files { - -typedef struct { - size_t size; - const uint8_t* content; -} StaticFile; - -extern const StaticFile index_html; -extern const StaticFile app_js; -extern const StaticFile md5_js; -extern const StaticFile style_css; -extern const StaticFile favicon_ico; - -} - -#endif //COMMON_HOMEKIT_STATIC_H \ No newline at end of file diff --git a/platformio/common/libs/static/library.json b/platformio/common/libs/static/library.json deleted file mode 100644 index bc650d7..0000000 --- a/platformio/common/libs/static/library.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "homekit_static", - "version": "1.0.1", - "build": { - "flags": "-I../../include" - } -} - diff --git a/platformio/common/libs/temphum/homekit/temphum.cpp b/platformio/common/libs/temphum/homekit/temphum.cpp deleted file mode 100644 index e69b3a5..0000000 --- a/platformio/common/libs/temphum/homekit/temphum.cpp +++ /dev/null @@ -1,89 +0,0 @@ -#ifndef CONFIG_TARGET_ESP01 -#include -#endif -#include -#include "temphum.h" - -namespace homekit::temphum { - -void Sensor::setup() const { -#ifndef CONFIG_TARGET_ESP01 - pinMode(CONFIG_SDA_GPIO, OUTPUT); - pinMode(CONFIG_SCL_GPIO, OUTPUT); - - Wire.begin(CONFIG_SDA_GPIO, CONFIG_SCL_GPIO); -#else - Wire.begin(); -#endif -} - -void Sensor::writeCommand(int reg) const { - Wire.beginTransmission(dev_addr); - Wire.write(reg); - Wire.endTransmission(); - delay(500); // wait for the measurement to be ready -} - -SensorData Si7021::read() { - uint8_t error = 0; - writeCommand(0xf3); // command to measure temperature - Wire.requestFrom(dev_addr, 2); - if (Wire.available() < 2) { - PRINTLN("Si7021: 0xf3: could not read 2 bytes"); - error = 1; - } - uint16_t temp_raw = Wire.read() << 8 | Wire.read(); - double temperature = ((175.72 * temp_raw) / 65536.0) - 46.85; - - writeCommand(0xf5); // command to measure humidity - Wire.requestFrom(dev_addr, 2); - if (Wire.available() < 2) { - PRINTLN("Si7021: 0xf5: could not read 2 bytes"); - error = 1; - } - uint16_t hum_raw = Wire.read() << 8 | Wire.read(); - double humidity = ((125.0 * hum_raw) / 65536.0) - 6.0; - - return { - .error = error, - .temp = temperature, - .rh = humidity - }; -} - -SensorData DHT12::read() { - SensorData sd; - byte raw[5]; - sd.error = 1; - - writeCommand(0); - Wire.requestFrom(dev_addr, 5); - - if (Wire.available() < 5) { - PRINTLN("DHT12: could not read 5 bytes"); - goto end; - } - - // Parse the received data - for (uint8_t i = 0; i < 5; i++) - raw[i] = Wire.read(); - - if (((raw[0] + raw[1] + raw[2] + raw[3]) & 0xff) != raw[4]) { - PRINTLN("DHT12: checksum error"); - goto end; - } - - // Calculate temperature and humidity values - sd.temp = raw[2] + (raw[3] & 0x7f) * 0.1; - if (raw[3] & 0x80) - sd.temp *= -1; - - sd.rh = raw[0] + raw[1] * 0.1; - - sd.error = 0; - -end: - return sd; -} - -} diff --git a/platformio/common/libs/temphum/homekit/temphum.h b/platformio/common/libs/temphum/homekit/temphum.h deleted file mode 100644 index 1952ce0..0000000 --- a/platformio/common/libs/temphum/homekit/temphum.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include - -namespace homekit::temphum { - -struct SensorData { - uint8_t error = 0; - double temp = 0; // celsius - double rh = 0; // relative humidity percentage -}; - - -class Sensor { -protected: - int dev_addr; -public: - explicit Sensor(int dev) : dev_addr(dev) {} - void setup() const; - void writeCommand(int reg) const; - virtual SensorData read() = 0; -}; - - -class Si7021 : public Sensor { -public: - SensorData read() override; - Si7021() : Sensor(0x40) {} -}; - - -class DHT12 : public Sensor { -public: - SensorData read() override; - DHT12() : Sensor(0x5c) {} -}; - -} \ No newline at end of file diff --git a/platformio/common/libs/temphum/library.json b/platformio/common/libs/temphum/library.json deleted file mode 100644 index 329b7ca..0000000 --- a/platformio/common/libs/temphum/library.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "homekit_temphum", - "version": "1.0.3", - "build": { - "flags": "-I../../include" - } -} - diff --git a/platformio/common/libs/wifi/homekit/wifi.cpp b/platformio/common/libs/wifi/homekit/wifi.cpp deleted file mode 100644 index 3060dd6..0000000 --- a/platformio/common/libs/wifi/homekit/wifi.cpp +++ /dev/null @@ -1,47 +0,0 @@ -#include -#include "wifi.h" -#include -#include - -namespace homekit::wifi { - -using namespace homekit; -using homekit::config::ConfigData; - -const char NODE_ID[] = CONFIG_NODE_ID; -const char AP_SSID[] = CONFIG_WIFI_AP_SSID; -const char STA_SSID[] = CONFIG_WIFI_STA_SSID; -const char STA_PSK[] = CONFIG_WIFI_STA_PSK; - -void getConfig(ConfigData &cfg, const char** ssid, const char** psk, const char** hostname) { - if (cfg.flags.wifi_configured) { - *ssid = cfg.wifi_ssid; - *psk = cfg.wifi_psk; - *hostname = cfg.node_id; - } else { - *ssid = STA_SSID; - *psk = STA_PSK; - *hostname = NODE_ID; - } -} - -std::shared_ptr> scan() { - if (WiFi.getMode() != WIFI_STA) { - PRINTLN("wifi::scan: switching mode to STA"); - WiFi.mode(WIFI_STA); - } - - std::shared_ptr> results(new std::list); - int count = WiFi.scanNetworks(); - for (int i = 0; i < count; i++) { - results->push_back(ScanResult { - .rssi = WiFi.RSSI(i), - .ssid = WiFi.SSID(i) - }); - } - - WiFi.scanDelete(); - return results; -} - -} \ No newline at end of file diff --git a/platformio/common/libs/wifi/homekit/wifi.h b/platformio/common/libs/wifi/homekit/wifi.h deleted file mode 100644 index 3fe77cb..0000000 --- a/platformio/common/libs/wifi/homekit/wifi.h +++ /dev/null @@ -1,40 +0,0 @@ -#ifndef HOMEKIT_TEPMHUM_WIFI_H -#define HOMEKIT_TEPMHUM_WIFI_H - -#include -#include -#include - -#include - -namespace homekit::wifi { - -using homekit::config::ConfigData; - -struct ScanResult { - int rssi; - String ssid; -}; - -void getConfig(ConfigData& cfg, const char** ssid, const char** psk, const char** hostname); - -std::shared_ptr> scan(); - -inline uint32_t getIPAsInteger() { - if (!WiFi.isConnected()) - return 0; - return WiFi.localIP().v4(); -} - -inline int8_t getRSSI() { - return WiFi.RSSI(); -} - -extern const char AP_SSID[]; -extern const char STA_SSID[]; -extern const char STA_PSK[]; -extern const char NODE_ID[]; - -} - -#endif //HOMEKIT_TEPMHUM_WIFI_H \ No newline at end of file diff --git a/platformio/common/libs/wifi/library.json b/platformio/common/libs/wifi/library.json deleted file mode 100644 index c7faecd..0000000 --- a/platformio/common/libs/wifi/library.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "homekit_wifi", - "version": "1.0.1", - "build": { - "flags": "-I../../include" - } -} - diff --git a/platformio/common/make_static.sh b/platformio/common/make_static.sh deleted file mode 100755 index d207e57..0000000 --- a/platformio/common/make_static.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/bash - -#set -x -#set -e - -COMMON_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)" -PROJECT_DIR="$(pwd)" - -fw_version="$(cat "$PROJECT_DIR/src/config.def.h" | grep "^#define FW_VERSION" | awk '{print $3}')" -header="$PROJECT_DIR/src/static.h" -source="$PROJECT_DIR/src/static.cpp" - -[ -f "$header" ] && rm "$header" -[ -f "$source" ] && rm "$source" - -is_minifyable() { - local ext="$1" - [ "$ext" = "html" ] || [ "$ext" = "css" ] || [ "$ext" = "js" ] -} - -minify() { - local ext="$1" - local bin="$(realpath "$COMMON_DIR"/../../tools/minify.js)" - "$bin" --type "$ext" -} - -# .h header -cat <> "$header" -/** - * This file is autogenerated with make_static.sh script - */ - -#pragma once - -#include - -namespace homekit::files { - -typedef struct { - size_t size; - const uint8_t* content; -} StaticFile; - -EOF - -cat <> "$source" -/** - * This file is autogenerated with make_static.sh script - */ - -#include "static.h" - -namespace homekit::files { - -EOF - -# loop over files -for ext in html js css ico; do - for f in "$COMMON_DIR"/static/*.$ext; do - filename="$(basename "$f")" - echo "processing ${filename}..." - filename="${filename/./_}" - - # write .h - echo "extern const StaticFile $filename;" >> "$header" - - # write .c - { - echo "static const uint8_t ${filename}_content[] PROGMEM = {" - - cat "$f" | - ( [ "$ext" = "html" ] && sed "s/{version}/$fw_version/" || cat ) | - ( is_minifyable "$ext" && minify "$ext" || cat ) | - gzip | - xxd -ps -c 16 | - sed 's/.\{2\}/0x&, /g' | - sed 's/^/ /' | - sed 's/[ \t]*$//' - - echo "};" - echo "const StaticFile $filename PROGMEM = {(sizeof(${filename}_content)/sizeof(${filename}_content[0])), ${filename}_content};" - echo "" - } >> "$source" - done -done - -# end of homekit::files -( echo ""; echo "}" ) >> "$header" -echo "}" >> "$source" diff --git a/platformio/common/static/app.js b/platformio/common/static/app.js deleted file mode 100644 index 299230c..0000000 --- a/platformio/common/static/app.js +++ /dev/null @@ -1,246 +0,0 @@ -function isObject(o) { - return Object.prototype.toString.call(o) === '[object Object]'; -} - -function ge(id) { - return document.getElementById(id) -} - -function hide(el) { - el.style.display = 'none' -} - -function cancelEvent(evt) { - if (evt.preventDefault) evt.preventDefault(); - if (evt.stopPropagation) evt.stopPropagation(); - - evt.cancelBubble = true; - evt.returnValue = false; - - return false; -} - -function errorText(e) { - return e instanceof Error ? e.message : e+'' -} - -(function() { - function request(method, url, data, callback) { - data = data || null; - - if (typeof callback != 'function') { - throw new Error('callback must be a function'); - } - - if (!url) - throw new Error('no url specified'); - - switch (method) { - case 'GET': - if (isObject(data)) { - for (var k in data) { - if (data.hasOwnProperty(k)) - url += (url.indexOf('?') === -1 ? '?' : '&')+encodeURIComponent(k)+'='+encodeURIComponent(data[k]) - } - } - break; - - case 'POST': - if (isObject(data)) { - var sdata = []; - for (var k in data) { - if (data.hasOwnProperty(k)) - sdata.push(encodeURIComponent(k)+'='+encodeURIComponent(data[k])); - } - data = sdata.join('&'); - } - break; - } - - var xhr = new XMLHttpRequest(); - xhr.open(method, url); - - if (method === 'POST') - xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); - - xhr.onreadystatechange = function() { - if (xhr.readyState === 4) { - if ('status' in xhr && !/^2|1223/.test(xhr.status)) - throw new Error('http code '+xhr.status) - callback(null, JSON.parse(xhr.responseText)); - } - }; - xhr.onerror = function(e) { - callback(e, null); - }; - - xhr.send(method === 'GET' ? null : data); - return xhr; - } - - window.ajax = { - get: request.bind(request, 'GET'), - post: request.bind(request, 'POST') - } -})(); - - -function lock(el) { - el.setAttribute('disabled', 'disabled'); -} - -function unlock(el) { - el.removeAttribute('disabled'); -} - -function initNetworkSettings() { - function setupField(el, value) { - if (value !== null) - el.value = value; - unlock(el); - } - - var doneRequestsCount = 0; - function onRequestDone() { - doneRequestsCount++; - if (doneRequestsCount === 2) { - hide(ge('loading_label')) - } - } - - var form = document.forms.network_settings; - form.addEventListener('submit', function(e) { - if (!form.hid.value.trim()) { - alert('Введите home id'); - return cancelEvent(e); - } - - if (form.psk.value.length < 8) { - alert('Неверный пароль (минимальная длина - 8 символов)'); - return cancelEvent(e); - } - - if (form.ssid.selectedIndex === -1) { - alert('Не выбрана точка доступа'); - return cancelEvent(e); - } - - lock(form.submit) - }) - form.show_psk.addEventListener('change', function(e) { - form.psk.setAttribute('type', e.target.checked ? 'text' : 'password'); - }); - form.ssid.addEventListener('change', function(e) { - var i = e.target.selectedIndex; - if (i !== -1) { - var opt = e.target.options[i]; - if (opt) - form.psk.value = ''; - } - }); - - ajax.get('/status', {}, function(error, response) { - try { - if (error) - throw error; - - setupField(form.hid, response.node_id || null); - setupField(form.psk, null); - setupField(form.submit, null); - - onRequestDone(); - } catch (error) { - alert(errorText(error)); - } - }); - - ajax.get('/scan', {}, function(error, response) { - try { - if (error) - throw error; - - form.ssid.innerHTML = ''; - for (var i = 0; i < response.list.length; i++) { - var ssid = response.list[i][0]; - var rssi = response.list[i][1]; - form.ssid.append(new Option(ssid + ' (' + rssi + ' dBm)', ssid)); - } - unlock(form.ssid); - - onRequestDone(); - } catch (error) { - alert(errorText(error)); - } - }); -} - -function initUpdateForm() { - var form = document.forms.update_settings; - form.addEventListener('submit', function(e) { - cancelEvent(e); - if (!form.file.files.length) { - alert('Файл обновления не выбран'); - return false; - } - - lock(form.submit); - - var xhr = new XMLHttpRequest(); - var fd = new FormData(); - fd.append('file', form.file.files[0]); - - xhr.upload.addEventListener('progress', function (e) { - var total = form.file.files[0].size; - var progress; - if (e.loaded < total) { - progress = Math.round(e.loaded / total * 100).toFixed(2); - } else { - progress = 100; - } - form.submit.innerHTML = progress + '%'; - }); - xhr.onreadystatechange = function() { - var errorMessage = 'Ошибка обновления'; - var successMessage = 'Обновление завершено, устройство перезагружается'; - if (xhr.readyState === 4) { - try { - var response = JSON.parse(xhr.responseText); - if (response.result === 1) { - alert(successMessage); - } else { - alert(errorMessage); - } - } catch (e) { - alert(successMessage); - } - } - }; - xhr.onerror = function(e) { - alert(errorText(e)); - }; - - xhr.open('POST', e.target.action); - xhr.send(fd); - - return false; - }); - form.file.addEventListener('change', function(e) { - if (e.target.files.length) { - var reader = new FileReader(); - reader.onload = function() { - var hash = window.md5(reader.result); - form.setAttribute('action', '/update?md5='+hash); - unlock(form.submit); - }; - reader.onerror = function() { - alert('Ошибка чтения файла'); - }; - reader.readAsBinaryString(e.target.files[0]); - } - }); -} - -window.initApp = function() { - initNetworkSettings(); - initUpdateForm(); -} \ No newline at end of file diff --git a/platformio/common/static/favicon.ico b/platformio/common/static/favicon.ico deleted file mode 100644 index 6940e4f..0000000 Binary files a/platformio/common/static/favicon.ico and /dev/null differ diff --git a/platformio/common/static/index.html b/platformio/common/static/index.html deleted file mode 100644 index d4a8040..0000000 --- a/platformio/common/static/index.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - Configuration - - - - - - -
Settings (loading...)
-
-
-
WiFi SSID
-
- -
- -
WiFi Password
-
- -
- -
-
- -
Home ID
-
- -
- - -
-
- -
Update firmware (.bin)
-
-
-
- -
- -
-
- -
Reset settings
-
-
- -
-
- -
Info
-
- ESP8266-based relayctl, firmware v{version}
- Part of homekit by Evgeny Zinoviev © 2022 -
- - \ No newline at end of file diff --git a/platformio/common/static/md5.js b/platformio/common/static/md5.js deleted file mode 100644 index b707a4e..0000000 --- a/platformio/common/static/md5.js +++ /dev/null @@ -1,615 +0,0 @@ -/** - * [js-md5]{@link https://github.com/emn178/js-md5} - * - * @namespace md5 - * @version 0.7.3 - * @author Chen, Yi-Cyuan [emn178@gmail.com] - * @copyright Chen, Yi-Cyuan 2014-2017 - * @license MIT - */ -(function () { - 'use strict'; - - var ERROR = 'input is invalid type'; - var ARRAY_BUFFER = typeof window.ArrayBuffer !== 'undefined'; - var HEX_CHARS = '0123456789abcdef'.split(''); - var EXTRA = [128, 32768, 8388608, -2147483648]; - var SHIFT = [0, 8, 16, 24]; - var OUTPUT_TYPES = ['hex', 'array', 'digest', 'buffer', 'arrayBuffer', 'base64']; - var BASE64_ENCODE_CHAR = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split(''); - - var blocks = [], buffer8; - if (ARRAY_BUFFER) { - var buffer = new ArrayBuffer(68); - buffer8 = new Uint8Array(buffer); - blocks = new Uint32Array(buffer); - } - - if (!Array.isArray) { - Array.isArray = function (obj) { - return Object.prototype.toString.call(obj) === '[object Array]'; - }; - } - - if (ARRAY_BUFFER && !ArrayBuffer.isView) { - ArrayBuffer.isView = function (obj) { - return typeof obj === 'object' && obj.buffer && obj.buffer.constructor === ArrayBuffer; - }; - } - - /** - * @method hex - * @memberof md5 - * @description Output hash as hex string - * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash - * @returns {String} Hex string - * @example - * md5.hex('The quick brown fox jumps over the lazy dog'); - * // equal to - * md5('The quick brown fox jumps over the lazy dog'); - */ - /** - * @method digest - * @memberof md5 - * @description Output hash as bytes array - * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash - * @returns {Array} Bytes array - * @example - * md5.digest('The quick brown fox jumps over the lazy dog'); - */ - /** - * @method array - * @memberof md5 - * @description Output hash as bytes array - * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash - * @returns {Array} Bytes array - * @example - * md5.array('The quick brown fox jumps over the lazy dog'); - */ - /** - * @method arrayBuffer - * @memberof md5 - * @description Output hash as ArrayBuffer - * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash - * @returns {ArrayBuffer} ArrayBuffer - * @example - * md5.arrayBuffer('The quick brown fox jumps over the lazy dog'); - */ - /** - * @method buffer - * @deprecated This maybe confuse with Buffer in node.js. Please use arrayBuffer instead. - * @memberof md5 - * @description Output hash as ArrayBuffer - * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash - * @returns {ArrayBuffer} ArrayBuffer - * @example - * md5.buffer('The quick brown fox jumps over the lazy dog'); - */ - /** - * @method base64 - * @memberof md5 - * @description Output hash as base64 string - * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash - * @returns {String} base64 string - * @example - * md5.base64('The quick brown fox jumps over the lazy dog'); - */ - var createOutputMethod = function (outputType) { - return function (message) { - return new Md5(true).update(message)[outputType](); - }; - }; - - /** - * @method create - * @memberof md5 - * @description Create Md5 object - * @returns {Md5} Md5 object. - * @example - * var hash = md5.create(); - */ - /** - * @method update - * @memberof md5 - * @description Create and update Md5 object - * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash - * @returns {Md5} Md5 object. - * @example - * var hash = md5.update('The quick brown fox jumps over the lazy dog'); - * // equal to - * var hash = md5.create(); - * hash.update('The quick brown fox jumps over the lazy dog'); - */ - var createMethod = function () { - var method = createOutputMethod('hex'); - method.create = function () { - return new Md5(); - }; - method.update = function (message) { - return method.create().update(message); - }; - for (var i = 0; i < OUTPUT_TYPES.length; ++i) { - var type = OUTPUT_TYPES[i]; - method[type] = createOutputMethod(type); - } - return method; - }; - - /** - * Md5 class - * @class Md5 - * @description This is internal class. - * @see {@link md5.create} - */ - function Md5(sharedMemory) { - if (sharedMemory) { - blocks[0] = blocks[16] = blocks[1] = blocks[2] = blocks[3] = - blocks[4] = blocks[5] = blocks[6] = blocks[7] = - blocks[8] = blocks[9] = blocks[10] = blocks[11] = - blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; - this.blocks = blocks; - this.buffer8 = buffer8; - } else { - if (ARRAY_BUFFER) { - var buffer = new ArrayBuffer(68); - this.buffer8 = new Uint8Array(buffer); - this.blocks = new Uint32Array(buffer); - } else { - this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - } - } - this.h0 = this.h1 = this.h2 = this.h3 = this.start = this.bytes = this.hBytes = 0; - this.finalized = this.hashed = false; - this.first = true; - } - - /** - * @method update - * @memberof Md5 - * @instance - * @description Update hash - * @param {String|Array|Uint8Array|ArrayBuffer} message message to hash - * @returns {Md5} Md5 object. - * @see {@link md5.update} - */ - Md5.prototype.update = function (message) { - if (this.finalized) { - return; - } - - var notString, type = typeof message; - if (type !== 'string') { - if (type === 'object') { - if (message === null) { - throw ERROR; - } else if (ARRAY_BUFFER && message.constructor === ArrayBuffer) { - message = new Uint8Array(message); - } else if (!Array.isArray(message)) { - if (!ARRAY_BUFFER || !ArrayBuffer.isView(message)) { - throw ERROR; - } - } - } else { - throw ERROR; - } - notString = true; - } - var code, index = 0, i, length = message.length, blocks = this.blocks; - var buffer8 = this.buffer8; - - while (index < length) { - if (this.hashed) { - this.hashed = false; - blocks[0] = blocks[16]; - blocks[16] = blocks[1] = blocks[2] = blocks[3] = - blocks[4] = blocks[5] = blocks[6] = blocks[7] = - blocks[8] = blocks[9] = blocks[10] = blocks[11] = - blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; - } - - if (notString) { - if (ARRAY_BUFFER) { - for (i = this.start; index < length && i < 64; ++index) { - buffer8[i++] = message[index]; - } - } else { - for (i = this.start; index < length && i < 64; ++index) { - blocks[i >> 2] |= message[index] << SHIFT[i++ & 3]; - } - } - } else { - if (ARRAY_BUFFER) { - for (i = this.start; index < length && i < 64; ++index) { - code = message.charCodeAt(index); - if (code < 0x80) { - buffer8[i++] = code; - } else if (code < 0x800) { - buffer8[i++] = 0xc0 | (code >> 6); - buffer8[i++] = 0x80 | (code & 0x3f); - } else if (code < 0xd800 || code >= 0xe000) { - buffer8[i++] = 0xe0 | (code >> 12); - buffer8[i++] = 0x80 | ((code >> 6) & 0x3f); - buffer8[i++] = 0x80 | (code & 0x3f); - } else { - code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); - buffer8[i++] = 0xf0 | (code >> 18); - buffer8[i++] = 0x80 | ((code >> 12) & 0x3f); - buffer8[i++] = 0x80 | ((code >> 6) & 0x3f); - buffer8[i++] = 0x80 | (code & 0x3f); - } - } - } else { - for (i = this.start; index < length && i < 64; ++index) { - code = message.charCodeAt(index); - if (code < 0x80) { - blocks[i >> 2] |= code << SHIFT[i++ & 3]; - } else if (code < 0x800) { - blocks[i >> 2] |= (0xc0 | (code >> 6)) << SHIFT[i++ & 3]; - blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; - } else if (code < 0xd800 || code >= 0xe000) { - blocks[i >> 2] |= (0xe0 | (code >> 12)) << SHIFT[i++ & 3]; - blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; - blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; - } else { - code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); - blocks[i >> 2] |= (0xf0 | (code >> 18)) << SHIFT[i++ & 3]; - blocks[i >> 2] |= (0x80 | ((code >> 12) & 0x3f)) << SHIFT[i++ & 3]; - blocks[i >> 2] |= (0x80 | ((code >> 6) & 0x3f)) << SHIFT[i++ & 3]; - blocks[i >> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; - } - } - } - } - this.lastByteIndex = i; - this.bytes += i - this.start; - if (i >= 64) { - this.start = i - 64; - this.hash(); - this.hashed = true; - } else { - this.start = i; - } - } - if (this.bytes > 4294967295) { - this.hBytes += this.bytes / 4294967296 << 0; - this.bytes = this.bytes % 4294967296; - } - return this; - }; - - Md5.prototype.finalize = function () { - if (this.finalized) { - return; - } - this.finalized = true; - var blocks = this.blocks, i = this.lastByteIndex; - blocks[i >> 2] |= EXTRA[i & 3]; - if (i >= 56) { - if (!this.hashed) { - this.hash(); - } - blocks[0] = blocks[16]; - blocks[16] = blocks[1] = blocks[2] = blocks[3] = - blocks[4] = blocks[5] = blocks[6] = blocks[7] = - blocks[8] = blocks[9] = blocks[10] = blocks[11] = - blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; - } - blocks[14] = this.bytes << 3; - blocks[15] = this.hBytes << 3 | this.bytes >>> 29; - this.hash(); - }; - - Md5.prototype.hash = function () { - var a, b, c, d, bc, da, blocks = this.blocks; - - if (this.first) { - a = blocks[0] - 680876937; - a = (a << 7 | a >>> 25) - 271733879 << 0; - d = (-1732584194 ^ a & 2004318071) + blocks[1] - 117830708; - d = (d << 12 | d >>> 20) + a << 0; - c = (-271733879 ^ (d & (a ^ -271733879))) + blocks[2] - 1126478375; - c = (c << 17 | c >>> 15) + d << 0; - b = (a ^ (c & (d ^ a))) + blocks[3] - 1316259209; - b = (b << 22 | b >>> 10) + c << 0; - } else { - a = this.h0; - b = this.h1; - c = this.h2; - d = this.h3; - a += (d ^ (b & (c ^ d))) + blocks[0] - 680876936; - a = (a << 7 | a >>> 25) + b << 0; - d += (c ^ (a & (b ^ c))) + blocks[1] - 389564586; - d = (d << 12 | d >>> 20) + a << 0; - c += (b ^ (d & (a ^ b))) + blocks[2] + 606105819; - c = (c << 17 | c >>> 15) + d << 0; - b += (a ^ (c & (d ^ a))) + blocks[3] - 1044525330; - b = (b << 22 | b >>> 10) + c << 0; - } - - a += (d ^ (b & (c ^ d))) + blocks[4] - 176418897; - a = (a << 7 | a >>> 25) + b << 0; - d += (c ^ (a & (b ^ c))) + blocks[5] + 1200080426; - d = (d << 12 | d >>> 20) + a << 0; - c += (b ^ (d & (a ^ b))) + blocks[6] - 1473231341; - c = (c << 17 | c >>> 15) + d << 0; - b += (a ^ (c & (d ^ a))) + blocks[7] - 45705983; - b = (b << 22 | b >>> 10) + c << 0; - a += (d ^ (b & (c ^ d))) + blocks[8] + 1770035416; - a = (a << 7 | a >>> 25) + b << 0; - d += (c ^ (a & (b ^ c))) + blocks[9] - 1958414417; - d = (d << 12 | d >>> 20) + a << 0; - c += (b ^ (d & (a ^ b))) + blocks[10] - 42063; - c = (c << 17 | c >>> 15) + d << 0; - b += (a ^ (c & (d ^ a))) + blocks[11] - 1990404162; - b = (b << 22 | b >>> 10) + c << 0; - a += (d ^ (b & (c ^ d))) + blocks[12] + 1804603682; - a = (a << 7 | a >>> 25) + b << 0; - d += (c ^ (a & (b ^ c))) + blocks[13] - 40341101; - d = (d << 12 | d >>> 20) + a << 0; - c += (b ^ (d & (a ^ b))) + blocks[14] - 1502002290; - c = (c << 17 | c >>> 15) + d << 0; - b += (a ^ (c & (d ^ a))) + blocks[15] + 1236535329; - b = (b << 22 | b >>> 10) + c << 0; - a += (c ^ (d & (b ^ c))) + blocks[1] - 165796510; - a = (a << 5 | a >>> 27) + b << 0; - d += (b ^ (c & (a ^ b))) + blocks[6] - 1069501632; - d = (d << 9 | d >>> 23) + a << 0; - c += (a ^ (b & (d ^ a))) + blocks[11] + 643717713; - c = (c << 14 | c >>> 18) + d << 0; - b += (d ^ (a & (c ^ d))) + blocks[0] - 373897302; - b = (b << 20 | b >>> 12) + c << 0; - a += (c ^ (d & (b ^ c))) + blocks[5] - 701558691; - a = (a << 5 | a >>> 27) + b << 0; - d += (b ^ (c & (a ^ b))) + blocks[10] + 38016083; - d = (d << 9 | d >>> 23) + a << 0; - c += (a ^ (b & (d ^ a))) + blocks[15] - 660478335; - c = (c << 14 | c >>> 18) + d << 0; - b += (d ^ (a & (c ^ d))) + blocks[4] - 405537848; - b = (b << 20 | b >>> 12) + c << 0; - a += (c ^ (d & (b ^ c))) + blocks[9] + 568446438; - a = (a << 5 | a >>> 27) + b << 0; - d += (b ^ (c & (a ^ b))) + blocks[14] - 1019803690; - d = (d << 9 | d >>> 23) + a << 0; - c += (a ^ (b & (d ^ a))) + blocks[3] - 187363961; - c = (c << 14 | c >>> 18) + d << 0; - b += (d ^ (a & (c ^ d))) + blocks[8] + 1163531501; - b = (b << 20 | b >>> 12) + c << 0; - a += (c ^ (d & (b ^ c))) + blocks[13] - 1444681467; - a = (a << 5 | a >>> 27) + b << 0; - d += (b ^ (c & (a ^ b))) + blocks[2] - 51403784; - d = (d << 9 | d >>> 23) + a << 0; - c += (a ^ (b & (d ^ a))) + blocks[7] + 1735328473; - c = (c << 14 | c >>> 18) + d << 0; - b += (d ^ (a & (c ^ d))) + blocks[12] - 1926607734; - b = (b << 20 | b >>> 12) + c << 0; - bc = b ^ c; - a += (bc ^ d) + blocks[5] - 378558; - a = (a << 4 | a >>> 28) + b << 0; - d += (bc ^ a) + blocks[8] - 2022574463; - d = (d << 11 | d >>> 21) + a << 0; - da = d ^ a; - c += (da ^ b) + blocks[11] + 1839030562; - c = (c << 16 | c >>> 16) + d << 0; - b += (da ^ c) + blocks[14] - 35309556; - b = (b << 23 | b >>> 9) + c << 0; - bc = b ^ c; - a += (bc ^ d) + blocks[1] - 1530992060; - a = (a << 4 | a >>> 28) + b << 0; - d += (bc ^ a) + blocks[4] + 1272893353; - d = (d << 11 | d >>> 21) + a << 0; - da = d ^ a; - c += (da ^ b) + blocks[7] - 155497632; - c = (c << 16 | c >>> 16) + d << 0; - b += (da ^ c) + blocks[10] - 1094730640; - b = (b << 23 | b >>> 9) + c << 0; - bc = b ^ c; - a += (bc ^ d) + blocks[13] + 681279174; - a = (a << 4 | a >>> 28) + b << 0; - d += (bc ^ a) + blocks[0] - 358537222; - d = (d << 11 | d >>> 21) + a << 0; - da = d ^ a; - c += (da ^ b) + blocks[3] - 722521979; - c = (c << 16 | c >>> 16) + d << 0; - b += (da ^ c) + blocks[6] + 76029189; - b = (b << 23 | b >>> 9) + c << 0; - bc = b ^ c; - a += (bc ^ d) + blocks[9] - 640364487; - a = (a << 4 | a >>> 28) + b << 0; - d += (bc ^ a) + blocks[12] - 421815835; - d = (d << 11 | d >>> 21) + a << 0; - da = d ^ a; - c += (da ^ b) + blocks[15] + 530742520; - c = (c << 16 | c >>> 16) + d << 0; - b += (da ^ c) + blocks[2] - 995338651; - b = (b << 23 | b >>> 9) + c << 0; - a += (c ^ (b | ~d)) + blocks[0] - 198630844; - a = (a << 6 | a >>> 26) + b << 0; - d += (b ^ (a | ~c)) + blocks[7] + 1126891415; - d = (d << 10 | d >>> 22) + a << 0; - c += (a ^ (d | ~b)) + blocks[14] - 1416354905; - c = (c << 15 | c >>> 17) + d << 0; - b += (d ^ (c | ~a)) + blocks[5] - 57434055; - b = (b << 21 | b >>> 11) + c << 0; - a += (c ^ (b | ~d)) + blocks[12] + 1700485571; - a = (a << 6 | a >>> 26) + b << 0; - d += (b ^ (a | ~c)) + blocks[3] - 1894986606; - d = (d << 10 | d >>> 22) + a << 0; - c += (a ^ (d | ~b)) + blocks[10] - 1051523; - c = (c << 15 | c >>> 17) + d << 0; - b += (d ^ (c | ~a)) + blocks[1] - 2054922799; - b = (b << 21 | b >>> 11) + c << 0; - a += (c ^ (b | ~d)) + blocks[8] + 1873313359; - a = (a << 6 | a >>> 26) + b << 0; - d += (b ^ (a | ~c)) + blocks[15] - 30611744; - d = (d << 10 | d >>> 22) + a << 0; - c += (a ^ (d | ~b)) + blocks[6] - 1560198380; - c = (c << 15 | c >>> 17) + d << 0; - b += (d ^ (c | ~a)) + blocks[13] + 1309151649; - b = (b << 21 | b >>> 11) + c << 0; - a += (c ^ (b | ~d)) + blocks[4] - 145523070; - a = (a << 6 | a >>> 26) + b << 0; - d += (b ^ (a | ~c)) + blocks[11] - 1120210379; - d = (d << 10 | d >>> 22) + a << 0; - c += (a ^ (d | ~b)) + blocks[2] + 718787259; - c = (c << 15 | c >>> 17) + d << 0; - b += (d ^ (c | ~a)) + blocks[9] - 343485551; - b = (b << 21 | b >>> 11) + c << 0; - - if (this.first) { - this.h0 = a + 1732584193 << 0; - this.h1 = b - 271733879 << 0; - this.h2 = c - 1732584194 << 0; - this.h3 = d + 271733878 << 0; - this.first = false; - } else { - this.h0 = this.h0 + a << 0; - this.h1 = this.h1 + b << 0; - this.h2 = this.h2 + c << 0; - this.h3 = this.h3 + d << 0; - } - }; - - /** - * @method hex - * @memberof Md5 - * @instance - * @description Output hash as hex string - * @returns {String} Hex string - * @see {@link md5.hex} - * @example - * hash.hex(); - */ - Md5.prototype.hex = function () { - this.finalize(); - - var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3; - - return HEX_CHARS[(h0 >> 4) & 0x0F] + HEX_CHARS[h0 & 0x0F] + - HEX_CHARS[(h0 >> 12) & 0x0F] + HEX_CHARS[(h0 >> 8) & 0x0F] + - HEX_CHARS[(h0 >> 20) & 0x0F] + HEX_CHARS[(h0 >> 16) & 0x0F] + - HEX_CHARS[(h0 >> 28) & 0x0F] + HEX_CHARS[(h0 >> 24) & 0x0F] + - HEX_CHARS[(h1 >> 4) & 0x0F] + HEX_CHARS[h1 & 0x0F] + - HEX_CHARS[(h1 >> 12) & 0x0F] + HEX_CHARS[(h1 >> 8) & 0x0F] + - HEX_CHARS[(h1 >> 20) & 0x0F] + HEX_CHARS[(h1 >> 16) & 0x0F] + - HEX_CHARS[(h1 >> 28) & 0x0F] + HEX_CHARS[(h1 >> 24) & 0x0F] + - HEX_CHARS[(h2 >> 4) & 0x0F] + HEX_CHARS[h2 & 0x0F] + - HEX_CHARS[(h2 >> 12) & 0x0F] + HEX_CHARS[(h2 >> 8) & 0x0F] + - HEX_CHARS[(h2 >> 20) & 0x0F] + HEX_CHARS[(h2 >> 16) & 0x0F] + - HEX_CHARS[(h2 >> 28) & 0x0F] + HEX_CHARS[(h2 >> 24) & 0x0F] + - HEX_CHARS[(h3 >> 4) & 0x0F] + HEX_CHARS[h3 & 0x0F] + - HEX_CHARS[(h3 >> 12) & 0x0F] + HEX_CHARS[(h3 >> 8) & 0x0F] + - HEX_CHARS[(h3 >> 20) & 0x0F] + HEX_CHARS[(h3 >> 16) & 0x0F] + - HEX_CHARS[(h3 >> 28) & 0x0F] + HEX_CHARS[(h3 >> 24) & 0x0F]; - }; - - /** - * @method toString - * @memberof Md5 - * @instance - * @description Output hash as hex string - * @returns {String} Hex string - * @see {@link md5.hex} - * @example - * hash.toString(); - */ - Md5.prototype.toString = Md5.prototype.hex; - - /** - * @method digest - * @memberof Md5 - * @instance - * @description Output hash as bytes array - * @returns {Array} Bytes array - * @see {@link md5.digest} - * @example - * hash.digest(); - */ - Md5.prototype.digest = function () { - this.finalize(); - - var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3; - return [ - h0 & 0xFF, (h0 >> 8) & 0xFF, (h0 >> 16) & 0xFF, (h0 >> 24) & 0xFF, - h1 & 0xFF, (h1 >> 8) & 0xFF, (h1 >> 16) & 0xFF, (h1 >> 24) & 0xFF, - h2 & 0xFF, (h2 >> 8) & 0xFF, (h2 >> 16) & 0xFF, (h2 >> 24) & 0xFF, - h3 & 0xFF, (h3 >> 8) & 0xFF, (h3 >> 16) & 0xFF, (h3 >> 24) & 0xFF - ]; - }; - - /** - * @method array - * @memberof Md5 - * @instance - * @description Output hash as bytes array - * @returns {Array} Bytes array - * @see {@link md5.array} - * @example - * hash.array(); - */ - Md5.prototype.array = Md5.prototype.digest; - - /** - * @method arrayBuffer - * @memberof Md5 - * @instance - * @description Output hash as ArrayBuffer - * @returns {ArrayBuffer} ArrayBuffer - * @see {@link md5.arrayBuffer} - * @example - * hash.arrayBuffer(); - */ - Md5.prototype.arrayBuffer = function () { - this.finalize(); - - var buffer = new ArrayBuffer(16); - var blocks = new Uint32Array(buffer); - blocks[0] = this.h0; - blocks[1] = this.h1; - blocks[2] = this.h2; - blocks[3] = this.h3; - return buffer; - }; - - /** - * @method buffer - * @deprecated This maybe confuse with Buffer in node.js. Please use arrayBuffer instead. - * @memberof Md5 - * @instance - * @description Output hash as ArrayBuffer - * @returns {ArrayBuffer} ArrayBuffer - * @see {@link md5.buffer} - * @example - * hash.buffer(); - */ - Md5.prototype.buffer = Md5.prototype.arrayBuffer; - - /** - * @method base64 - * @memberof Md5 - * @instance - * @description Output hash as base64 string - * @returns {String} base64 string - * @see {@link md5.base64} - * @example - * hash.base64(); - */ - Md5.prototype.base64 = function () { - var v1, v2, v3, base64Str = '', bytes = this.array(); - for (var i = 0; i < 15;) { - v1 = bytes[i++]; - v2 = bytes[i++]; - v3 = bytes[i++]; - base64Str += BASE64_ENCODE_CHAR[v1 >>> 2] + - BASE64_ENCODE_CHAR[(v1 << 4 | v2 >>> 4) & 63] + - BASE64_ENCODE_CHAR[(v2 << 2 | v3 >>> 6) & 63] + - BASE64_ENCODE_CHAR[v3 & 63]; - } - v1 = bytes[i]; - base64Str += BASE64_ENCODE_CHAR[v1 >>> 2] + - BASE64_ENCODE_CHAR[(v1 << 4) & 63] + - '=='; - return base64Str; - }; - - window.md5 = createMethod(); -})(); diff --git a/platformio/common/static/style.css b/platformio/common/static/style.css deleted file mode 100644 index 32bd02c..0000000 --- a/platformio/common/static/style.css +++ /dev/null @@ -1,85 +0,0 @@ -body, html { - padding: 0; - margin: 0; -} -body, button, input[type="text"], input[type="password"] { - font-size: 16px; - font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; -} - -.title { - padding: 10px 10px 6px; - font-weight: 600; - background-color: #eff2f5; - border-bottom: 1px #d9e0e7 solid; - color: #276eb4; - font-size: 15px; -} -.block { - padding: 10px; -} -.full-width { - width: 100%; - box-sizing: border-box; -} - -.form_label { - padding: 0 0 3px; - font-weight: 600; -} -.form_input { - margin-bottom: 15px; -} -.form_sublabel { - padding-top: 3px; -} - -input[type="text"], -input[type="password"], -select { - border-radius: 4px; - border: 1px #c9cccf solid; - padding: 7px 9px; - outline: none; -} -input[type="text"]:focus, -input[type="password"]:focus, -select:focus { - box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); -} -input[type="text"]:disabled, -input[type="password"]:disabled, -select:disabled { - background-color: #f1f2f3; - border-color: #f1f2f3; -} - -button { - border-radius: 4px; - border: 1px #c9cccf solid; - padding: 7px 15px; - outline: none; - background: #fff; - color: #000; /* fix for iOS */ - position: relative; - line-height: 18px; - font-weight: 600; -} -button:disabled { - background-color: #f1f2f3; - border-color: #f1f2f3; -} -button:not(:disabled):hover { - box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); - cursor: pointer; - border-color: #b5cce3; - color: #276eb4; -} -button:not(:disabled):active { - top: 1px; -} - -button.is_reset, -button.is_reset:not(:disabled):hover { - color: #e63917; -} \ No newline at end of file diff --git a/platformio/dumb_mqtt/.gitignore b/platformio/dumb_mqtt/.gitignore deleted file mode 100644 index 3fe18ad..0000000 --- a/platformio/dumb_mqtt/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.pio -CMakeListsPrivate.txt -cmake-build-*/ diff --git a/platformio/dumb_mqtt/src/main.cpp b/platformio/dumb_mqtt/src/main.cpp deleted file mode 100644 index eefc165..0000000 --- a/platformio/dumb_mqtt/src/main.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include -#include - -using namespace homekit; - -void setup() { - main::setup(); -} - -void loop() { - main::loop(nullptr); -} diff --git a/platformio/relayctl/.gitignore b/platformio/relayctl/.gitignore deleted file mode 100644 index 3fe18ad..0000000 --- a/platformio/relayctl/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.pio -CMakeListsPrivate.txt -cmake-build-*/ diff --git a/platformio/relayctl/src/main.cpp b/platformio/relayctl/src/main.cpp deleted file mode 100644 index c399641..0000000 --- a/platformio/relayctl/src/main.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include -#include -#include -#include -#include -#include - -using namespace homekit; -using main::LoopConfig; -using mqtt::Mqtt; -using mqtt::MqttRelayModule; - -MqttRelayModule* mqttRelayModule = nullptr; - -static void onMqttCreated(Mqtt& mqtt); - -LoopConfig loopConfig = { - .onMqttCreated = onMqttCreated -}; - -void setup() { - main::setup(); - relay::init(); -} - -void loop() { - main::loop(&loopConfig); -} - -static void onMqttCreated(Mqtt& mqtt) { - if (mqttRelayModule == nullptr) { - mqttRelayModule = new MqttRelayModule(); - mqtt.addModule(mqttRelayModule); - } -} \ No newline at end of file diff --git a/platformio/temphum/.gitignore b/platformio/temphum/.gitignore deleted file mode 100644 index 3fe18ad..0000000 --- a/platformio/temphum/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.pio -CMakeListsPrivate.txt -cmake-build-*/ diff --git a/platformio/temphum/src/main.cpp b/platformio/temphum/src/main.cpp deleted file mode 100644 index 2df8638..0000000 --- a/platformio/temphum/src/main.cpp +++ /dev/null @@ -1,42 +0,0 @@ -#include -#include -#include -#include -#include -#include - -using namespace homekit; -using main::LoopConfig; -using mqtt::Mqtt; -using mqtt::MqttTemphumModule; - -temphum::Sensor* sensor = nullptr; -MqttTemphumModule* mqttTemphumModule = nullptr; - -static void onMqttCreated(Mqtt& mqtt); - -LoopConfig loopConfig = { - .onMqttCreated = onMqttCreated -}; - -void setup() { - main::setup(); - -#if CONFIG_MODULE == HOMEKIT_SI7021 - sensor = new temphum::Si7021(); -#elif CONFIG_MODULE == HOMEKIT_DHT12 - sensor = new temphum::DHT12(); -#endif - sensor->setup(); -} - -void loop() { - main::loop(&loopConfig); -} - -static void onMqttCreated(Mqtt& mqtt) { - if (mqttTemphumModule == nullptr) { - mqttTemphumModule = new MqttTemphumModule(sensor); - mqtt.addModule(mqttTemphumModule); - } -} \ No newline at end of file diff --git a/platformio/temphum_relayctl/src/main.cpp b/platformio/temphum_relayctl/src/main.cpp deleted file mode 100644 index 7f0945e..0000000 --- a/platformio/temphum_relayctl/src/main.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace homekit; -using main::LoopConfig; -using mqtt::Mqtt; -using mqtt::MqttTemphumModule; -using mqtt::MqttRelayModule; - -temphum::Sensor* sensor = nullptr; -MqttTemphumModule* mqttTemphumModule = nullptr; -MqttRelayModule* mqttRelayModule = nullptr; - -static void onMqttCreated(Mqtt& mqtt); - -LoopConfig loopConfig = { - .onMqttCreated = onMqttCreated -}; - -void setup() { - main::setup(); - - relay::init(); - relay::off(); - -#if CONFIG_MODULE == HOMEKIT_SI7021 - sensor = new temphum::Si7021(); -#elif CONFIG_MODULE == HOMEKIT_DHT12 - sensor = new temphum::DHT12(); -#endif - sensor->setup(); -} - -void loop() { - main::loop(&loopConfig); -} - -static void onMqttCreated(Mqtt& mqtt) { - if (mqttTemphumModule == nullptr) { - mqttTemphumModule = new MqttTemphumModule(sensor); - mqttRelayModule = new MqttRelayModule(); - mqtt.addModule(mqttTemphumModule); - mqtt.addModule(mqttRelayModule); - } -} \ No newline at end of file diff --git a/py_include/__init__.py b/py_include/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/py_include/homekit/__init__.py b/py_include/homekit/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/py_include/homekit/api/__init__.py b/py_include/homekit/api/__init__.py deleted file mode 100644 index d641f62..0000000 --- a/py_include/homekit/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/py_include/homekit/api/__init__.pyi b/py_include/homekit/api/__init__.pyi deleted file mode 100644 index 5b98161..0000000 --- a/py_include/homekit/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/py_include/homekit/api/config.py b/py_include/homekit/api/config.py deleted file mode 100644 index 00c1097..0000000 --- a/py_include/homekit/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/py_include/homekit/api/errors/__init__.py b/py_include/homekit/api/errors/__init__.py deleted file mode 100644 index efb06aa..0000000 --- a/py_include/homekit/api/errors/__init__.py +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 85d788b..0000000 --- a/py_include/homekit/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/py_include/homekit/api/types/__init__.py b/py_include/homekit/api/types/__init__.py deleted file mode 100644 index 9f27ff6..0000000 --- a/py_include/homekit/api/types/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 981e798..0000000 --- a/py_include/homekit/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/py_include/homekit/api/web_api_client.py b/py_include/homekit/api/web_api_client.py deleted file mode 100644 index 15c1915..0000000 --- a/py_include/homekit/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/py_include/homekit/audio/__init__.py b/py_include/homekit/audio/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/py_include/homekit/audio/amixer.py b/py_include/homekit/audio/amixer.py deleted file mode 100644 index 5133c97..0000000 --- a/py_include/homekit/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/py_include/homekit/camera/__init__.py b/py_include/homekit/camera/__init__.py deleted file mode 100644 index 626930b..0000000 --- a/py_include/homekit/camera/__init__.py +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index fe6de0e..0000000 --- a/py_include/homekit/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/py_include/homekit/camera/types.py b/py_include/homekit/camera/types.py deleted file mode 100644 index de59022..0000000 --- a/py_include/homekit/camera/types.py +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 97f35aa..0000000 --- a/py_include/homekit/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/py_include/homekit/config/__init__.py b/py_include/homekit/config/__init__.py deleted file mode 100644 index 2fa5214..0000000 --- a/py_include/homekit/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/py_include/homekit/config/_configs.py b/py_include/homekit/config/_configs.py deleted file mode 100644 index 1628cba..0000000 --- a/py_include/homekit/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/py_include/homekit/config/config.py b/py_include/homekit/config/config.py deleted file mode 100644 index 7344386..0000000 --- a/py_include/homekit/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/py_include/homekit/database/__init__.py b/py_include/homekit/database/__init__.py deleted file mode 100644 index b50cbce..0000000 --- a/py_include/homekit/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/py_include/homekit/database/__init__.pyi b/py_include/homekit/database/__init__.pyi deleted file mode 100644 index 31aae5d..0000000 --- a/py_include/homekit/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/py_include/homekit/database/_base.py b/py_include/homekit/database/_base.py deleted file mode 100644 index c01e62b..0000000 --- a/py_include/homekit/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/py_include/homekit/database/bots.py b/py_include/homekit/database/bots.py deleted file mode 100644 index cde48b9..0000000 --- a/py_include/homekit/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/py_include/homekit/database/clickhouse.py b/py_include/homekit/database/clickhouse.py deleted file mode 100644 index d0ec283..0000000 --- a/py_include/homekit/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/py_include/homekit/database/inverter.py b/py_include/homekit/database/inverter.py deleted file mode 100644 index fc3f74f..0000000 --- a/py_include/homekit/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/py_include/homekit/database/inverter_time_formats.py b/py_include/homekit/database/inverter_time_formats.py deleted file mode 100644 index 7c37d30..0000000 --- a/py_include/homekit/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/py_include/homekit/database/mysql.py b/py_include/homekit/database/mysql.py deleted file mode 100644 index fe97cd4..0000000 --- a/py_include/homekit/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/py_include/homekit/database/sensors.py b/py_include/homekit/database/sensors.py deleted file mode 100644 index 8155108..0000000 --- a/py_include/homekit/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/py_include/homekit/database/simple_state.py b/py_include/homekit/database/simple_state.py deleted file mode 100644 index 2b8ebe7..0000000 --- a/py_include/homekit/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/py_include/homekit/database/sqlite.py b/py_include/homekit/database/sqlite.py deleted file mode 100644 index 0af1f54..0000000 --- a/py_include/homekit/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/py_include/homekit/http/__init__.py b/py_include/homekit/http/__init__.py deleted file mode 100644 index 6030e95..0000000 --- a/py_include/homekit/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/py_include/homekit/http/http.py b/py_include/homekit/http/http.py deleted file mode 100644 index 3e70751..0000000 --- a/py_include/homekit/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/py_include/homekit/inverter/__init__.py b/py_include/homekit/inverter/__init__.py deleted file mode 100644 index 8831ef3..0000000 --- a/py_include/homekit/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/py_include/homekit/inverter/config.py b/py_include/homekit/inverter/config.py deleted file mode 100644 index e284dfe..0000000 --- a/py_include/homekit/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/py_include/homekit/inverter/emulator.py b/py_include/homekit/inverter/emulator.py deleted file mode 100644 index e86b8bb..0000000 --- a/py_include/homekit/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/py_include/homekit/inverter/inverter_wrapper.py b/py_include/homekit/inverter/inverter_wrapper.py deleted file mode 100644 index df2c2fc..0000000 --- a/py_include/homekit/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/py_include/homekit/inverter/monitor.py b/py_include/homekit/inverter/monitor.py deleted file mode 100644 index 86f75ac..0000000 --- a/py_include/homekit/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/py_include/homekit/inverter/types.py b/py_include/homekit/inverter/types.py deleted file mode 100644 index 57021f1..0000000 --- a/py_include/homekit/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/py_include/homekit/inverter/util.py b/py_include/homekit/inverter/util.py deleted file mode 100644 index a577e6a..0000000 --- a/py_include/homekit/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/py_include/homekit/media/__init__.py b/py_include/homekit/media/__init__.py deleted file mode 100644 index 6923105..0000000 --- a/py_include/homekit/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/py_include/homekit/media/__init__.pyi b/py_include/homekit/media/__init__.pyi deleted file mode 100644 index 77c2176..0000000 --- a/py_include/homekit/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/py_include/homekit/media/node_client.py b/py_include/homekit/media/node_client.py deleted file mode 100644 index eb39898..0000000 --- a/py_include/homekit/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/py_include/homekit/media/node_server.py b/py_include/homekit/media/node_server.py deleted file mode 100644 index 5d0803c..0000000 --- a/py_include/homekit/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/py_include/homekit/media/record.py b/py_include/homekit/media/record.py deleted file mode 100644 index cd7447a..0000000 --- a/py_include/homekit/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/py_include/homekit/media/record_client.py b/py_include/homekit/media/record_client.py deleted file mode 100644 index 322495c..0000000 --- a/py_include/homekit/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/py_include/homekit/media/storage.py b/py_include/homekit/media/storage.py deleted file mode 100644 index dd74ff8..0000000 --- a/py_include/homekit/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/py_include/homekit/media/types.py b/py_include/homekit/media/types.py deleted file mode 100644 index acbc291..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/__init__.py b/py_include/homekit/mqtt/__init__.py deleted file mode 100644 index 707d59c..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/_config.py b/py_include/homekit/mqtt/_config.py deleted file mode 100644 index 9ba9443..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/_module.py b/py_include/homekit/mqtt/_module.py deleted file mode 100644 index 80f27bb..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/_mqtt.py b/py_include/homekit/mqtt/_mqtt.py deleted file mode 100644 index fb35a24..0000000 --- a/py_include/homekit/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__)), - '..', - '..', - '..', - '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 deleted file mode 100644 index 4e259a4..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/_payload.py b/py_include/homekit/mqtt/_payload.py deleted file mode 100644 index 58eeae3..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/_util.py b/py_include/homekit/mqtt/_util.py deleted file mode 100644 index 390d463..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/_wrapper.py b/py_include/homekit/mqtt/_wrapper.py deleted file mode 100644 index 3c2774c..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/module/diagnostics.py b/py_include/homekit/mqtt/module/diagnostics.py deleted file mode 100644 index 5db5e99..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/module/inverter.py b/py_include/homekit/mqtt/module/inverter.py deleted file mode 100644 index 29bde0a..0000000 --- a/py_include/homekit/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 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 deleted file mode 100644 index cd34332..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/module/relay.py b/py_include/homekit/mqtt/module/relay.py deleted file mode 100644 index e968031..0000000 --- a/py_include/homekit/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/py_include/homekit/mqtt/module/temphum.py b/py_include/homekit/mqtt/module/temphum.py deleted file mode 100644 index fd02cca..0000000 --- a/py_include/homekit/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/py_include/homekit/pio/__init__.py b/py_include/homekit/pio/__init__.py deleted file mode 100644 index 7216bc4..0000000 --- a/py_include/homekit/pio/__init__.py +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index a6afd20..0000000 --- a/py_include/homekit/pio/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class ProductConfigNotFoundError(Exception): - pass diff --git a/py_include/homekit/pio/products.py b/py_include/homekit/pio/products.py deleted file mode 100644 index 388da03..0000000 --- a/py_include/homekit/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/py_include/homekit/relay/__init__.py b/py_include/homekit/relay/__init__.py deleted file mode 100644 index 406403d..0000000 --- a/py_include/homekit/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/py_include/homekit/relay/__init__.pyi b/py_include/homekit/relay/__init__.pyi deleted file mode 100644 index 7a4a2f4..0000000 --- a/py_include/homekit/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/py_include/homekit/relay/sunxi_h3_client.py b/py_include/homekit/relay/sunxi_h3_client.py deleted file mode 100644 index 8c8d6c4..0000000 --- a/py_include/homekit/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/py_include/homekit/relay/sunxi_h3_server.py b/py_include/homekit/relay/sunxi_h3_server.py deleted file mode 100644 index 1f33969..0000000 --- a/py_include/homekit/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/py_include/homekit/soundsensor/__init__.py b/py_include/homekit/soundsensor/__init__.py deleted file mode 100644 index 30052f8..0000000 --- a/py_include/homekit/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/py_include/homekit/soundsensor/__init__.pyi b/py_include/homekit/soundsensor/__init__.pyi deleted file mode 100644 index cb34972..0000000 --- a/py_include/homekit/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/py_include/homekit/soundsensor/node.py b/py_include/homekit/soundsensor/node.py deleted file mode 100644 index 292452f..0000000 --- a/py_include/homekit/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/py_include/homekit/soundsensor/server.py b/py_include/homekit/soundsensor/server.py deleted file mode 100644 index a627390..0000000 --- a/py_include/homekit/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/py_include/homekit/soundsensor/server_client.py b/py_include/homekit/soundsensor/server_client.py deleted file mode 100644 index 7eef996..0000000 --- a/py_include/homekit/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/py_include/homekit/telegram/__init__.py b/py_include/homekit/telegram/__init__.py deleted file mode 100644 index a68dae1..0000000 --- a/py_include/homekit/telegram/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .telegram import send_message, send_photo diff --git a/py_include/homekit/telegram/_botcontext.py b/py_include/homekit/telegram/_botcontext.py deleted file mode 100644 index a143bfe..0000000 --- a/py_include/homekit/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/py_include/homekit/telegram/_botdb.py b/py_include/homekit/telegram/_botdb.py deleted file mode 100644 index 4e1aec0..0000000 --- a/py_include/homekit/telegram/_botdb.py +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index f5f85bb..0000000 --- a/py_include/homekit/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/py_include/homekit/telegram/_botutil.py b/py_include/homekit/telegram/_botutil.py deleted file mode 100644 index 111a704..0000000 --- a/py_include/homekit/telegram/_botutil.py +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index fc87c1c..0000000 --- a/py_include/homekit/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/py_include/homekit/telegram/bot.py b/py_include/homekit/telegram/bot.py deleted file mode 100644 index 2e33bea..0000000 --- a/py_include/homekit/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 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 deleted file mode 100644 index 4c7d74b..0000000 --- a/py_include/homekit/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/py_include/homekit/telegram/telegram.py b/py_include/homekit/telegram/telegram.py deleted file mode 100644 index f42363e..0000000 --- a/py_include/homekit/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/py_include/homekit/temphum/__init__.py b/py_include/homekit/temphum/__init__.py deleted file mode 100644 index 46d14e6..0000000 --- a/py_include/homekit/temphum/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .base import SensorType, BaseSensor diff --git a/py_include/homekit/temphum/base.py b/py_include/homekit/temphum/base.py deleted file mode 100644 index 602cab7..0000000 --- a/py_include/homekit/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/py_include/homekit/temphum/i2c.py b/py_include/homekit/temphum/i2c.py deleted file mode 100644 index 7d8e2e3..0000000 --- a/py_include/homekit/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/py_include/homekit/util.py b/py_include/homekit/util.py deleted file mode 100644 index 11e7116..0000000 --- a/py_include/homekit/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/py_include/pyA20/__init__.pyi b/py_include/pyA20/__init__.pyi deleted file mode 100644 index e69de29..0000000 diff --git a/py_include/pyA20/gpio/connector.pyi b/py_include/pyA20/gpio/connector.pyi deleted file mode 100644 index 12b2b6e..0000000 --- a/py_include/pyA20/gpio/connector.pyi +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 225fcbe..0000000 --- a/py_include/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/py_include/pyA20/gpio/port.pyi b/py_include/pyA20/gpio/port.pyi deleted file mode 100644 index 17f69fe..0000000 --- a/py_include/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/py_include/pyA20/port.pyi b/py_include/pyA20/port.pyi deleted file mode 100644 index e69de29..0000000 diff --git a/py_include/syncleo/__init__.py b/py_include/syncleo/__init__.py deleted file mode 100644 index 32563a5..0000000 --- a/py_include/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/py_include/syncleo/kettle.py b/py_include/syncleo/kettle.py deleted file mode 100644 index d6e0dd6..0000000 --- a/py_include/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/py_include/syncleo/protocol.py b/py_include/syncleo/protocol.py deleted file mode 100644 index 36a1a8f..0000000 --- a/py_include/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/test/__py_include.py b/test/__py_include.py new file mode 100644 index 0000000..8f98830 --- /dev/null +++ b/test/__py_include.py @@ -0,0 +1,9 @@ +import sys +import os.path + +for _name in ('include/py',): + sys.path.extend([ + os.path.realpath( + os.path.join(os.path.dirname(os.path.join(__file__)), '..', _name) + ) + ]) \ No newline at end of file diff --git a/test/mqtt_relay_server_util.py b/test/mqtt_relay_server_util.py index ac6a9ae..6c02d75 100755 --- a/test/mqtt_relay_server_util.py +++ b/test/mqtt_relay_server_util.py @@ -1,17 +1,11 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include -from src.home.config import config -from src.home.mqtt.relay import MQTTRelayClient +from homekit.config import config if __name__ == '__main__': - config.load_app('test_mqtt_relay_server') - relay = MQTTRelayClient('test') - relay.connect_and_loop() + print(config) + # config.load_app('test_mqtt_relay_server') + # relay = MQTTRelayClient('test') + # relay.connect_and_loop() diff --git a/test/mqtt_relay_util.py b/test/mqtt_relay_util.py index 0d8c764..394bbe8 100755 --- a/test/mqtt_relay_util.py +++ b/test/mqtt_relay_util.py @@ -1,15 +1,9 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include from argparse import ArgumentParser -from src.home.config import config -from src.home.mqtt.relay import MQTTRelayController +from homekit.config import config +from homekit.mqtt.relay import MQTTRelayController if __name__ == '__main__': diff --git a/test/test.py b/test/test.py index 413c25c..267a19f 100755 --- a/test/test.py +++ b/test/test.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +import __py_include from homekit.relay import RelayClient diff --git a/test/test_amixer.py b/test/test_amixer.py index 464941e..e4abc73 100755 --- a/test/test_amixer.py +++ b/test/test_amixer.py @@ -1,12 +1,9 @@ #!/usr/bin/env python3 -import sys, os.path -sys.path.extend([ - os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..')), -]) +import __py_include from argparse import ArgumentParser -from src.home.config import config -from src.home.audio import amixer +from homekit.config import config +from homekit.audio import amixer def validate_control(input: str): diff --git a/test/test_api.py b/test/test_api.py index ecf8764..80ab62a 100755 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,15 +1,9 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include -from src.home.api import WebApiClient -from src.home.api.types import BotType -from src.home.config import config +from homekit.api import WebApiClient +from homekit.api.types import BotType +from homekit.config import config if __name__ == '__main__': diff --git a/test/test_esp32_cam.py b/test/test_esp32_cam.py index 6a4ad25..962768f 100755 --- a/test/test_esp32_cam.py +++ b/test/test_esp32_cam.py @@ -1,18 +1,12 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include from pprint import pprint from argparse import ArgumentParser from time import sleep -from src.home.util import Addr -from src.home.camera import esp32 -from src.home.config import config +from homekit.util import Addr +from homekit.camera import esp32 +from homekit.config import config if __name__ == '__main__': parser = ArgumentParser() diff --git a/test/test_inverter_monitor.py b/test/test_inverter_monitor.py index 621c0e9..3231bab 100755 --- a/test/test_inverter_monitor.py +++ b/test/test_inverter_monitor.py @@ -1,22 +1,11 @@ #!/usr/bin/env python3 -import cmd -import time -import logging -import socket -import sys -import threading -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include from enum import Enum, auto from typing import Optional -from src.home.util import stringify -from src.home.config import config -from src.home.inverter import ( +from homekit.util import stringify +from homekit.config import config +from homekit.inverter import ( wrapper_instance as inverter, InverterMonitor, diff --git a/test/test_ipcam_server_cleanup.py b/test/test_ipcam_server_cleanup.py index 5f313a4..ae8d31c 100644 --- a/test/test_ipcam_server_cleanup.py +++ b/test/test_ipcam_server_cleanup.py @@ -1,19 +1,13 @@ #!/usr/bin/env python3 -import shutil -import sys +import __py_include +import logging import os +import shutil import re -import logging -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) from functools import cmp_to_key from datetime import datetime -from pprint import pprint -from src.home.config import config +from homekit.config import config logger = logging.getLogger(__name__) diff --git a/test/test_polaris_stuff.py b/test/test_polaris_stuff.py index b921891..7778667 100755 --- a/test/test_polaris_stuff.py +++ b/test/test_polaris_stuff.py @@ -1,13 +1,6 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) - -import src.syncleo as polaris +import __py_include +import syncleo if __name__ == '__main__': diff --git a/test/test_record_upload.py b/test/test_record_upload.py index c0daceb..f9c83d8 100755 --- a/test/test_record_upload.py +++ b/test/test_record_upload.py @@ -1,19 +1,12 @@ #!/usr/bin/env python3 +import __py_include import logging -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) - import time -from src.home.api import WebApiClient, RequestParams -from src.home.config import config -from src.home.media import SoundRecordClient -from src.home.util import Addr +from homekit.api import WebApiClient, RequestParams +from homekit.config import config +from homekit.media import SoundRecordClient +from homekit.util import Addr logger = logging.getLogger(__name__) diff --git a/test/test_send_fake_sound_hit.py b/test/test_send_fake_sound_hit.py index 61886cd..3cc3e50 100755 --- a/test/test_send_fake_sound_hit.py +++ b/test/test_send_fake_sound_hit.py @@ -1,14 +1,8 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include from argparse import ArgumentParser -from src.home.util import send_datagram, stringify, Addr +from homekit.util import send_datagram, stringify, Addr if __name__ == '__main__': diff --git a/test/test_sensors_plot.py b/test/test_sensors_plot.py deleted file mode 100755 index e69de29..0000000 diff --git a/test/test_sound_node_client.py b/test/test_sound_node_client.py index 16feb78..c3748ca 100755 --- a/test/test_sound_node_client.py +++ b/test/test_sound_node_client.py @@ -1,11 +1,8 @@ #!/usr/bin/env python3 -import sys, os.path -sys.path.extend([ - os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..')), -]) +import __py_include -from src.home.api.errors import ApiResponseError -from src.home.media import SoundNodeClient +from homekit.api.errors import ApiResponseError +from homekit.media import SoundNodeClient if __name__ == '__main__': diff --git a/test/test_sound_server_api.py b/test/test_sound_server_api.py index 77fe1ba..11cd422 100755 --- a/test/test_sound_server_api.py +++ b/test/test_sound_server_api.py @@ -1,17 +1,11 @@ #!/usr/bin/env python3 -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import __py_include import threading from time import sleep -from src.home.config import config -from src.home.api import WebApiClient -from src.home.api.types import SoundSensorLocation +from homekit.config import config +from homekit.api import WebApiClient +from homekit.api.types import SoundSensorLocation from typing import List, Tuple interrupted = False diff --git a/test/test_stopwatch.py b/test/test_stopwatch.py index 9dd7762..1da0fe7 100755 --- a/test/test_stopwatch.py +++ b/test/test_stopwatch.py @@ -1,3 +1,4 @@ +import __py_include from homekit.util import Stopwatch, StopwatchError from time import sleep diff --git a/test/test_telegram_aio_send_photo.py b/test/test_telegram_aio_send_photo.py index 4d05c03..019fa92 100644 --- a/test/test_telegram_aio_send_photo.py +++ b/test/test_telegram_aio_send_photo.py @@ -1,16 +1,9 @@ #!/usr/bin/env python3 +import __py_include import asyncio -import sys -import os.path -sys.path.extend([ - os.path.realpath( - os.path.join(os.path.dirname(os.path.join(__file__)), '..') - ) -]) +import homekit.telegram.aio as telegram -import src.home.telegram.aio as telegram - -from src.home.config import config +from homekit.config import config async def main(): -- cgit v1.2.3 From eaf8ccfd7de589ea540f810f626890d8cf267e04 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sat, 10 Jun 2023 23:23:00 +0300 Subject: readme: remove obsolete note --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 5897142..7979cf7 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,6 @@ a country house, solving real life tasks. Mostly undocumented. -## TODO - -esp8266/esp32 code: - -- move common stuff to the `commom` directory and use it as a framework - ## License BSD-3c -- cgit v1.2.3 From 6055011d82fe001a8cb88359b322c8a8581cc987 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sat, 10 Jun 2023 23:25:31 +0300 Subject: arduino/esp-32: move files --- .gitignore | 2 +- arduino/ESP32CameraWebServer/CameraWebServer.ino | 165 ++ arduino/ESP32CameraWebServer/app_httpd.cpp | 1287 ++++++++++++++++ arduino/ESP32CameraWebServer/camera_index.h | 1570 ++++++++++++++++++++ arduino/ESP32CameraWebServer/camera_pins.h | 273 ++++ arduino/ESP32CameraWebServer/index_ov2640.html | 1160 +++++++++++++++ .../esp32-cam/CameraWebServer/CameraWebServer.ino | 165 -- arduino/esp32-cam/CameraWebServer/app_httpd.cpp | 1287 ---------------- arduino/esp32-cam/CameraWebServer/camera_index.h | 1570 -------------------- arduino/esp32-cam/CameraWebServer/camera_pins.h | 273 ---- .../esp32-cam/CameraWebServer/index_ov2640.html | 1160 --------------- 11 files changed, 4456 insertions(+), 4456 deletions(-) create mode 100644 arduino/ESP32CameraWebServer/CameraWebServer.ino create mode 100644 arduino/ESP32CameraWebServer/app_httpd.cpp create mode 100644 arduino/ESP32CameraWebServer/camera_index.h create mode 100644 arduino/ESP32CameraWebServer/camera_pins.h create mode 100644 arduino/ESP32CameraWebServer/index_ov2640.html delete mode 100644 arduino/esp32-cam/CameraWebServer/CameraWebServer.ino delete mode 100644 arduino/esp32-cam/CameraWebServer/app_httpd.cpp delete mode 100644 arduino/esp32-cam/CameraWebServer/camera_index.h delete mode 100644 arduino/esp32-cam/CameraWebServer/camera_pins.h delete mode 100644 arduino/esp32-cam/CameraWebServer/index_ov2640.html diff --git a/.gitignore b/.gitignore index 6de5e71..9a32ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ __pycache__ /cpp /include/test.py /bin/test.py -/arduino/esp32-cam/CameraWebServer/wifi_password.h +/arduino/ESP32CameraWebServer/wifi_password.h cmake-build-* .pio platformio.ini diff --git a/arduino/ESP32CameraWebServer/CameraWebServer.ino b/arduino/ESP32CameraWebServer/CameraWebServer.ino new file mode 100644 index 0000000..ef589d9 --- /dev/null +++ b/arduino/ESP32CameraWebServer/CameraWebServer.ino @@ -0,0 +1,165 @@ +#include "esp_camera.h" +#include + +// +// WARNING!!! PSRAM IC required for UXGA resolution and high JPEG quality +// Ensure ESP32 Wrover Module or other board with PSRAM is selected +// Partial images will be transmitted if image exceeds buffer size +// +// You must select partition scheme from the board menu that has at least 3MB APP space. +// Face Recognition is DISABLED for ESP32 and ESP32-S2, because it takes up from 15 +// seconds to process single frame. Face Detection is ENABLED if PSRAM is enabled as well + +// =================== +// Select camera model +// =================== +//#define CAMERA_MODEL_WROVER_KIT // Has PSRAM +//#define CAMERA_MODEL_ESP_EYE // Has PSRAM +//#define CAMERA_MODEL_ESP32S3_EYE // Has PSRAM +//#define CAMERA_MODEL_M5STACK_PSRAM // Has PSRAM +//#define CAMERA_MODEL_M5STACK_V2_PSRAM // M5Camera version B Has PSRAM +//#define CAMERA_MODEL_M5STACK_WIDE // Has PSRAM +//#define CAMERA_MODEL_M5STACK_ESP32CAM // No PSRAM +//#define CAMERA_MODEL_M5STACK_UNITCAM // No PSRAM +#define CAMERA_MODEL_AI_THINKER // Has PSRAM +//#define CAMERA_MODEL_TTGO_T_JOURNAL // No PSRAM +// ** Espressif Internal Boards ** +//#define CAMERA_MODEL_ESP32_CAM_BOARD +//#define CAMERA_MODEL_ESP32S2_CAM_BOARD +//#define CAMERA_MODEL_ESP32S3_CAM_LCD + +#include "camera_pins.h" + +// =========================== +// Enter your WiFi credentials +// =========================== +#include "wifi_password.h" + +volatile float disconnected_since = 0; + +void startCameraServer(); + +void onWiFiDisconnect(WiFiEvent_t event, WiFiEventInfo_t info) { + disconnected_since = millis(); + WiFi.reconnect(); +} + +void onWiFiConnect(WiFiEvent_t event, WiFiEventInfo_t info) { + disconnected_since = 0; +} + +void setup() { + Serial.begin(115200); + //Serial.setDebugOutput(true); + Serial.println(); + + camera_config_t config; + config.ledc_channel = LEDC_CHANNEL_0; + config.ledc_timer = LEDC_TIMER_0; + config.pin_d0 = Y2_GPIO_NUM; + config.pin_d1 = Y3_GPIO_NUM; + config.pin_d2 = Y4_GPIO_NUM; + config.pin_d3 = Y5_GPIO_NUM; + config.pin_d4 = Y6_GPIO_NUM; + config.pin_d5 = Y7_GPIO_NUM; + config.pin_d6 = Y8_GPIO_NUM; + config.pin_d7 = Y9_GPIO_NUM; + config.pin_xclk = XCLK_GPIO_NUM; + config.pin_pclk = PCLK_GPIO_NUM; + config.pin_vsync = VSYNC_GPIO_NUM; + config.pin_href = HREF_GPIO_NUM; + config.pin_sscb_sda = SIOD_GPIO_NUM; + config.pin_sscb_scl = SIOC_GPIO_NUM; + config.pin_pwdn = PWDN_GPIO_NUM; + config.pin_reset = RESET_GPIO_NUM; + config.xclk_freq_hz = 20000000; + config.frame_size = FRAMESIZE_UXGA; + config.pixel_format = PIXFORMAT_JPEG; // for streaming + //config.pixel_format = PIXFORMAT_RGB565; // for face detection/recognition + config.grab_mode = CAMERA_GRAB_WHEN_EMPTY; + config.fb_location = CAMERA_FB_IN_PSRAM; + config.jpeg_quality = 12; + config.fb_count = 1; + + // if PSRAM IC present, init with UXGA resolution and higher JPEG quality + // for larger pre-allocated frame buffer. + if(config.pixel_format == PIXFORMAT_JPEG){ + if(psramFound()){ + config.jpeg_quality = 10; + config.fb_count = 2; + config.grab_mode = CAMERA_GRAB_LATEST; + } else { + // Limit the frame size when PSRAM is not available + config.frame_size = FRAMESIZE_SVGA; + config.fb_location = CAMERA_FB_IN_DRAM; + } + } else { + // Best option for face detection/recognition + config.frame_size = FRAMESIZE_240X240; +#if CONFIG_IDF_TARGET_ESP32S3 + config.fb_count = 2; +#endif + } + +#if defined(CAMERA_MODEL_ESP_EYE) + pinMode(13, INPUT_PULLUP); + pinMode(14, INPUT_PULLUP); +#endif + + // camera init + esp_err_t err = esp_camera_init(&config); + if (err != ESP_OK) { + Serial.printf("Camera init failed with error 0x%x", err); + return; + } + + sensor_t * s = esp_camera_sensor_get(); + // initial sensors are flipped vertically and colors are a bit saturated + if (s->id.PID == OV3660_PID) { + s->set_vflip(s, 1); // flip it back + s->set_brightness(s, 1); // up the brightness just a bit + s->set_saturation(s, -2); // lower the saturation + } + // drop down frame size for higher initial frame rate + if(config.pixel_format == PIXFORMAT_JPEG){ + s->set_framesize(s, FRAMESIZE_QVGA); + } + +#if defined(CAMERA_MODEL_M5STACK_WIDE) || defined(CAMERA_MODEL_M5STACK_ESP32CAM) + s->set_vflip(s, 1); + s->set_hmirror(s, 1); +#endif + +#if defined(CAMERA_MODEL_ESP32S3_EYE) + s->set_vflip(s, 1); +#endif + + WiFi.onEvent(onWiFiDisconnect, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + WiFi.onEvent(onWiFiConnect, ARDUINO_EVENT_WIFI_STA_CONNECTED); + + WiFi.begin(ssid, password); + WiFi.setSleep(false); + + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.println("WiFi connected"); + + startCameraServer(); + + Serial.print("Camera Ready! Use 'http://"); + Serial.print(WiFi.localIP()); + Serial.println("' to connect"); +} + +void loop() { + if (disconnected_since != 0 && (millis() - disconnected_since) > 60000) { + ESP.restart(); + return; + } + + // Do nothing. Everything is done in another task by the web server + delay(10000); +} diff --git a/arduino/ESP32CameraWebServer/app_httpd.cpp b/arduino/ESP32CameraWebServer/app_httpd.cpp new file mode 100644 index 0000000..e397c70 --- /dev/null +++ b/arduino/ESP32CameraWebServer/app_httpd.cpp @@ -0,0 +1,1287 @@ +// Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +#include + +#include "esp_http_server.h" +#include "esp_timer.h" +#include "esp_camera.h" +#include "img_converters.h" +#include "fb_gfx.h" +#include "driver/ledc.h" +#include "sdkconfig.h" +#include "camera_index.h" + +#if defined(ARDUINO_ARCH_ESP32) && defined(CONFIG_ARDUHAL_ESP_LOG) +#include "esp32-hal-log.h" +#define TAG "" +#else +#include "esp_log.h" +static const char *TAG = "camera_httpd"; +#endif + +// Face Detection will not work on boards without (or with disabled) PSRAM +#ifdef BOARD_HAS_PSRAM +#define CONFIG_ESP_FACE_DETECT_ENABLED 1 +// Face Recognition takes upward from 15 seconds per frame on chips other than ESP32S3 +// Makes no sense to have it enabled for them +#if CONFIG_IDF_TARGET_ESP32S3 +#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 1 +#else +#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 0 +#endif +#else +#define CONFIG_ESP_FACE_DETECT_ENABLED 0 +#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 0 +#endif + +#if CONFIG_ESP_FACE_DETECT_ENABLED + +#include +#include "human_face_detect_msr01.hpp" +#include "human_face_detect_mnp01.hpp" + +#define TWO_STAGE 1 /* very large firmware, very slow, reboots when streaming... + +#define FACE_ID_SAVE_NUMBER 7 +#endif + +#define FACE_COLOR_WHITE 0x00FFFFFF +#define FACE_COLOR_BLACK 0x00000000 +#define FACE_COLOR_RED 0x000000FF +#define FACE_COLOR_GREEN 0x0000FF00 +#define FACE_COLOR_BLUE 0x00FF0000 +#define FACE_COLOR_YELLOW (FACE_COLOR_RED | FACE_COLOR_GREEN) +#define FACE_COLOR_CYAN (FACE_COLOR_BLUE | FACE_COLOR_GREEN) +#define FACE_COLOR_PURPLE (FACE_COLOR_BLUE | FACE_COLOR_RED) +#endif + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED +int led_duty = 0; +bool isStreaming = false; +#ifdef CONFIG_LED_LEDC_LOW_SPEED_MODE +#define CONFIG_LED_LEDC_SPEED_MODE LEDC_LOW_SPEED_MODE +#else +#define CONFIG_LED_LEDC_SPEED_MODE LEDC_HIGH_SPEED_MODE +#endif +#endif + +typedef struct +{ + httpd_req_t *req; + size_t len; +} jpg_chunking_t; + +#define PART_BOUNDARY "123456789000000000000987654321" +static const char *_STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY; +static const char *_STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n"; +static const char *_STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\nX-Timestamp: %d.%06d\r\n\r\n"; + +httpd_handle_t stream_httpd = NULL; +httpd_handle_t camera_httpd = NULL; + +#if CONFIG_ESP_FACE_DETECT_ENABLED + +static int8_t detection_enabled = 0; + +// #if TWO_STAGE +// static HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); +// static HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); +// #else +// static HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); +// #endif + +static int8_t flash_enabled = 0; + +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED +static int8_t recognition_enabled = 0; +static int8_t is_enrolling = 0; + +#if QUANT_TYPE + // S16 model + FaceRecognition112V1S16 recognizer; +#else + // S8 model + FaceRecognition112V1S8 recognizer; +#endif +#endif + +#endif + +typedef struct +{ + size_t size; //number of values used for filtering + size_t index; //current value index + size_t count; //value count + int sum; + int *values; //array to be filled with values +} ra_filter_t; + +static ra_filter_t ra_filter; + +static ra_filter_t *ra_filter_init(ra_filter_t *filter, size_t sample_size) +{ + memset(filter, 0, sizeof(ra_filter_t)); + + filter->values = (int *)malloc(sample_size * sizeof(int)); + if (!filter->values) + { + return NULL; + } + memset(filter->values, 0, sample_size * sizeof(int)); + + filter->size = sample_size; + return filter; +} + +static int ra_filter_run(ra_filter_t *filter, int value) +{ + if (!filter->values) + { + return value; + } + filter->sum -= filter->values[filter->index]; + filter->values[filter->index] = value; + filter->sum += filter->values[filter->index]; + filter->index++; + filter->index = filter->index % filter->size; + if (filter->count < filter->size) + { + filter->count++; + } + return filter->sum / filter->count; +} + +#if CONFIG_ESP_FACE_DETECT_ENABLED +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED +static void rgb_print(fb_data_t *fb, uint32_t color, const char *str) +{ + fb_gfx_print(fb, (fb->width - (strlen(str) * 14)) / 2, 10, color, str); +} + +static int rgb_printf(fb_data_t *fb, uint32_t color, const char *format, ...) +{ + char loc_buf[64]; + char *temp = loc_buf; + int len; + va_list arg; + va_list copy; + va_start(arg, format); + va_copy(copy, arg); + len = vsnprintf(loc_buf, sizeof(loc_buf), format, arg); + va_end(copy); + if (len >= sizeof(loc_buf)) + { + temp = (char *)malloc(len + 1); + if (temp == NULL) + { + return 0; + } + } + vsnprintf(temp, len + 1, format, arg); + va_end(arg); + rgb_print(fb, color, temp); + if (len > 64) + { + free(temp); + } + return len; +} +#endif +static void draw_face_boxes(fb_data_t *fb, std::list *results, int face_id) +{ + int x, y, w, h; + uint32_t color = FACE_COLOR_YELLOW; + if (face_id < 0) + { + color = FACE_COLOR_RED; + } + else if (face_id > 0) + { + color = FACE_COLOR_GREEN; + } + if(fb->bytes_per_pixel == 2){ + //color = ((color >> 8) & 0xF800) | ((color >> 3) & 0x07E0) | (color & 0x001F); + color = ((color >> 16) & 0x001F) | ((color >> 3) & 0x07E0) | ((color << 8) & 0xF800); + } + int i = 0; + for (std::list::iterator prediction = results->begin(); prediction != results->end(); prediction++, i++) + { + // rectangle box + x = (int)prediction->box[0]; + y = (int)prediction->box[1]; + w = (int)prediction->box[2] - x + 1; + h = (int)prediction->box[3] - y + 1; + if((x + w) > fb->width){ + w = fb->width - x; + } + if((y + h) > fb->height){ + h = fb->height - y; + } + fb_gfx_drawFastHLine(fb, x, y, w, color); + fb_gfx_drawFastHLine(fb, x, y + h - 1, w, color); + fb_gfx_drawFastVLine(fb, x, y, h, color); + fb_gfx_drawFastVLine(fb, x + w - 1, y, h, color); +#if TWO_STAGE + // landmarks (left eye, mouth left, nose, right eye, mouth right) + int x0, y0, j; + for (j = 0; j < 10; j+=2) { + x0 = (int)prediction->keypoint[j]; + y0 = (int)prediction->keypoint[j+1]; + fb_gfx_fillRect(fb, x0, y0, 3, 3, color); + } +#endif + } +} + +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED +static int run_face_recognition(fb_data_t *fb, std::list *results) +{ + std::vector landmarks = results->front().keypoint; + int id = -1; + + Tensor tensor; + tensor.set_element((uint8_t *)fb->data).set_shape({fb->height, fb->width, 3}).set_auto_free(false); + + int enrolled_count = recognizer.get_enrolled_id_num(); + + if (enrolled_count < FACE_ID_SAVE_NUMBER && is_enrolling){ + id = recognizer.enroll_id(tensor, landmarks, "", true); + ESP_LOGI(TAG, "Enrolled ID: %d", id); + rgb_printf(fb, FACE_COLOR_CYAN, "ID[%u]", id); + } + + face_info_t recognize = recognizer.recognize(tensor, landmarks); + if(recognize.id >= 0){ + rgb_printf(fb, FACE_COLOR_GREEN, "ID[%u]: %.2f", recognize.id, recognize.similarity); + } else { + rgb_print(fb, FACE_COLOR_RED, "Intruder Alert!"); + } + return recognize.id; +} +#endif +#endif + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED +void enable_led(bool en) +{ // Turn LED On or Off + int duty = en ? led_duty : 0; + if (en && isStreaming && (led_duty > CONFIG_LED_MAX_INTENSITY)) + { + duty = CONFIG_LED_MAX_INTENSITY; + } + ledc_set_duty(CONFIG_LED_LEDC_SPEED_MODE, CONFIG_LED_LEDC_CHANNEL, duty); + ledc_update_duty(CONFIG_LED_LEDC_SPEED_MODE, CONFIG_LED_LEDC_CHANNEL); + ESP_LOGI(TAG, "Set LED intensity to %d", duty); +} +#endif + +static esp_err_t bmp_handler(httpd_req_t *req) +{ + camera_fb_t *fb = NULL; + esp_err_t res = ESP_OK; + uint64_t fr_start = esp_timer_get_time(); + fb = esp_camera_fb_get(); + if (!fb) + { + ESP_LOGE(TAG, "Camera capture failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "image/x-windows-bmp"); + httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=capture.bmp"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + + char ts[32]; + snprintf(ts, 32, "%ld.%06ld", fb->timestamp.tv_sec, fb->timestamp.tv_usec); + httpd_resp_set_hdr(req, "X-Timestamp", (const char *)ts); + + + uint8_t * buf = NULL; + size_t buf_len = 0; + bool converted = frame2bmp(fb, &buf, &buf_len); + esp_camera_fb_return(fb); + if(!converted){ + ESP_LOGE(TAG, "BMP Conversion failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + res = httpd_resp_send(req, (const char *)buf, buf_len); + free(buf); + uint64_t fr_end = esp_timer_get_time(); + ESP_LOGI(TAG, "BMP: %llums, %uB", (uint64_t)((fr_end - fr_start) / 1000), buf_len); + return res; +} + +static size_t jpg_encode_stream(void *arg, size_t index, const void *data, size_t len) +{ + jpg_chunking_t *j = (jpg_chunking_t *)arg; + if (!index) + { + j->len = 0; + } + if (httpd_resp_send_chunk(j->req, (const char *)data, len) != ESP_OK) + { + return 0; + } + j->len += len; + return len; +} + +static esp_err_t capture_handler(httpd_req_t *req) +{ + camera_fb_t *fb = NULL; + esp_err_t res = ESP_OK; + int64_t fr_start = esp_timer_get_time(); + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + enable_led(true); + vTaskDelay(150 / portTICK_PERIOD_MS); // The LED needs to be turned on ~150ms before the call to esp_camera_fb_get() + fb = esp_camera_fb_get(); // or it won't be visible in the frame. A better way to do this is needed. + enable_led(false); +#else + fb = esp_camera_fb_get(); +#endif + + if (!fb) + { + ESP_LOGE(TAG, "Camera capture failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "image/jpeg"); + httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=capture.jpg"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + + char ts[32]; + snprintf(ts, 32, "%ld.%06ld", fb->timestamp.tv_sec, fb->timestamp.tv_usec); + httpd_resp_set_hdr(req, "X-Timestamp", (const char *)ts); + +#if CONFIG_ESP_FACE_DETECT_ENABLED + size_t out_len, out_width, out_height; + uint8_t *out_buf; + bool s; + bool detected = false; + int face_id = 0; + if (!detection_enabled || fb->width > 400) + { +#endif + size_t fb_len = 0; + if (fb->format == PIXFORMAT_JPEG) + { + fb_len = fb->len; + res = httpd_resp_send(req, (const char *)fb->buf, fb->len); + } + else + { + jpg_chunking_t jchunk = {req, 0}; + res = frame2jpg_cb(fb, 80, jpg_encode_stream, &jchunk) ? ESP_OK : ESP_FAIL; + httpd_resp_send_chunk(req, NULL, 0); + fb_len = jchunk.len; + } + esp_camera_fb_return(fb); + int64_t fr_end = esp_timer_get_time(); + ESP_LOGI(TAG, "JPG: %uB %ums", (uint32_t)(fb_len), (uint32_t)((fr_end - fr_start) / 1000)); + return res; +#if CONFIG_ESP_FACE_DETECT_ENABLED + } + + jpg_chunking_t jchunk = {req, 0}; + + if (fb->format == PIXFORMAT_RGB565 +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + && !recognition_enabled +#endif + ){ +#if TWO_STAGE + HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); + HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); + std::list &candidates = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); + std::list &results = s2.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}, candidates); +#else + HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); + std::list &results = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); +#endif + if (results.size() > 0) { + fb_data_t rfb; + rfb.width = fb->width; + rfb.height = fb->height; + rfb.data = fb->buf; + rfb.bytes_per_pixel = 2; + rfb.format = FB_RGB565; + detected = true; + draw_face_boxes(&rfb, &results, face_id); + } + s = fmt2jpg_cb(fb->buf, fb->len, fb->width, fb->height, PIXFORMAT_RGB565, 90, jpg_encode_stream, &jchunk); + esp_camera_fb_return(fb); + } else + { + out_len = fb->width * fb->height * 3; + out_width = fb->width; + out_height = fb->height; + out_buf = (uint8_t*)malloc(out_len); + if (!out_buf) { + ESP_LOGE(TAG, "out_buf malloc failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + s = fmt2rgb888(fb->buf, fb->len, fb->format, out_buf); + esp_camera_fb_return(fb); + if (!s) { + free(out_buf); + ESP_LOGE(TAG, "to rgb888 failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + + fb_data_t rfb; + rfb.width = out_width; + rfb.height = out_height; + rfb.data = out_buf; + rfb.bytes_per_pixel = 3; + rfb.format = FB_BGR888; + +#if TWO_STAGE + HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); + HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); + std::list &candidates = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); + std::list &results = s2.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}, candidates); +#else + HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); + std::list &results = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); +#endif + + if (results.size() > 0) { + detected = true; +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + if (recognition_enabled) { + face_id = run_face_recognition(&rfb, &results); + } +#endif + draw_face_boxes(&rfb, &results, face_id); + } + + s = fmt2jpg_cb(out_buf, out_len, out_width, out_height, PIXFORMAT_RGB888, 90, jpg_encode_stream, &jchunk); + free(out_buf); + } + + if (!s) { + ESP_LOGE(TAG, "JPEG compression failed"); + httpd_resp_send_500(req); + return ESP_FAIL; + } + + int64_t fr_end = esp_timer_get_time(); + ESP_LOGI(TAG, "FACE: %uB %ums %s%d", (uint32_t)(jchunk.len), (uint32_t)((fr_end - fr_start) / 1000), detected ? "DETECTED " : "", face_id); + return res; +#endif +} + +static esp_err_t stream_handler(httpd_req_t *req) +{ + camera_fb_t *fb = NULL; + struct timeval _timestamp; + esp_err_t res = ESP_OK; + size_t _jpg_buf_len = 0; + uint8_t *_jpg_buf = NULL; + char *part_buf[128]; +#if CONFIG_ESP_FACE_DETECT_ENABLED + bool detected = false; + int face_id = 0; + int64_t fr_start = 0; + int64_t fr_ready = 0; + int64_t fr_face = 0; + int64_t fr_recognize = 0; + int64_t fr_encode = 0; + + size_t out_len = 0, out_width = 0, out_height = 0; + uint8_t *out_buf = NULL; + bool s = false; +#if TWO_STAGE + HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); + HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); +#else + HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); +#endif +#endif + + static int64_t last_frame = 0; + if (!last_frame) + { + last_frame = esp_timer_get_time(); + } + + res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE); + if (res != ESP_OK) + { + return res; + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_set_hdr(req, "X-Framerate", "60"); + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + enable_led(true); + isStreaming = true; +#endif + + while (true) + { +#if CONFIG_ESP_FACE_DETECT_ENABLED + detected = false; + face_id = 0; +#endif + + fb = esp_camera_fb_get(); + if (!fb) + { + ESP_LOGE(TAG, "Camera capture failed"); + res = ESP_FAIL; + } + else + { + _timestamp.tv_sec = fb->timestamp.tv_sec; + _timestamp.tv_usec = fb->timestamp.tv_usec; +#if CONFIG_ESP_FACE_DETECT_ENABLED + fr_start = esp_timer_get_time(); + fr_ready = fr_start; + fr_face = fr_start; + fr_encode = fr_start; + fr_recognize = fr_start; + if (!detection_enabled || fb->width > 400) + { +#endif + if (fb->format != PIXFORMAT_JPEG) + { + bool jpeg_converted = frame2jpg(fb, 80, &_jpg_buf, &_jpg_buf_len); + esp_camera_fb_return(fb); + fb = NULL; + if (!jpeg_converted) + { + ESP_LOGE(TAG, "JPEG compression failed"); + res = ESP_FAIL; + } + } + else + { + _jpg_buf_len = fb->len; + _jpg_buf = fb->buf; + } +#if CONFIG_ESP_FACE_DETECT_ENABLED + } + else + { + if (fb->format == PIXFORMAT_RGB565 +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + && !recognition_enabled +#endif + ){ + fr_ready = esp_timer_get_time(); +#if TWO_STAGE + std::list &candidates = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); + std::list &results = s2.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}, candidates); +#else + std::list &results = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); +#endif + fr_face = esp_timer_get_time(); + fr_recognize = fr_face; + if (results.size() > 0) { + fb_data_t rfb; + rfb.width = fb->width; + rfb.height = fb->height; + rfb.data = fb->buf; + rfb.bytes_per_pixel = 2; + rfb.format = FB_RGB565; + detected = true; + draw_face_boxes(&rfb, &results, face_id); + } + s = fmt2jpg(fb->buf, fb->len, fb->width, fb->height, PIXFORMAT_RGB565, 80, &_jpg_buf, &_jpg_buf_len); + esp_camera_fb_return(fb); + fb = NULL; + if (!s) { + ESP_LOGE(TAG, "fmt2jpg failed"); + res = ESP_FAIL; + } + fr_encode = esp_timer_get_time(); + } else + { + out_len = fb->width * fb->height * 3; + out_width = fb->width; + out_height = fb->height; + out_buf = (uint8_t*)malloc(out_len); + if (!out_buf) { + ESP_LOGE(TAG, "out_buf malloc failed"); + res = ESP_FAIL; + } else { + s = fmt2rgb888(fb->buf, fb->len, fb->format, out_buf); + esp_camera_fb_return(fb); + fb = NULL; + if (!s) { + free(out_buf); + ESP_LOGE(TAG, "to rgb888 failed"); + res = ESP_FAIL; + } else { + fr_ready = esp_timer_get_time(); + + fb_data_t rfb; + rfb.width = out_width; + rfb.height = out_height; + rfb.data = out_buf; + rfb.bytes_per_pixel = 3; + rfb.format = FB_BGR888; + +#if TWO_STAGE + std::list &candidates = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); + std::list &results = s2.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}, candidates); +#else + std::list &results = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); +#endif + + fr_face = esp_timer_get_time(); + fr_recognize = fr_face; + + if (results.size() > 0) { + detected = true; +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + if (recognition_enabled) { + face_id = run_face_recognition(&rfb, &results); + fr_recognize = esp_timer_get_time(); + } +#endif + draw_face_boxes(&rfb, &results, face_id); + } + s = fmt2jpg(out_buf, out_len, out_width, out_height, PIXFORMAT_RGB888, 90, &_jpg_buf, &_jpg_buf_len); + free(out_buf); + if (!s) { + ESP_LOGE(TAG, "fmt2jpg failed"); + res = ESP_FAIL; + } + fr_encode = esp_timer_get_time(); + } + } + } + } +#endif + } + if (res == ESP_OK) + { + res = httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY)); + } + if (res == ESP_OK) + { + size_t hlen = snprintf((char *)part_buf, 128, _STREAM_PART, _jpg_buf_len, _timestamp.tv_sec, _timestamp.tv_usec); + res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen); + } + if (res == ESP_OK) + { + res = httpd_resp_send_chunk(req, (const char *)_jpg_buf, _jpg_buf_len); + } + if (fb) + { + esp_camera_fb_return(fb); + fb = NULL; + _jpg_buf = NULL; + } + else if (_jpg_buf) + { + free(_jpg_buf); + _jpg_buf = NULL; + } + if (res != ESP_OK) + { + ESP_LOGE(TAG, "send frame failed failed"); + break; + } + int64_t fr_end = esp_timer_get_time(); + +#if CONFIG_ESP_FACE_DETECT_ENABLED + int64_t ready_time = (fr_ready - fr_start) / 1000; + int64_t face_time = (fr_face - fr_ready) / 1000; + int64_t recognize_time = (fr_recognize - fr_face) / 1000; + int64_t encode_time = (fr_encode - fr_recognize) / 1000; + int64_t process_time = (fr_encode - fr_start) / 1000; +#endif + + int64_t frame_time = fr_end - last_frame; + last_frame = fr_end; + frame_time /= 1000; + uint32_t avg_frame_time = ra_filter_run(&ra_filter, frame_time); + ESP_LOGI(TAG, "MJPG: %uB %ums (%.1ffps), AVG: %ums (%.1ffps)" +#if CONFIG_ESP_FACE_DETECT_ENABLED + ", %u+%u+%u+%u=%u %s%d" +#endif + , + (uint32_t)(_jpg_buf_len), + (uint32_t)frame_time, 1000.0 / (uint32_t)frame_time, + avg_frame_time, 1000.0 / avg_frame_time +#if CONFIG_ESP_FACE_DETECT_ENABLED + , + (uint32_t)ready_time, (uint32_t)face_time, (uint32_t)recognize_time, (uint32_t)encode_time, (uint32_t)process_time, + (detected) ? "DETECTED " : "", face_id +#endif + ); + } + +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + isStreaming = false; + enable_led(false); +#endif + + last_frame = 0; + return res; +} + +static esp_err_t parse_get(httpd_req_t *req, char **obuf) +{ + char *buf = NULL; + size_t buf_len = 0; + + buf_len = httpd_req_get_url_query_len(req) + 1; + if (buf_len > 1) { + buf = (char *)malloc(buf_len); + if (!buf) { + httpd_resp_send_500(req); + return ESP_FAIL; + } + if (httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) { + *obuf = buf; + return ESP_OK; + } + free(buf); + } + httpd_resp_send_404(req); + return ESP_FAIL; +} + +static esp_err_t cmd_handler(httpd_req_t *req) +{ + char *buf = NULL; + char variable[32]; + char value[32]; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + if (httpd_query_key_value(buf, "var", variable, sizeof(variable)) != ESP_OK || + httpd_query_key_value(buf, "val", value, sizeof(value)) != ESP_OK) { + free(buf); + httpd_resp_send_404(req); + return ESP_FAIL; + } + free(buf); + + int val = atoi(value); + ESP_LOGI(TAG, "%s = %d", variable, val); + sensor_t *s = esp_camera_sensor_get(); + int res = 0; + + if (!strcmp(variable, "framesize")) { + if (s->pixformat == PIXFORMAT_JPEG) { + res = s->set_framesize(s, (framesize_t)val); + } + } + else if (!strcmp(variable, "quality")) + res = s->set_quality(s, val); + else if (!strcmp(variable, "contrast")) + res = s->set_contrast(s, val); + else if (!strcmp(variable, "brightness")) + res = s->set_brightness(s, val); + else if (!strcmp(variable, "saturation")) + res = s->set_saturation(s, val); + else if (!strcmp(variable, "gainceiling")) + res = s->set_gainceiling(s, (gainceiling_t)val); + else if (!strcmp(variable, "colorbar")) + res = s->set_colorbar(s, val); + else if (!strcmp(variable, "awb")) + res = s->set_whitebal(s, val); + else if (!strcmp(variable, "agc")) + res = s->set_gain_ctrl(s, val); + else if (!strcmp(variable, "aec")) + res = s->set_exposure_ctrl(s, val); + else if (!strcmp(variable, "hmirror")) + res = s->set_hmirror(s, val); + else if (!strcmp(variable, "vflip")) + res = s->set_vflip(s, val); + else if (!strcmp(variable, "awb_gain")) + res = s->set_awb_gain(s, val); + else if (!strcmp(variable, "agc_gain")) + res = s->set_agc_gain(s, val); + else if (!strcmp(variable, "aec_value")) + res = s->set_aec_value(s, val); + else if (!strcmp(variable, "aec2")) + res = s->set_aec2(s, val); + else if (!strcmp(variable, "dcw")) + res = s->set_dcw(s, val); + else if (!strcmp(variable, "bpc")) + res = s->set_bpc(s, val); + else if (!strcmp(variable, "wpc")) + res = s->set_wpc(s, val); + else if (!strcmp(variable, "raw_gma")) + res = s->set_raw_gma(s, val); + else if (!strcmp(variable, "lenc")) + res = s->set_lenc(s, val); + else if (!strcmp(variable, "special_effect")) + res = s->set_special_effect(s, val); + else if (!strcmp(variable, "wb_mode")) + res = s->set_wb_mode(s, val); + else if (!strcmp(variable, "ae_level")) + res = s->set_ae_level(s, val); +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + else if (!strcmp(variable, "led_intensity")) { + led_duty = val; + if (isStreaming) + enable_led(true); + } +#endif + +#if CONFIG_ESP_FACE_DETECT_ENABLED + else if (!strcmp(variable, "face_detect")) { + detection_enabled = val; +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + if (!detection_enabled) { + recognition_enabled = 0; + } +#endif + } +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + else if (!strcmp(variable, "face_enroll")){ + is_enrolling = !is_enrolling; + ESP_LOGI(TAG, "Enrolling: %s", is_enrolling?"true":"false"); + } + else if (!strcmp(variable, "face_recognize")) { + recognition_enabled = val; + if (recognition_enabled) { + detection_enabled = val; + } + } +#endif +#endif + else if(!strcmp(variable, "flash") ) { + pinMode(4, OUTPUT); + digitalWrite(4, atoi(value)); + flash_enabled = atoi(value); + } + else { + ESP_LOGI(TAG, "Unknown command: %s", variable); + res = -1; + } + + if (res < 0) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static int print_reg(char * p, sensor_t * s, uint16_t reg, uint32_t mask){ + return sprintf(p, "\"0x%x\":%u,", reg, s->get_reg(s, reg, mask)); +} + +static esp_err_t status_handler(httpd_req_t *req) +{ + static char json_response[1024]; + + sensor_t *s = esp_camera_sensor_get(); + char *p = json_response; + *p++ = '{'; + + if(s->id.PID == OV5640_PID || s->id.PID == OV3660_PID){ + for(int reg = 0x3400; reg < 0x3406; reg+=2){ + p+=print_reg(p, s, reg, 0xFFF);//12 bit + } + p+=print_reg(p, s, 0x3406, 0xFF); + + p+=print_reg(p, s, 0x3500, 0xFFFF0);//16 bit + p+=print_reg(p, s, 0x3503, 0xFF); + p+=print_reg(p, s, 0x350a, 0x3FF);//10 bit + p+=print_reg(p, s, 0x350c, 0xFFFF);//16 bit + + for(int reg = 0x5480; reg <= 0x5490; reg++){ + p+=print_reg(p, s, reg, 0xFF); + } + + for(int reg = 0x5380; reg <= 0x538b; reg++){ + p+=print_reg(p, s, reg, 0xFF); + } + + for(int reg = 0x5580; reg < 0x558a; reg++){ + p+=print_reg(p, s, reg, 0xFF); + } + p+=print_reg(p, s, 0x558a, 0x1FF);//9 bit + } else if(s->id.PID == OV2640_PID){ + p+=print_reg(p, s, 0xd3, 0xFF); + p+=print_reg(p, s, 0x111, 0xFF); + p+=print_reg(p, s, 0x132, 0xFF); + } + + p += sprintf(p, "\"xclk\":%u,", s->xclk_freq_hz / 1000000); + p += sprintf(p, "\"pixformat\":%u,", s->pixformat); + p += sprintf(p, "\"framesize\":%u,", s->status.framesize); + p += sprintf(p, "\"quality\":%u,", s->status.quality); + p += sprintf(p, "\"brightness\":%d,", s->status.brightness); + p += sprintf(p, "\"contrast\":%d,", s->status.contrast); + p += sprintf(p, "\"saturation\":%d,", s->status.saturation); + p += sprintf(p, "\"sharpness\":%d,", s->status.sharpness); + p += sprintf(p, "\"special_effect\":%u,", s->status.special_effect); + p += sprintf(p, "\"wb_mode\":%u,", s->status.wb_mode); + p += sprintf(p, "\"awb\":%u,", s->status.awb); + p += sprintf(p, "\"awb_gain\":%u,", s->status.awb_gain); + p += sprintf(p, "\"aec\":%u,", s->status.aec); + p += sprintf(p, "\"aec2\":%u,", s->status.aec2); + p += sprintf(p, "\"ae_level\":%d,", s->status.ae_level); + p += sprintf(p, "\"aec_value\":%u,", s->status.aec_value); + p += sprintf(p, "\"agc\":%u,", s->status.agc); + p += sprintf(p, "\"agc_gain\":%u,", s->status.agc_gain); + p += sprintf(p, "\"gainceiling\":%u,", s->status.gainceiling); + p += sprintf(p, "\"bpc\":%u,", s->status.bpc); + p += sprintf(p, "\"wpc\":%u,", s->status.wpc); + p += sprintf(p, "\"raw_gma\":%u,", s->status.raw_gma); + p += sprintf(p, "\"lenc\":%u,", s->status.lenc); + p += sprintf(p, "\"hmirror\":%u,", s->status.hmirror); + p += sprintf(p, "\"vflip\":%u,", s->status.vflip); + p += sprintf(p, "\"dcw\":%u,", s->status.dcw); + p += sprintf(p, "\"colorbar\":%u", s->status.colorbar); +#ifdef CONFIG_LED_ILLUMINATOR_ENABLED + p += sprintf(p, ",\"led_intensity\":%u", led_duty); +#else + p += sprintf(p, ",\"led_intensity\":%d", -1); +#endif +#if CONFIG_ESP_FACE_DETECT_ENABLED + p += sprintf(p, ",\"face_detect\":%u", detection_enabled); +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + p += sprintf(p, ",\"face_enroll\":%u,", is_enrolling); + p += sprintf(p, ",\"face_recognize\":%u", recognition_enabled); +#endif +#endif + p += sprintf(p, ",\"flash\":%u", flash_enabled); + *p++ = '}'; + *p++ = 0; + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, json_response, strlen(json_response)); +} + +static esp_err_t xclk_handler(httpd_req_t *req) +{ + char *buf = NULL; + char _xclk[32]; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + if (httpd_query_key_value(buf, "xclk", _xclk, sizeof(_xclk)) != ESP_OK) { + free(buf); + httpd_resp_send_404(req); + return ESP_FAIL; + } + free(buf); + + int xclk = atoi(_xclk); + ESP_LOGI(TAG, "Set XCLK: %d MHz", xclk); + + sensor_t *s = esp_camera_sensor_get(); + int res = s->set_xclk(s, LEDC_TIMER_0, xclk); + if (res) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t reg_handler(httpd_req_t *req) +{ + char *buf = NULL; + char _reg[32]; + char _mask[32]; + char _val[32]; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + if (httpd_query_key_value(buf, "reg", _reg, sizeof(_reg)) != ESP_OK || + httpd_query_key_value(buf, "mask", _mask, sizeof(_mask)) != ESP_OK || + httpd_query_key_value(buf, "val", _val, sizeof(_val)) != ESP_OK) { + free(buf); + httpd_resp_send_404(req); + return ESP_FAIL; + } + free(buf); + + int reg = atoi(_reg); + int mask = atoi(_mask); + int val = atoi(_val); + ESP_LOGI(TAG, "Set Register: reg: 0x%02x, mask: 0x%02x, value: 0x%02x", reg, mask, val); + + sensor_t *s = esp_camera_sensor_get(); + int res = s->set_reg(s, reg, mask, val); + if (res) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t greg_handler(httpd_req_t *req) +{ + char *buf = NULL; + char _reg[32]; + char _mask[32]; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + if (httpd_query_key_value(buf, "reg", _reg, sizeof(_reg)) != ESP_OK || + httpd_query_key_value(buf, "mask", _mask, sizeof(_mask)) != ESP_OK) { + free(buf); + httpd_resp_send_404(req); + return ESP_FAIL; + } + free(buf); + + int reg = atoi(_reg); + int mask = atoi(_mask); + sensor_t *s = esp_camera_sensor_get(); + int res = s->get_reg(s, reg, mask); + if (res < 0) { + return httpd_resp_send_500(req); + } + ESP_LOGI(TAG, "Get Register: reg: 0x%02x, mask: 0x%02x, value: 0x%02x", reg, mask, res); + + char buffer[20]; + const char * val = itoa(res, buffer, 10); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, val, strlen(val)); +} + +static int parse_get_var(char *buf, const char * key, int def) +{ + char _int[16]; + if(httpd_query_key_value(buf, key, _int, sizeof(_int)) != ESP_OK){ + return def; + } + return atoi(_int); +} + +static esp_err_t pll_handler(httpd_req_t *req) +{ + char *buf = NULL; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + + int bypass = parse_get_var(buf, "bypass", 0); + int mul = parse_get_var(buf, "mul", 0); + int sys = parse_get_var(buf, "sys", 0); + int root = parse_get_var(buf, "root", 0); + int pre = parse_get_var(buf, "pre", 0); + int seld5 = parse_get_var(buf, "seld5", 0); + int pclken = parse_get_var(buf, "pclken", 0); + int pclk = parse_get_var(buf, "pclk", 0); + free(buf); + + ESP_LOGI(TAG, "Set Pll: bypass: %d, mul: %d, sys: %d, root: %d, pre: %d, seld5: %d, pclken: %d, pclk: %d", bypass, mul, sys, root, pre, seld5, pclken, pclk); + sensor_t *s = esp_camera_sensor_get(); + int res = s->set_pll(s, bypass, mul, sys, root, pre, seld5, pclken, pclk); + if (res) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t win_handler(httpd_req_t *req) +{ + char *buf = NULL; + + if (parse_get(req, &buf) != ESP_OK) { + return ESP_FAIL; + } + + int startX = parse_get_var(buf, "sx", 0); + int startY = parse_get_var(buf, "sy", 0); + int endX = parse_get_var(buf, "ex", 0); + int endY = parse_get_var(buf, "ey", 0); + int offsetX = parse_get_var(buf, "offx", 0); + int offsetY = parse_get_var(buf, "offy", 0); + int totalX = parse_get_var(buf, "tx", 0); + int totalY = parse_get_var(buf, "ty", 0); + int outputX = parse_get_var(buf, "ox", 0); + int outputY = parse_get_var(buf, "oy", 0); + bool scale = parse_get_var(buf, "scale", 0) == 1; + bool binning = parse_get_var(buf, "binning", 0) == 1; + free(buf); + + ESP_LOGI(TAG, "Set Window: Start: %d %d, End: %d %d, Offset: %d %d, Total: %d %d, Output: %d %d, Scale: %u, Binning: %u", startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); + sensor_t *s = esp_camera_sensor_get(); + int res = s->set_res_raw(s, startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); + if (res) { + return httpd_resp_send_500(req); + } + + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t uptime_handler(httpd_req_t *req) +{ + char buf[64]; + sprintf(buf, "{\"seconds\":%ld}", (long)(millis()/1000)); + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + return httpd_resp_send(req, buf, strlen(buf)); +} + +static esp_err_t index_handler(httpd_req_t *req) +{ + httpd_resp_set_type(req, "text/html"); + httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + sensor_t *s = esp_camera_sensor_get(); + if (s != NULL) { + if (s->id.PID == OV3660_PID) { + return httpd_resp_send(req, (const char *)index_ov3660_html_gz, index_ov3660_html_gz_len); + } else if (s->id.PID == OV5640_PID) { + return httpd_resp_send(req, (const char *)index_ov5640_html_gz, index_ov5640_html_gz_len); + } else { + return httpd_resp_send(req, (const char *)index_ov2640_html_gz, index_ov2640_html_gz_len); + } + } else { + ESP_LOGE(TAG, "Camera sensor not found"); + return httpd_resp_send_500(req); + } +} + +void startCameraServer() +{ + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.max_uri_handlers = 16; + + httpd_uri_t index_uri = { + .uri = "/", + .method = HTTP_GET, + .handler = index_handler, + .user_ctx = NULL}; + + httpd_uri_t status_uri = { + .uri = "/status", + .method = HTTP_GET, + .handler = status_handler, + .user_ctx = NULL}; + + httpd_uri_t cmd_uri = { + .uri = "/control", + .method = HTTP_GET, + .handler = cmd_handler, + .user_ctx = NULL}; + + httpd_uri_t capture_uri = { + .uri = "/capture", + .method = HTTP_GET, + .handler = capture_handler, + .user_ctx = NULL}; + + httpd_uri_t stream_uri = { + .uri = "/stream", + .method = HTTP_GET, + .handler = stream_handler, + .user_ctx = NULL}; + + httpd_uri_t bmp_uri = { + .uri = "/bmp", + .method = HTTP_GET, + .handler = bmp_handler, + .user_ctx = NULL}; + + httpd_uri_t xclk_uri = { + .uri = "/xclk", + .method = HTTP_GET, + .handler = xclk_handler, + .user_ctx = NULL}; + + httpd_uri_t reg_uri = { + .uri = "/reg", + .method = HTTP_GET, + .handler = reg_handler, + .user_ctx = NULL}; + + httpd_uri_t greg_uri = { + .uri = "/greg", + .method = HTTP_GET, + .handler = greg_handler, + .user_ctx = NULL}; + + httpd_uri_t pll_uri = { + .uri = "/pll", + .method = HTTP_GET, + .handler = pll_handler, + .user_ctx = NULL}; + + httpd_uri_t win_uri = { + .uri = "/resolution", + .method = HTTP_GET, + .handler = win_handler, + .user_ctx = NULL}; + + httpd_uri_t uptime_uri = { + .uri = "/uptime", + .method = HTTP_GET, + .handler = uptime_handler, + .user_ctx = NULL}; + + ra_filter_init(&ra_filter, 20); + +#if CONFIG_ESP_FACE_RECOGNITION_ENABLED + recognizer.set_partition(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "fr"); + + // load ids from flash partition + recognizer.set_ids_from_flash(); +#endif + ESP_LOGI(TAG, "Starting web server on port: '%d'", config.server_port); + if (httpd_start(&camera_httpd, &config) == ESP_OK) + { + httpd_register_uri_handler(camera_httpd, &index_uri); + httpd_register_uri_handler(camera_httpd, &cmd_uri); + httpd_register_uri_handler(camera_httpd, &status_uri); + httpd_register_uri_handler(camera_httpd, &capture_uri); + httpd_register_uri_handler(camera_httpd, &bmp_uri); + + httpd_register_uri_handler(camera_httpd, &xclk_uri); + httpd_register_uri_handler(camera_httpd, ®_uri); + httpd_register_uri_handler(camera_httpd, &greg_uri); + httpd_register_uri_handler(camera_httpd, &pll_uri); + httpd_register_uri_handler(camera_httpd, &win_uri); + httpd_register_uri_handler(camera_httpd, &uptime_uri); + } + + config.server_port += 1; + config.ctrl_port += 1; + ESP_LOGI(TAG, "Starting stream server on port: '%d'", config.server_port); + if (httpd_start(&stream_httpd, &config) == ESP_OK) + { + httpd_register_uri_handler(stream_httpd, &stream_uri); + } +} diff --git a/arduino/ESP32CameraWebServer/camera_index.h b/arduino/ESP32CameraWebServer/camera_index.h new file mode 100644 index 0000000..5ca12e9 --- /dev/null +++ b/arduino/ESP32CameraWebServer/camera_index.h @@ -0,0 +1,1570 @@ + + +//File: index_ov2640.html.gz, Size: 6787 +#define index_ov2640_html_gz_len 6787 +const uint8_t index_ov2640_html_gz[] = { +0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xED, 0x3D, 0x6B, 0x73, 0xDB, 0x46, +0x92, 0xDF, 0xFD, 0x2B, 0x60, 0x24, 0x6B, 0x92, 0x25, 0x92, 0x22, 0x29, 0x4A, 0x96, 0x15, 0x89, +0x3E, 0x5B, 0x96, 0x1F, 0xB5, 0x76, 0xE2, 0xB5, 0x12, 0xC7, 0x5B, 0xA9, 0x2D, 0x07, 0x24, 0x86, +0x24, 0x62, 0x10, 0xE0, 0x02, 0xA0, 0x48, 0x26, 0xA5, 0xDF, 0x71, 0x3F, 0xE8, 0xFE, 0xD8, 0x75, +0xCF, 0x03, 0x18, 0x00, 0x83, 0x17, 0x29, 0x91, 0x5E, 0xDF, 0xD1, 0xA9, 0x08, 0x8F, 0xE9, 0x9E, +0x7E, 0x77, 0xCF, 0x0C, 0x06, 0x38, 0x7F, 0x68, 0xBA, 0xA3, 0x60, 0x3D, 0x27, 0xDA, 0x34, 0x98, +0xD9, 0x83, 0x07, 0xE7, 0xEC, 0x8F, 0x06, 0xBF, 0xF3, 0x29, 0x31, 0x4C, 0x76, 0x48, 0x4F, 0x67, +0x24, 0x30, 0xB4, 0xD1, 0xD4, 0xF0, 0x7C, 0x12, 0x5C, 0xE8, 0x8B, 0x60, 0xDC, 0x3A, 0xD5, 0x93, +0xB7, 0x1D, 0x63, 0x46, 0x2E, 0xF4, 0x1B, 0x8B, 0x2C, 0xE7, 0xAE, 0x17, 0xE8, 0xDA, 0xC8, 0x75, +0x02, 0xE2, 0x40, 0xF3, 0xA5, 0x65, 0x06, 0xD3, 0x0B, 0x93, 0xDC, 0x58, 0x23, 0xD2, 0xA2, 0x27, +0x4D, 0xCB, 0xB1, 0x02, 0xCB, 0xB0, 0x5B, 0xFE, 0xC8, 0xB0, 0xC9, 0x45, 0x57, 0xC6, 0x15, 0x58, +0x81, 0x4D, 0x06, 0x57, 0xD7, 0xEF, 0x8F, 0x7A, 0xDA, 0x4F, 0x1F, 0x7B, 0xFD, 0x93, 0xCE, 0xF9, +0x21, 0xBB, 0x16, 0xB5, 0xF1, 0x83, 0xB5, 0x7C, 0x8E, 0xBF, 0xA1, 0x6B, 0xAE, 0xB5, 0xBF, 0x62, +0x97, 0xF0, 0x37, 0x06, 0x22, 0x5A, 0x63, 0x63, 0x66, 0xD9, 0xEB, 0x33, 0xED, 0x99, 0x07, 0x7D, +0x36, 0x5F, 0x13, 0xFB, 0x86, 0x04, 0xD6, 0xC8, 0x68, 0xFA, 0x86, 0xE3, 0xB7, 0x7C, 0xE2, 0x59, +0xE3, 0x1F, 0x52, 0x80, 0x43, 0x63, 0xF4, 0x65, 0xE2, 0xB9, 0x0B, 0xC7, 0x3C, 0xD3, 0xBE, 0xEB, +0x9E, 0xE2, 0xBF, 0x74, 0xA3, 0x91, 0x6B, 0xBB, 0x1E, 0xDC, 0xBF, 0x7A, 0x89, 0xFF, 0xD2, 0xF7, +0x69, 0xEF, 0xBE, 0xF5, 0x27, 0x39, 0xD3, 0xBA, 0x27, 0xF3, 0x55, 0xEC, 0xFE, 0xED, 0x83, 0xD8, +0xE9, 0xB4, 0x97, 0x45, 0x3D, 0x87, 0x3F, 0xCD, 0x87, 0xF7, 0xC9, 0x28, 0xB0, 0x5C, 0xA7, 0x3D, +0x33, 0x2C, 0x47, 0x81, 0xC9, 0xB4, 0xFC, 0xB9, 0x6D, 0x80, 0x0C, 0xC6, 0x36, 0xC9, 0xC5, 0xF3, +0xDD, 0x8C, 0x38, 0x8B, 0x66, 0x01, 0x36, 0x44, 0xD2, 0x32, 0x2D, 0x8F, 0xB5, 0x3A, 0x43, 0x39, +0x2C, 0x66, 0x4E, 0x21, 0xDA, 0x3C, 0xBA, 0x1C, 0xD7, 0x21, 0x0A, 0x01, 0x62, 0x47, 0x4B, 0xCF, +0x98, 0x63, 0x03, 0xFC, 0x9B, 0x6E, 0x32, 0xB3, 0x1C, 0x66, 0x54, 0x67, 0xDA, 0x51, 0xBF, 0x33, +0x5F, 0x15, 0xA8, 0xF2, 0xE8, 0x04, 0xFF, 0xA5, 0x1B, 0xCD, 0x0D, 0xD3, 0xB4, 0x9C, 0xC9, 0x99, +0x76, 0xAA, 0x44, 0xE1, 0x7A, 0x26, 0xF1, 0x5A, 0x9E, 0x61, 0x5A, 0x0B, 0xFF, 0x4C, 0xEB, 0xAB, +0xDA, 0xCC, 0x0C, 0x6F, 0x02, 0xB4, 0x04, 0x2E, 0x10, 0xDB, 0xEA, 0x2A, 0x29, 0xE1, 0x4D, 0x3C, +0x6B, 0x32, 0x0D, 0x40, 0xA5, 0xA9, 0x36, 0x49, 0xA1, 0x71, 0x17, 0x2A, 0xD2, 0x67, 0xAE, 0xDC, +0xD4, 0x52, 0x33, 0x6C, 0x6B, 0xE2, 0xB4, 0xAC, 0x80, 0xCC, 0x80, 0x1D, 0x3F, 0xF0, 0x48, 0x30, +0x9A, 0xE6, 0x91, 0x32, 0xB6, 0x26, 0x0B, 0x8F, 0x28, 0x08, 0x09, 0xE5, 0x96, 0xC3, 0x30, 0xDC, +0x4C, 0xDF, 0x6A, 0x2D, 0xC9, 0xF0, 0x8B, 0x15, 0xB4, 0xB8, 0x4C, 0x86, 0x64, 0xEC, 0x7A, 0x44, +0xD9, 0x52, 0xB4, 0xB0, 0xDD, 0xD1, 0x97, 0x96, 0x1F, 0x18, 0x5E, 0x50, 0x06, 0xA1, 0x31, 0x0E, +0x88, 0x57, 0x8C, 0x8F, 0xA0, 0x55, 0x14, 0x63, 0xCB, 0xEE, 0x96, 0x37, 0xB0, 0x1C, 0xDB, 0x72, +0x48, 0x79, 0xF2, 0xB2, 0xFA, 0x8D, 0xA3, 0x63, 0xAD, 0x4A, 0x28, 0xC6, 0x9A, 0x4D, 0xF2, 0xAC, +0x84, 0xF2, 0x9A, 0xEE, 0x8C, 0xFB, 0x4D, 0xB7, 0xD3, 0xF9, 0x5B, 0xFA, 0xE6, 0x94, 0x30, 0x33, +0x35, 0x16, 0x81, 0xBB, 0xBD, 0x47, 0xA4, 0xDC, 0x2A, 0xC1, 0xC7, 0x7F, 0xCD, 0x88, 0x69, 0x19, +0x5A, 0x5D, 0x72, 0xE7, 0xD3, 0x0E, 0xD8, 0x54, 0x43, 0x33, 0x1C, 0x53, 0xAB, 0xBB, 0x9E, 0x05, +0x8E, 0x60, 0xD0, 0x70, 0x63, 0xC3, 0x15, 0x48, 0x1C, 0x73, 0xD2, 0x50, 0xB0, 0x9C, 0xE3, 0x33, +0xB2, 0x44, 0xD4, 0x6E, 0x83, 0xBF, 0x12, 0x21, 0x07, 0x7F, 0x85, 0x0E, 0xA4, 0xE0, 0x91, 0xA2, +0xCF, 0xD3, 0x97, 0x4C, 0x61, 0x96, 0xCE, 0xF0, 0x37, 0x33, 0x56, 0xAD, 0x5C, 0xDD, 0x89, 0x46, +0x42, 0x87, 0x90, 0x66, 0x47, 0x75, 0x68, 0x7A, 0x33, 0xD5, 0x5A, 0x1A, 0x46, 0xC9, 0x86, 0x1A, +0x86, 0x23, 0x55, 0xAB, 0x1C, 0x7F, 0xB2, 0x51, 0x54, 0x60, 0x57, 0xCD, 0x6A, 0x14, 0x3B, 0xD8, +0x3F, 0x95, 0x0D, 0x31, 0x4E, 0x32, 0xA3, 0x08, 0xFE, 0xCA, 0x47, 0x92, 0x08, 0x59, 0x61, 0x34, +0x51, 0x20, 0xCE, 0x8E, 0x28, 0x29, 0xBC, 0x59, 0xDE, 0xAD, 0xC0, 0x9A, 0x4F, 0x42, 0xD9, 0xE8, +0xA2, 0x40, 0x9C, 0x47, 0x43, 0x61, 0x94, 0xC1, 0xDF, 0x6D, 0x89, 0x7A, 0xE3, 0xBB, 0xE1, 0x22, +0x08, 0x5C, 0xC7, 0xDF, 0x2A, 0x45, 0x65, 0xF9, 0xD9, 0x1F, 0x0B, 0x3F, 0xB0, 0xC6, 0xEB, 0x16, +0x77, 0x69, 0xF0, 0xB3, 0xB9, 0x01, 0x25, 0xE4, 0x90, 0x04, 0x4B, 0x42, 0xF2, 0xCB, 0x0D, 0xC7, +0xB8, 0x81, 0xB8, 0x33, 0x99, 0xD8, 0x2A, 0xDB, 0x1B, 0x2D, 0x3C, 0x1F, 0xEB, 0xB6, 0xB9, 0x6B, +0x01, 0x62, 0x2F, 0xDD, 0x71, 0xDC, 0x07, 0x4B, 0x76, 0xD4, 0x1A, 0x0D, 0x15, 0x7D, 0xB9, 0x8B, +0x00, 0x65, 0xAC, 0xD4, 0x84, 0x0B, 0xEC, 0x58, 0xC1, 0x5A, 0x79, 0x8F, 0x7B, 0xA2, 0xE2, 0x8E, +0x70, 0xC1, 0xDC, 0xB4, 0x10, 0xA7, 0xEB, 0x6C, 0x34, 0x25, 0xA3, 0x2F, 0xC4, 0x3C, 0x28, 0x2C, +0xC3, 0x8A, 0xCA, 0xC3, 0xB6, 0xE5, 0xCC, 0x17, 0x41, 0x0B, 0xCB, 0xA9, 0xF9, 0xBD, 0xE8, 0x9C, +0x1A, 0xA4, 0x60, 0xB1, 0xD7, 0xCB, 0x2B, 0x2A, 0x8E, 0xE7, 0xAB, 0x7C, 0x21, 0xC8, 0xC4, 0x0E, +0x6C, 0x63, 0x48, 0xEC, 0x3C, 0x92, 0xB9, 0x33, 0x64, 0x84, 0x5D, 0x1E, 0xAB, 0xB2, 0x6B, 0x37, +0x4A, 0x59, 0x94, 0xBC, 0xFA, 0x8F, 0xFF, 0x56, 0x5A, 0x8E, 0xF4, 0xB8, 0x19, 0xBB, 0xE4, 0x13, +0x1B, 0x1C, 0x2C, 0xAB, 0xF4, 0x86, 0x36, 0x4B, 0xA0, 0x21, 0xB7, 0x03, 0xCF, 0x70, 0x26, 0x04, +0x62, 0xC1, 0xAA, 0x29, 0x0E, 0xF3, 0x07, 0x06, 0xA5, 0xD8, 0xC7, 0x50, 0x7D, 0x9C, 0x3F, 0x10, +0x61, 0x01, 0xA1, 0xA9, 0xB5, 0xD9, 0xC1, 0x06, 0x55, 0x89, 0xA4, 0xDF, 0x5C, 0x42, 0xBA, 0x4A, +0xEB, 0x60, 0x85, 0x89, 0xD2, 0x73, 0xE2, 0xB6, 0xA5, 0x2C, 0xF4, 0x0B, 0x43, 0x83, 0x18, 0xF2, +0x8D, 0xC7, 0x45, 0x83, 0xC6, 0xF1, 0xF8, 0xA8, 0x73, 0xD4, 0x2F, 0xAC, 0x9C, 0x94, 0x5C, 0x26, +0x06, 0x8E, 0x8A, 0xD0, 0x11, 0x86, 0x95, 0x5C, 0x23, 0xF0, 0x8D, 0x1B, 0x65, 0xD1, 0xEE, 0xFA, +0x16, 0x1B, 0xB9, 0x19, 0x43, 0x1F, 0xC6, 0x6E, 0x81, 0x62, 0xE8, 0xC5, 0x0D, 0xBD, 0xA7, 0xA4, +0x8F, 0x96, 0x74, 0x4A, 0x17, 0x10, 0xE2, 0x55, 0x93, 0x1D, 0xD3, 0x80, 0xBA, 0x89, 0xA4, 0x60, +0x65, 0x51, 0x19, 0x90, 0x55, 0xD0, 0x32, 0xC9, 0xC8, 0xF5, 0x58, 0x35, 0x98, 0x31, 0x72, 0x4C, +0x28, 0xB2, 0xD8, 0x62, 0xCF, 0xA6, 0xEE, 0x0D, 0xF1, 0x14, 0xC2, 0x4A, 0x28, 0xB5, 0xFF, 0xA4, +0x6F, 0x96, 0xC0, 0x66, 0x40, 0x7A, 0x54, 0xCA, 0x3E, 0x8E, 0xAE, 0xD7, 0x1D, 0xF5, 0x72, 0xFD, +0x98, 0xA1, 0x6B, 0x83, 0xCF, 0x18, 0x43, 0x9B, 0x98, 0x39, 0xD9, 0xCC, 0x24, 0x63, 0x63, 0x61, +0x07, 0x05, 0x56, 0x69, 0x74, 0xF0, 0x5F, 0x5E, 0x8F, 0x34, 0x0C, 0xFD, 0x86, 0xF3, 0x42, 0x17, +0x34, 0x70, 0xFC, 0x4B, 0xD1, 0xA7, 0x28, 0x35, 0x8C, 0xF9, 0x9C, 0x18, 0xD0, 0x6A, 0x44, 0xB2, +0xF4, 0x50, 0x6A, 0x88, 0xA1, 0x8E, 0xF3, 0xA5, 0xC6, 0xED, 0x85, 0x0E, 0x1B, 0x16, 0x8F, 0x95, +0x78, 0x3E, 0x1B, 0xBB, 0xA3, 0x85, 0xAA, 0xAA, 0x29, 0xE7, 0x78, 0x69, 0x7C, 0x67, 0x42, 0x64, +0xBE, 0x6D, 0x51, 0xF7, 0x5F, 0x38, 0x0E, 0x6A, 0xB4, 0x15, 0x78, 0xC0, 0xA6, 0xA2, 0xA3, 0x72, +0x82, 0xDB, 0x28, 0x86, 0xC5, 0x04, 0x9B, 0x35, 0x77, 0x95, 0x08, 0x53, 0x8A, 0x70, 0x1A, 0x46, +0x5A, 0x0D, 0x62, 0x88, 0x65, 0x0A, 0x54, 0xDB, 0xC9, 0x25, 0x98, 0x2E, 0x66, 0xAA, 0x3A, 0x4A, +0x74, 0xD6, 0x85, 0xA4, 0xCF, 0xBA, 0xF3, 0x26, 0x43, 0xA3, 0xDE, 0x69, 0x76, 0x9A, 0x47, 0xF0, +0x3F, 0xC5, 0x78, 0x26, 0xDF, 0xB8, 0xB8, 0x78, 0x33, 0x2C, 0x2F, 0x11, 0xA2, 0x8B, 0xA7, 0x95, +0xB2, 0x82, 0x7D, 0xA1, 0x2E, 0xCA, 0x7B, 0x52, 0x7C, 0x7E, 0xA9, 0xDB, 0x2E, 0xC8, 0xC3, 0x19, +0x26, 0x5D, 0xDD, 0x10, 0x15, 0xD6, 0x52, 0x55, 0xC5, 0x33, 0xF7, 0xCF, 0x16, 0x2B, 0x42, 0xFE, +0xCF, 0x5B, 0xBB, 0x24, 0x8A, 0x6F, 0xDA, 0xD2, 0x2B, 0xCB, 0xC5, 0xDF, 0xB7, 0x6D, 0x74, 0xB2, +0xB5, 0xDE, 0xE2, 0x55, 0x1F, 0x50, 0xE8, 0xC0, 0x18, 0xD4, 0x83, 0xC1, 0x68, 0x66, 0x65, 0x28, +0xB5, 0xD9, 0x40, 0x06, 0x63, 0xCB, 0xB6, 0x5B, 0xB6, 0xBB, 0x2C, 0xAE, 0x44, 0xF2, 0x2D, 0x39, +0x65, 0xA7, 0xC5, 0x26, 0xBF, 0x29, 0xB5, 0x0B, 0x88, 0x5C, 0xFF, 0x11, 0xD4, 0x7E, 0xDB, 0x0E, +0x97, 0xEB, 0x1A, 0x9B, 0x25, 0x8A, 0x0D, 0xEC, 0x71, 0xBB, 0x8E, 0x4A, 0x99, 0x12, 0xAB, 0x04, +0xF3, 0x87, 0x3D, 0x4B, 0x2B, 0x18, 0x4D, 0x37, 0x18, 0x7A, 0x46, 0x03, 0x23, 0x8F, 0xD8, 0x06, +0x56, 0xF0, 0x1B, 0xCD, 0x50, 0x14, 0x0E, 0xDF, 0x64, 0xF0, 0x32, 0x9C, 0x50, 0xD1, 0x7D, 0x3D, +0xB3, 0x4B, 0x6D, 0x56, 0x3B, 0x64, 0xC7, 0x6A, 0xB5, 0x59, 0x17, 0x94, 0xFB, 0x71, 0xCF, 0x50, +0x37, 0xAA, 0x10, 0xD1, 0x45, 0xD0, 0x9E, 0x78, 0x64, 0x5D, 0x82, 0x99, 0x26, 0xFF, 0x7B, 0xC6, +0xE6, 0x8F, 0x37, 0x9F, 0x2A, 0xA1, 0x09, 0x80, 0x5B, 0x51, 0xBB, 0xEF, 0x97, 0xE8, 0x3A, 0xBB, +0xCB, 0x32, 0xF6, 0x18, 0xCE, 0x8E, 0xEA, 0x7A, 0x89, 0x70, 0x93, 0x93, 0x42, 0xD5, 0xA6, 0x2A, +0xB2, 0xAF, 0x7A, 0x3C, 0x4F, 0xC6, 0x41, 0xC6, 0xE2, 0x0F, 0xAD, 0x53, 0x8F, 0xF2, 0xA3, 0x5B, +0x4B, 0x9A, 0x4D, 0x29, 0x8C, 0x1C, 0xE1, 0x24, 0x66, 0xB6, 0xF5, 0x29, 0x31, 0x63, 0xF4, 0xAC, +0x8C, 0x3C, 0x5B, 0x25, 0xA2, 0x7C, 0xA6, 0x6A, 0x86, 0x36, 0x33, 0x9E, 0xF2, 0x41, 0x3D, 0xE4, +0x53, 0xBD, 0x77, 0xA2, 0x5C, 0x5B, 0xC9, 0x69, 0x9C, 0x47, 0x5A, 0xE6, 0x2C, 0x60, 0x3A, 0x65, +0x65, 0x0E, 0x90, 0xE5, 0x58, 0xA4, 0x54, 0x54, 0xBE, 0x57, 0xE6, 0x45, 0x98, 0xF4, 0x4C, 0x56, +0xAE, 0xB1, 0x5B, 0x33, 0x03, 0xCA, 0x5E, 0x34, 0x57, 0x03, 0x30, 0xAA, 0xF4, 0x57, 0xC6, 0xDC, +0xA5, 0x39, 0xD6, 0xEE, 0x49, 0xA7, 0xA0, 0xCB, 0x91, 0xED, 0xFA, 0x5B, 0x4E, 0x80, 0x65, 0xCF, +0x7F, 0x29, 0xEF, 0x94, 0x4A, 0xDD, 0xB9, 0x3E, 0x95, 0xEF, 0x8E, 0x09, 0x99, 0x77, 0x3B, 0xCA, +0x48, 0x9B, 0x3B, 0x4B, 0x49, 0x67, 0xD0, 0xE8, 0xFA, 0xE5, 0x99, 0x36, 0x22, 0xEA, 0x30, 0x1A, +0x9F, 0xA8, 0x2B, 0x33, 0x55, 0x9A, 0xAB, 0x87, 0xA9, 0x65, 0x9A, 0x24, 0x77, 0x2E, 0x18, 0xC7, +0xBC, 0x25, 0x8B, 0x07, 0xA4, 0x5F, 0x35, 0x29, 0x75, 0x2F, 0x4E, 0x91, 0xFB, 0x58, 0x43, 0xF7, +0xBE, 0x3D, 0x86, 0x27, 0x9A, 0xAC, 0x99, 0xF4, 0x78, 0x29, 0x92, 0x4B, 0xAA, 0xD2, 0xB9, 0xC3, +0xB9, 0x56, 0x14, 0x19, 0xC8, 0x01, 0x5B, 0xA5, 0xA3, 0x79, 0x82, 0x2A, 0xBA, 0x90, 0xD2, 0xE6, +0x6B, 0x4B, 0x7C, 0x19, 0xB0, 0x95, 0xB5, 0xBA, 0x72, 0x87, 0x4B, 0x6D, 0xD4, 0x02, 0x92, 0xFD, +0x66, 0x8A, 0x66, 0x4F, 0x95, 0x51, 0x0E, 0x91, 0x61, 0x8A, 0x11, 0x0F, 0x57, 0xC5, 0x5B, 0x6D, +0xEA, 0x1C, 0xE7, 0x87, 0xD2, 0xD3, 0x70, 0xE7, 0x87, 0xD1, 0x83, 0x7B, 0xE7, 0xF8, 0x48, 0x9C, +0xFC, 0xD0, 0x1C, 0xEF, 0x67, 0x64, 0x1B, 0xBE, 0x7F, 0xA1, 0xE3, 0xA3, 0x5D, 0x7A, 0xFC, 0x19, +0xBA, 0x73, 0xD3, 0xBA, 0xD1, 0x2C, 0xF3, 0x42, 0xB7, 0xDD, 0x89, 0x9B, 0xB8, 0x47, 0xEF, 0x33, +0x2D, 0x43, 0x1E, 0xBB, 0xD0, 0x63, 0xEB, 0x8B, 0x3A, 0x85, 0x8A, 0x2E, 0xE9, 0x83, 0x47, 0xDF, +0x3D, 0x79, 0xFC, 0xF8, 0xE4, 0x87, 0x47, 0xCE, 0xD0, 0x9F, 0xF3, 0xFF, 0xFF, 0xCC, 0x96, 0x63, +0x7F, 0xFA, 0xD8, 0x3B, 0xE9, 0xC3, 0x70, 0x8F, 0x04, 0x01, 0x98, 0x9E, 0x7F, 0x7E, 0x48, 0x91, +0x26, 0x08, 0x39, 0x04, 0x4A, 0x32, 0x68, 0xE3, 0xE5, 0x8E, 0x8A, 0x3C, 0xD1, 0xC4, 0x87, 0x0C, +0x3E, 0x34, 0x3C, 0x45, 0x13, 0xDA, 0x8C, 0x15, 0xD3, 0x34, 0x94, 0xE8, 0x54, 0x27, 0x43, 0x77, +0x95, 0xE4, 0x80, 0x32, 0xC5, 0x15, 0xC6, 0x5B, 0x11, 0x33, 0x0B, 0x21, 0x80, 0x51, 0x70, 0x5C, +0x5C, 0x85, 0x36, 0xCA, 0x46, 0x31, 0x15, 0x60, 0xE3, 0xD5, 0xC8, 0xFE, 0x22, 0x74, 0xAF, 0x0B, +0xA5, 0x38, 0x6E, 0xC0, 0x42, 0x65, 0x46, 0x57, 0x31, 0x56, 0x39, 0x8C, 0xB4, 0x6C, 0xC8, 0xB8, +0x00, 0xD1, 0xB6, 0x28, 0x76, 0x76, 0x2D, 0x1F, 0x13, 0xC5, 0x26, 0xE9, 0x55, 0x00, 0xEB, 0x83, +0x4F, 0x97, 0x6F, 0xFF, 0xAE, 0xBD, 0x7B, 0xFD, 0xA7, 0x52, 0x43, 0x45, 0x44, 0x61, 0x8C, 0x2E, +0xD1, 0x33, 0x05, 0x63, 0xFA, 0x10, 0x32, 0xD1, 0xB9, 0x66, 0x28, 0x06, 0xCC, 0xF6, 0x36, 0x71, +0x26, 0xC1, 0xF4, 0x42, 0xEF, 0xEA, 0xF8, 0x48, 0x8B, 0x38, 0xEB, 0xE9, 0x1A, 0xC6, 0x6F, 0x7A, +0x70, 0x63, 0xD8, 0x0B, 0x3C, 0xEA, 0x94, 0xE1, 0x35, 0x6D, 0x5A, 0xCA, 0x66, 0x3C, 0xB0, 0x84, +0x32, 0x96, 0x02, 0x71, 0x5C, 0xCA, 0xFA, 0xE0, 0x9A, 0x04, 0xE7, 0x87, 0xEC, 0x56, 0x81, 0xD6, +0xF2, 0xFB, 0x06, 0x4F, 0x66, 0xE6, 0x90, 0x67, 0x42, 0x79, 0x8A, 0x1F, 0x7B, 0xC6, 0x8C, 0xA0, +0x54, 0x4A, 0x69, 0x5E, 0xD6, 0x7A, 0x08, 0xA9, 0x0F, 0x3E, 0x10, 0x5A, 0x10, 0x01, 0x19, 0xA5, +0x14, 0x7F, 0xCE, 0x6B, 0xD4, 0x58, 0xFF, 0xA1, 0x3D, 0xF3, 0x35, 0xA9, 0x96, 0xC1, 0xCC, 0xBC, +0x84, 0xDC, 0x1F, 0xB6, 0x5A, 0x5A, 0xEF, 0xDD, 0x7B, 0xAD, 0xD5, 0x2A, 0xD1, 0xD8, 0x9D, 0x53, +0x77, 0xE2, 0xFA, 0xEF, 0x1E, 0xE9, 0x83, 0x5F, 0x3E, 0xBD, 0x7A, 0x56, 0x87, 0xBA, 0xB0, 0xB3, +0xEA, 0xF6, 0x3A, 0x9D, 0xC6, 0xF9, 0x21, 0x6B, 0x52, 0x1D, 0x57, 0x0F, 0xF4, 0x4A, 0x71, 0xF5, +0x4E, 0x01, 0x57, 0xA7, 0xD7, 0xDF, 0x02, 0x57, 0x57, 0x1F, 0xBC, 0x7E, 0xC1, 0x30, 0x3D, 0xEE, +0x6D, 0x43, 0x14, 0x18, 0x38, 0xA5, 0x09, 0xC8, 0x59, 0x3D, 0x3E, 0x39, 0xDD, 0x1C, 0xD3, 0x13, +0xE0, 0xEE, 0x23, 0x60, 0x3A, 0x05, 0x41, 0x9D, 0x6C, 0x23, 0xA7, 0x53, 0x7D, 0x80, 0x78, 0x20, +0xA2, 0xAF, 0xFA, 0xA7, 0x5B, 0xE0, 0x79, 0x0C, 0x22, 0x42, 0x44, 0x80, 0x64, 0x75, 0xB4, 0x8D, +0x8C, 0x4E, 0xF4, 0xC1, 0xE5, 0x9B, 0x97, 0xF5, 0x3E, 0x30, 0xD6, 0x7B, 0x72, 0xB2, 0x39, 0x9E, +0x63, 0x7D, 0xF0, 0x0F, 0x24, 0x08, 0x88, 0x59, 0xF5, 0xFA, 0x5B, 0x10, 0xD4, 0xD7, 0x07, 0x00, +0x8F, 0x38, 0x36, 0x46, 0x01, 0x76, 0xFD, 0x9A, 0x12, 0x83, 0x88, 0xBA, 0x8F, 0xB7, 0xE0, 0x0A, +0xAC, 0xFA, 0x1F, 0x28, 0x1E, 0x40, 0xB2, 0xEA, 0xF6, 0xB7, 0xB1, 0x69, 0x40, 0x44, 0x49, 0x02, +0x5F, 0x43, 0x57, 0xDB, 0x1C, 0x13, 0xD8, 0xF4, 0x93, 0x93, 0xD5, 0x93, 0x93, 0x72, 0x08, 0x30, +0x46, 0x62, 0xBC, 0xC9, 0x8B, 0xA2, 0xF9, 0x41, 0x36, 0x2F, 0x80, 0xFE, 0x7B, 0x01, 0xC3, 0xA2, +0x60, 0x5D, 0x39, 0x7C, 0x72, 0x38, 0x90, 0x09, 0x3B, 0x28, 0x17, 0x39, 0x25, 0x4A, 0xC2, 0x27, +0x74, 0xF4, 0x41, 0xBF, 0x44, 0x86, 0x8A, 0x95, 0x30, 0x14, 0x36, 0x46, 0x3F, 0x4D, 0x9B, 0x68, +0x79, 0x98, 0x30, 0xC1, 0x25, 0x8E, 0x74, 0x29, 0x82, 0x6C, 0x14, 0x9A, 0x15, 0xB4, 0x1A, 0x2B, +0x7D, 0x70, 0x72, 0x54, 0x98, 0xD2, 0x36, 0x57, 0xC6, 0x90, 0x0E, 0xC0, 0x1D, 0xE2, 0xFB, 0x95, +0xF5, 0x11, 0x81, 0xEA, 0x83, 0xE7, 0xE1, 0xF1, 0x36, 0x5A, 0x69, 0xF5, 0xB6, 0x50, 0x8B, 0x44, +0x0E, 0xD3, 0x4C, 0xAB, 0xC7, 0x55, 0x13, 0x15, 0x2F, 0x77, 0xAB, 0x98, 0x22, 0x6A, 0xB7, 0xD1, +0x0B, 0x16, 0xE0, 0x9E, 0xE1, 0x07, 0x95, 0xB5, 0x22, 0x00, 0x21, 0x42, 0xF3, 0xA3, 0xBD, 0x69, +0x24, 0x24, 0xE5, 0x1B, 0xD0, 0x87, 0x6F, 0x04, 0x0B, 0xF6, 0x2C, 0x54, 0x65, 0x8D, 0x44, 0xA0, +0x50, 0x0F, 0x84, 0xC7, 0x7B, 0xD3, 0x8A, 0x44, 0xCE, 0xB7, 0xA0, 0x97, 0x39, 0x19, 0x59, 0x86, +0xFD, 0x99, 0x8C, 0xC7, 0x90, 0xB0, 0xAA, 0xEB, 0x26, 0x06, 0x0E, 0xFA, 0x61, 0xE7, 0xDA, 0x15, +0x3D, 0xAF, 0x5C, 0x9B, 0x27, 0xD0, 0x6D, 0x5E, 0xA0, 0x27, 0xB3, 0x37, 0x9F, 0xA4, 0x26, 0x74, +0x48, 0xC4, 0x8E, 0xF4, 0xC1, 0x8F, 0x6E, 0x48, 0xE7, 0xE6, 0x05, 0xC6, 0x8F, 0x64, 0x42, 0xE7, +0x80, 0xB7, 0xA9, 0x76, 0x5E, 0x79, 0xC6, 0x9A, 0x6E, 0x32, 0xDC, 0xA6, 0xF8, 0xFA, 0x40, 0x4C, +0xED, 0x67, 0xCB, 0xD9, 0x9C, 0x99, 0x3E, 0x12, 0x42, 0x88, 0xB3, 0x1D, 0x16, 0x28, 0x49, 0x9F, +0xC3, 0xC1, 0x76, 0x48, 0x4E, 0x70, 0xBC, 0x3A, 0xB7, 0x8C, 0xAF, 0xA1, 0xDC, 0x32, 0x96, 0xC3, +0xCA, 0x6E, 0x01, 0x30, 0xFA, 0xE0, 0xD9, 0xAF, 0xCF, 0x2B, 0x07, 0x29, 0xB6, 0x92, 0x5A, 0xC6, +0xC2, 0xA3, 0xF9, 0x08, 0xEC, 0x2C, 0x35, 0x51, 0xA4, 0xF6, 0x9C, 0xB2, 0x93, 0x45, 0x0A, 0xBE, +0x04, 0x81, 0x74, 0xE1, 0x49, 0x97, 0xD8, 0x2C, 0xC7, 0xE3, 0xFD, 0x45, 0x30, 0x20, 0xE2, 0xF3, +0xC4, 0xB0, 0xAA, 0xE7, 0x15, 0x01, 0x48, 0x35, 0xA5, 0xBD, 0x82, 0xA3, 0x5D, 0xA9, 0x8B, 0x75, +0xBB, 0x37, 0x9D, 0x71, 0xAE, 0xF7, 0xAD, 0x38, 0x20, 0x64, 0xE6, 0x9A, 0xD5, 0xA7, 0x81, 0x38, +0x9C, 0x3E, 0x00, 0xAD, 0xBD, 0x83, 0x83, 0xCA, 0x59, 0x46, 0x20, 0xB8, 0xE7, 0xF4, 0xF2, 0x6C, +0x11, 0xB8, 0xDB, 0x64, 0x96, 0xEB, 0x85, 0xE3, 0xAC, 0xB7, 0x49, 0x2B, 0x97, 0xB6, 0xBB, 0x30, +0x37, 0xC7, 0x00, 0x39, 0xE5, 0xA7, 0xF1, 0xD8, 0x1A, 0x6D, 0x9E, 0x95, 0x20, 0xA3, 0xBC, 0x76, +0x67, 0x25, 0xE1, 0xEF, 0x39, 0x8A, 0x93, 0x51, 0xF5, 0x00, 0x41, 0x46, 0xA0, 0xC5, 0xAB, 0x4B, +0xED, 0xFA, 0xEA, 0xC7, 0xEB, 0x9F, 0x3E, 0xEC, 0x26, 0x3A, 0x40, 0x9F, 0x7B, 0x0A, 0x0C, 0xC8, +0xED, 0xBE, 0x63, 0x02, 0x10, 0xD1, 0xDB, 0x44, 0x4F, 0x3D, 0xA6, 0xA8, 0x17, 0xD7, 0xEF, 0x77, +0xA5, 0xA5, 0xDE, 0xFE, 0xD4, 0xD4, 0xFB, 0x1A, 0xF4, 0xF4, 0xD9, 0x26, 0x37, 0xC4, 0xDE, 0x40, +0x57, 0x0C, 0x10, 0xF5, 0xA5, 0xBD, 0xC5, 0xA3, 0xBD, 0x0D, 0xE4, 0x42, 0x52, 0xBE, 0x81, 0x61, +0x1C, 0x58, 0xC5, 0x67, 0x4A, 0xF4, 0x26, 0xCE, 0xC3, 0x20, 0xF5, 0xC1, 0xD5, 0x6A, 0xEE, 0xFA, +0x0B, 0xAF, 0x64, 0x42, 0x55, 0x6B, 0xA4, 0xB3, 0x95, 0x42, 0x04, 0x29, 0x4C, 0x23, 0x1D, 0xAE, +0x10, 0x5C, 0x24, 0x91, 0xD6, 0xCF, 0xFA, 0x77, 0xAA, 0x15, 0x44, 0x7E, 0x9F, 0x8A, 0x99, 0x6C, +0x90, 0x77, 0x26, 0x98, 0x77, 0x5E, 0x5D, 0xEE, 0x26, 0x94, 0x4D, 0xF6, 0x96, 0x70, 0x26, 0x7B, +0x4D, 0x38, 0x1A, 0x5F, 0xC3, 0x16, 0x52, 0xD8, 0x70, 0x10, 0xC1, 0x01, 0x61, 0xEC, 0xBC, 0xC9, +0x00, 0x42, 0xF2, 0x9C, 0xEE, 0x6A, 0x1B, 0xD7, 0x11, 0x64, 0xC4, 0x3D, 0xE7, 0x28, 0xF2, 0x9B, +0xE3, 0x3B, 0xF5, 0x9A, 0xA3, 0x42, 0x6A, 0xB7, 0x71, 0x1A, 0xE4, 0x64, 0x44, 0x2C, 0x1B, 0xB7, +0x32, 0x57, 0x55, 0x88, 0x04, 0xCB, 0x74, 0xA2, 0x5D, 0xB2, 0xB3, 0x6D, 0x74, 0xD3, 0xDB, 0x46, +0x37, 0x32, 0x45, 0x71, 0xF5, 0x9C, 0xDC, 0x53, 0xA6, 0xE9, 0xF6, 0x4E, 0xEF, 0x53, 0x3D, 0xC3, +0x79, 0xF5, 0x98, 0x06, 0x30, 0xFA, 0xE0, 0xF9, 0xFB, 0xDD, 0xC4, 0x34, 0xEC, 0xAC, 0x64, 0x4C, +0xDB, 0x2A, 0x82, 0x51, 0xA6, 0xF6, 0x5D, 0x8A, 0x2D, 0x37, 0xD0, 0xC6, 0x12, 0x09, 0xFF, 0x75, +0x47, 0xDA, 0x58, 0x96, 0xD7, 0xC6, 0x1D, 0x67, 0x98, 0xE5, 0xD7, 0xA0, 0x1F, 0xCF, 0x58, 0x7E, +0x9E, 0xCC, 0x8C, 0xCA, 0x3A, 0xE2, 0x70, 0xFA, 0xE0, 0x83, 0xB1, 0xD4, 0x5E, 0xBD, 0x7B, 0xB6, +0x13, 0x5D, 0x89, 0x4E, 0xF7, 0xA3, 0xAF, 0x90, 0xE5, 0x7D, 0xEB, 0xCC, 0x26, 0x4E, 0x75, 0xA7, +0x42, 0x20, 0x7D, 0xF0, 0x96, 0x38, 0xBE, 0x76, 0xE9, 0x7A, 0xFC, 0xB5, 0x73, 0x3B, 0xD1, 0x1A, +0xED, 0x79, 0x3F, 0x2A, 0x63, 0x4C, 0xEF, 0x5B, 0x5F, 0xD3, 0x99, 0xE5, 0x79, 0xAE, 0x57, 0x59, +0x65, 0x1C, 0x4E, 0x1F, 0xBC, 0x6E, 0xBD, 0xA3, 0x47, 0x3B, 0x51, 0x97, 0xE8, 0x75, 0x3F, 0x1A, +0x0B, 0x79, 0xDE, 0xB7, 0xD2, 0x6E, 0xC6, 0xB6, 0x35, 0xAF, 0xAC, 0x32, 0x0A, 0xA5, 0x0F, 0x3E, +0xB6, 0x5E, 0xC2, 0xDF, 0x9D, 0xA8, 0x8B, 0xF5, 0xB8, 0x1F, 0x65, 0x71, 0x6E, 0xF7, 0xAD, 0x2A, +0x73, 0xB4, 0xAC, 0xAC, 0x28, 0x80, 0xD1, 0x07, 0x2F, 0x2E, 0x7F, 0xD5, 0xEA, 0x2F, 0xDC, 0xA5, +0x83, 0x0F, 0x5C, 0x6A, 0x57, 0x3F, 0x36, 0x76, 0xA2, 0x31, 0xEC, 0x7A, 0x3F, 0xFA, 0xA2, 0x4C, +0xEF, 0x5B, 0x5B, 0x74, 0x5F, 0xCD, 0xD0, 0xA8, 0x1E, 0x0E, 0x05, 0x20, 0x3E, 0xFB, 0x02, 0x47, +0xDA, 0x73, 0x63, 0x37, 0x01, 0x31, 0xEC, 0x77, 0x17, 0x45, 0x7B, 0xC4, 0xE4, 0xBE, 0xF5, 0x64, +0x13, 0xB3, 0x84, 0x8A, 0xE2, 0x25, 0x86, 0xF9, 0x19, 0xB7, 0xA8, 0xE0, 0xD6, 0xCD, 0x35, 0xD4, +0x1A, 0x57, 0x2F, 0xB4, 0x37, 0xE2, 0xB4, 0x04, 0x37, 0x1B, 0xCF, 0xD9, 0x65, 0x0D, 0x6D, 0xE3, +0xF4, 0xC4, 0x07, 0xB7, 0xBD, 0xE3, 0xE3, 0xED, 0x86, 0xB7, 0x59, 0xD3, 0xA8, 0xC7, 0xC7, 0xF7, +0xA8, 0x93, 0xB1, 0x31, 0x22, 0x9F, 0x4D, 0x12, 0x6C, 0xF2, 0x30, 0x8C, 0x04, 0xAB, 0x0F, 0x5E, +0xC2, 0x89, 0xF6, 0x82, 0x9E, 0xEC, 0xAA, 0x0C, 0x94, 0xFB, 0xDF, 0x85, 0x27, 0xC5, 0xF8, 0xDD, +0xB7, 0x33, 0x51, 0x62, 0xA0, 0xE8, 0x76, 0x27, 0xCE, 0x46, 0x7B, 0x0B, 0x62, 0xE0, 0x5C, 0x7D, +0x1F, 0xD8, 0xF9, 0x6E, 0x15, 0x18, 0x11, 0xB1, 0x33, 0x1D, 0x4A, 0x7C, 0xEF, 0x5D, 0x8D, 0x70, +0x7D, 0x5A, 0x5D, 0x7B, 0x08, 0x05, 0x4A, 0xC3, 0x3F, 0xBB, 0xD1, 0x14, 0xED, 0x70, 0x27, 0x0A, +0x62, 0xAC, 0xDD, 0xA1, 0x5E, 0xB2, 0xEF, 0xCB, 0x3B, 0xBF, 0xF8, 0x2B, 0x53, 0x8B, 0x94, 0xC0, +0x77, 0x20, 0xD1, 0x79, 0x4E, 0x12, 0xB4, 0xFC, 0xC0, 0xB2, 0x6D, 0x7D, 0xF0, 0x8A, 0x04, 0xDA, +0x35, 0x1E, 0x96, 0xDC, 0x72, 0x24, 0x61, 0x11, 0xFB, 0x0D, 0x03, 0x8F, 0x18, 0x33, 0x7D, 0x70, +0x8D, 0x2F, 0x93, 0x05, 0x5C, 0x78, 0x56, 0x1D, 0x19, 0xB5, 0x6E, 0xE2, 0x78, 0x2E, 0x10, 0x15, +0x2A, 0x87, 0xBF, 0xA4, 0x4E, 0xD7, 0xC4, 0x91, 0x74, 0x6D, 0x70, 0x45, 0x1B, 0x6B, 0xE8, 0xFE, +0xC5, 0xDD, 0x95, 0xDE, 0x0B, 0x45, 0x37, 0x3F, 0xE2, 0x6E, 0xC6, 0xF8, 0xAB, 0xA6, 0x41, 0xAD, +0x6C, 0x6B, 0xF3, 0xE0, 0xDC, 0x9F, 0x1B, 0x8E, 0x68, 0x46, 0xF7, 0xFD, 0x2E, 0xF9, 0x46, 0xCE, +0xA1, 0x6B, 0x9B, 0xD0, 0xF0, 0x99, 0x79, 0x83, 0xAF, 0xDD, 0x32, 0xB5, 0xEB, 0x70, 0x4B, 0x22, +0x82, 0x80, 0x5D, 0x08, 0x0C, 0x05, 0xBA, 0x9F, 0x7A, 0x02, 0x3D, 0xDB, 0x3C, 0x8A, 0x2F, 0x2A, +0xCA, 0x51, 0x6E, 0xC6, 0x2E, 0x4A, 0x8F, 0x4C, 0x42, 0x41, 0xAA, 0x36, 0xD7, 0x2A, 0xF7, 0x54, +0x7E, 0x20, 0x13, 0xCB, 0x07, 0x1A, 0x35, 0x30, 0x8B, 0x43, 0xBA, 0x0F, 0x8D, 0xD9, 0x72, 0xB9, +0x3D, 0x8E, 0x72, 0x97, 0x7C, 0x87, 0xB6, 0x72, 0xE7, 0x6A, 0xA5, 0x52, 0x3E, 0xB9, 0xCF, 0x34, +0x8E, 0xB1, 0xC8, 0xE8, 0x1F, 0xB6, 0x5A, 0xD3, 0x3E, 0xEE, 0xA8, 0xD3, 0x04, 0x6B, 0xE7, 0x87, +0xD3, 0x7E, 0xD1, 0x66, 0xB0, 0xC2, 0xED, 0x90, 0xC0, 0xE9, 0xC6, 0xBB, 0x21, 0x51, 0x4A, 0x03, +0xA0, 0xA6, 0xA9, 0xBD, 0x33, 0xFC, 0x2F, 0x4D, 0xED, 0x23, 0x16, 0x5E, 0x3B, 0xDC, 0x14, 0x89, +0xB4, 0x1B, 0xA6, 0xE9, 0x65, 0x6E, 0x8C, 0xEC, 0xC7, 0x36, 0x46, 0x9E, 0x88, 0x8D, 0x91, 0xD2, +0x12, 0xC8, 0xAA, 0xDB, 0xED, 0x96, 0xE1, 0xBC, 0xE4, 0xDE, 0xC8, 0x3B, 0x61, 0x69, 0x06, 0xC2, +0x2C, 0xC9, 0x52, 0x5F, 0xB0, 0xD4, 0x97, 0x58, 0x3A, 0xBD, 0xCB, 0xDD, 0x9E, 0x77, 0xC2, 0x11, +0x5F, 0x60, 0xFF, 0x4A, 0x58, 0x2A, 0xB5, 0x81, 0x95, 0xDA, 0xF6, 0x5D, 0xED, 0x5F, 0xA5, 0x4D, +0x92, 0xC1, 0xF0, 0x38, 0x37, 0x16, 0x52, 0x10, 0xE6, 0xF3, 0xAF, 0xEE, 0xD2, 0xE7, 0x27, 0x5B, +0xF8, 0xFC, 0x24, 0xE5, 0xF3, 0x3B, 0x74, 0x76, 0x41, 0xF8, 0x37, 0xE6, 0xF0, 0x82, 0xAD, 0x0A, +0x4E, 0xAF, 0x64, 0x6B, 0xB7, 0x1E, 0x12, 0x5A, 0xC2, 0xAB, 0xBB, 0xF4, 0x90, 0x0C, 0xBB, 0xDD, +0xC8, 0x48, 0x79, 0xCC, 0x19, 0xEC, 0x26, 0x27, 0xD1, 0x4A, 0x4A, 0x56, 0x27, 0xEF, 0x1D, 0x77, +0x80, 0x1E, 0xF5, 0x79, 0xD9, 0x74, 0x17, 0xEA, 0x29, 0xBF, 0x51, 0x3E, 0xB3, 0xC9, 0xDD, 0x14, +0x65, 0xF8, 0xAA, 0x8A, 0xB9, 0x54, 0xE1, 0x96, 0x2E, 0xCC, 0x2E, 0xDF, 0xFE, 0xBD, 0x5A, 0x2D, +0x96, 0xEC, 0x69, 0x77, 0xF5, 0xD8, 0x66, 0xD6, 0x2A, 0x0B, 0x8C, 0xD3, 0x0E, 0x11, 0x07, 0x5F, +0x19, 0xD1, 0xFB, 0x14, 0x72, 0xAE, 0x18, 0x0A, 0x4A, 0x41, 0x21, 0x04, 0xCB, 0x1A, 0xEC, 0xD1, +0x40, 0xC8, 0x27, 0x93, 0xE1, 0x58, 0xC4, 0x35, 0xCD, 0x1D, 0x8F, 0xE9, 0x77, 0xC8, 0x1E, 0x63, +0xC0, 0xF0, 0xBF, 0xE0, 0xF5, 0x4E, 0x37, 0x24, 0x49, 0x35, 0xD4, 0x8B, 0x28, 0x0C, 0x69, 0xA3, +0x26, 0x56, 0x34, 0x6E, 0xAB, 0x2A, 0x82, 0x23, 0x26, 0x82, 0x17, 0x6F, 0x3E, 0xAA, 0x64, 0xC0, +0x7C, 0xAD, 0x93, 0x16, 0xC1, 0xD1, 0xE6, 0x2F, 0xBD, 0xE8, 0x96, 0x96, 0x56, 0x27, 0x92, 0xD6, +0xD1, 0x38, 0xDA, 0xBB, 0xBB, 0x4D, 0xC8, 0x52, 0x48, 0xE0, 0x98, 0x3D, 0x9D, 0xAF, 0xBD, 0x97, +0x3D, 0xA0, 0x94, 0x1D, 0x1C, 0x57, 0xB1, 0x03, 0xF3, 0x68, 0x0B, 0x33, 0x38, 0xCE, 0x30, 0x83, +0xBB, 0x92, 0x41, 0x5F, 0x1F, 0xBC, 0xDF, 0xC4, 0x0C, 0xFA, 0x25, 0xCD, 0xE0, 0x48, 0x98, 0x41, +0xB4, 0xB1, 0xBB, 0x5F, 0x56, 0x58, 0x92, 0x15, 0x3C, 0x1E, 0xE3, 0xF3, 0x4C, 0x8F, 0xCB, 0x79, +0xC2, 0xEE, 0x62, 0xEE, 0xD2, 0x72, 0xAA, 0xC7, 0xDB, 0x5F, 0x2D, 0xC7, 0x74, 0x97, 0xD5, 0x42, +0xAE, 0xDC, 0xD1, 0xD7, 0x1E, 0x6E, 0xAB, 0x8D, 0x5A, 0x71, 0x66, 0xA7, 0xB5, 0xC2, 0xCA, 0xDE, +0xF1, 0x5D, 0x4F, 0x4B, 0xBF, 0xD2, 0x25, 0xB6, 0x33, 0x54, 0xB4, 0x2E, 0x57, 0x04, 0xA4, 0xF7, +0xC2, 0xBC, 0x79, 0xA9, 0x6D, 0xF0, 0xBE, 0x0D, 0x05, 0xB2, 0x2E, 0x7B, 0x2B, 0x89, 0xB6, 0xC1, +0x6B, 0x49, 0x14, 0xD8, 0x32, 0xF6, 0x0E, 0xE1, 0x1B, 0x62, 0xB4, 0xCD, 0x5E, 0x11, 0x53, 0xB8, +0x8D, 0x86, 0xB5, 0xDA, 0x3C, 0xA5, 0x84, 0xE3, 0x32, 0xE6, 0xAD, 0x50, 0x63, 0x95, 0x7B, 0xF7, +0x01, 0xC5, 0x29, 0x19, 0x00, 0x07, 0x5F, 0xD1, 0x6D, 0x46, 0x3E, 0x29, 0xB7, 0x5B, 0x38, 0x49, +0x58, 0x85, 0xCA, 0xF0, 0xD3, 0x99, 0x14, 0xCC, 0xC2, 0xCE, 0x2B, 0x06, 0xB3, 0xA8, 0xCE, 0x07, +0x63, 0xDA, 0xFB, 0xE0, 0xE5, 0x9F, 0x0A, 0x96, 0xD6, 0x9B, 0xB3, 0x74, 0x74, 0x57, 0x2C, 0x6D, +0x91, 0xAA, 0x42, 0xEB, 0x0A, 0xDC, 0xC0, 0xB0, 0x37, 0x36, 0x2E, 0x06, 0x0D, 0xB6, 0xC5, 0x62, +0xAE, 0x76, 0x0D, 0xAC, 0xEE, 0xD4, 0xC0, 0x04, 0x01, 0xE5, 0x94, 0xD1, 0x4F, 0x2B, 0xE3, 0xF4, +0x6B, 0xB3, 0x2F, 0xC6, 0x51, 0x59, 0xF3, 0x52, 0x70, 0x74, 0xF2, 0x35, 0x99, 0x97, 0xBB, 0x08, +0xF0, 0xEA, 0xC6, 0xC1, 0x8B, 0x81, 0x63, 0xF0, 0xA2, 0x47, 0xBB, 0x37, 0xB0, 0x90, 0x82, 0x8D, +0xF5, 0x71, 0x74, 0xA7, 0xEF, 0xA2, 0xBB, 0x8B, 0x08, 0xC6, 0x58, 0xDA, 0xC2, 0xC4, 0x7A, 0xFD, +0x1D, 0x9A, 0x98, 0xB4, 0xD0, 0xC4, 0xF3, 0x20, 0x2F, 0x60, 0x74, 0xBE, 0x36, 0x10, 0x15, 0x34, +0x55, 0x56, 0x92, 0xD4, 0x59, 0xF9, 0xFC, 0x10, 0x8A, 0xC2, 0x34, 0x82, 0x0C, 0x3A, 0xCF, 0xD9, +0x27, 0x2D, 0xD5, 0x1D, 0x46, 0xAF, 0xA0, 0xA4, 0xCB, 0x6A, 0xD1, 0xCB, 0x8E, 0xC3, 0x42, 0x33, +0xF9, 0x12, 0xE4, 0xC2, 0xD7, 0x3D, 0x9E, 0x1B, 0xFC, 0xDD, 0x28, 0x37, 0x84, 0xAF, 0x01, 0x6A, +0x53, 0x8F, 0x8C, 0x2F, 0xF4, 0xEF, 0x42, 0x9C, 0x5C, 0x5A, 0xD8, 0x44, 0xD7, 0x20, 0x24, 0x3B, +0xB6, 0x6B, 0x60, 0xB1, 0x6A, 0xCC, 0x03, 0xA0, 0xB4, 0xFD, 0xC7, 0x1C, 0x27, 0x79, 0x0D, 0x7C, +0x91, 0x86, 0x51, 0x6E, 0xED, 0x98, 0xBE, 0x36, 0x99, 0x3F, 0xF1, 0x84, 0x87, 0xE1, 0x9A, 0xE1, +0xFF, 0xFC, 0x77, 0xD1, 0xD4, 0x0C, 0x7E, 0xD8, 0x34, 0x12, 0x00, 0x98, 0x91, 0x37, 0xBA, 0xD0, +0x81, 0x52, 0xCF, 0xF5, 0xA1, 0x14, 0xB5, 0x26, 0x56, 0x86, 0xAA, 0xB2, 0xA4, 0x7D, 0xA8, 0x12, +0x77, 0xA2, 0xB1, 0x62, 0x6C, 0x72, 0xEE, 0x8F, 0x3C, 0x6B, 0x0E, 0xA5, 0x9A, 0xE9, 0x8E, 0x16, +0x33, 0xE2, 0x04, 0x6D, 0xC3, 0x34, 0xAF, 0x6E, 0xE0, 0xE0, 0x2D, 0xCE, 0x30, 0x83, 0xE4, 0xEB, +0xB5, 0x17, 0x3F, 0xBD, 0xBB, 0x64, 0x2F, 0x13, 0x7D, 0x0B, 0xF2, 0x22, 0x66, 0xAD, 0xA9, 0x8D, +0x17, 0x0E, 0xAB, 0xDE, 0xEB, 0x04, 0xDB, 0xB2, 0x0F, 0xCC, 0xDE, 0x18, 0x9E, 0x36, 0x34, 0x7C, +0xF2, 0xDA, 0xF5, 0x03, 0xED, 0x42, 0x0B, 0x31, 0xDA, 0xEE, 0x88, 0xBE, 0xAC, 0xA6, 0xCD, 0xF8, +0xE2, 0x2D, 0x19, 0xE3, 0xBF, 0x78, 0x36, 0x34, 0x0D, 0xA1, 0x0E, 0xB4, 0xDA, 0xD9, 0x69, 0xB7, +0x86, 0xF6, 0x17, 0x76, 0x31, 0xC6, 0x4F, 0xC6, 0x42, 0xBB, 0xFA, 0xC2, 0xB3, 0x9B, 0xDA, 0x68, +0xD8, 0x60, 0x2F, 0x80, 0xA5, 0x97, 0xF1, 0x9A, 0x78, 0x33, 0x78, 0x3B, 0x98, 0x12, 0xA7, 0x1E, +0x51, 0x06, 0xCE, 0x30, 0x77, 0x1D, 0x3F, 0xF6, 0xF5, 0x5B, 0x6B, 0x1C, 0x5D, 0x6F, 0x43, 0x41, +0x1F, 0x2C, 0x7C, 0xED, 0xE1, 0xC5, 0x85, 0x86, 0x05, 0x6E, 0xEC, 0xC5, 0xB2, 0xA3, 0x61, 0xB2, +0x5D, 0x53, 0x4B, 0x5C, 0xF8, 0x19, 0x42, 0x83, 0xF4, 0x0A, 0xF3, 0x5B, 0x8D, 0xD8, 0x89, 0x77, +0x68, 0x87, 0x00, 0x18, 0x45, 0xEA, 0x8D, 0x38, 0x81, 0x75, 0xD3, 0x08, 0x8C, 0x46, 0xFC, 0x65, +0xB6, 0xD0, 0x2B, 0x50, 0xD2, 0xD4, 0xE8, 0x2D, 0xF9, 0xCD, 0xBA, 0xB7, 0x8D, 0x36, 0xC8, 0x10, +0xF8, 0x0D, 0xA1, 0x89, 0xE7, 0x25, 0x3F, 0xEB, 0x0B, 0xD0, 0xAD, 0x6E, 0x53, 0xC3, 0x3B, 0x71, +0x58, 0x89, 0xC8, 0x07, 0xE2, 0x9A, 0x10, 0x5A, 0x3E, 0x5A, 0x05, 0x4A, 0x86, 0xEE, 0x36, 0xA6, +0x22, 0x88, 0x3D, 0x1F, 0xC8, 0x04, 0x24, 0x36, 0x69, 0xF2, 0x01, 0x74, 0x93, 0x8E, 0x9E, 0x9B, +0x2C, 0x28, 0x4A, 0x5A, 0x3B, 0x3C, 0x04, 0x97, 0x86, 0xA0, 0x44, 0xC0, 0x2A, 0x26, 0xF5, 0x1A, +0x5F, 0xC0, 0x04, 0x8B, 0xAA, 0x75, 0x56, 0xB5, 0x03, 0x40, 0xD0, 0x0E, 0xDC, 0xEB, 0xC0, 0xB3, +0x9C, 0x09, 0x0C, 0x3D, 0x1A, 0x11, 0x36, 0x7A, 0x1B, 0x51, 0x26, 0xEE, 0xD3, 0xEB, 0xB4, 0x93, +0xE4, 0x8D, 0x3A, 0xBF, 0x7E, 0x50, 0x6B, 0xD4, 0x38, 0xF1, 0xF4, 0x1C, 0xCC, 0xAD, 0xCE, 0x0E, +0x1E, 0x51, 0x1A, 0x1B, 0xDA, 0xF9, 0x39, 0xEF, 0x86, 0xB5, 0xC2, 0x8B, 0xD0, 0x88, 0xFE, 0x49, +0xDC, 0x0A, 0x4D, 0xF1, 0xF7, 0xEF, 0xFF, 0x12, 0x36, 0x7B, 0x7B, 0x08, 0x54, 0x3F, 0xC5, 0x19, +0x84, 0xEF, 0xFF, 0x82, 0xFF, 0xDF, 0x3E, 0xA2, 0xD3, 0x06, 0xDF, 0xFF, 0x85, 0x7F, 0x6E, 0x1F, +0x41, 0x4F, 0x70, 0x4C, 0xFB, 0xBB, 0xFD, 0x9D, 0xCA, 0x21, 0x2D, 0xBD, 0x49, 0xA6, 0xF4, 0x42, +0xB1, 0x55, 0xA6, 0x69, 0x92, 0x43, 0xD4, 0xEF, 0x91, 0xFF, 0xD6, 0x47, 0xAE, 0x09, 0xEA, 0x09, +0xC0, 0x92, 0x85, 0xD2, 0x6D, 0x50, 0x89, 0x10, 0x54, 0xF8, 0x76, 0x67, 0x6B, 0x4C, 0x5B, 0x6A, +0xDC, 0x55, 0x22, 0x03, 0x11, 0x2D, 0xE7, 0x86, 0xE7, 0x93, 0x37, 0x4E, 0x50, 0x0F, 0x62, 0x4E, +0x91, 0x21, 0xF1, 0xC1, 0x20, 0xC6, 0x02, 0xFE, 0x00, 0x0E, 0xDA, 0xD5, 0xB8, 0xD2, 0x42, 0x63, +0x7B, 0x10, 0xDA, 0x61, 0x44, 0x29, 0xBB, 0x99, 0x61, 0x87, 0x9F, 0x46, 0xF6, 0x97, 0x3A, 0xBE, +0x6F, 0x36, 0x19, 0x2A, 0x52, 0x22, 0xC2, 0x46, 0x4F, 0xF1, 0x7F, 0x20, 0x17, 0xFC, 0x93, 0xA9, +0x1F, 0xC0, 0xCA, 0x4A, 0xF8, 0x3A, 0x9D, 0x02, 0xF8, 0xBC, 0x6A, 0x6A, 0xEC, 0x60, 0x0D, 0x9E, +0xE1, 0x98, 0x78, 0x8E, 0x7F, 0xD6, 0x42, 0x7B, 0x78, 0x81, 0x1F, 0xC1, 0x35, 0x5A, 0xB3, 0xE2, +0x25, 0x76, 0x80, 0xAD, 0x68, 0x8D, 0x41, 0x5B, 0xB1, 0x23, 0xB8, 0x86, 0xEF, 0x5E, 0x02, 0xDB, +0x6D, 0x6A, 0x43, 0xCB, 0x71, 0xE8, 0x41, 0x01, 0xF5, 0x51, 0xAA, 0x7F, 0xEA, 0xAF, 0x80, 0x03, +0x4E, 0xDA, 0xED, 0x23, 0x7F, 0x1D, 0x9E, 0xAD, 0x6F, 0x1F, 0x11, 0xBC, 0x47, 0x89, 0x84, 0xE3, +0x35, 0x3F, 0x86, 0xEB, 0x40, 0x1F, 0xDE, 0x11, 0x04, 0xD3, 0x0B, 0xEB, 0xE8, 0x02, 0xB4, 0x08, +0xF0, 0x3E, 0x27, 0x1E, 0xCE, 0xD6, 0xE1, 0x19, 0x42, 0x53, 0x58, 0xCE, 0x06, 0x9C, 0xAE, 0xA3, +0x53, 0xB8, 0x4B, 0xDF, 0x23, 0x85, 0x44, 0x30, 0x9E, 0x6E, 0x1F, 0x71, 0x9E, 0xE0, 0x12, 0x3F, +0x4A, 0x8A, 0x1A, 0x63, 0x42, 0xC0, 0xA3, 0xC8, 0x73, 0x96, 0xA4, 0xA5, 0xFC, 0x01, 0xFE, 0x71, +0x65, 0x13, 0x3C, 0x7C, 0xBE, 0x7E, 0x63, 0xD6, 0x6B, 0x7C, 0x41, 0xB6, 0x86, 0x31, 0x4C, 0x86, +0x69, 0xBB, 0xCE, 0xC8, 0xB6, 0x46, 0xE8, 0x28, 0xF5, 0x86, 0x76, 0x31, 0xE0, 0x71, 0x0C, 0x0D, +0x1A, 0x9A, 0xCB, 0x46, 0x9A, 0x89, 0x5A, 0x2C, 0x29, 0xD6, 0x1A, 0x6D, 0x6A, 0x87, 0xDC, 0xD6, +0x10, 0x05, 0x77, 0xC1, 0x72, 0x38, 0xB0, 0xB1, 0x02, 0x47, 0xCA, 0x5B, 0x72, 0x91, 0xD0, 0xD6, +0x12, 0x16, 0x8A, 0x46, 0x0E, 0xB5, 0x9D, 0x44, 0x94, 0xCD, 0xF1, 0x6A, 0xE1, 0xC0, 0x0F, 0x93, +0x0E, 0x0C, 0xAA, 0xF2, 0x82, 0x7A, 0xED, 0x0A, 0x37, 0x64, 0xFC, 0x56, 0x3B, 0xC0, 0x46, 0x07, +0xB5, 0x7F, 0x9D, 0x69, 0xB5, 0x03, 0xD9, 0x93, 0x6F, 0x93, 0x2E, 0xC7, 0x34, 0x36, 0x29, 0xA9, +0xB1, 0x89, 0xA4, 0xB1, 0xC9, 0xDD, 0x6A, 0x4C, 0x5E, 0x08, 0xDE, 0x46, 0x6B, 0xF2, 0xCA, 0x6B, +0x8E, 0xE6, 0x0A, 0xE1, 0xB9, 0xD2, 0xB8, 0xB6, 0x26, 0x2A, 0x6D, 0x6D, 0xA2, 0x26, 0x96, 0xE2, +0xC0, 0x7B, 0x88, 0xF7, 0xFA, 0xE7, 0x77, 0x6F, 0x31, 0x54, 0xAA, 0x55, 0x16, 0x6A, 0x2C, 0x59, +0x8E, 0x28, 0x30, 0x60, 0xEE, 0x8C, 0x05, 0xEE, 0x58, 0x0E, 0x3D, 0xA8, 0x69, 0x75, 0x8A, 0x12, +0x33, 0x68, 0x81, 0x21, 0xF0, 0xC0, 0x5B, 0xCE, 0x77, 0x31, 0xD8, 0x0A, 0xE7, 0x8D, 0xA0, 0x72, +0x6C, 0x01, 0x01, 0x4A, 0x29, 0x91, 0x61, 0x4E, 0x39, 0x8C, 0x94, 0x13, 0x76, 0xEE, 0x22, 0xD4, +0x5F, 0xFD, 0xB2, 0x41, 0x4D, 0xC4, 0xF4, 0x28, 0xB6, 0xF9, 0x85, 0xD2, 0xE1, 0x91, 0xBF, 0x94, +0x80, 0xF8, 0x1C, 0xB6, 0xC2, 0xC0, 0x45, 0x26, 0x28, 0x85, 0x46, 0x4C, 0x5D, 0x66, 0xE3, 0x59, +0x57, 0xC1, 0xB3, 0x56, 0xE0, 0xE1, 0x99, 0xA7, 0x14, 0x1A, 0x3E, 0xD1, 0x95, 0x89, 0xA5, 0x1C, +0x31, 0x7C, 0x72, 0x49, 0xC5, 0x13, 0xCF, 0x74, 0xE5, 0x78, 0xE2, 0x93, 0x22, 0xD9, 0x78, 0x4A, +0xCA, 0x86, 0xCF, 0x44, 0x28, 0xEC, 0x39, 0x59, 0x8D, 0x74, 0xC4, 0x7F, 0x9B, 0xD7, 0x1F, 0x63, +0x03, 0xA2, 0x45, 0xF4, 0x67, 0x2F, 0x5E, 0x32, 0xF9, 0x28, 0xAA, 0x45, 0x62, 0x2B, 0xB3, 0x01, +0xB1, 0xDB, 0x46, 0x00, 0xF1, 0x09, 0xC6, 0xF1, 0xC4, 0x6F, 0x63, 0x85, 0x1B, 0x8A, 0x31, 0x75, +0xAB, 0xED, 0x00, 0x01, 0x14, 0x61, 0xE3, 0xAC, 0x93, 0x34, 0xCF, 0x14, 0x2E, 0x76, 0x39, 0x0B, +0x1D, 0xBB, 0x9B, 0x81, 0x91, 0x67, 0x98, 0x38, 0x04, 0x5E, 0xCC, 0xC2, 0x46, 0x47, 0x31, 0x12, +0xAE, 0xDE, 0xF1, 0x71, 0x3A, 0xCF, 0xF0, 0x0E, 0xF8, 0x67, 0xED, 0x10, 0x03, 0xCE, 0x47, 0x45, +0x43, 0xB3, 0x11, 0x14, 0x82, 0x5A, 0x4D, 0x2C, 0xD9, 0xD5, 0xCE, 0x52, 0x15, 0x37, 0x40, 0xF0, +0x45, 0x38, 0xED, 0x29, 0xA3, 0x31, 0xF6, 0xB1, 0x96, 0x21, 0x0C, 0xBE, 0xC3, 0x6F, 0xB2, 0x31, +0x64, 0x74, 0x83, 0x4B, 0x88, 0x89, 0x5D, 0xC3, 0x81, 0x6B, 0xE2, 0x12, 0x5B, 0xDB, 0x69, 0xB9, +0x0E, 0x51, 0xF7, 0x1A, 0xAB, 0xDF, 0x79, 0x47, 0xFC, 0x8C, 0x3F, 0x94, 0x1E, 0x81, 0x79, 0x24, +0x58, 0x78, 0x0E, 0xAF, 0xE7, 0xD3, 0xF5, 0x8D, 0x72, 0x28, 0xB9, 0x43, 0xDB, 0x3C, 0x3C, 0xD4, +0x9E, 0x05, 0x81, 0x01, 0x0A, 0xC0, 0x75, 0xCA, 0x29, 0xCA, 0x47, 0x33, 0xF8, 0xA4, 0x84, 0xEB, +0xA1, 0x51, 0xB2, 0x87, 0x8A, 0x09, 0xF3, 0x5B, 0xFC, 0x84, 0x9D, 0x70, 0x67, 0x8A, 0xAA, 0xFD, +0xEF, 0x05, 0xF1, 0xD6, 0xD7, 0x54, 0x60, 0xAE, 0xF7, 0xCC, 0xB6, 0xEB, 0xB5, 0x76, 0xB4, 0xEC, +0x5C, 0x63, 0x63, 0xF0, 0x36, 0xA0, 0xBA, 0x82, 0x3E, 0x40, 0xC7, 0x91, 0xCD, 0x33, 0x6E, 0x42, +0xBD, 0xC3, 0xB8, 0xEB, 0x82, 0x2B, 0x23, 0x39, 0xE8, 0x87, 0x16, 0xAE, 0xF3, 0x85, 0xAC, 0x17, +0x73, 0x10, 0x7F, 0x34, 0x8C, 0x4F, 0x4C, 0x2C, 0x70, 0xE9, 0x90, 0x36, 0xB4, 0xBC, 0xE4, 0x03, +0xB9, 0xEE, 0x91, 0xA2, 0x51, 0xA4, 0x02, 0x6A, 0x9D, 0xE8, 0x89, 0xE9, 0x2F, 0xFC, 0xDC, 0x3E, +0x50, 0x9F, 0x29, 0xA6, 0x40, 0x38, 0x81, 0x5C, 0x78, 0x22, 0x79, 0x25, 0x7A, 0x48, 0x4C, 0x4F, +0xDC, 0x36, 0x1E, 0x44, 0x91, 0x61, 0x31, 0x37, 0x8D, 0x80, 0xC4, 0x83, 0x43, 0x68, 0x0B, 0xE2, +0xE6, 0xCC, 0x0D, 0x48, 0x22, 0x62, 0x58, 0xB8, 0x69, 0xC6, 0xB0, 0x3F, 0x46, 0xD6, 0x78, 0xAF, +0xEE, 0xAF, 0xF0, 0xF1, 0x0A, 0xFE, 0x9F, 0x9A, 0x83, 0x28, 0x37, 0x6E, 0x4E, 0x59, 0x48, 0x18, +0x0F, 0x22, 0x2B, 0x91, 0xE5, 0x10, 0x0B, 0x0B, 0xFC, 0xBE, 0xE8, 0xE9, 0xE1, 0x43, 0x7A, 0xF4, +0x20, 0x54, 0x9A, 0x88, 0x1E, 0x17, 0x5A, 0x74, 0x23, 0xA1, 0xE0, 0x34, 0xEE, 0x04, 0x0E, 0x81, +0x5C, 0xC2, 0xC0, 0x7C, 0x2B, 0x54, 0xEF, 0x1C, 0xAA, 0x4D, 0xB4, 0x85, 0xFF, 0x8F, 0xFA, 0x5F, +0x51, 0xD4, 0xBF, 0xBF, 0x10, 0x9F, 0x63, 0xDB, 0x09, 0x0F, 0x60, 0x70, 0xEA, 0x69, 0xC1, 0x83, +0x1A, 0x54, 0x3B, 0xCA, 0x79, 0x3F, 0x1E, 0xBA, 0x23, 0xFB, 0x9A, 0x5A, 0x26, 0x23, 0x3A, 0xB2, +0x2C, 0x94, 0x11, 0x4E, 0xDF, 0xE3, 0x04, 0x37, 0xCE, 0x76, 0xD7, 0x6B, 0x6C, 0x6D, 0x81, 0xC6, +0xE3, 0xDB, 0xA8, 0x24, 0x99, 0xBA, 0xCB, 0x3C, 0x48, 0x0F, 0xA2, 0xCE, 0x0D, 0x49, 0x00, 0x87, +0xD0, 0x7C, 0x3B, 0x4F, 0x61, 0xD7, 0x62, 0xDB, 0x0F, 0x4F, 0x06, 0xD0, 0x40, 0x5C, 0x01, 0xD0, +0xC0, 0xA3, 0x5E, 0x23, 0xA1, 0x25, 0x4E, 0x11, 0x56, 0x41, 0x56, 0x2E, 0x62, 0x5A, 0xE6, 0xC5, +0x31, 0xB3, 0x50, 0x5A, 0x21, 0xC8, 0xCA, 0x97, 0x01, 0x22, 0x7E, 0x7A, 0xA1, 0x39, 0x0B, 0xDB, +0x06, 0x1B, 0x44, 0x16, 0xC0, 0x06, 0xE5, 0xBB, 0xCA, 0x10, 0xFD, 0x9F, 0x1B, 0xCF, 0x42, 0xCA, +0x63, 0x12, 0x78, 0xF4, 0x28, 0x8E, 0x0D, 0x17, 0x19, 0x58, 0x19, 0x1F, 0xF6, 0xC6, 0xDA, 0x5F, +0xBA, 0xCE, 0xD8, 0x9A, 0x44, 0x79, 0x96, 0x93, 0x04, 0xC9, 0xFA, 0x61, 0x4C, 0xF0, 0x52, 0x8D, +0x03, 0x84, 0x58, 0x26, 0x15, 0x10, 0x7D, 0x63, 0x6A, 0x6A, 0x36, 0xF6, 0x29, 0xB5, 0xFA, 0x3A, +0xE1, 0xEF, 0x52, 0x6C, 0x80, 0xFC, 0xD1, 0x98, 0xA3, 0x0B, 0xA2, 0xDE, 0x09, 0xBB, 0x92, 0x31, +0x4E, 0x62, 0x18, 0x91, 0xB1, 0x04, 0xDD, 0xF8, 0xA3, 0xF8, 0xF0, 0xAD, 0x62, 0xFC, 0xCD, 0x66, +0xF2, 0xD7, 0x59, 0x69, 0xE7, 0x80, 0x06, 0xDF, 0x7C, 0x26, 0xA5, 0xF6, 0x74, 0x6D, 0x40, 0x1B, +0x66, 0x20, 0xA1, 0x1D, 0xA4, 0x91, 0xE4, 0x52, 0x2E, 0x5E, 0x2B, 0xAD, 0x10, 0x08, 0x45, 0xB7, +0x1C, 0xA2, 0x28, 0x68, 0xAF, 0x70, 0x98, 0x87, 0x2A, 0xB1, 0x25, 0x55, 0x81, 0x90, 0x39, 0x62, +0x9D, 0x6D, 0xEC, 0x63, 0x43, 0x74, 0x44, 0xCE, 0x7D, 0x2C, 0x7E, 0x3D, 0xB3, 0x27, 0x2D, 0xB1, +0x7B, 0x3D, 0xD5, 0xCF, 0x40, 0x6B, 0x75, 0x05, 0xF5, 0xD0, 0xF4, 0x15, 0x3E, 0x25, 0x10, 0xF2, +0x10, 0x5E, 0x88, 0x97, 0xAF, 0xC2, 0xAD, 0xC3, 0xE9, 0x6B, 0xD9, 0xCE, 0x58, 0x92, 0x8D, 0x32, +0x6C, 0x64, 0xC8, 0x77, 0x94, 0x73, 0xBA, 0x9A, 0xFC, 0xE1, 0x47, 0x39, 0x33, 0x6C, 0x99, 0x6E, +0x72, 0x70, 0xB2, 0x65, 0xDD, 0x24, 0xD2, 0xC5, 0x70, 0x66, 0x05, 0x0A, 0x84, 0xB5, 0x6E, 0xAD, +0x4A, 0xE6, 0x92, 0xBD, 0x9C, 0x45, 0x4A, 0x5A, 0xD4, 0x03, 0xA2, 0xD8, 0x74, 0x3C, 0xFD, 0x50, +0x8A, 0x6B, 0x3F, 0xBD, 0x31, 0x3C, 0x9C, 0x64, 0x47, 0x05, 0x27, 0x16, 0x7F, 0x18, 0x0A, 0xB6, +0x6A, 0x49, 0x51, 0xC4, 0xD7, 0x2D, 0xC5, 0x5A, 0x61, 0x7C, 0x0C, 0x20, 0x2F, 0x96, 0xFD, 0xEE, +0x11, 0x80, 0xF3, 0x71, 0x52, 0x43, 0xFB, 0xFE, 0x2F, 0x8A, 0xE2, 0x56, 0x1B, 0x43, 0x94, 0xF1, +0xA7, 0xC4, 0xA4, 0x4B, 0x12, 0x01, 0x7E, 0x2B, 0x15, 0x17, 0x7C, 0x62, 0xEB, 0x94, 0xB7, 0xBF, +0x87, 0x16, 0x12, 0x26, 0xA9, 0xC2, 0x61, 0x0A, 0x5D, 0xCE, 0xCE, 0x1F, 0xA1, 0xB0, 0xC2, 0x5E, +0x31, 0x29, 0x85, 0x3F, 0x16, 0x81, 0xEC, 0x36, 0x54, 0x43, 0xD0, 0xCD, 0x8F, 0x50, 0xF7, 0x24, +0xCC, 0xB4, 0xC1, 0x07, 0x58, 0xA0, 0x01, 0x53, 0x84, 0x4A, 0xA6, 0x23, 0x1C, 0x46, 0x31, 0x31, +0xC5, 0x24, 0xCC, 0x98, 0xE1, 0xBC, 0x14, 0xAF, 0xF5, 0xF2, 0xFA, 0x21, 0x94, 0xC5, 0x1F, 0x3E, +0x0C, 0x8B, 0x1A, 0x0F, 0x42, 0x31, 0xA4, 0x71, 0x60, 0x07, 0x12, 0x82, 0x98, 0x88, 0xB2, 0xC4, +0x14, 0xDF, 0x66, 0x5D, 0x8B, 0x22, 0x55, 0xE6, 0xA8, 0x4E, 0x93, 0x33, 0x2D, 0x4D, 0xB3, 0xB4, +0xDF, 0xDF, 0xA8, 0xC9, 0xFC, 0x8B, 0xCF, 0xC0, 0x48, 0x11, 0xAF, 0x51, 0x85, 0x9C, 0xD4, 0xE0, +0xB2, 0x80, 0x94, 0x3B, 0x2C, 0xB3, 0xC5, 0x0F, 0xC2, 0x1B, 0xC5, 0x07, 0xC5, 0x6C, 0x6A, 0x78, +0x29, 0xD7, 0x82, 0x21, 0x83, 0xB1, 0xB3, 0xF8, 0x68, 0x4F, 0x12, 0x8F, 0xB2, 0x0E, 0xCC, 0x14, +0x17, 0xB3, 0x2E, 0xE6, 0xAE, 0x37, 0x16, 0x59, 0xE6, 0x4E, 0xBC, 0xD2, 0xE7, 0x10, 0xA8, 0xBC, +0x22, 0x80, 0xCB, 0xF0, 0xB9, 0x93, 0x42, 0xC8, 0xE8, 0x19, 0x15, 0x09, 0x07, 0x7D, 0x00, 0xA5, +0xDC, 0xBA, 0x08, 0x6D, 0x1A, 0x03, 0x45, 0xAC, 0xC5, 0xB0, 0xB1, 0x5D, 0xEA, 0x12, 0xBC, 0x9C, +0x7A, 0xF2, 0xE0, 0xA5, 0x8D, 0xE9, 0x12, 0x34, 0xF5, 0xFB, 0x62, 0x60, 0xF9, 0x69, 0x17, 0x99, +0x76, 0xE3, 0xA6, 0x04, 0x70, 0xF4, 0x88, 0x8E, 0x04, 0x2A, 0xB2, 0x59, 0x1E, 0x60, 0xF8, 0xB6, +0x9A, 0x9A, 0xA4, 0x5F, 0x3F, 0x70, 0xE7, 0x6C, 0x73, 0x7E, 0x22, 0x0C, 0x2D, 0xE9, 0x2C, 0x69, +0x1B, 0xEF, 0xD7, 0x79, 0x71, 0x25, 0xCB, 0x36, 0xBE, 0x14, 0x22, 0x6F, 0xF2, 0xAF, 0xC5, 0x2B, +0x63, 0x3A, 0xCD, 0xAA, 0xEC, 0x01, 0x2D, 0xA5, 0xED, 0x7B, 0x23, 0x96, 0x0C, 0xC2, 0x07, 0x5A, +0x30, 0x56, 0xE1, 0xE1, 0xEF, 0xAC, 0x4F, 0x4C, 0xDE, 0x31, 0x9B, 0x6A, 0x14, 0xD2, 0xE2, 0xCE, +0x93, 0xA4, 0x44, 0x53, 0x51, 0xCC, 0xBF, 0x7D, 0xCC, 0x02, 0xFC, 0x05, 0x09, 0x0F, 0x34, 0xD9, +0xDE, 0x32, 0x82, 0x72, 0x24, 0x26, 0x1E, 0x05, 0xE3, 0xC4, 0x4B, 0x99, 0x8C, 0x3D, 0x0D, 0xF5, +0xF4, 0xF3, 0x68, 0x08, 0xC9, 0xEB, 0x05, 0x38, 0x1F, 0x78, 0xFB, 0xB2, 0xDE, 0xB8, 0xCD, 0x63, +0x87, 0x89, 0x2B, 0xB2, 0x9D, 0xB2, 0x44, 0xD0, 0x34, 0xA1, 0xC6, 0x16, 0x93, 0x8F, 0x1A, 0x9D, +0xEC, 0x30, 0x57, 0x8E, 0x18, 0xDC, 0x64, 0x09, 0xF6, 0x22, 0x2D, 0x5A, 0x56, 0xDF, 0xC6, 0x10, +0x44, 0x09, 0x20, 0x45, 0x6C, 0xA2, 0x84, 0x95, 0xEC, 0x42, 0x34, 0x10, 0xB4, 0xCB, 0x3E, 0x98, +0x41, 0x7B, 0xBC, 0xFA, 0x4F, 0x94, 0x8B, 0x4C, 0x00, 0xA1, 0x33, 0x65, 0xA0, 0xC0, 0xA7, 0xA8, +0x46, 0x86, 0x73, 0x63, 0xF8, 0xB2, 0xD3, 0x8C, 0x80, 0xA0, 0x80, 0x70, 0xBF, 0xA9, 0xEB, 0xAC, +0x81, 0xCE, 0x1D, 0x80, 0x9D, 0xB5, 0xE9, 0xB6, 0x13, 0x1C, 0xC7, 0xA0, 0x0D, 0xD0, 0x93, 0xD8, +0x6D, 0xF6, 0x05, 0x6F, 0x71, 0x9F, 0x9D, 0xB1, 0x06, 0x61, 0x2F, 0xF8, 0xCD, 0xEC, 0xB6, 0x31, +0x9F, 0x13, 0xC7, 0xBC, 0x9C, 0x5A, 0xB6, 0x59, 0x67, 0xA0, 0xE1, 0x83, 0x36, 0x9E, 0x46, 0xBF, +0x3F, 0x4D, 0x1F, 0xEA, 0xE0, 0x58, 0xC1, 0x9B, 0x2F, 0xD9, 0xB5, 0x7A, 0xAD, 0x67, 0x8A, 0x67, +0x72, 0x78, 0xB3, 0xB6, 0xE9, 0x19, 0xCB, 0x37, 0xF8, 0xCC, 0x1F, 0x35, 0x87, 0x66, 0xA7, 0xD9, +0xE1, 0x0D, 0x02, 0x28, 0xB6, 0x84, 0xC8, 0x11, 0x2F, 0x3E, 0x1B, 0xF5, 0xCB, 0x87, 0xB7, 0x11, +0xDE, 0xC0, 0x7D, 0xC1, 0x2E, 0xD5, 0x6B, 0xF4, 0xA1, 0xC1, 0xC3, 0x3F, 0xE6, 0xB8, 0xD4, 0x2C, +0xD2, 0x8C, 0x24, 0x46, 0x7C, 0x1E, 0x10, 0x45, 0xC5, 0x9A, 0xFF, 0x20, 0x23, 0x85, 0xCB, 0x0E, +0xE4, 0x09, 0x34, 0xF7, 0xBA, 0x0A, 0x54, 0x3C, 0x2D, 0x88, 0xE0, 0xC8, 0xC9, 0x4B, 0x18, 0xDE, +0xFE, 0x93, 0x18, 0x1E, 0xE8, 0xE3, 0x40, 0xAB, 0xEB, 0x1D, 0xFD, 0xA0, 0x4E, 0xAF, 0xBF, 0x03, +0x76, 0xA6, 0xF5, 0xC6, 0x41, 0xB7, 0xD1, 0x68, 0xFB, 0xA0, 0x33, 0x52, 0x6F, 0xF5, 0x44, 0x13, +0xF8, 0x43, 0xDB, 0xB0, 0x4E, 0xB2, 0xEF, 0xBF, 0x76, 0x17, 0x9E, 0x9F, 0xD7, 0xE0, 0x9D, 0xE5, +0x60, 0x26, 0xCE, 0x6B, 0x72, 0x0D, 0x83, 0x17, 0xC7, 0x4C, 0x35, 0xD1, 0xE9, 0x43, 0x8E, 0x62, +0xA4, 0x49, 0x9F, 0xFD, 0x82, 0x92, 0x5F, 0x2A, 0xF6, 0x79, 0xBD, 0x49, 0x70, 0xA6, 0xBC, 0x2E, +0xD6, 0xAE, 0x6E, 0x65, 0xE3, 0x88, 0x2A, 0x3A, 0x3E, 0xD7, 0x90, 0xD2, 0x7F, 0x22, 0x60, 0xF1, +0x1A, 0x29, 0x35, 0x87, 0x5E, 0xA6, 0x0C, 0x55, 0xD6, 0x57, 0xB9, 0xF5, 0x68, 0x7C, 0xA2, 0x39, +0x39, 0xC6, 0x8E, 0xD7, 0x9E, 0x97, 0x0B, 0xF0, 0xF1, 0x99, 0x88, 0xA8, 0xEC, 0x1A, 0x0E, 0x38, +0xC3, 0xD0, 0x0F, 0x03, 0xD0, 0xBC, 0x7C, 0x04, 0xB7, 0xA5, 0x0C, 0xC6, 0x47, 0xAB, 0x05, 0x00, +0xD2, 0x3B, 0xDE, 0x25, 0x58, 0x69, 0x14, 0x9C, 0x5B, 0x2F, 0x24, 0xDF, 0x4A, 0x4E, 0x51, 0x00, +0xD6, 0x34, 0xE7, 0x8A, 0x28, 0x03, 0xED, 0x1A, 0x61, 0xD0, 0x43, 0x20, 0x3E, 0x6C, 0x93, 0x42, +0x5E, 0xC6, 0xB0, 0x3E, 0x3D, 0xA4, 0x4F, 0xC4, 0xC2, 0xAC, 0xA1, 0x7C, 0x7A, 0x18, 0x7F, 0x2B, +0x19, 0x88, 0xF8, 0xDA, 0x43, 0x24, 0x42, 0x92, 0x2F, 0x6F, 0x22, 0xCB, 0x5B, 0x4C, 0x67, 0x14, +0x40, 0xC8, 0x1F, 0xA5, 0x60, 0xE2, 0x22, 0x25, 0xC5, 0x45, 0xB8, 0xB8, 0x10, 0x20, 0x1A, 0xE1, +0x16, 0xCF, 0xAD, 0x84, 0xF6, 0xFF, 0xEB, 0xF3, 0x88, 0xB3, 0xE5, 0x30, 0x97, 0x4E, 0x3E, 0x77, +0x21, 0xB1, 0x97, 0x0F, 0x10, 0xFB, 0x72, 0x15, 0x63, 0x6B, 0x39, 0x2C, 0xC7, 0x96, 0x98, 0xFB, +0x40, 0x80, 0x88, 0x2D, 0xF5, 0x0C, 0x89, 0x60, 0x25, 0x7C, 0x8B, 0x9C, 0x66, 0x38, 0xA6, 0x16, +0x7E, 0xBF, 0x3C, 0x24, 0x96, 0xBD, 0x82, 0xAD, 0xB0, 0xDC, 0x64, 0xCD, 0x24, 0x26, 0xC3, 0x39, +0x96, 0x42, 0xD0, 0xB0, 0xA5, 0x04, 0x1D, 0xD2, 0x91, 0x0B, 0x2D, 0x1A, 0xB1, 0xA2, 0x31, 0x3C, +0x2D, 0x25, 0xAC, 0xB0, 0x75, 0xE4, 0x38, 0x11, 0x02, 0x31, 0x47, 0x73, 0x9C, 0x9C, 0xD3, 0x63, +0x83, 0x17, 0xC6, 0x6C, 0x62, 0x88, 0x22, 0x37, 0x08, 0x59, 0x8A, 0xB5, 0x09, 0x1D, 0x84, 0xC1, +0x67, 0x91, 0x59, 0x48, 0x0A, 0x5B, 0xF3, 0xD4, 0xDF, 0xDB, 0x04, 0xE7, 0x43, 0xF8, 0x3E, 0x46, +0xDC, 0x83, 0xE8, 0x7A, 0x9A, 0xED, 0x2E, 0x09, 0xAE, 0x5F, 0x8A, 0xC7, 0x4D, 0xB4, 0x21, 0x81, +0x98, 0x4A, 0xD8, 0xAC, 0x16, 0xC6, 0xA0, 0x60, 0x6A, 0xF9, 0x30, 0x08, 0xC7, 0xEF, 0x8C, 0x92, +0x87, 0x7A, 0x98, 0x10, 0x0B, 0xD9, 0x4B, 0xCF, 0xF6, 0xC7, 0xC4, 0xC9, 0x60, 0x22, 0x59, 0x3E, +0xE4, 0x3C, 0xA6, 0x02, 0x51, 0xDE, 0x3C, 0x5A, 0x05, 0x11, 0x86, 0xB7, 0xBF, 0x5A, 0x29, 0xAA, +0x19, 0x28, 0x14, 0x64, 0x08, 0x16, 0xC9, 0x32, 0xE2, 0x35, 0x25, 0x4D, 0xD5, 0x64, 0x65, 0x8E, +0x46, 0x71, 0xCE, 0x5E, 0x19, 0xE5, 0xB3, 0xB5, 0xC2, 0x24, 0xCE, 0x12, 0x2B, 0xFB, 0x9D, 0x1F, +0x8A, 0x9D, 0x05, 0xEC, 0x0C, 0x0B, 0xC6, 0xC1, 0x83, 0xF3, 0xC3, 0x69, 0x30, 0xB3, 0x07, 0x0F, +0xFE, 0x17, 0x8F, 0xA0, 0x18, 0x01, 0x53, 0xAD, 0x00, 0x00 +}; + + +//File: index_ov3660.html.gz, Size: 8887 +#define index_ov3660_html_gz_len 8887 +const uint8_t index_ov3660_html_gz[] = { + 0x1F, 0x8B, 0x08, 0x08, 0xA3, 0xFA, 0x69, 0x5E, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x5F, + 0x6F, 0x76, 0x33, 0x36, 0x36, 0x30, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xED, 0x3D, 0x69, 0x73, + 0xDB, 0x46, 0xB2, 0xDF, 0xFD, 0x2B, 0x60, 0x24, 0x6B, 0x51, 0x65, 0x91, 0xE2, 0xAD, 0x23, 0x12, + 0xFD, 0x6C, 0x59, 0xB1, 0x53, 0x1B, 0x67, 0xBD, 0x71, 0xE2, 0x24, 0xB5, 0xB5, 0xE5, 0x80, 0xC4, + 0x90, 0x44, 0x0C, 0x02, 0x5C, 0x00, 0xD4, 0x91, 0x94, 0x7E, 0xC7, 0xFB, 0x41, 0xEF, 0x8F, 0xBD, + 0xEE, 0x39, 0x70, 0x71, 0x00, 0x0C, 0x00, 0x11, 0x52, 0xF2, 0x1E, 0x5D, 0x65, 0xE1, 0x98, 0xEE, + 0xE9, 0x7B, 0x7A, 0x7A, 0x06, 0xC0, 0xD9, 0x53, 0xD3, 0x9D, 0x05, 0xB7, 0x6B, 0xA2, 0x2D, 0x83, + 0x95, 0x3D, 0x79, 0x72, 0xC6, 0xFE, 0x68, 0xF0, 0x3B, 0x5B, 0x12, 0xC3, 0x64, 0x87, 0xF4, 0x74, + 0x45, 0x02, 0x43, 0x9B, 0x2D, 0x0D, 0xCF, 0x27, 0xC1, 0xB9, 0xBE, 0x09, 0xE6, 0xED, 0x63, 0x3D, + 0x7D, 0xDB, 0x31, 0x56, 0xE4, 0x5C, 0xBF, 0xB2, 0xC8, 0xF5, 0xDA, 0xF5, 0x02, 0x5D, 0x9B, 0xB9, + 0x4E, 0x40, 0x1C, 0x68, 0x7E, 0x6D, 0x99, 0xC1, 0xF2, 0xDC, 0x24, 0x57, 0xD6, 0x8C, 0xB4, 0xE9, + 0xC9, 0x81, 0xE5, 0x58, 0x81, 0x65, 0xD8, 0x6D, 0x7F, 0x66, 0xD8, 0xE4, 0xBC, 0x17, 0xC7, 0x15, + 0x58, 0x81, 0x4D, 0x26, 0x97, 0x1F, 0xDE, 0x0F, 0xFA, 0xDA, 0x3F, 0x3E, 0x0E, 0xC6, 0xE3, 0xEE, + 0xD9, 0x21, 0xBB, 0x16, 0xB5, 0xF1, 0x83, 0xDB, 0xF8, 0x39, 0xFE, 0xA6, 0xAE, 0x79, 0xAB, 0xFD, + 0x91, 0xB8, 0x84, 0xBF, 0x39, 0x10, 0xD1, 0x9E, 0x1B, 0x2B, 0xCB, 0xBE, 0x3D, 0xD5, 0x5E, 0x7A, + 0xD0, 0xE7, 0xC1, 0x5B, 0x62, 0x5F, 0x91, 0xC0, 0x9A, 0x19, 0x07, 0xBE, 0xE1, 0xF8, 0x6D, 0x9F, + 0x78, 0xD6, 0xFC, 0xAB, 0x2D, 0xC0, 0xA9, 0x31, 0xFB, 0xBC, 0xF0, 0xDC, 0x8D, 0x63, 0x9E, 0x6A, + 0x5F, 0xF4, 0x8E, 0xF1, 0xDF, 0x76, 0xA3, 0x99, 0x6B, 0xBB, 0x1E, 0xDC, 0xBF, 0xFC, 0x1A, 0xFF, + 0x6D, 0xDF, 0xA7, 0xBD, 0xFB, 0xD6, 0xEF, 0xE4, 0x54, 0xEB, 0x8D, 0xD7, 0x37, 0x89, 0xFB, 0x77, + 0x4F, 0x12, 0xA7, 0xCB, 0x7E, 0x16, 0xF5, 0x1C, 0xFE, 0x38, 0x1F, 0xDE, 0x27, 0xB3, 0xC0, 0x72, + 0x9D, 0xCE, 0xCA, 0xB0, 0x1C, 0x09, 0x26, 0xD3, 0xF2, 0xD7, 0xB6, 0x01, 0x32, 0x98, 0xDB, 0x24, + 0x17, 0xCF, 0x17, 0x2B, 0xE2, 0x6C, 0x0E, 0x0A, 0xB0, 0x21, 0x92, 0xB6, 0x69, 0x79, 0xAC, 0xD5, + 0x29, 0xCA, 0x61, 0xB3, 0x72, 0x0A, 0xD1, 0xE6, 0xD1, 0xE5, 0xB8, 0x0E, 0x91, 0x08, 0x10, 0x3B, + 0xBA, 0xF6, 0x8C, 0x35, 0x36, 0xC0, 0xBF, 0xDB, 0x4D, 0x56, 0x96, 0xC3, 0x8C, 0xEA, 0x54, 0x1B, + 0x0C, 0xBB, 0xEB, 0x9B, 0x02, 0x55, 0x0E, 0xC6, 0xF8, 0x6F, 0xBB, 0xD1, 0xDA, 0x30, 0x4D, 0xCB, + 0x59, 0x9C, 0x6A, 0xC7, 0x52, 0x14, 0xAE, 0x67, 0x12, 0xAF, 0xED, 0x19, 0xA6, 0xB5, 0xF1, 0x4F, + 0xB5, 0xA1, 0xAC, 0xCD, 0xCA, 0xF0, 0x16, 0x40, 0x4B, 0xE0, 0x02, 0xB1, 0xED, 0x9E, 0x94, 0x12, + 0xDE, 0xC4, 0xB3, 0x16, 0xCB, 0x00, 0x54, 0xBA, 0xD5, 0x26, 0x2D, 0x34, 0xEE, 0x42, 0x45, 0xFA, + 0xCC, 0x95, 0x9B, 0x5C, 0x6A, 0x86, 0x6D, 0x2D, 0x9C, 0xB6, 0x15, 0x90, 0x15, 0xB0, 0xE3, 0x07, + 0x1E, 0x09, 0x66, 0xCB, 0x3C, 0x52, 0xE6, 0xD6, 0x62, 0xE3, 0x11, 0x09, 0x21, 0xA1, 0xDC, 0x72, + 0x18, 0x86, 0x9B, 0xDB, 0xB7, 0xDA, 0xD7, 0x64, 0xFA, 0xD9, 0x0A, 0xDA, 0x5C, 0x26, 0x53, 0x32, + 0x77, 0x3D, 0x22, 0x6D, 0x29, 0x5A, 0xD8, 0xEE, 0xEC, 0x73, 0xDB, 0x0F, 0x0C, 0x2F, 0x50, 0x41, + 0x68, 0xCC, 0x03, 0xE2, 0x15, 0xE3, 0x23, 0x68, 0x15, 0xC5, 0xD8, 0xB2, 0xBB, 0xE5, 0x0D, 0x2C, + 0xC7, 0xB6, 0x1C, 0xA2, 0x4E, 0x5E, 0x56, 0xBF, 0x49, 0x74, 0xAC, 0x95, 0x82, 0x62, 0xAC, 0xD5, + 0x22, 0xCF, 0x4A, 0x28, 0xAF, 0xDB, 0x9D, 0x71, 0xBF, 0xE9, 0x75, 0xBB, 0x7F, 0xDB, 0xBE, 0xB9, + 0x24, 0xCC, 0x4C, 0x8D, 0x4D, 0xE0, 0xD6, 0xF7, 0x88, 0x2D, 0xB7, 0x4A, 0xF1, 0xF1, 0x5F, 0x2B, + 0x62, 0x5A, 0x86, 0xD6, 0x8A, 0xB9, 0xF3, 0x71, 0x17, 0x6C, 0x6A, 0x5F, 0x33, 0x1C, 0x53, 0x6B, + 0xB9, 0x9E, 0x05, 0x8E, 0x60, 0xD0, 0x70, 0x63, 0xC3, 0x15, 0x18, 0x38, 0xD6, 0x64, 0x5F, 0xC2, + 0x72, 0x8E, 0xCF, 0xC4, 0x25, 0x22, 0x77, 0x1B, 0xFC, 0x29, 0x84, 0x1C, 0xFC, 0x15, 0x3A, 0x90, + 0x84, 0x47, 0x8A, 0x3E, 0x4F, 0x5F, 0x71, 0x0A, 0xB3, 0x74, 0x86, 0xBF, 0x95, 0x71, 0xD3, 0xCE, + 0xD5, 0x9D, 0x68, 0x24, 0x74, 0x08, 0xC3, 0xEC, 0xAC, 0x05, 0x4D, 0xAF, 0x96, 0x5A, 0x5B, 0xC3, + 0x28, 0xB9, 0x2F, 0x87, 0xE1, 0x48, 0xE5, 0x2A, 0xC7, 0x5F, 0xDC, 0x28, 0x4A, 0xB0, 0x2B, 0x67, + 0x35, 0x8A, 0x1D, 0xEC, 0x9F, 0xCC, 0x86, 0x18, 0x27, 0x99, 0x51, 0x04, 0x7F, 0xEA, 0x91, 0x24, + 0x42, 0x56, 0x18, 0x4D, 0x24, 0x88, 0xB3, 0x23, 0xCA, 0x16, 0xDE, 0x2C, 0xEF, 0x96, 0x60, 0xCD, + 0x27, 0x41, 0x35, 0xBA, 0x48, 0x10, 0xE7, 0xD1, 0x50, 0x18, 0x65, 0xF0, 0x77, 0xA7, 0x90, 0x6F, + 0x7C, 0x31, 0xDD, 0x04, 0x81, 0xEB, 0xF8, 0xB5, 0x86, 0xA8, 0x2C, 0x3F, 0xFB, 0x6D, 0xE3, 0x07, + 0xD6, 0xFC, 0xB6, 0xCD, 0x5D, 0x1A, 0xFC, 0x6C, 0x6D, 0x40, 0x0A, 0x39, 0x25, 0xC1, 0x35, 0x21, + 0xF9, 0xE9, 0x86, 0x63, 0x5C, 0x41, 0xDC, 0x59, 0x2C, 0x6C, 0x99, 0xED, 0xCD, 0x36, 0x9E, 0x8F, + 0x79, 0xDB, 0xDA, 0xB5, 0x00, 0xB1, 0xB7, 0xDD, 0x71, 0xD2, 0x07, 0x15, 0x3B, 0x6A, 0xCF, 0xA6, + 0x92, 0xBE, 0xDC, 0x4D, 0x80, 0x32, 0x96, 0x6A, 0xC2, 0x05, 0x76, 0xAC, 0xE0, 0x56, 0x7A, 0x8F, + 0x7B, 0xA2, 0xE4, 0x8E, 0x70, 0xC1, 0xDC, 0x61, 0x21, 0x49, 0xD7, 0xE9, 0x6C, 0x49, 0x66, 0x9F, + 0x89, 0xF9, 0xBC, 0x30, 0x0D, 0x2B, 0x4A, 0x0F, 0x3B, 0x96, 0xB3, 0xDE, 0x04, 0x6D, 0x4C, 0xA7, + 0xD6, 0x3B, 0xD1, 0x39, 0x35, 0x48, 0xC1, 0x62, 0xBF, 0x9F, 0x97, 0x54, 0x8C, 0xD6, 0x37, 0xF9, + 0x42, 0x88, 0x13, 0x3B, 0xB1, 0x8D, 0x29, 0xB1, 0xF3, 0x48, 0xE6, 0xCE, 0x90, 0x11, 0x76, 0x79, + 0xAC, 0xCA, 0xCE, 0xDD, 0x28, 0x65, 0xD1, 0xE0, 0x35, 0x3C, 0xFA, 0x9B, 0xB2, 0x1C, 0xE9, 0xF1, + 0x41, 0xE2, 0x92, 0x4F, 0x6C, 0x70, 0xB0, 0xAC, 0xD4, 0x1B, 0xDA, 0x5C, 0x03, 0x0D, 0xB9, 0x1D, + 0x78, 0x86, 0xB3, 0x20, 0x10, 0x0B, 0x6E, 0x0E, 0xC4, 0x61, 0xFE, 0xC4, 0x40, 0x89, 0x7D, 0x0C, + 0xD5, 0xA3, 0xFC, 0x89, 0x08, 0x0B, 0x08, 0x07, 0x5A, 0x87, 0x1D, 0x54, 0xC8, 0x4A, 0x62, 0xFA, + 0xCD, 0x25, 0xA4, 0x27, 0xB5, 0x0E, 0x96, 0x98, 0x48, 0x3D, 0x27, 0x69, 0x5B, 0xD2, 0x44, 0xBF, + 0x30, 0x34, 0x88, 0x29, 0xDF, 0x7C, 0x5E, 0x34, 0x69, 0x9C, 0xCF, 0x07, 0xDD, 0xC1, 0xB0, 0x30, + 0x73, 0x92, 0x72, 0x99, 0x9A, 0x38, 0x4A, 0x42, 0x47, 0x18, 0x56, 0x72, 0x8D, 0xC0, 0x37, 0xAE, + 0xA4, 0x49, 0xBB, 0xEB, 0x5B, 0x6C, 0xE6, 0x66, 0x4C, 0x7D, 0x98, 0xBB, 0x05, 0x92, 0xA9, 0x17, + 0x37, 0xF4, 0xBE, 0x94, 0x3E, 0x9A, 0xD2, 0x49, 0x5D, 0x40, 0x88, 0x57, 0x4E, 0x76, 0x42, 0x03, + 0xF2, 0x26, 0x31, 0x05, 0x4B, 0x93, 0xCA, 0x80, 0xDC, 0x04, 0x6D, 0x93, 0xCC, 0x5C, 0x8F, 0x65, + 0x83, 0x19, 0x33, 0xC7, 0x94, 0x22, 0x8B, 0x2D, 0xF6, 0x74, 0xE9, 0x5E, 0x11, 0x4F, 0x22, 0xAC, + 0x94, 0x52, 0x87, 0x27, 0x43, 0x53, 0x01, 0x9B, 0x01, 0xC3, 0xA3, 0x54, 0xF6, 0x49, 0x74, 0xFD, + 0xDE, 0xAC, 0x9F, 0xEB, 0xC7, 0x0C, 0x5D, 0x07, 0x7C, 0xC6, 0x98, 0xDA, 0xC4, 0xCC, 0x19, 0xCD, + 0x4C, 0x32, 0x37, 0x36, 0x76, 0x50, 0x60, 0x95, 0x46, 0x17, 0xFF, 0xE5, 0xF5, 0x48, 0xC3, 0xD0, + 0xBF, 0xB0, 0x2E, 0x74, 0x4E, 0x03, 0xC7, 0xBF, 0x25, 0x7D, 0x8A, 0x54, 0xC3, 0x58, 0xAF, 0x89, + 0x01, 0xAD, 0x66, 0x24, 0x4B, 0x0F, 0x4A, 0x53, 0x0C, 0x79, 0x9C, 0x57, 0x9A, 0xB7, 0x17, 0x3A, + 0x6C, 0x98, 0x3C, 0x96, 0xE2, 0xF9, 0x74, 0xEE, 0xCE, 0x36, 0xB2, 0xAC, 0x46, 0xCD, 0xF1, 0xB6, + 0xF1, 0x9D, 0x0A, 0x91, 0xF9, 0xB6, 0x45, 0xDD, 0x7F, 0xE3, 0x38, 0xA8, 0xD1, 0x76, 0xE0, 0x01, + 0x9B, 0x92, 0x8E, 0xD4, 0x04, 0x57, 0x29, 0x86, 0x25, 0x04, 0x9B, 0x55, 0xBB, 0x4A, 0x85, 0x29, + 0x49, 0x38, 0x0D, 0x23, 0xAD, 0x06, 0x31, 0xC4, 0x32, 0x05, 0xAA, 0x7A, 0x72, 0x09, 0x96, 0x9B, + 0x95, 0x2C, 0x8F, 0x12, 0x9D, 0xF5, 0x60, 0xD0, 0x67, 0xDD, 0x79, 0x8B, 0xA9, 0xD1, 0xEA, 0x1E, + 0x74, 0x0F, 0x06, 0xF0, 0x9F, 0x64, 0x3E, 0x93, 0x6F, 0x5C, 0x5C, 0xBC, 0x19, 0x96, 0x97, 0x0A, + 0xD1, 0xC5, 0x65, 0xA5, 0xAC, 0x60, 0x5F, 0xA8, 0x0B, 0x75, 0x4F, 0x4A, 0xD6, 0x97, 0x7A, 0x9D, + 0x82, 0x71, 0x38, 0xC3, 0xA4, 0xCB, 0x1B, 0xA2, 0xC4, 0x5A, 0xCA, 0xAA, 0x78, 0xE5, 0xFE, 0xDE, + 0x66, 0x49, 0xC8, 0xFF, 0x79, 0x6B, 0x8F, 0x89, 0xE2, 0x2F, 0x6D, 0xE9, 0xA5, 0xE5, 0xE2, 0x3F, + 0xB4, 0x6D, 0x74, 0xB3, 0xB5, 0xDE, 0xE6, 0x59, 0x1F, 0x50, 0xE8, 0xC0, 0x1C, 0xD4, 0x83, 0xC9, + 0x68, 0x66, 0x66, 0x18, 0x6B, 0x53, 0x41, 0x06, 0x73, 0xCB, 0xB6, 0xDB, 0xB6, 0x7B, 0x5D, 0x9C, + 0x89, 0xE4, 0x5B, 0xF2, 0x96, 0x9D, 0x16, 0x9B, 0x7C, 0x55, 0x6A, 0x37, 0x10, 0xB9, 0xFE, 0x14, + 0xD4, 0xFE, 0xB5, 0x1D, 0x2E, 0xD7, 0x35, 0xAA, 0x0D, 0x14, 0x15, 0xEC, 0xB1, 0x5E, 0x47, 0x4A, + 0xA6, 0xC4, 0x32, 0xC1, 0xFC, 0x69, 0xCF, 0xB5, 0x15, 0xCC, 0x96, 0x15, 0xA6, 0x9E, 0xD1, 0xC4, + 0xC8, 0x23, 0xB6, 0x81, 0x19, 0x7C, 0xA5, 0x0A, 0x45, 0xE1, 0xF4, 0x2D, 0x0E, 0xAE, 0xC2, 0x09, + 0x15, 0xDD, 0xE3, 0xA9, 0x2E, 0x75, 0x58, 0xEE, 0x90, 0x1D, 0xAB, 0xE5, 0x66, 0x5D, 0x90, 0xEE, + 0x27, 0x3D, 0x43, 0xDE, 0xA8, 0x44, 0x44, 0x17, 0x41, 0x7B, 0xE1, 0x91, 0x5B, 0x05, 0x66, 0x0E, + 0xF8, 0xDF, 0x53, 0x56, 0x3F, 0xAE, 0x5E, 0x2A, 0xA1, 0x03, 0x00, 0xB7, 0xA2, 0xCE, 0xD0, 0x57, + 0xE8, 0x3A, 0xBB, 0x4B, 0x15, 0x7B, 0x0C, 0xAB, 0xA3, 0xBA, 0xAE, 0x10, 0x6E, 0x72, 0x86, 0x50, + 0xB9, 0xA9, 0x8A, 0xD1, 0x57, 0x3E, 0x9F, 0x27, 0xF3, 0x20, 0x63, 0xF1, 0x87, 0xE6, 0xA9, 0x83, + 0xFC, 0xE8, 0xD6, 0x8E, 0x55, 0x53, 0x0A, 0x23, 0x47, 0x58, 0xC4, 0xCC, 0xB6, 0x3E, 0x29, 0x66, + 0x8C, 0x9E, 0xA5, 0x91, 0x67, 0xAB, 0x44, 0xA4, 0xCF, 0x54, 0xCD, 0xD0, 0x66, 0xC5, 0x87, 0x7C, + 0x50, 0x0F, 0xF9, 0xB9, 0xD5, 0x1F, 0x4B, 0xD7, 0x56, 0x72, 0x1A, 0xE7, 0x91, 0x96, 0x59, 0x05, + 0xDC, 0x1E, 0xB2, 0x32, 0x27, 0xC8, 0xF1, 0x58, 0x24, 0x55, 0x54, 0xBE, 0x57, 0xE6, 0x45, 0x98, + 0xED, 0x4A, 0x56, 0xAE, 0xB1, 0x5B, 0x2B, 0x03, 0xD2, 0x5E, 0x34, 0x57, 0x03, 0x30, 0xCA, 0xF4, + 0xA7, 0x62, 0xEE, 0xB1, 0x1A, 0x6B, 0x6F, 0xDC, 0x2D, 0xE8, 0x72, 0x66, 0xBB, 0x7E, 0xCD, 0x02, + 0x58, 0x76, 0xFD, 0x4B, 0x7A, 0x47, 0x69, 0xE8, 0xCE, 0xF5, 0xA9, 0x7C, 0x77, 0x4C, 0xC9, 0xBC, + 0xD7, 0x95, 0x46, 0xDA, 0xDC, 0x2A, 0x25, 0xAD, 0xA0, 0xD1, 0xF5, 0xCB, 0x53, 0x6D, 0x46, 0xE4, + 0x61, 0x34, 0x59, 0xA8, 0x53, 0x29, 0x95, 0xE6, 0xEA, 0x61, 0x69, 0x99, 0x26, 0xC9, 0xAD, 0x05, + 0xE3, 0x9C, 0x57, 0x31, 0x79, 0x40, 0xFA, 0x65, 0x45, 0xA9, 0x9D, 0x38, 0x45, 0xEE, 0xB6, 0x86, + 0xDE, 0xAE, 0x3D, 0x86, 0x0F, 0x34, 0x59, 0x95, 0xF4, 0x64, 0x2A, 0x92, 0x4B, 0xAA, 0xD4, 0xB9, + 0xC3, 0x5A, 0x2B, 0x8A, 0x0C, 0xE4, 0x80, 0xAD, 0xB6, 0xA3, 0x79, 0x8A, 0x2A, 0xBA, 0x90, 0xD2, + 0xE1, 0x6B, 0x4B, 0x7C, 0x19, 0xB0, 0x9D, 0xB5, 0xBA, 0x72, 0x8F, 0x4B, 0x6D, 0xD4, 0x02, 0xD2, + 0xFD, 0x66, 0x8A, 0xE6, 0x81, 0x32, 0xA3, 0x1C, 0x22, 0xC3, 0x21, 0x46, 0x6C, 0xAE, 0x4A, 0xB6, + 0x2A, 0xEB, 0x1C, 0xE1, 0xF9, 0xD9, 0x61, 0x6C, 0x3B, 0xDC, 0xD9, 0x61, 0xB4, 0x73, 0xEF, 0x0C, + 0xF7, 0xC4, 0xC5, 0x77, 0xCD, 0xF1, 0x8E, 0x66, 0xB6, 0xE1, 0xFB, 0xE7, 0x3A, 0xEE, 0xED, 0xD2, + 0x93, 0x9B, 0xE8, 0xCE, 0x4C, 0xEB, 0x4A, 0xB3, 0xCC, 0x73, 0xDD, 0x76, 0x17, 0x6E, 0xEA, 0x1E, + 0xBD, 0xCF, 0xD4, 0x0C, 0x03, 0xD9, 0xB9, 0x9E, 0x58, 0x60, 0xD4, 0x29, 0x54, 0x74, 0x49, 0x9F, + 0x3C, 0xFB, 0xE2, 0xE4, 0xE8, 0x68, 0xFC, 0xD5, 0x33, 0x67, 0xEA, 0xAF, 0xF9, 0xFF, 0x3F, 0xB0, + 0xF5, 0x58, 0xB6, 0xA9, 0x0F, 0xC6, 0xB6, 0x20, 0x00, 0xDB, 0xF3, 0xCF, 0x0E, 0x29, 0xD2, 0x14, + 0x21, 0x87, 0x40, 0x49, 0x06, 0x6D, 0x3C, 0xDF, 0x91, 0x91, 0x27, 0x9A, 0xF8, 0x30, 0x84, 0x4F, + 0x0D, 0x4F, 0xD2, 0x84, 0x36, 0x63, 0xD9, 0x34, 0x8D, 0x25, 0x3A, 0x55, 0xCA, 0xD4, 0xBD, 0x49, + 0x73, 0x40, 0x99, 0xE2, 0x1A, 0xE3, 0xAD, 0x88, 0x99, 0x85, 0x10, 0xC0, 0x28, 0x38, 0xAE, 0xAE, + 0x42, 0x1B, 0x69, 0xA3, 0x84, 0x0A, 0xB0, 0xF1, 0xCD, 0xCC, 0xFE, 0x2C, 0x94, 0xAF, 0x0B, 0xA5, + 0x38, 0x6E, 0xC0, 0x62, 0x65, 0x46, 0x57, 0x09, 0x56, 0x39, 0x4C, 0x6C, 0xDD, 0x90, 0x71, 0x01, + 0xA2, 0x6D, 0x53, 0xEC, 0xEC, 0x5A, 0x3E, 0x26, 0x8A, 0x2D, 0xA6, 0x57, 0x01, 0xAC, 0x4F, 0x7E, + 0xBE, 0xF8, 0xF6, 0xEF, 0xDA, 0xBB, 0xB7, 0xBF, 0x4B, 0x35, 0x54, 0x44, 0x14, 0x06, 0x69, 0x85, + 0x9E, 0x29, 0x18, 0xD3, 0x87, 0x90, 0x89, 0xCE, 0x35, 0x43, 0x31, 0xE0, 0x70, 0x6F, 0x13, 0x67, + 0x11, 0x2C, 0xCF, 0xF5, 0x9E, 0x8E, 0x7B, 0x5A, 0xC4, 0x59, 0x5F, 0xD7, 0x30, 0x80, 0xD3, 0x83, + 0x2B, 0xC3, 0xDE, 0xE0, 0x51, 0x57, 0x85, 0xD7, 0x6D, 0xD3, 0x92, 0x36, 0xE3, 0x91, 0x25, 0x94, + 0x71, 0x2C, 0x12, 0x27, 0xA5, 0xAC, 0x4F, 0x3E, 0x90, 0xE0, 0xEC, 0x90, 0xDD, 0x2A, 0xD0, 0x5A, + 0x7E, 0xDF, 0xE0, 0xC9, 0xCC, 0x1C, 0xF2, 0x4C, 0x28, 0x4F, 0xF1, 0x73, 0xCF, 0x58, 0x11, 0x94, + 0x8A, 0x92, 0xE6, 0xE3, 0x5A, 0x0F, 0x21, 0xF5, 0xC9, 0xF7, 0x84, 0x66, 0x44, 0x40, 0x86, 0x92, + 0xE2, 0xCF, 0x78, 0x92, 0x9A, 0xE8, 0x3F, 0xB4, 0x67, 0xBE, 0x28, 0xD5, 0x36, 0x98, 0x99, 0x2B, + 0xC8, 0xFD, 0x69, 0xBB, 0xAD, 0x0D, 0xDE, 0xBD, 0xD7, 0xDA, 0x6D, 0x85, 0xC6, 0xEE, 0x9A, 0xBA, + 0x13, 0xD7, 0x7F, 0xEF, 0x48, 0x9F, 0xFC, 0xF3, 0xE7, 0x37, 0x2F, 0x5B, 0xFD, 0xEE, 0xF0, 0xF8, + 0xA6, 0x37, 0x1A, 0x0F, 0xF7, 0xCF, 0x0E, 0x59, 0x93, 0xF2, 0xB8, 0xC6, 0xFA, 0xE4, 0x3D, 0x12, + 0xD2, 0x3A, 0x1E, 0x0F, 0xEB, 0xE2, 0x1A, 0x21, 0xAE, 0xB7, 0xAF, 0x5B, 0x47, 0xFD, 0xEE, 0x4D, + 0xAF, 0x7F, 0xDC, 0xAD, 0x81, 0x6A, 0xA8, 0x4F, 0xBE, 0x06, 0x4C, 0xBD, 0x13, 0x44, 0xD5, 0x2D, + 0x87, 0x0A, 0x45, 0xDB, 0xAF, 0x28, 0xDA, 0x81, 0x3E, 0xF9, 0x11, 0x45, 0x0B, 0x39, 0x37, 0xF2, + 0xD0, 0xAD, 0xC3, 0x43, 0x1F, 0x5C, 0x86, 0xE2, 0x02, 0x51, 0x00, 0x13, 0xFD, 0x3A, 0xA2, 0xED, + 0xE9, 0x13, 0x14, 0x07, 0x62, 0x02, 0xE9, 0xD6, 0x40, 0x04, 0xB1, 0x83, 0xD2, 0x04, 0xE4, 0xDC, + 0x1C, 0x8D, 0x8F, 0xAB, 0x63, 0x3A, 0x01, 0xEE, 0x3E, 0x02, 0xA6, 0x63, 0x10, 0xD4, 0xB8, 0x8E, + 0x9C, 0x8E, 0xF5, 0x09, 0xE2, 0x19, 0x0F, 0xBB, 0x37, 0xC3, 0x3A, 0x36, 0x03, 0x5E, 0xF1, 0x16, + 0x11, 0x01, 0x92, 0x9B, 0x41, 0x1D, 0x19, 0x81, 0x4B, 0x5C, 0x7C, 0xF3, 0x75, 0x6B, 0x08, 0x8C, + 0xF5, 0x4F, 0xC6, 0xD5, 0xF1, 0x80, 0x3B, 0xFC, 0x13, 0x09, 0x02, 0x62, 0x6E, 0xFA, 0xC3, 0x1A, + 0x04, 0x81, 0x33, 0x00, 0x3C, 0xE2, 0xA8, 0x8C, 0x02, 0xEC, 0xFA, 0x2D, 0x25, 0x06, 0x11, 0xF5, + 0x8E, 0x6A, 0x70, 0x05, 0x56, 0xFD, 0x4F, 0x14, 0x0F, 0x20, 0xB9, 0xE9, 0x0D, 0xEB, 0xD8, 0x34, + 0x20, 0xA2, 0x24, 0x81, 0xAF, 0xA1, 0xAB, 0x55, 0xC7, 0x04, 0x36, 0x7D, 0x32, 0xBE, 0x39, 0x19, + 0xAB, 0x21, 0xC0, 0xE1, 0x07, 0x43, 0x79, 0xDE, 0x00, 0x95, 0x3F, 0x7E, 0xE5, 0x8D, 0x4D, 0xFF, + 0xD9, 0xC0, 0x94, 0x33, 0xB8, 0x2D, 0x3D, 0x32, 0x71, 0x38, 0x90, 0x09, 0x3B, 0x50, 0x1B, 0x94, + 0x62, 0x94, 0x84, 0xBB, 0x9F, 0xF4, 0xC9, 0x50, 0x61, 0xF0, 0x4F, 0x64, 0x87, 0x14, 0x36, 0x41, + 0x3F, 0xCD, 0x48, 0xD0, 0xF2, 0x30, 0x17, 0x01, 0x97, 0x18, 0xE8, 0xB1, 0x08, 0x52, 0x69, 0xD4, + 0x93, 0xD0, 0x6A, 0xDC, 0xE8, 0x93, 0xF1, 0xA0, 0x30, 0x5B, 0xA8, 0xAE, 0x8C, 0x29, 0x2D, 0x6E, + 0x38, 0xC4, 0xF7, 0x4B, 0xEB, 0x23, 0x02, 0xD5, 0x27, 0xAF, 0xC2, 0xE3, 0x3A, 0x5A, 0x69, 0x17, + 0x71, 0x4A, 0x61, 0x33, 0xD4, 0x12, 0x23, 0x87, 0x69, 0xA6, 0x3D, 0xE0, 0xAA, 0x89, 0x34, 0x73, + 0xBF, 0x8A, 0xD9, 0xA5, 0x5E, 0x70, 0x6E, 0xE3, 0x19, 0x7E, 0x50, 0x5A, 0x2B, 0x02, 0x10, 0x22, + 0x34, 0x3F, 0x7A, 0x30, 0x8D, 0x84, 0xA4, 0xFC, 0x05, 0xF4, 0xE1, 0x1B, 0xC1, 0x86, 0xED, 0x33, + 0x2B, 0xAD, 0x91, 0x08, 0x14, 0xF2, 0x81, 0xF0, 0xB8, 0x96, 0x56, 0xEA, 0x84, 0xAF, 0x18, 0x39, + 0x5C, 0x2F, 0x22, 0x84, 0x0D, 0x77, 0xA4, 0x97, 0x22, 0x6A, 0x6B, 0xE9, 0x65, 0x69, 0x78, 0xEB, + 0x4A, 0xE1, 0x2B, 0x84, 0x04, 0xAD, 0x88, 0xC3, 0x07, 0x73, 0x95, 0x88, 0x98, 0xBF, 0x80, 0xAF, + 0x98, 0xC4, 0x71, 0x2D, 0xBF, 0xFC, 0xD4, 0x93, 0xC3, 0xE9, 0x93, 0xD7, 0xA4, 0xFD, 0x1D, 0x1E, + 0xD5, 0x51, 0xC7, 0xCB, 0x4D, 0xE0, 0xD6, 0x50, 0x88, 0xA0, 0x85, 0xA9, 0xA3, 0xCB, 0xB5, 0x71, + 0xBC, 0x23, 0x6D, 0x1C, 0xEF, 0x50, 0x1B, 0x06, 0xF9, 0x64, 0x93, 0x2B, 0x62, 0x97, 0x56, 0x87, + 0x00, 0xD4, 0x27, 0x97, 0x37, 0x6B, 0xD7, 0xC7, 0xA7, 0x77, 0xBE, 0xC5, 0xF3, 0x5A, 0x4E, 0x32, + 0xAA, 0xA1, 0x93, 0x90, 0x20, 0xEE, 0x23, 0x23, 0xAE, 0x95, 0xD1, 0x8E, 0xB4, 0x52, 0x44, 0x6B, + 0x1D, 0xAD, 0x2C, 0x0C, 0xCB, 0x99, 0x11, 0xCB, 0xC6, 0x27, 0x09, 0xCA, 0x2A, 0x26, 0x06, 0xAB, + 0x4F, 0xDE, 0x44, 0x27, 0x75, 0x14, 0xD3, 0xAD, 0xA1, 0x97, 0x38, 0x3D, 0x49, 0x7F, 0x19, 0xC1, + 0xAC, 0x7C, 0x47, 0xBA, 0xE9, 0xF5, 0x76, 0x39, 0xAA, 0xAC, 0xC9, 0xCC, 0x32, 0xEC, 0x4F, 0x64, + 0x3E, 0x87, 0x69, 0x50, 0xF9, 0xA1, 0x25, 0x01, 0x0E, 0xE3, 0x0B, 0x3B, 0xD7, 0x2E, 0xE9, 0x79, + 0xE9, 0x62, 0x5A, 0x0A, 0x5D, 0xF5, 0x8A, 0x5A, 0x7A, 0x4E, 0xC8, 0x97, 0x95, 0x09, 0xAD, 0x61, + 0xB2, 0x23, 0x7D, 0xF2, 0x9D, 0x1B, 0xD2, 0x59, 0x7D, 0xDA, 0xFA, 0x1D, 0x59, 0xD0, 0x55, 0xDB, + 0x3A, 0x73, 0xE8, 0x37, 0x9E, 0x71, 0x4B, 0x5F, 0x0B, 0x50, 0x67, 0x4A, 0xFF, 0x3D, 0x31, 0xB5, + 0x1F, 0x2C, 0xA7, 0x3A, 0x33, 0x43, 0x24, 0x84, 0x10, 0xA7, 0x1E, 0x96, 0x11, 0x4C, 0x91, 0xE0, + 0xA0, 0x1E, 0x92, 0x31, 0x16, 0x98, 0xD7, 0x96, 0xF1, 0x18, 0x26, 0xF1, 0xC6, 0xF5, 0xB4, 0xFC, + 0x80, 0x72, 0x3D, 0x85, 0x71, 0xF9, 0xA7, 0x57, 0xDA, 0x25, 0xDD, 0x67, 0x5C, 0x3A, 0x5C, 0xB1, + 0x2D, 0x50, 0x2A, 0x86, 0x1E, 0xAD, 0x23, 0x60, 0x9F, 0x5B, 0x0B, 0x3C, 0x72, 0x07, 0x52, 0x5D, + 0xE4, 0x91, 0xB0, 0x27, 0x08, 0xA4, 0x3B, 0x46, 0xF4, 0x18, 0xB7, 0x6A, 0x3C, 0xEE, 0x30, 0x15, + 0x9B, 0x5D, 0x97, 0x4F, 0xC3, 0x66, 0xD7, 0xA0, 0x26, 0xF3, 0x0A, 0xB7, 0xA0, 0x9B, 0x1A, 0xE8, + 0xAB, 0x11, 0x45, 0x61, 0xAF, 0x0F, 0xA3, 0x28, 0xCA, 0xEF, 0x43, 0x2B, 0x0A, 0xAC, 0xE5, 0x13, + 0x8E, 0xA3, 0x55, 0x9C, 0x8A, 0x02, 0xEA, 0x93, 0x77, 0x86, 0xB3, 0x81, 0x41, 0xA6, 0x29, 0x85, + 0x85, 0x1D, 0x3F, 0x98, 0x7B, 0x71, 0xBE, 0x1F, 0x5A, 0x75, 0x40, 0xC8, 0xCA, 0x35, 0xCB, 0x4F, + 0x77, 0x38, 0x1C, 0x0B, 0x89, 0xEF, 0xE0, 0xA8, 0x74, 0x62, 0x20, 0x30, 0xEC, 0x38, 0x23, 0x60, + 0x53, 0xA9, 0xEA, 0xC9, 0xC0, 0x87, 0x8D, 0xE3, 0xDC, 0xD6, 0xC9, 0x04, 0x2E, 0x6C, 0x77, 0x63, + 0x56, 0xC7, 0x00, 0x69, 0xC0, 0x3F, 0xE6, 0x73, 0x6B, 0x56, 0x3D, 0x91, 0x80, 0x24, 0xE0, 0xAD, + 0xBB, 0x52, 0x84, 0xDF, 0xF1, 0xC0, 0x4B, 0x66, 0x15, 0x66, 0x72, 0x33, 0xD0, 0xE2, 0xE5, 0x45, + 0xA3, 0x03, 0x2F, 0xF4, 0xF9, 0x40, 0x91, 0x01, 0xB9, 0x7D, 0xE8, 0xA0, 0x00, 0x44, 0x7C, 0xA2, + 0xC6, 0x53, 0x45, 0x59, 0x0C, 0x32, 0x8C, 0xE8, 0x62, 0xFA, 0xFD, 0x50, 0xF3, 0xBB, 0x88, 0xA2, + 0xE4, 0xEC, 0xAE, 0x37, 0x1A, 0x8C, 0xC3, 0xE9, 0xDD, 0xA0, 0x7F, 0xBF, 0x13, 0x3C, 0x44, 0xBE, + 0x5B, 0xFD, 0xF4, 0xAB, 0xA8, 0x06, 0xA2, 0xD1, 0x77, 0xB8, 0xCE, 0x50, 0x22, 0x60, 0xD7, 0x77, + 0xA4, 0xFE, 0xC3, 0x79, 0x52, 0xFF, 0x11, 0xB8, 0xD2, 0xA2, 0x42, 0xC4, 0x5B, 0x60, 0xC4, 0x7B, + 0x73, 0xD1, 0x8C, 0x86, 0x16, 0x0F, 0x16, 0xEA, 0x16, 0x0F, 0x1A, 0xEA, 0x34, 0xBE, 0x43, 0x4D, + 0x48, 0xA1, 0x62, 0x06, 0xCB, 0x01, 0x59, 0x2D, 0xAB, 0x4E, 0x90, 0xEB, 0xDD, 0xD4, 0x89, 0x72, + 0x82, 0x8C, 0x64, 0x90, 0x1B, 0x47, 0xAB, 0x22, 0xA3, 0xFB, 0x5D, 0xD6, 0x1D, 0x16, 0x51, 0x5B, + 0xC7, 0x69, 0x3C, 0xE3, 0xFA, 0xD3, 0x62, 0x65, 0x94, 0x56, 0x06, 0x87, 0x03, 0x5D, 0xBC, 0x7B, + 0xD9, 0x64, 0xBA, 0x20, 0xFA, 0x7D, 0x18, 0x3F, 0x0A, 0xB9, 0x7E, 0xE8, 0x58, 0x67, 0x13, 0xA7, + 0x7C, 0xB0, 0x43, 0x20, 0x7D, 0xF2, 0x2D, 0x71, 0x7C, 0xED, 0xC2, 0xF5, 0xF8, 0xBB, 0x18, 0x1B, + 0xD1, 0x1A, 0xED, 0xF9, 0x61, 0x54, 0xC6, 0x98, 0x7E, 0x68, 0x7D, 0x2D, 0x57, 0x96, 0xE7, 0xB9, + 0x5E, 0x69, 0x95, 0x71, 0x38, 0x98, 0x56, 0xB4, 0xDF, 0xD1, 0xA3, 0x46, 0xD4, 0x25, 0x7A, 0x7D, + 0x18, 0x8D, 0x85, 0x3C, 0x3F, 0xB4, 0xD2, 0xAE, 0xE6, 0xB6, 0xB5, 0x2E, 0xAD, 0x32, 0x0A, 0xA5, + 0x4F, 0x3E, 0xB6, 0xBF, 0x86, 0xBF, 0x8D, 0xA8, 0x8B, 0xF5, 0xF8, 0x30, 0xCA, 0xE2, 0xDC, 0x3E, + 0xB4, 0xAA, 0xA6, 0xEB, 0xF2, 0xE1, 0x10, 0x60, 0xF4, 0xC9, 0xAB, 0xF7, 0xCD, 0xE4, 0x7E, 0xD8, + 0x99, 0xA2, 0x86, 0x6A, 0xE9, 0x83, 0x32, 0xF5, 0xD0, 0xDA, 0xB8, 0xAE, 0xA0, 0x8D, 0x6B, 0x24, + 0xFC, 0xA7, 0x86, 0xB4, 0x71, 0xAD, 0xAE, 0x8D, 0x7B, 0xF6, 0x97, 0xEB, 0xC7, 0xA0, 0x1F, 0xFA, + 0xB0, 0xDF, 0xD4, 0x28, 0x3F, 0x1C, 0x09, 0x40, 0xDC, 0x34, 0x06, 0x47, 0xDA, 0x2B, 0xA3, 0x99, + 0x01, 0x29, 0xEC, 0xB7, 0x09, 0x17, 0x8A, 0x98, 0x7C, 0x68, 0x3D, 0xD9, 0xC4, 0xAC, 0x90, 0xE4, + 0x99, 0x9F, 0xF0, 0xC9, 0x39, 0x7C, 0xA2, 0xFC, 0x16, 0xB2, 0xBD, 0xCB, 0xD7, 0xDA, 0x37, 0xE2, + 0xF4, 0xA1, 0x0A, 0x43, 0x49, 0x9A, 0x92, 0xF3, 0xA6, 0xFE, 0x68, 0x57, 0xDB, 0x32, 0x00, 0xF3, + 0x0E, 0x75, 0x33, 0x37, 0x66, 0xE4, 0x93, 0x49, 0x82, 0x2A, 0xEB, 0xFE, 0x31, 0x58, 0x7D, 0xF2, + 0x35, 0x9C, 0x68, 0xAF, 0xE9, 0x49, 0x53, 0xE9, 0x78, 0xBC, 0xFF, 0x26, 0x3C, 0x2A, 0xC1, 0xEF, + 0x43, 0x3B, 0x15, 0x25, 0x06, 0x26, 0x3F, 0xEE, 0xC2, 0xA9, 0xF4, 0xDC, 0x53, 0x02, 0x9C, 0xAB, + 0xEF, 0x7B, 0x76, 0xDE, 0xAC, 0x02, 0x23, 0x22, 0x1A, 0xD3, 0x61, 0x8C, 0xEF, 0x26, 0xD4, 0x18, + 0x7F, 0xF8, 0x91, 0xBF, 0x36, 0xB8, 0x48, 0x53, 0xFC, 0x21, 0x3C, 0xBA, 0xDD, 0x88, 0x04, 0x6D, + 0x3F, 0xB0, 0x6C, 0x5B, 0x9F, 0xBC, 0x21, 0x81, 0xF6, 0x01, 0x0F, 0x15, 0x9F, 0xBA, 0x8B, 0x61, + 0x11, 0xCF, 0xDC, 0x06, 0x1E, 0x31, 0x56, 0xFA, 0xE4, 0x03, 0xBE, 0x50, 0x19, 0x70, 0xE1, 0x59, + 0x79, 0x64, 0x54, 0x88, 0xC4, 0xF1, 0x5C, 0x20, 0x2A, 0x54, 0x12, 0x7F, 0x51, 0xA3, 0xAE, 0x89, + 0xA3, 0xD8, 0xB5, 0xC9, 0x25, 0x6D, 0xAC, 0xA1, 0x95, 0x15, 0x77, 0x17, 0x7F, 0x1C, 0x30, 0xDF, + 0x39, 0xE8, 0x03, 0xC0, 0xF8, 0x44, 0x6F, 0xF2, 0x7D, 0xEB, 0xA0, 0x56, 0xF6, 0x7C, 0xFF, 0xE4, + 0xCC, 0x5F, 0x1B, 0x8E, 0x68, 0x46, 0x1F, 0x7E, 0xBF, 0xE6, 0x4F, 0x33, 0x4F, 0x5D, 0xDB, 0xFC, + 0x2A, 0xB6, 0xF0, 0xFF, 0x21, 0x7C, 0x2C, 0x17, 0x41, 0xC0, 0x2E, 0x04, 0x86, 0x02, 0xE5, 0x2E, + 0x3D, 0x81, 0x9E, 0x3D, 0x41, 0x8D, 0x6F, 0xEB, 0xCA, 0xD1, 0x6E, 0xC6, 0x93, 0xC4, 0x1E, 0x59, + 0x84, 0x92, 0x94, 0x3D, 0x61, 0x2E, 0x7D, 0xAE, 0xF8, 0x7B, 0xB2, 0xB0, 0x7C, 0xA0, 0x51, 0x03, + 0xBB, 0x38, 0xA4, 0xCF, 0x62, 0x32, 0x5B, 0x56, 0x7B, 0xCE, 0x37, 0xDE, 0x25, 0x7F, 0x4D, 0x81, + 0xF4, 0xF1, 0xED, 0x52, 0xA9, 0x63, 0xFA, 0x59, 0xEB, 0x24, 0xC6, 0x22, 0xAB, 0x7F, 0xDA, 0x6E, + 0x2F, 0x87, 0xF8, 0x54, 0xA9, 0x26, 0x58, 0x3B, 0x3B, 0x5C, 0x0E, 0x8B, 0x9E, 0xDA, 0x2B, 0x7C, + 0x24, 0x18, 0x38, 0xAD, 0xFC, 0x44, 0x30, 0x4A, 0x69, 0x02, 0xD4, 0x1C, 0x68, 0xEF, 0x0C, 0xFF, + 0xF3, 0x81, 0xF6, 0x11, 0x87, 0xF8, 0x06, 0x1F, 0x0C, 0x46, 0xDA, 0x0D, 0xD3, 0xF4, 0x32, 0x1F, + 0x0E, 0x1E, 0x26, 0x1E, 0x0E, 0x1E, 0x8B, 0x87, 0x83, 0xA3, 0x95, 0xAA, 0xEE, 0xCD, 0xA0, 0xDB, + 0x3D, 0x56, 0x61, 0x5D, 0xF1, 0x01, 0xE1, 0x7B, 0xE1, 0x69, 0x05, 0xD2, 0x54, 0xE4, 0x69, 0x28, + 0x78, 0x8A, 0x6D, 0xD8, 0xBF, 0x99, 0xCF, 0x1F, 0x1B, 0x47, 0x7C, 0xC9, 0xB0, 0x3A, 0x4B, 0xDD, + 0x7E, 0xD3, 0x4F, 0x71, 0x53, 0xE3, 0xBE, 0xAF, 0x87, 0xB8, 0x69, 0x93, 0x74, 0x34, 0x1C, 0xE5, + 0x06, 0x43, 0x0A, 0xC2, 0x9C, 0xFE, 0xCD, 0x7D, 0x3A, 0xFD, 0xA2, 0x86, 0xD3, 0x2F, 0xB6, 0x9C, + 0xBE, 0x41, 0x6F, 0x17, 0x84, 0xFF, 0xD5, 0x3C, 0x5E, 0xF0, 0x55, 0xC2, 0xEB, 0xA5, 0x7C, 0x75, + 0xBB, 0xF7, 0xEA, 0xF7, 0x85, 0x4E, 0x12, 0x1A, 0xC3, 0x9B, 0xFB, 0x74, 0x92, 0x0C, 0xD3, 0xAD, + 0x64, 0xA7, 0x3C, 0xEC, 0x4C, 0x9A, 0x19, 0x97, 0x68, 0x36, 0x15, 0x57, 0x28, 0xEF, 0x1D, 0x1F, + 0xD7, 0x1D, 0x0C, 0x79, 0xEA, 0x74, 0x1F, 0xEA, 0x51, 0x7F, 0x61, 0x44, 0x66, 0x93, 0xFB, 0x49, + 0xCC, 0xD6, 0xB1, 0x14, 0x57, 0x39, 0x31, 0x7B, 0xFF, 0xED, 0xB7, 0xE5, 0x72, 0xB1, 0x78, 0x2F, + 0x8F, 0x24, 0x17, 0xCB, 0x2D, 0x53, 0xDF, 0xAE, 0xE1, 0x06, 0x52, 0x5D, 0xC9, 0x74, 0x23, 0x70, + 0x7D, 0xF2, 0x8A, 0x1E, 0x6B, 0x31, 0x89, 0x95, 0x32, 0x5E, 0xE5, 0x59, 0x27, 0x05, 0x8C, 0xD5, + 0xB1, 0x23, 0x12, 0xD2, 0xBA, 0x51, 0xC4, 0x95, 0x53, 0xBB, 0x8E, 0xB1, 0xA7, 0xCE, 0x54, 0x6D, + 0x9F, 0xA0, 0x4D, 0x8A, 0x52, 0xE1, 0xD5, 0xC6, 0xAE, 0xAC, 0x36, 0x0E, 0xAB, 0x4F, 0xDE, 0xC1, + 0x64, 0xDC, 0x5A, 0xDB, 0x16, 0xCC, 0x3C, 0x5A, 0x5D, 0xAD, 0xAD, 0x0D, 0x7A, 0xFB, 0x0D, 0x8E, + 0x91, 0x82, 0x8C, 0x92, 0x6F, 0xCB, 0xE9, 0x45, 0x0F, 0xB3, 0x0D, 0xEE, 0xE9, 0x75, 0x39, 0x75, + 0x15, 0xE2, 0xB9, 0x6E, 0x50, 0x59, 0x1B, 0x02, 0x18, 0x12, 0x15, 0x38, 0xD2, 0x22, 0x9D, 0xA8, + 0xAB, 0x22, 0xB6, 0xB5, 0x36, 0xC2, 0xA6, 0xA6, 0x0E, 0xA5, 0x8D, 0xB4, 0xB8, 0x3F, 0x45, 0x75, + 0x07, 0xAA, 0x04, 0x6B, 0x4F, 0x9F, 0xF4, 0x4B, 0x60, 0x28, 0xDE, 0x87, 0xCA, 0x5A, 0xD5, 0x77, + 0x22, 0xFF, 0xB6, 0x7A, 0xEC, 0xE3, 0xB0, 0x90, 0x76, 0xDF, 0x42, 0xAA, 0xBB, 0xD2, 0x5E, 0x43, + 0x5F, 0xD4, 0x89, 0x7A, 0xA3, 0x26, 0x9D, 0x48, 0x90, 0x51, 0xDD, 0x89, 0x7A, 0x8F, 0xC3, 0x87, + 0x50, 0x1F, 0x6B, 0x8F, 0x54, 0xD6, 0x07, 0x87, 0xD5, 0x27, 0xEF, 0x3D, 0x82, 0xCA, 0xA8, 0xE4, + 0x3D, 0x21, 0x92, 0x6A, 0xCE, 0x73, 0x0F, 0x8E, 0xD2, 0xEB, 0x8C, 0xEA, 0xE1, 0xE8, 0x97, 0x73, + 0x36, 0x09, 0x86, 0x81, 0x3C, 0x08, 0x0C, 0x1E, 0xA7, 0x0B, 0x13, 0xDB, 0x1C, 0x55, 0x77, 0x62, + 0x01, 0x8D, 0xB3, 0x67, 0x38, 0xAC, 0x6C, 0x38, 0x31, 0x44, 0x8F, 0x2A, 0xEE, 0xD6, 0xC4, 0x70, + 0x1F, 0xC6, 0x34, 0xE9, 0x97, 0x32, 0xE9, 0x66, 0x4C, 0x67, 0x8D, 0x2F, 0x17, 0x24, 0x6A, 0x7B, + 0x3E, 0x29, 0xB2, 0x78, 0xA4, 0x61, 0xB0, 0x10, 0x69, 0xE8, 0xEB, 0x05, 0xE9, 0x5E, 0xF7, 0x46, + 0x73, 0x5E, 0x41, 0xC0, 0xF6, 0x32, 0x4B, 0xF9, 0xAD, 0x01, 0x31, 0xE6, 0x64, 0x29, 0x70, 0xC8, + 0xEB, 0x63, 0xCB, 0x7F, 0x29, 0x61, 0x95, 0xC7, 0x0A, 0x0E, 0xCC, 0x55, 0x18, 0x0E, 0xDD, 0xCD, + 0xE6, 0xBF, 0x21, 0x15, 0x35, 0xC6, 0xEE, 0x06, 0x13, 0xE0, 0xD8, 0xB2, 0x11, 0x55, 0x00, 0x0B, + 0x9A, 0x01, 0x9B, 0xF1, 0x95, 0x58, 0x09, 0xCA, 0x6C, 0x72, 0x3F, 0xF3, 0xFC, 0x6B, 0xCB, 0x29, + 0x3F, 0xCF, 0xFF, 0xC9, 0x72, 0x4C, 0xF7, 0xBA, 0xDC, 0x54, 0x3F, 0xDE, 0xD1, 0x9F, 0x60, 0xAA, + 0x4F, 0x07, 0x4B, 0x5C, 0x2C, 0x6C, 0x7B, 0x44, 0xED, 0xA5, 0x33, 0x69, 0x21, 0x33, 0xE8, 0x1B, + 0x5C, 0x6A, 0x03, 0x14, 0xBE, 0x46, 0x97, 0x1E, 0x77, 0xED, 0x2F, 0x3F, 0x9F, 0xC6, 0x93, 0x5D, + 0x4E, 0x81, 0x9A, 0xC3, 0x0C, 0x25, 0x85, 0xC7, 0x07, 0xAF, 0xA5, 0xFE, 0xB2, 0xCD, 0xCF, 0xED, + 0x83, 0xF3, 0x73, 0x1F, 0x01, 0x99, 0x38, 0x66, 0x65, 0xCB, 0x42, 0xD8, 0xC8, 0xAE, 0x2E, 0x1D, + 0xB3, 0x51, 0xAB, 0x62, 0xBD, 0x57, 0xD6, 0x41, 0xBF, 0x7B, 0x74, 0xF2, 0xB8, 0xCC, 0x0A, 0x19, + 0xAA, 0x61, 0x54, 0xBD, 0xD1, 0xF0, 0xE8, 0xF1, 0xD8, 0x95, 0x3B, 0x9F, 0xB3, 0x15, 0xAE, 0x6A, + 0xA6, 0xC5, 0xC1, 0x6F, 0xE8, 0xA3, 0xB4, 0x3E, 0x69, 0x36, 0x5E, 0x85, 0x9D, 0xAB, 0xE9, 0x62, + 0x20, 0xD1, 0xC5, 0xF8, 0x71, 0x99, 0x16, 0xE7, 0x48, 0xD5, 0xBA, 0x24, 0x1C, 0xDD, 0x13, 0x43, + 0xF7, 0x61, 0x5A, 0x81, 0x1B, 0x18, 0x76, 0x65, 0xCB, 0x62, 0xD0, 0x60, 0x58, 0x3F, 0xE0, 0x81, + 0xF6, 0x01, 0xF8, 0x6C, 0xD4, 0xB8, 0x44, 0xFF, 0xD5, 0x03, 0xD7, 0xA0, 0xFB, 0xC8, 0xC6, 0x43, + 0xC6, 0x52, 0xAD, 0xD0, 0x35, 0x1E, 0x3E, 0x1E, 0xFB, 0x72, 0x37, 0x01, 0x5E, 0xAD, 0x1C, 0xBA, + 0x18, 0x38, 0x86, 0x2E, 0x7A, 0xD4, 0xBC, 0x89, 0x85, 0x14, 0xD4, 0x18, 0x1C, 0x87, 0x0F, 0xBF, + 0x7E, 0xFD, 0x8B, 0x84, 0xA7, 0x5A, 0x46, 0x36, 0x78, 0x2C, 0x41, 0x6C, 0x66, 0x28, 0xBF, 0x88, + 0x8D, 0x22, 0x8B, 0x67, 0xF3, 0x0C, 0x16, 0xE6, 0x70, 0xEC, 0xA0, 0xD1, 0x0A, 0x86, 0xE8, 0xFC, + 0xDE, 0x97, 0xEC, 0x42, 0xAE, 0x1E, 0x53, 0xBD, 0x62, 0x6A, 0x39, 0x4E, 0x55, 0x35, 0x71, 0x58, + 0x7D, 0xF2, 0x8A, 0x1D, 0x34, 0xBB, 0xB8, 0xCA, 0x3B, 0xBF, 0xFF, 0x95, 0x55, 0xC1, 0x55, 0xD3, + 0x6A, 0x4A, 0x15, 0x31, 0xBC, 0xF0, 0x4B, 0x11, 0x3A, 0xDF, 0xAD, 0x18, 0x7D, 0x39, 0xE2, 0xF1, + 0x94, 0x34, 0x16, 0xC6, 0x0A, 0x9F, 0x30, 0x2E, 0x5B, 0xD4, 0x78, 0x83, 0x60, 0xE5, 0x6A, 0x1A, + 0xC9, 0x9E, 0x1E, 0x77, 0x55, 0x63, 0x92, 0x7C, 0xB5, 0x24, 0x10, 0xDE, 0x9E, 0x5A, 0x86, 0x8F, + 0x4F, 0xE3, 0xC3, 0xB1, 0xF6, 0x0A, 0x8E, 0xB5, 0xF7, 0xF6, 0x26, 0x7C, 0x37, 0xAE, 0xCC, 0x21, + 0xE2, 0x3B, 0x9B, 0x22, 0x0C, 0x59, 0xDB, 0xD7, 0xE9, 0x86, 0x2E, 0xFE, 0x14, 0x16, 0x1C, 0xE3, + 0x3E, 0xA6, 0xD1, 0xF0, 0xB8, 0xAB, 0x6B, 0x2C, 0x2B, 0xE6, 0xCF, 0x90, 0xF8, 0x9F, 0xE9, 0x06, + 0xA7, 0x5E, 0x48, 0xA0, 0xCC, 0x01, 0xE2, 0xF4, 0x86, 0x04, 0x52, 0xFB, 0xAD, 0xB3, 0xEF, 0x68, + 0x5B, 0x22, 0x3D, 0x21, 0x8E, 0xAE, 0xD4, 0x10, 0x12, 0x2F, 0xC3, 0x64, 0xED, 0x55, 0x9E, 0x86, + 0x91, 0x0B, 0xA2, 0x27, 0x15, 0x04, 0xEE, 0xF3, 0xBA, 0x5F, 0x9E, 0xFA, 0x82, 0xA7, 0x9E, 0x1A, + 0x4F, 0xFD, 0x1A, 0x3C, 0xF5, 0x1B, 0xE2, 0x69, 0x20, 0x78, 0xEA, 0xAB, 0xF1, 0x34, 0xA8, 0xC1, + 0xD3, 0xA0, 0x21, 0x9E, 0x86, 0x82, 0xA7, 0x81, 0x1A, 0x4F, 0xC3, 0x1A, 0x3C, 0x0D, 0x1B, 0xE2, + 0x69, 0x24, 0x78, 0x1A, 0xAA, 0xF1, 0x34, 0xAA, 0xC1, 0xD3, 0xA8, 0x21, 0x9E, 0xC6, 0x82, 0xA7, + 0x91, 0x1A, 0x4F, 0xE3, 0x1A, 0x3C, 0x8D, 0x1B, 0xE2, 0xE9, 0x48, 0xF0, 0x34, 0x56, 0xE3, 0xE9, + 0xA8, 0x06, 0x4F, 0x47, 0x0D, 0xF1, 0x74, 0x2C, 0x78, 0x3A, 0x52, 0xE3, 0xE9, 0xB8, 0x06, 0x4F, + 0xC7, 0x0D, 0xF1, 0x74, 0x22, 0x78, 0x3A, 0x56, 0xE3, 0xE9, 0xA4, 0x06, 0x4F, 0x27, 0x0D, 0xF1, + 0x84, 0x8B, 0x72, 0x8C, 0xA9, 0x13, 0xC5, 0x41, 0xB7, 0x5B, 0x83, 0x2B, 0xA3, 0x29, 0xAE, 0xC2, + 0x54, 0xA2, 0xA7, 0x9A, 0x4B, 0xD4, 0x49, 0x26, 0xA6, 0x4D, 0xB1, 0x15, 0x65, 0x13, 0x8A, 0xE9, + 0x44, 0xAF, 0x4E, 0x3E, 0x31, 0x6B, 0x8A, 0xAD, 0x30, 0xA1, 0xE8, 0x29, 0x66, 0x14, 0xBD, 0x3A, + 0x29, 0x85, 0xD9, 0x14, 0x5B, 0x61, 0x4E, 0xD1, 0x53, 0x4C, 0x2A, 0x7A, 0x75, 0xB2, 0x0A, 0xD2, + 0x14, 0x5B, 0x61, 0x5A, 0xD1, 0x53, 0xCC, 0x2B, 0x7A, 0x75, 0x12, 0x8B, 0x79, 0x53, 0x6C, 0x85, + 0x99, 0x45, 0x4F, 0x31, 0xB5, 0xE8, 0xD5, 0xC8, 0x2D, 0x4E, 0xE4, 0x13, 0xB1, 0x7B, 0x65, 0x8B, + 0x04, 0x7C, 0x8A, 0x1C, 0x4D, 0xDA, 0x94, 0x1E, 0x3D, 0xE1, 0x40, 0xF8, 0x6C, 0x14, 0x13, 0xC8, + 0x85, 0xEB, 0xCC, 0xAD, 0x45, 0x58, 0x64, 0x78, 0x34, 0x4F, 0x49, 0xF8, 0xB1, 0xB7, 0xF2, 0x2A, + 0x17, 0x1A, 0x3E, 0xBC, 0xBE, 0x2C, 0x57, 0x66, 0x88, 0xF7, 0xF2, 0x27, 0x2A, 0x32, 0x00, 0xD9, + 0xFD, 0xF8, 0x27, 0x02, 0x94, 0xEA, 0x0A, 0x14, 0xA8, 0x4C, 0x45, 0x61, 0x14, 0xAF, 0x28, 0x8C, + 0x95, 0x2B, 0x0A, 0x8C, 0xB8, 0xDD, 0xD4, 0x12, 0x00, 0xF7, 0x80, 0x7D, 0xD7, 0x40, 0x9D, 0xE9, + 0x41, 0x75, 0xA6, 0x47, 0x65, 0x98, 0x1E, 0x54, 0x61, 0xBA, 0xC2, 0xD3, 0x8D, 0x8A, 0x72, 0x02, + 0x7A, 0xBF, 0xB6, 0x6E, 0x88, 0xA9, 0xFD, 0xA2, 0x2E, 0xAA, 0x5E, 0x75, 0x51, 0x1D, 0x95, 0x11, + 0x55, 0x6F, 0x87, 0xF6, 0x31, 0x12, 0x7C, 0xFF, 0xA8, 0xCE, 0xF7, 0xA8, 0x3A, 0xDF, 0x83, 0x32, + 0x7C, 0x8F, 0x76, 0xC8, 0xF7, 0x50, 0xF0, 0xFD, 0x51, 0x9D, 0xEF, 0x61, 0x75, 0xBE, 0x87, 0x65, + 0xF8, 0x1E, 0xEE, 0x90, 0xEF, 0x3E, 0x04, 0x9B, 0x1F, 0x3F, 0x6A, 0x3F, 0x2C, 0x3D, 0xE2, 0x2F, + 0x8B, 0x2B, 0x71, 0x0C, 0xA2, 0xEA, 0xD8, 0x3E, 0x6A, 0x60, 0xEE, 0x86, 0x14, 0x0E, 0xE2, 0x3C, + 0x15, 0xE6, 0xCD, 0x0C, 0x42, 0xE5, 0xC3, 0x41, 0x72, 0x9E, 0xE4, 0x33, 0xB7, 0x9E, 0x2A, 0x53, + 0xBB, 0x8B, 0x61, 0xC7, 0xFA, 0xE4, 0xED, 0xA6, 0xC4, 0xF8, 0x76, 0x5C, 0xDD, 0x9E, 0xD5, 0x2B, + 0xE6, 0x8C, 0xAE, 0x9D, 0xD9, 0xF3, 0x09, 0xE5, 0x19, 0xF2, 0x32, 0x5F, 0x41, 0xED, 0xD5, 0xAB, + 0x10, 0xA3, 0x06, 0xAA, 0xE4, 0x18, 0xE9, 0x8F, 0x18, 0x3B, 0x3F, 0x22, 0x43, 0x1A, 0x64, 0x2C, + 0x25, 0x06, 0xA3, 0xA3, 0x92, 0xDA, 0x3C, 0xAE, 0x18, 0x9D, 0x90, 0xC6, 0x9D, 0xA9, 0x13, 0xA7, + 0x1E, 0x28, 0x80, 0x8F, 0x15, 0x04, 0x30, 0xAE, 0x2E, 0x80, 0x52, 0x99, 0x0B, 0xD2, 0xB8, 0x3B, + 0x01, 0x74, 0x99, 0x00, 0x3E, 0x44, 0x6F, 0xA6, 0xCE, 0x31, 0xE8, 0x1A, 0x15, 0xA8, 0x51, 0x03, + 0x6B, 0x24, 0x18, 0x69, 0x7B, 0xC2, 0xA2, 0x81, 0xA3, 0x72, 0x0A, 0xED, 0x97, 0xCD, 0xAF, 0xE4, + 0xC5, 0x4F, 0x85, 0xFC, 0x7B, 0x97, 0x09, 0x56, 0xBF, 0x2B, 0x2C, 0xBA, 0xBC, 0x00, 0xBA, 0xD5, + 0x05, 0xD0, 0x2B, 0x25, 0x80, 0xEE, 0xE3, 0x4A, 0xC6, 0xC7, 0xDB, 0x1F, 0x13, 0x2E, 0x96, 0x56, + 0x59, 0xF7, 0x8F, 0x8D, 0x66, 0xFD, 0x32, 0xC2, 0xDA, 0xA9, 0xF7, 0x0F, 0x22, 0xCE, 0xB5, 0x5F, + 0xB4, 0xE4, 0xD6, 0xD7, 0xBC, 0x38, 0x50, 0xBD, 0x08, 0x38, 0x6A, 0x60, 0xBD, 0x0A, 0x29, 0x3C, + 0x91, 0x70, 0x56, 0x32, 0xC0, 0x9F, 0x54, 0x77, 0x87, 0x52, 0x1A, 0x46, 0x5A, 0x77, 0xA7, 0xE2, + 0x51, 0x42, 0x10, 0xEC, 0x43, 0xE6, 0x2A, 0x2A, 0xAE, 0x5E, 0x39, 0x1C, 0x35, 0xB0, 0xD4, 0x85, + 0x14, 0x1E, 0x4B, 0x38, 0x2B, 0xA9, 0xE2, 0xB2, 0x29, 0xE9, 0x71, 0xC5, 0xA9, 0x65, 0x6F, 0x97, + 0x39, 0x29, 0x56, 0xBB, 0x63, 0x82, 0x88, 0x7F, 0x65, 0x22, 0x4F, 0xC1, 0xD5, 0x2B, 0xDE, 0xA3, + 0x9A, 0xEB, 0xB3, 0xBB, 0x8B, 0xE4, 0x47, 0xB2, 0x4F, 0x90, 0x17, 0xDB, 0x41, 0xD9, 0x5C, 0xB6, + 0x5B, 0x71, 0xE0, 0xDB, 0x69, 0x2A, 0x0B, 0xBD, 0x43, 0xD6, 0xB3, 0xCD, 0x7D, 0x8E, 0x09, 0x54, + 0x5F, 0x79, 0x1B, 0x35, 0xB0, 0x3D, 0x04, 0x29, 0xEC, 0xEB, 0x93, 0x8F, 0x25, 0x99, 0xAA, 0x53, + 0x3F, 0xA8, 0xBC, 0x3F, 0xA4, 0xB9, 0xD2, 0xFB, 0x6C, 0x75, 0x53, 0xBE, 0xF4, 0x7E, 0xF1, 0xEE, + 0xE7, 0x72, 0xA5, 0xF7, 0x78, 0x2F, 0xCD, 0x95, 0xDE, 0xAB, 0xD9, 0x4C, 0xA9, 0x8D, 0xB2, 0xC0, + 0x18, 0xBE, 0x3F, 0x62, 0x66, 0xF9, 0xB4, 0x4B, 0x10, 0x8C, 0xF6, 0x5E, 0x9C, 0x86, 0x22, 0x8A, + 0x3D, 0xB1, 0x9F, 0x6C, 0x9F, 0x67, 0x3D, 0x83, 0x9C, 0xB0, 0xA0, 0xB6, 0x11, 0x76, 0xFB, 0x75, + 0x28, 0x9D, 0x31, 0xFF, 0x10, 0x57, 0x8D, 0x47, 0xEB, 0xB3, 0x5E, 0x1A, 0xD0, 0x39, 0x2A, 0x89, + 0x7B, 0xE7, 0x8F, 0xDC, 0x4F, 0x52, 0x8A, 0xEA, 0x51, 0xFD, 0xF4, 0xF0, 0x5C, 0xB9, 0x4E, 0x4E, + 0xC1, 0xCA, 0x44, 0xF3, 0x41, 0xBC, 0xD4, 0xA2, 0x1E, 0xCD, 0x19, 0x79, 0xBB, 0x89, 0xE6, 0x88, + 0x3B, 0xC1, 0x7B, 0x89, 0xAC, 0x86, 0xC1, 0x96, 0x13, 0x80, 0x7C, 0x13, 0x85, 0x82, 0x00, 0xB2, + 0x24, 0x70, 0x2F, 0x22, 0xE8, 0x53, 0x09, 0xF4, 0x53, 0xDA, 0xCF, 0x08, 0xFC, 0xB4, 0x7D, 0xD5, + 0xB8, 0x3F, 0x68, 0xA0, 0x36, 0x81, 0xE2, 0x4A, 0x70, 0x54, 0x52, 0xA7, 0xE5, 0x16, 0x07, 0x13, + 0x3A, 0x2D, 0x67, 0xD4, 0x3B, 0x5B, 0x1D, 0x04, 0xE4, 0x03, 0x2A, 0x80, 0x81, 0xB2, 0x4A, 0xAB, + 0x4F, 0x33, 0x07, 0x0D, 0xE4, 0x27, 0x28, 0xAD, 0x04, 0x47, 0x25, 0x55, 0x5A, 0x6E, 0xE9, 0x33, + 0xA1, 0x52, 0xF5, 0xF9, 0x25, 0x27, 0x72, 0x67, 0x2A, 0x1D, 0x52, 0x01, 0x0C, 0x95, 0x55, 0x5A, + 0x7D, 0xD6, 0x31, 0x68, 0x60, 0xF7, 0x2E, 0x4A, 0x2B, 0xC1, 0x51, 0x49, 0x95, 0x96, 0x5B, 0xB2, + 0x4B, 0xA8, 0x54, 0x7D, 0x3E, 0xC9, 0x89, 0xDC, 0x99, 0x4A, 0x47, 0x54, 0x00, 0x23, 0x65, 0x95, + 0x56, 0xAF, 0x14, 0x0C, 0x1A, 0x28, 0x06, 0xA1, 0xB4, 0x12, 0x1C, 0x95, 0x54, 0x69, 0xB9, 0xD5, + 0xE7, 0x84, 0x4A, 0xD5, 0xD7, 0x39, 0x38, 0x91, 0x3B, 0x53, 0xE9, 0x98, 0x0A, 0x60, 0xAC, 0xAC, + 0xD2, 0xEA, 0xFB, 0xAB, 0x06, 0x0D, 0xEC, 0xDD, 0x46, 0x69, 0x25, 0x38, 0x2A, 0xA9, 0xD2, 0x72, + 0xA5, 0xDB, 0x84, 0x4A, 0xD5, 0x57, 0x6E, 0x38, 0x91, 0x3B, 0x53, 0xE9, 0x11, 0x15, 0xC0, 0x91, + 0xB2, 0x4A, 0xAB, 0x6F, 0x5D, 0x1F, 0x34, 0x50, 0xCF, 0x43, 0x69, 0x25, 0x38, 0x2A, 0xA9, 0xD2, + 0x72, 0x15, 0x9C, 0x84, 0x4A, 0xD5, 0xF7, 0x4E, 0x71, 0x22, 0x77, 0xA6, 0xD2, 0x63, 0x2A, 0x80, + 0x63, 0x65, 0x95, 0x56, 0xDF, 0xB9, 0x3F, 0x68, 0x60, 0xE7, 0x3E, 0x4A, 0x2B, 0xC1, 0x51, 0x49, + 0x95, 0x96, 0xAB, 0xCD, 0x26, 0x54, 0xAA, 0xBE, 0xDD, 0x89, 0x13, 0xB9, 0x33, 0x95, 0x9E, 0x50, + 0x01, 0x9C, 0x28, 0xAB, 0xB4, 0xFA, 0x96, 0x81, 0x41, 0x03, 0x9B, 0x5F, 0x50, 0x5A, 0xDD, 0x38, + 0x47, 0x25, 0x55, 0x5A, 0x6E, 0x81, 0x71, 0x90, 0xB1, 0xF5, 0x45, 0x41, 0xA5, 0x59, 0x0B, 0x8C, + 0x8F, 0xA0, 0x7E, 0x67, 0x5C, 0x4F, 0x2B, 0x7C, 0xFA, 0xE5, 0xE5, 0x4F, 0xAF, 0xB2, 0x0B, 0xFB, + 0x99, 0x55, 0xBC, 0x44, 0x5F, 0x8F, 0xBD, 0x8C, 0x17, 0x97, 0x17, 0x12, 0x0E, 0x5A, 0x66, 0x2F, + 0x4D, 0xD4, 0xB6, 0x98, 0xCF, 0xB7, 0x34, 0x06, 0x5C, 0xC2, 0xD2, 0x06, 0xC3, 0xAE, 0x3C, 0x69, + 0x29, 0xB0, 0x34, 0x4E, 0xE5, 0x6E, 0x82, 0x07, 0x22, 0x87, 0xB9, 0x38, 0xF2, 0xFE, 0xBD, 0xD2, + 0x9A, 0x0E, 0x03, 0x48, 0x86, 0x8F, 0x61, 0xF7, 0x44, 0x31, 0x7E, 0x80, 0x0C, 0xB2, 0x36, 0xC6, + 0xDF, 0x63, 0x00, 0x41, 0x1A, 0x07, 0x8C, 0xA9, 0x37, 0xCA, 0x4C, 0xA5, 0xAB, 0x00, 0xA5, 0x98, + 0xCA, 0xAA, 0xEC, 0xDC, 0x33, 0x53, 0x43, 0xC6, 0x54, 0x8E, 0x93, 0xA6, 0x98, 0x4A, 0xCF, 0x83, + 0x4B, 0x31, 0x95, 0x35, 0x11, 0x8E, 0x98, 0x7A, 0x0C, 0x81, 0x8E, 0xCC, 0x8C, 0xC5, 0xAC, 0x42, + 0xA8, 0xBB, 0xBC, 0x38, 0x7C, 0xF9, 0xE6, 0x42, 0xA3, 0x4B, 0x9A, 0xAE, 0x5D, 0x32, 0xE2, 0x25, + 0x3B, 0xFD, 0x53, 0xC5, 0x3C, 0x4A, 0x7A, 0x2C, 0xEA, 0xBD, 0xB9, 0x50, 0x0D, 0x78, 0x1C, 0xB2, + 0x4C, 0xC8, 0x1B, 0x75, 0x07, 0x55, 0x2A, 0x84, 0x21, 0x91, 0x3B, 0x0A, 0x7A, 0x14, 0x7D, 0x3F, + 0x92, 0xC1, 0x65, 0x39, 0x19, 0x94, 0xAA, 0x92, 0x26, 0x65, 0x50, 0x22, 0xEC, 0x0B, 0x22, 0x77, + 0x29, 0x03, 0x8C, 0x92, 0x97, 0x17, 0xDA, 0xFB, 0xBF, 0x6B, 0x97, 0x37, 0x6B, 0xD7, 0xDF, 0x78, + 0xA4, 0x30, 0xAA, 0x70, 0xB8, 0x64, 0x5C, 0x19, 0x8F, 0x46, 0x03, 0xD5, 0xC0, 0x32, 0xCA, 0x1E, + 0x02, 0xE6, 0xDD, 0x7B, 0x8C, 0x97, 0x94, 0xD0, 0x61, 0xC8, 0xE0, 0xF7, 0x04, 0x34, 0xAD, 0x14, + 0x37, 0x39, 0x60, 0x92, 0xC3, 0x5E, 0x17, 0xB7, 0x57, 0x2B, 0x32, 0x28, 0xCF, 0x28, 0x07, 0xF7, + 0x3A, 0x1C, 0x50, 0x2A, 0x47, 0x21, 0x7B, 0x1F, 0x7F, 0xF8, 0xA0, 0xC6, 0x58, 0xBA, 0x8E, 0x56, + 0x4E, 0x75, 0x59, 0x8F, 0x8C, 0xDE, 0xD3, 0xA0, 0x20, 0xBD, 0x71, 0x76, 0x08, 0xA1, 0x77, 0x1B, + 0x26, 0x43, 0x92, 0x67, 0x73, 0x6B, 0x01, 0x76, 0x2C, 0xEF, 0x83, 0x8A, 0x96, 0xBD, 0xEC, 0x14, + 0xBF, 0x51, 0xD9, 0x9E, 0x41, 0xF4, 0x07, 0x93, 0x40, 0xA7, 0x13, 0x02, 0x5F, 0x19, 0x0B, 0x12, + 0x5D, 0xD7, 0x58, 0x6C, 0xCF, 0x8B, 0xD9, 0x06, 0x43, 0x68, 0x5C, 0x11, 0xFE, 0x41, 0x4D, 0x6D, + 0xE9, 0x91, 0xF9, 0xB9, 0xFE, 0x45, 0x88, 0x93, 0x3F, 0x95, 0x87, 0x4D, 0x74, 0xCD, 0x74, 0xAF, + 0x1D, 0xDB, 0x35, 0x70, 0x3C, 0x30, 0xD6, 0x01, 0x50, 0xDA, 0xF9, 0x6D, 0x8D, 0x2F, 0xBE, 0x32, + 0xF0, 0x21, 0x2E, 0x23, 0xA7, 0x9F, 0x98, 0x55, 0xCC, 0x6C, 0xD7, 0x17, 0xB3, 0x39, 0x3C, 0x0C, + 0x3F, 0xC0, 0xF9, 0x3F, 0xFF, 0x5D, 0xB4, 0x83, 0xC0, 0x5A, 0x2D, 0x62, 0x02, 0xD0, 0x35, 0xDF, + 0x9B, 0x9D, 0xEB, 0x40, 0xA9, 0xE7, 0xFA, 0xBE, 0xEB, 0x59, 0x0B, 0x2B, 0x43, 0x3B, 0x59, 0xD2, + 0x3E, 0x94, 0x89, 0x3B, 0xD5, 0x58, 0xA2, 0xF8, 0x33, 0x7F, 0xE6, 0x59, 0xEB, 0x60, 0xF2, 0xC4, + 0x74, 0x67, 0x9B, 0x15, 0x71, 0x82, 0x8E, 0x61, 0x9A, 0x97, 0x57, 0x70, 0xF0, 0x2D, 0x7E, 0xAC, + 0x0D, 0x24, 0xDF, 0xDA, 0x7B, 0xFD, 0x8F, 0x77, 0x38, 0x3A, 0xE3, 0x35, 0x90, 0x17, 0x31, 0xF7, + 0x0E, 0xB4, 0xF9, 0xC6, 0x61, 0x03, 0x64, 0x8B, 0x60, 0xDB, 0x7D, 0xED, 0x0F, 0xC0, 0x78, 0x65, + 0x78, 0xDA, 0xD4, 0xF0, 0xC9, 0x5B, 0xD7, 0x0F, 0xB4, 0x73, 0x2D, 0xC4, 0x68, 0xBB, 0x33, 0xBA, + 0x9D, 0xA3, 0xC3, 0xF8, 0xE2, 0x2D, 0x19, 0xE3, 0x3F, 0x7A, 0x36, 0x34, 0x0D, 0xA1, 0x9E, 0x6B, + 0x7B, 0xA7, 0xC7, 0xBD, 0x3D, 0xB4, 0xDD, 0xB0, 0x8B, 0x39, 0x81, 0xE8, 0x0F, 0xED, 0x5A, 0x1B, + 0xCF, 0x3E, 0xD0, 0x66, 0xD3, 0xFD, 0x3F, 0x28, 0xF5, 0xF4, 0x32, 0x5E, 0xDB, 0xE7, 0xCC, 0x74, + 0x82, 0x25, 0x71, 0x5A, 0x11, 0x65, 0x1E, 0xF1, 0xD7, 0xAE, 0xE3, 0x13, 0x46, 0x1C, 0xFB, 0x59, + 0xF3, 0xE8, 0x7A, 0xC7, 0x0F, 0x8C, 0x60, 0xE3, 0x6B, 0x4F, 0xCF, 0xCF, 0xB5, 0x7E, 0xB7, 0x1B, + 0x6F, 0xA6, 0x41, 0x37, 0xE9, 0x76, 0x07, 0x5A, 0xEA, 0xC2, 0x0F, 0xE4, 0x26, 0xD8, 0xFF, 0x2A, + 0x84, 0xB9, 0xD3, 0x88, 0xED, 0x93, 0x04, 0x92, 0x10, 0x00, 0x5F, 0x27, 0xD7, 0xDA, 0x4F, 0x12, + 0xD8, 0x32, 0x8D, 0xC0, 0xD8, 0xFF, 0x23, 0xA1, 0x2F, 0xE8, 0x15, 0x28, 0x39, 0xD0, 0xE8, 0xAD, + 0xAF, 0x62, 0xB7, 0xEE, 0xF6, 0x3B, 0x20, 0x43, 0xE0, 0x37, 0x84, 0x26, 0x9E, 0x97, 0xA4, 0x98, + 0x42, 0xB7, 0x7B, 0x07, 0x1A, 0xDE, 0x49, 0xC2, 0xC6, 0x88, 0x7C, 0x22, 0xAE, 0x09, 0xA1, 0xE5, + 0xA3, 0x95, 0xA0, 0x64, 0xE8, 0xEE, 0x12, 0x2A, 0x82, 0x38, 0xF4, 0x3D, 0x59, 0x80, 0xC4, 0x16, + 0x07, 0x3C, 0x2C, 0x1D, 0xD0, 0x98, 0x74, 0xC0, 0xC2, 0x59, 0x4C, 0x6B, 0xE0, 0xD0, 0xBE, 0x6B, + 0x13, 0xB0, 0x89, 0x45, 0x6B, 0x8F, 0x7F, 0x0A, 0x14, 0xEC, 0x69, 0xAF, 0x7B, 0xB3, 0xF7, 0x1C, + 0xC0, 0x3B, 0x81, 0xFB, 0x21, 0xF0, 0x2C, 0x67, 0xD1, 0xEA, 0x8D, 0xF7, 0x23, 0x5C, 0xF4, 0x36, + 0x22, 0x4C, 0xDD, 0xA7, 0xD7, 0x69, 0x17, 0xE9, 0x1B, 0x2D, 0x7E, 0xFD, 0xF9, 0xDE, 0xFE, 0x1E, + 0x27, 0x9D, 0x9E, 0x83, 0xB1, 0xB5, 0xD8, 0xC1, 0x33, 0x4A, 0xE1, 0xBE, 0x76, 0x76, 0xC6, 0xBB, + 0x61, 0xAD, 0xF0, 0x22, 0x34, 0xA2, 0x7F, 0x52, 0xB7, 0x42, 0x43, 0xFC, 0xF5, 0xCB, 0x3F, 0x84, + 0xC5, 0xDE, 0x1D, 0x02, 0xD5, 0x2F, 0x30, 0x2E, 0x7F, 0xF9, 0x07, 0xFC, 0x7F, 0xF7, 0x8C, 0x86, + 0xE2, 0x2F, 0xFF, 0xC0, 0x3F, 0x77, 0xCF, 0xA0, 0x27, 0x38, 0xA6, 0xFD, 0xDD, 0xFD, 0x4A, 0xA5, + 0xB0, 0x2D, 0xBB, 0x45, 0xA6, 0xEC, 0x42, 0xA1, 0x95, 0xA6, 0x69, 0x91, 0x43, 0xD4, 0xAF, 0x91, + 0xF7, 0xB6, 0x66, 0xAE, 0x09, 0xCA, 0x09, 0xC0, 0x8E, 0x85, 0xCA, 0x6D, 0x50, 0x89, 0x10, 0x54, + 0x57, 0xA8, 0xDC, 0x9A, 0xD3, 0x96, 0x1A, 0x77, 0x94, 0xC8, 0x3C, 0x44, 0xCB, 0xB5, 0xE1, 0xF9, + 0xE4, 0x1B, 0x27, 0x68, 0x05, 0x09, 0x97, 0xC8, 0x90, 0xF8, 0x64, 0x92, 0x60, 0x01, 0x7F, 0x00, + 0x07, 0xED, 0xF6, 0xB8, 0xD2, 0x42, 0x53, 0x7B, 0x12, 0x5A, 0x61, 0x44, 0x29, 0xBB, 0x99, 0x61, + 0x85, 0x3F, 0xCF, 0xEC, 0xCF, 0xAD, 0x1B, 0xF8, 0x2F, 0x1D, 0x28, 0xB6, 0x44, 0x84, 0x8D, 0x5E, + 0xE0, 0x7F, 0x20, 0x17, 0xFC, 0x93, 0xA9, 0x1F, 0xC0, 0xFA, 0xDE, 0xB6, 0x5B, 0xEC, 0xB3, 0x5F, + 0xA0, 0x9A, 0x0D, 0x04, 0x21, 0xFF, 0x16, 0xC3, 0x81, 0xEB, 0x06, 0x9F, 0x0E, 0xB4, 0xB5, 0x07, + 0x84, 0xD1, 0x2F, 0x7D, 0xC0, 0x31, 0x20, 0x22, 0x0E, 0xFB, 0x5B, 0x48, 0xC1, 0xDA, 0xB6, 0x5F, + 0x30, 0xAC, 0x40, 0x02, 0x3B, 0x00, 0x4D, 0x6D, 0xD0, 0x62, 0xE0, 0xFF, 0xBB, 0x67, 0xD0, 0x09, + 0x1C, 0xC2, 0xFF, 0x77, 0xCF, 0xB0, 0x2B, 0xD4, 0x25, 0xF6, 0x78, 0xF7, 0x0C, 0x7A, 0x84, 0x13, + 0xF8, 0x1F, 0xDA, 0x60, 0xBF, 0xD8, 0x0A, 0xFF, 0xC2, 0x1D, 0xDA, 0x3F, 0xDE, 0xA4, 0x07, 0xEC, + 0x02, 0x3F, 0xCD, 0x63, 0x90, 0xBD, 0xE9, 0xBE, 0x45, 0xDF, 0x3C, 0xFE, 0xE9, 0x06, 0xD8, 0xA1, + 0x07, 0xB7, 0xE0, 0xF8, 0x8E, 0x89, 0xE7, 0xF8, 0xE7, 0x56, 0x98, 0x27, 0x5E, 0xE0, 0x47, 0x70, + 0x8D, 0xBE, 0x9D, 0x15, 0x2F, 0xB1, 0x03, 0x6C, 0x45, 0xDF, 0xA5, 0x49, 0x5B, 0xB1, 0x23, 0xB8, + 0xC6, 0xDF, 0xC0, 0x78, 0xA0, 0xF1, 0x77, 0xFC, 0x15, 0x0A, 0x27, 0x7A, 0x07, 0xDF, 0x0B, 0xFF, + 0x06, 0x19, 0x64, 0xA4, 0xA1, 0x54, 0xC2, 0xB3, 0xDB, 0xBB, 0x67, 0x04, 0xEF, 0x51, 0x22, 0xE1, + 0xF8, 0x96, 0x1F, 0xC3, 0x75, 0xA0, 0x0F, 0xEF, 0x08, 0x82, 0xE9, 0x85, 0xDB, 0xE8, 0x02, 0xB4, + 0x08, 0xF0, 0x3E, 0x27, 0x1E, 0xCE, 0x6E, 0xC3, 0x33, 0x84, 0xA6, 0xB0, 0x9C, 0x0D, 0x38, 0xBD, + 0x8D, 0x4E, 0xE1, 0x2E, 0xF2, 0x82, 0x0A, 0xE0, 0x3C, 0xDD, 0x3D, 0xE3, 0x3C, 0xA1, 0x16, 0xD9, + 0x51, 0x5A, 0xD4, 0x18, 0xF4, 0x02, 0x1E, 0x24, 0x5F, 0xB1, 0x1C, 0x24, 0x36, 0x3C, 0x42, 0x00, + 0xB8, 0xB4, 0x09, 0x1E, 0xBE, 0xBA, 0xFD, 0xC6, 0x6C, 0xED, 0xF1, 0x4F, 0xB7, 0xEE, 0x61, 0x88, + 0x8E, 0xC3, 0x74, 0x5C, 0x67, 0x66, 0x5B, 0x33, 0x8C, 0x04, 0xAD, 0x7D, 0xED, 0x7C, 0xC2, 0xC3, + 0x34, 0x7A, 0x2C, 0x34, 0x8F, 0x7B, 0x61, 0x26, 0x6A, 0x8F, 0x7F, 0x7C, 0x74, 0x6F, 0xBF, 0x43, + 0x1D, 0x8D, 0x3B, 0x13, 0xA2, 0xE0, 0x31, 0x46, 0x0D, 0x07, 0x36, 0x96, 0xE0, 0xD8, 0x0A, 0x07, + 0xB9, 0x48, 0x68, 0xEB, 0x18, 0x16, 0x8A, 0x26, 0x3E, 0x92, 0x74, 0x53, 0x83, 0x48, 0x4E, 0xD8, + 0x12, 0x11, 0xEA, 0x69, 0x3A, 0x42, 0x81, 0xAA, 0xBC, 0xA0, 0xB5, 0x77, 0xE9, 0x79, 0xAE, 0xF7, + 0xAF, 0xBD, 0xE7, 0xD8, 0xE8, 0xF9, 0xDE, 0xBF, 0x4F, 0xB5, 0xBD, 0xE7, 0xF1, 0x50, 0x75, 0x97, + 0x8E, 0x29, 0x4C, 0x63, 0x0B, 0x45, 0x8D, 0x2D, 0x62, 0x1A, 0x5B, 0xDC, 0xAF, 0xC6, 0xE2, 0x9F, + 0x8C, 0xAD, 0xA3, 0xB5, 0xF8, 0x27, 0x5A, 0x73, 0x34, 0x57, 0x08, 0xCF, 0x95, 0xC6, 0xB5, 0xB5, + 0x90, 0x69, 0xAB, 0x8A, 0x9A, 0xD8, 0x18, 0x0E, 0xDE, 0x43, 0xBC, 0xB7, 0x3F, 0xBC, 0xFB, 0x16, + 0xC7, 0x02, 0xB9, 0xCA, 0x42, 0x8D, 0xA5, 0xB3, 0x2D, 0x09, 0x06, 0x4C, 0x0E, 0x12, 0x23, 0x53, + 0x22, 0x49, 0x78, 0xBE, 0xA7, 0xB5, 0x28, 0x4A, 0x4C, 0x11, 0x0A, 0x0C, 0x81, 0x8F, 0x2C, 0x6A, + 0xBE, 0x8B, 0xA3, 0x89, 0x70, 0xDE, 0x08, 0x2A, 0xC7, 0x16, 0x10, 0x40, 0x49, 0x89, 0x0C, 0xF3, + 0x96, 0xC3, 0xC4, 0x06, 0xBD, 0xC6, 0x5D, 0x84, 0xFA, 0xAB, 0xAF, 0x1A, 0xD4, 0x44, 0x4C, 0x8F, + 0x62, 0x9B, 0x5F, 0x28, 0x1D, 0x1E, 0xF9, 0x95, 0x04, 0xC4, 0x3F, 0x05, 0x22, 0x31, 0x70, 0x3E, + 0x62, 0x94, 0xC0, 0x72, 0x2B, 0xC1, 0x42, 0x47, 0x1A, 0x25, 0x1C, 0xF4, 0xF3, 0x11, 0x19, 0x18, + 0xD4, 0xA8, 0xA0, 0xDF, 0x6B, 0x90, 0x60, 0x10, 0x63, 0x9A, 0x12, 0x12, 0xF1, 0xAD, 0x81, 0x6C, + 0x3C, 0x6A, 0xC4, 0x88, 0x37, 0xFC, 0x4B, 0xF0, 0xF0, 0x31, 0x54, 0x09, 0x0D, 0x7F, 0x3B, 0x7D, + 0x26, 0x16, 0x35, 0x62, 0xF8, 0x0B, 0xE1, 0x65, 0x3C, 0xF1, 0x31, 0x5B, 0x8D, 0x27, 0xFE, 0x1E, + 0xF3, 0x6C, 0x3C, 0x8A, 0xB2, 0xE1, 0xEF, 0x0E, 0x97, 0x59, 0x1D, 0x4B, 0x11, 0x72, 0x1D, 0x83, + 0x35, 0x01, 0x60, 0x5E, 0x96, 0x7E, 0xD1, 0x3B, 0xED, 0x46, 0x18, 0x78, 0x46, 0x91, 0x87, 0x81, + 0x37, 0x49, 0x63, 0x10, 0xD1, 0xE1, 0x01, 0x72, 0xBB, 0x87, 0x88, 0x42, 0x90, 0xA3, 0xAB, 0x45, + 0x21, 0x48, 0xBB, 0x45, 0xF8, 0x09, 0x61, 0x32, 0xC2, 0x0F, 0x2D, 0x68, 0xB0, 0x2F, 0x18, 0xE7, + 0xC9, 0x3F, 0xFC, 0x20, 0xB0, 0x4C, 0x89, 0x88, 0x03, 0xD2, 0x79, 0x25, 0x4B, 0xE2, 0xDF, 0xBE, + 0x4D, 0x19, 0x12, 0x2D, 0x96, 0xDC, 0xFA, 0x6A, 0xA1, 0xEB, 0xD6, 0xCF, 0xC0, 0x40, 0xE7, 0x0E, + 0x6A, 0xB9, 0x19, 0xFF, 0x58, 0xAC, 0x04, 0x09, 0xCC, 0x39, 0x94, 0x50, 0xF0, 0x2F, 0x66, 0xCA, + 0x18, 0xA1, 0x1F, 0x54, 0x54, 0x62, 0x45, 0x7C, 0x3C, 0x51, 0x46, 0x07, 0x9D, 0xDE, 0xE4, 0x29, + 0x85, 0x7F, 0xA2, 0x2E, 0x4B, 0x23, 0x6B, 0xD5, 0x21, 0x57, 0x7C, 0x8E, 0x4D, 0x32, 0xEC, 0x56, + 0x9C, 0x15, 0x3E, 0xCC, 0x10, 0xBD, 0xF8, 0x28, 0xE6, 0xE2, 0xC4, 0x96, 0xA6, 0xA2, 0xC4, 0xEE, + 0x18, 0x01, 0x24, 0x47, 0xD3, 0x4D, 0x40, 0xFC, 0x0E, 0xD6, 0x0F, 0x42, 0xE1, 0x6C, 0xDD, 0xEA, + 0x38, 0x40, 0x00, 0x45, 0xB8, 0x1F, 0x8F, 0x55, 0x2C, 0x70, 0x6C, 0xE1, 0x62, 0x97, 0xB3, 0xD0, + 0xB1, 0xBB, 0x19, 0x18, 0x79, 0x7A, 0x9B, 0x84, 0xC0, 0x8B, 0x59, 0xD8, 0x68, 0x8D, 0x28, 0x86, + 0xAB, 0x3F, 0x1A, 0x6D, 0x27, 0xB9, 0xBC, 0x03, 0xB6, 0xAC, 0x84, 0x02, 0xE9, 0x60, 0x89, 0x3E, + 0x2A, 0x7B, 0xCD, 0x60, 0x16, 0xAA, 0xED, 0x89, 0x35, 0xA5, 0xBD, 0xD3, 0xAD, 0x7A, 0x06, 0x40, + 0x70, 0xAB, 0xD2, 0x5E, 0x30, 0x1A, 0x4F, 0xA3, 0x62, 0x89, 0xA6, 0x4D, 0x3D, 0x62, 0x7C, 0xFE, + 0x2A, 0x81, 0x8C, 0x56, 0xFF, 0x43, 0x4C, 0xEC, 0x1A, 0x16, 0x05, 0x53, 0x97, 0xD8, 0x13, 0x37, + 0x6D, 0xD7, 0x21, 0xF2, 0x5E, 0x13, 0xD5, 0x11, 0xDE, 0x11, 0x3F, 0x33, 0xC9, 0xDC, 0xD8, 0xD8, + 0x41, 0x04, 0xE6, 0x91, 0x60, 0xE3, 0x39, 0xBC, 0x5A, 0xB2, 0x3D, 0xB9, 0x92, 0x96, 0xE9, 0x1A, + 0xB4, 0xCD, 0xC3, 0x43, 0xED, 0x65, 0x10, 0x18, 0xA0, 0x00, 0x5C, 0x66, 0x5D, 0xA2, 0x7C, 0x34, + 0x83, 0x17, 0x7C, 0x5D, 0x0F, 0x8D, 0x12, 0xEB, 0xCF, 0x1E, 0x70, 0x4D, 0xBD, 0xD1, 0x07, 0x10, + 0xE1, 0xA4, 0x14, 0x55, 0xE7, 0x3F, 0x1B, 0xE2, 0xDD, 0x7E, 0xA0, 0x02, 0x73, 0xBD, 0x97, 0xE0, + 0x8B, 0x7B, 0x9D, 0x68, 0xA9, 0x64, 0x8F, 0xD5, 0x37, 0x3B, 0x80, 0xEA, 0x12, 0xFA, 0x00, 0x1D, + 0x47, 0x36, 0xCF, 0xB8, 0x09, 0xF5, 0xAE, 0x9D, 0x9F, 0x9F, 0x73, 0x65, 0xA4, 0x0B, 0xAA, 0xD0, + 0xC2, 0x75, 0x3E, 0x93, 0xDB, 0xCD, 0x1A, 0xC4, 0x1F, 0x95, 0x48, 0x53, 0x45, 0x5B, 0x2E, 0x1D, + 0xD2, 0x81, 0x96, 0x17, 0xBC, 0x4C, 0xD6, 0x1B, 0x48, 0x1A, 0x45, 0x2A, 0xA0, 0xD6, 0x89, 0x9E, + 0xF8, 0xD5, 0x56, 0xA3, 0xBB, 0x27, 0xF2, 0x33, 0x49, 0x79, 0x99, 0x13, 0xC8, 0x85, 0x27, 0x86, + 0xAE, 0x54, 0x0F, 0x4F, 0x92, 0xA8, 0xEE, 0xF6, 0x9F, 0x44, 0x91, 0x61, 0xB3, 0x36, 0x8D, 0x80, + 0x24, 0x83, 0x43, 0x68, 0x0B, 0xE2, 0xE6, 0xCA, 0x0D, 0x48, 0x2A, 0x62, 0x58, 0x8E, 0x15, 0x58, + 0x86, 0xFD, 0x31, 0xB2, 0xC6, 0x9D, 0xBA, 0xBF, 0xC4, 0xC7, 0x4B, 0xF8, 0xFF, 0x56, 0x85, 0x57, + 0xAD, 0x2A, 0xB9, 0x65, 0x21, 0x61, 0x3C, 0x88, 0xAC, 0x24, 0x2E, 0x87, 0x44, 0x58, 0xE0, 0xF7, + 0x45, 0x4F, 0x4F, 0x9F, 0xD2, 0xA3, 0x27, 0xA1, 0xD2, 0x44, 0xF4, 0x38, 0xD7, 0xA2, 0x1B, 0x29, + 0x05, 0x6F, 0xE3, 0x4E, 0xE1, 0x10, 0xC8, 0x63, 0x18, 0x98, 0x6F, 0x85, 0xEA, 0x5D, 0xC3, 0x54, + 0x17, 0x6D, 0xE1, 0xFF, 0xA3, 0xFE, 0x23, 0x8A, 0xFA, 0xBB, 0x0B, 0xF1, 0x39, 0xB6, 0x9D, 0xF2, + 0x00, 0x06, 0x27, 0x5F, 0x74, 0x79, 0xBE, 0x77, 0xA0, 0xC9, 0x57, 0x55, 0x52, 0x69, 0xC5, 0xD2, + 0x32, 0x19, 0xC9, 0x91, 0x5D, 0xA1, 0x84, 0x70, 0x61, 0x14, 0x97, 0x0E, 0x71, 0x1D, 0xB1, 0xB5, + 0xC7, 0x56, 0x6D, 0x69, 0x34, 0xBE, 0x8B, 0x12, 0x92, 0xA5, 0x7B, 0x9D, 0x07, 0xE9, 0x41, 0xCC, + 0xB9, 0x22, 0x29, 0xE0, 0x10, 0xDA, 0xB4, 0x7C, 0x63, 0x6A, 0x17, 0x77, 0xCD, 0xDB, 0x99, 0x7C, + 0x28, 0x80, 0x06, 0xE2, 0x0A, 0x80, 0x06, 0x1E, 0xF5, 0x99, 0x18, 0x5A, 0xE2, 0x14, 0x61, 0x15, + 0x64, 0xE5, 0x22, 0x9E, 0x1B, 0xE0, 0xC4, 0x49, 0xCC, 0x2C, 0x90, 0x96, 0x08, 0xB1, 0xF1, 0xCB, + 0x00, 0x91, 0x3C, 0x3D, 0xD7, 0x9C, 0x8D, 0x6D, 0x83, 0x05, 0x22, 0x0B, 0x60, 0x81, 0xF1, 0xBB, + 0xD2, 0x00, 0xFD, 0xE7, 0x8D, 0x66, 0x21, 0xE5, 0x09, 0x09, 0x3C, 0x7B, 0x96, 0xC4, 0x86, 0xCB, + 0xB7, 0x2C, 0x35, 0x0F, 0x7B, 0x63, 0xED, 0xD9, 0xDB, 0x74, 0xA3, 0x51, 0x96, 0x93, 0x04, 0x43, + 0xF5, 0xD3, 0x84, 0xE0, 0x63, 0x19, 0x0E, 0x10, 0x62, 0x99, 0x54, 0x40, 0xB8, 0x49, 0x43, 0xDF, + 0x5A, 0xE9, 0x7A, 0x41, 0xAD, 0xBE, 0x45, 0xF8, 0x1E, 0x9D, 0x7D, 0x90, 0x3F, 0x1A, 0x73, 0x74, + 0x41, 0x64, 0x3B, 0x61, 0x57, 0x71, 0x8C, 0x8B, 0x04, 0x46, 0x64, 0x2C, 0x45, 0x37, 0xFE, 0x68, + 0x07, 0xD0, 0x14, 0x77, 0xC8, 0xC4, 0x06, 0xEF, 0xED, 0xD1, 0x9F, 0x76, 0xBC, 0xDD, 0x30, 0x97, + 0x82, 0xEB, 0xE9, 0xA7, 0x05, 0x34, 0x97, 0x31, 0x46, 0xD1, 0x5D, 0x4F, 0x91, 0x25, 0x4A, 0x02, + 0x1C, 0xE6, 0xA1, 0x9A, 0x1B, 0x33, 0xF2, 0xC9, 0x23, 0x33, 0x77, 0xE1, 0x58, 0xBF, 0x13, 0x19, + 0x42, 0xE6, 0x50, 0x2D, 0xE2, 0x78, 0xAE, 0x98, 0x66, 0x23, 0x72, 0xEE, 0x2B, 0xC9, 0xEB, 0x99, + 0x3D, 0x69, 0x3A, 0xB8, 0xD5, 0x27, 0x0B, 0x37, 0x1F, 0xF8, 0x56, 0x70, 0xBB, 0xDD, 0xCF, 0x44, + 0x6B, 0xF7, 0x04, 0xF5, 0xD0, 0xF4, 0x0D, 0x6E, 0xD0, 0x09, 0x79, 0x08, 0x2F, 0x24, 0x93, 0x50, + 0xE1, 0x9E, 0xE1, 0x0A, 0x58, 0xDC, 0x5E, 0xD8, 0x50, 0x19, 0x8D, 0x93, 0x91, 0x41, 0xDE, 0xD3, + 0xC8, 0xD1, 0xC3, 0x61, 0x43, 0x1A, 0xDF, 0x6B, 0x0E, 0x1A, 0x39, 0x38, 0xD9, 0xC6, 0x97, 0x34, + 0xD2, 0xCD, 0x74, 0x65, 0x05, 0x12, 0x84, 0x7B, 0xBD, 0xBD, 0x32, 0xE3, 0x4F, 0xDC, 0x5B, 0x59, + 0xC4, 0xA3, 0xA9, 0x39, 0x20, 0x4A, 0xAC, 0xE8, 0xCD, 0xD8, 0x06, 0xCF, 0x17, 0x30, 0xFD, 0xC6, + 0x75, 0x3A, 0x54, 0x70, 0x6A, 0x81, 0x9C, 0xA1, 0x60, 0xFB, 0x3A, 0x28, 0x8A, 0xE4, 0xCE, 0x0E, + 0xB1, 0x9B, 0x22, 0x99, 0xC9, 0xC7, 0x37, 0x14, 0xFC, 0xEA, 0x11, 0x80, 0xF3, 0xB1, 0x9A, 0xA8, + 0x7D, 0xF9, 0x07, 0x45, 0x71, 0xA7, 0xCD, 0x21, 0x5A, 0xF8, 0x4B, 0x62, 0xD2, 0xCA, 0x57, 0xB0, + 0xF1, 0x4F, 0x35, 0x5C, 0x14, 0x4F, 0xEC, 0xE4, 0xB8, 0xFB, 0x35, 0xB4, 0x90, 0x70, 0xB0, 0x29, + 0x9C, 0x6C, 0xD0, 0x0D, 0x3F, 0xF9, 0xF3, 0x0C, 0x96, 0x9E, 0x4B, 0x0A, 0x4B, 0xF8, 0x63, 0x91, + 0xC4, 0xEE, 0x40, 0x4E, 0x03, 0xDD, 0x7C, 0x07, 0xD9, 0x4B, 0xCA, 0x4C, 0xF7, 0xF9, 0x34, 0x09, + 0x34, 0x60, 0x8A, 0x90, 0xC7, 0x74, 0x84, 0x93, 0x21, 0x26, 0xA6, 0x84, 0x84, 0x19, 0x33, 0x9C, + 0x97, 0xE2, 0xDD, 0x30, 0x3C, 0x0B, 0x08, 0x65, 0xF1, 0x9B, 0x0F, 0x93, 0x9B, 0xFD, 0x27, 0xA1, + 0x18, 0xB6, 0x71, 0x60, 0x07, 0x31, 0x04, 0x09, 0x11, 0x65, 0x89, 0x89, 0x1B, 0x4D, 0x72, 0x5E, + 0x96, 0x23, 0x33, 0xF6, 0x8B, 0x8D, 0x99, 0x74, 0xC0, 0xA4, 0x3D, 0xFF, 0x8B, 0x1A, 0xCD, 0xBF, + 0x0F, 0xD8, 0x20, 0x1B, 0x8B, 0x79, 0xFB, 0x65, 0x08, 0xDA, 0x9A, 0x24, 0x16, 0x12, 0x73, 0x6F, + 0xE9, 0xB2, 0xF8, 0x41, 0x80, 0xA3, 0xF8, 0x20, 0x29, 0xDD, 0x9A, 0x26, 0xC6, 0x73, 0xBA, 0x90, + 0x41, 0x89, 0x6C, 0x62, 0x73, 0x3C, 0x21, 0x1E, 0x69, 0x3E, 0x97, 0x29, 0x2E, 0x66, 0x5F, 0xCC, + 0x61, 0xAF, 0x2C, 0x72, 0x9D, 0x5B, 0x37, 0xA5, 0x7B, 0xB5, 0xA8, 0xBC, 0x22, 0x80, 0x8B, 0x70, + 0x6F, 0x5E, 0x21, 0x64, 0xB4, 0x8F, 0x2F, 0x86, 0x83, 0x6E, 0xD2, 0x53, 0x5B, 0x5C, 0xA5, 0x4D, + 0x13, 0xA0, 0x88, 0xB5, 0x18, 0x56, 0xEC, 0xDB, 0x4E, 0x93, 0x1F, 0x1F, 0x7C, 0xF2, 0xE0, 0xE9, + 0x28, 0xC7, 0x1A, 0xC7, 0xA0, 0xA9, 0xE7, 0x17, 0x03, 0xC7, 0x77, 0x04, 0xC6, 0x69, 0x37, 0xAE, + 0x14, 0x80, 0xA3, 0x6D, 0x8C, 0x31, 0x50, 0x31, 0x9E, 0xE5, 0x01, 0x42, 0x1B, 0xB6, 0x4D, 0x75, + 0x2F, 0xA6, 0x5F, 0x3F, 0x70, 0xD7, 0x1F, 0x28, 0x21, 0xA9, 0x40, 0x74, 0x4D, 0x17, 0x07, 0x3A, + 0x78, 0xBF, 0xC5, 0xD3, 0xA4, 0xB8, 0x6C, 0x93, 0xEB, 0xA9, 0x1F, 0x70, 0xE5, 0x40, 0x63, 0x78, + 0xF6, 0x92, 0x39, 0x2E, 0x5D, 0x54, 0x90, 0xF6, 0x80, 0x96, 0xD2, 0xF1, 0xBD, 0x19, 0x1B, 0x0E, + 0xC2, 0x4D, 0x7F, 0x18, 0xAD, 0xF0, 0xF0, 0x57, 0xD6, 0x27, 0x0E, 0xDF, 0x09, 0x9B, 0xDA, 0x2F, + 0xA4, 0xC5, 0x5D, 0xA7, 0x49, 0x89, 0x4A, 0x4A, 0xCC, 0xBF, 0x7D, 0x1C, 0x07, 0xD8, 0xD0, 0x87, + 0x11, 0x33, 0x66, 0x6F, 0x19, 0x61, 0x39, 0x12, 0x13, 0x8F, 0x83, 0x49, 0xE2, 0x63, 0x63, 0x19, + 0xDB, 0x31, 0xFA, 0xE2, 0xD3, 0x6C, 0x0A, 0xC3, 0xD7, 0x6B, 0x70, 0x3E, 0xF0, 0xF6, 0xEB, 0xD6, + 0xFE, 0x5D, 0x1E, 0x3B, 0x4C, 0x5C, 0x91, 0xED, 0xA8, 0x12, 0x41, 0x07, 0x0A, 0x39, 0xB6, 0x84, + 0x7C, 0xE4, 0xE8, 0xE2, 0x0E, 0x73, 0xE9, 0x88, 0x69, 0x4A, 0x96, 0x60, 0xCF, 0xB7, 0x45, 0xCB, + 0x32, 0xD5, 0x04, 0x82, 0x68, 0x08, 0xD8, 0x22, 0x36, 0x95, 0xA8, 0xC6, 0xEC, 0x42, 0x34, 0x10, + 0xB4, 0xC7, 0x7D, 0x30, 0x83, 0xF6, 0x64, 0x1E, 0x9F, 0x4A, 0x18, 0x99, 0x00, 0x42, 0x67, 0xCA, + 0x59, 0xC2, 0x99, 0x19, 0xCE, 0x95, 0x91, 0x58, 0xC2, 0x99, 0x01, 0x41, 0x01, 0xE1, 0x7E, 0xD3, + 0xD2, 0x59, 0x03, 0x9D, 0x3B, 0x00, 0x3B, 0xEB, 0xD0, 0x87, 0x61, 0x70, 0x46, 0x82, 0x36, 0x40, + 0x4F, 0x12, 0xB7, 0x97, 0x84, 0xBE, 0x80, 0x90, 0xDF, 0x67, 0x67, 0xAC, 0x41, 0xD8, 0xCB, 0xD4, + 0x35, 0x6F, 0x3B, 0xC6, 0x7A, 0x4D, 0x1C, 0xF3, 0x62, 0x69, 0xD9, 0x66, 0x8B, 0x81, 0xC6, 0xD6, + 0x31, 0x30, 0x2C, 0x12, 0xBA, 0xF5, 0x8D, 0x63, 0x05, 0x6F, 0xBE, 0x60, 0xD7, 0x5A, 0x7B, 0x7D, + 0x53, 0xEC, 0x5C, 0xE4, 0xCD, 0x3A, 0xA6, 0x67, 0x5C, 0x7F, 0x83, 0xFB, 0xA2, 0xA9, 0x39, 0x1C, + 0x74, 0x0F, 0xBA, 0xBC, 0x41, 0x00, 0xE9, 0x96, 0x10, 0x39, 0xE2, 0xC5, 0xFD, 0xA3, 0x3F, 0x7E, + 0xFF, 0x6D, 0x84, 0x37, 0x70, 0x5F, 0xB3, 0x4B, 0xAD, 0x3D, 0xBA, 0xB1, 0xFA, 0xF0, 0xB7, 0x35, + 0xEE, 0x57, 0x11, 0xC3, 0x4C, 0x4C, 0x8C, 0xB8, 0x67, 0x1A, 0x45, 0xC5, 0x9A, 0x7F, 0x15, 0x47, + 0x0A, 0x97, 0x1D, 0x18, 0x27, 0xD0, 0xDC, 0x5B, 0x32, 0x50, 0xB1, 0xA3, 0x1A, 0xC1, 0x91, 0x93, + 0xAF, 0x61, 0xA2, 0xFA, 0x0B, 0x31, 0x3C, 0xD0, 0xC7, 0x73, 0xAD, 0xA5, 0x77, 0xF5, 0xE7, 0x2D, + 0x7A, 0xFD, 0x1D, 0xB0, 0xB3, 0x6C, 0xED, 0x3F, 0xEF, 0xED, 0xEF, 0x77, 0x7C, 0xD0, 0x19, 0x69, + 0xB5, 0xFB, 0xA2, 0x09, 0xFC, 0xA1, 0x6D, 0x58, 0x27, 0xD9, 0xF7, 0xDF, 0xBA, 0x1B, 0xCF, 0xCF, + 0x6B, 0xF0, 0xCE, 0x72, 0x70, 0x24, 0xCE, 0x6B, 0xF2, 0x01, 0xA6, 0x2F, 0x8E, 0xB9, 0xD5, 0x44, + 0xA7, 0x1B, 0xC1, 0xC5, 0x9C, 0x91, 0xEE, 0x8F, 0x85, 0xA4, 0x3F, 0x96, 0xEE, 0xF3, 0x8C, 0x93, + 0x60, 0xC5, 0xBB, 0x25, 0x96, 0xA5, 0xEE, 0xE2, 0xC6, 0x11, 0xE5, 0x74, 0xBC, 0x6A, 0xB0, 0xA5, + 0xFF, 0x54, 0xC0, 0xE2, 0x59, 0xD2, 0x56, 0x2D, 0x5C, 0x25, 0x11, 0x95, 0x66, 0x58, 0xB9, 0x19, + 0x69, 0xB2, 0x60, 0x9C, 0x9E, 0x2D, 0x27, 0xB3, 0xCF, 0x8B, 0x0D, 0xF8, 0xF8, 0x4A, 0x44, 0x54, + 0x76, 0x0D, 0xA7, 0x9C, 0x61, 0xE8, 0x87, 0x29, 0x68, 0xDE, 0x78, 0x04, 0xB7, 0x63, 0x23, 0x18, + 0x9F, 0xAF, 0x16, 0x00, 0xD0, 0x49, 0x6A, 0x38, 0x8A, 0x69, 0x08, 0xB5, 0x4D, 0xB6, 0x24, 0x44, + 0x40, 0xBB, 0xFD, 0x30, 0x62, 0x21, 0x10, 0x9F, 0x75, 0x45, 0xBA, 0xDB, 0x9E, 0x5D, 0xA7, 0x03, + 0xD6, 0xD6, 0xAC, 0xFA, 0x2E, 0xA6, 0x2D, 0xF1, 0x70, 0x4E, 0xC4, 0x0F, 0xC9, 0x67, 0x9E, 0xC4, + 0x99, 0x17, 0x55, 0x82, 0x02, 0x88, 0x4F, 0x34, 0xC7, 0x8F, 0xB3, 0x4F, 0x14, 0xD9, 0x27, 0x9C, + 0x7D, 0x04, 0x88, 0x26, 0x9C, 0xC5, 0x25, 0x8B, 0xD0, 0x18, 0x7F, 0x7A, 0x15, 0x71, 0x76, 0x3D, + 0xCD, 0xA5, 0x93, 0x97, 0x12, 0x62, 0xEC, 0xE5, 0x03, 0x40, 0xFB, 0x15, 0xF8, 0x43, 0x9C, 0xAD, + 0xEB, 0xA9, 0x1A, 0x5B, 0xA2, 0x14, 0x81, 0x00, 0x11, 0x5B, 0xF2, 0x82, 0x85, 0x60, 0xE5, 0x35, + 0x09, 0xF8, 0x23, 0x7C, 0x86, 0x63, 0x6A, 0x73, 0xCF, 0x58, 0x11, 0xFC, 0x62, 0x7B, 0x48, 0xAC, + 0x49, 0xEF, 0x17, 0xE6, 0x7E, 0xAC, 0x59, 0x8C, 0xC9, 0xB0, 0xE4, 0x51, 0x08, 0x1A, 0xB6, 0x8C, + 0x41, 0x87, 0x74, 0xE4, 0x42, 0x8B, 0x46, 0x2C, 0x83, 0x0B, 0x4F, 0x95, 0x84, 0x15, 0xB6, 0x8E, + 0x1C, 0x21, 0x42, 0x20, 0x4A, 0x26, 0xA3, 0x74, 0xA9, 0x8C, 0xCD, 0x24, 0x18, 0xB3, 0xA9, 0xF9, + 0x42, 0xBC, 0x41, 0xC8, 0x52, 0xA2, 0x4D, 0xE8, 0x20, 0x0C, 0x3E, 0x8B, 0xCC, 0x42, 0x52, 0xD8, + 0x42, 0xA2, 0xFE, 0xDE, 0x26, 0x58, 0x9E, 0xE0, 0x2F, 0x65, 0xBC, 0xF8, 0xE6, 0x6B, 0xCD, 0xF5, + 0x34, 0xDB, 0xBD, 0x26, 0xB8, 0x28, 0x28, 0x36, 0x90, 0x69, 0x53, 0x02, 0x01, 0x8E, 0xB0, 0x22, + 0x13, 0xEE, 0x8F, 0x09, 0x96, 0x96, 0x0F, 0x73, 0x62, 0x7C, 0x75, 0x28, 0x79, 0xAA, 0x87, 0xA3, + 0x53, 0x21, 0x7B, 0xDB, 0x25, 0xF4, 0x84, 0x38, 0x19, 0x4C, 0x24, 0xCB, 0xA7, 0x9C, 0xC7, 0xAD, + 0xC0, 0x92, 0x57, 0xD6, 0x2A, 0x21, 0xC2, 0xF0, 0xF6, 0xA3, 0x95, 0xA2, 0x9C, 0x81, 0x42, 0x41, + 0x86, 0x60, 0x91, 0x2C, 0x23, 0x5E, 0xB7, 0xA4, 0x29, 0xAB, 0x1D, 0xE6, 0x68, 0x14, 0x4B, 0xE1, + 0xD2, 0x68, 0x9E, 0xAD, 0x15, 0x26, 0x71, 0x36, 0xCA, 0xB1, 0xDF, 0xD9, 0xA1, 0x78, 0x14, 0x8A, + 0x9D, 0x61, 0xF6, 0x36, 0x79, 0x72, 0x76, 0xB8, 0x0C, 0x56, 0xF6, 0xE4, 0xC9, 0xFF, 0x02, 0x49, + 0x60, 0xC8, 0xA8, 0x55, 0x0C, 0x01, 0x00 +}; + + +//File: index_ov5640.html.gz, Size: 9124 +#define index_ov5640_html_gz_len 9124 +const uint8_t index_ov5640_html_gz[] = { + 0x1F, 0x8B, 0x08, 0x08, 0xD9, 0x6C, 0x6A, 0x5E, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x5F, + 0x6F, 0x76, 0x35, 0x36, 0x34, 0x30, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xED, 0x3D, 0x6B, 0x77, + 0xDB, 0xB6, 0x92, 0xDF, 0xF3, 0x2B, 0x18, 0xF5, 0x6E, 0x24, 0x9F, 0x58, 0xB6, 0xA8, 0x97, 0x1F, + 0xB1, 0x95, 0x4D, 0x1C, 0x27, 0xE9, 0xB9, 0x4D, 0x6F, 0x1A, 0xA7, 0x69, 0x7B, 0xBA, 0x3D, 0x29, + 0x25, 0x41, 0x12, 0x1B, 0x8A, 0xD4, 0x25, 0x29, 0xCB, 0x6E, 0x8E, 0x7F, 0xC7, 0xFE, 0xA0, 0xFD, + 0x63, 0x3B, 0x03, 0x80, 0x24, 0x48, 0x81, 0x24, 0x48, 0x4A, 0xB2, 0xDB, 0x5D, 0xE5, 0x9C, 0x98, + 0x0F, 0xCC, 0x60, 0xDE, 0x18, 0x0C, 0x40, 0xF2, 0xEC, 0xF1, 0xD8, 0x19, 0xF9, 0xB7, 0x0B, 0xA2, + 0xCD, 0xFC, 0xB9, 0x35, 0x78, 0x74, 0xC6, 0xFE, 0x68, 0xF0, 0x3B, 0x9B, 0x11, 0x63, 0xCC, 0x0E, + 0xE9, 0xE9, 0x9C, 0xF8, 0x86, 0x36, 0x9A, 0x19, 0xAE, 0x47, 0xFC, 0xF3, 0xDA, 0xD2, 0x9F, 0x34, + 0x8F, 0x6B, 0xC9, 0xDB, 0xB6, 0x31, 0x27, 0xE7, 0xB5, 0x6B, 0x93, 0xAC, 0x16, 0x8E, 0xEB, 0xD7, + 0xB4, 0x91, 0x63, 0xFB, 0xC4, 0x86, 0xE6, 0x2B, 0x73, 0xEC, 0xCF, 0xCE, 0xC7, 0xE4, 0xDA, 0x1C, + 0x91, 0x26, 0x3D, 0xD9, 0x37, 0x6D, 0xD3, 0x37, 0x0D, 0xAB, 0xE9, 0x8D, 0x0C, 0x8B, 0x9C, 0xEB, + 0x22, 0x2E, 0xDF, 0xF4, 0x2D, 0x32, 0xB8, 0xBC, 0x7A, 0xDF, 0x69, 0x6B, 0xFF, 0xFA, 0xD4, 0xEB, + 0x77, 0x5B, 0x67, 0x87, 0xEC, 0x5A, 0xD4, 0xC6, 0xF3, 0x6F, 0xC5, 0x73, 0xFC, 0x0D, 0x9D, 0xF1, + 0xAD, 0xF6, 0x35, 0x76, 0x09, 0x7F, 0x13, 0x20, 0xA2, 0x39, 0x31, 0xE6, 0xA6, 0x75, 0x7B, 0xAA, + 0xBD, 0x70, 0xA1, 0xCF, 0xFD, 0xB7, 0xC4, 0xBA, 0x26, 0xBE, 0x39, 0x32, 0xF6, 0x3D, 0xC3, 0xF6, + 0x9A, 0x1E, 0x71, 0xCD, 0xC9, 0xB3, 0x35, 0xC0, 0xA1, 0x31, 0xFA, 0x32, 0x75, 0x9D, 0xA5, 0x3D, + 0x3E, 0xD5, 0xBE, 0xD1, 0x8F, 0xF1, 0xDF, 0x7A, 0xA3, 0x91, 0x63, 0x39, 0x2E, 0xDC, 0xBF, 0x7C, + 0x8D, 0xFF, 0xD6, 0xEF, 0xD3, 0xDE, 0x3D, 0xF3, 0x4F, 0x72, 0xAA, 0xE9, 0xFD, 0xC5, 0x4D, 0xEC, + 0xFE, 0xDD, 0xA3, 0xD8, 0xE9, 0xAC, 0x9D, 0x46, 0x3D, 0x87, 0x3F, 0xCE, 0x86, 0xF7, 0xC8, 0xC8, + 0x37, 0x1D, 0xFB, 0x60, 0x6E, 0x98, 0xB6, 0x04, 0xD3, 0xD8, 0xF4, 0x16, 0x96, 0x01, 0x32, 0x98, + 0x58, 0x24, 0x13, 0xCF, 0x37, 0x73, 0x62, 0x2F, 0xF7, 0x73, 0xB0, 0x21, 0x92, 0xE6, 0xD8, 0x74, + 0x59, 0xAB, 0x53, 0x94, 0xC3, 0x72, 0x6E, 0xE7, 0xA2, 0xCD, 0xA2, 0xCB, 0x76, 0x6C, 0x22, 0x11, + 0x20, 0x76, 0xB4, 0x72, 0x8D, 0x05, 0x36, 0xC0, 0xBF, 0xEB, 0x4D, 0xE6, 0xA6, 0xCD, 0x8C, 0xEA, + 0x54, 0xEB, 0x74, 0x5B, 0x8B, 0x9B, 0x1C, 0x55, 0x76, 0xFA, 0xF8, 0x6F, 0xBD, 0xD1, 0xC2, 0x18, + 0x8F, 0x4D, 0x7B, 0x7A, 0xAA, 0x1D, 0x4B, 0x51, 0x38, 0xEE, 0x98, 0xB8, 0x4D, 0xD7, 0x18, 0x9B, + 0x4B, 0xEF, 0x54, 0xEB, 0xCA, 0xDA, 0xCC, 0x0D, 0x77, 0x0A, 0xB4, 0xF8, 0x0E, 0x10, 0xDB, 0xD4, + 0xA5, 0x94, 0xF0, 0x26, 0xAE, 0x39, 0x9D, 0xF9, 0xA0, 0xD2, 0xB5, 0x36, 0x49, 0xA1, 0x71, 0x17, + 0xCA, 0xD3, 0x67, 0xA6, 0xDC, 0xE4, 0x52, 0x33, 0x2C, 0x73, 0x6A, 0x37, 0x4D, 0x9F, 0xCC, 0x81, + 0x1D, 0xCF, 0x77, 0x89, 0x3F, 0x9A, 0x65, 0x91, 0x32, 0x31, 0xA7, 0x4B, 0x97, 0x48, 0x08, 0x09, + 0xE5, 0x96, 0xC1, 0x30, 0xDC, 0x5C, 0xBF, 0xD5, 0x5C, 0x91, 0xE1, 0x17, 0xD3, 0x6F, 0x72, 0x99, + 0x0C, 0xC9, 0xC4, 0x71, 0x89, 0xB4, 0x65, 0xD0, 0xC2, 0x72, 0x46, 0x5F, 0x9A, 0x9E, 0x6F, 0xB8, + 0xBE, 0x0A, 0x42, 0x63, 0xE2, 0x13, 0x37, 0x1F, 0x1F, 0x41, 0xAB, 0xC8, 0xC7, 0x96, 0xDE, 0x2D, + 0x6F, 0x60, 0xDA, 0x96, 0x69, 0x13, 0x75, 0xF2, 0xD2, 0xFA, 0x8D, 0xA3, 0x63, 0xAD, 0x14, 0x14, + 0x63, 0xCE, 0xA7, 0x59, 0x56, 0x42, 0x79, 0x5D, 0xEF, 0x8C, 0xFB, 0x8D, 0xDE, 0x6A, 0xFD, 0xC7, + 0xFA, 0xCD, 0x19, 0x61, 0x66, 0x6A, 0x2C, 0x7D, 0xA7, 0xBA, 0x47, 0xAC, 0xB9, 0x55, 0x82, 0x8F, + 0xFF, 0x9C, 0x93, 0xB1, 0x69, 0x68, 0x0D, 0xC1, 0x9D, 0x8F, 0x5B, 0x60, 0x53, 0x7B, 0x9A, 0x61, + 0x8F, 0xB5, 0x86, 0xE3, 0x9A, 0xE0, 0x08, 0x06, 0x0D, 0x37, 0x16, 0x5C, 0x81, 0x81, 0x63, 0x41, + 0xF6, 0x24, 0x2C, 0x67, 0xF8, 0x8C, 0x28, 0x11, 0xB9, 0xDB, 0xE0, 0x4F, 0x21, 0xE4, 0xE0, 0x2F, + 0xD7, 0x81, 0x24, 0x3C, 0x52, 0xF4, 0x59, 0xFA, 0x12, 0x29, 0x4C, 0xD3, 0x19, 0xFE, 0xE6, 0xC6, + 0x4D, 0x33, 0x53, 0x77, 0x41, 0xA3, 0x40, 0x87, 0x30, 0xCC, 0x8E, 0x1A, 0xD0, 0xF4, 0x7A, 0xA6, + 0x35, 0x35, 0x8C, 0x92, 0x7B, 0x72, 0x18, 0x8E, 0x54, 0xAE, 0x72, 0xFC, 0x89, 0x46, 0x51, 0x80, + 0x5D, 0x39, 0xAB, 0x51, 0xEC, 0x60, 0xFF, 0x64, 0x36, 0xC4, 0x38, 0x49, 0x8D, 0x22, 0xF8, 0x53, + 0x8F, 0x24, 0x11, 0xB2, 0xDC, 0x68, 0x22, 0x41, 0x9C, 0x1E, 0x51, 0xD6, 0xF0, 0xA6, 0x79, 0xB7, + 0x04, 0x6B, 0x36, 0x09, 0xAA, 0xD1, 0x45, 0x82, 0x38, 0x8B, 0x86, 0xDC, 0x28, 0x83, 0xBF, 0x3B, + 0x85, 0x7C, 0xE3, 0x9B, 0xE1, 0xD2, 0xF7, 0x1D, 0xDB, 0xAB, 0x34, 0x44, 0xA5, 0xF9, 0xD9, 0x1F, + 0x4B, 0xCF, 0x37, 0x27, 0xB7, 0x4D, 0xEE, 0xD2, 0xE0, 0x67, 0x0B, 0x03, 0x52, 0xC8, 0x21, 0xF1, + 0x57, 0x84, 0x64, 0xA7, 0x1B, 0xB6, 0x71, 0x0D, 0x71, 0x67, 0x3A, 0xB5, 0x64, 0xB6, 0x37, 0x5A, + 0xBA, 0x1E, 0xE6, 0x6D, 0x0B, 0xC7, 0x04, 0xC4, 0xEE, 0x7A, 0xC7, 0x71, 0x1F, 0x54, 0xEC, 0xA8, + 0x39, 0x1A, 0x4A, 0xFA, 0x72, 0x96, 0x3E, 0xCA, 0x58, 0xAA, 0x09, 0x07, 0xD8, 0x31, 0xFD, 0x5B, + 0xE9, 0x3D, 0xEE, 0x89, 0x92, 0x3B, 0x81, 0x0B, 0x66, 0x0E, 0x0B, 0x71, 0xBA, 0x4E, 0x47, 0x33, + 0x32, 0xFA, 0x42, 0xC6, 0x4F, 0x73, 0xD3, 0xB0, 0xBC, 0xF4, 0xF0, 0xC0, 0xB4, 0x17, 0x4B, 0xBF, + 0x89, 0xE9, 0xD4, 0x62, 0x2B, 0x3A, 0xA7, 0x06, 0x19, 0xB0, 0xD8, 0x6E, 0x67, 0x25, 0x15, 0xBD, + 0xC5, 0x4D, 0xB6, 0x10, 0x44, 0x62, 0x07, 0x96, 0x31, 0x24, 0x56, 0x16, 0xC9, 0xDC, 0x19, 0x52, + 0xC2, 0x2E, 0x8F, 0x55, 0xE9, 0xB9, 0x1B, 0xA5, 0x2C, 0x1A, 0xBC, 0xBA, 0x47, 0xFF, 0xA1, 0x2C, + 0x47, 0x7A, 0xBC, 0x1F, 0xBB, 0xE4, 0x11, 0x0B, 0x1C, 0x2C, 0x71, 0x6D, 0x61, 0xA4, 0x26, 0xE3, + 0xD0, 0x62, 0x05, 0x54, 0x65, 0x76, 0xE9, 0x1A, 0xF6, 0x94, 0x40, 0x74, 0xB8, 0xD9, 0x0F, 0x0E, + 0xB3, 0xA7, 0x0A, 0x4A, 0x02, 0xC1, 0xE0, 0xDD, 0xCB, 0x9E, 0x9A, 0xB0, 0x10, 0xB1, 0xAF, 0x1D, + 0xB0, 0x83, 0x12, 0x79, 0x8A, 0xA0, 0xF1, 0x4C, 0x42, 0x74, 0xA9, 0xBD, 0xB0, 0x54, 0x45, 0xEA, + 0x4B, 0x71, 0x6B, 0x93, 0xA6, 0xFE, 0xB9, 0xC1, 0x22, 0x98, 0x04, 0x4E, 0x26, 0x79, 0xD3, 0xC8, + 0xC9, 0xA4, 0xD3, 0xEA, 0x74, 0x73, 0x73, 0x29, 0x29, 0x97, 0x89, 0xA9, 0xA4, 0x24, 0x98, 0x84, + 0x81, 0x26, 0x5F, 0x17, 0xA7, 0x33, 0xE7, 0x9A, 0xB8, 0x12, 0x45, 0x24, 0xC8, 0xED, 0x9E, 0x74, + 0xC7, 0x0A, 0xD8, 0x0C, 0x18, 0x0A, 0xAE, 0x65, 0x81, 0x36, 0x8E, 0xAE, 0xAD, 0x8F, 0xDA, 0x99, + 0x16, 0xCA, 0xD0, 0x1D, 0x80, 0x35, 0x18, 0x43, 0x8B, 0x8C, 0x33, 0x22, 0xF7, 0x98, 0x4C, 0x8C, + 0xA5, 0xE5, 0xE7, 0xC8, 0xDB, 0x68, 0xE1, 0xBF, 0xAC, 0x1E, 0xA9, 0x7B, 0xFD, 0x8A, 0x35, 0x90, + 0x73, 0xEA, 0x12, 0xBF, 0x49, 0xFA, 0x0C, 0x86, 0x55, 0x63, 0xB1, 0x20, 0x06, 0xB4, 0x1A, 0x91, + 0xB4, 0xD9, 0xAA, 0x52, 0x3A, 0x2D, 0x8F, 0x69, 0x4A, 0x73, 0xD4, 0x5C, 0x53, 0x0C, 0x13, 0xA5, + 0x42, 0x3C, 0x9F, 0x4E, 0x9C, 0xD1, 0x52, 0x36, 0x82, 0xAB, 0x99, 0xD4, 0x3A, 0xBE, 0xD3, 0x40, + 0x64, 0x9E, 0x65, 0x52, 0xC3, 0x5E, 0xDA, 0x36, 0x6A, 0xB4, 0xE9, 0xBB, 0xC0, 0xA6, 0xA4, 0x23, + 0x35, 0xC1, 0x95, 0xF2, 0xCE, 0x98, 0x60, 0xD3, 0xEA, 0x34, 0x09, 0x07, 0x94, 0x04, 0x8A, 0x30, + 0x86, 0x68, 0x9E, 0x03, 0x4C, 0x05, 0xA8, 0xAA, 0xC9, 0xC5, 0x9F, 0x2D, 0xE7, 0xB2, 0x9C, 0x21, + 0xE8, 0x4C, 0x87, 0x01, 0x8E, 0x75, 0xE7, 0x4E, 0x87, 0x46, 0xA3, 0xB5, 0xDF, 0xDA, 0xEF, 0xC0, + 0x7F, 0x92, 0xDC, 0x3D, 0xDB, 0xB8, 0xB8, 0x78, 0x53, 0x2C, 0x2F, 0x11, 0x7C, 0xF2, 0x4B, 0x28, + 0x69, 0x61, 0x2C, 0x57, 0x17, 0xEA, 0x9E, 0x14, 0xAF, 0xA5, 0xE8, 0x07, 0x39, 0x23, 0x4C, 0x8A, + 0x49, 0x17, 0x37, 0x44, 0x89, 0xB5, 0x14, 0x55, 0xF1, 0xDC, 0xF9, 0xB3, 0xC9, 0x86, 0xD7, 0xFF, + 0xF3, 0xD6, 0x2E, 0x88, 0xE2, 0x6F, 0x6D, 0xE9, 0x85, 0xE5, 0xE2, 0xDD, 0xB7, 0x6D, 0xB4, 0xD2, + 0xB5, 0xDE, 0xE4, 0xF9, 0x0C, 0x50, 0x68, 0x43, 0xC6, 0xE9, 0xC2, 0xC4, 0x2B, 0x35, 0xE7, 0x11, + 0xDA, 0x94, 0x90, 0xC1, 0xC4, 0xB4, 0xAC, 0xA6, 0xE5, 0xAC, 0xF2, 0x33, 0x91, 0x6C, 0x4B, 0x5E, + 0xB3, 0xD3, 0x7C, 0x93, 0x2F, 0x4B, 0xED, 0x12, 0x22, 0xD7, 0x5F, 0x82, 0xDA, 0xBF, 0xB7, 0xC3, + 0x65, 0xBA, 0x46, 0xB9, 0x81, 0xA2, 0x84, 0x3D, 0x56, 0xEB, 0x48, 0xC9, 0x94, 0x58, 0x26, 0x98, + 0x39, 0xAB, 0xF3, 0x56, 0xA6, 0x3F, 0x9A, 0x95, 0x98, 0x54, 0x2D, 0x1C, 0xCF, 0x64, 0xCB, 0x37, + 0x2E, 0xB1, 0x0C, 0xCC, 0xE0, 0x4B, 0xCD, 0xC6, 0x73, 0x27, 0x26, 0x22, 0xB8, 0x0A, 0x27, 0x54, + 0x74, 0x0F, 0xA7, 0x92, 0x72, 0xC0, 0x72, 0x87, 0xF4, 0x58, 0x2D, 0x37, 0xEB, 0x9C, 0x74, 0x3F, + 0xEE, 0x19, 0xF2, 0x46, 0x05, 0x22, 0x7A, 0x10, 0xB4, 0xA7, 0x2E, 0xB9, 0x55, 0x60, 0x66, 0x9F, + 0xFF, 0x3D, 0x65, 0xB5, 0xD2, 0xF2, 0x45, 0x00, 0x3A, 0x00, 0x70, 0x2B, 0x3A, 0xE8, 0x7A, 0x0A, + 0x5D, 0xA7, 0x77, 0xA9, 0x62, 0x8F, 0x61, 0x25, 0xB0, 0x56, 0x53, 0x08, 0x37, 0x19, 0x43, 0xA8, + 0xDC, 0x54, 0x83, 0xD1, 0x57, 0x7A, 0xD3, 0x22, 0x13, 0x3F, 0x65, 0xA1, 0x83, 0xE6, 0xA9, 0x9D, + 0xEC, 0xE8, 0xD6, 0x14, 0xEA, 0x04, 0xB9, 0x91, 0x23, 0x2C, 0xD8, 0xA5, 0x5B, 0x9F, 0x14, 0x33, + 0x46, 0xCF, 0xC2, 0xC8, 0xD3, 0x55, 0x12, 0xA4, 0xCF, 0x54, 0xCD, 0xD0, 0x66, 0xCE, 0x87, 0x7C, + 0x50, 0x0F, 0xF9, 0xB9, 0xD1, 0xEE, 0x4B, 0xD7, 0x11, 0x32, 0x1A, 0x67, 0x91, 0xC6, 0x2A, 0x5E, + 0x4A, 0x43, 0x56, 0xEA, 0x04, 0x59, 0x8C, 0x45, 0x52, 0x45, 0x65, 0x7B, 0x65, 0x56, 0x84, 0x59, + 0xAF, 0xD1, 0x64, 0x1A, 0xBB, 0x39, 0x37, 0x20, 0xED, 0x45, 0x73, 0x35, 0x00, 0xA3, 0x4C, 0x7F, + 0x2A, 0xE6, 0x2E, 0xD4, 0x13, 0xF5, 0x7E, 0x2B, 0xA7, 0xCB, 0x91, 0xE5, 0x78, 0xD9, 0x7E, 0x65, + 0x0C, 0x41, 0x7E, 0x4B, 0x5F, 0xD2, 0x11, 0xAF, 0x6A, 0x4A, 0x2B, 0x4F, 0xD4, 0xB8, 0xA5, 0x77, + 0x94, 0x86, 0xEE, 0x4C, 0x9F, 0xCA, 0x76, 0xC7, 0x84, 0xCC, 0xF5, 0x96, 0x34, 0xD2, 0x66, 0xD6, + 0xDF, 0x7C, 0x72, 0x03, 0xF3, 0x4D, 0x5C, 0xAB, 0x3B, 0xD5, 0x46, 0x44, 0x1E, 0x46, 0x63, 0x83, + 0x9C, 0xAE, 0x52, 0x04, 0xCC, 0xD4, 0xC3, 0xCC, 0x1C, 0x8F, 0x49, 0x66, 0x95, 0x13, 0xE7, 0xBC, + 0xD9, 0xA1, 0xD2, 0x90, 0x96, 0xD3, 0x0A, 0x68, 0xB2, 0x9D, 0xAE, 0xCA, 0xCC, 0xE1, 0x2A, 0x25, + 0xF4, 0xC5, 0x24, 0x24, 0x6D, 0x22, 0x54, 0x61, 0xE5, 0x21, 0x12, 0x15, 0x31, 0x26, 0x23, 0xC7, + 0x65, 0x8B, 0xB8, 0x29, 0x13, 0xFF, 0x72, 0x33, 0x2B, 0x44, 0x2E, 0x2B, 0xDD, 0x6D, 0x25, 0x74, + 0x64, 0x6E, 0x74, 0xD0, 0xB7, 0x1D, 0x57, 0xF8, 0x70, 0x9C, 0x56, 0x49, 0x8F, 0x27, 0x6C, 0x99, + 0xA4, 0x4A, 0x43, 0x60, 0xA8, 0x46, 0x14, 0x19, 0xC8, 0x01, 0x5B, 0xAD, 0x2B, 0x34, 0x41, 0x15, + 0x5D, 0x5A, 0x39, 0xE0, 0xAB, 0x4D, 0x7C, 0x61, 0xB0, 0x99, 0xB6, 0xDE, 0xB2, 0xC1, 0xC5, 0x37, + 0x6A, 0x01, 0xC9, 0x7E, 0x53, 0x45, 0x73, 0x4F, 0xF9, 0x63, 0x06, 0x91, 0xE1, 0x40, 0x1C, 0x6C, + 0xB7, 0x8A, 0xB7, 0x2A, 0x1B, 0x42, 0xCE, 0x0E, 0x85, 0xFD, 0x71, 0x67, 0x87, 0xD1, 0x56, 0xBE, + 0x33, 0xDC, 0x24, 0x27, 0x6E, 0xA3, 0xE3, 0xFD, 0x8C, 0x2C, 0xC3, 0xF3, 0xCE, 0x6B, 0xB8, 0xD9, + 0xAB, 0x16, 0xDF, 0x55, 0x77, 0x36, 0x36, 0xAF, 0x35, 0x73, 0x7C, 0x5E, 0xB3, 0x9C, 0xA9, 0x93, + 0xB8, 0x47, 0xEF, 0x33, 0x2D, 0xC3, 0x68, 0x7F, 0x5E, 0x8B, 0xAD, 0x38, 0xD6, 0x28, 0x54, 0x74, + 0xA9, 0x36, 0x78, 0xF2, 0xCD, 0xC9, 0xD1, 0x51, 0xFF, 0xD9, 0x13, 0x7B, 0xE8, 0x2D, 0xF8, 0xFF, + 0x1F, 0xD9, 0x02, 0xAD, 0x47, 0x7C, 0x1F, 0x6C, 0xCE, 0x3B, 0x3B, 0xA4, 0xD8, 0x12, 0x14, 0x1C, + 0x02, 0x09, 0x29, 0x44, 0xF1, 0x6C, 0x50, 0x46, 0x57, 0xD0, 0xC4, 0x83, 0x04, 0x67, 0x68, 0xB8, + 0x92, 0x26, 0xB4, 0x19, 0x9B, 0x6B, 0xD0, 0x18, 0x52, 0xA3, 0xCA, 0x18, 0x3A, 0x37, 0x49, 0xD2, + 0x29, 0x37, 0x5C, 0x53, 0xBC, 0x15, 0x19, 0xA7, 0x21, 0x04, 0x30, 0x0A, 0x8E, 0xEB, 0xAC, 0xD0, + 0x46, 0xDA, 0x28, 0x26, 0x7B, 0x6C, 0x7C, 0x33, 0xB2, 0xBE, 0x04, 0x4A, 0xAF, 0x05, 0xDA, 0xB0, + 0x1D, 0x9F, 0x8D, 0x24, 0x29, 0x5D, 0xC5, 0x58, 0xE5, 0x30, 0xC2, 0x6A, 0x21, 0xE3, 0x02, 0x44, + 0xDB, 0xA4, 0xD8, 0xD9, 0xB5, 0x6C, 0x4C, 0x14, 0x9B, 0xA0, 0xD0, 0x00, 0xB8, 0x36, 0xF8, 0xF9, + 0xE2, 0xBB, 0x7F, 0x6A, 0xEF, 0xDE, 0xFE, 0x29, 0xD5, 0x50, 0x1E, 0x51, 0x18, 0x9C, 0x15, 0x7A, + 0xA6, 0x60, 0x4C, 0x1F, 0x81, 0x4C, 0x6A, 0x5C, 0x33, 0x14, 0x03, 0x26, 0x43, 0x16, 0xB1, 0xA7, + 0xFE, 0xEC, 0xBC, 0xA6, 0xD7, 0x70, 0x77, 0x4B, 0x70, 0xD6, 0xAE, 0x69, 0x18, 0xB8, 0xE9, 0xC1, + 0xB5, 0x61, 0x2D, 0xF1, 0xA8, 0xA5, 0xC2, 0xEB, 0xBA, 0x69, 0x49, 0x9B, 0xF1, 0x88, 0x12, 0xCA, + 0x58, 0x88, 0xC0, 0x71, 0x29, 0xD7, 0x06, 0x57, 0xC4, 0x3F, 0x3B, 0x64, 0xB7, 0x72, 0xB4, 0x96, + 0xDD, 0x37, 0xB8, 0x30, 0x33, 0x87, 0x2C, 0x13, 0xCA, 0x52, 0xFC, 0xC4, 0x35, 0xE6, 0x04, 0xA5, + 0xA2, 0xA4, 0x79, 0x51, 0xEB, 0x21, 0x64, 0x6D, 0xF0, 0x81, 0xD0, 0x2C, 0x03, 0xC8, 0x50, 0x52, + 0xFC, 0x19, 0x4F, 0xE1, 0x63, 0xFD, 0x87, 0xF6, 0xCC, 0x97, 0xEC, 0x9A, 0x06, 0x33, 0x73, 0x05, + 0xB9, 0x3F, 0x6E, 0x36, 0xB5, 0xDE, 0xBB, 0xF7, 0x5A, 0xB3, 0xA9, 0xD0, 0xD8, 0x59, 0x50, 0x77, + 0x0A, 0xF4, 0x0F, 0x16, 0xC2, 0xA8, 0x21, 0x54, 0x3F, 0xEC, 0xA8, 0x36, 0xF8, 0xE1, 0xEA, 0xE7, + 0x37, 0x2F, 0x1A, 0xED, 0x5E, 0xBF, 0x75, 0xA3, 0x9F, 0xB4, 0x5B, 0x7B, 0x67, 0x87, 0x0C, 0xAE, + 0x78, 0x07, 0x60, 0x60, 0xEF, 0xB5, 0xD7, 0x6F, 0x5F, 0x35, 0xF4, 0xD6, 0x71, 0x55, 0x64, 0xFA, + 0x49, 0x6D, 0xF0, 0xD3, 0x0F, 0x11, 0x65, 0xFD, 0x56, 0x15, 0x64, 0xC7, 0xC0, 0x26, 0xD0, 0xC5, + 0x50, 0x75, 0xBB, 0x85, 0x50, 0xA1, 0xC8, 0x3B, 0xE5, 0x44, 0xAE, 0x1F, 0x41, 0xBF, 0x94, 0x87, + 0x56, 0xF7, 0xF8, 0x46, 0xEF, 0xF5, 0xBB, 0x15, 0x78, 0xE8, 0xA3, 0x74, 0x81, 0x90, 0xC6, 0x71, + 0xBF, 0x5B, 0x15, 0x57, 0x0F, 0x71, 0x81, 0x40, 0x8E, 0xDA, 0x20, 0x8F, 0xF6, 0x71, 0x15, 0xD1, + 0x76, 0x6B, 0x03, 0xAA, 0xF2, 0x13, 0x44, 0xD5, 0x2A, 0x86, 0x0A, 0x45, 0xDB, 0x2E, 0x29, 0xDA, + 0x4E, 0x6D, 0xF0, 0x23, 0x8A, 0x16, 0x2D, 0x03, 0x78, 0xA8, 0x64, 0x1E, 0x6D, 0x88, 0x52, 0x14, + 0x57, 0x1B, 0xED, 0xB6, 0xD5, 0xAE, 0x22, 0x5A, 0xBD, 0x36, 0x40, 0x71, 0x20, 0xA6, 0xA3, 0x4A, + 0x0E, 0x00, 0xDE, 0x44, 0x69, 0x02, 0x72, 0x6E, 0x8E, 0xFA, 0xC7, 0xE5, 0x31, 0x81, 0x27, 0x5D, + 0x7D, 0x02, 0x4C, 0xC7, 0x20, 0xA8, 0x4A, 0x6E, 0x04, 0x5E, 0x84, 0x78, 0xFA, 0xDD, 0xD6, 0x4D, + 0xB7, 0x8A, 0xCD, 0x80, 0x57, 0xBC, 0x45, 0x44, 0x80, 0xE4, 0xA6, 0x53, 0x45, 0x46, 0xE0, 0x12, + 0x17, 0xDF, 0xBE, 0x6E, 0x74, 0x81, 0xB1, 0xF6, 0x49, 0xBF, 0x3C, 0x1E, 0x70, 0x87, 0x1F, 0x90, + 0x20, 0x20, 0xE6, 0xA6, 0x5D, 0x2C, 0x3A, 0xC4, 0x11, 0x81, 0x33, 0x00, 0x3C, 0xE2, 0x28, 0x8D, + 0x02, 0xEC, 0xFA, 0x2D, 0x25, 0x06, 0x11, 0xE9, 0x47, 0x15, 0xB8, 0x02, 0xAB, 0xFE, 0x01, 0xC5, + 0x03, 0x48, 0x30, 0xE8, 0x55, 0x30, 0x45, 0x40, 0x44, 0x49, 0xD2, 0xFB, 0xD4, 0xD5, 0xCA, 0x63, + 0x02, 0x9B, 0x3E, 0xE9, 0xDF, 0x9C, 0xF4, 0xD5, 0x10, 0xE0, 0x88, 0x8F, 0xA3, 0x54, 0x56, 0x4E, + 0x90, 0x9D, 0x32, 0x64, 0xA5, 0x03, 0xFF, 0x5E, 0x1A, 0x16, 0xCC, 0x6F, 0x0A, 0x27, 0x03, 0x1C, + 0x0E, 0x64, 0xC2, 0x0E, 0xD4, 0xF2, 0x00, 0x81, 0x92, 0x70, 0xA3, 0x59, 0x6D, 0xD0, 0x55, 0xC8, + 0xB7, 0x62, 0x09, 0x39, 0x85, 0x8D, 0xD1, 0x4F, 0x93, 0x40, 0xB4, 0x3C, 0x4C, 0xFF, 0xC0, 0x25, + 0x3A, 0x35, 0x21, 0x82, 0x94, 0x4A, 0x34, 0x24, 0xB4, 0x1A, 0x37, 0xB5, 0x41, 0xBF, 0x93, 0x9B, + 0xA0, 0x95, 0x57, 0xC6, 0x90, 0xD6, 0x68, 0x6C, 0xE2, 0x79, 0x85, 0xF5, 0x11, 0x81, 0xD6, 0x06, + 0x2F, 0xC3, 0xE3, 0x2A, 0x5A, 0x69, 0xE6, 0x71, 0x4A, 0x61, 0x53, 0xD4, 0x22, 0x90, 0xC3, 0x34, + 0xD3, 0xEC, 0x70, 0xD5, 0x44, 0x9A, 0xD9, 0xAC, 0x62, 0xB6, 0xA9, 0x17, 0x9C, 0x4E, 0xBA, 0x86, + 0xE7, 0x17, 0xD6, 0x4A, 0x00, 0x08, 0x11, 0x9A, 0x1F, 0xDD, 0x9B, 0x46, 0x42, 0x52, 0xFE, 0x06, + 0xFA, 0xF0, 0x0C, 0x7F, 0xC9, 0xAA, 0x85, 0x85, 0x35, 0x12, 0x81, 0x42, 0x3E, 0x10, 0x1E, 0x57, + 0xD2, 0x4A, 0x95, 0xF0, 0x25, 0x90, 0xC3, 0xF5, 0x12, 0x84, 0xB0, 0xEE, 0x96, 0xF4, 0x92, 0x47, + 0x6D, 0x25, 0xBD, 0xCC, 0x0C, 0x77, 0x51, 0x2A, 0x7C, 0x85, 0x90, 0xA0, 0x95, 0xE0, 0xF0, 0xDE, + 0x5C, 0x25, 0x22, 0xE6, 0x6F, 0xE0, 0x2B, 0x63, 0x62, 0x3B, 0xA6, 0x57, 0x7C, 0xB6, 0xCF, 0xE1, + 0x6A, 0x83, 0x57, 0xA4, 0xF9, 0x3D, 0x1E, 0x55, 0x51, 0xC7, 0x8B, 0xA5, 0xEF, 0x54, 0x50, 0x48, + 0x40, 0x0B, 0x53, 0x47, 0x8B, 0x6B, 0xE3, 0x78, 0x4B, 0xDA, 0x38, 0xDE, 0xA2, 0x36, 0x0C, 0xF2, + 0xD9, 0x22, 0xD7, 0xC4, 0x2A, 0xAC, 0x8E, 0x00, 0xB0, 0x36, 0xB8, 0xBC, 0x59, 0x38, 0x1E, 0x3E, + 0x3A, 0xF5, 0x1D, 0x9E, 0x57, 0x72, 0x92, 0x5E, 0x05, 0x9D, 0x84, 0x04, 0x71, 0x1F, 0xE9, 0x71, + 0xAD, 0xF4, 0xB6, 0xA4, 0x95, 0x3C, 0x5A, 0xAB, 0x68, 0x65, 0x6A, 0x98, 0xF6, 0x88, 0x98, 0x16, + 0x3E, 0xC6, 0x51, 0x54, 0x31, 0x02, 0x6C, 0x6D, 0xF0, 0x26, 0x3A, 0xA9, 0xA2, 0x98, 0x56, 0x05, + 0xBD, 0x88, 0xF4, 0xC4, 0xFD, 0xA5, 0x07, 0xB3, 0xF2, 0x2D, 0xE9, 0x46, 0xD7, 0xB7, 0x39, 0xAA, + 0x2C, 0xC8, 0xC8, 0x34, 0xAC, 0xCF, 0x64, 0x32, 0x81, 0x69, 0x50, 0xF1, 0xA1, 0x25, 0x06, 0x0E, + 0xE3, 0x0B, 0x3B, 0xD7, 0x2E, 0xE9, 0x79, 0xE1, 0xFA, 0x65, 0x02, 0x5D, 0xF9, 0x22, 0x66, 0x72, + 0x4E, 0x28, 0x2D, 0x4B, 0x7E, 0xEF, 0x84, 0x74, 0x96, 0x9F, 0xB6, 0x7E, 0x4F, 0xA6, 0x74, 0x1B, + 0x41, 0x95, 0x39, 0xF4, 0x1B, 0xD7, 0xB8, 0xA5, 0xEF, 0x64, 0xA8, 0x32, 0xA5, 0xFF, 0x40, 0xC6, + 0xDA, 0x47, 0xD3, 0x2E, 0xCF, 0x4C, 0x17, 0x09, 0x21, 0xC4, 0xAE, 0x86, 0xA5, 0x07, 0x53, 0x24, + 0x38, 0xA8, 0x86, 0xA4, 0x8F, 0x35, 0xFD, 0x85, 0x69, 0x3C, 0x84, 0x49, 0xBC, 0xB1, 0x1A, 0x16, + 0x1F, 0x50, 0x56, 0x43, 0x18, 0x97, 0x7F, 0x7A, 0xA9, 0x5D, 0xD2, 0x8D, 0xEF, 0x85, 0xC3, 0x15, + 0xDB, 0x93, 0xA7, 0x62, 0xE8, 0xD1, 0xD2, 0x0D, 0xF6, 0xB9, 0xB6, 0xA6, 0x26, 0x77, 0x20, 0xD5, + 0x75, 0x35, 0x09, 0x7B, 0x01, 0x81, 0x74, 0x0B, 0x53, 0x4D, 0xE0, 0x56, 0x8D, 0xC7, 0x2D, 0xA6, + 0x62, 0xA3, 0x55, 0xF1, 0x34, 0x6C, 0xB4, 0x02, 0x35, 0x8D, 0xAF, 0xF1, 0x99, 0x88, 0xB1, 0x06, + 0xFA, 0xDA, 0x89, 0xA2, 0xB0, 0xD7, 0xFB, 0x51, 0x14, 0xE5, 0xF7, 0xBE, 0x15, 0x05, 0xD6, 0xF2, + 0x19, 0xC7, 0xD1, 0x32, 0x4E, 0x45, 0x01, 0x6B, 0x83, 0x77, 0x86, 0xBD, 0x84, 0x41, 0x66, 0x57, + 0x0A, 0x0B, 0x3B, 0xBE, 0x37, 0xF7, 0xE2, 0x7C, 0xDF, 0xB7, 0xEA, 0x80, 0x90, 0xB9, 0x33, 0x2E, + 0x3E, 0xDD, 0xE1, 0x70, 0x2C, 0x24, 0xBE, 0x83, 0xA3, 0xC2, 0x89, 0x41, 0x80, 0x61, 0xCB, 0x19, + 0x01, 0x9B, 0x4A, 0x95, 0x4F, 0x06, 0xAE, 0x96, 0xB6, 0x7D, 0x5B, 0x25, 0x13, 0xB8, 0xB0, 0x9C, + 0xE5, 0xB8, 0x3C, 0x06, 0x48, 0x03, 0xFE, 0x35, 0x99, 0x98, 0xA3, 0xF2, 0x89, 0x04, 0x24, 0x01, + 0x6F, 0x9D, 0xB9, 0x22, 0xFC, 0x96, 0x07, 0x5E, 0x32, 0x2A, 0x31, 0x93, 0x1B, 0x81, 0x16, 0x2F, + 0x2F, 0x76, 0x3A, 0xF0, 0x42, 0x9F, 0xF7, 0x14, 0x19, 0x90, 0xDB, 0xFB, 0x0E, 0x0A, 0x40, 0xC4, + 0x67, 0x6A, 0x3C, 0x65, 0x94, 0xC5, 0x20, 0xC3, 0x88, 0x1E, 0x4C, 0xBF, 0xEF, 0x6B, 0x7E, 0x17, + 0x51, 0x14, 0x9F, 0xDD, 0xE1, 0xD2, 0x73, 0x38, 0xBD, 0xEB, 0xB4, 0x37, 0x3B, 0xC1, 0x43, 0xE4, + 0xDB, 0xD5, 0x4F, 0xBB, 0x8C, 0x6A, 0x20, 0x1A, 0x7D, 0x8F, 0xEB, 0x0C, 0x05, 0x02, 0x76, 0x75, + 0x47, 0x6A, 0xDF, 0x9F, 0x27, 0xB5, 0x1F, 0x80, 0x2B, 0x4D, 0x4B, 0x44, 0xBC, 0x29, 0x46, 0xBC, + 0x37, 0x17, 0xBB, 0xD1, 0xD0, 0xF4, 0xDE, 0x42, 0xDD, 0xF4, 0x5E, 0x43, 0x9D, 0xC6, 0x37, 0x05, + 0x06, 0x52, 0x28, 0x99, 0xC1, 0x72, 0x40, 0x56, 0xCB, 0xAA, 0x12, 0xE4, 0xF4, 0x9B, 0x2A, 0x51, + 0x2E, 0x20, 0x23, 0x1E, 0xE4, 0xFA, 0xD1, 0xAA, 0x48, 0x6F, 0xB3, 0xCB, 0xBA, 0xDD, 0x3C, 0x6A, + 0xAB, 0x38, 0x8D, 0x6B, 0xAC, 0x3E, 0x4F, 0xE7, 0x46, 0x61, 0x65, 0x70, 0x38, 0xD0, 0xC5, 0xBB, + 0x17, 0xBB, 0x4C, 0x17, 0x82, 0x7E, 0xEF, 0xC7, 0x8F, 0x42, 0xAE, 0xEF, 0x3B, 0xD6, 0x59, 0xC4, + 0x2E, 0x1E, 0xEC, 0x10, 0xA8, 0x36, 0xF8, 0x8E, 0xD8, 0x9E, 0x76, 0xE1, 0xB8, 0xFC, 0x45, 0x98, + 0x3B, 0xD1, 0x1A, 0xED, 0xF9, 0x7E, 0x54, 0xC6, 0x98, 0xBE, 0x6F, 0x7D, 0xCD, 0xE6, 0xA6, 0xEB, + 0x3A, 0x6E, 0x61, 0x95, 0x71, 0x38, 0x98, 0x56, 0x34, 0xDF, 0xD1, 0xA3, 0x9D, 0xA8, 0x2B, 0xE8, + 0xF5, 0x7E, 0x34, 0x16, 0xF2, 0x7C, 0xDF, 0x4A, 0xBB, 0x9E, 0x58, 0xE6, 0xA2, 0xB0, 0xCA, 0x28, + 0x54, 0x6D, 0xF0, 0xA9, 0xF9, 0x1A, 0xFE, 0xEE, 0x44, 0x5D, 0xAC, 0xC7, 0xFB, 0x51, 0x16, 0xE7, + 0xF6, 0xBE, 0x55, 0x35, 0x5C, 0x14, 0x0F, 0x87, 0x00, 0x53, 0x1B, 0xBC, 0x7C, 0xBF, 0x9B, 0xDC, + 0x0F, 0x3B, 0x53, 0xD4, 0x50, 0x25, 0x7D, 0x50, 0xA6, 0xEE, 0x5B, 0x1B, 0xAB, 0x12, 0xDA, 0x58, + 0x21, 0xE1, 0x3F, 0xED, 0x48, 0x1B, 0x2B, 0x75, 0x6D, 0x6C, 0xD8, 0x5F, 0x56, 0x0F, 0x41, 0x3F, + 0xF4, 0xE9, 0xD3, 0xA1, 0x51, 0x7C, 0x38, 0x0A, 0x00, 0x71, 0xD3, 0x18, 0x1C, 0x69, 0x2F, 0x8D, + 0xDD, 0x0C, 0x48, 0x61, 0xBF, 0xBB, 0x70, 0xA1, 0x88, 0xC9, 0xFB, 0xD6, 0xD3, 0xC4, 0x18, 0x91, + 0xCF, 0x63, 0xE2, 0x97, 0x59, 0x5B, 0x16, 0x60, 0x6B, 0x83, 0xD7, 0x70, 0xA2, 0xBD, 0xA2, 0x27, + 0xBB, 0x4A, 0xF9, 0xC4, 0xFE, 0x77, 0xA1, 0xB5, 0x18, 0xBF, 0x0F, 0x42, 0x71, 0x90, 0x60, 0x3B, + 0x53, 0xBB, 0xD4, 0xE3, 0x4C, 0x31, 0x70, 0xAE, 0xBE, 0x0F, 0xEC, 0x7C, 0xB7, 0x0A, 0x8C, 0x88, + 0xD8, 0x99, 0x0E, 0x05, 0xBE, 0x37, 0xA8, 0x46, 0xC5, 0xA7, 0x1A, 0xF9, 0x9B, 0x81, 0xF3, 0x74, + 0xC5, 0x9F, 0xAE, 0xA3, 0x9B, 0x5A, 0x88, 0xDF, 0xF4, 0x7C, 0xD3, 0xB2, 0x60, 0x2A, 0x4C, 0x7C, + 0xED, 0x0A, 0x0F, 0x15, 0x1F, 0xA7, 0x13, 0xB0, 0x04, 0x0F, 0xD1, 0xFA, 0x2E, 0x31, 0xE6, 0xB5, + 0xC1, 0x15, 0xBE, 0x33, 0x19, 0x70, 0xE1, 0x59, 0x71, 0x64, 0x54, 0x8C, 0xC4, 0x76, 0x1D, 0x20, + 0x2A, 0x54, 0x13, 0x7F, 0x3F, 0x65, 0x4D, 0x0B, 0x8E, 0x84, 0x6B, 0x83, 0x4B, 0xDA, 0x58, 0x43, + 0x3B, 0xCB, 0xEF, 0x4E, 0xF9, 0x39, 0x3F, 0xFA, 0x44, 0x2F, 0x3E, 0xA2, 0x1B, 0x7F, 0xA3, 0x3A, + 0xE8, 0x95, 0xBD, 0xD5, 0x60, 0x70, 0x46, 0xDF, 0x0E, 0xCB, 0x9B, 0xD1, 0x87, 0xD9, 0x57, 0xFC, + 0xE9, 0xE4, 0xA1, 0x63, 0x8D, 0x9F, 0x09, 0xAB, 0xCB, 0x57, 0xE1, 0xE3, 0xB6, 0x08, 0x02, 0x86, + 0x11, 0x60, 0xC8, 0x51, 0xFE, 0xCC, 0x0D, 0xD0, 0xB3, 0x27, 0xA2, 0xF1, 0x1D, 0x65, 0x19, 0xCA, + 0x4D, 0x79, 0x34, 0xD8, 0x25, 0xD3, 0x50, 0x90, 0xB2, 0x27, 0xC6, 0xA5, 0x0F, 0x0A, 0x7F, 0x20, + 0x53, 0xD3, 0x03, 0x1A, 0x35, 0x30, 0x8B, 0x43, 0xFA, 0x8C, 0x25, 0x33, 0x66, 0xB5, 0xE7, 0x77, + 0xC5, 0x2E, 0xF9, 0xCB, 0x19, 0xA4, 0x8F, 0x63, 0x17, 0xCA, 0x4F, 0x92, 0x0F, 0x4F, 0xC7, 0x31, + 0xE6, 0x19, 0xFD, 0xE3, 0x66, 0x73, 0xD6, 0xC5, 0xA7, 0x45, 0xB5, 0x80, 0xB5, 0xB3, 0xC3, 0x59, + 0x37, 0xEF, 0xD1, 0xB0, 0xDC, 0x47, 0x7D, 0x81, 0xD3, 0xD2, 0x4F, 0xFA, 0xA2, 0x94, 0x06, 0x40, + 0xCD, 0xBE, 0xF6, 0xCE, 0xF0, 0xBE, 0xEC, 0x6B, 0x9F, 0xB0, 0x00, 0xB7, 0xC3, 0x07, 0x7E, 0x91, + 0x76, 0x63, 0x3C, 0x76, 0x53, 0x1F, 0xFA, 0xED, 0xC6, 0x1E, 0xFA, 0xED, 0x07, 0x0F, 0xFD, 0xF6, + 0xA3, 0xDD, 0x6E, 0x37, 0x9D, 0x56, 0xEB, 0x58, 0x85, 0x75, 0xC5, 0x07, 0x7F, 0x37, 0xC2, 0xD3, + 0x1C, 0xA4, 0xA9, 0xC8, 0x53, 0x37, 0xE0, 0x49, 0xD8, 0x15, 0x7E, 0x33, 0x99, 0x3C, 0x34, 0x8E, + 0xF8, 0xBA, 0x54, 0x79, 0x96, 0x5A, 0xED, 0x5D, 0x3F, 0x9D, 0x4D, 0x8D, 0x7B, 0x53, 0x0F, 0x67, + 0xD3, 0x26, 0xC9, 0x68, 0xD8, 0xCB, 0x0C, 0x86, 0x14, 0x84, 0x39, 0xFD, 0x9B, 0x4D, 0x3A, 0xFD, + 0xB4, 0x82, 0xD3, 0x4F, 0xD7, 0x9C, 0x7E, 0x87, 0xDE, 0x1E, 0x10, 0xFE, 0x77, 0xF3, 0xF8, 0x80, + 0xAF, 0x02, 0x5E, 0x2F, 0xE5, 0xAB, 0xD5, 0xDA, 0xA8, 0xDF, 0xE7, 0x3A, 0x49, 0x68, 0x0C, 0x6F, + 0x36, 0xE9, 0x24, 0x29, 0xA6, 0x5B, 0xCA, 0x4E, 0x79, 0xD8, 0x19, 0xEC, 0x66, 0x5C, 0xA2, 0xD9, + 0x94, 0xA8, 0x50, 0xDE, 0x3B, 0x3E, 0x13, 0xDA, 0xE9, 0xF2, 0xD4, 0x69, 0x13, 0xEA, 0x51, 0x7F, + 0x11, 0x44, 0x6A, 0x93, 0xCD, 0x24, 0x66, 0x0B, 0x21, 0xC3, 0x55, 0x4E, 0xCC, 0xDE, 0x7F, 0xF7, + 0x5D, 0xB1, 0x5C, 0x4C, 0xEC, 0xE5, 0x81, 0xE4, 0x62, 0x99, 0xB5, 0xD0, 0xDB, 0x05, 0xDC, 0x40, + 0xAA, 0x4B, 0x99, 0x6E, 0x04, 0x5E, 0x1B, 0xBC, 0xA4, 0xC7, 0x9A, 0x20, 0xB1, 0x42, 0xC6, 0xAB, + 0x3C, 0xED, 0xA4, 0x80, 0x42, 0xB1, 0x34, 0x22, 0x21, 0xA9, 0x1B, 0x45, 0x5C, 0x19, 0x05, 0x52, + 0x81, 0x3D, 0x75, 0xA6, 0x2A, 0xFB, 0x04, 0x6D, 0x92, 0x97, 0x0A, 0x2F, 0x5C, 0x52, 0x5A, 0x6D, + 0x1C, 0xB6, 0x36, 0x78, 0xEF, 0x12, 0xED, 0x95, 0x79, 0xAD, 0xCE, 0x9B, 0xB0, 0x51, 0x30, 0x44, + 0xA2, 0x26, 0xE5, 0xE4, 0x0E, 0x3E, 0xE9, 0xAE, 0x40, 0x5C, 0x6C, 0x57, 0xDD, 0x4E, 0x27, 0xC1, + 0x0A, 0x69, 0x57, 0xBB, 0x1A, 0x86, 0x4E, 0x6D, 0xD0, 0xA9, 0x86, 0xA1, 0x5B, 0x1B, 0x74, 0xAB, + 0x61, 0xE8, 0x81, 0x1C, 0x0E, 0x7A, 0xD5, 0x70, 0xF4, 0x6B, 0x83, 0x7E, 0x35, 0x0C, 0x47, 0x20, + 0xCB, 0xAA, 0x54, 0x40, 0xE6, 0x72, 0x5C, 0x00, 0x43, 0xFE, 0x26, 0x47, 0xD6, 0xAA, 0xBA, 0xF3, + 0xCC, 0x97, 0x56, 0x69, 0xE7, 0xE1, 0xB0, 0xB5, 0xC1, 0xBB, 0xA5, 0xE5, 0x9B, 0x0B, 0xCB, 0x84, + 0x69, 0x7B, 0xA3, 0xAB, 0x35, 0xB5, 0x76, 0xAF, 0xBD, 0xB7, 0xC3, 0x0C, 0x33, 0xA0, 0x43, 0xED, + 0x1D, 0x52, 0x9D, 0x20, 0x09, 0xD3, 0x8F, 0xC5, 0x77, 0x0A, 0x3C, 0x88, 0x70, 0xE6, 0x3A, 0x8E, + 0x5F, 0x5A, 0x1D, 0x01, 0x30, 0xA4, 0xF9, 0x70, 0x54, 0x3A, 0x9A, 0x45, 0x68, 0xCA, 0x18, 0x7A, + 0xCA, 0x26, 0xE7, 0x8A, 0xE1, 0x4C, 0x2F, 0x16, 0xCE, 0x76, 0xE7, 0x3E, 0xDE, 0x6D, 0xF9, 0x94, + 0x81, 0xC3, 0xC2, 0x6C, 0xF5, 0x16, 0x66, 0x88, 0x73, 0x54, 0x98, 0xD6, 0x68, 0x81, 0xFB, 0xE8, + 0xBD, 0x5D, 0x7A, 0x4F, 0x40, 0x46, 0xC1, 0x37, 0xB0, 0x89, 0xDE, 0xF3, 0x30, 0x9C, 0x87, 0xEA, + 0x83, 0x58, 0xE3, 0x5E, 0x79, 0x8D, 0x04, 0xD0, 0x90, 0x0F, 0xE0, 0x7B, 0xF0, 0x2A, 0xF9, 0x91, + 0x80, 0xAC, 0x9C, 0x23, 0x55, 0x77, 0x1A, 0xA9, 0x2B, 0x56, 0xCC, 0x0B, 0xDA, 0x95, 0x47, 0xF5, + 0xCE, 0x43, 0x1C, 0x0B, 0x17, 0xF8, 0xEA, 0x44, 0xA2, 0xB6, 0xBD, 0x92, 0x22, 0x13, 0x93, 0x48, + 0x06, 0xCB, 0x8D, 0x86, 0x6D, 0x2B, 0xDF, 0x69, 0xE6, 0x1F, 0x10, 0xB0, 0xBE, 0xDA, 0x54, 0x7C, + 0x15, 0x5E, 0x60, 0x4E, 0x36, 0x11, 0x08, 0x79, 0x7D, 0x70, 0xB3, 0x00, 0x24, 0xAC, 0xF4, 0x34, + 0x80, 0x03, 0x73, 0x15, 0x86, 0x91, 0xB8, 0xA3, 0x17, 0x88, 0xC4, 0xE2, 0x8C, 0x20, 0xC4, 0x57, + 0x72, 0xB4, 0xBB, 0xF7, 0xF4, 0xBF, 0x2B, 0x0F, 0x1E, 0x15, 0x5D, 0xBF, 0x60, 0x1A, 0x2C, 0xC1, + 0x80, 0xEF, 0x0B, 0xD4, 0x8B, 0x24, 0xF4, 0x9B, 0x0B, 0x1F, 0xC2, 0x5A, 0x21, 0x35, 0x38, 0x3A, + 0x74, 0x13, 0x9F, 0xCD, 0xF3, 0x0B, 0x2C, 0xFF, 0xA5, 0x36, 0xD9, 0x4C, 0x75, 0x67, 0x65, 0xDA, + 0xC5, 0xAB, 0x3B, 0x3F, 0x99, 0xF6, 0xD8, 0x59, 0x15, 0x2B, 0xF0, 0x88, 0x1D, 0xFD, 0x05, 0x0A, + 0x3C, 0x34, 0x3D, 0xC0, 0x15, 0xE2, 0xA6, 0x4B, 0xD4, 0xDE, 0x67, 0x93, 0x14, 0x32, 0x83, 0xBE, + 0xC1, 0x05, 0x56, 0x40, 0xE1, 0x69, 0x74, 0xBD, 0x79, 0xDB, 0x99, 0xDA, 0xCF, 0xA7, 0x62, 0xAE, + 0xC6, 0x29, 0x50, 0xCB, 0xD5, 0xBA, 0x92, 0x72, 0xF3, 0xBD, 0x57, 0xD0, 0x7F, 0x59, 0xE7, 0xE7, + 0xF6, 0xDE, 0xF9, 0xD9, 0xC4, 0x00, 0x44, 0xEC, 0x71, 0x69, 0xCB, 0x42, 0xD8, 0xC8, 0xAE, 0x2E, + 0xED, 0xF1, 0x4E, 0xAD, 0x8A, 0xF5, 0x5E, 0x5A, 0x07, 0xED, 0x7E, 0xBB, 0xF3, 0xB0, 0xCC, 0x0A, + 0x19, 0xAA, 0x60, 0x54, 0xFA, 0x49, 0xEF, 0x01, 0x4D, 0x69, 0x9C, 0xC9, 0x84, 0xAD, 0x6B, 0x96, + 0x33, 0x2D, 0x0E, 0x7E, 0x43, 0x9F, 0xD2, 0xF5, 0xC8, 0x6E, 0xE3, 0x55, 0xD8, 0x79, 0xC1, 0xD2, + 0x8C, 0xA0, 0x8B, 0xFE, 0xC3, 0x32, 0x2D, 0xCE, 0x91, 0xAA, 0x75, 0x49, 0x38, 0xEA, 0x3E, 0x1C, + 0xD3, 0xF2, 0x1D, 0xDF, 0xB0, 0x4A, 0x5B, 0x16, 0x83, 0x06, 0xC3, 0xFA, 0x88, 0x07, 0xDA, 0x15, + 0xF0, 0xB9, 0x53, 0xE3, 0x0A, 0xFA, 0x2F, 0x1F, 0xB8, 0x8E, 0xBB, 0x1B, 0x52, 0x46, 0x05, 0x96, + 0x7E, 0x59, 0x67, 0xA9, 0x52, 0xE8, 0xEA, 0x6F, 0x68, 0x91, 0x7C, 0x23, 0xA1, 0x6B, 0xE9, 0xE3, + 0xD5, 0xD2, 0xA1, 0x8B, 0x81, 0x63, 0xE8, 0xA2, 0x47, 0xBB, 0x37, 0xB1, 0x90, 0x82, 0xF2, 0x36, + 0xD6, 0x3B, 0xD9, 0xE4, 0x16, 0x98, 0x4D, 0x44, 0x30, 0xC6, 0x53, 0x25, 0x23, 0xDB, 0x94, 0xDF, + 0x54, 0x36, 0xB2, 0x91, 0xA1, 0xFC, 0x8E, 0x37, 0x8A, 0x4C, 0xCC, 0xE6, 0x19, 0x2C, 0xCC, 0xE1, + 0xD8, 0xC1, 0x4E, 0x2B, 0x36, 0x41, 0xE7, 0x1B, 0x5F, 0xA8, 0x0D, 0xB9, 0x7A, 0x48, 0xF5, 0x99, + 0xA1, 0x69, 0xDB, 0x65, 0xD5, 0xC4, 0x61, 0x6B, 0x83, 0x97, 0xEC, 0x60, 0xB7, 0x4B, 0xEA, 0xBC, + 0xF3, 0xCD, 0xAF, 0xA7, 0x07, 0x5C, 0xED, 0x5A, 0x4D, 0x89, 0x22, 0x86, 0x1B, 0x7E, 0xF7, 0xA3, + 0xC6, 0xF7, 0xA8, 0x46, 0xDF, 0x01, 0x79, 0x38, 0x25, 0x8D, 0xA9, 0x31, 0xC7, 0x87, 0x97, 0x8B, + 0x16, 0x35, 0xDE, 0x20, 0x58, 0xB1, 0x9A, 0x46, 0xBC, 0xA7, 0x87, 0x5D, 0xD5, 0x18, 0xC4, 0xDF, + 0x5A, 0x09, 0x84, 0x37, 0x87, 0xA6, 0xE1, 0xE1, 0x83, 0xFE, 0x70, 0xAC, 0xBD, 0x84, 0x63, 0xED, + 0xBD, 0xB5, 0x0C, 0x5F, 0xBB, 0x2B, 0x73, 0x08, 0x71, 0x3F, 0x5B, 0x84, 0x21, 0xED, 0xA9, 0x05, + 0xBA, 0x8D, 0x8F, 0x3F, 0xE0, 0x05, 0xC7, 0xB8, 0x7B, 0xAD, 0xD7, 0x3D, 0x6E, 0xD5, 0x34, 0x96, + 0x15, 0xF3, 0xC7, 0xFA, 0xBD, 0x2F, 0x74, 0x5B, 0x9B, 0x1E, 0x12, 0x28, 0x73, 0x00, 0x91, 0xDE, + 0x90, 0x40, 0x6A, 0xBF, 0x55, 0x76, 0x9B, 0xAD, 0x4B, 0x44, 0x0F, 0xC4, 0xD1, 0x92, 0x1A, 0x42, + 0xEC, 0x3D, 0x9B, 0xAC, 0x7D, 0xFC, 0x05, 0x05, 0xED, 0x9E, 0xEC, 0xFD, 0xA7, 0x72, 0x41, 0xE8, + 0x52, 0x41, 0xE0, 0xEE, 0xBE, 0xCD, 0xF2, 0xD4, 0x0E, 0x78, 0xD2, 0xD5, 0x78, 0x6A, 0x57, 0xE0, + 0xA9, 0xBD, 0x23, 0x9E, 0x3A, 0x01, 0x4F, 0x6D, 0x35, 0x9E, 0x3A, 0x15, 0x78, 0xEA, 0xEC, 0x88, + 0xA7, 0x6E, 0xC0, 0x53, 0x47, 0x8D, 0xA7, 0x6E, 0x05, 0x9E, 0xBA, 0x3B, 0xE2, 0xA9, 0x17, 0xF0, + 0xD4, 0x55, 0xE3, 0xA9, 0x57, 0x81, 0xA7, 0xDE, 0x8E, 0x78, 0xEA, 0x07, 0x3C, 0xF5, 0xD4, 0x78, + 0xEA, 0x57, 0xE0, 0xA9, 0xBF, 0x23, 0x9E, 0x8E, 0x02, 0x9E, 0xFA, 0x6A, 0x3C, 0x1D, 0x55, 0xE0, + 0xE9, 0x68, 0x47, 0x3C, 0x1D, 0x07, 0x3C, 0x1D, 0xA9, 0xF1, 0x74, 0x5C, 0x81, 0xA7, 0xE3, 0x1D, + 0xF1, 0x74, 0x12, 0xF0, 0x74, 0xAC, 0xC6, 0xD3, 0x49, 0x05, 0x9E, 0x4E, 0x76, 0xC4, 0x13, 0xEE, + 0xA6, 0x62, 0x4C, 0x9D, 0x28, 0x0E, 0xBA, 0xAD, 0x0A, 0x5C, 0x19, 0xBB, 0xE2, 0x2A, 0x4C, 0x25, + 0x74, 0xD5, 0x5C, 0xA2, 0x4A, 0x32, 0x31, 0xDC, 0x15, 0x5B, 0x51, 0x36, 0xA1, 0x98, 0x4E, 0xE8, + 0x55, 0xF2, 0x89, 0xD1, 0xAE, 0xD8, 0x0A, 0x13, 0x0A, 0x5D, 0x31, 0xA3, 0xD0, 0xAB, 0xA4, 0x14, + 0xE3, 0x5D, 0xB1, 0x15, 0xE6, 0x14, 0xBA, 0x62, 0x52, 0xA1, 0x57, 0xC9, 0x2A, 0xC8, 0xAE, 0xD8, + 0x0A, 0xD3, 0x0A, 0x5D, 0x31, 0xAF, 0xD0, 0xAB, 0x24, 0x16, 0x93, 0x5D, 0xB1, 0x15, 0x66, 0x16, + 0xBA, 0x62, 0x6A, 0xA1, 0x57, 0xC8, 0x2D, 0x4E, 0xE4, 0x13, 0xB1, 0x8D, 0xB2, 0x45, 0x7C, 0x3E, + 0x45, 0x8E, 0x26, 0x6D, 0x4A, 0x0F, 0x1C, 0x71, 0x20, 0x7C, 0x22, 0x8E, 0x09, 0xE4, 0xC2, 0xB1, + 0x27, 0xE6, 0x34, 0x2C, 0x32, 0x3C, 0x98, 0x67, 0x63, 0x3C, 0xE1, 0x85, 0xBF, 0xCA, 0x85, 0x86, + 0xAB, 0x57, 0x97, 0xC5, 0xCA, 0x0C, 0x62, 0x2F, 0x7F, 0xA1, 0x22, 0x03, 0x90, 0xDD, 0x16, 0xBF, + 0x3E, 0xA0, 0x54, 0x57, 0xA0, 0x40, 0x45, 0x2A, 0x0A, 0x3D, 0xB1, 0xA2, 0xD0, 0x57, 0xAE, 0x28, + 0x30, 0xE2, 0xB6, 0x53, 0x4B, 0x00, 0xDC, 0x1D, 0xF6, 0xC9, 0x04, 0x75, 0xA6, 0x3B, 0xE5, 0x99, + 0xEE, 0x15, 0x61, 0xBA, 0x53, 0x86, 0xE9, 0x12, 0xCF, 0xB4, 0x2A, 0xCA, 0x09, 0xE8, 0x7D, 0x6D, + 0xDE, 0x90, 0xB1, 0xF6, 0x8B, 0xBA, 0xA8, 0xF4, 0xF2, 0xA2, 0x3A, 0x2A, 0x22, 0x2A, 0x7D, 0x8B, + 0xF6, 0xD1, 0x0B, 0xF8, 0xFE, 0x51, 0x9D, 0xEF, 0x5E, 0x79, 0xBE, 0x3B, 0x45, 0xF8, 0xEE, 0x6D, + 0x91, 0xEF, 0x6E, 0xC0, 0xF7, 0x27, 0x75, 0xBE, 0xBB, 0xE5, 0xF9, 0xEE, 0x16, 0xE1, 0xBB, 0xBB, + 0x45, 0xBE, 0xDB, 0x10, 0x6C, 0x7E, 0xFC, 0xA4, 0x7D, 0x9C, 0xB9, 0xC4, 0x9B, 0xE5, 0x57, 0xE2, + 0x18, 0x44, 0xD9, 0xB1, 0xBD, 0xB7, 0x83, 0xB9, 0x1B, 0x52, 0xD8, 0x11, 0x79, 0xCA, 0xCD, 0x9B, + 0x19, 0x84, 0xCA, 0x37, 0x89, 0xE4, 0x3C, 0xC9, 0x67, 0x6E, 0xBA, 0x2A, 0x53, 0xDB, 0x8B, 0x61, + 0xC7, 0xB5, 0xC1, 0xDB, 0x65, 0x81, 0xF1, 0xED, 0xB8, 0xBC, 0x3D, 0xAB, 0x57, 0xCC, 0x19, 0x5D, + 0x5B, 0xB3, 0xE7, 0x13, 0xCA, 0x33, 0xE4, 0x65, 0x9E, 0x82, 0xDA, 0xCB, 0x57, 0x21, 0x7A, 0x3B, + 0xA8, 0x92, 0x63, 0xA4, 0x3F, 0x62, 0xEC, 0xFC, 0x88, 0x0C, 0x69, 0x90, 0xB1, 0x14, 0x18, 0x8C, + 0x8E, 0x0A, 0x6A, 0xF3, 0xB8, 0x64, 0x74, 0x42, 0x1A, 0xB7, 0xA6, 0x4E, 0x9C, 0x7A, 0xA0, 0x00, + 0x3E, 0x95, 0x10, 0x40, 0xBF, 0xBC, 0x00, 0x0A, 0x65, 0x2E, 0x48, 0xE3, 0xF6, 0x04, 0xD0, 0x62, + 0x02, 0xB8, 0x8A, 0x5E, 0x7A, 0x9D, 0x61, 0xD0, 0x15, 0x2A, 0x50, 0xBD, 0x1D, 0xAC, 0x91, 0x60, + 0xA4, 0xD5, 0x03, 0x8B, 0x06, 0x8E, 0x8A, 0x29, 0xB4, 0x5D, 0x34, 0xBF, 0x92, 0x17, 0x3F, 0x15, + 0xF2, 0xEF, 0x6D, 0x26, 0x58, 0xED, 0x56, 0x60, 0xD1, 0xC5, 0x05, 0xD0, 0x2A, 0x2F, 0x00, 0xBD, + 0x90, 0x00, 0x5A, 0x0F, 0x2B, 0x19, 0xEF, 0xAF, 0x7F, 0xA7, 0x38, 0x5F, 0x5A, 0x45, 0xDD, 0x5F, + 0x18, 0xCD, 0xDA, 0x45, 0x84, 0xB5, 0x55, 0xEF, 0xEF, 0x44, 0x9C, 0x6B, 0xBF, 0x68, 0xF1, 0xAD, + 0xAF, 0x59, 0x71, 0xA0, 0x7C, 0x11, 0xB0, 0xB7, 0x83, 0xF5, 0x2A, 0xA4, 0xF0, 0x44, 0xC2, 0x59, + 0xC1, 0x00, 0x7F, 0x52, 0xDE, 0x1D, 0x0A, 0x69, 0x18, 0x69, 0xDD, 0x9E, 0x8A, 0x7B, 0x31, 0x41, + 0xB0, 0x6F, 0xA4, 0xAB, 0xA8, 0xB8, 0x7C, 0xE5, 0xB0, 0xB7, 0x83, 0xA5, 0x2E, 0xA4, 0xF0, 0x58, + 0xC2, 0x59, 0x41, 0x15, 0x17, 0x4D, 0x49, 0x8F, 0x4B, 0x4E, 0x2D, 0xF5, 0x6D, 0xE6, 0xA4, 0x58, + 0xED, 0x16, 0x04, 0x21, 0x7E, 0xC0, 0x22, 0x4B, 0xC1, 0xE5, 0x2B, 0xDE, 0xBD, 0x8A, 0xEB, 0xB3, + 0xDB, 0x8B, 0xE4, 0x47, 0xB2, 0xAF, 0x9B, 0xE7, 0xDB, 0x41, 0xD1, 0x5C, 0xB6, 0x55, 0x72, 0xE0, + 0xDB, 0x6A, 0x2A, 0x0B, 0xBD, 0x43, 0xD6, 0xB3, 0xCE, 0x7D, 0x86, 0x09, 0x94, 0x5F, 0x79, 0xEB, + 0xED, 0x60, 0x7B, 0x08, 0x52, 0xD8, 0xAE, 0x0D, 0x3E, 0x15, 0x64, 0xAA, 0x4A, 0xFD, 0xA0, 0xF4, + 0xFE, 0x90, 0xDD, 0x95, 0xDE, 0x47, 0xF3, 0x9B, 0xE2, 0xA5, 0xF7, 0x8B, 0x77, 0x3F, 0x17, 0x2B, + 0xBD, 0x8B, 0xBD, 0xEC, 0xAE, 0xF4, 0x5E, 0xCE, 0x66, 0x0A, 0x6D, 0x94, 0x05, 0xC6, 0xF0, 0x55, + 0x48, 0x23, 0xD3, 0xA3, 0x5D, 0x82, 0x60, 0xB4, 0xF7, 0xC1, 0x69, 0x28, 0x22, 0xE1, 0x19, 0xE5, + 0x78, 0xFB, 0x2C, 0xEB, 0xE9, 0x64, 0x84, 0x85, 0x52, 0xCF, 0xF0, 0xE2, 0x0B, 0x75, 0xFA, 0xFC, + 0x1B, 0x5F, 0x15, 0x9E, 0x05, 0x4E, 0x7B, 0xD7, 0xC8, 0xC1, 0x51, 0x41, 0xDC, 0x5B, 0x7F, 0xC5, + 0xC0, 0x20, 0xA1, 0x28, 0x9D, 0xEA, 0x47, 0xC7, 0x73, 0xE5, 0x3A, 0x39, 0x05, 0x2B, 0x12, 0xCD, + 0x3B, 0x62, 0xA9, 0x45, 0x3D, 0x9A, 0x33, 0xF2, 0xB6, 0x13, 0xCD, 0x11, 0x77, 0x8C, 0xF7, 0x02, + 0x59, 0x0D, 0x83, 0x2D, 0x26, 0x00, 0xF9, 0x26, 0x0A, 0x05, 0x01, 0xA4, 0x49, 0x60, 0x23, 0x22, + 0x68, 0x53, 0x09, 0xB4, 0x13, 0xDA, 0x4F, 0x09, 0xFC, 0xB4, 0x7D, 0xD9, 0xB8, 0xDF, 0xD9, 0x41, + 0x6D, 0x02, 0xC5, 0x15, 0xE3, 0xA8, 0xA0, 0x4E, 0x8B, 0x2D, 0x0E, 0xC6, 0x74, 0x5A, 0xCC, 0xA8, + 0xB7, 0xB6, 0x3A, 0x08, 0xC8, 0x3B, 0x54, 0x00, 0x1D, 0x65, 0x95, 0x96, 0x9F, 0x66, 0x76, 0x76, + 0x90, 0x9F, 0xA0, 0xB4, 0x62, 0x1C, 0x15, 0x54, 0x69, 0xB1, 0xA5, 0xCF, 0x98, 0x4A, 0xD5, 0xE7, + 0x97, 0x9C, 0xC8, 0xAD, 0xA9, 0xB4, 0x4B, 0x05, 0xD0, 0x55, 0x56, 0x69, 0xF9, 0x59, 0x47, 0x67, + 0x07, 0xBB, 0x77, 0x51, 0x5A, 0x31, 0x8E, 0x0A, 0xAA, 0xB4, 0xD8, 0x92, 0x5D, 0x4C, 0xA5, 0xEA, + 0xF3, 0x49, 0x4E, 0xE4, 0xD6, 0x54, 0xDA, 0xA3, 0x02, 0xE8, 0x29, 0xAB, 0xB4, 0x7C, 0xA5, 0xA0, + 0xB3, 0x83, 0x62, 0x10, 0x4A, 0x2B, 0xC6, 0x51, 0x41, 0x95, 0x16, 0x5B, 0x7D, 0x8E, 0xA9, 0x54, + 0x7D, 0x9D, 0x83, 0x13, 0xB9, 0x35, 0x95, 0xF6, 0xA9, 0x00, 0xFA, 0xCA, 0x2A, 0x2D, 0xBF, 0xBF, + 0xAA, 0xB3, 0x83, 0xBD, 0xDB, 0x28, 0xAD, 0x18, 0x47, 0x05, 0x55, 0x5A, 0xAC, 0x74, 0x1B, 0x53, + 0xA9, 0xFA, 0xCA, 0x0D, 0x27, 0x72, 0x6B, 0x2A, 0x3D, 0xA2, 0x02, 0x38, 0x52, 0x56, 0x69, 0xF9, + 0xAD, 0xEB, 0x9D, 0x1D, 0xD4, 0xF3, 0x50, 0x5A, 0x31, 0x8E, 0x0A, 0xAA, 0xB4, 0x58, 0x05, 0x27, + 0xA6, 0x52, 0xF5, 0xBD, 0x53, 0x9C, 0xC8, 0xAD, 0xA9, 0xF4, 0x98, 0x0A, 0xE0, 0x58, 0x59, 0xA5, + 0xE5, 0x77, 0xEE, 0x77, 0x76, 0xB0, 0x73, 0x1F, 0xA5, 0x15, 0xE3, 0xA8, 0xA0, 0x4A, 0x8B, 0xD5, + 0x66, 0x63, 0x2A, 0x55, 0xDF, 0xEE, 0xC4, 0x89, 0xDC, 0x9A, 0x4A, 0x4F, 0xA8, 0x00, 0x4E, 0x94, + 0x55, 0x5A, 0x7E, 0xCB, 0x40, 0x67, 0x07, 0x9B, 0x5F, 0x50, 0x5A, 0x2D, 0x91, 0xA3, 0x82, 0x2A, + 0x2D, 0xB6, 0xC0, 0xD8, 0x49, 0xD9, 0xFA, 0xA2, 0xA0, 0xD2, 0xB4, 0x05, 0xC6, 0x07, 0x50, 0xBF, + 0x33, 0x56, 0xC3, 0x12, 0x1F, 0xFC, 0x79, 0xF1, 0xD3, 0xCB, 0xF4, 0xC2, 0x7E, 0x6A, 0x15, 0x2F, + 0xD6, 0xD7, 0x43, 0x2F, 0xE3, 0x89, 0xF2, 0x42, 0xC2, 0x41, 0xCB, 0xEC, 0x25, 0x91, 0xDA, 0x1A, + 0xF3, 0xD9, 0x96, 0xC6, 0x80, 0x0B, 0x58, 0x5A, 0xA7, 0xDB, 0x92, 0x27, 0x2D, 0x39, 0x96, 0xC6, + 0xA9, 0xDC, 0x4E, 0xF0, 0x40, 0xE4, 0x30, 0x17, 0x47, 0xDE, 0x3F, 0x28, 0xAD, 0xE9, 0x30, 0x80, + 0x78, 0xF8, 0xE8, 0xB6, 0x4E, 0x14, 0xE3, 0x07, 0xC8, 0x20, 0x6D, 0x63, 0xFC, 0x06, 0x03, 0x08, + 0xD2, 0xD8, 0x61, 0x4C, 0xBD, 0x51, 0x66, 0x2A, 0x59, 0x05, 0x28, 0xC4, 0x54, 0x5A, 0x65, 0x67, + 0xC3, 0x4C, 0x75, 0x19, 0x53, 0x19, 0x4E, 0x9A, 0x60, 0x2A, 0x39, 0x0F, 0x2E, 0xC4, 0x54, 0xDA, + 0x44, 0x38, 0x62, 0xEA, 0x21, 0x04, 0x3A, 0x32, 0x32, 0xA6, 0xA3, 0x12, 0xA1, 0xEE, 0xF2, 0xE2, + 0xF0, 0xC5, 0x9B, 0x0B, 0x8D, 0x2E, 0x69, 0x3A, 0x56, 0xC1, 0x88, 0x17, 0xEF, 0xF4, 0x2F, 0x15, + 0xF3, 0x28, 0xE9, 0x42, 0xD4, 0x7B, 0x73, 0xA1, 0x1A, 0xF0, 0x38, 0x64, 0x91, 0x90, 0xD7, 0x6B, + 0x75, 0xCA, 0x54, 0x08, 0x43, 0x22, 0xB7, 0x14, 0xF4, 0x28, 0xFA, 0x76, 0x24, 0x83, 0xCB, 0x62, + 0x32, 0x28, 0x54, 0x25, 0x8D, 0xCB, 0xA0, 0x40, 0xD8, 0x0F, 0x88, 0xDC, 0xA6, 0x0C, 0x30, 0x4A, + 0x5E, 0x5E, 0x68, 0xEF, 0xFF, 0xA9, 0x5D, 0xDE, 0x2C, 0x1C, 0x6F, 0xE9, 0x92, 0xDC, 0xA8, 0xC2, + 0xE1, 0xE2, 0x71, 0xA5, 0xDF, 0xEB, 0x75, 0x54, 0x03, 0x4B, 0x2F, 0x7D, 0x08, 0x98, 0xB4, 0x36, + 0x18, 0x2F, 0x29, 0xA1, 0xDD, 0x90, 0xC1, 0x0F, 0x04, 0x34, 0xAD, 0x14, 0x37, 0x39, 0x60, 0x9C, + 0x43, 0xBD, 0x85, 0xDB, 0xAB, 0x15, 0x19, 0x94, 0x67, 0x94, 0x9D, 0x8D, 0x0E, 0x07, 0x94, 0xCA, + 0x5E, 0xC8, 0xDE, 0xA7, 0x8F, 0x57, 0x6A, 0x8C, 0x25, 0xEB, 0x68, 0xC5, 0x54, 0x97, 0xF6, 0xC8, + 0x68, 0xC1, 0x41, 0x41, 0xDE, 0xE8, 0xEC, 0x10, 0x62, 0xEC, 0xBA, 0x6C, 0x52, 0x44, 0x76, 0x36, + 0x31, 0xA7, 0x60, 0xB0, 0x72, 0x59, 0x52, 0x19, 0xB2, 0xB7, 0x9A, 0xE2, 0x17, 0x48, 0x9B, 0x23, + 0x08, 0xF3, 0xA0, 0x7B, 0xF4, 0xAE, 0x40, 0xB2, 0x73, 0x63, 0x4A, 0xA2, 0xEB, 0x1A, 0x0B, 0xE2, + 0x59, 0xC1, 0xD9, 0x60, 0x08, 0x8D, 0x6B, 0xC2, 0x3F, 0x97, 0xAA, 0xCD, 0x5C, 0x32, 0x39, 0xAF, + 0x7D, 0x13, 0xE2, 0xE4, 0x8F, 0xDF, 0x61, 0x93, 0x9A, 0x36, 0x76, 0x56, 0xB6, 0xE5, 0x18, 0x18, + 0xF8, 0x8D, 0x85, 0x0F, 0x94, 0x1E, 0xFC, 0xB1, 0xC0, 0x37, 0x5C, 0x19, 0xF8, 0xB4, 0x96, 0x91, + 0xD1, 0x8F, 0xA0, 0xFE, 0x91, 0xE5, 0x78, 0xC1, 0xB4, 0x0D, 0x0F, 0xC3, 0xCF, 0xAB, 0xFE, 0xCF, + 0x7F, 0xE7, 0x6D, 0x15, 0x30, 0xE7, 0x53, 0x41, 0x00, 0x35, 0xCD, 0x73, 0x47, 0xE7, 0x35, 0xA0, + 0xD4, 0x75, 0x3C, 0xCF, 0x71, 0xCD, 0xA9, 0x99, 0x32, 0x36, 0xA7, 0x49, 0xFB, 0x50, 0x26, 0xEE, + 0x44, 0x63, 0xC9, 0xB0, 0x7F, 0xE6, 0x8D, 0x5C, 0x73, 0xE1, 0x0F, 0x1E, 0x8D, 0x9D, 0xD1, 0x72, + 0x4E, 0x6C, 0xFF, 0xC0, 0x18, 0x8F, 0x2F, 0xAF, 0xE1, 0xE0, 0x3B, 0xFC, 0x16, 0x1F, 0x48, 0xBE, + 0x51, 0x7F, 0xF5, 0xAF, 0x77, 0x38, 0x0C, 0xE3, 0x35, 0x90, 0x17, 0x19, 0xD7, 0xF7, 0xB5, 0xC9, + 0xD2, 0x66, 0x23, 0x61, 0x83, 0x60, 0xDB, 0x3D, 0xED, 0x2B, 0x60, 0xBC, 0x36, 0x5C, 0x6D, 0x68, + 0x78, 0xE4, 0xAD, 0xE3, 0xF9, 0xDA, 0xB9, 0x16, 0x62, 0xB4, 0x9C, 0x11, 0xDD, 0xB7, 0x71, 0xC0, + 0xF8, 0xE2, 0x2D, 0x19, 0xE3, 0x3F, 0xBA, 0x16, 0x34, 0x0D, 0xA1, 0x9E, 0x6A, 0xF5, 0xD3, 0x63, + 0xBD, 0x8E, 0xF6, 0x17, 0x76, 0x31, 0x21, 0x10, 0xE6, 0xA1, 0x5D, 0x63, 0xE9, 0x5A, 0xFB, 0xDA, + 0x68, 0xB8, 0xF7, 0x95, 0x52, 0x4F, 0x2F, 0xE3, 0xB5, 0x3D, 0xCE, 0xCC, 0x81, 0x3F, 0x23, 0x76, + 0x23, 0xA2, 0xCC, 0x25, 0xDE, 0xC2, 0xB1, 0x3D, 0xC2, 0x88, 0x63, 0x3F, 0x73, 0x12, 0x5D, 0x3F, + 0xF0, 0x7C, 0xC3, 0x5F, 0x7A, 0xDA, 0xE3, 0xF3, 0x73, 0xAD, 0xDD, 0x6A, 0x89, 0xCD, 0x34, 0xE8, + 0x26, 0xD9, 0x6E, 0x5F, 0x4B, 0x5C, 0xF8, 0x48, 0x6E, 0xFC, 0xBD, 0x67, 0x21, 0xCC, 0x9D, 0x46, + 0x2C, 0x8F, 0xC4, 0x90, 0x84, 0x00, 0xF8, 0xDE, 0xB8, 0xC6, 0x5E, 0x9C, 0xC0, 0xC6, 0xD8, 0xF0, + 0x8D, 0xBD, 0xAF, 0x31, 0x7D, 0x41, 0xAF, 0x40, 0xC9, 0xBE, 0x46, 0x6F, 0x3D, 0x13, 0x6E, 0xDD, + 0xED, 0x1D, 0x80, 0x0C, 0x81, 0xDF, 0x10, 0x9A, 0xB8, 0x6E, 0x9C, 0x62, 0x0A, 0xDD, 0xD4, 0xF7, + 0x35, 0xBC, 0x13, 0x87, 0x15, 0x88, 0x7C, 0x14, 0x5C, 0x0B, 0x84, 0x96, 0x8D, 0x56, 0x82, 0x92, + 0xA1, 0xBB, 0x8B, 0xA9, 0x08, 0x02, 0xCE, 0x07, 0x32, 0x05, 0x89, 0x4D, 0xF7, 0x79, 0xFC, 0xD9, + 0xA7, 0xC1, 0x67, 0x9F, 0xC5, 0x2D, 0x41, 0x6B, 0x87, 0x87, 0xE0, 0xD2, 0x9E, 0x63, 0x11, 0xB0, + 0x8A, 0x69, 0xA3, 0xCE, 0xBF, 0xF5, 0x0A, 0x16, 0x55, 0x6F, 0xDD, 0xD4, 0x9F, 0x02, 0x82, 0x03, + 0xDF, 0xB9, 0xF2, 0x5D, 0xD3, 0x9E, 0x36, 0xF4, 0xFE, 0x5E, 0x84, 0x8D, 0xDE, 0x46, 0x94, 0x89, + 0xFB, 0xF4, 0x3A, 0xED, 0x24, 0x79, 0xA3, 0xC1, 0xAF, 0x3F, 0xAD, 0xEF, 0xD5, 0x39, 0xF1, 0xF4, + 0x1C, 0xCC, 0xAD, 0xC1, 0x0E, 0x9E, 0x50, 0x1A, 0xF7, 0xB4, 0xB3, 0x33, 0xDE, 0x0D, 0x6B, 0x85, + 0x17, 0xA1, 0x11, 0xFD, 0x93, 0xB8, 0x15, 0x9A, 0xE2, 0xEF, 0xFF, 0xF8, 0x1A, 0xD8, 0xEC, 0xDD, + 0x21, 0x50, 0xFD, 0x1C, 0x43, 0xF0, 0x3F, 0xBE, 0xC2, 0xFF, 0x77, 0x4F, 0x68, 0xD4, 0xFD, 0xC7, + 0x57, 0xFC, 0x73, 0xF7, 0x04, 0x7A, 0x82, 0x63, 0xDA, 0xDF, 0xDD, 0xEF, 0x54, 0x0E, 0xEB, 0xD2, + 0x9B, 0xA6, 0x4A, 0x2F, 0x14, 0x5B, 0x61, 0x9A, 0xA6, 0x19, 0x44, 0xFD, 0x1E, 0xF9, 0x6F, 0x63, + 0xE4, 0x8C, 0x41, 0x3D, 0x3E, 0x58, 0x72, 0xA0, 0x74, 0x0B, 0x54, 0x12, 0x08, 0xAA, 0x15, 0x28, + 0xDD, 0x9C, 0xD0, 0x96, 0x1A, 0x77, 0x95, 0xC8, 0x40, 0x82, 0x96, 0x0B, 0xC3, 0xF5, 0xC8, 0xB7, + 0xB6, 0xDF, 0xF0, 0x63, 0x4E, 0x91, 0x22, 0xF1, 0xC1, 0x20, 0xC6, 0x02, 0xFE, 0x00, 0x0E, 0xDA, + 0xD5, 0xB9, 0xD2, 0x42, 0x63, 0xE3, 0x7F, 0x13, 0x66, 0xF3, 0xA6, 0x90, 0xD9, 0x34, 0xA8, 0xD8, + 0xC2, 0x3E, 0xF7, 0x8A, 0x98, 0x10, 0x90, 0x25, 0x18, 0x10, 0x75, 0x88, 0x48, 0x64, 0xEC, 0x62, + 0x8A, 0x43, 0xFC, 0x3C, 0xB2, 0xBE, 0x34, 0x6E, 0xE0, 0xBF, 0x64, 0xCC, 0x5A, 0xD3, 0x15, 0x36, + 0x7A, 0x8E, 0xFF, 0x81, 0x82, 0xF0, 0x4F, 0xAA, 0xA1, 0x00, 0xD6, 0xF7, 0x96, 0xD5, 0x60, 0x1F, + 0x98, 0x03, 0x1B, 0x59, 0x42, 0x3C, 0xF4, 0x6E, 0x31, 0x32, 0x39, 0x8E, 0xFF, 0x79, 0x5F, 0x5B, + 0xB8, 0x40, 0x18, 0xFD, 0x96, 0x0A, 0x1C, 0x03, 0x22, 0x62, 0xB3, 0xBF, 0xB9, 0x14, 0x2C, 0x2C, + 0xEB, 0x39, 0xC3, 0x0A, 0x24, 0xB0, 0x03, 0x30, 0x99, 0x25, 0x9A, 0x2E, 0xFC, 0x7F, 0xF7, 0x04, + 0x3A, 0x81, 0x43, 0xF8, 0xFF, 0xEE, 0x09, 0x76, 0x85, 0x46, 0x85, 0x3D, 0xDE, 0x3D, 0x81, 0x1E, + 0xE1, 0x04, 0xFE, 0x87, 0x36, 0xD8, 0x2F, 0xB6, 0xC2, 0xBF, 0x70, 0x87, 0xF6, 0x8F, 0x37, 0xE9, + 0x01, 0xBB, 0xC0, 0x4F, 0xB3, 0x18, 0x64, 0x6F, 0xD7, 0x6F, 0xD0, 0xB7, 0x9D, 0x7F, 0xBE, 0x01, + 0x76, 0xE8, 0xC1, 0x2D, 0xC4, 0x20, 0x7B, 0x8C, 0xE7, 0xF8, 0xE7, 0x36, 0x50, 0x30, 0x5E, 0xE0, + 0x47, 0x70, 0x8D, 0xBE, 0x11, 0x16, 0x2F, 0xB1, 0x03, 0x6C, 0x45, 0xDF, 0xDF, 0x49, 0x5B, 0xB1, + 0x23, 0xB8, 0xC6, 0xDF, 0xFA, 0xB8, 0xAF, 0xF1, 0xF7, 0x0A, 0xE6, 0x0A, 0x27, 0x7A, 0xEF, 0xDF, + 0x73, 0xEF, 0x06, 0x19, 0x64, 0xA4, 0xA1, 0x54, 0xC2, 0xB3, 0xDB, 0xBB, 0x27, 0x04, 0xEF, 0x51, + 0x22, 0xE1, 0xF8, 0x96, 0x1F, 0xC3, 0x75, 0xA0, 0x0F, 0xEF, 0x04, 0x04, 0xD3, 0x0B, 0xB7, 0xD1, + 0x05, 0x68, 0xE1, 0xE3, 0x7D, 0x4E, 0x3C, 0x9C, 0xDD, 0x86, 0x67, 0x08, 0x4D, 0x61, 0x39, 0x1B, + 0x70, 0x7A, 0x1B, 0x9D, 0xC2, 0x5D, 0xE4, 0x05, 0x15, 0xC0, 0x79, 0xBA, 0x7B, 0xC2, 0x79, 0x42, + 0x2D, 0xB2, 0xA3, 0xB8, 0xA8, 0xE1, 0x7F, 0xF4, 0x23, 0x9F, 0x07, 0xEC, 0x4F, 0x81, 0x77, 0x12, + 0x6B, 0x4F, 0x3B, 0x1F, 0xF0, 0xB8, 0x8F, 0x01, 0x00, 0x3C, 0x0A, 0xAE, 0x13, 0xEB, 0xC0, 0xF0, + 0xC1, 0x21, 0x20, 0x6F, 0x22, 0xDE, 0x01, 0x46, 0x94, 0xD0, 0xCD, 0xD7, 0x6E, 0x1D, 0xD8, 0xE0, + 0x16, 0x14, 0xE1, 0xDE, 0x29, 0x0F, 0x1B, 0x88, 0x88, 0x71, 0xB9, 0x86, 0x8B, 0x5D, 0x4E, 0x43, + 0xC7, 0xEE, 0xA6, 0x60, 0xE4, 0xA1, 0x30, 0x0E, 0x81, 0x17, 0xD3, 0xB0, 0xD1, 0x51, 0x43, 0xC0, + 0xD5, 0xEE, 0xF5, 0x22, 0x6C, 0x89, 0x48, 0xC7, 0xE6, 0x94, 0x28, 0x90, 0x03, 0xCC, 0xCF, 0xA3, + 0xA1, 0x70, 0x04, 0xE6, 0xA0, 0xD5, 0x83, 0x09, 0x65, 0xFD, 0x74, 0x2D, 0xC2, 0x01, 0x04, 0x2F, + 0x20, 0x68, 0xCF, 0x19, 0x8D, 0xA7, 0x51, 0xF8, 0xD4, 0xB4, 0x21, 0x24, 0x3B, 0x5F, 0x9E, 0xC5, + 0x90, 0xD1, 0xD4, 0x3F, 0xC4, 0xC4, 0xAE, 0x61, 0xA2, 0x90, 0xB8, 0xC4, 0xB6, 0xDB, 0x35, 0x1D, + 0x9B, 0xC8, 0x7B, 0x8D, 0xC5, 0x4B, 0xDE, 0x11, 0x3F, 0x1B, 0x93, 0x89, 0xB1, 0xB4, 0xFC, 0x08, + 0xCC, 0x25, 0x90, 0xE8, 0xDA, 0x3C, 0x6C, 0xB1, 0x24, 0x3F, 0x77, 0xE8, 0xCE, 0x18, 0x2A, 0x82, + 0x51, 0xE1, 0x71, 0x72, 0x54, 0x00, 0xAB, 0x74, 0xFD, 0x46, 0xFD, 0xD2, 0x75, 0x1D, 0xF7, 0xD7, + 0xFA, 0x53, 0x6C, 0xF4, 0xB4, 0xFE, 0xDB, 0xA9, 0x46, 0xE3, 0xE9, 0x5E, 0x3C, 0xB8, 0x0B, 0xE1, + 0xF3, 0xF0, 0x50, 0x7B, 0xE1, 0xFB, 0x06, 0x28, 0x00, 0x6B, 0x2C, 0x33, 0x94, 0x8F, 0x66, 0xF0, + 0x24, 0xD0, 0x71, 0xD1, 0x28, 0xD9, 0xF7, 0xEE, 0x41, 0x22, 0x98, 0x58, 0x7A, 0x00, 0x12, 0x24, + 0x99, 0x14, 0xD5, 0xC1, 0xBF, 0x97, 0xC4, 0xBD, 0xBD, 0xA2, 0x02, 0x73, 0xDC, 0x17, 0x10, 0x2A, + 0xEB, 0x07, 0xD1, 0x3C, 0xA9, 0xCE, 0x72, 0x9E, 0x03, 0x40, 0x75, 0x09, 0x7D, 0x80, 0x8E, 0x23, + 0x9B, 0x67, 0xDC, 0x84, 0x7A, 0x87, 0x71, 0xEE, 0x9C, 0x2B, 0x23, 0x99, 0x64, 0x41, 0x0B, 0xC7, + 0xFE, 0x42, 0x6E, 0x97, 0x0B, 0x10, 0x7F, 0x94, 0x36, 0x25, 0x12, 0x39, 0x2E, 0x1D, 0x72, 0x00, + 0x2D, 0x2F, 0xF8, 0xC0, 0xA9, 0x77, 0x24, 0x8D, 0x22, 0x15, 0x50, 0xEB, 0x44, 0x4F, 0x7C, 0xB6, + 0xD6, 0xE8, 0xEE, 0x91, 0xFC, 0x4C, 0x92, 0x72, 0x72, 0x02, 0xB9, 0xF0, 0xC0, 0xB5, 0xA9, 0x63, + 0x27, 0x7A, 0x48, 0xA4, 0x83, 0x90, 0x0C, 0x46, 0x91, 0x61, 0xB9, 0x80, 0xE4, 0x93, 0xC4, 0x83, + 0x43, 0x68, 0x0B, 0xC1, 0xCD, 0xB9, 0xE3, 0x93, 0x44, 0xC4, 0x30, 0x6D, 0xD3, 0x37, 0x0D, 0xEB, + 0x53, 0x64, 0x8D, 0x5B, 0x75, 0x7F, 0x89, 0x8F, 0x17, 0xF0, 0xFF, 0xB5, 0x9C, 0x4F, 0x2D, 0x4F, + 0x59, 0xB3, 0x90, 0x30, 0x1E, 0x44, 0x56, 0x22, 0xCA, 0x21, 0x16, 0x16, 0xF8, 0xFD, 0xA0, 0xA7, + 0xC7, 0x8F, 0xE9, 0xD1, 0xA3, 0x50, 0x69, 0x41, 0xF4, 0x38, 0xD7, 0xA2, 0x1B, 0x09, 0x05, 0xAF, + 0xE3, 0x4E, 0xE0, 0x08, 0x90, 0x0B, 0x18, 0x12, 0x81, 0x7F, 0x01, 0xE9, 0x0D, 0xDA, 0xC2, 0xFF, + 0x47, 0xFD, 0x07, 0x14, 0xF5, 0xB7, 0x17, 0xE2, 0x33, 0x6C, 0x3B, 0xE1, 0x01, 0x0C, 0x4E, 0x9E, + 0x4F, 0x3F, 0x85, 0x44, 0x5B, 0x9E, 0x24, 0x87, 0xA1, 0x3B, 0x9C, 0xEC, 0xC3, 0x64, 0xE6, 0x92, + 0x85, 0xE7, 0x97, 0xB7, 0xDF, 0x8E, 0x1B, 0xF5, 0xF0, 0x8D, 0x46, 0xF5, 0x3D, 0x8C, 0x4B, 0x96, + 0x39, 0xFA, 0x12, 0x86, 0xA5, 0xC8, 0xF2, 0x20, 0xA5, 0xC1, 0xEC, 0x1F, 0x27, 0xD6, 0xE6, 0x88, + 0x9B, 0xEA, 0xAB, 0x0F, 0x2F, 0xDE, 0x7D, 0x7E, 0xF1, 0xF1, 0xE3, 0x07, 0x6D, 0x09, 0x36, 0xAB, + 0xF7, 0x3F, 0x63, 0xDA, 0x02, 0x93, 0x00, 0xF7, 0x33, 0xD0, 0xE7, 0x7D, 0xA6, 0x48, 0x5B, 0xBF, + 0xFE, 0xF6, 0x6B, 0xFB, 0x37, 0x00, 0xFD, 0xFA, 0x5F, 0x76, 0x9D, 0x31, 0x82, 0xA8, 0x9E, 0x02, + 0x2E, 0x3C, 0xFE, 0x5A, 0x7F, 0x1A, 0x18, 0x7C, 0x23, 0x9D, 0xC2, 0xF0, 0xF5, 0xBA, 0xF5, 0x3D, + 0x60, 0xF5, 0x6E, 0x1F, 0x50, 0xB1, 0x74, 0x10, 0xC6, 0x9C, 0x06, 0x96, 0x2A, 0x4C, 0xE8, 0x40, + 0x7F, 0x06, 0x7F, 0xCE, 0x34, 0xFD, 0x08, 0xFE, 0x3E, 0x7D, 0x1A, 0x99, 0x48, 0xC9, 0xEE, 0xEA, + 0x4F, 0x4D, 0xDA, 0x19, 0xCC, 0x4E, 0x1A, 0xE6, 0x19, 0x48, 0xF2, 0x79, 0x7D, 0xBF, 0x7E, 0x5A, + 0xAF, 0xC3, 0xB5, 0xA0, 0xFB, 0xBB, 0x18, 0x3B, 0x77, 0xCF, 0x42, 0x0E, 0xD9, 0xE8, 0x0A, 0x37, + 0x22, 0xF1, 0x8B, 0x59, 0xDD, 0x4B, 0x56, 0xE5, 0x3A, 0x4F, 0xD7, 0x09, 0x7B, 0x9B, 0xF5, 0x94, + 0x0E, 0x88, 0x22, 0x4C, 0x86, 0x82, 0x58, 0x68, 0x08, 0x7D, 0x2D, 0x15, 0x35, 0x1D, 0x6E, 0xC7, + 0x63, 0x17, 0xB4, 0x4D, 0xAD, 0x65, 0x6F, 0xCD, 0x85, 0xD5, 0x70, 0x60, 0x63, 0x09, 0x8E, 0xB5, + 0xE9, 0x66, 0x26, 0x12, 0xDA, 0x5A, 0xC0, 0xB2, 0x96, 0xF0, 0xB4, 0xEE, 0x2F, 0xD7, 0x61, 0x1A, + 0x9B, 0x2A, 0x6A, 0x6C, 0x2A, 0x68, 0x6C, 0xBA, 0x59, 0x8D, 0x71, 0xD4, 0x95, 0xB5, 0x16, 0xE0, + 0xC9, 0xD1, 0x5C, 0x2E, 0x3C, 0x57, 0x1A, 0xD7, 0xD6, 0x54, 0xA6, 0xAD, 0x32, 0x6A, 0x62, 0xB1, + 0x0B, 0x26, 0x45, 0xC4, 0x7D, 0xFB, 0xF1, 0xDD, 0x77, 0x18, 0x6D, 0xE4, 0x2A, 0x0B, 0x35, 0x96, + 0x4C, 0xAE, 0x24, 0x18, 0x30, 0x28, 0xC6, 0x2A, 0x1F, 0x89, 0xB0, 0xA9, 0x45, 0x15, 0x84, 0x1C, + 0x43, 0xE0, 0x05, 0x03, 0x35, 0xDF, 0xC5, 0x22, 0x41, 0xE0, 0xBC, 0x11, 0x54, 0x86, 0x2D, 0x20, + 0x80, 0x92, 0x12, 0x19, 0xE6, 0x35, 0x87, 0x11, 0x6A, 0x19, 0x3B, 0x77, 0x11, 0xEA, 0xAF, 0x9E, + 0x6A, 0x50, 0x0B, 0xA6, 0xEA, 0x51, 0x6C, 0xF3, 0x72, 0xA5, 0xC3, 0x27, 0xF4, 0x4A, 0x02, 0xE2, + 0x5F, 0x95, 0x93, 0x18, 0x38, 0x2F, 0x04, 0x14, 0xC0, 0x72, 0x2B, 0xC1, 0x42, 0x0B, 0x08, 0x4A, + 0x38, 0xE8, 0x97, 0xC8, 0x52, 0x30, 0xA8, 0x51, 0x41, 0x3F, 0xFD, 0x25, 0xC1, 0x10, 0x94, 0x2A, + 0x94, 0x90, 0x04, 0x9F, 0xAD, 0x4A, 0xC7, 0xA3, 0x46, 0x4C, 0xF0, 0xB1, 0x28, 0x09, 0x1E, 0x5E, + 0x1A, 0x51, 0x42, 0xC3, 0x3F, 0x74, 0x94, 0x8A, 0x45, 0x8D, 0x18, 0xFE, 0x6D, 0x21, 0x19, 0x4F, + 0xBC, 0x14, 0xA3, 0xC6, 0x13, 0xFF, 0x24, 0x4E, 0x3A, 0x1E, 0x45, 0xD9, 0xF0, 0xCF, 0xD0, 0xC8, + 0xAC, 0x8E, 0x55, 0x7E, 0x32, 0x1D, 0x83, 0x35, 0x01, 0x60, 0x9E, 0xAA, 0x3E, 0xD7, 0xC5, 0xCC, + 0x9A, 0x17, 0x8A, 0xB2, 0x30, 0xF0, 0x26, 0x49, 0x0C, 0x41, 0x74, 0xB8, 0x87, 0x92, 0xDD, 0x7D, + 0x44, 0xA1, 0xF7, 0x96, 0xA5, 0x16, 0x85, 0x16, 0x96, 0x15, 0x84, 0x9F, 0x10, 0x26, 0x25, 0xFC, + 0xD0, 0x25, 0x33, 0x5A, 0x6F, 0xCD, 0x94, 0x3F, 0x6D, 0xC1, 0xF0, 0xAE, 0x2B, 0x11, 0x71, 0xCC, + 0x97, 0x96, 0x92, 0x25, 0x41, 0x3B, 0x8E, 0x46, 0x34, 0x24, 0xBA, 0x1C, 0x77, 0xEB, 0xA9, 0x85, + 0xAE, 0x5B, 0x2F, 0x05, 0x03, 0x2D, 0x09, 0xAB, 0xE5, 0x66, 0xD0, 0x32, 0x05, 0xC9, 0xC2, 0x55, + 0x4B, 0xEF, 0xA0, 0x5D, 0x1A, 0x23, 0x58, 0x7F, 0x56, 0x63, 0x25, 0xF8, 0xEA, 0xB8, 0x8C, 0x0E, + 0x5A, 0xB5, 0xCE, 0x52, 0x0A, 0xFF, 0xBA, 0x73, 0x9A, 0x46, 0x16, 0xAA, 0x43, 0x6E, 0xF0, 0xFD, + 0x63, 0xC9, 0xB0, 0x5B, 0xB2, 0xD8, 0xBF, 0x7B, 0xE7, 0x30, 0xAE, 0x89, 0x82, 0x6B, 0x84, 0x2B, + 0xF3, 0x3C, 0xBB, 0x8B, 0xC0, 0x32, 0xBC, 0x63, 0x64, 0xD8, 0xD7, 0x46, 0xCC, 0x3B, 0x46, 0x30, + 0xFD, 0xF5, 0x09, 0x47, 0xDD, 0xA8, 0xB1, 0x06, 0x35, 0x4E, 0x23, 0x3B, 0x3B, 0xA0, 0x5B, 0xD6, + 0xB0, 0x10, 0x62, 0x92, 0x15, 0x3B, 0x89, 0xDD, 0x9E, 0x11, 0xFA, 0x9A, 0x10, 0x7E, 0x9F, 0x9D, + 0xB1, 0x06, 0x61, 0x2F, 0x43, 0x67, 0x7C, 0x7B, 0x60, 0x2C, 0x16, 0x10, 0xBC, 0x2E, 0x66, 0xA6, + 0x35, 0x6E, 0x30, 0x50, 0xC1, 0x44, 0x70, 0x6F, 0x02, 0xA1, 0xAB, 0x56, 0x1C, 0x2B, 0x30, 0x7C, + 0xC1, 0xAE, 0x35, 0xEA, 0xED, 0x71, 0xB0, 0x66, 0xC4, 0x9B, 0x1D, 0x8C, 0x5D, 0x63, 0xF5, 0x2D, + 0x6E, 0x6A, 0x68, 0x60, 0xA7, 0xFB, 0xAD, 0xFD, 0x16, 0x6F, 0xE0, 0xBB, 0xB7, 0x61, 0x96, 0x89, + 0x78, 0x71, 0xF1, 0xF7, 0xC7, 0x0F, 0xDF, 0x45, 0x78, 0x7D, 0xE7, 0x15, 0xBB, 0xD4, 0xA8, 0xD3, + 0x5D, 0x11, 0x87, 0x7F, 0x2C, 0x70, 0x2A, 0x10, 0x28, 0x45, 0x10, 0x23, 0x6E, 0x78, 0x40, 0x51, + 0xB1, 0xE6, 0xCF, 0x44, 0xA4, 0x70, 0xD9, 0x26, 0x2B, 0x0D, 0x30, 0x91, 0x86, 0x0C, 0x34, 0xD8, + 0x0E, 0x81, 0xE0, 0xC8, 0xC9, 0xEB, 0xA5, 0x65, 0xFD, 0x42, 0x0C, 0x17, 0xF4, 0xF1, 0x54, 0x6B, + 0xD4, 0x5A, 0xB5, 0xA7, 0x0D, 0x7A, 0xFD, 0x1D, 0xB0, 0x33, 0x6B, 0xEC, 0x3D, 0xD5, 0xF7, 0xF6, + 0x0E, 0x3C, 0xD0, 0x19, 0x69, 0x34, 0xDB, 0x41, 0x13, 0xF8, 0x43, 0xDB, 0xB0, 0x4E, 0xD2, 0xEF, + 0xBF, 0x75, 0x96, 0xAE, 0x97, 0xD5, 0xE0, 0x9D, 0x69, 0x63, 0x11, 0x27, 0xAB, 0xC9, 0x15, 0x01, + 0xC1, 0x8E, 0xD7, 0x9A, 0xD4, 0xE8, 0x2E, 0x0E, 0x5E, 0xFE, 0xD0, 0xE8, 0xE2, 0xB6, 0xD6, 0x10, + 0x2B, 0x3A, 0x7C, 0x19, 0x91, 0xA0, 0xA1, 0x37, 0x02, 0x8F, 0xBF, 0x13, 0x8D, 0x03, 0x5C, 0x16, + 0x0C, 0xE0, 0x7B, 0xF0, 0x80, 0x03, 0x97, 0xCC, 0x9D, 0x6B, 0xB2, 0xA6, 0x7F, 0x6C, 0x1E, 0x1A, + 0xFF, 0xCC, 0x1C, 0xB3, 0x32, 0x4D, 0x64, 0xB7, 0x58, 0x15, 0xC2, 0x0D, 0x22, 0xB8, 0x85, 0x02, + 0xF7, 0x53, 0x34, 0xEA, 0x6C, 0xF7, 0x0A, 0x1D, 0x15, 0xEE, 0x22, 0xB7, 0x99, 0x39, 0xAB, 0x2C, + 0x48, 0xD6, 0x7B, 0x02, 0x38, 0x84, 0x1E, 0x9B, 0x9E, 0x31, 0xB4, 0xF2, 0xBB, 0xE6, 0xED, 0xC6, + 0xBC, 0xFC, 0x0D, 0x0D, 0x82, 0x2B, 0x00, 0xEA, 0xBB, 0xB4, 0x4E, 0x28, 0xA0, 0x25, 0x76, 0x1E, + 0xD6, 0x80, 0xAC, 0x4C, 0xC4, 0x13, 0x03, 0x26, 0x4F, 0x71, 0xCC, 0xAC, 0x78, 0x5C, 0xA0, 0xAC, + 0x2C, 0x5E, 0x06, 0x88, 0xF8, 0x29, 0x98, 0x33, 0x58, 0xA8, 0xF6, 0x9C, 0xB2, 0xA0, 0x9D, 0xC6, + 0xEE, 0x86, 0xB9, 0x8D, 0x58, 0x30, 0x7D, 0xC4, 0xE2, 0xDF, 0x5F, 0xB0, 0x82, 0x1B, 0x52, 0x1E, + 0x93, 0xC0, 0x93, 0x27, 0x71, 0x6C, 0xB8, 0x8D, 0x85, 0x0D, 0x20, 0x61, 0x6F, 0xAC, 0x3D, 0x7B, + 0x7D, 0x78, 0xB4, 0xB2, 0xC0, 0x49, 0x82, 0xA1, 0xE0, 0x71, 0x4C, 0xF0, 0xC2, 0x18, 0x01, 0x84, + 0x98, 0x63, 0x2A, 0x20, 0xDC, 0x95, 0x56, 0x5B, 0x5B, 0xEF, 0x7F, 0x4E, 0xAD, 0xBE, 0x41, 0xF8, + 0xA6, 0xC4, 0x3D, 0x90, 0x3F, 0x1A, 0x73, 0x74, 0x21, 0x3E, 0x87, 0x4E, 0x60, 0x9C, 0xC6, 0x30, + 0x22, 0x63, 0x09, 0xBA, 0xF1, 0x77, 0x78, 0x48, 0x31, 0x4E, 0x0D, 0xD3, 0xBE, 0x20, 0x26, 0xA6, + 0x7F, 0x7B, 0xC2, 0x5D, 0xDA, 0x3D, 0x20, 0xC2, 0x0D, 0x83, 0xC2, 0x72, 0xC6, 0xFA, 0x7A, 0xC8, + 0xE1, 0x21, 0x6D, 0x9A, 0x82, 0x86, 0x76, 0xB1, 0x8E, 0x26, 0x93, 0xFA, 0xD5, 0xF0, 0x33, 0x62, + 0x93, 0x09, 0x85, 0xA2, 0x5B, 0x0D, 0x51, 0x1C, 0xB4, 0x57, 0x38, 0xCC, 0x42, 0x35, 0x31, 0x46, + 0xE4, 0xB3, 0x0B, 0x71, 0x6C, 0x6A, 0x9B, 0x7F, 0x12, 0x19, 0x42, 0xE6, 0x8C, 0x0D, 0x62, 0xBB, + 0x4E, 0x90, 0x48, 0x22, 0x72, 0xEE, 0x67, 0xF1, 0xEB, 0xF1, 0x21, 0x3A, 0x70, 0xBD, 0x70, 0x69, + 0x5D, 0xB4, 0x05, 0x56, 0xFA, 0x8F, 0x26, 0xC0, 0x91, 0xB1, 0x6D, 0xA8, 0x12, 0xAE, 0x63, 0x19, + 0x3C, 0x6C, 0x22, 0xD6, 0xAB, 0x2B, 0x16, 0xC1, 0x33, 0x70, 0xB2, 0xCD, 0x7D, 0x49, 0xA4, 0xCB, + 0xE1, 0xDC, 0xF4, 0x25, 0x08, 0xEB, 0x7A, 0x5D, 0x8A, 0x2B, 0xA5, 0x9E, 0x2E, 0x7A, 0x22, 0x8B, + 0x66, 0x74, 0xA9, 0x11, 0x10, 0xC5, 0xB6, 0x0A, 0x8C, 0xD8, 0x6E, 0xF5, 0xE7, 0x30, 0xE2, 0xE2, + 0x06, 0x00, 0x54, 0x75, 0x62, 0x0B, 0x10, 0x43, 0xC1, 0xF6, 0xAE, 0x51, 0x14, 0xF1, 0xDD, 0x6B, + 0xC1, 0x8E, 0xB1, 0xF8, 0xCA, 0xA4, 0xB8, 0xF7, 0xE5, 0x77, 0x97, 0x00, 0x9C, 0x87, 0xF3, 0x59, + 0xED, 0x1F, 0x5F, 0x29, 0x8A, 0x3B, 0x6D, 0x02, 0x91, 0xC0, 0x9B, 0x91, 0x31, 0x9D, 0x7B, 0xF9, + 0x4B, 0xEF, 0x54, 0xC3, 0x6D, 0x3F, 0xB1, 0xDD, 0x6A, 0x77, 0xBF, 0x87, 0x16, 0x12, 0x0E, 0x24, + 0xD1, 0x7A, 0x2B, 0x67, 0x7B, 0x6D, 0xDD, 0xF5, 0x91, 0xC2, 0x0A, 0x2B, 0x87, 0x55, 0x5A, 0x65, + 0x95, 0x2D, 0x4E, 0x26, 0xA3, 0x14, 0xCF, 0x31, 0x1F, 0xA9, 0xF4, 0x4D, 0x77, 0x5D, 0xAA, 0x74, + 0x29, 0xC9, 0x2E, 0xF1, 0xC7, 0xC2, 0x98, 0x25, 0x8C, 0xF9, 0x09, 0x3F, 0xDA, 0xE3, 0x72, 0x02, + 0x13, 0x19, 0x07, 0xF1, 0x96, 0x19, 0x11, 0xAE, 0x3E, 0x33, 0x3D, 0xC6, 0x4C, 0x80, 0x49, 0x9B, + 0x0B, 0x3B, 0x7F, 0x4B, 0x22, 0x5F, 0x76, 0x09, 0x95, 0xF5, 0x87, 0x07, 0x79, 0x7B, 0x28, 0x02, + 0x19, 0x0E, 0xEC, 0x40, 0x40, 0x10, 0x13, 0x51, 0x21, 0x15, 0x65, 0xC8, 0x8C, 0xFD, 0x84, 0x01, + 0x9B, 0x8E, 0xD6, 0xB4, 0xE7, 0x5F, 0xA9, 0x55, 0xFF, 0xB6, 0xCF, 0x46, 0x78, 0x21, 0x68, 0xEE, + 0x15, 0x21, 0x68, 0x6D, 0x55, 0x3E, 0x97, 0x98, 0x8D, 0xAD, 0x4F, 0x06, 0x3F, 0x88, 0xC5, 0x14, + 0xDF, 0xB9, 0xD6, 0x5A, 0x5B, 0x97, 0x17, 0x17, 0xD1, 0x42, 0x06, 0x25, 0xB2, 0x11, 0x16, 0xD5, + 0x03, 0xF1, 0x48, 0x17, 0xD0, 0x52, 0xC5, 0xC5, 0xEC, 0x8B, 0x45, 0x14, 0x9C, 0x16, 0x64, 0xCE, + 0x9F, 0xE8, 0x86, 0x59, 0x2A, 0xAF, 0x08, 0xE0, 0x22, 0xDC, 0x20, 0x9D, 0x0B, 0x19, 0x6D, 0xA6, + 0x16, 0x70, 0xD0, 0xF9, 0x98, 0xDA, 0xFA, 0x03, 0x9F, 0xBA, 0x09, 0xA0, 0x88, 0x35, 0x1F, 0x36, + 0x78, 0x4A, 0x26, 0x49, 0xBE, 0x38, 0x7A, 0x65, 0xC1, 0xD3, 0x61, 0x92, 0x35, 0x16, 0xA0, 0xA9, + 0xE7, 0xE7, 0x03, 0x8B, 0xDB, 0xB2, 0xEB, 0x82, 0xB0, 0x3D, 0xDF, 0x59, 0x5C, 0xD1, 0xAB, 0x89, + 0xA8, 0xB0, 0xA2, 0xC5, 0xAC, 0x03, 0xBC, 0x1F, 0xCC, 0x93, 0x44, 0x46, 0xE3, 0xF5, 0xFF, 0x2B, + 0xAC, 0x74, 0x69, 0x0C, 0x4F, 0x3D, 0x9E, 0xED, 0xD2, 0x22, 0x98, 0xB4, 0x07, 0x3A, 0xE7, 0xF4, + 0xDC, 0x11, 0x1B, 0x3C, 0xC2, 0x6D, 0xD0, 0x18, 0x3A, 0xF0, 0xF0, 0x77, 0xD6, 0x27, 0xA6, 0x12, + 0x31, 0x05, 0xEF, 0xE5, 0xD2, 0xE2, 0x2C, 0x92, 0xA4, 0x44, 0x01, 0x9E, 0x39, 0x9B, 0x87, 0xA3, + 0x06, 0x1B, 0x28, 0x31, 0x7C, 0x09, 0xCA, 0x4F, 0x89, 0x91, 0x91, 0x98, 0x78, 0x50, 0x8A, 0x13, + 0x2F, 0x8C, 0x7C, 0x6C, 0x0F, 0xFD, 0xF3, 0xCF, 0xA3, 0x21, 0x0C, 0x76, 0x38, 0x01, 0x04, 0xD7, + 0x5B, 0x35, 0xF6, 0xEE, 0xB2, 0xD8, 0x61, 0xE2, 0x8A, 0x14, 0xA9, 0x4A, 0x04, 0x8D, 0xDA, 0x72, + 0x6C, 0x31, 0xF9, 0xC8, 0xD1, 0x89, 0xD6, 0x7B, 0x69, 0x07, 0x13, 0x96, 0x34, 0xC1, 0x9E, 0xAF, + 0x8B, 0x96, 0xE5, 0xAC, 0x31, 0x04, 0x51, 0x3C, 0x5E, 0x23, 0x36, 0x91, 0x94, 0x0A, 0x76, 0x11, + 0x34, 0x08, 0x68, 0x17, 0x1D, 0x22, 0x85, 0xF6, 0xF8, 0x58, 0x99, 0x48, 0xFF, 0x02, 0x95, 0x5F, + 0x2C, 0x81, 0x88, 0x79, 0xA0, 0x72, 0x76, 0x0D, 0x33, 0xDC, 0xD0, 0x36, 0x21, 0xE3, 0xCD, 0xF2, + 0x19, 0xB8, 0x2D, 0x38, 0x1A, 0x4F, 0x8F, 0x73, 0x00, 0x68, 0x4E, 0xCC, 0x1E, 0x84, 0xA1, 0xB0, + 0x6C, 0xC3, 0xAE, 0xAF, 0x09, 0x89, 0x77, 0x66, 0x7C, 0x81, 0x66, 0x23, 0xD6, 0x4C, 0x40, 0x02, + 0x78, 0xD7, 0xD3, 0x05, 0x89, 0x20, 0xA0, 0xDD, 0x5E, 0xA8, 0x17, 0x04, 0xE2, 0x99, 0x68, 0xA4, + 0x95, 0xF4, 0xD9, 0xC4, 0xFA, 0x4C, 0x22, 0xA1, 0xB0, 0xF4, 0x19, 0xC4, 0xFA, 0xEC, 0xE1, 0x4E, + 0xD0, 0x42, 0xF0, 0xC4, 0x56, 0x24, 0x48, 0x92, 0x2D, 0x75, 0x22, 0x4A, 0x3D, 0x98, 0x49, 0xE5, + 0x40, 0x7C, 0xA6, 0xA9, 0x88, 0x28, 0x32, 0xA2, 0x28, 0x32, 0xC2, 0x45, 0x86, 0x00, 0x51, 0xE2, + 0x9E, 0x3F, 0xAD, 0x0B, 0xE3, 0xCA, 0x4F, 0x2F, 0x23, 0xCE, 0x56, 0xC3, 0x4C, 0x3A, 0xF9, 0x94, + 0x49, 0x60, 0x2F, 0x1B, 0x00, 0xDA, 0xCF, 0x61, 0xDC, 0x16, 0xD9, 0x5A, 0x0D, 0xD5, 0xD8, 0x0A, + 0xA6, 0x5C, 0x08, 0x10, 0xB1, 0x25, 0x9F, 0x98, 0x05, 0xAC, 0xBC, 0x22, 0x3E, 0x7F, 0xAE, 0xD3, + 0xB0, 0xC7, 0xDA, 0xC4, 0x35, 0xE6, 0xC4, 0x83, 0x09, 0x59, 0x48, 0xEC, 0x98, 0xDE, 0xCF, 0x1D, + 0xA2, 0x58, 0x33, 0x81, 0xC9, 0x70, 0x6A, 0x97, 0x0B, 0x1A, 0xB6, 0x14, 0xA0, 0x43, 0x3A, 0x32, + 0xA1, 0x83, 0x46, 0x6C, 0x6C, 0x0B, 0x4F, 0x95, 0x84, 0x15, 0xB6, 0x8E, 0x9C, 0x27, 0x42, 0xC0, + 0x26, 0x4A, 0x03, 0xAD, 0x97, 0x2C, 0x27, 0xB0, 0x84, 0x87, 0x31, 0x9B, 0x48, 0x6B, 0xC4, 0x06, + 0x21, 0x4B, 0xB1, 0x36, 0xA1, 0x83, 0x30, 0xF8, 0x34, 0x32, 0x73, 0x49, 0x61, 0xE5, 0xEA, 0xDA, + 0x7B, 0x8B, 0xE0, 0x34, 0x8F, 0xBF, 0xA9, 0xF3, 0xE2, 0xDB, 0xD7, 0x9A, 0xE3, 0x6A, 0x96, 0xB3, + 0x22, 0xB8, 0x59, 0x34, 0x58, 0x0A, 0xD6, 0x86, 0x04, 0x12, 0x4B, 0xC2, 0x26, 0xD3, 0x18, 0x87, + 0xFC, 0x99, 0xE9, 0x41, 0xEA, 0x8E, 0xEF, 0x93, 0x25, 0x8F, 0x6B, 0x61, 0x31, 0x34, 0x97, 0xBD, + 0xF5, 0xAD, 0x55, 0x31, 0x71, 0x32, 0x98, 0x48, 0x96, 0x8F, 0x39, 0x8F, 0x6B, 0xC1, 0x28, 0x6B, + 0xFA, 0x5E, 0x40, 0x84, 0xE1, 0xED, 0x07, 0x2B, 0x45, 0x39, 0x03, 0xB9, 0x82, 0x0C, 0xC1, 0x22, + 0x59, 0x46, 0xBC, 0xAE, 0x49, 0x53, 0x56, 0x23, 0xC9, 0xD0, 0x28, 0x96, 0x0B, 0xA5, 0x71, 0x3E, + 0x5D, 0x2B, 0x4C, 0xE2, 0x2C, 0x55, 0x67, 0xBF, 0xB3, 0xC3, 0xE0, 0xB1, 0x39, 0x76, 0x86, 0x8B, + 0x05, 0x83, 0x47, 0x67, 0x87, 0x33, 0x7F, 0x6E, 0x0D, 0x1E, 0xFD, 0x2F, 0x5E, 0x72, 0x0B, 0x89, + 0x42, 0x10, 0x01, 0x00 +}; diff --git a/arduino/ESP32CameraWebServer/camera_pins.h b/arduino/ESP32CameraWebServer/camera_pins.h new file mode 100644 index 0000000..e1be287 --- /dev/null +++ b/arduino/ESP32CameraWebServer/camera_pins.h @@ -0,0 +1,273 @@ + +#if defined(CAMERA_MODEL_WROVER_KIT) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 21 +#define SIOD_GPIO_NUM 26 +#define SIOC_GPIO_NUM 27 + +#define Y9_GPIO_NUM 35 +#define Y8_GPIO_NUM 34 +#define Y7_GPIO_NUM 39 +#define Y6_GPIO_NUM 36 +#define Y5_GPIO_NUM 19 +#define Y4_GPIO_NUM 18 +#define Y3_GPIO_NUM 5 +#define Y2_GPIO_NUM 4 +#define VSYNC_GPIO_NUM 25 +#define HREF_GPIO_NUM 23 +#define PCLK_GPIO_NUM 22 + +#elif defined(CAMERA_MODEL_ESP_EYE) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 4 +#define SIOD_GPIO_NUM 18 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 36 +#define Y8_GPIO_NUM 37 +#define Y7_GPIO_NUM 38 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 35 +#define Y4_GPIO_NUM 14 +#define Y3_GPIO_NUM 13 +#define Y2_GPIO_NUM 34 +#define VSYNC_GPIO_NUM 5 +#define HREF_GPIO_NUM 27 +#define PCLK_GPIO_NUM 25 + +#elif defined(CAMERA_MODEL_M5STACK_PSRAM) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 25 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 32 +#define VSYNC_GPIO_NUM 22 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_M5STACK_V2_PSRAM) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 22 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 32 +#define VSYNC_GPIO_NUM 25 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_M5STACK_WIDE) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 22 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 32 +#define VSYNC_GPIO_NUM 25 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_M5STACK_ESP32CAM) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 25 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 17 +#define VSYNC_GPIO_NUM 22 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_M5STACK_UNITCAM) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 25 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 32 +#define VSYNC_GPIO_NUM 22 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + +#elif defined(CAMERA_MODEL_AI_THINKER) +#define PWDN_GPIO_NUM 32 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 0 +#define SIOD_GPIO_NUM 26 +#define SIOC_GPIO_NUM 27 + +#define Y9_GPIO_NUM 35 +#define Y8_GPIO_NUM 34 +#define Y7_GPIO_NUM 39 +#define Y6_GPIO_NUM 36 +#define Y5_GPIO_NUM 21 +#define Y4_GPIO_NUM 19 +#define Y3_GPIO_NUM 18 +#define Y2_GPIO_NUM 5 +#define VSYNC_GPIO_NUM 25 +#define HREF_GPIO_NUM 23 +#define PCLK_GPIO_NUM 22 + +#elif defined(CAMERA_MODEL_TTGO_T_JOURNAL) +#define PWDN_GPIO_NUM 0 +#define RESET_GPIO_NUM 15 +#define XCLK_GPIO_NUM 27 +#define SIOD_GPIO_NUM 25 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 19 +#define Y8_GPIO_NUM 36 +#define Y7_GPIO_NUM 18 +#define Y6_GPIO_NUM 39 +#define Y5_GPIO_NUM 5 +#define Y4_GPIO_NUM 34 +#define Y3_GPIO_NUM 35 +#define Y2_GPIO_NUM 17 +#define VSYNC_GPIO_NUM 22 +#define HREF_GPIO_NUM 26 +#define PCLK_GPIO_NUM 21 + + +#elif defined(CAMERA_MODEL_ESP32_CAM_BOARD) +// The 18 pin header on the board has Y5 and Y3 swapped +#define USE_BOARD_HEADER 0 +#define PWDN_GPIO_NUM 32 +#define RESET_GPIO_NUM 33 +#define XCLK_GPIO_NUM 4 +#define SIOD_GPIO_NUM 18 +#define SIOC_GPIO_NUM 23 + +#define Y9_GPIO_NUM 36 +#define Y8_GPIO_NUM 19 +#define Y7_GPIO_NUM 21 +#define Y6_GPIO_NUM 39 +#if USE_BOARD_HEADER +#define Y5_GPIO_NUM 13 +#else +#define Y5_GPIO_NUM 35 +#endif +#define Y4_GPIO_NUM 14 +#if USE_BOARD_HEADER +#define Y3_GPIO_NUM 35 +#else +#define Y3_GPIO_NUM 13 +#endif +#define Y2_GPIO_NUM 34 +#define VSYNC_GPIO_NUM 5 +#define HREF_GPIO_NUM 27 +#define PCLK_GPIO_NUM 25 + +#elif defined(CAMERA_MODEL_ESP32S3_CAM_LCD) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 40 +#define SIOD_GPIO_NUM 17 +#define SIOC_GPIO_NUM 18 + +#define Y9_GPIO_NUM 39 +#define Y8_GPIO_NUM 41 +#define Y7_GPIO_NUM 42 +#define Y6_GPIO_NUM 12 +#define Y5_GPIO_NUM 3 +#define Y4_GPIO_NUM 14 +#define Y3_GPIO_NUM 47 +#define Y2_GPIO_NUM 13 +#define VSYNC_GPIO_NUM 21 +#define HREF_GPIO_NUM 38 +#define PCLK_GPIO_NUM 11 + +#elif defined(CAMERA_MODEL_ESP32S2_CAM_BOARD) +// The 18 pin header on the board has Y5 and Y3 swapped +#define USE_BOARD_HEADER 0 +#define PWDN_GPIO_NUM 1 +#define RESET_GPIO_NUM 2 +#define XCLK_GPIO_NUM 42 +#define SIOD_GPIO_NUM 41 +#define SIOC_GPIO_NUM 18 + +#define Y9_GPIO_NUM 16 +#define Y8_GPIO_NUM 39 +#define Y7_GPIO_NUM 40 +#define Y6_GPIO_NUM 15 +#if USE_BOARD_HEADER +#define Y5_GPIO_NUM 12 +#else +#define Y5_GPIO_NUM 13 +#endif +#define Y4_GPIO_NUM 5 +#if USE_BOARD_HEADER +#define Y3_GPIO_NUM 13 +#else +#define Y3_GPIO_NUM 12 +#endif +#define Y2_GPIO_NUM 14 +#define VSYNC_GPIO_NUM 38 +#define HREF_GPIO_NUM 4 +#define PCLK_GPIO_NUM 3 + +#elif defined(CAMERA_MODEL_ESP32S3_EYE) +#define PWDN_GPIO_NUM -1 +#define RESET_GPIO_NUM -1 +#define XCLK_GPIO_NUM 15 +#define SIOD_GPIO_NUM 4 +#define SIOC_GPIO_NUM 5 + +#define Y2_GPIO_NUM 11 +#define Y3_GPIO_NUM 9 +#define Y4_GPIO_NUM 8 +#define Y5_GPIO_NUM 10 +#define Y6_GPIO_NUM 12 +#define Y7_GPIO_NUM 18 +#define Y8_GPIO_NUM 17 +#define Y9_GPIO_NUM 16 + +#define VSYNC_GPIO_NUM 6 +#define HREF_GPIO_NUM 7 +#define PCLK_GPIO_NUM 13 + +#else +#error "Camera model not selected" +#endif diff --git a/arduino/ESP32CameraWebServer/index_ov2640.html b/arduino/ESP32CameraWebServer/index_ov2640.html new file mode 100644 index 0000000..4f3738c --- /dev/null +++ b/arduino/ESP32CameraWebServer/index_ov2640.html @@ -0,0 +1,1160 @@ + + + + + + ESP32 OV2460 + + + +
+ +
+ +
+ +
+
+
+ + + diff --git a/arduino/esp32-cam/CameraWebServer/CameraWebServer.ino b/arduino/esp32-cam/CameraWebServer/CameraWebServer.ino deleted file mode 100644 index ef589d9..0000000 --- a/arduino/esp32-cam/CameraWebServer/CameraWebServer.ino +++ /dev/null @@ -1,165 +0,0 @@ -#include "esp_camera.h" -#include - -// -// WARNING!!! PSRAM IC required for UXGA resolution and high JPEG quality -// Ensure ESP32 Wrover Module or other board with PSRAM is selected -// Partial images will be transmitted if image exceeds buffer size -// -// You must select partition scheme from the board menu that has at least 3MB APP space. -// Face Recognition is DISABLED for ESP32 and ESP32-S2, because it takes up from 15 -// seconds to process single frame. Face Detection is ENABLED if PSRAM is enabled as well - -// =================== -// Select camera model -// =================== -//#define CAMERA_MODEL_WROVER_KIT // Has PSRAM -//#define CAMERA_MODEL_ESP_EYE // Has PSRAM -//#define CAMERA_MODEL_ESP32S3_EYE // Has PSRAM -//#define CAMERA_MODEL_M5STACK_PSRAM // Has PSRAM -//#define CAMERA_MODEL_M5STACK_V2_PSRAM // M5Camera version B Has PSRAM -//#define CAMERA_MODEL_M5STACK_WIDE // Has PSRAM -//#define CAMERA_MODEL_M5STACK_ESP32CAM // No PSRAM -//#define CAMERA_MODEL_M5STACK_UNITCAM // No PSRAM -#define CAMERA_MODEL_AI_THINKER // Has PSRAM -//#define CAMERA_MODEL_TTGO_T_JOURNAL // No PSRAM -// ** Espressif Internal Boards ** -//#define CAMERA_MODEL_ESP32_CAM_BOARD -//#define CAMERA_MODEL_ESP32S2_CAM_BOARD -//#define CAMERA_MODEL_ESP32S3_CAM_LCD - -#include "camera_pins.h" - -// =========================== -// Enter your WiFi credentials -// =========================== -#include "wifi_password.h" - -volatile float disconnected_since = 0; - -void startCameraServer(); - -void onWiFiDisconnect(WiFiEvent_t event, WiFiEventInfo_t info) { - disconnected_since = millis(); - WiFi.reconnect(); -} - -void onWiFiConnect(WiFiEvent_t event, WiFiEventInfo_t info) { - disconnected_since = 0; -} - -void setup() { - Serial.begin(115200); - //Serial.setDebugOutput(true); - Serial.println(); - - camera_config_t config; - config.ledc_channel = LEDC_CHANNEL_0; - config.ledc_timer = LEDC_TIMER_0; - config.pin_d0 = Y2_GPIO_NUM; - config.pin_d1 = Y3_GPIO_NUM; - config.pin_d2 = Y4_GPIO_NUM; - config.pin_d3 = Y5_GPIO_NUM; - config.pin_d4 = Y6_GPIO_NUM; - config.pin_d5 = Y7_GPIO_NUM; - config.pin_d6 = Y8_GPIO_NUM; - config.pin_d7 = Y9_GPIO_NUM; - config.pin_xclk = XCLK_GPIO_NUM; - config.pin_pclk = PCLK_GPIO_NUM; - config.pin_vsync = VSYNC_GPIO_NUM; - config.pin_href = HREF_GPIO_NUM; - config.pin_sscb_sda = SIOD_GPIO_NUM; - config.pin_sscb_scl = SIOC_GPIO_NUM; - config.pin_pwdn = PWDN_GPIO_NUM; - config.pin_reset = RESET_GPIO_NUM; - config.xclk_freq_hz = 20000000; - config.frame_size = FRAMESIZE_UXGA; - config.pixel_format = PIXFORMAT_JPEG; // for streaming - //config.pixel_format = PIXFORMAT_RGB565; // for face detection/recognition - config.grab_mode = CAMERA_GRAB_WHEN_EMPTY; - config.fb_location = CAMERA_FB_IN_PSRAM; - config.jpeg_quality = 12; - config.fb_count = 1; - - // if PSRAM IC present, init with UXGA resolution and higher JPEG quality - // for larger pre-allocated frame buffer. - if(config.pixel_format == PIXFORMAT_JPEG){ - if(psramFound()){ - config.jpeg_quality = 10; - config.fb_count = 2; - config.grab_mode = CAMERA_GRAB_LATEST; - } else { - // Limit the frame size when PSRAM is not available - config.frame_size = FRAMESIZE_SVGA; - config.fb_location = CAMERA_FB_IN_DRAM; - } - } else { - // Best option for face detection/recognition - config.frame_size = FRAMESIZE_240X240; -#if CONFIG_IDF_TARGET_ESP32S3 - config.fb_count = 2; -#endif - } - -#if defined(CAMERA_MODEL_ESP_EYE) - pinMode(13, INPUT_PULLUP); - pinMode(14, INPUT_PULLUP); -#endif - - // camera init - esp_err_t err = esp_camera_init(&config); - if (err != ESP_OK) { - Serial.printf("Camera init failed with error 0x%x", err); - return; - } - - sensor_t * s = esp_camera_sensor_get(); - // initial sensors are flipped vertically and colors are a bit saturated - if (s->id.PID == OV3660_PID) { - s->set_vflip(s, 1); // flip it back - s->set_brightness(s, 1); // up the brightness just a bit - s->set_saturation(s, -2); // lower the saturation - } - // drop down frame size for higher initial frame rate - if(config.pixel_format == PIXFORMAT_JPEG){ - s->set_framesize(s, FRAMESIZE_QVGA); - } - -#if defined(CAMERA_MODEL_M5STACK_WIDE) || defined(CAMERA_MODEL_M5STACK_ESP32CAM) - s->set_vflip(s, 1); - s->set_hmirror(s, 1); -#endif - -#if defined(CAMERA_MODEL_ESP32S3_EYE) - s->set_vflip(s, 1); -#endif - - WiFi.onEvent(onWiFiDisconnect, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); - WiFi.onEvent(onWiFiConnect, ARDUINO_EVENT_WIFI_STA_CONNECTED); - - WiFi.begin(ssid, password); - WiFi.setSleep(false); - - while (WiFi.status() != WL_CONNECTED) { - delay(500); - Serial.print("."); - } - Serial.println(""); - Serial.println("WiFi connected"); - - startCameraServer(); - - Serial.print("Camera Ready! Use 'http://"); - Serial.print(WiFi.localIP()); - Serial.println("' to connect"); -} - -void loop() { - if (disconnected_since != 0 && (millis() - disconnected_since) > 60000) { - ESP.restart(); - return; - } - - // Do nothing. Everything is done in another task by the web server - delay(10000); -} diff --git a/arduino/esp32-cam/CameraWebServer/app_httpd.cpp b/arduino/esp32-cam/CameraWebServer/app_httpd.cpp deleted file mode 100644 index e397c70..0000000 --- a/arduino/esp32-cam/CameraWebServer/app_httpd.cpp +++ /dev/null @@ -1,1287 +0,0 @@ -// Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -#include - -#include "esp_http_server.h" -#include "esp_timer.h" -#include "esp_camera.h" -#include "img_converters.h" -#include "fb_gfx.h" -#include "driver/ledc.h" -#include "sdkconfig.h" -#include "camera_index.h" - -#if defined(ARDUINO_ARCH_ESP32) && defined(CONFIG_ARDUHAL_ESP_LOG) -#include "esp32-hal-log.h" -#define TAG "" -#else -#include "esp_log.h" -static const char *TAG = "camera_httpd"; -#endif - -// Face Detection will not work on boards without (or with disabled) PSRAM -#ifdef BOARD_HAS_PSRAM -#define CONFIG_ESP_FACE_DETECT_ENABLED 1 -// Face Recognition takes upward from 15 seconds per frame on chips other than ESP32S3 -// Makes no sense to have it enabled for them -#if CONFIG_IDF_TARGET_ESP32S3 -#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 1 -#else -#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 0 -#endif -#else -#define CONFIG_ESP_FACE_DETECT_ENABLED 0 -#define CONFIG_ESP_FACE_RECOGNITION_ENABLED 0 -#endif - -#if CONFIG_ESP_FACE_DETECT_ENABLED - -#include -#include "human_face_detect_msr01.hpp" -#include "human_face_detect_mnp01.hpp" - -#define TWO_STAGE 1 /* very large firmware, very slow, reboots when streaming... - -#define FACE_ID_SAVE_NUMBER 7 -#endif - -#define FACE_COLOR_WHITE 0x00FFFFFF -#define FACE_COLOR_BLACK 0x00000000 -#define FACE_COLOR_RED 0x000000FF -#define FACE_COLOR_GREEN 0x0000FF00 -#define FACE_COLOR_BLUE 0x00FF0000 -#define FACE_COLOR_YELLOW (FACE_COLOR_RED | FACE_COLOR_GREEN) -#define FACE_COLOR_CYAN (FACE_COLOR_BLUE | FACE_COLOR_GREEN) -#define FACE_COLOR_PURPLE (FACE_COLOR_BLUE | FACE_COLOR_RED) -#endif - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED -int led_duty = 0; -bool isStreaming = false; -#ifdef CONFIG_LED_LEDC_LOW_SPEED_MODE -#define CONFIG_LED_LEDC_SPEED_MODE LEDC_LOW_SPEED_MODE -#else -#define CONFIG_LED_LEDC_SPEED_MODE LEDC_HIGH_SPEED_MODE -#endif -#endif - -typedef struct -{ - httpd_req_t *req; - size_t len; -} jpg_chunking_t; - -#define PART_BOUNDARY "123456789000000000000987654321" -static const char *_STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY; -static const char *_STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n"; -static const char *_STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\nX-Timestamp: %d.%06d\r\n\r\n"; - -httpd_handle_t stream_httpd = NULL; -httpd_handle_t camera_httpd = NULL; - -#if CONFIG_ESP_FACE_DETECT_ENABLED - -static int8_t detection_enabled = 0; - -// #if TWO_STAGE -// static HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); -// static HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); -// #else -// static HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); -// #endif - -static int8_t flash_enabled = 0; - -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED -static int8_t recognition_enabled = 0; -static int8_t is_enrolling = 0; - -#if QUANT_TYPE - // S16 model - FaceRecognition112V1S16 recognizer; -#else - // S8 model - FaceRecognition112V1S8 recognizer; -#endif -#endif - -#endif - -typedef struct -{ - size_t size; //number of values used for filtering - size_t index; //current value index - size_t count; //value count - int sum; - int *values; //array to be filled with values -} ra_filter_t; - -static ra_filter_t ra_filter; - -static ra_filter_t *ra_filter_init(ra_filter_t *filter, size_t sample_size) -{ - memset(filter, 0, sizeof(ra_filter_t)); - - filter->values = (int *)malloc(sample_size * sizeof(int)); - if (!filter->values) - { - return NULL; - } - memset(filter->values, 0, sample_size * sizeof(int)); - - filter->size = sample_size; - return filter; -} - -static int ra_filter_run(ra_filter_t *filter, int value) -{ - if (!filter->values) - { - return value; - } - filter->sum -= filter->values[filter->index]; - filter->values[filter->index] = value; - filter->sum += filter->values[filter->index]; - filter->index++; - filter->index = filter->index % filter->size; - if (filter->count < filter->size) - { - filter->count++; - } - return filter->sum / filter->count; -} - -#if CONFIG_ESP_FACE_DETECT_ENABLED -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED -static void rgb_print(fb_data_t *fb, uint32_t color, const char *str) -{ - fb_gfx_print(fb, (fb->width - (strlen(str) * 14)) / 2, 10, color, str); -} - -static int rgb_printf(fb_data_t *fb, uint32_t color, const char *format, ...) -{ - char loc_buf[64]; - char *temp = loc_buf; - int len; - va_list arg; - va_list copy; - va_start(arg, format); - va_copy(copy, arg); - len = vsnprintf(loc_buf, sizeof(loc_buf), format, arg); - va_end(copy); - if (len >= sizeof(loc_buf)) - { - temp = (char *)malloc(len + 1); - if (temp == NULL) - { - return 0; - } - } - vsnprintf(temp, len + 1, format, arg); - va_end(arg); - rgb_print(fb, color, temp); - if (len > 64) - { - free(temp); - } - return len; -} -#endif -static void draw_face_boxes(fb_data_t *fb, std::list *results, int face_id) -{ - int x, y, w, h; - uint32_t color = FACE_COLOR_YELLOW; - if (face_id < 0) - { - color = FACE_COLOR_RED; - } - else if (face_id > 0) - { - color = FACE_COLOR_GREEN; - } - if(fb->bytes_per_pixel == 2){ - //color = ((color >> 8) & 0xF800) | ((color >> 3) & 0x07E0) | (color & 0x001F); - color = ((color >> 16) & 0x001F) | ((color >> 3) & 0x07E0) | ((color << 8) & 0xF800); - } - int i = 0; - for (std::list::iterator prediction = results->begin(); prediction != results->end(); prediction++, i++) - { - // rectangle box - x = (int)prediction->box[0]; - y = (int)prediction->box[1]; - w = (int)prediction->box[2] - x + 1; - h = (int)prediction->box[3] - y + 1; - if((x + w) > fb->width){ - w = fb->width - x; - } - if((y + h) > fb->height){ - h = fb->height - y; - } - fb_gfx_drawFastHLine(fb, x, y, w, color); - fb_gfx_drawFastHLine(fb, x, y + h - 1, w, color); - fb_gfx_drawFastVLine(fb, x, y, h, color); - fb_gfx_drawFastVLine(fb, x + w - 1, y, h, color); -#if TWO_STAGE - // landmarks (left eye, mouth left, nose, right eye, mouth right) - int x0, y0, j; - for (j = 0; j < 10; j+=2) { - x0 = (int)prediction->keypoint[j]; - y0 = (int)prediction->keypoint[j+1]; - fb_gfx_fillRect(fb, x0, y0, 3, 3, color); - } -#endif - } -} - -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED -static int run_face_recognition(fb_data_t *fb, std::list *results) -{ - std::vector landmarks = results->front().keypoint; - int id = -1; - - Tensor tensor; - tensor.set_element((uint8_t *)fb->data).set_shape({fb->height, fb->width, 3}).set_auto_free(false); - - int enrolled_count = recognizer.get_enrolled_id_num(); - - if (enrolled_count < FACE_ID_SAVE_NUMBER && is_enrolling){ - id = recognizer.enroll_id(tensor, landmarks, "", true); - ESP_LOGI(TAG, "Enrolled ID: %d", id); - rgb_printf(fb, FACE_COLOR_CYAN, "ID[%u]", id); - } - - face_info_t recognize = recognizer.recognize(tensor, landmarks); - if(recognize.id >= 0){ - rgb_printf(fb, FACE_COLOR_GREEN, "ID[%u]: %.2f", recognize.id, recognize.similarity); - } else { - rgb_print(fb, FACE_COLOR_RED, "Intruder Alert!"); - } - return recognize.id; -} -#endif -#endif - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED -void enable_led(bool en) -{ // Turn LED On or Off - int duty = en ? led_duty : 0; - if (en && isStreaming && (led_duty > CONFIG_LED_MAX_INTENSITY)) - { - duty = CONFIG_LED_MAX_INTENSITY; - } - ledc_set_duty(CONFIG_LED_LEDC_SPEED_MODE, CONFIG_LED_LEDC_CHANNEL, duty); - ledc_update_duty(CONFIG_LED_LEDC_SPEED_MODE, CONFIG_LED_LEDC_CHANNEL); - ESP_LOGI(TAG, "Set LED intensity to %d", duty); -} -#endif - -static esp_err_t bmp_handler(httpd_req_t *req) -{ - camera_fb_t *fb = NULL; - esp_err_t res = ESP_OK; - uint64_t fr_start = esp_timer_get_time(); - fb = esp_camera_fb_get(); - if (!fb) - { - ESP_LOGE(TAG, "Camera capture failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - - httpd_resp_set_type(req, "image/x-windows-bmp"); - httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=capture.bmp"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - - char ts[32]; - snprintf(ts, 32, "%ld.%06ld", fb->timestamp.tv_sec, fb->timestamp.tv_usec); - httpd_resp_set_hdr(req, "X-Timestamp", (const char *)ts); - - - uint8_t * buf = NULL; - size_t buf_len = 0; - bool converted = frame2bmp(fb, &buf, &buf_len); - esp_camera_fb_return(fb); - if(!converted){ - ESP_LOGE(TAG, "BMP Conversion failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - res = httpd_resp_send(req, (const char *)buf, buf_len); - free(buf); - uint64_t fr_end = esp_timer_get_time(); - ESP_LOGI(TAG, "BMP: %llums, %uB", (uint64_t)((fr_end - fr_start) / 1000), buf_len); - return res; -} - -static size_t jpg_encode_stream(void *arg, size_t index, const void *data, size_t len) -{ - jpg_chunking_t *j = (jpg_chunking_t *)arg; - if (!index) - { - j->len = 0; - } - if (httpd_resp_send_chunk(j->req, (const char *)data, len) != ESP_OK) - { - return 0; - } - j->len += len; - return len; -} - -static esp_err_t capture_handler(httpd_req_t *req) -{ - camera_fb_t *fb = NULL; - esp_err_t res = ESP_OK; - int64_t fr_start = esp_timer_get_time(); - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - enable_led(true); - vTaskDelay(150 / portTICK_PERIOD_MS); // The LED needs to be turned on ~150ms before the call to esp_camera_fb_get() - fb = esp_camera_fb_get(); // or it won't be visible in the frame. A better way to do this is needed. - enable_led(false); -#else - fb = esp_camera_fb_get(); -#endif - - if (!fb) - { - ESP_LOGE(TAG, "Camera capture failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - - httpd_resp_set_type(req, "image/jpeg"); - httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=capture.jpg"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - - char ts[32]; - snprintf(ts, 32, "%ld.%06ld", fb->timestamp.tv_sec, fb->timestamp.tv_usec); - httpd_resp_set_hdr(req, "X-Timestamp", (const char *)ts); - -#if CONFIG_ESP_FACE_DETECT_ENABLED - size_t out_len, out_width, out_height; - uint8_t *out_buf; - bool s; - bool detected = false; - int face_id = 0; - if (!detection_enabled || fb->width > 400) - { -#endif - size_t fb_len = 0; - if (fb->format == PIXFORMAT_JPEG) - { - fb_len = fb->len; - res = httpd_resp_send(req, (const char *)fb->buf, fb->len); - } - else - { - jpg_chunking_t jchunk = {req, 0}; - res = frame2jpg_cb(fb, 80, jpg_encode_stream, &jchunk) ? ESP_OK : ESP_FAIL; - httpd_resp_send_chunk(req, NULL, 0); - fb_len = jchunk.len; - } - esp_camera_fb_return(fb); - int64_t fr_end = esp_timer_get_time(); - ESP_LOGI(TAG, "JPG: %uB %ums", (uint32_t)(fb_len), (uint32_t)((fr_end - fr_start) / 1000)); - return res; -#if CONFIG_ESP_FACE_DETECT_ENABLED - } - - jpg_chunking_t jchunk = {req, 0}; - - if (fb->format == PIXFORMAT_RGB565 -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - && !recognition_enabled -#endif - ){ -#if TWO_STAGE - HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); - HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); - std::list &candidates = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); - std::list &results = s2.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}, candidates); -#else - HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); - std::list &results = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); -#endif - if (results.size() > 0) { - fb_data_t rfb; - rfb.width = fb->width; - rfb.height = fb->height; - rfb.data = fb->buf; - rfb.bytes_per_pixel = 2; - rfb.format = FB_RGB565; - detected = true; - draw_face_boxes(&rfb, &results, face_id); - } - s = fmt2jpg_cb(fb->buf, fb->len, fb->width, fb->height, PIXFORMAT_RGB565, 90, jpg_encode_stream, &jchunk); - esp_camera_fb_return(fb); - } else - { - out_len = fb->width * fb->height * 3; - out_width = fb->width; - out_height = fb->height; - out_buf = (uint8_t*)malloc(out_len); - if (!out_buf) { - ESP_LOGE(TAG, "out_buf malloc failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - s = fmt2rgb888(fb->buf, fb->len, fb->format, out_buf); - esp_camera_fb_return(fb); - if (!s) { - free(out_buf); - ESP_LOGE(TAG, "to rgb888 failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - - fb_data_t rfb; - rfb.width = out_width; - rfb.height = out_height; - rfb.data = out_buf; - rfb.bytes_per_pixel = 3; - rfb.format = FB_BGR888; - -#if TWO_STAGE - HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); - HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); - std::list &candidates = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); - std::list &results = s2.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}, candidates); -#else - HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); - std::list &results = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); -#endif - - if (results.size() > 0) { - detected = true; -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - if (recognition_enabled) { - face_id = run_face_recognition(&rfb, &results); - } -#endif - draw_face_boxes(&rfb, &results, face_id); - } - - s = fmt2jpg_cb(out_buf, out_len, out_width, out_height, PIXFORMAT_RGB888, 90, jpg_encode_stream, &jchunk); - free(out_buf); - } - - if (!s) { - ESP_LOGE(TAG, "JPEG compression failed"); - httpd_resp_send_500(req); - return ESP_FAIL; - } - - int64_t fr_end = esp_timer_get_time(); - ESP_LOGI(TAG, "FACE: %uB %ums %s%d", (uint32_t)(jchunk.len), (uint32_t)((fr_end - fr_start) / 1000), detected ? "DETECTED " : "", face_id); - return res; -#endif -} - -static esp_err_t stream_handler(httpd_req_t *req) -{ - camera_fb_t *fb = NULL; - struct timeval _timestamp; - esp_err_t res = ESP_OK; - size_t _jpg_buf_len = 0; - uint8_t *_jpg_buf = NULL; - char *part_buf[128]; -#if CONFIG_ESP_FACE_DETECT_ENABLED - bool detected = false; - int face_id = 0; - int64_t fr_start = 0; - int64_t fr_ready = 0; - int64_t fr_face = 0; - int64_t fr_recognize = 0; - int64_t fr_encode = 0; - - size_t out_len = 0, out_width = 0, out_height = 0; - uint8_t *out_buf = NULL; - bool s = false; -#if TWO_STAGE - HumanFaceDetectMSR01 s1(0.1F, 0.5F, 10, 0.2F); - HumanFaceDetectMNP01 s2(0.5F, 0.3F, 5); -#else - HumanFaceDetectMSR01 s1(0.3F, 0.5F, 10, 0.2F); -#endif -#endif - - static int64_t last_frame = 0; - if (!last_frame) - { - last_frame = esp_timer_get_time(); - } - - res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE); - if (res != ESP_OK) - { - return res; - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - httpd_resp_set_hdr(req, "X-Framerate", "60"); - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - enable_led(true); - isStreaming = true; -#endif - - while (true) - { -#if CONFIG_ESP_FACE_DETECT_ENABLED - detected = false; - face_id = 0; -#endif - - fb = esp_camera_fb_get(); - if (!fb) - { - ESP_LOGE(TAG, "Camera capture failed"); - res = ESP_FAIL; - } - else - { - _timestamp.tv_sec = fb->timestamp.tv_sec; - _timestamp.tv_usec = fb->timestamp.tv_usec; -#if CONFIG_ESP_FACE_DETECT_ENABLED - fr_start = esp_timer_get_time(); - fr_ready = fr_start; - fr_face = fr_start; - fr_encode = fr_start; - fr_recognize = fr_start; - if (!detection_enabled || fb->width > 400) - { -#endif - if (fb->format != PIXFORMAT_JPEG) - { - bool jpeg_converted = frame2jpg(fb, 80, &_jpg_buf, &_jpg_buf_len); - esp_camera_fb_return(fb); - fb = NULL; - if (!jpeg_converted) - { - ESP_LOGE(TAG, "JPEG compression failed"); - res = ESP_FAIL; - } - } - else - { - _jpg_buf_len = fb->len; - _jpg_buf = fb->buf; - } -#if CONFIG_ESP_FACE_DETECT_ENABLED - } - else - { - if (fb->format == PIXFORMAT_RGB565 -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - && !recognition_enabled -#endif - ){ - fr_ready = esp_timer_get_time(); -#if TWO_STAGE - std::list &candidates = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); - std::list &results = s2.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}, candidates); -#else - std::list &results = s1.infer((uint16_t *)fb->buf, {(int)fb->height, (int)fb->width, 3}); -#endif - fr_face = esp_timer_get_time(); - fr_recognize = fr_face; - if (results.size() > 0) { - fb_data_t rfb; - rfb.width = fb->width; - rfb.height = fb->height; - rfb.data = fb->buf; - rfb.bytes_per_pixel = 2; - rfb.format = FB_RGB565; - detected = true; - draw_face_boxes(&rfb, &results, face_id); - } - s = fmt2jpg(fb->buf, fb->len, fb->width, fb->height, PIXFORMAT_RGB565, 80, &_jpg_buf, &_jpg_buf_len); - esp_camera_fb_return(fb); - fb = NULL; - if (!s) { - ESP_LOGE(TAG, "fmt2jpg failed"); - res = ESP_FAIL; - } - fr_encode = esp_timer_get_time(); - } else - { - out_len = fb->width * fb->height * 3; - out_width = fb->width; - out_height = fb->height; - out_buf = (uint8_t*)malloc(out_len); - if (!out_buf) { - ESP_LOGE(TAG, "out_buf malloc failed"); - res = ESP_FAIL; - } else { - s = fmt2rgb888(fb->buf, fb->len, fb->format, out_buf); - esp_camera_fb_return(fb); - fb = NULL; - if (!s) { - free(out_buf); - ESP_LOGE(TAG, "to rgb888 failed"); - res = ESP_FAIL; - } else { - fr_ready = esp_timer_get_time(); - - fb_data_t rfb; - rfb.width = out_width; - rfb.height = out_height; - rfb.data = out_buf; - rfb.bytes_per_pixel = 3; - rfb.format = FB_BGR888; - -#if TWO_STAGE - std::list &candidates = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); - std::list &results = s2.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}, candidates); -#else - std::list &results = s1.infer((uint8_t *)out_buf, {(int)out_height, (int)out_width, 3}); -#endif - - fr_face = esp_timer_get_time(); - fr_recognize = fr_face; - - if (results.size() > 0) { - detected = true; -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - if (recognition_enabled) { - face_id = run_face_recognition(&rfb, &results); - fr_recognize = esp_timer_get_time(); - } -#endif - draw_face_boxes(&rfb, &results, face_id); - } - s = fmt2jpg(out_buf, out_len, out_width, out_height, PIXFORMAT_RGB888, 90, &_jpg_buf, &_jpg_buf_len); - free(out_buf); - if (!s) { - ESP_LOGE(TAG, "fmt2jpg failed"); - res = ESP_FAIL; - } - fr_encode = esp_timer_get_time(); - } - } - } - } -#endif - } - if (res == ESP_OK) - { - res = httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY)); - } - if (res == ESP_OK) - { - size_t hlen = snprintf((char *)part_buf, 128, _STREAM_PART, _jpg_buf_len, _timestamp.tv_sec, _timestamp.tv_usec); - res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen); - } - if (res == ESP_OK) - { - res = httpd_resp_send_chunk(req, (const char *)_jpg_buf, _jpg_buf_len); - } - if (fb) - { - esp_camera_fb_return(fb); - fb = NULL; - _jpg_buf = NULL; - } - else if (_jpg_buf) - { - free(_jpg_buf); - _jpg_buf = NULL; - } - if (res != ESP_OK) - { - ESP_LOGE(TAG, "send frame failed failed"); - break; - } - int64_t fr_end = esp_timer_get_time(); - -#if CONFIG_ESP_FACE_DETECT_ENABLED - int64_t ready_time = (fr_ready - fr_start) / 1000; - int64_t face_time = (fr_face - fr_ready) / 1000; - int64_t recognize_time = (fr_recognize - fr_face) / 1000; - int64_t encode_time = (fr_encode - fr_recognize) / 1000; - int64_t process_time = (fr_encode - fr_start) / 1000; -#endif - - int64_t frame_time = fr_end - last_frame; - last_frame = fr_end; - frame_time /= 1000; - uint32_t avg_frame_time = ra_filter_run(&ra_filter, frame_time); - ESP_LOGI(TAG, "MJPG: %uB %ums (%.1ffps), AVG: %ums (%.1ffps)" -#if CONFIG_ESP_FACE_DETECT_ENABLED - ", %u+%u+%u+%u=%u %s%d" -#endif - , - (uint32_t)(_jpg_buf_len), - (uint32_t)frame_time, 1000.0 / (uint32_t)frame_time, - avg_frame_time, 1000.0 / avg_frame_time -#if CONFIG_ESP_FACE_DETECT_ENABLED - , - (uint32_t)ready_time, (uint32_t)face_time, (uint32_t)recognize_time, (uint32_t)encode_time, (uint32_t)process_time, - (detected) ? "DETECTED " : "", face_id -#endif - ); - } - -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - isStreaming = false; - enable_led(false); -#endif - - last_frame = 0; - return res; -} - -static esp_err_t parse_get(httpd_req_t *req, char **obuf) -{ - char *buf = NULL; - size_t buf_len = 0; - - buf_len = httpd_req_get_url_query_len(req) + 1; - if (buf_len > 1) { - buf = (char *)malloc(buf_len); - if (!buf) { - httpd_resp_send_500(req); - return ESP_FAIL; - } - if (httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) { - *obuf = buf; - return ESP_OK; - } - free(buf); - } - httpd_resp_send_404(req); - return ESP_FAIL; -} - -static esp_err_t cmd_handler(httpd_req_t *req) -{ - char *buf = NULL; - char variable[32]; - char value[32]; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - if (httpd_query_key_value(buf, "var", variable, sizeof(variable)) != ESP_OK || - httpd_query_key_value(buf, "val", value, sizeof(value)) != ESP_OK) { - free(buf); - httpd_resp_send_404(req); - return ESP_FAIL; - } - free(buf); - - int val = atoi(value); - ESP_LOGI(TAG, "%s = %d", variable, val); - sensor_t *s = esp_camera_sensor_get(); - int res = 0; - - if (!strcmp(variable, "framesize")) { - if (s->pixformat == PIXFORMAT_JPEG) { - res = s->set_framesize(s, (framesize_t)val); - } - } - else if (!strcmp(variable, "quality")) - res = s->set_quality(s, val); - else if (!strcmp(variable, "contrast")) - res = s->set_contrast(s, val); - else if (!strcmp(variable, "brightness")) - res = s->set_brightness(s, val); - else if (!strcmp(variable, "saturation")) - res = s->set_saturation(s, val); - else if (!strcmp(variable, "gainceiling")) - res = s->set_gainceiling(s, (gainceiling_t)val); - else if (!strcmp(variable, "colorbar")) - res = s->set_colorbar(s, val); - else if (!strcmp(variable, "awb")) - res = s->set_whitebal(s, val); - else if (!strcmp(variable, "agc")) - res = s->set_gain_ctrl(s, val); - else if (!strcmp(variable, "aec")) - res = s->set_exposure_ctrl(s, val); - else if (!strcmp(variable, "hmirror")) - res = s->set_hmirror(s, val); - else if (!strcmp(variable, "vflip")) - res = s->set_vflip(s, val); - else if (!strcmp(variable, "awb_gain")) - res = s->set_awb_gain(s, val); - else if (!strcmp(variable, "agc_gain")) - res = s->set_agc_gain(s, val); - else if (!strcmp(variable, "aec_value")) - res = s->set_aec_value(s, val); - else if (!strcmp(variable, "aec2")) - res = s->set_aec2(s, val); - else if (!strcmp(variable, "dcw")) - res = s->set_dcw(s, val); - else if (!strcmp(variable, "bpc")) - res = s->set_bpc(s, val); - else if (!strcmp(variable, "wpc")) - res = s->set_wpc(s, val); - else if (!strcmp(variable, "raw_gma")) - res = s->set_raw_gma(s, val); - else if (!strcmp(variable, "lenc")) - res = s->set_lenc(s, val); - else if (!strcmp(variable, "special_effect")) - res = s->set_special_effect(s, val); - else if (!strcmp(variable, "wb_mode")) - res = s->set_wb_mode(s, val); - else if (!strcmp(variable, "ae_level")) - res = s->set_ae_level(s, val); -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - else if (!strcmp(variable, "led_intensity")) { - led_duty = val; - if (isStreaming) - enable_led(true); - } -#endif - -#if CONFIG_ESP_FACE_DETECT_ENABLED - else if (!strcmp(variable, "face_detect")) { - detection_enabled = val; -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - if (!detection_enabled) { - recognition_enabled = 0; - } -#endif - } -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - else if (!strcmp(variable, "face_enroll")){ - is_enrolling = !is_enrolling; - ESP_LOGI(TAG, "Enrolling: %s", is_enrolling?"true":"false"); - } - else if (!strcmp(variable, "face_recognize")) { - recognition_enabled = val; - if (recognition_enabled) { - detection_enabled = val; - } - } -#endif -#endif - else if(!strcmp(variable, "flash") ) { - pinMode(4, OUTPUT); - digitalWrite(4, atoi(value)); - flash_enabled = atoi(value); - } - else { - ESP_LOGI(TAG, "Unknown command: %s", variable); - res = -1; - } - - if (res < 0) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static int print_reg(char * p, sensor_t * s, uint16_t reg, uint32_t mask){ - return sprintf(p, "\"0x%x\":%u,", reg, s->get_reg(s, reg, mask)); -} - -static esp_err_t status_handler(httpd_req_t *req) -{ - static char json_response[1024]; - - sensor_t *s = esp_camera_sensor_get(); - char *p = json_response; - *p++ = '{'; - - if(s->id.PID == OV5640_PID || s->id.PID == OV3660_PID){ - for(int reg = 0x3400; reg < 0x3406; reg+=2){ - p+=print_reg(p, s, reg, 0xFFF);//12 bit - } - p+=print_reg(p, s, 0x3406, 0xFF); - - p+=print_reg(p, s, 0x3500, 0xFFFF0);//16 bit - p+=print_reg(p, s, 0x3503, 0xFF); - p+=print_reg(p, s, 0x350a, 0x3FF);//10 bit - p+=print_reg(p, s, 0x350c, 0xFFFF);//16 bit - - for(int reg = 0x5480; reg <= 0x5490; reg++){ - p+=print_reg(p, s, reg, 0xFF); - } - - for(int reg = 0x5380; reg <= 0x538b; reg++){ - p+=print_reg(p, s, reg, 0xFF); - } - - for(int reg = 0x5580; reg < 0x558a; reg++){ - p+=print_reg(p, s, reg, 0xFF); - } - p+=print_reg(p, s, 0x558a, 0x1FF);//9 bit - } else if(s->id.PID == OV2640_PID){ - p+=print_reg(p, s, 0xd3, 0xFF); - p+=print_reg(p, s, 0x111, 0xFF); - p+=print_reg(p, s, 0x132, 0xFF); - } - - p += sprintf(p, "\"xclk\":%u,", s->xclk_freq_hz / 1000000); - p += sprintf(p, "\"pixformat\":%u,", s->pixformat); - p += sprintf(p, "\"framesize\":%u,", s->status.framesize); - p += sprintf(p, "\"quality\":%u,", s->status.quality); - p += sprintf(p, "\"brightness\":%d,", s->status.brightness); - p += sprintf(p, "\"contrast\":%d,", s->status.contrast); - p += sprintf(p, "\"saturation\":%d,", s->status.saturation); - p += sprintf(p, "\"sharpness\":%d,", s->status.sharpness); - p += sprintf(p, "\"special_effect\":%u,", s->status.special_effect); - p += sprintf(p, "\"wb_mode\":%u,", s->status.wb_mode); - p += sprintf(p, "\"awb\":%u,", s->status.awb); - p += sprintf(p, "\"awb_gain\":%u,", s->status.awb_gain); - p += sprintf(p, "\"aec\":%u,", s->status.aec); - p += sprintf(p, "\"aec2\":%u,", s->status.aec2); - p += sprintf(p, "\"ae_level\":%d,", s->status.ae_level); - p += sprintf(p, "\"aec_value\":%u,", s->status.aec_value); - p += sprintf(p, "\"agc\":%u,", s->status.agc); - p += sprintf(p, "\"agc_gain\":%u,", s->status.agc_gain); - p += sprintf(p, "\"gainceiling\":%u,", s->status.gainceiling); - p += sprintf(p, "\"bpc\":%u,", s->status.bpc); - p += sprintf(p, "\"wpc\":%u,", s->status.wpc); - p += sprintf(p, "\"raw_gma\":%u,", s->status.raw_gma); - p += sprintf(p, "\"lenc\":%u,", s->status.lenc); - p += sprintf(p, "\"hmirror\":%u,", s->status.hmirror); - p += sprintf(p, "\"vflip\":%u,", s->status.vflip); - p += sprintf(p, "\"dcw\":%u,", s->status.dcw); - p += sprintf(p, "\"colorbar\":%u", s->status.colorbar); -#ifdef CONFIG_LED_ILLUMINATOR_ENABLED - p += sprintf(p, ",\"led_intensity\":%u", led_duty); -#else - p += sprintf(p, ",\"led_intensity\":%d", -1); -#endif -#if CONFIG_ESP_FACE_DETECT_ENABLED - p += sprintf(p, ",\"face_detect\":%u", detection_enabled); -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - p += sprintf(p, ",\"face_enroll\":%u,", is_enrolling); - p += sprintf(p, ",\"face_recognize\":%u", recognition_enabled); -#endif -#endif - p += sprintf(p, ",\"flash\":%u", flash_enabled); - *p++ = '}'; - *p++ = 0; - httpd_resp_set_type(req, "application/json"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, json_response, strlen(json_response)); -} - -static esp_err_t xclk_handler(httpd_req_t *req) -{ - char *buf = NULL; - char _xclk[32]; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - if (httpd_query_key_value(buf, "xclk", _xclk, sizeof(_xclk)) != ESP_OK) { - free(buf); - httpd_resp_send_404(req); - return ESP_FAIL; - } - free(buf); - - int xclk = atoi(_xclk); - ESP_LOGI(TAG, "Set XCLK: %d MHz", xclk); - - sensor_t *s = esp_camera_sensor_get(); - int res = s->set_xclk(s, LEDC_TIMER_0, xclk); - if (res) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static esp_err_t reg_handler(httpd_req_t *req) -{ - char *buf = NULL; - char _reg[32]; - char _mask[32]; - char _val[32]; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - if (httpd_query_key_value(buf, "reg", _reg, sizeof(_reg)) != ESP_OK || - httpd_query_key_value(buf, "mask", _mask, sizeof(_mask)) != ESP_OK || - httpd_query_key_value(buf, "val", _val, sizeof(_val)) != ESP_OK) { - free(buf); - httpd_resp_send_404(req); - return ESP_FAIL; - } - free(buf); - - int reg = atoi(_reg); - int mask = atoi(_mask); - int val = atoi(_val); - ESP_LOGI(TAG, "Set Register: reg: 0x%02x, mask: 0x%02x, value: 0x%02x", reg, mask, val); - - sensor_t *s = esp_camera_sensor_get(); - int res = s->set_reg(s, reg, mask, val); - if (res) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static esp_err_t greg_handler(httpd_req_t *req) -{ - char *buf = NULL; - char _reg[32]; - char _mask[32]; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - if (httpd_query_key_value(buf, "reg", _reg, sizeof(_reg)) != ESP_OK || - httpd_query_key_value(buf, "mask", _mask, sizeof(_mask)) != ESP_OK) { - free(buf); - httpd_resp_send_404(req); - return ESP_FAIL; - } - free(buf); - - int reg = atoi(_reg); - int mask = atoi(_mask); - sensor_t *s = esp_camera_sensor_get(); - int res = s->get_reg(s, reg, mask); - if (res < 0) { - return httpd_resp_send_500(req); - } - ESP_LOGI(TAG, "Get Register: reg: 0x%02x, mask: 0x%02x, value: 0x%02x", reg, mask, res); - - char buffer[20]; - const char * val = itoa(res, buffer, 10); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, val, strlen(val)); -} - -static int parse_get_var(char *buf, const char * key, int def) -{ - char _int[16]; - if(httpd_query_key_value(buf, key, _int, sizeof(_int)) != ESP_OK){ - return def; - } - return atoi(_int); -} - -static esp_err_t pll_handler(httpd_req_t *req) -{ - char *buf = NULL; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - - int bypass = parse_get_var(buf, "bypass", 0); - int mul = parse_get_var(buf, "mul", 0); - int sys = parse_get_var(buf, "sys", 0); - int root = parse_get_var(buf, "root", 0); - int pre = parse_get_var(buf, "pre", 0); - int seld5 = parse_get_var(buf, "seld5", 0); - int pclken = parse_get_var(buf, "pclken", 0); - int pclk = parse_get_var(buf, "pclk", 0); - free(buf); - - ESP_LOGI(TAG, "Set Pll: bypass: %d, mul: %d, sys: %d, root: %d, pre: %d, seld5: %d, pclken: %d, pclk: %d", bypass, mul, sys, root, pre, seld5, pclken, pclk); - sensor_t *s = esp_camera_sensor_get(); - int res = s->set_pll(s, bypass, mul, sys, root, pre, seld5, pclken, pclk); - if (res) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static esp_err_t win_handler(httpd_req_t *req) -{ - char *buf = NULL; - - if (parse_get(req, &buf) != ESP_OK) { - return ESP_FAIL; - } - - int startX = parse_get_var(buf, "sx", 0); - int startY = parse_get_var(buf, "sy", 0); - int endX = parse_get_var(buf, "ex", 0); - int endY = parse_get_var(buf, "ey", 0); - int offsetX = parse_get_var(buf, "offx", 0); - int offsetY = parse_get_var(buf, "offy", 0); - int totalX = parse_get_var(buf, "tx", 0); - int totalY = parse_get_var(buf, "ty", 0); - int outputX = parse_get_var(buf, "ox", 0); - int outputY = parse_get_var(buf, "oy", 0); - bool scale = parse_get_var(buf, "scale", 0) == 1; - bool binning = parse_get_var(buf, "binning", 0) == 1; - free(buf); - - ESP_LOGI(TAG, "Set Window: Start: %d %d, End: %d %d, Offset: %d %d, Total: %d %d, Output: %d %d, Scale: %u, Binning: %u", startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); - sensor_t *s = esp_camera_sensor_get(); - int res = s->set_res_raw(s, startX, startY, endX, endY, offsetX, offsetY, totalX, totalY, outputX, outputY, scale, binning); - if (res) { - return httpd_resp_send_500(req); - } - - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, NULL, 0); -} - -static esp_err_t uptime_handler(httpd_req_t *req) -{ - char buf[64]; - sprintf(buf, "{\"seconds\":%ld}", (long)(millis()/1000)); - httpd_resp_set_type(req, "application/json"); - httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); - return httpd_resp_send(req, buf, strlen(buf)); -} - -static esp_err_t index_handler(httpd_req_t *req) -{ - httpd_resp_set_type(req, "text/html"); - httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); - sensor_t *s = esp_camera_sensor_get(); - if (s != NULL) { - if (s->id.PID == OV3660_PID) { - return httpd_resp_send(req, (const char *)index_ov3660_html_gz, index_ov3660_html_gz_len); - } else if (s->id.PID == OV5640_PID) { - return httpd_resp_send(req, (const char *)index_ov5640_html_gz, index_ov5640_html_gz_len); - } else { - return httpd_resp_send(req, (const char *)index_ov2640_html_gz, index_ov2640_html_gz_len); - } - } else { - ESP_LOGE(TAG, "Camera sensor not found"); - return httpd_resp_send_500(req); - } -} - -void startCameraServer() -{ - httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.max_uri_handlers = 16; - - httpd_uri_t index_uri = { - .uri = "/", - .method = HTTP_GET, - .handler = index_handler, - .user_ctx = NULL}; - - httpd_uri_t status_uri = { - .uri = "/status", - .method = HTTP_GET, - .handler = status_handler, - .user_ctx = NULL}; - - httpd_uri_t cmd_uri = { - .uri = "/control", - .method = HTTP_GET, - .handler = cmd_handler, - .user_ctx = NULL}; - - httpd_uri_t capture_uri = { - .uri = "/capture", - .method = HTTP_GET, - .handler = capture_handler, - .user_ctx = NULL}; - - httpd_uri_t stream_uri = { - .uri = "/stream", - .method = HTTP_GET, - .handler = stream_handler, - .user_ctx = NULL}; - - httpd_uri_t bmp_uri = { - .uri = "/bmp", - .method = HTTP_GET, - .handler = bmp_handler, - .user_ctx = NULL}; - - httpd_uri_t xclk_uri = { - .uri = "/xclk", - .method = HTTP_GET, - .handler = xclk_handler, - .user_ctx = NULL}; - - httpd_uri_t reg_uri = { - .uri = "/reg", - .method = HTTP_GET, - .handler = reg_handler, - .user_ctx = NULL}; - - httpd_uri_t greg_uri = { - .uri = "/greg", - .method = HTTP_GET, - .handler = greg_handler, - .user_ctx = NULL}; - - httpd_uri_t pll_uri = { - .uri = "/pll", - .method = HTTP_GET, - .handler = pll_handler, - .user_ctx = NULL}; - - httpd_uri_t win_uri = { - .uri = "/resolution", - .method = HTTP_GET, - .handler = win_handler, - .user_ctx = NULL}; - - httpd_uri_t uptime_uri = { - .uri = "/uptime", - .method = HTTP_GET, - .handler = uptime_handler, - .user_ctx = NULL}; - - ra_filter_init(&ra_filter, 20); - -#if CONFIG_ESP_FACE_RECOGNITION_ENABLED - recognizer.set_partition(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "fr"); - - // load ids from flash partition - recognizer.set_ids_from_flash(); -#endif - ESP_LOGI(TAG, "Starting web server on port: '%d'", config.server_port); - if (httpd_start(&camera_httpd, &config) == ESP_OK) - { - httpd_register_uri_handler(camera_httpd, &index_uri); - httpd_register_uri_handler(camera_httpd, &cmd_uri); - httpd_register_uri_handler(camera_httpd, &status_uri); - httpd_register_uri_handler(camera_httpd, &capture_uri); - httpd_register_uri_handler(camera_httpd, &bmp_uri); - - httpd_register_uri_handler(camera_httpd, &xclk_uri); - httpd_register_uri_handler(camera_httpd, ®_uri); - httpd_register_uri_handler(camera_httpd, &greg_uri); - httpd_register_uri_handler(camera_httpd, &pll_uri); - httpd_register_uri_handler(camera_httpd, &win_uri); - httpd_register_uri_handler(camera_httpd, &uptime_uri); - } - - config.server_port += 1; - config.ctrl_port += 1; - ESP_LOGI(TAG, "Starting stream server on port: '%d'", config.server_port); - if (httpd_start(&stream_httpd, &config) == ESP_OK) - { - httpd_register_uri_handler(stream_httpd, &stream_uri); - } -} diff --git a/arduino/esp32-cam/CameraWebServer/camera_index.h b/arduino/esp32-cam/CameraWebServer/camera_index.h deleted file mode 100644 index 5ca12e9..0000000 --- a/arduino/esp32-cam/CameraWebServer/camera_index.h +++ /dev/null @@ -1,1570 +0,0 @@ - - -//File: index_ov2640.html.gz, Size: 6787 -#define index_ov2640_html_gz_len 6787 -const uint8_t index_ov2640_html_gz[] = { -0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xED, 0x3D, 0x6B, 0x73, 0xDB, 0x46, -0x92, 0xDF, 0xFD, 0x2B, 0x60, 0x24, 0x6B, 0x92, 0x25, 0x92, 0x22, 0x29, 0x4A, 0x96, 0x15, 0x89, -0x3E, 0x5B, 0x96, 0x1F, 0xB5, 0x76, 0xE2, 0xB5, 0x12, 0xC7, 0x5B, 0xA9, 0x2D, 0x07, 0x24, 0x86, -0x24, 0x62, 0x10, 0xE0, 0x02, 0xA0, 0x48, 0x26, 0xA5, 0xDF, 0x71, 0x3F, 0xE8, 0xFE, 0xD8, 0x75, -0xCF, 0x03, 0x18, 0x00, 0x83, 0x17, 0x29, 0x91, 0x5E, 0xDF, 0xD1, 0xA9, 0x08, 0x8F, 0xE9, 0x9E, -0x7E, 0x77, 0xCF, 0x0C, 0x06, 0x38, 0x7F, 0x68, 0xBA, 0xA3, 0x60, 0x3D, 0x27, 0xDA, 0x34, 0x98, -0xD9, 0x83, 0x07, 0xE7, 0xEC, 0x8F, 0x06, 0xBF, 0xF3, 0x29, 0x31, 0x4C, 0x76, 0x48, 0x4F, 0x67, -0x24, 0x30, 0xB4, 0xD1, 0xD4, 0xF0, 0x7C, 0x12, 0x5C, 0xE8, 0x8B, 0x60, 0xDC, 0x3A, 0xD5, 0x93, -0xB7, 0x1D, 0x63, 0x46, 0x2E, 0xF4, 0x1B, 0x8B, 0x2C, 0xE7, 0xAE, 0x17, 0xE8, 0xDA, 0xC8, 0x75, -0x02, 0xE2, 0x40, 0xF3, 0xA5, 0x65, 0x06, 0xD3, 0x0B, 0x93, 0xDC, 0x58, 0x23, 0xD2, 0xA2, 0x27, -0x4D, 0xCB, 0xB1, 0x02, 0xCB, 0xB0, 0x5B, 0xFE, 0xC8, 0xB0, 0xC9, 0x45, 0x57, 0xC6, 0x15, 0x58, -0x81, 0x4D, 0x06, 0x57, 0xD7, 0xEF, 0x8F, 0x7A, 0xDA, 0x4F, 0x1F, 0x7B, 0xFD, 0x93, 0xCE, 0xF9, -0x21, 0xBB, 0x16, 0xB5, 0xF1, 0x83, 0xB5, 0x7C, 0x8E, 0xBF, 0xA1, 0x6B, 0xAE, 0xB5, 0xBF, 0x62, -0x97, 0xF0, 0x37, 0x06, 0x22, 0x5A, 0x63, 0x63, 0x66, 0xD9, 0xEB, 0x33, 0xED, 0x99, 0x07, 0x7D, -0x36, 0x5F, 0x13, 0xFB, 0x86, 0x04, 0xD6, 0xC8, 0x68, 0xFA, 0x86, 0xE3, 0xB7, 0x7C, 0xE2, 0x59, -0xE3, 0x1F, 0x52, 0x80, 0x43, 0x63, 0xF4, 0x65, 0xE2, 0xB9, 0x0B, 0xC7, 0x3C, 0xD3, 0xBE, 0xEB, -0x9E, 0xE2, 0xBF, 0x74, 0xA3, 0x91, 0x6B, 0xBB, 0x1E, 0xDC, 0xBF, 0x7A, 0x89, 0xFF, 0xD2, 0xF7, -0x69, 0xEF, 0xBE, 0xF5, 0x27, 0x39, 0xD3, 0xBA, 0x27, 0xF3, 0x55, 0xEC, 0xFE, 0xED, 0x83, 0xD8, -0xE9, 0xB4, 0x97, 0x45, 0x3D, 0x87, 0x3F, 0xCD, 0x87, 0xF7, 0xC9, 0x28, 0xB0, 0x5C, 0xA7, 0x3D, -0x33, 0x2C, 0x47, 0x81, 0xC9, 0xB4, 0xFC, 0xB9, 0x6D, 0x80, 0x0C, 0xC6, 0x36, 0xC9, 0xC5, 0xF3, -0xDD, 0x8C, 0x38, 0x8B, 0x66, 0x01, 0x36, 0x44, 0xD2, 0x32, 0x2D, 0x8F, 0xB5, 0x3A, 0x43, 0x39, -0x2C, 0x66, 0x4E, 0x21, 0xDA, 0x3C, 0xBA, 0x1C, 0xD7, 0x21, 0x0A, 0x01, 0x62, 0x47, 0x4B, 0xCF, -0x98, 0x63, 0x03, 0xFC, 0x9B, 0x6E, 0x32, 0xB3, 0x1C, 0x66, 0x54, 0x67, 0xDA, 0x51, 0xBF, 0x33, -0x5F, 0x15, 0xA8, 0xF2, 0xE8, 0x04, 0xFF, 0xA5, 0x1B, 0xCD, 0x0D, 0xD3, 0xB4, 0x9C, 0xC9, 0x99, -0x76, 0xAA, 0x44, 0xE1, 0x7A, 0x26, 0xF1, 0x5A, 0x9E, 0x61, 0x5A, 0x0B, 0xFF, 0x4C, 0xEB, 0xAB, -0xDA, 0xCC, 0x0C, 0x6F, 0x02, 0xB4, 0x04, 0x2E, 0x10, 0xDB, 0xEA, 0x2A, 0x29, 0xE1, 0x4D, 0x3C, -0x6B, 0x32, 0x0D, 0x40, 0xA5, 0xA9, 0x36, 0x49, 0xA1, 0x71, 0x17, 0x2A, 0xD2, 0x67, 0xAE, 0xDC, -0xD4, 0x52, 0x33, 0x6C, 0x6B, 0xE2, 0xB4, 0xAC, 0x80, 0xCC, 0x80, 0x1D, 0x3F, 0xF0, 0x48, 0x30, -0x9A, 0xE6, 0x91, 0x32, 0xB6, 0x26, 0x0B, 0x8F, 0x28, 0x08, 0x09, 0xE5, 0x96, 0xC3, 0x30, 0xDC, -0x4C, 0xDF, 0x6A, 0x2D, 0xC9, 0xF0, 0x8B, 0x15, 0xB4, 0xB8, 0x4C, 0x86, 0x64, 0xEC, 0x7A, 0x44, -0xD9, 0x52, 0xB4, 0xB0, 0xDD, 0xD1, 0x97, 0x96, 0x1F, 0x18, 0x5E, 0x50, 0x06, 0xA1, 0x31, 0x0E, -0x88, 0x57, 0x8C, 0x8F, 0xA0, 0x55, 0x14, 0x63, 0xCB, 0xEE, 0x96, 0x37, 0xB0, 0x1C, 0xDB, 0x72, -0x48, 0x79, 0xF2, 0xB2, 0xFA, 0x8D, 0xA3, 0x63, 0xAD, 0x4A, 0x28, 0xC6, 0x9A, 0x4D, 0xF2, 0xAC, -0x84, 0xF2, 0x9A, 0xEE, 0x8C, 0xFB, 0x4D, 0xB7, 0xD3, 0xF9, 0x5B, 0xFA, 0xE6, 0x94, 0x30, 0x33, -0x35, 0x16, 0x81, 0xBB, 0xBD, 0x47, 0xA4, 0xDC, 0x2A, 0xC1, 0xC7, 0x7F, 0xCD, 0x88, 0x69, 0x19, -0x5A, 0x5D, 0x72, 0xE7, 0xD3, 0x0E, 0xD8, 0x54, 0x43, 0x33, 0x1C, 0x53, 0xAB, 0xBB, 0x9E, 0x05, -0x8E, 0x60, 0xD0, 0x70, 0x63, 0xC3, 0x15, 0x48, 0x1C, 0x73, 0xD2, 0x50, 0xB0, 0x9C, 0xE3, 0x33, -0xB2, 0x44, 0xD4, 0x6E, 0x83, 0xBF, 0x12, 0x21, 0x07, 0x7F, 0x85, 0x0E, 0xA4, 0xE0, 0x91, 0xA2, -0xCF, 0xD3, 0x97, 0x4C, 0x61, 0x96, 0xCE, 0xF0, 0x37, 0x33, 0x56, 0xAD, 0x5C, 0xDD, 0x89, 0x46, -0x42, 0x87, 0x90, 0x66, 0x47, 0x75, 0x68, 0x7A, 0x33, 0xD5, 0x5A, 0x1A, 0x46, 0xC9, 0x86, 0x1A, -0x86, 0x23, 0x55, 0xAB, 0x1C, 0x7F, 0xB2, 0x51, 0x54, 0x60, 0x57, 0xCD, 0x6A, 0x14, 0x3B, 0xD8, -0x3F, 0x95, 0x0D, 0x31, 0x4E, 0x32, 0xA3, 0x08, 0xFE, 0xCA, 0x47, 0x92, 0x08, 0x59, 0x61, 0x34, -0x51, 0x20, 0xCE, 0x8E, 0x28, 0x29, 0xBC, 0x59, 0xDE, 0xAD, 0xC0, 0x9A, 0x4F, 0x42, 0xD9, 0xE8, -0xA2, 0x40, 0x9C, 0x47, 0x43, 0x61, 0x94, 0xC1, 0xDF, 0x6D, 0x89, 0x7A, 0xE3, 0xBB, 0xE1, 0x22, -0x08, 0x5C, 0xC7, 0xDF, 0x2A, 0x45, 0x65, 0xF9, 0xD9, 0x1F, 0x0B, 0x3F, 0xB0, 0xC6, 0xEB, 0x16, -0x77, 0x69, 0xF0, 0xB3, 0xB9, 0x01, 0x25, 0xE4, 0x90, 0x04, 0x4B, 0x42, 0xF2, 0xCB, 0x0D, 0xC7, -0xB8, 0x81, 0xB8, 0x33, 0x99, 0xD8, 0x2A, 0xDB, 0x1B, 0x2D, 0x3C, 0x1F, 0xEB, 0xB6, 0xB9, 0x6B, -0x01, 0x62, 0x2F, 0xDD, 0x71, 0xDC, 0x07, 0x4B, 0x76, 0xD4, 0x1A, 0x0D, 0x15, 0x7D, 0xB9, 0x8B, -0x00, 0x65, 0xAC, 0xD4, 0x84, 0x0B, 0xEC, 0x58, 0xC1, 0x5A, 0x79, 0x8F, 0x7B, 0xA2, 0xE2, 0x8E, -0x70, 0xC1, 0xDC, 0xB4, 0x10, 0xA7, 0xEB, 0x6C, 0x34, 0x25, 0xA3, 0x2F, 0xC4, 0x3C, 0x28, 0x2C, -0xC3, 0x8A, 0xCA, 0xC3, 0xB6, 0xE5, 0xCC, 0x17, 0x41, 0x0B, 0xCB, 0xA9, 0xF9, 0xBD, 0xE8, 0x9C, -0x1A, 0xA4, 0x60, 0xB1, 0xD7, 0xCB, 0x2B, 0x2A, 0x8E, 0xE7, 0xAB, 0x7C, 0x21, 0xC8, 0xC4, 0x0E, -0x6C, 0x63, 0x48, 0xEC, 0x3C, 0x92, 0xB9, 0x33, 0x64, 0x84, 0x5D, 0x1E, 0xAB, 0xB2, 0x6B, 0x37, -0x4A, 0x59, 0x94, 0xBC, 0xFA, 0x8F, 0xFF, 0x56, 0x5A, 0x8E, 0xF4, 0xB8, 0x19, 0xBB, 0xE4, 0x13, -0x1B, 0x1C, 0x2C, 0xAB, 0xF4, 0x86, 0x36, 0x4B, 0xA0, 0x21, 0xB7, 0x03, 0xCF, 0x70, 0x26, 0x04, -0x62, 0xC1, 0xAA, 0x29, 0x0E, 0xF3, 0x07, 0x06, 0xA5, 0xD8, 0xC7, 0x50, 0x7D, 0x9C, 0x3F, 0x10, -0x61, 0x01, 0xA1, 0xA9, 0xB5, 0xD9, 0xC1, 0x06, 0x55, 0x89, 0xA4, 0xDF, 0x5C, 0x42, 0xBA, 0x4A, -0xEB, 0x60, 0x85, 0x89, 0xD2, 0x73, 0xE2, 0xB6, 0xA5, 0x2C, 0xF4, 0x0B, 0x43, 0x83, 0x18, 0xF2, -0x8D, 0xC7, 0x45, 0x83, 0xC6, 0xF1, 0xF8, 0xA8, 0x73, 0xD4, 0x2F, 0xAC, 0x9C, 0x94, 0x5C, 0x26, -0x06, 0x8E, 0x8A, 0xD0, 0x11, 0x86, 0x95, 0x5C, 0x23, 0xF0, 0x8D, 0x1B, 0x65, 0xD1, 0xEE, 0xFA, -0x16, 0x1B, 0xB9, 0x19, 0x43, 0x1F, 0xC6, 0x6E, 0x81, 0x62, 0xE8, 0xC5, 0x0D, 0xBD, 0xA7, 0xA4, -0x8F, 0x96, 0x74, 0x4A, 0x17, 0x10, 0xE2, 0x55, 0x93, 0x1D, 0xD3, 0x80, 0xBA, 0x89, 0xA4, 0x60, -0x65, 0x51, 0x19, 0x90, 0x55, 0xD0, 0x32, 0xC9, 0xC8, 0xF5, 0x58, 0x35, 0x98, 0x31, 0x72, 0x4C, -0x28, 0xB2, 0xD8, 0x62, 0xCF, 0xA6, 0xEE, 0x0D, 0xF1, 0x14, 0xC2, 0x4A, 0x28, 0xB5, 0xFF, 0xA4, -0x6F, 0x96, 0xC0, 0x66, 0x40, 0x7A, 0x54, 0xCA, 0x3E, 0x8E, 0xAE, 0xD7, 0x1D, 0xF5, 0x72, 0xFD, -0x98, 0xA1, 0x6B, 0x83, 0xCF, 0x18, 0x43, 0x9B, 0x98, 0x39, 0xD9, 0xCC, 0x24, 0x63, 0x63, 0x61, -0x07, 0x05, 0x56, 0x69, 0x74, 0xF0, 0x5F, 0x5E, 0x8F, 0x34, 0x0C, 0xFD, 0x86, 0xF3, 0x42, 0x17, -0x34, 0x70, 0xFC, 0x4B, 0xD1, 0xA7, 0x28, 0x35, 0x8C, 0xF9, 0x9C, 0x18, 0xD0, 0x6A, 0x44, 0xB2, -0xF4, 0x50, 0x6A, 0x88, 0xA1, 0x8E, 0xF3, 0xA5, 0xC6, 0xED, 0x85, 0x0E, 0x1B, 0x16, 0x8F, 0x95, -0x78, 0x3E, 0x1B, 0xBB, 0xA3, 0x85, 0xAA, 0xAA, 0x29, 0xE7, 0x78, 0x69, 0x7C, 0x67, 0x42, 0x64, -0xBE, 0x6D, 0x51, 0xF7, 0x5F, 0x38, 0x0E, 0x6A, 0xB4, 0x15, 0x78, 0xC0, 0xA6, 0xA2, 0xA3, 0x72, -0x82, 0xDB, 0x28, 0x86, 0xC5, 0x04, 0x9B, 0x35, 0x77, 0x95, 0x08, 0x53, 0x8A, 0x70, 0x1A, 0x46, -0x5A, 0x0D, 0x62, 0x88, 0x65, 0x0A, 0x54, 0xDB, 0xC9, 0x25, 0x98, 0x2E, 0x66, 0xAA, 0x3A, 0x4A, -0x74, 0xD6, 0x85, 0xA4, 0xCF, 0xBA, 0xF3, 0x26, 0x43, 0xA3, 0xDE, 0x69, 0x76, 0x9A, 0x47, 0xF0, -0x3F, 0xC5, 0x78, 0x26, 0xDF, 0xB8, 0xB8, 0x78, 0x33, 0x2C, 0x2F, 0x11, 0xA2, 0x8B, 0xA7, 0x95, -0xB2, 0x82, 0x7D, 0xA1, 0x2E, 0xCA, 0x7B, 0x52, 0x7C, 0x7E, 0xA9, 0xDB, 0x2E, 0xC8, 0xC3, 0x19, -0x26, 0x5D, 0xDD, 0x10, 0x15, 0xD6, 0x52, 0x55, 0xC5, 0x33, 0xF7, 0xCF, 0x16, 0x2B, 0x42, 0xFE, -0xCF, 0x5B, 0xBB, 0x24, 0x8A, 0x6F, 0xDA, 0xD2, 0x2B, 0xCB, 0xC5, 0xDF, 0xB7, 0x6D, 0x74, 0xB2, -0xB5, 0xDE, 0xE2, 0x55, 0x1F, 0x50, 0xE8, 0xC0, 0x18, 0xD4, 0x83, 0xC1, 0x68, 0x66, 0x65, 0x28, -0xB5, 0xD9, 0x40, 0x06, 0x63, 0xCB, 0xB6, 0x5B, 0xB6, 0xBB, 0x2C, 0xAE, 0x44, 0xF2, 0x2D, 0x39, -0x65, 0xA7, 0xC5, 0x26, 0xBF, 0x29, 0xB5, 0x0B, 0x88, 0x5C, 0xFF, 0x11, 0xD4, 0x7E, 0xDB, 0x0E, -0x97, 0xEB, 0x1A, 0x9B, 0x25, 0x8A, 0x0D, 0xEC, 0x71, 0xBB, 0x8E, 0x4A, 0x99, 0x12, 0xAB, 0x04, -0xF3, 0x87, 0x3D, 0x4B, 0x2B, 0x18, 0x4D, 0x37, 0x18, 0x7A, 0x46, 0x03, 0x23, 0x8F, 0xD8, 0x06, -0x56, 0xF0, 0x1B, 0xCD, 0x50, 0x14, 0x0E, 0xDF, 0x64, 0xF0, 0x32, 0x9C, 0x50, 0xD1, 0x7D, 0x3D, -0xB3, 0x4B, 0x6D, 0x56, 0x3B, 0x64, 0xC7, 0x6A, 0xB5, 0x59, 0x17, 0x94, 0xFB, 0x71, 0xCF, 0x50, -0x37, 0xAA, 0x10, 0xD1, 0x45, 0xD0, 0x9E, 0x78, 0x64, 0x5D, 0x82, 0x99, 0x26, 0xFF, 0x7B, 0xC6, -0xE6, 0x8F, 0x37, 0x9F, 0x2A, 0xA1, 0x09, 0x80, 0x5B, 0x51, 0xBB, 0xEF, 0x97, 0xE8, 0x3A, 0xBB, -0xCB, 0x32, 0xF6, 0x18, 0xCE, 0x8E, 0xEA, 0x7A, 0x89, 0x70, 0x93, 0x93, 0x42, 0xD5, 0xA6, 0x2A, -0xB2, 0xAF, 0x7A, 0x3C, 0x4F, 0xC6, 0x41, 0xC6, 0xE2, 0x0F, 0xAD, 0x53, 0x8F, 0xF2, 0xA3, 0x5B, -0x4B, 0x9A, 0x4D, 0x29, 0x8C, 0x1C, 0xE1, 0x24, 0x66, 0xB6, 0xF5, 0x29, 0x31, 0x63, 0xF4, 0xAC, -0x8C, 0x3C, 0x5B, 0x25, 0xA2, 0x7C, 0xA6, 0x6A, 0x86, 0x36, 0x33, 0x9E, 0xF2, 0x41, 0x3D, 0xE4, -0x53, 0xBD, 0x77, 0xA2, 0x5C, 0x5B, 0xC9, 0x69, 0x9C, 0x47, 0x5A, 0xE6, 0x2C, 0x60, 0x3A, 0x65, -0x65, 0x0E, 0x90, 0xE5, 0x58, 0xA4, 0x54, 0x54, 0xBE, 0x57, 0xE6, 0x45, 0x98, 0xF4, 0x4C, 0x56, -0xAE, 0xB1, 0x5B, 0x33, 0x03, 0xCA, 0x5E, 0x34, 0x57, 0x03, 0x30, 0xAA, 0xF4, 0x57, 0xC6, 0xDC, -0xA5, 0x39, 0xD6, 0xEE, 0x49, 0xA7, 0xA0, 0xCB, 0x91, 0xED, 0xFA, 0x5B, 0x4E, 0x80, 0x65, 0xCF, -0x7F, 0x29, 0xEF, 0x94, 0x4A, 0xDD, 0xB9, 0x3E, 0x95, 0xEF, 0x8E, 0x09, 0x99, 0x77, 0x3B, 0xCA, -0x48, 0x9B, 0x3B, 0x4B, 0x49, 0x67, 0xD0, 0xE8, 0xFA, 0xE5, 0x99, 0x36, 0x22, 0xEA, 0x30, 0x1A, -0x9F, 0xA8, 0x2B, 0x33, 0x55, 0x9A, 0xAB, 0x87, 0xA9, 0x65, 0x9A, 0x24, 0x77, 0x2E, 0x18, 0xC7, -0xBC, 0x25, 0x8B, 0x07, 0xA4, 0x5F, 0x35, 0x29, 0x75, 0x2F, 0x4E, 0x91, 0xFB, 0x58, 0x43, 0xF7, -0xBE, 0x3D, 0x86, 0x27, 0x9A, 0xAC, 0x99, 0xF4, 0x78, 0x29, 0x92, 0x4B, 0xAA, 0xD2, 0xB9, 0xC3, -0xB9, 0x56, 0x14, 0x19, 0xC8, 0x01, 0x5B, 0xA5, 0xA3, 0x79, 0x82, 0x2A, 0xBA, 0x90, 0xD2, 0xE6, -0x6B, 0x4B, 0x7C, 0x19, 0xB0, 0x95, 0xB5, 0xBA, 0x72, 0x87, 0x4B, 0x6D, 0xD4, 0x02, 0x92, 0xFD, -0x66, 0x8A, 0x66, 0x4F, 0x95, 0x51, 0x0E, 0x91, 0x61, 0x8A, 0x11, 0x0F, 0x57, 0xC5, 0x5B, 0x6D, -0xEA, 0x1C, 0xE7, 0x87, 0xD2, 0xD3, 0x70, 0xE7, 0x87, 0xD1, 0x83, 0x7B, 0xE7, 0xF8, 0x48, 0x9C, -0xFC, 0xD0, 0x1C, 0xEF, 0x67, 0x64, 0x1B, 0xBE, 0x7F, 0xA1, 0xE3, 0xA3, 0x5D, 0x7A, 0xFC, 0x19, -0xBA, 0x73, 0xD3, 0xBA, 0xD1, 0x2C, 0xF3, 0x42, 0xB7, 0xDD, 0x89, 0x9B, 0xB8, 0x47, 0xEF, 0x33, -0x2D, 0x43, 0x1E, 0xBB, 0xD0, 0x63, 0xEB, 0x8B, 0x3A, 0x85, 0x8A, 0x2E, 0xE9, 0x83, 0x47, 0xDF, -0x3D, 0x79, 0xFC, 0xF8, 0xE4, 0x87, 0x47, 0xCE, 0xD0, 0x9F, 0xF3, 0xFF, 0xFF, 0xCC, 0x96, 0x63, -0x7F, 0xFA, 0xD8, 0x3B, 0xE9, 0xC3, 0x70, 0x8F, 0x04, 0x01, 0x98, 0x9E, 0x7F, 0x7E, 0x48, 0x91, -0x26, 0x08, 0x39, 0x04, 0x4A, 0x32, 0x68, 0xE3, 0xE5, 0x8E, 0x8A, 0x3C, 0xD1, 0xC4, 0x87, 0x0C, -0x3E, 0x34, 0x3C, 0x45, 0x13, 0xDA, 0x8C, 0x15, 0xD3, 0x34, 0x94, 0xE8, 0x54, 0x27, 0x43, 0x77, -0x95, 0xE4, 0x80, 0x32, 0xC5, 0x15, 0xC6, 0x5B, 0x11, 0x33, 0x0B, 0x21, 0x80, 0x51, 0x70, 0x5C, -0x5C, 0x85, 0x36, 0xCA, 0x46, 0x31, 0x15, 0x60, 0xE3, 0xD5, 0xC8, 0xFE, 0x22, 0x74, 0xAF, 0x0B, -0xA5, 0x38, 0x6E, 0xC0, 0x42, 0x65, 0x46, 0x57, 0x31, 0x56, 0x39, 0x8C, 0xB4, 0x6C, 0xC8, 0xB8, -0x00, 0xD1, 0xB6, 0x28, 0x76, 0x76, 0x2D, 0x1F, 0x13, 0xC5, 0x26, 0xE9, 0x55, 0x00, 0xEB, 0x83, -0x4F, 0x97, 0x6F, 0xFF, 0xAE, 0xBD, 0x7B, 0xFD, 0xA7, 0x52, 0x43, 0x45, 0x44, 0x61, 0x8C, 0x2E, -0xD1, 0x33, 0x05, 0x63, 0xFA, 0x10, 0x32, 0xD1, 0xB9, 0x66, 0x28, 0x06, 0xCC, 0xF6, 0x36, 0x71, -0x26, 0xC1, 0xF4, 0x42, 0xEF, 0xEA, 0xF8, 0x48, 0x8B, 0x38, 0xEB, 0xE9, 0x1A, 0xC6, 0x6F, 0x7A, -0x70, 0x63, 0xD8, 0x0B, 0x3C, 0xEA, 0x94, 0xE1, 0x35, 0x6D, 0x5A, 0xCA, 0x66, 0x3C, 0xB0, 0x84, -0x32, 0x96, 0x02, 0x71, 0x5C, 0xCA, 0xFA, 0xE0, 0x9A, 0x04, 0xE7, 0x87, 0xEC, 0x56, 0x81, 0xD6, -0xF2, 0xFB, 0x06, 0x4F, 0x66, 0xE6, 0x90, 0x67, 0x42, 0x79, 0x8A, 0x1F, 0x7B, 0xC6, 0x8C, 0xA0, -0x54, 0x4A, 0x69, 0x5E, 0xD6, 0x7A, 0x08, 0xA9, 0x0F, 0x3E, 0x10, 0x5A, 0x10, 0x01, 0x19, 0xA5, -0x14, 0x7F, 0xCE, 0x6B, 0xD4, 0x58, 0xFF, 0xA1, 0x3D, 0xF3, 0x35, 0xA9, 0x96, 0xC1, 0xCC, 0xBC, -0x84, 0xDC, 0x1F, 0xB6, 0x5A, 0x5A, 0xEF, 0xDD, 0x7B, 0xAD, 0xD5, 0x2A, 0xD1, 0xD8, 0x9D, 0x53, -0x77, 0xE2, 0xFA, 0xEF, 0x1E, 0xE9, 0x83, 0x5F, 0x3E, 0xBD, 0x7A, 0x56, 0x87, 0xBA, 0xB0, 0xB3, -0xEA, 0xF6, 0x3A, 0x9D, 0xC6, 0xF9, 0x21, 0x6B, 0x52, 0x1D, 0x57, 0x0F, 0xF4, 0x4A, 0x71, 0xF5, -0x4E, 0x01, 0x57, 0xA7, 0xD7, 0xDF, 0x02, 0x57, 0x57, 0x1F, 0xBC, 0x7E, 0xC1, 0x30, 0x3D, 0xEE, -0x6D, 0x43, 0x14, 0x18, 0x38, 0xA5, 0x09, 0xC8, 0x59, 0x3D, 0x3E, 0x39, 0xDD, 0x1C, 0xD3, 0x13, -0xE0, 0xEE, 0x23, 0x60, 0x3A, 0x05, 0x41, 0x9D, 0x6C, 0x23, 0xA7, 0x53, 0x7D, 0x80, 0x78, 0x20, -0xA2, 0xAF, 0xFA, 0xA7, 0x5B, 0xE0, 0x79, 0x0C, 0x22, 0x42, 0x44, 0x80, 0x64, 0x75, 0xB4, 0x8D, -0x8C, 0x4E, 0xF4, 0xC1, 0xE5, 0x9B, 0x97, 0xF5, 0x3E, 0x30, 0xD6, 0x7B, 0x72, 0xB2, 0x39, 0x9E, -0x63, 0x7D, 0xF0, 0x0F, 0x24, 0x08, 0x88, 0x59, 0xF5, 0xFA, 0x5B, 0x10, 0xD4, 0xD7, 0x07, 0x00, -0x8F, 0x38, 0x36, 0x46, 0x01, 0x76, 0xFD, 0x9A, 0x12, 0x83, 0x88, 0xBA, 0x8F, 0xB7, 0xE0, 0x0A, -0xAC, 0xFA, 0x1F, 0x28, 0x1E, 0x40, 0xB2, 0xEA, 0xF6, 0xB7, 0xB1, 0x69, 0x40, 0x44, 0x49, 0x02, -0x5F, 0x43, 0x57, 0xDB, 0x1C, 0x13, 0xD8, 0xF4, 0x93, 0x93, 0xD5, 0x93, 0x93, 0x72, 0x08, 0x30, -0x46, 0x62, 0xBC, 0xC9, 0x8B, 0xA2, 0xF9, 0x41, 0x36, 0x2F, 0x80, 0xFE, 0x7B, 0x01, 0xC3, 0xA2, -0x60, 0x5D, 0x39, 0x7C, 0x72, 0x38, 0x90, 0x09, 0x3B, 0x28, 0x17, 0x39, 0x25, 0x4A, 0xC2, 0x27, -0x74, 0xF4, 0x41, 0xBF, 0x44, 0x86, 0x8A, 0x95, 0x30, 0x14, 0x36, 0x46, 0x3F, 0x4D, 0x9B, 0x68, -0x79, 0x98, 0x30, 0xC1, 0x25, 0x8E, 0x74, 0x29, 0x82, 0x6C, 0x14, 0x9A, 0x15, 0xB4, 0x1A, 0x2B, -0x7D, 0x70, 0x72, 0x54, 0x98, 0xD2, 0x36, 0x57, 0xC6, 0x90, 0x0E, 0xC0, 0x1D, 0xE2, 0xFB, 0x95, -0xF5, 0x11, 0x81, 0xEA, 0x83, 0xE7, 0xE1, 0xF1, 0x36, 0x5A, 0x69, 0xF5, 0xB6, 0x50, 0x8B, 0x44, -0x0E, 0xD3, 0x4C, 0xAB, 0xC7, 0x55, 0x13, 0x15, 0x2F, 0x77, 0xAB, 0x98, 0x22, 0x6A, 0xB7, 0xD1, -0x0B, 0x16, 0xE0, 0x9E, 0xE1, 0x07, 0x95, 0xB5, 0x22, 0x00, 0x21, 0x42, 0xF3, 0xA3, 0xBD, 0x69, -0x24, 0x24, 0xE5, 0x1B, 0xD0, 0x87, 0x6F, 0x04, 0x0B, 0xF6, 0x2C, 0x54, 0x65, 0x8D, 0x44, 0xA0, -0x50, 0x0F, 0x84, 0xC7, 0x7B, 0xD3, 0x8A, 0x44, 0xCE, 0xB7, 0xA0, 0x97, 0x39, 0x19, 0x59, 0x86, -0xFD, 0x99, 0x8C, 0xC7, 0x90, 0xB0, 0xAA, 0xEB, 0x26, 0x06, 0x0E, 0xFA, 0x61, 0xE7, 0xDA, 0x15, -0x3D, 0xAF, 0x5C, 0x9B, 0x27, 0xD0, 0x6D, 0x5E, 0xA0, 0x27, 0xB3, 0x37, 0x9F, 0xA4, 0x26, 0x74, -0x48, 0xC4, 0x8E, 0xF4, 0xC1, 0x8F, 0x6E, 0x48, 0xE7, 0xE6, 0x05, 0xC6, 0x8F, 0x64, 0x42, 0xE7, -0x80, 0xB7, 0xA9, 0x76, 0x5E, 0x79, 0xC6, 0x9A, 0x6E, 0x32, 0xDC, 0xA6, 0xF8, 0xFA, 0x40, 0x4C, -0xED, 0x67, 0xCB, 0xD9, 0x9C, 0x99, 0x3E, 0x12, 0x42, 0x88, 0xB3, 0x1D, 0x16, 0x28, 0x49, 0x9F, -0xC3, 0xC1, 0x76, 0x48, 0x4E, 0x70, 0xBC, 0x3A, 0xB7, 0x8C, 0xAF, 0xA1, 0xDC, 0x32, 0x96, 0xC3, -0xCA, 0x6E, 0x01, 0x30, 0xFA, 0xE0, 0xD9, 0xAF, 0xCF, 0x2B, 0x07, 0x29, 0xB6, 0x92, 0x5A, 0xC6, -0xC2, 0xA3, 0xF9, 0x08, 0xEC, 0x2C, 0x35, 0x51, 0xA4, 0xF6, 0x9C, 0xB2, 0x93, 0x45, 0x0A, 0xBE, -0x04, 0x81, 0x74, 0xE1, 0x49, 0x97, 0xD8, 0x2C, 0xC7, 0xE3, 0xFD, 0x45, 0x30, 0x20, 0xE2, 0xF3, -0xC4, 0xB0, 0xAA, 0xE7, 0x15, 0x01, 0x48, 0x35, 0xA5, 0xBD, 0x82, 0xA3, 0x5D, 0xA9, 0x8B, 0x75, -0xBB, 0x37, 0x9D, 0x71, 0xAE, 0xF7, 0xAD, 0x38, 0x20, 0x64, 0xE6, 0x9A, 0xD5, 0xA7, 0x81, 0x38, -0x9C, 0x3E, 0x00, 0xAD, 0xBD, 0x83, 0x83, 0xCA, 0x59, 0x46, 0x20, 0xB8, 0xE7, 0xF4, 0xF2, 0x6C, -0x11, 0xB8, 0xDB, 0x64, 0x96, 0xEB, 0x85, 0xE3, 0xAC, 0xB7, 0x49, 0x2B, 0x97, 0xB6, 0xBB, 0x30, -0x37, 0xC7, 0x00, 0x39, 0xE5, 0xA7, 0xF1, 0xD8, 0x1A, 0x6D, 0x9E, 0x95, 0x20, 0xA3, 0xBC, 0x76, -0x67, 0x25, 0xE1, 0xEF, 0x39, 0x8A, 0x93, 0x51, 0xF5, 0x00, 0x41, 0x46, 0xA0, 0xC5, 0xAB, 0x4B, -0xED, 0xFA, 0xEA, 0xC7, 0xEB, 0x9F, 0x3E, 0xEC, 0x26, 0x3A, 0x40, 0x9F, 0x7B, 0x0A, 0x0C, 0xC8, -0xED, 0xBE, 0x63, 0x02, 0x10, 0xD1, 0xDB, 0x44, 0x4F, 0x3D, 0xA6, 0xA8, 0x17, 0xD7, 0xEF, 0x77, -0xA5, 0xA5, 0xDE, 0xFE, 0xD4, 0xD4, 0xFB, 0x1A, 0xF4, 0xF4, 0xD9, 0x26, 0x37, 0xC4, 0xDE, 0x40, -0x57, 0x0C, 0x10, 0xF5, 0xA5, 0xBD, 0xC5, 0xA3, 0xBD, 0x0D, 0xE4, 0x42, 0x52, 0xBE, 0x81, 0x61, -0x1C, 0x58, 0xC5, 0x67, 0x4A, 0xF4, 0x26, 0xCE, 0xC3, 0x20, 0xF5, 0xC1, 0xD5, 0x6A, 0xEE, 0xFA, -0x0B, 0xAF, 0x64, 0x42, 0x55, 0x6B, 0xA4, 0xB3, 0x95, 0x42, 0x04, 0x29, 0x4C, 0x23, 0x1D, 0xAE, -0x10, 0x5C, 0x24, 0x91, 0xD6, 0xCF, 0xFA, 0x77, 0xAA, 0x15, 0x44, 0x7E, 0x9F, 0x8A, 0x99, 0x6C, -0x90, 0x77, 0x26, 0x98, 0x77, 0x5E, 0x5D, 0xEE, 0x26, 0x94, 0x4D, 0xF6, 0x96, 0x70, 0x26, 0x7B, -0x4D, 0x38, 0x1A, 0x5F, 0xC3, 0x16, 0x52, 0xD8, 0x70, 0x10, 0xC1, 0x01, 0x61, 0xEC, 0xBC, 0xC9, -0x00, 0x42, 0xF2, 0x9C, 0xEE, 0x6A, 0x1B, 0xD7, 0x11, 0x64, 0xC4, 0x3D, 0xE7, 0x28, 0xF2, 0x9B, -0xE3, 0x3B, 0xF5, 0x9A, 0xA3, 0x42, 0x6A, 0xB7, 0x71, 0x1A, 0xE4, 0x64, 0x44, 0x2C, 0x1B, 0xB7, -0x32, 0x57, 0x55, 0x88, 0x04, 0xCB, 0x74, 0xA2, 0x5D, 0xB2, 0xB3, 0x6D, 0x74, 0xD3, 0xDB, 0x46, -0x37, 0x32, 0x45, 0x71, 0xF5, 0x9C, 0xDC, 0x53, 0xA6, 0xE9, 0xF6, 0x4E, 0xEF, 0x53, 0x3D, 0xC3, -0x79, 0xF5, 0x98, 0x06, 0x30, 0xFA, 0xE0, 0xF9, 0xFB, 0xDD, 0xC4, 0x34, 0xEC, 0xAC, 0x64, 0x4C, -0xDB, 0x2A, 0x82, 0x51, 0xA6, 0xF6, 0x5D, 0x8A, 0x2D, 0x37, 0xD0, 0xC6, 0x12, 0x09, 0xFF, 0x75, -0x47, 0xDA, 0x58, 0x96, 0xD7, 0xC6, 0x1D, 0x67, 0x98, 0xE5, 0xD7, 0xA0, 0x1F, 0xCF, 0x58, 0x7E, -0x9E, 0xCC, 0x8C, 0xCA, 0x3A, 0xE2, 0x70, 0xFA, 0xE0, 0x83, 0xB1, 0xD4, 0x5E, 0xBD, 0x7B, 0xB6, -0x13, 0x5D, 0x89, 0x4E, 0xF7, 0xA3, 0xAF, 0x90, 0xE5, 0x7D, 0xEB, 0xCC, 0x26, 0x4E, 0x75, 0xA7, -0x42, 0x20, 0x7D, 0xF0, 0x96, 0x38, 0xBE, 0x76, 0xE9, 0x7A, 0xFC, 0xB5, 0x73, 0x3B, 0xD1, 0x1A, -0xED, 0x79, 0x3F, 0x2A, 0x63, 0x4C, 0xEF, 0x5B, 0x5F, 0xD3, 0x99, 0xE5, 0x79, 0xAE, 0x57, 0x59, -0x65, 0x1C, 0x4E, 0x1F, 0xBC, 0x6E, 0xBD, 0xA3, 0x47, 0x3B, 0x51, 0x97, 0xE8, 0x75, 0x3F, 0x1A, -0x0B, 0x79, 0xDE, 0xB7, 0xD2, 0x6E, 0xC6, 0xB6, 0x35, 0xAF, 0xAC, 0x32, 0x0A, 0xA5, 0x0F, 0x3E, -0xB6, 0x5E, 0xC2, 0xDF, 0x9D, 0xA8, 0x8B, 0xF5, 0xB8, 0x1F, 0x65, 0x71, 0x6E, 0xF7, 0xAD, 0x2A, -0x73, 0xB4, 0xAC, 0xAC, 0x28, 0x80, 0xD1, 0x07, 0x2F, 0x2E, 0x7F, 0xD5, 0xEA, 0x2F, 0xDC, 0xA5, -0x83, 0x0F, 0x5C, 0x6A, 0x57, 0x3F, 0x36, 0x76, 0xA2, 0x31, 0xEC, 0x7A, 0x3F, 0xFA, 0xA2, 0x4C, -0xEF, 0x5B, 0x5B, 0x74, 0x5F, 0xCD, 0xD0, 0xA8, 0x1E, 0x0E, 0x05, 0x20, 0x3E, 0xFB, 0x02, 0x47, -0xDA, 0x73, 0x63, 0x37, 0x01, 0x31, 0xEC, 0x77, 0x17, 0x45, 0x7B, 0xC4, 0xE4, 0xBE, 0xF5, 0x64, -0x13, 0xB3, 0x84, 0x8A, 0xE2, 0x25, 0x86, 0xF9, 0x19, 0xB7, 0xA8, 0xE0, 0xD6, 0xCD, 0x35, 0xD4, -0x1A, 0x57, 0x2F, 0xB4, 0x37, 0xE2, 0xB4, 0x04, 0x37, 0x1B, 0xCF, 0xD9, 0x65, 0x0D, 0x6D, 0xE3, -0xF4, 0xC4, 0x07, 0xB7, 0xBD, 0xE3, 0xE3, 0xED, 0x86, 0xB7, 0x59, 0xD3, 0xA8, 0xC7, 0xC7, 0xF7, -0xA8, 0x93, 0xB1, 0x31, 0x22, 0x9F, 0x4D, 0x12, 0x6C, 0xF2, 0x30, 0x8C, 0x04, 0xAB, 0x0F, 0x5E, -0xC2, 0x89, 0xF6, 0x82, 0x9E, 0xEC, 0xAA, 0x0C, 0x94, 0xFB, 0xDF, 0x85, 0x27, 0xC5, 0xF8, 0xDD, -0xB7, 0x33, 0x51, 0x62, 0xA0, 0xE8, 0x76, 0x27, 0xCE, 0x46, 0x7B, 0x0B, 0x62, 0xE0, 0x5C, 0x7D, -0x1F, 0xD8, 0xF9, 0x6E, 0x15, 0x18, 0x11, 0xB1, 0x33, 0x1D, 0x4A, 0x7C, 0xEF, 0x5D, 0x8D, 0x70, -0x7D, 0x5A, 0x5D, 0x7B, 0x08, 0x05, 0x4A, 0xC3, 0x3F, 0xBB, 0xD1, 0x14, 0xED, 0x70, 0x27, 0x0A, -0x62, 0xAC, 0xDD, 0xA1, 0x5E, 0xB2, 0xEF, 0xCB, 0x3B, 0xBF, 0xF8, 0x2B, 0x53, 0x8B, 0x94, 0xC0, -0x77, 0x20, 0xD1, 0x79, 0x4E, 0x12, 0xB4, 0xFC, 0xC0, 0xB2, 0x6D, 0x7D, 0xF0, 0x8A, 0x04, 0xDA, -0x35, 0x1E, 0x96, 0xDC, 0x72, 0x24, 0x61, 0x11, 0xFB, 0x0D, 0x03, 0x8F, 0x18, 0x33, 0x7D, 0x70, -0x8D, 0x2F, 0x93, 0x05, 0x5C, 0x78, 0x56, 0x1D, 0x19, 0xB5, 0x6E, 0xE2, 0x78, 0x2E, 0x10, 0x15, -0x2A, 0x87, 0xBF, 0xA4, 0x4E, 0xD7, 0xC4, 0x91, 0x74, 0x6D, 0x70, 0x45, 0x1B, 0x6B, 0xE8, 0xFE, -0xC5, 0xDD, 0x95, 0xDE, 0x0B, 0x45, 0x37, 0x3F, 0xE2, 0x6E, 0xC6, 0xF8, 0xAB, 0xA6, 0x41, 0xAD, -0x6C, 0x6B, 0xF3, 0xE0, 0xDC, 0x9F, 0x1B, 0x8E, 0x68, 0x46, 0xF7, 0xFD, 0x2E, 0xF9, 0x46, 0xCE, -0xA1, 0x6B, 0x9B, 0xD0, 0xF0, 0x99, 0x79, 0x83, 0xAF, 0xDD, 0x32, 0xB5, 0xEB, 0x70, 0x4B, 0x22, -0x82, 0x80, 0x5D, 0x08, 0x0C, 0x05, 0xBA, 0x9F, 0x7A, 0x02, 0x3D, 0xDB, 0x3C, 0x8A, 0x2F, 0x2A, -0xCA, 0x51, 0x6E, 0xC6, 0x2E, 0x4A, 0x8F, 0x4C, 0x42, 0x41, 0xAA, 0x36, 0xD7, 0x2A, 0xF7, 0x54, -0x7E, 0x20, 0x13, 0xCB, 0x07, 0x1A, 0x35, 0x30, 0x8B, 0x43, 0xBA, 0x0F, 0x8D, 0xD9, 0x72, 0xB9, -0x3D, 0x8E, 0x72, 0x97, 0x7C, 0x87, 0xB6, 0x72, 0xE7, 0x6A, 0xA5, 0x52, 0x3E, 0xB9, 0xCF, 0x34, -0x8E, 0xB1, 0xC8, 0xE8, 0x1F, 0xB6, 0x5A, 0xD3, 0x3E, 0xEE, 0xA8, 0xD3, 0x04, 0x6B, 0xE7, 0x87, -0xD3, 0x7E, 0xD1, 0x66, 0xB0, 0xC2, 0xED, 0x90, 0xC0, 0xE9, 0xC6, 0xBB, 0x21, 0x51, 0x4A, 0x03, -0xA0, 0xA6, 0xA9, 0xBD, 0x33, 0xFC, 0x2F, 0x4D, 0xED, 0x23, 0x16, 0x5E, 0x3B, 0xDC, 0x14, 0x89, -0xB4, 0x1B, 0xA6, 0xE9, 0x65, 0x6E, 0x8C, 0xEC, 0xC7, 0x36, 0x46, 0x9E, 0x88, 0x8D, 0x91, 0xD2, -0x12, 0xC8, 0xAA, 0xDB, 0xED, 0x96, 0xE1, 0xBC, 0xE4, 0xDE, 0xC8, 0x3B, 0x61, 0x69, 0x06, 0xC2, -0x2C, 0xC9, 0x52, 0x5F, 0xB0, 0xD4, 0x97, 0x58, 0x3A, 0xBD, 0xCB, 0xDD, 0x9E, 0x77, 0xC2, 0x11, -0x5F, 0x60, 0xFF, 0x4A, 0x58, 0x2A, 0xB5, 0x81, 0x95, 0xDA, 0xF6, 0x5D, 0xED, 0x5F, 0xA5, 0x4D, -0x92, 0xC1, 0xF0, 0x38, 0x37, 0x16, 0x52, 0x10, 0xE6, 0xF3, 0xAF, 0xEE, 0xD2, 0xE7, 0x27, 0x5B, -0xF8, 0xFC, 0x24, 0xE5, 0xF3, 0x3B, 0x74, 0x76, 0x41, 0xF8, 0x37, 0xE6, 0xF0, 0x82, 0xAD, 0x0A, -0x4E, 0xAF, 0x64, 0x6B, 0xB7, 0x1E, 0x12, 0x5A, 0xC2, 0xAB, 0xBB, 0xF4, 0x90, 0x0C, 0xBB, 0xDD, -0xC8, 0x48, 0x79, 0xCC, 0x19, 0xEC, 0x26, 0x27, 0xD1, 0x4A, 0x4A, 0x56, 0x27, 0xEF, 0x1D, 0x77, -0x80, 0x1E, 0xF5, 0x79, 0xD9, 0x74, 0x17, 0xEA, 0x29, 0xBF, 0x51, 0x3E, 0xB3, 0xC9, 0xDD, 0x14, -0x65, 0xF8, 0xAA, 0x8A, 0xB9, 0x54, 0xE1, 0x96, 0x2E, 0xCC, 0x2E, 0xDF, 0xFE, 0xBD, 0x5A, 0x2D, -0x96, 0xEC, 0x69, 0x77, 0xF5, 0xD8, 0x66, 0xD6, 0x2A, 0x0B, 0x8C, 0xD3, 0x0E, 0x11, 0x07, 0x5F, -0x19, 0xD1, 0xFB, 0x14, 0x72, 0xAE, 0x18, 0x0A, 0x4A, 0x41, 0x21, 0x04, 0xCB, 0x1A, 0xEC, 0xD1, -0x40, 0xC8, 0x27, 0x93, 0xE1, 0x58, 0xC4, 0x35, 0xCD, 0x1D, 0x8F, 0xE9, 0x77, 0xC8, 0x1E, 0x63, -0xC0, 0xF0, 0xBF, 0xE0, 0xF5, 0x4E, 0x37, 0x24, 0x49, 0x35, 0xD4, 0x8B, 0x28, 0x0C, 0x69, 0xA3, -0x26, 0x56, 0x34, 0x6E, 0xAB, 0x2A, 0x82, 0x23, 0x26, 0x82, 0x17, 0x6F, 0x3E, 0xAA, 0x64, 0xC0, -0x7C, 0xAD, 0x93, 0x16, 0xC1, 0xD1, 0xE6, 0x2F, 0xBD, 0xE8, 0x96, 0x96, 0x56, 0x27, 0x92, 0xD6, -0xD1, 0x38, 0xDA, 0xBB, 0xBB, 0x4D, 0xC8, 0x52, 0x48, 0xE0, 0x98, 0x3D, 0x9D, 0xAF, 0xBD, 0x97, -0x3D, 0xA0, 0x94, 0x1D, 0x1C, 0x57, 0xB1, 0x03, 0xF3, 0x68, 0x0B, 0x33, 0x38, 0xCE, 0x30, 0x83, -0xBB, 0x92, 0x41, 0x5F, 0x1F, 0xBC, 0xDF, 0xC4, 0x0C, 0xFA, 0x25, 0xCD, 0xE0, 0x48, 0x98, 0x41, -0xB4, 0xB1, 0xBB, 0x5F, 0x56, 0x58, 0x92, 0x15, 0x3C, 0x1E, 0xE3, 0xF3, 0x4C, 0x8F, 0xCB, 0x79, -0xC2, 0xEE, 0x62, 0xEE, 0xD2, 0x72, 0xAA, 0xC7, 0xDB, 0x5F, 0x2D, 0xC7, 0x74, 0x97, 0xD5, 0x42, -0xAE, 0xDC, 0xD1, 0xD7, 0x1E, 0x6E, 0xAB, 0x8D, 0x5A, 0x71, 0x66, 0xA7, 0xB5, 0xC2, 0xCA, 0xDE, -0xF1, 0x5D, 0x4F, 0x4B, 0xBF, 0xD2, 0x25, 0xB6, 0x33, 0x54, 0xB4, 0x2E, 0x57, 0x04, 0xA4, 0xF7, -0xC2, 0xBC, 0x79, 0xA9, 0x6D, 0xF0, 0xBE, 0x0D, 0x05, 0xB2, 0x2E, 0x7B, 0x2B, 0x89, 0xB6, 0xC1, -0x6B, 0x49, 0x14, 0xD8, 0x32, 0xF6, 0x0E, 0xE1, 0x1B, 0x62, 0xB4, 0xCD, 0x5E, 0x11, 0x53, 0xB8, -0x8D, 0x86, 0xB5, 0xDA, 0x3C, 0xA5, 0x84, 0xE3, 0x32, 0xE6, 0xAD, 0x50, 0x63, 0x95, 0x7B, 0xF7, -0x01, 0xC5, 0x29, 0x19, 0x00, 0x07, 0x5F, 0xD1, 0x6D, 0x46, 0x3E, 0x29, 0xB7, 0x5B, 0x38, 0x49, -0x58, 0x85, 0xCA, 0xF0, 0xD3, 0x99, 0x14, 0xCC, 0xC2, 0xCE, 0x2B, 0x06, 0xB3, 0xA8, 0xCE, 0x07, -0x63, 0xDA, 0xFB, 0xE0, 0xE5, 0x9F, 0x0A, 0x96, 0xD6, 0x9B, 0xB3, 0x74, 0x74, 0x57, 0x2C, 0x6D, -0x91, 0xAA, 0x42, 0xEB, 0x0A, 0xDC, 0xC0, 0xB0, 0x37, 0x36, 0x2E, 0x06, 0x0D, 0xB6, 0xC5, 0x62, -0xAE, 0x76, 0x0D, 0xAC, 0xEE, 0xD4, 0xC0, 0x04, 0x01, 0xE5, 0x94, 0xD1, 0x4F, 0x2B, 0xE3, 0xF4, -0x6B, 0xB3, 0x2F, 0xC6, 0x51, 0x59, 0xF3, 0x52, 0x70, 0x74, 0xF2, 0x35, 0x99, 0x97, 0xBB, 0x08, -0xF0, 0xEA, 0xC6, 0xC1, 0x8B, 0x81, 0x63, 0xF0, 0xA2, 0x47, 0xBB, 0x37, 0xB0, 0x90, 0x82, 0x8D, -0xF5, 0x71, 0x74, 0xA7, 0xEF, 0xA2, 0xBB, 0x8B, 0x08, 0xC6, 0x58, 0xDA, 0xC2, 0xC4, 0x7A, 0xFD, -0x1D, 0x9A, 0x98, 0xB4, 0xD0, 0xC4, 0xF3, 0x20, 0x2F, 0x60, 0x74, 0xBE, 0x36, 0x10, 0x15, 0x34, -0x55, 0x56, 0x92, 0xD4, 0x59, 0xF9, 0xFC, 0x10, 0x8A, 0xC2, 0x34, 0x82, 0x0C, 0x3A, 0xCF, 0xD9, -0x27, 0x2D, 0xD5, 0x1D, 0x46, 0xAF, 0xA0, 0xA4, 0xCB, 0x6A, 0xD1, 0xCB, 0x8E, 0xC3, 0x42, 0x33, -0xF9, 0x12, 0xE4, 0xC2, 0xD7, 0x3D, 0x9E, 0x1B, 0xFC, 0xDD, 0x28, 0x37, 0x84, 0xAF, 0x01, 0x6A, -0x53, 0x8F, 0x8C, 0x2F, 0xF4, 0xEF, 0x42, 0x9C, 0x5C, 0x5A, 0xD8, 0x44, 0xD7, 0x20, 0x24, 0x3B, -0xB6, 0x6B, 0x60, 0xB1, 0x6A, 0xCC, 0x03, 0xA0, 0xB4, 0xFD, 0xC7, 0x1C, 0x27, 0x79, 0x0D, 0x7C, -0x91, 0x86, 0x51, 0x6E, 0xED, 0x98, 0xBE, 0x36, 0x99, 0x3F, 0xF1, 0x84, 0x87, 0xE1, 0x9A, 0xE1, -0xFF, 0xFC, 0x77, 0xD1, 0xD4, 0x0C, 0x7E, 0xD8, 0x34, 0x12, 0x00, 0x98, 0x91, 0x37, 0xBA, 0xD0, -0x81, 0x52, 0xCF, 0xF5, 0xA1, 0x14, 0xB5, 0x26, 0x56, 0x86, 0xAA, 0xB2, 0xA4, 0x7D, 0xA8, 0x12, -0x77, 0xA2, 0xB1, 0x62, 0x6C, 0x72, 0xEE, 0x8F, 0x3C, 0x6B, 0x0E, 0xA5, 0x9A, 0xE9, 0x8E, 0x16, -0x33, 0xE2, 0x04, 0x6D, 0xC3, 0x34, 0xAF, 0x6E, 0xE0, 0xE0, 0x2D, 0xCE, 0x30, 0x83, 0xE4, 0xEB, -0xB5, 0x17, 0x3F, 0xBD, 0xBB, 0x64, 0x2F, 0x13, 0x7D, 0x0B, 0xF2, 0x22, 0x66, 0xAD, 0xA9, 0x8D, -0x17, 0x0E, 0xAB, 0xDE, 0xEB, 0x04, 0xDB, 0xB2, 0x0F, 0xCC, 0xDE, 0x18, 0x9E, 0x36, 0x34, 0x7C, -0xF2, 0xDA, 0xF5, 0x03, 0xED, 0x42, 0x0B, 0x31, 0xDA, 0xEE, 0x88, 0xBE, 0xAC, 0xA6, 0xCD, 0xF8, -0xE2, 0x2D, 0x19, 0xE3, 0xBF, 0x78, 0x36, 0x34, 0x0D, 0xA1, 0x0E, 0xB4, 0xDA, 0xD9, 0x69, 0xB7, -0x86, 0xF6, 0x17, 0x76, 0x31, 0xC6, 0x4F, 0xC6, 0x42, 0xBB, 0xFA, 0xC2, 0xB3, 0x9B, 0xDA, 0x68, -0xD8, 0x60, 0x2F, 0x80, 0xA5, 0x97, 0xF1, 0x9A, 0x78, 0x33, 0x78, 0x3B, 0x98, 0x12, 0xA7, 0x1E, -0x51, 0x06, 0xCE, 0x30, 0x77, 0x1D, 0x3F, 0xF6, 0xF5, 0x5B, 0x6B, 0x1C, 0x5D, 0x6F, 0x43, 0x41, -0x1F, 0x2C, 0x7C, 0xED, 0xE1, 0xC5, 0x85, 0x86, 0x05, 0x6E, 0xEC, 0xC5, 0xB2, 0xA3, 0x61, 0xB2, -0x5D, 0x53, 0x4B, 0x5C, 0xF8, 0x19, 0x42, 0x83, 0xF4, 0x0A, 0xF3, 0x5B, 0x8D, 0xD8, 0x89, 0x77, -0x68, 0x87, 0x00, 0x18, 0x45, 0xEA, 0x8D, 0x38, 0x81, 0x75, 0xD3, 0x08, 0x8C, 0x46, 0xFC, 0x65, -0xB6, 0xD0, 0x2B, 0x50, 0xD2, 0xD4, 0xE8, 0x2D, 0xF9, 0xCD, 0xBA, 0xB7, 0x8D, 0x36, 0xC8, 0x10, -0xF8, 0x0D, 0xA1, 0x89, 0xE7, 0x25, 0x3F, 0xEB, 0x0B, 0xD0, 0xAD, 0x6E, 0x53, 0xC3, 0x3B, 0x71, -0x58, 0x89, 0xC8, 0x07, 0xE2, 0x9A, 0x10, 0x5A, 0x3E, 0x5A, 0x05, 0x4A, 0x86, 0xEE, 0x36, 0xA6, -0x22, 0x88, 0x3D, 0x1F, 0xC8, 0x04, 0x24, 0x36, 0x69, 0xF2, 0x01, 0x74, 0x93, 0x8E, 0x9E, 0x9B, -0x2C, 0x28, 0x4A, 0x5A, 0x3B, 0x3C, 0x04, 0x97, 0x86, 0xA0, 0x44, 0xC0, 0x2A, 0x26, 0xF5, 0x1A, -0x5F, 0xC0, 0x04, 0x8B, 0xAA, 0x75, 0x56, 0xB5, 0x03, 0x40, 0xD0, 0x0E, 0xDC, 0xEB, 0xC0, 0xB3, -0x9C, 0x09, 0x0C, 0x3D, 0x1A, 0x11, 0x36, 0x7A, 0x1B, 0x51, 0x26, 0xEE, 0xD3, 0xEB, 0xB4, 0x93, -0xE4, 0x8D, 0x3A, 0xBF, 0x7E, 0x50, 0x6B, 0xD4, 0x38, 0xF1, 0xF4, 0x1C, 0xCC, 0xAD, 0xCE, 0x0E, -0x1E, 0x51, 0x1A, 0x1B, 0xDA, 0xF9, 0x39, 0xEF, 0x86, 0xB5, 0xC2, 0x8B, 0xD0, 0x88, 0xFE, 0x49, -0xDC, 0x0A, 0x4D, 0xF1, 0xF7, 0xEF, 0xFF, 0x12, 0x36, 0x7B, 0x7B, 0x08, 0x54, 0x3F, 0xC5, 0x19, -0x84, 0xEF, 0xFF, 0x82, 0xFF, 0xDF, 0x3E, 0xA2, 0xD3, 0x06, 0xDF, 0xFF, 0x85, 0x7F, 0x6E, 0x1F, -0x41, 0x4F, 0x70, 0x4C, 0xFB, 0xBB, 0xFD, 0x9D, 0xCA, 0x21, 0x2D, 0xBD, 0x49, 0xA6, 0xF4, 0x42, -0xB1, 0x55, 0xA6, 0x69, 0x92, 0x43, 0xD4, 0xEF, 0x91, 0xFF, 0xD6, 0x47, 0xAE, 0x09, 0xEA, 0x09, -0xC0, 0x92, 0x85, 0xD2, 0x6D, 0x50, 0x89, 0x10, 0x54, 0xF8, 0x76, 0x67, 0x6B, 0x4C, 0x5B, 0x6A, -0xDC, 0x55, 0x22, 0x03, 0x11, 0x2D, 0xE7, 0x86, 0xE7, 0x93, 0x37, 0x4E, 0x50, 0x0F, 0x62, 0x4E, -0x91, 0x21, 0xF1, 0xC1, 0x20, 0xC6, 0x02, 0xFE, 0x00, 0x0E, 0xDA, 0xD5, 0xB8, 0xD2, 0x42, 0x63, -0x7B, 0x10, 0xDA, 0x61, 0x44, 0x29, 0xBB, 0x99, 0x61, 0x87, 0x9F, 0x46, 0xF6, 0x97, 0x3A, 0xBE, -0x6F, 0x36, 0x19, 0x2A, 0x52, 0x22, 0xC2, 0x46, 0x4F, 0xF1, 0x7F, 0x20, 0x17, 0xFC, 0x93, 0xA9, -0x1F, 0xC0, 0xCA, 0x4A, 0xF8, 0x3A, 0x9D, 0x02, 0xF8, 0xBC, 0x6A, 0x6A, 0xEC, 0x60, 0x0D, 0x9E, -0xE1, 0x98, 0x78, 0x8E, 0x7F, 0xD6, 0x42, 0x7B, 0x78, 0x81, 0x1F, 0xC1, 0x35, 0x5A, 0xB3, 0xE2, -0x25, 0x76, 0x80, 0xAD, 0x68, 0x8D, 0x41, 0x5B, 0xB1, 0x23, 0xB8, 0x86, 0xEF, 0x5E, 0x02, 0xDB, -0x6D, 0x6A, 0x43, 0xCB, 0x71, 0xE8, 0x41, 0x01, 0xF5, 0x51, 0xAA, 0x7F, 0xEA, 0xAF, 0x80, 0x03, -0x4E, 0xDA, 0xED, 0x23, 0x7F, 0x1D, 0x9E, 0xAD, 0x6F, 0x1F, 0x11, 0xBC, 0x47, 0x89, 0x84, 0xE3, -0x35, 0x3F, 0x86, 0xEB, 0x40, 0x1F, 0xDE, 0x11, 0x04, 0xD3, 0x0B, 0xEB, 0xE8, 0x02, 0xB4, 0x08, -0xF0, 0x3E, 0x27, 0x1E, 0xCE, 0xD6, 0xE1, 0x19, 0x42, 0x53, 0x58, 0xCE, 0x06, 0x9C, 0xAE, 0xA3, -0x53, 0xB8, 0x4B, 0xDF, 0x23, 0x85, 0x44, 0x30, 0x9E, 0x6E, 0x1F, 0x71, 0x9E, 0xE0, 0x12, 0x3F, -0x4A, 0x8A, 0x1A, 0x63, 0x42, 0xC0, 0xA3, 0xC8, 0x73, 0x96, 0xA4, 0xA5, 0xFC, 0x01, 0xFE, 0x71, -0x65, 0x13, 0x3C, 0x7C, 0xBE, 0x7E, 0x63, 0xD6, 0x6B, 0x7C, 0x41, 0xB6, 0x86, 0x31, 0x4C, 0x86, -0x69, 0xBB, 0xCE, 0xC8, 0xB6, 0x46, 0xE8, 0x28, 0xF5, 0x86, 0x76, 0x31, 0xE0, 0x71, 0x0C, 0x0D, -0x1A, 0x9A, 0xCB, 0x46, 0x9A, 0x89, 0x5A, 0x2C, 0x29, 0xD6, 0x1A, 0x6D, 0x6A, 0x87, 0xDC, 0xD6, -0x10, 0x05, 0x77, 0xC1, 0x72, 0x38, 0xB0, 0xB1, 0x02, 0x47, 0xCA, 0x5B, 0x72, 0x91, 0xD0, 0xD6, -0x12, 0x16, 0x8A, 0x46, 0x0E, 0xB5, 0x9D, 0x44, 0x94, 0xCD, 0xF1, 0x6A, 0xE1, 0xC0, 0x0F, 0x93, -0x0E, 0x0C, 0xAA, 0xF2, 0x82, 0x7A, 0xED, 0x0A, 0x37, 0x64, 0xFC, 0x56, 0x3B, 0xC0, 0x46, 0x07, -0xB5, 0x7F, 0x9D, 0x69, 0xB5, 0x03, 0xD9, 0x93, 0x6F, 0x93, 0x2E, 0xC7, 0x34, 0x36, 0x29, 0xA9, -0xB1, 0x89, 0xA4, 0xB1, 0xC9, 0xDD, 0x6A, 0x4C, 0x5E, 0x08, 0xDE, 0x46, 0x6B, 0xF2, 0xCA, 0x6B, -0x8E, 0xE6, 0x0A, 0xE1, 0xB9, 0xD2, 0xB8, 0xB6, 0x26, 0x2A, 0x6D, 0x6D, 0xA2, 0x26, 0x96, 0xE2, -0xC0, 0x7B, 0x88, 0xF7, 0xFA, 0xE7, 0x77, 0x6F, 0x31, 0x54, 0xAA, 0x55, 0x16, 0x6A, 0x2C, 0x59, -0x8E, 0x28, 0x30, 0x60, 0xEE, 0x8C, 0x05, 0xEE, 0x58, 0x0E, 0x3D, 0xA8, 0x69, 0x75, 0x8A, 0x12, -0x33, 0x68, 0x81, 0x21, 0xF0, 0xC0, 0x5B, 0xCE, 0x77, 0x31, 0xD8, 0x0A, 0xE7, 0x8D, 0xA0, 0x72, -0x6C, 0x01, 0x01, 0x4A, 0x29, 0x91, 0x61, 0x4E, 0x39, 0x8C, 0x94, 0x13, 0x76, 0xEE, 0x22, 0xD4, -0x5F, 0xFD, 0xB2, 0x41, 0x4D, 0xC4, 0xF4, 0x28, 0xB6, 0xF9, 0x85, 0xD2, 0xE1, 0x91, 0xBF, 0x94, -0x80, 0xF8, 0x1C, 0xB6, 0xC2, 0xC0, 0x45, 0x26, 0x28, 0x85, 0x46, 0x4C, 0x5D, 0x66, 0xE3, 0x59, -0x57, 0xC1, 0xB3, 0x56, 0xE0, 0xE1, 0x99, 0xA7, 0x14, 0x1A, 0x3E, 0xD1, 0x95, 0x89, 0xA5, 0x1C, -0x31, 0x7C, 0x72, 0x49, 0xC5, 0x13, 0xCF, 0x74, 0xE5, 0x78, 0xE2, 0x93, 0x22, 0xD9, 0x78, 0x4A, -0xCA, 0x86, 0xCF, 0x44, 0x28, 0xEC, 0x39, 0x59, 0x8D, 0x74, 0xC4, 0x7F, 0x9B, 0xD7, 0x1F, 0x63, -0x03, 0xA2, 0x45, 0xF4, 0x67, 0x2F, 0x5E, 0x32, 0xF9, 0x28, 0xAA, 0x45, 0x62, 0x2B, 0xB3, 0x01, -0xB1, 0xDB, 0x46, 0x00, 0xF1, 0x09, 0xC6, 0xF1, 0xC4, 0x6F, 0x63, 0x85, 0x1B, 0x8A, 0x31, 0x75, -0xAB, 0xED, 0x00, 0x01, 0x14, 0x61, 0xE3, 0xAC, 0x93, 0x34, 0xCF, 0x14, 0x2E, 0x76, 0x39, 0x0B, -0x1D, 0xBB, 0x9B, 0x81, 0x91, 0x67, 0x98, 0x38, 0x04, 0x5E, 0xCC, 0xC2, 0x46, 0x47, 0x31, 0x12, -0xAE, 0xDE, 0xF1, 0x71, 0x3A, 0xCF, 0xF0, 0x0E, 0xF8, 0x67, 0xED, 0x10, 0x03, 0xCE, 0x47, 0x45, -0x43, 0xB3, 0x11, 0x14, 0x82, 0x5A, 0x4D, 0x2C, 0xD9, 0xD5, 0xCE, 0x52, 0x15, 0x37, 0x40, 0xF0, -0x45, 0x38, 0xED, 0x29, 0xA3, 0x31, 0xF6, 0xB1, 0x96, 0x21, 0x0C, 0xBE, 0xC3, 0x6F, 0xB2, 0x31, -0x64, 0x74, 0x83, 0x4B, 0x88, 0x89, 0x5D, 0xC3, 0x81, 0x6B, 0xE2, 0x12, 0x5B, 0xDB, 0x69, 0xB9, -0x0E, 0x51, 0xF7, 0x1A, 0xAB, 0xDF, 0x79, 0x47, 0xFC, 0x8C, 0x3F, 0x94, 0x1E, 0x81, 0x79, 0x24, -0x58, 0x78, 0x0E, 0xAF, 0xE7, 0xD3, 0xF5, 0x8D, 0x72, 0x28, 0xB9, 0x43, 0xDB, 0x3C, 0x3C, 0xD4, -0x9E, 0x05, 0x81, 0x01, 0x0A, 0xC0, 0x75, 0xCA, 0x29, 0xCA, 0x47, 0x33, 0xF8, 0xA4, 0x84, 0xEB, -0xA1, 0x51, 0xB2, 0x87, 0x8A, 0x09, 0xF3, 0x5B, 0xFC, 0x84, 0x9D, 0x70, 0x67, 0x8A, 0xAA, 0xFD, -0xEF, 0x05, 0xF1, 0xD6, 0xD7, 0x54, 0x60, 0xAE, 0xF7, 0xCC, 0xB6, 0xEB, 0xB5, 0x76, 0xB4, 0xEC, -0x5C, 0x63, 0x63, 0xF0, 0x36, 0xA0, 0xBA, 0x82, 0x3E, 0x40, 0xC7, 0x91, 0xCD, 0x33, 0x6E, 0x42, -0xBD, 0xC3, 0xB8, 0xEB, 0x82, 0x2B, 0x23, 0x39, 0xE8, 0x87, 0x16, 0xAE, 0xF3, 0x85, 0xAC, 0x17, -0x73, 0x10, 0x7F, 0x34, 0x8C, 0x4F, 0x4C, 0x2C, 0x70, 0xE9, 0x90, 0x36, 0xB4, 0xBC, 0xE4, 0x03, -0xB9, 0xEE, 0x91, 0xA2, 0x51, 0xA4, 0x02, 0x6A, 0x9D, 0xE8, 0x89, 0xE9, 0x2F, 0xFC, 0xDC, 0x3E, -0x50, 0x9F, 0x29, 0xA6, 0x40, 0x38, 0x81, 0x5C, 0x78, 0x22, 0x79, 0x25, 0x7A, 0x48, 0x4C, 0x4F, -0xDC, 0x36, 0x1E, 0x44, 0x91, 0x61, 0x31, 0x37, 0x8D, 0x80, 0xC4, 0x83, 0x43, 0x68, 0x0B, 0xE2, -0xE6, 0xCC, 0x0D, 0x48, 0x22, 0x62, 0x58, 0xB8, 0x69, 0xC6, 0xB0, 0x3F, 0x46, 0xD6, 0x78, 0xAF, -0xEE, 0xAF, 0xF0, 0xF1, 0x0A, 0xFE, 0x9F, 0x9A, 0x83, 0x28, 0x37, 0x6E, 0x4E, 0x59, 0x48, 0x18, -0x0F, 0x22, 0x2B, 0x91, 0xE5, 0x10, 0x0B, 0x0B, 0xFC, 0xBE, 0xE8, 0xE9, 0xE1, 0x43, 0x7A, 0xF4, -0x20, 0x54, 0x9A, 0x88, 0x1E, 0x17, 0x5A, 0x74, 0x23, 0xA1, 0xE0, 0x34, 0xEE, 0x04, 0x0E, 0x81, -0x5C, 0xC2, 0xC0, 0x7C, 0x2B, 0x54, 0xEF, 0x1C, 0xAA, 0x4D, 0xB4, 0x85, 0xFF, 0x8F, 0xFA, 0x5F, -0x51, 0xD4, 0xBF, 0xBF, 0x10, 0x9F, 0x63, 0xDB, 0x09, 0x0F, 0x60, 0x70, 0xEA, 0x69, 0xC1, 0x83, -0x1A, 0x54, 0x3B, 0xCA, 0x79, 0x3F, 0x1E, 0xBA, 0x23, 0xFB, 0x9A, 0x5A, 0x26, 0x23, 0x3A, 0xB2, -0x2C, 0x94, 0x11, 0x4E, 0xDF, 0xE3, 0x04, 0x37, 0xCE, 0x76, 0xD7, 0x6B, 0x6C, 0x6D, 0x81, 0xC6, -0xE3, 0xDB, 0xA8, 0x24, 0x99, 0xBA, 0xCB, 0x3C, 0x48, 0x0F, 0xA2, 0xCE, 0x0D, 0x49, 0x00, 0x87, -0xD0, 0x7C, 0x3B, 0x4F, 0x61, 0xD7, 0x62, 0xDB, 0x0F, 0x4F, 0x06, 0xD0, 0x40, 0x5C, 0x01, 0xD0, -0xC0, 0xA3, 0x5E, 0x23, 0xA1, 0x25, 0x4E, 0x11, 0x56, 0x41, 0x56, 0x2E, 0x62, 0x5A, 0xE6, 0xC5, -0x31, 0xB3, 0x50, 0x5A, 0x21, 0xC8, 0xCA, 0x97, 0x01, 0x22, 0x7E, 0x7A, 0xA1, 0x39, 0x0B, 0xDB, -0x06, 0x1B, 0x44, 0x16, 0xC0, 0x06, 0xE5, 0xBB, 0xCA, 0x10, 0xFD, 0x9F, 0x1B, 0xCF, 0x42, 0xCA, -0x63, 0x12, 0x78, 0xF4, 0x28, 0x8E, 0x0D, 0x17, 0x19, 0x58, 0x19, 0x1F, 0xF6, 0xC6, 0xDA, 0x5F, -0xBA, 0xCE, 0xD8, 0x9A, 0x44, 0x79, 0x96, 0x93, 0x04, 0xC9, 0xFA, 0x61, 0x4C, 0xF0, 0x52, 0x8D, -0x03, 0x84, 0x58, 0x26, 0x15, 0x10, 0x7D, 0x63, 0x6A, 0x6A, 0x36, 0xF6, 0x29, 0xB5, 0xFA, 0x3A, -0xE1, 0xEF, 0x52, 0x6C, 0x80, 0xFC, 0xD1, 0x98, 0xA3, 0x0B, 0xA2, 0xDE, 0x09, 0xBB, 0x92, 0x31, -0x4E, 0x62, 0x18, 0x91, 0xB1, 0x04, 0xDD, 0xF8, 0xA3, 0xF8, 0xF0, 0xAD, 0x62, 0xFC, 0xCD, 0x66, -0xF2, 0xD7, 0x59, 0x69, 0xE7, 0x80, 0x06, 0xDF, 0x7C, 0x26, 0xA5, 0xF6, 0x74, 0x6D, 0x40, 0x1B, -0x66, 0x20, 0xA1, 0x1D, 0xA4, 0x91, 0xE4, 0x52, 0x2E, 0x5E, 0x2B, 0xAD, 0x10, 0x08, 0x45, 0xB7, -0x1C, 0xA2, 0x28, 0x68, 0xAF, 0x70, 0x98, 0x87, 0x2A, 0xB1, 0x25, 0x55, 0x81, 0x90, 0x39, 0x62, -0x9D, 0x6D, 0xEC, 0x63, 0x43, 0x74, 0x44, 0xCE, 0x7D, 0x2C, 0x7E, 0x3D, 0xB3, 0x27, 0x2D, 0xB1, -0x7B, 0x3D, 0xD5, 0xCF, 0x40, 0x6B, 0x75, 0x05, 0xF5, 0xD0, 0xF4, 0x15, 0x3E, 0x25, 0x10, 0xF2, -0x10, 0x5E, 0x88, 0x97, 0xAF, 0xC2, 0xAD, 0xC3, 0xE9, 0x6B, 0xD9, 0xCE, 0x58, 0x92, 0x8D, 0x32, -0x6C, 0x64, 0xC8, 0x77, 0x94, 0x73, 0xBA, 0x9A, 0xFC, 0xE1, 0x47, 0x39, 0x33, 0x6C, 0x99, 0x6E, -0x72, 0x70, 0xB2, 0x65, 0xDD, 0x24, 0xD2, 0xC5, 0x70, 0x66, 0x05, 0x0A, 0x84, 0xB5, 0x6E, 0xAD, -0x4A, 0xE6, 0x92, 0xBD, 0x9C, 0x45, 0x4A, 0x5A, 0xD4, 0x03, 0xA2, 0xD8, 0x74, 0x3C, 0xFD, 0x50, -0x8A, 0x6B, 0x3F, 0xBD, 0x31, 0x3C, 0x9C, 0x64, 0x47, 0x05, 0x27, 0x16, 0x7F, 0x18, 0x0A, 0xB6, -0x6A, 0x49, 0x51, 0xC4, 0xD7, 0x2D, 0xC5, 0x5A, 0x61, 0x7C, 0x0C, 0x20, 0x2F, 0x96, 0xFD, 0xEE, -0x11, 0x80, 0xF3, 0x71, 0x52, 0x43, 0xFB, 0xFE, 0x2F, 0x8A, 0xE2, 0x56, 0x1B, 0x43, 0x94, 0xF1, -0xA7, 0xC4, 0xA4, 0x4B, 0x12, 0x01, 0x7E, 0x2B, 0x15, 0x17, 0x7C, 0x62, 0xEB, 0x94, 0xB7, 0xBF, -0x87, 0x16, 0x12, 0x26, 0xA9, 0xC2, 0x61, 0x0A, 0x5D, 0xCE, 0xCE, 0x1F, 0xA1, 0xB0, 0xC2, 0x5E, -0x31, 0x29, 0x85, 0x3F, 0x16, 0x81, 0xEC, 0x36, 0x54, 0x43, 0xD0, 0xCD, 0x8F, 0x50, 0xF7, 0x24, -0xCC, 0xB4, 0xC1, 0x07, 0x58, 0xA0, 0x01, 0x53, 0x84, 0x4A, 0xA6, 0x23, 0x1C, 0x46, 0x31, 0x31, -0xC5, 0x24, 0xCC, 0x98, 0xE1, 0xBC, 0x14, 0xAF, 0xF5, 0xF2, 0xFA, 0x21, 0x94, 0xC5, 0x1F, 0x3E, -0x0C, 0x8B, 0x1A, 0x0F, 0x42, 0x31, 0xA4, 0x71, 0x60, 0x07, 0x12, 0x82, 0x98, 0x88, 0xB2, 0xC4, -0x14, 0xDF, 0x66, 0x5D, 0x8B, 0x22, 0x55, 0xE6, 0xA8, 0x4E, 0x93, 0x33, 0x2D, 0x4D, 0xB3, 0xB4, -0xDF, 0xDF, 0xA8, 0xC9, 0xFC, 0x8B, 0xCF, 0xC0, 0x48, 0x11, 0xAF, 0x51, 0x85, 0x9C, 0xD4, 0xE0, -0xB2, 0x80, 0x94, 0x3B, 0x2C, 0xB3, 0xC5, 0x0F, 0xC2, 0x1B, 0xC5, 0x07, 0xC5, 0x6C, 0x6A, 0x78, -0x29, 0xD7, 0x82, 0x21, 0x83, 0xB1, 0xB3, 0xF8, 0x68, 0x4F, 0x12, 0x8F, 0xB2, 0x0E, 0xCC, 0x14, -0x17, 0xB3, 0x2E, 0xE6, 0xAE, 0x37, 0x16, 0x59, 0xE6, 0x4E, 0xBC, 0xD2, 0xE7, 0x10, 0xA8, 0xBC, -0x22, 0x80, 0xCB, 0xF0, 0xB9, 0x93, 0x42, 0xC8, 0xE8, 0x19, 0x15, 0x09, 0x07, 0x7D, 0x00, 0xA5, -0xDC, 0xBA, 0x08, 0x6D, 0x1A, 0x03, 0x45, 0xAC, 0xC5, 0xB0, 0xB1, 0x5D, 0xEA, 0x12, 0xBC, 0x9C, -0x7A, 0xF2, 0xE0, 0xA5, 0x8D, 0xE9, 0x12, 0x34, 0xF5, 0xFB, 0x62, 0x60, 0xF9, 0x69, 0x17, 0x99, -0x76, 0xE3, 0xA6, 0x04, 0x70, 0xF4, 0x88, 0x8E, 0x04, 0x2A, 0xB2, 0x59, 0x1E, 0x60, 0xF8, 0xB6, -0x9A, 0x9A, 0xA4, 0x5F, 0x3F, 0x70, 0xE7, 0x6C, 0x73, 0x7E, 0x22, 0x0C, 0x2D, 0xE9, 0x2C, 0x69, -0x1B, 0xEF, 0xD7, 0x79, 0x71, 0x25, 0xCB, 0x36, 0xBE, 0x14, 0x22, 0x6F, 0xF2, 0xAF, 0xC5, 0x2B, -0x63, 0x3A, 0xCD, 0xAA, 0xEC, 0x01, 0x2D, 0xA5, 0xED, 0x7B, 0x23, 0x96, 0x0C, 0xC2, 0x07, 0x5A, -0x30, 0x56, 0xE1, 0xE1, 0xEF, 0xAC, 0x4F, 0x4C, 0xDE, 0x31, 0x9B, 0x6A, 0x14, 0xD2, 0xE2, 0xCE, -0x93, 0xA4, 0x44, 0x53, 0x51, 0xCC, 0xBF, 0x7D, 0xCC, 0x02, 0xFC, 0x05, 0x09, 0x0F, 0x34, 0xD9, -0xDE, 0x32, 0x82, 0x72, 0x24, 0x26, 0x1E, 0x05, 0xE3, 0xC4, 0x4B, 0x99, 0x8C, 0x3D, 0x0D, 0xF5, -0xF4, 0xF3, 0x68, 0x08, 0xC9, 0xEB, 0x05, 0x38, 0x1F, 0x78, 0xFB, 0xB2, 0xDE, 0xB8, 0xCD, 0x63, -0x87, 0x89, 0x2B, 0xB2, 0x9D, 0xB2, 0x44, 0xD0, 0x34, 0xA1, 0xC6, 0x16, 0x93, 0x8F, 0x1A, 0x9D, -0xEC, 0x30, 0x57, 0x8E, 0x18, 0xDC, 0x64, 0x09, 0xF6, 0x22, 0x2D, 0x5A, 0x56, 0xDF, 0xC6, 0x10, -0x44, 0x09, 0x20, 0x45, 0x6C, 0xA2, 0x84, 0x95, 0xEC, 0x42, 0x34, 0x10, 0xB4, 0xCB, 0x3E, 0x98, -0x41, 0x7B, 0xBC, 0xFA, 0x4F, 0x94, 0x8B, 0x4C, 0x00, 0xA1, 0x33, 0x65, 0xA0, 0xC0, 0xA7, 0xA8, -0x46, 0x86, 0x73, 0x63, 0xF8, 0xB2, 0xD3, 0x8C, 0x80, 0xA0, 0x80, 0x70, 0xBF, 0xA9, 0xEB, 0xAC, -0x81, 0xCE, 0x1D, 0x80, 0x9D, 0xB5, 0xE9, 0xB6, 0x13, 0x1C, 0xC7, 0xA0, 0x0D, 0xD0, 0x93, 0xD8, -0x6D, 0xF6, 0x05, 0x6F, 0x71, 0x9F, 0x9D, 0xB1, 0x06, 0x61, 0x2F, 0xF8, 0xCD, 0xEC, 0xB6, 0x31, -0x9F, 0x13, 0xC7, 0xBC, 0x9C, 0x5A, 0xB6, 0x59, 0x67, 0xA0, 0xE1, 0x83, 0x36, 0x9E, 0x46, 0xBF, -0x3F, 0x4D, 0x1F, 0xEA, 0xE0, 0x58, 0xC1, 0x9B, 0x2F, 0xD9, 0xB5, 0x7A, 0xAD, 0x67, 0x8A, 0x67, -0x72, 0x78, 0xB3, 0xB6, 0xE9, 0x19, 0xCB, 0x37, 0xF8, 0xCC, 0x1F, 0x35, 0x87, 0x66, 0xA7, 0xD9, -0xE1, 0x0D, 0x02, 0x28, 0xB6, 0x84, 0xC8, 0x11, 0x2F, 0x3E, 0x1B, 0xF5, 0xCB, 0x87, 0xB7, 0x11, -0xDE, 0xC0, 0x7D, 0xC1, 0x2E, 0xD5, 0x6B, 0xF4, 0xA1, 0xC1, 0xC3, 0x3F, 0xE6, 0xB8, 0xD4, 0x2C, -0xD2, 0x8C, 0x24, 0x46, 0x7C, 0x1E, 0x10, 0x45, 0xC5, 0x9A, 0xFF, 0x20, 0x23, 0x85, 0xCB, 0x0E, -0xE4, 0x09, 0x34, 0xF7, 0xBA, 0x0A, 0x54, 0x3C, 0x2D, 0x88, 0xE0, 0xC8, 0xC9, 0x4B, 0x18, 0xDE, -0xFE, 0x93, 0x18, 0x1E, 0xE8, 0xE3, 0x40, 0xAB, 0xEB, 0x1D, 0xFD, 0xA0, 0x4E, 0xAF, 0xBF, 0x03, -0x76, 0xA6, 0xF5, 0xC6, 0x41, 0xB7, 0xD1, 0x68, 0xFB, 0xA0, 0x33, 0x52, 0x6F, 0xF5, 0x44, 0x13, -0xF8, 0x43, 0xDB, 0xB0, 0x4E, 0xB2, 0xEF, 0xBF, 0x76, 0x17, 0x9E, 0x9F, 0xD7, 0xE0, 0x9D, 0xE5, -0x60, 0x26, 0xCE, 0x6B, 0x72, 0x0D, 0x83, 0x17, 0xC7, 0x4C, 0x35, 0xD1, 0xE9, 0x43, 0x8E, 0x62, -0xA4, 0x49, 0x9F, 0xFD, 0x82, 0x92, 0x5F, 0x2A, 0xF6, 0x79, 0xBD, 0x49, 0x70, 0xA6, 0xBC, 0x2E, -0xD6, 0xAE, 0x6E, 0x65, 0xE3, 0x88, 0x2A, 0x3A, 0x3E, 0xD7, 0x90, 0xD2, 0x7F, 0x22, 0x60, 0xF1, -0x1A, 0x29, 0x35, 0x87, 0x5E, 0xA6, 0x0C, 0x55, 0xD6, 0x57, 0xB9, 0xF5, 0x68, 0x7C, 0xA2, 0x39, -0x39, 0xC6, 0x8E, 0xD7, 0x9E, 0x97, 0x0B, 0xF0, 0xF1, 0x99, 0x88, 0xA8, 0xEC, 0x1A, 0x0E, 0x38, -0xC3, 0xD0, 0x0F, 0x03, 0xD0, 0xBC, 0x7C, 0x04, 0xB7, 0xA5, 0x0C, 0xC6, 0x47, 0xAB, 0x05, 0x00, -0xD2, 0x3B, 0xDE, 0x25, 0x58, 0x69, 0x14, 0x9C, 0x5B, 0x2F, 0x24, 0xDF, 0x4A, 0x4E, 0x51, 0x00, -0xD6, 0x34, 0xE7, 0x8A, 0x28, 0x03, 0xED, 0x1A, 0x61, 0xD0, 0x43, 0x20, 0x3E, 0x6C, 0x93, 0x42, -0x5E, 0xC6, 0xB0, 0x3E, 0x3D, 0xA4, 0x4F, 0xC4, 0xC2, 0xAC, 0xA1, 0x7C, 0x7A, 0x18, 0x7F, 0x2B, -0x19, 0x88, 0xF8, 0xDA, 0x43, 0x24, 0x42, 0x92, 0x2F, 0x6F, 0x22, 0xCB, 0x5B, 0x4C, 0x67, 0x14, -0x40, 0xC8, 0x1F, 0xA5, 0x60, 0xE2, 0x22, 0x25, 0xC5, 0x45, 0xB8, 0xB8, 0x10, 0x20, 0x1A, 0xE1, -0x16, 0xCF, 0xAD, 0x84, 0xF6, 0xFF, 0xEB, 0xF3, 0x88, 0xB3, 0xE5, 0x30, 0x97, 0x4E, 0x3E, 0x77, -0x21, 0xB1, 0x97, 0x0F, 0x10, 0xFB, 0x72, 0x15, 0x63, 0x6B, 0x39, 0x2C, 0xC7, 0x96, 0x98, 0xFB, -0x40, 0x80, 0x88, 0x2D, 0xF5, 0x0C, 0x89, 0x60, 0x25, 0x7C, 0x8B, 0x9C, 0x66, 0x38, 0xA6, 0x16, -0x7E, 0xBF, 0x3C, 0x24, 0x96, 0xBD, 0x82, 0xAD, 0xB0, 0xDC, 0x64, 0xCD, 0x24, 0x26, 0xC3, 0x39, -0x96, 0x42, 0xD0, 0xB0, 0xA5, 0x04, 0x1D, 0xD2, 0x91, 0x0B, 0x2D, 0x1A, 0xB1, 0xA2, 0x31, 0x3C, -0x2D, 0x25, 0xAC, 0xB0, 0x75, 0xE4, 0x38, 0x11, 0x02, 0x31, 0x47, 0x73, 0x9C, 0x9C, 0xD3, 0x63, -0x83, 0x17, 0xC6, 0x6C, 0x62, 0x88, 0x22, 0x37, 0x08, 0x59, 0x8A, 0xB5, 0x09, 0x1D, 0x84, 0xC1, -0x67, 0x91, 0x59, 0x48, 0x0A, 0x5B, 0xF3, 0xD4, 0xDF, 0xDB, 0x04, 0xE7, 0x43, 0xF8, 0x3E, 0x46, -0xDC, 0x83, 0xE8, 0x7A, 0x9A, 0xED, 0x2E, 0x09, 0xAE, 0x5F, 0x8A, 0xC7, 0x4D, 0xB4, 0x21, 0x81, -0x98, 0x4A, 0xD8, 0xAC, 0x16, 0xC6, 0xA0, 0x60, 0x6A, 0xF9, 0x30, 0x08, 0xC7, 0xEF, 0x8C, 0x92, -0x87, 0x7A, 0x98, 0x10, 0x0B, 0xD9, 0x4B, 0xCF, 0xF6, 0xC7, 0xC4, 0xC9, 0x60, 0x22, 0x59, 0x3E, -0xE4, 0x3C, 0xA6, 0x02, 0x51, 0xDE, 0x3C, 0x5A, 0x05, 0x11, 0x86, 0xB7, 0xBF, 0x5A, 0x29, 0xAA, -0x19, 0x28, 0x14, 0x64, 0x08, 0x16, 0xC9, 0x32, 0xE2, 0x35, 0x25, 0x4D, 0xD5, 0x64, 0x65, 0x8E, -0x46, 0x71, 0xCE, 0x5E, 0x19, 0xE5, 0xB3, 0xB5, 0xC2, 0x24, 0xCE, 0x12, 0x2B, 0xFB, 0x9D, 0x1F, -0x8A, 0x9D, 0x05, 0xEC, 0x0C, 0x0B, 0xC6, 0xC1, 0x83, 0xF3, 0xC3, 0x69, 0x30, 0xB3, 0x07, 0x0F, -0xFE, 0x17, 0x8F, 0xA0, 0x18, 0x01, 0x53, 0xAD, 0x00, 0x00 -}; - - -//File: index_ov3660.html.gz, Size: 8887 -#define index_ov3660_html_gz_len 8887 -const uint8_t index_ov3660_html_gz[] = { - 0x1F, 0x8B, 0x08, 0x08, 0xA3, 0xFA, 0x69, 0x5E, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x5F, - 0x6F, 0x76, 0x33, 0x36, 0x36, 0x30, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xED, 0x3D, 0x69, 0x73, - 0xDB, 0x46, 0xB2, 0xDF, 0xFD, 0x2B, 0x60, 0x24, 0x6B, 0x51, 0x65, 0x91, 0xE2, 0xAD, 0x23, 0x12, - 0xFD, 0x6C, 0x59, 0xB1, 0x53, 0x1B, 0x67, 0xBD, 0x71, 0xE2, 0x24, 0xB5, 0xB5, 0xE5, 0x80, 0xC4, - 0x90, 0x44, 0x0C, 0x02, 0x5C, 0x00, 0xD4, 0x91, 0x94, 0x7E, 0xC7, 0xFB, 0x41, 0xEF, 0x8F, 0xBD, - 0xEE, 0x39, 0x70, 0x71, 0x00, 0x0C, 0x00, 0x11, 0x52, 0xF2, 0x1E, 0x5D, 0x65, 0xE1, 0x98, 0xEE, - 0xE9, 0x7B, 0x7A, 0x7A, 0x06, 0xC0, 0xD9, 0x53, 0xD3, 0x9D, 0x05, 0xB7, 0x6B, 0xA2, 0x2D, 0x83, - 0x95, 0x3D, 0x79, 0x72, 0xC6, 0xFE, 0x68, 0xF0, 0x3B, 0x5B, 0x12, 0xC3, 0x64, 0x87, 0xF4, 0x74, - 0x45, 0x02, 0x43, 0x9B, 0x2D, 0x0D, 0xCF, 0x27, 0xC1, 0xB9, 0xBE, 0x09, 0xE6, 0xED, 0x63, 0x3D, - 0x7D, 0xDB, 0x31, 0x56, 0xE4, 0x5C, 0xBF, 0xB2, 0xC8, 0xF5, 0xDA, 0xF5, 0x02, 0x5D, 0x9B, 0xB9, - 0x4E, 0x40, 0x1C, 0x68, 0x7E, 0x6D, 0x99, 0xC1, 0xF2, 0xDC, 0x24, 0x57, 0xD6, 0x8C, 0xB4, 0xE9, - 0xC9, 0x81, 0xE5, 0x58, 0x81, 0x65, 0xD8, 0x6D, 0x7F, 0x66, 0xD8, 0xE4, 0xBC, 0x17, 0xC7, 0x15, - 0x58, 0x81, 0x4D, 0x26, 0x97, 0x1F, 0xDE, 0x0F, 0xFA, 0xDA, 0x3F, 0x3E, 0x0E, 0xC6, 0xE3, 0xEE, - 0xD9, 0x21, 0xBB, 0x16, 0xB5, 0xF1, 0x83, 0xDB, 0xF8, 0x39, 0xFE, 0xA6, 0xAE, 0x79, 0xAB, 0xFD, - 0x91, 0xB8, 0x84, 0xBF, 0x39, 0x10, 0xD1, 0x9E, 0x1B, 0x2B, 0xCB, 0xBE, 0x3D, 0xD5, 0x5E, 0x7A, - 0xD0, 0xE7, 0xC1, 0x5B, 0x62, 0x5F, 0x91, 0xC0, 0x9A, 0x19, 0x07, 0xBE, 0xE1, 0xF8, 0x6D, 0x9F, - 0x78, 0xD6, 0xFC, 0xAB, 0x2D, 0xC0, 0xA9, 0x31, 0xFB, 0xBC, 0xF0, 0xDC, 0x8D, 0x63, 0x9E, 0x6A, - 0x5F, 0xF4, 0x8E, 0xF1, 0xDF, 0x76, 0xA3, 0x99, 0x6B, 0xBB, 0x1E, 0xDC, 0xBF, 0xFC, 0x1A, 0xFF, - 0x6D, 0xDF, 0xA7, 0xBD, 0xFB, 0xD6, 0xEF, 0xE4, 0x54, 0xEB, 0x8D, 0xD7, 0x37, 0x89, 0xFB, 0x77, - 0x4F, 0x12, 0xA7, 0xCB, 0x7E, 0x16, 0xF5, 0x1C, 0xFE, 0x38, 0x1F, 0xDE, 0x27, 0xB3, 0xC0, 0x72, - 0x9D, 0xCE, 0xCA, 0xB0, 0x1C, 0x09, 0x26, 0xD3, 0xF2, 0xD7, 0xB6, 0x01, 0x32, 0x98, 0xDB, 0x24, - 0x17, 0xCF, 0x17, 0x2B, 0xE2, 0x6C, 0x0E, 0x0A, 0xB0, 0x21, 0x92, 0xB6, 0x69, 0x79, 0xAC, 0xD5, - 0x29, 0xCA, 0x61, 0xB3, 0x72, 0x0A, 0xD1, 0xE6, 0xD1, 0xE5, 0xB8, 0x0E, 0x91, 0x08, 0x10, 0x3B, - 0xBA, 0xF6, 0x8C, 0x35, 0x36, 0xC0, 0xBF, 0xDB, 0x4D, 0x56, 0x96, 0xC3, 0x8C, 0xEA, 0x54, 0x1B, - 0x0C, 0xBB, 0xEB, 0x9B, 0x02, 0x55, 0x0E, 0xC6, 0xF8, 0x6F, 0xBB, 0xD1, 0xDA, 0x30, 0x4D, 0xCB, - 0x59, 0x9C, 0x6A, 0xC7, 0x52, 0x14, 0xAE, 0x67, 0x12, 0xAF, 0xED, 0x19, 0xA6, 0xB5, 0xF1, 0x4F, - 0xB5, 0xA1, 0xAC, 0xCD, 0xCA, 0xF0, 0x16, 0x40, 0x4B, 0xE0, 0x02, 0xB1, 0xED, 0x9E, 0x94, 0x12, - 0xDE, 0xC4, 0xB3, 0x16, 0xCB, 0x00, 0x54, 0xBA, 0xD5, 0x26, 0x2D, 0x34, 0xEE, 0x42, 0x45, 0xFA, - 0xCC, 0x95, 0x9B, 0x5C, 0x6A, 0x86, 0x6D, 0x2D, 0x9C, 0xB6, 0x15, 0x90, 0x15, 0xB0, 0xE3, 0x07, - 0x1E, 0x09, 0x66, 0xCB, 0x3C, 0x52, 0xE6, 0xD6, 0x62, 0xE3, 0x11, 0x09, 0x21, 0xA1, 0xDC, 0x72, - 0x18, 0x86, 0x9B, 0xDB, 0xB7, 0xDA, 0xD7, 0x64, 0xFA, 0xD9, 0x0A, 0xDA, 0x5C, 0x26, 0x53, 0x32, - 0x77, 0x3D, 0x22, 0x6D, 0x29, 0x5A, 0xD8, 0xEE, 0xEC, 0x73, 0xDB, 0x0F, 0x0C, 0x2F, 0x50, 0x41, - 0x68, 0xCC, 0x03, 0xE2, 0x15, 0xE3, 0x23, 0x68, 0x15, 0xC5, 0xD8, 0xB2, 0xBB, 0xE5, 0x0D, 0x2C, - 0xC7, 0xB6, 0x1C, 0xA2, 0x4E, 0x5E, 0x56, 0xBF, 0x49, 0x74, 0xAC, 0x95, 0x82, 0x62, 0xAC, 0xD5, - 0x22, 0xCF, 0x4A, 0x28, 0xAF, 0xDB, 0x9D, 0x71, 0xBF, 0xE9, 0x75, 0xBB, 0x7F, 0xDB, 0xBE, 0xB9, - 0x24, 0xCC, 0x4C, 0x8D, 0x4D, 0xE0, 0xD6, 0xF7, 0x88, 0x2D, 0xB7, 0x4A, 0xF1, 0xF1, 0x5F, 0x2B, - 0x62, 0x5A, 0x86, 0xD6, 0x8A, 0xB9, 0xF3, 0x71, 0x17, 0x6C, 0x6A, 0x5F, 0x33, 0x1C, 0x53, 0x6B, - 0xB9, 0x9E, 0x05, 0x8E, 0x60, 0xD0, 0x70, 0x63, 0xC3, 0x15, 0x18, 0x38, 0xD6, 0x64, 0x5F, 0xC2, - 0x72, 0x8E, 0xCF, 0xC4, 0x25, 0x22, 0x77, 0x1B, 0xFC, 0x29, 0x84, 0x1C, 0xFC, 0x15, 0x3A, 0x90, - 0x84, 0x47, 0x8A, 0x3E, 0x4F, 0x5F, 0x71, 0x0A, 0xB3, 0x74, 0x86, 0xBF, 0x95, 0x71, 0xD3, 0xCE, - 0xD5, 0x9D, 0x68, 0x24, 0x74, 0x08, 0xC3, 0xEC, 0xAC, 0x05, 0x4D, 0xAF, 0x96, 0x5A, 0x5B, 0xC3, - 0x28, 0xB9, 0x2F, 0x87, 0xE1, 0x48, 0xE5, 0x2A, 0xC7, 0x5F, 0xDC, 0x28, 0x4A, 0xB0, 0x2B, 0x67, - 0x35, 0x8A, 0x1D, 0xEC, 0x9F, 0xCC, 0x86, 0x18, 0x27, 0x99, 0x51, 0x04, 0x7F, 0xEA, 0x91, 0x24, - 0x42, 0x56, 0x18, 0x4D, 0x24, 0x88, 0xB3, 0x23, 0xCA, 0x16, 0xDE, 0x2C, 0xEF, 0x96, 0x60, 0xCD, - 0x27, 0x41, 0x35, 0xBA, 0x48, 0x10, 0xE7, 0xD1, 0x50, 0x18, 0x65, 0xF0, 0x77, 0xA7, 0x90, 0x6F, - 0x7C, 0x31, 0xDD, 0x04, 0x81, 0xEB, 0xF8, 0xB5, 0x86, 0xA8, 0x2C, 0x3F, 0xFB, 0x6D, 0xE3, 0x07, - 0xD6, 0xFC, 0xB6, 0xCD, 0x5D, 0x1A, 0xFC, 0x6C, 0x6D, 0x40, 0x0A, 0x39, 0x25, 0xC1, 0x35, 0x21, - 0xF9, 0xE9, 0x86, 0x63, 0x5C, 0x41, 0xDC, 0x59, 0x2C, 0x6C, 0x99, 0xED, 0xCD, 0x36, 0x9E, 0x8F, - 0x79, 0xDB, 0xDA, 0xB5, 0x00, 0xB1, 0xB7, 0xDD, 0x71, 0xD2, 0x07, 0x15, 0x3B, 0x6A, 0xCF, 0xA6, - 0x92, 0xBE, 0xDC, 0x4D, 0x80, 0x32, 0x96, 0x6A, 0xC2, 0x05, 0x76, 0xAC, 0xE0, 0x56, 0x7A, 0x8F, - 0x7B, 0xA2, 0xE4, 0x8E, 0x70, 0xC1, 0xDC, 0x61, 0x21, 0x49, 0xD7, 0xE9, 0x6C, 0x49, 0x66, 0x9F, - 0x89, 0xF9, 0xBC, 0x30, 0x0D, 0x2B, 0x4A, 0x0F, 0x3B, 0x96, 0xB3, 0xDE, 0x04, 0x6D, 0x4C, 0xA7, - 0xD6, 0x3B, 0xD1, 0x39, 0x35, 0x48, 0xC1, 0x62, 0xBF, 0x9F, 0x97, 0x54, 0x8C, 0xD6, 0x37, 0xF9, - 0x42, 0x88, 0x13, 0x3B, 0xB1, 0x8D, 0x29, 0xB1, 0xF3, 0x48, 0xE6, 0xCE, 0x90, 0x11, 0x76, 0x79, - 0xAC, 0xCA, 0xCE, 0xDD, 0x28, 0x65, 0xD1, 0xE0, 0x35, 0x3C, 0xFA, 0x9B, 0xB2, 0x1C, 0xE9, 0xF1, - 0x41, 0xE2, 0x92, 0x4F, 0x6C, 0x70, 0xB0, 0xAC, 0xD4, 0x1B, 0xDA, 0x5C, 0x03, 0x0D, 0xB9, 0x1D, - 0x78, 0x86, 0xB3, 0x20, 0x10, 0x0B, 0x6E, 0x0E, 0xC4, 0x61, 0xFE, 0xC4, 0x40, 0x89, 0x7D, 0x0C, - 0xD5, 0xA3, 0xFC, 0x89, 0x08, 0x0B, 0x08, 0x07, 0x5A, 0x87, 0x1D, 0x54, 0xC8, 0x4A, 0x62, 0xFA, - 0xCD, 0x25, 0xA4, 0x27, 0xB5, 0x0E, 0x96, 0x98, 0x48, 0x3D, 0x27, 0x69, 0x5B, 0xD2, 0x44, 0xBF, - 0x30, 0x34, 0x88, 0x29, 0xDF, 0x7C, 0x5E, 0x34, 0x69, 0x9C, 0xCF, 0x07, 0xDD, 0xC1, 0xB0, 0x30, - 0x73, 0x92, 0x72, 0x99, 0x9A, 0x38, 0x4A, 0x42, 0x47, 0x18, 0x56, 0x72, 0x8D, 0xC0, 0x37, 0xAE, - 0xA4, 0x49, 0xBB, 0xEB, 0x5B, 0x6C, 0xE6, 0x66, 0x4C, 0x7D, 0x98, 0xBB, 0x05, 0x92, 0xA9, 0x17, - 0x37, 0xF4, 0xBE, 0x94, 0x3E, 0x9A, 0xD2, 0x49, 0x5D, 0x40, 0x88, 0x57, 0x4E, 0x76, 0x42, 0x03, - 0xF2, 0x26, 0x31, 0x05, 0x4B, 0x93, 0xCA, 0x80, 0xDC, 0x04, 0x6D, 0x93, 0xCC, 0x5C, 0x8F, 0x65, - 0x83, 0x19, 0x33, 0xC7, 0x94, 0x22, 0x8B, 0x2D, 0xF6, 0x74, 0xE9, 0x5E, 0x11, 0x4F, 0x22, 0xAC, - 0x94, 0x52, 0x87, 0x27, 0x43, 0x53, 0x01, 0x9B, 0x01, 0xC3, 0xA3, 0x54, 0xF6, 0x49, 0x74, 0xFD, - 0xDE, 0xAC, 0x9F, 0xEB, 0xC7, 0x0C, 0x5D, 0x07, 0x7C, 0xC6, 0x98, 0xDA, 0xC4, 0xCC, 0x19, 0xCD, - 0x4C, 0x32, 0x37, 0x36, 0x76, 0x50, 0x60, 0x95, 0x46, 0x17, 0xFF, 0xE5, 0xF5, 0x48, 0xC3, 0xD0, - 0xBF, 0xB0, 0x2E, 0x74, 0x4E, 0x03, 0xC7, 0xBF, 0x25, 0x7D, 0x8A, 0x54, 0xC3, 0x58, 0xAF, 0x89, - 0x01, 0xAD, 0x66, 0x24, 0x4B, 0x0F, 0x4A, 0x53, 0x0C, 0x79, 0x9C, 0x57, 0x9A, 0xB7, 0x17, 0x3A, - 0x6C, 0x98, 0x3C, 0x96, 0xE2, 0xF9, 0x74, 0xEE, 0xCE, 0x36, 0xB2, 0xAC, 0x46, 0xCD, 0xF1, 0xB6, - 0xF1, 0x9D, 0x0A, 0x91, 0xF9, 0xB6, 0x45, 0xDD, 0x7F, 0xE3, 0x38, 0xA8, 0xD1, 0x76, 0xE0, 0x01, - 0x9B, 0x92, 0x8E, 0xD4, 0x04, 0x57, 0x29, 0x86, 0x25, 0x04, 0x9B, 0x55, 0xBB, 0x4A, 0x85, 0x29, - 0x49, 0x38, 0x0D, 0x23, 0xAD, 0x06, 0x31, 0xC4, 0x32, 0x05, 0xAA, 0x7A, 0x72, 0x09, 0x96, 0x9B, - 0x95, 0x2C, 0x8F, 0x12, 0x9D, 0xF5, 0x60, 0xD0, 0x67, 0xDD, 0x79, 0x8B, 0xA9, 0xD1, 0xEA, 0x1E, - 0x74, 0x0F, 0x06, 0xF0, 0x9F, 0x64, 0x3E, 0x93, 0x6F, 0x5C, 0x5C, 0xBC, 0x19, 0x96, 0x97, 0x0A, - 0xD1, 0xC5, 0x65, 0xA5, 0xAC, 0x60, 0x5F, 0xA8, 0x0B, 0x75, 0x4F, 0x4A, 0xD6, 0x97, 0x7A, 0x9D, - 0x82, 0x71, 0x38, 0xC3, 0xA4, 0xCB, 0x1B, 0xA2, 0xC4, 0x5A, 0xCA, 0xAA, 0x78, 0xE5, 0xFE, 0xDE, - 0x66, 0x49, 0xC8, 0xFF, 0x79, 0x6B, 0x8F, 0x89, 0xE2, 0x2F, 0x6D, 0xE9, 0xA5, 0xE5, 0xE2, 0x3F, - 0xB4, 0x6D, 0x74, 0xB3, 0xB5, 0xDE, 0xE6, 0x59, 0x1F, 0x50, 0xE8, 0xC0, 0x1C, 0xD4, 0x83, 0xC9, - 0x68, 0x66, 0x66, 0x18, 0x6B, 0x53, 0x41, 0x06, 0x73, 0xCB, 0xB6, 0xDB, 0xB6, 0x7B, 0x5D, 0x9C, - 0x89, 0xE4, 0x5B, 0xF2, 0x96, 0x9D, 0x16, 0x9B, 0x7C, 0x55, 0x6A, 0x37, 0x10, 0xB9, 0xFE, 0x14, - 0xD4, 0xFE, 0xB5, 0x1D, 0x2E, 0xD7, 0x35, 0xAA, 0x0D, 0x14, 0x15, 0xEC, 0xB1, 0x5E, 0x47, 0x4A, - 0xA6, 0xC4, 0x32, 0xC1, 0xFC, 0x69, 0xCF, 0xB5, 0x15, 0xCC, 0x96, 0x15, 0xA6, 0x9E, 0xD1, 0xC4, - 0xC8, 0x23, 0xB6, 0x81, 0x19, 0x7C, 0xA5, 0x0A, 0x45, 0xE1, 0xF4, 0x2D, 0x0E, 0xAE, 0xC2, 0x09, - 0x15, 0xDD, 0xE3, 0xA9, 0x2E, 0x75, 0x58, 0xEE, 0x90, 0x1D, 0xAB, 0xE5, 0x66, 0x5D, 0x90, 0xEE, - 0x27, 0x3D, 0x43, 0xDE, 0xA8, 0x44, 0x44, 0x17, 0x41, 0x7B, 0xE1, 0x91, 0x5B, 0x05, 0x66, 0x0E, - 0xF8, 0xDF, 0x53, 0x56, 0x3F, 0xAE, 0x5E, 0x2A, 0xA1, 0x03, 0x00, 0xB7, 0xA2, 0xCE, 0xD0, 0x57, - 0xE8, 0x3A, 0xBB, 0x4B, 0x15, 0x7B, 0x0C, 0xAB, 0xA3, 0xBA, 0xAE, 0x10, 0x6E, 0x72, 0x86, 0x50, - 0xB9, 0xA9, 0x8A, 0xD1, 0x57, 0x3E, 0x9F, 0x27, 0xF3, 0x20, 0x63, 0xF1, 0x87, 0xE6, 0xA9, 0x83, - 0xFC, 0xE8, 0xD6, 0x8E, 0x55, 0x53, 0x0A, 0x23, 0x47, 0x58, 0xC4, 0xCC, 0xB6, 0x3E, 0x29, 0x66, - 0x8C, 0x9E, 0xA5, 0x91, 0x67, 0xAB, 0x44, 0xA4, 0xCF, 0x54, 0xCD, 0xD0, 0x66, 0xC5, 0x87, 0x7C, - 0x50, 0x0F, 0xF9, 0xB9, 0xD5, 0x1F, 0x4B, 0xD7, 0x56, 0x72, 0x1A, 0xE7, 0x91, 0x96, 0x59, 0x05, - 0xDC, 0x1E, 0xB2, 0x32, 0x27, 0xC8, 0xF1, 0x58, 0x24, 0x55, 0x54, 0xBE, 0x57, 0xE6, 0x45, 0x98, - 0xED, 0x4A, 0x56, 0xAE, 0xB1, 0x5B, 0x2B, 0x03, 0xD2, 0x5E, 0x34, 0x57, 0x03, 0x30, 0xCA, 0xF4, - 0xA7, 0x62, 0xEE, 0xB1, 0x1A, 0x6B, 0x6F, 0xDC, 0x2D, 0xE8, 0x72, 0x66, 0xBB, 0x7E, 0xCD, 0x02, - 0x58, 0x76, 0xFD, 0x4B, 0x7A, 0x47, 0x69, 0xE8, 0xCE, 0xF5, 0xA9, 0x7C, 0x77, 0x4C, 0xC9, 0xBC, - 0xD7, 0x95, 0x46, 0xDA, 0xDC, 0x2A, 0x25, 0xAD, 0xA0, 0xD1, 0xF5, 0xCB, 0x53, 0x6D, 0x46, 0xE4, - 0x61, 0x34, 0x59, 0xA8, 0x53, 0x29, 0x95, 0xE6, 0xEA, 0x61, 0x69, 0x99, 0x26, 0xC9, 0xAD, 0x05, - 0xE3, 0x9C, 0x57, 0x31, 0x79, 0x40, 0xFA, 0x65, 0x45, 0xA9, 0x9D, 0x38, 0x45, 0xEE, 0xB6, 0x86, - 0xDE, 0xAE, 0x3D, 0x86, 0x0F, 0x34, 0x59, 0x95, 0xF4, 0x64, 0x2A, 0x92, 0x4B, 0xAA, 0xD4, 0xB9, - 0xC3, 0x5A, 0x2B, 0x8A, 0x0C, 0xE4, 0x80, 0xAD, 0xB6, 0xA3, 0x79, 0x8A, 0x2A, 0xBA, 0x90, 0xD2, - 0xE1, 0x6B, 0x4B, 0x7C, 0x19, 0xB0, 0x9D, 0xB5, 0xBA, 0x72, 0x8F, 0x4B, 0x6D, 0xD4, 0x02, 0xD2, - 0xFD, 0x66, 0x8A, 0xE6, 0x81, 0x32, 0xA3, 0x1C, 0x22, 0xC3, 0x21, 0x46, 0x6C, 0xAE, 0x4A, 0xB6, - 0x2A, 0xEB, 0x1C, 0xE1, 0xF9, 0xD9, 0x61, 0x6C, 0x3B, 0xDC, 0xD9, 0x61, 0xB4, 0x73, 0xEF, 0x0C, - 0xF7, 0xC4, 0xC5, 0x77, 0xCD, 0xF1, 0x8E, 0x66, 0xB6, 0xE1, 0xFB, 0xE7, 0x3A, 0xEE, 0xED, 0xD2, - 0x93, 0x9B, 0xE8, 0xCE, 0x4C, 0xEB, 0x4A, 0xB3, 0xCC, 0x73, 0xDD, 0x76, 0x17, 0x6E, 0xEA, 0x1E, - 0xBD, 0xCF, 0xD4, 0x0C, 0x03, 0xD9, 0xB9, 0x9E, 0x58, 0x60, 0xD4, 0x29, 0x54, 0x74, 0x49, 0x9F, - 0x3C, 0xFB, 0xE2, 0xE4, 0xE8, 0x68, 0xFC, 0xD5, 0x33, 0x67, 0xEA, 0xAF, 0xF9, 0xFF, 0x3F, 0xB0, - 0xF5, 0x58, 0xB6, 0xA9, 0x0F, 0xC6, 0xB6, 0x20, 0x00, 0xDB, 0xF3, 0xCF, 0x0E, 0x29, 0xD2, 0x14, - 0x21, 0x87, 0x40, 0x49, 0x06, 0x6D, 0x3C, 0xDF, 0x91, 0x91, 0x27, 0x9A, 0xF8, 0x30, 0x84, 0x4F, - 0x0D, 0x4F, 0xD2, 0x84, 0x36, 0x63, 0xD9, 0x34, 0x8D, 0x25, 0x3A, 0x55, 0xCA, 0xD4, 0xBD, 0x49, - 0x73, 0x40, 0x99, 0xE2, 0x1A, 0xE3, 0xAD, 0x88, 0x99, 0x85, 0x10, 0xC0, 0x28, 0x38, 0xAE, 0xAE, - 0x42, 0x1B, 0x69, 0xA3, 0x84, 0x0A, 0xB0, 0xF1, 0xCD, 0xCC, 0xFE, 0x2C, 0x94, 0xAF, 0x0B, 0xA5, - 0x38, 0x6E, 0xC0, 0x62, 0x65, 0x46, 0x57, 0x09, 0x56, 0x39, 0x4C, 0x6C, 0xDD, 0x90, 0x71, 0x01, - 0xA2, 0x6D, 0x53, 0xEC, 0xEC, 0x5A, 0x3E, 0x26, 0x8A, 0x2D, 0xA6, 0x57, 0x01, 0xAC, 0x4F, 0x7E, - 0xBE, 0xF8, 0xF6, 0xEF, 0xDA, 0xBB, 0xB7, 0xBF, 0x4B, 0x35, 0x54, 0x44, 0x14, 0x06, 0x69, 0x85, - 0x9E, 0x29, 0x18, 0xD3, 0x87, 0x90, 0x89, 0xCE, 0x35, 0x43, 0x31, 0xE0, 0x70, 0x6F, 0x13, 0x67, - 0x11, 0x2C, 0xCF, 0xF5, 0x9E, 0x8E, 0x7B, 0x5A, 0xC4, 0x59, 0x5F, 0xD7, 0x30, 0x80, 0xD3, 0x83, - 0x2B, 0xC3, 0xDE, 0xE0, 0x51, 0x57, 0x85, 0xD7, 0x6D, 0xD3, 0x92, 0x36, 0xE3, 0x91, 0x25, 0x94, - 0x71, 0x2C, 0x12, 0x27, 0xA5, 0xAC, 0x4F, 0x3E, 0x90, 0xE0, 0xEC, 0x90, 0xDD, 0x2A, 0xD0, 0x5A, - 0x7E, 0xDF, 0xE0, 0xC9, 0xCC, 0x1C, 0xF2, 0x4C, 0x28, 0x4F, 0xF1, 0x73, 0xCF, 0x58, 0x11, 0x94, - 0x8A, 0x92, 0xE6, 0xE3, 0x5A, 0x0F, 0x21, 0xF5, 0xC9, 0xF7, 0x84, 0x66, 0x44, 0x40, 0x86, 0x92, - 0xE2, 0xCF, 0x78, 0x92, 0x9A, 0xE8, 0x3F, 0xB4, 0x67, 0xBE, 0x28, 0xD5, 0x36, 0x98, 0x99, 0x2B, - 0xC8, 0xFD, 0x69, 0xBB, 0xAD, 0x0D, 0xDE, 0xBD, 0xD7, 0xDA, 0x6D, 0x85, 0xC6, 0xEE, 0x9A, 0xBA, - 0x13, 0xD7, 0x7F, 0xEF, 0x48, 0x9F, 0xFC, 0xF3, 0xE7, 0x37, 0x2F, 0x5B, 0xFD, 0xEE, 0xF0, 0xF8, - 0xA6, 0x37, 0x1A, 0x0F, 0xF7, 0xCF, 0x0E, 0x59, 0x93, 0xF2, 0xB8, 0xC6, 0xFA, 0xE4, 0x3D, 0x12, - 0xD2, 0x3A, 0x1E, 0x0F, 0xEB, 0xE2, 0x1A, 0x21, 0xAE, 0xB7, 0xAF, 0x5B, 0x47, 0xFD, 0xEE, 0x4D, - 0xAF, 0x7F, 0xDC, 0xAD, 0x81, 0x6A, 0xA8, 0x4F, 0xBE, 0x06, 0x4C, 0xBD, 0x13, 0x44, 0xD5, 0x2D, - 0x87, 0x0A, 0x45, 0xDB, 0xAF, 0x28, 0xDA, 0x81, 0x3E, 0xF9, 0x11, 0x45, 0x0B, 0x39, 0x37, 0xF2, - 0xD0, 0xAD, 0xC3, 0x43, 0x1F, 0x5C, 0x86, 0xE2, 0x02, 0x51, 0x00, 0x13, 0xFD, 0x3A, 0xA2, 0xED, - 0xE9, 0x13, 0x14, 0x07, 0x62, 0x02, 0xE9, 0xD6, 0x40, 0x04, 0xB1, 0x83, 0xD2, 0x04, 0xE4, 0xDC, - 0x1C, 0x8D, 0x8F, 0xAB, 0x63, 0x3A, 0x01, 0xEE, 0x3E, 0x02, 0xA6, 0x63, 0x10, 0xD4, 0xB8, 0x8E, - 0x9C, 0x8E, 0xF5, 0x09, 0xE2, 0x19, 0x0F, 0xBB, 0x37, 0xC3, 0x3A, 0x36, 0x03, 0x5E, 0xF1, 0x16, - 0x11, 0x01, 0x92, 0x9B, 0x41, 0x1D, 0x19, 0x81, 0x4B, 0x5C, 0x7C, 0xF3, 0x75, 0x6B, 0x08, 0x8C, - 0xF5, 0x4F, 0xC6, 0xD5, 0xF1, 0x80, 0x3B, 0xFC, 0x13, 0x09, 0x02, 0x62, 0x6E, 0xFA, 0xC3, 0x1A, - 0x04, 0x81, 0x33, 0x00, 0x3C, 0xE2, 0xA8, 0x8C, 0x02, 0xEC, 0xFA, 0x2D, 0x25, 0x06, 0x11, 0xF5, - 0x8E, 0x6A, 0x70, 0x05, 0x56, 0xFD, 0x4F, 0x14, 0x0F, 0x20, 0xB9, 0xE9, 0x0D, 0xEB, 0xD8, 0x34, - 0x20, 0xA2, 0x24, 0x81, 0xAF, 0xA1, 0xAB, 0x55, 0xC7, 0x04, 0x36, 0x7D, 0x32, 0xBE, 0x39, 0x19, - 0xAB, 0x21, 0xC0, 0xE1, 0x07, 0x43, 0x79, 0xDE, 0x00, 0x95, 0x3F, 0x7E, 0xE5, 0x8D, 0x4D, 0xFF, - 0xD9, 0xC0, 0x94, 0x33, 0xB8, 0x2D, 0x3D, 0x32, 0x71, 0x38, 0x90, 0x09, 0x3B, 0x50, 0x1B, 0x94, - 0x62, 0x94, 0x84, 0xBB, 0x9F, 0xF4, 0xC9, 0x50, 0x61, 0xF0, 0x4F, 0x64, 0x87, 0x14, 0x36, 0x41, - 0x3F, 0xCD, 0x48, 0xD0, 0xF2, 0x30, 0x17, 0x01, 0x97, 0x18, 0xE8, 0xB1, 0x08, 0x52, 0x69, 0xD4, - 0x93, 0xD0, 0x6A, 0xDC, 0xE8, 0x93, 0xF1, 0xA0, 0x30, 0x5B, 0xA8, 0xAE, 0x8C, 0x29, 0x2D, 0x6E, - 0x38, 0xC4, 0xF7, 0x4B, 0xEB, 0x23, 0x02, 0xD5, 0x27, 0xAF, 0xC2, 0xE3, 0x3A, 0x5A, 0x69, 0x17, - 0x71, 0x4A, 0x61, 0x33, 0xD4, 0x12, 0x23, 0x87, 0x69, 0xA6, 0x3D, 0xE0, 0xAA, 0x89, 0x34, 0x73, - 0xBF, 0x8A, 0xD9, 0xA5, 0x5E, 0x70, 0x6E, 0xE3, 0x19, 0x7E, 0x50, 0x5A, 0x2B, 0x02, 0x10, 0x22, - 0x34, 0x3F, 0x7A, 0x30, 0x8D, 0x84, 0xA4, 0xFC, 0x05, 0xF4, 0xE1, 0x1B, 0xC1, 0x86, 0xED, 0x33, - 0x2B, 0xAD, 0x91, 0x08, 0x14, 0xF2, 0x81, 0xF0, 0xB8, 0x96, 0x56, 0xEA, 0x84, 0xAF, 0x18, 0x39, - 0x5C, 0x2F, 0x22, 0x84, 0x0D, 0x77, 0xA4, 0x97, 0x22, 0x6A, 0x6B, 0xE9, 0x65, 0x69, 0x78, 0xEB, - 0x4A, 0xE1, 0x2B, 0x84, 0x04, 0xAD, 0x88, 0xC3, 0x07, 0x73, 0x95, 0x88, 0x98, 0xBF, 0x80, 0xAF, - 0x98, 0xC4, 0x71, 0x2D, 0xBF, 0xFC, 0xD4, 0x93, 0xC3, 0xE9, 0x93, 0xD7, 0xA4, 0xFD, 0x1D, 0x1E, - 0xD5, 0x51, 0xC7, 0xCB, 0x4D, 0xE0, 0xD6, 0x50, 0x88, 0xA0, 0x85, 0xA9, 0xA3, 0xCB, 0xB5, 0x71, - 0xBC, 0x23, 0x6D, 0x1C, 0xEF, 0x50, 0x1B, 0x06, 0xF9, 0x64, 0x93, 0x2B, 0x62, 0x97, 0x56, 0x87, - 0x00, 0xD4, 0x27, 0x97, 0x37, 0x6B, 0xD7, 0xC7, 0xA7, 0x77, 0xBE, 0xC5, 0xF3, 0x5A, 0x4E, 0x32, - 0xAA, 0xA1, 0x93, 0x90, 0x20, 0xEE, 0x23, 0x23, 0xAE, 0x95, 0xD1, 0x8E, 0xB4, 0x52, 0x44, 0x6B, - 0x1D, 0xAD, 0x2C, 0x0C, 0xCB, 0x99, 0x11, 0xCB, 0xC6, 0x27, 0x09, 0xCA, 0x2A, 0x26, 0x06, 0xAB, - 0x4F, 0xDE, 0x44, 0x27, 0x75, 0x14, 0xD3, 0xAD, 0xA1, 0x97, 0x38, 0x3D, 0x49, 0x7F, 0x19, 0xC1, - 0xAC, 0x7C, 0x47, 0xBA, 0xE9, 0xF5, 0x76, 0x39, 0xAA, 0xAC, 0xC9, 0xCC, 0x32, 0xEC, 0x4F, 0x64, - 0x3E, 0x87, 0x69, 0x50, 0xF9, 0xA1, 0x25, 0x01, 0x0E, 0xE3, 0x0B, 0x3B, 0xD7, 0x2E, 0xE9, 0x79, - 0xE9, 0x62, 0x5A, 0x0A, 0x5D, 0xF5, 0x8A, 0x5A, 0x7A, 0x4E, 0xC8, 0x97, 0x95, 0x09, 0xAD, 0x61, - 0xB2, 0x23, 0x7D, 0xF2, 0x9D, 0x1B, 0xD2, 0x59, 0x7D, 0xDA, 0xFA, 0x1D, 0x59, 0xD0, 0x55, 0xDB, - 0x3A, 0x73, 0xE8, 0x37, 0x9E, 0x71, 0x4B, 0x5F, 0x0B, 0x50, 0x67, 0x4A, 0xFF, 0x3D, 0x31, 0xB5, - 0x1F, 0x2C, 0xA7, 0x3A, 0x33, 0x43, 0x24, 0x84, 0x10, 0xA7, 0x1E, 0x96, 0x11, 0x4C, 0x91, 0xE0, - 0xA0, 0x1E, 0x92, 0x31, 0x16, 0x98, 0xD7, 0x96, 0xF1, 0x18, 0x26, 0xF1, 0xC6, 0xF5, 0xB4, 0xFC, - 0x80, 0x72, 0x3D, 0x85, 0x71, 0xF9, 0xA7, 0x57, 0xDA, 0x25, 0xDD, 0x67, 0x5C, 0x3A, 0x5C, 0xB1, - 0x2D, 0x50, 0x2A, 0x86, 0x1E, 0xAD, 0x23, 0x60, 0x9F, 0x5B, 0x0B, 0x3C, 0x72, 0x07, 0x52, 0x5D, - 0xE4, 0x91, 0xB0, 0x27, 0x08, 0xA4, 0x3B, 0x46, 0xF4, 0x18, 0xB7, 0x6A, 0x3C, 0xEE, 0x30, 0x15, - 0x9B, 0x5D, 0x97, 0x4F, 0xC3, 0x66, 0xD7, 0xA0, 0x26, 0xF3, 0x0A, 0xB7, 0xA0, 0x9B, 0x1A, 0xE8, - 0xAB, 0x11, 0x45, 0x61, 0xAF, 0x0F, 0xA3, 0x28, 0xCA, 0xEF, 0x43, 0x2B, 0x0A, 0xAC, 0xE5, 0x13, - 0x8E, 0xA3, 0x55, 0x9C, 0x8A, 0x02, 0xEA, 0x93, 0x77, 0x86, 0xB3, 0x81, 0x41, 0xA6, 0x29, 0x85, - 0x85, 0x1D, 0x3F, 0x98, 0x7B, 0x71, 0xBE, 0x1F, 0x5A, 0x75, 0x40, 0xC8, 0xCA, 0x35, 0xCB, 0x4F, - 0x77, 0x38, 0x1C, 0x0B, 0x89, 0xEF, 0xE0, 0xA8, 0x74, 0x62, 0x20, 0x30, 0xEC, 0x38, 0x23, 0x60, - 0x53, 0xA9, 0xEA, 0xC9, 0xC0, 0x87, 0x8D, 0xE3, 0xDC, 0xD6, 0xC9, 0x04, 0x2E, 0x6C, 0x77, 0x63, - 0x56, 0xC7, 0x00, 0x69, 0xC0, 0x3F, 0xE6, 0x73, 0x6B, 0x56, 0x3D, 0x91, 0x80, 0x24, 0xE0, 0xAD, - 0xBB, 0x52, 0x84, 0xDF, 0xF1, 0xC0, 0x4B, 0x66, 0x15, 0x66, 0x72, 0x33, 0xD0, 0xE2, 0xE5, 0x45, - 0xA3, 0x03, 0x2F, 0xF4, 0xF9, 0x40, 0x91, 0x01, 0xB9, 0x7D, 0xE8, 0xA0, 0x00, 0x44, 0x7C, 0xA2, - 0xC6, 0x53, 0x45, 0x59, 0x0C, 0x32, 0x8C, 0xE8, 0x62, 0xFA, 0xFD, 0x50, 0xF3, 0xBB, 0x88, 0xA2, - 0xE4, 0xEC, 0xAE, 0x37, 0x1A, 0x8C, 0xC3, 0xE9, 0xDD, 0xA0, 0x7F, 0xBF, 0x13, 0x3C, 0x44, 0xBE, - 0x5B, 0xFD, 0xF4, 0xAB, 0xA8, 0x06, 0xA2, 0xD1, 0x77, 0xB8, 0xCE, 0x50, 0x22, 0x60, 0xD7, 0x77, - 0xA4, 0xFE, 0xC3, 0x79, 0x52, 0xFF, 0x11, 0xB8, 0xD2, 0xA2, 0x42, 0xC4, 0x5B, 0x60, 0xC4, 0x7B, - 0x73, 0xD1, 0x8C, 0x86, 0x16, 0x0F, 0x16, 0xEA, 0x16, 0x0F, 0x1A, 0xEA, 0x34, 0xBE, 0x43, 0x4D, - 0x48, 0xA1, 0x62, 0x06, 0xCB, 0x01, 0x59, 0x2D, 0xAB, 0x4E, 0x90, 0xEB, 0xDD, 0xD4, 0x89, 0x72, - 0x82, 0x8C, 0x64, 0x90, 0x1B, 0x47, 0xAB, 0x22, 0xA3, 0xFB, 0x5D, 0xD6, 0x1D, 0x16, 0x51, 0x5B, - 0xC7, 0x69, 0x3C, 0xE3, 0xFA, 0xD3, 0x62, 0x65, 0x94, 0x56, 0x06, 0x87, 0x03, 0x5D, 0xBC, 0x7B, - 0xD9, 0x64, 0xBA, 0x20, 0xFA, 0x7D, 0x18, 0x3F, 0x0A, 0xB9, 0x7E, 0xE8, 0x58, 0x67, 0x13, 0xA7, - 0x7C, 0xB0, 0x43, 0x20, 0x7D, 0xF2, 0x2D, 0x71, 0x7C, 0xED, 0xC2, 0xF5, 0xF8, 0xBB, 0x18, 0x1B, - 0xD1, 0x1A, 0xED, 0xF9, 0x61, 0x54, 0xC6, 0x98, 0x7E, 0x68, 0x7D, 0x2D, 0x57, 0x96, 0xE7, 0xB9, - 0x5E, 0x69, 0x95, 0x71, 0x38, 0x98, 0x56, 0xB4, 0xDF, 0xD1, 0xA3, 0x46, 0xD4, 0x25, 0x7A, 0x7D, - 0x18, 0x8D, 0x85, 0x3C, 0x3F, 0xB4, 0xD2, 0xAE, 0xE6, 0xB6, 0xB5, 0x2E, 0xAD, 0x32, 0x0A, 0xA5, - 0x4F, 0x3E, 0xB6, 0xBF, 0x86, 0xBF, 0x8D, 0xA8, 0x8B, 0xF5, 0xF8, 0x30, 0xCA, 0xE2, 0xDC, 0x3E, - 0xB4, 0xAA, 0xA6, 0xEB, 0xF2, 0xE1, 0x10, 0x60, 0xF4, 0xC9, 0xAB, 0xF7, 0xCD, 0xE4, 0x7E, 0xD8, - 0x99, 0xA2, 0x86, 0x6A, 0xE9, 0x83, 0x32, 0xF5, 0xD0, 0xDA, 0xB8, 0xAE, 0xA0, 0x8D, 0x6B, 0x24, - 0xFC, 0xA7, 0x86, 0xB4, 0x71, 0xAD, 0xAE, 0x8D, 0x7B, 0xF6, 0x97, 0xEB, 0xC7, 0xA0, 0x1F, 0xFA, - 0xB0, 0xDF, 0xD4, 0x28, 0x3F, 0x1C, 0x09, 0x40, 0xDC, 0x34, 0x06, 0x47, 0xDA, 0x2B, 0xA3, 0x99, - 0x01, 0x29, 0xEC, 0xB7, 0x09, 0x17, 0x8A, 0x98, 0x7C, 0x68, 0x3D, 0xD9, 0xC4, 0xAC, 0x90, 0xE4, - 0x99, 0x9F, 0xF0, 0xC9, 0x39, 0x7C, 0xA2, 0xFC, 0x16, 0xB2, 0xBD, 0xCB, 0xD7, 0xDA, 0x37, 0xE2, - 0xF4, 0xA1, 0x0A, 0x43, 0x49, 0x9A, 0x92, 0xF3, 0xA6, 0xFE, 0x68, 0x57, 0xDB, 0x32, 0x00, 0xF3, - 0x0E, 0x75, 0x33, 0x37, 0x66, 0xE4, 0x93, 0x49, 0x82, 0x2A, 0xEB, 0xFE, 0x31, 0x58, 0x7D, 0xF2, - 0x35, 0x9C, 0x68, 0xAF, 0xE9, 0x49, 0x53, 0xE9, 0x78, 0xBC, 0xFF, 0x26, 0x3C, 0x2A, 0xC1, 0xEF, - 0x43, 0x3B, 0x15, 0x25, 0x06, 0x26, 0x3F, 0xEE, 0xC2, 0xA9, 0xF4, 0xDC, 0x53, 0x02, 0x9C, 0xAB, - 0xEF, 0x7B, 0x76, 0xDE, 0xAC, 0x02, 0x23, 0x22, 0x1A, 0xD3, 0x61, 0x8C, 0xEF, 0x26, 0xD4, 0x18, - 0x7F, 0xF8, 0x91, 0xBF, 0x36, 0xB8, 0x48, 0x53, 0xFC, 0x21, 0x3C, 0xBA, 0xDD, 0x88, 0x04, 0x6D, - 0x3F, 0xB0, 0x6C, 0x5B, 0x9F, 0xBC, 0x21, 0x81, 0xF6, 0x01, 0x0F, 0x15, 0x9F, 0xBA, 0x8B, 0x61, - 0x11, 0xCF, 0xDC, 0x06, 0x1E, 0x31, 0x56, 0xFA, 0xE4, 0x03, 0xBE, 0x50, 0x19, 0x70, 0xE1, 0x59, - 0x79, 0x64, 0x54, 0x88, 0xC4, 0xF1, 0x5C, 0x20, 0x2A, 0x54, 0x12, 0x7F, 0x51, 0xA3, 0xAE, 0x89, - 0xA3, 0xD8, 0xB5, 0xC9, 0x25, 0x6D, 0xAC, 0xA1, 0x95, 0x15, 0x77, 0x17, 0x7F, 0x1C, 0x30, 0xDF, - 0x39, 0xE8, 0x03, 0xC0, 0xF8, 0x44, 0x6F, 0xF2, 0x7D, 0xEB, 0xA0, 0x56, 0xF6, 0x7C, 0xFF, 0xE4, - 0xCC, 0x5F, 0x1B, 0x8E, 0x68, 0x46, 0x1F, 0x7E, 0xBF, 0xE6, 0x4F, 0x33, 0x4F, 0x5D, 0xDB, 0xFC, - 0x2A, 0xB6, 0xF0, 0xFF, 0x21, 0x7C, 0x2C, 0x17, 0x41, 0xC0, 0x2E, 0x04, 0x86, 0x02, 0xE5, 0x2E, - 0x3D, 0x81, 0x9E, 0x3D, 0x41, 0x8D, 0x6F, 0xEB, 0xCA, 0xD1, 0x6E, 0xC6, 0x93, 0xC4, 0x1E, 0x59, - 0x84, 0x92, 0x94, 0x3D, 0x61, 0x2E, 0x7D, 0xAE, 0xF8, 0x7B, 0xB2, 0xB0, 0x7C, 0xA0, 0x51, 0x03, - 0xBB, 0x38, 0xA4, 0xCF, 0x62, 0x32, 0x5B, 0x56, 0x7B, 0xCE, 0x37, 0xDE, 0x25, 0x7F, 0x4D, 0x81, - 0xF4, 0xF1, 0xED, 0x52, 0xA9, 0x63, 0xFA, 0x59, 0xEB, 0x24, 0xC6, 0x22, 0xAB, 0x7F, 0xDA, 0x6E, - 0x2F, 0x87, 0xF8, 0x54, 0xA9, 0x26, 0x58, 0x3B, 0x3B, 0x5C, 0x0E, 0x8B, 0x9E, 0xDA, 0x2B, 0x7C, - 0x24, 0x18, 0x38, 0xAD, 0xFC, 0x44, 0x30, 0x4A, 0x69, 0x02, 0xD4, 0x1C, 0x68, 0xEF, 0x0C, 0xFF, - 0xF3, 0x81, 0xF6, 0x11, 0x87, 0xF8, 0x06, 0x1F, 0x0C, 0x46, 0xDA, 0x0D, 0xD3, 0xF4, 0x32, 0x1F, - 0x0E, 0x1E, 0x26, 0x1E, 0x0E, 0x1E, 0x8B, 0x87, 0x83, 0xA3, 0x95, 0xAA, 0xEE, 0xCD, 0xA0, 0xDB, - 0x3D, 0x56, 0x61, 0x5D, 0xF1, 0x01, 0xE1, 0x7B, 0xE1, 0x69, 0x05, 0xD2, 0x54, 0xE4, 0x69, 0x28, - 0x78, 0x8A, 0x6D, 0xD8, 0xBF, 0x99, 0xCF, 0x1F, 0x1B, 0x47, 0x7C, 0xC9, 0xB0, 0x3A, 0x4B, 0xDD, - 0x7E, 0xD3, 0x4F, 0x71, 0x53, 0xE3, 0xBE, 0xAF, 0x87, 0xB8, 0x69, 0x93, 0x74, 0x34, 0x1C, 0xE5, - 0x06, 0x43, 0x0A, 0xC2, 0x9C, 0xFE, 0xCD, 0x7D, 0x3A, 0xFD, 0xA2, 0x86, 0xD3, 0x2F, 0xB6, 0x9C, - 0xBE, 0x41, 0x6F, 0x17, 0x84, 0xFF, 0xD5, 0x3C, 0x5E, 0xF0, 0x55, 0xC2, 0xEB, 0xA5, 0x7C, 0x75, - 0xBB, 0xF7, 0xEA, 0xF7, 0x85, 0x4E, 0x12, 0x1A, 0xC3, 0x9B, 0xFB, 0x74, 0x92, 0x0C, 0xD3, 0xAD, - 0x64, 0xA7, 0x3C, 0xEC, 0x4C, 0x9A, 0x19, 0x97, 0x68, 0x36, 0x15, 0x57, 0x28, 0xEF, 0x1D, 0x1F, - 0xD7, 0x1D, 0x0C, 0x79, 0xEA, 0x74, 0x1F, 0xEA, 0x51, 0x7F, 0x61, 0x44, 0x66, 0x93, 0xFB, 0x49, - 0xCC, 0xD6, 0xB1, 0x14, 0x57, 0x39, 0x31, 0x7B, 0xFF, 0xED, 0xB7, 0xE5, 0x72, 0xB1, 0x78, 0x2F, - 0x8F, 0x24, 0x17, 0xCB, 0x2D, 0x53, 0xDF, 0xAE, 0xE1, 0x06, 0x52, 0x5D, 0xC9, 0x74, 0x23, 0x70, - 0x7D, 0xF2, 0x8A, 0x1E, 0x6B, 0x31, 0x89, 0x95, 0x32, 0x5E, 0xE5, 0x59, 0x27, 0x05, 0x8C, 0xD5, - 0xB1, 0x23, 0x12, 0xD2, 0xBA, 0x51, 0xC4, 0x95, 0x53, 0xBB, 0x8E, 0xB1, 0xA7, 0xCE, 0x54, 0x6D, - 0x9F, 0xA0, 0x4D, 0x8A, 0x52, 0xE1, 0xD5, 0xC6, 0xAE, 0xAC, 0x36, 0x0E, 0xAB, 0x4F, 0xDE, 0xC1, - 0x64, 0xDC, 0x5A, 0xDB, 0x16, 0xCC, 0x3C, 0x5A, 0x5D, 0xAD, 0xAD, 0x0D, 0x7A, 0xFB, 0x0D, 0x8E, - 0x91, 0x82, 0x8C, 0x92, 0x6F, 0xCB, 0xE9, 0x45, 0x0F, 0xB3, 0x0D, 0xEE, 0xE9, 0x75, 0x39, 0x75, - 0x15, 0xE2, 0xB9, 0x6E, 0x50, 0x59, 0x1B, 0x02, 0x18, 0x12, 0x15, 0x38, 0xD2, 0x22, 0x9D, 0xA8, - 0xAB, 0x22, 0xB6, 0xB5, 0x36, 0xC2, 0xA6, 0xA6, 0x0E, 0xA5, 0x8D, 0xB4, 0xB8, 0x3F, 0x45, 0x75, - 0x07, 0xAA, 0x04, 0x6B, 0x4F, 0x9F, 0xF4, 0x4B, 0x60, 0x28, 0xDE, 0x87, 0xCA, 0x5A, 0xD5, 0x77, - 0x22, 0xFF, 0xB6, 0x7A, 0xEC, 0xE3, 0xB0, 0x90, 0x76, 0xDF, 0x42, 0xAA, 0xBB, 0xD2, 0x5E, 0x43, - 0x5F, 0xD4, 0x89, 0x7A, 0xA3, 0x26, 0x9D, 0x48, 0x90, 0x51, 0xDD, 0x89, 0x7A, 0x8F, 0xC3, 0x87, - 0x50, 0x1F, 0x6B, 0x8F, 0x54, 0xD6, 0x07, 0x87, 0xD5, 0x27, 0xEF, 0x3D, 0x82, 0xCA, 0xA8, 0xE4, - 0x3D, 0x21, 0x92, 0x6A, 0xCE, 0x73, 0x0F, 0x8E, 0xD2, 0xEB, 0x8C, 0xEA, 0xE1, 0xE8, 0x97, 0x73, - 0x36, 0x09, 0x86, 0x81, 0x3C, 0x08, 0x0C, 0x1E, 0xA7, 0x0B, 0x13, 0xDB, 0x1C, 0x55, 0x77, 0x62, - 0x01, 0x8D, 0xB3, 0x67, 0x38, 0xAC, 0x6C, 0x38, 0x31, 0x44, 0x8F, 0x2A, 0xEE, 0xD6, 0xC4, 0x70, - 0x1F, 0xC6, 0x34, 0xE9, 0x97, 0x32, 0xE9, 0x66, 0x4C, 0x67, 0x8D, 0x2F, 0x17, 0x24, 0x6A, 0x7B, - 0x3E, 0x29, 0xB2, 0x78, 0xA4, 0x61, 0xB0, 0x10, 0x69, 0xE8, 0xEB, 0x05, 0xE9, 0x5E, 0xF7, 0x46, - 0x73, 0x5E, 0x41, 0xC0, 0xF6, 0x32, 0x4B, 0xF9, 0xAD, 0x01, 0x31, 0xE6, 0x64, 0x29, 0x70, 0xC8, - 0xEB, 0x63, 0xCB, 0x7F, 0x29, 0x61, 0x95, 0xC7, 0x0A, 0x0E, 0xCC, 0x55, 0x18, 0x0E, 0xDD, 0xCD, - 0xE6, 0xBF, 0x21, 0x15, 0x35, 0xC6, 0xEE, 0x06, 0x13, 0xE0, 0xD8, 0xB2, 0x11, 0x55, 0x00, 0x0B, - 0x9A, 0x01, 0x9B, 0xF1, 0x95, 0x58, 0x09, 0xCA, 0x6C, 0x72, 0x3F, 0xF3, 0xFC, 0x6B, 0xCB, 0x29, - 0x3F, 0xCF, 0xFF, 0xC9, 0x72, 0x4C, 0xF7, 0xBA, 0xDC, 0x54, 0x3F, 0xDE, 0xD1, 0x9F, 0x60, 0xAA, - 0x4F, 0x07, 0x4B, 0x5C, 0x2C, 0x6C, 0x7B, 0x44, 0xED, 0xA5, 0x33, 0x69, 0x21, 0x33, 0xE8, 0x1B, - 0x5C, 0x6A, 0x03, 0x14, 0xBE, 0x46, 0x97, 0x1E, 0x77, 0xED, 0x2F, 0x3F, 0x9F, 0xC6, 0x93, 0x5D, - 0x4E, 0x81, 0x9A, 0xC3, 0x0C, 0x25, 0x85, 0xC7, 0x07, 0xAF, 0xA5, 0xFE, 0xB2, 0xCD, 0xCF, 0xED, - 0x83, 0xF3, 0x73, 0x1F, 0x01, 0x99, 0x38, 0x66, 0x65, 0xCB, 0x42, 0xD8, 0xC8, 0xAE, 0x2E, 0x1D, - 0xB3, 0x51, 0xAB, 0x62, 0xBD, 0x57, 0xD6, 0x41, 0xBF, 0x7B, 0x74, 0xF2, 0xB8, 0xCC, 0x0A, 0x19, - 0xAA, 0x61, 0x54, 0xBD, 0xD1, 0xF0, 0xE8, 0xF1, 0xD8, 0x95, 0x3B, 0x9F, 0xB3, 0x15, 0xAE, 0x6A, - 0xA6, 0xC5, 0xC1, 0x6F, 0xE8, 0xA3, 0xB4, 0x3E, 0x69, 0x36, 0x5E, 0x85, 0x9D, 0xAB, 0xE9, 0x62, - 0x20, 0xD1, 0xC5, 0xF8, 0x71, 0x99, 0x16, 0xE7, 0x48, 0xD5, 0xBA, 0x24, 0x1C, 0xDD, 0x13, 0x43, - 0xF7, 0x61, 0x5A, 0x81, 0x1B, 0x18, 0x76, 0x65, 0xCB, 0x62, 0xD0, 0x60, 0x58, 0x3F, 0xE0, 0x81, - 0xF6, 0x01, 0xF8, 0x6C, 0xD4, 0xB8, 0x44, 0xFF, 0xD5, 0x03, 0xD7, 0xA0, 0xFB, 0xC8, 0xC6, 0x43, - 0xC6, 0x52, 0xAD, 0xD0, 0x35, 0x1E, 0x3E, 0x1E, 0xFB, 0x72, 0x37, 0x01, 0x5E, 0xAD, 0x1C, 0xBA, - 0x18, 0x38, 0x86, 0x2E, 0x7A, 0xD4, 0xBC, 0x89, 0x85, 0x14, 0xD4, 0x18, 0x1C, 0x87, 0x0F, 0xBF, - 0x7E, 0xFD, 0x8B, 0x84, 0xA7, 0x5A, 0x46, 0x36, 0x78, 0x2C, 0x41, 0x6C, 0x66, 0x28, 0xBF, 0x88, - 0x8D, 0x22, 0x8B, 0x67, 0xF3, 0x0C, 0x16, 0xE6, 0x70, 0xEC, 0xA0, 0xD1, 0x0A, 0x86, 0xE8, 0xFC, - 0xDE, 0x97, 0xEC, 0x42, 0xAE, 0x1E, 0x53, 0xBD, 0x62, 0x6A, 0x39, 0x4E, 0x55, 0x35, 0x71, 0x58, - 0x7D, 0xF2, 0x8A, 0x1D, 0x34, 0xBB, 0xB8, 0xCA, 0x3B, 0xBF, 0xFF, 0x95, 0x55, 0xC1, 0x55, 0xD3, - 0x6A, 0x4A, 0x15, 0x31, 0xBC, 0xF0, 0x4B, 0x11, 0x3A, 0xDF, 0xAD, 0x18, 0x7D, 0x39, 0xE2, 0xF1, - 0x94, 0x34, 0x16, 0xC6, 0x0A, 0x9F, 0x30, 0x2E, 0x5B, 0xD4, 0x78, 0x83, 0x60, 0xE5, 0x6A, 0x1A, - 0xC9, 0x9E, 0x1E, 0x77, 0x55, 0x63, 0x92, 0x7C, 0xB5, 0x24, 0x10, 0xDE, 0x9E, 0x5A, 0x86, 0x8F, - 0x4F, 0xE3, 0xC3, 0xB1, 0xF6, 0x0A, 0x8E, 0xB5, 0xF7, 0xF6, 0x26, 0x7C, 0x37, 0xAE, 0xCC, 0x21, - 0xE2, 0x3B, 0x9B, 0x22, 0x0C, 0x59, 0xDB, 0xD7, 0xE9, 0x86, 0x2E, 0xFE, 0x14, 0x16, 0x1C, 0xE3, - 0x3E, 0xA6, 0xD1, 0xF0, 0xB8, 0xAB, 0x6B, 0x2C, 0x2B, 0xE6, 0xCF, 0x90, 0xF8, 0x9F, 0xE9, 0x06, - 0xA7, 0x5E, 0x48, 0xA0, 0xCC, 0x01, 0xE2, 0xF4, 0x86, 0x04, 0x52, 0xFB, 0xAD, 0xB3, 0xEF, 0x68, - 0x5B, 0x22, 0x3D, 0x21, 0x8E, 0xAE, 0xD4, 0x10, 0x12, 0x2F, 0xC3, 0x64, 0xED, 0x55, 0x9E, 0x86, - 0x91, 0x0B, 0xA2, 0x27, 0x15, 0x04, 0xEE, 0xF3, 0xBA, 0x5F, 0x9E, 0xFA, 0x82, 0xA7, 0x9E, 0x1A, - 0x4F, 0xFD, 0x1A, 0x3C, 0xF5, 0x1B, 0xE2, 0x69, 0x20, 0x78, 0xEA, 0xAB, 0xF1, 0x34, 0xA8, 0xC1, - 0xD3, 0xA0, 0x21, 0x9E, 0x86, 0x82, 0xA7, 0x81, 0x1A, 0x4F, 0xC3, 0x1A, 0x3C, 0x0D, 0x1B, 0xE2, - 0x69, 0x24, 0x78, 0x1A, 0xAA, 0xF1, 0x34, 0xAA, 0xC1, 0xD3, 0xA8, 0x21, 0x9E, 0xC6, 0x82, 0xA7, - 0x91, 0x1A, 0x4F, 0xE3, 0x1A, 0x3C, 0x8D, 0x1B, 0xE2, 0xE9, 0x48, 0xF0, 0x34, 0x56, 0xE3, 0xE9, - 0xA8, 0x06, 0x4F, 0x47, 0x0D, 0xF1, 0x74, 0x2C, 0x78, 0x3A, 0x52, 0xE3, 0xE9, 0xB8, 0x06, 0x4F, - 0xC7, 0x0D, 0xF1, 0x74, 0x22, 0x78, 0x3A, 0x56, 0xE3, 0xE9, 0xA4, 0x06, 0x4F, 0x27, 0x0D, 0xF1, - 0x84, 0x8B, 0x72, 0x8C, 0xA9, 0x13, 0xC5, 0x41, 0xB7, 0x5B, 0x83, 0x2B, 0xA3, 0x29, 0xAE, 0xC2, - 0x54, 0xA2, 0xA7, 0x9A, 0x4B, 0xD4, 0x49, 0x26, 0xA6, 0x4D, 0xB1, 0x15, 0x65, 0x13, 0x8A, 0xE9, - 0x44, 0xAF, 0x4E, 0x3E, 0x31, 0x6B, 0x8A, 0xAD, 0x30, 0xA1, 0xE8, 0x29, 0x66, 0x14, 0xBD, 0x3A, - 0x29, 0x85, 0xD9, 0x14, 0x5B, 0x61, 0x4E, 0xD1, 0x53, 0x4C, 0x2A, 0x7A, 0x75, 0xB2, 0x0A, 0xD2, - 0x14, 0x5B, 0x61, 0x5A, 0xD1, 0x53, 0xCC, 0x2B, 0x7A, 0x75, 0x12, 0x8B, 0x79, 0x53, 0x6C, 0x85, - 0x99, 0x45, 0x4F, 0x31, 0xB5, 0xE8, 0xD5, 0xC8, 0x2D, 0x4E, 0xE4, 0x13, 0xB1, 0x7B, 0x65, 0x8B, - 0x04, 0x7C, 0x8A, 0x1C, 0x4D, 0xDA, 0x94, 0x1E, 0x3D, 0xE1, 0x40, 0xF8, 0x6C, 0x14, 0x13, 0xC8, - 0x85, 0xEB, 0xCC, 0xAD, 0x45, 0x58, 0x64, 0x78, 0x34, 0x4F, 0x49, 0xF8, 0xB1, 0xB7, 0xF2, 0x2A, - 0x17, 0x1A, 0x3E, 0xBC, 0xBE, 0x2C, 0x57, 0x66, 0x88, 0xF7, 0xF2, 0x27, 0x2A, 0x32, 0x00, 0xD9, - 0xFD, 0xF8, 0x27, 0x02, 0x94, 0xEA, 0x0A, 0x14, 0xA8, 0x4C, 0x45, 0x61, 0x14, 0xAF, 0x28, 0x8C, - 0x95, 0x2B, 0x0A, 0x8C, 0xB8, 0xDD, 0xD4, 0x12, 0x00, 0xF7, 0x80, 0x7D, 0xD7, 0x40, 0x9D, 0xE9, - 0x41, 0x75, 0xA6, 0x47, 0x65, 0x98, 0x1E, 0x54, 0x61, 0xBA, 0xC2, 0xD3, 0x8D, 0x8A, 0x72, 0x02, - 0x7A, 0xBF, 0xB6, 0x6E, 0x88, 0xA9, 0xFD, 0xA2, 0x2E, 0xAA, 0x5E, 0x75, 0x51, 0x1D, 0x95, 0x11, - 0x55, 0x6F, 0x87, 0xF6, 0x31, 0x12, 0x7C, 0xFF, 0xA8, 0xCE, 0xF7, 0xA8, 0x3A, 0xDF, 0x83, 0x32, - 0x7C, 0x8F, 0x76, 0xC8, 0xF7, 0x50, 0xF0, 0xFD, 0x51, 0x9D, 0xEF, 0x61, 0x75, 0xBE, 0x87, 0x65, - 0xF8, 0x1E, 0xEE, 0x90, 0xEF, 0x3E, 0x04, 0x9B, 0x1F, 0x3F, 0x6A, 0x3F, 0x2C, 0x3D, 0xE2, 0x2F, - 0x8B, 0x2B, 0x71, 0x0C, 0xA2, 0xEA, 0xD8, 0x3E, 0x6A, 0x60, 0xEE, 0x86, 0x14, 0x0E, 0xE2, 0x3C, - 0x15, 0xE6, 0xCD, 0x0C, 0x42, 0xE5, 0xC3, 0x41, 0x72, 0x9E, 0xE4, 0x33, 0xB7, 0x9E, 0x2A, 0x53, - 0xBB, 0x8B, 0x61, 0xC7, 0xFA, 0xE4, 0xED, 0xA6, 0xC4, 0xF8, 0x76, 0x5C, 0xDD, 0x9E, 0xD5, 0x2B, - 0xE6, 0x8C, 0xAE, 0x9D, 0xD9, 0xF3, 0x09, 0xE5, 0x19, 0xF2, 0x32, 0x5F, 0x41, 0xED, 0xD5, 0xAB, - 0x10, 0xA3, 0x06, 0xAA, 0xE4, 0x18, 0xE9, 0x8F, 0x18, 0x3B, 0x3F, 0x22, 0x43, 0x1A, 0x64, 0x2C, - 0x25, 0x06, 0xA3, 0xA3, 0x92, 0xDA, 0x3C, 0xAE, 0x18, 0x9D, 0x90, 0xC6, 0x9D, 0xA9, 0x13, 0xA7, - 0x1E, 0x28, 0x80, 0x8F, 0x15, 0x04, 0x30, 0xAE, 0x2E, 0x80, 0x52, 0x99, 0x0B, 0xD2, 0xB8, 0x3B, - 0x01, 0x74, 0x99, 0x00, 0x3E, 0x44, 0x6F, 0xA6, 0xCE, 0x31, 0xE8, 0x1A, 0x15, 0xA8, 0x51, 0x03, - 0x6B, 0x24, 0x18, 0x69, 0x7B, 0xC2, 0xA2, 0x81, 0xA3, 0x72, 0x0A, 0xED, 0x97, 0xCD, 0xAF, 0xE4, - 0xC5, 0x4F, 0x85, 0xFC, 0x7B, 0x97, 0x09, 0x56, 0xBF, 0x2B, 0x2C, 0xBA, 0xBC, 0x00, 0xBA, 0xD5, - 0x05, 0xD0, 0x2B, 0x25, 0x80, 0xEE, 0xE3, 0x4A, 0xC6, 0xC7, 0xDB, 0x1F, 0x13, 0x2E, 0x96, 0x56, - 0x59, 0xF7, 0x8F, 0x8D, 0x66, 0xFD, 0x32, 0xC2, 0xDA, 0xA9, 0xF7, 0x0F, 0x22, 0xCE, 0xB5, 0x5F, - 0xB4, 0xE4, 0xD6, 0xD7, 0xBC, 0x38, 0x50, 0xBD, 0x08, 0x38, 0x6A, 0x60, 0xBD, 0x0A, 0x29, 0x3C, - 0x91, 0x70, 0x56, 0x32, 0xC0, 0x9F, 0x54, 0x77, 0x87, 0x52, 0x1A, 0x46, 0x5A, 0x77, 0xA7, 0xE2, - 0x51, 0x42, 0x10, 0xEC, 0x43, 0xE6, 0x2A, 0x2A, 0xAE, 0x5E, 0x39, 0x1C, 0x35, 0xB0, 0xD4, 0x85, - 0x14, 0x1E, 0x4B, 0x38, 0x2B, 0xA9, 0xE2, 0xB2, 0x29, 0xE9, 0x71, 0xC5, 0xA9, 0x65, 0x6F, 0x97, - 0x39, 0x29, 0x56, 0xBB, 0x63, 0x82, 0x88, 0x7F, 0x65, 0x22, 0x4F, 0xC1, 0xD5, 0x2B, 0xDE, 0xA3, - 0x9A, 0xEB, 0xB3, 0xBB, 0x8B, 0xE4, 0x47, 0xB2, 0x4F, 0x90, 0x17, 0xDB, 0x41, 0xD9, 0x5C, 0xB6, - 0x5B, 0x71, 0xE0, 0xDB, 0x69, 0x2A, 0x0B, 0xBD, 0x43, 0xD6, 0xB3, 0xCD, 0x7D, 0x8E, 0x09, 0x54, - 0x5F, 0x79, 0x1B, 0x35, 0xB0, 0x3D, 0x04, 0x29, 0xEC, 0xEB, 0x93, 0x8F, 0x25, 0x99, 0xAA, 0x53, - 0x3F, 0xA8, 0xBC, 0x3F, 0xA4, 0xB9, 0xD2, 0xFB, 0x6C, 0x75, 0x53, 0xBE, 0xF4, 0x7E, 0xF1, 0xEE, - 0xE7, 0x72, 0xA5, 0xF7, 0x78, 0x2F, 0xCD, 0x95, 0xDE, 0xAB, 0xD9, 0x4C, 0xA9, 0x8D, 0xB2, 0xC0, - 0x18, 0xBE, 0x3F, 0x62, 0x66, 0xF9, 0xB4, 0x4B, 0x10, 0x8C, 0xF6, 0x5E, 0x9C, 0x86, 0x22, 0x8A, - 0x3D, 0xB1, 0x9F, 0x6C, 0x9F, 0x67, 0x3D, 0x83, 0x9C, 0xB0, 0xA0, 0xB6, 0x11, 0x76, 0xFB, 0x75, - 0x28, 0x9D, 0x31, 0xFF, 0x10, 0x57, 0x8D, 0x47, 0xEB, 0xB3, 0x5E, 0x1A, 0xD0, 0x39, 0x2A, 0x89, - 0x7B, 0xE7, 0x8F, 0xDC, 0x4F, 0x52, 0x8A, 0xEA, 0x51, 0xFD, 0xF4, 0xF0, 0x5C, 0xB9, 0x4E, 0x4E, - 0xC1, 0xCA, 0x44, 0xF3, 0x41, 0xBC, 0xD4, 0xA2, 0x1E, 0xCD, 0x19, 0x79, 0xBB, 0x89, 0xE6, 0x88, - 0x3B, 0xC1, 0x7B, 0x89, 0xAC, 0x86, 0xC1, 0x96, 0x13, 0x80, 0x7C, 0x13, 0x85, 0x82, 0x00, 0xB2, - 0x24, 0x70, 0x2F, 0x22, 0xE8, 0x53, 0x09, 0xF4, 0x53, 0xDA, 0xCF, 0x08, 0xFC, 0xB4, 0x7D, 0xD5, - 0xB8, 0x3F, 0x68, 0xA0, 0x36, 0x81, 0xE2, 0x4A, 0x70, 0x54, 0x52, 0xA7, 0xE5, 0x16, 0x07, 0x13, - 0x3A, 0x2D, 0x67, 0xD4, 0x3B, 0x5B, 0x1D, 0x04, 0xE4, 0x03, 0x2A, 0x80, 0x81, 0xB2, 0x4A, 0xAB, - 0x4F, 0x33, 0x07, 0x0D, 0xE4, 0x27, 0x28, 0xAD, 0x04, 0x47, 0x25, 0x55, 0x5A, 0x6E, 0xE9, 0x33, - 0xA1, 0x52, 0xF5, 0xF9, 0x25, 0x27, 0x72, 0x67, 0x2A, 0x1D, 0x52, 0x01, 0x0C, 0x95, 0x55, 0x5A, - 0x7D, 0xD6, 0x31, 0x68, 0x60, 0xF7, 0x2E, 0x4A, 0x2B, 0xC1, 0x51, 0x49, 0x95, 0x96, 0x5B, 0xB2, - 0x4B, 0xA8, 0x54, 0x7D, 0x3E, 0xC9, 0x89, 0xDC, 0x99, 0x4A, 0x47, 0x54, 0x00, 0x23, 0x65, 0x95, - 0x56, 0xAF, 0x14, 0x0C, 0x1A, 0x28, 0x06, 0xA1, 0xB4, 0x12, 0x1C, 0x95, 0x54, 0x69, 0xB9, 0xD5, - 0xE7, 0x84, 0x4A, 0xD5, 0xD7, 0x39, 0x38, 0x91, 0x3B, 0x53, 0xE9, 0x98, 0x0A, 0x60, 0xAC, 0xAC, - 0xD2, 0xEA, 0xFB, 0xAB, 0x06, 0x0D, 0xEC, 0xDD, 0x46, 0x69, 0x25, 0x38, 0x2A, 0xA9, 0xD2, 0x72, - 0xA5, 0xDB, 0x84, 0x4A, 0xD5, 0x57, 0x6E, 0x38, 0x91, 0x3B, 0x53, 0xE9, 0x11, 0x15, 0xC0, 0x91, - 0xB2, 0x4A, 0xAB, 0x6F, 0x5D, 0x1F, 0x34, 0x50, 0xCF, 0x43, 0x69, 0x25, 0x38, 0x2A, 0xA9, 0xD2, - 0x72, 0x15, 0x9C, 0x84, 0x4A, 0xD5, 0xF7, 0x4E, 0x71, 0x22, 0x77, 0xA6, 0xD2, 0x63, 0x2A, 0x80, - 0x63, 0x65, 0x95, 0x56, 0xDF, 0xB9, 0x3F, 0x68, 0x60, 0xE7, 0x3E, 0x4A, 0x2B, 0xC1, 0x51, 0x49, - 0x95, 0x96, 0xAB, 0xCD, 0x26, 0x54, 0xAA, 0xBE, 0xDD, 0x89, 0x13, 0xB9, 0x33, 0x95, 0x9E, 0x50, - 0x01, 0x9C, 0x28, 0xAB, 0xB4, 0xFA, 0x96, 0x81, 0x41, 0x03, 0x9B, 0x5F, 0x50, 0x5A, 0xDD, 0x38, - 0x47, 0x25, 0x55, 0x5A, 0x6E, 0x81, 0x71, 0x90, 0xB1, 0xF5, 0x45, 0x41, 0xA5, 0x59, 0x0B, 0x8C, - 0x8F, 0xA0, 0x7E, 0x67, 0x5C, 0x4F, 0x2B, 0x7C, 0xFA, 0xE5, 0xE5, 0x4F, 0xAF, 0xB2, 0x0B, 0xFB, - 0x99, 0x55, 0xBC, 0x44, 0x5F, 0x8F, 0xBD, 0x8C, 0x17, 0x97, 0x17, 0x12, 0x0E, 0x5A, 0x66, 0x2F, - 0x4D, 0xD4, 0xB6, 0x98, 0xCF, 0xB7, 0x34, 0x06, 0x5C, 0xC2, 0xD2, 0x06, 0xC3, 0xAE, 0x3C, 0x69, - 0x29, 0xB0, 0x34, 0x4E, 0xE5, 0x6E, 0x82, 0x07, 0x22, 0x87, 0xB9, 0x38, 0xF2, 0xFE, 0xBD, 0xD2, - 0x9A, 0x0E, 0x03, 0x48, 0x86, 0x8F, 0x61, 0xF7, 0x44, 0x31, 0x7E, 0x80, 0x0C, 0xB2, 0x36, 0xC6, - 0xDF, 0x63, 0x00, 0x41, 0x1A, 0x07, 0x8C, 0xA9, 0x37, 0xCA, 0x4C, 0xA5, 0xAB, 0x00, 0xA5, 0x98, - 0xCA, 0xAA, 0xEC, 0xDC, 0x33, 0x53, 0x43, 0xC6, 0x54, 0x8E, 0x93, 0xA6, 0x98, 0x4A, 0xCF, 0x83, - 0x4B, 0x31, 0x95, 0x35, 0x11, 0x8E, 0x98, 0x7A, 0x0C, 0x81, 0x8E, 0xCC, 0x8C, 0xC5, 0xAC, 0x42, - 0xA8, 0xBB, 0xBC, 0x38, 0x7C, 0xF9, 0xE6, 0x42, 0xA3, 0x4B, 0x9A, 0xAE, 0x5D, 0x32, 0xE2, 0x25, - 0x3B, 0xFD, 0x53, 0xC5, 0x3C, 0x4A, 0x7A, 0x2C, 0xEA, 0xBD, 0xB9, 0x50, 0x0D, 0x78, 0x1C, 0xB2, - 0x4C, 0xC8, 0x1B, 0x75, 0x07, 0x55, 0x2A, 0x84, 0x21, 0x91, 0x3B, 0x0A, 0x7A, 0x14, 0x7D, 0x3F, - 0x92, 0xC1, 0x65, 0x39, 0x19, 0x94, 0xAA, 0x92, 0x26, 0x65, 0x50, 0x22, 0xEC, 0x0B, 0x22, 0x77, - 0x29, 0x03, 0x8C, 0x92, 0x97, 0x17, 0xDA, 0xFB, 0xBF, 0x6B, 0x97, 0x37, 0x6B, 0xD7, 0xDF, 0x78, - 0xA4, 0x30, 0xAA, 0x70, 0xB8, 0x64, 0x5C, 0x19, 0x8F, 0x46, 0x03, 0xD5, 0xC0, 0x32, 0xCA, 0x1E, - 0x02, 0xE6, 0xDD, 0x7B, 0x8C, 0x97, 0x94, 0xD0, 0x61, 0xC8, 0xE0, 0xF7, 0x04, 0x34, 0xAD, 0x14, - 0x37, 0x39, 0x60, 0x92, 0xC3, 0x5E, 0x17, 0xB7, 0x57, 0x2B, 0x32, 0x28, 0xCF, 0x28, 0x07, 0xF7, - 0x3A, 0x1C, 0x50, 0x2A, 0x47, 0x21, 0x7B, 0x1F, 0x7F, 0xF8, 0xA0, 0xC6, 0x58, 0xBA, 0x8E, 0x56, - 0x4E, 0x75, 0x59, 0x8F, 0x8C, 0xDE, 0xD3, 0xA0, 0x20, 0xBD, 0x71, 0x76, 0x08, 0xA1, 0x77, 0x1B, - 0x26, 0x43, 0x92, 0x67, 0x73, 0x6B, 0x01, 0x76, 0x2C, 0xEF, 0x83, 0x8A, 0x96, 0xBD, 0xEC, 0x14, - 0xBF, 0x51, 0xD9, 0x9E, 0x41, 0xF4, 0x07, 0x93, 0x40, 0xA7, 0x13, 0x02, 0x5F, 0x19, 0x0B, 0x12, - 0x5D, 0xD7, 0x58, 0x6C, 0xCF, 0x8B, 0xD9, 0x06, 0x43, 0x68, 0x5C, 0x11, 0xFE, 0x41, 0x4D, 0x6D, - 0xE9, 0x91, 0xF9, 0xB9, 0xFE, 0x45, 0x88, 0x93, 0x3F, 0x95, 0x87, 0x4D, 0x74, 0xCD, 0x74, 0xAF, - 0x1D, 0xDB, 0x35, 0x70, 0x3C, 0x30, 0xD6, 0x01, 0x50, 0xDA, 0xF9, 0x6D, 0x8D, 0x2F, 0xBE, 0x32, - 0xF0, 0x21, 0x2E, 0x23, 0xA7, 0x9F, 0x98, 0x55, 0xCC, 0x6C, 0xD7, 0x17, 0xB3, 0x39, 0x3C, 0x0C, - 0x3F, 0xC0, 0xF9, 0x3F, 0xFF, 0x5D, 0xB4, 0x83, 0xC0, 0x5A, 0x2D, 0x62, 0x02, 0xD0, 0x35, 0xDF, - 0x9B, 0x9D, 0xEB, 0x40, 0xA9, 0xE7, 0xFA, 0xBE, 0xEB, 0x59, 0x0B, 0x2B, 0x43, 0x3B, 0x59, 0xD2, - 0x3E, 0x94, 0x89, 0x3B, 0xD5, 0x58, 0xA2, 0xF8, 0x33, 0x7F, 0xE6, 0x59, 0xEB, 0x60, 0xF2, 0xC4, - 0x74, 0x67, 0x9B, 0x15, 0x71, 0x82, 0x8E, 0x61, 0x9A, 0x97, 0x57, 0x70, 0xF0, 0x2D, 0x7E, 0xAC, - 0x0D, 0x24, 0xDF, 0xDA, 0x7B, 0xFD, 0x8F, 0x77, 0x38, 0x3A, 0xE3, 0x35, 0x90, 0x17, 0x31, 0xF7, - 0x0E, 0xB4, 0xF9, 0xC6, 0x61, 0x03, 0x64, 0x8B, 0x60, 0xDB, 0x7D, 0xED, 0x0F, 0xC0, 0x78, 0x65, - 0x78, 0xDA, 0xD4, 0xF0, 0xC9, 0x5B, 0xD7, 0x0F, 0xB4, 0x73, 0x2D, 0xC4, 0x68, 0xBB, 0x33, 0xBA, - 0x9D, 0xA3, 0xC3, 0xF8, 0xE2, 0x2D, 0x19, 0xE3, 0x3F, 0x7A, 0x36, 0x34, 0x0D, 0xA1, 0x9E, 0x6B, - 0x7B, 0xA7, 0xC7, 0xBD, 0x3D, 0xB4, 0xDD, 0xB0, 0x8B, 0x39, 0x81, 0xE8, 0x0F, 0xED, 0x5A, 0x1B, - 0xCF, 0x3E, 0xD0, 0x66, 0xD3, 0xFD, 0x3F, 0x28, 0xF5, 0xF4, 0x32, 0x5E, 0xDB, 0xE7, 0xCC, 0x74, - 0x82, 0x25, 0x71, 0x5A, 0x11, 0x65, 0x1E, 0xF1, 0xD7, 0xAE, 0xE3, 0x13, 0x46, 0x1C, 0xFB, 0x59, - 0xF3, 0xE8, 0x7A, 0xC7, 0x0F, 0x8C, 0x60, 0xE3, 0x6B, 0x4F, 0xCF, 0xCF, 0xB5, 0x7E, 0xB7, 0x1B, - 0x6F, 0xA6, 0x41, 0x37, 0xE9, 0x76, 0x07, 0x5A, 0xEA, 0xC2, 0x0F, 0xE4, 0x26, 0xD8, 0xFF, 0x2A, - 0x84, 0xB9, 0xD3, 0x88, 0xED, 0x93, 0x04, 0x92, 0x10, 0x00, 0x5F, 0x27, 0xD7, 0xDA, 0x4F, 0x12, - 0xD8, 0x32, 0x8D, 0xC0, 0xD8, 0xFF, 0x23, 0xA1, 0x2F, 0xE8, 0x15, 0x28, 0x39, 0xD0, 0xE8, 0xAD, - 0xAF, 0x62, 0xB7, 0xEE, 0xF6, 0x3B, 0x20, 0x43, 0xE0, 0x37, 0x84, 0x26, 0x9E, 0x97, 0xA4, 0x98, - 0x42, 0xB7, 0x7B, 0x07, 0x1A, 0xDE, 0x49, 0xC2, 0xC6, 0x88, 0x7C, 0x22, 0xAE, 0x09, 0xA1, 0xE5, - 0xA3, 0x95, 0xA0, 0x64, 0xE8, 0xEE, 0x12, 0x2A, 0x82, 0x38, 0xF4, 0x3D, 0x59, 0x80, 0xC4, 0x16, - 0x07, 0x3C, 0x2C, 0x1D, 0xD0, 0x98, 0x74, 0xC0, 0xC2, 0x59, 0x4C, 0x6B, 0xE0, 0xD0, 0xBE, 0x6B, - 0x13, 0xB0, 0x89, 0x45, 0x6B, 0x8F, 0x7F, 0x0A, 0x14, 0xEC, 0x69, 0xAF, 0x7B, 0xB3, 0xF7, 0x1C, - 0xC0, 0x3B, 0x81, 0xFB, 0x21, 0xF0, 0x2C, 0x67, 0xD1, 0xEA, 0x8D, 0xF7, 0x23, 0x5C, 0xF4, 0x36, - 0x22, 0x4C, 0xDD, 0xA7, 0xD7, 0x69, 0x17, 0xE9, 0x1B, 0x2D, 0x7E, 0xFD, 0xF9, 0xDE, 0xFE, 0x1E, - 0x27, 0x9D, 0x9E, 0x83, 0xB1, 0xB5, 0xD8, 0xC1, 0x33, 0x4A, 0xE1, 0xBE, 0x76, 0x76, 0xC6, 0xBB, - 0x61, 0xAD, 0xF0, 0x22, 0x34, 0xA2, 0x7F, 0x52, 0xB7, 0x42, 0x43, 0xFC, 0xF5, 0xCB, 0x3F, 0x84, - 0xC5, 0xDE, 0x1D, 0x02, 0xD5, 0x2F, 0x30, 0x2E, 0x7F, 0xF9, 0x07, 0xFC, 0x7F, 0xF7, 0x8C, 0x86, - 0xE2, 0x2F, 0xFF, 0xC0, 0x3F, 0x77, 0xCF, 0xA0, 0x27, 0x38, 0xA6, 0xFD, 0xDD, 0xFD, 0x4A, 0xA5, - 0xB0, 0x2D, 0xBB, 0x45, 0xA6, 0xEC, 0x42, 0xA1, 0x95, 0xA6, 0x69, 0x91, 0x43, 0xD4, 0xAF, 0x91, - 0xF7, 0xB6, 0x66, 0xAE, 0x09, 0xCA, 0x09, 0xC0, 0x8E, 0x85, 0xCA, 0x6D, 0x50, 0x89, 0x10, 0x54, - 0x57, 0xA8, 0xDC, 0x9A, 0xD3, 0x96, 0x1A, 0x77, 0x94, 0xC8, 0x3C, 0x44, 0xCB, 0xB5, 0xE1, 0xF9, - 0xE4, 0x1B, 0x27, 0x68, 0x05, 0x09, 0x97, 0xC8, 0x90, 0xF8, 0x64, 0x92, 0x60, 0x01, 0x7F, 0x00, - 0x07, 0xED, 0xF6, 0xB8, 0xD2, 0x42, 0x53, 0x7B, 0x12, 0x5A, 0x61, 0x44, 0x29, 0xBB, 0x99, 0x61, - 0x85, 0x3F, 0xCF, 0xEC, 0xCF, 0xAD, 0x1B, 0xF8, 0x2F, 0x1D, 0x28, 0xB6, 0x44, 0x84, 0x8D, 0x5E, - 0xE0, 0x7F, 0x20, 0x17, 0xFC, 0x93, 0xA9, 0x1F, 0xC0, 0xFA, 0xDE, 0xB6, 0x5B, 0xEC, 0xB3, 0x5F, - 0xA0, 0x9A, 0x0D, 0x04, 0x21, 0xFF, 0x16, 0xC3, 0x81, 0xEB, 0x06, 0x9F, 0x0E, 0xB4, 0xB5, 0x07, - 0x84, 0xD1, 0x2F, 0x7D, 0xC0, 0x31, 0x20, 0x22, 0x0E, 0xFB, 0x5B, 0x48, 0xC1, 0xDA, 0xB6, 0x5F, - 0x30, 0xAC, 0x40, 0x02, 0x3B, 0x00, 0x4D, 0x6D, 0xD0, 0x62, 0xE0, 0xFF, 0xBB, 0x67, 0xD0, 0x09, - 0x1C, 0xC2, 0xFF, 0x77, 0xCF, 0xB0, 0x2B, 0xD4, 0x25, 0xF6, 0x78, 0xF7, 0x0C, 0x7A, 0x84, 0x13, - 0xF8, 0x1F, 0xDA, 0x60, 0xBF, 0xD8, 0x0A, 0xFF, 0xC2, 0x1D, 0xDA, 0x3F, 0xDE, 0xA4, 0x07, 0xEC, - 0x02, 0x3F, 0xCD, 0x63, 0x90, 0xBD, 0xE9, 0xBE, 0x45, 0xDF, 0x3C, 0xFE, 0xE9, 0x06, 0xD8, 0xA1, - 0x07, 0xB7, 0xE0, 0xF8, 0x8E, 0x89, 0xE7, 0xF8, 0xE7, 0x56, 0x98, 0x27, 0x5E, 0xE0, 0x47, 0x70, - 0x8D, 0xBE, 0x9D, 0x15, 0x2F, 0xB1, 0x03, 0x6C, 0x45, 0xDF, 0xA5, 0x49, 0x5B, 0xB1, 0x23, 0xB8, - 0xC6, 0xDF, 0xC0, 0x78, 0xA0, 0xF1, 0x77, 0xFC, 0x15, 0x0A, 0x27, 0x7A, 0x07, 0xDF, 0x0B, 0xFF, - 0x06, 0x19, 0x64, 0xA4, 0xA1, 0x54, 0xC2, 0xB3, 0xDB, 0xBB, 0x67, 0x04, 0xEF, 0x51, 0x22, 0xE1, - 0xF8, 0x96, 0x1F, 0xC3, 0x75, 0xA0, 0x0F, 0xEF, 0x08, 0x82, 0xE9, 0x85, 0xDB, 0xE8, 0x02, 0xB4, - 0x08, 0xF0, 0x3E, 0x27, 0x1E, 0xCE, 0x6E, 0xC3, 0x33, 0x84, 0xA6, 0xB0, 0x9C, 0x0D, 0x38, 0xBD, - 0x8D, 0x4E, 0xE1, 0x2E, 0xF2, 0x82, 0x0A, 0xE0, 0x3C, 0xDD, 0x3D, 0xE3, 0x3C, 0xA1, 0x16, 0xD9, - 0x51, 0x5A, 0xD4, 0x18, 0xF4, 0x02, 0x1E, 0x24, 0x5F, 0xB1, 0x1C, 0x24, 0x36, 0x3C, 0x42, 0x00, - 0xB8, 0xB4, 0x09, 0x1E, 0xBE, 0xBA, 0xFD, 0xC6, 0x6C, 0xED, 0xF1, 0x4F, 0xB7, 0xEE, 0x61, 0x88, - 0x8E, 0xC3, 0x74, 0x5C, 0x67, 0x66, 0x5B, 0x33, 0x8C, 0x04, 0xAD, 0x7D, 0xED, 0x7C, 0xC2, 0xC3, - 0x34, 0x7A, 0x2C, 0x34, 0x8F, 0x7B, 0x61, 0x26, 0x6A, 0x8F, 0x7F, 0x7C, 0x74, 0x6F, 0xBF, 0x43, - 0x1D, 0x8D, 0x3B, 0x13, 0xA2, 0xE0, 0x31, 0x46, 0x0D, 0x07, 0x36, 0x96, 0xE0, 0xD8, 0x0A, 0x07, - 0xB9, 0x48, 0x68, 0xEB, 0x18, 0x16, 0x8A, 0x26, 0x3E, 0x92, 0x74, 0x53, 0x83, 0x48, 0x4E, 0xD8, - 0x12, 0x11, 0xEA, 0x69, 0x3A, 0x42, 0x81, 0xAA, 0xBC, 0xA0, 0xB5, 0x77, 0xE9, 0x79, 0xAE, 0xF7, - 0xAF, 0xBD, 0xE7, 0xD8, 0xE8, 0xF9, 0xDE, 0xBF, 0x4F, 0xB5, 0xBD, 0xE7, 0xF1, 0x50, 0x75, 0x97, - 0x8E, 0x29, 0x4C, 0x63, 0x0B, 0x45, 0x8D, 0x2D, 0x62, 0x1A, 0x5B, 0xDC, 0xAF, 0xC6, 0xE2, 0x9F, - 0x8C, 0xAD, 0xA3, 0xB5, 0xF8, 0x27, 0x5A, 0x73, 0x34, 0x57, 0x08, 0xCF, 0x95, 0xC6, 0xB5, 0xB5, - 0x90, 0x69, 0xAB, 0x8A, 0x9A, 0xD8, 0x18, 0x0E, 0xDE, 0x43, 0xBC, 0xB7, 0x3F, 0xBC, 0xFB, 0x16, - 0xC7, 0x02, 0xB9, 0xCA, 0x42, 0x8D, 0xA5, 0xB3, 0x2D, 0x09, 0x06, 0x4C, 0x0E, 0x12, 0x23, 0x53, - 0x22, 0x49, 0x78, 0xBE, 0xA7, 0xB5, 0x28, 0x4A, 0x4C, 0x11, 0x0A, 0x0C, 0x81, 0x8F, 0x2C, 0x6A, - 0xBE, 0x8B, 0xA3, 0x89, 0x70, 0xDE, 0x08, 0x2A, 0xC7, 0x16, 0x10, 0x40, 0x49, 0x89, 0x0C, 0xF3, - 0x96, 0xC3, 0xC4, 0x06, 0xBD, 0xC6, 0x5D, 0x84, 0xFA, 0xAB, 0xAF, 0x1A, 0xD4, 0x44, 0x4C, 0x8F, - 0x62, 0x9B, 0x5F, 0x28, 0x1D, 0x1E, 0xF9, 0x95, 0x04, 0xC4, 0x3F, 0x05, 0x22, 0x31, 0x70, 0x3E, - 0x62, 0x94, 0xC0, 0x72, 0x2B, 0xC1, 0x42, 0x47, 0x1A, 0x25, 0x1C, 0xF4, 0xF3, 0x11, 0x19, 0x18, - 0xD4, 0xA8, 0xA0, 0xDF, 0x6B, 0x90, 0x60, 0x10, 0x63, 0x9A, 0x12, 0x12, 0xF1, 0xAD, 0x81, 0x6C, - 0x3C, 0x6A, 0xC4, 0x88, 0x37, 0xFC, 0x4B, 0xF0, 0xF0, 0x31, 0x54, 0x09, 0x0D, 0x7F, 0x3B, 0x7D, - 0x26, 0x16, 0x35, 0x62, 0xF8, 0x0B, 0xE1, 0x65, 0x3C, 0xF1, 0x31, 0x5B, 0x8D, 0x27, 0xFE, 0x1E, - 0xF3, 0x6C, 0x3C, 0x8A, 0xB2, 0xE1, 0xEF, 0x0E, 0x97, 0x59, 0x1D, 0x4B, 0x11, 0x72, 0x1D, 0x83, - 0x35, 0x01, 0x60, 0x5E, 0x96, 0x7E, 0xD1, 0x3B, 0xED, 0x46, 0x18, 0x78, 0x46, 0x91, 0x87, 0x81, - 0x37, 0x49, 0x63, 0x10, 0xD1, 0xE1, 0x01, 0x72, 0xBB, 0x87, 0x88, 0x42, 0x90, 0xA3, 0xAB, 0x45, - 0x21, 0x48, 0xBB, 0x45, 0xF8, 0x09, 0x61, 0x32, 0xC2, 0x0F, 0x2D, 0x68, 0xB0, 0x2F, 0x18, 0xE7, - 0xC9, 0x3F, 0xFC, 0x20, 0xB0, 0x4C, 0x89, 0x88, 0x03, 0xD2, 0x79, 0x25, 0x4B, 0xE2, 0xDF, 0xBE, - 0x4D, 0x19, 0x12, 0x2D, 0x96, 0xDC, 0xFA, 0x6A, 0xA1, 0xEB, 0xD6, 0xCF, 0xC0, 0x40, 0xE7, 0x0E, - 0x6A, 0xB9, 0x19, 0xFF, 0x58, 0xAC, 0x04, 0x09, 0xCC, 0x39, 0x94, 0x50, 0xF0, 0x2F, 0x66, 0xCA, - 0x18, 0xA1, 0x1F, 0x54, 0x54, 0x62, 0x45, 0x7C, 0x3C, 0x51, 0x46, 0x07, 0x9D, 0xDE, 0xE4, 0x29, - 0x85, 0x7F, 0xA2, 0x2E, 0x4B, 0x23, 0x6B, 0xD5, 0x21, 0x57, 0x7C, 0x8E, 0x4D, 0x32, 0xEC, 0x56, - 0x9C, 0x15, 0x3E, 0xCC, 0x10, 0xBD, 0xF8, 0x28, 0xE6, 0xE2, 0xC4, 0x96, 0xA6, 0xA2, 0xC4, 0xEE, - 0x18, 0x01, 0x24, 0x47, 0xD3, 0x4D, 0x40, 0xFC, 0x0E, 0xD6, 0x0F, 0x42, 0xE1, 0x6C, 0xDD, 0xEA, - 0x38, 0x40, 0x00, 0x45, 0xB8, 0x1F, 0x8F, 0x55, 0x2C, 0x70, 0x6C, 0xE1, 0x62, 0x97, 0xB3, 0xD0, - 0xB1, 0xBB, 0x19, 0x18, 0x79, 0x7A, 0x9B, 0x84, 0xC0, 0x8B, 0x59, 0xD8, 0x68, 0x8D, 0x28, 0x86, - 0xAB, 0x3F, 0x1A, 0x6D, 0x27, 0xB9, 0xBC, 0x03, 0xB6, 0xAC, 0x84, 0x02, 0xE9, 0x60, 0x89, 0x3E, - 0x2A, 0x7B, 0xCD, 0x60, 0x16, 0xAA, 0xED, 0x89, 0x35, 0xA5, 0xBD, 0xD3, 0xAD, 0x7A, 0x06, 0x40, - 0x70, 0xAB, 0xD2, 0x5E, 0x30, 0x1A, 0x4F, 0xA3, 0x62, 0x89, 0xA6, 0x4D, 0x3D, 0x62, 0x7C, 0xFE, - 0x2A, 0x81, 0x8C, 0x56, 0xFF, 0x43, 0x4C, 0xEC, 0x1A, 0x16, 0x05, 0x53, 0x97, 0xD8, 0x13, 0x37, - 0x6D, 0xD7, 0x21, 0xF2, 0x5E, 0x13, 0xD5, 0x11, 0xDE, 0x11, 0x3F, 0x33, 0xC9, 0xDC, 0xD8, 0xD8, - 0x41, 0x04, 0xE6, 0x91, 0x60, 0xE3, 0x39, 0xBC, 0x5A, 0xB2, 0x3D, 0xB9, 0x92, 0x96, 0xE9, 0x1A, - 0xB4, 0xCD, 0xC3, 0x43, 0xED, 0x65, 0x10, 0x18, 0xA0, 0x00, 0x5C, 0x66, 0x5D, 0xA2, 0x7C, 0x34, - 0x83, 0x17, 0x7C, 0x5D, 0x0F, 0x8D, 0x12, 0xEB, 0xCF, 0x1E, 0x70, 0x4D, 0xBD, 0xD1, 0x07, 0x10, - 0xE1, 0xA4, 0x14, 0x55, 0xE7, 0x3F, 0x1B, 0xE2, 0xDD, 0x7E, 0xA0, 0x02, 0x73, 0xBD, 0x97, 0xE0, - 0x8B, 0x7B, 0x9D, 0x68, 0xA9, 0x64, 0x8F, 0xD5, 0x37, 0x3B, 0x80, 0xEA, 0x12, 0xFA, 0x00, 0x1D, - 0x47, 0x36, 0xCF, 0xB8, 0x09, 0xF5, 0xAE, 0x9D, 0x9F, 0x9F, 0x73, 0x65, 0xA4, 0x0B, 0xAA, 0xD0, - 0xC2, 0x75, 0x3E, 0x93, 0xDB, 0xCD, 0x1A, 0xC4, 0x1F, 0x95, 0x48, 0x53, 0x45, 0x5B, 0x2E, 0x1D, - 0xD2, 0x81, 0x96, 0x17, 0xBC, 0x4C, 0xD6, 0x1B, 0x48, 0x1A, 0x45, 0x2A, 0xA0, 0xD6, 0x89, 0x9E, - 0xF8, 0xD5, 0x56, 0xA3, 0xBB, 0x27, 0xF2, 0x33, 0x49, 0x79, 0x99, 0x13, 0xC8, 0x85, 0x27, 0x86, - 0xAE, 0x54, 0x0F, 0x4F, 0x92, 0xA8, 0xEE, 0xF6, 0x9F, 0x44, 0x91, 0x61, 0xB3, 0x36, 0x8D, 0x80, - 0x24, 0x83, 0x43, 0x68, 0x0B, 0xE2, 0xE6, 0xCA, 0x0D, 0x48, 0x2A, 0x62, 0x58, 0x8E, 0x15, 0x58, - 0x86, 0xFD, 0x31, 0xB2, 0xC6, 0x9D, 0xBA, 0xBF, 0xC4, 0xC7, 0x4B, 0xF8, 0xFF, 0x56, 0x85, 0x57, - 0xAD, 0x2A, 0xB9, 0x65, 0x21, 0x61, 0x3C, 0x88, 0xAC, 0x24, 0x2E, 0x87, 0x44, 0x58, 0xE0, 0xF7, - 0x45, 0x4F, 0x4F, 0x9F, 0xD2, 0xA3, 0x27, 0xA1, 0xD2, 0x44, 0xF4, 0x38, 0xD7, 0xA2, 0x1B, 0x29, - 0x05, 0x6F, 0xE3, 0x4E, 0xE1, 0x10, 0xC8, 0x63, 0x18, 0x98, 0x6F, 0x85, 0xEA, 0x5D, 0xC3, 0x54, - 0x17, 0x6D, 0xE1, 0xFF, 0xA3, 0xFE, 0x23, 0x8A, 0xFA, 0xBB, 0x0B, 0xF1, 0x39, 0xB6, 0x9D, 0xF2, - 0x00, 0x06, 0x27, 0x5F, 0x74, 0x79, 0xBE, 0x77, 0xA0, 0xC9, 0x57, 0x55, 0x52, 0x69, 0xC5, 0xD2, - 0x32, 0x19, 0xC9, 0x91, 0x5D, 0xA1, 0x84, 0x70, 0x61, 0x14, 0x97, 0x0E, 0x71, 0x1D, 0xB1, 0xB5, - 0xC7, 0x56, 0x6D, 0x69, 0x34, 0xBE, 0x8B, 0x12, 0x92, 0xA5, 0x7B, 0x9D, 0x07, 0xE9, 0x41, 0xCC, - 0xB9, 0x22, 0x29, 0xE0, 0x10, 0xDA, 0xB4, 0x7C, 0x63, 0x6A, 0x17, 0x77, 0xCD, 0xDB, 0x99, 0x7C, - 0x28, 0x80, 0x06, 0xE2, 0x0A, 0x80, 0x06, 0x1E, 0xF5, 0x99, 0x18, 0x5A, 0xE2, 0x14, 0x61, 0x15, - 0x64, 0xE5, 0x22, 0x9E, 0x1B, 0xE0, 0xC4, 0x49, 0xCC, 0x2C, 0x90, 0x96, 0x08, 0xB1, 0xF1, 0xCB, - 0x00, 0x91, 0x3C, 0x3D, 0xD7, 0x9C, 0x8D, 0x6D, 0x83, 0x05, 0x22, 0x0B, 0x60, 0x81, 0xF1, 0xBB, - 0xD2, 0x00, 0xFD, 0xE7, 0x8D, 0x66, 0x21, 0xE5, 0x09, 0x09, 0x3C, 0x7B, 0x96, 0xC4, 0x86, 0xCB, - 0xB7, 0x2C, 0x35, 0x0F, 0x7B, 0x63, 0xED, 0xD9, 0xDB, 0x74, 0xA3, 0x51, 0x96, 0x93, 0x04, 0x43, - 0xF5, 0xD3, 0x84, 0xE0, 0x63, 0x19, 0x0E, 0x10, 0x62, 0x99, 0x54, 0x40, 0xB8, 0x49, 0x43, 0xDF, - 0x5A, 0xE9, 0x7A, 0x41, 0xAD, 0xBE, 0x45, 0xF8, 0x1E, 0x9D, 0x7D, 0x90, 0x3F, 0x1A, 0x73, 0x74, - 0x41, 0x64, 0x3B, 0x61, 0x57, 0x71, 0x8C, 0x8B, 0x04, 0x46, 0x64, 0x2C, 0x45, 0x37, 0xFE, 0x68, - 0x07, 0xD0, 0x14, 0x77, 0xC8, 0xC4, 0x06, 0xEF, 0xED, 0xD1, 0x9F, 0x76, 0xBC, 0xDD, 0x30, 0x97, - 0x82, 0xEB, 0xE9, 0xA7, 0x05, 0x34, 0x97, 0x31, 0x46, 0xD1, 0x5D, 0x4F, 0x91, 0x25, 0x4A, 0x02, - 0x1C, 0xE6, 0xA1, 0x9A, 0x1B, 0x33, 0xF2, 0xC9, 0x23, 0x33, 0x77, 0xE1, 0x58, 0xBF, 0x13, 0x19, - 0x42, 0xE6, 0x50, 0x2D, 0xE2, 0x78, 0xAE, 0x98, 0x66, 0x23, 0x72, 0xEE, 0x2B, 0xC9, 0xEB, 0x99, - 0x3D, 0x69, 0x3A, 0xB8, 0xD5, 0x27, 0x0B, 0x37, 0x1F, 0xF8, 0x56, 0x70, 0xBB, 0xDD, 0xCF, 0x44, - 0x6B, 0xF7, 0x04, 0xF5, 0xD0, 0xF4, 0x0D, 0x6E, 0xD0, 0x09, 0x79, 0x08, 0x2F, 0x24, 0x93, 0x50, - 0xE1, 0x9E, 0xE1, 0x0A, 0x58, 0xDC, 0x5E, 0xD8, 0x50, 0x19, 0x8D, 0x93, 0x91, 0x41, 0xDE, 0xD3, - 0xC8, 0xD1, 0xC3, 0x61, 0x43, 0x1A, 0xDF, 0x6B, 0x0E, 0x1A, 0x39, 0x38, 0xD9, 0xC6, 0x97, 0x34, - 0xD2, 0xCD, 0x74, 0x65, 0x05, 0x12, 0x84, 0x7B, 0xBD, 0xBD, 0x32, 0xE3, 0x4F, 0xDC, 0x5B, 0x59, - 0xC4, 0xA3, 0xA9, 0x39, 0x20, 0x4A, 0xAC, 0xE8, 0xCD, 0xD8, 0x06, 0xCF, 0x17, 0x30, 0xFD, 0xC6, - 0x75, 0x3A, 0x54, 0x70, 0x6A, 0x81, 0x9C, 0xA1, 0x60, 0xFB, 0x3A, 0x28, 0x8A, 0xE4, 0xCE, 0x0E, - 0xB1, 0x9B, 0x22, 0x99, 0xC9, 0xC7, 0x37, 0x14, 0xFC, 0xEA, 0x11, 0x80, 0xF3, 0xB1, 0x9A, 0xA8, - 0x7D, 0xF9, 0x07, 0x45, 0x71, 0xA7, 0xCD, 0x21, 0x5A, 0xF8, 0x4B, 0x62, 0xD2, 0xCA, 0x57, 0xB0, - 0xF1, 0x4F, 0x35, 0x5C, 0x14, 0x4F, 0xEC, 0xE4, 0xB8, 0xFB, 0x35, 0xB4, 0x90, 0x70, 0xB0, 0x29, - 0x9C, 0x6C, 0xD0, 0x0D, 0x3F, 0xF9, 0xF3, 0x0C, 0x96, 0x9E, 0x4B, 0x0A, 0x4B, 0xF8, 0x63, 0x91, - 0xC4, 0xEE, 0x40, 0x4E, 0x03, 0xDD, 0x7C, 0x07, 0xD9, 0x4B, 0xCA, 0x4C, 0xF7, 0xF9, 0x34, 0x09, - 0x34, 0x60, 0x8A, 0x90, 0xC7, 0x74, 0x84, 0x93, 0x21, 0x26, 0xA6, 0x84, 0x84, 0x19, 0x33, 0x9C, - 0x97, 0xE2, 0xDD, 0x30, 0x3C, 0x0B, 0x08, 0x65, 0xF1, 0x9B, 0x0F, 0x93, 0x9B, 0xFD, 0x27, 0xA1, - 0x18, 0xB6, 0x71, 0x60, 0x07, 0x31, 0x04, 0x09, 0x11, 0x65, 0x89, 0x89, 0x1B, 0x4D, 0x72, 0x5E, - 0x96, 0x23, 0x33, 0xF6, 0x8B, 0x8D, 0x99, 0x74, 0xC0, 0xA4, 0x3D, 0xFF, 0x8B, 0x1A, 0xCD, 0xBF, - 0x0F, 0xD8, 0x20, 0x1B, 0x8B, 0x79, 0xFB, 0x65, 0x08, 0xDA, 0x9A, 0x24, 0x16, 0x12, 0x73, 0x6F, - 0xE9, 0xB2, 0xF8, 0x41, 0x80, 0xA3, 0xF8, 0x20, 0x29, 0xDD, 0x9A, 0x26, 0xC6, 0x73, 0xBA, 0x90, - 0x41, 0x89, 0x6C, 0x62, 0x73, 0x3C, 0x21, 0x1E, 0x69, 0x3E, 0x97, 0x29, 0x2E, 0x66, 0x5F, 0xCC, - 0x61, 0xAF, 0x2C, 0x72, 0x9D, 0x5B, 0x37, 0xA5, 0x7B, 0xB5, 0xA8, 0xBC, 0x22, 0x80, 0x8B, 0x70, - 0x6F, 0x5E, 0x21, 0x64, 0xB4, 0x8F, 0x2F, 0x86, 0x83, 0x6E, 0xD2, 0x53, 0x5B, 0x5C, 0xA5, 0x4D, - 0x13, 0xA0, 0x88, 0xB5, 0x18, 0x56, 0xEC, 0xDB, 0x4E, 0x93, 0x1F, 0x1F, 0x7C, 0xF2, 0xE0, 0xE9, - 0x28, 0xC7, 0x1A, 0xC7, 0xA0, 0xA9, 0xE7, 0x17, 0x03, 0xC7, 0x77, 0x04, 0xC6, 0x69, 0x37, 0xAE, - 0x14, 0x80, 0xA3, 0x6D, 0x8C, 0x31, 0x50, 0x31, 0x9E, 0xE5, 0x01, 0x42, 0x1B, 0xB6, 0x4D, 0x75, - 0x2F, 0xA6, 0x5F, 0x3F, 0x70, 0xD7, 0x1F, 0x28, 0x21, 0xA9, 0x40, 0x74, 0x4D, 0x17, 0x07, 0x3A, - 0x78, 0xBF, 0xC5, 0xD3, 0xA4, 0xB8, 0x6C, 0x93, 0xEB, 0xA9, 0x1F, 0x70, 0xE5, 0x40, 0x63, 0x78, - 0xF6, 0x92, 0x39, 0x2E, 0x5D, 0x54, 0x90, 0xF6, 0x80, 0x96, 0xD2, 0xF1, 0xBD, 0x19, 0x1B, 0x0E, - 0xC2, 0x4D, 0x7F, 0x18, 0xAD, 0xF0, 0xF0, 0x57, 0xD6, 0x27, 0x0E, 0xDF, 0x09, 0x9B, 0xDA, 0x2F, - 0xA4, 0xC5, 0x5D, 0xA7, 0x49, 0x89, 0x4A, 0x4A, 0xCC, 0xBF, 0x7D, 0x1C, 0x07, 0xD8, 0xD0, 0x87, - 0x11, 0x33, 0x66, 0x6F, 0x19, 0x61, 0x39, 0x12, 0x13, 0x8F, 0x83, 0x49, 0xE2, 0x63, 0x63, 0x19, - 0xDB, 0x31, 0xFA, 0xE2, 0xD3, 0x6C, 0x0A, 0xC3, 0xD7, 0x6B, 0x70, 0x3E, 0xF0, 0xF6, 0xEB, 0xD6, - 0xFE, 0x5D, 0x1E, 0x3B, 0x4C, 0x5C, 0x91, 0xED, 0xA8, 0x12, 0x41, 0x07, 0x0A, 0x39, 0xB6, 0x84, - 0x7C, 0xE4, 0xE8, 0xE2, 0x0E, 0x73, 0xE9, 0x88, 0x69, 0x4A, 0x96, 0x60, 0xCF, 0xB7, 0x45, 0xCB, - 0x32, 0xD5, 0x04, 0x82, 0x68, 0x08, 0xD8, 0x22, 0x36, 0x95, 0xA8, 0xC6, 0xEC, 0x42, 0x34, 0x10, - 0xB4, 0xC7, 0x7D, 0x30, 0x83, 0xF6, 0x64, 0x1E, 0x9F, 0x4A, 0x18, 0x99, 0x00, 0x42, 0x67, 0xCA, - 0x59, 0xC2, 0x99, 0x19, 0xCE, 0x95, 0x91, 0x58, 0xC2, 0x99, 0x01, 0x41, 0x01, 0xE1, 0x7E, 0xD3, - 0xD2, 0x59, 0x03, 0x9D, 0x3B, 0x00, 0x3B, 0xEB, 0xD0, 0x87, 0x61, 0x70, 0x46, 0x82, 0x36, 0x40, - 0x4F, 0x12, 0xB7, 0x97, 0x84, 0xBE, 0x80, 0x90, 0xDF, 0x67, 0x67, 0xAC, 0x41, 0xD8, 0xCB, 0xD4, - 0x35, 0x6F, 0x3B, 0xC6, 0x7A, 0x4D, 0x1C, 0xF3, 0x62, 0x69, 0xD9, 0x66, 0x8B, 0x81, 0xC6, 0xD6, - 0x31, 0x30, 0x2C, 0x12, 0xBA, 0xF5, 0x8D, 0x63, 0x05, 0x6F, 0xBE, 0x60, 0xD7, 0x5A, 0x7B, 0x7D, - 0x53, 0xEC, 0x5C, 0xE4, 0xCD, 0x3A, 0xA6, 0x67, 0x5C, 0x7F, 0x83, 0xFB, 0xA2, 0xA9, 0x39, 0x1C, - 0x74, 0x0F, 0xBA, 0xBC, 0x41, 0x00, 0xE9, 0x96, 0x10, 0x39, 0xE2, 0xC5, 0xFD, 0xA3, 0x3F, 0x7E, - 0xFF, 0x6D, 0x84, 0x37, 0x70, 0x5F, 0xB3, 0x4B, 0xAD, 0x3D, 0xBA, 0xB1, 0xFA, 0xF0, 0xB7, 0x35, - 0xEE, 0x57, 0x11, 0xC3, 0x4C, 0x4C, 0x8C, 0xB8, 0x67, 0x1A, 0x45, 0xC5, 0x9A, 0x7F, 0x15, 0x47, - 0x0A, 0x97, 0x1D, 0x18, 0x27, 0xD0, 0xDC, 0x5B, 0x32, 0x50, 0xB1, 0xA3, 0x1A, 0xC1, 0x91, 0x93, - 0xAF, 0x61, 0xA2, 0xFA, 0x0B, 0x31, 0x3C, 0xD0, 0xC7, 0x73, 0xAD, 0xA5, 0x77, 0xF5, 0xE7, 0x2D, - 0x7A, 0xFD, 0x1D, 0xB0, 0xB3, 0x6C, 0xED, 0x3F, 0xEF, 0xED, 0xEF, 0x77, 0x7C, 0xD0, 0x19, 0x69, - 0xB5, 0xFB, 0xA2, 0x09, 0xFC, 0xA1, 0x6D, 0x58, 0x27, 0xD9, 0xF7, 0xDF, 0xBA, 0x1B, 0xCF, 0xCF, - 0x6B, 0xF0, 0xCE, 0x72, 0x70, 0x24, 0xCE, 0x6B, 0xF2, 0x01, 0xA6, 0x2F, 0x8E, 0xB9, 0xD5, 0x44, - 0xA7, 0x1B, 0xC1, 0xC5, 0x9C, 0x91, 0xEE, 0x8F, 0x85, 0xA4, 0x3F, 0x96, 0xEE, 0xF3, 0x8C, 0x93, - 0x60, 0xC5, 0xBB, 0x25, 0x96, 0xA5, 0xEE, 0xE2, 0xC6, 0x11, 0xE5, 0x74, 0xBC, 0x6A, 0xB0, 0xA5, - 0xFF, 0x54, 0xC0, 0xE2, 0x59, 0xD2, 0x56, 0x2D, 0x5C, 0x25, 0x11, 0x95, 0x66, 0x58, 0xB9, 0x19, - 0x69, 0xB2, 0x60, 0x9C, 0x9E, 0x2D, 0x27, 0xB3, 0xCF, 0x8B, 0x0D, 0xF8, 0xF8, 0x4A, 0x44, 0x54, - 0x76, 0x0D, 0xA7, 0x9C, 0x61, 0xE8, 0x87, 0x29, 0x68, 0xDE, 0x78, 0x04, 0xB7, 0x63, 0x23, 0x18, - 0x9F, 0xAF, 0x16, 0x00, 0xD0, 0x49, 0x6A, 0x38, 0x8A, 0x69, 0x08, 0xB5, 0x4D, 0xB6, 0x24, 0x44, - 0x40, 0xBB, 0xFD, 0x30, 0x62, 0x21, 0x10, 0x9F, 0x75, 0x45, 0xBA, 0xDB, 0x9E, 0x5D, 0xA7, 0x03, - 0xD6, 0xD6, 0xAC, 0xFA, 0x2E, 0xA6, 0x2D, 0xF1, 0x70, 0x4E, 0xC4, 0x0F, 0xC9, 0x67, 0x9E, 0xC4, - 0x99, 0x17, 0x55, 0x82, 0x02, 0x88, 0x4F, 0x34, 0xC7, 0x8F, 0xB3, 0x4F, 0x14, 0xD9, 0x27, 0x9C, - 0x7D, 0x04, 0x88, 0x26, 0x9C, 0xC5, 0x25, 0x8B, 0xD0, 0x18, 0x7F, 0x7A, 0x15, 0x71, 0x76, 0x3D, - 0xCD, 0xA5, 0x93, 0x97, 0x12, 0x62, 0xEC, 0xE5, 0x03, 0x40, 0xFB, 0x15, 0xF8, 0x43, 0x9C, 0xAD, - 0xEB, 0xA9, 0x1A, 0x5B, 0xA2, 0x14, 0x81, 0x00, 0x11, 0x5B, 0xF2, 0x82, 0x85, 0x60, 0xE5, 0x35, - 0x09, 0xF8, 0x23, 0x7C, 0x86, 0x63, 0x6A, 0x73, 0xCF, 0x58, 0x11, 0xFC, 0x62, 0x7B, 0x48, 0xAC, - 0x49, 0xEF, 0x17, 0xE6, 0x7E, 0xAC, 0x59, 0x8C, 0xC9, 0xB0, 0xE4, 0x51, 0x08, 0x1A, 0xB6, 0x8C, - 0x41, 0x87, 0x74, 0xE4, 0x42, 0x8B, 0x46, 0x2C, 0x83, 0x0B, 0x4F, 0x95, 0x84, 0x15, 0xB6, 0x8E, - 0x1C, 0x21, 0x42, 0x20, 0x4A, 0x26, 0xA3, 0x74, 0xA9, 0x8C, 0xCD, 0x24, 0x18, 0xB3, 0xA9, 0xF9, - 0x42, 0xBC, 0x41, 0xC8, 0x52, 0xA2, 0x4D, 0xE8, 0x20, 0x0C, 0x3E, 0x8B, 0xCC, 0x42, 0x52, 0xD8, - 0x42, 0xA2, 0xFE, 0xDE, 0x26, 0x58, 0x9E, 0xE0, 0x2F, 0x65, 0xBC, 0xF8, 0xE6, 0x6B, 0xCD, 0xF5, - 0x34, 0xDB, 0xBD, 0x26, 0xB8, 0x28, 0x28, 0x36, 0x90, 0x69, 0x53, 0x02, 0x01, 0x8E, 0xB0, 0x22, - 0x13, 0xEE, 0x8F, 0x09, 0x96, 0x96, 0x0F, 0x73, 0x62, 0x7C, 0x75, 0x28, 0x79, 0xAA, 0x87, 0xA3, - 0x53, 0x21, 0x7B, 0xDB, 0x25, 0xF4, 0x84, 0x38, 0x19, 0x4C, 0x24, 0xCB, 0xA7, 0x9C, 0xC7, 0xAD, - 0xC0, 0x92, 0x57, 0xD6, 0x2A, 0x21, 0xC2, 0xF0, 0xF6, 0xA3, 0x95, 0xA2, 0x9C, 0x81, 0x42, 0x41, - 0x86, 0x60, 0x91, 0x2C, 0x23, 0x5E, 0xB7, 0xA4, 0x29, 0xAB, 0x1D, 0xE6, 0x68, 0x14, 0x4B, 0xE1, - 0xD2, 0x68, 0x9E, 0xAD, 0x15, 0x26, 0x71, 0x36, 0xCA, 0xB1, 0xDF, 0xD9, 0xA1, 0x78, 0x14, 0x8A, - 0x9D, 0x61, 0xF6, 0x36, 0x79, 0x72, 0x76, 0xB8, 0x0C, 0x56, 0xF6, 0xE4, 0xC9, 0xFF, 0x02, 0x49, - 0x60, 0xC8, 0xA8, 0x55, 0x0C, 0x01, 0x00 -}; - - -//File: index_ov5640.html.gz, Size: 9124 -#define index_ov5640_html_gz_len 9124 -const uint8_t index_ov5640_html_gz[] = { - 0x1F, 0x8B, 0x08, 0x08, 0xD9, 0x6C, 0x6A, 0x5E, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x5F, - 0x6F, 0x76, 0x35, 0x36, 0x34, 0x30, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xED, 0x3D, 0x6B, 0x77, - 0xDB, 0xB6, 0x92, 0xDF, 0xF3, 0x2B, 0x18, 0xF5, 0x6E, 0x24, 0x9F, 0x58, 0xB6, 0xA8, 0x97, 0x1F, - 0xB1, 0x95, 0x4D, 0x1C, 0x27, 0xE9, 0xB9, 0x4D, 0x6F, 0x1A, 0xA7, 0x69, 0x7B, 0xBA, 0x3D, 0x29, - 0x25, 0x41, 0x12, 0x1B, 0x8A, 0xD4, 0x25, 0x29, 0xCB, 0x6E, 0x8E, 0x7F, 0xC7, 0xFE, 0xA0, 0xFD, - 0x63, 0x3B, 0x03, 0x80, 0x24, 0x48, 0x81, 0x24, 0x48, 0x4A, 0xB2, 0xDB, 0x5D, 0xE5, 0x9C, 0x98, - 0x0F, 0xCC, 0x60, 0xDE, 0x18, 0x0C, 0x40, 0xF2, 0xEC, 0xF1, 0xD8, 0x19, 0xF9, 0xB7, 0x0B, 0xA2, - 0xCD, 0xFC, 0xB9, 0x35, 0x78, 0x74, 0xC6, 0xFE, 0x68, 0xF0, 0x3B, 0x9B, 0x11, 0x63, 0xCC, 0x0E, - 0xE9, 0xE9, 0x9C, 0xF8, 0x86, 0x36, 0x9A, 0x19, 0xAE, 0x47, 0xFC, 0xF3, 0xDA, 0xD2, 0x9F, 0x34, - 0x8F, 0x6B, 0xC9, 0xDB, 0xB6, 0x31, 0x27, 0xE7, 0xB5, 0x6B, 0x93, 0xAC, 0x16, 0x8E, 0xEB, 0xD7, - 0xB4, 0x91, 0x63, 0xFB, 0xC4, 0x86, 0xE6, 0x2B, 0x73, 0xEC, 0xCF, 0xCE, 0xC7, 0xE4, 0xDA, 0x1C, - 0x91, 0x26, 0x3D, 0xD9, 0x37, 0x6D, 0xD3, 0x37, 0x0D, 0xAB, 0xE9, 0x8D, 0x0C, 0x8B, 0x9C, 0xEB, - 0x22, 0x2E, 0xDF, 0xF4, 0x2D, 0x32, 0xB8, 0xBC, 0x7A, 0xDF, 0x69, 0x6B, 0xFF, 0xFA, 0xD4, 0xEB, - 0x77, 0x5B, 0x67, 0x87, 0xEC, 0x5A, 0xD4, 0xC6, 0xF3, 0x6F, 0xC5, 0x73, 0xFC, 0x0D, 0x9D, 0xF1, - 0xAD, 0xF6, 0x35, 0x76, 0x09, 0x7F, 0x13, 0x20, 0xA2, 0x39, 0x31, 0xE6, 0xA6, 0x75, 0x7B, 0xAA, - 0xBD, 0x70, 0xA1, 0xCF, 0xFD, 0xB7, 0xC4, 0xBA, 0x26, 0xBE, 0x39, 0x32, 0xF6, 0x3D, 0xC3, 0xF6, - 0x9A, 0x1E, 0x71, 0xCD, 0xC9, 0xB3, 0x35, 0xC0, 0xA1, 0x31, 0xFA, 0x32, 0x75, 0x9D, 0xA5, 0x3D, - 0x3E, 0xD5, 0xBE, 0xD1, 0x8F, 0xF1, 0xDF, 0x7A, 0xA3, 0x91, 0x63, 0x39, 0x2E, 0xDC, 0xBF, 0x7C, - 0x8D, 0xFF, 0xD6, 0xEF, 0xD3, 0xDE, 0x3D, 0xF3, 0x4F, 0x72, 0xAA, 0xE9, 0xFD, 0xC5, 0x4D, 0xEC, - 0xFE, 0xDD, 0xA3, 0xD8, 0xE9, 0xAC, 0x9D, 0x46, 0x3D, 0x87, 0x3F, 0xCE, 0x86, 0xF7, 0xC8, 0xC8, - 0x37, 0x1D, 0xFB, 0x60, 0x6E, 0x98, 0xB6, 0x04, 0xD3, 0xD8, 0xF4, 0x16, 0x96, 0x01, 0x32, 0x98, - 0x58, 0x24, 0x13, 0xCF, 0x37, 0x73, 0x62, 0x2F, 0xF7, 0x73, 0xB0, 0x21, 0x92, 0xE6, 0xD8, 0x74, - 0x59, 0xAB, 0x53, 0x94, 0xC3, 0x72, 0x6E, 0xE7, 0xA2, 0xCD, 0xA2, 0xCB, 0x76, 0x6C, 0x22, 0x11, - 0x20, 0x76, 0xB4, 0x72, 0x8D, 0x05, 0x36, 0xC0, 0xBF, 0xEB, 0x4D, 0xE6, 0xA6, 0xCD, 0x8C, 0xEA, - 0x54, 0xEB, 0x74, 0x5B, 0x8B, 0x9B, 0x1C, 0x55, 0x76, 0xFA, 0xF8, 0x6F, 0xBD, 0xD1, 0xC2, 0x18, - 0x8F, 0x4D, 0x7B, 0x7A, 0xAA, 0x1D, 0x4B, 0x51, 0x38, 0xEE, 0x98, 0xB8, 0x4D, 0xD7, 0x18, 0x9B, - 0x4B, 0xEF, 0x54, 0xEB, 0xCA, 0xDA, 0xCC, 0x0D, 0x77, 0x0A, 0xB4, 0xF8, 0x0E, 0x10, 0xDB, 0xD4, - 0xA5, 0x94, 0xF0, 0x26, 0xAE, 0x39, 0x9D, 0xF9, 0xA0, 0xD2, 0xB5, 0x36, 0x49, 0xA1, 0x71, 0x17, - 0xCA, 0xD3, 0x67, 0xA6, 0xDC, 0xE4, 0x52, 0x33, 0x2C, 0x73, 0x6A, 0x37, 0x4D, 0x9F, 0xCC, 0x81, - 0x1D, 0xCF, 0x77, 0x89, 0x3F, 0x9A, 0x65, 0x91, 0x32, 0x31, 0xA7, 0x4B, 0x97, 0x48, 0x08, 0x09, - 0xE5, 0x96, 0xC1, 0x30, 0xDC, 0x5C, 0xBF, 0xD5, 0x5C, 0x91, 0xE1, 0x17, 0xD3, 0x6F, 0x72, 0x99, - 0x0C, 0xC9, 0xC4, 0x71, 0x89, 0xB4, 0x65, 0xD0, 0xC2, 0x72, 0x46, 0x5F, 0x9A, 0x9E, 0x6F, 0xB8, - 0xBE, 0x0A, 0x42, 0x63, 0xE2, 0x13, 0x37, 0x1F, 0x1F, 0x41, 0xAB, 0xC8, 0xC7, 0x96, 0xDE, 0x2D, - 0x6F, 0x60, 0xDA, 0x96, 0x69, 0x13, 0x75, 0xF2, 0xD2, 0xFA, 0x8D, 0xA3, 0x63, 0xAD, 0x14, 0x14, - 0x63, 0xCE, 0xA7, 0x59, 0x56, 0x42, 0x79, 0x5D, 0xEF, 0x8C, 0xFB, 0x8D, 0xDE, 0x6A, 0xFD, 0xC7, - 0xFA, 0xCD, 0x19, 0x61, 0x66, 0x6A, 0x2C, 0x7D, 0xA7, 0xBA, 0x47, 0xAC, 0xB9, 0x55, 0x82, 0x8F, - 0xFF, 0x9C, 0x93, 0xB1, 0x69, 0x68, 0x0D, 0xC1, 0x9D, 0x8F, 0x5B, 0x60, 0x53, 0x7B, 0x9A, 0x61, - 0x8F, 0xB5, 0x86, 0xE3, 0x9A, 0xE0, 0x08, 0x06, 0x0D, 0x37, 0x16, 0x5C, 0x81, 0x81, 0x63, 0x41, - 0xF6, 0x24, 0x2C, 0x67, 0xF8, 0x8C, 0x28, 0x11, 0xB9, 0xDB, 0xE0, 0x4F, 0x21, 0xE4, 0xE0, 0x2F, - 0xD7, 0x81, 0x24, 0x3C, 0x52, 0xF4, 0x59, 0xFA, 0x12, 0x29, 0x4C, 0xD3, 0x19, 0xFE, 0xE6, 0xC6, - 0x4D, 0x33, 0x53, 0x77, 0x41, 0xA3, 0x40, 0x87, 0x30, 0xCC, 0x8E, 0x1A, 0xD0, 0xF4, 0x7A, 0xA6, - 0x35, 0x35, 0x8C, 0x92, 0x7B, 0x72, 0x18, 0x8E, 0x54, 0xAE, 0x72, 0xFC, 0x89, 0x46, 0x51, 0x80, - 0x5D, 0x39, 0xAB, 0x51, 0xEC, 0x60, 0xFF, 0x64, 0x36, 0xC4, 0x38, 0x49, 0x8D, 0x22, 0xF8, 0x53, - 0x8F, 0x24, 0x11, 0xB2, 0xDC, 0x68, 0x22, 0x41, 0x9C, 0x1E, 0x51, 0xD6, 0xF0, 0xA6, 0x79, 0xB7, - 0x04, 0x6B, 0x36, 0x09, 0xAA, 0xD1, 0x45, 0x82, 0x38, 0x8B, 0x86, 0xDC, 0x28, 0x83, 0xBF, 0x3B, - 0x85, 0x7C, 0xE3, 0x9B, 0xE1, 0xD2, 0xF7, 0x1D, 0xDB, 0xAB, 0x34, 0x44, 0xA5, 0xF9, 0xD9, 0x1F, - 0x4B, 0xCF, 0x37, 0x27, 0xB7, 0x4D, 0xEE, 0xD2, 0xE0, 0x67, 0x0B, 0x03, 0x52, 0xC8, 0x21, 0xF1, - 0x57, 0x84, 0x64, 0xA7, 0x1B, 0xB6, 0x71, 0x0D, 0x71, 0x67, 0x3A, 0xB5, 0x64, 0xB6, 0x37, 0x5A, - 0xBA, 0x1E, 0xE6, 0x6D, 0x0B, 0xC7, 0x04, 0xC4, 0xEE, 0x7A, 0xC7, 0x71, 0x1F, 0x54, 0xEC, 0xA8, - 0x39, 0x1A, 0x4A, 0xFA, 0x72, 0x96, 0x3E, 0xCA, 0x58, 0xAA, 0x09, 0x07, 0xD8, 0x31, 0xFD, 0x5B, - 0xE9, 0x3D, 0xEE, 0x89, 0x92, 0x3B, 0x81, 0x0B, 0x66, 0x0E, 0x0B, 0x71, 0xBA, 0x4E, 0x47, 0x33, - 0x32, 0xFA, 0x42, 0xC6, 0x4F, 0x73, 0xD3, 0xB0, 0xBC, 0xF4, 0xF0, 0xC0, 0xB4, 0x17, 0x4B, 0xBF, - 0x89, 0xE9, 0xD4, 0x62, 0x2B, 0x3A, 0xA7, 0x06, 0x19, 0xB0, 0xD8, 0x6E, 0x67, 0x25, 0x15, 0xBD, - 0xC5, 0x4D, 0xB6, 0x10, 0x44, 0x62, 0x07, 0x96, 0x31, 0x24, 0x56, 0x16, 0xC9, 0xDC, 0x19, 0x52, - 0xC2, 0x2E, 0x8F, 0x55, 0xE9, 0xB9, 0x1B, 0xA5, 0x2C, 0x1A, 0xBC, 0xBA, 0x47, 0xFF, 0xA1, 0x2C, - 0x47, 0x7A, 0xBC, 0x1F, 0xBB, 0xE4, 0x11, 0x0B, 0x1C, 0x2C, 0x71, 0x6D, 0x61, 0xA4, 0x26, 0xE3, - 0xD0, 0x62, 0x05, 0x54, 0x65, 0x76, 0xE9, 0x1A, 0xF6, 0x94, 0x40, 0x74, 0xB8, 0xD9, 0x0F, 0x0E, - 0xB3, 0xA7, 0x0A, 0x4A, 0x02, 0xC1, 0xE0, 0xDD, 0xCB, 0x9E, 0x9A, 0xB0, 0x10, 0xB1, 0xAF, 0x1D, - 0xB0, 0x83, 0x12, 0x79, 0x8A, 0xA0, 0xF1, 0x4C, 0x42, 0x74, 0xA9, 0xBD, 0xB0, 0x54, 0x45, 0xEA, - 0x4B, 0x71, 0x6B, 0x93, 0xA6, 0xFE, 0xB9, 0xC1, 0x22, 0x98, 0x04, 0x4E, 0x26, 0x79, 0xD3, 0xC8, - 0xC9, 0xA4, 0xD3, 0xEA, 0x74, 0x73, 0x73, 0x29, 0x29, 0x97, 0x89, 0xA9, 0xA4, 0x24, 0x98, 0x84, - 0x81, 0x26, 0x5F, 0x17, 0xA7, 0x33, 0xE7, 0x9A, 0xB8, 0x12, 0x45, 0x24, 0xC8, 0xED, 0x9E, 0x74, - 0xC7, 0x0A, 0xD8, 0x0C, 0x18, 0x0A, 0xAE, 0x65, 0x81, 0x36, 0x8E, 0xAE, 0xAD, 0x8F, 0xDA, 0x99, - 0x16, 0xCA, 0xD0, 0x1D, 0x80, 0x35, 0x18, 0x43, 0x8B, 0x8C, 0x33, 0x22, 0xF7, 0x98, 0x4C, 0x8C, - 0xA5, 0xE5, 0xE7, 0xC8, 0xDB, 0x68, 0xE1, 0xBF, 0xAC, 0x1E, 0xA9, 0x7B, 0xFD, 0x8A, 0x35, 0x90, - 0x73, 0xEA, 0x12, 0xBF, 0x49, 0xFA, 0x0C, 0x86, 0x55, 0x63, 0xB1, 0x20, 0x06, 0xB4, 0x1A, 0x91, - 0xB4, 0xD9, 0xAA, 0x52, 0x3A, 0x2D, 0x8F, 0x69, 0x4A, 0x73, 0xD4, 0x5C, 0x53, 0x0C, 0x13, 0xA5, - 0x42, 0x3C, 0x9F, 0x4E, 0x9C, 0xD1, 0x52, 0x36, 0x82, 0xAB, 0x99, 0xD4, 0x3A, 0xBE, 0xD3, 0x40, - 0x64, 0x9E, 0x65, 0x52, 0xC3, 0x5E, 0xDA, 0x36, 0x6A, 0xB4, 0xE9, 0xBB, 0xC0, 0xA6, 0xA4, 0x23, - 0x35, 0xC1, 0x95, 0xF2, 0xCE, 0x98, 0x60, 0xD3, 0xEA, 0x34, 0x09, 0x07, 0x94, 0x04, 0x8A, 0x30, - 0x86, 0x68, 0x9E, 0x03, 0x4C, 0x05, 0xA8, 0xAA, 0xC9, 0xC5, 0x9F, 0x2D, 0xE7, 0xB2, 0x9C, 0x21, - 0xE8, 0x4C, 0x87, 0x01, 0x8E, 0x75, 0xE7, 0x4E, 0x87, 0x46, 0xA3, 0xB5, 0xDF, 0xDA, 0xEF, 0xC0, - 0x7F, 0x92, 0xDC, 0x3D, 0xDB, 0xB8, 0xB8, 0x78, 0x53, 0x2C, 0x2F, 0x11, 0x7C, 0xF2, 0x4B, 0x28, - 0x69, 0x61, 0x2C, 0x57, 0x17, 0xEA, 0x9E, 0x14, 0xAF, 0xA5, 0xE8, 0x07, 0x39, 0x23, 0x4C, 0x8A, - 0x49, 0x17, 0x37, 0x44, 0x89, 0xB5, 0x14, 0x55, 0xF1, 0xDC, 0xF9, 0xB3, 0xC9, 0x86, 0xD7, 0xFF, - 0xF3, 0xD6, 0x2E, 0x88, 0xE2, 0x6F, 0x6D, 0xE9, 0x85, 0xE5, 0xE2, 0xDD, 0xB7, 0x6D, 0xB4, 0xD2, - 0xB5, 0xDE, 0xE4, 0xF9, 0x0C, 0x50, 0x68, 0x43, 0xC6, 0xE9, 0xC2, 0xC4, 0x2B, 0x35, 0xE7, 0x11, - 0xDA, 0x94, 0x90, 0xC1, 0xC4, 0xB4, 0xAC, 0xA6, 0xE5, 0xAC, 0xF2, 0x33, 0x91, 0x6C, 0x4B, 0x5E, - 0xB3, 0xD3, 0x7C, 0x93, 0x2F, 0x4B, 0xED, 0x12, 0x22, 0xD7, 0x5F, 0x82, 0xDA, 0xBF, 0xB7, 0xC3, - 0x65, 0xBA, 0x46, 0xB9, 0x81, 0xA2, 0x84, 0x3D, 0x56, 0xEB, 0x48, 0xC9, 0x94, 0x58, 0x26, 0x98, - 0x39, 0xAB, 0xF3, 0x56, 0xA6, 0x3F, 0x9A, 0x95, 0x98, 0x54, 0x2D, 0x1C, 0xCF, 0x64, 0xCB, 0x37, - 0x2E, 0xB1, 0x0C, 0xCC, 0xE0, 0x4B, 0xCD, 0xC6, 0x73, 0x27, 0x26, 0x22, 0xB8, 0x0A, 0x27, 0x54, - 0x74, 0x0F, 0xA7, 0x92, 0x72, 0xC0, 0x72, 0x87, 0xF4, 0x58, 0x2D, 0x37, 0xEB, 0x9C, 0x74, 0x3F, - 0xEE, 0x19, 0xF2, 0x46, 0x05, 0x22, 0x7A, 0x10, 0xB4, 0xA7, 0x2E, 0xB9, 0x55, 0x60, 0x66, 0x9F, - 0xFF, 0x3D, 0x65, 0xB5, 0xD2, 0xF2, 0x45, 0x00, 0x3A, 0x00, 0x70, 0x2B, 0x3A, 0xE8, 0x7A, 0x0A, - 0x5D, 0xA7, 0x77, 0xA9, 0x62, 0x8F, 0x61, 0x25, 0xB0, 0x56, 0x53, 0x08, 0x37, 0x19, 0x43, 0xA8, - 0xDC, 0x54, 0x83, 0xD1, 0x57, 0x7A, 0xD3, 0x22, 0x13, 0x3F, 0x65, 0xA1, 0x83, 0xE6, 0xA9, 0x9D, - 0xEC, 0xE8, 0xD6, 0x14, 0xEA, 0x04, 0xB9, 0x91, 0x23, 0x2C, 0xD8, 0xA5, 0x5B, 0x9F, 0x14, 0x33, - 0x46, 0xCF, 0xC2, 0xC8, 0xD3, 0x55, 0x12, 0xA4, 0xCF, 0x54, 0xCD, 0xD0, 0x66, 0xCE, 0x87, 0x7C, - 0x50, 0x0F, 0xF9, 0xB9, 0xD1, 0xEE, 0x4B, 0xD7, 0x11, 0x32, 0x1A, 0x67, 0x91, 0xC6, 0x2A, 0x5E, - 0x4A, 0x43, 0x56, 0xEA, 0x04, 0x59, 0x8C, 0x45, 0x52, 0x45, 0x65, 0x7B, 0x65, 0x56, 0x84, 0x59, - 0xAF, 0xD1, 0x64, 0x1A, 0xBB, 0x39, 0x37, 0x20, 0xED, 0x45, 0x73, 0x35, 0x00, 0xA3, 0x4C, 0x7F, - 0x2A, 0xE6, 0x2E, 0xD4, 0x13, 0xF5, 0x7E, 0x2B, 0xA7, 0xCB, 0x91, 0xE5, 0x78, 0xD9, 0x7E, 0x65, - 0x0C, 0x41, 0x7E, 0x4B, 0x5F, 0xD2, 0x11, 0xAF, 0x6A, 0x4A, 0x2B, 0x4F, 0xD4, 0xB8, 0xA5, 0x77, - 0x94, 0x86, 0xEE, 0x4C, 0x9F, 0xCA, 0x76, 0xC7, 0x84, 0xCC, 0xF5, 0x96, 0x34, 0xD2, 0x66, 0xD6, - 0xDF, 0x7C, 0x72, 0x03, 0xF3, 0x4D, 0x5C, 0xAB, 0x3B, 0xD5, 0x46, 0x44, 0x1E, 0x46, 0x63, 0x83, - 0x9C, 0xAE, 0x52, 0x04, 0xCC, 0xD4, 0xC3, 0xCC, 0x1C, 0x8F, 0x49, 0x66, 0x95, 0x13, 0xE7, 0xBC, - 0xD9, 0xA1, 0xD2, 0x90, 0x96, 0xD3, 0x0A, 0x68, 0xB2, 0x9D, 0xAE, 0xCA, 0xCC, 0xE1, 0x2A, 0x25, - 0xF4, 0xC5, 0x24, 0x24, 0x6D, 0x22, 0x54, 0x61, 0xE5, 0x21, 0x12, 0x15, 0x31, 0x26, 0x23, 0xC7, - 0x65, 0x8B, 0xB8, 0x29, 0x13, 0xFF, 0x72, 0x33, 0x2B, 0x44, 0x2E, 0x2B, 0xDD, 0x6D, 0x25, 0x74, - 0x64, 0x6E, 0x74, 0xD0, 0xB7, 0x1D, 0x57, 0xF8, 0x70, 0x9C, 0x56, 0x49, 0x8F, 0x27, 0x6C, 0x99, - 0xA4, 0x4A, 0x43, 0x60, 0xA8, 0x46, 0x14, 0x19, 0xC8, 0x01, 0x5B, 0xAD, 0x2B, 0x34, 0x41, 0x15, - 0x5D, 0x5A, 0x39, 0xE0, 0xAB, 0x4D, 0x7C, 0x61, 0xB0, 0x99, 0xB6, 0xDE, 0xB2, 0xC1, 0xC5, 0x37, - 0x6A, 0x01, 0xC9, 0x7E, 0x53, 0x45, 0x73, 0x4F, 0xF9, 0x63, 0x06, 0x91, 0xE1, 0x40, 0x1C, 0x6C, - 0xB7, 0x8A, 0xB7, 0x2A, 0x1B, 0x42, 0xCE, 0x0E, 0x85, 0xFD, 0x71, 0x67, 0x87, 0xD1, 0x56, 0xBE, - 0x33, 0xDC, 0x24, 0x27, 0x6E, 0xA3, 0xE3, 0xFD, 0x8C, 0x2C, 0xC3, 0xF3, 0xCE, 0x6B, 0xB8, 0xD9, - 0xAB, 0x16, 0xDF, 0x55, 0x77, 0x36, 0x36, 0xAF, 0x35, 0x73, 0x7C, 0x5E, 0xB3, 0x9C, 0xA9, 0x93, - 0xB8, 0x47, 0xEF, 0x33, 0x2D, 0xC3, 0x68, 0x7F, 0x5E, 0x8B, 0xAD, 0x38, 0xD6, 0x28, 0x54, 0x74, - 0xA9, 0x36, 0x78, 0xF2, 0xCD, 0xC9, 0xD1, 0x51, 0xFF, 0xD9, 0x13, 0x7B, 0xE8, 0x2D, 0xF8, 0xFF, - 0x1F, 0xD9, 0x02, 0xAD, 0x47, 0x7C, 0x1F, 0x6C, 0xCE, 0x3B, 0x3B, 0xA4, 0xD8, 0x12, 0x14, 0x1C, - 0x02, 0x09, 0x29, 0x44, 0xF1, 0x6C, 0x50, 0x46, 0x57, 0xD0, 0xC4, 0x83, 0x04, 0x67, 0x68, 0xB8, - 0x92, 0x26, 0xB4, 0x19, 0x9B, 0x6B, 0xD0, 0x18, 0x52, 0xA3, 0xCA, 0x18, 0x3A, 0x37, 0x49, 0xD2, - 0x29, 0x37, 0x5C, 0x53, 0xBC, 0x15, 0x19, 0xA7, 0x21, 0x04, 0x30, 0x0A, 0x8E, 0xEB, 0xAC, 0xD0, - 0x46, 0xDA, 0x28, 0x26, 0x7B, 0x6C, 0x7C, 0x33, 0xB2, 0xBE, 0x04, 0x4A, 0xAF, 0x05, 0xDA, 0xB0, - 0x1D, 0x9F, 0x8D, 0x24, 0x29, 0x5D, 0xC5, 0x58, 0xE5, 0x30, 0xC2, 0x6A, 0x21, 0xE3, 0x02, 0x44, - 0xDB, 0xA4, 0xD8, 0xD9, 0xB5, 0x6C, 0x4C, 0x14, 0x9B, 0xA0, 0xD0, 0x00, 0xB8, 0x36, 0xF8, 0xF9, - 0xE2, 0xBB, 0x7F, 0x6A, 0xEF, 0xDE, 0xFE, 0x29, 0xD5, 0x50, 0x1E, 0x51, 0x18, 0x9C, 0x15, 0x7A, - 0xA6, 0x60, 0x4C, 0x1F, 0x81, 0x4C, 0x6A, 0x5C, 0x33, 0x14, 0x03, 0x26, 0x43, 0x16, 0xB1, 0xA7, - 0xFE, 0xEC, 0xBC, 0xA6, 0xD7, 0x70, 0x77, 0x4B, 0x70, 0xD6, 0xAE, 0x69, 0x18, 0xB8, 0xE9, 0xC1, - 0xB5, 0x61, 0x2D, 0xF1, 0xA8, 0xA5, 0xC2, 0xEB, 0xBA, 0x69, 0x49, 0x9B, 0xF1, 0x88, 0x12, 0xCA, - 0x58, 0x88, 0xC0, 0x71, 0x29, 0xD7, 0x06, 0x57, 0xC4, 0x3F, 0x3B, 0x64, 0xB7, 0x72, 0xB4, 0x96, - 0xDD, 0x37, 0xB8, 0x30, 0x33, 0x87, 0x2C, 0x13, 0xCA, 0x52, 0xFC, 0xC4, 0x35, 0xE6, 0x04, 0xA5, - 0xA2, 0xA4, 0x79, 0x51, 0xEB, 0x21, 0x64, 0x6D, 0xF0, 0x81, 0xD0, 0x2C, 0x03, 0xC8, 0x50, 0x52, - 0xFC, 0x19, 0x4F, 0xE1, 0x63, 0xFD, 0x87, 0xF6, 0xCC, 0x97, 0xEC, 0x9A, 0x06, 0x33, 0x73, 0x05, - 0xB9, 0x3F, 0x6E, 0x36, 0xB5, 0xDE, 0xBB, 0xF7, 0x5A, 0xB3, 0xA9, 0xD0, 0xD8, 0x59, 0x50, 0x77, - 0x0A, 0xF4, 0x0F, 0x16, 0xC2, 0xA8, 0x21, 0x54, 0x3F, 0xEC, 0xA8, 0x36, 0xF8, 0xE1, 0xEA, 0xE7, - 0x37, 0x2F, 0x1A, 0xED, 0x5E, 0xBF, 0x75, 0xA3, 0x9F, 0xB4, 0x5B, 0x7B, 0x67, 0x87, 0x0C, 0xAE, - 0x78, 0x07, 0x60, 0x60, 0xEF, 0xB5, 0xD7, 0x6F, 0x5F, 0x35, 0xF4, 0xD6, 0x71, 0x55, 0x64, 0xFA, - 0x49, 0x6D, 0xF0, 0xD3, 0x0F, 0x11, 0x65, 0xFD, 0x56, 0x15, 0x64, 0xC7, 0xC0, 0x26, 0xD0, 0xC5, - 0x50, 0x75, 0xBB, 0x85, 0x50, 0xA1, 0xC8, 0x3B, 0xE5, 0x44, 0xAE, 0x1F, 0x41, 0xBF, 0x94, 0x87, - 0x56, 0xF7, 0xF8, 0x46, 0xEF, 0xF5, 0xBB, 0x15, 0x78, 0xE8, 0xA3, 0x74, 0x81, 0x90, 0xC6, 0x71, - 0xBF, 0x5B, 0x15, 0x57, 0x0F, 0x71, 0x81, 0x40, 0x8E, 0xDA, 0x20, 0x8F, 0xF6, 0x71, 0x15, 0xD1, - 0x76, 0x6B, 0x03, 0xAA, 0xF2, 0x13, 0x44, 0xD5, 0x2A, 0x86, 0x0A, 0x45, 0xDB, 0x2E, 0x29, 0xDA, - 0x4E, 0x6D, 0xF0, 0x23, 0x8A, 0x16, 0x2D, 0x03, 0x78, 0xA8, 0x64, 0x1E, 0x6D, 0x88, 0x52, 0x14, - 0x57, 0x1B, 0xED, 0xB6, 0xD5, 0xAE, 0x22, 0x5A, 0xBD, 0x36, 0x40, 0x71, 0x20, 0xA6, 0xA3, 0x4A, - 0x0E, 0x00, 0xDE, 0x44, 0x69, 0x02, 0x72, 0x6E, 0x8E, 0xFA, 0xC7, 0xE5, 0x31, 0x81, 0x27, 0x5D, - 0x7D, 0x02, 0x4C, 0xC7, 0x20, 0xA8, 0x4A, 0x6E, 0x04, 0x5E, 0x84, 0x78, 0xFA, 0xDD, 0xD6, 0x4D, - 0xB7, 0x8A, 0xCD, 0x80, 0x57, 0xBC, 0x45, 0x44, 0x80, 0xE4, 0xA6, 0x53, 0x45, 0x46, 0xE0, 0x12, - 0x17, 0xDF, 0xBE, 0x6E, 0x74, 0x81, 0xB1, 0xF6, 0x49, 0xBF, 0x3C, 0x1E, 0x70, 0x87, 0x1F, 0x90, - 0x20, 0x20, 0xE6, 0xA6, 0x5D, 0x2C, 0x3A, 0xC4, 0x11, 0x81, 0x33, 0x00, 0x3C, 0xE2, 0x28, 0x8D, - 0x02, 0xEC, 0xFA, 0x2D, 0x25, 0x06, 0x11, 0xE9, 0x47, 0x15, 0xB8, 0x02, 0xAB, 0xFE, 0x01, 0xC5, - 0x03, 0x48, 0x30, 0xE8, 0x55, 0x30, 0x45, 0x40, 0x44, 0x49, 0xD2, 0xFB, 0xD4, 0xD5, 0xCA, 0x63, - 0x02, 0x9B, 0x3E, 0xE9, 0xDF, 0x9C, 0xF4, 0xD5, 0x10, 0xE0, 0x88, 0x8F, 0xA3, 0x54, 0x56, 0x4E, - 0x90, 0x9D, 0x32, 0x64, 0xA5, 0x03, 0xFF, 0x5E, 0x1A, 0x16, 0xCC, 0x6F, 0x0A, 0x27, 0x03, 0x1C, - 0x0E, 0x64, 0xC2, 0x0E, 0xD4, 0xF2, 0x00, 0x81, 0x92, 0x70, 0xA3, 0x59, 0x6D, 0xD0, 0x55, 0xC8, - 0xB7, 0x62, 0x09, 0x39, 0x85, 0x8D, 0xD1, 0x4F, 0x93, 0x40, 0xB4, 0x3C, 0x4C, 0xFF, 0xC0, 0x25, - 0x3A, 0x35, 0x21, 0x82, 0x94, 0x4A, 0x34, 0x24, 0xB4, 0x1A, 0x37, 0xB5, 0x41, 0xBF, 0x93, 0x9B, - 0xA0, 0x95, 0x57, 0xC6, 0x90, 0xD6, 0x68, 0x6C, 0xE2, 0x79, 0x85, 0xF5, 0x11, 0x81, 0xD6, 0x06, - 0x2F, 0xC3, 0xE3, 0x2A, 0x5A, 0x69, 0xE6, 0x71, 0x4A, 0x61, 0x53, 0xD4, 0x22, 0x90, 0xC3, 0x34, - 0xD3, 0xEC, 0x70, 0xD5, 0x44, 0x9A, 0xD9, 0xAC, 0x62, 0xB6, 0xA9, 0x17, 0x9C, 0x4E, 0xBA, 0x86, - 0xE7, 0x17, 0xD6, 0x4A, 0x00, 0x08, 0x11, 0x9A, 0x1F, 0xDD, 0x9B, 0x46, 0x42, 0x52, 0xFE, 0x06, - 0xFA, 0xF0, 0x0C, 0x7F, 0xC9, 0xAA, 0x85, 0x85, 0x35, 0x12, 0x81, 0x42, 0x3E, 0x10, 0x1E, 0x57, - 0xD2, 0x4A, 0x95, 0xF0, 0x25, 0x90, 0xC3, 0xF5, 0x12, 0x84, 0xB0, 0xEE, 0x96, 0xF4, 0x92, 0x47, - 0x6D, 0x25, 0xBD, 0xCC, 0x0C, 0x77, 0x51, 0x2A, 0x7C, 0x85, 0x90, 0xA0, 0x95, 0xE0, 0xF0, 0xDE, - 0x5C, 0x25, 0x22, 0xE6, 0x6F, 0xE0, 0x2B, 0x63, 0x62, 0x3B, 0xA6, 0x57, 0x7C, 0xB6, 0xCF, 0xE1, - 0x6A, 0x83, 0x57, 0xA4, 0xF9, 0x3D, 0x1E, 0x55, 0x51, 0xC7, 0x8B, 0xA5, 0xEF, 0x54, 0x50, 0x48, - 0x40, 0x0B, 0x53, 0x47, 0x8B, 0x6B, 0xE3, 0x78, 0x4B, 0xDA, 0x38, 0xDE, 0xA2, 0x36, 0x0C, 0xF2, - 0xD9, 0x22, 0xD7, 0xC4, 0x2A, 0xAC, 0x8E, 0x00, 0xB0, 0x36, 0xB8, 0xBC, 0x59, 0x38, 0x1E, 0x3E, - 0x3A, 0xF5, 0x1D, 0x9E, 0x57, 0x72, 0x92, 0x5E, 0x05, 0x9D, 0x84, 0x04, 0x71, 0x1F, 0xE9, 0x71, - 0xAD, 0xF4, 0xB6, 0xA4, 0x95, 0x3C, 0x5A, 0xAB, 0x68, 0x65, 0x6A, 0x98, 0xF6, 0x88, 0x98, 0x16, - 0x3E, 0xC6, 0x51, 0x54, 0x31, 0x02, 0x6C, 0x6D, 0xF0, 0x26, 0x3A, 0xA9, 0xA2, 0x98, 0x56, 0x05, - 0xBD, 0x88, 0xF4, 0xC4, 0xFD, 0xA5, 0x07, 0xB3, 0xF2, 0x2D, 0xE9, 0x46, 0xD7, 0xB7, 0x39, 0xAA, - 0x2C, 0xC8, 0xC8, 0x34, 0xAC, 0xCF, 0x64, 0x32, 0x81, 0x69, 0x50, 0xF1, 0xA1, 0x25, 0x06, 0x0E, - 0xE3, 0x0B, 0x3B, 0xD7, 0x2E, 0xE9, 0x79, 0xE1, 0xFA, 0x65, 0x02, 0x5D, 0xF9, 0x22, 0x66, 0x72, - 0x4E, 0x28, 0x2D, 0x4B, 0x7E, 0xEF, 0x84, 0x74, 0x96, 0x9F, 0xB6, 0x7E, 0x4F, 0xA6, 0x74, 0x1B, - 0x41, 0x95, 0x39, 0xF4, 0x1B, 0xD7, 0xB8, 0xA5, 0xEF, 0x64, 0xA8, 0x32, 0xA5, 0xFF, 0x40, 0xC6, - 0xDA, 0x47, 0xD3, 0x2E, 0xCF, 0x4C, 0x17, 0x09, 0x21, 0xC4, 0xAE, 0x86, 0xA5, 0x07, 0x53, 0x24, - 0x38, 0xA8, 0x86, 0xA4, 0x8F, 0x35, 0xFD, 0x85, 0x69, 0x3C, 0x84, 0x49, 0xBC, 0xB1, 0x1A, 0x16, - 0x1F, 0x50, 0x56, 0x43, 0x18, 0x97, 0x7F, 0x7A, 0xA9, 0x5D, 0xD2, 0x8D, 0xEF, 0x85, 0xC3, 0x15, - 0xDB, 0x93, 0xA7, 0x62, 0xE8, 0xD1, 0xD2, 0x0D, 0xF6, 0xB9, 0xB6, 0xA6, 0x26, 0x77, 0x20, 0xD5, - 0x75, 0x35, 0x09, 0x7B, 0x01, 0x81, 0x74, 0x0B, 0x53, 0x4D, 0xE0, 0x56, 0x8D, 0xC7, 0x2D, 0xA6, - 0x62, 0xA3, 0x55, 0xF1, 0x34, 0x6C, 0xB4, 0x02, 0x35, 0x8D, 0xAF, 0xF1, 0x99, 0x88, 0xB1, 0x06, - 0xFA, 0xDA, 0x89, 0xA2, 0xB0, 0xD7, 0xFB, 0x51, 0x14, 0xE5, 0xF7, 0xBE, 0x15, 0x05, 0xD6, 0xF2, - 0x19, 0xC7, 0xD1, 0x32, 0x4E, 0x45, 0x01, 0x6B, 0x83, 0x77, 0x86, 0xBD, 0x84, 0x41, 0x66, 0x57, - 0x0A, 0x0B, 0x3B, 0xBE, 0x37, 0xF7, 0xE2, 0x7C, 0xDF, 0xB7, 0xEA, 0x80, 0x90, 0xB9, 0x33, 0x2E, - 0x3E, 0xDD, 0xE1, 0x70, 0x2C, 0x24, 0xBE, 0x83, 0xA3, 0xC2, 0x89, 0x41, 0x80, 0x61, 0xCB, 0x19, - 0x01, 0x9B, 0x4A, 0x95, 0x4F, 0x06, 0xAE, 0x96, 0xB6, 0x7D, 0x5B, 0x25, 0x13, 0xB8, 0xB0, 0x9C, - 0xE5, 0xB8, 0x3C, 0x06, 0x48, 0x03, 0xFE, 0x35, 0x99, 0x98, 0xA3, 0xF2, 0x89, 0x04, 0x24, 0x01, - 0x6F, 0x9D, 0xB9, 0x22, 0xFC, 0x96, 0x07, 0x5E, 0x32, 0x2A, 0x31, 0x93, 0x1B, 0x81, 0x16, 0x2F, - 0x2F, 0x76, 0x3A, 0xF0, 0x42, 0x9F, 0xF7, 0x14, 0x19, 0x90, 0xDB, 0xFB, 0x0E, 0x0A, 0x40, 0xC4, - 0x67, 0x6A, 0x3C, 0x65, 0x94, 0xC5, 0x20, 0xC3, 0x88, 0x1E, 0x4C, 0xBF, 0xEF, 0x6B, 0x7E, 0x17, - 0x51, 0x14, 0x9F, 0xDD, 0xE1, 0xD2, 0x73, 0x38, 0xBD, 0xEB, 0xB4, 0x37, 0x3B, 0xC1, 0x43, 0xE4, - 0xDB, 0xD5, 0x4F, 0xBB, 0x8C, 0x6A, 0x20, 0x1A, 0x7D, 0x8F, 0xEB, 0x0C, 0x05, 0x02, 0x76, 0x75, - 0x47, 0x6A, 0xDF, 0x9F, 0x27, 0xB5, 0x1F, 0x80, 0x2B, 0x4D, 0x4B, 0x44, 0xBC, 0x29, 0x46, 0xBC, - 0x37, 0x17, 0xBB, 0xD1, 0xD0, 0xF4, 0xDE, 0x42, 0xDD, 0xF4, 0x5E, 0x43, 0x9D, 0xC6, 0x37, 0x05, - 0x06, 0x52, 0x28, 0x99, 0xC1, 0x72, 0x40, 0x56, 0xCB, 0xAA, 0x12, 0xE4, 0xF4, 0x9B, 0x2A, 0x51, - 0x2E, 0x20, 0x23, 0x1E, 0xE4, 0xFA, 0xD1, 0xAA, 0x48, 0x6F, 0xB3, 0xCB, 0xBA, 0xDD, 0x3C, 0x6A, - 0xAB, 0x38, 0x8D, 0x6B, 0xAC, 0x3E, 0x4F, 0xE7, 0x46, 0x61, 0x65, 0x70, 0x38, 0xD0, 0xC5, 0xBB, - 0x17, 0xBB, 0x4C, 0x17, 0x82, 0x7E, 0xEF, 0xC7, 0x8F, 0x42, 0xAE, 0xEF, 0x3B, 0xD6, 0x59, 0xC4, - 0x2E, 0x1E, 0xEC, 0x10, 0xA8, 0x36, 0xF8, 0x8E, 0xD8, 0x9E, 0x76, 0xE1, 0xB8, 0xFC, 0x45, 0x98, - 0x3B, 0xD1, 0x1A, 0xED, 0xF9, 0x7E, 0x54, 0xC6, 0x98, 0xBE, 0x6F, 0x7D, 0xCD, 0xE6, 0xA6, 0xEB, - 0x3A, 0x6E, 0x61, 0x95, 0x71, 0x38, 0x98, 0x56, 0x34, 0xDF, 0xD1, 0xA3, 0x9D, 0xA8, 0x2B, 0xE8, - 0xF5, 0x7E, 0x34, 0x16, 0xF2, 0x7C, 0xDF, 0x4A, 0xBB, 0x9E, 0x58, 0xE6, 0xA2, 0xB0, 0xCA, 0x28, - 0x54, 0x6D, 0xF0, 0xA9, 0xF9, 0x1A, 0xFE, 0xEE, 0x44, 0x5D, 0xAC, 0xC7, 0xFB, 0x51, 0x16, 0xE7, - 0xF6, 0xBE, 0x55, 0x35, 0x5C, 0x14, 0x0F, 0x87, 0x00, 0x53, 0x1B, 0xBC, 0x7C, 0xBF, 0x9B, 0xDC, - 0x0F, 0x3B, 0x53, 0xD4, 0x50, 0x25, 0x7D, 0x50, 0xA6, 0xEE, 0x5B, 0x1B, 0xAB, 0x12, 0xDA, 0x58, - 0x21, 0xE1, 0x3F, 0xED, 0x48, 0x1B, 0x2B, 0x75, 0x6D, 0x6C, 0xD8, 0x5F, 0x56, 0x0F, 0x41, 0x3F, - 0xF4, 0xE9, 0xD3, 0xA1, 0x51, 0x7C, 0x38, 0x0A, 0x00, 0x71, 0xD3, 0x18, 0x1C, 0x69, 0x2F, 0x8D, - 0xDD, 0x0C, 0x48, 0x61, 0xBF, 0xBB, 0x70, 0xA1, 0x88, 0xC9, 0xFB, 0xD6, 0xD3, 0xC4, 0x18, 0x91, - 0xCF, 0x63, 0xE2, 0x97, 0x59, 0x5B, 0x16, 0x60, 0x6B, 0x83, 0xD7, 0x70, 0xA2, 0xBD, 0xA2, 0x27, - 0xBB, 0x4A, 0xF9, 0xC4, 0xFE, 0x77, 0xA1, 0xB5, 0x18, 0xBF, 0x0F, 0x42, 0x71, 0x90, 0x60, 0x3B, - 0x53, 0xBB, 0xD4, 0xE3, 0x4C, 0x31, 0x70, 0xAE, 0xBE, 0x0F, 0xEC, 0x7C, 0xB7, 0x0A, 0x8C, 0x88, - 0xD8, 0x99, 0x0E, 0x05, 0xBE, 0x37, 0xA8, 0x46, 0xC5, 0xA7, 0x1A, 0xF9, 0x9B, 0x81, 0xF3, 0x74, - 0xC5, 0x9F, 0xAE, 0xA3, 0x9B, 0x5A, 0x88, 0xDF, 0xF4, 0x7C, 0xD3, 0xB2, 0x60, 0x2A, 0x4C, 0x7C, - 0xED, 0x0A, 0x0F, 0x15, 0x1F, 0xA7, 0x13, 0xB0, 0x04, 0x0F, 0xD1, 0xFA, 0x2E, 0x31, 0xE6, 0xB5, - 0xC1, 0x15, 0xBE, 0x33, 0x19, 0x70, 0xE1, 0x59, 0x71, 0x64, 0x54, 0x8C, 0xC4, 0x76, 0x1D, 0x20, - 0x2A, 0x54, 0x13, 0x7F, 0x3F, 0x65, 0x4D, 0x0B, 0x8E, 0x84, 0x6B, 0x83, 0x4B, 0xDA, 0x58, 0x43, - 0x3B, 0xCB, 0xEF, 0x4E, 0xF9, 0x39, 0x3F, 0xFA, 0x44, 0x2F, 0x3E, 0xA2, 0x1B, 0x7F, 0xA3, 0x3A, - 0xE8, 0x95, 0xBD, 0xD5, 0x60, 0x70, 0x46, 0xDF, 0x0E, 0xCB, 0x9B, 0xD1, 0x87, 0xD9, 0x57, 0xFC, - 0xE9, 0xE4, 0xA1, 0x63, 0x8D, 0x9F, 0x09, 0xAB, 0xCB, 0x57, 0xE1, 0xE3, 0xB6, 0x08, 0x02, 0x86, - 0x11, 0x60, 0xC8, 0x51, 0xFE, 0xCC, 0x0D, 0xD0, 0xB3, 0x27, 0xA2, 0xF1, 0x1D, 0x65, 0x19, 0xCA, - 0x4D, 0x79, 0x34, 0xD8, 0x25, 0xD3, 0x50, 0x90, 0xB2, 0x27, 0xC6, 0xA5, 0x0F, 0x0A, 0x7F, 0x20, - 0x53, 0xD3, 0x03, 0x1A, 0x35, 0x30, 0x8B, 0x43, 0xFA, 0x8C, 0x25, 0x33, 0x66, 0xB5, 0xE7, 0x77, - 0xC5, 0x2E, 0xF9, 0xCB, 0x19, 0xA4, 0x8F, 0x63, 0x17, 0xCA, 0x4F, 0x92, 0x0F, 0x4F, 0xC7, 0x31, - 0xE6, 0x19, 0xFD, 0xE3, 0x66, 0x73, 0xD6, 0xC5, 0xA7, 0x45, 0xB5, 0x80, 0xB5, 0xB3, 0xC3, 0x59, - 0x37, 0xEF, 0xD1, 0xB0, 0xDC, 0x47, 0x7D, 0x81, 0xD3, 0xD2, 0x4F, 0xFA, 0xA2, 0x94, 0x06, 0x40, - 0xCD, 0xBE, 0xF6, 0xCE, 0xF0, 0xBE, 0xEC, 0x6B, 0x9F, 0xB0, 0x00, 0xB7, 0xC3, 0x07, 0x7E, 0x91, - 0x76, 0x63, 0x3C, 0x76, 0x53, 0x1F, 0xFA, 0xED, 0xC6, 0x1E, 0xFA, 0xED, 0x07, 0x0F, 0xFD, 0xF6, - 0xA3, 0xDD, 0x6E, 0x37, 0x9D, 0x56, 0xEB, 0x58, 0x85, 0x75, 0xC5, 0x07, 0x7F, 0x37, 0xC2, 0xD3, - 0x1C, 0xA4, 0xA9, 0xC8, 0x53, 0x37, 0xE0, 0x49, 0xD8, 0x15, 0x7E, 0x33, 0x99, 0x3C, 0x34, 0x8E, - 0xF8, 0xBA, 0x54, 0x79, 0x96, 0x5A, 0xED, 0x5D, 0x3F, 0x9D, 0x4D, 0x8D, 0x7B, 0x53, 0x0F, 0x67, - 0xD3, 0x26, 0xC9, 0x68, 0xD8, 0xCB, 0x0C, 0x86, 0x14, 0x84, 0x39, 0xFD, 0x9B, 0x4D, 0x3A, 0xFD, - 0xB4, 0x82, 0xD3, 0x4F, 0xD7, 0x9C, 0x7E, 0x87, 0xDE, 0x1E, 0x10, 0xFE, 0x77, 0xF3, 0xF8, 0x80, - 0xAF, 0x02, 0x5E, 0x2F, 0xE5, 0xAB, 0xD5, 0xDA, 0xA8, 0xDF, 0xE7, 0x3A, 0x49, 0x68, 0x0C, 0x6F, - 0x36, 0xE9, 0x24, 0x29, 0xA6, 0x5B, 0xCA, 0x4E, 0x79, 0xD8, 0x19, 0xEC, 0x66, 0x5C, 0xA2, 0xD9, - 0x94, 0xA8, 0x50, 0xDE, 0x3B, 0x3E, 0x13, 0xDA, 0xE9, 0xF2, 0xD4, 0x69, 0x13, 0xEA, 0x51, 0x7F, - 0x11, 0x44, 0x6A, 0x93, 0xCD, 0x24, 0x66, 0x0B, 0x21, 0xC3, 0x55, 0x4E, 0xCC, 0xDE, 0x7F, 0xF7, - 0x5D, 0xB1, 0x5C, 0x4C, 0xEC, 0xE5, 0x81, 0xE4, 0x62, 0x99, 0xB5, 0xD0, 0xDB, 0x05, 0xDC, 0x40, - 0xAA, 0x4B, 0x99, 0x6E, 0x04, 0x5E, 0x1B, 0xBC, 0xA4, 0xC7, 0x9A, 0x20, 0xB1, 0x42, 0xC6, 0xAB, - 0x3C, 0xED, 0xA4, 0x80, 0x42, 0xB1, 0x34, 0x22, 0x21, 0xA9, 0x1B, 0x45, 0x5C, 0x19, 0x05, 0x52, - 0x81, 0x3D, 0x75, 0xA6, 0x2A, 0xFB, 0x04, 0x6D, 0x92, 0x97, 0x0A, 0x2F, 0x5C, 0x52, 0x5A, 0x6D, - 0x1C, 0xB6, 0x36, 0x78, 0xEF, 0x12, 0xED, 0x95, 0x79, 0xAD, 0xCE, 0x9B, 0xB0, 0x51, 0x30, 0x44, - 0xA2, 0x26, 0xE5, 0xE4, 0x0E, 0x3E, 0xE9, 0xAE, 0x40, 0x5C, 0x6C, 0x57, 0xDD, 0x4E, 0x27, 0xC1, - 0x0A, 0x69, 0x57, 0xBB, 0x1A, 0x86, 0x4E, 0x6D, 0xD0, 0xA9, 0x86, 0xA1, 0x5B, 0x1B, 0x74, 0xAB, - 0x61, 0xE8, 0x81, 0x1C, 0x0E, 0x7A, 0xD5, 0x70, 0xF4, 0x6B, 0x83, 0x7E, 0x35, 0x0C, 0x47, 0x20, - 0xCB, 0xAA, 0x54, 0x40, 0xE6, 0x72, 0x5C, 0x00, 0x43, 0xFE, 0x26, 0x47, 0xD6, 0xAA, 0xBA, 0xF3, - 0xCC, 0x97, 0x56, 0x69, 0xE7, 0xE1, 0xB0, 0xB5, 0xC1, 0xBB, 0xA5, 0xE5, 0x9B, 0x0B, 0xCB, 0x84, - 0x69, 0x7B, 0xA3, 0xAB, 0x35, 0xB5, 0x76, 0xAF, 0xBD, 0xB7, 0xC3, 0x0C, 0x33, 0xA0, 0x43, 0xED, - 0x1D, 0x52, 0x9D, 0x20, 0x09, 0xD3, 0x8F, 0xC5, 0x77, 0x0A, 0x3C, 0x88, 0x70, 0xE6, 0x3A, 0x8E, - 0x5F, 0x5A, 0x1D, 0x01, 0x30, 0xA4, 0xF9, 0x70, 0x54, 0x3A, 0x9A, 0x45, 0x68, 0xCA, 0x18, 0x7A, - 0xCA, 0x26, 0xE7, 0x8A, 0xE1, 0x4C, 0x2F, 0x16, 0xCE, 0x76, 0xE7, 0x3E, 0xDE, 0x6D, 0xF9, 0x94, - 0x81, 0xC3, 0xC2, 0x6C, 0xF5, 0x16, 0x66, 0x88, 0x73, 0x54, 0x98, 0xD6, 0x68, 0x81, 0xFB, 0xE8, - 0xBD, 0x5D, 0x7A, 0x4F, 0x40, 0x46, 0xC1, 0x37, 0xB0, 0x89, 0xDE, 0xF3, 0x30, 0x9C, 0x87, 0xEA, - 0x83, 0x58, 0xE3, 0x5E, 0x79, 0x8D, 0x04, 0xD0, 0x90, 0x0F, 0xE0, 0x7B, 0xF0, 0x2A, 0xF9, 0x91, - 0x80, 0xAC, 0x9C, 0x23, 0x55, 0x77, 0x1A, 0xA9, 0x2B, 0x56, 0xCC, 0x0B, 0xDA, 0x95, 0x47, 0xF5, - 0xCE, 0x43, 0x1C, 0x0B, 0x17, 0xF8, 0xEA, 0x44, 0xA2, 0xB6, 0xBD, 0x92, 0x22, 0x13, 0x93, 0x48, - 0x06, 0xCB, 0x8D, 0x86, 0x6D, 0x2B, 0xDF, 0x69, 0xE6, 0x1F, 0x10, 0xB0, 0xBE, 0xDA, 0x54, 0x7C, - 0x15, 0x5E, 0x60, 0x4E, 0x36, 0x11, 0x08, 0x79, 0x7D, 0x70, 0xB3, 0x00, 0x24, 0xAC, 0xF4, 0x34, - 0x80, 0x03, 0x73, 0x15, 0x86, 0x91, 0xB8, 0xA3, 0x17, 0x88, 0xC4, 0xE2, 0x8C, 0x20, 0xC4, 0x57, - 0x72, 0xB4, 0xBB, 0xF7, 0xF4, 0xBF, 0x2B, 0x0F, 0x1E, 0x15, 0x5D, 0xBF, 0x60, 0x1A, 0x2C, 0xC1, - 0x80, 0xEF, 0x0B, 0xD4, 0x8B, 0x24, 0xF4, 0x9B, 0x0B, 0x1F, 0xC2, 0x5A, 0x21, 0x35, 0x38, 0x3A, - 0x74, 0x13, 0x9F, 0xCD, 0xF3, 0x0B, 0x2C, 0xFF, 0xA5, 0x36, 0xD9, 0x4C, 0x75, 0x67, 0x65, 0xDA, - 0xC5, 0xAB, 0x3B, 0x3F, 0x99, 0xF6, 0xD8, 0x59, 0x15, 0x2B, 0xF0, 0x88, 0x1D, 0xFD, 0x05, 0x0A, - 0x3C, 0x34, 0x3D, 0xC0, 0x15, 0xE2, 0xA6, 0x4B, 0xD4, 0xDE, 0x67, 0x93, 0x14, 0x32, 0x83, 0xBE, - 0xC1, 0x05, 0x56, 0x40, 0xE1, 0x69, 0x74, 0xBD, 0x79, 0xDB, 0x99, 0xDA, 0xCF, 0xA7, 0x62, 0xAE, - 0xC6, 0x29, 0x50, 0xCB, 0xD5, 0xBA, 0x92, 0x72, 0xF3, 0xBD, 0x57, 0xD0, 0x7F, 0x59, 0xE7, 0xE7, - 0xF6, 0xDE, 0xF9, 0xD9, 0xC4, 0x00, 0x44, 0xEC, 0x71, 0x69, 0xCB, 0x42, 0xD8, 0xC8, 0xAE, 0x2E, - 0xED, 0xF1, 0x4E, 0xAD, 0x8A, 0xF5, 0x5E, 0x5A, 0x07, 0xED, 0x7E, 0xBB, 0xF3, 0xB0, 0xCC, 0x0A, - 0x19, 0xAA, 0x60, 0x54, 0xFA, 0x49, 0xEF, 0x01, 0x4D, 0x69, 0x9C, 0xC9, 0x84, 0xAD, 0x6B, 0x96, - 0x33, 0x2D, 0x0E, 0x7E, 0x43, 0x9F, 0xD2, 0xF5, 0xC8, 0x6E, 0xE3, 0x55, 0xD8, 0x79, 0xC1, 0xD2, - 0x8C, 0xA0, 0x8B, 0xFE, 0xC3, 0x32, 0x2D, 0xCE, 0x91, 0xAA, 0x75, 0x49, 0x38, 0xEA, 0x3E, 0x1C, - 0xD3, 0xF2, 0x1D, 0xDF, 0xB0, 0x4A, 0x5B, 0x16, 0x83, 0x06, 0xC3, 0xFA, 0x88, 0x07, 0xDA, 0x15, - 0xF0, 0xB9, 0x53, 0xE3, 0x0A, 0xFA, 0x2F, 0x1F, 0xB8, 0x8E, 0xBB, 0x1B, 0x52, 0x46, 0x05, 0x96, - 0x7E, 0x59, 0x67, 0xA9, 0x52, 0xE8, 0xEA, 0x6F, 0x68, 0x91, 0x7C, 0x23, 0xA1, 0x6B, 0xE9, 0xE3, - 0xD5, 0xD2, 0xA1, 0x8B, 0x81, 0x63, 0xE8, 0xA2, 0x47, 0xBB, 0x37, 0xB1, 0x90, 0x82, 0xF2, 0x36, - 0xD6, 0x3B, 0xD9, 0xE4, 0x16, 0x98, 0x4D, 0x44, 0x30, 0xC6, 0x53, 0x25, 0x23, 0xDB, 0x94, 0xDF, - 0x54, 0x36, 0xB2, 0x91, 0xA1, 0xFC, 0x8E, 0x37, 0x8A, 0x4C, 0xCC, 0xE6, 0x19, 0x2C, 0xCC, 0xE1, - 0xD8, 0xC1, 0x4E, 0x2B, 0x36, 0x41, 0xE7, 0x1B, 0x5F, 0xA8, 0x0D, 0xB9, 0x7A, 0x48, 0xF5, 0x99, - 0xA1, 0x69, 0xDB, 0x65, 0xD5, 0xC4, 0x61, 0x6B, 0x83, 0x97, 0xEC, 0x60, 0xB7, 0x4B, 0xEA, 0xBC, - 0xF3, 0xCD, 0xAF, 0xA7, 0x07, 0x5C, 0xED, 0x5A, 0x4D, 0x89, 0x22, 0x86, 0x1B, 0x7E, 0xF7, 0xA3, - 0xC6, 0xF7, 0xA8, 0x46, 0xDF, 0x01, 0x79, 0x38, 0x25, 0x8D, 0xA9, 0x31, 0xC7, 0x87, 0x97, 0x8B, - 0x16, 0x35, 0xDE, 0x20, 0x58, 0xB1, 0x9A, 0x46, 0xBC, 0xA7, 0x87, 0x5D, 0xD5, 0x18, 0xC4, 0xDF, - 0x5A, 0x09, 0x84, 0x37, 0x87, 0xA6, 0xE1, 0xE1, 0x83, 0xFE, 0x70, 0xAC, 0xBD, 0x84, 0x63, 0xED, - 0xBD, 0xB5, 0x0C, 0x5F, 0xBB, 0x2B, 0x73, 0x08, 0x71, 0x3F, 0x5B, 0x84, 0x21, 0xED, 0xA9, 0x05, - 0xBA, 0x8D, 0x8F, 0x3F, 0xE0, 0x05, 0xC7, 0xB8, 0x7B, 0xAD, 0xD7, 0x3D, 0x6E, 0xD5, 0x34, 0x96, - 0x15, 0xF3, 0xC7, 0xFA, 0xBD, 0x2F, 0x74, 0x5B, 0x9B, 0x1E, 0x12, 0x28, 0x73, 0x00, 0x91, 0xDE, - 0x90, 0x40, 0x6A, 0xBF, 0x55, 0x76, 0x9B, 0xAD, 0x4B, 0x44, 0x0F, 0xC4, 0xD1, 0x92, 0x1A, 0x42, - 0xEC, 0x3D, 0x9B, 0xAC, 0x7D, 0xFC, 0x05, 0x05, 0xED, 0x9E, 0xEC, 0xFD, 0xA7, 0x72, 0x41, 0xE8, - 0x52, 0x41, 0xE0, 0xEE, 0xBE, 0xCD, 0xF2, 0xD4, 0x0E, 0x78, 0xD2, 0xD5, 0x78, 0x6A, 0x57, 0xE0, - 0xA9, 0xBD, 0x23, 0x9E, 0x3A, 0x01, 0x4F, 0x6D, 0x35, 0x9E, 0x3A, 0x15, 0x78, 0xEA, 0xEC, 0x88, - 0xA7, 0x6E, 0xC0, 0x53, 0x47, 0x8D, 0xA7, 0x6E, 0x05, 0x9E, 0xBA, 0x3B, 0xE2, 0xA9, 0x17, 0xF0, - 0xD4, 0x55, 0xE3, 0xA9, 0x57, 0x81, 0xA7, 0xDE, 0x8E, 0x78, 0xEA, 0x07, 0x3C, 0xF5, 0xD4, 0x78, - 0xEA, 0x57, 0xE0, 0xA9, 0xBF, 0x23, 0x9E, 0x8E, 0x02, 0x9E, 0xFA, 0x6A, 0x3C, 0x1D, 0x55, 0xE0, - 0xE9, 0x68, 0x47, 0x3C, 0x1D, 0x07, 0x3C, 0x1D, 0xA9, 0xF1, 0x74, 0x5C, 0x81, 0xA7, 0xE3, 0x1D, - 0xF1, 0x74, 0x12, 0xF0, 0x74, 0xAC, 0xC6, 0xD3, 0x49, 0x05, 0x9E, 0x4E, 0x76, 0xC4, 0x13, 0xEE, - 0xA6, 0x62, 0x4C, 0x9D, 0x28, 0x0E, 0xBA, 0xAD, 0x0A, 0x5C, 0x19, 0xBB, 0xE2, 0x2A, 0x4C, 0x25, - 0x74, 0xD5, 0x5C, 0xA2, 0x4A, 0x32, 0x31, 0xDC, 0x15, 0x5B, 0x51, 0x36, 0xA1, 0x98, 0x4E, 0xE8, - 0x55, 0xF2, 0x89, 0xD1, 0xAE, 0xD8, 0x0A, 0x13, 0x0A, 0x5D, 0x31, 0xA3, 0xD0, 0xAB, 0xA4, 0x14, - 0xE3, 0x5D, 0xB1, 0x15, 0xE6, 0x14, 0xBA, 0x62, 0x52, 0xA1, 0x57, 0xC9, 0x2A, 0xC8, 0xAE, 0xD8, - 0x0A, 0xD3, 0x0A, 0x5D, 0x31, 0xAF, 0xD0, 0xAB, 0x24, 0x16, 0x93, 0x5D, 0xB1, 0x15, 0x66, 0x16, - 0xBA, 0x62, 0x6A, 0xA1, 0x57, 0xC8, 0x2D, 0x4E, 0xE4, 0x13, 0xB1, 0x8D, 0xB2, 0x45, 0x7C, 0x3E, - 0x45, 0x8E, 0x26, 0x6D, 0x4A, 0x0F, 0x1C, 0x71, 0x20, 0x7C, 0x22, 0x8E, 0x09, 0xE4, 0xC2, 0xB1, - 0x27, 0xE6, 0x34, 0x2C, 0x32, 0x3C, 0x98, 0x67, 0x63, 0x3C, 0xE1, 0x85, 0xBF, 0xCA, 0x85, 0x86, - 0xAB, 0x57, 0x97, 0xC5, 0xCA, 0x0C, 0x62, 0x2F, 0x7F, 0xA1, 0x22, 0x03, 0x90, 0xDD, 0x16, 0xBF, - 0x3E, 0xA0, 0x54, 0x57, 0xA0, 0x40, 0x45, 0x2A, 0x0A, 0x3D, 0xB1, 0xA2, 0xD0, 0x57, 0xAE, 0x28, - 0x30, 0xE2, 0xB6, 0x53, 0x4B, 0x00, 0xDC, 0x1D, 0xF6, 0xC9, 0x04, 0x75, 0xA6, 0x3B, 0xE5, 0x99, - 0xEE, 0x15, 0x61, 0xBA, 0x53, 0x86, 0xE9, 0x12, 0xCF, 0xB4, 0x2A, 0xCA, 0x09, 0xE8, 0x7D, 0x6D, - 0xDE, 0x90, 0xB1, 0xF6, 0x8B, 0xBA, 0xA8, 0xF4, 0xF2, 0xA2, 0x3A, 0x2A, 0x22, 0x2A, 0x7D, 0x8B, - 0xF6, 0xD1, 0x0B, 0xF8, 0xFE, 0x51, 0x9D, 0xEF, 0x5E, 0x79, 0xBE, 0x3B, 0x45, 0xF8, 0xEE, 0x6D, - 0x91, 0xEF, 0x6E, 0xC0, 0xF7, 0x27, 0x75, 0xBE, 0xBB, 0xE5, 0xF9, 0xEE, 0x16, 0xE1, 0xBB, 0xBB, - 0x45, 0xBE, 0xDB, 0x10, 0x6C, 0x7E, 0xFC, 0xA4, 0x7D, 0x9C, 0xB9, 0xC4, 0x9B, 0xE5, 0x57, 0xE2, - 0x18, 0x44, 0xD9, 0xB1, 0xBD, 0xB7, 0x83, 0xB9, 0x1B, 0x52, 0xD8, 0x11, 0x79, 0xCA, 0xCD, 0x9B, - 0x19, 0x84, 0xCA, 0x37, 0x89, 0xE4, 0x3C, 0xC9, 0x67, 0x6E, 0xBA, 0x2A, 0x53, 0xDB, 0x8B, 0x61, - 0xC7, 0xB5, 0xC1, 0xDB, 0x65, 0x81, 0xF1, 0xED, 0xB8, 0xBC, 0x3D, 0xAB, 0x57, 0xCC, 0x19, 0x5D, - 0x5B, 0xB3, 0xE7, 0x13, 0xCA, 0x33, 0xE4, 0x65, 0x9E, 0x82, 0xDA, 0xCB, 0x57, 0x21, 0x7A, 0x3B, - 0xA8, 0x92, 0x63, 0xA4, 0x3F, 0x62, 0xEC, 0xFC, 0x88, 0x0C, 0x69, 0x90, 0xB1, 0x14, 0x18, 0x8C, - 0x8E, 0x0A, 0x6A, 0xF3, 0xB8, 0x64, 0x74, 0x42, 0x1A, 0xB7, 0xA6, 0x4E, 0x9C, 0x7A, 0xA0, 0x00, - 0x3E, 0x95, 0x10, 0x40, 0xBF, 0xBC, 0x00, 0x0A, 0x65, 0x2E, 0x48, 0xE3, 0xF6, 0x04, 0xD0, 0x62, - 0x02, 0xB8, 0x8A, 0x5E, 0x7A, 0x9D, 0x61, 0xD0, 0x15, 0x2A, 0x50, 0xBD, 0x1D, 0xAC, 0x91, 0x60, - 0xA4, 0xD5, 0x03, 0x8B, 0x06, 0x8E, 0x8A, 0x29, 0xB4, 0x5D, 0x34, 0xBF, 0x92, 0x17, 0x3F, 0x15, - 0xF2, 0xEF, 0x6D, 0x26, 0x58, 0xED, 0x56, 0x60, 0xD1, 0xC5, 0x05, 0xD0, 0x2A, 0x2F, 0x00, 0xBD, - 0x90, 0x00, 0x5A, 0x0F, 0x2B, 0x19, 0xEF, 0xAF, 0x7F, 0xA7, 0x38, 0x5F, 0x5A, 0x45, 0xDD, 0x5F, - 0x18, 0xCD, 0xDA, 0x45, 0x84, 0xB5, 0x55, 0xEF, 0xEF, 0x44, 0x9C, 0x6B, 0xBF, 0x68, 0xF1, 0xAD, - 0xAF, 0x59, 0x71, 0xA0, 0x7C, 0x11, 0xB0, 0xB7, 0x83, 0xF5, 0x2A, 0xA4, 0xF0, 0x44, 0xC2, 0x59, - 0xC1, 0x00, 0x7F, 0x52, 0xDE, 0x1D, 0x0A, 0x69, 0x18, 0x69, 0xDD, 0x9E, 0x8A, 0x7B, 0x31, 0x41, - 0xB0, 0x6F, 0xA4, 0xAB, 0xA8, 0xB8, 0x7C, 0xE5, 0xB0, 0xB7, 0x83, 0xA5, 0x2E, 0xA4, 0xF0, 0x58, - 0xC2, 0x59, 0x41, 0x15, 0x17, 0x4D, 0x49, 0x8F, 0x4B, 0x4E, 0x2D, 0xF5, 0x6D, 0xE6, 0xA4, 0x58, - 0xED, 0x16, 0x04, 0x21, 0x7E, 0xC0, 0x22, 0x4B, 0xC1, 0xE5, 0x2B, 0xDE, 0xBD, 0x8A, 0xEB, 0xB3, - 0xDB, 0x8B, 0xE4, 0x47, 0xB2, 0xAF, 0x9B, 0xE7, 0xDB, 0x41, 0xD1, 0x5C, 0xB6, 0x55, 0x72, 0xE0, - 0xDB, 0x6A, 0x2A, 0x0B, 0xBD, 0x43, 0xD6, 0xB3, 0xCE, 0x7D, 0x86, 0x09, 0x94, 0x5F, 0x79, 0xEB, - 0xED, 0x60, 0x7B, 0x08, 0x52, 0xD8, 0xAE, 0x0D, 0x3E, 0x15, 0x64, 0xAA, 0x4A, 0xFD, 0xA0, 0xF4, - 0xFE, 0x90, 0xDD, 0x95, 0xDE, 0x47, 0xF3, 0x9B, 0xE2, 0xA5, 0xF7, 0x8B, 0x77, 0x3F, 0x17, 0x2B, - 0xBD, 0x8B, 0xBD, 0xEC, 0xAE, 0xF4, 0x5E, 0xCE, 0x66, 0x0A, 0x6D, 0x94, 0x05, 0xC6, 0xF0, 0x55, - 0x48, 0x23, 0xD3, 0xA3, 0x5D, 0x82, 0x60, 0xB4, 0xF7, 0xC1, 0x69, 0x28, 0x22, 0xE1, 0x19, 0xE5, - 0x78, 0xFB, 0x2C, 0xEB, 0xE9, 0x64, 0x84, 0x85, 0x52, 0xCF, 0xF0, 0xE2, 0x0B, 0x75, 0xFA, 0xFC, - 0x1B, 0x5F, 0x15, 0x9E, 0x05, 0x4E, 0x7B, 0xD7, 0xC8, 0xC1, 0x51, 0x41, 0xDC, 0x5B, 0x7F, 0xC5, - 0xC0, 0x20, 0xA1, 0x28, 0x9D, 0xEA, 0x47, 0xC7, 0x73, 0xE5, 0x3A, 0x39, 0x05, 0x2B, 0x12, 0xCD, - 0x3B, 0x62, 0xA9, 0x45, 0x3D, 0x9A, 0x33, 0xF2, 0xB6, 0x13, 0xCD, 0x11, 0x77, 0x8C, 0xF7, 0x02, - 0x59, 0x0D, 0x83, 0x2D, 0x26, 0x00, 0xF9, 0x26, 0x0A, 0x05, 0x01, 0xA4, 0x49, 0x60, 0x23, 0x22, - 0x68, 0x53, 0x09, 0xB4, 0x13, 0xDA, 0x4F, 0x09, 0xFC, 0xB4, 0x7D, 0xD9, 0xB8, 0xDF, 0xD9, 0x41, - 0x6D, 0x02, 0xC5, 0x15, 0xE3, 0xA8, 0xA0, 0x4E, 0x8B, 0x2D, 0x0E, 0xC6, 0x74, 0x5A, 0xCC, 0xA8, - 0xB7, 0xB6, 0x3A, 0x08, 0xC8, 0x3B, 0x54, 0x00, 0x1D, 0x65, 0x95, 0x96, 0x9F, 0x66, 0x76, 0x76, - 0x90, 0x9F, 0xA0, 0xB4, 0x62, 0x1C, 0x15, 0x54, 0x69, 0xB1, 0xA5, 0xCF, 0x98, 0x4A, 0xD5, 0xE7, - 0x97, 0x9C, 0xC8, 0xAD, 0xA9, 0xB4, 0x4B, 0x05, 0xD0, 0x55, 0x56, 0x69, 0xF9, 0x59, 0x47, 0x67, - 0x07, 0xBB, 0x77, 0x51, 0x5A, 0x31, 0x8E, 0x0A, 0xAA, 0xB4, 0xD8, 0x92, 0x5D, 0x4C, 0xA5, 0xEA, - 0xF3, 0x49, 0x4E, 0xE4, 0xD6, 0x54, 0xDA, 0xA3, 0x02, 0xE8, 0x29, 0xAB, 0xB4, 0x7C, 0xA5, 0xA0, - 0xB3, 0x83, 0x62, 0x10, 0x4A, 0x2B, 0xC6, 0x51, 0x41, 0x95, 0x16, 0x5B, 0x7D, 0x8E, 0xA9, 0x54, - 0x7D, 0x9D, 0x83, 0x13, 0xB9, 0x35, 0x95, 0xF6, 0xA9, 0x00, 0xFA, 0xCA, 0x2A, 0x2D, 0xBF, 0xBF, - 0xAA, 0xB3, 0x83, 0xBD, 0xDB, 0x28, 0xAD, 0x18, 0x47, 0x05, 0x55, 0x5A, 0xAC, 0x74, 0x1B, 0x53, - 0xA9, 0xFA, 0xCA, 0x0D, 0x27, 0x72, 0x6B, 0x2A, 0x3D, 0xA2, 0x02, 0x38, 0x52, 0x56, 0x69, 0xF9, - 0xAD, 0xEB, 0x9D, 0x1D, 0xD4, 0xF3, 0x50, 0x5A, 0x31, 0x8E, 0x0A, 0xAA, 0xB4, 0x58, 0x05, 0x27, - 0xA6, 0x52, 0xF5, 0xBD, 0x53, 0x9C, 0xC8, 0xAD, 0xA9, 0xF4, 0x98, 0x0A, 0xE0, 0x58, 0x59, 0xA5, - 0xE5, 0x77, 0xEE, 0x77, 0x76, 0xB0, 0x73, 0x1F, 0xA5, 0x15, 0xE3, 0xA8, 0xA0, 0x4A, 0x8B, 0xD5, - 0x66, 0x63, 0x2A, 0x55, 0xDF, 0xEE, 0xC4, 0x89, 0xDC, 0x9A, 0x4A, 0x4F, 0xA8, 0x00, 0x4E, 0x94, - 0x55, 0x5A, 0x7E, 0xCB, 0x40, 0x67, 0x07, 0x9B, 0x5F, 0x50, 0x5A, 0x2D, 0x91, 0xA3, 0x82, 0x2A, - 0x2D, 0xB6, 0xC0, 0xD8, 0x49, 0xD9, 0xFA, 0xA2, 0xA0, 0xD2, 0xB4, 0x05, 0xC6, 0x07, 0x50, 0xBF, - 0x33, 0x56, 0xC3, 0x12, 0x1F, 0xFC, 0x79, 0xF1, 0xD3, 0xCB, 0xF4, 0xC2, 0x7E, 0x6A, 0x15, 0x2F, - 0xD6, 0xD7, 0x43, 0x2F, 0xE3, 0x89, 0xF2, 0x42, 0xC2, 0x41, 0xCB, 0xEC, 0x25, 0x91, 0xDA, 0x1A, - 0xF3, 0xD9, 0x96, 0xC6, 0x80, 0x0B, 0x58, 0x5A, 0xA7, 0xDB, 0x92, 0x27, 0x2D, 0x39, 0x96, 0xC6, - 0xA9, 0xDC, 0x4E, 0xF0, 0x40, 0xE4, 0x30, 0x17, 0x47, 0xDE, 0x3F, 0x28, 0xAD, 0xE9, 0x30, 0x80, - 0x78, 0xF8, 0xE8, 0xB6, 0x4E, 0x14, 0xE3, 0x07, 0xC8, 0x20, 0x6D, 0x63, 0xFC, 0x06, 0x03, 0x08, - 0xD2, 0xD8, 0x61, 0x4C, 0xBD, 0x51, 0x66, 0x2A, 0x59, 0x05, 0x28, 0xC4, 0x54, 0x5A, 0x65, 0x67, - 0xC3, 0x4C, 0x75, 0x19, 0x53, 0x19, 0x4E, 0x9A, 0x60, 0x2A, 0x39, 0x0F, 0x2E, 0xC4, 0x54, 0xDA, - 0x44, 0x38, 0x62, 0xEA, 0x21, 0x04, 0x3A, 0x32, 0x32, 0xA6, 0xA3, 0x12, 0xA1, 0xEE, 0xF2, 0xE2, - 0xF0, 0xC5, 0x9B, 0x0B, 0x8D, 0x2E, 0x69, 0x3A, 0x56, 0xC1, 0x88, 0x17, 0xEF, 0xF4, 0x2F, 0x15, - 0xF3, 0x28, 0xE9, 0x42, 0xD4, 0x7B, 0x73, 0xA1, 0x1A, 0xF0, 0x38, 0x64, 0x91, 0x90, 0xD7, 0x6B, - 0x75, 0xCA, 0x54, 0x08, 0x43, 0x22, 0xB7, 0x14, 0xF4, 0x28, 0xFA, 0x76, 0x24, 0x83, 0xCB, 0x62, - 0x32, 0x28, 0x54, 0x25, 0x8D, 0xCB, 0xA0, 0x40, 0xD8, 0x0F, 0x88, 0xDC, 0xA6, 0x0C, 0x30, 0x4A, - 0x5E, 0x5E, 0x68, 0xEF, 0xFF, 0xA9, 0x5D, 0xDE, 0x2C, 0x1C, 0x6F, 0xE9, 0x92, 0xDC, 0xA8, 0xC2, - 0xE1, 0xE2, 0x71, 0xA5, 0xDF, 0xEB, 0x75, 0x54, 0x03, 0x4B, 0x2F, 0x7D, 0x08, 0x98, 0xB4, 0x36, - 0x18, 0x2F, 0x29, 0xA1, 0xDD, 0x90, 0xC1, 0x0F, 0x04, 0x34, 0xAD, 0x14, 0x37, 0x39, 0x60, 0x9C, - 0x43, 0xBD, 0x85, 0xDB, 0xAB, 0x15, 0x19, 0x94, 0x67, 0x94, 0x9D, 0x8D, 0x0E, 0x07, 0x94, 0xCA, - 0x5E, 0xC8, 0xDE, 0xA7, 0x8F, 0x57, 0x6A, 0x8C, 0x25, 0xEB, 0x68, 0xC5, 0x54, 0x97, 0xF6, 0xC8, - 0x68, 0xC1, 0x41, 0x41, 0xDE, 0xE8, 0xEC, 0x10, 0x62, 0xEC, 0xBA, 0x6C, 0x52, 0x44, 0x76, 0x36, - 0x31, 0xA7, 0x60, 0xB0, 0x72, 0x59, 0x52, 0x19, 0xB2, 0xB7, 0x9A, 0xE2, 0x17, 0x48, 0x9B, 0x23, - 0x08, 0xF3, 0xA0, 0x7B, 0xF4, 0xAE, 0x40, 0xB2, 0x73, 0x63, 0x4A, 0xA2, 0xEB, 0x1A, 0x0B, 0xE2, - 0x59, 0xC1, 0xD9, 0x60, 0x08, 0x8D, 0x6B, 0xC2, 0x3F, 0x97, 0xAA, 0xCD, 0x5C, 0x32, 0x39, 0xAF, - 0x7D, 0x13, 0xE2, 0xE4, 0x8F, 0xDF, 0x61, 0x93, 0x9A, 0x36, 0x76, 0x56, 0xB6, 0xE5, 0x18, 0x18, - 0xF8, 0x8D, 0x85, 0x0F, 0x94, 0x1E, 0xFC, 0xB1, 0xC0, 0x37, 0x5C, 0x19, 0xF8, 0xB4, 0x96, 0x91, - 0xD1, 0x8F, 0xA0, 0xFE, 0x91, 0xE5, 0x78, 0xC1, 0xB4, 0x0D, 0x0F, 0xC3, 0xCF, 0xAB, 0xFE, 0xCF, - 0x7F, 0xE7, 0x6D, 0x15, 0x30, 0xE7, 0x53, 0x41, 0x00, 0x35, 0xCD, 0x73, 0x47, 0xE7, 0x35, 0xA0, - 0xD4, 0x75, 0x3C, 0xCF, 0x71, 0xCD, 0xA9, 0x99, 0x32, 0x36, 0xA7, 0x49, 0xFB, 0x50, 0x26, 0xEE, - 0x44, 0x63, 0xC9, 0xB0, 0x7F, 0xE6, 0x8D, 0x5C, 0x73, 0xE1, 0x0F, 0x1E, 0x8D, 0x9D, 0xD1, 0x72, - 0x4E, 0x6C, 0xFF, 0xC0, 0x18, 0x8F, 0x2F, 0xAF, 0xE1, 0xE0, 0x3B, 0xFC, 0x16, 0x1F, 0x48, 0xBE, - 0x51, 0x7F, 0xF5, 0xAF, 0x77, 0x38, 0x0C, 0xE3, 0x35, 0x90, 0x17, 0x19, 0xD7, 0xF7, 0xB5, 0xC9, - 0xD2, 0x66, 0x23, 0x61, 0x83, 0x60, 0xDB, 0x3D, 0xED, 0x2B, 0x60, 0xBC, 0x36, 0x5C, 0x6D, 0x68, - 0x78, 0xE4, 0xAD, 0xE3, 0xF9, 0xDA, 0xB9, 0x16, 0x62, 0xB4, 0x9C, 0x11, 0xDD, 0xB7, 0x71, 0xC0, - 0xF8, 0xE2, 0x2D, 0x19, 0xE3, 0x3F, 0xBA, 0x16, 0x34, 0x0D, 0xA1, 0x9E, 0x6A, 0xF5, 0xD3, 0x63, - 0xBD, 0x8E, 0xF6, 0x17, 0x76, 0x31, 0x21, 0x10, 0xE6, 0xA1, 0x5D, 0x63, 0xE9, 0x5A, 0xFB, 0xDA, - 0x68, 0xB8, 0xF7, 0x95, 0x52, 0x4F, 0x2F, 0xE3, 0xB5, 0x3D, 0xCE, 0xCC, 0x81, 0x3F, 0x23, 0x76, - 0x23, 0xA2, 0xCC, 0x25, 0xDE, 0xC2, 0xB1, 0x3D, 0xC2, 0x88, 0x63, 0x3F, 0x73, 0x12, 0x5D, 0x3F, - 0xF0, 0x7C, 0xC3, 0x5F, 0x7A, 0xDA, 0xE3, 0xF3, 0x73, 0xAD, 0xDD, 0x6A, 0x89, 0xCD, 0x34, 0xE8, - 0x26, 0xD9, 0x6E, 0x5F, 0x4B, 0x5C, 0xF8, 0x48, 0x6E, 0xFC, 0xBD, 0x67, 0x21, 0xCC, 0x9D, 0x46, - 0x2C, 0x8F, 0xC4, 0x90, 0x84, 0x00, 0xF8, 0xDE, 0xB8, 0xC6, 0x5E, 0x9C, 0xC0, 0xC6, 0xD8, 0xF0, - 0x8D, 0xBD, 0xAF, 0x31, 0x7D, 0x41, 0xAF, 0x40, 0xC9, 0xBE, 0x46, 0x6F, 0x3D, 0x13, 0x6E, 0xDD, - 0xED, 0x1D, 0x80, 0x0C, 0x81, 0xDF, 0x10, 0x9A, 0xB8, 0x6E, 0x9C, 0x62, 0x0A, 0xDD, 0xD4, 0xF7, - 0x35, 0xBC, 0x13, 0x87, 0x15, 0x88, 0x7C, 0x14, 0x5C, 0x0B, 0x84, 0x96, 0x8D, 0x56, 0x82, 0x92, - 0xA1, 0xBB, 0x8B, 0xA9, 0x08, 0x02, 0xCE, 0x07, 0x32, 0x05, 0x89, 0x4D, 0xF7, 0x79, 0xFC, 0xD9, - 0xA7, 0xC1, 0x67, 0x9F, 0xC5, 0x2D, 0x41, 0x6B, 0x87, 0x87, 0xE0, 0xD2, 0x9E, 0x63, 0x11, 0xB0, - 0x8A, 0x69, 0xA3, 0xCE, 0xBF, 0xF5, 0x0A, 0x16, 0x55, 0x6F, 0xDD, 0xD4, 0x9F, 0x02, 0x82, 0x03, - 0xDF, 0xB9, 0xF2, 0x5D, 0xD3, 0x9E, 0x36, 0xF4, 0xFE, 0x5E, 0x84, 0x8D, 0xDE, 0x46, 0x94, 0x89, - 0xFB, 0xF4, 0x3A, 0xED, 0x24, 0x79, 0xA3, 0xC1, 0xAF, 0x3F, 0xAD, 0xEF, 0xD5, 0x39, 0xF1, 0xF4, - 0x1C, 0xCC, 0xAD, 0xC1, 0x0E, 0x9E, 0x50, 0x1A, 0xF7, 0xB4, 0xB3, 0x33, 0xDE, 0x0D, 0x6B, 0x85, - 0x17, 0xA1, 0x11, 0xFD, 0x93, 0xB8, 0x15, 0x9A, 0xE2, 0xEF, 0xFF, 0xF8, 0x1A, 0xD8, 0xEC, 0xDD, - 0x21, 0x50, 0xFD, 0x1C, 0x43, 0xF0, 0x3F, 0xBE, 0xC2, 0xFF, 0x77, 0x4F, 0x68, 0xD4, 0xFD, 0xC7, - 0x57, 0xFC, 0x73, 0xF7, 0x04, 0x7A, 0x82, 0x63, 0xDA, 0xDF, 0xDD, 0xEF, 0x54, 0x0E, 0xEB, 0xD2, - 0x9B, 0xA6, 0x4A, 0x2F, 0x14, 0x5B, 0x61, 0x9A, 0xA6, 0x19, 0x44, 0xFD, 0x1E, 0xF9, 0x6F, 0x63, - 0xE4, 0x8C, 0x41, 0x3D, 0x3E, 0x58, 0x72, 0xA0, 0x74, 0x0B, 0x54, 0x12, 0x08, 0xAA, 0x15, 0x28, - 0xDD, 0x9C, 0xD0, 0x96, 0x1A, 0x77, 0x95, 0xC8, 0x40, 0x82, 0x96, 0x0B, 0xC3, 0xF5, 0xC8, 0xB7, - 0xB6, 0xDF, 0xF0, 0x63, 0x4E, 0x91, 0x22, 0xF1, 0xC1, 0x20, 0xC6, 0x02, 0xFE, 0x00, 0x0E, 0xDA, - 0xD5, 0xB9, 0xD2, 0x42, 0x63, 0xE3, 0x7F, 0x13, 0x66, 0xF3, 0xA6, 0x90, 0xD9, 0x34, 0xA8, 0xD8, - 0xC2, 0x3E, 0xF7, 0x8A, 0x98, 0x10, 0x90, 0x25, 0x18, 0x10, 0x75, 0x88, 0x48, 0x64, 0xEC, 0x62, - 0x8A, 0x43, 0xFC, 0x3C, 0xB2, 0xBE, 0x34, 0x6E, 0xE0, 0xBF, 0x64, 0xCC, 0x5A, 0xD3, 0x15, 0x36, - 0x7A, 0x8E, 0xFF, 0x81, 0x82, 0xF0, 0x4F, 0xAA, 0xA1, 0x00, 0xD6, 0xF7, 0x96, 0xD5, 0x60, 0x1F, - 0x98, 0x03, 0x1B, 0x59, 0x42, 0x3C, 0xF4, 0x6E, 0x31, 0x32, 0x39, 0x8E, 0xFF, 0x79, 0x5F, 0x5B, - 0xB8, 0x40, 0x18, 0xFD, 0x96, 0x0A, 0x1C, 0x03, 0x22, 0x62, 0xB3, 0xBF, 0xB9, 0x14, 0x2C, 0x2C, - 0xEB, 0x39, 0xC3, 0x0A, 0x24, 0xB0, 0x03, 0x30, 0x99, 0x25, 0x9A, 0x2E, 0xFC, 0x7F, 0xF7, 0x04, - 0x3A, 0x81, 0x43, 0xF8, 0xFF, 0xEE, 0x09, 0x76, 0x85, 0x46, 0x85, 0x3D, 0xDE, 0x3D, 0x81, 0x1E, - 0xE1, 0x04, 0xFE, 0x87, 0x36, 0xD8, 0x2F, 0xB6, 0xC2, 0xBF, 0x70, 0x87, 0xF6, 0x8F, 0x37, 0xE9, - 0x01, 0xBB, 0xC0, 0x4F, 0xB3, 0x18, 0x64, 0x6F, 0xD7, 0x6F, 0xD0, 0xB7, 0x9D, 0x7F, 0xBE, 0x01, - 0x76, 0xE8, 0xC1, 0x2D, 0xC4, 0x20, 0x7B, 0x8C, 0xE7, 0xF8, 0xE7, 0x36, 0x50, 0x30, 0x5E, 0xE0, - 0x47, 0x70, 0x8D, 0xBE, 0x11, 0x16, 0x2F, 0xB1, 0x03, 0x6C, 0x45, 0xDF, 0xDF, 0x49, 0x5B, 0xB1, - 0x23, 0xB8, 0xC6, 0xDF, 0xFA, 0xB8, 0xAF, 0xF1, 0xF7, 0x0A, 0xE6, 0x0A, 0x27, 0x7A, 0xEF, 0xDF, - 0x73, 0xEF, 0x06, 0x19, 0x64, 0xA4, 0xA1, 0x54, 0xC2, 0xB3, 0xDB, 0xBB, 0x27, 0x04, 0xEF, 0x51, - 0x22, 0xE1, 0xF8, 0x96, 0x1F, 0xC3, 0x75, 0xA0, 0x0F, 0xEF, 0x04, 0x04, 0xD3, 0x0B, 0xB7, 0xD1, - 0x05, 0x68, 0xE1, 0xE3, 0x7D, 0x4E, 0x3C, 0x9C, 0xDD, 0x86, 0x67, 0x08, 0x4D, 0x61, 0x39, 0x1B, - 0x70, 0x7A, 0x1B, 0x9D, 0xC2, 0x5D, 0xE4, 0x05, 0x15, 0xC0, 0x79, 0xBA, 0x7B, 0xC2, 0x79, 0x42, - 0x2D, 0xB2, 0xA3, 0xB8, 0xA8, 0xE1, 0x7F, 0xF4, 0x23, 0x9F, 0x07, 0xEC, 0x4F, 0x81, 0x77, 0x12, - 0x6B, 0x4F, 0x3B, 0x1F, 0xF0, 0xB8, 0x8F, 0x01, 0x00, 0x3C, 0x0A, 0xAE, 0x13, 0xEB, 0xC0, 0xF0, - 0xC1, 0x21, 0x20, 0x6F, 0x22, 0xDE, 0x01, 0x46, 0x94, 0xD0, 0xCD, 0xD7, 0x6E, 0x1D, 0xD8, 0xE0, - 0x16, 0x14, 0xE1, 0xDE, 0x29, 0x0F, 0x1B, 0x88, 0x88, 0x71, 0xB9, 0x86, 0x8B, 0x5D, 0x4E, 0x43, - 0xC7, 0xEE, 0xA6, 0x60, 0xE4, 0xA1, 0x30, 0x0E, 0x81, 0x17, 0xD3, 0xB0, 0xD1, 0x51, 0x43, 0xC0, - 0xD5, 0xEE, 0xF5, 0x22, 0x6C, 0x89, 0x48, 0xC7, 0xE6, 0x94, 0x28, 0x90, 0x03, 0xCC, 0xCF, 0xA3, - 0xA1, 0x70, 0x04, 0xE6, 0xA0, 0xD5, 0x83, 0x09, 0x65, 0xFD, 0x74, 0x2D, 0xC2, 0x01, 0x04, 0x2F, - 0x20, 0x68, 0xCF, 0x19, 0x8D, 0xA7, 0x51, 0xF8, 0xD4, 0xB4, 0x21, 0x24, 0x3B, 0x5F, 0x9E, 0xC5, - 0x90, 0xD1, 0xD4, 0x3F, 0xC4, 0xC4, 0xAE, 0x61, 0xA2, 0x90, 0xB8, 0xC4, 0xB6, 0xDB, 0x35, 0x1D, - 0x9B, 0xC8, 0x7B, 0x8D, 0xC5, 0x4B, 0xDE, 0x11, 0x3F, 0x1B, 0x93, 0x89, 0xB1, 0xB4, 0xFC, 0x08, - 0xCC, 0x25, 0x90, 0xE8, 0xDA, 0x3C, 0x6C, 0xB1, 0x24, 0x3F, 0x77, 0xE8, 0xCE, 0x18, 0x2A, 0x82, - 0x51, 0xE1, 0x71, 0x72, 0x54, 0x00, 0xAB, 0x74, 0xFD, 0x46, 0xFD, 0xD2, 0x75, 0x1D, 0xF7, 0xD7, - 0xFA, 0x53, 0x6C, 0xF4, 0xB4, 0xFE, 0xDB, 0xA9, 0x46, 0xE3, 0xE9, 0x5E, 0x3C, 0xB8, 0x0B, 0xE1, - 0xF3, 0xF0, 0x50, 0x7B, 0xE1, 0xFB, 0x06, 0x28, 0x00, 0x6B, 0x2C, 0x33, 0x94, 0x8F, 0x66, 0xF0, - 0x24, 0xD0, 0x71, 0xD1, 0x28, 0xD9, 0xF7, 0xEE, 0x41, 0x22, 0x98, 0x58, 0x7A, 0x00, 0x12, 0x24, - 0x99, 0x14, 0xD5, 0xC1, 0xBF, 0x97, 0xC4, 0xBD, 0xBD, 0xA2, 0x02, 0x73, 0xDC, 0x17, 0x10, 0x2A, - 0xEB, 0x07, 0xD1, 0x3C, 0xA9, 0xCE, 0x72, 0x9E, 0x03, 0x40, 0x75, 0x09, 0x7D, 0x80, 0x8E, 0x23, - 0x9B, 0x67, 0xDC, 0x84, 0x7A, 0x87, 0x71, 0xEE, 0x9C, 0x2B, 0x23, 0x99, 0x64, 0x41, 0x0B, 0xC7, - 0xFE, 0x42, 0x6E, 0x97, 0x0B, 0x10, 0x7F, 0x94, 0x36, 0x25, 0x12, 0x39, 0x2E, 0x1D, 0x72, 0x00, - 0x2D, 0x2F, 0xF8, 0xC0, 0xA9, 0x77, 0x24, 0x8D, 0x22, 0x15, 0x50, 0xEB, 0x44, 0x4F, 0x7C, 0xB6, - 0xD6, 0xE8, 0xEE, 0x91, 0xFC, 0x4C, 0x92, 0x72, 0x72, 0x02, 0xB9, 0xF0, 0xC0, 0xB5, 0xA9, 0x63, - 0x27, 0x7A, 0x48, 0xA4, 0x83, 0x90, 0x0C, 0x46, 0x91, 0x61, 0xB9, 0x80, 0xE4, 0x93, 0xC4, 0x83, - 0x43, 0x68, 0x0B, 0xC1, 0xCD, 0xB9, 0xE3, 0x93, 0x44, 0xC4, 0x30, 0x6D, 0xD3, 0x37, 0x0D, 0xEB, - 0x53, 0x64, 0x8D, 0x5B, 0x75, 0x7F, 0x89, 0x8F, 0x17, 0xF0, 0xFF, 0xB5, 0x9C, 0x4F, 0x2D, 0x4F, - 0x59, 0xB3, 0x90, 0x30, 0x1E, 0x44, 0x56, 0x22, 0xCA, 0x21, 0x16, 0x16, 0xF8, 0xFD, 0xA0, 0xA7, - 0xC7, 0x8F, 0xE9, 0xD1, 0xA3, 0x50, 0x69, 0x41, 0xF4, 0x38, 0xD7, 0xA2, 0x1B, 0x09, 0x05, 0xAF, - 0xE3, 0x4E, 0xE0, 0x08, 0x90, 0x0B, 0x18, 0x12, 0x81, 0x7F, 0x01, 0xE9, 0x0D, 0xDA, 0xC2, 0xFF, - 0x47, 0xFD, 0x07, 0x14, 0xF5, 0xB7, 0x17, 0xE2, 0x33, 0x6C, 0x3B, 0xE1, 0x01, 0x0C, 0x4E, 0x9E, - 0x4F, 0x3F, 0x85, 0x44, 0x5B, 0x9E, 0x24, 0x87, 0xA1, 0x3B, 0x9C, 0xEC, 0xC3, 0x64, 0xE6, 0x92, - 0x85, 0xE7, 0x97, 0xB7, 0xDF, 0x8E, 0x1B, 0xF5, 0xF0, 0x8D, 0x46, 0xF5, 0x3D, 0x8C, 0x4B, 0x96, - 0x39, 0xFA, 0x12, 0x86, 0xA5, 0xC8, 0xF2, 0x20, 0xA5, 0xC1, 0xEC, 0x1F, 0x27, 0xD6, 0xE6, 0x88, - 0x9B, 0xEA, 0xAB, 0x0F, 0x2F, 0xDE, 0x7D, 0x7E, 0xF1, 0xF1, 0xE3, 0x07, 0x6D, 0x09, 0x36, 0xAB, - 0xF7, 0x3F, 0x63, 0xDA, 0x02, 0x93, 0x00, 0xF7, 0x33, 0xD0, 0xE7, 0x7D, 0xA6, 0x48, 0x5B, 0xBF, - 0xFE, 0xF6, 0x6B, 0xFB, 0x37, 0x00, 0xFD, 0xFA, 0x5F, 0x76, 0x9D, 0x31, 0x82, 0xA8, 0x9E, 0x02, - 0x2E, 0x3C, 0xFE, 0x5A, 0x7F, 0x1A, 0x18, 0x7C, 0x23, 0x9D, 0xC2, 0xF0, 0xF5, 0xBA, 0xF5, 0x3D, - 0x60, 0xF5, 0x6E, 0x1F, 0x50, 0xB1, 0x74, 0x10, 0xC6, 0x9C, 0x06, 0x96, 0x2A, 0x4C, 0xE8, 0x40, - 0x7F, 0x06, 0x7F, 0xCE, 0x34, 0xFD, 0x08, 0xFE, 0x3E, 0x7D, 0x1A, 0x99, 0x48, 0xC9, 0xEE, 0xEA, - 0x4F, 0x4D, 0xDA, 0x19, 0xCC, 0x4E, 0x1A, 0xE6, 0x19, 0x48, 0xF2, 0x79, 0x7D, 0xBF, 0x7E, 0x5A, - 0xAF, 0xC3, 0xB5, 0xA0, 0xFB, 0xBB, 0x18, 0x3B, 0x77, 0xCF, 0x42, 0x0E, 0xD9, 0xE8, 0x0A, 0x37, - 0x22, 0xF1, 0x8B, 0x59, 0xDD, 0x4B, 0x56, 0xE5, 0x3A, 0x4F, 0xD7, 0x09, 0x7B, 0x9B, 0xF5, 0x94, - 0x0E, 0x88, 0x22, 0x4C, 0x86, 0x82, 0x58, 0x68, 0x08, 0x7D, 0x2D, 0x15, 0x35, 0x1D, 0x6E, 0xC7, - 0x63, 0x17, 0xB4, 0x4D, 0xAD, 0x65, 0x6F, 0xCD, 0x85, 0xD5, 0x70, 0x60, 0x63, 0x09, 0x8E, 0xB5, - 0xE9, 0x66, 0x26, 0x12, 0xDA, 0x5A, 0xC0, 0xB2, 0x96, 0xF0, 0xB4, 0xEE, 0x2F, 0xD7, 0x61, 0x1A, - 0x9B, 0x2A, 0x6A, 0x6C, 0x2A, 0x68, 0x6C, 0xBA, 0x59, 0x8D, 0x71, 0xD4, 0x95, 0xB5, 0x16, 0xE0, - 0xC9, 0xD1, 0x5C, 0x2E, 0x3C, 0x57, 0x1A, 0xD7, 0xD6, 0x54, 0xA6, 0xAD, 0x32, 0x6A, 0x62, 0xB1, - 0x0B, 0x26, 0x45, 0xC4, 0x7D, 0xFB, 0xF1, 0xDD, 0x77, 0x18, 0x6D, 0xE4, 0x2A, 0x0B, 0x35, 0x96, - 0x4C, 0xAE, 0x24, 0x18, 0x30, 0x28, 0xC6, 0x2A, 0x1F, 0x89, 0xB0, 0xA9, 0x45, 0x15, 0x84, 0x1C, - 0x43, 0xE0, 0x05, 0x03, 0x35, 0xDF, 0xC5, 0x22, 0x41, 0xE0, 0xBC, 0x11, 0x54, 0x86, 0x2D, 0x20, - 0x80, 0x92, 0x12, 0x19, 0xE6, 0x35, 0x87, 0x11, 0x6A, 0x19, 0x3B, 0x77, 0x11, 0xEA, 0xAF, 0x9E, - 0x6A, 0x50, 0x0B, 0xA6, 0xEA, 0x51, 0x6C, 0xF3, 0x72, 0xA5, 0xC3, 0x27, 0xF4, 0x4A, 0x02, 0xE2, - 0x5F, 0x95, 0x93, 0x18, 0x38, 0x2F, 0x04, 0x14, 0xC0, 0x72, 0x2B, 0xC1, 0x42, 0x0B, 0x08, 0x4A, - 0x38, 0xE8, 0x97, 0xC8, 0x52, 0x30, 0xA8, 0x51, 0x41, 0x3F, 0xFD, 0x25, 0xC1, 0x10, 0x94, 0x2A, - 0x94, 0x90, 0x04, 0x9F, 0xAD, 0x4A, 0xC7, 0xA3, 0x46, 0x4C, 0xF0, 0xB1, 0x28, 0x09, 0x1E, 0x5E, - 0x1A, 0x51, 0x42, 0xC3, 0x3F, 0x74, 0x94, 0x8A, 0x45, 0x8D, 0x18, 0xFE, 0x6D, 0x21, 0x19, 0x4F, - 0xBC, 0x14, 0xA3, 0xC6, 0x13, 0xFF, 0x24, 0x4E, 0x3A, 0x1E, 0x45, 0xD9, 0xF0, 0xCF, 0xD0, 0xC8, - 0xAC, 0x8E, 0x55, 0x7E, 0x32, 0x1D, 0x83, 0x35, 0x01, 0x60, 0x9E, 0xAA, 0x3E, 0xD7, 0xC5, 0xCC, - 0x9A, 0x17, 0x8A, 0xB2, 0x30, 0xF0, 0x26, 0x49, 0x0C, 0x41, 0x74, 0xB8, 0x87, 0x92, 0xDD, 0x7D, - 0x44, 0xA1, 0xF7, 0x96, 0xA5, 0x16, 0x85, 0x16, 0x96, 0x15, 0x84, 0x9F, 0x10, 0x26, 0x25, 0xFC, - 0xD0, 0x25, 0x33, 0x5A, 0x6F, 0xCD, 0x94, 0x3F, 0x6D, 0xC1, 0xF0, 0xAE, 0x2B, 0x11, 0x71, 0xCC, - 0x97, 0x96, 0x92, 0x25, 0x41, 0x3B, 0x8E, 0x46, 0x34, 0x24, 0xBA, 0x1C, 0x77, 0xEB, 0xA9, 0x85, - 0xAE, 0x5B, 0x2F, 0x05, 0x03, 0x2D, 0x09, 0xAB, 0xE5, 0x66, 0xD0, 0x32, 0x05, 0xC9, 0xC2, 0x55, - 0x4B, 0xEF, 0xA0, 0x5D, 0x1A, 0x23, 0x58, 0x7F, 0x56, 0x63, 0x25, 0xF8, 0xEA, 0xB8, 0x8C, 0x0E, - 0x5A, 0xB5, 0xCE, 0x52, 0x0A, 0xFF, 0xBA, 0x73, 0x9A, 0x46, 0x16, 0xAA, 0x43, 0x6E, 0xF0, 0xFD, - 0x63, 0xC9, 0xB0, 0x5B, 0xB2, 0xD8, 0xBF, 0x7B, 0xE7, 0x30, 0xAE, 0x89, 0x82, 0x6B, 0x84, 0x2B, - 0xF3, 0x3C, 0xBB, 0x8B, 0xC0, 0x32, 0xBC, 0x63, 0x64, 0xD8, 0xD7, 0x46, 0xCC, 0x3B, 0x46, 0x30, - 0xFD, 0xF5, 0x09, 0x47, 0xDD, 0xA8, 0xB1, 0x06, 0x35, 0x4E, 0x23, 0x3B, 0x3B, 0xA0, 0x5B, 0xD6, - 0xB0, 0x10, 0x62, 0x92, 0x15, 0x3B, 0x89, 0xDD, 0x9E, 0x11, 0xFA, 0x9A, 0x10, 0x7E, 0x9F, 0x9D, - 0xB1, 0x06, 0x61, 0x2F, 0x43, 0x67, 0x7C, 0x7B, 0x60, 0x2C, 0x16, 0x10, 0xBC, 0x2E, 0x66, 0xA6, - 0x35, 0x6E, 0x30, 0x50, 0xC1, 0x44, 0x70, 0x6F, 0x02, 0xA1, 0xAB, 0x56, 0x1C, 0x2B, 0x30, 0x7C, - 0xC1, 0xAE, 0x35, 0xEA, 0xED, 0x71, 0xB0, 0x66, 0xC4, 0x9B, 0x1D, 0x8C, 0x5D, 0x63, 0xF5, 0x2D, - 0x6E, 0x6A, 0x68, 0x60, 0xA7, 0xFB, 0xAD, 0xFD, 0x16, 0x6F, 0xE0, 0xBB, 0xB7, 0x61, 0x96, 0x89, - 0x78, 0x71, 0xF1, 0xF7, 0xC7, 0x0F, 0xDF, 0x45, 0x78, 0x7D, 0xE7, 0x15, 0xBB, 0xD4, 0xA8, 0xD3, - 0x5D, 0x11, 0x87, 0x7F, 0x2C, 0x70, 0x2A, 0x10, 0x28, 0x45, 0x10, 0x23, 0x6E, 0x78, 0x40, 0x51, - 0xB1, 0xE6, 0xCF, 0x44, 0xA4, 0x70, 0xD9, 0x26, 0x2B, 0x0D, 0x30, 0x91, 0x86, 0x0C, 0x34, 0xD8, - 0x0E, 0x81, 0xE0, 0xC8, 0xC9, 0xEB, 0xA5, 0x65, 0xFD, 0x42, 0x0C, 0x17, 0xF4, 0xF1, 0x54, 0x6B, - 0xD4, 0x5A, 0xB5, 0xA7, 0x0D, 0x7A, 0xFD, 0x1D, 0xB0, 0x33, 0x6B, 0xEC, 0x3D, 0xD5, 0xF7, 0xF6, - 0x0E, 0x3C, 0xD0, 0x19, 0x69, 0x34, 0xDB, 0x41, 0x13, 0xF8, 0x43, 0xDB, 0xB0, 0x4E, 0xD2, 0xEF, - 0xBF, 0x75, 0x96, 0xAE, 0x97, 0xD5, 0xE0, 0x9D, 0x69, 0x63, 0x11, 0x27, 0xAB, 0xC9, 0x15, 0x01, - 0xC1, 0x8E, 0xD7, 0x9A, 0xD4, 0xE8, 0x2E, 0x0E, 0x5E, 0xFE, 0xD0, 0xE8, 0xE2, 0xB6, 0xD6, 0x10, - 0x2B, 0x3A, 0x7C, 0x19, 0x91, 0xA0, 0xA1, 0x37, 0x02, 0x8F, 0xBF, 0x13, 0x8D, 0x03, 0x5C, 0x16, - 0x0C, 0xE0, 0x7B, 0xF0, 0x80, 0x03, 0x97, 0xCC, 0x9D, 0x6B, 0xB2, 0xA6, 0x7F, 0x6C, 0x1E, 0x1A, - 0xFF, 0xCC, 0x1C, 0xB3, 0x32, 0x4D, 0x64, 0xB7, 0x58, 0x15, 0xC2, 0x0D, 0x22, 0xB8, 0x85, 0x02, - 0xF7, 0x53, 0x34, 0xEA, 0x6C, 0xF7, 0x0A, 0x1D, 0x15, 0xEE, 0x22, 0xB7, 0x99, 0x39, 0xAB, 0x2C, - 0x48, 0xD6, 0x7B, 0x02, 0x38, 0x84, 0x1E, 0x9B, 0x9E, 0x31, 0xB4, 0xF2, 0xBB, 0xE6, 0xED, 0xC6, - 0xBC, 0xFC, 0x0D, 0x0D, 0x82, 0x2B, 0x00, 0xEA, 0xBB, 0xB4, 0x4E, 0x28, 0xA0, 0x25, 0x76, 0x1E, - 0xD6, 0x80, 0xAC, 0x4C, 0xC4, 0x13, 0x03, 0x26, 0x4F, 0x71, 0xCC, 0xAC, 0x78, 0x5C, 0xA0, 0xAC, - 0x2C, 0x5E, 0x06, 0x88, 0xF8, 0x29, 0x98, 0x33, 0x58, 0xA8, 0xF6, 0x9C, 0xB2, 0xA0, 0x9D, 0xC6, - 0xEE, 0x86, 0xB9, 0x8D, 0x58, 0x30, 0x7D, 0xC4, 0xE2, 0xDF, 0x5F, 0xB0, 0x82, 0x1B, 0x52, 0x1E, - 0x93, 0xC0, 0x93, 0x27, 0x71, 0x6C, 0xB8, 0x8D, 0x85, 0x0D, 0x20, 0x61, 0x6F, 0xAC, 0x3D, 0x7B, - 0x7D, 0x78, 0xB4, 0xB2, 0xC0, 0x49, 0x82, 0xA1, 0xE0, 0x71, 0x4C, 0xF0, 0xC2, 0x18, 0x01, 0x84, - 0x98, 0x63, 0x2A, 0x20, 0xDC, 0x95, 0x56, 0x5B, 0x5B, 0xEF, 0x7F, 0x4E, 0xAD, 0xBE, 0x41, 0xF8, - 0xA6, 0xC4, 0x3D, 0x90, 0x3F, 0x1A, 0x73, 0x74, 0x21, 0x3E, 0x87, 0x4E, 0x60, 0x9C, 0xC6, 0x30, - 0x22, 0x63, 0x09, 0xBA, 0xF1, 0x77, 0x78, 0x48, 0x31, 0x4E, 0x0D, 0xD3, 0xBE, 0x20, 0x26, 0xA6, - 0x7F, 0x7B, 0xC2, 0x5D, 0xDA, 0x3D, 0x20, 0xC2, 0x0D, 0x83, 0xC2, 0x72, 0xC6, 0xFA, 0x7A, 0xC8, - 0xE1, 0x21, 0x6D, 0x9A, 0x82, 0x86, 0x76, 0xB1, 0x8E, 0x26, 0x93, 0xFA, 0xD5, 0xF0, 0x33, 0x62, - 0x93, 0x09, 0x85, 0xA2, 0x5B, 0x0D, 0x51, 0x1C, 0xB4, 0x57, 0x38, 0xCC, 0x42, 0x35, 0x31, 0x46, - 0xE4, 0xB3, 0x0B, 0x71, 0x6C, 0x6A, 0x9B, 0x7F, 0x12, 0x19, 0x42, 0xE6, 0x8C, 0x0D, 0x62, 0xBB, - 0x4E, 0x90, 0x48, 0x22, 0x72, 0xEE, 0x67, 0xF1, 0xEB, 0xF1, 0x21, 0x3A, 0x70, 0xBD, 0x70, 0x69, - 0x5D, 0xB4, 0x05, 0x56, 0xFA, 0x8F, 0x26, 0xC0, 0x91, 0xB1, 0x6D, 0xA8, 0x12, 0xAE, 0x63, 0x19, - 0x3C, 0x6C, 0x22, 0xD6, 0xAB, 0x2B, 0x16, 0xC1, 0x33, 0x70, 0xB2, 0xCD, 0x7D, 0x49, 0xA4, 0xCB, - 0xE1, 0xDC, 0xF4, 0x25, 0x08, 0xEB, 0x7A, 0x5D, 0x8A, 0x2B, 0xA5, 0x9E, 0x2E, 0x7A, 0x22, 0x8B, - 0x66, 0x74, 0xA9, 0x11, 0x10, 0xC5, 0xB6, 0x0A, 0x8C, 0xD8, 0x6E, 0xF5, 0xE7, 0x30, 0xE2, 0xE2, - 0x06, 0x00, 0x54, 0x75, 0x62, 0x0B, 0x10, 0x43, 0xC1, 0xF6, 0xAE, 0x51, 0x14, 0xF1, 0xDD, 0x6B, - 0xC1, 0x8E, 0xB1, 0xF8, 0xCA, 0xA4, 0xB8, 0xF7, 0xE5, 0x77, 0x97, 0x00, 0x9C, 0x87, 0xF3, 0x59, - 0xED, 0x1F, 0x5F, 0x29, 0x8A, 0x3B, 0x6D, 0x02, 0x91, 0xC0, 0x9B, 0x91, 0x31, 0x9D, 0x7B, 0xF9, - 0x4B, 0xEF, 0x54, 0xC3, 0x6D, 0x3F, 0xB1, 0xDD, 0x6A, 0x77, 0xBF, 0x87, 0x16, 0x12, 0x0E, 0x24, - 0xD1, 0x7A, 0x2B, 0x67, 0x7B, 0x6D, 0xDD, 0xF5, 0x91, 0xC2, 0x0A, 0x2B, 0x87, 0x55, 0x5A, 0x65, - 0x95, 0x2D, 0x4E, 0x26, 0xA3, 0x14, 0xCF, 0x31, 0x1F, 0xA9, 0xF4, 0x4D, 0x77, 0x5D, 0xAA, 0x74, - 0x29, 0xC9, 0x2E, 0xF1, 0xC7, 0xC2, 0x98, 0x25, 0x8C, 0xF9, 0x09, 0x3F, 0xDA, 0xE3, 0x72, 0x02, - 0x13, 0x19, 0x07, 0xF1, 0x96, 0x19, 0x11, 0xAE, 0x3E, 0x33, 0x3D, 0xC6, 0x4C, 0x80, 0x49, 0x9B, - 0x0B, 0x3B, 0x7F, 0x4B, 0x22, 0x5F, 0x76, 0x09, 0x95, 0xF5, 0x87, 0x07, 0x79, 0x7B, 0x28, 0x02, - 0x19, 0x0E, 0xEC, 0x40, 0x40, 0x10, 0x13, 0x51, 0x21, 0x15, 0x65, 0xC8, 0x8C, 0xFD, 0x84, 0x01, - 0x9B, 0x8E, 0xD6, 0xB4, 0xE7, 0x5F, 0xA9, 0x55, 0xFF, 0xB6, 0xCF, 0x46, 0x78, 0x21, 0x68, 0xEE, - 0x15, 0x21, 0x68, 0x6D, 0x55, 0x3E, 0x97, 0x98, 0x8D, 0xAD, 0x4F, 0x06, 0x3F, 0x88, 0xC5, 0x14, - 0xDF, 0xB9, 0xD6, 0x5A, 0x5B, 0x97, 0x17, 0x17, 0xD1, 0x42, 0x06, 0x25, 0xB2, 0x11, 0x16, 0xD5, - 0x03, 0xF1, 0x48, 0x17, 0xD0, 0x52, 0xC5, 0xC5, 0xEC, 0x8B, 0x45, 0x14, 0x9C, 0x16, 0x64, 0xCE, - 0x9F, 0xE8, 0x86, 0x59, 0x2A, 0xAF, 0x08, 0xE0, 0x22, 0xDC, 0x20, 0x9D, 0x0B, 0x19, 0x6D, 0xA6, - 0x16, 0x70, 0xD0, 0xF9, 0x98, 0xDA, 0xFA, 0x03, 0x9F, 0xBA, 0x09, 0xA0, 0x88, 0x35, 0x1F, 0x36, - 0x78, 0x4A, 0x26, 0x49, 0xBE, 0x38, 0x7A, 0x65, 0xC1, 0xD3, 0x61, 0x92, 0x35, 0x16, 0xA0, 0xA9, - 0xE7, 0xE7, 0x03, 0x8B, 0xDB, 0xB2, 0xEB, 0x82, 0xB0, 0x3D, 0xDF, 0x59, 0x5C, 0xD1, 0xAB, 0x89, - 0xA8, 0xB0, 0xA2, 0xC5, 0xAC, 0x03, 0xBC, 0x1F, 0xCC, 0x93, 0x44, 0x46, 0xE3, 0xF5, 0xFF, 0x2B, - 0xAC, 0x74, 0x69, 0x0C, 0x4F, 0x3D, 0x9E, 0xED, 0xD2, 0x22, 0x98, 0xB4, 0x07, 0x3A, 0xE7, 0xF4, - 0xDC, 0x11, 0x1B, 0x3C, 0xC2, 0x6D, 0xD0, 0x18, 0x3A, 0xF0, 0xF0, 0x77, 0xD6, 0x27, 0xA6, 0x12, - 0x31, 0x05, 0xEF, 0xE5, 0xD2, 0xE2, 0x2C, 0x92, 0xA4, 0x44, 0x01, 0x9E, 0x39, 0x9B, 0x87, 0xA3, - 0x06, 0x1B, 0x28, 0x31, 0x7C, 0x09, 0xCA, 0x4F, 0x89, 0x91, 0x91, 0x98, 0x78, 0x50, 0x8A, 0x13, - 0x2F, 0x8C, 0x7C, 0x6C, 0x0F, 0xFD, 0xF3, 0xCF, 0xA3, 0x21, 0x0C, 0x76, 0x38, 0x01, 0x04, 0xD7, - 0x5B, 0x35, 0xF6, 0xEE, 0xB2, 0xD8, 0x61, 0xE2, 0x8A, 0x14, 0xA9, 0x4A, 0x04, 0x8D, 0xDA, 0x72, - 0x6C, 0x31, 0xF9, 0xC8, 0xD1, 0x89, 0xD6, 0x7B, 0x69, 0x07, 0x13, 0x96, 0x34, 0xC1, 0x9E, 0xAF, - 0x8B, 0x96, 0xE5, 0xAC, 0x31, 0x04, 0x51, 0x3C, 0x5E, 0x23, 0x36, 0x91, 0x94, 0x0A, 0x76, 0x11, - 0x34, 0x08, 0x68, 0x17, 0x1D, 0x22, 0x85, 0xF6, 0xF8, 0x58, 0x99, 0x48, 0xFF, 0x02, 0x95, 0x5F, - 0x2C, 0x81, 0x88, 0x79, 0xA0, 0x72, 0x76, 0x0D, 0x33, 0xDC, 0xD0, 0x36, 0x21, 0xE3, 0xCD, 0xF2, - 0x19, 0xB8, 0x2D, 0x38, 0x1A, 0x4F, 0x8F, 0x73, 0x00, 0x68, 0x4E, 0xCC, 0x1E, 0x84, 0xA1, 0xB0, - 0x6C, 0xC3, 0xAE, 0xAF, 0x09, 0x89, 0x77, 0x66, 0x7C, 0x81, 0x66, 0x23, 0xD6, 0x4C, 0x40, 0x02, - 0x78, 0xD7, 0xD3, 0x05, 0x89, 0x20, 0xA0, 0xDD, 0x5E, 0xA8, 0x17, 0x04, 0xE2, 0x99, 0x68, 0xA4, - 0x95, 0xF4, 0xD9, 0xC4, 0xFA, 0x4C, 0x22, 0xA1, 0xB0, 0xF4, 0x19, 0xC4, 0xFA, 0xEC, 0xE1, 0x4E, - 0xD0, 0x42, 0xF0, 0xC4, 0x56, 0x24, 0x48, 0x92, 0x2D, 0x75, 0x22, 0x4A, 0x3D, 0x98, 0x49, 0xE5, - 0x40, 0x7C, 0xA6, 0xA9, 0x88, 0x28, 0x32, 0xA2, 0x28, 0x32, 0xC2, 0x45, 0x86, 0x00, 0x51, 0xE2, - 0x9E, 0x3F, 0xAD, 0x0B, 0xE3, 0xCA, 0x4F, 0x2F, 0x23, 0xCE, 0x56, 0xC3, 0x4C, 0x3A, 0xF9, 0x94, - 0x49, 0x60, 0x2F, 0x1B, 0x00, 0xDA, 0xCF, 0x61, 0xDC, 0x16, 0xD9, 0x5A, 0x0D, 0xD5, 0xD8, 0x0A, - 0xA6, 0x5C, 0x08, 0x10, 0xB1, 0x25, 0x9F, 0x98, 0x05, 0xAC, 0xBC, 0x22, 0x3E, 0x7F, 0xAE, 0xD3, - 0xB0, 0xC7, 0xDA, 0xC4, 0x35, 0xE6, 0xC4, 0x83, 0x09, 0x59, 0x48, 0xEC, 0x98, 0xDE, 0xCF, 0x1D, - 0xA2, 0x58, 0x33, 0x81, 0xC9, 0x70, 0x6A, 0x97, 0x0B, 0x1A, 0xB6, 0x14, 0xA0, 0x43, 0x3A, 0x32, - 0xA1, 0x83, 0x46, 0x6C, 0x6C, 0x0B, 0x4F, 0x95, 0x84, 0x15, 0xB6, 0x8E, 0x9C, 0x27, 0x42, 0xC0, - 0x26, 0x4A, 0x03, 0xAD, 0x97, 0x2C, 0x27, 0xB0, 0x84, 0x87, 0x31, 0x9B, 0x48, 0x6B, 0xC4, 0x06, - 0x21, 0x4B, 0xB1, 0x36, 0xA1, 0x83, 0x30, 0xF8, 0x34, 0x32, 0x73, 0x49, 0x61, 0xE5, 0xEA, 0xDA, - 0x7B, 0x8B, 0xE0, 0x34, 0x8F, 0xBF, 0xA9, 0xF3, 0xE2, 0xDB, 0xD7, 0x9A, 0xE3, 0x6A, 0x96, 0xB3, - 0x22, 0xB8, 0x59, 0x34, 0x58, 0x0A, 0xD6, 0x86, 0x04, 0x12, 0x4B, 0xC2, 0x26, 0xD3, 0x18, 0x87, - 0xFC, 0x99, 0xE9, 0x41, 0xEA, 0x8E, 0xEF, 0x93, 0x25, 0x8F, 0x6B, 0x61, 0x31, 0x34, 0x97, 0xBD, - 0xF5, 0xAD, 0x55, 0x31, 0x71, 0x32, 0x98, 0x48, 0x96, 0x8F, 0x39, 0x8F, 0x6B, 0xC1, 0x28, 0x6B, - 0xFA, 0x5E, 0x40, 0x84, 0xE1, 0xED, 0x07, 0x2B, 0x45, 0x39, 0x03, 0xB9, 0x82, 0x0C, 0xC1, 0x22, - 0x59, 0x46, 0xBC, 0xAE, 0x49, 0x53, 0x56, 0x23, 0xC9, 0xD0, 0x28, 0x96, 0x0B, 0xA5, 0x71, 0x3E, - 0x5D, 0x2B, 0x4C, 0xE2, 0x2C, 0x55, 0x67, 0xBF, 0xB3, 0xC3, 0xE0, 0xB1, 0x39, 0x76, 0x86, 0x8B, - 0x05, 0x83, 0x47, 0x67, 0x87, 0x33, 0x7F, 0x6E, 0x0D, 0x1E, 0xFD, 0x2F, 0x5E, 0x72, 0x0B, 0x89, - 0x42, 0x10, 0x01, 0x00 -}; diff --git a/arduino/esp32-cam/CameraWebServer/camera_pins.h b/arduino/esp32-cam/CameraWebServer/camera_pins.h deleted file mode 100644 index e1be287..0000000 --- a/arduino/esp32-cam/CameraWebServer/camera_pins.h +++ /dev/null @@ -1,273 +0,0 @@ - -#if defined(CAMERA_MODEL_WROVER_KIT) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 21 -#define SIOD_GPIO_NUM 26 -#define SIOC_GPIO_NUM 27 - -#define Y9_GPIO_NUM 35 -#define Y8_GPIO_NUM 34 -#define Y7_GPIO_NUM 39 -#define Y6_GPIO_NUM 36 -#define Y5_GPIO_NUM 19 -#define Y4_GPIO_NUM 18 -#define Y3_GPIO_NUM 5 -#define Y2_GPIO_NUM 4 -#define VSYNC_GPIO_NUM 25 -#define HREF_GPIO_NUM 23 -#define PCLK_GPIO_NUM 22 - -#elif defined(CAMERA_MODEL_ESP_EYE) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 4 -#define SIOD_GPIO_NUM 18 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 36 -#define Y8_GPIO_NUM 37 -#define Y7_GPIO_NUM 38 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 35 -#define Y4_GPIO_NUM 14 -#define Y3_GPIO_NUM 13 -#define Y2_GPIO_NUM 34 -#define VSYNC_GPIO_NUM 5 -#define HREF_GPIO_NUM 27 -#define PCLK_GPIO_NUM 25 - -#elif defined(CAMERA_MODEL_M5STACK_PSRAM) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 25 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 32 -#define VSYNC_GPIO_NUM 22 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_M5STACK_V2_PSRAM) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 22 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 32 -#define VSYNC_GPIO_NUM 25 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_M5STACK_WIDE) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 22 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 32 -#define VSYNC_GPIO_NUM 25 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_M5STACK_ESP32CAM) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 25 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 17 -#define VSYNC_GPIO_NUM 22 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_M5STACK_UNITCAM) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 25 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 32 -#define VSYNC_GPIO_NUM 22 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - -#elif defined(CAMERA_MODEL_AI_THINKER) -#define PWDN_GPIO_NUM 32 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 0 -#define SIOD_GPIO_NUM 26 -#define SIOC_GPIO_NUM 27 - -#define Y9_GPIO_NUM 35 -#define Y8_GPIO_NUM 34 -#define Y7_GPIO_NUM 39 -#define Y6_GPIO_NUM 36 -#define Y5_GPIO_NUM 21 -#define Y4_GPIO_NUM 19 -#define Y3_GPIO_NUM 18 -#define Y2_GPIO_NUM 5 -#define VSYNC_GPIO_NUM 25 -#define HREF_GPIO_NUM 23 -#define PCLK_GPIO_NUM 22 - -#elif defined(CAMERA_MODEL_TTGO_T_JOURNAL) -#define PWDN_GPIO_NUM 0 -#define RESET_GPIO_NUM 15 -#define XCLK_GPIO_NUM 27 -#define SIOD_GPIO_NUM 25 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 19 -#define Y8_GPIO_NUM 36 -#define Y7_GPIO_NUM 18 -#define Y6_GPIO_NUM 39 -#define Y5_GPIO_NUM 5 -#define Y4_GPIO_NUM 34 -#define Y3_GPIO_NUM 35 -#define Y2_GPIO_NUM 17 -#define VSYNC_GPIO_NUM 22 -#define HREF_GPIO_NUM 26 -#define PCLK_GPIO_NUM 21 - - -#elif defined(CAMERA_MODEL_ESP32_CAM_BOARD) -// The 18 pin header on the board has Y5 and Y3 swapped -#define USE_BOARD_HEADER 0 -#define PWDN_GPIO_NUM 32 -#define RESET_GPIO_NUM 33 -#define XCLK_GPIO_NUM 4 -#define SIOD_GPIO_NUM 18 -#define SIOC_GPIO_NUM 23 - -#define Y9_GPIO_NUM 36 -#define Y8_GPIO_NUM 19 -#define Y7_GPIO_NUM 21 -#define Y6_GPIO_NUM 39 -#if USE_BOARD_HEADER -#define Y5_GPIO_NUM 13 -#else -#define Y5_GPIO_NUM 35 -#endif -#define Y4_GPIO_NUM 14 -#if USE_BOARD_HEADER -#define Y3_GPIO_NUM 35 -#else -#define Y3_GPIO_NUM 13 -#endif -#define Y2_GPIO_NUM 34 -#define VSYNC_GPIO_NUM 5 -#define HREF_GPIO_NUM 27 -#define PCLK_GPIO_NUM 25 - -#elif defined(CAMERA_MODEL_ESP32S3_CAM_LCD) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 40 -#define SIOD_GPIO_NUM 17 -#define SIOC_GPIO_NUM 18 - -#define Y9_GPIO_NUM 39 -#define Y8_GPIO_NUM 41 -#define Y7_GPIO_NUM 42 -#define Y6_GPIO_NUM 12 -#define Y5_GPIO_NUM 3 -#define Y4_GPIO_NUM 14 -#define Y3_GPIO_NUM 47 -#define Y2_GPIO_NUM 13 -#define VSYNC_GPIO_NUM 21 -#define HREF_GPIO_NUM 38 -#define PCLK_GPIO_NUM 11 - -#elif defined(CAMERA_MODEL_ESP32S2_CAM_BOARD) -// The 18 pin header on the board has Y5 and Y3 swapped -#define USE_BOARD_HEADER 0 -#define PWDN_GPIO_NUM 1 -#define RESET_GPIO_NUM 2 -#define XCLK_GPIO_NUM 42 -#define SIOD_GPIO_NUM 41 -#define SIOC_GPIO_NUM 18 - -#define Y9_GPIO_NUM 16 -#define Y8_GPIO_NUM 39 -#define Y7_GPIO_NUM 40 -#define Y6_GPIO_NUM 15 -#if USE_BOARD_HEADER -#define Y5_GPIO_NUM 12 -#else -#define Y5_GPIO_NUM 13 -#endif -#define Y4_GPIO_NUM 5 -#if USE_BOARD_HEADER -#define Y3_GPIO_NUM 13 -#else -#define Y3_GPIO_NUM 12 -#endif -#define Y2_GPIO_NUM 14 -#define VSYNC_GPIO_NUM 38 -#define HREF_GPIO_NUM 4 -#define PCLK_GPIO_NUM 3 - -#elif defined(CAMERA_MODEL_ESP32S3_EYE) -#define PWDN_GPIO_NUM -1 -#define RESET_GPIO_NUM -1 -#define XCLK_GPIO_NUM 15 -#define SIOD_GPIO_NUM 4 -#define SIOC_GPIO_NUM 5 - -#define Y2_GPIO_NUM 11 -#define Y3_GPIO_NUM 9 -#define Y4_GPIO_NUM 8 -#define Y5_GPIO_NUM 10 -#define Y6_GPIO_NUM 12 -#define Y7_GPIO_NUM 18 -#define Y8_GPIO_NUM 17 -#define Y9_GPIO_NUM 16 - -#define VSYNC_GPIO_NUM 6 -#define HREF_GPIO_NUM 7 -#define PCLK_GPIO_NUM 13 - -#else -#error "Camera model not selected" -#endif diff --git a/arduino/esp32-cam/CameraWebServer/index_ov2640.html b/arduino/esp32-cam/CameraWebServer/index_ov2640.html deleted file mode 100644 index 4f3738c..0000000 --- a/arduino/esp32-cam/CameraWebServer/index_ov2640.html +++ /dev/null @@ -1,1160 +0,0 @@ - - - - - - ESP32 OV2460 - - - -
- -
- -
- -
-
-
- - - -- cgit v1.2.3 From ee0341e137f6a8dcf90d5a744e334f66b9d6d60a Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 01:34:08 +0300 Subject: fix platformio.ini generation --- bin/pio_ini.py | 26 +++++++++++++++++--------- include/py/homekit/config/__init__.py | 3 ++- include/py/homekit/pio/products.py | 4 ++-- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/bin/pio_ini.py b/bin/pio_ini.py index 34ad395..2926234 100755 --- a/bin/pio_ini.py +++ b/bin/pio_ini.py @@ -8,17 +8,19 @@ from pprint import pprint from argparse import ArgumentParser, ArgumentError from homekit.pio import get_products, platformio_ini from homekit.pio.exceptions import ProductConfigNotFoundError +from homekit.config import CONFIG_DIRECTORIES def get_config(product: str) -> dict: - config_path = os.path.join( - os.getenv('HOME'), '.config', - 'homekit_pio', f'{product}.yaml' - ) - if not os.path.exists(config_path): - raise ProductConfigNotFoundError(f'{config_path}: product config not found') - - with open(config_path, 'r') as f: + path = None + for directory in CONFIG_DIRECTORIES: + config_path = os.path.join(directory, 'pio', f'{product}.yaml') + if os.path.exists(config_path) and os.path.isfile(config_path): + path = config_path + break + if not path: + raise ProductConfigNotFoundError(f'pio/{product}.yaml not found') + with open(path, 'r') as f: return yaml.safe_load(f) @@ -83,7 +85,8 @@ def bsd_get(product_config: dict, defines[f'CONFIG_{define_name}'] = f'HOMEKIT_{attr_value.upper()}' return if kwargs['type'] == 'bool': - defines[f'CONFIG_{define_name}'] = True + if attr_value is True: + defines[f'CONFIG_{define_name}'] = True return defines[f'CONFIG_{define_name}'] = str(attr_value) bsd_walk(product_config, f) @@ -124,6 +127,11 @@ if __name__ == '__main__': raise ArgumentError(None, f'target {arg.target} not found for product {product}') bsd, bsd_enums = bsd_get(product_config, arg) + print('>>> bsd:') + pprint(bsd) + print('>>> bsd_enums:') + pprint(bsd_enums) + ini = platformio_ini(product_config=product_config, target=arg.target, build_specific_defines=bsd, diff --git a/include/py/homekit/config/__init__.py b/include/py/homekit/config/__init__.py index 2fa5214..8fedfa6 100644 --- a/include/py/homekit/config/__init__.py +++ b/include/py/homekit/config/__init__.py @@ -5,7 +5,8 @@ from .config import ( Translation, config, is_development_mode, - setup_logging + setup_logging, + CONFIG_DIRECTORIES ) from ._configs import ( LinuxBoardsConfig, diff --git a/include/py/homekit/pio/products.py b/include/py/homekit/pio/products.py index 388da03..c4fcd73 100644 --- a/include/py/homekit/pio/products.py +++ b/include/py/homekit/pio/products.py @@ -8,8 +8,8 @@ from collections import OrderedDict _logger = logging.getLogger(__name__) _products_dir = os.path.join( os.path.dirname(__file__), - '..', '..', '..', - 'platformio' + '..', '..', '..', '..', + 'pio' ) -- cgit v1.2.3 From 1215bbf102498fb585b310ba0b5043df875f71fd Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 01:34:50 +0300 Subject: pio_ini: remove debug code that breaks it :( --- bin/pio_ini.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bin/pio_ini.py b/bin/pio_ini.py index 2926234..7254eca 100755 --- a/bin/pio_ini.py +++ b/bin/pio_ini.py @@ -4,7 +4,6 @@ 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 @@ -127,10 +126,6 @@ if __name__ == '__main__': raise ArgumentError(None, f'target {arg.target} not found for product {product}') bsd, bsd_enums = bsd_get(product_config, arg) - print('>>> bsd:') - pprint(bsd) - print('>>> bsd_enums:') - pprint(bsd_enums) ini = platformio_ini(product_config=product_config, target=arg.target, -- cgit v1.2.3 From 00b3cd120f6357a35ef7e8b1c3ffad458a068266 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 01:43:36 +0300 Subject: pio: fix libs paths --- include/pio/libs/main/library.json | 6 +++--- include/pio/libs/mqtt_module_diagnostics/library.json | 4 ++-- include/pio/libs/mqtt_module_ota/library.json | 6 +++--- include/pio/libs/mqtt_module_relay/library.json | 6 +++--- include/pio/libs/mqtt_module_temphum/library.json | 4 ++-- include/py/homekit/pio/products.py | 6 ++++-- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/include/pio/libs/main/library.json b/include/pio/libs/main/library.json index 728d4f8..c5586d8 100644 --- a/include/pio/libs/main/library.json +++ b/include/pio/libs/main/library.json @@ -1,12 +1,12 @@ { "name": "homekit_main", - "version": "1.0.10", + "version": "1.0.11", "build": { "flags": "-I../../include" }, "dependencies": { - "homekit_mqtt_module_ota": "file://../common/libs/mqtt_module_ota", - "homekit_mqtt_module_diagnostics": "file://../common/libs/mqtt_module_diagnostics" + "homekit_mqtt_module_ota": "file://../../include/pio/libs/mqtt_module_ota", + "homekit_mqtt_module_diagnostics": "file://../../include/pio/libs/mqtt_module_diagnostics" } } diff --git a/include/pio/libs/mqtt_module_diagnostics/library.json b/include/pio/libs/mqtt_module_diagnostics/library.json index a3d3244..70acb79 100644 --- a/include/pio/libs/mqtt_module_diagnostics/library.json +++ b/include/pio/libs/mqtt_module_diagnostics/library.json @@ -1,10 +1,10 @@ { "name": "homekit_mqtt_module_diagnostics", - "version": "1.0.2", + "version": "1.0.3", "build": { "flags": "-I../../include" }, "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt" + "homekit_mqtt": "file://../../include/pio/libs/mqtt" } } diff --git a/include/pio/libs/mqtt_module_ota/library.json b/include/pio/libs/mqtt_module_ota/library.json index 4f40a47..1577fed 100644 --- a/include/pio/libs/mqtt_module_ota/library.json +++ b/include/pio/libs/mqtt_module_ota/library.json @@ -1,11 +1,11 @@ { "name": "homekit_mqtt_module_ota", - "version": "1.0.5", + "version": "1.0.6", "build": { "flags": "-I../../include" }, "dependencies": { - "homekit_led": "file://../common/libs/led", - "homekit_mqtt": "file://../common/libs/mqtt" + "homekit_led": "file://../../include/pio/libs/led", + "homekit_mqtt": "file://../../include/pio/libs/mqtt" } } diff --git a/include/pio/libs/mqtt_module_relay/library.json b/include/pio/libs/mqtt_module_relay/library.json index 6cbbfb0..18a510c 100644 --- a/include/pio/libs/mqtt_module_relay/library.json +++ b/include/pio/libs/mqtt_module_relay/library.json @@ -1,11 +1,11 @@ { "name": "homekit_mqtt_module_relay", - "version": "1.0.5", + "version": "1.0.6", "build": { "flags": "-I../../include" }, "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt", - "homekit_relay": "file://../common/libs/relay" + "homekit_mqtt": "file://../../include/pio/libs/mqtt", + "homekit_relay": "file://../../include/pio/libs/relay" } } diff --git a/include/pio/libs/mqtt_module_temphum/library.json b/include/pio/libs/mqtt_module_temphum/library.json index 068debd..c7ee7af 100644 --- a/include/pio/libs/mqtt_module_temphum/library.json +++ b/include/pio/libs/mqtt_module_temphum/library.json @@ -5,7 +5,7 @@ "flags": "-I../../include" }, "dependencies": { - "homekit_mqtt": "file://../common/libs/mqtt", - "homekit_temphum": "file://../common/libs/temphum" + "homekit_mqtt": "file://../../include/pio/libs/mqtt", + "homekit_temphum": "file://../../include/pio/libs/temphum" } } diff --git a/include/py/homekit/pio/products.py b/include/py/homekit/pio/products.py index c4fcd73..a0e7a1f 100644 --- a/include/py/homekit/pio/products.py +++ b/include/py/homekit/pio/products.py @@ -89,8 +89,10 @@ def platformio_ini(product_config: dict, buf.write(f'upload_port = {upload_port}\n') buf.write(f'monitor_speed = {monitor_speed}\n') if libs: - buf.write(f'lib_deps =') + buf.write(f'lib_deps =\n') for lib in libs: + if lib.startswith('homekit_'): + lib = 'file://../../include/pio/libs/'+lib[8:] buf.write(f' {lib}\n') buf.write(f'build_flags =\n') if defines: @@ -107,7 +109,7 @@ def platformio_ini(product_config: dict, if type(value) is str and not is_enum: buf.write('"\\"') buf.write('\n') - buf.write(f' -I../common/include') + buf.write(f' -I../../include/pio/include') buf.write(f'\nbuild_type = {build_type}') return buf.getvalue() -- cgit v1.2.3 From 1d0b9c5d1c90c4f7c7a6eb0c3cf32ffb843f2533 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 02:07:51 +0300 Subject: telegram bots: get rid of requests logging via webapi --- bin/inverter_bot.py | 2 -- bin/polaris_kettle_bot.py | 4 ---- bin/pump_bot.py | 2 -- bin/sensors_bot.py | 4 ---- bin/sound_bot.py | 4 +--- bin/web_api.py | 26 +------------------------- include/py/homekit/api/types/__init__.py | 1 - include/py/homekit/api/types/types.py | 11 ----------- include/py/homekit/api/web_api_client.py | 10 ---------- include/py/homekit/database/bots.py | 10 ---------- include/py/homekit/telegram/_botutil.py | 17 ----------------- include/py/homekit/telegram/bot.py | 29 +++++++++-------------------- test/test_api.py | 5 ++--- 13 files changed, 13 insertions(+), 112 deletions(-) diff --git a/bin/inverter_bot.py b/bin/inverter_bot.py index fdfe436..7da21aa 100755 --- a/bin/inverter_bot.py +++ b/bin/inverter_bot.py @@ -28,7 +28,6 @@ from homekit.inverter.types import ( 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 @@ -921,7 +920,6 @@ class InverterStore(bot.BotDatabase): 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)) diff --git a/bin/polaris_kettle_bot.py b/bin/polaris_kettle_bot.py index 3a24fe0..05c2aae 100755 --- a/bin/polaris_kettle_bot.py +++ b/bin/polaris_kettle_bot.py @@ -10,7 +10,6 @@ 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 @@ -738,9 +737,6 @@ if __name__ == '__main__': kc = KettleController() - if 'api' in config: - bot.enable_logging(BotType.POLARIS_KETTLE) - bot.run() # bot library handles signals, so when sigterm or something like that happens, we should stop all other threads here diff --git a/bin/pump_bot.py b/bin/pump_bot.py index 08d0dc6..2583c5f 100755 --- a/bin/pump_bot.py +++ b/bin/pump_bot.py @@ -11,7 +11,6 @@ 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 @@ -248,7 +247,6 @@ if __name__ == '__main__': mqtt.connect_and_loop(loop_forever=False) - bot.enable_logging(BotType.PUMP) bot.run() try: diff --git a/bin/sensors_bot.py b/bin/sensors_bot.py index c2b0070..43932e1 100755 --- a/bin/sensors_bot.py +++ b/bin/sensors_bot.py @@ -20,7 +20,6 @@ from homekit.telegram import bot from homekit.util import chunks, MySimpleSocketClient from homekit.api import WebApiClient from homekit.api.types import ( - BotType, TemperatureSensorLocation ) @@ -176,7 +175,4 @@ def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: if __name__ == '__main__': - if 'api' in config: - bot.enable_logging(BotType.SENSORS) - bot.run() diff --git a/bin/sound_bot.py b/bin/sound_bot.py index 518151d..fa22ba7 100755 --- a/bin/sound_bot.py +++ b/bin/sound_bot.py @@ -11,7 +11,7 @@ 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.types import SoundSensorLocation from homekit.api.errors import ApiResponseError from homekit.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient from homekit.soundsensor import SoundSensorServerGuardClient @@ -884,7 +884,5 @@ if __name__ == '__main__': finished_handler=record_onfinished, download_on_finish=True) - if 'api' in config: - bot.enable_logging(BotType.SOUND) bot.run() record_client.stop() diff --git a/bin/web_api.py b/bin/web_api.py index 0e0fd0b..e543d22 100755 --- a/bin/web_api.py +++ b/bin/web_api.py @@ -11,7 +11,7 @@ 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.api.types import TemperatureSensorLocation, SoundSensorLocation from homekit.media import SoundRecordStorage @@ -126,30 +126,6 @@ class WebAPIServer(http.HTTPServer): BotsDatabase().add_sound_hits(hits, datetime.now()) return self.ok() - async def POST_bot_request_log(self, req: http.Request): - data = await req.post() - - try: - user_id = int(data['user_id']) - except KeyError: - user_id = 0 - - try: - message = data['message'] - except KeyError: - message = '' - - bot = BotType(int(data['bot'])) - - # validate message - if message.strip() == '': - raise ValueError('message can\'t be empty') - - # add record to the database - BotsDatabase().add_request(bot, user_id, message) - - return self.ok() - async def POST_openwrt_log(self, req: http.Request): data = await req.post() diff --git a/include/py/homekit/api/types/__init__.py b/include/py/homekit/api/types/__init__.py index 9f27ff6..22ce4e6 100644 --- a/include/py/homekit/api/types/__init__.py +++ b/include/py/homekit/api/types/__init__.py @@ -1,5 +1,4 @@ from .types import ( - BotType, TemperatureSensorDataType, TemperatureSensorLocation, SoundSensorLocation diff --git a/include/py/homekit/api/types/types.py b/include/py/homekit/api/types/types.py index 981e798..294a712 100644 --- a/include/py/homekit/api/types/types.py +++ b/include/py/homekit/api/types/types.py @@ -1,17 +1,6 @@ from enum import Enum, auto -class BotType(Enum): - INVERTER = auto() - PUMP = auto() - SENSORS = auto() - ADMIN = auto() - SOUND = auto() - POLARIS_KETTLE = auto() - PUMP_MQTT = auto() - RELAY_MQTT = auto() - - class TemperatureSensorLocation(Enum): BIG_HOUSE_1 = auto() BIG_HOUSE_2 = auto() diff --git a/include/py/homekit/api/web_api_client.py b/include/py/homekit/api/web_api_client.py index 15c1915..f9a8963 100644 --- a/include/py/homekit/api/web_api_client.py +++ b/include/py/homekit/api/web_api_client.py @@ -57,16 +57,6 @@ class WebApiClient: # 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): diff --git a/include/py/homekit/database/bots.py b/include/py/homekit/database/bots.py index cde48b9..fb5f326 100644 --- a/include/py/homekit/database/bots.py +++ b/include/py/homekit/database/bots.py @@ -2,7 +2,6 @@ import pytz from .mysql import mysql_now, MySQLDatabase, datetime_fmt from ..api.types import ( - BotType, SoundSensorLocation ) from typing import Optional, List, Tuple @@ -27,15 +26,6 @@ class OpenwrtLogRecord: class BotsDatabase(MySQLDatabase): - def add_request(self, - bot: BotType, - user_id: int, - message: str): - with self.cursor() as cursor: - cursor.execute("INSERT INTO requests_log (user_id, message, bot, time) VALUES (%s, %s, %s, %s)", - (user_id, message, bot.name.lower(), mysql_now())) - self.commit() - def add_openwrt_logs(self, lines: List[Tuple[datetime, str]], access_point: int): diff --git a/include/py/homekit/telegram/_botutil.py b/include/py/homekit/telegram/_botutil.py index 111a704..4fbbf28 100644 --- a/include/py/homekit/telegram/_botutil.py +++ b/include/py/homekit/telegram/_botutil.py @@ -3,9 +3,6 @@ import traceback from html import escape from telegram import User -from homekit.api import WebApiClient as APIClient -from homekit.api.types import BotType -from homekit.api.errors import ApiResponseError _logger = logging.getLogger(__name__) @@ -24,20 +21,6 @@ def user_any_name(user: User) -> str: return name -class ReportingHelper: - def __init__(self, client: APIClient, bot_type: BotType): - self.client = client - self.bot_type = bot_type - - def report(self, message, text: str = None) -> None: - if text is None: - text = message.text - try: - self.client.log_bot_request(self.bot_type, message.chat_id, text) - except ApiResponseError as error: - _logger.exception(error) - - def exc2text(e: Exception) -> str: tb = ''.join(traceback.format_tb(e.__traceback__)) return f'{e.__class__.__name__}: ' + escape(str(e)) + "\n\n" + escape(tb) diff --git a/include/py/homekit/telegram/bot.py b/include/py/homekit/telegram/bot.py index 2e33bea..5ed8b06 100644 --- a/include/py/homekit/telegram/bot.py +++ b/include/py/homekit/telegram/bot.py @@ -21,12 +21,10 @@ 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 ._botutil import exc2text, IgnoreMarkup from ._botcontext import Context @@ -39,7 +37,6 @@ _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 @@ -511,22 +508,14 @@ async def _default_any_handler(ctx: Context): 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 _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 notify_all(text_getter: callable, diff --git a/test/test_api.py b/test/test_api.py index 80ab62a..b35a597 100755 --- a/test/test_api.py +++ b/test/test_api.py @@ -2,12 +2,11 @@ import __py_include from homekit.api import WebApiClient -from homekit.api.types import BotType from homekit.config import config if __name__ == '__main__': config.load_app('test_api') - api = WebApiClient() - print(api.log_bot_request(BotType.ADMIN, 1, "test_api.py")) + # api = WebApiClient() + # print(api.log_bot_request(BotType.ADMIN, 1, "test_api.py")) -- cgit v1.2.3 From eaab12b8f4722ceae1039e4745088c555d6cbd1e Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 02:27:43 +0300 Subject: pump_bot: port to new config scheme and PTB 20 --- bin/pump_bot.py | 152 +++++++++++++++++++++++-------------- bin/relay_mqtt_bot.py | 3 +- include/py/homekit/telegram/bot.py | 28 +++---- 3 files changed, 113 insertions(+), 70 deletions(-) diff --git a/bin/pump_bot.py b/bin/pump_bot.py index 2583c5f..e00e844 100755 --- a/bin/pump_bot.py +++ b/bin/pump_bot.py @@ -1,27 +1,62 @@ #!/usr/bin/env python3 import __py_include +import sys +import asyncio from enum import Enum -from typing import Optional +from typing import Optional, Union from telegram import ReplyKeyboardMarkup, User from time import time from datetime import datetime -from homekit.config import config, is_development_mode +from homekit.config import config, is_development_mode, AppConfigUnit from homekit.telegram import bot +from homekit.telegram.config import TelegramBotConfig, TelegramUserListType from homekit.telegram._botutil import user_any_name from homekit.relay.sunxi_h3_client import RelayClient -from homekit.mqtt import MqttNode, MqttWrapper, MqttPayload +from homekit.mqtt import MqttNode, MqttWrapper, MqttPayload, MqttNodesConfig, MqttModule from homekit.mqtt.module.relay import MqttPowerStatusPayload, MqttRelayModule from homekit.mqtt.module.temphum import MqttTemphumDataPayload from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload -config.load_app('pump_bot') +if __name__ != '__main__': + print(f'this script can not be imported as module', file=sys.stderr) + sys.exit(1) + + +mqtt_nodes_config = MqttNodesConfig() + + +class PumpBotUserListType(TelegramUserListType): + SILENT = 'silent_users' + + +class PumpBotConfig(AppConfigUnit, TelegramBotConfig): + NAME = 'pump_bot' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + **super(TelegramBotConfig).schema(), + PumpBotUserListType.SILENT: TelegramBotConfig._userlist_schema(), + 'watering_relay_node': {'type': 'string'}, + 'pump_relay_addr': cls._addr_schema() + } + + @staticmethod + def custom_validator(data): + relay_node_names = mqtt_nodes_config.get_nodes(filters=('relay',), only_names=True) + if data['watering_relay_node'] not in relay_node_names: + raise ValueError(f'unknown relay node "{data["watering_relay_node"]}"') + + +config.load_app(PumpBotConfig) + +mqtt: MqttWrapper +mqtt_node: MqttNode +mqtt_relay_module: Union[MqttRelayModule, MqttModule] -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 = { @@ -99,81 +134,89 @@ class UserAction(Enum): def get_relay() -> RelayClient: - relay = RelayClient(host=config['relay']['ip'], port=config['relay']['port']) + relay = RelayClient(host=config.app_config['pump_relay_addr'].host, + port=config.app_config['pump_relay_addr'].port) relay.connect() return relay -def on(ctx: bot.Context, silent=False) -> None: +async def on(ctx: bot.Context, silent=False) -> None: get_relay().on() - ctx.reply(ctx.lang('done')) + futures = [ctx.reply(ctx.lang('done'))] if not silent: - notify(ctx.user, UserAction.ON) + futures.append(notify(ctx.user, UserAction.ON)) + await asyncio.gather(*futures) -def off(ctx: bot.Context, silent=False) -> None: +async def off(ctx: bot.Context, silent=False) -> None: get_relay().off() - ctx.reply(ctx.lang('done')) + futures = [ctx.reply(ctx.lang('done'))] if not silent: - notify(ctx.user, UserAction.OFF) + futures.append(notify(ctx.user, UserAction.OFF)) + await asyncio.gather(*futures) -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) +async def watering_on(ctx: bot.Context) -> None: + mqtt_relay_module.switchpower(True) + await asyncio.gather( + ctx.reply(ctx.lang('sent')), + notify(ctx.user, UserAction.WATERING_ON) + ) -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) +async def watering_off(ctx: bot.Context) -> None: + mqtt_relay_module.switchpower(False) + await asyncio.gather( + ctx.reply(ctx.lang('sent')), + notify(ctx.user, UserAction.WATERING_OFF) + ) -def notify(user: User, action: UserAction) -> None: +async def notify(user: User, action: UserAction) -> None: notification_key = 'user_watering_notification' if action in (UserAction.WATERING_ON, UserAction.WATERING_OFF) else 'user_action_notification' + def text_getter(lang: str): action_name = bot.lang.get(f'user_action_{action.value}', lang) user_name = user_any_name(user) return 'ℹ ' + bot.lang.get(notification_key, lang, user.id, user_name, action_name) - bot.notify_all(text_getter, exclude=(user.id,)) + await bot.notify_all(text_getter, exclude=(user.id,)) @bot.handler(message='enable') -def enable_handler(ctx: bot.Context) -> None: - on(ctx) +async def enable_handler(ctx: bot.Context) -> None: + await on(ctx) @bot.handler(message='enable_silently') -def enable_s_handler(ctx: bot.Context) -> None: - on(ctx, True) +async def enable_s_handler(ctx: bot.Context) -> None: + await on(ctx, True) @bot.handler(message='disable') -def disable_handler(ctx: bot.Context) -> None: - off(ctx) +async def disable_handler(ctx: bot.Context) -> None: + await off(ctx) @bot.handler(message='start_watering') -def start_watering(ctx: bot.Context) -> None: - watering_on(ctx) +async def start_watering(ctx: bot.Context) -> None: + await watering_on(ctx) @bot.handler(message='stop_watering') -def stop_watering(ctx: bot.Context) -> None: - watering_off(ctx) +async def stop_watering(ctx: bot.Context) -> None: + await watering_off(ctx) @bot.handler(message='disable_silently') -def disable_s_handler(ctx: bot.Context) -> None: - off(ctx, True) +async def disable_s_handler(ctx: bot.Context) -> None: + await off(ctx, True) @bot.handler(message='status') -def status(ctx: bot.Context) -> None: - ctx.reply( +async def status(ctx: bot.Context) -> None: + await ctx.reply( ctx.lang('enabled') if get_relay().status() == 'on' else ctx.lang('disabled') ) @@ -186,7 +229,7 @@ def _get_timestamp_as_string(timestamp: int) -> str: @bot.handler(message='watering_status') -def watering_status(ctx: bot.Context) -> None: +async 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' @@ -195,13 +238,13 @@ def watering_status(ctx: bot.Context) -> None: 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) + await ctx.reply(buf) @bot.defaultreplymarkup def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: buttons = [] - if ctx.user_id in config['bot']['silent_users']: + if ctx.user_id in config.app_config.get_user_ids(PumpBotUserListType.SILENT): buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')]) buttons.append([ctx.lang('enable'), ctx.lang('disable'), ctx.lang('status')],) buttons.append([ctx.lang('start_watering'), ctx.lang('stop_watering'), ctx.lang('watering_status')]) @@ -234,22 +277,21 @@ def mqtt_payload_callback(mqtt_node: MqttNode, payload: MqttPayload): 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 = MqttWrapper(client_id='pump_bot') +mqtt_node = MqttNode(node_id=config.app_config['watering_relay_node']) +if is_development_mode(): + mqtt_node.load_module('diagnostics') - mqtt_node.load_module('temphum') - mqtt_relay_module = mqtt_node.load_module('relay') +mqtt_node.load_module('temphum') +mqtt_relay_module = mqtt_node.load_module('relay') - mqtt_node.add_payload_callback(mqtt_payload_callback) +mqtt_node.add_payload_callback(mqtt_payload_callback) - mqtt.connect_and_loop(loop_forever=False) +mqtt.connect_and_loop(loop_forever=False) - bot.run() +bot.run() - try: - mqtt.disconnect() - except: - pass +try: + mqtt.disconnect() +except: + pass diff --git a/bin/relay_mqtt_bot.py b/bin/relay_mqtt_bot.py index 1c1cc94..3ad0a9b 100755 --- a/bin/relay_mqtt_bot.py +++ b/bin/relay_mqtt_bot.py @@ -10,8 +10,7 @@ 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 import MqttPayload, MqttNode, MqttWrapper, MqttModule, MqttNodesConfig from homekit.mqtt.module.relay import MqttRelayModule, MqttRelayState from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload diff --git a/include/py/homekit/telegram/bot.py b/include/py/homekit/telegram/bot.py index 5ed8b06..cf68b1d 100644 --- a/include/py/homekit/telegram/bot.py +++ b/include/py/homekit/telegram/bot.py @@ -26,6 +26,7 @@ from ._botlang import lang, languages from ._botdb import BotDatabase from ._botutil import exc2text, IgnoreMarkup from ._botcontext import Context +from .config import TelegramUserListType db: Optional[BotDatabase] = None @@ -518,29 +519,30 @@ async def _default_any_handler(ctx: Context): # _reporting.report(update.callback_query.message, text=update.callback_query.data) -def notify_all(text_getter: callable, - exclude: Tuple[int] = ()) -> None: - if 'notify_users' not in config['bot']: - _logger.error('notify_all() called but no notify_users directive found in the config') +async def notify_all(text_getter: callable, + exclude: Tuple[int] = ()) -> None: + notify_user_ids = config.app_config.get_user_ids(TelegramUserListType.NOTIFY) + if not notify_user_ids: + _logger.error('notify_all() called but no notify_users defined in the config') return - for user_id in config['bot']['notify_users']: + for user_id in notify_user_ids: if user_id in exclude: continue text = text_getter(db.get_user_lang(user_id)) - _application.bot.send_message(chat_id=user_id, - text=text, - parse_mode='HTML') + await _application.bot.send_message(chat_id=user_id, + text=text, + parse_mode='HTML') -def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> None: +async def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> None: if isinstance(text, Exception): text = exc2text(text) - _application.bot.send_message(chat_id=user_id, - text=text, - parse_mode='HTML', - **kwargs) + await _application.bot.send_message(chat_id=user_id, + text=text, + parse_mode='HTML', + **kwargs) def send_photo(user_id, **kwargs): -- cgit v1.2.3 From a3d6fadb2e99a87346d5b4f2c97755cc6f17f3b7 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 02:33:22 +0300 Subject: gpiorelayd: get rid of config, use command line arguments instead --- bin/gpiorelayd.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/bin/gpiorelayd.py b/bin/gpiorelayd.py index 1f4d2e2..89ba78e 100755 --- a/bin/gpiorelayd.py +++ b/bin/gpiorelayd.py @@ -4,6 +4,8 @@ import os import sys import __py_include +from argparse import ArgumentParser +from homekit.util import Addr from homekit.config import config from homekit.relay.sunxi_h3_server import RelayServer @@ -11,14 +13,19 @@ logger = logging.getLogger(__name__) if __name__ == '__main__': - if not os.getegid() == 0: + if os.getegid() != 0: sys.exit('Must be run as root.') - config.load_app() + parser = ArgumentParser() + parser.add_argument('--pin', type=str, required=True, + help='name of GPIO pin of Allwinner H3 sunxi board') + parser.add_argument('--listen', type=str, required=True, + help='address to listen to, in ip:port format') + + arg = config.load_app(no_config=True, parser=parser) + listen = Addr.fromstring(arg.listen) try: - s = RelayServer(pinname=config.get('relayd.pin'), - addr=config.get_addr('relayd.listen')) - s.run() + RelayServer(pinname=arg.pin, addr=listen).run() except KeyboardInterrupt: logger.info('Exiting...') -- cgit v1.2.3 From d1331c2904703efc2e10b6942170726930b630c8 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 02:35:52 +0300 Subject: gpiorelayd: update systemd service unit file --- systemd/gpiorelayd@.service | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/systemd/gpiorelayd@.service b/systemd/gpiorelayd@.service index a3a8356..e3922dc 100644 --- a/systemd/gpiorelayd@.service +++ b/systemd/gpiorelayd@.service @@ -1,12 +1,13 @@ [Unit] -Description=GPIO Relay Daemon +Description=Homekit: GPIO Relay Daemon for H3 boards After=network-online.target [Service] User=root Group=root Restart=on-failure -ExecStart=/home/user/homekit/bin/gpiorelayd.py -c /etc/gpiorelayd.conf.d/%i.toml +EnvironmentFile=/etc/default/homekit_gpiorelayd_%i +ExecStart=/home/user/homekit/bin/gpiorelayd.py --pin $PIN --listen $LISTEN WorkingDirectory=/root [Install] -- cgit v1.2.3 From 975d2bc6ed6d588187fea4bb538e04ac30cbd989 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 03:06:54 +0300 Subject: telegram/bot: fix missing async/await in some functions --- include/py/homekit/telegram/bot.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/include/py/homekit/telegram/bot.py b/include/py/homekit/telegram/bot.py index cf68b1d..8a78c6f 100644 --- a/include/py/homekit/telegram/bot.py +++ b/include/py/homekit/telegram/bot.py @@ -545,27 +545,27 @@ async def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> No **kwargs) -def send_photo(user_id, **kwargs): - _application.bot.send_photo(chat_id=user_id, **kwargs) +async def send_photo(user_id, **kwargs): + await _application.bot.send_photo(chat_id=user_id, **kwargs) -def send_audio(user_id, **kwargs): - _application.bot.send_audio(chat_id=user_id, **kwargs) +async def send_audio(user_id, **kwargs): + await _application.bot.send_audio(chat_id=user_id, **kwargs) -def send_file(user_id, **kwargs): - _application.bot.send_document(chat_id=user_id, **kwargs) +async def send_file(user_id, **kwargs): + await _application.bot.send_document(chat_id=user_id, **kwargs) -def edit_message_text(user_id, message_id, *args, **kwargs): - _application.bot.edit_message_text(chat_id=user_id, - message_id=message_id, - parse_mode='HTML', - *args, **kwargs) +async def edit_message_text(user_id, message_id, *args, **kwargs): + await _application.bot.edit_message_text(chat_id=user_id, + message_id=message_id, + parse_mode='HTML', + *args, **kwargs) -def delete_message(user_id, message_id): - _application.bot.delete_message(chat_id=user_id, message_id=message_id) +async def delete_message(user_id, message_id): + await _application.bot.delete_message(chat_id=user_id, message_id=message_id) def set_database(_db: BotDatabase): -- cgit v1.2.3 From 0109d6c01db94822757cd7cb84034dd6f4d6cea8 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 03:20:01 +0300 Subject: inverter bot: migrate to PTB 20 (not tested yet) --- bin/inverter_bot.py | 243 ++++++++++++++++++--------------- include/py/homekit/inverter/monitor.py | 2 +- include/py/homekit/telegram/bot.py | 20 +-- 3 files changed, 142 insertions(+), 123 deletions(-) diff --git a/bin/inverter_bot.py b/bin/inverter_bot.py index 7da21aa..032f513 100755 --- a/bin/inverter_bot.py +++ b/bin/inverter_bot.py @@ -5,6 +5,7 @@ import datetime import json import itertools import sys +import asyncio import __py_include from inverterd import Format, InverterError @@ -347,8 +348,11 @@ def monitor_charging(event: ChargingEvent, **kwargs) -> None: key = f'chrg_evt_{key}' if is_util: key = f'util_{key}' - bot.notify_all( - lambda lang: bot.lang.get(key, lang, *args) + + asyncio.ensure_future( + bot.notify_all( + lambda lang: bot.lang.get(key, lang, *args) + ) ) @@ -363,9 +367,11 @@ def monitor_battery(state: BatteryState, v: float, load_watts: int) -> None: logger.error('unknown battery state:', state) return - bot.notify_all( - lambda lang: bot.lang.get('battery_level_changed', lang, - emoji, bot.lang.get(f'bat_state_{state.name.lower()}', lang), v, load_watts) + asyncio.ensure_future( + bot.notify_all( + lambda lang: bot.lang.get('battery_level_changed', lang, + emoji, bot.lang.get(f'bat_state_{state.name.lower()}', lang), v, load_watts) + ) ) @@ -375,14 +381,18 @@ def monitor_util(event: ACPresentEvent): else: key = 'disconnected' key = f'util_{key}' - bot.notify_all( - lambda lang: bot.lang.get(key, lang) + asyncio.ensure_future( + bot.notify_all( + lambda lang: bot.lang.get(key, lang) + ) ) def monitor_error(error: str) -> None: - bot.notify_all( - lambda lang: bot.lang.get('error_message', lang, error) + asyncio.ensure_future( + bot.notify_all( + lambda lang: bot.lang.get('error_message', lang, error) + ) ) @@ -392,35 +402,37 @@ def osp_change_cb(new_osp: OutputSourcePriority, setosp(new_osp) - bot.notify_all( - lambda lang: bot.lang.get('osp_auto_changed_notification', lang, - bot.lang.get(f'settings_osp_{new_osp.value.lower()}', lang), v, solar_input), + asyncio.ensure_future( + bot.notify_all( + lambda lang: bot.lang.get('osp_auto_changed_notification', lang, + bot.lang.get(f'settings_osp_{new_osp.value.lower()}', lang), v, solar_input), + ) ) @bot.handler(command='status') -def full_status(ctx: bot.Context) -> None: +async def full_status(ctx: bot.Context) -> None: status = inverter.exec('get-status', format=Format.TABLE) - ctx.reply(beautify_table(status)) + await ctx.reply(beautify_table(status)) @bot.handler(command='config') -def full_rated(ctx: bot.Context) -> None: +async def full_rated(ctx: bot.Context) -> None: rated = inverter.exec('get-rated', format=Format.TABLE) - ctx.reply(beautify_table(rated)) + await ctx.reply(beautify_table(rated)) @bot.handler(command='errors') -def full_errors(ctx: bot.Context) -> None: +async def full_errors(ctx: bot.Context) -> None: errors = inverter.exec('get-errors', format=Format.TABLE) - ctx.reply(beautify_table(errors)) + await ctx.reply(beautify_table(errors)) @bot.handler(command='flags') -def flags_handler(ctx: bot.Context) -> None: +async def flags_handler(ctx: bot.Context) -> None: flags = inverter.exec('get-flags')['data'] text, markup = build_flags_keyboard(flags, ctx) - ctx.reply(text, markup=markup) + await ctx.reply(text, markup=markup) def build_flags_keyboard(flags: dict, ctx: bot.Context) -> Tuple[str, InlineKeyboardMarkup]: @@ -477,11 +489,11 @@ class SettingsConversation(bot.conversation): REDISCHARGE_VOLTAGES = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58] @bot.conventer(START, message='settings') - def start_enter(self, ctx: bot.Context): + async def start_enter(self, ctx: bot.Context): buttons = list(chunks(list(self.START_BUTTONS), 2)) buttons.reverse() - return self.reply(ctx, self.START, ctx.lang('settings_msg'), buttons, - with_cancel=True) + return await self.reply(ctx, self.START, ctx.lang('settings_msg'), buttons, + with_cancel=True) @bot.convinput(START, messages={ 'settings_osp': OSP, @@ -490,16 +502,16 @@ class SettingsConversation(bot.conversation): 'settings_bat_cut_off_voltage': BAT_CUT_OFF_VOLTAGE, 'settings_ac_max_charging_current': AC_MAX_CHARGING_CURRENT }) - def start_input(self, ctx: bot.Context): + async def start_input(self, ctx: bot.Context): pass @bot.conventer(OSP) - def osp_enter(self, ctx: bot.Context): - return self.reply(ctx, self.OSP, ctx.lang('settings_osp_msg'), self.OSP_BUTTONS, - with_back=True) + async def osp_enter(self, ctx: bot.Context): + return await self.reply(ctx, self.OSP, ctx.lang('settings_osp_msg'), self.OSP_BUTTONS, + with_back=True) @bot.convinput(OSP, messages=OSP_BUTTONS) - def osp_input(self, ctx: bot.Context): + async def osp_input(self, ctx: bot.Context): selected_sp = None for sp in OutputSourcePriority: if ctx.text == ctx.lang(f'settings_osp_{sp.value.lower()}'): @@ -512,25 +524,28 @@ class SettingsConversation(bot.conversation): # apply the mode setosp(selected_sp) - # reply to user - ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()) - - # notify other users - bot.notify_all( - lambda lang: bot.lang.get('osp_changed_notification', lang, - ctx.user.id, ctx.user.name, - bot.lang.get(f'settings_osp_{selected_sp.value.lower()}', lang)), - exclude=(ctx.user_id,) + await asyncio.gather( + # reply to user + ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()), + + # notify other users + bot.notify_all( + lambda lang: bot.lang.get('osp_changed_notification', lang, + ctx.user.id, ctx.user.name, + bot.lang.get(f'settings_osp_{selected_sp.value.lower()}', lang)), + exclude=(ctx.user_id,) + ) ) + return self.END @bot.conventer(AC_PRESET) - def acpreset_enter(self, ctx: bot.Context): - return self.reply(ctx, self.AC_PRESET, ctx.lang('settings_ac_preset_msg'), self.AC_PRESET_BUTTONS, - with_back=True) + async def acpreset_enter(self, ctx: bot.Context): + return await self.reply(ctx, self.AC_PRESET, ctx.lang('settings_ac_preset_msg'), self.AC_PRESET_BUTTONS, + with_back=True) @bot.convinput(AC_PRESET, messages=AC_PRESET_BUTTONS) - def acpreset_input(self, ctx: bot.Context): + async def acpreset_input(self, ctx: bot.Context): if monitor.active_current is not None: raise RuntimeError('generator charging program is active') @@ -547,85 +562,88 @@ class SettingsConversation(bot.conversation): # save bot.db.set_param('ac_mode', str(newmode.value)) - # reply to user - ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()) - - # notify other users - bot.notify_all( - lambda lang: bot.lang.get('ac_mode_changed_notification', lang, - ctx.user.id, ctx.user.name, - bot.lang.get(str(newmode.value), lang)), - exclude=(ctx.user_id,) + await asyncio.gather( + # reply to user + ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()), + + # notify other users + bot.notify_all( + lambda lang: bot.lang.get('ac_mode_changed_notification', lang, + ctx.user.id, ctx.user.name, + bot.lang.get(str(newmode.value), lang)), + exclude=(ctx.user_id,) + ) ) + return self.END @bot.conventer(BAT_THRESHOLDS_1) - def thresholds1_enter(self, ctx: bot.Context): + async def thresholds1_enter(self, ctx: bot.Context): buttons = list(map(lambda v: f'{v} V', self.RECHARGE_VOLTAGES)) buttons = chunks(buttons, 4) - return self.reply(ctx, self.BAT_THRESHOLDS_1, ctx.lang('settings_select_bottom_threshold'), buttons, - with_back=True, buttons_lang_completed=True) + return await self.reply(ctx, self.BAT_THRESHOLDS_1, ctx.lang('settings_select_bottom_threshold'), buttons, + with_back=True, buttons_lang_completed=True) @bot.convinput(BAT_THRESHOLDS_1, messages=list(map(lambda n: f'{n} V', RECHARGE_VOLTAGES)), messages_lang_completed=True) - def thresholds1_input(self, ctx: bot.Context): + async def thresholds1_input(self, ctx: bot.Context): v = self._parse_voltage(ctx.text) ctx.user_data['bat_thrsh_v1'] = v - return self.invoke(self.BAT_THRESHOLDS_2, ctx) + return await self.invoke(self.BAT_THRESHOLDS_2, ctx) @bot.conventer(BAT_THRESHOLDS_2) - def thresholds2_enter(self, ctx: bot.Context): + async def thresholds2_enter(self, ctx: bot.Context): buttons = list(map(lambda v: f'{v} V', self.REDISCHARGE_VOLTAGES)) buttons = chunks(buttons, 4) - return self.reply(ctx, self.BAT_THRESHOLDS_2, ctx.lang('settings_select_upper_threshold'), buttons, - with_back=True, buttons_lang_completed=True) + return await self.reply(ctx, self.BAT_THRESHOLDS_2, ctx.lang('settings_select_upper_threshold'), buttons, + with_back=True, buttons_lang_completed=True) @bot.convinput(BAT_THRESHOLDS_2, messages=list(map(lambda n: f'{n} V', REDISCHARGE_VOLTAGES)), messages_lang_completed=True) - def thresholds2_input(self, ctx: bot.Context): + async def thresholds2_input(self, ctx: bot.Context): v2 = v = self._parse_voltage(ctx.text) v1 = ctx.user_data['bat_thrsh_v1'] del ctx.user_data['bat_thrsh_v1'] response = inverter.exec('set-charge-thresholds', (v1, v2)) - ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', - markup=bot.IgnoreMarkup()) + await ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', + markup=bot.IgnoreMarkup()) return self.END @bot.conventer(AC_MAX_CHARGING_CURRENT) - def ac_max_enter(self, ctx: bot.Context): + async def ac_max_enter(self, ctx: bot.Context): buttons = self._get_allowed_ac_charge_amps() buttons = map(lambda n: f'{n} A', buttons) buttons = [list(buttons)] - return self.reply(ctx, self.AC_MAX_CHARGING_CURRENT, ctx.lang('settings_select_max_current'), buttons, - with_back=True, buttons_lang_completed=True) + return await self.reply(ctx, self.AC_MAX_CHARGING_CURRENT, ctx.lang('settings_select_max_current'), buttons, + with_back=True, buttons_lang_completed=True) @bot.convinput(AC_MAX_CHARGING_CURRENT, regex=r'^\d+ A$') - def ac_max_input(self, ctx: bot.Context): + async def ac_max_input(self, ctx: bot.Context): a = self._parse_amps(ctx.text) allowed = self._get_allowed_ac_charge_amps() if a not in allowed: raise ValueError('input is not allowed') response = inverter.exec('set-max-ac-charge-current', (0, a)) - ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', - markup=bot.IgnoreMarkup()) + await ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', + markup=bot.IgnoreMarkup()) return self.END @bot.conventer(BAT_CUT_OFF_VOLTAGE) - def cutoff_enter(self, ctx: bot.Context): - return self.reply(ctx, self.BAT_CUT_OFF_VOLTAGE, ctx.lang('settings_enter_cutoff_voltage'), None, - with_back=True) + async def cutoff_enter(self, ctx: bot.Context): + return await self.reply(ctx, self.BAT_CUT_OFF_VOLTAGE, ctx.lang('settings_enter_cutoff_voltage'), None, + with_back=True) @bot.convinput(BAT_CUT_OFF_VOLTAGE, regex=r'^(\d{2}(\.\d{1})?)$') - def cutoff_input(self, ctx: bot.Context): + async def cutoff_input(self, ctx: bot.Context): v = float(ctx.text) if 40.0 <= v <= 48.0: response = inverter.exec('set-battery-cutoff-voltage', (v,)) - ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', - markup=bot.IgnoreMarkup()) + await ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR', + markup=bot.IgnoreMarkup()) else: raise ValueError('invalid voltage') @@ -660,38 +678,38 @@ class ConsumptionConversation(bot.conversation): INTERVAL_BUTTONS_FLAT = list(itertools.chain.from_iterable(INTERVAL_BUTTONS)) @bot.conventer(START, message='consumption') - def start_enter(self, ctx: bot.Context): - return self.reply(ctx, self.START, ctx.lang('consumption_msg'), [self.START_BUTTONS], - with_cancel=True) + async def start_enter(self, ctx: bot.Context): + return await self.reply(ctx, self.START, ctx.lang('consumption_msg'), [self.START_BUTTONS], + with_cancel=True) @bot.convinput(START, messages={ 'consumption_total': TOTAL, 'consumption_grid': GRID }) - def start_input(self, ctx: bot.Context): + async def start_input(self, ctx: bot.Context): pass @bot.conventer(TOTAL) - def total_enter(self, ctx: bot.Context): - return self._render_interval_btns(ctx, self.TOTAL) + async def total_enter(self, ctx: bot.Context): + return await self._render_interval_btns(ctx, self.TOTAL) @bot.conventer(GRID) - def grid_enter(self, ctx: bot.Context): - return self._render_interval_btns(ctx, self.GRID) + async def grid_enter(self, ctx: bot.Context): + return await self._render_interval_btns(ctx, self.GRID) - def _render_interval_btns(self, ctx: bot.Context, state): - return self.reply(ctx, state, ctx.lang('consumption_select_interval'), self.INTERVAL_BUTTONS, - with_back=True) + async def _render_interval_btns(self, ctx: bot.Context, state): + return await self.reply(ctx, state, ctx.lang('consumption_select_interval'), self.INTERVAL_BUTTONS, + with_back=True) @bot.convinput(TOTAL, messages=INTERVAL_BUTTONS_FLAT) - def total_input(self, ctx: bot.Context): - return self._render_interval_results(ctx, self.TOTAL) + async def total_input(self, ctx: bot.Context): + return await self._render_interval_results(ctx, self.TOTAL) @bot.convinput(GRID, messages=INTERVAL_BUTTONS_FLAT) - def grid_input(self, ctx: bot.Context): - return self._render_interval_results(ctx, self.GRID) + async def grid_input(self, ctx: bot.Context): + return await self._render_interval_results(ctx, self.GRID) - def _render_interval_results(self, ctx: bot.Context, state): + async def _render_interval_results(self, ctx: bot.Context, state): # if ctx.text == ctx.lang('to_select_interval'): # TODO # pass @@ -715,41 +733,43 @@ class ConsumptionConversation(bot.conversation): # [InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait')] # ]) - message = ctx.reply(ctx.lang('consumption_request_sent'), - markup=bot.IgnoreMarkup()) + message = await ctx.reply(ctx.lang('consumption_request_sent'), + markup=bot.IgnoreMarkup()) api = WebApiClient(timeout=60) method = 'inverter_get_consumed_energy' if state == self.TOTAL else 'inverter_get_grid_consumed_energy' try: wh = getattr(api, method)(s_from, s_to) - bot.delete_message(message.chat_id, message.message_id) - ctx.reply('%.2f Wh' % (wh,), - markup=bot.IgnoreMarkup()) + await bot.delete_message(message.chat_id, message.message_id) + await ctx.reply('%.2f Wh' % (wh,), + markup=bot.IgnoreMarkup()) return self.END except Exception as e: - bot.delete_message(message.chat_id, message.message_id) - ctx.reply_exc(e) + await asyncio.gather( + bot.delete_message(message.chat_id, message.message_id), + ctx.reply_exc(e) + ) # other # ----- @bot.handler(command='monstatus') -def monstatus_handler(ctx: bot.Context) -> None: +async def monstatus_handler(ctx: bot.Context) -> None: msg = '' st = monitor.dump_status() for k, v in st.items(): msg += k + ': ' + str(v) + '\n' - ctx.reply(msg) + await ctx.reply(msg) @bot.handler(command='monsetcur') -def monsetcur_handler(ctx: bot.Context) -> None: - ctx.reply('not implemented yet') +async def monsetcur_handler(ctx: bot.Context) -> None: + await ctx.reply('not implemented yet') @bot.callbackhandler -def button_callback(ctx: bot.Context) -> None: +async def button_callback(ctx: bot.Context) -> None: query = ctx.callback_query if query.data.startswith('flag_'): @@ -762,7 +782,7 @@ def button_callback(ctx: bot.Context) -> None: json_key = k break if not found: - query.answer(ctx.lang('flags_invalid')) + await query.answer(ctx.lang('flags_invalid')) return flags = inverter.exec('get-flags')['data'] @@ -773,32 +793,31 @@ def button_callback(ctx: bot.Context) -> None: response = inverter.exec('set-flag', (flag, target_flag_value)) # notify user - query.answer(ctx.lang('done') if response['result'] == 'ok' else ctx.lang('flags_fail')) + await query.answer(ctx.lang('done') if response['result'] == 'ok' else ctx.lang('flags_fail')) # edit message flags[json_key] = not cur_flag_value text, markup = build_flags_keyboard(flags, ctx) - query.edit_message_text(text, reply_markup=markup) + await query.edit_message_text(text, reply_markup=markup) else: - query.answer(ctx.lang('unexpected_callback_data')) + await query.answer(ctx.lang('unexpected_callback_data')) @bot.exceptionhandler -def exception_handler(e: Exception, ctx: bot.Context) -> Optional[bool]: +async def exception_handler(e: Exception, ctx: bot.Context) -> Optional[bool]: if isinstance(e, InverterError): try: err = json.loads(str(e))['message'] except json.decoder.JSONDecodeError: err = str(e) err = re.sub(r'((?:.*)?error:) (.*)', r'\1 \2', err) - ctx.reply(err, - markup=bot.IgnoreMarkup()) + await ctx.reply(err, markup=bot.IgnoreMarkup()) return True @bot.handler(message='status') -def status_handler(ctx: bot.Context) -> None: +async def status_handler(ctx: bot.Context) -> None: gs = inverter.exec('get-status')['data'] rated = inverter.exec('get-rated')['data'] @@ -842,11 +861,11 @@ def status_handler(ctx: bot.Context) -> None: html += f'\n{ctx.lang("priority")}: {rated["output_source_priority"]}' # send response - ctx.reply(html) + await ctx.reply(html) @bot.handler(message='generation') -def generation_handler(ctx: bot.Context) -> None: +async def generation_handler(ctx: bot.Context) -> None: today = datetime.date.today() yday = today - datetime.timedelta(days=1) yday2 = today - datetime.timedelta(days=2) @@ -876,7 +895,7 @@ def generation_handler(ctx: bot.Context) -> None: html += f'\n{ctx.lang("yday2")}: %s Wh' % (gen_yday2['wh']) # send response - ctx.reply(html) + await ctx.reply(html) @bot.defaultreplymarkup diff --git a/include/py/homekit/inverter/monitor.py b/include/py/homekit/inverter/monitor.py index 86f75ac..5955d92 100644 --- a/include/py/homekit/inverter/monitor.py +++ b/include/py/homekit/inverter/monitor.py @@ -25,7 +25,7 @@ def _pd_from_string(pd: str) -> BatteryPowerDirection: class MonitorConfig: def __getattr__(self, item): - return config['monitor'][item] + return config.app_config['monitor'][item] cfg = MonitorConfig() diff --git a/include/py/homekit/telegram/bot.py b/include/py/homekit/telegram/bot.py index 8a78c6f..2efd9e4 100644 --- a/include/py/homekit/telegram/bot.py +++ b/include/py/homekit/telegram/bot.py @@ -274,7 +274,7 @@ class conversation: continue cd = f.__dict__['_conv_data'] if cd['enter'] and cd['state'] == state: - return cd['orig_f'](self, ctx) + return await cd['orig_f'](self, ctx) raise RuntimeError(f'invoke: failed to find method for state {state}') @@ -362,14 +362,14 @@ class conversation: # buttons.insert(0, [ctx.lang('back')]) buttons.append([ctx.lang('back')]) - def reply(self, - ctx: Context, - state: Union[int, Enum], - text: str, - buttons: Optional[list], - with_cancel=False, - with_back=False, - buttons_lang_completed=False): + async def reply(self, + ctx: Context, + state: Union[int, Enum], + text: str, + buttons: Optional[list], + with_cancel=False, + with_back=False, + buttons_lang_completed=False): if buttons: new_buttons = [] @@ -400,7 +400,7 @@ class conversation: self.add_back_button(ctx, new_buttons) markup = ReplyKeyboardMarkup(new_buttons, one_time_keyboard=True) if new_buttons else IgnoreMarkup() - ctx.reply(text, markup=markup) + await ctx.reply(text, markup=markup) self.set_user_state(ctx.user_id, state) return state -- cgit v1.2.3 From 387c26e218f7bf10819d7bed657f7f62b64e18ce Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 03:30:12 +0300 Subject: move some scripts around, delete obsolete ones --- bin/ipcam_capture.sh | 119 ++++++++ bin/ipcam_motion_worker.sh | 327 +++++++++++++++++++++ bin/ipcam_rtsp2hls.sh | 127 ++++++++ include/bash/include.bash | 130 ++++++++ .../homekit_ipcam_capture_restart.sh | 7 + .../homekit_ipcam_rtsp2hls_restart.sh | 8 + .../homekit_make_netns_per_upstream.sh | 38 +++ .../homekit_sunxi_h3_i2c_reset.sh | 25 ++ .../homekit_sunxi_setup_amixer.sh | 114 +++++++ .../homekit_sync_recordings_to_remote.sh | 72 +++++ misc/scripts/ipcam_capture_restart.sh | 7 - misc/scripts/ipcam_rtsp2hls_restart.sh | 8 - misc/scripts/make_netns_per_upstream.sh | 38 --- misc/scripts/remote_server/clickhouse_backup.sh | 31 ++ .../scripts/remote_server/remove_old_recordings.sh | 5 + systemd/ipcam_capture@.service | 2 +- systemd/ipcam_rtsp2hls@.service | 2 +- tools/clickhouse-backup.sh | 31 -- tools/ipcam_capture.sh | 119 -------- tools/ipcam_motion_worker.sh | 327 --------------------- tools/ipcam_rtsp2hls.sh | 127 -------- tools/lib.bash | 130 -------- tools/process-motion-timecodes.py | 61 ---- tools/remove-old-recordings.sh | 5 - tools/rotate-video.sh | 2 +- tools/sunxi-h3-i2c-reset.sh | 25 -- tools/sunxi-setup-amixer.sh | 114 ------- tools/sync-recordings-to-remote.sh | 72 ----- tools/video-util.sh | 2 +- 29 files changed, 1007 insertions(+), 1068 deletions(-) create mode 100755 bin/ipcam_capture.sh create mode 100755 bin/ipcam_motion_worker.sh create mode 100755 bin/ipcam_rtsp2hls.sh create mode 100644 include/bash/include.bash create mode 100644 misc/scripts/home_linux_boards/homekit_ipcam_capture_restart.sh create mode 100644 misc/scripts/home_linux_boards/homekit_ipcam_rtsp2hls_restart.sh create mode 100644 misc/scripts/home_linux_boards/homekit_make_netns_per_upstream.sh create mode 100644 misc/scripts/home_linux_boards/homekit_sunxi_h3_i2c_reset.sh create mode 100755 misc/scripts/home_linux_boards/homekit_sunxi_setup_amixer.sh create mode 100755 misc/scripts/home_linux_boards/homekit_sync_recordings_to_remote.sh delete mode 100644 misc/scripts/ipcam_capture_restart.sh delete mode 100644 misc/scripts/ipcam_rtsp2hls_restart.sh delete mode 100644 misc/scripts/make_netns_per_upstream.sh create mode 100644 misc/scripts/remote_server/clickhouse_backup.sh create mode 100644 misc/scripts/remote_server/remove_old_recordings.sh delete mode 100644 tools/clickhouse-backup.sh delete mode 100755 tools/ipcam_capture.sh delete mode 100755 tools/ipcam_motion_worker.sh delete mode 100755 tools/ipcam_rtsp2hls.sh delete mode 100644 tools/lib.bash delete mode 100755 tools/process-motion-timecodes.py delete mode 100644 tools/remove-old-recordings.sh delete mode 100644 tools/sunxi-h3-i2c-reset.sh delete mode 100755 tools/sunxi-setup-amixer.sh delete mode 100755 tools/sync-recordings-to-remote.sh diff --git a/bin/ipcam_capture.sh b/bin/ipcam_capture.sh new file mode 100755 index 0000000..b97c856 --- /dev/null +++ b/bin/ipcam_capture.sh @@ -0,0 +1,119 @@ +#!/bin/bash + +PROGNAME="$0" +PORT=554 +IP= +CREDS= +DEBUG=0 +CHANNEL=1 +FORCE_UDP=0 +FORCE_TCP=0 +EXTENSION="mp4" + +die() { + echo >&2 "error: $@" + exit 1 +} + +usage() { + cat </dev/null && pwd )" +PROGNAME="$0" + +. "$DIR/../include/bash/include.bash" + +curl_opts="-s --connect-timeout 10 --retry 5 --max-time 180 --retry-delay 0 --retry-max-time 180" +allow_multiple= +fetch_limit=10 + +config= +config_camera= +is_remote= +api_url= + +dvr_scan_path="$HOME/.local/bin/dvr-scan" +fs_root="/var/ipcam_motion_fs" +fs_max_filesize=146800640 + +declare -A config=() + +usage() { + cat </dev/null || die "failed to change to ${fs_root}" + touch tmp || die "directory '${fs_root}' is not writable" + rm tmp + + [ -f "video.mp4" ] && { + echowarn "video.mp4 already exists in ${fs_root}, removing.." + rm "video.mp4" + } + fi + + while read line; do + words=($line) + file=${words[0]} + size=${words[1]} + camera=${words[2]} + + debug "next video: cam=$camera file=$file" + + read_camera_motion_config "$camera" +# dump_config + + if [ "$is_remote" = "0" ]; then + local_recs_dir="$(get_recordings_dir "$camera")" + + debug "[$camera] processing $file..." + + tc=$(do_motion "$camera" "${local_recs_dir}/$file") + debug "[$camera] $file: timecodes=$tc" + + report_timecodes "$camera" "$file" "$tc" + else + if (( size > fs_max_filesize )); then + echoerr "[$camera] won't download $file, size exceeds fs_max_filesize ($size > ${fs_max_filesize})" + report_failure "$camera" "$file" "too large file" + continue + fi + + url="${api_url}/api/recordings/${camera}/download/${file}" + debug "[$camera] downloading $url..." + + if ! download "$url" "video.mp4"; then + echoerr "[$camera] failed to download $file" + report_failure "$camera" "$file" "download error" + continue + fi + + tc=$(do_motion "$camera" "video.mp4") + debug "[$camera] $file: timecodes=$tc" + + report_timecodes "$camera" "$file" "$tc" + + rm "video.mp4" + fi + done < <(get_recordings_list) + + if [ "$is_remote" = "1" ]; then popd >/dev/null; fi +} + +do_motion() { + local camera="$1" + local input="$2" + local tc + + local timecodes=() + + time_start + while read line; do + if ! [[ "$line" =~ ^#.* ]]; then + tc="$(do_dvr_scan "$input" "$line")" + if [ -n "$tc" ]; then + timecodes+=("$tc") + fi + fi + done < <(get_camera_roi_config "$camera") + + debug "[$camera] do_motion: finished in $(time_elapsed)s" + + timecodes="$(echo "${timecodes[@]}" | sed 's/ */ /g' | xargs)" + timecodes="${timecodes// /,}" + + echo "$timecodes" +} + +dvr_scan() { + "${dvr_scan_path}" "$@" +} + +do_dvr_scan() { + local input="$1" + local args= + + if [ ! -z "$2" ]; then + args="-roi $2" + echoinfo "dvr_scan(${BOLD}${input}${RST}${CYAN}): roi=($2), mt=${config[threshold]}" + else + echoinfo "dvr_scan(${BOLD}${input}${RST}${CYAN}): no roi, mt=${config[threshold]}" + fi + + dvr_scan -q -i "$input" -so \ + --min-event-length ${config[min_event_length]} \ + -df ${config[downscale_factor]} \ + --frame-skip ${config[frame_skip]} \ + -t ${config[threshold]} $args | tail -1 +} + +[[ $# -lt 1 ]] && usage + +while [[ $# -gt 0 ]]; do + case $1 in + -L|--fetch-limit) + fetch_limit="$2" + shift; shift + ;; + + --allow-multiple) + allow_multiple=1 + shift + ;; + + --remote) + is_remote=1 + shift + ;; + + --local) + is_remote=0 + shift + ;; + + --dvr-scan-path) + dvr_scan_path="$2" + shift; shift + ;; + + --fs-root) + fs_root="$2" + shift; shift + ;; + + --fs-max-filesize) + fs_max_filesize="$2" + shift; shift + ;; + + --api-url) + api_url="$2" + shift; shift + ;; + + -v) + VERBOSE=1 + shift + ;; + + -vx) + VERBOSE=1 + set -x + shift + ;; + + *) + die "unrecognized argument '$1'" + exit 1 + ;; + esac +done + +if [ -z "$allow_multiple" ] && pidof -o %PPID -x "$(basename "${BASH_SOURCE[0]}")" >/dev/null; then + die "process already running" +fi + +[ -z "$is_remote" ] && die "either --remote or --local is required" +[ -z "$api_url" ] && die "--api-url is required" + +process_queue \ No newline at end of file diff --git a/bin/ipcam_rtsp2hls.sh b/bin/ipcam_rtsp2hls.sh new file mode 100755 index 0000000..c321820 --- /dev/null +++ b/bin/ipcam_rtsp2hls.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +PROGNAME="$0" +OUTDIR=/var/ipcamfs # should be tmpfs +PORT=554 +NAME= +IP= +USER= +PASSWORD= +DEBUG=0 +CHANNEL=1 +FORCE_UDP=0 +FORCE_TCP=0 +CUSTOM_PATH= + +die() { + echo >&2 "error: $@" + exit 1 +} + +usage() { + cat <&2 echo "${CYAN}$@${RST}" +} + +echoerr() { + >&2 echo "${RED}${BOLD}error:${RST}${RED} $@${RST}" +} + +echowarn() { + >&2 echo "${YELLOW}${BOLD}warning:${RST}${YELLOW} $@${RST}" +} + +die() { + echoerr "$@" + exit 1 +} + +debug() { + if [ -n "$VERBOSE" ]; then + >&2 echo "$@" + fi +} + + +# measuring executing time +# ------------------------ + +__time_started= + +time_start() { + __time_started=$(date +%s) +} + +time_elapsed() { + local fin=$(date +%s) + echo $(( fin - __time_started )) +} + + +# config parsing +# -------------- + +read_config() { + local config_file="$1" + local dst="$2" + + [ -f "$config_file" ] || die "read_config: $config_file: no such file" + + local n=0 + local failed= + local key + local value + + while read line; do + n=$(( n+1 )) + + # skip empty lines or comments + if [ -z "$line" ] || [[ "$line" =~ ^#.* ]]; then + continue + fi + + if [[ $line = *"="* ]]; then + key="${line%%=*}" + value="${line#*=}" + eval "$dst[$key]=\"$value\"" + else + echoerr "config: invalid line $n" + failed=1 + fi + done < <(cat "$config_file") + + [ -z "$failed" ] +} + +check_config() { + local var="$1" + local keys="$2" + + local failed= + + for key in $keys; do + if [ -z "$(eval "echo -n \${$var[$key]}")" ]; then + echoerr "config: ${BOLD}${key}${RST}${RED} is missing" + failed=1 + fi + done + + [ -z "$failed" ] +} + + +# other functions +# --------------- + +installed() { + command -v "$1" > /dev/null + return $? +} + +download() { + local source="$1" + local target="$2" + + if installed curl; then + curl -f -s -o "$target" "$source" + elif installed wget; then + wget -q -O "$target" "$source" + else + die "neither curl nor wget found, can't proceed" + fi +} + +file_in_use() { + [ -n "$(lsof "$1")" ] +} + +file_mtime() { + stat -c %Y "$1" +} diff --git a/misc/scripts/home_linux_boards/homekit_ipcam_capture_restart.sh b/misc/scripts/home_linux_boards/homekit_ipcam_capture_restart.sh new file mode 100644 index 0000000..85144da --- /dev/null +++ b/misc/scripts/home_linux_boards/homekit_ipcam_capture_restart.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +for f in $(ls /etc/ipcam_capture.conf.d/ | xargs); do + camera="${f/.conf/}" + echo "restarting $camera" + systemctl restart ipcam_capture@${camera} +done \ No newline at end of file diff --git a/misc/scripts/home_linux_boards/homekit_ipcam_rtsp2hls_restart.sh b/misc/scripts/home_linux_boards/homekit_ipcam_rtsp2hls_restart.sh new file mode 100644 index 0000000..61ee623 --- /dev/null +++ b/misc/scripts/home_linux_boards/homekit_ipcam_rtsp2hls_restart.sh @@ -0,0 +1,8 @@ +#!/bin/bash +cd /etc/ipcam_rtsp2hls.conf.d/ +for f in *-low.conf; do + f=${f/-low.conf/} + echo "restarting $f" + systemctl restart ipcam_rtsp2hls@${f} + systemctl restart ipcam_rtsp2hls@${f}-low +done diff --git a/misc/scripts/home_linux_boards/homekit_make_netns_per_upstream.sh b/misc/scripts/home_linux_boards/homekit_make_netns_per_upstream.sh new file mode 100644 index 0000000..fb152fa --- /dev/null +++ b/misc/scripts/home_linux_boards/homekit_make_netns_per_upstream.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +set -x +set -e + +get_default_iface() { + ip -4 r show default | awk '{print $5}' +} + +declare -A UPSTREAMS=( + [mtsil]=102 + [mtsazov]=100 + [rtazov]=101 +) + +for name in "${!UPSTREAMS[@]}"; do + mark=${UPSTREAMS[$name]} + veth_addr=10.${mark}.1.1 + vpeer_addr=10.${mark}.1.2 + veth_if=veth${name} + vpeer_if=vpeer${name} + + ip netns add $name + ip link add $veth_if type veth peer name $vpeer_if + ip link set $vpeer_if netns $name + ip addr add $veth_addr/24 dev $veth_if + ip link set $veth_if up + + ip netns exec $name ip addr add $vpeer_addr/24 dev $vpeer_if + ip netns exec $name ip link set $vpeer_if up + ip netns exec $name ip link set lo up + ip netns exec $name ip route add default via $veth_addr + + iptables -t mangle -A PREROUTING -s $vpeer_addr/24 -j MARK --set-mark $mark + iptables -t nat -A POSTROUTING -s $vpeer_addr/24 -o "$(get_default_iface)" -j MASQUERADE +done + +sysctl net.ipv4.ip_forward=1 diff --git a/misc/scripts/home_linux_boards/homekit_sunxi_h3_i2c_reset.sh b/misc/scripts/home_linux_boards/homekit_sunxi_h3_i2c_reset.sh new file mode 100644 index 0000000..e654dfb --- /dev/null +++ b/misc/scripts/home_linux_boards/homekit_sunxi_h3_i2c_reset.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +devices="1c2ac00.i2c 1c2b000.i2c" +pins="8 9 28 30" +driver_path="/sys/bus/platform/drivers/mv64xxx_i2c" + +driver_unbind() { + echo -n "$1" > "$driver_path/unbind" +} + +driver_bind() { + echo -n "$1" > "$driver_path/bind" +} + +for dev in $devices; do driver_unbind "$dev"; done +echo "unbind done" + +for pin in pins; do + gpio mode $pin out + gpio write $pin 0 +done +echo "gpio reset done" + +for dev in $devices; do driver_bind "$dev"; done +echo "bind done" \ No newline at end of file diff --git a/misc/scripts/home_linux_boards/homekit_sunxi_setup_amixer.sh b/misc/scripts/home_linux_boards/homekit_sunxi_setup_amixer.sh new file mode 100755 index 0000000..5746514 --- /dev/null +++ b/misc/scripts/home_linux_boards/homekit_sunxi_setup_amixer.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +amixer() { + /usr/bin/amixer "$@" +} + +setup_opi_pc2() { + for v in unmute cap; do + amixer set "Line In" $v + amixer set "Mic1" $v + amixer set "Mic2" $v + done + + for k in "Mic1 Boost" "Line In" "Mic1" "Mic2 Boost" "Mic2"; do + amixer set "$k" "86%" + done +} + +setup_opi_one() { + for v in unmute cap; do + amixer set "Line In" $v + amixer set "Mic1" $v + done + + for k in "Mic1 Boost" "Line In" "Mic1"; do + amixer set "$k" "86%" + done +} + +setup_opi3lts() { + switches=( + "Left DAC Mixer ADCL" + "Left DAC Mixer I2SDACL" + "Left I2S Mixer ADCL" + "Left I2S Mixer I2SDACL" + "Left Input Mixer LINEINL" + "Left Input Mixer MIC1" + "Left Input Mixer MIC2" + "Left Input Mixer OMixerL" + "Left Input Mixer OMixerR" + "Left Input Mixer PhoneN" + "Left Input Mixer PhonePN" + "Left Output Mixer DACL" + "Left Output Mixer DACR" + "Left Output Mixer LINEINL" + "Left Output Mixer MIC1" + "Left Output Mixer MIC2" + "Left Output Mixer PhoneN" + "Left Output Mixer PhonePN" + "Right DAC Mixer ADCR" + "Right DAC Mixer I2SDACR" + "Right I2S Mixer ADCR" + "Right I2S Mixer I2SDACR" + "Right Input Mixer LINEINR" + "Right Input Mixer MIC1" + "Right Input Mixer MIC2" + "Right Input Mixer OMixerL" + "Right Input Mixer OMixerR" + "Right Input Mixer PhoneP" + "Right Input Mixer PhonePN" + "Right Output Mixer DACL" + "Right Output Mixer DACR" + "Right Output Mixer LINEINR" + "Right Output Mixer MIC1" + "Right Output Mixer MIC2" + "Right Output Mixer PhoneP" + "Right Output Mixer PhonePN" + ) + for v in "${switches[@]}"; do + value=on + case "$v" in + *Input*) + value=on + ;; + *Output*) + value=off + ;; + esac + amixer set "$v" $value + done + + to_mute=( + "I2S Mixer ADC" + "I2S Mixer DAC" + "ADC Input" + "DAC Mixer ADC" + "DAC Mxier DAC" # this is not a typo + ) + for v in "${to_mute[@]}"; do + amixer set "$v" "0%" + done + + amixer set "Master" "100%" + amixer set "MIC1 Boost" "100%" + amixer set "MIC2 Boost" "100%" + amixer set "Line Out Mixer" "86%" + amixer set "MIC Out Mixer" "71%" +} + +device="$(tr -d '\0' < /sys/firmware/devicetree/base/model)" +case "$device" in + *"Orange Pi PC 2") + setup_opi_pc2 + ;; + *"Orange Pi One"|*"Orange Pi Lite") + setup_opi_one + ;; + *"OrangePi 3 LTS") + setup_opi3lts + ;; + *) + >&2 echo "error: unidentified device: $device" + ;; +esac diff --git a/misc/scripts/home_linux_boards/homekit_sync_recordings_to_remote.sh b/misc/scripts/home_linux_boards/homekit_sync_recordings_to_remote.sh new file mode 100755 index 0000000..cf979d1 --- /dev/null +++ b/misc/scripts/home_linux_boards/homekit_sync_recordings_to_remote.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +PROGNAME="$0" +NODE_CONFIG="/etc/sound_node.toml" +REMOTE_USER=user +REMOTE_SERVER=solarmon.ru +REMOTE_DIRECTORY=/var/recordings + +set -e + +echoerr() { + >&2 echo "error: $@" +} + +echowarn() { + >&2 echo "warning: $@" +} + +telegram_alert() { + if [ -z "$TG_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then return; fi + curl -X POST \ + -F "chat_id=${TG_CHAT_ID}" \ + -F "text=$1" \ + "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" +} + +fatal() { + echoerr "$@" + telegram_alert "$PROGNAME: $@" + exit 1 +} + +get_config_var() { + local varname="$1" + cat "$NODE_CONFIG" | grep "^$varname = \"" | awk '{print $3}' | tr -d '"' +} + +get_mp3_count() { + find "$LOCAL_DIR" -mindepth 1 -type f -name "*.mp3" -printf x | wc -c +} + +[ -z "$TG_TOKEN" ] && echowarn "TG_TOKEN is not set" +[ -z "$TG_CHAT_ID" ] && echowarn "TG_CHAT_ID is not set" + +NODE_NAME=$(get_config_var name) +LOCAL_DIR=$(get_config_var storage) + +[ -z "$NODE_NAME" ] && fatal "failed to parse NODE_NAME" +[ -z "$LOCAL_DIR" ] && fatal "failed to parse LOCAL_DIR" + +[ -d "$LOCAL_DIR" ] || fatal "$LOCAL_DIR is not a directory" + +COUNT=$(get_mp3_count) +(( $COUNT < 1 )) && { + echo "seems there's nothing to sync" + exit +} + +cd "$LOCAL_DIR" || fatal "failed to change to $LOCAL_DIR" + +rsync -azPv -e "ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=ERROR" \ + *.mp3 \ + ${REMOTE_USER}@${REMOTE_SERVER}:"${REMOTE_DIRECTORY}/${NODE_NAME}/" \ + --exclude temp.mp3 + +RC=$? + +if [ $RC -eq 0 ]; then + find "$LOCAL_DIR" -name "*.mp3" -type f -mmin +1440 -delete || fatal "find failed to delete old files" +else + fatal "failed to rsync: code $RC" +fi diff --git a/misc/scripts/ipcam_capture_restart.sh b/misc/scripts/ipcam_capture_restart.sh deleted file mode 100644 index 85144da..0000000 --- a/misc/scripts/ipcam_capture_restart.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -for f in $(ls /etc/ipcam_capture.conf.d/ | xargs); do - camera="${f/.conf/}" - echo "restarting $camera" - systemctl restart ipcam_capture@${camera} -done \ No newline at end of file diff --git a/misc/scripts/ipcam_rtsp2hls_restart.sh b/misc/scripts/ipcam_rtsp2hls_restart.sh deleted file mode 100644 index 61ee623..0000000 --- a/misc/scripts/ipcam_rtsp2hls_restart.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -cd /etc/ipcam_rtsp2hls.conf.d/ -for f in *-low.conf; do - f=${f/-low.conf/} - echo "restarting $f" - systemctl restart ipcam_rtsp2hls@${f} - systemctl restart ipcam_rtsp2hls@${f}-low -done diff --git a/misc/scripts/make_netns_per_upstream.sh b/misc/scripts/make_netns_per_upstream.sh deleted file mode 100644 index fb152fa..0000000 --- a/misc/scripts/make_netns_per_upstream.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -set -x -set -e - -get_default_iface() { - ip -4 r show default | awk '{print $5}' -} - -declare -A UPSTREAMS=( - [mtsil]=102 - [mtsazov]=100 - [rtazov]=101 -) - -for name in "${!UPSTREAMS[@]}"; do - mark=${UPSTREAMS[$name]} - veth_addr=10.${mark}.1.1 - vpeer_addr=10.${mark}.1.2 - veth_if=veth${name} - vpeer_if=vpeer${name} - - ip netns add $name - ip link add $veth_if type veth peer name $vpeer_if - ip link set $vpeer_if netns $name - ip addr add $veth_addr/24 dev $veth_if - ip link set $veth_if up - - ip netns exec $name ip addr add $vpeer_addr/24 dev $vpeer_if - ip netns exec $name ip link set $vpeer_if up - ip netns exec $name ip link set lo up - ip netns exec $name ip route add default via $veth_addr - - iptables -t mangle -A PREROUTING -s $vpeer_addr/24 -j MARK --set-mark $mark - iptables -t nat -A POSTROUTING -s $vpeer_addr/24 -o "$(get_default_iface)" -j MASQUERADE -done - -sysctl net.ipv4.ip_forward=1 diff --git a/misc/scripts/remote_server/clickhouse_backup.sh b/misc/scripts/remote_server/clickhouse_backup.sh new file mode 100644 index 0000000..6e938e4 --- /dev/null +++ b/misc/scripts/remote_server/clickhouse_backup.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +DIR=/var/lib/clickhouse/backup +MAX_COUNT=3 +NAME=backup_$(date -u +%Y-%m-%d) + +create() { + local name="$1" + clickhouse-backup create "$name" +} + +del() { + local name="$1" + clickhouse-backup delete local "$name" +} + +# create a backup +create "$NAME" + +# compress backup +cd "$DIR" +tar czvf $NAME.tar.gz $NAME + +# delete uncompressed files +del "$NAME" + +# delete old backups +for file in $(ls -t "${DIR}" | tail -n +$(( MAX_COUNT+1 ))); do + echo "removing $file..." + rm "$file" +done \ No newline at end of file diff --git a/misc/scripts/remote_server/remove_old_recordings.sh b/misc/scripts/remote_server/remove_old_recordings.sh new file mode 100644 index 0000000..d376572 --- /dev/null +++ b/misc/scripts/remote_server/remove_old_recordings.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +# to be launched by cron on remote server + +find /var/recordings -type f -mtime +14 -delete diff --git a/systemd/ipcam_capture@.service b/systemd/ipcam_capture@.service index b1c363e..e195231 100644 --- a/systemd/ipcam_capture@.service +++ b/systemd/ipcam_capture@.service @@ -8,7 +8,7 @@ RestartSec=3 User=user Group=user EnvironmentFile=/etc/ipcam_capture.conf.d/%i.conf -ExecStart=/home/user/homekit/tools/ipcam_capture.sh --outdir $OUTDIR --creds $CREDS --ip $IP --port $PORT $ARGS +ExecStart=/home/user/homekit/bin/ipcam_capture.sh --outdir $OUTDIR --creds $CREDS --ip $IP --port $PORT $ARGS Restart=always [Install] diff --git a/systemd/ipcam_rtsp2hls@.service b/systemd/ipcam_rtsp2hls@.service index efcdd6a..9ce6cca 100644 --- a/systemd/ipcam_rtsp2hls@.service +++ b/systemd/ipcam_rtsp2hls@.service @@ -8,7 +8,7 @@ RestartSec=3 User=user Group=user EnvironmentFile=/etc/ipcam_rtsp2hls.conf.d/%i.conf -ExecStart=/home/user/homekit/tools/ipcam_rtsp2hls.sh --name %i --user $USER --password $PASSWORD --ip $IP --port $PORT $ARGS +ExecStart=/home/user/homekit/bin/ipcam_rtsp2hls.sh --name %i --user $USER --password $PASSWORD --ip $IP --port $PORT $ARGS Restart=on-failure RestartSec=3 diff --git a/tools/clickhouse-backup.sh b/tools/clickhouse-backup.sh deleted file mode 100644 index 6e938e4..0000000 --- a/tools/clickhouse-backup.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -DIR=/var/lib/clickhouse/backup -MAX_COUNT=3 -NAME=backup_$(date -u +%Y-%m-%d) - -create() { - local name="$1" - clickhouse-backup create "$name" -} - -del() { - local name="$1" - clickhouse-backup delete local "$name" -} - -# create a backup -create "$NAME" - -# compress backup -cd "$DIR" -tar czvf $NAME.tar.gz $NAME - -# delete uncompressed files -del "$NAME" - -# delete old backups -for file in $(ls -t "${DIR}" | tail -n +$(( MAX_COUNT+1 ))); do - echo "removing $file..." - rm "$file" -done \ No newline at end of file diff --git a/tools/ipcam_capture.sh b/tools/ipcam_capture.sh deleted file mode 100755 index 08b9093..0000000 --- a/tools/ipcam_capture.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/bash - -PROGNAME="$0" -PORT=554 -IP= -CREDS= -DEBUG=0 -CHANNEL=1 -FORCE_UDP=0 -FORCE_TCP=0 -EXTENSION="mp4" - -die() { - echo >&2 "error: $@" - exit 1 -} - -usage() { - cat </dev/null && pwd )" -PROGNAME="$0" - -. "$DIR/lib.bash" - -curl_opts="-s --connect-timeout 10 --retry 5 --max-time 180 --retry-delay 0 --retry-max-time 180" -allow_multiple= -fetch_limit=10 - -config= -config_camera= -is_remote= -api_url= - -dvr_scan_path="$HOME/.local/bin/dvr-scan" -fs_root="/var/ipcam_motion_fs" -fs_max_filesize=146800640 - -declare -A config=() - -usage() { - cat </dev/null || die "failed to change to ${fs_root}" - touch tmp || die "directory '${fs_root}' is not writable" - rm tmp - - [ -f "video.mp4" ] && { - echowarn "video.mp4 already exists in ${fs_root}, removing.." - rm "video.mp4" - } - fi - - while read line; do - words=($line) - file=${words[0]} - size=${words[1]} - camera=${words[2]} - - debug "next video: cam=$camera file=$file" - - read_camera_motion_config "$camera" -# dump_config - - if [ "$is_remote" = "0" ]; then - local_recs_dir="$(get_recordings_dir "$camera")" - - debug "[$camera] processing $file..." - - tc=$(do_motion "$camera" "${local_recs_dir}/$file") - debug "[$camera] $file: timecodes=$tc" - - report_timecodes "$camera" "$file" "$tc" - else - if (( size > fs_max_filesize )); then - echoerr "[$camera] won't download $file, size exceeds fs_max_filesize ($size > ${fs_max_filesize})" - report_failure "$camera" "$file" "too large file" - continue - fi - - url="${api_url}/api/recordings/${camera}/download/${file}" - debug "[$camera] downloading $url..." - - if ! download "$url" "video.mp4"; then - echoerr "[$camera] failed to download $file" - report_failure "$camera" "$file" "download error" - continue - fi - - tc=$(do_motion "$camera" "video.mp4") - debug "[$camera] $file: timecodes=$tc" - - report_timecodes "$camera" "$file" "$tc" - - rm "video.mp4" - fi - done < <(get_recordings_list) - - if [ "$is_remote" = "1" ]; then popd >/dev/null; fi -} - -do_motion() { - local camera="$1" - local input="$2" - local tc - - local timecodes=() - - time_start - while read line; do - if ! [[ "$line" =~ ^#.* ]]; then - tc="$(do_dvr_scan "$input" "$line")" - if [ -n "$tc" ]; then - timecodes+=("$tc") - fi - fi - done < <(get_camera_roi_config "$camera") - - debug "[$camera] do_motion: finished in $(time_elapsed)s" - - timecodes="$(echo "${timecodes[@]}" | sed 's/ */ /g' | xargs)" - timecodes="${timecodes// /,}" - - echo "$timecodes" -} - -dvr_scan() { - "${dvr_scan_path}" "$@" -} - -do_dvr_scan() { - local input="$1" - local args= - - if [ ! -z "$2" ]; then - args="-roi $2" - echoinfo "dvr_scan(${BOLD}${input}${RST}${CYAN}): roi=($2), mt=${config[threshold]}" - else - echoinfo "dvr_scan(${BOLD}${input}${RST}${CYAN}): no roi, mt=${config[threshold]}" - fi - - dvr_scan -q -i "$input" -so \ - --min-event-length ${config[min_event_length]} \ - -df ${config[downscale_factor]} \ - --frame-skip ${config[frame_skip]} \ - -t ${config[threshold]} $args | tail -1 -} - -[[ $# -lt 1 ]] && usage - -while [[ $# -gt 0 ]]; do - case $1 in - -L|--fetch-limit) - fetch_limit="$2" - shift; shift - ;; - - --allow-multiple) - allow_multiple=1 - shift - ;; - - --remote) - is_remote=1 - shift - ;; - - --local) - is_remote=0 - shift - ;; - - --dvr-scan-path) - dvr_scan_path="$2" - shift; shift - ;; - - --fs-root) - fs_root="$2" - shift; shift - ;; - - --fs-max-filesize) - fs_max_filesize="$2" - shift; shift - ;; - - --api-url) - api_url="$2" - shift; shift - ;; - - -v) - VERBOSE=1 - shift - ;; - - -vx) - VERBOSE=1 - set -x - shift - ;; - - *) - die "unrecognized argument '$1'" - exit 1 - ;; - esac -done - -if [ -z "$allow_multiple" ] && pidof -o %PPID -x "$(basename "${BASH_SOURCE[0]}")" >/dev/null; then - die "process already running" -fi - -[ -z "$is_remote" ] && die "either --remote or --local is required" -[ -z "$api_url" ] && die "--api-url is required" - -process_queue \ No newline at end of file diff --git a/tools/ipcam_rtsp2hls.sh b/tools/ipcam_rtsp2hls.sh deleted file mode 100755 index c321820..0000000 --- a/tools/ipcam_rtsp2hls.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/bin/bash - -PROGNAME="$0" -OUTDIR=/var/ipcamfs # should be tmpfs -PORT=554 -NAME= -IP= -USER= -PASSWORD= -DEBUG=0 -CHANNEL=1 -FORCE_UDP=0 -FORCE_TCP=0 -CUSTOM_PATH= - -die() { - echo >&2 "error: $@" - exit 1 -} - -usage() { - cat <&2 echo "${CYAN}$@${RST}" -} - -echoerr() { - >&2 echo "${RED}${BOLD}error:${RST}${RED} $@${RST}" -} - -echowarn() { - >&2 echo "${YELLOW}${BOLD}warning:${RST}${YELLOW} $@${RST}" -} - -die() { - echoerr "$@" - exit 1 -} - -debug() { - if [ -n "$VERBOSE" ]; then - >&2 echo "$@" - fi -} - - -# measuring executing time -# ------------------------ - -__time_started= - -time_start() { - __time_started=$(date +%s) -} - -time_elapsed() { - local fin=$(date +%s) - echo $(( fin - __time_started )) -} - - -# config parsing -# -------------- - -read_config() { - local config_file="$1" - local dst="$2" - - [ -f "$config_file" ] || die "read_config: $config_file: no such file" - - local n=0 - local failed= - local key - local value - - while read line; do - n=$(( n+1 )) - - # skip empty lines or comments - if [ -z "$line" ] || [[ "$line" =~ ^#.* ]]; then - continue - fi - - if [[ $line = *"="* ]]; then - key="${line%%=*}" - value="${line#*=}" - eval "$dst[$key]=\"$value\"" - else - echoerr "config: invalid line $n" - failed=1 - fi - done < <(cat "$config_file") - - [ -z "$failed" ] -} - -check_config() { - local var="$1" - local keys="$2" - - local failed= - - for key in $keys; do - if [ -z "$(eval "echo -n \${$var[$key]}")" ]; then - echoerr "config: ${BOLD}${key}${RST}${RED} is missing" - failed=1 - fi - done - - [ -z "$failed" ] -} - - -# other functions -# --------------- - -installed() { - command -v "$1" > /dev/null - return $? -} - -download() { - local source="$1" - local target="$2" - - if installed curl; then - curl -f -s -o "$target" "$source" - elif installed wget; then - wget -q -O "$target" "$source" - else - die "neither curl nor wget found, can't proceed" - fi -} - -file_in_use() { - [ -n "$(lsof "$1")" ] -} - -file_mtime() { - stat -c %Y "$1" -} diff --git a/tools/process-motion-timecodes.py b/tools/process-motion-timecodes.py deleted file mode 100755 index 7be7977..0000000 --- a/tools/process-motion-timecodes.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -import os.path -from src.home.camera.util import dvr_scan_timecodes - -from argparse import ArgumentParser -from datetime import datetime, timedelta - -DATETIME_FORMAT = '%Y-%m-%d-%H.%M.%S' - - -def chunks(lst, n): - for i in range(0, len(lst), n): - yield lst[i:i + n] - - -def time2seconds(time: str) -> int: - time, frac = time.split('.') - frac = int(frac) - - h, m, s = [int(i) for i in time.split(':')] - - return round(s + m*60 + h*3600 + frac/1000) - - -def filename_to_datetime(filename: str) -> datetime: - filename = os.path.basename(filename).replace('record_', '').replace('.mp4', '') - return datetime.strptime(filename, DATETIME_FORMAT) - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('--source-filename', type=str, required=True, - help='recording filename') - parser.add_argument('--timecodes', type=str, required=True, - help='timecodes') - parser.add_argument('--padding', type=int, default=2, - help='amount of seconds to add before and after each fragment') - arg = parser.parse_args() - - if arg.padding < 0: - raise ValueError('invalid padding') - - fragments = dvr_scan_timecodes(arg.timecodes) - file_dt = filename_to_datetime(arg.source_filename) - - for fragment in fragments: - start, end = fragment - - start -= arg.padding - end += arg.padding - - if start < 0: - start = 0 - - duration = end - start - - dt1 = (file_dt + timedelta(seconds=start)).strftime(DATETIME_FORMAT) - dt2 = (file_dt + timedelta(seconds=end)).strftime(DATETIME_FORMAT) - filename = f'{dt1}__{dt2}.mp4' - - print(f'{start} {duration} {filename}') diff --git a/tools/remove-old-recordings.sh b/tools/remove-old-recordings.sh deleted file mode 100644 index d376572..0000000 --- a/tools/remove-old-recordings.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -# to be launched by cron on remote server - -find /var/recordings -type f -mtime +14 -delete diff --git a/tools/rotate-video.sh b/tools/rotate-video.sh index 6d27b44..5ce4efe 100755 --- a/tools/rotate-video.sh +++ b/tools/rotate-video.sh @@ -5,7 +5,7 @@ set -e DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &>/dev/null && pwd )" PROGNAME="$0" -. "$DIR/lib.bash" +. "$DIR/../include/bash/include.bash" usage() { diff --git a/tools/sunxi-h3-i2c-reset.sh b/tools/sunxi-h3-i2c-reset.sh deleted file mode 100644 index e654dfb..0000000 --- a/tools/sunxi-h3-i2c-reset.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh - -devices="1c2ac00.i2c 1c2b000.i2c" -pins="8 9 28 30" -driver_path="/sys/bus/platform/drivers/mv64xxx_i2c" - -driver_unbind() { - echo -n "$1" > "$driver_path/unbind" -} - -driver_bind() { - echo -n "$1" > "$driver_path/bind" -} - -for dev in $devices; do driver_unbind "$dev"; done -echo "unbind done" - -for pin in pins; do - gpio mode $pin out - gpio write $pin 0 -done -echo "gpio reset done" - -for dev in $devices; do driver_bind "$dev"; done -echo "bind done" \ No newline at end of file diff --git a/tools/sunxi-setup-amixer.sh b/tools/sunxi-setup-amixer.sh deleted file mode 100755 index 5746514..0000000 --- a/tools/sunxi-setup-amixer.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash - -amixer() { - /usr/bin/amixer "$@" -} - -setup_opi_pc2() { - for v in unmute cap; do - amixer set "Line In" $v - amixer set "Mic1" $v - amixer set "Mic2" $v - done - - for k in "Mic1 Boost" "Line In" "Mic1" "Mic2 Boost" "Mic2"; do - amixer set "$k" "86%" - done -} - -setup_opi_one() { - for v in unmute cap; do - amixer set "Line In" $v - amixer set "Mic1" $v - done - - for k in "Mic1 Boost" "Line In" "Mic1"; do - amixer set "$k" "86%" - done -} - -setup_opi3lts() { - switches=( - "Left DAC Mixer ADCL" - "Left DAC Mixer I2SDACL" - "Left I2S Mixer ADCL" - "Left I2S Mixer I2SDACL" - "Left Input Mixer LINEINL" - "Left Input Mixer MIC1" - "Left Input Mixer MIC2" - "Left Input Mixer OMixerL" - "Left Input Mixer OMixerR" - "Left Input Mixer PhoneN" - "Left Input Mixer PhonePN" - "Left Output Mixer DACL" - "Left Output Mixer DACR" - "Left Output Mixer LINEINL" - "Left Output Mixer MIC1" - "Left Output Mixer MIC2" - "Left Output Mixer PhoneN" - "Left Output Mixer PhonePN" - "Right DAC Mixer ADCR" - "Right DAC Mixer I2SDACR" - "Right I2S Mixer ADCR" - "Right I2S Mixer I2SDACR" - "Right Input Mixer LINEINR" - "Right Input Mixer MIC1" - "Right Input Mixer MIC2" - "Right Input Mixer OMixerL" - "Right Input Mixer OMixerR" - "Right Input Mixer PhoneP" - "Right Input Mixer PhonePN" - "Right Output Mixer DACL" - "Right Output Mixer DACR" - "Right Output Mixer LINEINR" - "Right Output Mixer MIC1" - "Right Output Mixer MIC2" - "Right Output Mixer PhoneP" - "Right Output Mixer PhonePN" - ) - for v in "${switches[@]}"; do - value=on - case "$v" in - *Input*) - value=on - ;; - *Output*) - value=off - ;; - esac - amixer set "$v" $value - done - - to_mute=( - "I2S Mixer ADC" - "I2S Mixer DAC" - "ADC Input" - "DAC Mixer ADC" - "DAC Mxier DAC" # this is not a typo - ) - for v in "${to_mute[@]}"; do - amixer set "$v" "0%" - done - - amixer set "Master" "100%" - amixer set "MIC1 Boost" "100%" - amixer set "MIC2 Boost" "100%" - amixer set "Line Out Mixer" "86%" - amixer set "MIC Out Mixer" "71%" -} - -device="$(tr -d '\0' < /sys/firmware/devicetree/base/model)" -case "$device" in - *"Orange Pi PC 2") - setup_opi_pc2 - ;; - *"Orange Pi One"|*"Orange Pi Lite") - setup_opi_one - ;; - *"OrangePi 3 LTS") - setup_opi3lts - ;; - *) - >&2 echo "error: unidentified device: $device" - ;; -esac diff --git a/tools/sync-recordings-to-remote.sh b/tools/sync-recordings-to-remote.sh deleted file mode 100755 index cf979d1..0000000 --- a/tools/sync-recordings-to-remote.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/bash - -PROGNAME="$0" -NODE_CONFIG="/etc/sound_node.toml" -REMOTE_USER=user -REMOTE_SERVER=solarmon.ru -REMOTE_DIRECTORY=/var/recordings - -set -e - -echoerr() { - >&2 echo "error: $@" -} - -echowarn() { - >&2 echo "warning: $@" -} - -telegram_alert() { - if [ -z "$TG_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then return; fi - curl -X POST \ - -F "chat_id=${TG_CHAT_ID}" \ - -F "text=$1" \ - "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" -} - -fatal() { - echoerr "$@" - telegram_alert "$PROGNAME: $@" - exit 1 -} - -get_config_var() { - local varname="$1" - cat "$NODE_CONFIG" | grep "^$varname = \"" | awk '{print $3}' | tr -d '"' -} - -get_mp3_count() { - find "$LOCAL_DIR" -mindepth 1 -type f -name "*.mp3" -printf x | wc -c -} - -[ -z "$TG_TOKEN" ] && echowarn "TG_TOKEN is not set" -[ -z "$TG_CHAT_ID" ] && echowarn "TG_CHAT_ID is not set" - -NODE_NAME=$(get_config_var name) -LOCAL_DIR=$(get_config_var storage) - -[ -z "$NODE_NAME" ] && fatal "failed to parse NODE_NAME" -[ -z "$LOCAL_DIR" ] && fatal "failed to parse LOCAL_DIR" - -[ -d "$LOCAL_DIR" ] || fatal "$LOCAL_DIR is not a directory" - -COUNT=$(get_mp3_count) -(( $COUNT < 1 )) && { - echo "seems there's nothing to sync" - exit -} - -cd "$LOCAL_DIR" || fatal "failed to change to $LOCAL_DIR" - -rsync -azPv -e "ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=ERROR" \ - *.mp3 \ - ${REMOTE_USER}@${REMOTE_SERVER}:"${REMOTE_DIRECTORY}/${NODE_NAME}/" \ - --exclude temp.mp3 - -RC=$? - -if [ $RC -eq 0 ]; then - find "$LOCAL_DIR" -name "*.mp3" -type f -mmin +1440 -delete || fatal "find failed to delete old files" -else - fatal "failed to rsync: code $RC" -fi diff --git a/tools/video-util.sh b/tools/video-util.sh index 0ee5560..6fe6109 100755 --- a/tools/video-util.sh +++ b/tools/video-util.sh @@ -5,7 +5,7 @@ set -e DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &> /dev/null && pwd )" PROGNAME="$0" -. "$DIR/lib.bash" +. "$DIR/../include/bash/include.bash" input= output= -- cgit v1.2.3 From 58d6d519d104196314c02b77cdd05cd996f71508 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 03:32:21 +0300 Subject: start splitting requirements.txt into multiple files --- requirements.txt | 6 +----- requirements_kettle.txt | 3 +++ 2 files changed, 4 insertions(+), 5 deletions(-) create mode 100644 requirements_kettle.txt diff --git a/requirements.txt b/requirements.txt index 4595dea..521ae41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,8 +17,4 @@ cerberus~=1.3.4 # following can be installed from debian repositories # matplotlib~=3.5.0 -Pillow==9.5.0 - -# for polaris kettle protocol implementation -cryptography==41.0.1 -zeroconf==0.64.1 \ No newline at end of file +Pillow==9.5.0 \ No newline at end of file diff --git a/requirements_kettle.txt b/requirements_kettle.txt new file mode 100644 index 0000000..d003269 --- /dev/null +++ b/requirements_kettle.txt @@ -0,0 +1,3 @@ +# for polaris kettle protocol implementation +cryptography==41.0.1 +zeroconf==0.64.1 \ No newline at end of file -- cgit v1.2.3 From ba321657e0e724082df206857f80ca08c4d999dc Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 04:29:14 +0300 Subject: misc/scripts: reorganize files --- .../usr/local/bin/homekit_ipcam_capture_restart.sh | 7 ++ .../local/bin/homekit_ipcam_rtsp2hls_restart.sh | 8 ++ .../local/bin/homekit_make_netns_per_upstream.sh | 38 +++++++ .../usr/local/bin/homekit_sunxi_h3_i2c_reset.sh | 25 +++++ .../usr/local/bin/homekit_sunxi_setup_amixer.sh | 114 +++++++++++++++++++++ .../local/bin/homekit_sync_recordings_to_remote.sh | 72 +++++++++++++ .../usr/local/bin/clickhouse_backup.sh | 31 ++++++ .../usr/local/bin/remove_old_recordings.sh | 5 + .../homekit_ipcam_capture_restart.sh | 7 -- .../homekit_ipcam_rtsp2hls_restart.sh | 8 -- .../homekit_make_netns_per_upstream.sh | 38 ------- .../homekit_sunxi_h3_i2c_reset.sh | 25 ----- .../homekit_sunxi_setup_amixer.sh | 114 --------------------- .../homekit_sync_recordings_to_remote.sh | 72 ------------- misc/scripts/remote_server/clickhouse_backup.sh | 31 ------ .../scripts/remote_server/remove_old_recordings.sh | 5 - 16 files changed, 300 insertions(+), 300 deletions(-) create mode 100755 misc/home_linux_boards/usr/local/bin/homekit_ipcam_capture_restart.sh create mode 100755 misc/home_linux_boards/usr/local/bin/homekit_ipcam_rtsp2hls_restart.sh create mode 100755 misc/home_linux_boards/usr/local/bin/homekit_make_netns_per_upstream.sh create mode 100755 misc/home_linux_boards/usr/local/bin/homekit_sunxi_h3_i2c_reset.sh create mode 100755 misc/home_linux_boards/usr/local/bin/homekit_sunxi_setup_amixer.sh create mode 100755 misc/home_linux_boards/usr/local/bin/homekit_sync_recordings_to_remote.sh create mode 100755 misc/remote_server/usr/local/bin/clickhouse_backup.sh create mode 100755 misc/remote_server/usr/local/bin/remove_old_recordings.sh delete mode 100644 misc/scripts/home_linux_boards/homekit_ipcam_capture_restart.sh delete mode 100644 misc/scripts/home_linux_boards/homekit_ipcam_rtsp2hls_restart.sh delete mode 100644 misc/scripts/home_linux_boards/homekit_make_netns_per_upstream.sh delete mode 100644 misc/scripts/home_linux_boards/homekit_sunxi_h3_i2c_reset.sh delete mode 100755 misc/scripts/home_linux_boards/homekit_sunxi_setup_amixer.sh delete mode 100755 misc/scripts/home_linux_boards/homekit_sync_recordings_to_remote.sh delete mode 100644 misc/scripts/remote_server/clickhouse_backup.sh delete mode 100644 misc/scripts/remote_server/remove_old_recordings.sh diff --git a/misc/home_linux_boards/usr/local/bin/homekit_ipcam_capture_restart.sh b/misc/home_linux_boards/usr/local/bin/homekit_ipcam_capture_restart.sh new file mode 100755 index 0000000..85144da --- /dev/null +++ b/misc/home_linux_boards/usr/local/bin/homekit_ipcam_capture_restart.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +for f in $(ls /etc/ipcam_capture.conf.d/ | xargs); do + camera="${f/.conf/}" + echo "restarting $camera" + systemctl restart ipcam_capture@${camera} +done \ No newline at end of file diff --git a/misc/home_linux_boards/usr/local/bin/homekit_ipcam_rtsp2hls_restart.sh b/misc/home_linux_boards/usr/local/bin/homekit_ipcam_rtsp2hls_restart.sh new file mode 100755 index 0000000..61ee623 --- /dev/null +++ b/misc/home_linux_boards/usr/local/bin/homekit_ipcam_rtsp2hls_restart.sh @@ -0,0 +1,8 @@ +#!/bin/bash +cd /etc/ipcam_rtsp2hls.conf.d/ +for f in *-low.conf; do + f=${f/-low.conf/} + echo "restarting $f" + systemctl restart ipcam_rtsp2hls@${f} + systemctl restart ipcam_rtsp2hls@${f}-low +done diff --git a/misc/home_linux_boards/usr/local/bin/homekit_make_netns_per_upstream.sh b/misc/home_linux_boards/usr/local/bin/homekit_make_netns_per_upstream.sh new file mode 100755 index 0000000..fb152fa --- /dev/null +++ b/misc/home_linux_boards/usr/local/bin/homekit_make_netns_per_upstream.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +set -x +set -e + +get_default_iface() { + ip -4 r show default | awk '{print $5}' +} + +declare -A UPSTREAMS=( + [mtsil]=102 + [mtsazov]=100 + [rtazov]=101 +) + +for name in "${!UPSTREAMS[@]}"; do + mark=${UPSTREAMS[$name]} + veth_addr=10.${mark}.1.1 + vpeer_addr=10.${mark}.1.2 + veth_if=veth${name} + vpeer_if=vpeer${name} + + ip netns add $name + ip link add $veth_if type veth peer name $vpeer_if + ip link set $vpeer_if netns $name + ip addr add $veth_addr/24 dev $veth_if + ip link set $veth_if up + + ip netns exec $name ip addr add $vpeer_addr/24 dev $vpeer_if + ip netns exec $name ip link set $vpeer_if up + ip netns exec $name ip link set lo up + ip netns exec $name ip route add default via $veth_addr + + iptables -t mangle -A PREROUTING -s $vpeer_addr/24 -j MARK --set-mark $mark + iptables -t nat -A POSTROUTING -s $vpeer_addr/24 -o "$(get_default_iface)" -j MASQUERADE +done + +sysctl net.ipv4.ip_forward=1 diff --git a/misc/home_linux_boards/usr/local/bin/homekit_sunxi_h3_i2c_reset.sh b/misc/home_linux_boards/usr/local/bin/homekit_sunxi_h3_i2c_reset.sh new file mode 100755 index 0000000..e654dfb --- /dev/null +++ b/misc/home_linux_boards/usr/local/bin/homekit_sunxi_h3_i2c_reset.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +devices="1c2ac00.i2c 1c2b000.i2c" +pins="8 9 28 30" +driver_path="/sys/bus/platform/drivers/mv64xxx_i2c" + +driver_unbind() { + echo -n "$1" > "$driver_path/unbind" +} + +driver_bind() { + echo -n "$1" > "$driver_path/bind" +} + +for dev in $devices; do driver_unbind "$dev"; done +echo "unbind done" + +for pin in pins; do + gpio mode $pin out + gpio write $pin 0 +done +echo "gpio reset done" + +for dev in $devices; do driver_bind "$dev"; done +echo "bind done" \ No newline at end of file diff --git a/misc/home_linux_boards/usr/local/bin/homekit_sunxi_setup_amixer.sh b/misc/home_linux_boards/usr/local/bin/homekit_sunxi_setup_amixer.sh new file mode 100755 index 0000000..5746514 --- /dev/null +++ b/misc/home_linux_boards/usr/local/bin/homekit_sunxi_setup_amixer.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +amixer() { + /usr/bin/amixer "$@" +} + +setup_opi_pc2() { + for v in unmute cap; do + amixer set "Line In" $v + amixer set "Mic1" $v + amixer set "Mic2" $v + done + + for k in "Mic1 Boost" "Line In" "Mic1" "Mic2 Boost" "Mic2"; do + amixer set "$k" "86%" + done +} + +setup_opi_one() { + for v in unmute cap; do + amixer set "Line In" $v + amixer set "Mic1" $v + done + + for k in "Mic1 Boost" "Line In" "Mic1"; do + amixer set "$k" "86%" + done +} + +setup_opi3lts() { + switches=( + "Left DAC Mixer ADCL" + "Left DAC Mixer I2SDACL" + "Left I2S Mixer ADCL" + "Left I2S Mixer I2SDACL" + "Left Input Mixer LINEINL" + "Left Input Mixer MIC1" + "Left Input Mixer MIC2" + "Left Input Mixer OMixerL" + "Left Input Mixer OMixerR" + "Left Input Mixer PhoneN" + "Left Input Mixer PhonePN" + "Left Output Mixer DACL" + "Left Output Mixer DACR" + "Left Output Mixer LINEINL" + "Left Output Mixer MIC1" + "Left Output Mixer MIC2" + "Left Output Mixer PhoneN" + "Left Output Mixer PhonePN" + "Right DAC Mixer ADCR" + "Right DAC Mixer I2SDACR" + "Right I2S Mixer ADCR" + "Right I2S Mixer I2SDACR" + "Right Input Mixer LINEINR" + "Right Input Mixer MIC1" + "Right Input Mixer MIC2" + "Right Input Mixer OMixerL" + "Right Input Mixer OMixerR" + "Right Input Mixer PhoneP" + "Right Input Mixer PhonePN" + "Right Output Mixer DACL" + "Right Output Mixer DACR" + "Right Output Mixer LINEINR" + "Right Output Mixer MIC1" + "Right Output Mixer MIC2" + "Right Output Mixer PhoneP" + "Right Output Mixer PhonePN" + ) + for v in "${switches[@]}"; do + value=on + case "$v" in + *Input*) + value=on + ;; + *Output*) + value=off + ;; + esac + amixer set "$v" $value + done + + to_mute=( + "I2S Mixer ADC" + "I2S Mixer DAC" + "ADC Input" + "DAC Mixer ADC" + "DAC Mxier DAC" # this is not a typo + ) + for v in "${to_mute[@]}"; do + amixer set "$v" "0%" + done + + amixer set "Master" "100%" + amixer set "MIC1 Boost" "100%" + amixer set "MIC2 Boost" "100%" + amixer set "Line Out Mixer" "86%" + amixer set "MIC Out Mixer" "71%" +} + +device="$(tr -d '\0' < /sys/firmware/devicetree/base/model)" +case "$device" in + *"Orange Pi PC 2") + setup_opi_pc2 + ;; + *"Orange Pi One"|*"Orange Pi Lite") + setup_opi_one + ;; + *"OrangePi 3 LTS") + setup_opi3lts + ;; + *) + >&2 echo "error: unidentified device: $device" + ;; +esac diff --git a/misc/home_linux_boards/usr/local/bin/homekit_sync_recordings_to_remote.sh b/misc/home_linux_boards/usr/local/bin/homekit_sync_recordings_to_remote.sh new file mode 100755 index 0000000..cf979d1 --- /dev/null +++ b/misc/home_linux_boards/usr/local/bin/homekit_sync_recordings_to_remote.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +PROGNAME="$0" +NODE_CONFIG="/etc/sound_node.toml" +REMOTE_USER=user +REMOTE_SERVER=solarmon.ru +REMOTE_DIRECTORY=/var/recordings + +set -e + +echoerr() { + >&2 echo "error: $@" +} + +echowarn() { + >&2 echo "warning: $@" +} + +telegram_alert() { + if [ -z "$TG_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then return; fi + curl -X POST \ + -F "chat_id=${TG_CHAT_ID}" \ + -F "text=$1" \ + "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" +} + +fatal() { + echoerr "$@" + telegram_alert "$PROGNAME: $@" + exit 1 +} + +get_config_var() { + local varname="$1" + cat "$NODE_CONFIG" | grep "^$varname = \"" | awk '{print $3}' | tr -d '"' +} + +get_mp3_count() { + find "$LOCAL_DIR" -mindepth 1 -type f -name "*.mp3" -printf x | wc -c +} + +[ -z "$TG_TOKEN" ] && echowarn "TG_TOKEN is not set" +[ -z "$TG_CHAT_ID" ] && echowarn "TG_CHAT_ID is not set" + +NODE_NAME=$(get_config_var name) +LOCAL_DIR=$(get_config_var storage) + +[ -z "$NODE_NAME" ] && fatal "failed to parse NODE_NAME" +[ -z "$LOCAL_DIR" ] && fatal "failed to parse LOCAL_DIR" + +[ -d "$LOCAL_DIR" ] || fatal "$LOCAL_DIR is not a directory" + +COUNT=$(get_mp3_count) +(( $COUNT < 1 )) && { + echo "seems there's nothing to sync" + exit +} + +cd "$LOCAL_DIR" || fatal "failed to change to $LOCAL_DIR" + +rsync -azPv -e "ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=ERROR" \ + *.mp3 \ + ${REMOTE_USER}@${REMOTE_SERVER}:"${REMOTE_DIRECTORY}/${NODE_NAME}/" \ + --exclude temp.mp3 + +RC=$? + +if [ $RC -eq 0 ]; then + find "$LOCAL_DIR" -name "*.mp3" -type f -mmin +1440 -delete || fatal "find failed to delete old files" +else + fatal "failed to rsync: code $RC" +fi diff --git a/misc/remote_server/usr/local/bin/clickhouse_backup.sh b/misc/remote_server/usr/local/bin/clickhouse_backup.sh new file mode 100755 index 0000000..6e938e4 --- /dev/null +++ b/misc/remote_server/usr/local/bin/clickhouse_backup.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +DIR=/var/lib/clickhouse/backup +MAX_COUNT=3 +NAME=backup_$(date -u +%Y-%m-%d) + +create() { + local name="$1" + clickhouse-backup create "$name" +} + +del() { + local name="$1" + clickhouse-backup delete local "$name" +} + +# create a backup +create "$NAME" + +# compress backup +cd "$DIR" +tar czvf $NAME.tar.gz $NAME + +# delete uncompressed files +del "$NAME" + +# delete old backups +for file in $(ls -t "${DIR}" | tail -n +$(( MAX_COUNT+1 ))); do + echo "removing $file..." + rm "$file" +done \ No newline at end of file diff --git a/misc/remote_server/usr/local/bin/remove_old_recordings.sh b/misc/remote_server/usr/local/bin/remove_old_recordings.sh new file mode 100755 index 0000000..d376572 --- /dev/null +++ b/misc/remote_server/usr/local/bin/remove_old_recordings.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +# to be launched by cron on remote server + +find /var/recordings -type f -mtime +14 -delete diff --git a/misc/scripts/home_linux_boards/homekit_ipcam_capture_restart.sh b/misc/scripts/home_linux_boards/homekit_ipcam_capture_restart.sh deleted file mode 100644 index 85144da..0000000 --- a/misc/scripts/home_linux_boards/homekit_ipcam_capture_restart.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -for f in $(ls /etc/ipcam_capture.conf.d/ | xargs); do - camera="${f/.conf/}" - echo "restarting $camera" - systemctl restart ipcam_capture@${camera} -done \ No newline at end of file diff --git a/misc/scripts/home_linux_boards/homekit_ipcam_rtsp2hls_restart.sh b/misc/scripts/home_linux_boards/homekit_ipcam_rtsp2hls_restart.sh deleted file mode 100644 index 61ee623..0000000 --- a/misc/scripts/home_linux_boards/homekit_ipcam_rtsp2hls_restart.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -cd /etc/ipcam_rtsp2hls.conf.d/ -for f in *-low.conf; do - f=${f/-low.conf/} - echo "restarting $f" - systemctl restart ipcam_rtsp2hls@${f} - systemctl restart ipcam_rtsp2hls@${f}-low -done diff --git a/misc/scripts/home_linux_boards/homekit_make_netns_per_upstream.sh b/misc/scripts/home_linux_boards/homekit_make_netns_per_upstream.sh deleted file mode 100644 index fb152fa..0000000 --- a/misc/scripts/home_linux_boards/homekit_make_netns_per_upstream.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -set -x -set -e - -get_default_iface() { - ip -4 r show default | awk '{print $5}' -} - -declare -A UPSTREAMS=( - [mtsil]=102 - [mtsazov]=100 - [rtazov]=101 -) - -for name in "${!UPSTREAMS[@]}"; do - mark=${UPSTREAMS[$name]} - veth_addr=10.${mark}.1.1 - vpeer_addr=10.${mark}.1.2 - veth_if=veth${name} - vpeer_if=vpeer${name} - - ip netns add $name - ip link add $veth_if type veth peer name $vpeer_if - ip link set $vpeer_if netns $name - ip addr add $veth_addr/24 dev $veth_if - ip link set $veth_if up - - ip netns exec $name ip addr add $vpeer_addr/24 dev $vpeer_if - ip netns exec $name ip link set $vpeer_if up - ip netns exec $name ip link set lo up - ip netns exec $name ip route add default via $veth_addr - - iptables -t mangle -A PREROUTING -s $vpeer_addr/24 -j MARK --set-mark $mark - iptables -t nat -A POSTROUTING -s $vpeer_addr/24 -o "$(get_default_iface)" -j MASQUERADE -done - -sysctl net.ipv4.ip_forward=1 diff --git a/misc/scripts/home_linux_boards/homekit_sunxi_h3_i2c_reset.sh b/misc/scripts/home_linux_boards/homekit_sunxi_h3_i2c_reset.sh deleted file mode 100644 index e654dfb..0000000 --- a/misc/scripts/home_linux_boards/homekit_sunxi_h3_i2c_reset.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh - -devices="1c2ac00.i2c 1c2b000.i2c" -pins="8 9 28 30" -driver_path="/sys/bus/platform/drivers/mv64xxx_i2c" - -driver_unbind() { - echo -n "$1" > "$driver_path/unbind" -} - -driver_bind() { - echo -n "$1" > "$driver_path/bind" -} - -for dev in $devices; do driver_unbind "$dev"; done -echo "unbind done" - -for pin in pins; do - gpio mode $pin out - gpio write $pin 0 -done -echo "gpio reset done" - -for dev in $devices; do driver_bind "$dev"; done -echo "bind done" \ No newline at end of file diff --git a/misc/scripts/home_linux_boards/homekit_sunxi_setup_amixer.sh b/misc/scripts/home_linux_boards/homekit_sunxi_setup_amixer.sh deleted file mode 100755 index 5746514..0000000 --- a/misc/scripts/home_linux_boards/homekit_sunxi_setup_amixer.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash - -amixer() { - /usr/bin/amixer "$@" -} - -setup_opi_pc2() { - for v in unmute cap; do - amixer set "Line In" $v - amixer set "Mic1" $v - amixer set "Mic2" $v - done - - for k in "Mic1 Boost" "Line In" "Mic1" "Mic2 Boost" "Mic2"; do - amixer set "$k" "86%" - done -} - -setup_opi_one() { - for v in unmute cap; do - amixer set "Line In" $v - amixer set "Mic1" $v - done - - for k in "Mic1 Boost" "Line In" "Mic1"; do - amixer set "$k" "86%" - done -} - -setup_opi3lts() { - switches=( - "Left DAC Mixer ADCL" - "Left DAC Mixer I2SDACL" - "Left I2S Mixer ADCL" - "Left I2S Mixer I2SDACL" - "Left Input Mixer LINEINL" - "Left Input Mixer MIC1" - "Left Input Mixer MIC2" - "Left Input Mixer OMixerL" - "Left Input Mixer OMixerR" - "Left Input Mixer PhoneN" - "Left Input Mixer PhonePN" - "Left Output Mixer DACL" - "Left Output Mixer DACR" - "Left Output Mixer LINEINL" - "Left Output Mixer MIC1" - "Left Output Mixer MIC2" - "Left Output Mixer PhoneN" - "Left Output Mixer PhonePN" - "Right DAC Mixer ADCR" - "Right DAC Mixer I2SDACR" - "Right I2S Mixer ADCR" - "Right I2S Mixer I2SDACR" - "Right Input Mixer LINEINR" - "Right Input Mixer MIC1" - "Right Input Mixer MIC2" - "Right Input Mixer OMixerL" - "Right Input Mixer OMixerR" - "Right Input Mixer PhoneP" - "Right Input Mixer PhonePN" - "Right Output Mixer DACL" - "Right Output Mixer DACR" - "Right Output Mixer LINEINR" - "Right Output Mixer MIC1" - "Right Output Mixer MIC2" - "Right Output Mixer PhoneP" - "Right Output Mixer PhonePN" - ) - for v in "${switches[@]}"; do - value=on - case "$v" in - *Input*) - value=on - ;; - *Output*) - value=off - ;; - esac - amixer set "$v" $value - done - - to_mute=( - "I2S Mixer ADC" - "I2S Mixer DAC" - "ADC Input" - "DAC Mixer ADC" - "DAC Mxier DAC" # this is not a typo - ) - for v in "${to_mute[@]}"; do - amixer set "$v" "0%" - done - - amixer set "Master" "100%" - amixer set "MIC1 Boost" "100%" - amixer set "MIC2 Boost" "100%" - amixer set "Line Out Mixer" "86%" - amixer set "MIC Out Mixer" "71%" -} - -device="$(tr -d '\0' < /sys/firmware/devicetree/base/model)" -case "$device" in - *"Orange Pi PC 2") - setup_opi_pc2 - ;; - *"Orange Pi One"|*"Orange Pi Lite") - setup_opi_one - ;; - *"OrangePi 3 LTS") - setup_opi3lts - ;; - *) - >&2 echo "error: unidentified device: $device" - ;; -esac diff --git a/misc/scripts/home_linux_boards/homekit_sync_recordings_to_remote.sh b/misc/scripts/home_linux_boards/homekit_sync_recordings_to_remote.sh deleted file mode 100755 index cf979d1..0000000 --- a/misc/scripts/home_linux_boards/homekit_sync_recordings_to_remote.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/bash - -PROGNAME="$0" -NODE_CONFIG="/etc/sound_node.toml" -REMOTE_USER=user -REMOTE_SERVER=solarmon.ru -REMOTE_DIRECTORY=/var/recordings - -set -e - -echoerr() { - >&2 echo "error: $@" -} - -echowarn() { - >&2 echo "warning: $@" -} - -telegram_alert() { - if [ -z "$TG_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then return; fi - curl -X POST \ - -F "chat_id=${TG_CHAT_ID}" \ - -F "text=$1" \ - "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" -} - -fatal() { - echoerr "$@" - telegram_alert "$PROGNAME: $@" - exit 1 -} - -get_config_var() { - local varname="$1" - cat "$NODE_CONFIG" | grep "^$varname = \"" | awk '{print $3}' | tr -d '"' -} - -get_mp3_count() { - find "$LOCAL_DIR" -mindepth 1 -type f -name "*.mp3" -printf x | wc -c -} - -[ -z "$TG_TOKEN" ] && echowarn "TG_TOKEN is not set" -[ -z "$TG_CHAT_ID" ] && echowarn "TG_CHAT_ID is not set" - -NODE_NAME=$(get_config_var name) -LOCAL_DIR=$(get_config_var storage) - -[ -z "$NODE_NAME" ] && fatal "failed to parse NODE_NAME" -[ -z "$LOCAL_DIR" ] && fatal "failed to parse LOCAL_DIR" - -[ -d "$LOCAL_DIR" ] || fatal "$LOCAL_DIR is not a directory" - -COUNT=$(get_mp3_count) -(( $COUNT < 1 )) && { - echo "seems there's nothing to sync" - exit -} - -cd "$LOCAL_DIR" || fatal "failed to change to $LOCAL_DIR" - -rsync -azPv -e "ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=ERROR" \ - *.mp3 \ - ${REMOTE_USER}@${REMOTE_SERVER}:"${REMOTE_DIRECTORY}/${NODE_NAME}/" \ - --exclude temp.mp3 - -RC=$? - -if [ $RC -eq 0 ]; then - find "$LOCAL_DIR" -name "*.mp3" -type f -mmin +1440 -delete || fatal "find failed to delete old files" -else - fatal "failed to rsync: code $RC" -fi diff --git a/misc/scripts/remote_server/clickhouse_backup.sh b/misc/scripts/remote_server/clickhouse_backup.sh deleted file mode 100644 index 6e938e4..0000000 --- a/misc/scripts/remote_server/clickhouse_backup.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -DIR=/var/lib/clickhouse/backup -MAX_COUNT=3 -NAME=backup_$(date -u +%Y-%m-%d) - -create() { - local name="$1" - clickhouse-backup create "$name" -} - -del() { - local name="$1" - clickhouse-backup delete local "$name" -} - -# create a backup -create "$NAME" - -# compress backup -cd "$DIR" -tar czvf $NAME.tar.gz $NAME - -# delete uncompressed files -del "$NAME" - -# delete old backups -for file in $(ls -t "${DIR}" | tail -n +$(( MAX_COUNT+1 ))); do - echo "removing $file..." - rm "$file" -done \ No newline at end of file diff --git a/misc/scripts/remote_server/remove_old_recordings.sh b/misc/scripts/remote_server/remove_old_recordings.sh deleted file mode 100644 index d376572..0000000 --- a/misc/scripts/remote_server/remove_old_recordings.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -# to be launched by cron on remote server - -find /var/recordings -type f -mtime +14 -delete -- cgit v1.2.3 From 62ee71fdb0eb07adbf0071103617aa96c993fe22 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 05:03:43 +0300 Subject: ipcam: start porting to new config and multiserver scheme --- .gitignore | 2 +- bin/ipcam_server.py | 16 +++-- include/py/homekit/camera/config.py | 82 ++++++++++++++++++++++ include/py/homekit/camera/types.py | 12 ++++ include/py/homekit/config/config.py | 3 + include/py/homekit/database/sqlite.py | 11 +-- .../etc/default/homekit_ipcam_server | 2 + systemd/ipcam_server.service | 5 +- test/test.py | 8 ++- 9 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 include/py/homekit/camera/config.py create mode 100644 misc/home_linux_boards/etc/default/homekit_ipcam_server diff --git a/.gitignore b/.gitignore index 9a32ecc..b113ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ __pycache__ /include/test/test_inverter_monitor.log /youtrack-certificate /cpp -/include/test.py +/test/test.py /bin/test.py /arduino/ESP32CameraWebServer/wifi_password.h cmake-build-* diff --git a/bin/ipcam_server.py b/bin/ipcam_server.py index 211bc86..a9d6a0b 100755 --- a/bin/ipcam_server.py +++ b/bin/ipcam_server.py @@ -9,6 +9,7 @@ import __py_include import homekit.telegram.aio as telegram +from argparse import ArgumentParser from apscheduler.schedulers.asyncio import AsyncIOScheduler from asyncio import Lock @@ -53,8 +54,8 @@ def get_all_cams() -> list: class IPCamServerDatabase(SQLiteBase): SCHEMA = 4 - def __init__(self): - super().__init__() + def __init__(self, path=None): + super().__init__(path=path) def schema_init(self, version: int) -> None: cursor = self.cursor() @@ -319,9 +320,9 @@ class IPCamWebServer(http.HTTPServer): # other global stuff # ------------------ -def open_database(): +def open_database(database_path: str): global db - db = IPCamServerDatabase() + db = IPCamServerDatabase(database_path) # update cams list in database, if needed cams = db.get_all_timestamps().keys() @@ -558,9 +559,12 @@ logger = logging.getLogger(__name__) # -------------------- if __name__ == '__main__': - config.load_app('ipcam_server') + parser = ArgumentParser() + parser.add_argument('--listen', type=str, required=True) + parser.add_argument('--database-path', type=str, required=True) + arg = config.load_app(no_config=True, parser=parser) - open_database() + open_database(arg.database_path) loop = asyncio.get_event_loop() diff --git a/include/py/homekit/camera/config.py b/include/py/homekit/camera/config.py new file mode 100644 index 0000000..e0891a6 --- /dev/null +++ b/include/py/homekit/camera/config.py @@ -0,0 +1,82 @@ +from ..config import ConfigUnit, LinuxBoardsConfig +from typing import Optional +from .types import CameraType, VideoContainerType, VideoCodecType + + +_lbc = LinuxBoardsConfig() + + +def _validate_roi_line(field, value, error) -> bool: + p = value.split(' ') + if len(p) != 4: + error(field, f'{field}: must contain four coordinates separated by space') + for n in p: + if not n.isnumeric(): + error(field, f'{field}: invalid coordinates (not a number)') + return True + + +class IpcamConfig(ConfigUnit): + NAME = 'ipcam' + + @classmethod + def schema(cls) -> Optional[dict]: + lbc = LinuxBoardsConfig() + return { + 'cams': { + 'type': 'dict', + 'keysrules': {'type': ['string', 'integer']}, + 'valuesrules': { + 'type': 'dict', + 'schema': { + 'type': {'type': 'string', 'allowed': [t.value for t in CameraType], 'required': True}, + 'codec': {'type': 'string', 'allowed': [t.value for t in VideoCodecType], 'required': True}, + 'container': {'type': 'string', 'allowed': [t.value for t in VideoContainerType], 'required': True}, + 'server': {'type': 'string', 'allowed': list(lbc.get().keys()), 'required': True}, + 'disk': {'type': 'integer', 'required': True}, + 'motion': { + 'type': 'dict', + 'schema': { + 'threshold': {'type': ['float', 'integer']}, + 'roi': { + 'type': 'list', + 'schema': {'type': 'string', 'check_with': _validate_roi_line} + } + } + } + } + } + }, + 'motion_padding': {'type': 'integer', 'required': True}, + 'motion_telegram': {'type': 'boolean', 'required': True}, + 'fix_interval': {'type': 'integer', 'required': True}, + 'fix_enabled': {'type': 'boolean', 'required': True}, + 'cleanup_min_gb': {'type': 'integer', 'required': True}, + 'cleanup_interval': {'type': 'integer', 'required': True}, + + # TODO FIXME + 'fragment_url_templates': cls._url_templates_schema(), + 'original_file_url_templates': cls._url_templates_schema() + } + + @staticmethod + def custom_validator(data): + for n, cam in data['cams'].items(): + linux_box = _lbc[cam['server']] + if 'ext_hdd' not in linux_box: + raise ValueError(f'cam-{n}: linux box {cam["server"]} must have ext_hdd defined') + disk = cam['disk']-1 + if disk < 0 or disk >= len(linux_box['ext_hdd']): + raise ValueError(f'cam-{n}: invalid disk index for linux box {cam["server"]}') + + @classmethod + def _url_templates_schema(cls) -> dict: + return { + 'type': 'list', + 'empty': False, + 'schema': { + 'type': 'list', + 'empty': False, + 'schema': {'type': 'string'} + } + } \ No newline at end of file diff --git a/include/py/homekit/camera/types.py b/include/py/homekit/camera/types.py index de59022..0d3a384 100644 --- a/include/py/homekit/camera/types.py +++ b/include/py/homekit/camera/types.py @@ -3,3 +3,15 @@ from enum import Enum class CameraType(Enum): ESP32 = 'esp32' + ALIEXPRESS_NONAME = 'ali' + HIKVISION = 'hik' + + +class VideoContainerType(Enum): + MP4 = 'mp4' + MOV = 'mov' + + +class VideoCodecType(Enum): + H264 = 'h264' + H265 = 'h265' diff --git a/include/py/homekit/config/config.py b/include/py/homekit/config/config.py index 7344386..f2a3990 100644 --- a/include/py/homekit/config/config.py +++ b/include/py/homekit/config/config.py @@ -158,6 +158,9 @@ class ConfigUnit(BaseConfigUnit): else: normalized = v.validated(self._data, schema) + if not normalized: + raise cerberus.DocumentError(f'validation failed: {v.errors}') + self._data = normalized try: diff --git a/include/py/homekit/database/sqlite.py b/include/py/homekit/database/sqlite.py index 0af1f54..8b0c44c 100644 --- a/include/py/homekit/database/sqlite.py +++ b/include/py/homekit/database/sqlite.py @@ -15,10 +15,13 @@ def _get_database_path(name: str) -> str: 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) + def __init__(self, name=None, path=None, check_same_thread=False): + if not path: + if not name: + name = config.app_config['database_name'] + database_path = _get_database_path(name) + else: + database_path = path if not os.path.exists(os.path.dirname(database_path)): os.makedirs(os.path.dirname(database_path)) diff --git a/misc/home_linux_boards/etc/default/homekit_ipcam_server b/misc/home_linux_boards/etc/default/homekit_ipcam_server new file mode 100644 index 0000000..e5ee2a3 --- /dev/null +++ b/misc/home_linux_boards/etc/default/homekit_ipcam_server @@ -0,0 +1,2 @@ +LISTEN="0.0.0.0:8320" +DATABASE_PATH="/data1/ipcam_server.db" \ No newline at end of file diff --git a/systemd/ipcam_server.service b/systemd/ipcam_server.service index e6f8918..53e588d 100644 --- a/systemd/ipcam_server.service +++ b/systemd/ipcam_server.service @@ -1,5 +1,5 @@ [Unit] -Description=HomeKit IPCam Server +Description=Homekit IPCam Server After=network-online.target [Service] @@ -7,7 +7,8 @@ User=user Group=user Restart=always RestartSec=10 -ExecStart=/home/user/homekit/bin/ipcam_server.py +EnvironmentFile=/etc/default/homekit_ipcam_server +ExecStart=/home/user/homekit/bin/ipcam_server.py --listen "$LISTEN" --database-path "$DATABASE_PATH" WorkingDirectory=/home/user [Install] diff --git a/test/test.py b/test/test.py index 267a19f..0c4a347 100755 --- a/test/test.py +++ b/test/test.py @@ -1,8 +1,10 @@ #!/usr/bin/env python import __py_include -from homekit.relay import RelayClient + +from pprint import pprint +from homekit.camera.config import IpcamConfig if __name__ == '__main__': - c = RelayClient() - print(c, c._host) \ No newline at end of file + c = IpcamConfig() + pprint(c.get()) \ No newline at end of file -- cgit v1.2.3 From 26bd30dff41f5f0e3857283155362a96c47ab9bb Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 05:04:41 +0300 Subject: minor fix --- include/py/homekit/camera/config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/include/py/homekit/camera/config.py b/include/py/homekit/camera/config.py index e0891a6..331e595 100644 --- a/include/py/homekit/camera/config.py +++ b/include/py/homekit/camera/config.py @@ -21,7 +21,6 @@ class IpcamConfig(ConfigUnit): @classmethod def schema(cls) -> Optional[dict]: - lbc = LinuxBoardsConfig() return { 'cams': { 'type': 'dict', @@ -32,7 +31,7 @@ class IpcamConfig(ConfigUnit): 'type': {'type': 'string', 'allowed': [t.value for t in CameraType], 'required': True}, 'codec': {'type': 'string', 'allowed': [t.value for t in VideoCodecType], 'required': True}, 'container': {'type': 'string', 'allowed': [t.value for t in VideoContainerType], 'required': True}, - 'server': {'type': 'string', 'allowed': list(lbc.get().keys()), 'required': True}, + 'server': {'type': 'string', 'allowed': list(_lbc.get().keys()), 'required': True}, 'disk': {'type': 'integer', 'required': True}, 'motion': { 'type': 'dict', -- cgit v1.2.3 From 3da04de6fd83bca19447a865bf84b3403a14e0c1 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 05:06:28 +0300 Subject: ipcam/config: fix schema validation --- include/py/homekit/camera/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/py/homekit/camera/config.py b/include/py/homekit/camera/config.py index 331e595..0d4c747 100644 --- a/include/py/homekit/camera/config.py +++ b/include/py/homekit/camera/config.py @@ -65,7 +65,7 @@ class IpcamConfig(ConfigUnit): if 'ext_hdd' not in linux_box: raise ValueError(f'cam-{n}: linux box {cam["server"]} must have ext_hdd defined') disk = cam['disk']-1 - if disk < 0 or disk >= len(linux_box['ext_hdd']): + if disk < 1 or disk >= len(linux_box['ext_hdd']): raise ValueError(f'cam-{n}: invalid disk index for linux box {cam["server"]}') @classmethod -- cgit v1.2.3 From 08e736c48990bec0423f72cf52dd1a457e9f9590 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 05:06:47 +0300 Subject: Revert "ipcam/config: fix schema validation" This reverts commit 3da04de6fd83bca19447a865bf84b3403a14e0c1. --- include/py/homekit/camera/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/py/homekit/camera/config.py b/include/py/homekit/camera/config.py index 0d4c747..331e595 100644 --- a/include/py/homekit/camera/config.py +++ b/include/py/homekit/camera/config.py @@ -65,7 +65,7 @@ class IpcamConfig(ConfigUnit): if 'ext_hdd' not in linux_box: raise ValueError(f'cam-{n}: linux box {cam["server"]} must have ext_hdd defined') disk = cam['disk']-1 - if disk < 1 or disk >= len(linux_box['ext_hdd']): + if disk < 0 or disk >= len(linux_box['ext_hdd']): raise ValueError(f'cam-{n}: invalid disk index for linux box {cam["server"]}') @classmethod -- cgit v1.2.3 From cbb6ad451749c54039e6d7c7eeeb5e387d95c069 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 05:10:21 +0300 Subject: typo --- bin/pio_ini.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/pio_ini.py b/bin/pio_ini.py index 7254eca..ee85732 100755 --- a/bin/pio_ini.py +++ b/bin/pio_ini.py @@ -109,7 +109,7 @@ if __name__ == '__main__': product_config = get_config(product) - # then everythingm else + # then everything else parser = ArgumentParser(parents=[product_parser]) parser.add_argument('--target', type=str, required=True, choices=product_config['targets'], help='PIO build target') -- cgit v1.2.3 From 58b5a1b5fca1cd898b1121778a3205ce2dafae36 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 14:02:31 +0300 Subject: delete test/test.py --- test/test.py | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100755 test/test.py diff --git a/test/test.py b/test/test.py deleted file mode 100755 index 0c4a347..0000000 --- a/test/test.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python -import __py_include - -from pprint import pprint -from homekit.camera.config import IpcamConfig - - -if __name__ == '__main__': - c = IpcamConfig() - pprint(c.get()) \ No newline at end of file -- cgit v1.2.3 From 5d8e81b6c8fc7abe75188007c6a86bb501a314ad Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 11 Jun 2023 14:02:47 +0300 Subject: config: turn ConfigUnit into singleton --- include/py/homekit/config/config.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/include/py/homekit/config/config.py b/include/py/homekit/config/config.py index f2a3990..29364af 100644 --- a/include/py/homekit/config/config.py +++ b/include/py/homekit/config/config.py @@ -76,6 +76,13 @@ class BaseConfigUnit(ABC): class ConfigUnit(BaseConfigUnit): NAME = 'dumb' + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(ConfigUnit, cls).__new__(cls, *args, **kwargs) + return cls._instance + def __init__(self, name=None, load=True): super().__init__() -- cgit v1.2.3 From e97f98e5e27a6df3827564cce594f27f18c89267 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Wed, 14 Jun 2023 14:06:26 +0300 Subject: wip --- bin/ipcam_capture.py | 141 +++++++++++++++++++++++ bin/ipcam_capture.sh | 119 -------------------- bin/ipcam_rtsp2hls.sh | 127 --------------------- bin/ipcam_server.py | 205 ++++++++++++++-------------------- bin/web_api.py | 1 - include/py/homekit/audio/amixer.py | 14 +-- include/py/homekit/camera/__init__.py | 3 +- include/py/homekit/camera/config.py | 57 +++++++++- include/py/homekit/camera/types.py | 29 +++++ include/py/homekit/camera/util.py | 70 +++++++++++- include/py/homekit/config/_configs.py | 6 + systemd/ipcam_capture@.service | 15 --- systemd/ipcam_rtsp2hls@.service | 16 --- 13 files changed, 386 insertions(+), 417 deletions(-) create mode 100755 bin/ipcam_capture.py delete mode 100755 bin/ipcam_capture.sh delete mode 100755 bin/ipcam_rtsp2hls.sh delete mode 100644 systemd/ipcam_capture@.service delete mode 100644 systemd/ipcam_rtsp2hls@.service diff --git a/bin/ipcam_capture.py b/bin/ipcam_capture.py new file mode 100755 index 0000000..5de14af --- /dev/null +++ b/bin/ipcam_capture.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +import __py_include +import sys +import os +import subprocess +import asyncio +import signal + +from typing import TextIO +from argparse import ArgumentParser +from socket import gethostname +from asyncio.streams import StreamReader +from homekit.config import LinuxBoardsConfig, config as homekit_config +from homekit.camera import IpcamConfig, CaptureType +from homekit.camera.util import get_hls_directory, get_hls_channel_name, get_recordings_path + +ipcam_config = IpcamConfig() +lbc_config = LinuxBoardsConfig() +channels = (1, 2) +tasks = [] +restart_delay = 3 +lock = asyncio.Lock() +worker_type: CaptureType + + +async def read_output(stream: StreamReader, + thread_name: str, + output: TextIO): + try: + while True: + line = await stream.readline() + if not line: + break + print(f"[{thread_name}] {line.decode().strip()}", file=output) + + except asyncio.LimitOverrunError: + print(f"[{thread_name}] Output limit exceeded.", file=output) + + except Exception as e: + print(f"[{thread_name}] Error occurred while reading output: {e}", file=sys.stderr) + + +async def run_ffmpeg(cam: int, channel: int): + prefix = get_hls_channel_name(cam, channel) + + if homekit_config.app_config.logging_is_verbose(): + debug_args = ['-v', '-info'] + else: + debug_args = ['-nostats', '-loglevel', 'error'] + + protocol = 'tcp' if ipcam_config.should_use_tcp_for_rtsp(cam) else 'udp' + user, pw = ipcam_config.get_rtsp_creds() + ip = ipcam_config.get_camera_ip(cam) + path = ipcam_config.get_camera_type(cam).get_channel_url(channel) + ext = ipcam_config.get_camera_container(cam) + ffmpeg_command = ['ffmpeg', *debug_args, + '-rtsp_transport', protocol, + '-i', f'rtsp://{user}:{pw}@{ip}:554{path}', + '-c', 'copy',] + + if worker_type == CaptureType.HLS: + ffmpeg_command.extend(['-bufsize', '1835k', + '-pix_fmt', 'yuv420p', + '-flags', '-global_header', + '-hls_time', '2', + '-hls_list_size', '3', + '-hls_flags', 'delete_segments', + os.path.join(get_hls_directory(cam, channel), 'live.m3u8')]) + + elif worker_type == CaptureType.RECORD: + ffmpeg_command.extend(['-f', 'segment', + '-strftime', '1', + '-segment_time', '00:10:00', + '-segment_atclocktime', '1', + os.path.join(get_recordings_path(cam), f'record_%Y-%m-%d-%H.%M.%S.{ext.value}')]) + + else: + raise ValueError(f'invalid worker type: {worker_type}') + + while True: + try: + process = await asyncio.create_subprocess_exec( + *ffmpeg_command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + stdout_task = asyncio.create_task(read_output(process.stdout, prefix, sys.stdout)) + stderr_task = asyncio.create_task(read_output(process.stderr, prefix, sys.stderr)) + + await asyncio.gather(stdout_task, stderr_task) + + # check the return code of the process + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ffmpeg_command) + + except (FileNotFoundError, PermissionError, subprocess.CalledProcessError) as e: + # an error occurred, print the error message + error_message = f"Error occurred in {prefix}: {e}" + print(error_message, file=sys.stderr) + + # sleep for 5 seconds before restarting the process + await asyncio.sleep(restart_delay) + + +async def run(): + kwargs = {} + if worker_type == CaptureType.RECORD: + kwargs['filter_by_server'] = gethostname() + for cam in ipcam_config.get_all_cam_names(**kwargs): + for channel in channels: + task = asyncio.create_task(run_ffmpeg(cam, channel)) + tasks.append(task) + + try: + await asyncio.gather(*tasks) + except KeyboardInterrupt: + print('KeyboardInterrupt: stopping processes...', file=sys.stderr) + for task in tasks: + task.cancel() + + # wait for subprocesses to terminate + await asyncio.gather(*tasks, return_exceptions=True) + + # send termination signal to all subprocesses + for task in tasks: + process = task.get_stack() + if process: + process.send_signal(signal.SIGTERM) + + +if __name__ == '__main__': + capture_types = [t.value for t in CaptureType] + parser = ArgumentParser() + parser.add_argument('type', type=str, metavar='CAPTURE_TYPE', choices=tuple(capture_types), + help='capture type (variants: '+', '.join(capture_types)+')') + + arg = homekit_config.load_app(no_config=True, parser=parser) + worker_type = CaptureType(arg['type']) + + asyncio.run(run()) diff --git a/bin/ipcam_capture.sh b/bin/ipcam_capture.sh deleted file mode 100755 index b97c856..0000000 --- a/bin/ipcam_capture.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/bash - -PROGNAME="$0" -PORT=554 -IP= -CREDS= -DEBUG=0 -CHANNEL=1 -FORCE_UDP=0 -FORCE_TCP=0 -EXTENSION="mp4" - -die() { - echo >&2 "error: $@" - exit 1 -} - -usage() { - cat <&2 "error: $@" - exit 1 -} - -usage() { - cat < bool: - return filename.startswith('record_') and filename.endswith('.mp4') - - -def filename_to_datetime(filename: str) -> datetime: - filename = os.path.basename(filename).replace('record_', '').replace('.mp4', '') - return datetime.strptime(filename, datetime_format) - - -def get_all_cams() -> list: - return [cam for cam in config['camera'].keys()] +ipcam_config = IpcamConfig() +lbc_config = LinuxBoardsConfig() # ipcam database # -------------- -class IPCamServerDatabase(SQLiteBase): +class IpcamServerDatabase(SQLiteBase): SCHEMA = 4 def __init__(self, path=None): @@ -67,7 +58,7 @@ class IPCamServerDatabase(SQLiteBase): fix_time INTEGER NOT NULL, motion_time INTEGER NOT NULL )""") - for cam in config['camera'].keys(): + for cam in ipcam_config.get_all_cam_names_for_this_server(): self.add_camera(cam) if version < 2: @@ -135,7 +126,7 @@ class IPCamServerDatabase(SQLiteBase): # ipcam web api # ------------- -class IPCamWebServer(http.HTTPServer): +class IpcamWebServer(http.HTTPServer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -146,16 +137,16 @@ class IPCamWebServer(http.HTTPServer): self.get('/api/timestamp/{name}/{type}', self.get_timestamp) self.get('/api/timestamp/all', self.get_all_timestamps) - self.post('/api/debug/migrate-mtimes', self.debug_migrate_mtimes) self.post('/api/debug/fix', self.debug_fix) self.post('/api/debug/cleanup', self.debug_cleanup) + self.post('/api/timestamp/{name}/{type}', self.set_timestamp) self.post('/api/motion/done/{name}', self.submit_motion) self.post('/api/motion/fail/{name}', self.submit_motion_failure) - self.get('/api/motion/params/{name}', self.get_motion_params) - self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params) + # self.get('/api/motion/params/{name}', self.get_motion_params) + # self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params) self.queue_lock = Lock() @@ -173,7 +164,7 @@ class IPCamWebServer(http.HTTPServer): files = get_recordings_files(camera, filter, limit) if files: - time = filename_to_datetime(files[len(files)-1]['name']) + time = datetime_from_filename(files[len(files)-1]['name']) db.set_timestamp(camera, TimeFilterType.MOTION_START, time) return self.ok({'files': files}) @@ -188,7 +179,7 @@ class IPCamWebServer(http.HTTPServer): if files: times_by_cam = {} for file in files: - time = filename_to_datetime(file['name']) + time = datetime_from_filename(file['name']) if file['cam'] not in times_by_cam or times_by_cam[file['cam']] < time: times_by_cam[file['cam']] = time for cam, time in times_by_cam.items(): @@ -200,14 +191,14 @@ class IPCamWebServer(http.HTTPServer): cam = int(req.match_info['name']) file = req.match_info['file'] - fullpath = os.path.join(config['camera'][cam]['recordings_path'], file) + fullpath = os.path.join(get_recordings_path(cam), file) if not os.path.isfile(fullpath): raise ValueError(f'file "{fullpath}" does not exists') return http.FileResponse(fullpath) async def camlist(self, req: http.Request): - return self.ok(config['camera']) + return self.ok(ipcam_config.get_all_cam_names_for_this_server()) async def submit_motion(self, req: http.Request): data = await req.post() @@ -216,7 +207,7 @@ class IPCamWebServer(http.HTTPServer): timecodes = data['timecodes'] filename = data['filename'] - time = filename_to_datetime(filename) + time = datetime_from_filename(filename) try: if timecodes != '': @@ -239,27 +230,10 @@ class IPCamWebServer(http.HTTPServer): message = data['message'] db.add_motion_failure(camera, filename, message) - db.set_timestamp(camera, TimeFilterType.MOTION, filename_to_datetime(filename)) + db.set_timestamp(camera, TimeFilterType.MOTION, datetime_from_filename(filename)) return self.ok() - async def debug_migrate_mtimes(self, req: http.Request): - written = {} - for cam in config['camera'].keys(): - confdir = os.path.join(os.getenv('HOME'), '.config', f'video-util-{cam}') - for time_type in TimeFilterType: - txt_file = os.path.join(confdir, f'{time_type.value}_mtime') - if os.path.isfile(txt_file): - with open(txt_file, 'r') as fd: - data = fd.read() - db.set_timestamp(cam, time_type, int(data.strip())) - - if cam not in written: - written[cam] = [] - written[cam].append(time_type) - - return self.ok({'written': written}) - async def debug_fix(self, req: http.Request): asyncio.ensure_future(fix_job()) return self.ok() @@ -280,26 +254,26 @@ class IPCamWebServer(http.HTTPServer): async def get_all_timestamps(self, req: http.Request): return self.ok(db.get_all_timestamps()) - async def get_motion_params(self, req: http.Request): - data = config['motion_params'][int(req.match_info['name'])] - lines = [ - f'threshold={data["threshold"]}', - f'min_event_length=3s', - f'frame_skip=2', - f'downscale_factor=3', - ] - return self.plain('\n'.join(lines)+'\n') - - async def get_motion_roi_params(self, req: http.Request): - data = config['motion_params'][int(req.match_info['name'])] - return self.plain('\n'.join(data['roi'])+'\n') + # async def get_motion_params(self, req: http.Request): + # data = config['motion_params'][int(req.match_info['name'])] + # lines = [ + # f'threshold={data["threshold"]}', + # f'min_event_length=3s', + # f'frame_skip=2', + # f'downscale_factor=3', + # ] + # return self.plain('\n'.join(lines)+'\n') + # + # async def get_motion_roi_params(self, req: http.Request): + # data = config['motion_params'][int(req.match_info['name'])] + # return self.plain('\n'.join(data['roi'])+'\n') @staticmethod def _getset_timestamp_params(req: http.Request, need_time=False): values = [] cam = int(req.match_info['name']) - assert cam in config['camera'], 'invalid camera' + assert cam in ipcam_config.get_all_cam_names_for_this_server(), 'invalid camera' values.append(cam) values.append(TimeFilterType(req.match_info['type'])) @@ -307,7 +281,7 @@ class IPCamWebServer(http.HTTPServer): if need_time: time = req.query['time'] if time.startswith('record_'): - time = filename_to_datetime(time) + time = datetime_from_filename(time) elif time.isnumeric(): time = int(time) else: @@ -322,30 +296,22 @@ class IPCamWebServer(http.HTTPServer): def open_database(database_path: str): global db - db = IPCamServerDatabase(database_path) + db = IpcamServerDatabase(database_path) # update cams list in database, if needed - cams = db.get_all_timestamps().keys() - for cam in config['camera']: - if cam not in cams: + stored_cams = db.get_all_timestamps().keys() + for cam in ipcam_config.get_all_cam_names_for_this_server(): + if cam not in stored_cams: db.add_camera(cam) -def get_recordings_path(cam: int) -> str: - return config['camera'][cam]['recordings_path'] - - -def get_motion_path(cam: int) -> str: - return config['camera'][cam]['motion_path'] - - def get_recordings_files(cam: Optional[int] = None, time_filter_type: Optional[TimeFilterType] = None, limit=0) -> List[dict]: from_time = 0 to_time = int(time.time()) - cams = [cam] if cam is not None else get_all_cams() + cams = [cam] if cam is not None else ipcam_config.get_all_cam_names_for_this_server() files = [] for cam in cams: if time_filter_type: @@ -362,7 +328,7 @@ def get_recordings_files(cam: Optional[int] = None, 'name': file, 'size': os.path.getsize(os.path.join(recdir, file))} for file in os.listdir(recdir) - if valid_recording_name(file) and from_time < filename_to_datetime(file) <= to_time] + if is_valid_recording_name(file) and from_time < datetime_from_filename(file) <= to_time] cam_files.sort(key=lambda file: file['name']) if cam_files: @@ -382,7 +348,7 @@ def get_recordings_files(cam: Optional[int] = None, async def process_fragments(camera: int, filename: str, fragments: List[Tuple[int, int]]) -> None: - time = filename_to_datetime(filename) + time = datetime_from_filename(filename) rec_dir = get_recordings_path(camera) motion_dir = get_motion_path(camera) @@ -392,8 +358,8 @@ async def process_fragments(camera: int, for fragment in fragments: start, end = fragment - start -= config['motion']['padding'] - end += config['motion']['padding'] + start -= ipcam_config['motion_padding'] + end += ipcam_config['motion_padding'] if start < 0: start = 0 @@ -408,14 +374,14 @@ async def process_fragments(camera: int, start_pos=start, duration=duration) - if fragments and 'telegram' in config['motion'] and config['motion']['telegram']: + if fragments and ipcam_config['motion_telegram']: asyncio.ensure_future(motion_notify_tg(camera, filename, fragments)) async def motion_notify_tg(camera: int, filename: str, fragments: List[Tuple[int, int]]): - dt_file = filename_to_datetime(filename) + dt_file = datetime_from_filename(filename) fmt = '%H:%M:%S' text = f'Camera: {camera}\n' @@ -423,8 +389,8 @@ async def motion_notify_tg(camera: int, text += _tg_links(TelegramLinkType.ORIGINAL_FILE, camera, filename) for start, end in fragments: - start -= config['motion']['padding'] - end += config['motion']['padding'] + start -= ipcam_config['motion_padding'] + end += ipcam_config['motion_padding'] if start < 0: start = 0 @@ -446,7 +412,7 @@ def _tg_links(link_type: TelegramLinkType, camera: int, file: str) -> str: links = [] - for link_name, link_template in config['telegram'][f'{link_type.value}_url_templates']: + for link_name, link_template in ipcam_config[f'{link_type.value}_url_templates']: link = link_template.replace('{camera}', str(camera)).replace('{file}', file) links.append(f'{link_name}') return ' '.join(links) @@ -462,7 +428,7 @@ async def fix_job() -> None: try: fix_job_running = True - for cam in config['camera'].keys(): + for cam in ipcam_config.get_all_cam_names_for_this_server(): files = get_recordings_files(cam, TimeFilterType.FIX) if not files: logger.debug(f'fix_job: no files for camera {cam}') @@ -473,7 +439,7 @@ async def fix_job() -> None: for file in files: fullpath = os.path.join(get_recordings_path(cam), file['name']) await camutil.ffmpeg_recreate(fullpath) - timestamp = filename_to_datetime(file['name']) + timestamp = datetime_from_filename(file['name']) if timestamp: db.set_timestamp(cam, TimeFilterType.FIX, timestamp) @@ -482,21 +448,9 @@ async def fix_job() -> None: async def cleanup_job() -> None: - def fn2dt(name: str) -> datetime: - name = os.path.basename(name) - - if name.startswith('record_'): - return datetime.strptime(re.match(r'record_(.*?)\.mp4', name).group(1), datetime_format) - - m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.mp4', name) - if m: - return datetime.strptime(m.group(1), datetime_format) - - raise ValueError(f'unrecognized filename format: {name}') - def compare(i1: str, i2: str) -> int: - dt1 = fn2dt(i1) - dt2 = fn2dt(i2) + dt1 = datetime_from_filename(i1) + dt2 = datetime_from_filename(i2) if dt1 < dt2: return -1 @@ -516,18 +470,19 @@ async def cleanup_job() -> None: cleanup_job_running = True gb = float(1 << 30) - for storage in config['storages']: + disk_number = 0 + for storage in lbc_config.get_board_disks(gethostname()): + disk_number += 1 if os.path.exists(storage['mountpoint']): total, used, free = shutil.disk_usage(storage['mountpoint']) free_gb = free // gb - if free_gb < config['cleanup_min_gb']: - # print(f"{storage['mountpoint']}: free={free}, free_gb={free_gb}") + if free_gb < ipcam_config['cleanup_min_gb']: cleaned = 0 files = [] - for cam in storage['cams']: - for _dir in (config['camera'][cam]['recordings_path'], config['camera'][cam]['motion_path']): + for cam in ipcam_config.get_all_cam_names_for_this_server(filter_by_disk=disk_number): + for _dir in (get_recordings_path(cam), get_motion_path(cam)): files += list(map(lambda file: os.path.join(_dir, file), os.listdir(_dir))) - files = list(filter(lambda path: os.path.isfile(path) and path.endswith('.mp4'), files)) + files = list(filter(lambda path: os.path.isfile(path) and path.endswith(tuple([f'.{t.value}' for t in VideoContainerType])), files)) files.sort(key=cmp_to_key(compare)) for file in files: @@ -537,7 +492,7 @@ async def cleanup_job() -> None: cleaned += size except OSError as e: logger.exception(e) - if (free + cleaned) // gb >= config['cleanup_min_gb']: + if (free + cleaned) // gb >= ipcam_config['cleanup_min_gb']: break else: logger.error(f"cleanup_job: {storage['mountpoint']} not found") @@ -550,8 +505,8 @@ cleanup_job_running = False datetime_format = '%Y-%m-%d-%H.%M.%S' datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}' -db: Optional[IPCamServerDatabase] = None -server: Optional[IPCamWebServer] = None +db: Optional[IpcamServerDatabase] = None +server: Optional[IpcamWebServer] = None logger = logging.getLogger(__name__) @@ -562,7 +517,7 @@ if __name__ == '__main__': parser = ArgumentParser() parser.add_argument('--listen', type=str, required=True) parser.add_argument('--database-path', type=str, required=True) - arg = config.load_app(no_config=True, parser=parser) + arg = homekit_config.load_app(no_config=True, parser=parser) open_database(arg.database_path) @@ -570,10 +525,14 @@ if __name__ == '__main__': try: scheduler = AsyncIOScheduler(event_loop=loop) - if config['fix_enabled']: - scheduler.add_job(fix_job, 'interval', seconds=config['fix_interval'], misfire_grace_time=None) - - scheduler.add_job(cleanup_job, 'interval', seconds=config['cleanup_interval'], misfire_grace_time=None) + if ipcam_config['fix_enabled']: + scheduler.add_job(fix_job, 'interval', + seconds=ipcam_config['fix_interval'], + misfire_grace_time=None) + + scheduler.add_job(cleanup_job, 'interval', + seconds=ipcam_config['cleanup_interval'], + misfire_grace_time=None) scheduler.start() except KeyError: pass @@ -581,5 +540,5 @@ if __name__ == '__main__': asyncio.ensure_future(fix_job()) asyncio.ensure_future(cleanup_job()) - server = IPCamWebServer(config.get_addr('server.listen')) + server = IpcamWebServer(Addr.fromstring(arg.listen)) server.run() diff --git a/bin/web_api.py b/bin/web_api.py index e543d22..d221838 100755 --- a/bin/web_api.py +++ b/bin/web_api.py @@ -42,7 +42,6 @@ class WebAPIServer(http.HTTPServer): self.get('/sound_sensors/hits/', self.GET_sound_sensors_hits) self.post('/sound_sensors/hits/', self.POST_sound_sensors_hits) - self.post('/log/bot_request/', self.POST_bot_request_log) self.post('/log/openwrt/', self.POST_openwrt_log) self.get('/inverter/consumed_energy/', self.GET_consumed_energy) diff --git a/include/py/homekit/audio/amixer.py b/include/py/homekit/audio/amixer.py index 5133c97..8ed754b 100644 --- a/include/py/homekit/audio/amixer.py +++ b/include/py/homekit/audio/amixer.py @@ -1,6 +1,6 @@ import subprocess -from ..config import app_config as config +from ..config import config from threading import Lock from typing import Union, List @@ -10,14 +10,14 @@ _default_step = 5 def has_control(s: str) -> bool: - for control in config['amixer']['controls']: + for control in config.app_config['amixer']['controls']: if control['name'] == s: return True return False def get_caps(s: str) -> List[str]: - for control in config['amixer']['controls']: + for control in config.app_config['amixer']['controls']: if control['name'] == s: return control['caps'] raise KeyError(f'control {s} not found') @@ -25,7 +25,7 @@ def get_caps(s: str) -> List[str]: def get_all() -> list: controls = [] - for control in config['amixer']['controls']: + for control in config.app_config['amixer']['controls']: controls.append({ 'name': control['name'], 'info': get(control['name']), @@ -55,8 +55,8 @@ def nocap(control): def _get_default_step() -> int: - if 'step' in config['amixer']: - return int(config['amixer']['step']) + if 'step' in config.app_config['amixer']: + return int(config.app_config['amixer']['step']) return _default_step @@ -75,7 +75,7 @@ def decr(control, step=None): def call(*args, return_code=False) -> Union[int, str]: with _lock: - result = subprocess.run([config['amixer']['bin'], *args], + result = subprocess.run([config.app_config['amixer']['bin'], *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if return_code: diff --git a/include/py/homekit/camera/__init__.py b/include/py/homekit/camera/__init__.py index 626930b..4875031 100644 --- a/include/py/homekit/camera/__init__.py +++ b/include/py/homekit/camera/__init__.py @@ -1 +1,2 @@ -from .types import CameraType \ No newline at end of file +from .types import CameraType, VideoContainerType, VideoCodecType, CaptureType +from .config import IpcamConfig \ No newline at end of file diff --git a/include/py/homekit/camera/config.py b/include/py/homekit/camera/config.py index 331e595..c7dbc38 100644 --- a/include/py/homekit/camera/config.py +++ b/include/py/homekit/camera/config.py @@ -1,8 +1,9 @@ +import socket + from ..config import ConfigUnit, LinuxBoardsConfig from typing import Optional from .types import CameraType, VideoContainerType, VideoCodecType - _lbc = LinuxBoardsConfig() @@ -42,7 +43,8 @@ class IpcamConfig(ConfigUnit): 'schema': {'type': 'string', 'check_with': _validate_roi_line} } } - } + }, + 'rtsp_tcp': {'type': 'boolean'} } } }, @@ -55,7 +57,19 @@ class IpcamConfig(ConfigUnit): # TODO FIXME 'fragment_url_templates': cls._url_templates_schema(), - 'original_file_url_templates': cls._url_templates_schema() + 'original_file_url_templates': cls._url_templates_schema(), + + 'hls_path': {'type': 'string', 'required': True}, + 'motion_processing_tmpfs_path': {'type': 'string', 'required': True}, + + 'rtsp_creds': { + 'required': True, + 'type': 'dict', + 'schema': { + 'login': {'type': 'string', 'required': True}, + 'password': {'type': 'string', 'required': True}, + } + } } @staticmethod @@ -78,4 +92,39 @@ class IpcamConfig(ConfigUnit): 'empty': False, 'schema': {'type': 'string'} } - } \ No newline at end of file + } + + def get_all_cam_names(self, + filter_by_server: Optional[str] = None, + filter_by_disk: Optional[int] = None) -> list[int]: + cams = [] + if filter_by_server is not None and filter_by_server not in _lbc: + raise ValueError(f'invalid filter_by_server: {filter_by_server} not found in {_lbc.__class__.__name__}') + for cam, params in self['cams'].items(): + if filter_by_server is None or params['server'] == filter_by_server: + if filter_by_disk is None or params['disk'] == filter_by_disk: + cams.append(int(cam)) + return cams + + def get_all_cam_names_for_this_server(self, + filter_by_disk: Optional[int] = None): + return self.get_all_cam_names(filter_by_server=socket.gethostname(), + filter_by_disk=filter_by_disk) + + def get_cam_server_and_disk(self, cam: int) -> tuple[str, int]: + return self['cams'][cam]['server'], self['cams'][cam]['disk'] + + def get_camera_container(self, cam: int) -> VideoContainerType: + return VideoContainerType(self['cams'][cam]['container']) + + def get_camera_type(self, cam: int) -> CameraType: + return CameraType(self['cams'][cam]['type']) + + def get_rtsp_creds(self) -> tuple[str, str]: + return self['rtsp_creds']['login'], self['rtsp_creds']['password'] + + def should_use_tcp_for_rtsp(self, cam: int) -> bool: + return 'rtsp_tcp' in self['cams'][cam] and self['cams'][cam]['rtsp_tcp'] + + def get_camera_ip(self, camera: int) -> str: + return f'192.168.5.{camera}' diff --git a/include/py/homekit/camera/types.py b/include/py/homekit/camera/types.py index 0d3a384..c313b58 100644 --- a/include/py/homekit/camera/types.py +++ b/include/py/homekit/camera/types.py @@ -6,6 +6,19 @@ class CameraType(Enum): ALIEXPRESS_NONAME = 'ali' HIKVISION = 'hik' + def get_channel_url(self, channel: int) -> str: + if channel not in (1, 2): + raise ValueError(f'channel {channel} is invalid') + if channel == 1: + return '' + elif channel == 2: + if self.value == CameraType.HIKVISION: + return '/Streaming/Channels/2' + elif self.value == CameraType.ALIEXPRESS_NONAME: + return '/?stream=1.sdp' + else: + raise ValueError(f'unsupported camera type {self.value}') + class VideoContainerType(Enum): MP4 = 'mp4' @@ -15,3 +28,19 @@ class VideoContainerType(Enum): class VideoCodecType(Enum): H264 = 'h264' H265 = 'h265' + + +class TimeFilterType(Enum): + FIX = 'fix' + MOTION = 'motion' + MOTION_START = 'motion_start' + + +class TelegramLinkType(Enum): + FRAGMENT = 'fragment' + ORIGINAL_FILE = 'original_file' + + +class CaptureType(Enum): + HLS = 'hls' + RECORD = 'record' diff --git a/include/py/homekit/camera/util.py b/include/py/homekit/camera/util.py index 97f35aa..58c2c70 100644 --- a/include/py/homekit/camera/util.py +++ b/include/py/homekit/camera/util.py @@ -2,13 +2,21 @@ import asyncio import os.path import logging import psutil +import re +from datetime import datetime from typing import List, Tuple from ..util import chunks -from ..config import config +from ..config import config, LinuxBoardsConfig +from .config import IpcamConfig +from .types import VideoContainerType _logger = logging.getLogger(__name__) -_temporary_fixing = '.temporary_fixing.mp4' +_ipcam_config = IpcamConfig() +_lbc_config = LinuxBoardsConfig() + +datetime_format = '%Y-%m-%d-%H.%M.%S' +datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}' def _get_ffmpeg_path() -> str: @@ -26,7 +34,8 @@ def time2seconds(time: str) -> int: async def ffmpeg_recreate(filename: str): filedir = os.path.dirname(filename) - tempname = os.path.join(filedir, _temporary_fixing) + _, fileext = os.path.splitext(filename) + tempname = os.path.join(filedir, f'.temporary_fixing.{fileext}') mtime = os.path.getmtime(filename) args = [_get_ffmpeg_path(), '-nostats', '-loglevel', 'error', '-i', filename, '-c', 'copy', '-y', tempname] @@ -104,4 +113,57 @@ def has_handle(fpath): except Exception: pass - return False \ No newline at end of file + return False + + +def get_recordings_path(cam: int) -> str: + server, disk = _ipcam_config.get_cam_server_and_disk(cam) + disks = _lbc_config.get_board_disks(server) + disk_mountpoint = disks[disk-1] + return f'{disk_mountpoint}/cam-{cam}' + + +def get_motion_path(cam: int) -> str: + return f'{get_recordings_path(cam)}/motion' + + +def is_valid_recording_name(filename: str) -> bool: + if not filename.startswith('record_'): + return False + + for container_type in VideoContainerType: + if filename.endswith(f'.{container_type.value}'): + return True + + return False + + +def datetime_from_filename(name: str) -> datetime: + name = os.path.basename(name) + exts = '|'.join([t.value for t in VideoContainerType]) + + if name.startswith('record_'): + return datetime.strptime(re.match(rf'record_(.*?)\.(?:{exts})', name).group(1), datetime_format) + + m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.(?:{exts})', name) + if m: + return datetime.strptime(m.group(1), datetime_format) + + raise ValueError(f'unrecognized filename format: {name}') + + +def get_hls_channel_name(cam: int, channel: int) -> str: + name = str(cam) + if channel == 2: + name += '-low' + return name + + +def get_hls_directory(cam, channel) -> str: + dirname = os.path.join( + _ipcam_config['hls_path'], + get_hls_channel_name(cam, channel) + ) + if not os.path.exists(dirname): + os.makedirs(dirname) + return dirname \ No newline at end of file diff --git a/include/py/homekit/config/_configs.py b/include/py/homekit/config/_configs.py index 1628cba..f88c8ea 100644 --- a/include/py/homekit/config/_configs.py +++ b/include/py/homekit/config/_configs.py @@ -53,3 +53,9 @@ class LinuxBoardsConfig(ConfigUnit): }, } } + + def get_board_disks(self, name: str) -> list[dict]: + return self[name]['ext_hdd'] + + def get_board_disks_count(self, name: str) -> int: + return len(self[name]['ext_hdd']) diff --git a/systemd/ipcam_capture@.service b/systemd/ipcam_capture@.service deleted file mode 100644 index e195231..0000000 --- a/systemd/ipcam_capture@.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=save ipcam streams -After=network-online.target - -[Service] -Restart=always -RestartSec=3 -User=user -Group=user -EnvironmentFile=/etc/ipcam_capture.conf.d/%i.conf -ExecStart=/home/user/homekit/bin/ipcam_capture.sh --outdir $OUTDIR --creds $CREDS --ip $IP --port $PORT $ARGS -Restart=always - -[Install] -WantedBy=multi-user.target diff --git a/systemd/ipcam_rtsp2hls@.service b/systemd/ipcam_rtsp2hls@.service deleted file mode 100644 index 9ce6cca..0000000 --- a/systemd/ipcam_rtsp2hls@.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=convert rtsp to hls for viewing live camera feeds in browser -After=network-online.target - -[Service] -Restart=always -RestartSec=3 -User=user -Group=user -EnvironmentFile=/etc/ipcam_rtsp2hls.conf.d/%i.conf -ExecStart=/home/user/homekit/bin/ipcam_rtsp2hls.sh --name %i --user $USER --password $PASSWORD --ip $IP --port $PORT $ARGS -Restart=on-failure -RestartSec=3 - -[Install] -WantedBy=multi-user.target -- cgit v1.2.3 From 94afba2bb100504c19c271ea10ae7a95058d3e08 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Thu, 7 Sep 2023 00:38:21 +0300 Subject: mqtt_node_util: add --legacy-relay option --- bin/mqtt_node_util.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/mqtt_node_util.py b/bin/mqtt_node_util.py index 420a87e..cf451fd 100755 --- a/bin/mqtt_node_util.py +++ b/bin/mqtt_node_util.py @@ -23,6 +23,7 @@ if __name__ == '__main__': help='mqtt modules to include') parser.add_argument('--switch-relay', choices=[0, 1], type=int, help='send relay state') + parser.add_argument('--legacy-relay', action='store_true') parser.add_argument('--push-ota', type=str, metavar='OTA_FILENAME', help='push OTA, receives path to firmware.bin') @@ -45,7 +46,10 @@ if __name__ == '__main__': if arg.modules: for m in arg.modules: - module_instance = mqtt_node.load_module(m) + kwargs = {} + if m == 'relay' and arg.legacy_relay: + kwargs['legacy_topics'] = True + module_instance = mqtt_node.load_module(m, **kwargs) if m == 'relay' and arg.switch_relay is not None: module_instance.switchpower(arg.switch_relay == 1) -- cgit v1.2.3 From 6994741c612e74a28683ca7cdd7b14f9876a0305 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Thu, 7 Sep 2023 00:38:34 +0300 Subject: mqtt: fix cacert path --- include/py/homekit/mqtt/_mqtt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/include/py/homekit/mqtt/_mqtt.py b/include/py/homekit/mqtt/_mqtt.py index fb35a24..47ee9ae 100644 --- a/include/py/homekit/mqtt/_mqtt.py +++ b/include/py/homekit/mqtt/_mqtt.py @@ -45,6 +45,7 @@ class Mqtt: '..', '..', '..', + '..', 'misc', 'mqtt_ca.crt' )) -- cgit v1.2.3 From 949eec3dc9cd37c70fb553e3e3f57decc8c89afc Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Thu, 7 Sep 2023 01:32:21 +0300 Subject: ConfigUnit: fix static class variable inheritance --- include/py/homekit/config/config.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/include/py/homekit/config/config.py b/include/py/homekit/config/config.py index 29364af..773de1e 100644 --- a/include/py/homekit/config/config.py +++ b/include/py/homekit/config/config.py @@ -52,6 +52,8 @@ class BaseConfigUnit(ABC): def load_from(self, path: str): with open(path, 'r') as fd: self._data = yaml.safe_load(fd) + if self._data is None: + raise TypeError(f'config file {path} is empty') def get(self, key: Optional[str] = None, @@ -78,6 +80,10 @@ class ConfigUnit(BaseConfigUnit): _instance = None + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls._instance = None + def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super(ConfigUnit, cls).__new__(cls, *args, **kwargs) @@ -200,7 +206,7 @@ class AppConfigUnit(ConfigUnit): def logging_get_fmt(self) -> Optional[str]: try: return self['logging']['default_fmt'] - except KeyError: + except (KeyError, TypeError): return self._logging_fmt def logging_set_file(self, file: str) -> None: @@ -209,7 +215,7 @@ class AppConfigUnit(ConfigUnit): def logging_get_file(self) -> Optional[str]: try: return self['logging']['file'] - except KeyError: + except (KeyError, TypeError): return self._logging_file def logging_set_verbose(self): @@ -218,7 +224,7 @@ class AppConfigUnit(ConfigUnit): def logging_is_verbose(self) -> bool: try: return bool(self['logging']['verbose']) - except KeyError: + except (KeyError, TypeError): return self._logging_verbose @@ -271,7 +277,9 @@ class Config: and not isinstance(name, bool) \ and issubclass(name, AppConfigUnit) or name == AppConfigUnit: self.app_name = name.NAME + print(self.app_config) self.app_config = name() + print(self.app_config) app_config = self.app_config else: self.app_name = name if isinstance(name, str) else None -- cgit v1.2.3 From 44aad914a3cea1b6e39cf5db7bebeafb59191707 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Thu, 7 Sep 2023 01:32:58 +0300 Subject: util: Addr.fromstring(): minor rcode style fix --- include/py/homekit/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/py/homekit/util.py b/include/py/homekit/util.py index 11e7116..22bba86 100644 --- a/include/py/homekit/util.py +++ b/include/py/homekit/util.py @@ -60,7 +60,7 @@ class Addr: if not colons: host = addr - port= None + port = None else: host, port = addr.split(':') -- cgit v1.2.3 From a32e4a1629a20026c364059c7bbaec1dbd64353b Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 17 Sep 2023 04:38:12 +0300 Subject: multiple fixes --- include/py/homekit/config/config.py | 3 ++- include/py/homekit/database/_base.py | 2 +- include/py/homekit/database/sqlite.py | 2 +- include/py/homekit/telegram/bot.py | 2 +- include/py/homekit/telegram/config.py | 11 +++++++---- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/include/py/homekit/config/config.py b/include/py/homekit/config/config.py index 773de1e..5fe1ae8 100644 --- a/include/py/homekit/config/config.py +++ b/include/py/homekit/config/config.py @@ -10,6 +10,7 @@ from argparse import ArgumentParser from enum import Enum, auto from os.path import join, isdir, isfile from ..util import Addr +from pprint import pprint class MyValidator(cerberus.Validator): @@ -140,7 +141,7 @@ class ConfigUnit(BaseConfigUnit): schema['logging'] = { 'type': 'dict', 'schema': { - 'logging': {'type': 'boolean'} + 'verbose': {'type': 'boolean'} } } diff --git a/include/py/homekit/database/_base.py b/include/py/homekit/database/_base.py index c01e62b..dcec9da 100644 --- a/include/py/homekit/database/_base.py +++ b/include/py/homekit/database/_base.py @@ -1,7 +1,7 @@ import os -def get_data_root_directory(name: str) -> str: +def get_data_root_directory() -> str: return os.path.join( os.environ['HOME'], '.config', diff --git a/include/py/homekit/database/sqlite.py b/include/py/homekit/database/sqlite.py index 8b0c44c..1651a93 100644 --- a/include/py/homekit/database/sqlite.py +++ b/include/py/homekit/database/sqlite.py @@ -18,7 +18,7 @@ class SQLiteBase: def __init__(self, name=None, path=None, check_same_thread=False): if not path: if not name: - name = config.app_config['database_name'] + name = config.app_name database_path = _get_database_path(name) else: database_path = path diff --git a/include/py/homekit/telegram/bot.py b/include/py/homekit/telegram/bot.py index 2efd9e4..f5f620a 100644 --- a/include/py/homekit/telegram/bot.py +++ b/include/py/homekit/telegram/bot.py @@ -266,7 +266,7 @@ class conversation: return self.invoke(state, ctx) return _invoke - def invoke(self, state, ctx: Context): + async def invoke(self, state, ctx: Context): self._logger.debug(f'invoke, state={state}') for item in dir(self): f = getattr(self, item) diff --git a/include/py/homekit/telegram/config.py b/include/py/homekit/telegram/config.py index 4c7d74b..5f41008 100644 --- a/include/py/homekit/telegram/config.py +++ b/include/py/homekit/telegram/config.py @@ -51,15 +51,15 @@ class TelegramBotConfig(ConfigUnit, ABC): 'type': 'dict', 'schema': { 'token': {'type': 'string', 'required': True}, - TelegramUserListType.USERS: {**TelegramBotConfig._userlist_schema(), 'required': True}, - TelegramUserListType.NOTIFY: TelegramBotConfig._userlist_schema(), + TelegramUserListType.USERS.value: {**TelegramBotConfig._userlist_schema(), 'required': True}, + TelegramUserListType.NOTIFY.value: TelegramBotConfig._userlist_schema(), } } } @staticmethod def _userlist_schema() -> dict: - return {'type': 'list', 'schema': {'type': ['string', 'int']}} + return {'type': 'list', 'schema': {'type': ['string', 'integer']}} @staticmethod def custom_validator(data): @@ -72,4 +72,7 @@ class TelegramBotConfig(ConfigUnit, ABC): 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 + try: + return list(map(_user_id_mapper, self['bot'][ult.value])) + except KeyError: + return [] \ No newline at end of file -- cgit v1.2.3 From 9b78ccca3546f93955571f4a20a44a1739e718b8 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 17 Sep 2023 04:38:26 +0300 Subject: bin: add lugobaya_pump_mqtt_bot test app --- bin/lugovaya_pump_mqtt_bot.py | 201 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100755 bin/lugovaya_pump_mqtt_bot.py diff --git a/bin/lugovaya_pump_mqtt_bot.py b/bin/lugovaya_pump_mqtt_bot.py new file mode 100755 index 0000000..72a2e87 --- /dev/null +++ b/bin/lugovaya_pump_mqtt_bot.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +import datetime +import __py_include + +from enum import Enum +from typing import Optional +from telegram import ReplyKeyboardMarkup, User + +from homekit.config import config, AppConfigUnit +from homekit.telegram import bot +from homekit.telegram.config import TelegramBotConfig +from homekit.telegram._botutil import user_any_name +from homekit.mqtt import MqttNode, MqttPayload, MqttNodesConfig, MqttWrapper +from homekit.mqtt.module.relay import MqttRelayState, MqttRelayModule +from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload + + +class LugovayaPumpMqttBotConfig(TelegramBotConfig, AppConfigUnit): + NAME = 'lugovaya_pump_mqtt_bot' + + @classmethod + def schema(cls) -> Optional[dict]: + return { + **TelegramBotConfig.schema(), + 'relay_node_id': { + 'type': 'string', + 'required': True + }, + } + + @staticmethod + def custom_validator(data): + relay_node_names = MqttNodesConfig().get_nodes(filters=('relay',), only_names=True) + if data['relay_node_id'] not in relay_node_names: + raise ValueError('unknown relay node "%s"' % (data['relay_node_id'],)) + + +config.load_app(LugovayaPumpMqttBotConfig) + +bot.initialize() +bot.lang.ru( + start_message="Выберите команду на клавиатуре", + start_message_no_access="Доступ запрещён. Вы можете отправить заявку на получение доступа.", + unknown_command="Неизвестная команда", + send_access_request="Отправить заявку", + management="Админка", + + enable="Включить", + enabled="Включен ✅", + + disable="Выключить", + disabled="Выключен ❌", + + status="Статус", + status_updated=' (обновлено %s)', + + done="Готово 👌", + user_action_notification='Пользователь %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: MqttWrapper +relay_state = MqttRelayState() +relay_module: MqttRelayModule + + +class UserAction(Enum): + ON = 'on' + OFF = 'off' + + +# def on_mqtt_message(home_id, message: MqttPayload): +# if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload): +# kwargs = dict(rssi=message.rssi, enabled=message.flags.state) +# if isinstance(message, InitialDiagnosticsPayload): +# kwargs['fw_version'] = message.fw_version +# relay_state.update(**kwargs) + + +async def notify(user: User, action: UserAction) -> None: + def text_getter(lang: str): + action_name = bot.lang.get(f'user_action_{action.value}', lang) + user_name = user_any_name(user) + return 'ℹ ' + bot.lang.get('user_action_notification', lang, + user.id, user_name, action_name) + + await bot.notify_all(text_getter, exclude=(user.id,)) + + +@bot.handler(message='enable') +async def enable_handler(ctx: bot.Context) -> None: + relay_module.switchpower(True) + await ctx.reply(ctx.lang('done')) + await notify(ctx.user, UserAction.ON) + + +@bot.handler(message='disable') +async def disable_handler(ctx: bot.Context) -> None: + relay_module.switchpower(False) + await ctx.reply(ctx.lang('done')) + await notify(ctx.user, UserAction.OFF) + + +@bot.handler(message='status') +async def status(ctx: bot.Context) -> None: + label = ctx.lang('enabled') if relay_state.enabled else ctx.lang('disabled') + if relay_state.ever_updated: + date_label = '' + today = datetime.date.today() + if today != relay_state.update_time.date(): + yday = today - datetime.timedelta(days=1) + yyday = today - datetime.timedelta(days=2) + if yday == relay_state.update_time.date(): + date_label = ctx.lang('date_yday') + elif yyday == relay_state.update_time.date(): + date_label = ctx.lang('date_yyday') + else: + date_label = relay_state.update_time.strftime('%d.%m.%Y') + date_label += ' ' + date_label += ctx.lang('date_at') + ' ' + date_label += relay_state.update_time.strftime('%H:%M') + label += ctx.lang('status_updated', date_label) + await ctx.reply(label) + + +async def start(ctx: bot.Context) -> None: + if ctx.user_id in config['bot']['users']: + await ctx.reply(ctx.lang('start_message')) + else: + buttons = [ + [ctx.lang('send_access_request')] + ] + await ctx.reply(ctx.lang('start_message_no_access'), + markup=ReplyKeyboardMarkup(buttons, one_time_keyboard=False)) + + +@bot.exceptionhandler +def exception_handler(e: Exception, ctx: bot.Context) -> bool: + return False + + +@bot.defaultreplymarkup +def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: + buttons = [[ctx.lang('enable'), ctx.lang('disable')], [ctx.lang('status')]] + # if ctx.user_id in config['bot']['admin_users']: + # buttons.append([ctx.lang('management')]) + return ReplyKeyboardMarkup(buttons, one_time_keyboard=False) + + +node_data = MqttNodesConfig().get_node(config.app_config['relay_node_id']) + +mqtt = MqttWrapper(client_id='lugovaya_pump_mqtt_bot') +mqtt_node = MqttNode(node_id=config.app_config['relay_node_id'], + node_secret=node_data['password']) +module_kwargs = {} +try: + if node_data['relay']['legacy_topics']: + module_kwargs['legacy_topics'] = True +except KeyError: + pass +relay_module = mqtt_node.load_module('relay', **module_kwargs) +mqtt_node.add_payload_callback(on_mqtt_message) +mqtt.add_node(mqtt_node) + +mqtt.connect_and_loop(loop_forever=False) + +bot.run(start_handler=start) + +mqtt.disconnect() -- cgit v1.2.3 From bdbb296697f55f4c3a07af43c9aaf7a9ea86f3d0 Mon Sep 17 00:00:00 2001 From: Evgeny Zinoviev Date: Sun, 17 Sep 2023 04:48:05 +0300 Subject: fix --- bin/lugovaya_pump_mqtt_bot.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bin/lugovaya_pump_mqtt_bot.py b/bin/lugovaya_pump_mqtt_bot.py index 72a2e87..85402d1 100755 --- a/bin/lugovaya_pump_mqtt_bot.py +++ b/bin/lugovaya_pump_mqtt_bot.py @@ -173,7 +173,13 @@ def exception_handler(e: Exception, ctx: bot.Context) -> bool: @bot.defaultreplymarkup def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]: - buttons = [[ctx.lang('enable'), ctx.lang('disable')], [ctx.lang('status')]] + 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) @@ -191,7 +197,7 @@ try: except KeyError: pass relay_module = mqtt_node.load_module('relay', **module_kwargs) -mqtt_node.add_payload_callback(on_mqtt_message) +# mqtt_node.add_payload_callback(on_mqtt_message) mqtt.add_node(mqtt_node) mqtt.connect_and_loop(loop_forever=False) -- cgit v1.2.3