mqtt, esp: add new esp8266-based device

This commit is contained in:
Evgeny Zinoviev 2023-05-11 04:18:08 +03:00
parent 586d84b0c0
commit 0aba139aef
56 changed files with 2320 additions and 319 deletions

View File

@ -3,11 +3,12 @@
#set -x
#set -e
DIR="$(dirname "$(realpath "$0")")"
COMMON_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)"
PROJECT_DIR="$(pwd)"
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"
fw_version="$(cat "$PROJECT_DIR/src/config.def.h" | grep "^#define FW_VERSION" | awk '{print $3}')"
header="$PROJECT_DIR/src/static.h"
source="$PROJECT_DIR/src/static.cpp"
[ -f "$header" ] && rm "$header"
[ -f "$source" ] && rm "$source"
@ -19,7 +20,7 @@ is_minifyable() {
minify() {
local ext="$1"
local bin="$(realpath "$DIR"/../../tools/minify.js)"
local bin="$(realpath "$COMMON_DIR"/../../tools/minify.js)"
"$bin" --type "$ext"
}
@ -55,7 +56,7 @@ EOF
# loop over files
for ext in html js css ico; do
for f in "$DIR"/static/*.$ext; do
for f in "$COMMON_DIR"/static/*.$ext; do
filename="$(basename "$f")"
echo "processing ${filename}..."
filename="${filename/./_}"

View File

@ -144,7 +144,7 @@ function initNetworkSettings() {
if (error)
throw error;
setupField(form.hid, response.home_id || null);
setupField(form.hid, response.node_id || null);
setupField(form.psk, null);
setupField(form.submit, null);

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -140,8 +140,8 @@ void loop() {
if (mqtt->ota.readyToRestart) {
mqtt->disconnect();
} else if (mqtt->statStopWatch.elapsed(10000)) {
mqtt->sendStat();
} else if (mqtt->diagnosticsStopWatch.elapsed(10000)) {
mqtt->sendDiagnostics();
}
#if MQTT_BLINK

View File

@ -19,8 +19,8 @@ static const char MQTT_PASSWORD[] = DEFAULT_MQTT_PASSWORD;
static const char MQTT_CLIENT_ID[] = DEFAULT_MQTT_CLIENT_ID;
static const char MQTT_SECRET[HOME_SECRET_SIZE+1] = HOME_SECRET;
static const char TOPIC_STAT[] = "stat";
static const char TOPIC_INITIAL_STAT[] = "stat1";
static const char TOPIC_DIAGNOSTICS[] = "stat";
static const char TOPIC_INITIAL_DIAGNOSTICS[] = "stat1";
static const char TOPIC_OTA_RESPONSE[] = "otares";
static const char TOPIC_RELAY_POWER[] = "power";
static const char TOPIC_ADMIN_OTA[] = "admin/ota";
@ -45,7 +45,7 @@ MQTT::MQTT() {
client.onConnect([&](bool sessionPresent) {
PRINTLN("mqtt: connected");
sendInitialStat();
sendInitialDiagnostics();
subscribe(TOPIC_RELAY_POWER, 1);
subscribe(TOPIC_ADMIN_OTA);
@ -174,36 +174,36 @@ uint16_t MQTT::subscribe(const String &topic, uint8_t qos) {
return packetId;
}
void MQTT::sendInitialStat() {
void MQTT::sendInitialDiagnostics() {
auto cfg = config::read();
InitialStatPayload stat{
InitialDiagnosticsPayload stat{
.ip = wifi::getIPAsInteger(),
.fw_version = FW_VERSION,
.rssi = wifi::getRSSI(),
.free_heap = ESP.getFreeHeap(),
.flags = StatFlags{
.flags = DiagnosticsFlags{
.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_INITIAL_STAT, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
statStopWatch.save();
publish(TOPIC_INITIAL_DIAGNOSTICS, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
diagnosticsStopWatch.save();
}
void MQTT::sendStat() {
StatPayload stat{
void MQTT::sendDiagnostics() {
DiagnosticsPayload stat{
.rssi = wifi::getRSSI(),
.free_heap = ESP.getFreeHeap(),
.flags = StatFlags{
.flags = DiagnosticsFlags{
.state = static_cast<uint8_t>(relay::getState() ? 1 : 0),
.config_changed_value_present = 0,
.config_changed = 0
}
};
publish(TOPIC_STAT, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
statStopWatch.save();
publish(TOPIC_DIAGNOSTICS, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
diagnosticsStopWatch.save();
}
uint16_t MQTT::sendOtaResponse(OTAResult status, uint8_t error_code) {
@ -237,7 +237,7 @@ void MQTT::handleRelayPowerPayload(const uint8_t *payload, uint32_t length) {
PRINTLN("error: unexpected state value");
}
sendStat();
sendDiagnostics();
}
void MQTT::handleAdminOtaPayload(uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) {

View File

@ -52,11 +52,11 @@ private:
uint16_t publish(const String& topic, uint8_t* payload, size_t length);
uint16_t subscribe(const String& topic, uint8_t qos = 0);
void sendInitialStat();
void sendInitialDiagnostics();
uint16_t sendOtaResponse(OTAResult status, uint8_t error_code = 0);
public:
StopWatch statStopWatch;
StopWatch diagnosticsStopWatch;
OTAStatus ota;
MQTT();
@ -64,28 +64,28 @@ public:
void disconnect();
void reconnect();
void loop();
void sendStat();
void sendDiagnostics();
};
struct StatFlags {
struct DiagnosticsFlags {
uint8_t state: 1;
uint8_t config_changed_value_present: 1;
uint8_t config_changed: 1;
uint8_t reserved: 5;
} __attribute__((packed));
struct InitialStatPayload {
struct InitialDiagnosticsPayload {
uint32_t ip;
uint8_t fw_version;
int8_t rssi;
uint32_t free_heap;
StatFlags flags;
DiagnosticsFlags flags;
} __attribute__((packed));
struct StatPayload {
struct DiagnosticsPayload {
int8_t rssi;
uint32_t free_heap;
StatFlags flags;
DiagnosticsFlags flags;
} __attribute__((packed));
struct PowerPayload {

3
platformio/temphum/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.pio
CMakeListsPrivate.txt
cmake-build-*/

View File

@ -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("temphum" 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})

View File

@ -0,0 +1,23 @@
; 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 =
https://github.com/bertmelis/espMqttClient#unordered-acks
;build_flags =
; -DDEBUG
; -DDEBUG_ESP_SSL
; -DDEBUG_ESP_PORT=Serial
build_type = release

View File

@ -0,0 +1,84 @@
#include <EEPROM.h>
#include <strings.h>
#include "config.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] PROGMEM = {
0x00000000, 0x1db71064, 0x3b6e20c8, 0x26d930ac,
0x76dc4190, 0x6b6b51f4, 0x4db26158, 0x5005713c,
0xedb88320, 0xf00f9344, 0xd6d6a3e8, 0xcb61b38c,
0x9b64c2b0, 0x86d3d2d4, 0xa00ae278, 0xbdbdf21c
};
static uint32_t eeprom_crc(const uint8_t* data, size_t len) {
uint32_t crc = ~0L;
for (size_t index = 0; index < len; index++) {
crc = pgm_read_word(&crc_table[(crc ^ data[index]) & 0x0f]) ^ (crc >> 4);
crc = pgm_read_word(&crc_table[(crc ^ (data[index] >> 4)) & 0x0f]) ^ (crc >> 4);
crc = ~crc;
}
return crc;
}
ConfigData read() {
ConfigData data;
EEPROM.begin(sizeof(ConfigData));
EEPROM.get(0, data);
EEPROM.end();
#ifdef DEBUG
if (!isValid(data)) {
PRINTLN("config::read(): data is not valid!");
}
#endif
return data;
}
void write(ConfigData& data) {
EEPROM.begin(sizeof(ConfigData));
data.magic = magic;
data.crc = GET_DATA_CRC(data);
EEPROM.put(0, data);
EEPROM.end();
}
void erase() {
ConfigData data;
erase(data);
}
void erase(ConfigData& data) {
bzero(reinterpret_cast<uint8_t*>(&data), sizeof(data));
write(data);
}
bool isValid(ConfigData& data) {
return data.crc == GET_DATA_CRC(data);
}
bool isDirty(ConfigData& data) {
return data.magic != magic;
}
char* ConfigData::escapeHomeId(char* buf, size_t len) {
if (len < 32)
return nullptr;
size_t id_len = strlen(node_id);
char* c = node_id;
char* dst = buf;
for (size_t i = 0; i < id_len; i++) {
if (*c == '"')
*(dst++) = '\\';
*(dst++) = *c;
c++;
}
*dst = '\0';
return buf;
}
}

View File

@ -0,0 +1,33 @@
#pragma once
#define FW_VERSION 7
#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
#define RELAY_PIN 5
// 12 bytes string
#define HOME_SECRET_SIZE 12
#define HOME_SECRET ""
#define MQTT_BLINK 1
#define ARRAY_SIZE(X) sizeof((X))/sizeof((X)[0])

View File

@ -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* escapeHomeId(char* buf, size_t len);
} __attribute__((packed));
ConfigData read();
void write(ConfigData& data);
void erase();
void erase(ConfigData& data);
bool isValid(ConfigData& data);
bool isDirty(ConfigData& data);
}

View File

@ -0,0 +1,279 @@
#include <Arduino.h>
#include <string.h>
#include "static.h"
#include "http_server.h"
#include "config.h"
#include "config.def.h"
#include "logging.h"
#include "util.h"
#include "led.h"
namespace homekit {
using files::StaticFile;
static const char CONTENT_TYPE_HTML[] PROGMEM = "text/html; charset=utf-8";
static const char CONTENT_TYPE_CSS[] PROGMEM = "text/css";
static const char CONTENT_TYPE_JS[] PROGMEM = "application/javascript";
static const char CONTENT_TYPE_JSON[] PROGMEM = "application/json";
static const char CONTENT_TYPE_FAVICON[] PROGMEM = "image/x-icon";
static const char JSON_UPDATE_FMT[] PROGMEM = "{\"result\":%d}";
static const char JSON_STATUS_FMT[] PROGMEM = "{\"node_id\":\"%s\""
#ifdef DEBUG
",\"configured\":%d"
",\"crc\":%u"
",\"fl_n\":%d"
",\"fl_w\":%d"
#endif
"}";
static const size_t JSON_BUF_SIZE = 192;
static const char JSON_SCAN_FIRST_LIST[] PROGMEM = "{\"list\":[";
static const char MSG_IS_INVALID[] PROGMEM = " is invalid";
static const char MSG_IS_MISSING[] PROGMEM = " is missing";
static const char GZIP[] PROGMEM = "gzip";
static const char CONTENT_ENCODING[] PROGMEM = "Content-Encoding";
static const char NOT_FOUND[] PROGMEM = "Not Found";
static const char ROUTE_STYLE_CSS[] PROGMEM = "/style.css";
static const char ROUTE_APP_JS[] PROGMEM = "/app.js";
static const char ROUTE_MD5_JS[] PROGMEM = "/md5.js";
static const char ROUTE_FAVICON_ICO[] PROGMEM = "/favicon.ico";
static const char ROUTE_STATUS[] PROGMEM = "/status";
static const char ROUTE_SCAN[] PROGMEM = "/scan";
static const char ROUTE_RESET[] PROGMEM = "/reset";
// #ifdef DEBUG
static const char ROUTE_HEAP[] PROGMEM = "/heap";
// #endif
static const char ROUTE_UPDATE[] PROGMEM = "/update";
void HttpServer::start() {
server.on(FPSTR(ROUTE_STYLE_CSS), HTTP_GET, [&]() { sendGzip(files::style_css, CONTENT_TYPE_CSS); });
server.on(FPSTR(ROUTE_APP_JS), HTTP_GET, [&]() { sendGzip(files::app_js, CONTENT_TYPE_JS); });
server.on(FPSTR(ROUTE_MD5_JS), HTTP_GET, [&]() { sendGzip(files::md5_js, CONTENT_TYPE_JS); });
server.on(FPSTR(ROUTE_FAVICON_ICO), HTTP_GET, [&]() { sendGzip(files::favicon_ico, CONTENT_TYPE_FAVICON); });
server.on("/", HTTP_GET, [&]() { sendGzip(files::index_html, CONTENT_TYPE_HTML); });
server.on(FPSTR(ROUTE_STATUS), HTTP_GET, [&]() {
char json_buf[JSON_BUF_SIZE];
auto cfg = config::read();
if (!isValid(cfg) || !cfg.flags.node_configured) {
sprintf_P(json_buf, JSON_STATUS_FMT
, 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.escapeHomeId(escaped_node_id, 32);
sprintf_P(json_buf, JSON_STATUS_FMT
, escaped_node_id_res == nullptr ? "?" : escaped_node_id
#ifdef DEBUG
, 1
, cfg.crc
, cfg.flags.node_configured
, cfg.flags.wifi_configured
#endif
);
}
server.send(200, FPSTR(CONTENT_TYPE_JSON), json_buf);
});
server.on(FPSTR(ROUTE_STATUS), HTTP_POST, [&]() {
auto cfg = config::read();
String s;
if (!getInputParam("ssid", 32, s)) return;
strncpy(cfg.wifi_ssid, s.c_str(), 32);
PRINTF("saving ssid: %s\n", cfg.wifi_ssid);
if (!getInputParam("psk", 63, s)) return;
strncpy(cfg.wifi_psk, s.c_str(), 63);
PRINTF("saving psk: %s\n", cfg.wifi_psk);
if (!getInputParam("hid", 16, s)) return;
strcpy(cfg.node_id, s.c_str());
PRINTF("saving home id: %s\n", cfg.node_id);
cfg.flags.node_configured = 1;
cfg.flags.wifi_configured = 1;
config::write(cfg);
restartTimer.once(0, restart);
});
server.on(FPSTR(ROUTE_RESET), HTTP_POST, [&]() {
config::erase();
restartTimer.once(1, restart);
});
server.on(FPSTR(ROUTE_HEAP), HTTP_GET, [&]() {
server.send(200, FPSTR(CONTENT_TYPE_HTML), String(ESP.getFreeHeap()));
});
server.on(FPSTR(ROUTE_SCAN), HTTP_GET, [&]() {
size_t i = 0;
size_t len;
const char* ssid;
bool enough = false;
bzero(reinterpret_cast<uint8_t*>(scanBuf), scanBufSize);
char* cur = scanBuf;
strncpy_P(cur, JSON_SCAN_FIRST_LIST, scanBufSize);
cur += 9;
for (auto& res: *scanResults) {
ssid = res.ssid.c_str();
len = res.ssid.length();
// new item (array with 2 items)
*cur++ = '[';
// 1. ssid (string)
*cur++ = '"';
for (size_t j = 0; j < len; j++) {
if (*(ssid+j) == '"')
*cur++ = '\\';
*cur++ = *(ssid+j);
}
*cur++ = '"';
*cur++ = ',';
// 2. rssi (number)
cur += sprintf(cur, "%d", res.rssi);
// close array
*cur++ = ']';
if ((size_t)(cur - scanBuf) >= (size_t) ARRAY_SIZE(scanBuf) - 40)
enough = true;
if (i < scanResults->size() - 1 || enough)
*cur++ = ',';
if (enough)
break;
i++;
}
*cur++ = ']';
*cur++ = '}';
*cur++ = '\0';
server.send(200, FPSTR(CONTENT_TYPE_JSON), scanBuf);
});
server.on(FPSTR(ROUTE_UPDATE), HTTP_POST, [&]() {
char json_buf[16];
bool should_reboot = !Update.hasError() && !ota.invalidMd5;
Update.clearError();
sprintf_P(json_buf, JSON_UPDATE_FMT, should_reboot ? 1 : 0);
server.send(200, FPSTR(CONTENT_TYPE_JSON), json_buf);
if (should_reboot)
restartTimer.once(1, restart);
}, [&]() {
HTTPUpload& upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
ota.clean();
String s;
if (!getInputParam("md5", 0, s)) {
ota.invalidMd5 = true;
PRINTLN("http/ota: md5 not found");
return;
}
if (!Update.setMD5(s.c_str())) {
ota.invalidMd5 = true;
PRINTLN("http/ota: setMD5() failed");
return;
}
Serial.printf("http/ota: starting, filename=%s\n", upload.filename.c_str());
if (!Update.begin(otaGetMaxUpdateSize())) {
#ifdef DEBUG
Update.printError(Serial);
#endif
}
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (!Update.isRunning())
return;
PRINTF("http/ota: writing %ul\n", upload.currentSize);
esp_led.blink(1, 1);
if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
#ifdef DEBUG
Update.printError(Serial);
#endif
}
} else if (upload.status == UPLOAD_FILE_END) {
if (!Update.isRunning())
return;
if (Update.end(true)) {
PRINTF("http/ota: ok, total size %ul\n", upload.totalSize);
} else {
#ifdef DEBUG
Update.printError(Serial);
#endif
}
}
});
server.onNotFound([&]() {
server.send(404, FPSTR(CONTENT_TYPE_HTML), NOT_FOUND);
});
server.begin();
}
void HttpServer::loop() {
server.handleClient();
}
void HttpServer::sendGzip(const StaticFile& file, PGM_P content_type) {
server.sendHeader(FPSTR(CONTENT_ENCODING), FPSTR(GZIP));
server.send_P(200, content_type, (const char*)file.content, file.size);
}
void HttpServer::sendError(const String& message) {
char buf[32];
if (snprintf_P(buf, 32, PSTR("error: %s"), message.c_str()) == 32)
buf[31] = '\0';
server.send(400, FPSTR(CONTENT_TYPE_HTML), buf);
}
bool HttpServer::getInputParam(const char *field_name,
size_t max_len,
String& dst) {
if (!server.hasArg(field_name)) {
sendError(String(field_name) + String(MSG_IS_MISSING));
return false;
}
String field = server.arg(field_name);
if (!field.length() || (max_len != 0 && field.length() > max_len)) {
sendError(String(field_name) + String(MSG_IS_INVALID));
return false;
}
dst = field;
return true;
}
}

View File

@ -0,0 +1,56 @@
#pragma once
#include <ESP8266WebServer.h>
#include <Ticker.h>
#include <memory>
#include <list>
#include <utility>
#include "config.h"
#include "wifi.h"
#include "static.h"
namespace homekit {
struct OTAStatus {
bool invalidMd5;
OTAStatus() : invalidMd5(false) {}
inline void clean() {
invalidMd5 = false;
}
};
using files::StaticFile;
class HttpServer {
private:
ESP8266WebServer server;
Ticker restartTimer;
std::shared_ptr<std::list<wifi::ScanResult>> scanResults;
OTAStatus ota;
char* scanBuf;
size_t scanBufSize;
void sendGzip(const StaticFile& file, PGM_P content_type);
void sendError(const String& message);
bool getInputParam(const char* field_name, size_t max_len, String& dst);
public:
explicit HttpServer(std::shared_ptr<std::list<wifi::ScanResult>> scanResults)
: server(80)
, scanResults(std::move(scanResults))
, scanBufSize(512) {
scanBuf = new char[scanBufSize];
};
~HttpServer() {
delete[] scanBuf;
}
void start();
void loop();
};
}

View File

@ -0,0 +1,9 @@
#include "led.h"
#include "config.def.h"
namespace homekit {
Led board_led(BOARD_LED_PIN);
Led esp_led(ESP_LED_PIN);
}

View File

@ -0,0 +1,39 @@
#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 {
on();
delay(delay_ms);
off();
if (last_delay)
delay(delay_ms);
}
void blink(uint8_t count, uint16_t delay_ms) const {
for (uint8_t i = 0; i < count; i++) {
on_off(delay_ms, i < count-1);
}
}
};
extern Led board_led;
extern Led esp_led;
}

View File

@ -0,0 +1,18 @@
#pragma once
#include <stdlib.h>
#include "config.def.h"
#ifdef DEBUG
#define PRINTLN(s) Serial.println(s)
#define PRINT(s) Serial.print(s)
#define PRINTF(fmt, ...) Serial.printf(fmt, ##__VA_ARGS__)
#else
#define PRINTLN(s)
#define PRINT(s)
#define PRINTF(...)
#endif

View File

@ -0,0 +1,183 @@
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <DNSServer.h>
#include <Ticker.h>
#include <Wire.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 "temphum.h"
#include "stopwatch.h"
using namespace homekit;
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;
#if MQTT_BLINK
static StopWatch blinkStopWatch;
#endif
static DNSServer* dnsServer = nullptr;
static void onWifiConnected(const WiFiEventStationModeGotIP& event);
static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event);
static void wifiConnect() {
const char *ssid, *psk, *hostname;
auto cfg = config::read();
wifi::getConfig(cfg, &ssid, &psk, &hostname);
PRINTF("Wi-Fi STA creds: ssid=%s, psk=%s, hostname=%s\n", ssid, psk, hostname);
wifi_state = WiFiConnectionState::WAITING;
WiFi.mode(WIFI_STA);
WiFi.hostname(hostname);
WiFi.begin(ssid, psk);
PRINT("connecting to wifi..");
}
static void wifiHotspot() {
esp_led.on();
auto scanResults = wifi::scan();
WiFi.mode(WIFI_AP);
WiFi.softAP(wifi::AP_SSID);
dnsServer = new DNSServer();
dnsServer->start(53, "*", WiFi.softAPIP());
service = new HttpServer(scanResults);
((HttpServer*)service)->start();
}
static void waitForRecoveryPress() {
pinMode(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;
}
}
}
void setup() {
WiFi.disconnect();
waitForRecoveryPress();
temphum::setup();
#ifdef DEBUG
Serial.begin(115200);
#endif
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::WAITING) {
PRINT(".");
esp_led.blink(2, 50);
delay(1000);
return;
}
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();
#if MQTT_BLINK
blinkStopWatch.save();
#endif
}
auto mqtt = (mqtt::MQTT*)service;
if (static_cast<int>(wifi_state) >= 1 && mqtt != nullptr) {
mqtt->loop();
if (mqtt->ota.readyToRestart) {
mqtt->disconnect();
} else if (mqtt->diagnosticsStopWatch.elapsed(10000)) {
mqtt->sendDiagnostics();
auto data = temphum::read();
mqtt->sendTempHumData(data.temp, data.rh);
}
#if MQTT_BLINK
// periodically blink board led
if (blinkStopWatch.elapsed(5000)) {
board_led.blink(1, 10);
blinkStopWatch.save();
}
#endif
}
} else {
if (dnsServer != nullptr)
dnsServer->processNextRequest();
auto httpServer = (HttpServer*)service;
if (httpServer != nullptr)
httpServer->loop();
}
}
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);
}

View File

@ -0,0 +1,325 @@
#include <ESP8266httpUpdate.h>
#include "mqtt.h"
#include "logging.h"
#include "wifi.h"
#include "config.def.h"
#include "config.h"
#include "static.h"
#include "util.h"
#include "led.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[HOME_SECRET_SIZE+1] = HOME_SECRET;
static const char TOPIC_DIAGNOSTICS[] = "stat";
static const char TOPIC_INITIAL_DIAGNOSTICS[] = "stat1";
static const char TOPIC_OTA_RESPONSE[] = "otares";
static const char TOPIC_TEMPHUM_DATA[] = "data";
static const char TOPIC_ADMIN_OTA[] = "admin/ota";
static const uint16_t MQTT_KEEPALIVE = 30;
enum class IncomingMessage {
UNKNOWN,
OTA
};
using namespace espMqttClientTypes;
#define MD5_SIZE 16
MQTT::MQTT() {
auto cfg = config::read();
homeId = String(cfg.flags.node_configured ? cfg.node_id : wifi::NODE_ID);
randomSeed(micros());
client.onConnect([&](bool sessionPresent) {
PRINTLN("mqtt: connected");
sendInitialDiagnostics();
subscribe(TOPIC_ADMIN_OTA);
});
client.onDisconnect([&](DisconnectReason reason) {
PRINTF("mqtt: disconnected, reason=%d\n", static_cast<int>(reason));
#ifdef DEBUG
if (reason == DisconnectReason::TLS_BAD_FINGERPRINT)
PRINTLN("reason: bad fingerprint");
#endif
if (ota.started()) {
PRINTLN("mqtt: update was in progress, canceling..");
ota.clean();
Update.end();
Update.clearError();
}
if (ota.readyToRestart) {
restartTimer.once(1, restart);
} else {
reconnectTimer.once(2, [&]() {
reconnect();
});
}
});
client.onSubscribe([&](uint16_t packetId, const SubscribeReturncode* returncodes, size_t len) {
PRINTF("mqtt: subscribe ack, packet_id=%d\n", packetId);
for (size_t i = 0; i < len; i++) {
PRINTF(" return code: %u\n", static_cast<unsigned int>(*(returncodes+i)));
}
});
client.onUnsubscribe([&](uint16_t packetId) {
PRINTF("mqtt: unsubscribe ack, packet_id=%d\n", packetId);
});
client.onMessage([&](const MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) {
PRINTF("mqtt: message received, topic=%s, qos=%d, dup=%d, retain=%d, len=%ul, index=%ul, total=%ul\n",
topic, properties.qos, (int)properties.dup, (int)properties.retain, len, index, total);
IncomingMessage msgType = IncomingMessage::UNKNOWN;
const char *ptr = topic + homeId.length() + 10;
String relevantTopic(ptr);
if (relevantTopic == TOPIC_ADMIN_OTA)
msgType = IncomingMessage::OTA;
if (len != total && msgType != IncomingMessage::OTA) {
PRINTLN("mqtt: received partial message, not supported");
return;
}
switch (msgType) {
case IncomingMessage::OTA:
if (ota.finished)
break;
handleAdminOtaPayload(properties.packetId, payload, len, index, total);
break;
case IncomingMessage::UNKNOWN:
PRINTF("error: invalid topic %s\n", topic);
break;
}
});
client.onPublish([&](uint16_t packetId) {
PRINTF("mqtt: publish ack, packet_id=%d\n", packetId);
if (ota.finished && packetId == ota.publishResultPacketId) {
ota.readyToRestart = true;
}
});
client.setServer(MQTT_SERVER, MQTT_PORT);
client.setClientId(MQTT_CLIENT_ID);
client.setCredentials(MQTT_USERNAME, MQTT_PASSWORD);
client.setCleanSession(true);
client.setFingerprint(MQTT_CA_FINGERPRINT);
client.setKeepAlive(MQTT_KEEPALIVE);
}
void MQTT::connect() {
reconnect();
}
void MQTT::reconnect() {
if (client.connected()) {
PRINTLN("warning: already connected");
return;
}
client.connect();
}
void MQTT::disconnect() {
// TODO test how this works???
reconnectTimer.detach();
client.disconnect();
}
uint16_t MQTT::publish(const String &topic, uint8_t *payload, size_t length) {
String fullTopic = "hk/" + homeId + "/temphum/" + topic;
return client.publish(fullTopic.c_str(), 1, false, payload, length);
}
void MQTT::loop() {
client.loop();
}
uint16_t MQTT::subscribe(const String &topic, uint8_t qos) {
String fullTopic = "hk/" + homeId + "/temphum/" + topic;
PRINTF("mqtt: subscribing to %s...\n", fullTopic.c_str());
uint16_t packetId = client.subscribe(fullTopic.c_str(), qos);
if (!packetId)
PRINTF("error: failed to subscribe to %s\n", fullTopic.c_str());
return packetId;
}
void MQTT::sendInitialDiagnostics() {
auto cfg = config::read();
InitialDiagnosticsPayload stat{
.ip = wifi::getIPAsInteger(),
.fw_version = FW_VERSION,
.rssi = wifi::getRSSI(),
.free_heap = ESP.getFreeHeap(),
.flags = DiagnosticsFlags{
.state = 1,
.config_changed_value_present = 1,
.config_changed = static_cast<uint8_t>(cfg.flags.node_configured ||
cfg.flags.wifi_configured ? 1 : 0)
}
};
publish(TOPIC_INITIAL_DIAGNOSTICS, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
diagnosticsStopWatch.save();
}
void MQTT::sendDiagnostics() {
DiagnosticsPayload stat{
.rssi = wifi::getRSSI(),
.free_heap = ESP.getFreeHeap(),
.flags = DiagnosticsFlags{
.state = 1,
.config_changed_value_present = 0,
.config_changed = 0
}
};
publish(TOPIC_DIAGNOSTICS, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
diagnosticsStopWatch.save();
}
void MQTT::sendTempHumData(double temp, double rh) {
TempHumDataPayload data {
.temp = temp,
.rh = rh
};
publish(TOPIC_TEMPHUM_DATA, reinterpret_cast<uint8_t*>(&data), sizeof(data));
}
uint16_t MQTT::sendOtaResponse(OTAResult status, uint8_t error_code) {
OTAResponse resp{
.status = status,
.error_code = error_code
};
return publish(TOPIC_OTA_RESPONSE, reinterpret_cast<uint8_t*>(&resp), sizeof(resp));
}
void MQTT::handleAdminOtaPayload(uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) {
char md5[33];
char* md5Ptr = md5;
if (index != 0 && ota.dataPacketId != packetId) {
PRINTLN("mqtt/ota: non-matching packet id");
return;
}
Update.runAsync(true);
if (index == 0) {
if (length < HOME_SECRET_SIZE + MD5_SIZE) {
PRINTLN("mqtt/ota: failed to check secret, first packet size is too small");
return;
}
if (memcmp((const char*)payload, HOME_SECRET, HOME_SECRET_SIZE) != 0) {
PRINTLN("mqtt/ota: invalid secret");
return;
}
PRINTF("mqtt/ota: starting update, total=%ul\n", total-HOME_SECRET_SIZE);
for (int i = 0; i < MD5_SIZE; i++) {
md5Ptr += sprintf(md5Ptr, "%02x", *((unsigned char*)(payload+HOME_SECRET_SIZE+i)));
}
md5[32] = '\0';
PRINTF("mqtt/ota: md5 is %s\n", md5);
PRINTF("mqtt/ota: first packet is %ul bytes length\n", length);
md5[32] = '\0';
if (Update.isRunning()) {
Update.end();
Update.clearError();
}
if (!Update.setMD5(md5)) {
PRINTLN("mqtt/ota: setMD5 failed");
return;
}
ota.dataPacketId = packetId;
if (!Update.begin(total - HOME_SECRET_SIZE - MD5_SIZE)) {
ota.clean();
#ifdef DEBUG
Update.printError(Serial);
#endif
sendOtaResponse(OTAResult::UPDATE_ERROR, Update.getError());
}
ota.written = Update.write(const_cast<uint8_t*>(payload)+HOME_SECRET_SIZE + MD5_SIZE, length-HOME_SECRET_SIZE - MD5_SIZE);
ota.written += HOME_SECRET_SIZE + MD5_SIZE;
esp_led.blink(1, 1);
PRINTF("mqtt/ota: updating %u/%u\n", ota.written, Update.size());
} else {
if (!Update.isRunning()) {
PRINTLN("mqtt/ota: update is not running");
return;
}
if (index == ota.written) {
size_t written;
if ((written = Update.write(const_cast<uint8_t*>(payload), length)) != length) {
PRINTF("mqtt/ota: error: tried to write %ul bytes, write() returned %ul\n",
length, written);
ota.clean();
Update.end();
Update.clearError();
sendOtaResponse(OTAResult::WRITE_ERROR);
return;
}
ota.written += length;
esp_led.blink(1, 1);
PRINTF("mqtt/ota: updating %u/%u\n",
ota.written - HOME_SECRET_SIZE - MD5_SIZE,
Update.size());
} else {
PRINTF("mqtt/ota: position is invalid, expected %ul, got %ul\n", ota.written, index);
ota.clean();
Update.end();
Update.clearError();
}
}
if (Update.isFinished()) {
ota.dataPacketId = 0;
if (Update.end()) {
ota.finished = true;
ota.publishResultPacketId = sendOtaResponse(OTAResult::OK);
PRINTF("mqtt/ota: ok, otares packet_id=%d\n", ota.publishResultPacketId);
} else {
ota.clean();
PRINTF("mqtt/ota: error: %u\n", Update.getError());
#ifdef DEBUG
Update.printError(Serial);
#endif
Update.clearError();
sendOtaResponse(OTAResult::UPDATE_ERROR, Update.getError());
}
}
}
}

View File

@ -0,0 +1,107 @@
#include <ESP8266WiFi.h>
#include <espMqttClient.h>
#include <Ticker.h>
#include "stopwatch.h"
namespace homekit { namespace mqtt {
enum class OTAResult: uint8_t {
OK = 0,
UPDATE_ERROR = 1,
WRITE_ERROR = 2,
};
struct OTAStatus {
uint16_t dataPacketId;
uint16_t publishResultPacketId;
bool finished;
bool readyToRestart;
size_t written;
OTAStatus()
: dataPacketId(0)
, publishResultPacketId(0)
, finished(false)
, readyToRestart(false)
, written(0)
{}
inline void clean() {
dataPacketId = 0;
publishResultPacketId = 0;
finished = false;
readyToRestart = false;
written = 0;
}
inline bool started() const {
return dataPacketId != 0;
}
};
class MQTT {
private:
String homeId;
WiFiClientSecure httpsSecureClient;
espMqttClientSecure client;
Ticker reconnectTimer;
Ticker restartTimer;
void handleAdminOtaPayload(uint16_t packetId, const uint8_t* payload, size_t length, size_t index, size_t total);
uint16_t publish(const String& topic, uint8_t* payload, size_t length);
uint16_t subscribe(const String& topic, uint8_t qos = 0);
void sendInitialDiagnostics();
uint16_t sendOtaResponse(OTAResult status, uint8_t error_code = 0);
public:
StopWatch diagnosticsStopWatch;
OTAStatus ota;
MQTT();
void connect();
void disconnect();
void reconnect();
void loop();
void sendDiagnostics();
void sendTempHumData(double temp, double rh);
};
struct DiagnosticsFlags {
uint8_t state: 1;
uint8_t config_changed_value_present: 1;
uint8_t config_changed: 1;
uint8_t reserved: 5;
} __attribute__((packed));
struct InitialDiagnosticsPayload {
uint32_t ip;
uint8_t fw_version;
int8_t rssi;
uint32_t free_heap;
DiagnosticsFlags flags;
} __attribute__((packed));
struct DiagnosticsPayload {
int8_t rssi;
uint32_t free_heap;
DiagnosticsFlags flags;
} __attribute__((packed));
struct PowerPayload {
char secret[12];
uint8_t state;
} __attribute__((packed));
struct TempHumDataPayload {
double temp;
double rh;
} __attribute__((packed));
struct OTAResponse {
OTAResult status;
uint8_t error_code;
} __attribute__((packed));
} }

View File

@ -0,0 +1,450 @@
/**
* This file is autogenerated with make_static.sh script
*/
#include "static.h"
namespace homekit::files {
static const uint8_t index_html_content[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x9d, 0x56, 0x4d, 0x6f, 0xdb, 0x38,
0x10, 0xbd, 0xe7, 0x57, 0xb0, 0x3c, 0x14, 0x09, 0x10, 0x4b, 0x9b, 0x14, 0xcd, 0x16, 0xad, 0x24,
0xa0, 0xd8, 0x76, 0xb1, 0x05, 0x7a, 0x08, 0x6a, 0x14, 0x0b, 0xec, 0xc5, 0xa0, 0xa8, 0x91, 0xc5,
0x9a, 0x22, 0x59, 0x71, 0x24, 0xc7, 0xfd, 0xf5, 0x1d, 0x52, 0x92, 0x3f, 0xb2, 0x46, 0xfa, 0x71,
0xb1, 0x34, 0xc3, 0x99, 0x37, 0x6f, 0x1e, 0x87, 0xa2, 0xb3, 0x67, 0x95, 0x95, 0xb8, 0x73, 0xc0,
0x1a, 0x6c, 0x75, 0x71, 0x91, 0x85, 0x07, 0xd3, 0xc2, 0xac, 0x73, 0x0e, 0x86, 0x07, 0x07, 0x88,
0x8a, 0x1e, 0x2d, 0xa0, 0xa0, 0x18, 0x74, 0x0b, 0xf8, 0xda, 0xab, 0x21, 0xe7, 0xd2, 0x1a, 0x04,
0x83, 0x8b, 0x90, 0xcc, 0xd9, 0x64, 0xe5, 0x1c, 0xe1, 0x01, 0xd3, 0x00, 0xf2, 0x86, 0xc9, 0x46,
0x74, 0x1e, 0x30, 0xef, 0xb1, 0x5e, 0xbc, 0xe2, 0x33, 0x86, 0x11, 0x2d, 0xe4, 0x7c, 0x50, 0xb0,
0x75, 0xb6, 0xc3, 0xa3, 0xcc, 0xad, 0xaa, 0xb0, 0xc9, 0x2b, 0x18, 0x94, 0x84, 0x45, 0x34, 0xae,
0x95, 0x51, 0xa8, 0x84, 0x5e, 0x78, 0x29, 0x34, 0xe4, 0x37, 0xd7, 0x2d, 0x39, 0xda, 0xbe, 0x3d,
0xd8, 0xe2, 0xe1, 0xc4, 0xee, 0x3d, 0x74, 0xd1, 0x10, 0x25, 0xd9, 0xc6, 0x86, 0xa2, 0xa8, 0x50,
0x43, 0xf1, 0x97, 0x35, 0xb5, 0x5a, 0xf7, 0x9d, 0x40, 0x65, 0x4d, 0x96, 0x8e, 0xce, 0x8b, 0x4c,
0x2b, 0xb3, 0x61, 0x1d, 0xe8, 0x9c, 0xfb, 0x86, 0xd8, 0xc8, 0x1e, 0x99, 0x22, 0x42, 0x9c, 0x35,
0x1d, 0xd4, 0x39, 0x4f, 0x6b, 0x31, 0x04, 0x3b, 0xa1, 0x1f, 0xce, 0x42, 0xa7, 0x39, 0x57, 0xad,
0x58, 0x43, 0xfa, 0xb0, 0x88, 0x71, 0xa7, 0x10, 0xb8, 0xd3, 0xe0, 0x1b, 0x00, 0x9c, 0x63, 0xa3,
0x18, 0xd2, 0xfb, 0x3d, 0x5e, 0x0c, 0x49, 0x82, 0x87, 0x32, 0xbd, 0xec, 0x94, 0x43, 0xe6, 0x3b,
0x49, 0x2b, 0x6d, 0xf5, 0x32, 0xf9, 0x42, 0xee, 0x2c, 0x1d, 0xdd, 0x8f, 0xd7, 0x85, 0x73, 0x8f,
0xd7, 0xd3, 0x69, 0x6b, 0x4a, 0x5b, 0xed, 0x98, 0x35, 0xda, 0x8a, 0x8a, 0xe8, 0x91, 0x64, 0x6f,
0x9d, 0xbb, 0xbc, 0x0a, 0x15, 0x2a, 0x35, 0x30, 0xa9, 0x85, 0xf7, 0x44, 0x25, 0x74, 0xcc, 0x8b,
0x25, 0x20, 0x2a, 0xb3, 0xf6, 0x2c, 0xf3, 0x4e, 0x18, 0xa6, 0x28, 0x23, 0xe4, 0x91, 0x6b, 0x45,
0xa2, 0x81, 0xe6, 0xc5, 0xe5, 0x64, 0x27, 0x49, 0x72, 0x45, 0xc5, 0x28, 0x8a, 0x6a, 0x12, 0xd0,
0x29, 0x5c, 0xa9, 0xad, 0xdc, 0x84, 0x12, 0xb5, 0xed, 0x5a, 0x46, 0x1b, 0xdb, 0x58, 0x82, 0x72,
0xd6, 0x53, 0xef, 0x42, 0x06, 0x91, 0x63, 0xb7, 0x02, 0x7b, 0x6a, 0x7e, 0xdc, 0x72, 0x03, 0xb8,
0xb5, 0xdd, 0x66, 0xe5, 0x27, 0x0a, 0x8f, 0x08, 0x06, 0xa0, 0x99, 0xc3, 0xbf, 0xea, 0x6f, 0xc5,
0x96, 0xcb, 0x0f, 0xef, 0xce, 0x54, 0x8e, 0x71, 0xca, 0xb8, 0x1e, 0xa3, 0x86, 0xa0, 0x41, 0x62,
0xec, 0xc3, 0x7b, 0x55, 0xad, 0x46, 0x7b, 0x2e, 0x19, 0x5c, 0x7c, 0x9f, 0xd8, 0x6b, 0x3d, 0xce,
0x55, 0x48, 0xb4, 0x2e, 0x90, 0x64, 0x83, 0xd0, 0x3d, 0x05, 0xf2, 0xe2, 0xe3, 0xbe, 0xeb, 0x2c,
0x1d, 0xd7, 0x82, 0xc2, 0x23, 0x5c, 0x78, 0x3b, 0xcf, 0xe3, 0x98, 0xef, 0x3d, 0xb9, 0xa9, 0xc1,
0xea, 0x87, 0x9c, 0xe3, 0xcb, 0x34, 0x21, 0x6e, 0x4a, 0xe2, 0x7b, 0x26, 0x13, 0x75, 0xe7, 0x37,
0xe7, 0x98, 0xc7, 0x4e, 0x6b, 0x5d, 0xad, 0xe2, 0x3a, 0xcd, 0xbf, 0x06, 0xb3, 0xa6, 0x63, 0xc3,
0xef, 0x5e, 0x70, 0x56, 0x29, 0x1f, 0x06, 0xbf, 0x3a, 0x53, 0xdc, 0xf7, 0xe5, 0xc4, 0x95, 0x26,
0x36, 0xbc, 0x30, 0x72, 0xc7, 0xa9, 0xdf, 0x46, 0xa8, 0xe2, 0x84, 0x95, 0x6c, 0x40, 0x6e, 0x4a,
0xfb, 0xb0, 0xd7, 0x71, 0x0e, 0x1b, 0x85, 0xde, 0x27, 0xb1, 0xf0, 0xca, 0xdc, 0xbe, 0xf1, 0x88,
0x7c, 0x50, 0xeb, 0x69, 0xd1, 0xfe, 0xb1, 0x2d, 0xb0, 0x9f, 0xd8, 0xe2, 0x63, 0x62, 0xe1, 0x40,
0x1d, 0x49, 0x75, 0xd4, 0xff, 0xcd, 0xdd, 0x4c, 0xb6, 0x09, 0x7b, 0x3e, 0xcb, 0xd4, 0x9c, 0x1f,
0x80, 0x63, 0xa9, 0xa6, 0xfa, 0x65, 0x8f, 0x48, 0x03, 0x31, 0xd6, 0x21, 0xb9, 0x5a, 0x85, 0x87,
0xb0, 0x59, 0x87, 0xd1, 0x5d, 0x2c, 0xc5, 0x00, 0x4c, 0x98, 0x8a, 0x7d, 0x82, 0xd2, 0x5a, 0xcc,
0xd2, 0x31, 0x39, 0x80, 0x05, 0xee, 0x67, 0x5b, 0x9f, 0x0e, 0xe0, 0x67, 0x57, 0x09, 0x04, 0x56,
0xab, 0xae, 0xdd, 0x8a, 0x0e, 0xd8, 0x65, 0x52, 0x2a, 0x73, 0xf5, 0xbb, 0x27, 0xac, 0x8f, 0x68,
0x9c, 0x81, 0x91, 0x23, 0xf1, 0xb6, 0xd7, 0xa8, 0x9c, 0xe8, 0x30, 0x12, 0x59, 0xd0, 0xaa, 0x98,
0x75, 0x19, 0x63, 0x9f, 0x3c, 0x7e, 0x67, 0x35, 0xaf, 0x15, 0xf1, 0xa6, 0x92, 0x12, 0x1c, 0x7d,
0xa5, 0x03, 0xdd, 0xeb, 0xf0, 0x93, 0xac, 0xbf, 0xcd, 0xc8, 0x31, 0xe2, 0x07, 0x4a, 0x9e, 0x08,
0x78, 0x90, 0xff, 0xb3, 0x0b, 0x9f, 0x9b, 0x5f, 0x11, 0xf0, 0x13, 0x50, 0x07, 0x6c, 0xee, 0xe2,
0x77, 0x85, 0xeb, 0x02, 0x0a, 0xff, 0x39, 0xb2, 0x13, 0xae, 0xf2, 0xab, 0x29, 0x2b, 0x52, 0xf8,
0x15, 0xce, 0x1f, 0x4c, 0x6d, 0x9f, 0x60, 0xfa, 0x7e, 0x79, 0xff, 0xea, 0xf6, 0xee, 0x6e, 0x51,
0x0a, 0x4f, 0xa3, 0x96, 0x95, 0x05, 0x5d, 0x27, 0x62, 0x27, 0x51, 0x53, 0x8d, 0xe2, 0xfa, 0x30,
0x2b, 0xc3, 0x9f, 0x59, 0xd9, 0x15, 0x17, 0xf7, 0xb4, 0xbd, 0xcc, 0xd6, 0x2c, 0x13, 0xd3, 0xb5,
0x12, 0xae, 0x65, 0xff, 0x3a, 0x4d, 0xd7, 0x0a, 0x13, 0xd9, 0xdc, 0xb8, 0x44, 0xd9, 0xb4, 0xa1,
0xd3, 0xb5, 0x21, 0x9b, 0x7c, 0x29, 0x2f, 0x26, 0x2b, 0x4b, 0x45, 0xc1, 0xca, 0xdd, 0xff, 0x33,
0xa7, 0x2c, 0x5e, 0xbc, 0x1f, 0xd6, 0x60, 0x76, 0xec, 0x3f, 0x65, 0x2c, 0x5d, 0xd1, 0x43, 0x4c,
0x78, 0x2e, 0xad, 0xdb, 0xbd, 0x61, 0xb7, 0x7f, 0xdc, 0xde, 0x1e, 0x8e, 0x76, 0xb8, 0x74, 0xe2,
0x1d, 0x14, 0xff, 0x36, 0x7c, 0x07, 0x90, 0xb9, 0x94, 0x17, 0x47, 0x08, 0x00, 0x00,
};
const StaticFile index_html PROGMEM = {(sizeof(index_html_content)/sizeof(index_html_content[0])), index_html_content};
static const uint8_t app_js_content[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x9d, 0x57, 0x6d, 0x6f, 0xdb, 0x46,
0x12, 0xfe, 0xde, 0x5f, 0x41, 0x2d, 0x70, 0x06, 0xf7, 0x44, 0xd3, 0x2f, 0xd7, 0x02, 0x85, 0x18,
0x42, 0x48, 0xda, 0xe4, 0x92, 0x22, 0xa9, 0x8b, 0x24, 0x57, 0x1c, 0x60, 0xf8, 0x82, 0x95, 0x38,
0xb2, 0x18, 0x53, 0xbb, 0xec, 0xee, 0xd2, 0xb2, 0x4f, 0x11, 0x70, 0x69, 0x0a, 0xb4, 0x40, 0x0b,
0x04, 0xe8, 0xf7, 0xcb, 0xa7, 0xfe, 0x00, 0x37, 0x77, 0xbe, 0x4b, 0x2e, 0x4d, 0xfa, 0x17, 0xa8,
0x7f, 0xd4, 0x99, 0x25, 0x29, 0xd1, 0xb2, 0x81, 0x06, 0xf7, 0xc1, 0x12, 0xb5, 0x9c, 0x9d, 0x9d,
0x79, 0x66, 0x9e, 0x67, 0xc7, 0xa3, 0x42, 0x0e, 0x6d, 0xaa, 0xa4, 0x07, 0x3e, 0xf0, 0x99, 0x06,
0x5b, 0x68, 0xc9, 0xf6, 0xd5, 0xe0, 0x31, 0x0c, 0xad, 0xb7, 0xe7, 0xbe, 0x0e, 0x58, 0x1c, 0xc7,
0xd5, 0x63, 0x98, 0x6b, 0x65, 0x95, 0x3d, 0xcd, 0x21, 0xb4, 0xea, 0x81, 0xd5, 0xa9, 0x3c, 0x0c,
0x87, 0x22, 0xcb, 0x70, 0xef, 0x7c, 0xd4, 0x78, 0xb2, 0x2b, 0x4f, 0x5e, 0xa2, 0x86, 0xc5, 0x04,
0xa4, 0x0d, 0x0f, 0xc1, 0xde, 0xcc, 0x80, 0x1e, 0x6f, 0x9c, 0xde, 0x49, 0x2e, 0xd8, 0x4b, 0xb2,
0x87, 0xd0, 0xd8, 0xd3, 0x0c, 0xc2, 0x24, 0x35, 0x79, 0x26, 0x4e, 0x63, 0x26, 0x95, 0x04, 0xb6,
0x32, 0xd2, 0x2d, 0xa7, 0x80, 0x61, 0xc0, 0x31, 0xba, 0xfa, 0x14, 0x46, 0xa2, 0xc8, 0xec, 0xc6,
0xc6, 0xfa, 0x8a, 0xcf, 0x03, 0x72, 0xa8, 0xf2, 0x2f, 0xb4, 0xca, 0xc5, 0xa1, 0x20, 0x17, 0x64,
0xb5, 0xb6, 0xe4, 0xcc, 0x86, 0x42, 0x0e, 0x21, 0xbb, 0x51, 0x0c, 0x06, 0x19, 0xc4, 0x9d, 0x6d,
0x5c, 0xa9, 0x8e, 0xf9, 0x52, 0x64, 0x05, 0x2e, 0xec, 0x04, 0x9d, 0x9d, 0x55, 0x18, 0x69, 0x3b,
0x0c, 0x2f, 0x95, 0xc6, 0xd2, 0x6e, 0x35, 0xf2, 0x6e, 0x6a, 0xad, 0x74, 0x1f, 0xc2, 0x09, 0x18,
0x23, 0x0e, 0xa1, 0x07, 0x5d, 0xd6, 0x8a, 0x5e, 0xd5, 0x29, 0x82, 0xbd, 0x6e, 0x11, 0xb5, 0x41,
0x61, 0xc1, 0x67, 0x98, 0xaa, 0xc0, 0x33, 0x13, 0x16, 0xac, 0x1e, 0x5b, 0xb0, 0x88, 0x6a, 0x8f,
0x86, 0x89, 0x3a, 0x86, 0xab, 0xb6, 0xb5, 0x6c, 0x8d, 0xcf, 0x67, 0xa3, 0x56, 0x25, 0x03, 0xcb,
0x67, 0xb2, 0xc8, 0xb2, 0x4e, 0x1c, 0x23, 0x3a, 0x3e, 0x84, 0xc7, 0x2e, 0x19, 0xcb, 0x03, 0xf2,
0x3a, 0x3f, 0x16, 0xda, 0x33, 0xf1, 0x76, 0xb4, 0xdc, 0x52, 0xe0, 0xfe, 0x5d, 0xac, 0x72, 0xb7,
0x6b, 0x36, 0x36, 0xa4, 0x6f, 0x7d, 0x96, 0x29, 0x91, 0x60, 0x75, 0x1f, 0x65, 0x62, 0x00, 0x19,
0xe3, 0xd5, 0x9e, 0x24, 0x5e, 0xd6, 0x73, 0xa4, 0xf4, 0xc4, 0x84, 0x12, 0xec, 0x54, 0xe9, 0xa3,
0x47, 0x98, 0x98, 0x45, 0x6b, 0x13, 0x25, 0xa1, 0x48, 0x92, 0x9b, 0x54, 0x89, 0xbb, 0xa9, 0xb1,
0x20, 0x41, 0xfb, 0xcc, 0x14, 0x83, 0x49, 0x6a, 0x59, 0xe0, 0x37, 0xc7, 0xb5, 0xfb, 0x23, 0x1c,
0xa7, 0x49, 0x15, 0x5d, 0x88, 0x19, 0x4e, 0x7c, 0xde, 0x4f, 0xc2, 0xdc, 0x1c, 0xd5, 0x4b, 0x19,
0xc8, 0x43, 0x3b, 0xbe, 0xf6, 0x71, 0xdf, 0x17, 0x19, 0x68, 0x0c, 0xab, 0xfc, 0x67, 0x79, 0x5e,
0xbe, 0x2c, 0xcf, 0x17, 0xff, 0x28, 0xdf, 0x2e, 0xbe, 0x2f, 0x5f, 0x7b, 0xe5, 0xaf, 0xe5, 0x19,
0xfe, 0x78, 0x57, 0xbe, 0x59, 0xfc, 0xe0, 0xf9, 0xe5, 0x2f, 0xe5, 0xab, 0xf2, 0x2d, 0xfe, 0xfd,
0x52, 0x9e, 0xd1, 0x0a, 0x3e, 0x9f, 0x2d, 0x9e, 0x7b, 0xe5, 0xbf, 0xcb, 0x37, 0xee, 0xc5, 0x99,
0xb7, 0xe9, 0x7d, 0xec, 0x2d, 0x9e, 0x3a, 0x8b, 0x97, 0xb4, 0x0b, 0xff, 0x5e, 0x72, 0xc6, 0x03,
0xea, 0x2f, 0xde, 0xdb, 0xdc, 0x41, 0x10, 0x92, 0xd0, 0x18, 0x0c, 0xca, 0x40, 0x86, 0x1d, 0x0f,
0xc9, 0x1d, 0x99, 0xc0, 0xc9, 0x85, 0x00, 0xbc, 0xf2, 0x25, 0x9e, 0xfd, 0x33, 0x1e, 0x7b, 0xe6,
0x7c, 0x2e, 0xbe, 0x2e, 0xdf, 0x2d, 0xbe, 0x2d, 0xff, 0x87, 0x8f, 0x78, 0xd2, 0xbb, 0xc5, 0xd3,
0xc5, 0xd7, 0x8b, 0x67, 0x14, 0xd8, 0xd2, 0xef, 0xb1, 0x4a, 0x13, 0x6c, 0x02, 0x74, 0xea, 0xb0,
0xe0, 0xbd, 0xa5, 0xbb, 0x1f, 0x29, 0x1b, 0xdc, 0xf5, 0x0a, 0x9d, 0x9c, 0x7b, 0x63, 0x35, 0xc1,
0xae, 0x4a, 0x9a, 0x7d, 0x73, 0xce, 0x03, 0xdc, 0x33, 0x56, 0xd3, 0x47, 0x84, 0xc9, 0x65, 0x68,
0x87, 0x63, 0x21, 0x0f, 0x61, 0x0d, 0xda, 0x0a, 0xc0, 0x8b, 0xbd, 0x46, 0x94, 0x65, 0xd8, 0xda,
0x56, 0x68, 0x64, 0x62, 0x38, 0x1c, 0xc3, 0xf0, 0x08, 0x92, 0x3e, 0xb3, 0x70, 0x62, 0x59, 0x8f,
0xe5, 0xc2, 0x18, 0x2c, 0x24, 0xf5, 0x54, 0x75, 0x24, 0x01, 0xf0, 0x9e, 0xc7, 0x51, 0x63, 0xd8,
0x78, 0xe9, 0xfa, 0x02, 0x6c, 0xd1, 0xe6, 0xce, 0xb2, 0x05, 0xeb, 0xf7, 0x2a, 0xa7, 0x8d, 0x66,
0xdf, 0x1e, 0xe0, 0x6a, 0xab, 0xd6, 0x31, 0x63, 0x55, 0xc2, 0xe2, 0xb1, 0x38, 0x21, 0xb9, 0xf0,
0xd9, 0x16, 0xb2, 0xcb, 0x16, 0x86, 0x05, 0xb3, 0x79, 0xeb, 0x48, 0x1b, 0x48, 0x3e, 0xb3, 0xfa,
0x74, 0x96, 0x8e, 0x7c, 0xcb, 0xed, 0x58, 0xab, 0xa9, 0x67, 0x23, 0xf0, 0x5d, 0x2b, 0x05, 0x32,
0x94, 0x2a, 0x81, 0x47, 0x69, 0xf2, 0xe4, 0x09, 0x11, 0x00, 0x09, 0x5e, 0x1d, 0x12, 0xac, 0x7e,
0x55, 0x35, 0xa8, 0x17, 0xb0, 0xf3, 0xe7, 0x43, 0x61, 0x87, 0x63, 0xf4, 0x35, 0xab, 0x8a, 0x92,
0xe2, 0x23, 0x9f, 0xaf, 0x87, 0x82, 0x32, 0xb1, 0x16, 0x88, 0x63, 0x59, 0x1d, 0x08, 0xd4, 0x81,
0x40, 0x54, 0x83, 0x97, 0x4a, 0x44, 0xec, 0xf6, 0xc3, 0x7b, 0x77, 0x31, 0xaf, 0x08, 0xc9, 0xe2,
0x13, 0x4c, 0x12, 0x39, 0x27, 0xaf, 0xd9, 0x30, 0x43, 0x44, 0xeb, 0xde, 0x8e, 0x64, 0xb7, 0x5b,
0x61, 0xa8, 0xe3, 0xea, 0xc5, 0xbe, 0x3c, 0xd8, 0xdf, 0x3e, 0x08, 0x54, 0xeb, 0xe7, 0xce, 0x41,
0xe3, 0x56, 0xe4, 0x39, 0xc8, 0xc4, 0x97, 0x30, 0xf5, 0xf6, 0x1c, 0x90, 0xbe, 0xee, 0x32, 0xcf,
0x67, 0x5d, 0x85, 0x5f, 0xc9, 0x8d, 0x09, 0x67, 0x81, 0xc6, 0xe0, 0x85, 0x5f, 0xd9, 0xb7, 0xf3,
0x83, 0x55, 0x7e, 0x50, 0xe5, 0x37, 0xbf, 0xc0, 0x7f, 0x8a, 0x01, 0xd6, 0x09, 0x5e, 0xe4, 0x89,
0xb0, 0xb0, 0xe2, 0x37, 0xbc, 0x17, 0xbf, 0x11, 0x17, 0xc4, 0x44, 0xe3, 0x77, 0xd0, 0x81, 0x70,
0x94, 0x66, 0xd5, 0x87, 0xa9, 0x73, 0xe6, 0x35, 0xf9, 0x1b, 0x0e, 0xfc, 0x84, 0x3c, 0x7a, 0x5d,
0xbe, 0xf1, 0x90, 0x8b, 0x3f, 0x23, 0xa1, 0x90, 0x91, 0xc8, 0xcb, 0x73, 0xe2, 0x31, 0x71, 0xf7,
0xed, 0x1a, 0xe1, 0x90, 0x1c, 0x9d, 0x9d, 0x08, 0x15, 0xb5, 0x21, 0x53, 0x54, 0x41, 0x4b, 0x98,
0xfc, 0xf5, 0xde, 0xdd, 0xdb, 0xd6, 0xe6, 0xf7, 0xe1, 0xab, 0x02, 0x8c, 0x0d, 0x84, 0x5b, 0xbc,
0x85, 0x99, 0x7c, 0x2a, 0xac, 0x88, 0x9a, 0x63, 0x1b, 0x14, 0x19, 0x05, 0x45, 0xac, 0x58, 0x45,
0x88, 0xc8, 0x73, 0xec, 0xa1, 0x22, 0x27, 0xfd, 0xbb, 0x22, 0x57, 0xbc, 0xff, 0x0e, 0x35, 0x4a,
0xfc, 0x5a, 0xb6, 0x2e, 0x82, 0x40, 0xc7, 0x6b, 0xae, 0x42, 0x93, 0xfe, 0x1d, 0x22, 0x49, 0x95,
0x44, 0x77, 0x90, 0x5c, 0xd3, 0xfd, 0x7b, 0xc2, 0x8e, 0x43, 0xad, 0x0a, 0x3c, 0xbe, 0x59, 0xdd,
0xd2, 0x7f, 0xdc, 0xd9, 0xde, 0xe6, 0x78, 0xa3, 0xde, 0x4a, 0x4f, 0x20, 0xf1, 0x77, 0x79, 0x0f,
0x7f, 0x07, 0x4d, 0x7e, 0xad, 0x56, 0x92, 0x5d, 0xf6, 0x07, 0x46, 0x8d, 0x29, 0x43, 0x25, 0x35,
0x88, 0xe4, 0x94, 0x18, 0x02, 0x15, 0x2d, 0xe3, 0x65, 0x40, 0x4d, 0x2d, 0x59, 0xf9, 0x62, 0x1d,
0x50, 0xc2, 0xf2, 0xbf, 0x88, 0xa2, 0x93, 0xd0, 0xc5, 0x77, 0x6e, 0xf1, 0x5d, 0xe0, 0x2d, 0x9e,
0x39, 0xd1, 0x22, 0x1d, 0x7d, 0x4d, 0x4f, 0xa4, 0x8d, 0x24, 0xad, 0xa4, 0xb3, 0xe7, 0x6e, 0xc3,
0xbf, 0xd0, 0xfc, 0x59, 0xf9, 0x1f, 0x7c, 0x3a, 0x47, 0xc3, 0xa7, 0x8b, 0xe7, 0x2c, 0xc2, 0x12,
0x7f, 0x88, 0x72, 0x29, 0x43, 0x17, 0xc9, 0x03, 0x8a, 0x84, 0x13, 0x1d, 0x48, 0x43, 0x3f, 0x7b,
0xb0, 0xf7, 0x79, 0x98, 0x0b, 0x6d, 0xc0, 0xa7, 0xf7, 0x26, 0x47, 0xca, 0xc3, 0x43, 0x14, 0x1b,
0x4e, 0xbf, 0xf0, 0x86, 0xee, 0x57, 0xc5, 0x07, 0xde, 0x6b, 0xba, 0xe0, 0x05, 0x86, 0xf3, 0x0a,
0xe3, 0x75, 0x32, 0x7a, 0x45, 0x27, 0xb0, 0x4b, 0x44, 0xc5, 0x9b, 0x6c, 0xee, 0xa0, 0x00, 0xba,
0x7c, 0xe3, 0xb6, 0x28, 0xb5, 0x3b, 0x9d, 0x4c, 0xb0, 0xde, 0x3e, 0xfb, 0x62, 0xef, 0xc1, 0x43,
0x16, 0xd8, 0x46, 0x89, 0x84, 0xb3, 0x26, 0x30, 0x0d, 0x75, 0x83, 0xa0, 0xbe, 0x22, 0x70, 0xeb,
0x22, 0xbe, 0x8f, 0xfc, 0x55, 0x8d, 0xbe, 0xf4, 0x78, 0xa1, 0xc9, 0x67, 0xab, 0xb6, 0xbc, 0x85,
0xeb, 0xf7, 0x11, 0x24, 0xd0, 0x11, 0x85, 0x4b, 0x65, 0x5f, 0xaf, 0x96, 0x8d, 0xa7, 0xa9, 0x4c,
0xd4, 0x34, 0x9c, 0x24, 0x1f, 0x55, 0x90, 0x21, 0x48, 0x3c, 0x5a, 0x9f, 0x19, 0xaa, 0x98, 0x71,
0x62, 0xd8, 0xaa, 0x98, 0xd9, 0x47, 0xf3, 0x98, 0x75, 0xab, 0x8b, 0xbd, 0x21, 0xc4, 0x95, 0xa0,
0x34, 0x98, 0x5c, 0x04, 0x7a, 0xf1, 0x2d, 0xdd, 0x3b, 0x35, 0xd5, 0x16, 0xdf, 0x54, 0x44, 0xa4,
0x8b, 0x8b, 0x7c, 0x50, 0x5d, 0xaf, 0x9b, 0x1b, 0xa9, 0x14, 0xfa, 0xb4, 0x1a, 0xf6, 0xd6, 0x52,
0x25, 0xb6, 0x38, 0x21, 0xe9, 0xb4, 0x8e, 0x69, 0xcd, 0x81, 0x28, 0xb2, 0x81, 0x0e, 0xd2, 0x4a,
0x0d, 0x62, 0x5d, 0x49, 0x72, 0xc0, 0x1a, 0x0b, 0xd6, 0x89, 0xe9, 0x5e, 0xc2, 0xe9, 0x29, 0xad,
0xe5, 0x93, 0xc0, 0x72, 0x93, 0x14, 0x82, 0x8d, 0x63, 0xe5, 0x40, 0x0c, 0x8f, 0xbc, 0x49, 0x61,
0xac, 0x37, 0x00, 0x4f, 0x78, 0xcb, 0x7d, 0x9c, 0x7a, 0xaf, 0x23, 0x2f, 0x6f, 0x92, 0xca, 0x2b,
0x74, 0xe6, 0x99, 0x1c, 0x86, 0xe9, 0x28, 0xa5, 0x19, 0x29, 0x32, 0xd3, 0xb4, 0x6e, 0x9a, 0xa1,
0x30, 0xc0, 0xfe, 0x7c, 0xf3, 0x21, 0xeb, 0x91, 0x60, 0xfb, 0xa8, 0x93, 0x8d, 0x2e, 0x2b, 0x9c,
0xe4, 0x3c, 0xcd, 0x75, 0x38, 0x16, 0x66, 0x6f, 0x2a, 0x69, 0x42, 0x44, 0xa8, 0x4e, 0x7d, 0xc5,
0xf1, 0x8e, 0x92, 0xdd, 0xd8, 0x77, 0x73, 0x81, 0x44, 0x26, 0xe2, 0x9d, 0xb6, 0x37, 0xf2, 0x59,
0x9f, 0xf1, 0x3e, 0x7e, 0xf4, 0xd8, 0x06, 0xe3, 0x5d, 0x90, 0x43, 0xbc, 0x72, 0xfe, 0x72, 0xff,
0xce, 0x27, 0x6a, 0x82, 0x7d, 0x8e, 0x2d, 0x83, 0x1b, 0xbb, 0x0c, 0xcb, 0x72, 0xc5, 0x1b, 0xbd,
0xaf, 0x0e, 0x38, 0x8f, 0x06, 0x88, 0xed, 0x51, 0xe4, 0x22, 0x72, 0x5d, 0xb9, 0x0c, 0xc9, 0x35,
0x83, 0x88, 0xf7, 0x0f, 0xa2, 0xf7, 0x09, 0x4e, 0x84, 0x79, 0x61, 0x50, 0xda, 0xff, 0x8f, 0x08,
0x74, 0x2c, 0xc2, 0xc7, 0x2a, 0x45, 0x5a, 0x60, 0x0e, 0xf3, 0x7a, 0x24, 0xbc, 0xac, 0xa1, 0x8d,
0x68, 0x9a, 0x8a, 0x43, 0x74, 0xf9, 0x06, 0x55, 0xc8, 0xb1, 0xbb, 0xd8, 0x0d, 0x35, 0x68, 0x6d,
0x7b, 0xdb, 0xb5, 0xb8, 0xcf, 0x3e, 0x51, 0x12, 0x19, 0x63, 0x37, 0xab, 0xa1, 0x83, 0xa1, 0xda,
0x66, 0xe9, 0xd0, 0x0d, 0xdc, 0x5b, 0x27, 0x9b, 0xd3, 0xe9, 0x74, 0x93, 0xee, 0x96, 0x4d, 0x2c,
0x54, 0x15, 0x1d, 0x8d, 0x3b, 0xe6, 0x77, 0x04, 0xad, 0xd6, 0x1a, 0xd3, 0xd6, 0x1a, 0x5a, 0x64,
0xf5, 0x88, 0x80, 0x10, 0xe1, 0xe4, 0xda, 0xd9, 0xfa, 0xdb, 0xee, 0x93, 0x9d, 0xdd, 0xdd, 0x3f,
0x6d, 0x85, 0x16, 0xe3, 0xf1, 0x31, 0x38, 0xf7, 0x9a, 0x5f, 0xee, 0x94, 0x31, 0xa6, 0xe8, 0xd1,
0xe9, 0x1e, 0xeb, 0x2e, 0xcd, 0xa2, 0xd4, 0x77, 0x1d, 0xda, 0x52, 0x2f, 0x73, 0x51, 0xbd, 0x48,
0x71, 0xcc, 0x95, 0x8a, 0x83, 0x5a, 0x53, 0x8d, 0x14, 0x64, 0xe0, 0x04, 0xc5, 0xf5, 0x1a, 0xa1,
0xd4, 0xa7, 0xf5, 0x9e, 0xc6, 0x2c, 0xe7, 0x35, 0xc9, 0x69, 0xb0, 0x88, 0x67, 0xc8, 0xa3, 0x9e,
0x0d, 0x07, 0x29, 0xdd, 0x05, 0x81, 0xb3, 0xe6, 0x41, 0xae, 0x4c, 0x7b, 0xd1, 0x21, 0x8d, 0x87,
0xe2, 0x7f, 0x2a, 0xf5, 0xd6, 0x54, 0xa6, 0xf6, 0x7a, 0x9e, 0xb7, 0xc1, 0xc1, 0xe9, 0xdf, 0xdd,
0xf3, 0xd1, 0x07, 0xbf, 0x01, 0xdd, 0x89, 0x77, 0x95, 0xce, 0x0d, 0x00, 0x00,
};
const StaticFile app_js PROGMEM = {(sizeof(app_js_content)/sizeof(app_js_content[0])), app_js_content};
static const uint8_t md5_js_content[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xad, 0x59, 0x79, 0x73, 0x1b, 0xb7,
0x15, 0xff, 0xbf, 0x9f, 0x42, 0xe2, 0x4c, 0x39, 0xbb, 0xb3, 0x2b, 0x05, 0xf7, 0x61, 0x72, 0xe5,
0x89, 0x93, 0x1e, 0xe9, 0x95, 0xb6, 0x69, 0xd2, 0x83, 0x43, 0xcd, 0xd0, 0xd2, 0xd2, 0xbb, 0x89,
0x42, 0xaa, 0x58, 0xd0, 0xb2, 0x62, 0xd2, 0x9f, 0xbd, 0x0f, 0xd8, 0x0b, 0x4b, 0x89, 0x3a, 0xac,
0x8e, 0x2d, 0x2c, 0x08, 0xbc, 0xf7, 0x80, 0xdf, 0xbb, 0x70, 0x1d, 0x2f, 0x37, 0xab, 0x0b, 0x5b,
0xae, 0x57, 0x51, 0xfc, 0x71, 0xb4, 0xa9, 0xf2, 0xa3, 0xca, 0x9a, 0xf2, 0xc2, 0x8e, 0x26, 0xef,
0x17, 0xe6, 0xc8, 0xa6, 0x26, 0x1b, 0x95, 0xab, 0xeb, 0x8d, 0x3d, 0x2a, 0xab, 0xa3, 0x72, 0xf5,
0x7e, 0x71, 0x55, 0x5e, 0x1e, 0xd9, 0xdb, 0xeb, 0x7c, 0x94, 0x96, 0xd9, 0xfb, 0x35, 0xfc, 0x40,
0xc7, 0x59, 0x76, 0x53, 0xae, 0x2e, 0xd7, 0x37, 0xa7, 0x5f, 0x1a, 0xb3, 0xb8, 0x7d, 0xb3, 0x59,
0x2e, 0x73, 0x93, 0x16, 0xd9, 0x08, 0x61, 0x42, 0x19, 0x17, 0x52, 0xe9, 0xc5, 0xdb, 0x8b, 0xcb,
0x7c, 0x39, 0x3a, 0xad, 0xae, 0xaf, 0x4a, 0x1b, 0x8d, 0x46, 0x71, 0x5a, 0x65, 0x33, 0x4c, 0x54,
0x4a, 0x89, 0x14, 0x2a, 0x55, 0x54, 0x29, 0x81, 0x54, 0x7a, 0x42, 0x30, 0x93, 0x4c, 0x51, 0xc1,
0xd4, 0x3c, 0xcd, 0xb3, 0x19, 0x4a, 0x55, 0x8a, 0x45, 0x4a, 0xd8, 0x3c, 0x5d, 0x64, 0xb3, 0x51,
0x91, 0x7f, 0x18, 0xa5, 0xa3, 0x85, 0x1b, 0x04, 0xbe, 0x97, 0xe5, 0xbb, 0xbc, 0xb2, 0x50, 0x79,
0xeb, 0x07, 0x6c, 0x7b, 0xde, 0xb4, 0xbf, 0xde, 0x2e, 0xaa, 0x5c, 0xb0, 0xd1, 0x3c, 0x5d, 0x66,
0xa3, 0x2f, 0xdf, 0x7c, 0xf5, 0xf5, 0x6f, 0x7e, 0xfb, 0xbb, 0xdf, 0x7f, 0xf3, 0x87, 0x3f, 0xfe,
0xe9, 0xcf, 0x7f, 0xf9, 0xf6, 0xaf, 0x7f, 0xfb, 0xfb, 0x77, 0xff, 0xf8, 0xfe, 0x87, 0x7f, 0xfe,
0xeb, 0xdf, 0xff, 0xa9, 0x67, 0xf6, 0xae, 0x28, 0x7f, 0xfc, 0xe9, 0xea, 0xe7, 0xd5, 0xfa, 0xfa,
0xbf, 0xa6, 0xb2, 0x9b, 0xf7, 0x37, 0x1f, 0x6e, 0x7f, 0xe9, 0x67, 0x9f, 0x7c, 0x11, 0xce, 0x7c,
0x95, 0xcd, 0xe6, 0x93, 0x72, 0x19, 0x95, 0xf1, 0x47, 0xa7, 0xa1, 0x75, 0xb6, 0xca, 0x6f, 0x8e,
0x02, 0xe4, 0x91, 0x50, 0xf1, 0xc4, 0xfa, 0xd6, 0xef, 0xcb, 0x95, 0x55, 0xbe, 0x2b, 0x5a, 0x3b,
0xc6, 0xb6, 0x8d, 0x92, 0xb6, 0x71, 0xe7, 0x2b, 0xa7, 0x65, 0xe5, 0xbf, 0xdb, 0x6d, 0x34, 0xf8,
0x9d, 0x75, 0xa6, 0xb1, 0xf1, 0x47, 0x93, 0xdb, 0x8d, 0x59, 0x8d, 0x66, 0xeb, 0xb7, 0x3f, 0xe6,
0x17, 0xb6, 0x1e, 0x71, 0x3e, 0xca, 0xb2, 0xec, 0x5b, 0xdf, 0x70, 0x7a, 0x6d, 0xd6, 0x76, 0xed,
0x2c, 0x73, 0x6a, 0xd7, 0xdf, 0x81, 0x09, 0x57, 0xef, 0x4e, 0x2f, 0x16, 0x57, 0x57, 0xc0, 0xba,
0x8b, 0xd3, 0x72, 0x3c, 0x3e, 0x0e, 0xe6, 0x08, 0x03, 0xfc, 0x50, 0xe6, 0x37, 0xe3, 0x71, 0x74,
0xb7, 0xf1, 0xbe, 0x41, 0xeb, 0x31, 0x61, 0x30, 0x27, 0x7f, 0xbd, 0x3c, 0xb2, 0xe3, 0xb1, 0x3d,
0xad, 0xf5, 0xde, 0xd7, 0x4e, 0x2f, 0xd6, 0x2b, 0xf0, 0x9d, 0xcd, 0x85, 0x5d, 0x1b, 0x98, 0x56,
0x20, 0x79, 0x17, 0x7b, 0x67, 0xba, 0x0f, 0xcf, 0x51, 0xd7, 0x64, 0xba, 0x26, 0xa7, 0xa6, 0x4d,
0x74, 0x8c, 0xe2, 0xd3, 0xcd, 0xf5, 0xe5, 0xc2, 0xe6, 0xd0, 0x35, 0xb3, 0xf3, 0x28, 0xde, 0xed,
0x26, 0x2d, 0x35, 0xf4, 0x03, 0x3d, 0x58, 0xc1, 0xc4, 0xab, 0x19, 0x9a, 0x67, 0xab, 0x19, 0x16,
0xbe, 0x74, 0x05, 0x71, 0x05, 0x75, 0x05, 0x73, 0x05, 0x77, 0x85, 0xef, 0x95, 0xae, 0x50, 0xae,
0xd0, 0x9e, 0xb8, 0x66, 0xf4, 0x3c, 0xd8, 0x33, 0x61, 0xcf, 0x85, 0x3d, 0x1b, 0x06, 0x3e, 0x94,
0xda, 0xa2, 0xac, 0x4e, 0xdf, 0x5e, 0xad, 0x2f, 0x7e, 0xaa, 0xb2, 0x55, 0xf3, 0xcb, 0x63, 0x52,
0x99, 0x9d, 0xe4, 0x57, 0x10, 0x2e, 0xbd, 0x2b, 0x14, 0xf7, 0xbb, 0x42, 0xc8, 0xb3, 0xe7, 0x15,
0x45, 0x3c, 0x1c, 0x60, 0xcf, 0x3f, 0x8a, 0x78, 0xe7, 0x87, 0x08, 0x69, 0x20, 0x28, 0x1e, 0xf9,
0x37, 0xaf, 0x87, 0x2c, 0x50, 0x56, 0x7f, 0x71, 0xf3, 0x25, 0xcd, 0x97, 0xd6, 0xdf, 0xca, 0x2e,
0x8c, 0xad, 0xab, 0x6f, 0x6f, 0x6d, 0x5e, 0x35, 0xbd, 0x6f, 0x7c, 0xbd, 0x01, 0xbe, 0x2c, 0x57,
0x10, 0xf0, 0xbf, 0xe4, 0x97, 0x4d, 0xe7, 0xa2, 0x2a, 0xa0, 0x7e, 0x8c, 0xdb, 0x5e, 0x08, 0x96,
0xec, 0x18, 0xed, 0x36, 0x81, 0xf3, 0xd5, 0x26, 0x1b, 0x58, 0x1a, 0x34, 0x74, 0x3c, 0x14, 0xd7,
0xe8, 0x0b, 0xb2, 0x40, 0xeb, 0x50, 0x2e, 0xa2, 0x46, 0x95, 0x77, 0xdb, 0x11, 0x24, 0x94, 0xca,
0x73, 0xb5, 0x7e, 0xe7, 0x1b, 0x6c, 0x61, 0xd6, 0x37, 0x47, 0xc6, 0x11, 0xae, 0x36, 0x57, 0x57,
0xe0, 0x61, 0x36, 0x6c, 0x2b, 0x9d, 0x23, 0x1e, 0xf4, 0xc0, 0xf8, 0x4e, 0x40, 0xda, 0xb8, 0x33,
0xdf, 0xf1, 0x30, 0xea, 0xa0, 0x6b, 0xbb, 0x05, 0x71, 0x77, 0x43, 0x03, 0x7a, 0xe2, 0x6e, 0xcc,
0xc2, 0x21, 0x5f, 0xae, 0x4d, 0xe4, 0xa0, 0x2c, 0xd2, 0x25, 0xc4, 0x37, 0x4a, 0xd7, 0x99, 0x3d,
0xbd, 0xca, 0x57, 0xef, 0x6c, 0x91, 0xde, 0x66, 0x81, 0xd9, 0xd2, 0x4d, 0x16, 0xfa, 0xc1, 0x64,
0x35, 0x5d, 0x4f, 0x3c, 0xc4, 0x40, 0xaf, 0x10, 0x8d, 0x7b, 0x5a, 0xbe, 0x75, 0xae, 0x7d, 0xeb,
0x5c, 0x3b, 0xf5, 0xa5, 0xab, 0xbb, 0x82, 0xb8, 0x82, 0xba, 0x82, 0xb9, 0x82, 0xbb, 0xc2, 0xf7,
0x4a, 0x57, 0x28, 0x57, 0x68, 0x4f, 0x5c, 0xb3, 0x7b, 0x1e, 0xec, 0x99, 0xb0, 0xe7, 0xc2, 0x9e,
0xcd, 0x3b, 0x78, 0x9c, 0x16, 0xb1, 0xf7, 0x60, 0x87, 0x64, 0x19, 0x78, 0x86, 0x9b, 0xe2, 0x78,
0xbc, 0x9c, 0x0a, 0x36, 0x49, 0x92, 0x55, 0xbc, 0x99, 0x2d, 0x93, 0x64, 0x9e, 0xd9, 0xd9, 0x6a,
0x5e, 0xeb, 0xed, 0x11, 0xfa, 0xdb, 0xd9, 0xf2, 0xec, 0x8c, 0xcc, 0xb7, 0x9e, 0x63, 0x3a, 0xcd,
0x67, 0x74, 0xec, 0x04, 0x04, 0x21, 0xf3, 0x88, 0x80, 0x68, 0x01, 0xaa, 0xbc, 0x28, 0x16, 0xe6,
0xab, 0xf5, 0x65, 0xfe, 0xa5, 0x8d, 0x56, 0x71, 0x3c, 0x85, 0x15, 0xe3, 0x75, 0x33, 0x91, 0xc5,
0xab, 0xc5, 0x94, 0x20, 0xa6, 0x5e, 0x47, 0x4d, 0x03, 0xd6, 0x64, 0xbb, 0x38, 0x3b, 0x13, 0x69,
0xfb, 0x9b, 0xa8, 0xad, 0xa0, 0xe3, 0x45, 0x0c, 0x84, 0x9c, 0x13, 0x2d, 0xb6, 0xd0, 0x9d, 0x71,
0x49, 0x19, 0xeb, 0x78, 0x08, 0x61, 0x8e, 0x07, 0x93, 0x90, 0xc9, 0x09, 0x19, 0x0b, 0x7a, 0x57,
0x0e, 0xcc, 0x48, 0x70, 0x4e, 0x45, 0x12, 0x45, 0x18, 0x11, 0xd7, 0x34, 0x9d, 0x62, 0xb4, 0xf5,
0xf5, 0xc1, 0x54, 0xdd, 0xf4, 0xe3, 0x96, 0x9f, 0x30, 0xe4, 0xc7, 0x50, 0x7b, 0x63, 0x60, 0xb2,
0x37, 0xc8, 0xa1, 0x71, 0x9f, 0xa4, 0xee, 0x43, 0xda, 0xea, 0xcc, 0xb0, 0xe8, 0x6d, 0xd0, 0x69,
0xae, 0xeb, 0x8c, 0x5a, 0xe5, 0xc5, 0x3d, 0x55, 0x1a, 0xf4, 0xb6, 0x53, 0xe9, 0x7b, 0xef, 0xd5,
0x6a, 0xcf, 0xd1, 0x29, 0xf6, 0xb0, 0xc0, 0x06, 0xee, 0x33, 0x46, 0x7c, 0xa6, 0xfe, 0x83, 0xd9,
0xb4, 0x26, 0x78, 0x70, 0x36, 0xde, 0x20, 0xff, 0xc7, 0xf9, 0xd6, 0x59, 0xf8, 0x6a, 0x51, 0x59,
0x97, 0x52, 0xbf, 0x59, 0x5d, 0xe6, 0x1f, 0xb2, 0x65, 0xda, 0xe7, 0xdb, 0x24, 0x5b, 0x9e, 0xf4,
0xf6, 0x4c, 0x97, 0x67, 0x99, 0x00, 0x1d, 0x06, 0xa9, 0x79, 0x79, 0x22, 0x58, 0xda, 0x65, 0x84,
0x28, 0x4e, 0x07, 0xd9, 0x01, 0xc5, 0xaf, 0x42, 0xda, 0x5d, 0xb3, 0x76, 0xf6, 0xf2, 0xcf, 0x18,
0xd1, 0x4c, 0x0b, 0x49, 0x34, 0xef, 0x32, 0xcb, 0x9b, 0x7a, 0xe0, 0x9e, 0xe8, 0x8b, 0x8e, 0x48,
0x4c, 0xa7, 0x28, 0xdd, 0x5f, 0x0d, 0x7c, 0xf5, 0xd7, 0x3d, 0x4d, 0x3d, 0x87, 0xdd, 0x2e, 0x0d,
0x33, 0x7e, 0x9b, 0xd5, 0xb3, 0x60, 0x23, 0x79, 0x5f, 0xca, 0xdf, 0x5b, 0x51, 0x8e, 0x51, 0xbd,
0xc3, 0x1c, 0xa4, 0x49, 0x93, 0xdd, 0xd5, 0xda, 0xc4, 0xce, 0x4c, 0xad, 0xe7, 0x0a, 0x74, 0x6b,
0xe6, 0xa9, 0x01, 0x8f, 0x13, 0xc3, 0x6c, 0xb9, 0xdd, 0x0e, 0x14, 0xe5, 0xf2, 0xa6, 0xf5, 0x79,
0xd3, 0x97, 0xae, 0xee, 0x0a, 0xe2, 0x0a, 0xea, 0x0a, 0xe6, 0x0a, 0xee, 0x0a, 0xdf, 0x2b, 0x5d,
0xa1, 0x5c, 0xa1, 0x3d, 0x71, 0xcd, 0xee, 0x79, 0xb0, 0x67, 0xc2, 0x9e, 0x0b, 0x7b, 0xb6, 0x26,
0x6f, 0x36, 0x3f, 0x3b, 0x35, 0x4d, 0xa7, 0x34, 0xad, 0x3b, 0x03, 0x5d, 0x43, 0xe3, 0x36, 0x30,
0x09, 0xc0, 0xd0, 0xa1, 0x49, 0xf7, 0x34, 0xe9, 0x1a, 0x43, 0x2d, 0x36, 0x3b, 0xf0, 0xb4, 0x4c,
0x61, 0xa9, 0x4c, 0x73, 0xd8, 0x04, 0x07, 0xba, 0x9a, 0xf4, 0x6b, 0xf0, 0x6b, 0x93, 0x45, 0x91,
0xfb, 0xb3, 0xfe, 0x6f, 0x01, 0xf0, 0x4f, 0x84, 0x42, 0x4a, 0x0a, 0x4d, 0x25, 0xf8, 0xa4, 0xdc,
0x5a, 0x37, 0x34, 0x8f, 0x4f, 0x88, 0xc4, 0x92, 0x52, 0x25, 0x35, 0x58, 0x3b, 0x3e, 0x8f, 0x4a,
0xa0, 0x87, 0xbf, 0xbe, 0xf9, 0x3c, 0x2a, 0xa0, 0x09, 0xfe, 0x4e, 0xa0, 0x81, 0x70, 0xc5, 0xb0,
0x66, 0xe7, 0x04, 0x21, 0x46, 0xb1, 0x42, 0x12, 0x8f, 0x6d, 0x9c, 0x2c, 0x40, 0x97, 0x27, 0x18,
0x4b, 0x45, 0x91, 0x44, 0x2e, 0xa4, 0x30, 0xd9, 0x16, 0x4e, 0x3c, 0x8a, 0x13, 0xeb, 0xc4, 0x8e,
0x43, 0x79, 0xb0, 0x62, 0x02, 0x07, 0x71, 0x1c, 0x44, 0x30, 0x60, 0x92, 0xdc, 0xb1, 0xc8, 0x6d,
0x09, 0x2c, 0x98, 0xc7, 0x49, 0x51, 0xb3, 0x14, 0x0d, 0x25, 0x05, 0x4a, 0x8a, 0x05, 0xe1, 0x9a,
0x20, 0x0d, 0x94, 0x84, 0x6c, 0x8d, 0xa3, 0x04, 0xe1, 0x25, 0x50, 0xbe, 0x8a, 0x1a, 0x7f, 0x29,
0x50, 0xeb, 0x2b, 0x05, 0x86, 0x93, 0x48, 0xb3, 0xc3, 0x49, 0xbd, 0x22, 0x92, 0x56, 0x13, 0x89,
0xc7, 0xd2, 0xec, 0x7a, 0xe2, 0x73, 0x33, 0x8e, 0xca, 0xf3, 0xc2, 0x0f, 0x13, 0x28, 0x48, 0x84,
0x0a, 0x4a, 0x4c, 0xa0, 0x18, 0x60, 0x37, 0x8d, 0x42, 0xa0, 0x5a, 0x9e, 0xdb, 0x31, 0xfc, 0x2e,
0xe3, 0x46, 0x05, 0x54, 0x69, 0x2e, 0x18, 0x57, 0xe2, 0x5e, 0x15, 0xd8, 0x73, 0xd3, 0x20, 0x4f,
0x04, 0x12, 0x18, 0x71, 0x85, 0xf5, 0xa3, 0xc0, 0x11, 0x63, 0x9c, 0x70, 0x4a, 0xd1, 0x5d, 0xe0,
0xf1, 0x5d, 0x68, 0x45, 0x08, 0x88, 0x01, 0xbb, 0x14, 0x0c, 0x2b, 0xa5, 0xe5, 0xe7, 0x00, 0xe2,
0xf3, 0x04, 0x83, 0x9d, 0x91, 0x42, 0x8c, 0x3c, 0x8c, 0x48, 0xc0, 0x48, 0x0c, 0x9c, 0x83, 0x62,
0xca, 0xf0, 0x83, 0x90, 0xe4, 0xfc, 0x84, 0x71, 0x89, 0xb8, 0x56, 0xf4, 0x2e, 0xa0, 0x47, 0xf0,
0x28, 0x98, 0x8f, 0x94, 0x08, 0x51, 0xce, 0xf0, 0x67, 0x59, 0x48, 0xc3, 0x34, 0xb5, 0x73, 0x60,
0xc6, 0xb0, 0x7c, 0x10, 0x10, 0x44, 0xfb, 0x09, 0x23, 0xc8, 0xe7, 0xf6, 0xc3, 0x60, 0xb0, 0xf3,
0x7a, 0xad, 0x11, 0x43, 0x30, 0x21, 0xf2, 0x6c, 0x3c, 0x90, 0x46, 0x12, 0x08, 0x21, 0x26, 0x10,
0x15, 0x8a, 0x7c, 0x96, 0xcb, 0x81, 0x87, 0x30, 0x04, 0x3a, 0xc7, 0x08, 0x3f, 0x8c, 0xc7, 0xf9,
0x02, 0x47, 0x60, 0x4d, 0x42, 0x34, 0x7a, 0x18, 0x94, 0x37, 0x3b, 0x15, 0x9c, 0x72, 0x4a, 0xee,
0x09, 0xb7, 0x1e, 0x54, 0x3b, 0x25, 0x98, 0xcd, 0xb8, 0x87, 0x08, 0xe8, 0x06, 0x31, 0x81, 0x05,
0x97, 0x5a, 0x70, 0xec, 0x46, 0xe5, 0x35, 0x40, 0xd9, 0x02, 0xec, 0xbd, 0x07, 0x09, 0xcd, 0x11,
0x16, 0xd4, 0xa9, 0x41, 0xd7, 0x28, 0x68, 0x83, 0xc2, 0x01, 0xee, 0x34, 0x61, 0x9d, 0x02, 0x03,
0xfd, 0x27, 0x82, 0x51, 0x48, 0x2c, 0x12, 0x7b, 0x4b, 0xb1, 0x1a, 0x94, 0x6a, 0x40, 0xf5, 0x81,
0x4d, 0x25, 0x84, 0xa6, 0xa4, 0xc8, 0x1b, 0x09, 0xd5, 0x78, 0xc8, 0x67, 0xe0, 0xe1, 0xf3, 0x13,
0x89, 0x30, 0x87, 0x08, 0xd7, 0xf8, 0x20, 0x1e, 0x70, 0x9e, 0x84, 0x2a, 0x40, 0x83, 0xbc, 0x93,
0x3f, 0x1d, 0x0e, 0x48, 0x17, 0x02, 0xb9, 0x8c, 0x48, 0xf9, 0x61, 0x38, 0xcc, 0xd9, 0x1c, 0x36,
0x3d, 0x52, 0x31, 0xf5, 0x42, 0x38, 0x7a, 0x9e, 0x70, 0xa1, 0x18, 0x03, 0x25, 0xaa, 0xc3, 0x70,
0x9c, 0xef, 0x20, 0xac, 0x15, 0xb8, 0xa9, 0xf7, 0x9d, 0x27, 0x03, 0x72, 0xf9, 0x4b, 0x49, 0x2a,
0xa8, 0x16, 0xf8, 0x30, 0x1e, 0x17, 0xd6, 0x60, 0x79, 0x4e, 0xc1, 0x41, 0xf1, 0x0b, 0x01, 0xb9,
0x80, 0x80, 0xe8, 0x66, 0x42, 0x61, 0x26, 0xe4, 0x41, 0x48, 0xb0, 0xf6, 0x70, 0x0c, 0x81, 0x03,
0x2a, 0x7c, 0x0e, 0x1e, 0xe9, 0x12, 0x90, 0x0b, 0x0b, 0x05, 0xc9, 0xee, 0x30, 0x20, 0xec, 0x96,
0x36, 0x4d, 0xc0, 0x94, 0x12, 0x36, 0xc1, 0x0f, 0x21, 0xca, 0xb3, 0x0e, 0x54, 0x54, 0x65, 0x0e,
0xc4, 0x79, 0x87, 0xab, 0x02, 0x69, 0xb5, 0xc7, 0xc1, 0x34, 0xc1, 0xe3, 0x40, 0x0e, 0xab, 0xc1,
0xa8, 0x06, 0x4c, 0xad, 0xbc, 0x13, 0x02, 0x31, 0xcd, 0x25, 0x60, 0xf6, 0x53, 0xc2, 0x35, 0x18,
0xdc, 0x81, 0x09, 0xd2, 0x48, 0x0e, 0xf0, 0x9b, 0xb0, 0xc1, 0x8a, 0x6a, 0x44, 0x11, 0xf7, 0x69,
0x0b, 0x8b, 0x1a, 0x87, 0x68, 0x70, 0xb4, 0x56, 0x07, 0xa8, 0x48, 0x73, 0xee, 0x72, 0x2d, 0xa1,
0x1e, 0x82, 0xfe, 0x0c, 0x04, 0x2e, 0x07, 0x38, 0x41, 0xb0, 0x7a, 0x0b, 0x74, 0x3f, 0x0a, 0xe6,
0x52, 0x8e, 0x24, 0x4a, 0x83, 0xe3, 0x3f, 0x15, 0x85, 0x74, 0x62, 0x39, 0xd3, 0xb2, 0x4e, 0x1a,
0xf7, 0x62, 0x40, 0xce, 0x73, 0x35, 0x18, 0x0b, 0x09, 0x86, 0x5e, 0x88, 0x82, 0x42, 0xae, 0x51,
0x30, 0x4b, 0x8d, 0x25, 0xbb, 0x1f, 0x85, 0xcb, 0x33, 0x5c, 0x41, 0x60, 0x12, 0x42, 0x9e, 0x08,
0x02, 0xdc, 0x15, 0xa8, 0x39, 0xc1, 0x5a, 0xea, 0x03, 0x20, 0xc4, 0x3c, 0x91, 0x02, 0x11, 0x8d,
0x95, 0x7e, 0x19, 0x02, 0x58, 0xfd, 0x40, 0x0b, 0x54, 0x30, 0xa6, 0xe4, 0xfd, 0x00, 0x9c, 0xe3,
0x32, 0x82, 0x15, 0xe6, 0xaa, 0x4e, 0x40, 0x4f, 0x71, 0x26, 0x58, 0x2e, 0xc0, 0xba, 0x92, 0x01,
0x0a, 0x74, 0x00, 0x02, 0x88, 0xd5, 0x1a, 0xf6, 0x30, 0x0a, 0x96, 0x81, 0x07, 0x30, 0xf4, 0xf1,
0x1d, 0x46, 0x77, 0x64, 0xb6, 0x9f, 0xba, 0x3c, 0x0e, 0x59, 0x48, 0x50, 0x04, 0x19, 0x0b, 0xc4,
0x88, 0x1a, 0x80, 0x68, 0x00, 0x6c, 0x3f, 0x95, 0x5d, 0x90, 0xc2, 0xbe, 0x52, 0x69, 0xcc, 0x30,
0xaf, 0x8f, 0x83, 0x1e, 0x04, 0x69, 0x41, 0x84, 0xc1, 0x1d, 0x15, 0xdb, 0x4f, 0x41, 0x96, 0x63,
0x2e, 0x0f, 0x31, 0x8d, 0x3c, 0x1f, 0xaf, 0x81, 0xc8, 0x06, 0xc8, 0xf6, 0x93, 0x6d, 0x97, 0x00,
0x88, 0x36, 0xea, 0x52, 0xb0, 0xc3, 0x82, 0xeb, 0xd0, 0xc6, 0xcf, 0x05, 0xe3, 0x17, 0x7f, 0xd8,
0xcc, 0x30, 0x88, 0x6d, 0x89, 0x0f, 0xc3, 0xf1, 0x39, 0x14, 0xce, 0x5a, 0x0a, 0xd2, 0x89, 0x78,
0x06, 0x1c, 0xef, 0xfa, 0x1c, 0x73, 0x42, 0x1f, 0xc0, 0x82, 0x5d, 0xf6, 0x00, 0xc0, 0x04, 0xdc,
0x5a, 0xbf, 0x04, 0x8d, 0xcb, 0xe1, 0x90, 0xe9, 0x61, 0xff, 0x47, 0xb9, 0x3e, 0x0c, 0xc6, 0xad,
0x70, 0x10, 0x8b, 0x70, 0x52, 0xf0, 0x16, 0x7c, 0x22, 0x16, 0xb7, 0x3f, 0xe0, 0xc2, 0xad, 0x40,
0xb0, 0xaa, 0x3e, 0x84, 0x06, 0x42, 0x14, 0x43, 0xa2, 0xc1, 0x1c, 0x0b, 0xf6, 0x22, 0x38, 0xde,
0x17, 0x38, 0xe8, 0x0e, 0x8e, 0x33, 0x0f, 0xa0, 0xf1, 0x87, 0x1e, 0x48, 0xbf, 0x18, 0x56, 0x12,
0xfd, 0x74, 0x3c, 0x60, 0x79, 0x09, 0xda, 0x52, 0x92, 0x78, 0x5d, 0x1d, 0x82, 0x03, 0xf1, 0x4a,
0xc1, 0xcf, 0xc0, 0x3f, 0xea, 0xa8, 0xd9, 0x03, 0x13, 0x1c, 0xf5, 0xa2, 0xee, 0x72, 0x37, 0xe9,
0x8e, 0x68, 0xb4, 0xa3, 0x29, 0x70, 0x66, 0x06, 0x87, 0xbc, 0xb4, 0xbd, 0xfb, 0x2d, 0x83, 0x13,
0x5d, 0xdf, 0x4e, 0xb3, 0x22, 0x69, 0xc9, 0xd5, 0x70, 0xa4, 0xec, 0x18, 0xc7, 0xaf, 0xfa, 0xd1,
0xea, 0xaf, 0xc7, 0xda, 0x0d, 0xd5, 0x7c, 0xbd, 0xa6, 0xba, 0x81, 0x9a, 0x6f, 0x30, 0xf1, 0xf6,
0xc6, 0xb9, 0xa0, 0x35, 0xe8, 0xbd, 0x43, 0xaf, 0xbb, 0x17, 0xe9, 0xcf, 0xbc, 0x83, 0x5b, 0x82,
0x28, 0x0e, 0xef, 0x08, 0x0e, 0x9c, 0xf9, 0xda, 0x9b, 0x6b, 0x3a, 0x69, 0xee, 0x40, 0x8a, 0x19,
0x98, 0x90, 0x8d, 0x5d, 0xb6, 0x2a, 0xc0, 0x0d, 0xc7, 0xd6, 0x7d, 0xad, 0xbf, 0xe1, 0xa9, 0xdb,
0xa0, 0xae, 0xfa, 0x2a, 0x41, 0x7d, 0x1d, 0x8b, 0xa0, 0x3d, 0xa4, 0x69, 0xc5, 0x99, 0x81, 0x64,
0xd3, 0x34, 0x75, 0x92, 0x4d, 0x2f, 0xd9, 0x04, 0x92, 0x4d, 0x20, 0xd9, 0x04, 0x92, 0x4d, 0x20,
0xb9, 0x1c, 0x48, 0x2e, 0x9b, 0xa6, 0x4e, 0x72, 0xd9, 0x4b, 0x2e, 0x03, 0xc9, 0x65, 0x20, 0xb9,
0x0c, 0x24, 0x97, 0x81, 0xe4, 0x6a, 0x20, 0xb9, 0x6a, 0x9a, 0x3a, 0xc9, 0x55, 0x2f, 0xb9, 0x0a,
0x24, 0x57, 0x81, 0xe4, 0x2a, 0x90, 0x5c, 0xb5, 0x92, 0x87, 0x66, 0x6c, 0x1f, 0x9d, 0xb2, 0x3d,
0xdb, 0x0e, 0x88, 0xea, 0x37, 0xbb, 0x97, 0x99, 0xbb, 0xd8, 0x33, 0xf7, 0x8c, 0x70, 0x30, 0x71,
0xea, 0x6d, 0x0a, 0xd5, 0xb4, 0xb6, 0x62, 0x53, 0x83, 0x99, 0xba, 0x9a, 0x23, 0x31, 0xa9, 0x69,
0x49, 0x4c, 0x47, 0x62, 0x06, 0x24, 0x65, 0x5a, 0xb6, 0x24, 0x65, 0x47, 0x52, 0x0e, 0x48, 0x8a,
0xb4, 0x68, 0x49, 0x8a, 0x8e, 0xa4, 0x68, 0x49, 0xf6, 0x74, 0xe2, 0x1f, 0x24, 0xb3, 0xbb, 0x0a,
0xb8, 0x4b, 0x54, 0x3f, 0x33, 0x3c, 0xae, 0x98, 0xfd, 0x07, 0x26, 0x58, 0x7a, 0x41, 0x49, 0xfb,
0xef, 0x46, 0x36, 0x6e, 0x43, 0xc1, 0xf8, 0x9b, 0xb0, 0x56, 0x9b, 0xfe, 0x16, 0xac, 0x51, 0xa8,
0xf1, 0xb7, 0x61, 0xed, 0xb5, 0x89, 0xbf, 0x15, 0xab, 0xd5, 0x9a, 0xda, 0x21, 0x88, 0xfa, 0xc9,
0x22, 0x3b, 0x30, 0xe5, 0x21, 0xa9, 0x7f, 0x72, 0x0d, 0x51, 0xb4, 0xef, 0x23, 0xcd, 0x2d, 0x56,
0x36, 0x1a, 0xb5, 0xe1, 0xea, 0x65, 0x44, 0x71, 0x9a, 0x67, 0x68, 0x92, 0x43, 0x5e, 0x9c, 0xc4,
0x36, 0xab, 0x66, 0xb9, 0xbb, 0x57, 0x35, 0x6d, 0xa5, 0x6c, 0x2b, 0x90, 0xbf, 0x97, 0x33, 0x9f,
0x97, 0xe7, 0xc9, 0x72, 0x26, 0x28, 0x1c, 0x76, 0xdd, 0xa6, 0xc6, 0xe5, 0x48, 0x16, 0xb7, 0x4d,
0x90, 0x85, 0x88, 0xcf, 0xae, 0xa2, 0x6d, 0x2a, 0xe7, 0xad, 0x1e, 0xbc, 0xec, 0x7d, 0x39, 0x4e,
0xc6, 0x58, 0xc0, 0x42, 0x32, 0xca, 0xb2, 0xd1, 0x2e, 0x6d, 0x1e, 0xb1, 0x7f, 0xbe, 0xe4, 0x77,
0xee, 0xe0, 0xb2, 0xdb, 0xc8, 0xbf, 0x3e, 0xc7, 0x13, 0x7b, 0x7a, 0x61, 0xf2, 0xc1, 0x03, 0xd7,
0xf0, 0xd9, 0x72, 0x97, 0xda, 0x3b, 0x4f, 0x60, 0xfd, 0xcb, 0x66, 0xcb, 0x1d, 0x05, 0x4f, 0x9b,
0xbb, 0x49, 0xab, 0x24, 0x03, 0xaa, 0x30, 0xd3, 0x45, 0xf3, 0x80, 0x34, 0x49, 0x12, 0x53, 0x0f,
0x5f, 0x66, 0x8b, 0x99, 0x99, 0x4f, 0xec, 0xac, 0x9c, 0xc3, 0x44, 0xca, 0xb8, 0xbb, 0xeb, 0xdd,
0x45, 0x31, 0xfc, 0x9f, 0xfc, 0xea, 0x7f, 0xf1, 0xc2, 0x99, 0x50, 0xc3, 0x1f, 0x00, 0x00,
};
const StaticFile md5_js PROGMEM = {(sizeof(md5_js_content)/sizeof(md5_js_content[0])), md5_js_content};
static const uint8_t style_css_content[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x9d, 0x53, 0x5d, 0x6b, 0xdb, 0x30,
0x14, 0x7d, 0xdf, 0xaf, 0x08, 0x94, 0x41, 0x0b, 0x76, 0xb0, 0x9b, 0x26, 0x59, 0x64, 0xf6, 0xb0,
0x3d, 0x8c, 0xed, 0x61, 0x4f, 0x65, 0x4f, 0xa3, 0x14, 0x7d, 0x5c, 0xd9, 0x22, 0xb2, 0x25, 0xa4,
0xeb, 0x26, 0x99, 0xf1, 0x7f, 0x9f, 0xfc, 0x11, 0x37, 0x69, 0x32, 0x28, 0xc3, 0x20, 0xb8, 0x1f,
0xd2, 0x39, 0xf7, 0xdc, 0x63, 0x66, 0xc4, 0x21, 0x2a, 0xb0, 0xd4, 0x8d, 0xa5, 0x42, 0xa8, 0x2a,
0x27, 0x49, 0x56, 0x52, 0x97, 0xab, 0x8a, 0x24, 0x2d, 0xeb, 0x8a, 0xac, 0x46, 0x34, 0x55, 0xa4,
0x2a, 0x5b, 0xe3, 0x6f, 0x3c, 0x58, 0xf8, 0x6c, 0xa9, 0xf7, 0x3b, 0xe3, 0xc4, 0xd3, 0x69, 0x12,
0x61, 0x8f, 0x4f, 0x8d, 0x34, 0x15, 0xc6, 0x5e, 0xfd, 0x01, 0x92, 0xae, 0xec, 0x3e, 0xeb, 0x43,
0x49, 0x4b, 0xa5, 0x0f, 0x24, 0xa6, 0xd6, 0x6a, 0x88, 0xfd, 0xc1, 0x23, 0x94, 0xd1, 0x57, 0xad,
0xaa, 0xed, 0x4f, 0xca, 0x1f, 0xfb, 0xf0, 0x5b, 0xe8, 0x8b, 0x1e, 0x21, 0x37, 0x30, 0xfb, 0xf5,
0x23, 0xfa, 0x0e, 0xfa, 0x05, 0x50, 0x71, 0x1a, 0x7d, 0x71, 0x8a, 0xea, 0xc8, 0xd3, 0xca, 0xc7,
0x1e, 0x9c, 0x92, 0xed, 0x1c, 0x15, 0x6a, 0x98, 0xb8, 0xa6, 0x89, 0xdd, 0xcf, 0xfa, 0x63, 0x42,
0xdb, 0x81, 0xca, 0x0b, 0x24, 0xab, 0x24, 0xc9, 0x18, 0xe5, 0xdb, 0xdc, 0x99, 0xba, 0x12, 0x31,
0x37, 0xda, 0x38, 0x72, 0x03, 0x52, 0xde, 0xcb, 0x65, 0xc6, 0x02, 0x79, 0x70, 0x31, 0x33, 0x61,
0xb2, 0x92, 0xa4, 0xe1, 0xba, 0x37, 0x5a, 0x89, 0xd9, 0x8d, 0xd8, 0x40, 0x02, 0xeb, 0x6c, 0xec,
0xbe, 0x5f, 0xaf, 0x80, 0x3d, 0x64, 0x27, 0x33, 0x2d, 0xed, 0xbe, 0x9d, 0x33, 0x6d, 0xf8, 0xf6,
0x8c, 0x42, 0x3b, 0x97, 0xb5, 0xd6, 0xf1, 0x4e, 0x09, 0x2c, 0x9a, 0xfe, 0x0c, 0xe9, 0xe4, 0x63,
0xc0, 0xd9, 0x77, 0x17, 0xbb, 0xb6, 0x09, 0xb2, 0x6b, 0x36, 0xae, 0x7c, 0xd6, 0x94, 0xc1, 0x89,
0xe8, 0xb3, 0x64, 0xb6, 0xb8, 0x1c, 0x61, 0xec, 0xed, 0x65, 0x6e, 0x86, 0xb5, 0x4c, 0xac, 0x7b,
0x2e, 0x7d, 0xd9, 0xd7, 0xec, 0xec, 0xb5, 0x18, 0x8d, 0x25, 0xe1, 0xb5, 0xf6, 0x5d, 0x3b, 0x8b,
0x3c, 0x68, 0xe0, 0xd8, 0x8c, 0x0c, 0x1d, 0x15, 0xaa, 0xf6, 0xe4, 0x21, 0x90, 0x19, 0x32, 0xa7,
0xfa, 0xf0, 0x0d, 0xe7, 0x5c, 0x66, 0x47, 0xd6, 0xeb, 0x50, 0xd9, 0x84, 0x46, 0x53, 0x63, 0x58,
0x27, 0x04, 0xc7, 0x5c, 0x43, 0x24, 0xd2, 0xf0, 0xda, 0x5f, 0xe0, 0x8e, 0xe9, 0x01, 0x7d, 0x08,
0x9a, 0x5e, 0xb0, 0x82, 0x0a, 0xb3, 0xeb, 0x15, 0xe9, 0x37, 0xeb, 0x72, 0x46, 0x6f, 0x93, 0xa8,
0xfb, 0xe6, 0xe9, 0xf2, 0xae, 0x1d, 0xfc, 0x48, 0x84, 0xf2, 0x94, 0x69, 0x10, 0x57, 0x8d, 0x79,
0xb5, 0x3a, 0xa0, 0x4e, 0x95, 0x11, 0xf8, 0x18, 0x37, 0x97, 0x6e, 0x91, 0x69, 0x70, 0xcb, 0xe2,
0xe8, 0x96, 0xb3, 0xe4, 0xc8, 0xe2, 0x7f, 0x45, 0xeb, 0x96, 0xf7, 0xaa, 0xda, 0x89, 0x51, 0xc3,
0xfb, 0x52, 0x1e, 0xfd, 0x97, 0x04, 0x0f, 0x5b, 0xe3, 0x15, 0xaa, 0x30, 0xaf, 0x03, 0x4d, 0x51,
0xbd, 0x40, 0xd6, 0xdd, 0x89, 0x8b, 0xc1, 0x22, 0xe9, 0xa7, 0x2b, 0x9e, 0x19, 0x05, 0xaa, 0x0c,
0xde, 0x4e, 0xd3, 0xdd, 0x91, 0xc2, 0xbc, 0x80, 0x7b, 0x8f, 0xc0, 0x19, 0xaf, 0x9d, 0x0f, 0xf0,
0xd6, 0xa8, 0x0a, 0xc1, 0xbd, 0x19, 0x9f, 0x2d, 0x39, 0x87, 0xc5, 0xf9, 0x1f, 0xf2, 0x0f, 0x44,
0xca, 0x3b, 0xbe, 0x4d, 0x67, 0xc6, 0xa0, 0xc6, 0xd8, 0x34, 0x57, 0xfe, 0xd9, 0x81, 0x07, 0x8c,
0xde, 0xc4, 0xd7, 0xf9, 0x1e, 0xff, 0xdb, 0xd5, 0x62, 0x93, 0xae, 0xdb, 0x0f, 0x7f, 0x01, 0x37,
0xdb, 0x6e, 0xf6, 0xae, 0x04, 0x00, 0x00,
};
const StaticFile style_css PROGMEM = {(sizeof(style_css_content)/sizeof(style_css_content[0])), style_css_content};
static const uint8_t favicon_ico_content[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x99, 0x4b, 0x48, 0x15, 0x61,
0x14, 0xc7, 0xcf, 0xc5, 0x17, 0x2e, 0x4a, 0x57, 0xe5, 0x63, 0xe1, 0x85, 0x42, 0x23, 0x8c, 0x8c,
0x20, 0x4d, 0x45, 0xdb, 0x59, 0x14, 0x2e, 0x7a, 0xab, 0x68, 0x1b, 0x17, 0xae, 0x24, 0x41, 0xf1,
0x41, 0xa0, 0x41, 0xa1, 0x11, 0x28, 0x1a, 0x2e, 0x12, 0x72, 0xe7, 0x03, 0x09, 0x74, 0x15, 0x54,
0x1b, 0x97, 0xd9, 0x53, 0x23, 0x8a, 0x5a, 0x94, 0x25, 0x59, 0x91, 0x82, 0xa0, 0x81, 0x99, 0x39,
0xfd, 0x8f, 0x73, 0x46, 0xbf, 0xc6, 0xb9, 0x73, 0x67, 0xee, 0x9d, 0xab, 0x41, 0x1e, 0xf8, 0x71,
0xe7, 0x7e, 0xe7, 0xf1, 0x9f, 0xb9, 0xdf, 0x7c, 0x8f, 0x99, 0x4b, 0xe4, 0xa3, 0x28, 0x4a, 0x4c,
0x24, 0x7c, 0xfa, 0xa9, 0x2a, 0x9a, 0xe8, 0x08, 0x11, 0x25, 0x25, 0xe9, 0xdf, 0xdb, 0xe2, 0x89,
0x7a, 0xd1, 0xe6, 0xf7, 0xeb, 0xdf, 0x07, 0x11, 0x97, 0xbe, 0x93, 0x68, 0x1f, 0x62, 0x70, 0x88,
0x16, 0xbd, 0x7d, 0xd5, 0x10, 0x37, 0x43, 0x3a, 0x0e, 0x2c, 0x06, 0xdc, 0x16, 0x62, 0x9c, 0xa5,
0xfc, 0x65, 0xb1, 0x60, 0x58, 0x88, 0x75, 0x99, 0x9b, 0x06, 0x2a, 0xc1, 0x1b, 0xa1, 0x52, 0xda,
0x82, 0x19, 0xae, 0x90, 0xca, 0xc0, 0x04, 0x58, 0x02, 0x9a, 0xb0, 0x24, 0x6d, 0xa5, 0x12, 0x13,
0xc8, 0xd8, 0x3f, 0xab, 0xe4, 0x99, 0x99, 0x95, 0xfa, 0x56, 0xe6, 0x17, 0x8d, 0x40, 0xb9, 0x06,
0x13, 0x12, 0x6b, 0xb6, 0x4b, 0xa6, 0x73, 0x0e, 0xc4, 0x92, 0xc4, 0x9a, 0xed, 0xba, 0x83, 0x5c,
0x83, 0x6b, 0x16, 0xf9, 0x1d, 0x2e, 0xf2, 0x3b, 0x2c, 0xf2, 0xdb, 0x5d, 0xe4, 0xb7, 0x9b, 0x72,
0xf9, 0xfe, 0xea, 0x76, 0x91, 0xcf, 0xb1, 0x6a, 0x3f, 0x9e, 0x03, 0x5f, 0x5c, 0xe4, 0x73, 0xec,
0x59, 0x25, 0xbf, 0x00, 0xdc, 0x02, 0x5d, 0xe0, 0x2e, 0x58, 0x00, 0xf7, 0xe5, 0x7b, 0x97, 0x1c,
0x2f, 0x88, 0xaf, 0x4b, 0x62, 0x0b, 0x4c, 0xd7, 0xe0, 0x93, 0xcf, 0x7c, 0xf0, 0x99, 0xf4, 0x7b,
0xc9, 0xb0, 0x52, 0x69, 0xcb, 0x37, 0xc5, 0x5a, 0x59, 0x1e, 0x98, 0x06, 0x25, 0x4a, 0x5b, 0x89,
0xb4, 0xe5, 0xd9, 0xe4, 0x19, 0x96, 0x09, 0x9e, 0x83, 0x22, 0xa5, 0xad, 0x48, 0xda, 0x32, 0xcd,
0xc1, 0x8b, 0x18, 0x55, 0x93, 0x71, 0x44, 0xa3, 0x51, 0x44, 0x2d, 0x3e, 0x1d, 0xa7, 0xc6, 0x79,
0x3c, 0xcf, 0x60, 0x2a, 0xa2, 0x2c, 0x52, 0xe6, 0x99, 0x78, 0x57, 0xf3, 0x8c, 0xd9, 0x52, 0xc1,
0x43, 0xf0, 0x40, 0x8e, 0xbd, 0xb6, 0x1c, 0xf0, 0x5e, 0xc8, 0x89, 0x40, 0xfd, 0x6c, 0xf0, 0x41,
0xc8, 0xf6, 0xb0, 0x2e, 0x8f, 0x91, 0xbd, 0xa0, 0x0a, 0x7c, 0x15, 0xf8, 0x78, 0x0f, 0xd9, 0xcf,
0x69, 0xc1, 0x8c, 0x7b, 0x9c, 0xbb, 0xaf, 0x0d, 0x3c, 0x05, 0xf3, 0xb4, 0x3e, 0x4e, 0xe6, 0xa5,
0xad, 0x15, 0x1c, 0x24, 0xfb, 0xfb, 0xd6, 0xca, 0xf8, 0xbc, 0x78, 0x6c, 0x3a, 0x99, 0x17, 0xc7,
0x25, 0xd6, 0xe9, 0xb5, 0xf0, 0xb9, 0x9c, 0x27, 0x7d, 0x5c, 0x38, 0x1d, 0xf7, 0xd3, 0x92, 0xe3,
0xe4, 0x3a, 0xf8, 0x37, 0x79, 0xe1, 0xa2, 0xb6, 0x7a, 0x1d, 0x59, 0x41, 0x6a, 0x73, 0x5f, 0xb6,
0x85, 0x50, 0xdb, 0xe0, 0x06, 0xd9, 0xaf, 0xc9, 0xe9, 0xa4, 0xf7, 0x5b, 0xa8, 0xf5, 0x9f, 0x48,
0x8d, 0x40, 0x76, 0x0c, 0xfc, 0x08, 0xa3, 0x3e, 0xcf, 0xa5, 0x85, 0x36, 0xf5, 0xcf, 0x84, 0x51,
0xdb, 0xe0, 0xb4, 0x4d, 0xfd, 0x72, 0x0f, 0xea, 0x97, 0xdb, 0xd4, 0xaf, 0xf0, 0xa0, 0x7e, 0xc5,
0x16, 0xd6, 0x2f, 0xf3, 0xa0, 0x7e, 0xa0, 0x7d, 0x12, 0xcf, 0x57, 0x9d, 0x1e, 0xd4, 0xef, 0x94,
0x5a, 0xaa, 0x61, 0x15, 0xa3, 0x5e, 0xf0, 0xd3, 0x83, 0xfa, 0x5c, 0xe3, 0x8e, 0xd4, 0x34, 0x8c,
0xe7, 0x8d, 0x26, 0xf0, 0x58, 0x18, 0x03, 0xcf, 0x48, 0x5f, 0xea, 0x38, 0x67, 0x4a, 0xf1, 0x19,
0x4c, 0x89, 0x6f, 0x46, 0x62, 0xc7, 0x14, 0x5f, 0x13, 0x6d, 0x9c, 0x8b, 0x78, 0x5c, 0xef, 0x06,
0x29, 0xa4, 0x2f, 0xad, 0xbc, 0xc4, 0x8e, 0x80, 0x15, 0xd2, 0xe7, 0xe1, 0x64, 0xf1, 0xa5, 0xc8,
0x71, 0xab, 0xf8, 0x46, 0x68, 0x7d, 0x39, 0x4e, 0x91, 0x1a, 0x4e, 0xf6, 0xed, 0x09, 0xa0, 0x5f,
0x6a, 0x34, 0x58, 0xf8, 0x1b, 0xc4, 0xd7, 0x2f, 0xb1, 0x6e, 0x8d, 0x1f, 0x43, 0x06, 0xa4, 0x46,
0xa3, 0x85, 0xbf, 0x51, 0x7c, 0x03, 0x12, 0xeb, 0xd6, 0xf8, 0x9c, 0xfa, 0xa4, 0x46, 0xbd, 0x85,
0xbf, 0x5e, 0x7c, 0x7d, 0x14, 0xda, 0xf9, 0x73, 0xff, 0x5f, 0x01, 0x9f, 0xc0, 0x49, 0x0b, 0x3f,
0xb7, 0x4d, 0x49, 0x4c, 0x94, 0x85, 0x7f, 0xd5, 0xb4, 0xab, 0xda, 0x2a, 0x2b, 0xd4, 0x4c, 0x8b,
0x78, 0x94, 0x99, 0xc3, 0xa9, 0x4c, 0x52, 0xdc, 0x2a, 0xa3, 0x48, 0x63, 0x5a, 0x70, 0x2b, 0x04,
0xc2, 0x6c, 0xdc, 0xb6, 0x98, 0x94, 0xb6, 0x06, 0x77, 0xbc, 0x9f, 0xf4, 0x89, 0x79, 0x6d, 0x1f,
0x96, 0xb8, 0x71, 0x1f, 0x76, 0xea, 0x72, 0x51, 0x48, 0x48, 0xf9, 0x77, 0xe0, 0x2d, 0x48, 0x0b,
0xb5, 0x4e, 0x18, 0xfa, 0xd5, 0xb4, 0x3e, 0x36, 0xab, 0xb7, 0x40, 0xbf, 0x56, 0xd1, 0xaf, 0xfd,
0x0f, 0xf5, 0xeb, 0x14, 0xfd, 0xba, 0x4d, 0xd2, 0xe4, 0xdb, 0x9a, 0x9f, 0x35, 0x5f, 0x82, 0xdf,
0x8a, 0x3e, 0x1f, 0xf3, 0x9e, 0x92, 0x9f, 0x23, 0x33, 0x22, 0xa0, 0xcb, 0x73, 0xe7, 0x10, 0xe9,
0xf3, 0x46, 0xb0, 0x75, 0x82, 0x63, 0x06, 0xc1, 0x2e, 0x8f, 0xb4, 0xf3, 0x68, 0x7d, 0x3d, 0x71,
0xc3, 0x77, 0x90, 0x1b, 0xa6, 0x76, 0x21, 0xe9, 0x7b, 0xb3, 0x50, 0xd7, 0x4c, 0xce, 0x2d, 0x08,
0xe3, 0x37, 0x77, 0xb3, 0x97, 0x0f, 0xc4, 0x37, 0x90, 0x1c, 0x82, 0xfe, 0x90, 0x07, 0xda, 0x06,
0x83, 0x2e, 0xb5, 0xf7, 0x9b, 0xee, 0xef, 0x70, 0xe1, 0x5a, 0x07, 0x5c, 0xe8, 0xbb, 0x79, 0x77,
0xe3, 0x94, 0x9b, 0x2e, 0xf4, 0x5f, 0x47, 0x40, 0xff, 0x95, 0x43, 0x6d, 0xde, 0x23, 0x39, 0x19,
0xe7, 0xa1, 0xf4, 0x41, 0xb4, 0x03, 0xfd, 0xd4, 0x08, 0x68, 0x1b, 0xa4, 0x38, 0xd0, 0xcf, 0x88,
0xa0, 0x7e, 0xd0, 0xb9, 0x79, 0x5b, 0x7f, 0x5b, 0x7f, 0xab, 0xf4, 0x61, 0x17, 0xc1, 0xbd, 0x08,
0xea, 0x73, 0xed, 0x0b, 0x01, 0xb4, 0xe3, 0xc1, 0xaf, 0x08, 0x6a, 0x1b, 0xf0, 0xff, 0x05, 0xf1,
0x16, 0xfa, 0xfc, 0x50, 0x31, 0xbe, 0x09, 0xfa, 0xfc, 0x0e, 0xcd, 0x67, 0xf3, 0x1b, 0x1c, 0x02,
0x87, 0x4d, 0xd4, 0x28, 0xf9, 0xc3, 0x16, 0x7e, 0x83, 0x61, 0x25, 0xae, 0xc6, 0xc2, 0x7f, 0xc8,
0xea, 0xda, 0x1d, 0xdc, 0x93, 0xc5, 0x4a, 0xdd, 0x1e, 0x9b, 0xb8, 0x1e, 0x25, 0xae, 0xd8, 0xad,
0xce, 0xb6, 0xfe, 0x3f, 0xab, 0x7f, 0x42, 0xa9, 0xdb, 0x6d, 0x13, 0xa7, 0xfe, 0x67, 0x75, 0xdc,
0x43, 0xfd, 0x1d, 0xa4, 0xbf, 0x27, 0xe2, 0x77, 0xfc, 0x47, 0x6d, 0xe2, 0x72, 0x49, 0xdf, 0xef,
0x3e, 0xe2, 0x1c, 0x27, 0xb5, 0xb5, 0xe5, 0x42, 0x6d, 0x03, 0x93, 0x71, 0x9a, 0xd6, 0xe2, 0xd3,
0x70, 0x11, 0x1a, 0x36, 0x42, 0xcd, 0xcb, 0x78, 0x0e, 0x60, 0x16, 0xf1, 0x9c, 0x6f, 0x30, 0x47,
0x94, 0x60, 0xc7, 0x24, 0x51, 0xdc, 0x28, 0x51, 0x14, 0xc3, 0xc7, 0x5a, 0xeb, 0x47, 0xcd, 0x8a,
0x3f, 0x05, 0x2f, 0x43, 0xb9, 0xce, 0x1e, 0x00, 0x00,
};
const StaticFile favicon_ico PROGMEM = {(sizeof(favicon_ico_content)/sizeof(favicon_ico_content[0])), favicon_ico_content};
}

View File

@ -0,0 +1,22 @@
/**
* 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 md5_js;
extern const StaticFile style_css;
extern const StaticFile favicon_ico;
}

View File

@ -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;
}
};
}

View File

@ -0,0 +1,53 @@
#include "temphum.h"
#include "logging.h"
#include <Arduino.h>
namespace homekit::temphum {
static const int addr = 0x40;
void setup() {
pinMode(D2, OUTPUT);
pinMode(D3, OUTPUT);
Wire.begin(D2, D3);
Wire.beginTransmission(addr);
Wire.write(0xfe);
Wire.endTransmission();
delay(500);
}
struct data read() {
// Request temperature measurement from the Si7021 sensor
Wire.beginTransmission(addr);
Wire.write(0xF3); // command to measure temperature
Wire.endTransmission();
delay(500); // wait for the measurement to be ready
// Read the temperature measurement from the Si7021 sensor
Wire.requestFrom(addr, 2);
uint16_t temp_raw = Wire.read() << 8 | Wire.read();
double temperature = ((175.72 * temp_raw) / 65536.0) - 46.85;
// Request humidity measurement from the Si7021 sensor
Wire.beginTransmission(addr);
Wire.write(0xF5); // command to measure humidity
Wire.endTransmission();
delay(500); // wait for the measurement to be ready
// Read the humidity measurement from the Si7021 sensor
Wire.requestFrom(addr, 2);
uint16_t hum_raw = Wire.read() << 8 | Wire.read();
double humidity = ((125.0 * hum_raw) / 65536.0) - 6.0;
return {
.temp = temperature,
.rh = humidity
};
}
}

View File

@ -0,0 +1,15 @@
#pragma once
#include <Wire.h>
namespace homekit::temphum {
struct data {
double temp; // celsius
double rh; // relative humidity percentage
};
void setup();
struct data read();
}

View File

@ -0,0 +1,13 @@
#pragma once
namespace homekit {
inline size_t otaGetMaxUpdateSize() {
return (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
}
inline void restart() {
ESP.restart();
}
}

View File

@ -0,0 +1,48 @@
#include <pgmspace.h>
#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 AP_SSID[] = DEFAULT_WIFI_AP_SSID;
const char STA_SSID[] = DEFAULT_WIFI_STA_SSID;
const char STA_PSK[] = DEFAULT_WIFI_STA_PSK;
void getConfig(ConfigData &cfg, const char** ssid, const char** psk, const char** hostname) {
if (cfg.flags.wifi_configured) {
*ssid = cfg.wifi_ssid;
*psk = cfg.wifi_psk;
*hostname = cfg.node_id;
} else {
*ssid = STA_SSID;
*psk = STA_PSK;
*hostname = NODE_ID;
}
}
std::shared_ptr<std::list<ScanResult>> scan() {
if (WiFi.getMode() != WIFI_STA) {
PRINTLN("wifi::scan: switching mode to STA");
WiFi.mode(WIFI_STA);
}
std::shared_ptr<std::list<ScanResult>> results(new std::list<ScanResult>);
int count = WiFi.scanNetworks();
for (int i = 0; i < count; i++) {
results->push_back(ScanResult {
.rssi = WiFi.RSSI(i),
.ssid = WiFi.SSID(i)
});
}
WiFi.scanDelete();
return results;
}
}

View File

@ -0,0 +1,36 @@
#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, const char** ssid, const char** psk, const char** hostname);
std::shared_ptr<std::list<ScanResult>> scan();
inline uint32_t getIPAsInteger() {
if (!WiFi.isConnected())
return 0;
return WiFi.localIP().v4();
}
inline int8_t getRSSI() {
return WiFi.RSSI();
}
extern const char AP_SSID[];
extern const char STA_SSID[];
extern const char STA_PSK[];
extern const char NODE_ID[];
}

42
src/esp_mqtt_util.py Executable file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
from typing import Optional
from argparse import ArgumentParser
from enum import Enum
from home.config import config
from home.mqtt import MqttRelay
from home.mqtt.esp import MqttEspBase
from home.mqtt.temphum import MqttTempHum
from home.mqtt.esp import MqttEspDevice
mqtt_client: Optional[MqttEspBase] = None
class NodeType(Enum):
RELAY = 'relay'
TEMPHUM = 'temphum'
if __name__ == '__main__':
parser = ArgumentParser()
parser.add_argument('--device-id', type=str, required=True)
parser.add_argument('--type', type=str, required=True,
choices=[i.name.lower() for i in NodeType])
config.load('mqtt_util', parser=parser)
arg = parser.parse_args()
mqtt_node_type = NodeType(arg.type)
devices = MqttEspDevice(id=arg.device_id)
if mqtt_node_type == NodeType.RELAY:
mqtt_client = MqttRelay(devices=devices)
elif mqtt_node_type == NodeType.TEMPHUM:
mqtt_client = MqttTempHum(devices=devices)
mqtt_client.set_message_callback(lambda device_id, payload: print(payload))
mqtt_client.configure_tls()
try:
mqtt_client.connect_and_loop()
except KeyboardInterrupt:
mqtt_client.disconnect()

View File

@ -1,3 +1,4 @@
from .mqtt import MQTTBase
from .mqtt import MqttBase
from .util import poll_tick
from .relay import MQTTRelay, MQTTRelayState, MQTTRelayDevice
from .relay import MqttRelay, MqttRelayState
from .temphum import MqttTempHum

106
src/home/mqtt/esp.py Normal file
View File

@ -0,0 +1,106 @@
import re
import paho.mqtt.client as mqtt
from .mqtt import MqttBase
from typing import Optional, Union
from .payload.esp import (
OTAPayload,
OTAResultPayload,
DiagnosticsPayload,
InitialDiagnosticsPayload
)
class MqttEspDevice:
id: str
secret: Optional[str]
def __init__(self, id: str, secret: Optional[str] = None):
self.id = id
self.secret = secret
class MqttEspBase(MqttBase):
_devices: list[MqttEspDevice]
_message_callback: Optional[callable]
_ota_publish_callback: Optional[callable]
TOPIC_LEAF = 'esp'
def __init__(self,
devices: Union[MqttEspDevice, list[MqttEspDevice]],
subscribe_to_updates=True):
super().__init__(clean_session=True)
if not isinstance(devices, list):
devices = [devices]
self._devices = devices
self._message_callback = None
self._ota_publish_callback = None
self._subscribe_to_updates = subscribe_to_updates
self._ota_mid = None
def on_connect(self, client: mqtt.Client, userdata, flags, rc):
super().on_connect(client, userdata, flags, rc)
if self._subscribe_to_updates:
for device in self._devices:
topic = f'hk/{device.id}/{self.TOPIC_LEAF}/#'
self._logger.debug(f"subscribing to {topic}")
client.subscribe(topic, qos=1)
def on_publish(self, client: mqtt.Client, userdata, mid):
if self._ota_mid is not None and mid == self._ota_mid and self._ota_publish_callback:
self._ota_publish_callback()
def set_message_callback(self, callback: callable):
self._message_callback = callback
def on_message(self, client: mqtt.Client, userdata, msg):
try:
match = re.match(self.get_mqtt_topics(), msg.topic)
self._logger.debug(f'topic: {msg.topic}')
if not match:
return
device_id = match.group(1)
subtopic = match.group(2)
# try:
next(d for d in self._devices if d.id == device_id)
# except StopIteration:h
# return
message = None
if subtopic == 'stat':
message = DiagnosticsPayload.unpack(msg.payload)
elif subtopic == 'stat1':
message = InitialDiagnosticsPayload.unpack(msg.payload)
elif subtopic == 'otares':
message = OTAResultPayload.unpack(msg.payload)
if message and self._message_callback:
self._message_callback(device_id, message)
return True
except Exception as e:
self._logger.exception(str(e))
def push_ota(self,
device_id,
filename: str,
publish_callback: callable,
qos: int):
device = next(d for d in self._devices if d.id == device_id)
assert device.secret is not None, 'device secret not specified'
self._ota_publish_callback = publish_callback
payload = OTAPayload(secret=device.secret, filename=filename)
publish_result = self._client.publish(f'hk/{device.id}/{self.TOPIC_LEAF}/admin/ota',
payload=payload.pack(),
qos=qos)
self._ota_mid = publish_result.mid
self._client.loop_write()
@classmethod
def get_mqtt_topics(cls, additional_topics: Optional[list[str]] = None):
return rf'^hk/(.*?)/{cls.TOPIC_LEAF}/(stat|stat1|otares'+('|'+('|'.join(additional_topics)) if additional_topics else '')+')$'

View File

@ -13,7 +13,7 @@ def username_and_password() -> Tuple[str, str]:
return username, password
class MQTTBase:
class MqttBase:
def __init__(self, clean_session=True):
self._client = mqtt.Client(client_id=config['mqtt']['client_id'],
protocol=mqtt.MQTTv311,

View File

@ -1 +1 @@
from .base_payload import MQTTPayload
from .base_payload import MqttPayload

View File

@ -5,7 +5,21 @@ import re
from typing import Optional, Tuple
class MQTTPayload(abc.ABC):
def pldstr(self) -> str:
attrs = []
for field in self.__class__.__annotations__:
if hasattr(self, field):
attr = getattr(self, field)
attrs.append(f'{field}={attr}')
if attrs:
attrs_s = ' '
attrs_s += ', '.join(attrs)
else:
attrs_s = ''
return f'<%s{attrs_s}>' % (self.__class__.__name__,)
class MqttPayload(abc.ABC):
FORMAT = ''
PACKER = {}
UNPACKER = {}
@ -70,7 +84,7 @@ class MQTTPayload(abc.ABC):
bf_number = -1
i += 1
if issubclass(field_type, MQTTPayloadCustomField):
if issubclass(field_type, MqttPayloadCustomField):
kwargs[field] = field_type.unpack(data[i])
else:
kwargs[field] = cls._unpack_field(field, data[i])
@ -87,15 +101,18 @@ class MQTTPayload(abc.ABC):
@classmethod
def _unpack_field(cls, name, val):
if isinstance(val, MQTTPayloadCustomField):
if isinstance(val, MqttPayloadCustomField):
return
if cls.UNPACKER and name in cls.UNPACKER:
return cls.UNPACKER[name](val)
else:
return val
def __str__(self):
return pldstr(self)
class MQTTPayloadCustomField(abc.ABC):
class MqttPayloadCustomField(abc.ABC):
def __init__(self, **kwargs):
for field in self.__class__.__annotations__:
setattr(self, field, kwargs[field])
@ -109,6 +126,9 @@ class MQTTPayloadCustomField(abc.ABC):
def unpack(cls, *args, **kwargs):
pass
def __str__(self):
return pldstr(self)
def bit_field(seq_no: int, total_bits: int, bits: int):
return type(f'MQTTPayloadBitField_{seq_no}_{total_bits}_{bits}', (object,), {

View File

@ -0,0 +1,78 @@
import hashlib
from .base_payload import MqttPayload, MqttPayloadCustomField
class OTAResultPayload(MqttPayload):
FORMAT = '=BB'
result: int
error_code: int
class OTAPayload(MqttPayload):
secret: str
filename: str
# structure of returned data:
#
# uint8_t[len(secret)] secret;
# uint8_t[16] md5;
# *uint8_t data
def pack(self):
buf = bytearray(self.secret.encode())
m = hashlib.md5()
with open(self.filename, 'rb') as fd:
content = fd.read()
m.update(content)
buf.extend(m.digest())
buf.extend(content)
return buf
def unpack(cls, buf: bytes):
raise RuntimeError(f'{cls.__class__.__name__}.unpack: not implemented')
# secret = buf[:12].decode()
# filename = buf[12:].decode()
# return OTAPayload(secret=secret, filename=filename)
class DiagnosticsFlags(MqttPayloadCustomField):
state: bool
config_changed_value_present: bool
config_changed: bool
@staticmethod
def unpack(flags: int):
# _logger.debug(f'StatFlags.unpack: flags={flags}')
state = flags & 0x1
ccvp = (flags >> 1) & 0x1
cc = (flags >> 2) & 0x1
# _logger.debug(f'StatFlags.unpack: state={state}')
return DiagnosticsFlags(state=(state == 1),
config_changed_value_present=(ccvp == 1),
config_changed=(cc == 1))
def __index__(self):
bits = 0
bits |= (int(self.state) & 0x1)
bits |= (int(self.config_changed_value_present) & 0x1) << 1
bits |= (int(self.config_changed) & 0x1) << 2
return bits
class InitialDiagnosticsPayload(MqttPayload):
FORMAT = '=IBbIB'
ip: int
fw_version: int
rssi: int
free_heap: int
flags: DiagnosticsFlags
class DiagnosticsPayload(MqttPayload):
FORMAT = '=bIB'
rssi: int
free_heap: int
flags: DiagnosticsFlags

View File

@ -1,13 +1,13 @@
import struct
from .base_payload import MQTTPayload, bit_field
from .base_payload import MqttPayload, bit_field
from typing import Tuple
_mult_10 = lambda n: int(n*10)
_div_10 = lambda n: n/10
class Status(MQTTPayload):
class Status(MqttPayload):
# 46 bytes
FORMAT = 'IHHHHHHBHHHHHBHHHHHHHH'
@ -65,7 +65,7 @@ class Status(MQTTPayload):
load_connected: bit_field(0, 16, 1)
class Generation(MQTTPayload):
class Generation(MqttPayload):
# 8 bytes
FORMAT = 'II'

View File

@ -1,53 +1,13 @@
import hashlib
from .base_payload import MQTTPayload, MQTTPayloadCustomField
from .base_payload import MqttPayload
from .esp import (
OTAResultPayload,
OTAPayload,
InitialDiagnosticsPayload,
DiagnosticsPayload
)
# _logger = logging.getLogger(__name__)
class StatFlags(MQTTPayloadCustomField):
state: bool
config_changed_value_present: bool
config_changed: bool
@staticmethod
def unpack(flags: int):
# _logger.debug(f'StatFlags.unpack: flags={flags}')
state = flags & 0x1
ccvp = (flags >> 1) & 0x1
cc = (flags >> 2) & 0x1
# _logger.debug(f'StatFlags.unpack: state={state}')
return StatFlags(state=(state == 1),
config_changed_value_present=(ccvp == 1),
config_changed=(cc == 1))
def __index__(self):
bits = 0
bits |= (int(self.state) & 0x1)
bits |= (int(self.config_changed_value_present) & 0x1) << 1
bits |= (int(self.config_changed) & 0x1) << 2
return bits
class InitialStatPayload(MQTTPayload):
FORMAT = '=IBbIB'
ip: int
fw_version: int
rssi: int
free_heap: int
flags: StatFlags
class StatPayload(MQTTPayload):
FORMAT = '=bIB'
rssi: int
free_heap: int
flags: StatFlags
class PowerPayload(MQTTPayload):
class PowerPayload(MqttPayload):
FORMAT = '=12sB'
PACKER = {
'state': lambda n: int(n),
@ -60,37 +20,3 @@ class PowerPayload(MQTTPayload):
secret: str
state: bool
class OTAResultPayload(MQTTPayload):
FORMAT = '=BB'
result: int
error_code: int
class OTAPayload(MQTTPayload):
secret: str
filename: str
# structure of returned data:
#
# uint8_t[len(secret)] secret;
# uint8_t[16] md5;
# *uint8_t data
def pack(self):
buf = bytearray(self.secret.encode())
m = hashlib.md5()
with open(self.filename, 'rb') as fd:
content = fd.read()
m.update(content)
buf.extend(m.digest())
buf.extend(content)
return buf
def unpack(cls, buf: bytes):
raise RuntimeError(f'{cls.__class__.__name__}.unpack: not implemented')
# secret = buf[:12].decode()
# filename = buf[12:].decode()
# return OTAPayload(secret=secret, filename=filename)

View File

@ -1,10 +1,10 @@
from .base_payload import MQTTPayload
from .base_payload import MqttPayload
_mult_100 = lambda n: int(n*100)
_div_100 = lambda n: n/100
class Temperature(MQTTPayload):
class Temperature(MqttPayload):
FORMAT = 'IhH'
PACKER = {
'temp': _mult_100,

View File

@ -0,0 +1,14 @@
from .base_payload import MqttPayload
two_digits_precision = lambda x: round(x, 2)
class TempHumDataPayload(MqttPayload):
FORMAT = '=dd'
UNPACKER = {
'temp': two_digits_precision,
'rh': two_digits_precision
}
temp: float
rh: float

View File

@ -2,89 +2,14 @@ import paho.mqtt.client as mqtt
import re
import datetime
from .mqtt import MQTTBase
from typing import Optional, Union
from .payload.relay import (
InitialStatPayload,
StatPayload,
PowerPayload,
OTAPayload,
OTAResultPayload
)
from .esp import MqttEspBase
class MQTTRelayDevice:
id: str
secret: Optional[str]
def __init__(self, id: str, secret: Optional[str] = None):
self.id = id
self.secret = secret
class MQTTRelay(MQTTBase):
_devices: list[MQTTRelayDevice]
_message_callback: Optional[callable]
_ota_publish_callback: Optional[callable]
def __init__(self,
devices: Union[MQTTRelayDevice, list[MQTTRelayDevice]],
subscribe_to_updates=True):
super().__init__(clean_session=True)
if not isinstance(devices, list):
devices = [devices]
self._devices = devices
self._message_callback = None
self._ota_publish_callback = None
self._subscribe_to_updates = subscribe_to_updates
self._ota_mid = None
def on_connect(self, client: mqtt.Client, userdata, flags, rc):
super().on_connect(client, userdata, flags, rc)
if self._subscribe_to_updates:
for device in self._devices:
topic = f'hk/{device.id}/relay/#'
self._logger.debug(f"subscribing to {topic}")
client.subscribe(topic, qos=1)
def on_publish(self, client: mqtt.Client, userdata, mid):
if self._ota_mid is not None and mid == self._ota_mid and self._ota_publish_callback:
self._ota_publish_callback()
def set_message_callback(self, callback: callable):
self._message_callback = callback
def on_message(self, client: mqtt.Client, userdata, msg):
try:
match = re.match(r'^hk/(.*?)/relay/(stat|stat1|power|otares)$', msg.topic)
self._logger.debug(f'topic: {msg.topic}')
if not match:
return
device_id = match.group(1)
subtopic = match.group(2)
try:
next(d for d in self._devices if d.id == device_id)
except StopIteration:
return
message = None
if subtopic == 'stat':
message = StatPayload.unpack(msg.payload)
elif subtopic == 'stat1':
message = InitialStatPayload.unpack(msg.payload)
elif subtopic == 'power':
message = PowerPayload.unpack(msg.payload)
elif subtopic == 'otares':
message = OTAResultPayload.unpack(msg.payload)
if message and self._message_callback:
self._message_callback(device_id, message)
except Exception as e:
self._logger.exception(str(e))
class MqttRelay(MqttEspBase):
TOPIC_LEAF = 'relay'
def set_power(self, device_id, enable: bool, secret=None):
device = next(d for d in self._devices if d.id == device_id)
@ -94,29 +19,35 @@ class MQTTRelay(MQTTBase):
payload = PowerPayload(secret=secret,
state=enable)
self._client.publish(f'hk/{device.id}/relay/power',
self._client.publish(f'hk/{device.id}/{self.TOPIC_LEAF}/power',
payload=payload.pack(),
qos=1)
self._client.loop_write()
def push_ota(self,
device_id,
filename: str,
publish_callback: callable,
qos: int):
device = next(d for d in self._devices if d.id == device_id)
assert device.secret is not None, 'device secret not specified'
def on_message(self, client: mqtt.Client, userdata, msg):
if super().on_message(client, userdata, msg):
return
self._ota_publish_callback = publish_callback
payload = OTAPayload(secret=device.secret, filename=filename)
publish_result = self._client.publish(f'hk/{device.id}/relay/admin/ota',
payload=payload.pack(),
qos=qos)
self._ota_mid = publish_result.mid
self._client.loop_write()
try:
match = re.match(self.get_mqtt_topics(['power']), msg.topic)
if not match:
return
device_id = match.group(1)
subtopic = match.group(2)
message = None
if subtopic == 'power':
message = PowerPayload.unpack(msg.payload)
if message and self._message_callback:
self._message_callback(device_id, message)
except Exception as e:
self._logger.exception(str(e))
class MQTTRelayState:
class MqttRelayState:
enabled: bool
update_time: datetime.datetime
rssi: int

33
src/home/mqtt/temphum.py Normal file
View File

@ -0,0 +1,33 @@
import paho.mqtt.client as mqtt
import re
from .payload.temphum import (
TempHumDataPayload
)
from .esp import MqttEspBase
class MqttTempHum(MqttEspBase):
TOPIC_LEAF = 'temphum'
def on_message(self, client: mqtt.Client, userdata, msg):
if super().on_message(client, userdata, msg):
return
try:
match = re.match(self.get_mqtt_topics(['data']), msg.topic)
if not match:
return
device_id = match.group(1)
subtopic = match.group(2)
message = None
if subtopic == 'data':
message = TempHumDataPayload.unpack(msg.payload)
if message and self._message_callback:
self._message_callback(device_id, message)
except Exception as e:
self._logger.exception(str(e))

View File

@ -1,15 +1,14 @@
#!/usr/bin/env python3
import paho.mqtt.client as mqtt
import re
import logging
from home.mqtt import MQTTBase
from home.mqtt import MqttBase
from home.mqtt.payload.inverter import Status, Generation
from home.database import InverterDatabase
from home.config import config
class MQTTReceiver(MQTTBase):
class MqttReceiver(MqttBase):
def __init__(self):
super().__init__(clean_session=False)
self.database = InverterDatabase()
@ -70,6 +69,6 @@ class MQTTReceiver(MQTTBase):
if __name__ == '__main__':
config.load('inverter_mqtt_receiver')
server = MQTTReceiver()
server = MqttReceiver()
server.connect_and_loop()

View File

@ -5,11 +5,11 @@ import json
import inverterd
from home.config import config
from home.mqtt import MQTTBase, poll_tick
from home.mqtt import MqttBase, poll_tick
from home.mqtt.payload.inverter import Status, Generation
class MQTTClient(MQTTBase):
class MqttClient(MqttBase):
def __init__(self):
super().__init__()
@ -66,7 +66,7 @@ class MQTTClient(MQTTBase):
if __name__ == '__main__':
config.load('inverter_mqtt_sender')
client = MQTTClient()
client = MqttClient()
client.configure_tls()
client.connect_and_loop(loop_forever=False)
client.poll_inverter()

View File

@ -10,7 +10,7 @@ import paho.mqtt.client as mqtt
from home.telegram import bot
from home.api.types import BotType
from home.mqtt import MQTTBase
from home.mqtt import MqttBase
from home.config import config
from home.util import chunks
from syncleo import (
@ -204,7 +204,7 @@ class KettleInfo:
class KettleController(threading.Thread,
MQTTBase,
MqttBase,
DeviceListener,
IncomingMessageListener,
KettleInfoListener,
@ -224,7 +224,7 @@ class KettleController(threading.Thread,
def __init__(self):
# basic setup
MQTTBase.__init__(self, clean_session=False)
MqttBase.__init__(self, clean_session=False)
threading.Thread.__init__(self)
self._logger = logging.getLogger(self.__class__.__name__)

View File

@ -8,7 +8,7 @@ import paho.mqtt.client as mqtt
from typing import Optional
from argparse import ArgumentParser
from queue import SimpleQueue
from home.mqtt import MQTTBase
from home.mqtt import MqttBase
from home.config import config
from syncleo import (
Kettle,
@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
control_tasks = SimpleQueue()
class MQTTServer(MQTTBase):
class MqttServer(MqttBase):
def __init__(self):
super().__init__(clean_session=False)
@ -78,7 +78,7 @@ def main():
arg = config.load('polaris_kettle_util', use_cli=True, parser=parser)
if arg.mode == 'mqtt':
server = MQTTServer()
server = MqttServer()
try:
server.connect_and_loop(loop_forever=True)
except KeyboardInterrupt:

View File

@ -8,10 +8,10 @@ from telegram import ReplyKeyboardMarkup, User
from home.config import config
from home.telegram import bot
from home.telegram._botutil import user_any_name
from home.api.types import BotType
from home.mqtt import MQTTRelay, MQTTRelayState, MQTTRelayDevice
from home.mqtt.payload import MQTTPayload
from home.mqtt.payload.relay import InitialStatPayload, StatPayload
from home.mqtt.esp import MqttEspDevice
from home.mqtt import MqttRelay, MqttRelayState
from home.mqtt.payload import MqttPayload
from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload
config.load('pump_mqtt_bot')
@ -70,8 +70,8 @@ bot.lang.en(
)
mqtt_relay: Optional[MQTTRelay] = None
relay_state = MQTTRelayState()
mqtt_relay: Optional[MqttRelay] = None
relay_state = MqttRelayState()
class UserAction(Enum):
@ -79,10 +79,10 @@ class UserAction(Enum):
OFF = 'off'
def on_mqtt_message(home_id, message: MQTTPayload):
if isinstance(message, InitialStatPayload) or isinstance(message, StatPayload):
def on_mqtt_message(home_id, message: MqttPayload):
if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload):
kwargs = dict(rssi=message.rssi, enabled=message.flags.state)
if isinstance(message, InitialStatPayload):
if isinstance(message, InitialDiagnosticsPayload):
kwargs['fw_version'] = message.fw_version
relay_state.update(**kwargs)
@ -157,8 +157,8 @@ def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
if __name__ == '__main__':
mqtt_relay = MQTTRelay(devices=MQTTRelayDevice(id=config['mqtt']['home_id'],
secret=config['mqtt']['home_secret']))
mqtt_relay = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'],
secret=config['mqtt']['home_secret']))
mqtt_relay.set_message_callback(on_mqtt_message)
mqtt_relay.configure_tls()
mqtt_relay.connect_and_loop(loop_forever=False)

View File

@ -6,10 +6,10 @@ from functools import partial
from home.config import config
from home.telegram import bot
from home.api.types import BotType
from home.mqtt import MQTTRelay, MQTTRelayState, MQTTRelayDevice
from home.mqtt.payload import MQTTPayload
from home.mqtt.payload.relay import InitialStatPayload, StatPayload
from home.mqtt import MqttRelay, MqttRelayState
from home.mqtt.esp import MqttEspDevice
from home.mqtt.payload import MqttPayload
from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload
config.load('relay_mqtt_bot')
@ -34,8 +34,8 @@ status_emoji = {
'on': '',
'off': ''
}
mqtt_relay: Optional[MQTTRelay] = None
relay_states: dict[str, MQTTRelayState] = {}
mqtt_relay: Optional[MqttRelay] = None
relay_states: dict[str, MqttRelayState] = {}
class UserAction(Enum):
@ -43,13 +43,13 @@ class UserAction(Enum):
OFF = 'off'
def on_mqtt_message(home_id, message: MQTTPayload):
if isinstance(message, InitialStatPayload) or isinstance(message, StatPayload):
def on_mqtt_message(home_id, message: MqttPayload):
if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload):
kwargs = dict(rssi=message.rssi, enabled=message.flags.state)
if isinstance(message, InitialStatPayload):
if isinstance(message, InitialDiagnosticsPayload):
kwargs['fw_version'] = message.fw_version
if home_id not in relay_states:
relay_states[home_id] = MQTTRelayState()
relay_states[home_id] = MqttRelayState()
relay_states[home_id].update(**kwargs)
@ -87,8 +87,8 @@ def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
if __name__ == '__main__':
devices = []
for device_id, data in config['relays'].items():
devices.append(MQTTRelayDevice(id=device_id,
secret=data['secret']))
devices.append(MqttEspDevice(id=device_id,
secret=data['secret']))
labels = data['labels']
bot.lang.ru(**{device_id: labels['ru']})
bot.lang.en(**{device_id: labels['en']})
@ -101,7 +101,7 @@ if __name__ == '__main__':
messages.append(f'{type_emoji}{status_emoji[action.value]} {labels[_lang]}')
bot.handler(texts=messages)(partial(enable_handler if action == UserAction.ON else disable_handler, device_id))
mqtt_relay = MQTTRelay(devices=devices)
mqtt_relay = MqttRelay(devices=devices)
mqtt_relay.set_message_callback(on_mqtt_message)
mqtt_relay.configure_tls()
mqtt_relay.connect_and_loop(loop_forever=False)

View File

@ -1,20 +1,21 @@
#!/usr/bin/env python3
from home import http
from home.config import config
from home.mqtt import MQTTRelay, MQTTRelayDevice, MQTTRelayState
from home.mqtt.payload import MQTTPayload
from home.mqtt.payload.relay import InitialStatPayload, StatPayload
from home.mqtt import MqttRelay, MqttRelayState
from home.mqtt.esp import MqttEspDevice
from home.mqtt.payload import MqttPayload
from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload
from typing import Optional
mqtt_relay: Optional[MQTTRelay] = None
relay_states: dict[str, MQTTRelayState] = {}
mqtt_relay: Optional[MqttRelay] = None
relay_states: dict[str, MqttRelayState] = {}
def on_mqtt_message(device_id, message: MQTTPayload):
if isinstance(message, InitialStatPayload) or isinstance(message, StatPayload):
def on_mqtt_message(device_id, message: MqttPayload):
if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload):
kwargs = dict(rssi=message.rssi, enabled=message.flags.state)
if device_id not in relay_states:
relay_states[device_id] = MQTTRelayState()
relay_states[device_id] = MqttRelayState()
relay_states[device_id].update(**kwargs)
@ -54,7 +55,7 @@ class RelayMqttHttpProxy(http.HTTPServer):
if __name__ == '__main__':
config.load('relay_mqtt_http_proxy')
mqtt_relay = MQTTRelay(devices=[MQTTRelayDevice(id=device_id) for device_id in config.get('relay.devices')])
mqtt_relay = MqttRelay(devices=[MqttEspDevice(id=device_id) for device_id in config.get('relay.devices')])
mqtt_relay.configure_tls()
mqtt_relay.set_message_callback(on_mqtt_message)
mqtt_relay.connect_and_loop(loop_forever=False)

View File

@ -1,45 +0,0 @@
#!/usr/bin/env python3
from typing import Optional
from argparse import ArgumentParser
from home.config import config
from home.mqtt import MQTTRelay, MQTTRelayDevice
from home.mqtt.payload import MQTTPayload
from home.mqtt.payload.relay import (
InitialStatPayload, StatPayload, OTAResultPayload
)
mqtt_relay: Optional[MQTTRelay] = None
def on_mqtt_message(device_id, p: MQTTPayload):
message = None
if isinstance(p, InitialStatPayload) or isinstance(p, StatPayload):
message = f'[stat] state={"on" if p.flags.state else "off"}'
message += f' rssi={p.rssi}'
message += f' free_heap={p.free_heap}'
if isinstance(p, InitialStatPayload):
message += f' fw={p.fw_version}'
elif isinstance(p, OTAResultPayload):
message = f'[otares] result={p.result} error_code={p.error_code}'
if message:
print(message)
if __name__ == '__main__':
parser = ArgumentParser()
parser.add_argument('--device-id', type=str, required=True)
config.load('relay_mqtt_util', parser=parser)
arg = parser.parse_args()
mqtt_relay = MQTTRelay(devices=MQTTRelayDevice(id=arg.device_id))
mqtt_relay.set_message_callback(on_mqtt_message)
mqtt_relay.configure_tls()
try:
mqtt_relay.connect_and_loop()
except KeyboardInterrupt:
mqtt_relay.disconnect()

View File

@ -2,7 +2,7 @@
import paho.mqtt.client as mqtt
import re
from home.mqtt import MQTTBase
from home.mqtt import MqttBase
from home.config import config
from home.mqtt.payload.sensors import Temperature
from home.api.types import TemperatureSensorLocation
@ -16,7 +16,7 @@ def get_sensor_type(sensor: str) -> TemperatureSensorLocation:
raise ValueError(f'unexpected sensor value: {sensor}')
class MQTTServer(MQTTBase):
class MqttServer(MqttBase):
def __init__(self):
super().__init__(clean_session=False)
self.database = SensorsDatabase()
@ -49,5 +49,5 @@ class MQTTServer(MQTTBase):
if __name__ == '__main__':
config.load('sensors_mqtt_receiver')
server = MQTTServer()
server = MqttServer()
server.connect_and_loop()

View File

@ -3,12 +3,12 @@ import time
import json
from home.util import parse_addr, MySimpleSocketClient
from home.mqtt import MQTTBase, poll_tick
from home.mqtt import MqttBase, poll_tick
from home.mqtt.payload.sensors import Temperature
from home.config import config
class MQTTClient(MQTTBase):
class MqttClient(MqttBase):
def __init__(self):
super().__init__(self)
self._home_id = config['mqtt']['home_id']
@ -52,7 +52,7 @@ class MQTTClient(MQTTBase):
if __name__ == '__main__':
config.load('sensors_mqtt_sender')
client = MQTTClient()
client = MqttClient()
client.configure_tls()
client.connect_and_loop(loop_forever=False)
client.poll()

1
src/temphum.py Normal file → Executable file
View File

@ -1,3 +1,4 @@
#!/usr/bin/env python3
from argparse import ArgumentParser
from home.temphum import SensorType, create_sensor

View File

@ -10,7 +10,7 @@ sys.path.extend([
from time import sleep
from argparse import ArgumentParser
from src.home.config import config
from src.home.mqtt import MQTTRelay, MQTTRelayDevice
from src.home.mqtt import MqttRelay, MQTTESPDevice
def guess_filename(product: str, build_target: str):
@ -34,7 +34,7 @@ def relayctl_publish_ota(filename: str,
global stop
stop = True
mqtt_relay = MQTTRelay(devices=MQTTRelayDevice(id=device_id, secret=home_secret))
mqtt_relay = MqttRelay(devices=MQTTESPDevice(id=device_id, secret=home_secret))
mqtt_relay.configure_tls()
mqtt_relay.connect_and_loop(loop_forever=False)
mqtt_relay.push_ota(device_id, filename, published, qos)