summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--package.json14
-rw-r--r--platformio/relayctl/.gitignore3
-rw-r--r--platformio/relayctl/CMakeLists.txt33
-rwxr-xr-xplatformio/relayctl/make_static.sh87
-rw-r--r--platformio/relayctl/platformio.ini20
-rw-r--r--platformio/relayctl/src/config.cpp88
-rw-r--r--platformio/relayctl/src/config.def.h.example32
-rw-r--r--platformio/relayctl/src/config.h34
-rw-r--r--platformio/relayctl/src/http_server.cpp249
-rw-r--r--platformio/relayctl/src/http_server.h37
-rw-r--r--platformio/relayctl/src/led.cpp20
-rw-r--r--platformio/relayctl/src/led.h24
-rw-r--r--platformio/relayctl/src/logging.cpp37
-rw-r--r--platformio/relayctl/src/logging.h24
-rw-r--r--platformio/relayctl/src/main.cpp170
-rw-r--r--platformio/relayctl/src/mqtt.cpp172
-rw-r--r--platformio/relayctl/src/mqtt.h57
-rw-r--r--platformio/relayctl/src/relay.h24
-rw-r--r--platformio/relayctl/src/static.cpp276
-rw-r--r--platformio/relayctl/src/static.h21
-rw-r--r--platformio/relayctl/src/stopwatch.h30
-rw-r--r--platformio/relayctl/src/wifi.cpp49
-rw-r--r--platformio/relayctl/src/wifi.h35
-rw-r--r--platformio/relayctl/static/app.js222
-rw-r--r--platformio/relayctl/static/favicon.icobin0 -> 7886 bytes
-rw-r--r--platformio/relayctl/static/index.html62
-rw-r--r--platformio/relayctl/static/style.css85
-rwxr-xr-xtools/minify.js64
-rw-r--r--yarn.lock180
30 files changed, 2154 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index c753c3a..d22a6ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,17 @@
.idea
/venv
+/node_modules
*.pyc
+config.def.h
__pycache__
.DS_Store
/src/test/test_inverter_monitor.log
/youtrack-certificate
/cpp
/esp32-cam/CameraWebServer/wifi_password.h
+cmake-build-*
+.pio
+CMakeListsPrivate.txt
*.swp
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..16706ba
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "homekit",
+ "version": "1.0.0",
+ "main": "index.js",
+ "repository": "git@ch1p.io:homekit.git",
+ "author": "Evgeny Zinoviev <me@ch1p.io>",
+ "license": "MIT",
+ "devDependencies": {
+ "clean-css": "^5.3.1",
+ "html-minifier-terser": "^7.1.0",
+ "minimist": "^1.2.7",
+ "terser": "^5.16.1"
+ }
+}
diff --git a/platformio/relayctl/.gitignore b/platformio/relayctl/.gitignore
new file mode 100644
index 0000000..3fe18ad
--- /dev/null
+++ b/platformio/relayctl/.gitignore
@@ -0,0 +1,3 @@
+.pio
+CMakeListsPrivate.txt
+cmake-build-*/
diff --git a/platformio/relayctl/CMakeLists.txt b/platformio/relayctl/CMakeLists.txt
new file mode 100644
index 0000000..cbc0a64
--- /dev/null
+++ b/platformio/relayctl/CMakeLists.txt
@@ -0,0 +1,33 @@
+# !!! WARNING !!! AUTO-GENERATED FILE, PLEASE DO NOT MODIFY IT AND USE
+# https://docs.platformio.org/page/projectconf/section_env_build.html#build-flags
+#
+# If you need to override existing CMake configuration or add extra,
+# please create `CMakeListsUser.txt` in the root of project.
+# The `CMakeListsUser.txt` will not be overwritten by PlatformIO.
+
+cmake_minimum_required(VERSION 3.13)
+set(CMAKE_SYSTEM_NAME Generic)
+set(CMAKE_C_COMPILER_WORKS 1)
+set(CMAKE_CXX_COMPILER_WORKS 1)
+
+project("relayctl" C CXX)
+
+include(CMakeListsPrivate.txt)
+
+if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/CMakeListsUser.txt)
+include(CMakeListsUser.txt)
+endif()
+
+add_custom_target(
+ Production ALL
+ COMMAND platformio -c clion run "$<$<NOT:$<CONFIG:All>>:-e${CMAKE_BUILD_TYPE}>"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+add_custom_target(
+ Debug ALL
+ COMMAND platformio -c clion debug "$<$<NOT:$<CONFIG:All>>:-e${CMAKE_BUILD_TYPE}>"
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+add_executable(Z_DUMMY_TARGET ${SRC_LIST})
diff --git a/platformio/relayctl/make_static.sh b/platformio/relayctl/make_static.sh
new file mode 100755
index 0000000..079d26b
--- /dev/null
+++ b/platformio/relayctl/make_static.sh
@@ -0,0 +1,87 @@
+#!/bin/bash
+
+#set -x
+#set -e
+
+DIR="$(dirname "$(realpath "$0")")"
+
+fw_version="$(cat "$DIR/src/config.def.h" | grep "^#define FW_VERSION" | awk '{print $3}')"
+header="$DIR/src/static.h"
+source="$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 "$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 "$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/^/ /'
+
+ echo "};"
+ echo "const StaticFile $filename PROGMEM = {(sizeof(${filename}_content)/sizeof(${filename}_content[0])), ${filename}_content};"
+ echo ""
+ } >> "$source"
+ done
+done
+
+# end of homekit::files
+( echo ""; echo "}" ) >> "$header"
+echo "}" >> "$source"
diff --git a/platformio/relayctl/platformio.ini b/platformio/relayctl/platformio.ini
new file mode 100644
index 0000000..592eba9
--- /dev/null
+++ b/platformio/relayctl/platformio.ini
@@ -0,0 +1,20 @@
+; PlatformIO Project Configuration File
+;
+; Build options: build flags, source filter
+; Upload options: custom upload port, speed and extra flags
+; Library options: dependencies, extra library storages
+; Advanced options: extra scripting
+;
+; Please visit documentation for the other options and examples
+; https://docs.platformio.org/page/projectconf.html
+
+[env:esp12e]
+platform = espressif8266
+board = esp12e
+framework = arduino
+upload_port = /dev/ttyUSB0
+monitor_speed = 115200
+lib_deps =
+ ESP Async WebServer
+ knolleary/PubSubClient@^2.8
+ me-no-dev/ESPAsyncTCP@^1.2.2
diff --git a/platformio/relayctl/src/config.cpp b/platformio/relayctl/src/config.cpp
new file mode 100644
index 0000000..d143a7f
--- /dev/null
+++ b/platformio/relayctl/src/config.cpp
@@ -0,0 +1,88 @@
+#include <EEPROM.h>
+#include <strings.h>
+#include "config.h"
+#include "config.def.h"
+#include "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] = {
+ 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 = crc_table[(crc ^ data[index]) & 0x0f] ^ (crc >> 4);
+ crc = crc_table[(crc ^ (data[index] >> 4)) & 0x0f] ^ (crc >> 4);
+ crc = ~crc;
+ }
+ return crc;
+}
+
+ConfigData read() {
+ ConfigData data {0};
+ 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;
+}
+
+bool write(ConfigData& data) {
+ EEPROM.begin(sizeof(ConfigData));
+ data.magic = magic;
+ data.crc = GET_DATA_CRC(data);
+ EEPROM.put(0, data);
+ return EEPROM.end();
+}
+
+bool erase() {
+ ConfigData data;
+ return erase(data);
+}
+
+bool erase(ConfigData& data) {
+ bzero(reinterpret_cast<uint8_t*>(&data), sizeof(data));
+ data.magic = magic;
+ EEPROM.begin(sizeof(data));
+ EEPROM.put(0, data);
+ return EEPROM.end();
+}
+
+bool isValid(ConfigData& data) {
+ return data.crc == GET_DATA_CRC(data);
+}
+
+bool isDirty(ConfigData& data) {
+ return data.magic != magic;
+}
+
+char* ConfigData::escapeNodeId(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;
+}
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/config.def.h.example b/platformio/relayctl/src/config.def.h.example
new file mode 100644
index 0000000..13ddd46
--- /dev/null
+++ b/platformio/relayctl/src/config.def.h.example
@@ -0,0 +1,32 @@
+#pragma once
+
+#define FW_VERSION 3
+
+#define DEFAULT_WIFI_AP_SSID ""
+#define DEFAULT_WIFI_STA_SSID ""
+#define DEFAULT_WIFI_STA_PSK ""
+
+#define DEFAULT_MQTT_SERVER "mqtt.solarmon.ru"
+#define DEFAULT_MQTT_PORT 8883
+#define DEFAULT_MQTT_USERNAME ""
+#define DEFAULT_MQTT_PASSWORD ""
+#define DEFAULT_MQTT_CLIENT_ID ""
+#define DEFAULT_MQTT_CA_FINGERPRINT { \
+ 0x0e, 0xb6, 0x3a, 0x02, 0x1f, \
+ 0x4e, 0x1e, 0xe1, 0x6a, 0x67, \
+ 0x62, 0xec, 0x64, 0xd4, 0x84, \
+ 0x8a, 0xb0, 0xc9, 0x9c, 0xbb \
+};
+
+#define DEFAULT_NODE_ID "relay-node"
+
+#define FLASH_BUTTON_PIN 0
+#define ESP_LED_PIN 2
+#define BOARD_LED_PIN 16
+
+// 12 bytes string
+#define SECRET ""
+
+#define DEBUG
+
+#define ARRAY_SIZE(X) sizeof((X))/sizeof((X)[0]) \ No newline at end of file
diff --git a/platformio/relayctl/src/config.h b/platformio/relayctl/src/config.h
new file mode 100644
index 0000000..58a5f25
--- /dev/null
+++ b/platformio/relayctl/src/config.h
@@ -0,0 +1,34 @@
+#pragma once
+
+#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* escapeNodeId(char* buf, size_t len);
+} __attribute__((packed));
+
+
+ConfigData read();
+bool write(ConfigData& data);
+bool erase();
+bool erase(ConfigData& data);
+bool isValid(ConfigData& data);
+bool isDirty(ConfigData& data);
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/http_server.cpp b/platformio/relayctl/src/http_server.cpp
new file mode 100644
index 0000000..0899231
--- /dev/null
+++ b/platformio/relayctl/src/http_server.cpp
@@ -0,0 +1,249 @@
+#include <Arduino.h>
+#include <string.h>
+
+#include "http_server.h"
+#include "config.h"
+#include "wifi.h"
+#include "config.def.h"
+#include "logging.h"
+
+namespace homekit {
+
+static const char CONTENT_TYPE_HTML[] = "text/html; charset=utf-8";
+static const char CONTENT_TYPE_CSS[] = "text/css";
+static const char CONTENT_TYPE_JS[] = "application/javascript";
+static const char CONTENT_TYPE_JSON[] = "application/json";
+static const char CONTENT_TYPE_FAVICON[] = "image/x-icon";
+
+static const char JSON_STATUS_FMT[] = "{\"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 NODE_ID_ERROR[] = "?";
+
+static const char FIELD_NODE_ID[] = "nid";
+static const char FIELD_SSID[] = "ssid";
+static const char FIELD_PSK[] = "psk";
+
+static const char MSG_IS_INVALID[] = " is invalid";
+static const char MSG_IS_MISSING[] = " is missing";
+
+static const char GZIP[] = "gzip";
+static const char CONTENT_ENCODING[] = "Content-Encoding";
+static const char NOT_FOUND[] = "Not Found";
+
+static void do_restart() {
+ ESP.restart();
+}
+
+void HttpServer::start() {
+ _server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest* req) { sendGzip(req, files::style_css, CONTENT_TYPE_CSS); });
+ _server.on("/app.js", HTTP_GET, [](AsyncWebServerRequest* req) { sendGzip(req, files::app_js, CONTENT_TYPE_JS); });
+ _server.on("/favicon.ico", HTTP_GET, [](AsyncWebServerRequest* req) { sendGzip(req, files::favicon_ico, CONTENT_TYPE_FAVICON); });
+ _server.on("/", HTTP_GET, [&](AsyncWebServerRequest* req) { sendGzip(req, files::index_html, CONTENT_TYPE_HTML); });
+
+ _server.on("/status", HTTP_GET, [](AsyncWebServerRequest* req) {
+ char json_buf[JSON_BUF_SIZE];
+ auto cfg = config::read();
+ char *ssid, *psk;
+ wifi::getConfig(cfg, &ssid, &psk, nullptr);
+
+ if (!isValid(cfg) || !cfg.flags.node_configured) {
+ sprintf(json_buf, JSON_STATUS_FMT
+ , DEFAULT_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.escapeNodeId(escaped_node_id, 32);
+ sprintf(json_buf, JSON_STATUS_FMT
+ , escaped_node_id_res == nullptr ? NODE_ID_ERROR : escaped_node_id
+#ifdef DEBUG
+ , 1
+ , cfg.crc
+ , cfg.flags.node_configured
+ , cfg.flags.wifi_configured
+#endif
+ );
+ }
+ req->send(200, CONTENT_TYPE_JSON, json_buf);
+ });
+
+ _server.on("/status", HTTP_POST, [&](AsyncWebServerRequest* req) {
+ auto cfg = config::read();
+ char *s;
+
+ if (!handleInputStr(req, FIELD_SSID, 32, &s)) return;
+ strncpy(cfg.wifi_ssid, s, 32);
+ PRINTF("saving ssid: %s\n", cfg.wifi_ssid);
+
+ if (!handleInputStr(req, FIELD_PSK, 63, &s)) return;
+ strncpy(cfg.wifi_psk, s, 63);
+ PRINTF("saving psk: %s\n", cfg.wifi_psk);
+
+ if (!handleInputStr(req, FIELD_NODE_ID, 16, &s)) return;
+ strcpy(cfg.node_id, s);
+ PRINTF("saving node id: %s\n", cfg.node_id);
+
+ cfg.flags.node_configured = 1;
+ cfg.flags.wifi_configured = 1;
+
+ if (!config::write(cfg)) {
+ PRINTLN("eeprom write error");
+ return sendError(req, "eeprom error");
+ }
+
+ restartTimer.once(0, do_restart);
+ });
+
+ _server.on("/reset", HTTP_POST, [&](AsyncWebServerRequest* req) {
+ config::erase();
+ restartTimer.once(0, do_restart);
+ });
+
+ _server.on("/heap", HTTP_GET, [](AsyncWebServerRequest* req) {
+ req->send(200, CONTENT_TYPE_HTML, String(ESP.getFreeHeap()));
+ });
+
+ _server.on("/scan", HTTP_GET, [&](AsyncWebServerRequest* req) {
+ int i = 0;
+ size_t len;
+ const char* ssid;
+ bool enough = false;
+
+ bzero(reinterpret_cast<uint8_t*>(buf1k), ARRAY_SIZE(buf1k));
+ char* cur = buf1k;
+
+ strncpy(cur, "{\"list\":[", ARRAY_SIZE(buf1k));
+ 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 (cur - buf1k >= ARRAY_SIZE(buf1k)-40)
+ enough = true;
+
+ if (i < _scanResults->size()-1 || enough)
+ *cur++ = ',';
+
+ if (enough)
+ break;
+
+ i++;
+ }
+
+ *cur++ = ']';
+ *cur++ = '}';
+ *cur++ = '\0';
+
+ req->send(200, CONTENT_TYPE_JSON, buf1k);
+ });
+
+ _server.on("/update", HTTP_POST, [&](AsyncWebServerRequest* req) {
+ char json_buf[16];
+ bool should_reboot = !Update.hasError();
+
+ sprintf(json_buf, "{\"result\":%d}", should_reboot ? 1 : 0);
+
+ auto resp = req->beginResponse(200, CONTENT_TYPE_JSON, json_buf);
+ req->send(resp);
+
+ if (should_reboot) restartTimer.once(1, do_restart);
+ }, [&](AsyncWebServerRequest *req, const String& filename, size_t index, uint8_t *data, size_t len, bool final) {
+ if (!index) {
+ PRINTF("update start: %s\n", filename.c_str());
+ Update.runAsync(true);
+ if (!Update.begin((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000))
+ Update.printError(Serial);
+ }
+
+ if (!Update.hasError() && len) {
+ if (Update.write(data, len) != len) {
+ Update.printError(Serial);
+ }
+ }
+
+ if (final) { // if the final flag is set then this is the last frame of data
+ if (Update.end(true)) {
+ PRINTF("update success: %uB\n", index+len);
+ } else {
+ Update.printError(Serial);
+ }
+ }
+ });
+
+ _server.onNotFound([](AsyncWebServerRequest* req) {
+ req->send(404, CONTENT_TYPE_HTML, NOT_FOUND);
+ });
+
+ _server.begin();
+}
+
+void HttpServer::sendGzip(AsyncWebServerRequest* req, StaticFile file, const char* content_type) {
+ auto resp = req->beginResponse_P(200, content_type, file.content, file.size);
+ resp->addHeader(CONTENT_ENCODING, GZIP);
+ req->send(resp);
+}
+
+void HttpServer::sendError(AsyncWebServerRequest* req, const String& message) {
+ char buf[32];
+ if (snprintf(buf, 32, "error: %s", message.c_str()) == 32)
+ buf[31] = '\0';
+ req->send(400, CONTENT_TYPE_HTML, buf);
+}
+
+bool HttpServer::handleInputStr(AsyncWebServerRequest *req,
+ const char *field_name,
+ size_t max_len,
+ char **dst) {
+ const char* s;
+ size_t len;
+
+ if (!req->hasParam(field_name, true)) {
+ sendError(req, String(field_name) + String(MSG_IS_MISSING));
+ return false;
+ }
+
+ s = req->getParam(field_name, true)->value().c_str();
+ len = strlen(s);
+ if (!len || len > max_len) {
+ sendError(req, String(FIELD_NODE_ID) + String(MSG_IS_INVALID));
+ return false;
+ }
+
+ *dst = (char*)s;
+ return true;
+}
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/http_server.h b/platformio/relayctl/src/http_server.h
new file mode 100644
index 0000000..c7e3d9c
--- /dev/null
+++ b/platformio/relayctl/src/http_server.h
@@ -0,0 +1,37 @@
+#pragma once
+
+#include <memory>
+#include <list>
+#include <Ticker.h>
+#include <utility>
+#include <ESPAsyncWebServer.h>
+#include "static.h"
+#include "config.h"
+#include "wifi.h"
+
+namespace homekit {
+
+using files::StaticFile;
+
+class HttpServer {
+private:
+ AsyncWebServer _server;
+ Ticker restartTimer;
+ std::shared_ptr<std::list<wifi::ScanResult>> _scanResults;
+ char buf1k[1024];
+
+ static void sendGzip(AsyncWebServerRequest* req, StaticFile file, const char* content_type);
+ static void sendError(AsyncWebServerRequest* req, const String& message);
+
+ static bool handleInputStr(AsyncWebServerRequest* req, const char* field_name, size_t max_len, char** dst);
+ // static bool handle_input_addr(AsyncWebServerRequest* req, const char* field_name, ConfigIPv4Addr* addr_dst);
+
+public:
+ explicit HttpServer(std::shared_ptr<std::list<wifi::ScanResult>> scanResults)
+ : _server(80)
+ , _scanResults(std::move(scanResults)) {};
+
+ void start();
+};
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/led.cpp b/platformio/relayctl/src/led.cpp
new file mode 100644
index 0000000..eafc662
--- /dev/null
+++ b/platformio/relayctl/src/led.cpp
@@ -0,0 +1,20 @@
+#include "led.h"
+
+namespace homekit {
+
+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);
+ }
+}
+
+void Led::on_off(uint16_t delay_ms, bool last_delay) const {
+ on();
+ delay(delay_ms);
+
+ off();
+ if (last_delay)
+ delay(delay_ms);
+}
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/led.h b/platformio/relayctl/src/led.h
new file mode 100644
index 0000000..ccb3f11
--- /dev/null
+++ b/platformio/relayctl/src/led.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#include <Arduino.h>
+
+namespace homekit {
+
+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;
+};
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/logging.cpp b/platformio/relayctl/src/logging.cpp
new file mode 100644
index 0000000..a929d81
--- /dev/null
+++ b/platformio/relayctl/src/logging.cpp
@@ -0,0 +1,37 @@
+#include <stdio.h>
+#include "logging.h"
+
+#ifdef DEBUG
+namespace homekit {
+
+void hexdump(const void* data, size_t size) {
+ char ascii[17];
+ size_t i, j;
+ ascii[16] = '\0';
+ for (i = 0; i < size; ++i) {
+ printf("%02X ", ((unsigned char*)data)[i]);
+ if (((unsigned char*)data)[i] >= ' ' && ((unsigned char*)data)[i] <= '~') {
+ ascii[i % 16] = ((unsigned char*)data)[i];
+ } else {
+ ascii[i % 16] = '.';
+ }
+ if ((i+1) % 8 == 0 || i+1 == size) {
+ printf(" ");
+ if ((i+1) % 16 == 0) {
+ printf("| %s \n", ascii);
+ } else if (i+1 == size) {
+ ascii[(i+1) % 16] = '\0';
+ if ((i+1) % 16 <= 8) {
+ printf(" ");
+ }
+ for (j = (i+1) % 16; j < 16; ++j) {
+ printf(" ");
+ }
+ printf("| %s \n", ascii);
+ }
+ }
+ }
+}
+
+}
+#endif \ No newline at end of file
diff --git a/platformio/relayctl/src/logging.h b/platformio/relayctl/src/logging.h
new file mode 100644
index 0000000..c31e98f
--- /dev/null
+++ b/platformio/relayctl/src/logging.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#include <stdlib.h>
+#include "config.def.h"
+
+#ifdef DEBUG
+
+namespace homekit {
+void hexdump(const void* data, size_t size);
+}
+
+#define PRINTLN(s) Serial.println(s)
+#define PRINT(s) Serial.print(s)
+#define PRINTF(fmt, ...) Serial.printf(fmt, ##__VA_ARGS__)
+#define HEXDUMP(data, size) homekit::hexdump((data), (size));
+
+#else
+
+#define PRINTLN(s)
+#define PRINT(s)
+#define PRINTF(a)
+#define HEXDUMP(data, size)
+
+#endif
diff --git a/platformio/relayctl/src/main.cpp b/platformio/relayctl/src/main.cpp
new file mode 100644
index 0000000..4a634d6
--- /dev/null
+++ b/platformio/relayctl/src/main.cpp
@@ -0,0 +1,170 @@
+#include <Arduino.h>
+#include <ESP8266WiFi.h>
+#include <DNSServer.h>
+#include <Ticker.h>
+
+#include "mqtt.h"
+#include "config.h"
+#include "logging.h"
+#include "http_server.h"
+#include "led.h"
+#include "config.def.h"
+#include "wifi.h"
+#include "relay.h"
+#include "stopwatch.h"
+
+using namespace homekit;
+
+static Led board_led(BOARD_LED_PIN);
+static Led esp_led(ESP_LED_PIN);
+
+enum class WorkingMode {
+ RECOVERY, // AP mode, http server with configuration
+ NORMAL, // MQTT client
+};
+static enum WorkingMode working_mode = WorkingMode::NORMAL;
+
+enum class WiFiConnectionState {
+ WAITING = 0,
+ JUST_CONNECTED = 1,
+ CONNECTED = 2
+};
+
+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 StopWatch blinkStopWatch;
+
+static DNSServer* dnsServer;
+
+static void onWifiConnected(const WiFiEventStationModeGotIP& event);
+static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event);
+
+static void wifiConnect() {
+ 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.setHostname(hostname);
+ WiFi.begin(ssid, psk);
+
+ PRINT("connecting to wifi..");
+ while (WiFi.status() != WL_CONNECTED) {
+ esp_led.blink(2, 50);
+ delay(1000);
+ PRINT('.');
+ }
+ PRINT(' ');
+}
+
+static void wifiHotspot() {
+ esp_led.on();
+
+ auto scanResults = wifi::scan();
+
+ WiFi.mode(WIFI_AP);
+ WiFi.softAP(wifi::WIFI_AP_SSID);
+
+ dnsServer = new DNSServer();
+ dnsServer->start(53, "*", WiFi.softAPIP());
+
+ service = new HttpServer(scanResults);
+ ((HttpServer*)service)->start();
+}
+
+void setup() {
+#ifdef DEBUG
+ Serial.begin(115200);
+#endif
+
+ relay::init();
+
+ pinMode(FLASH_BUTTON_PIN, INPUT_PULLUP);
+ for (uint16_t i = 0; i < recovery_boot_detection_ms; i += recovery_boot_delay_ms) {
+ delay(recovery_boot_delay_ms);
+ if (digitalRead(FLASH_BUTTON_PIN) == LOW) {
+ working_mode = WorkingMode::RECOVERY;
+ break;
+ }
+ }
+
+ auto cfg = config::read();
+ if (config::isDirty(cfg)) {
+ PRINTLN("config is dirty, erasing...");
+ config::erase(cfg);
+ board_led.blink(10, 50);
+ }
+
+ switch (working_mode) {
+ case WorkingMode::RECOVERY:
+ wifiHotspot();
+ break;
+
+ case WorkingMode::NORMAL:
+ wifiConnectHandler = WiFi.onStationModeGotIP(onWifiConnected);
+ wifiDisconnectHandler = WiFi.onStationModeDisconnected(onWifiDisconnected);
+ wifiConnect();
+ break;
+ }
+}
+
+void loop() {
+ if (working_mode == WorkingMode::NORMAL) {
+ if (wifi_state == WiFiConnectionState::JUST_CONNECTED) {
+ board_led.blink(3, 300);
+ wifi_state = WiFiConnectionState::CONNECTED;
+
+ if (service == nullptr)
+ service = new mqtt::MQTT();
+
+ ((mqtt::MQTT*)service)->connect();
+ blinkStopWatch.save();
+ }
+
+ auto mqtt = (mqtt::MQTT*)service;
+ if (static_cast<int>(wifi_state) >= 1
+ && mqtt != nullptr) {
+ if (!mqtt->loop()) {
+ PRINTLN("mqtt::loop() returned false");
+ // FIXME do something here
+ }
+
+ if (mqtt->statStopWatch.elapsed(10000)) {
+ mqtt->sendStat();
+ }
+
+ // periodically blink board led
+ if (blinkStopWatch.elapsed(5000)) {
+ board_led.blink(1, 10);
+ blinkStopWatch.save();
+ }
+ }
+
+ delay(500);
+ } else {
+ if (dnsServer != nullptr)
+ dnsServer->processNextRequest();
+ }
+}
+
+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);
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/mqtt.cpp b/platformio/relayctl/src/mqtt.cpp
new file mode 100644
index 0000000..cca215b
--- /dev/null
+++ b/platformio/relayctl/src/mqtt.cpp
@@ -0,0 +1,172 @@
+#include "mqtt.h"
+#include "logging.h"
+#include "wifi.h"
+#include "config.def.h"
+#include "relay.h"
+#include "config.h"
+
+namespace homekit::mqtt {
+
+static const uint8_t MQTT_CA_FINGERPRINT[] = DEFAULT_MQTT_CA_FINGERPRINT;
+static const char MQTT_SERVER[] = DEFAULT_MQTT_SERVER;
+static const uint16_t MQTT_PORT = DEFAULT_MQTT_PORT;
+static const char MQTT_USERNAME[] = DEFAULT_MQTT_USERNAME;
+static const char MQTT_PASSWORD[] = DEFAULT_MQTT_PASSWORD;
+static const char MQTT_CLIENT_ID[] = DEFAULT_MQTT_CLIENT_ID;
+
+static const char MQTT_SECRET[] = SECRET;
+static const char TOPIC_RELAY_POWER[] = "relay/power";
+static const char TOPIC_STAT[] = "stat";
+static const char TOPIC_STAT1[] = "stat1";
+static const char TOPIC_ADMIN[] = "admin";
+static const char TOPIC_RELAY[] = "relay";
+
+
+using namespace homekit;
+
+MQTT::MQTT() : client(wifiClient) {
+ randomSeed(micros());
+
+ wifiClient.setFingerprint(MQTT_CA_FINGERPRINT);
+
+ client.setServer(MQTT_SERVER, MQTT_PORT);
+ client.setCallback([&](char* topic, byte* payload, unsigned int length) {
+ this->callback(topic, payload, length);
+ });
+}
+
+void MQTT::connect() {
+ reconnect();
+}
+
+void MQTT::reconnect() {
+ char buf[128] {0};
+
+ if (client.connected()) {
+ PRINTLN("warning: already connected");
+ return;
+ }
+
+ // Attempt to connect
+ if (client.connect(MQTT_CLIENT_ID, MQTT_USERNAME, MQTT_PASSWORD)) {
+ PRINTLN("mqtt: connected");
+
+ sendInitialStat();
+
+ subscribe(TOPIC_RELAY);
+ subscribe(TOPIC_ADMIN);
+ } else {
+ PRINTF("mqtt: failed to connect, rc=%d\n", client.state());
+ wifiClient.getLastSSLError(buf, sizeof(buf));
+ PRINTF("SSL error: %s\n", buf);
+
+ reconnectTimer.once(2, [&]() {
+ reconnect();
+ });
+ }
+}
+
+void MQTT::disconnect() {
+ // TODO test how this works???
+ reconnectTimer.detach();
+ client.disconnect();
+ wifiClient.stop();
+}
+
+bool MQTT::loop() {
+ return client.loop();
+}
+
+bool MQTT::publish(const char* topic, uint8_t *payload, size_t length) {
+ char full_topic[40] {0};
+ strcpy(full_topic, "/hk/");
+ strcat(full_topic, wifi::NODE_ID);
+ strcat(full_topic, "/");
+ strcat(full_topic, topic);
+ return client.publish(full_topic, payload, length);
+}
+
+bool MQTT::subscribe(const char *topic) {
+ char full_topic[40] {0};
+ strcpy(full_topic, "/hk/");
+ strcat(full_topic, wifi::NODE_ID);
+ strcat(full_topic, "/");
+ strcat(full_topic, topic);
+ strcat(full_topic, "/#");
+ bool res = client.subscribe(full_topic, 1);
+ if (!res)
+ PRINTF("error: failed to subscribe to %s\n", full_topic);
+ return res;
+}
+
+void MQTT::sendInitialStat() {
+ auto cfg = config::read();
+ InitialStatPayload stat {
+ .ip = wifi::getIPAsInteger(),
+ .fw_version = FW_VERSION,
+ .rssi = wifi::getRSSI(),
+ .free_heap = ESP.getFreeHeap(),
+ .flags = StatFlags {
+ .state = static_cast<uint8_t>(relay::getState() ? 1 : 0),
+ .config_changed_value_present = 1,
+ .config_changed = static_cast<uint8_t>(cfg.flags.node_configured || cfg.flags.wifi_configured ? 1 : 0)
+ }
+ };
+ publish(TOPIC_STAT1, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
+ statStopWatch.save();
+}
+
+void MQTT::sendStat() {
+ StatPayload stat {
+ .rssi = wifi::getRSSI(),
+ .free_heap = ESP.getFreeHeap(),
+ .flags = StatFlags {
+ .state = static_cast<uint8_t>(relay::getState() ? 1 : 0),
+ .config_changed_value_present = 0,
+ .config_changed = 0
+ }
+ };
+
+ PRINTF("free heap: %d\n", ESP.getFreeHeap());
+
+ publish(TOPIC_STAT, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
+ statStopWatch.save();
+}
+
+void MQTT::callback(char* topic, uint8_t* payload, uint32_t length) {
+ const size_t bufsize = 16;
+ char relevant_topic[bufsize];
+ strncpy(relevant_topic, topic+strlen(wifi::NODE_ID)+5, bufsize);
+
+ if (strncmp(TOPIC_RELAY_POWER, relevant_topic, bufsize) == 0) {
+ handleRelayPowerPayload(payload, length);
+ } else {
+ PRINTF("error: invalid topic %s\n", topic);
+ }
+}
+
+void MQTT::handleRelayPowerPayload(uint8_t *payload, uint32_t length) {
+ if (length != sizeof(PowerPayload)) {
+ PRINTF("error: size of payload (%ul) does not match expected (%ul)\n",
+ length, sizeof(PowerPayload));
+ return;
+ }
+
+ auto pd = reinterpret_cast<struct PowerPayload*>(payload);
+ if (strncmp(pd->secret, MQTT_SECRET, sizeof(pd->secret)) != 0) {
+ PRINTLN("error: invalid secret");
+ return;
+ }
+
+ if (pd->state == 1) {
+ relay::setOn();
+ } else if (pd->state == 0) {
+ relay::setOff();
+ } else {
+ PRINTLN("error: unexpected state value");
+ }
+
+ sendStat();
+}
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/mqtt.h b/platformio/relayctl/src/mqtt.h
new file mode 100644
index 0000000..a769750
--- /dev/null
+++ b/platformio/relayctl/src/mqtt.h
@@ -0,0 +1,57 @@
+#include <ESP8266WiFi.h>
+#include <PubSubClient.h>
+#include <Ticker.h>
+#include "stopwatch.h"
+
+namespace homekit::mqtt {
+
+class MQTT {
+private:
+ WiFiClientSecure wifiClient;
+ PubSubClient client;
+ Ticker reconnectTimer;
+
+ void callback(char* topic, uint8_t* payload, size_t length);
+ void handleRelayPowerPayload(uint8_t* payload, uint32_t length);
+ bool publish(const char* topic, uint8_t* payload, size_t length);
+ bool subscribe(const char* topic);
+ void sendInitialStat();
+
+public:
+ StopWatch statStopWatch;
+
+ MQTT();
+ void connect();
+ void disconnect();
+ void reconnect();
+ bool loop();
+ void sendStat();
+};
+
+struct StatFlags {
+ uint8_t state: 1;
+ uint8_t config_changed_value_present: 1;
+ uint8_t config_changed: 1;
+ uint8_t reserved: 5;
+} __attribute__((packed));
+
+struct InitialStatPayload {
+ uint32_t ip;
+ uint8_t fw_version;
+ int8_t rssi;
+ uint32_t free_heap;
+ StatFlags flags;
+} __attribute__((packed));
+
+struct StatPayload {
+ int8_t rssi;
+ uint32_t free_heap;
+ StatFlags flags;
+} __attribute__((packed));
+
+struct PowerPayload {
+ char secret[12];
+ uint8_t state;
+} __attribute__((packed));
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/relay.h b/platformio/relayctl/src/relay.h
new file mode 100644
index 0000000..a3519ac
--- /dev/null
+++ b/platformio/relayctl/src/relay.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#include <Arduino.h>
+#include "config.def.h"
+
+namespace homekit::relay {
+
+inline void init() {
+ pinMode(RELAY_PIN, OUTPUT);
+}
+
+inline bool getState() {
+ return digitalRead(RELAY_PIN) == 1;
+}
+
+inline void setOn() {
+ digitalWrite(RELAY_PIN, HIGH);
+}
+
+inline void setOff() {
+ digitalWrite(RELAY_PIN, LOW);
+}
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/src/static.cpp b/platformio/relayctl/src/static.cpp
new file mode 100644
index 0000000..8e2c56d
--- /dev/null
+++ b/platformio/relayctl/src/static.cpp
@@ -0,0 +1,276 @@
+/**
+ * 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, 0xdb, 0x8a, 0xdc, 0x38,
+ 0x10, 0x7d, 0x9f, 0xaf, 0x50, 0xf4, 0xb0, 0xcc, 0xc0, 0xb4, 0xbd, 0xd3, 0x81, 0x21, 0x6c, 0x6c,
+ 0xc3, 0x92, 0x0b, 0x04, 0xc2, 0x32, 0x4c, 0x13, 0x02, 0x79, 0x69, 0x64, 0xb9, 0xdc, 0x56, 0x5a,
+ 0x96, 0xb4, 0x56, 0xd9, 0x3d, 0xbd, 0x5f, 0x9f, 0x92, 0x6c, 0xf7, 0x65, 0xd3, 0x4c, 0x2e, 0x2f,
+ 0x6d, 0x57, 0xa9, 0xea, 0xd4, 0xa9, 0xa3, 0x92, 0xd5, 0xd9, 0x8b, 0xca, 0x4a, 0xdc, 0x3b, 0x60,
+ 0x0d, 0xb6, 0xba, 0xb8, 0xca, 0xc2, 0x83, 0x69, 0x61, 0x36, 0x39, 0x07, 0xc3, 0x83, 0x03, 0x44,
+ 0x45, 0x8f, 0x16, 0x50, 0x50, 0x0c, 0xba, 0x05, 0xfc, 0xdb, 0xab, 0x21, 0xe7, 0xd2, 0x1a, 0x04,
+ 0x83, 0x8b, 0x90, 0xcc, 0xd9, 0x64, 0xe5, 0x1c, 0xe1, 0x09, 0xd3, 0x00, 0xf2, 0x9a, 0xc9, 0x46,
+ 0x74, 0x1e, 0x30, 0xef, 0xb1, 0x5e, 0xbc, 0xe2, 0x33, 0x86, 0x11, 0x2d, 0xe4, 0x7c, 0x50, 0xb0,
+ 0x73, 0xb6, 0xc3, 0x93, 0xcc, 0x9d, 0xaa, 0xb0, 0xc9, 0x2b, 0x18, 0x94, 0x84, 0x45, 0x34, 0x6e,
+ 0x95, 0x51, 0xa8, 0x84, 0x5e, 0x78, 0x29, 0x34, 0xe4, 0x77, 0xb7, 0x2d, 0x39, 0xda, 0xbe, 0x3d,
+ 0xda, 0xe2, 0xe9, 0xcc, 0xee, 0x3d, 0x74, 0xd1, 0x10, 0x25, 0xd9, 0xc6, 0x86, 0xa2, 0xa8, 0x50,
+ 0x43, 0xf1, 0xc6, 0x9a, 0x5a, 0x6d, 0xfa, 0x4e, 0xa0, 0xb2, 0x26, 0x4b, 0x47, 0xe7, 0x55, 0xa6,
+ 0x95, 0xd9, 0xb2, 0x0e, 0x74, 0xce, 0x7d, 0x43, 0x6c, 0x64, 0x8f, 0x4c, 0x11, 0x21, 0xce, 0x9a,
+ 0x0e, 0xea, 0x9c, 0xa7, 0xb5, 0x18, 0x82, 0x9d, 0xd0, 0x0f, 0x67, 0xa1, 0xd3, 0x9c, 0xab, 0x56,
+ 0x6c, 0x20, 0x7d, 0x5a, 0xc4, 0xb8, 0x73, 0x08, 0xdc, 0x6b, 0xf0, 0x0d, 0x00, 0xce, 0xb1, 0x51,
+ 0x0c, 0xe9, 0xfd, 0x01, 0x2f, 0x86, 0x24, 0xc1, 0x43, 0x99, 0x5e, 0x76, 0xca, 0x21, 0xf3, 0x9d,
+ 0xa4, 0x15, 0xe1, 0x5c, 0xf2, 0x95, 0xdc, 0x59, 0x3a, 0xba, 0x69, 0x3d, 0x9d, 0xa4, 0x2f, 0x6d,
+ 0xb5, 0x67, 0xd6, 0x68, 0x2b, 0x2a, 0x2a, 0x4f, 0x92, 0xfc, 0xed, 0xdc, 0xf5, 0x4d, 0x40, 0xa8,
+ 0xd4, 0xc0, 0xa4, 0x16, 0xde, 0x53, 0xa9, 0xd0, 0x11, 0x2f, 0x56, 0x80, 0xa8, 0xcc, 0xc6, 0xb3,
+ 0xcc, 0x3b, 0x61, 0x98, 0xa2, 0x8c, 0x90, 0x47, 0xae, 0x35, 0x89, 0x02, 0x9a, 0x17, 0xd7, 0x93,
+ 0x9d, 0x24, 0xc9, 0x0d, 0x15, 0xa3, 0x28, 0xaa, 0x49, 0x40, 0xe7, 0x70, 0xa5, 0xb6, 0x72, 0x1b,
+ 0x4a, 0xd4, 0xb6, 0x6b, 0x19, 0x6d, 0x5c, 0x63, 0x09, 0xca, 0x59, 0x4f, 0xbd, 0x09, 0x19, 0x44,
+ 0x8c, 0xdd, 0x08, 0xec, 0xa9, 0xb9, 0x71, 0x4b, 0x0d, 0xe0, 0xce, 0x76, 0xdb, 0xb5, 0x9f, 0x28,
+ 0xfc, 0x8f, 0x60, 0x00, 0x9a, 0x39, 0x7c, 0x56, 0xef, 0x15, 0x5b, 0xad, 0x3e, 0xbc, 0xbd, 0x50,
+ 0x39, 0xc6, 0x29, 0xe3, 0x7a, 0x8c, 0x1a, 0x81, 0x06, 0x89, 0xb1, 0x0f, 0xef, 0x55, 0xb5, 0x1e,
+ 0xed, 0xb9, 0x64, 0x70, 0xf1, 0x43, 0x62, 0xaf, 0xf5, 0x38, 0x37, 0x21, 0xd1, 0xba, 0x40, 0x92,
+ 0x0d, 0x42, 0xf7, 0x14, 0xc8, 0x8b, 0x8f, 0x87, 0xae, 0xb3, 0x74, 0x5c, 0x0b, 0x0a, 0x8f, 0x70,
+ 0xe1, 0xed, 0x32, 0x8f, 0x53, 0xbe, 0x0f, 0xe4, 0xa6, 0x06, 0xab, 0x1f, 0x72, 0x8e, 0x2f, 0xd3,
+ 0x04, 0xb8, 0x29, 0x89, 0x1f, 0x98, 0x4c, 0xd4, 0x9d, 0xdf, 0x5e, 0x62, 0x1e, 0x3b, 0xad, 0x75,
+ 0xb5, 0x8e, 0xeb, 0x34, 0xdf, 0x1a, 0xcc, 0x86, 0x8e, 0x05, 0xbf, 0x7f, 0xc9, 0x59, 0xa5, 0x7c,
+ 0x18, 0xec, 0xea, 0x42, 0x71, 0xd9, 0x80, 0xdc, 0x96, 0xf6, 0x29, 0x4e, 0x64, 0x20, 0xcd, 0xc8,
+ 0x1d, 0xa7, 0x7a, 0x17, 0xa1, 0x8a, 0x33, 0x56, 0x87, 0xe8, 0x59, 0xc7, 0x39, 0x6c, 0x14, 0xfa,
+ 0x90, 0xc4, 0xc2, 0x2b, 0x73, 0x87, 0xc6, 0x23, 0xf2, 0x51, 0xad, 0xe7, 0x45, 0xfb, 0xc7, 0x56,
+ 0xc0, 0x7e, 0x62, 0x8b, 0x4f, 0x89, 0x85, 0x03, 0x73, 0x22, 0xd5, 0x49, 0xff, 0x77, 0xf7, 0x87,
+ 0x39, 0x0b, 0x7b, 0x3e, 0xcb, 0x64, 0x2e, 0x0f, 0xc0, 0xa9, 0x54, 0x53, 0xfd, 0xb2, 0x47, 0xa4,
+ 0x81, 0x18, 0xeb, 0xf8, 0xbe, 0x6c, 0x15, 0x1e, 0xc3, 0x66, 0x1d, 0x46, 0x77, 0xb1, 0x12, 0x03,
+ 0x30, 0x61, 0x2a, 0xf6, 0x08, 0xa5, 0xb5, 0x98, 0xa5, 0x63, 0x72, 0x00, 0x0b, 0xdc, 0x2f, 0xb6,
+ 0x3e, 0x1d, 0xc0, 0x4f, 0xae, 0x12, 0x08, 0xac, 0x56, 0x5d, 0xbb, 0x13, 0x1d, 0xb0, 0xeb, 0xa4,
+ 0x54, 0xe6, 0xe6, 0x77, 0x4f, 0x58, 0x1f, 0xd1, 0x38, 0x03, 0x23, 0x47, 0xe2, 0x6d, 0xaf, 0x51,
+ 0x39, 0xd1, 0x61, 0x24, 0xb2, 0xa0, 0x55, 0x31, 0xeb, 0x32, 0xc6, 0x3e, 0x7b, 0xfc, 0x2e, 0x6a,
+ 0x5e, 0x2b, 0xe2, 0x4d, 0x25, 0x25, 0x38, 0xfa, 0x0a, 0x07, 0xba, 0xb7, 0xe1, 0x27, 0xd9, 0xfc,
+ 0x37, 0x23, 0xc7, 0x88, 0x1f, 0x28, 0x79, 0x2e, 0xe0, 0x27, 0x17, 0xbe, 0x32, 0xbf, 0xa2, 0xdb,
+ 0x23, 0x10, 0x71, 0x36, 0x93, 0xff, 0x5d, 0xbd, 0xba, 0x80, 0xc2, 0x7f, 0x8a, 0xe3, 0x8c, 0xab,
+ 0xfc, 0x7a, 0xca, 0x8a, 0x14, 0x7e, 0x85, 0xf3, 0x07, 0x53, 0xdb, 0x67, 0x98, 0xbe, 0x5b, 0x3d,
+ 0xbc, 0x5a, 0xde, 0xdf, 0x2f, 0x4a, 0xe1, 0x69, 0xc2, 0xb2, 0xb2, 0xa0, 0x5b, 0x42, 0xec, 0x25,
+ 0x6a, 0xaa, 0x51, 0xdc, 0x1e, 0x47, 0x64, 0x58, 0x66, 0x65, 0x57, 0x5c, 0x3d, 0xd0, 0xae, 0x32,
+ 0x5b, 0xb3, 0x4c, 0x4c, 0xb7, 0x45, 0xb8, 0x6d, 0xfd, 0x5f, 0x69, 0xba, 0x51, 0x98, 0xc8, 0xe6,
+ 0xce, 0x25, 0xca, 0xa6, 0x8d, 0x6d, 0x61, 0x4b, 0x36, 0xf9, 0x52, 0x5e, 0x4c, 0x56, 0x96, 0x8a,
+ 0x82, 0x95, 0xfb, 0xef, 0x33, 0xa7, 0x2c, 0x5e, 0xbc, 0x1b, 0x36, 0x60, 0xf6, 0xec, 0x8b, 0x32,
+ 0x96, 0x6e, 0xde, 0x21, 0x26, 0xfc, 0x21, 0xad, 0xdb, 0xbf, 0x66, 0xcb, 0x3f, 0x97, 0xcb, 0xe3,
+ 0x89, 0x0e, 0x77, 0x4d, 0xbc, 0x7a, 0xe2, 0xbf, 0x81, 0x6f, 0xa3, 0xf2, 0xc7, 0xe5, 0x1e, 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, 0x56, 0x5b, 0x6f, 0xdc, 0x44,
+ 0x14, 0x7e, 0xe7, 0x57, 0x78, 0x47, 0x22, 0xf2, 0xb0, 0x8e, 0x73, 0x81, 0x87, 0x6a, 0x5d, 0x6b,
+ 0xd5, 0x2b, 0x2d, 0x6a, 0x9b, 0xaa, 0x49, 0x11, 0x52, 0x14, 0xa2, 0x59, 0xfb, 0x6c, 0xd6, 0x8d,
+ 0x77, 0xc6, 0x8c, 0xc7, 0xd9, 0x84, 0x6d, 0x24, 0xda, 0x3e, 0x80, 0x04, 0x52, 0x25, 0xde, 0xe1,
+ 0x89, 0x1f, 0x90, 0x06, 0x02, 0x2d, 0x6d, 0xc3, 0x5f, 0xf0, 0xfe, 0x23, 0xce, 0x19, 0x7b, 0xaf,
+ 0x89, 0x44, 0xc5, 0x43, 0x36, 0xf6, 0x78, 0xce, 0xed, 0x3b, 0xe7, 0xfb, 0x66, 0xba, 0x85, 0x8c,
+ 0x4c, 0xa2, 0xa4, 0x93, 0xe4, 0x1b, 0x9d, 0x27, 0x10, 0x19, 0x17, 0xf8, 0x50, 0x83, 0x29, 0xb4,
+ 0x64, 0xdb, 0xca, 0xae, 0x38, 0xd5, 0x87, 0x1d, 0x16, 0x86, 0x61, 0xf5, 0xe8, 0x67, 0x5a, 0x19,
+ 0x65, 0x8e, 0x32, 0xf0, 0x8d, 0xda, 0x34, 0x3a, 0x91, 0x7b, 0x7e, 0x24, 0xd2, 0x14, 0x6d, 0x8f,
+ 0xbb, 0x63, 0x87, 0x7b, 0x30, 0x75, 0xe5, 0xc4, 0x2a, 0x2a, 0xfa, 0x20, 0x8d, 0xbf, 0x07, 0xe6,
+ 0x56, 0x0a, 0xf4, 0x78, 0xfd, 0xe8, 0x6e, 0x3c, 0x67, 0x10, 0x09, 0x19, 0x41, 0x7a, 0xeb, 0x00,
+ 0xbf, 0xcd, 0x58, 0x02, 0x06, 0x03, 0x5a, 0xbb, 0x09, 0x5d, 0x51, 0xa4, 0x66, 0x69, 0x69, 0x71,
+ 0xc5, 0xe5, 0x1e, 0xf8, 0xb9, 0x51, 0xd9, 0x43, 0xad, 0x32, 0xb1, 0x27, 0xc8, 0x19, 0xed, 0x5a,
+ 0x58, 0xb2, 0xdb, 0xaa, 0x18, 0xd7, 0x8b, 0x4e, 0x27, 0x85, 0xb0, 0xb1, 0x8a, 0x2b, 0x55, 0x98,
+ 0x2f, 0x45, 0x5a, 0xe0, 0xc2, 0x9a, 0xd7, 0x58, 0x9b, 0x26, 0x04, 0x5a, 0x2b, 0xbd, 0x05, 0x87,
+ 0x73, 0xe9, 0x38, 0x89, 0xcc, 0x0d, 0x79, 0x51, 0x5d, 0xe7, 0x16, 0x6d, 0x68, 0x83, 0xdf, 0x87,
+ 0x3c, 0x17, 0x7b, 0xd0, 0x82, 0x26, 0x63, 0x53, 0xf3, 0x54, 0x45, 0xfb, 0x64, 0x89, 0x99, 0x80,
+ 0xb9, 0x66, 0x10, 0xa6, 0x4e, 0x61, 0xc0, 0x65, 0x71, 0x92, 0x0b, 0x0c, 0x1f, 0x33, 0x6f, 0xfa,
+ 0x38, 0x03, 0x43, 0x21, 0xa7, 0x86, 0x1a, 0xfa, 0xea, 0x00, 0x2e, 0xb3, 0x9d, 0x31, 0x48, 0x64,
+ 0x62, 0x1e, 0x80, 0x19, 0x28, 0xbd, 0xbf, 0x09, 0xc6, 0x60, 0x33, 0x72, 0x97, 0x0f, 0xa7, 0x55,
+ 0xb8, 0xe0, 0x19, 0x3e, 0x94, 0x45, 0x9a, 0x36, 0xc2, 0x10, 0xf1, 0x73, 0xc1, 0x3f, 0xb0, 0xe5,
+ 0x1a, 0xee, 0x4d, 0x82, 0x1d, 0x1f, 0x08, 0xed, 0x98, 0x70, 0x35, 0x98, 0xd8, 0x21, 0x60, 0xc3,
+ 0x75, 0x6c, 0x79, 0xb3, 0x49, 0x36, 0xd8, 0x4d, 0x96, 0x2a, 0x11, 0xa3, 0xf7, 0xdd, 0x54, 0x74,
+ 0x20, 0x65, 0x1c, 0x01, 0x3e, 0x4a, 0xc1, 0xc7, 0x94, 0xb2, 0x54, 0x1c, 0x85, 0x4c, 0x2a, 0x09,
+ 0xac, 0x72, 0xa4, 0xc3, 0x49, 0xc3, 0xbb, 0x4a, 0xf7, 0x73, 0x5f, 0x56, 0xf9, 0xed, 0xe6, 0x75,
+ 0x82, 0x81, 0xf6, 0x45, 0x1c, 0xdb, 0x5e, 0xdf, 0x4b, 0x72, 0x03, 0x12, 0xb4, 0xcb, 0xf2, 0xa2,
+ 0xd3, 0x4f, 0x0c, 0xf3, 0xdc, 0x71, 0x0e, 0x33, 0xb8, 0x6b, 0x5f, 0x26, 0x71, 0x95, 0xb7, 0x8f,
+ 0x68, 0xf4, 0x5d, 0xde, 0xd6, 0x7e, 0x96, 0xef, 0xd7, 0x4b, 0x29, 0xc8, 0x3d, 0xd3, 0xbb, 0x7a,
+ 0xa5, 0xed, 0x8a, 0x14, 0xb4, 0x71, 0x59, 0xf9, 0x4b, 0x79, 0x56, 0x9e, 0x96, 0x67, 0xa3, 0xef,
+ 0xca, 0xf7, 0xa3, 0x1f, 0xcb, 0x37, 0x4e, 0xf9, 0x4f, 0x79, 0x82, 0x2f, 0xe7, 0xe5, 0xdb, 0xd1,
+ 0x4f, 0x8e, 0x5b, 0xbe, 0x2b, 0x5f, 0x97, 0xef, 0xf1, 0xef, 0x5d, 0x79, 0x42, 0x2b, 0xf8, 0x7c,
+ 0x32, 0x7a, 0xe9, 0x94, 0x7f, 0x94, 0x6f, 0xed, 0x87, 0x13, 0x67, 0xd9, 0xb9, 0xe2, 0x8c, 0x9e,
+ 0xd9, 0x1d, 0xa7, 0x64, 0x85, 0x7f, 0xa7, 0x9c, 0x71, 0x6f, 0x7e, 0x4a, 0x79, 0x6b, 0x79, 0x2d,
+ 0x0c, 0xb5, 0x9f, 0xe7, 0x98, 0x5d, 0x0e, 0x29, 0x92, 0x03, 0xe2, 0xbb, 0x32, 0x86, 0xc3, 0xb9,
+ 0x4c, 0x9c, 0xf2, 0x14, 0x93, 0x78, 0x85, 0xf1, 0x4f, 0xac, 0xf3, 0xd1, 0xf3, 0xf2, 0x7c, 0xf4,
+ 0x7d, 0xf9, 0x37, 0x3e, 0x62, 0xc8, 0xf3, 0xd1, 0xb3, 0xd1, 0xf3, 0xd1, 0x0b, 0xca, 0xf0, 0x92,
+ 0x00, 0x07, 0x2a, 0x89, 0xab, 0x59, 0xc2, 0x30, 0x16, 0x21, 0xde, 0x9a, 0xf8, 0xfe, 0x99, 0x6a,
+ 0x44, 0x17, 0xaf, 0xd1, 0xe3, 0x99, 0x23, 0x55, 0x8c, 0xf3, 0x19, 0x5f, 0x74, 0x72, 0xcc, 0xb9,
+ 0x87, 0xd6, 0x3d, 0x35, 0xd8, 0x25, 0xcc, 0x2e, 0x42, 0x1f, 0xf5, 0x84, 0xdc, 0x83, 0x45, 0xe8,
+ 0x2d, 0xc0, 0xf3, 0xc3, 0x4b, 0xa4, 0x67, 0x48, 0x1b, 0x23, 0x34, 0x52, 0xd9, 0x8f, 0x7a, 0x10,
+ 0xed, 0x43, 0xdc, 0x66, 0x06, 0x69, 0xc2, 0x5a, 0x2c, 0x13, 0x79, 0x8e, 0x8d, 0xa6, 0xf9, 0xac,
+ 0x42, 0x12, 0x2e, 0x1f, 0x18, 0xae, 0x9a, 0xc0, 0x89, 0xeb, 0x39, 0x34, 0x83, 0xe5, 0xb5, 0xc9,
+ 0xf0, 0xd6, 0xdf, 0x55, 0x46, 0x86, 0xf9, 0xb6, 0xd9, 0xc1, 0xd5, 0x99, 0x59, 0x08, 0x19, 0xab,
+ 0x0a, 0x16, 0x4f, 0xc4, 0x21, 0xe9, 0x8d, 0xcb, 0x56, 0x90, 0xb1, 0xa6, 0xc8, 0x99, 0x37, 0x3c,
+ 0x9e, 0x09, 0x69, 0x3c, 0xc5, 0x87, 0x46, 0x1f, 0x0d, 0x93, 0xae, 0x6b, 0xb8, 0xe9, 0x69, 0x35,
+ 0x70, 0x4c, 0x00, 0xae, 0x1d, 0x35, 0x4f, 0xf9, 0x84, 0xe6, 0x6e, 0x12, 0x3f, 0x7d, 0x4a, 0xd4,
+ 0x41, 0xf1, 0xa8, 0x82, 0x78, 0xd3, 0xb7, 0xaa, 0x1b, 0xf5, 0x02, 0xd2, 0xe5, 0x38, 0x12, 0x26,
+ 0xea, 0xa1, 0xaf, 0x61, 0xd5, 0x9e, 0xa9, 0x7e, 0x18, 0xcc, 0x68, 0x31, 0x25, 0x6c, 0xd1, 0x42,
+ 0x42, 0x96, 0xa7, 0x75, 0x42, 0x50, 0x27, 0x04, 0x41, 0x0d, 0x62, 0x22, 0x11, 0xb9, 0x3b, 0x5b,
+ 0xf7, 0xef, 0x61, 0x7d, 0x01, 0x92, 0xca, 0x25, 0xb8, 0x14, 0x12, 0x56, 0x5d, 0x35, 0x7e, 0x8a,
+ 0xc8, 0xd6, 0x1c, 0x08, 0x54, 0xb3, 0x59, 0x61, 0x99, 0x84, 0xd5, 0x87, 0x6d, 0xb5, 0xb3, 0xbd,
+ 0xba, 0xe3, 0x89, 0x99, 0xd7, 0xb5, 0x9d, 0xb1, 0x5b, 0x91, 0x65, 0x20, 0x63, 0x57, 0xc2, 0xc0,
+ 0xd9, 0xb0, 0x80, 0xba, 0x49, 0x93, 0x39, 0x2e, 0x6b, 0x0a, 0xfc, 0x17, 0x5f, 0xef, 0x73, 0xe6,
+ 0x25, 0x98, 0x7c, 0xad, 0x13, 0x95, 0xd1, 0x6c, 0xb1, 0x70, 0xb1, 0x58, 0xa8, 0x8a, 0x9d, 0x17,
+ 0xa8, 0xc7, 0x59, 0x2c, 0x0c, 0xdc, 0x46, 0x31, 0x70, 0xab, 0xec, 0x60, 0x51, 0x22, 0x0a, 0xbb,
+ 0x63, 0xaa, 0x10, 0xf0, 0x41, 0x0a, 0x81, 0x88, 0x21, 0x5a, 0xb3, 0xe3, 0x8e, 0xaa, 0xd6, 0x00,
+ 0xbf, 0x9b, 0xa4, 0xd5, 0x4f, 0x5e, 0xe3, 0xc2, 0x6b, 0x21, 0x19, 0x33, 0xe7, 0x37, 0xa4, 0xe2,
+ 0x9b, 0xf2, 0xad, 0x83, 0xbc, 0x7e, 0x85, 0x9c, 0x44, 0x76, 0x23, 0xc7, 0xcf, 0x48, 0x13, 0x48,
+ 0x07, 0xde, 0x2f, 0x70, 0x16, 0x29, 0xd5, 0x58, 0x0b, 0x2a, 0xad, 0x1c, 0xb3, 0x30, 0xa0, 0x32,
+ 0x64, 0x48, 0xd0, 0x7d, 0x75, 0xff, 0xde, 0x1d, 0x63, 0xb2, 0x47, 0xf0, 0x4d, 0x01, 0xb9, 0xf1,
+ 0xb4, 0x5d, 0xa4, 0x62, 0x6f, 0x0a, 0x23, 0x82, 0x89, 0x84, 0xd5, 0x60, 0x33, 0xca, 0x8b, 0x48,
+ 0x34, 0x4d, 0x12, 0x1b, 0x84, 0xa8, 0x22, 0x06, 0xa4, 0xb1, 0x97, 0x14, 0x8e, 0x07, 0xee, 0x9e,
+ 0xc6, 0x53, 0x66, 0xa1, 0x74, 0x9b, 0x01, 0x86, 0x5b, 0x70, 0xe5, 0xe7, 0xc9, 0xb7, 0x10, 0x48,
+ 0x6a, 0x38, 0xba, 0x83, 0xf8, 0xaa, 0x6e, 0xdf, 0x17, 0xa6, 0xe7, 0x6b, 0x55, 0x60, 0xf8, 0xf1,
+ 0xea, 0x8a, 0xfe, 0x64, 0x6d, 0x75, 0x95, 0xe3, 0x11, 0x7e, 0x3b, 0x39, 0x84, 0xd8, 0x5d, 0xe7,
+ 0x2d, 0x7c, 0xf7, 0xc6, 0xf5, 0xcd, 0x4c, 0x9c, 0x6c, 0xb2, 0x8f, 0x19, 0xcd, 0xaf, 0xf4, 0x95,
+ 0xd4, 0x20, 0xe2, 0x23, 0x22, 0x14, 0x54, 0x2c, 0x0e, 0x27, 0x09, 0xf1, 0xe1, 0x67, 0x78, 0x5e,
+ 0x48, 0xdf, 0xee, 0xd8, 0xa4, 0x1d, 0xc8, 0x4c, 0x94, 0xc7, 0xf0, 0x8b, 0xcd, 0x8d, 0x07, 0x7e,
+ 0x26, 0x74, 0x0e, 0x2e, 0x7d, 0xcd, 0x33, 0xe4, 0x2d, 0xd0, 0xac, 0x70, 0x7a, 0xc3, 0x23, 0xbc,
+ 0x3d, 0xee, 0xca, 0xaf, 0x8b, 0xcd, 0xa0, 0x3e, 0xfc, 0x85, 0x1d, 0xb0, 0x52, 0x3e, 0xfa, 0xc1,
+ 0x2e, 0x9e, 0x7b, 0xce, 0xe8, 0x85, 0xd5, 0x4c, 0xd2, 0xf3, 0x37, 0xf4, 0x44, 0x1a, 0x4d, 0x12,
+ 0x4f, 0x7a, 0x7f, 0x66, 0x0d, 0x7e, 0xc7, 0xed, 0x2f, 0xca, 0x3f, 0xf1, 0xe9, 0x0c, 0x37, 0x3e,
+ 0x1b, 0xbd, 0x64, 0xbc, 0x35, 0x89, 0x82, 0x8e, 0x5e, 0x63, 0x24, 0xab, 0xbf, 0x97, 0xf4, 0x9f,
+ 0x04, 0xc4, 0xd6, 0x6a, 0x87, 0x3a, 0x9c, 0x15, 0xa9, 0xcb, 0x86, 0x9d, 0xb6, 0x62, 0x63, 0x5d,
+ 0xf6, 0x70, 0x63, 0x73, 0x8b, 0x79, 0x66, 0xac, 0x50, 0xc2, 0x5a, 0x11, 0x6a, 0x39, 0xb5, 0x5d,
+ 0xd3, 0x0c, 0x5d, 0x20, 0xc6, 0xb5, 0x2c, 0x43, 0xe0, 0x2e, 0x3d, 0xc3, 0xbd, 0x45, 0xe2, 0x1c,
+ 0x37, 0x66, 0xb0, 0x9e, 0x3f, 0xe0, 0x3d, 0x1c, 0x03, 0x4b, 0x05, 0x1c, 0xc8, 0x4a, 0xb1, 0x3c,
+ 0x36, 0xde, 0xc1, 0x1a, 0x21, 0xc9, 0x36, 0x5e, 0x58, 0x74, 0xad, 0x2a, 0x34, 0x9e, 0xf6, 0xf2,
+ 0x82, 0x52, 0x8c, 0xf7, 0xb6, 0x8e, 0x88, 0xf6, 0x9d, 0x7e, 0x91, 0x1b, 0xa7, 0x03, 0x8e, 0x70,
+ 0x26, 0x76, 0x3c, 0x40, 0x87, 0x0d, 0x73, 0xd1, 0x48, 0x2a, 0xa7, 0xd0, 0xa9, 0x93, 0x67, 0x10,
+ 0x25, 0xdd, 0x84, 0xae, 0x23, 0x41, 0x3e, 0x48, 0x6a, 0x3d, 0x88, 0x44, 0x0e, 0xec, 0xf3, 0x5b,
+ 0x5b, 0xac, 0x85, 0xd6, 0x93, 0x6b, 0xa5, 0xe4, 0x7c, 0xa2, 0x5a, 0x58, 0xb8, 0x23, 0xb9, 0xf4,
+ 0x7b, 0x22, 0xdf, 0x18, 0x48, 0xba, 0xa3, 0x21, 0xa8, 0x47, 0xae, 0xe2, 0x38, 0x2f, 0xa6, 0x19,
+ 0xba, 0x74, 0xa6, 0x86, 0x34, 0x80, 0xa8, 0xfc, 0x1b, 0x5d, 0x97, 0xb5, 0x19, 0x6f, 0xe3, 0x4f,
+ 0x8b, 0x2d, 0x31, 0xde, 0x04, 0x19, 0xa1, 0x30, 0x3f, 0x7e, 0x74, 0xf7, 0x86, 0xea, 0xe3, 0x20,
+ 0x11, 0xe5, 0x15, 0x6f, 0xb2, 0x90, 0x5d, 0xf6, 0x45, 0xa2, 0xda, 0x71, 0x1e, 0x74, 0x70, 0x24,
+ 0xf7, 0x03, 0x9b, 0x98, 0xed, 0xd1, 0x62, 0x66, 0xb5, 0x5e, 0x6e, 0xef, 0x04, 0x1f, 0x92, 0x63,
+ 0xe2, 0x67, 0x45, 0x8e, 0xa5, 0xfe, 0x8f, 0x44, 0x64, 0x98, 0xf8, 0x4f, 0x54, 0x82, 0xb3, 0x82,
+ 0xa5, 0x1c, 0xdb, 0xbb, 0x92, 0xb8, 0x44, 0x41, 0xc6, 0x92, 0x21, 0xaa, 0xc1, 0xa2, 0x83, 0xc1,
+ 0xab, 0x32, 0x47, 0x60, 0x90, 0x55, 0x82, 0x4e, 0xe5, 0x7a, 0xef, 0x1d, 0x64, 0x1b, 0x49, 0xc4,
+ 0x0d, 0x25, 0x51, 0x2c, 0xcc, 0x72, 0x75, 0x42, 0x33, 0xd4, 0x9a, 0x34, 0x89, 0xec, 0xcd, 0x77,
+ 0xe5, 0x70, 0x79, 0x30, 0x18, 0x2c, 0x93, 0xcc, 0x2e, 0x63, 0xdb, 0xaa, 0xec, 0xe8, 0x96, 0x20,
+ 0xfe, 0x83, 0xce, 0x88, 0x12, 0x31, 0x5a, 0xcc, 0x30, 0xda, 0x2e, 0xb2, 0xfa, 0x3c, 0x45, 0x88,
+ 0xc4, 0xd2, 0x52, 0x63, 0xe5, 0xeb, 0xf5, 0xa7, 0x6b, 0xeb, 0xeb, 0x9f, 0xae, 0xf8, 0x06, 0xf3,
+ 0x71, 0x31, 0x39, 0xfb, 0x99, 0x5f, 0x9c, 0x9b, 0x1e, 0x96, 0xe8, 0x50, 0x74, 0x07, 0x0f, 0x98,
+ 0xf1, 0xb6, 0x40, 0xbb, 0x76, 0x5e, 0x67, 0x54, 0x42, 0xcc, 0xab, 0x04, 0x22, 0x65, 0x73, 0xbd,
+ 0x48, 0x47, 0x8d, 0xd8, 0xd8, 0xf3, 0x97, 0x36, 0x58, 0x96, 0xd9, 0xc9, 0x23, 0x94, 0xda, 0xb4,
+ 0xde, 0x42, 0xfa, 0x89, 0xe3, 0x01, 0x4e, 0x92, 0x1a, 0xf8, 0x74, 0xfa, 0x86, 0x43, 0x64, 0x66,
+ 0x0b, 0xfc, 0x0e, 0x2e, 0xa1, 0xad, 0xdd, 0xcd, 0xbd, 0x4c, 0xe5, 0xb3, 0x8b, 0x16, 0x69, 0x0c,
+ 0xea, 0xf2, 0xe0, 0xa3, 0x7f, 0x01, 0x1a, 0x08, 0xa7, 0x18, 0x21, 0x0d, 0x00, 0x00,
+};
+const StaticFile app_js PROGMEM = {(sizeof(app_js_content)/sizeof(app_js_content[0])), app_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, 0x95, 0x26, 0x4d, 0x06, 0x65, 0x18, 0x04, 0xf7, 0x43,
+ 0x3a, 0xe7, 0x9e, 0x7b, 0xcc, 0x8c, 0x38, 0x44, 0x05, 0x96, 0xba, 0xb1, 0x54, 0x08, 0x55, 0xe5,
+ 0x24, 0xc9, 0x4a, 0xea, 0x72, 0x55, 0x91, 0xa4, 0x65, 0x5d, 0x91, 0xd5, 0x88, 0xa6, 0x8a, 0x54,
+ 0x65, 0x6b, 0xfc, 0x8d, 0x07, 0x0b, 0x9f, 0x2d, 0xf5, 0x7e, 0x67, 0x9c, 0x78, 0x3a, 0x4d, 0x22,
+ 0xec, 0xf1, 0xa9, 0x91, 0xa6, 0xc2, 0xd8, 0xab, 0x3f, 0x40, 0xd2, 0x95, 0xdd, 0x67, 0x7d, 0x28,
+ 0x69, 0xa9, 0xf4, 0x81, 0xc4, 0xd4, 0x5a, 0x0d, 0xb1, 0x3f, 0x78, 0x84, 0x32, 0xfa, 0xaa, 0x55,
+ 0xb5, 0xfd, 0x49, 0xf9, 0x63, 0x1f, 0x7e, 0x0b, 0x7d, 0xd1, 0x23, 0xe4, 0x06, 0x66, 0xbf, 0x7e,
+ 0x44, 0xdf, 0x41, 0xbf, 0x00, 0x2a, 0x4e, 0xa3, 0x2f, 0x4e, 0x51, 0x1d, 0x79, 0x5a, 0xf9, 0xd8,
+ 0x83, 0x53, 0xb2, 0x9d, 0xa3, 0x42, 0x0d, 0x47, 0xae, 0x69, 0x62, 0xf7, 0xb3, 0xfe, 0x38, 0xa2,
+ 0xed, 0x40, 0xe5, 0x05, 0x92, 0x55, 0x92, 0x64, 0x8c, 0xf2, 0x6d, 0xee, 0x4c, 0x5d, 0x89, 0x98,
+ 0x1b, 0x6d, 0x1c, 0xb9, 0x01, 0x29, 0xef, 0xe5, 0x32, 0x63, 0x81, 0x3c, 0xb8, 0x98, 0x99, 0x30,
+ 0x59, 0x49, 0xd2, 0x70, 0xdd, 0x1b, 0xad, 0xc4, 0xec, 0x46, 0x6c, 0x20, 0x81, 0x75, 0x36, 0x76,
+ 0xdf, 0xaf, 0x57, 0xc0, 0x1e, 0xb2, 0x93, 0x99, 0x96, 0x76, 0xdf, 0xce, 0x99, 0x36, 0x7c, 0x7b,
+ 0x46, 0xa1, 0x9d, 0xcb, 0x5a, 0xeb, 0x78, 0xa7, 0x04, 0x16, 0x4d, 0x7f, 0x86, 0x74, 0xf2, 0x31,
+ 0xe0, 0xec, 0xbb, 0x8b, 0x5d, 0xdb, 0x11, 0xb2, 0x6b, 0x36, 0xae, 0x7c, 0xd6, 0x94, 0xc1, 0x89,
+ 0xe8, 0xb3, 0x64, 0xb6, 0xb8, 0x1c, 0x61, 0xec, 0xed, 0x65, 0x6e, 0x86, 0xb5, 0x1c, 0x59, 0xf7,
+ 0x5c, 0xfa, 0x32, 0x2f, 0x80, 0x6f, 0xc3, 0xcb, 0xd3, 0x6b, 0x31, 0x1a, 0x4b, 0xc2, 0x6b, 0xed,
+ 0xbb, 0x76, 0x16, 0x79, 0xd0, 0xc0, 0xb1, 0x19, 0x19, 0x3a, 0x2a, 0x54, 0xed, 0xc9, 0x43, 0x20,
+ 0x33, 0x64, 0x4e, 0xf5, 0xe1, 0x1b, 0xce, 0xb9, 0xcc, 0x26, 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, 0x1e, 0x2b, 0x23, 0xf0, 0x14, 0x37, 0x97, 0x6e, 0x91, 0x69,
+ 0x70, 0xcb, 0x62, 0x72, 0xcb, 0x59, 0x72, 0x64, 0xf1, 0xbf, 0xa2, 0x75, 0xcb, 0x7b, 0x55, 0xed,
+ 0xc4, 0xa8, 0xe1, 0x7d, 0x29, 0x27, 0xff, 0x25, 0xc1, 0xc3, 0xd6, 0x78, 0x85, 0x2a, 0xcc, 0xeb,
+ 0x40, 0x53, 0x54, 0x2f, 0x90, 0x75, 0x77, 0xe2, 0x62, 0xb0, 0x48, 0xfa, 0xe9, 0x8a, 0x67, 0x46,
+ 0x81, 0x2a, 0x83, 0xb7, 0xc7, 0xe9, 0xee, 0x48, 0x61, 0x5e, 0xc0, 0xbd, 0x47, 0xe0, 0x8c, 0xd7,
+ 0xce, 0x07, 0x78, 0x6b, 0x54, 0x85, 0xe0, 0xde, 0x8c, 0xcf, 0x96, 0x9c, 0xc3, 0xe2, 0xfc, 0x0f,
+ 0xf9, 0x07, 0x22, 0xe5, 0x1d, 0xdf, 0xa6, 0x33, 0x63, 0x50, 0x63, 0x6c, 0x9a, 0x2b, 0xff, 0xec,
+ 0xc0, 0x03, 0x46, 0x6f, 0xe2, 0xeb, 0x7c, 0xa7, 0xff, 0x76, 0xb5, 0xd8, 0xa4, 0xeb, 0xf6, 0xc3,
+ 0x5f, 0xa4, 0x85, 0xb2, 0x78, 0xae, 0x04, 0x00, 0x00,
+};
+const StaticFile style_css PROGMEM = {(sizeof(style_css_content)/sizeof(style_css_content[0])), style_css_content};
+
+static const uint8_t favicon_ico_content[] PROGMEM = {
+ 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x99, 0x4b, 0x48, 0x15, 0x61,
+ 0x14, 0xc7, 0xcf, 0xc5, 0x17, 0x2e, 0x4a, 0x57, 0xe5, 0x63, 0xe1, 0x85, 0x42, 0x23, 0x8c, 0x8c,
+ 0x20, 0x4d, 0x45, 0xdb, 0x59, 0x14, 0x2e, 0x7a, 0xab, 0x68, 0x1b, 0x17, 0xae, 0x24, 0x41, 0xf1,
+ 0x41, 0xa0, 0x41, 0xa1, 0x11, 0x28, 0x1a, 0x2e, 0x12, 0x72, 0xe7, 0x03, 0x09, 0x74, 0x15, 0x54,
+ 0x1b, 0x97, 0xd9, 0x53, 0x23, 0x8a, 0x5a, 0x94, 0x25, 0x59, 0x91, 0x82, 0xa0, 0x81, 0x99, 0x39,
+ 0xfd, 0x8f, 0x73, 0x46, 0xbf, 0xc6, 0xb9, 0x73, 0x67, 0xee, 0x9d, 0xab, 0x41, 0x1e, 0xf8, 0x71,
+ 0xe7, 0x7e, 0xe7, 0xf1, 0x9f, 0xb9, 0xdf, 0x7c, 0x8f, 0x99, 0x4b, 0xe4, 0xa3, 0x28, 0x4a, 0x4c,
+ 0x24, 0x7c, 0xfa, 0xa9, 0x2a, 0x9a, 0xe8, 0x08, 0x11, 0x25, 0x25, 0xe9, 0xdf, 0xdb, 0xe2, 0x89,
+ 0x7a, 0xd1, 0xe6, 0xf7, 0xeb, 0xdf, 0x07, 0x11, 0x97, 0xbe, 0x93, 0x68, 0x1f, 0x62, 0x70, 0x88,
+ 0x16, 0xbd, 0x7d, 0xd5, 0x10, 0x37, 0x43, 0x3a, 0x0e, 0x2c, 0x06, 0xdc, 0x16, 0x62, 0x9c, 0xa5,
+ 0xfc, 0x65, 0xb1, 0x60, 0x58, 0x88, 0x75, 0x99, 0x9b, 0x06, 0x2a, 0xc1, 0x1b, 0xa1, 0x52, 0xda,
+ 0x82, 0x19, 0xae, 0x90, 0xca, 0xc0, 0x04, 0x58, 0x02, 0x9a, 0xb0, 0x24, 0x6d, 0xa5, 0x12, 0x13,
+ 0xc8, 0xd8, 0x3f, 0xab, 0xe4, 0x99, 0x99, 0x95, 0xfa, 0x56, 0xe6, 0x17, 0x8d, 0x40, 0xb9, 0x06,
+ 0x13, 0x12, 0x6b, 0xb6, 0x4b, 0xa6, 0x73, 0x0e, 0xc4, 0x92, 0xc4, 0x9a, 0xed, 0xba, 0x83, 0x5c,
+ 0x83, 0x6b, 0x16, 0xf9, 0x1d, 0x2e, 0xf2, 0x3b, 0x2c, 0xf2, 0xdb, 0x5d, 0xe4, 0xb7, 0x9b, 0x72,
+ 0xf9, 0xfe, 0xea, 0x76, 0x91, 0xcf, 0xb1, 0x6a, 0x3f, 0x9e, 0x03, 0x5f, 0x5c, 0xe4, 0x73, 0xec,
+ 0x59, 0x25, 0xbf, 0x00, 0xdc, 0x02, 0x5d, 0xe0, 0x2e, 0x58, 0x00, 0xf7, 0xe5, 0x7b, 0x97, 0x1c,
+ 0x2f, 0x88, 0xaf, 0x4b, 0x62, 0x0b, 0x4c, 0xd7, 0xe0, 0x93, 0xcf, 0x7c, 0xf0, 0x99, 0xf4, 0x7b,
+ 0xc9, 0xb0, 0x52, 0x69, 0xcb, 0x37, 0xc5, 0x5a, 0x59, 0x1e, 0x98, 0x06, 0x25, 0x4a, 0x5b, 0x89,
+ 0xb4, 0xe5, 0xd9, 0xe4, 0x19, 0x96, 0x09, 0x9e, 0x83, 0x22, 0xa5, 0xad, 0x48, 0xda, 0x32, 0xcd,
+ 0xc1, 0x8b, 0x18, 0x55, 0x93, 0x71, 0x44, 0xa3, 0x51, 0x44, 0x2d, 0x3e, 0x1d, 0xa7, 0xc6, 0x79,
+ 0x3c, 0xcf, 0x60, 0x2a, 0xa2, 0x2c, 0x52, 0xe6, 0x99, 0x78, 0x57, 0xf3, 0x8c, 0xd9, 0x52, 0xc1,
+ 0x43, 0xf0, 0x40, 0x8e, 0xbd, 0xb6, 0x1c, 0xf0, 0x5e, 0xc8, 0x89, 0x40, 0xfd, 0x6c, 0xf0, 0x41,
+ 0xc8, 0xf6, 0xb0, 0x2e, 0x8f, 0x91, 0xbd, 0xa0, 0x0a, 0x7c, 0x15, 0xf8, 0x78, 0x0f, 0xd9, 0xcf,
+ 0x69, 0xc1, 0x8c, 0x7b, 0x9c, 0xbb, 0xaf, 0x0d, 0x3c, 0x05, 0xf3, 0xb4, 0x3e, 0x4e, 0xe6, 0xa5,
+ 0xad, 0x15, 0x1c, 0x24, 0xfb, 0xfb, 0xd6, 0xca, 0xf8, 0xbc, 0x78, 0x6c, 0x3a, 0x99, 0x17, 0xc7,
+ 0x25, 0xd6, 0xe9, 0xb5, 0xf0, 0xb9, 0x9c, 0x27, 0x7d, 0x5c, 0x38, 0x1d, 0xf7, 0xd3, 0x92, 0xe3,
+ 0xe4, 0x3a, 0xf8, 0x37, 0x79, 0xe1, 0xa2, 0xb6, 0x7a, 0x1d, 0x59, 0x41, 0x6a, 0x73, 0x5f, 0xb6,
+ 0x85, 0x50, 0xdb, 0xe0, 0x06, 0xd9, 0xaf, 0xc9, 0xe9, 0xa4, 0xf7, 0x5b, 0xa8, 0xf5, 0x9f, 0x48,
+ 0x8d, 0x40, 0x76, 0x0c, 0xfc, 0x08, 0xa3, 0x3e, 0xcf, 0xa5, 0x85, 0x36, 0xf5, 0xcf, 0x84, 0x51,
+ 0xdb, 0xe0, 0xb4, 0x4d, 0xfd, 0x72, 0x0f, 0xea, 0x97, 0xdb, 0xd4, 0xaf, 0xf0, 0xa0, 0x7e, 0xc5,
+ 0x16, 0xd6, 0x2f, 0xf3, 0xa0, 0x7e, 0xa0, 0x7d, 0x12, 0xcf, 0x57, 0x9d, 0x1e, 0xd4, 0xef, 0x94,
+ 0x5a, 0xaa, 0x61, 0x15, 0xa3, 0x5e, 0xf0, 0xd3, 0x83, 0xfa, 0x5c, 0xe3, 0x8e, 0xd4, 0x34, 0x8c,
+ 0xe7, 0x8d, 0x26, 0xf0, 0x58, 0x18, 0x03, 0xcf, 0x48, 0x5f, 0xea, 0x38, 0x67, 0x4a, 0xf1, 0x19,
+ 0x4c, 0x89, 0x6f, 0x46, 0x62, 0xc7, 0x14, 0x5f, 0x13, 0x6d, 0x9c, 0x8b, 0x78, 0x5c, 0xef, 0x06,
+ 0x29, 0xa4, 0x2f, 0xad, 0xbc, 0xc4, 0x8e, 0x80, 0x15, 0xd2, 0xe7, 0xe1, 0x64, 0xf1, 0xa5, 0xc8,
+ 0x71, 0xab, 0xf8, 0x46, 0x68, 0x7d, 0x39, 0x4e, 0x91, 0x1a, 0x4e, 0xf6, 0xed, 0x09, 0xa0, 0x5f,
+ 0x6a, 0x34, 0x58, 0xf8, 0x1b, 0xc4, 0xd7, 0x2f, 0xb1, 0x6e, 0x8d, 0x1f, 0x43, 0x06, 0xa4, 0x46,
+ 0xa3, 0x85, 0xbf, 0x51, 0x7c, 0x03, 0x12, 0xeb, 0xd6, 0xf8, 0x9c, 0xfa, 0xa4, 0x46, 0xbd, 0x85,
+ 0xbf, 0x5e, 0x7c, 0x7d, 0x14, 0xda, 0xf9, 0x73, 0xff, 0x5f, 0x01, 0x9f, 0xc0, 0x49, 0x0b, 0x3f,
+ 0xb7, 0x4d, 0x49, 0x4c, 0x94, 0x85, 0x7f, 0xd5, 0xb4, 0xab, 0xda, 0x2a, 0x2b, 0xd4, 0x4c, 0x8b,
+ 0x78, 0x94, 0x99, 0xc3, 0xa9, 0x4c, 0x52, 0xdc, 0x2a, 0xa3, 0x48, 0x63, 0x5a, 0x70, 0x2b, 0x04,
+ 0xc2, 0x6c, 0xdc, 0xb6, 0x98, 0x94, 0xb6, 0x06, 0x77, 0xbc, 0x9f, 0xf4, 0x89, 0x79, 0x6d, 0x1f,
+ 0x96, 0xb8, 0x71, 0x1f, 0x76, 0xea, 0x72, 0x51, 0x48, 0x48, 0xf9, 0x77, 0xe0, 0x2d, 0x48, 0x0b,
+ 0xb5, 0x4e, 0x18, 0xfa, 0xd5, 0xb4, 0x3e, 0x36, 0xab, 0xb7, 0x40, 0xbf, 0x56, 0xd1, 0xaf, 0xfd,
+ 0x0f, 0xf5, 0xeb, 0x14, 0xfd, 0xba, 0x4d, 0xd2, 0xe4, 0xdb, 0x9a, 0x9f, 0x35, 0x5f, 0x82, 0xdf,
+ 0x8a, 0x3e, 0x1f, 0xf3, 0x9e, 0x92, 0x9f, 0x23, 0x33, 0x22, 0xa0, 0xcb, 0x73, 0xe7, 0x10, 0xe9,
+ 0xf3, 0x46, 0xb0, 0x75, 0x82, 0x63, 0x06, 0xc1, 0x2e, 0x8f, 0xb4, 0xf3, 0x68, 0x7d, 0x3d, 0x71,
+ 0xc3, 0x77, 0x90, 0x1b, 0xa6, 0x76, 0x21, 0xe9, 0x7b, 0xb3, 0x50, 0xd7, 0x4c, 0xce, 0x2d, 0x08,
+ 0xe3, 0x37, 0x77, 0xb3, 0x97, 0x0f, 0xc4, 0x37, 0x90, 0x1c, 0x82, 0xfe, 0x90, 0x07, 0xda, 0x06,
+ 0x83, 0x2e, 0xb5, 0xf7, 0x9b, 0xee, 0xef, 0x70, 0xe1, 0x5a, 0x07, 0x5c, 0xe8, 0xbb, 0x79, 0x77,
+ 0xe3, 0x94, 0x9b, 0x2e, 0xf4, 0x5f, 0x47, 0x40, 0xff, 0x95, 0x43, 0x6d, 0xde, 0x23, 0x39, 0x19,
+ 0xe7, 0xa1, 0xf4, 0x41, 0xb4, 0x03, 0xfd, 0xd4, 0x08, 0x68, 0x1b, 0xa4, 0x38, 0xd0, 0xcf, 0x88,
+ 0xa0, 0x7e, 0xd0, 0xb9, 0x79, 0x5b, 0x7f, 0x5b, 0x7f, 0xab, 0xf4, 0x61, 0x17, 0xc1, 0xbd, 0x08,
+ 0xea, 0x73, 0xed, 0x0b, 0x01, 0xb4, 0xe3, 0xc1, 0xaf, 0x08, 0x6a, 0x1b, 0xf0, 0xff, 0x05, 0xf1,
+ 0x16, 0xfa, 0xfc, 0x50, 0x31, 0xbe, 0x09, 0xfa, 0xfc, 0x0e, 0xcd, 0x67, 0xf3, 0x1b, 0x1c, 0x02,
+ 0x87, 0x4d, 0xd4, 0x28, 0xf9, 0xc3, 0x16, 0x7e, 0x83, 0x61, 0x25, 0xae, 0xc6, 0xc2, 0x7f, 0xc8,
+ 0xea, 0xda, 0x1d, 0xdc, 0x93, 0xc5, 0x4a, 0xdd, 0x1e, 0x9b, 0xb8, 0x1e, 0x25, 0xae, 0xd8, 0xad,
+ 0xce, 0xb6, 0xfe, 0x3f, 0xab, 0x7f, 0x42, 0xa9, 0xdb, 0x6d, 0x13, 0xa7, 0xfe, 0x67, 0x75, 0xdc,
+ 0x43, 0xfd, 0x1d, 0xa4, 0xbf, 0x27, 0xe2, 0x77, 0xfc, 0x47, 0x6d, 0xe2, 0x72, 0x49, 0xdf, 0xef,
+ 0x3e, 0xe2, 0x1c, 0x27, 0xb5, 0xb5, 0xe5, 0x42, 0x6d, 0x03, 0x93, 0x71, 0x9a, 0xd6, 0xe2, 0xd3,
+ 0x70, 0x11, 0x1a, 0x36, 0x42, 0xcd, 0xcb, 0x78, 0x0e, 0x60, 0x16, 0xf1, 0x9c, 0x6f, 0x30, 0x47,
+ 0x94, 0x60, 0xc7, 0x24, 0x51, 0xdc, 0x28, 0x51, 0x14, 0xc3, 0xc7, 0x5a, 0xeb, 0x47, 0xcd, 0x8a,
+ 0x3f, 0x05, 0x2f, 0x43, 0xb9, 0xce, 0x1e, 0x00, 0x00,
+};
+const StaticFile favicon_ico PROGMEM = {(sizeof(favicon_ico_content)/sizeof(favicon_ico_content[0])), favicon_ico_content};
+
+}
diff --git a/platformio/relayctl/src/static.h b/platformio/relayctl/src/static.h
new file mode 100644
index 0000000..3273e68
--- /dev/null
+++ b/platformio/relayctl/src/static.h
@@ -0,0 +1,21 @@
+/**
+ * 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;
+
+extern const StaticFile index_html;
+extern const StaticFile app_js;
+extern const StaticFile style_css;
+extern const StaticFile favicon_ico;
+
+}
diff --git a/platformio/relayctl/src/stopwatch.h b/platformio/relayctl/src/stopwatch.h
new file mode 100644
index 0000000..bac2fcc
--- /dev/null
+++ b/platformio/relayctl/src/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/platformio/relayctl/src/wifi.cpp b/platformio/relayctl/src/wifi.cpp
new file mode 100644
index 0000000..b85d1dc
--- /dev/null
+++ b/platformio/relayctl/src/wifi.cpp
@@ -0,0 +1,49 @@
+#include "config.def.h"
+#include "wifi.h"
+#include "config.h"
+#include "logging.h"
+
+namespace homekit::wifi {
+
+using namespace homekit;
+using homekit::config::ConfigData;
+
+const char NODE_ID[] = DEFAULT_NODE_ID;
+const char WIFI_AP_SSID[] = DEFAULT_WIFI_AP_SSID;
+const char WIFI_STA_SSID[] = DEFAULT_WIFI_STA_SSID;
+const char WIFI_STA_PSK[] = DEFAULT_WIFI_STA_PSK;
+
+void getConfig(ConfigData& cfg, char** ssid_dst, char** psk_dst, char** hostname_dst) {
+ if (cfg.flags.wifi_configured) {
+ *ssid_dst = cfg.wifi_ssid;
+ *psk_dst = cfg.wifi_psk;
+ if (hostname_dst != nullptr)
+ *hostname_dst = cfg.node_id;
+ } else {
+ *ssid_dst = (char*)WIFI_STA_SSID;
+ *psk_dst = (char*)WIFI_STA_PSK;
+ if (hostname_dst != nullptr)
+ *hostname_dst = (char*)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/platformio/relayctl/src/wifi.h b/platformio/relayctl/src/wifi.h
new file mode 100644
index 0000000..65dbd53
--- /dev/null
+++ b/platformio/relayctl/src/wifi.h
@@ -0,0 +1,35 @@
+#pragma once
+
+#include <ESP8266WiFi.h>
+#include <list>
+#include <memory>
+#include "config.h"
+
+namespace homekit::wifi {
+
+using homekit::config::ConfigData;
+
+struct ScanResult {
+ int rssi;
+ String ssid;
+};
+
+void getConfig(ConfigData &cfg, char **ssid_dst, char **psk_dst, char **hostname_dst);
+std::shared_ptr<std::list<ScanResult>> scan();
+
+inline int8_t getRSSI() {
+ return WiFi.RSSI();
+}
+
+inline uint32_t getIPAsInteger() {
+ if (!WiFi.isConnected())
+ return 0;
+ return WiFi.localIP().v4();
+}
+
+extern const char WIFI_AP_SSID[];
+extern const char WIFI_STA_SSID[];
+extern const char WIFI_STA_PSK[];
+extern const char NODE_ID[];
+
+} \ No newline at end of file
diff --git a/platformio/relayctl/static/app.js b/platformio/relayctl/static/app.js
new file mode 100644
index 0000000..e345957
--- /dev/null
+++ b/platformio/relayctl/static/app.js
@@ -0,0 +1,222 @@
+function isObject(o) {
+ return Object.prototype.toString.call(o) === '[object Object]';
+}
+
+function ge(id) {
+ return document.getElementById(id)
+}
+
+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) {
+ ge('loading_label').style.display = 'none';
+ }
+ }
+
+ var form = document.forms.network_settings;
+ form.addEventListener('submit', function(e) {
+ if (!form.nid.value.trim()) {
+ alert('Введите node 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.nid, 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() {
+ if (xhr.readyState === 4) {
+ var response = JSON.parse(xhr.responseText);
+ if (response.result === 1) {
+ alert('Обновление завершено, устройство перезагружается');
+ } else {
+ alert('Ошибка обновления');
+ }
+ }
+ };
+ xhr.onerror = function(e) {
+ alert(errorText(e))
+ };
+
+ xhr.open('POST', e.target.action);
+ xhr.send(fd);
+
+ return false;
+ });
+}
+
+function initApp() {
+ initNetworkSettings();
+ initUpdateForm();
+} \ No newline at end of file
diff --git a/platformio/relayctl/static/favicon.ico b/platformio/relayctl/static/favicon.ico
new file mode 100644
index 0000000..6940e4f
--- /dev/null
+++ b/platformio/relayctl/static/favicon.ico
Binary files differ
diff --git a/platformio/relayctl/static/index.html b/platformio/relayctl/static/index.html
new file mode 100644
index 0000000..d89967d
--- /dev/null
+++ b/platformio/relayctl/static/index.html
@@ -0,0 +1,62 @@
+<!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="/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_checkbox">
+ <label for="show_psk"><input type="checkbox" name="show_psk" id="show_psk"> show password</label>
+ </div>
+ </div>
+
+ <div class="form_label">Node ID</div>
+ <div class="form_input">
+ <input type="text" value="" maxlength="16" name="nid" id="fld_nid" 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">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/platformio/relayctl/static/style.css b/platformio/relayctl/static/style.css
new file mode 100644
index 0000000..9a950a5
--- /dev/null
+++ b/platformio/relayctl/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_checkbox {
+ 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/tools/minify.js b/tools/minify.js
new file mode 100755
index 0000000..105c4eb
--- /dev/null
+++ b/tools/minify.js
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+
+const {minify: minifyJs} = require('terser')
+const {minify: minifyHtml} = require('html-minifier-terser')
+const CleanCSS = require('clean-css');
+const parseArgs = require('minimist')
+const {promises: fs} = require('fs')
+
+const argv = process.argv.slice(2)
+if (!argv.length) {
+ console.log(`usage: ${process.argv[1]} --type js|css|html filename`)
+ process.exit(1)
+}
+
+async function read() {
+ const chunks = []
+ for await (const chunk of process.stdin)
+ chunks.push(chunk)
+ return Buffer.concat(chunks).toString('utf-8')
+}
+
+const args = parseArgs(argv, {
+ string: ['type'],
+})
+
+;(async () => {
+ if (!['js', 'css', 'html'].includes(args.type))
+ throw new Error('invalid type')
+
+ const content = await read()
+
+ switch (args.type) {
+ case 'html':
+ console.log(await minifyHtml(content, {
+ collapseBooleanAttributes: true,
+ collapseInlineTagWhitespace: true,
+ collapseWhitespace: true,
+ conservativeCollapse: true,
+ html5: true,
+ includeAutoGeneratedTags: true,
+ keepClosingSlash: false,
+ minifyCSS: true,
+ minifyJS: true,
+ minifyURLs: false,
+ preserveLineBreaks: true,
+ removeComments: true,
+ removeAttributeQuotes: false,
+ sortAttributes: false,
+ sortClassName: false,
+ useShortDoctype: true,
+ }))
+ break
+
+ case 'css':
+ console.log(new CleanCSS({level:2}).minify(content).styles)
+ break
+
+ case 'js':
+ console.log((await minifyJs(content, {
+ ecma: 5
+ })).code)
+ break
+ }
+})()
diff --git a/yarn.lock b/yarn.lock
new file mode 100644
index 0000000..864363b
--- /dev/null
+++ b/yarn.lock
@@ -0,0 +1,180 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@jridgewell/gen-mapping@^0.3.0":
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9"
+ integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==
+ dependencies:
+ "@jridgewell/set-array" "^1.0.1"
+ "@jridgewell/sourcemap-codec" "^1.4.10"
+ "@jridgewell/trace-mapping" "^0.3.9"
+
+"@jridgewell/resolve-uri@3.1.0":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
+ integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
+
+"@jridgewell/set-array@^1.0.1":
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
+ integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
+
+"@jridgewell/source-map@^0.3.2":
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb"
+ integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.0"
+ "@jridgewell/trace-mapping" "^0.3.9"
+
+"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10":
+ version "1.4.14"
+ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
+ integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
+
+"@jridgewell/trace-mapping@^0.3.9":
+ version "0.3.17"
+ resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985"
+ integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==
+ dependencies:
+ "@jridgewell/resolve-uri" "3.1.0"
+ "@jridgewell/sourcemap-codec" "1.4.14"
+
+acorn@^8.5.0:
+ version "8.8.1"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73"
+ integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==
+
+buffer-from@^1.0.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
+ integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
+
+camel-case@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a"
+ integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==
+ dependencies:
+ pascal-case "^3.1.2"
+ tslib "^2.0.3"
+
+clean-css@5.2.0:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.2.0.tgz#44e4a04e8873ff0041df97acecf23a4a6519844e"
+ integrity sha512-2639sWGa43EMmG7fn8mdVuBSs6HuWaSor+ZPoFWzenBc6oN+td8YhTfghWXZ25G1NiiSvz8bOFBS7PdSbTiqEA==
+ dependencies:
+ source-map "~0.6.0"
+
+clean-css@^5.3.1:
+ version "5.3.1"
+ resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.1.tgz#d0610b0b90d125196a2894d35366f734e5d7aa32"
+ integrity sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==
+ dependencies:
+ source-map "~0.6.0"
+
+commander@^2.20.0:
+ version "2.20.3"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+ integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
+commander@^9.4.1:
+ version "9.4.1"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-9.4.1.tgz#d1dd8f2ce6faf93147295c0df13c7c21141cfbdd"
+ integrity sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==
+
+dot-case@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751"
+ integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==
+ dependencies:
+ no-case "^3.0.4"
+ tslib "^2.0.3"
+
+entities@^4.4.0:
+ version "4.4.0"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174"
+ integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==
+
+html-minifier-terser@^7.1.0:
+ version "7.1.0"
+ resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-7.1.0.tgz#cd62d42158be9a6bef0fcd40f94127345743d9b5"
+ integrity sha512-BvPO2S7Ip0Q5qt+Y8j/27Vclj6uHC6av0TMoDn7/bJPhMWHI2UtR2e/zEgJn3/qYAmxumrGp9q4UHurL6mtW9Q==
+ dependencies:
+ camel-case "^4.1.2"
+ clean-css "5.2.0"
+ commander "^9.4.1"
+ entities "^4.4.0"
+ param-case "^3.0.4"
+ relateurl "^0.2.7"
+ terser "^5.15.1"
+
+lower-case@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28"
+ integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==
+ dependencies:
+ tslib "^2.0.3"
+
+minimist@^1.2.7:
+ version "1.2.7"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
+ integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
+
+no-case@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d"
+ integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==
+ dependencies:
+ lower-case "^2.0.2"
+ tslib "^2.0.3"
+
+param-case@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"
+ integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==
+ dependencies:
+ dot-case "^3.0.4"
+ tslib "^2.0.3"
+
+pascal-case@^3.1.2:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb"
+ integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==
+ dependencies:
+ no-case "^3.0.4"
+ tslib "^2.0.3"
+
+relateurl@^0.2.7:
+ version "0.2.7"
+ resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
+ integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==
+
+source-map-support@~0.5.20:
+ version "0.5.21"
+ resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+ integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
+ dependencies:
+ buffer-from "^1.0.0"
+ source-map "^0.6.0"
+
+source-map@^0.6.0, source-map@~0.6.0:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+ integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+terser@^5.15.1, terser@^5.16.1:
+ version "5.16.1"
+ resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.1.tgz#5af3bc3d0f24241c7fb2024199d5c461a1075880"
+ integrity sha512-xvQfyfA1ayT0qdK47zskQgRZeWLoOQ8JQ6mIgRGVNwZKdQMU+5FkCBjmv4QjcrTzyZquRw2FVtlJSRUmMKQslw==
+ dependencies:
+ "@jridgewell/source-map" "^0.3.2"
+ acorn "^8.5.0"
+ commander "^2.20.0"
+ source-map-support "~0.5.20"
+
+tslib@^2.0.3:
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
+ integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==