summaryrefslogtreecommitdiff
path: root/platformio/relayctl/src
diff options
context:
space:
mode:
Diffstat (limited to 'platformio/relayctl/src')
-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
18 files changed, 1379 insertions, 0 deletions
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