summaryrefslogtreecommitdiff
path: root/include
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2023-09-27 00:54:57 +0300
committerEvgeny Zinoviev <me@ch1p.io>2023-09-27 00:54:57 +0300
commitd3a295872c49defb55fc8e4e43e55550991e0927 (patch)
treeb9dca15454f9027d5a9dad0d4443a20de04dbc5d /include
parentb7cbc2571c1870b4582ead45277d0aa7f961bec8 (diff)
parentbdbb296697f55f4c3a07af43c9aaf7a9ea86f3d0 (diff)
Merge branch 'master' of ch1p.io:homekit
Diffstat (limited to 'include')
-rw-r--r--include/bash/include.bash130
-rw-r--r--include/pio/include/homekit/logging.h20
-rw-r--r--include/pio/include/homekit/macros.h1
-rw-r--r--include/pio/include/homekit/stopwatch.h30
-rw-r--r--include/pio/include/homekit/util.h13
-rw-r--r--include/pio/libs/config/homekit/config.cpp84
-rw-r--r--include/pio/libs/config/homekit/config.h37
-rw-r--r--include/pio/libs/config/library.json8
-rw-r--r--include/pio/libs/http_server/homekit/http_server.cpp282
-rw-r--r--include/pio/libs/http_server/homekit/http_server.h62
-rw-r--r--include/pio/libs/http_server/library.json8
-rw-r--r--include/pio/libs/led/homekit/led.cpp27
-rw-r--r--include/pio/libs/led/homekit/led.h33
-rw-r--r--include/pio/libs/led/library.json8
-rw-r--r--include/pio/libs/main/homekit/main.cpp213
-rw-r--r--include/pio/libs/main/homekit/main.h52
-rw-r--r--include/pio/libs/main/library.json12
-rw-r--r--include/pio/libs/mqtt/homekit/mqtt/module.cpp26
-rw-r--r--include/pio/libs/mqtt/homekit/mqtt/module.h56
-rw-r--r--include/pio/libs/mqtt/homekit/mqtt/mqtt.cpp162
-rw-r--r--include/pio/libs/mqtt/homekit/mqtt/mqtt.h48
-rw-r--r--include/pio/libs/mqtt/homekit/mqtt/payload.h15
-rw-r--r--include/pio/libs/mqtt/library.json7
-rw-r--r--include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.cpp56
-rw-r--r--include/pio/libs/mqtt_module_diagnostics/homekit/mqtt/module/diagnostics.h49
-rw-r--r--include/pio/libs/mqtt_module_diagnostics/library.json10
-rw-r--r--include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.cpp160
-rw-r--r--include/pio/libs/mqtt_module_ota/homekit/mqtt/module/ota.h75
-rw-r--r--include/pio/libs/mqtt_module_ota/library.json11
-rw-r--r--include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.cpp58
-rw-r--r--include/pio/libs/mqtt_module_relay/homekit/mqtt/module/relay.h29
-rw-r--r--include/pio/libs/mqtt_module_relay/library.json11
-rw-r--r--include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.cpp23
-rw-r--r--include/pio/libs/mqtt_module_temphum/homekit/mqtt/module/temphum.h28
-rw-r--r--include/pio/libs/mqtt_module_temphum/library.json11
-rw-r--r--include/pio/libs/relay/homekit/relay.cpp22
-rw-r--r--include/pio/libs/relay/homekit/relay.h13
-rw-r--r--include/pio/libs/relay/library.json8
-rw-r--r--include/pio/libs/static/homekit/static.cpp450
-rw-r--r--include/pio/libs/static/homekit/static.h25
-rw-r--r--include/pio/libs/static/library.json8
-rw-r--r--include/pio/libs/temphum/homekit/temphum.cpp89
-rw-r--r--include/pio/libs/temphum/homekit/temphum.h38
-rw-r--r--include/pio/libs/temphum/library.json8
-rw-r--r--include/pio/libs/wifi/homekit/wifi.cpp47
-rw-r--r--include/pio/libs/wifi/homekit/wifi.h40
-rw-r--r--include/pio/libs/wifi/library.json8
-rwxr-xr-xinclude/pio/make_static.sh89
-rw-r--r--include/pio/static/app.js246
-rw-r--r--include/pio/static/favicon.icobin0 -> 7886 bytes
-rw-r--r--include/pio/static/index.html63
-rw-r--r--include/pio/static/md5.js615
-rw-r--r--include/pio/static/style.css85
-rw-r--r--include/py/__init__.py0
-rw-r--r--include/py/homekit/__init__.py0
-rw-r--r--include/py/homekit/api/__init__.py19
-rw-r--r--include/py/homekit/api/__init__.pyi5
-rw-r--r--include/py/homekit/api/config.py15
-rw-r--r--include/py/homekit/api/errors/__init__.py1
-rw-r--r--include/py/homekit/api/errors/api_response_error.py28
-rw-r--r--include/py/homekit/api/types/__init__.py5
-rw-r--r--include/py/homekit/api/types/types.py22
-rw-r--r--include/py/homekit/api/web_api_client.py217
-rw-r--r--include/py/homekit/audio/__init__.py0
-rw-r--r--include/py/homekit/audio/amixer.py91
-rw-r--r--include/py/homekit/camera/__init__.py2
-rw-r--r--include/py/homekit/camera/config.py130
-rw-r--r--include/py/homekit/camera/esp32.py226
-rw-r--r--include/py/homekit/camera/types.py46
-rw-r--r--include/py/homekit/camera/util.py169
-rw-r--r--include/py/homekit/config/__init__.py14
-rw-r--r--include/py/homekit/config/_configs.py61
-rw-r--r--include/py/homekit/config/config.py406
-rw-r--r--include/py/homekit/database/__init__.py29
-rw-r--r--include/py/homekit/database/__init__.pyi11
-rw-r--r--include/py/homekit/database/_base.py9
-rw-r--r--include/py/homekit/database/bots.py96
-rw-r--r--include/py/homekit/database/clickhouse.py39
-rw-r--r--include/py/homekit/database/inverter.py212
-rw-r--r--include/py/homekit/database/inverter_time_formats.py2
-rw-r--r--include/py/homekit/database/mysql.py47
-rw-r--r--include/py/homekit/database/sensors.py69
-rw-r--r--include/py/homekit/database/simple_state.py48
-rw-r--r--include/py/homekit/database/sqlite.py70
-rw-r--r--include/py/homekit/http/__init__.py2
-rw-r--r--include/py/homekit/http/http.py106
-rw-r--r--include/py/homekit/inverter/__init__.py3
-rw-r--r--include/py/homekit/inverter/config.py13
-rw-r--r--include/py/homekit/inverter/emulator.py556
-rw-r--r--include/py/homekit/inverter/inverter_wrapper.py48
-rw-r--r--include/py/homekit/inverter/monitor.py499
-rw-r--r--include/py/homekit/inverter/types.py64
-rw-r--r--include/py/homekit/inverter/util.py8
-rw-r--r--include/py/homekit/media/__init__.py22
-rw-r--r--include/py/homekit/media/__init__.pyi27
-rw-r--r--include/py/homekit/media/node_client.py119
-rw-r--r--include/py/homekit/media/node_server.py86
-rw-r--r--include/py/homekit/media/record.py461
-rw-r--r--include/py/homekit/media/record_client.py166
-rw-r--r--include/py/homekit/media/storage.py210
-rw-r--r--include/py/homekit/media/types.py13
-rw-r--r--include/py/homekit/mqtt/__init__.py7
-rw-r--r--include/py/homekit/mqtt/_config.py165
-rw-r--r--include/py/homekit/mqtt/_module.py70
-rw-r--r--include/py/homekit/mqtt/_mqtt.py87
-rw-r--r--include/py/homekit/mqtt/_node.py92
-rw-r--r--include/py/homekit/mqtt/_payload.py145
-rw-r--r--include/py/homekit/mqtt/_util.py15
-rw-r--r--include/py/homekit/mqtt/_wrapper.py60
-rw-r--r--include/py/homekit/mqtt/module/diagnostics.py64
-rw-r--r--include/py/homekit/mqtt/module/inverter.py195
-rw-r--r--include/py/homekit/mqtt/module/ota.py77
-rw-r--r--include/py/homekit/mqtt/module/relay.py92
-rw-r--r--include/py/homekit/mqtt/module/temphum.py82
-rw-r--r--include/py/homekit/pio/__init__.py1
-rw-r--r--include/py/homekit/pio/exceptions.py2
-rw-r--r--include/py/homekit/pio/products.py115
-rw-r--r--include/py/homekit/relay/__init__.py16
-rw-r--r--include/py/homekit/relay/__init__.pyi2
-rw-r--r--include/py/homekit/relay/sunxi_h3_client.py39
-rw-r--r--include/py/homekit/relay/sunxi_h3_server.py82
-rw-r--r--include/py/homekit/soundsensor/__init__.py22
-rw-r--r--include/py/homekit/soundsensor/__init__.pyi8
-rw-r--r--include/py/homekit/soundsensor/node.py75
-rw-r--r--include/py/homekit/soundsensor/server.py128
-rw-r--r--include/py/homekit/soundsensor/server_client.py38
-rw-r--r--include/py/homekit/telegram/__init__.py1
-rw-r--r--include/py/homekit/telegram/_botcontext.py86
-rw-r--r--include/py/homekit/telegram/_botdb.py32
-rw-r--r--include/py/homekit/telegram/_botlang.py120
-rw-r--r--include/py/homekit/telegram/_botutil.py30
-rw-r--r--include/py/homekit/telegram/aio.py18
-rw-r--r--include/py/homekit/telegram/bot.py574
-rw-r--r--include/py/homekit/telegram/config.py78
-rw-r--r--include/py/homekit/telegram/telegram.py49
-rw-r--r--include/py/homekit/temphum/__init__.py1
-rw-r--r--include/py/homekit/temphum/base.py19
-rw-r--r--include/py/homekit/temphum/i2c.py52
-rw-r--r--include/py/homekit/util.py255
-rw-r--r--include/py/pyA20/__init__.pyi0
-rw-r--r--include/py/pyA20/gpio/connector.pyi2
-rw-r--r--include/py/pyA20/gpio/gpio.pyi24
-rw-r--r--include/py/pyA20/gpio/port.pyi36
-rw-r--r--include/py/pyA20/port.pyi0
-rw-r--r--include/py/syncleo/__init__.py12
-rw-r--r--include/py/syncleo/kettle.py243
-rw-r--r--include/py/syncleo/protocol.py1169
147 files changed, 12681 insertions, 0 deletions
diff --git a/include/bash/include.bash b/include/bash/include.bash
new file mode 100644
index 0000000..1d73ab2
--- /dev/null
+++ b/include/bash/include.bash
@@ -0,0 +1,130 @@
+# colored output
+# --------------
+
+BOLD=$(tput bold)
+RST=$(tput sgr0)
+RED=$(tput setaf 1)
+GREEN=$(tput setaf 2)
+YELLOW=$(tput setaf 3)
+CYAN=$(tput setaf 6)
+VERBOSE=
+
+echoinfo() {
+ >&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/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 <stdlib.h>
+
+#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 <Arduino.h>
+
+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 <EEPROM.h>
+#include <strings.h>
+#include "config.h"
+#include <homekit/logging.h>
+
+#define GET_DATA_CRC(data) \
+ eeprom_crc(reinterpret_cast<uint8_t*>(&(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<uint8_t*>(&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 <Arduino.h>
+
+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 <Arduino.h>
+#include <string.h>
+
+#include <homekit/static.h>
+#include <homekit/config.h>
+#include <homekit/logging.h>
+#include <homekit/macros.h>
+#include <homekit/util.h>
+
+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<uint8_t*>(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 <ESP8266WebServer.h>
+#include <Ticker.h>
+#include <memory>
+#include <list>
+#include <utility>
+
+#include <homekit/config.h>
+#include <homekit/wifi.h>
+#include <homekit/static.h>
+
+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<std::list<wifi::ScanResult>> 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<std::list<wifi::ScanResult>> 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 <Arduino.h>
+#include <stdint.h>
+
+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 <homekit/led.h>
+#include <homekit/mqtt/mqtt.h>
+#include <homekit/mqtt/module/diagnostics.h>
+#include <homekit/mqtt/module/ota.h>
+
+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<int>(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 <Arduino.h>
+#include <ESP8266WiFi.h>
+#include <DNSServer.h>
+#include <Ticker.h>
+#include <Wire.h>
+
+#include <homekit/config.h>
+#include <homekit/logging.h>
+#ifndef CONFIG_TARGET_ESP01
+#ifndef CONFIG_NO_RECOVERY
+#include <homekit/http_server.h>
+#endif
+#endif
+#include <homekit/wifi.h>
+#include <homekit/mqtt/mqtt.h>
+
+#include <functional>
+
+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<void(mqtt::Mqtt&)> 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..c5586d8
--- /dev/null
+++ b/include/pio/libs/main/library.json
@@ -0,0 +1,12 @@
+{
+ "name": "homekit_main",
+ "version": "1.0.11",
+ "build": {
+ "flags": "-I../../include"
+ },
+ "dependencies": {
+ "homekit_mqtt_module_ota": "file://../../include/pio/libs/mqtt_module_ota",
+ "homekit_mqtt_module_diagnostics": "file://../../include/pio/libs/mqtt_module_diagnostics"
+ }
+}
+
diff --git a/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 <homekit/logging.h>
+
+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 <homekit/stopwatch.h>
+
+
+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 <homekit/config.h>
+#include <homekit/wifi.h>
+#include <homekit/logging.h>
+
+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<int>(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<unsigned int>(*(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 <vector>
+#include <map>
+#include <cstdint>
+#include <espMqttClient.h>
+#include <Ticker.h>
+#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<MqttModule*> modules;
+ std::map<String, MqttModule*> 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 <unistd.h>
+
+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 <homekit/wifi.h>
+#include <ESP8266WiFi.h>
+
+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<uint8_t>(cfg.flags.node_configured ||
+ cfg.flags.wifi_configured ? 1 : 0)
+ }
+ };
+ mqtt.publish(TOPIC_INITIAL_DIAGNOSTICS, reinterpret_cast<uint8_t*>(&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<uint8_t*>(&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 <stdint.h>
+#include <homekit/mqtt/module.h>
+
+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..70acb79
--- /dev/null
+++ b/include/pio/libs/mqtt_module_diagnostics/library.json
@@ -0,0 +1,10 @@
+{
+ "name": "homekit_mqtt_module_diagnostics",
+ "version": "1.0.3",
+ "build": {
+ "flags": "-I../../include"
+ },
+ "dependencies": {
+ "homekit_mqtt": "file://../../include/pio/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 <homekit/logging.h>
+#include <homekit/util.h>
+#include <homekit/led.h>
+
+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<uint8_t*>(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<uint8_t*>(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<uint8_t*>(&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 <stdint.h>
+#include <Ticker.h>
+#include <homekit/mqtt/module.h>
+
+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..1577fed
--- /dev/null
+++ b/include/pio/libs/mqtt_module_ota/library.json
@@ -0,0 +1,11 @@
+{
+ "name": "homekit_mqtt_module_ota",
+ "version": "1.0.6",
+ "build": {
+ "flags": "-I../../include"
+ },
+ "dependencies": {
+ "homekit_led": "file://../../include/pio/libs/led",
+ "homekit_mqtt": "file://../../include/pio/libs/mqtt"
+ }
+}
diff --git a/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 <homekit/relay.h>
+#include <homekit/logging.h>
+
+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<const struct MqttRelaySwitchPayload*>(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<uint8_t*>(&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 <homekit/mqtt/module.h>
+
+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..18a510c
--- /dev/null
+++ b/include/pio/libs/mqtt_module_relay/library.json
@@ -0,0 +1,11 @@
+{
+ "name": "homekit_mqtt_module_relay",
+ "version": "1.0.6",
+ "build": {
+ "flags": "-I../../include"
+ },
+ "dependencies": {
+ "homekit_mqtt": "file://../../include/pio/libs/mqtt",
+ "homekit_relay": "file://../../include/pio/libs/relay"
+ }
+}
diff --git a/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<uint8_t*>(&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 <homekit/mqtt/module.h>
+#include <homekit/temphum.h>
+
+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..c7ee7af
--- /dev/null
+++ b/include/pio/libs/mqtt_module_temphum/library.json
@@ -0,0 +1,11 @@
+{
+ "name": "homekit_mqtt_module_temphum",
+ "version": "1.0.10",
+ "build": {
+ "flags": "-I../../include"
+ },
+ "dependencies": {
+ "homekit_mqtt": "file://../../include/pio/libs/mqtt",
+ "homekit_temphum": "file://../../include/pio/libs/temphum"
+ }
+}
diff --git a/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 <Arduino.h>
+#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 <stdlib.h>
+
+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 <Arduino.h>
+#endif
+#include <homekit/logging.h>
+#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 <Wire.h>
+
+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 <pgmspace.h>
+#include "wifi.h"
+#include <homekit/config.h>
+#include <homekit/logging.h>
+
+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<std::list<ScanResult>> scan() {
+ if (WiFi.getMode() != WIFI_STA) {
+ PRINTLN("wifi::scan: switching mode to STA");
+ WiFi.mode(WIFI_STA);
+ }
+
+ std::shared_ptr<std::list<ScanResult>> results(new std::list<ScanResult>);
+ 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 <ESP8266WiFi.h>
+#include <list>
+#include <memory>
+
+#include <homekit/config.h>
+
+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<std::list<ScanResult>> 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 <<EOF >> "$header"
+/**
+ * This file is autogenerated with make_static.sh script
+ */
+
+#pragma once
+
+#include <stdlib.h>
+
+namespace homekit::files {
+
+typedef struct {
+ size_t size;
+ const uint8_t* content;
+} StaticFile;
+
+EOF
+
+cat <<EOF >> "$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
--- /dev/null
+++ b/include/pio/static/favicon.ico
Binary files 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 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
+ <title>Configuration</title>
+ <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
+ <link rel="stylesheet" type="text/css" href="/style.css">
+ <script src="/md5.js"></script>
+ <script src="/app.js"></script>
+</head>
+<body onload="initApp()">
+<div class="title">Settings <span id="loading_label">(loading...)</span></div>
+<div class="block">
+ <form method="post" action="/status" name="network_settings">
+ <div class="form_label">WiFi SSID</div>
+ <div class="form_input">
+ <select id="ssid_select" name="ssid" class="full-width">
+ <option value="">Loading...</option>
+ </select>
+ </div>
+
+ <div class="form_label">WiFi Password</div>
+ <div class="form_input">
+ <input type="password" value="" name="psk" class="full-width" id="fld_psk" maxlength="63" disabled>
+ <div class="form_sublabel">
+ <label for="show_psk"><input type="checkbox" name="show_psk" id="show_psk"> show password</label>
+ </div>
+ </div>
+
+ <div class="form_label">Home ID</div>
+ <div class="form_input">
+ <input type="text" value="" maxlength="16" name="hid" id="fld_hid" class="full-width" disabled>
+ </div>
+
+ <button type="submit" disabled="disabled" name="submit">Save and Reboot</button>
+ </form>
+</div>
+
+<div class="title">Update firmware (.bin)</div>
+<div class="block">
+ <form method="post" action="/update" enctype="multipart/form-data" name="update_settings">
+ <div class="form_input">
+ <input type="file" accept=".bin,.bin.gz" name="file">
+ </div>
+ <button type="submit" name="submit" disabled="disabled">Upload</button>
+ </form>
+</div>
+
+<div class="title">Reset settings</div>
+<div class="block">
+ <form method="post" action="/reset">
+ <button type="submit" name="submit" class="is_reset">Reset</button>
+ </form>
+</div>
+
+<div class="title">Info</div>
+<div class="block">
+ ESP8266-based <b>relayctl</b>, firmware v{version}<br>
+ Part of <a href="https://git.ch1p.io/homekit.git/">homekit</a> by <a href="https://ch1p.io">Evgeny Zinoviev</a> &copy; 2022
+</div>
+</body>
+</html> \ 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
--- /dev/null
+++ b/include/py/__init__.py
diff --git a/include/py/homekit/__init__.py b/include/py/homekit/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/include/py/homekit/__init__.py
diff --git a/include/py/homekit/api/__init__.py b/include/py/homekit/api/__init__.py
new file mode 100644
index 0000000..d641f62
--- /dev/null
+++ b/include/py/homekit/api/__init__.py
@@ -0,0 +1,19 @@
+import importlib
+
+__all__ = [
+ # web_api_client.py
+ 'WebApiClient',
+ 'RequestParams',
+
+ # config.py
+ 'WebApiConfig'
+]
+
+
+def __getattr__(name):
+ if name in __all__:
+ file = 'config' if name == 'WebApiConfig' else 'web_api_client'
+ module = importlib.import_module(f'.{file}', __name__)
+ return getattr(module, name)
+
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/include/py/homekit/api/__init__.pyi b/include/py/homekit/api/__init__.pyi
new file mode 100644
index 0000000..5b98161
--- /dev/null
+++ b/include/py/homekit/api/__init__.pyi
@@ -0,0 +1,5 @@
+from .web_api_client import (
+ RequestParams as RequestParams,
+ WebApiClient as WebApiClient
+)
+from .config import WebApiConfig as WebApiConfig
diff --git a/include/py/homekit/api/config.py b/include/py/homekit/api/config.py
new file mode 100644
index 0000000..00c1097
--- /dev/null
+++ b/include/py/homekit/api/config.py
@@ -0,0 +1,15 @@
+from ..config import ConfigUnit
+from typing import Optional, Union
+
+
+class WebApiConfig(ConfigUnit):
+ NAME = 'web_api'
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'listen_addr': cls._addr_schema(required=True),
+ 'host': cls._addr_schema(required=True),
+ 'token': dict(type='string', required=True),
+ 'recordings_dir': dict(type='string', required=True)
+ } \ No newline at end of file
diff --git a/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..22ce4e6
--- /dev/null
+++ b/include/py/homekit/api/types/__init__.py
@@ -0,0 +1,5 @@
+from .types import (
+ 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..294a712
--- /dev/null
+++ b/include/py/homekit/api/types/types.py
@@ -0,0 +1,22 @@
+from enum import Enum, 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..f9a8963
--- /dev/null
+++ b/include/py/homekit/api/web_api_client.py
@@ -0,0 +1,217 @@
+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_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
--- /dev/null
+++ b/include/py/homekit/audio/__init__.py
diff --git a/include/py/homekit/audio/amixer.py b/include/py/homekit/audio/amixer.py
new file mode 100644
index 0000000..8ed754b
--- /dev/null
+++ b/include/py/homekit/audio/amixer.py
@@ -0,0 +1,91 @@
+import subprocess
+
+from ..config import config
+from threading import Lock
+from typing import Union, List
+
+
+_lock = Lock()
+_default_step = 5
+
+
+def has_control(s: str) -> bool:
+ for control in config.app_config['amixer']['controls']:
+ if control['name'] == s:
+ return True
+ return False
+
+
+def get_caps(s: str) -> List[str]:
+ for control in config.app_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.app_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.app_config['amixer']:
+ return int(config.app_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.app_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..4875031
--- /dev/null
+++ b/include/py/homekit/camera/__init__.py
@@ -0,0 +1,2 @@
+from .types import CameraType, VideoContainerType, VideoCodecType, CaptureType
+from .config import IpcamConfig \ No newline at end of file
diff --git a/include/py/homekit/camera/config.py b/include/py/homekit/camera/config.py
new file mode 100644
index 0000000..c7dbc38
--- /dev/null
+++ b/include/py/homekit/camera/config.py
@@ -0,0 +1,130 @@
+import socket
+
+from ..config import ConfigUnit, LinuxBoardsConfig
+from typing import Optional
+from .types import CameraType, VideoContainerType, VideoCodecType
+
+_lbc = LinuxBoardsConfig()
+
+
+def _validate_roi_line(field, value, error) -> bool:
+ p = value.split(' ')
+ if len(p) != 4:
+ error(field, f'{field}: must contain four coordinates separated by space')
+ for n in p:
+ if not n.isnumeric():
+ error(field, f'{field}: invalid coordinates (not a number)')
+ return True
+
+
+class IpcamConfig(ConfigUnit):
+ NAME = 'ipcam'
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'cams': {
+ 'type': 'dict',
+ 'keysrules': {'type': ['string', 'integer']},
+ 'valuesrules': {
+ 'type': 'dict',
+ 'schema': {
+ 'type': {'type': 'string', 'allowed': [t.value for t in CameraType], 'required': True},
+ 'codec': {'type': 'string', 'allowed': [t.value for t in VideoCodecType], 'required': True},
+ 'container': {'type': 'string', 'allowed': [t.value for t in VideoContainerType], 'required': True},
+ 'server': {'type': 'string', 'allowed': list(_lbc.get().keys()), 'required': True},
+ 'disk': {'type': 'integer', 'required': True},
+ 'motion': {
+ 'type': 'dict',
+ 'schema': {
+ 'threshold': {'type': ['float', 'integer']},
+ 'roi': {
+ 'type': 'list',
+ 'schema': {'type': 'string', 'check_with': _validate_roi_line}
+ }
+ }
+ },
+ 'rtsp_tcp': {'type': 'boolean'}
+ }
+ }
+ },
+ 'motion_padding': {'type': 'integer', 'required': True},
+ 'motion_telegram': {'type': 'boolean', 'required': True},
+ 'fix_interval': {'type': 'integer', 'required': True},
+ 'fix_enabled': {'type': 'boolean', 'required': True},
+ 'cleanup_min_gb': {'type': 'integer', 'required': True},
+ 'cleanup_interval': {'type': 'integer', 'required': True},
+
+ # TODO FIXME
+ 'fragment_url_templates': cls._url_templates_schema(),
+ 'original_file_url_templates': cls._url_templates_schema(),
+
+ 'hls_path': {'type': 'string', 'required': True},
+ 'motion_processing_tmpfs_path': {'type': 'string', 'required': True},
+
+ 'rtsp_creds': {
+ 'required': True,
+ 'type': 'dict',
+ 'schema': {
+ 'login': {'type': 'string', 'required': True},
+ 'password': {'type': 'string', 'required': True},
+ }
+ }
+ }
+
+ @staticmethod
+ def custom_validator(data):
+ for n, cam in data['cams'].items():
+ linux_box = _lbc[cam['server']]
+ if 'ext_hdd' not in linux_box:
+ raise ValueError(f'cam-{n}: linux box {cam["server"]} must have ext_hdd defined')
+ disk = cam['disk']-1
+ if disk < 0 or disk >= len(linux_box['ext_hdd']):
+ raise ValueError(f'cam-{n}: invalid disk index for linux box {cam["server"]}')
+
+ @classmethod
+ def _url_templates_schema(cls) -> dict:
+ return {
+ 'type': 'list',
+ 'empty': False,
+ 'schema': {
+ 'type': 'list',
+ 'empty': False,
+ 'schema': {'type': 'string'}
+ }
+ }
+
+ def get_all_cam_names(self,
+ filter_by_server: Optional[str] = None,
+ filter_by_disk: Optional[int] = None) -> list[int]:
+ cams = []
+ if filter_by_server is not None and filter_by_server not in _lbc:
+ raise ValueError(f'invalid filter_by_server: {filter_by_server} not found in {_lbc.__class__.__name__}')
+ for cam, params in self['cams'].items():
+ if filter_by_server is None or params['server'] == filter_by_server:
+ if filter_by_disk is None or params['disk'] == filter_by_disk:
+ cams.append(int(cam))
+ return cams
+
+ def get_all_cam_names_for_this_server(self,
+ filter_by_disk: Optional[int] = None):
+ return self.get_all_cam_names(filter_by_server=socket.gethostname(),
+ filter_by_disk=filter_by_disk)
+
+ def get_cam_server_and_disk(self, cam: int) -> tuple[str, int]:
+ return self['cams'][cam]['server'], self['cams'][cam]['disk']
+
+ def get_camera_container(self, cam: int) -> VideoContainerType:
+ return VideoContainerType(self['cams'][cam]['container'])
+
+ def get_camera_type(self, cam: int) -> CameraType:
+ return CameraType(self['cams'][cam]['type'])
+
+ def get_rtsp_creds(self) -> tuple[str, str]:
+ return self['rtsp_creds']['login'], self['rtsp_creds']['password']
+
+ def should_use_tcp_for_rtsp(self, cam: int) -> bool:
+ return 'rtsp_tcp' in self['cams'][cam] and self['cams'][cam]['rtsp_tcp']
+
+ def get_camera_ip(self, camera: int) -> str:
+ return f'192.168.5.{camera}'
diff --git a/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..c313b58
--- /dev/null
+++ b/include/py/homekit/camera/types.py
@@ -0,0 +1,46 @@
+from enum import Enum
+
+
+class CameraType(Enum):
+ ESP32 = 'esp32'
+ ALIEXPRESS_NONAME = 'ali'
+ HIKVISION = 'hik'
+
+ def get_channel_url(self, channel: int) -> str:
+ if channel not in (1, 2):
+ raise ValueError(f'channel {channel} is invalid')
+ if channel == 1:
+ return ''
+ elif channel == 2:
+ if self.value == CameraType.HIKVISION:
+ return '/Streaming/Channels/2'
+ elif self.value == CameraType.ALIEXPRESS_NONAME:
+ return '/?stream=1.sdp'
+ else:
+ raise ValueError(f'unsupported camera type {self.value}')
+
+
+class VideoContainerType(Enum):
+ MP4 = 'mp4'
+ MOV = 'mov'
+
+
+class VideoCodecType(Enum):
+ H264 = 'h264'
+ H265 = 'h265'
+
+
+class TimeFilterType(Enum):
+ FIX = 'fix'
+ MOTION = 'motion'
+ MOTION_START = 'motion_start'
+
+
+class TelegramLinkType(Enum):
+ FRAGMENT = 'fragment'
+ ORIGINAL_FILE = 'original_file'
+
+
+class CaptureType(Enum):
+ HLS = 'hls'
+ RECORD = 'record'
diff --git a/include/py/homekit/camera/util.py b/include/py/homekit/camera/util.py
new file mode 100644
index 0000000..58c2c70
--- /dev/null
+++ b/include/py/homekit/camera/util.py
@@ -0,0 +1,169 @@
+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, LinuxBoardsConfig
+from .config import IpcamConfig
+from .types import VideoContainerType
+
+_logger = logging.getLogger(__name__)
+_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:
+ 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)
+ _, 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]
+ 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
+
+
+def get_recordings_path(cam: int) -> str:
+ server, disk = _ipcam_config.get_cam_server_and_disk(cam)
+ disks = _lbc_config.get_board_disks(server)
+ disk_mountpoint = disks[disk-1]
+ return f'{disk_mountpoint}/cam-{cam}'
+
+
+def get_motion_path(cam: int) -> str:
+ return f'{get_recordings_path(cam)}/motion'
+
+
+def is_valid_recording_name(filename: str) -> bool:
+ if not filename.startswith('record_'):
+ return False
+
+ for container_type in VideoContainerType:
+ if filename.endswith(f'.{container_type.value}'):
+ return True
+
+ return False
+
+
+def datetime_from_filename(name: str) -> datetime:
+ name = os.path.basename(name)
+ exts = '|'.join([t.value for t in VideoContainerType])
+
+ if name.startswith('record_'):
+ return datetime.strptime(re.match(rf'record_(.*?)\.(?:{exts})', name).group(1), datetime_format)
+
+ m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.(?:{exts})', name)
+ if m:
+ return datetime.strptime(m.group(1), datetime_format)
+
+ raise ValueError(f'unrecognized filename format: {name}')
+
+
+def get_hls_channel_name(cam: int, channel: int) -> str:
+ name = str(cam)
+ if channel == 2:
+ name += '-low'
+ return name
+
+
+def get_hls_directory(cam, channel) -> str:
+ dirname = os.path.join(
+ _ipcam_config['hls_path'],
+ get_hls_channel_name(cam, channel)
+ )
+ if not os.path.exists(dirname):
+ os.makedirs(dirname)
+ return dirname \ No newline at end of file
diff --git a/include/py/homekit/config/__init__.py b/include/py/homekit/config/__init__.py
new file mode 100644
index 0000000..8fedfa6
--- /dev/null
+++ b/include/py/homekit/config/__init__.py
@@ -0,0 +1,14 @@
+from .config import (
+ Config,
+ ConfigUnit,
+ AppConfigUnit,
+ Translation,
+ config,
+ is_development_mode,
+ setup_logging,
+ CONFIG_DIRECTORIES
+)
+from ._configs import (
+ LinuxBoardsConfig,
+ ServicesListConfig
+) \ No newline at end of file
diff --git a/include/py/homekit/config/_configs.py b/include/py/homekit/config/_configs.py
new file mode 100644
index 0000000..f88c8ea
--- /dev/null
+++ b/include/py/homekit/config/_configs.py
@@ -0,0 +1,61 @@
+from .config import ConfigUnit
+from typing import Optional
+
+
+class ServicesListConfig(ConfigUnit):
+ NAME = 'services_list'
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'type': 'list',
+ 'empty': False,
+ 'schema': {
+ 'type': 'string'
+ }
+ }
+
+
+class LinuxBoardsConfig(ConfigUnit):
+ NAME = 'linux_boards'
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'type': 'dict',
+ 'schema': {
+ 'mdns': {'type': 'string', 'required': True},
+ 'board': {'type': 'string', 'required': True},
+ 'network': {
+ 'type': 'list',
+ 'required': True,
+ 'empty': False,
+ 'allowed': ['wifi', 'ethernet']
+ },
+ 'ram': {'type': 'integer', 'required': True},
+ 'online': {'type': 'boolean', 'required': True},
+
+ # optional
+ 'services': {
+ 'type': 'list',
+ 'empty': False,
+ 'allowed': ServicesListConfig().get()
+ },
+ 'ext_hdd': {
+ 'type': 'list',
+ 'schema': {
+ 'type': 'dict',
+ 'schema': {
+ 'mountpoint': {'type': 'string', 'required': True},
+ 'size': {'type': 'integer', 'required': True}
+ }
+ },
+ },
+ }
+ }
+
+ def get_board_disks(self, name: str) -> list[dict]:
+ return self[name]['ext_hdd']
+
+ def get_board_disks_count(self, name: str) -> int:
+ return len(self[name]['ext_hdd'])
diff --git a/include/py/homekit/config/config.py b/include/py/homekit/config/config.py
new file mode 100644
index 0000000..5fe1ae8
--- /dev/null
+++ b/include/py/homekit/config/config.py
@@ -0,0 +1,406 @@
+import yaml
+import logging
+import os
+import cerberus
+import cerberus.errors
+
+from abc import ABC
+from typing import Optional, Any, MutableMapping, Union
+from argparse import ArgumentParser
+from enum import Enum, auto
+from os.path import join, isdir, isfile
+from ..util import Addr
+from pprint import pprint
+
+
+class MyValidator(cerberus.Validator):
+ def _normalize_coerce_addr(self, value):
+ return Addr.fromstring(value)
+
+
+MyValidator.types_mapping['addr'] = cerberus.TypeDefinition('Addr', (Addr,), ())
+
+
+CONFIG_DIRECTORIES = (
+ join(os.environ['HOME'], '.config', 'homekit'),
+ '/etc/homekit'
+)
+
+
+class RootSchemaType(Enum):
+ DEFAULT = auto()
+ DICT = auto()
+ LIST = auto()
+
+
+class BaseConfigUnit(ABC):
+ _data: MutableMapping[str, Any]
+ _logger: logging.Logger
+
+ def __init__(self):
+ self._data = {}
+ self._logger = logging.getLogger(self.__class__.__name__)
+
+ def __getitem__(self, key):
+ return self._data[key]
+
+ def __setitem__(self, key, value):
+ raise NotImplementedError('overwriting config values is prohibited')
+
+ def __contains__(self, key):
+ return key in self._data
+
+ def load_from(self, path: str):
+ with open(path, 'r') as fd:
+ self._data = yaml.safe_load(fd)
+ if self._data is None:
+ raise TypeError(f'config file {path} is empty')
+
+ def get(self,
+ key: Optional[str] = None,
+ default=None):
+ if key is None:
+ return self._data
+
+ cur = self._data
+ pts = key.split('.')
+ for i in range(len(pts)):
+ k = pts[i]
+ if i < len(pts)-1:
+ if k not in cur:
+ raise KeyError(f'key {k} not found')
+ else:
+ return cur[k] if k in cur else default
+ cur = self._data[k]
+
+ raise KeyError(f'option {key} not found')
+
+
+class ConfigUnit(BaseConfigUnit):
+ NAME = 'dumb'
+
+ _instance = None
+
+ def __init_subclass__(cls, **kwargs):
+ super().__init_subclass__(**kwargs)
+ cls._instance = None
+
+ def __new__(cls, *args, **kwargs):
+ if cls._instance is None:
+ cls._instance = super(ConfigUnit, cls).__new__(cls, *args, **kwargs)
+ return cls._instance
+
+ def __init__(self, name=None, load=True):
+ super().__init__()
+
+ self._data = {}
+ self._logger = logging.getLogger(self.__class__.__name__)
+
+ if self.NAME != 'dumb' and load:
+ self.load_from(self.get_config_path())
+ self.validate()
+
+ elif name is not None:
+ self.NAME = name
+
+ @classmethod
+ def get_config_path(cls, name=None) -> str:
+ if name is None:
+ name = cls.NAME
+ if name is None:
+ raise ValueError('get_config_path: name is none')
+
+ for dirname in CONFIG_DIRECTORIES:
+ if isdir(dirname):
+ filename = join(dirname, f'{name}.yaml')
+ if isfile(filename):
+ return filename
+
+ raise IOError(f'\'{name}.yaml\' not found')
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return None
+
+ @classmethod
+ def _addr_schema(cls, required=False, **kwargs):
+ return {
+ 'type': 'addr',
+ 'coerce': Addr.fromstring,
+ 'required': required,
+ **kwargs
+ }
+
+ def validate(self):
+ schema = self.schema()
+ if not schema:
+ self._logger.warning('validate: no schema')
+ return
+
+ if isinstance(self, AppConfigUnit):
+ schema['logging'] = {
+ 'type': 'dict',
+ 'schema': {
+ 'verbose': {'type': 'boolean'}
+ }
+ }
+
+ rst = RootSchemaType.DEFAULT
+ try:
+ if schema['type'] == 'dict':
+ rst = RootSchemaType.DICT
+ elif schema['type'] == 'list':
+ rst = RootSchemaType.LIST
+ elif schema['roottype'] == 'dict':
+ del schema['roottype']
+ rst = RootSchemaType.DICT
+ except KeyError:
+ pass
+
+ v = MyValidator()
+
+ if rst == RootSchemaType.DICT:
+ normalized = v.validated({'document': self._data},
+ {'document': {
+ 'type': 'dict',
+ 'keysrules': {'type': 'string'},
+ 'valuesrules': schema
+ }})['document']
+ elif rst == RootSchemaType.LIST:
+ v = MyValidator()
+ normalized = v.validated({'document': self._data}, {'document': schema})['document']
+ else:
+ normalized = v.validated(self._data, schema)
+
+ if not normalized:
+ raise cerberus.DocumentError(f'validation failed: {v.errors}')
+
+ self._data = normalized
+
+ try:
+ self.custom_validator(self._data)
+ except Exception as e:
+ raise cerberus.DocumentError(f'{self.__class__.__name__}: {str(e)}')
+
+ @staticmethod
+ def custom_validator(data):
+ pass
+
+ def get_addr(self, key: str):
+ return Addr.fromstring(self.get(key))
+
+
+class AppConfigUnit(ConfigUnit):
+ _logging_verbose: bool
+ _logging_fmt: Optional[str]
+ _logging_file: Optional[str]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(load=False, *args, **kwargs)
+ self._logging_verbose = False
+ self._logging_fmt = None
+ self._logging_file = None
+
+ def logging_set_fmt(self, fmt: str) -> None:
+ self._logging_fmt = fmt
+
+ def logging_get_fmt(self) -> Optional[str]:
+ try:
+ return self['logging']['default_fmt']
+ except (KeyError, TypeError):
+ return self._logging_fmt
+
+ def logging_set_file(self, file: str) -> None:
+ self._logging_file = file
+
+ def logging_get_file(self) -> Optional[str]:
+ try:
+ return self['logging']['file']
+ except (KeyError, TypeError):
+ return self._logging_file
+
+ def logging_set_verbose(self):
+ self._logging_verbose = True
+
+ def logging_is_verbose(self) -> bool:
+ try:
+ return bool(self['logging']['verbose'])
+ except (KeyError, TypeError):
+ return self._logging_verbose
+
+
+class TranslationUnit(BaseConfigUnit):
+ pass
+
+
+class Translation:
+ LANGUAGES = ('en', 'ru')
+ _langs: dict[str, TranslationUnit]
+
+ def __init__(self, name: str):
+ super().__init__()
+ self._langs = {}
+ for lang in self.LANGUAGES:
+ for dirname in CONFIG_DIRECTORIES:
+ if isdir(dirname):
+ filename = join(dirname, f'i18n-{lang}', f'{name}.yaml')
+ if lang in self._langs:
+ raise RuntimeError(f'{name}: translation unit for lang \'{lang}\' already loaded')
+ self._langs[lang] = TranslationUnit()
+ self._langs[lang].load_from(filename)
+ diff = set()
+ for data in self._langs.values():
+ diff ^= data.get().keys()
+ if len(diff) > 0:
+ raise RuntimeError(f'{name}: translation units have difference in keys: ' + ', '.join(diff))
+
+ def get(self, lang: str) -> TranslationUnit:
+ return self._langs[lang]
+
+
+class Config:
+ app_name: Optional[str]
+ app_config: AppConfigUnit
+
+ def __init__(self):
+ self.app_name = None
+ self.app_config = AppConfigUnit()
+
+ def load_app(self,
+ name: Optional[Union[str, AppConfigUnit, bool]] = None,
+ use_cli=True,
+ parser: ArgumentParser = None,
+ no_config=False):
+ global app_config
+
+ if not no_config \
+ and not isinstance(name, str) \
+ and not isinstance(name, bool) \
+ and issubclass(name, AppConfigUnit) or name == AppConfigUnit:
+ self.app_name = name.NAME
+ print(self.app_config)
+ self.app_config = name()
+ print(self.app_config)
+ app_config = self.app_config
+ else:
+ self.app_name = name if isinstance(name, str) else None
+
+ if self.app_name is None and not use_cli:
+ raise RuntimeError('either config name must be none or use_cli must be True')
+
+ no_config = name is False or no_config
+ path = None
+
+ if use_cli:
+ if parser is None:
+ parser = ArgumentParser()
+ if not no_config:
+ parser.add_argument('-c', '--config', type=str, required=name is None,
+ help='Path to the config in TOML or YAML format')
+ parser.add_argument('-V', '--verbose', action='store_true')
+ parser.add_argument('--log-file', type=str)
+ parser.add_argument('--log-default-fmt', action='store_true')
+ args = parser.parse_args()
+
+ if not no_config and args.config:
+ path = args.config
+
+ if args.verbose:
+ self.app_config.logging_set_verbose()
+ if args.log_file:
+ self.app_config.logging_set_file(args.log_file)
+ if args.log_default_fmt:
+ self.app_config.logging_set_fmt(args.log_default_fmt)
+
+ if not isinstance(name, ConfigUnit):
+ if not no_config and path is None:
+ path = ConfigUnit.get_config_path(name=self.app_name)
+
+ if not no_config:
+ self.app_config.load_from(path)
+ self.app_config.validate()
+
+ setup_logging(self.app_config.logging_is_verbose(),
+ self.app_config.logging_get_file(),
+ self.app_config.logging_get_fmt())
+
+ if use_cli:
+ return args
+
+
+config = Config()
+
+
+def is_development_mode() -> bool:
+ if 'HK_MODE' in os.environ and os.environ['HK_MODE'] == 'dev':
+ return True
+
+ return ('logging' in config.app_config) and ('verbose' in config.app_config['logging']) and (config.app_config['logging']['verbose'] is True)
+
+
+def setup_logging(verbose=False, log_file=None, default_fmt=None):
+ logging_level = logging.INFO
+ if is_development_mode() or verbose:
+ logging_level = logging.DEBUG
+ _add_logging_level('TRACE', logging.DEBUG-5)
+
+ log_config = {'level': logging_level}
+ if not default_fmt:
+ log_config['format'] = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+
+ if log_file is not None:
+ log_config['filename'] = log_file
+ log_config['encoding'] = 'utf-8'
+
+ logging.basicConfig(**log_config)
+
+
+# https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945
+def _add_logging_level(levelName, levelNum, methodName=None):
+ """
+ Comprehensively adds a new logging level to the `logging` module and the
+ currently configured logging class.
+
+ `levelName` becomes an attribute of the `logging` module with the value
+ `levelNum`. `methodName` becomes a convenience method for both `logging`
+ itself and the class returned by `logging.getLoggerClass()` (usually just
+ `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is
+ used.
+
+ To avoid accidental clobberings of existing attributes, this method will
+ raise an `AttributeError` if the level name is already an attribute of the
+ `logging` module or if the method name is already present
+
+ Example
+ -------
+ >>> addLoggingLevel('TRACE', logging.DEBUG - 5)
+ >>> logging.getLogger(__name__).setLevel("TRACE")
+ >>> logging.getLogger(__name__).trace('that worked')
+ >>> logging.trace('so did this')
+ >>> logging.TRACE
+ 5
+
+ """
+ if not methodName:
+ methodName = levelName.lower()
+
+ if hasattr(logging, levelName):
+ raise AttributeError('{} already defined in logging module'.format(levelName))
+ if hasattr(logging, methodName):
+ raise AttributeError('{} already defined in logging module'.format(methodName))
+ if hasattr(logging.getLoggerClass(), methodName):
+ raise AttributeError('{} already defined in logger class'.format(methodName))
+
+ # This method was inspired by the answers to Stack Overflow post
+ # http://stackoverflow.com/q/2183233/2988730, especially
+ # http://stackoverflow.com/a/13638084/2988730
+ def logForLevel(self, message, *args, **kwargs):
+ if self.isEnabledFor(levelNum):
+ self._log(levelNum, message, args, **kwargs)
+ def logToRoot(message, *args, **kwargs):
+ logging.log(levelNum, message, *args, **kwargs)
+
+ logging.addLevelName(levelNum, levelName)
+ setattr(logging, levelName, levelNum)
+ setattr(logging.getLoggerClass(), methodName, logForLevel)
+ setattr(logging, methodName, logToRoot) \ No newline at end of file
diff --git a/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..dcec9da
--- /dev/null
+++ b/include/py/homekit/database/_base.py
@@ -0,0 +1,9 @@
+import os
+
+
+def get_data_root_directory() -> str:
+ return os.path.join(
+ os.environ['HOME'],
+ '.config',
+ 'homekit',
+ 'data') \ No newline at end of file
diff --git a/include/py/homekit/database/bots.py b/include/py/homekit/database/bots.py
new file mode 100644
index 0000000..fb5f326
--- /dev/null
+++ b/include/py/homekit/database/bots.py
@@ -0,0 +1,96 @@
+import pytz
+
+from .mysql import mysql_now, MySQLDatabase, datetime_fmt
+from ..api.types import (
+ SoundSensorLocation
+)
+from typing import Optional, List, Tuple
+from datetime import datetime
+from html import escape
+
+
+class OpenwrtLogRecord:
+ id: int
+ log_time: datetime
+ received_time: datetime
+ text: str
+
+ def __init__(self, id, text, log_time, received_time):
+ self.id = id
+ self.text = text
+ self.log_time = log_time
+ self.received_time = received_time
+
+ def __repr__(self):
+ return f"<b>{self.log_time.strftime('%H:%M:%S')}</b> {escape(self.text)}"
+
+
+class BotsDatabase(MySQLDatabase):
+ def add_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..1651a93
--- /dev/null
+++ b/include/py/homekit/database/sqlite.py
@@ -0,0 +1,70 @@
+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, path=None, check_same_thread=False):
+ if not path:
+ if not name:
+ name = config.app_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))
+
+ 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..5955d92
--- /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.app_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'<b>\1:</b> \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..47ee9ae
--- /dev/null
+++ b/include/py/homekit/mqtt/_mqtt.py
@@ -0,0 +1,87 @@
+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..a0e7a1f
--- /dev/null
+++ b/include/py/homekit/pio/products.py
@@ -0,0 +1,115 @@
+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__),
+ '..', '..', '..', '..',
+ 'pio'
+)
+
+
+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 =\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:
+ 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../../include/pio/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..4fbbf28
--- /dev/null
+++ b/include/py/homekit/telegram/_botutil.py
@@ -0,0 +1,30 @@
+import logging
+import traceback
+
+from html import escape
+from telegram import User
+
+_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
+
+
+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..f5f620a
--- /dev/null
+++ b/include/py/homekit/telegram/bot.py
@@ -0,0 +1,574 @@
+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 ._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
+
+_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
+_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
+
+ async 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 await 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')])
+
+ 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 = []
+ 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()
+ await 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)
+
+
+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 notify_user_ids:
+ if user_id in exclude:
+ continue
+
+ text = text_getter(db.get_user_lang(user_id))
+ await _application.bot.send_message(chat_id=user_id,
+ text=text,
+ parse_mode='HTML')
+
+
+async def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> None:
+ if isinstance(text, Exception):
+ text = exc2text(text)
+ await _application.bot.send_message(chat_id=user_id,
+ text=text,
+ parse_mode='HTML',
+ **kwargs)
+
+
+async def send_photo(user_id, **kwargs):
+ await _application.bot.send_photo(chat_id=user_id, **kwargs)
+
+
+async def send_audio(user_id, **kwargs):
+ await _application.bot.send_audio(chat_id=user_id, **kwargs)
+
+
+async def send_file(user_id, **kwargs):
+ await _application.bot.send_document(chat_id=user_id, **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)
+
+
+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):
+ 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..5f41008
--- /dev/null
+++ b/include/py/homekit/telegram/config.py
@@ -0,0 +1,78 @@
+from ..config import ConfigUnit
+from typing import Optional, Union
+from abc import ABC
+from enum import Enum
+
+
+class TelegramUserListType(Enum):
+ USERS = 'users'
+ NOTIFY = 'notify_users'
+
+
+class TelegramUserIdsConfig(ConfigUnit):
+ NAME = 'telegram_user_ids'
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'roottype': 'dict',
+ 'type': 'integer'
+ }
+
+
+_user_ids_config = TelegramUserIdsConfig()
+
+
+def _user_id_mapper(user: Union[str, int]) -> int:
+ if isinstance(user, int):
+ return user
+ return _user_ids_config[user]
+
+
+class TelegramChatsConfig(ConfigUnit):
+ NAME = 'telegram_chats'
+
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'type': 'dict',
+ 'schema': {
+ 'id': {'type': 'string', 'required': True},
+ 'token': {'type': 'string', 'required': True},
+ }
+ }
+
+
+class TelegramBotConfig(ConfigUnit, ABC):
+ @classmethod
+ def schema(cls) -> Optional[dict]:
+ return {
+ 'bot': {
+ 'type': 'dict',
+ 'schema': {
+ 'token': {'type': 'string', 'required': True},
+ TelegramUserListType.USERS.value: {**TelegramBotConfig._userlist_schema(), 'required': True},
+ TelegramUserListType.NOTIFY.value: TelegramBotConfig._userlist_schema(),
+ }
+ }
+ }
+
+ @staticmethod
+ def _userlist_schema() -> dict:
+ return {'type': 'list', 'schema': {'type': ['string', 'integer']}}
+
+ @staticmethod
+ def custom_validator(data):
+ for ult in TelegramUserListType:
+ users = data['bot'][ult.value]
+ for user in users:
+ if isinstance(user, str):
+ if user not in _user_ids_config:
+ raise ValueError(f'user {user} not found in {TelegramUserIdsConfig.NAME}')
+
+ def get_user_ids(self,
+ ult: TelegramUserListType = TelegramUserListType.USERS) -> list[int]:
+ try:
+ return list(map(_user_id_mapper, self['bot'][ult.value]))
+ except KeyError:
+ return [] \ No newline at end of file
diff --git a/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..22bba86
--- /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
--- /dev/null
+++ b/include/py/pyA20/__init__.pyi
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
--- /dev/null
+++ b/include/py/pyA20/port.pyi
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('<BBH', buf)
+ return FrameHead(seq, FrameType(ft), length)
+
+ def __init__(self,
+ seq: Optional[int],
+ frame_type: FrameType,
+ length: Optional[int] = None):
+ self.seq = seq
+ self.type = frame_type
+ self.length = length or 0
+
+ def pack(self) -> bytes:
+ assert self.length != 0, "FrameHead.length has not been set"
+ assert self.seq is not None, "FrameHead.seq has not been set"
+ return struct.pack('<BBH', self.seq, self.type.value, self.length)
+
+
+class FrameItem:
+ head: FrameHead
+ payload: bytes
+
+ def __init__(self, head: FrameHead, payload: Optional[bytes] = None):
+ self.head = head
+ self.payload = payload
+
+ def setpayload(self, payload: Union[bytes, bytearray]):
+ if isinstance(payload, bytearray):
+ payload = bytes(payload)
+ self.payload = payload
+ self.head.length = len(payload)
+
+ def pack(self) -> bytes:
+ ba = bytearray(self.head.pack())
+ ba.extend(self.payload)
+ return bytes(ba)
+
+
+# high-level wrappers around FrameItem
+# ------------------------------------
+
+class MessagePhase(Enum):
+ WAITING = 0
+ SENT = 1
+ DONE = 2
+
+
+class Message:
+ frame: Optional[FrameItem]
+ id: int
+
+ _global_id = 0
+
+ def __init__(self):
+ self.frame = None
+
+ # global internal message id, only useful for debugging purposes
+ self.id = self.next_id()
+
+ def __repr__(self):
+ return f'<{self.__class__.__name__} id={self.id} seq={self.frame.head.seq}>'
+
+ @staticmethod
+ def next_id():
+ _id = Message._global_id
+ Message._global_id = (Message._global_id + 1) % 100000
+ return _id
+
+ @staticmethod
+ def from_encrypted(buf: bytes, inkey: bytes, outkey: bytes) -> Message:
+ _logger.debug(f'Message:from_encrypted: buf={buf.hex()}')
+
+ assert len(buf) >= 4, 'invalid size'
+ head = FrameHead.from_bytes(buf[:4])
+
+ assert len(buf) == head.length + 4, f'invalid buf size ({len(buf)} != {head.length})'
+ payload = buf[4:]
+ b = head.seq
+
+ j = b & 0xF
+ k = b >> 4 & 0xF
+
+ key = bytearray(len(inkey))
+ arraycopy(inkey, j, key, 0, len(inkey) - j)
+ arraycopy(inkey, 0, key, len(inkey) - j, j)
+
+ iv = bytearray(len(outkey))
+ arraycopy(outkey, k, iv, 0, len(outkey) - k)
+ arraycopy(outkey, 0, iv, len(outkey) - k, k)
+
+ cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv))
+ decryptor = cipher.decryptor()
+ decrypted_data = decryptor.update(payload) + decryptor.finalize()
+
+ unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
+ decrypted_data = unpadder.update(decrypted_data)
+ decrypted_data += unpadder.finalize()
+
+ assert len(decrypted_data) != 0, 'decrypted data is null'
+ assert head.seq == decrypted_data[0], f'decrypted seq mismatch {head.seq} != {decrypted_data[0]}'
+
+ # _logger.debug('Message.from_encrypted: plaintext: '+decrypted_data.hex())
+
+ if head.type == FrameType.ACK:
+ return AckMessage(head.seq)
+
+ elif head.type == FrameType.NAK:
+ return NakMessage(head.seq)
+
+ elif head.type == FrameType.AUX:
+ # TODO implement AUX
+ raise NotImplementedError('FrameType AUX is not yet implemented')
+
+ elif head.type == FrameType.CMD:
+ type = decrypted_data[1]
+ data = decrypted_data[2:]
+
+ cl = UnknownMessage
+
+ subclasses = [cl for cl in CmdIncomingMessage.__subclasses__() if cl is not SimpleBooleanMessage]
+ subclasses.extend(SimpleBooleanMessage.__subclasses__())
+
+ for _cl in subclasses:
+ # `UnknownMessage` is a special class that holds a packed command that we don't recognize.
+ # It will be used anyway if we don't find a match, so skip it here
+ if _cl == UnknownMessage:
+ continue
+
+ if _cl.TYPE == type:
+ cl = _cl
+ break
+
+ m = cl.from_packed_data(data, seq=head.seq)
+ if isinstance(m, UnknownMessage):
+ m.set_type(type)
+ return m
+
+ else:
+ raise NotImplementedError(f'Unexpected frame type: {head.type}')
+
+ def pack_data(self) -> bytes:
+ return b''
+
+ @property
+ def seq(self) -> Union[int, None]:
+ try:
+ return self.frame.head.seq
+ except:
+ return None
+
+ @seq.setter
+ def seq(self, seq: int):
+ self.frame.head.seq = seq
+
+ def encrypt(self, outkey: bytes, inkey: bytes, token: bytes, pubkey: bytes):
+ assert self.frame is not None
+
+ data = self._get_data_to_encrypt()
+ assert data is not None
+
+ b = self.frame.head.seq
+ i = b & 0xf
+ j = b >> 4 & 0xf
+
+ outkey = bytearray(outkey)
+
+ l = len(outkey)
+ key = bytearray(l)
+
+ arraycopy(outkey, i, key, 0, l-i)
+ arraycopy(outkey, 0, key, l-i, i)
+
+ inkey = bytearray(inkey)
+
+ l = len(inkey)
+ iv = bytearray(l)
+
+ arraycopy(inkey, j, iv, 0, l-j)
+ arraycopy(inkey, 0, iv, l-j, j)
+
+ cipher = ciphers.Cipher(algorithms.AES(key), modes.CBC(iv))
+ encryptor = cipher.encryptor()
+
+ newdata = bytearray(len(data)+1)
+ newdata[0] = b
+
+ arraycopy(data, 0, newdata, 1, len(data))
+
+ newdata = bytes(newdata)
+ _logger.debug('frame payload to be encrypted: ' + newdata.hex())
+
+ padder = padding.PKCS7(algorithms.AES.block_size).padder()
+ ciphertext = bytearray()
+ ciphertext.extend(encryptor.update(padder.update(newdata) + padder.finalize()))
+ ciphertext.extend(encryptor.finalize())
+
+ self.frame.setpayload(ciphertext)
+
+ def _get_data_to_encrypt(self) -> bytes:
+ return self.pack_data()
+
+
+class AckMessage(Message, ABC):
+ def __init__(self, seq: Optional[int] = None):
+ super().__init__()
+ self.frame = FrameItem(FrameHead(seq, FrameType.ACK, None))
+
+
+class NakMessage(Message, ABC):
+ def __init__(self, seq: Optional[int] = None):
+ super().__init__()
+ self.frame = FrameItem(FrameHead(seq, FrameType.NAK, None))
+
+
+class CmdMessage(Message):
+ type: Optional[int]
+ data: bytes
+
+ TYPE = None
+
+ def _get_data_to_encrypt(self) -> bytes:
+ buf = bytearray()
+ buf.append(self.get_type())
+ buf.extend(self.pack_data())
+ return bytes(buf)
+
+ def __init__(self, seq: Optional[int] = None):
+ super().__init__()
+ self.frame = FrameItem(FrameHead(seq, FrameType.CMD))
+ self.data = b''
+
+ def _repr_fields(self) -> ReprDict:
+ return {
+ 'cmd': self.get_type()
+ }
+
+ def __repr__(self):
+ params = [
+ __name__+'.'+self.__class__.__name__,
+ f'id={self.id}',
+ f'seq={self.seq}'
+ ]
+ fields = self._repr_fields()
+ if fields:
+ for k, v in fields.items():
+ params.append(f'{k}={v}')
+ elif self.data:
+ params.append(f'data={self.data.hex()}')
+ return '<'+' '.join(params)+'>'
+
+ def get_type(self) -> int:
+ return self.__class__.TYPE
+
+
+class CmdIncomingMessage(CmdMessage):
+ @staticmethod
+ @abstractmethod
+ def from_packed_data(cls, data: bytes, seq: Optional[int] = None):
+ pass
+
+ @abstractmethod
+ def _repr_fields(self) -> ReprDict:
+ pass
+
+
+class CmdOutgoingMessage(CmdMessage):
+ @abstractmethod
+ def pack_data(self) -> bytes:
+ return b''
+
+
+class ModeMessage(CmdOutgoingMessage, CmdIncomingMessage):
+ TYPE = 1
+
+ pt: PowerType
+
+ def __init__(self, power_type: PowerType, seq: Optional[int] = None):
+ super().__init__(seq)
+ self.pt = power_type
+
+ @classmethod
+ def from_packed_data(cls, data: bytes, seq=0) -> ModeMessage:
+ assert len(data) == 1, 'data size expected to be 1'
+ mode, = struct.unpack('B', data)
+ return ModeMessage(PowerType(mode), seq=seq)
+
+ def pack_data(self) -> bytes:
+ return self.pt.value.to_bytes(1, byteorder='little')
+
+ def _repr_fields(self) -> ReprDict:
+ return {'mode': self.pt.name}
+
+
+class TargetTemperatureMessage(CmdOutgoingMessage, CmdIncomingMessage):
+ temperature: int
+
+ TYPE = 2
+
+ def __init__(self, temp: int, seq: Optional[int] = None):
+ super().__init__(seq)
+ self.temperature = temp
+
+ @classmethod
+ def from_packed_data(cls, data: bytes, seq=0) -> TargetTemperatureMessage:
+ assert len(data) == 2, 'data size expected to be 2'
+ nat, frac = struct.unpack('BB', data)
+ temp = int(nat + (frac / 100))
+ return TargetTemperatureMessage(temp, seq=seq)
+
+ def pack_data(self) -> bytes:
+ return bytes([self.temperature, 0])
+
+ def _repr_fields(self) -> ReprDict:
+ return {'temperature': self.temperature}
+
+
+class PingMessage(CmdIncomingMessage, CmdOutgoingMessage):
+ TYPE = 255
+
+ @classmethod
+ def from_packed_data(cls, data: bytes, seq=0) -> PingMessage:
+ assert len(data) == 0, 'no data expected'
+ return PingMessage(seq=seq)
+
+ def pack_data(self) -> bytes:
+ return b''
+
+ def _repr_fields(self) -> ReprDict:
+ return {}
+
+
+# This is the first protocol message. Sent by a client.
+# Kettle usually ACKs this, but sometimes i don't get any ACK and the very next message is HandshakeResponseMessage.
+class HandshakeMessage(CmdMessage):
+ TYPE = 0
+
+ def encrypt(self,
+ outkey: bytes,
+ inkey: bytes,
+ token: bytes,
+ pubkey: bytes):
+ cipher = ciphers.Cipher(algorithms.AES(outkey), modes.CBC(inkey))
+ encryptor = cipher.encryptor()
+
+ ciphertext = bytearray()
+ ciphertext.extend(encryptor.update(token))
+ ciphertext.extend(encryptor.finalize())
+
+ pld = bytearray()
+ pld.append(0)
+ pld.extend(pubkey)
+ pld.extend(ciphertext)
+
+ self.frame.setpayload(pld)
+
+
+# Kettle either sends this right after the handshake, of first it ACKs the handshake then sends this.
+class HandshakeResponseMessage(CmdIncomingMessage):
+ TYPE = 0
+
+ protocol: int
+ fw_major: int
+ fw_minor: int
+ mode: int
+ token: bytes
+
+ def __init__(self,
+ protocol: int,
+ fw_major: int,
+ fw_minor: int,
+ mode: int,
+ token: bytes,
+ seq: Optional[int] = None):
+ super().__init__(seq)
+ self.protocol = protocol
+ self.fw_major = fw_major
+ self.fw_minor = fw_minor
+ self.mode = mode
+ self.token = token
+
+ @classmethod
+ def from_packed_data(cls, data: bytes, seq=0) -> HandshakeResponseMessage:
+ protocol, fw_major, fw_minor, mode = struct.unpack('<HBBB', data[:5])
+ return HandshakeResponseMessage(protocol, fw_major, fw_minor, mode, token=data[5:], seq=seq)
+
+ def _repr_fields(self) -> ReprDict:
+ return {
+ 'protocol': self.protocol,
+ 'fw': f'{self.fw_major}.{self.fw_minor}',
+ 'mode': self.mode,
+ 'token': self.token.hex()
+ }
+
+
+# Apparently, some hardware info.
+# On the other hand, if you look at com.syncleiot.iottransport.commands.CmdHardware, its mqtt topic says "mcu_firmware".
+# My device returns 1.1.1. The kettle uses on ESP8266 ESP-12F MCU under the hood (or, more precisely, under a piece of
+# cheap plastic), so maybe 1.1.1 is some MCU ROM version.
+class DeviceHardwareMessage(CmdIncomingMessage):
+ TYPE = 143 # -113
+
+ hw: List[int]
+
+ def __init__(self, hw: List[int], seq: Optional[int] = None):
+ super().__init__(seq)
+ self.hw = hw
+
+ @classmethod
+ def from_packed_data(cls, data: bytes, seq=0) -> DeviceHardwareMessage:
+ assert len(data) == 3, 'invalid data size, expected 3'
+ hw = list(struct.unpack('<BBB', data))
+ return DeviceHardwareMessage(hw, seq=seq)
+
+ def _repr_fields(self) -> ReprDict:
+ return {'device_hardware': '.'.join(map(str, self.hw))}
+
+
+# This message is sent by kettle right after the HandshakeMessageResponse.
+# The diagnostic data is supposed to be sent to vendor, which we, obviously, not going to do.
+# So just ACK and skip it.
+class DeviceDiagnosticMessage(CmdIncomingMessage):
+ TYPE = 145 # -111
+
+ diag_data: bytes
+
+ def __init__(self, diag_data: bytes, seq: Optional[int] = None):
+ super().__init__(seq)
+ self.diag_data = diag_data
+
+ @classmethod
+ def from_packed_data(cls, data: bytes, seq=0) -> DeviceDiagnosticMessage:
+ return DeviceDiagnosticMessage(diag_data=data, seq=seq)
+
+ def _repr_fields(self) -> ReprDict:
+ return {'diag_data': self.diag_data.hex()}
+
+
+class SimpleBooleanMessage(ABC, CmdIncomingMessage):
+ value: bool
+
+ def __init__(self, value: bool, seq: Optional[int] = None):
+ super().__init__(seq)
+ self.value = value
+
+ @classmethod
+ def from_packed_data(cls, data: bytes, seq: Optional[int] = None):
+ assert len(data) == 1, 'invalid data size, expected 1'
+ enabled, = struct.unpack('<B', data)
+ return cls(value=enabled == 1, seq=seq)
+
+ @abstractmethod
+ def _repr_fields(self) -> ReprDict:
+ pass
+
+
+class AccessControlMessage(SimpleBooleanMessage):
+ TYPE = 133 # -123
+
+ def _repr_fields(self) -> ReprDict:
+ return {'acl_enabled': self.value}
+
+
+class ErrorMessage(SimpleBooleanMessage):
+ TYPE = 7
+
+ def _repr_fields(self) -> ReprDict:
+ return {'error': self.value}
+
+
+class ChildLockMessage(SimpleBooleanMessage):
+ TYPE = 30
+
+ def _repr_fields(self) -> ReprDict:
+ return {'child_lock': self.value}
+
+
+class VolumeMessage(SimpleBooleanMessage):
+ TYPE = 9
+
+ def _repr_fields(self) -> ReprDict:
+ return {'volume': self.value}
+
+
+class BacklightMessage(SimpleBooleanMessage):
+ TYPE = 28
+
+ def _repr_fields(self) -> ReprDict:
+ return {'backlight': self.value}
+
+
+class CurrentTemperatureMessage(CmdIncomingMessage):
+ TYPE = 20
+
+ current_temperature: int
+
+ def __init__(self, temp: int, seq: Optional[int] = None):
+ super().__init__(seq)
+ self.current_temperature = temp
+
+ @classmethod
+ def from_packed_data(cls, data: bytes, seq=0) -> CurrentTemperatureMessage:
+ assert len(data) == 2, 'data size expected to be 2'
+ nat, frac = struct.unpack('BB', data)
+ temp = int(nat + (frac / 100))
+ return CurrentTemperatureMessage(temp, seq=seq)
+
+ def pack_data(self) -> bytes:
+ return bytes([self.current_temperature, 0])
+
+ def _repr_fields(self) -> ReprDict:
+ return {'current_temperature': self.current_temperature}
+
+
+class UnknownMessage(CmdIncomingMessage):
+ type: Optional[int]
+ data: bytes
+
+ def __init__(self, data: bytes, **kwargs):
+ super().__init__(**kwargs)
+ self.type = None
+ self.data = data
+
+ @classmethod
+ def from_packed_data(cls, data: bytes, seq=0) -> UnknownMessage:
+ return UnknownMessage(data, seq=seq)
+
+ def set_type(self, type: int):
+ self.type = type
+
+ def get_type(self) -> int:
+ return self.type
+
+ def _repr_fields(self) -> ReprDict:
+ return {
+ 'type': self.type,
+ 'data': self.data.hex()
+ }
+
+
+class WrappedMessage:
+ _message: Message
+ _handler: Optional[callable]
+ _validator: Optional[callable]
+ _logger: Optional[logging.Logger]
+ _phase: MessagePhase
+ _phase_update_time: float
+
+ def __init__(self,
+ message: Message,
+ handler: Optional[callable] = None,
+ validator: Optional[callable] = None,
+ ack=False):
+ self._message = message
+ self._handler = handler
+ self._validator = validator
+ self._logger = None
+ self._phase = MessagePhase.WAITING
+ self._phase_update_time = 0
+ if not validator and ack:
+ self._validator = lambda m: isinstance(m, AckMessage)
+
+ def setlogger(self, logger: logging.Logger):
+ self._logger = logger
+
+ def validate(self, message: Message):
+ if not self._validator:
+ return True
+ return self._validator(message)
+
+ def call(self, *args, error_message: str = None) -> None:
+ if not self._handler:
+ return
+ try:
+ self._handler(*args)
+ except Exception as exc:
+ logger = self._logger or logging.getLogger(self.__class__.__name__)
+ logger.error(f'{error_message}, see exception below:')
+ logger.exception(exc)
+
+ @property
+ def phase(self) -> MessagePhase:
+ return self._phase
+
+ @phase.setter
+ def phase(self, phase: MessagePhase):
+ self._phase = phase
+ self._phase_update_time = 0 if phase == MessagePhase.WAITING else time.time()
+
+ @property
+ def phase_update_time(self) -> float:
+ return self._phase_update_time
+
+ @property
+ def message(self) -> Message:
+ return self._message
+
+ @property
+ def id(self) -> int:
+ return self._message.id
+
+ @property
+ def seq(self) -> int:
+ return self._message.seq
+
+ @seq.setter
+ def seq(self, seq: int):
+ self._message.seq = seq
+
+ def __repr__(self):
+ return f'<{__name__}.{self.__class__.__name__} message={self._message.__repr__()}>'
+
+
+# Connection stuff
+# Well, strictly speaking, as it's UDP, there's no connection, but who cares.
+# ---------------------------------------------------------------------------
+
+class IncomingMessageListener:
+ @abstractmethod
+ def incoming_message(self, message: Message) -> Optional[Message]:
+ pass
+
+
+class ConnectionStatus(Enum):
+ NOT_CONNECTED = auto()
+ CONNECTING = auto()
+ CONNECTED = auto()
+ RECONNECTING = auto()
+ DISCONNECTED = auto()
+
+
+class ConnectionStatusListener:
+ @abstractmethod
+ def connection_status_updated(self, status: ConnectionStatus):
+ pass
+
+
+class UDPConnection(threading.Thread, ConnectionStatusListener):
+ inseq: int
+ outseq: int
+ source_port: int
+ device_addr: str
+ device_port: int
+ device_token: bytes
+ device_pubkey: bytes
+ interrupted: bool
+ response_handlers: Dict[int, WrappedMessage]
+ outgoing_queue: List[WrappedMessage]
+ pubkey: Optional[bytes]
+ encinkey: Optional[bytes]
+ encoutkey: Optional[bytes]
+ inc_listeners: List[IncomingMessageListener]
+ conn_listeners: List[ConnectionStatusListener]
+ outgoing_time: float
+ outgoing_time_1st: float
+ incoming_time: float
+ status: ConnectionStatus
+ reconnect_tries: int
+ read_timeout: int
+
+ _addr_lock: threading.Lock
+ _iml_lock: threading.Lock
+ _csl_lock: threading.Lock
+ _st_lock: threading.Lock
+
+ def __init__(self,
+ addr: Union[IPv4Address, IPv6Address],
+ port: int,
+ device_pubkey: bytes,
+ device_token: bytes,
+ read_timeout: int = 1):
+ super().__init__()
+ self._logger = logging.getLogger(f'{__name__}.{self.__class__.__name__} <{hex(id(self))}>')
+ self.setName(self.__class__.__name__)
+
+ self.inseq = 0
+ self.outseq = 0
+ self.source_port = random.randint(1024, 65535)
+ self.device_addr = str(addr)
+ self.device_port = port
+ self.device_token = device_token
+ self.device_pubkey = device_pubkey
+ self.outgoing_queue = []
+ self.response_handlers = {}
+ self.interrupted = False
+ self.outgoing_time = 0
+ self.outgoing_time_1st = 0
+ self.incoming_time = 0
+ self.inc_listeners = []
+ self.conn_listeners = [self]
+ self.status = ConnectionStatus.NOT_CONNECTED
+ self.reconnect_tries = 0
+ self.read_timeout = read_timeout
+
+ self._iml_lock = threading.Lock()
+ self._csl_lock = threading.Lock()
+ self._addr_lock = threading.Lock()
+ self._st_lock = threading.Lock()
+
+ self.pubkey = None
+ self.encinkey = None
+ self.encoutkey = None
+
+ def connection_status_updated(self, status: ConnectionStatus):
+ # self._logger.info(f'connection_status_updated: status = {status}')
+ with self._st_lock:
+ # self._logger.debug(f'connection_status_updated: lock acquired')
+ self.status = status
+ if status == ConnectionStatus.RECONNECTING:
+ self.reconnect_tries += 1
+ if status in (ConnectionStatus.CONNECTED, ConnectionStatus.NOT_CONNECTED, ConnectionStatus.DISCONNECTED):
+ self.reconnect_tries = 0
+
+ def _cleanup(self):
+ # erase outgoing queue
+ for wm in self.outgoing_queue:
+ wm.call(False,
+ error_message=f'_cleanup: exception while calling cb(False) on message {wm.message}')
+ self.outgoing_queue = []
+ self.response_handlers = {}
+
+ # reset timestamps
+ self.incoming_time = 0
+ self.outgoing_time = 0
+ self.outgoing_time_1st = 0
+
+ self._logger.debug('_cleanup: done')
+
+ def set_address(self, addr: Union[IPv4Address, IPv6Address], port: int):
+ with self._addr_lock:
+ if self.device_addr != str(addr) or self.device_port != port:
+ self.device_addr = str(addr)
+ self.device_port = port
+ self._logger.info(f'updated device network address: {self.device_addr}:{self.device_port}')
+
+ def set_device_pubkey(self, pubkey: bytes):
+ if self.device_pubkey.hex() != pubkey.hex():
+ self._logger.info(f'device pubkey has changed (old={self.device_pubkey.hex()}, new={pubkey.hex()})')
+ self.device_pubkey = pubkey
+ self._notify_cs(ConnectionStatus.RECONNECTING)
+
+ def get_address(self) -> Tuple[str, int]:
+ with self._addr_lock:
+ return self.device_addr, self.device_port
+
+ def add_incoming_message_listener(self, listener: IncomingMessageListener):
+ with self._iml_lock:
+ if listener not in self.inc_listeners:
+ self.inc_listeners.append(listener)
+
+ def add_connection_status_listener(self, listener: ConnectionStatusListener):
+ with self._csl_lock:
+ if listener not in self.conn_listeners:
+ self.conn_listeners.append(listener)
+
+ def _notify_cs(self, status: ConnectionStatus):
+ # self._logger.debug(f'_notify_cs: status={status}')
+ with self._csl_lock:
+ for obj in self.conn_listeners:
+ # self._logger.debug(f'_notify_cs: notifying {obj}')
+ obj.connection_status_updated(status)
+
+ def _prepare_keys(self):
+ # generate key pair
+ privkey = X25519PrivateKey.generate()
+
+ self.pubkey = bytes(reversed(privkey.public_key().public_bytes(encoding=srlz.Encoding.Raw,
+ format=srlz.PublicFormat.Raw)))
+
+ # generate shared key
+ device_pubkey = X25519PublicKey.from_public_bytes(
+ bytes(reversed(self.device_pubkey))
+ )
+ shared_key = bytes(reversed(
+ privkey.exchange(device_pubkey)
+ ))
+
+ # in/out encryption keys
+ digest = hashes.Hash(hashes.SHA256())
+ digest.update(shared_key)
+
+ shared_sha256 = digest.finalize()
+
+ self.encinkey = shared_sha256[:16]
+ self.encoutkey = shared_sha256[16:]
+
+ self._logger.info('encryption keys have been created')
+
+ def _handshake_callback(self, r: MessageResponse):
+ # if got error for our HandshakeMessage, reset everything and try again
+ if r is False:
+ # self._logger.debug('_handshake_callback: set status=RECONNETING')
+ self._notify_cs(ConnectionStatus.RECONNECTING)
+ else:
+ # self._logger.debug('_handshake_callback: set status=CONNECTED')
+ self._notify_cs(ConnectionStatus.CONNECTED)
+
+ def run(self):
+ self._logger.info('starting server loop')
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.bind(('0.0.0.0', self.source_port))
+ sock.settimeout(self.read_timeout)
+
+ while not self.interrupted:
+ with self._st_lock:
+ status = self.status
+
+ if status in (ConnectionStatus.DISCONNECTED, ConnectionStatus.RECONNECTING):
+ self._cleanup()
+ if status == ConnectionStatus.DISCONNECTED:
+ break
+
+ # no activity for some time means connection is broken
+ fail = False
+ fail_path = 0
+ if self.incoming_time > 0 and time.time() - self.incoming_time >= DISCONNECT_TIMEOUT:
+ fail = True
+ fail_path = 1
+ elif self.outgoing_time_1st > 0 and self.incoming_time == 0 and time.time() - self.outgoing_time_1st >= DISCONNECT_TIMEOUT:
+ fail = True
+ fail_path = 2
+
+ if fail:
+ self._logger.debug(f'run: setting status=RECONNECTING because of long inactivity, fail_path={fail_path}')
+ self._notify_cs(ConnectionStatus.RECONNECTING)
+
+ # establishing a connection
+ if status in (ConnectionStatus.RECONNECTING, ConnectionStatus.NOT_CONNECTED):
+ if status == ConnectionStatus.RECONNECTING and self.reconnect_tries >= 3:
+ self._notify_cs(ConnectionStatus.DISCONNECTED)
+ continue
+
+ self._reset_outseq()
+ self._prepare_keys()
+
+ # shake the imaginary kettle's hand
+ wrapped = WrappedMessage(HandshakeMessage(),
+ handler=self._handshake_callback,
+ validator=lambda m: isinstance(m, (AckMessage, HandshakeResponseMessage)))
+ self.enqueue_message(wrapped, prepend=True)
+ self._notify_cs(ConnectionStatus.CONNECTING)
+
+ # pick next (wrapped) message to send
+ wm = self._get_next_message() # wm means "wrapped message"
+ if wm:
+ one_shot = isinstance(wm.message, (AckMessage, NakMessage))
+
+ if not isinstance(wm.message, (AckMessage, NakMessage)):
+ old_seq = wm.seq
+ wm.seq = self.outseq
+ self._set_response_handler(wm, old_seq=old_seq)
+ elif wm.seq is None:
+ # ack/nak is a response to some incoming message (and it must have the same seqno that incoming
+ # message had)
+ raise RuntimeError(f'run: seq must be set for {wm.__class__.__name__}')
+
+ self._logger.debug(f'run: sending message: {wm.message}, one_shot={one_shot}, phase={wm.phase}')
+ encrypted = False
+ try:
+ wm.message.encrypt(outkey=self.encoutkey, inkey=self.encinkey,
+ token=self.device_token, pubkey=self.pubkey)
+ encrypted = True
+ except ValueError as exc:
+ # handle "ValueError: Invalid padding bytes."
+ self._logger.error('run: failed to encrypt the message.')
+ self._logger.exception(exc)
+
+ if encrypted:
+ buf = wm.message.frame.pack()
+ # self._logger.debug(f'run: raw data to be sent: {buf.hex()}')
+
+ # sending the first time
+ if wm.phase == MessagePhase.WAITING:
+ sock.sendto(buf, self.get_address())
+ # resending
+ elif wm.phase == MessagePhase.SENT:
+ left = RESEND_ATTEMPTS
+ while left > 0:
+ sock.sendto(buf, self.get_address())
+ left -= 1
+ if left > 0:
+ time.sleep(0.05)
+
+ if one_shot or wm.phase == MessagePhase.SENT:
+ wm.phase = MessagePhase.DONE
+ else:
+ wm.phase = MessagePhase.SENT
+
+ now = time.time()
+ self.outgoing_time = now
+ if not self.outgoing_time_1st:
+ self.outgoing_time_1st = now
+
+ # receiving data
+ try:
+ data = sock.recv(4096)
+ self._handle_incoming(data)
+ except (TimeoutError, socket.timeout):
+ pass
+
+ self._logger.info('bye...')
+
+ def _get_next_message(self) -> Optional[WrappedMessage]:
+ message = None
+ lpfx = '_get_next_message:'
+ remove_list = []
+ for wm in self.outgoing_queue:
+ if wm.phase == MessagePhase.DONE:
+ if isinstance(wm.message, (AckMessage, NakMessage, PingMessage)) or time.time() - wm.phase_update_time >= MESSAGE_QUEUE_REMOVE_DELAY:
+ remove_list.append(wm)
+ continue
+ message = wm
+ break
+
+ for wm in remove_list:
+ self._logger.debug(f'{lpfx} rm path: removing id={wm.id} seq={wm.seq}')
+
+ # clear message handler
+ if wm.seq in self.response_handlers:
+ self.response_handlers[wm.seq].call(
+ False, error_message=f'{lpfx} rm path: error while calling callback for seq={wm.seq}')
+ del self.response_handlers[wm.seq]
+
+ # remove from queue
+ try:
+ self.outgoing_queue.remove(wm)
+ except ValueError as exc:
+ self._logger.error(f'{lpfx} rm path: removing from outgoing_queue raised an exception: {str(exc)}')
+
+ # ping pong
+ if not message and self.outgoing_time_1st != 0 and self.status == ConnectionStatus.CONNECTED:
+ now = time.time()
+ out_delta = now - self.outgoing_time
+ in_delta = now - self.incoming_time
+ if max(out_delta, in_delta) > PING_FREQUENCY:
+ self._logger.debug(f'{lpfx} no activity: in for {in_delta:.2f}s, out for {out_delta:.2f}s, time to ping the damn thing')
+ message = WrappedMessage(PingMessage(), ack=True)
+ # add it to outgoing_queue in order to be aggressively resent in future (if needed)
+ self.outgoing_queue.insert(0, message)
+
+ return message
+
+ def _handle_incoming(self, buf: bytes):
+ try:
+ incoming_message = Message.from_encrypted(buf, inkey=self.encinkey, outkey=self.encoutkey)
+ except ValueError as exc:
+ # handle "ValueError: Invalid padding bytes."
+ self._logger.error('_handle_incoming: failed to decrypt incoming frame:')
+ self._logger.exception(exc)
+ return
+
+ self.incoming_time = time.time()
+ seq = incoming_message.seq
+
+ lpfx = f'handle_incoming({incoming_message.id}):'
+ self._logger.debug(f'{lpfx} received: {incoming_message}')
+
+ if isinstance(incoming_message, (AckMessage, NakMessage)):
+ seq_max = self.outseq
+ seq_name = 'outseq'
+ else:
+ seq_max = self.inseq
+ seq_name = 'inseq'
+ self.inseq = seq
+
+ if seq < seq_max < 0xfd:
+ self._logger.debug(f'{lpfx} dropping: seq={seq}, {seq_name}={seq_max}')
+ return
+
+ if seq not in self.response_handlers:
+ self._handle_incoming_cmd(incoming_message)
+ return
+
+ callback_value = None # None means don't call a callback
+ handler = self.response_handlers[seq]
+
+ if handler.validate(incoming_message):
+ self._logger.debug(f'{lpfx} response OK')
+ handler.phase = MessagePhase.DONE
+ callback_value = incoming_message
+ self._incr_outseq()
+ else:
+ self._logger.warning(f'{lpfx} response is INVALID')
+
+ # It seems that we've received an incoming CmdMessage or PingMessage with the same seqno that our outgoing
+ # message had. Bad, but what can I say, this is quick-and-dirty made UDP based protocol and this sort of
+ # shit just happens.
+
+ # (To be fair, maybe my implementation is not perfect either. But hey, what did you expect from a
+ # reverse-engineered re-implementation of custom UDP-based protocol that some noname vendor uses for their
+ # cheap IoT devices? I think _that_ is _the_ definition of shit. At least my implementation is FOSS, which
+ # is more than you'll ever be able to say about them.)
+
+ # All this crapload of code below might not be needed at all, 'cause the protocol uses separate frame seq
+ # numbers for IN and OUT frames and this situation is not highly likely, as Theresa May could argue.
+ # After a handshake, a kettle sends us 10 or so CmdMessages, and then either we continuously ping it every
+ # 3 seconds, or kettle pings us. This in any case widens the gap between inseq and outseq.
+
+ # But! the seqno is only 1 byte in size and once it reaches 0xff, it circles back to zero. And that (plus,
+ # perhaps, some bad luck) gives a chance for a collision.
+
+ if handler.phase == MessagePhase.DONE or isinstance(handler.message, HandshakeMessage):
+ # no more attempts left, returning error back to user
+ # as to handshake, it cannot fail.
+ callback_value = False
+
+ # else:
+ # # try resending the message
+ # handler.phase_reset()
+ # max_seq = self.outseq
+ # wait_remap = {}
+ # for m in self.outgoing_queue:
+ # if m.seq in self.waiting_for_response:
+ # wait_remap[m.seq] = (m.seq+1) % 256
+ # m.set_seq((m.seq+1) % 256)
+ # if m.seq > max_seq:
+ # max_seq = m.seq
+ # if max_seq > self.outseq:
+ # self.outseq = max_seq % 256
+ # if wait_remap:
+ # waiting_new = {}
+ # for old_seq, new_seq in wait_remap.items():
+ # waiting_new[new_seq] = self.waiting_for_response[old_seq]
+ # self.waiting_for_response = waiting_new
+
+ if isinstance(incoming_message, (PingMessage, CmdIncomingMessage)):
+ # handle incoming message as usual, as we need to ack/nak it anyway
+ self._handle_incoming_cmd(incoming_message)
+
+ if callback_value is not None:
+ handler.call(callback_value,
+ error_message=f'{lpfx} error while calling callback for msg id={handler.message.id} seq={seq}')
+ del self.response_handlers[seq]
+
+ def _handle_incoming_cmd(self, incoming_message: Message):
+ if isinstance(incoming_message, (AckMessage, NakMessage)):
+ self._logger.debug(f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): it\'s {incoming_message.__class__.__name__}, ignoring')
+ return
+
+ replied = False
+ with self._iml_lock:
+ for f in self.inc_listeners:
+ retval = safe_callback_call(f.incoming_message, incoming_message,
+ logger=self._logger,
+ error_message=f'_handle_incoming_cmd({incoming_message.id}, seq={incoming_message.seq}): error while calling message listener')
+ if isinstance(retval, Message):
+ if isinstance(retval, (AckMessage, NakMessage)):
+ retval.seq = incoming_message.seq
+ self.enqueue_message(WrappedMessage(retval), prepend=True)
+ replied = True
+ break
+ else:
+ raise RuntimeError('are you sure your response is correct? only ack/nak are allowed')
+
+ if not replied:
+ self.enqueue_message(WrappedMessage(AckMessage(incoming_message.seq)), prepend=True)
+
+ def enqueue_message(self, wrapped: WrappedMessage, prepend=False):
+ self._logger.debug(f'enqueue_message: {wrapped.message}')
+ if not prepend:
+ self.outgoing_queue.append(wrapped)
+ else:
+ self.outgoing_queue.insert(0, wrapped)
+
+ def _set_response_handler(self, wm: WrappedMessage, old_seq=None):
+ if old_seq in self.response_handlers:
+ del self.response_handlers[old_seq]
+
+ seq = wm.seq
+ assert seq is not None, 'seq is not set'
+
+ if seq in self.response_handlers:
+ self._logger.debug(f'_set_response_handler(seq={seq}): handler is already set, cancelling it')
+ self.response_handlers[seq].call(False,
+ error_message=f'_set_response_handler({seq}): error while calling old callback')
+ self.response_handlers[seq] = wm
+
+ def _incr_outseq(self) -> None:
+ self.outseq = (self.outseq + 1) % 256
+
+ def _reset_outseq(self):
+ self.outseq = 0
+ self._logger.debug(f'_reset_outseq: set 0')
+
+
+MessageResponse = Union[Message, bool]