diff --git a/.gitignore b/.gitignore index d5ca835..5f24f66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .pio .vscode -build/* \ No newline at end of file +build/* +secrets.ini \ No newline at end of file diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..31b4f99 --- /dev/null +++ b/data/index.html @@ -0,0 +1,230 @@ + + + + + + OpenTherm Gateway + + + + + +
+ +
+ +
+
+
+
+

Network

+

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+

System

+

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+

States and sensors

+

More information and settings can be found in your home assistant after setting up network and MQTT

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + \ No newline at end of file diff --git a/data/network.html b/data/network.html new file mode 100644 index 0000000..36e3828 --- /dev/null +++ b/data/network.html @@ -0,0 +1,166 @@ + + + + + + + Network settings - OpenTherm Gateway + + + + + +
+ +
+ +
+
+
+
+

Network settings

+

+
+ +
+ +
+
+ +
+
+
+

Available networks

+

+
+ +
+
+ + + + + + + + + +
#SSIDSignal
+
+ +
+ +
+
+
+

WiFi settings

+

+
+
+ +
+
+
+ +
+
+
+

AP settings

+

+
+
+ +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/data/settings.html b/data/settings.html new file mode 100644 index 0000000..00f62df --- /dev/null +++ b/data/settings.html @@ -0,0 +1,275 @@ + + + + + + + Settings - OpenTherm Gateway + + + + + +
+ +
+ +
+
+
+
+

Portal settings

+

+
+ +
+ +
+
+ +
+
+
+

OpenTherm settings

+

+
+ +
+ +
+
+ +
+
+
+

MQTT settings

+

+
+ +
+ +
+
+ +
+
+
+

Sensor settings

+

+
+ +
+ +
+
+ +
+
+
+

External pump settings

+

+
+ +
+ +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/data/static/app.css.gz b/data/static/app.css.gz new file mode 100644 index 0000000..b84d3d0 Binary files /dev/null and b/data/static/app.css.gz differ diff --git a/data/static/app.js.gz b/data/static/app.js.gz new file mode 100644 index 0000000..870a8c3 Binary files /dev/null and b/data/static/app.js.gz differ diff --git a/data/static/favicon.ico.gz b/data/static/favicon.ico.gz new file mode 100644 index 0000000..9a48af9 Binary files /dev/null and b/data/static/favicon.ico.gz differ diff --git a/data/static/pico.min.css.gz b/data/static/pico.min.css.gz new file mode 100644 index 0000000..6bbb5ed Binary files /dev/null and b/data/static/pico.min.css.gz differ diff --git a/data/upgrade.html b/data/upgrade.html new file mode 100644 index 0000000..fe97463 --- /dev/null +++ b/data/upgrade.html @@ -0,0 +1,108 @@ + + + + + + + Upgrade - OpenTherm Gateway + + + + + +
+ +
+ +
+
+
+
+

Backup & restore

+

+ In this section you can save and restore a backup of ALL settings. +

+
+ +
+ + +
+ + +
+
+
+
+ +
+
+
+

Upgrade

+

+ In this section you can upgrade the firmware and filesystem of your device.
+ Latest releases can be downloaded from the Releases page of the project repository. +

+
+ +
+
+ + + +
+ +
    +
  • After a successful upgrade the filesystem, ALL settings will be reset to default values! Save them before upgrading.
  • +
  • After a successful upgrade, the device will automatically reboot after 10 seconds.
  • +
+ + +
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/lib/BufferedWebServer/BufferedWebServer.h b/lib/BufferedWebServer/BufferedWebServer.h new file mode 100644 index 0000000..0960052 --- /dev/null +++ b/lib/BufferedWebServer/BufferedWebServer.h @@ -0,0 +1,82 @@ +class BufferedWebServer { +public: + BufferedWebServer(WebServer* webServer, size_t bufferSize = 64) { + this->webServer = webServer; + this->bufferSize = bufferSize; + this->buffer = (uint8_t*)malloc(bufferSize * sizeof(*this->buffer)); + } + + ~BufferedWebServer() { + free(this->buffer); + } + + void send(int code, const char* contentType, JsonDocument& content) { + #ifdef ARDUINO_ARCH_ESP8266 + if (!this->webServer->chunkedResponseModeStart(code, contentType)) { + this->webServer->send(505, F("text/html"), F("HTTP1.1 required")); + return; + } + #else + this->webServer->send(code, contentType, ""); + #endif + + this->webServer->setContentLength(measureJson(content)); + serializeJson(content, *this); + this->flush(); + + #ifdef ARDUINO_ARCH_ESP8266 + this->webServer->chunkedResponseFinalize(); + #else + this->webServer->sendContent(""); + #endif + } + + size_t write(uint8_t c) { + this->buffer[this->bufferPos++] = c; + + if (this->bufferPos >= this->bufferSize) { + this->flush(); + } + + return 1; + } + + size_t write(const uint8_t* buffer, size_t length) { + size_t written = 0; + while (written < length) { + size_t copySize = this->bufferSize - this->bufferPos; + if (written + copySize > length) { + copySize = length - written; + } + + memcpy(this->buffer + this->bufferPos, buffer + written, copySize); + this->bufferPos += copySize; + + if (this->bufferPos >= this->bufferSize) { + this->flush(); + } + + written += copySize; + } + + return written; + } + + void flush() { + if (this->bufferPos == 0) { + return; + } + + this->webServer->sendContent((const char*)this->buffer, this->bufferPos); + this->bufferPos = 0; + #ifdef ARDUINO_ARCH_ESP8266 + ::yield(); + #endif + } + +protected: + WebServer* webServer = nullptr; + uint8_t* buffer; + size_t bufferSize = 64; + size_t bufferPos = 0; +}; \ No newline at end of file diff --git a/lib/Connection/Connection.cpp b/lib/Connection/Connection.cpp new file mode 100644 index 0000000..fc5387a --- /dev/null +++ b/lib/Connection/Connection.cpp @@ -0,0 +1,129 @@ +#include "Connection.h" + +void Connection::setup(bool useDhcp) { + setUseDhcp(useDhcp); + + #if defined(ARDUINO_ARCH_ESP8266) + wifi_set_event_handler_cb(Connection::onEvent); + #elif defined(ARDUINO_ARCH_ESP32) + WiFi.onEvent(Connection::onEvent); + #endif +} + +void Connection::setUseDhcp(bool value) { + useDhcp = value; +} + +Connection::Status Connection::getStatus() { + return status; +} + +Connection::DisconnectReason Connection::getDisconnectReason() { + return disconnectReason; +} + +#if defined(ARDUINO_ARCH_ESP8266) +void Connection::onEvent(System_Event_t *evt) { + switch (evt->event) { + case EVENT_STAMODE_CONNECTED: + status = useDhcp ? Status::CONNECTING : Status::CONNECTED; + disconnectReason = DisconnectReason::NONE; + + break; + + case EVENT_STAMODE_GOT_IP: + status = Status::CONNECTED; + disconnectReason = DisconnectReason::NONE; + break; + + case EVENT_STAMODE_DHCP_TIMEOUT: + status = Status::DISCONNECTED; + disconnectReason = DisconnectReason::DHCP_TIMEOUT; + break; + + case EVENT_STAMODE_DISCONNECTED: + status = Status::DISCONNECTED; + disconnectReason = convertDisconnectReason(evt->event_info.disconnected.reason); + + break; + + default: + break; + } +} +#elif defined(ARDUINO_ARCH_ESP32) +void Connection::onEvent(WiFiEvent_t event, WiFiEventInfo_t info) { + switch (event) { + case ARDUINO_EVENT_WIFI_STA_CONNECTED: + status = useDhcp ? Status::CONNECTING : Status::CONNECTED; + disconnectReason = DisconnectReason::NONE; + + break; + + case ARDUINO_EVENT_WIFI_STA_GOT_IP: + case ARDUINO_EVENT_WIFI_STA_GOT_IP6: + status = Status::CONNECTED; + disconnectReason = DisconnectReason::NONE; + break; + + case ARDUINO_EVENT_WIFI_STA_LOST_IP: + status = Status::DISCONNECTED; + disconnectReason = DisconnectReason::DHCP_TIMEOUT; + break; + + case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: + status = Status::DISCONNECTED; + disconnectReason = convertDisconnectReason(info.wifi_sta_disconnected.reason); + + break; + + default: + break; + } + + //Serial.printf("SYS EVENT: %d, reason: %d\n\r", evt->event, disconnectReason); +} +#endif + +Connection::DisconnectReason Connection::convertDisconnectReason(uint8_t reason) { + switch (reason) { + #if defined(ARDUINO_ARCH_ESP8266) + case REASON_BEACON_TIMEOUT: + return DisconnectReason::BEACON_TIMEOUT; + + case REASON_NO_AP_FOUND: + return DisconnectReason::NO_AP_FOUND; + + case REASON_AUTH_FAIL: + return DisconnectReason::AUTH_FAIL; + + case REASON_ASSOC_FAIL: + return DisconnectReason::ASSOC_FAIL; + + case REASON_HANDSHAKE_TIMEOUT: + return DisconnectReason::HANDSHAKE_TIMEOUT; + #elif defined(ARDUINO_ARCH_ESP32) + case WIFI_REASON_BEACON_TIMEOUT: + return DisconnectReason::BEACON_TIMEOUT; + + case WIFI_REASON_NO_AP_FOUND: + return DisconnectReason::NO_AP_FOUND; + + case WIFI_REASON_AUTH_FAIL: + return DisconnectReason::AUTH_FAIL; + + case WIFI_REASON_ASSOC_FAIL: + return DisconnectReason::ASSOC_FAIL; + + case WIFI_REASON_HANDSHAKE_TIMEOUT: + return DisconnectReason::HANDSHAKE_TIMEOUT; + #endif + + default: + return DisconnectReason::OTHER; + } +} + +bool Connection::useDhcp = false; +Connection::Status Connection::status = Status::DISCONNECTED; +Connection::DisconnectReason Connection::disconnectReason = DisconnectReason::NONE; \ No newline at end of file diff --git a/lib/Connection/Connection.h b/lib/Connection/Connection.h new file mode 100644 index 0000000..eb8965a --- /dev/null +++ b/lib/Connection/Connection.h @@ -0,0 +1,43 @@ +#if defined(ARDUINO_ARCH_ESP8266) + #include + #include "lwip/etharp.h" +#elif defined(ARDUINO_ARCH_ESP32) + #include +#endif + +struct Connection { + enum class Status { + CONNECTED, + CONNECTING, + GOT_IP, + DISCONNECTED + }; + + enum class DisconnectReason { + BEACON_TIMEOUT, + NO_AP_FOUND, + AUTH_FAIL, + ASSOC_FAIL, + HANDSHAKE_TIMEOUT, + DHCP_TIMEOUT, + OTHER, + NONE + }; + + static Status status; + static DisconnectReason disconnectReason; + + static void setup(bool useDhcp); + static void setUseDhcp(bool value); + static Status getStatus(); + static DisconnectReason getDisconnectReason(); + #if defined(ARDUINO_ARCH_ESP8266) + static void onEvent(System_Event_t *evt); + #elif defined(ARDUINO_ARCH_ESP32) + static void onEvent(WiFiEvent_t event, WiFiEventInfo_t info); + #endif + +protected: + static DisconnectReason convertDisconnectReason(uint8_t reason); + static bool useDhcp; +}; \ No newline at end of file diff --git a/lib/HomeAssistantHelper/strings.h b/lib/HomeAssistantHelper/strings.h index aecf265..7811a19 100644 --- a/lib/HomeAssistantHelper/strings.h +++ b/lib/HomeAssistantHelper/strings.h @@ -3,6 +3,15 @@ #define PROGMEM #endif +const char HA_ENTITY_BINARY_SENSOR[] PROGMEM = "binary_sensor"; +const char HA_ENTITY_BUTTON[] PROGMEM = "button"; +const char HA_ENTITY_FAN[] PROGMEM = "fan"; +const char HA_ENTITY_CLIMATE[] PROGMEM = "climate"; +const char HA_ENTITY_NUMBER[] PROGMEM = "number"; +const char HA_ENTITY_SELECT[] PROGMEM = "select"; +const char HA_ENTITY_SENSOR[] PROGMEM = "sensor"; +const char HA_ENTITY_SWITCH[] PROGMEM = "switch"; + const char HA_DEVICE[] PROGMEM = "device"; const char HA_IDENTIFIERS[] PROGMEM = "identifiers"; const char HA_SW_VERSION[] PROGMEM = "sw_version"; diff --git a/lib/MqttWriter/MqttWriter.h b/lib/MqttWriter/MqttWriter.h index da2362f..f247ae9 100644 --- a/lib/MqttWriter/MqttWriter.h +++ b/lib/MqttWriter/MqttWriter.h @@ -1,6 +1,6 @@ #pragma once #include -#include +#include #ifdef ARDUINO_ARCH_ESP32 #include #endif @@ -8,7 +8,7 @@ class MqttWriter { public: - MqttWriter(PubSubClient* client, size_t bufferSize = 64) { + MqttWriter(MqttClient* client, size_t bufferSize = 64) { this->client = client; this->bufferSize = bufferSize; this->buffer = (uint8_t*) malloc(bufferSize * sizeof(*this->buffer)); @@ -94,9 +94,10 @@ public: this->bufferPos = 0; size_t docSize = measureJson(doc); size_t written = 0; - if (this->client->beginPublish(topic, docSize, retained)) { + if (this->client->beginMessage(topic, docSize, retained)) { serializeJson(doc, *this); this->flush(); + this->client->endMessage(); written = this->writeAfterLock; } @@ -110,7 +111,7 @@ public: } bool publish(const char* topic, const char* buffer, bool retained = false) { - return this->publish(topic, (uint8_t*) buffer, strlen(buffer), retained); + return this->publish(topic, (const uint8_t*) buffer, strlen(buffer), retained); } bool publish(const char* topic, const uint8_t* buffer, size_t length, bool retained = false) { @@ -128,12 +129,13 @@ public: this->bufferPos = 0; size_t written = 0; bool result = false; - if (length == 0) { - result = this->client->publish(topic, nullptr, 0, retained); + if (!length || buffer == nullptr) { + result = this->client->beginMessage(topic, 0, retained) && this->client->endMessage(); - } else if (this->client->beginPublish(topic, length, retained)) { + } else if (this->client->beginMessage(topic, length, retained)) { this->write(buffer, length); this->flush(); + this->client->endMessage(); written = this->writeAfterLock; result = written == length; @@ -204,7 +206,7 @@ public: } protected: - PubSubClient* client; + MqttClient* client; uint8_t* buffer; size_t bufferSize = 64; size_t bufferPos = 0; diff --git a/lib/WebServerHandlers/DynamicPage.h b/lib/WebServerHandlers/DynamicPage.h new file mode 100644 index 0000000..7f1f056 --- /dev/null +++ b/lib/WebServerHandlers/DynamicPage.h @@ -0,0 +1,227 @@ +#include + + +class DynamicPage : public RequestHandler { +public: + typedef std::function canHandleFunction; + typedef std::function beforeSendFunction; + typedef std::function templateFunction; + + DynamicPage(const char* uri, FS* fs, const char* path, const char* cacheHeader = nullptr) { + this->uri = uri; + this->fs = fs; + this->path = path; + this->cacheHeader = cacheHeader; + } + + DynamicPage* setCanHandleFunction(canHandleFunction val = nullptr) { + this->canHandleFn = val; + + return this; + } + + DynamicPage* setBeforeSendFunction(beforeSendFunction val = nullptr) { + this->beforeSendFn = val; + + return this; + } + + DynamicPage* setTemplateFunction(templateFunction val = nullptr) { + this->templateFn = val; + + return this; + } + + #if defined(ARDUINO_ARCH_ESP32) + bool canHandle(HTTPMethod method, const String uri) override { + #else + bool canHandle(HTTPMethod method, const String& uri) override { + #endif + return uri.equals(this->uri) && (!this->canHandleFn || this->canHandleFn(method, uri)); + } + + #if defined(ARDUINO_ARCH_ESP32) + bool handle(WebServer& server, HTTPMethod method, const String uri) override { + #else + bool handle(WebServer& server, HTTPMethod method, const String& uri) override { + #endif + if (!this->canHandle(method, uri)) { + return false; + } + + if (this->beforeSendFn && !this->beforeSendFn()) { + return true; + } + + File file = this->fs->open(this->path, "r"); + if (!file) { + return false; + + } else if (file.isDirectory()) { + file.close(); + return false; + } + + if (this->cacheHeader != nullptr) { + server.sendHeader("Cache-Control", this->cacheHeader); + } + + #ifdef ARDUINO_ARCH_ESP8266 + if (!server.chunkedResponseModeStart(200, F("text/html"))) { + server.send(505, F("text/html"), F("HTTP1.1 required")); + return true; + } + #else + server.send(200, F("text/html"), ""); + #endif + + uint8_t* argStartPos = nullptr; + uint8_t* argEndPos = nullptr; + uint8_t argName[16]; + size_t sizeArgName = 0; + bool argNameProcess = false; + while (file.available()) { + uint8_t buf[64]; + size_t length = file.read(buf, sizeof(buf)); + size_t offset = 0; + + if (argNameProcess) { + argEndPos = (uint8_t*) memchr(buf, '}', length); + + if (argEndPos != nullptr) { + size_t fullSizeArgName = sizeArgName + (argEndPos - buf); + if (fullSizeArgName < sizeof(argName)) { + // copy full arg name + if (argEndPos - buf > 0) { + memcpy(argName + sizeArgName, buf, argEndPos - buf); + } + argName[fullSizeArgName] = '\0'; + + // send arg value + String argValue = this->templateFn((const char*) argName); + if (argValue.length()) { + server.sendContent(argValue.c_str()); + + } else if (fullSizeArgName > 0) { + server.sendContent("{"); + server.sendContent((const char*) argName); + server.sendContent("}"); + } + + offset = size_t(argEndPos - buf + 1); + sizeArgName = 0; + argNameProcess = false; + } + } + + if (argNameProcess) { + server.sendContent("{"); + + if (sizeArgName > 0) { + argName[sizeArgName] = '\0'; + server.sendContent((const char*) argName); + } + + argNameProcess = false; + } + } + + do { + uint8_t* currentBuf = buf + offset; + size_t currentLength = length - offset; + + argStartPos = (uint8_t*) memchr(currentBuf, '{', currentLength); + + // send all content + if (argStartPos == nullptr) { + if (currentLength > 0) { + server.sendContent((const char*) currentBuf, currentLength); + } + + break; + } + + argEndPos = (uint8_t*) memchr(argStartPos, '}', length - (argStartPos - buf)); + if (argEndPos != nullptr) { + sizeArgName = argEndPos - argStartPos - 1; + + // send all content if arg len > space + if (sizeArgName >= sizeof(argName)) { + if (currentLength > 0) { + server.sendContent((const char*) currentBuf, currentLength); + } + + break; + } + + // arg name + memcpy(argName, argStartPos + 1, sizeArgName); + argName[sizeArgName] = '\0'; + + // send arg value + String argValue = this->templateFn((const char*) argName); + if (argValue.length()) { + // send content before var + if (argStartPos - buf > 0) { + server.sendContent((const char*) currentBuf, argStartPos - buf); + } + + server.sendContent(argValue.c_str()); + + } else { + server.sendContent((const char*) currentBuf, argEndPos - currentBuf + 1); + } + + offset = size_t(argEndPos - currentBuf + 1); + + } else { + sizeArgName = length - size_t(argStartPos - currentBuf) - 1; + Serial.printf("sizeArgName: %d\r\n", sizeArgName); + + // send all content if arg len > space + if (sizeArgName >= sizeof(argName)) { + if (currentLength) { + server.sendContent((const char*) currentBuf, currentLength); + } + + break; + } + + // send content before var + if (argStartPos - buf > 0) { + server.sendContent((const char*) currentBuf, argStartPos - buf); + } + + // copy arg name chunk + if (sizeArgName > 0) { + memcpy(argName, argStartPos + 1, sizeArgName); + } + + argNameProcess = true; + + break; + } + } while(true); + } + + file.close(); + + #ifdef ARDUINO_ARCH_ESP8266 + server.chunkedResponseFinalize(); + #else + server.sendContent(""); + #endif + + return true; + } + +protected: + FS* fs = nullptr; + canHandleFunction canHandleFn; + beforeSendFunction beforeSendFn; + templateFunction templateFn; + String eTag; + const char* uri = nullptr; + const char* path = nullptr; + const char* cacheHeader = nullptr; +}; \ No newline at end of file diff --git a/lib/WebServerHandlers/StaticPage.h b/lib/WebServerHandlers/StaticPage.h new file mode 100644 index 0000000..5daf3af --- /dev/null +++ b/lib/WebServerHandlers/StaticPage.h @@ -0,0 +1,96 @@ +#include + +class StaticPage : public RequestHandler { +public: + typedef std::function canHandleFunction; + typedef std::function beforeSendFunction; + + StaticPage(const char* uri, FS* fs, const char* path, const char* cacheHeader = nullptr) { + this->uri = uri; + this->fs = fs; + this->path = path; + this->cacheHeader = cacheHeader; + } + + StaticPage* setCanHandleFunction(canHandleFunction val = nullptr) { + this->canHandleFn = val; + + return this; + } + + StaticPage* setBeforeSendFunction(beforeSendFunction val = nullptr) { + this->beforeSendFn = val; + + return this; + } + + #if defined(ARDUINO_ARCH_ESP32) + bool canHandle(HTTPMethod method, const String uri) override { + #else + bool canHandle(HTTPMethod method, const String& uri) override { + #endif + return method == HTTP_GET && uri.equals(this->uri) && (!this->canHandleFn || this->canHandleFn(method, uri)); + } + + #if defined(ARDUINO_ARCH_ESP32) + bool handle(WebServer& server, HTTPMethod method, const String uri) override { + #else + bool handle(WebServer& server, HTTPMethod method, const String& uri) override { + #endif + if (!this->canHandle(method, uri)) { + return false; + } + + if (this->beforeSendFn && !this->beforeSendFn()) { + return true; + } + + #if defined(ARDUINO_ARCH_ESP8266) + if (server._eTagEnabled) { + if (server._eTagFunction) { + this->eTag = (server._eTagFunction)(*this->fs, this->path); + + } else if (this->eTag.isEmpty()) { + this->eTag = esp8266webserver::calcETag(*this->fs, this->path); + } + + if (server.header("If-None-Match").equals(this->eTag.c_str())) { + server.send(304); + return true; + } + } + #endif + + File file = this->fs->open(this->path, "r"); + if (!file) { + return false; + + } else if (file.isDirectory()) { + file.close(); + return false; + } + + if (this->cacheHeader != nullptr) { + server.sendHeader("Cache-Control", this->cacheHeader); + } + + #if defined(ARDUINO_ARCH_ESP8266) + if (server._eTagEnabled && this->eTag.length() > 0) { + server.sendHeader("ETag", this->eTag); + } + #endif + + server.streamFile(file, F("text/html"), method); + + return true; + } + +protected: + FS* fs = nullptr; + canHandleFunction canHandleFn; + beforeSendFunction beforeSendFn; + String eTag; + const char* uri = nullptr; + const char* path = nullptr; + const char* cacheHeader = nullptr; +}; \ No newline at end of file diff --git a/lib/WebServerHandlers/UpgradeHandler.h b/lib/WebServerHandlers/UpgradeHandler.h new file mode 100644 index 0000000..37e0dc0 --- /dev/null +++ b/lib/WebServerHandlers/UpgradeHandler.h @@ -0,0 +1,218 @@ +#include + +class UpgradeHandler : public RequestHandler { +public: + enum class UpgradeType { + FIRMWARE = 0, + FILESYSTEM = 1 + }; + + enum class UpgradeStatus { + NONE, + NO_FILE, + SUCCESS, + PROHIBITED, + ABORTED, + ERROR_ON_START, + ERROR_ON_WRITE, + ERROR_ON_FINISH + }; + + typedef struct { + UpgradeType type; + UpgradeStatus status; + String error; + } UpgradeResult; + + typedef std::function CanHandleFunction; + typedef std::function CanUploadFunction; + typedef std::function BeforeUpgradeFunction; + typedef std::function AfterUpgradeFunction; + + UpgradeHandler(const char* uri) { + this->uri = uri; + } + + UpgradeHandler* setCanHandleFunction(CanHandleFunction val = nullptr) { + this->canHandleFn = val; + + return this; + } + + UpgradeHandler* setCanUploadFunction(CanUploadFunction val = nullptr) { + this->canUploadFn = val; + + return this; + } + + UpgradeHandler* setBeforeUpgradeFunction(BeforeUpgradeFunction val = nullptr) { + this->beforeUpgradeFn = val; + + return this; + } + + UpgradeHandler* setAfterUpgradeFunction(AfterUpgradeFunction val = nullptr) { + this->afterUpgradeFn = val; + + return this; + } + + #if defined(ARDUINO_ARCH_ESP32) + bool canHandle(HTTPMethod method, const String uri) override { + #else + bool canHandle(HTTPMethod method, const String& uri) override { + #endif + return method == HTTP_POST && uri.equals(this->uri) && (!this->canHandleFn || this->canHandleFn(method, uri)); + } + + #if defined(ARDUINO_ARCH_ESP32) + bool canUpload(const String uri) override { + #else + bool canUpload(const String& uri) override { + #endif + return uri.equals(this->uri) && (!this->canUploadFn || this->canUploadFn(uri)); + } + + #if defined(ARDUINO_ARCH_ESP32) + bool handle(WebServer& server, HTTPMethod method, const String uri) override { + #else + bool handle(WebServer& server, HTTPMethod method, const String& uri) override { + #endif + if (this->afterUpgradeFn) { + this->afterUpgradeFn(this->firmwareResult, this->filesystemResult); + } + + this->firmwareResult.status = UpgradeStatus::NONE; + this->firmwareResult.error.clear(); + + this->filesystemResult.status = UpgradeStatus::NONE; + this->filesystemResult.error.clear(); + + return true; + } + + #if defined(ARDUINO_ARCH_ESP32) + void upload(WebServer& server, const String uri, HTTPUpload& upload) override { + #else + void upload(WebServer& server, const String& uri, HTTPUpload& upload) override { + #endif + UpgradeResult* result; + if (upload.name.equals("firmware")) { + result = &this->firmwareResult; + } else if (upload.name.equals("filesystem")) { + result = &this->filesystemResult; + } else { + return; + } + + if (result->status != UpgradeStatus::NONE) { + return; + } + + if (this->beforeUpgradeFn && !this->beforeUpgradeFn(result->type)) { + result->status = UpgradeStatus::PROHIBITED; + return; + } + + if (!upload.filename.length()) { + result->status = UpgradeStatus::NO_FILE; + return; + } + + if (upload.status == UPLOAD_FILE_START) { + // reset + if (Update.isRunning()) { + Update.end(false); + Update.clearError(); + } + + bool begin = false; + #ifdef ARDUINO_ARCH_ESP8266 + Update.runAsync(true); + + if (result->type == UpgradeType::FIRMWARE) { + begin = Update.begin((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000, U_FLASH); + + } else if (result->type == UpgradeType::FILESYSTEM) { + close_all_fs(); + begin = Update.begin((size_t)FS_end - (size_t)FS_start, U_FS); + } + #elif defined(ARDUINO_ARCH_ESP32) + if (result->type == UpgradeType::FIRMWARE) { + begin = Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH); + + } else if (result->type == UpgradeType::FILESYSTEM) { + begin = Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS); + } + #endif + + if (!begin || Update.hasError()) { + result->status = UpgradeStatus::ERROR_ON_START; + #ifdef ARDUINO_ARCH_ESP8266 + result->error = Update.getErrorString(); + #else + result->error = Update.errorString(); + #endif + + Log.serrorln("PORTAL.OTA", F("File '%s', on start: %s"), upload.filename.c_str(), result->error.c_str()); + return; + } + + Log.sinfoln("PORTAL.OTA", F("File '%s', started"), upload.filename.c_str()); + + } else if (upload.status == UPLOAD_FILE_WRITE) { + if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { + Update.end(false); + + result->status = UpgradeStatus::ERROR_ON_WRITE; + #ifdef ARDUINO_ARCH_ESP8266 + result->error = Update.getErrorString(); + #else + result->error = Update.errorString(); + #endif + + Log.serrorln( + "PORTAL.OTA", + F("File '%s', on writing %d bytes: %s"), + upload.filename.c_str(), upload.totalSize, result->error + ); + + } else { + Log.sinfoln("PORTAL.OTA", F("File '%s', writed %d bytes"), upload.filename.c_str(), upload.totalSize); + } + + } else if (upload.status == UPLOAD_FILE_END) { + if (Update.end(true)) { + result->status = UpgradeStatus::SUCCESS; + + Log.sinfoln("PORTAL.OTA", F("File '%s': finish"), upload.filename.c_str()); + + } else { + result->status = UpgradeStatus::ERROR_ON_FINISH; + #ifdef ARDUINO_ARCH_ESP8266 + result->error = Update.getErrorString(); + #else + result->error = Update.errorString(); + #endif + + Log.serrorln("PORTAL.OTA", F("File '%s', on finish: %s"), upload.filename.c_str(), result->error); + } + + } else if (upload.status == UPLOAD_FILE_ABORTED) { + Update.end(false); + result->status = UpgradeStatus::ABORTED; + + Log.serrorln("PORTAL.OTA", F("File '%s': aborted"), upload.filename.c_str()); + } + } + +protected: + CanHandleFunction canHandleFn; + CanUploadFunction canUploadFn; + BeforeUpgradeFunction beforeUpgradeFn; + AfterUpgradeFunction afterUpgradeFn; + const char* uri = nullptr; + + UpgradeResult firmwareResult{UpgradeType::FIRMWARE, UpgradeStatus::NONE}; + UpgradeResult filesystemResult{UpgradeType::FILESYSTEM, UpgradeStatus::NONE}; +}; \ No newline at end of file diff --git a/noCompressedData/static/app.css b/noCompressedData/static/app.css new file mode 100644 index 0000000..b293965 --- /dev/null +++ b/noCompressedData/static/app.css @@ -0,0 +1,64 @@ +@media (min-width: 1280px) { + .container { + max-width: 1000px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1000px; + } +} + +.hidden { + display: none !important; +} + +header, +main, +footer { + padding-top: 1.5em !important; + padding-bottom: 1.5em !important; +} + +article { + margin-top: 1em; + margin-bottom: 1em; +} + +footer { + text-align: center; +} + +button.success { + background-color: var(--pico-form-element-valid-border-color); + border-color: var(--pico-form-element-valid-border-color); +} + +button.failed { + background-color: var(--pico-form-element-invalid-border-color); + border-color: var(--pico-form-element-invalid-border-color); +} + +tr.network:hover { + --pico-background-color: var(--pico-primary-focus); + cursor: pointer; +} + +.greatSignal { + background-color: var(--pico-form-element-valid-border-color); +} + +.normalSignal { + background-color: #e48500; +} + +.badSignal { + background-color: var(--pico-form-element-invalid-border-color); +} + +.primary { + border: 0.25em solid var(--pico-form-element-invalid-border-color); + padding: 1em; + margin-bottom: 1em; +} \ No newline at end of file diff --git a/noCompressedData/static/app.js b/noCompressedData/static/app.js new file mode 100644 index 0000000..58c1780 --- /dev/null +++ b/noCompressedData/static/app.js @@ -0,0 +1,636 @@ +function setupForm(formSelector) { + const form = document.querySelector(formSelector); + if (!form) { + return; + } + + const url = form.action; + let button = form.querySelector('button[type="submit"]'); + let defaultText; + + if (button) { + defaultText = button.textContent; + button.onmouseout = function (event) { + if (button.hasAttribute('aria-busy')) { + return; + } + + button.classList.remove('success', 'failed'); + button.textContent = defaultText; + }; + } + + form.addEventListener('submit', async function (event) { + event.preventDefault(); + + if (button) { + button.textContent = 'Please wait...'; + button.setAttribute('disabled', true); + button.setAttribute('aria-busy', true); + } + + const onSuccess = function (response) { + if (button) { + button.textContent = 'Saved'; + button.removeAttribute('disabled'); + button.classList.add('success'); + button.removeAttribute('aria-busy'); + + } + } + + const onFailed = function (response) { + if (button) { + button.textContent = 'Error'; + button.removeAttribute('disabled'); + button.classList.add('failed'); + button.removeAttribute('aria-busy'); + } + } + + try { + let fd = new FormData(form); + let checkboxes = form.querySelectorAll('input[type="checkbox"]'); + for (let checkbox of checkboxes) { + fd.append(checkbox.getAttribute('name'), checkbox.checked); + } + + let response = await fetch(url, { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json' + }, + body: form2json(fd) + }); + + if (response.ok) { + onSuccess(response); + + } else { + onFailed(response); + } + + } catch (err) { + onFailed(false); + } + }); +} + +function setupNetworkScanForm(formSelector, tableSelector) { + const form = document.querySelector(formSelector); + if (!form) { + console.error("form not found"); + return; + } + + const url = form.action; + let button = form.querySelector('button[type="submit"]'); + let defaultText; + + if (button) { + defaultText = button.innerHTML; + } + + const onSubmitFn = async function (event) { + if (event) { + event.preventDefault(); + } + + if (button) { + button.innerHTML = 'Please wait...'; + button.setAttribute('disabled', true); + button.setAttribute('aria-busy', true); + } + + let table = document.querySelector(tableSelector); + if (!table) { + console.error("table not found"); + return; + } + + const onSuccess = async function (response) { + let result = await response.json(); + console.log('networks: ', result); + + let tbody = table.querySelector('tbody'); + if (!tbody) { + tbody = table.createTBody(); + } + + while (tbody.rows.length > 0) { + tbody.rows[0].remove(); + } + + for (let i = 0; i < result.length; i++) { + let row = tbody.insertRow(-1); + row.classList.add("network"); + row.setAttribute('data-ssid', result[i].hidden ? '' : result[i].ssid); + row.onclick = function () { + const input = document.querySelector('input.sta-ssid'); + const ssid = this.getAttribute('data-ssid'); + if (!input || !ssid) { + return; + } + + input.value = ssid; + input.focus(); + }; + + row.insertCell().textContent = "#" + (i + 1); + row.insertCell().innerHTML = result[i].hidden ? 'Hidden' : result[i].ssid; + + const signalCell = row.insertCell(); + const signalElement = document.createElement("kbd"); + signalElement.textContent = result[i].signalQuality + "%"; + if (result[i].signalQuality > 60) { + signalElement.classList.add('greatSignal'); + } else if (result[i].signalQuality > 40) { + signalElement.classList.add('normalSignal'); + } else { + signalElement.classList.add('badSignal'); + } + signalCell.appendChild(signalElement); + } + + if (button) { + button.innerHTML = defaultText; + button.removeAttribute('disabled'); + button.removeAttribute('aria-busy'); + } + } + + const onFailed = async function (response) { + table.classList.remove('hidden'); + + if (button) { + button.innerHTML = defaultText; + button.removeAttribute('disabled'); + button.removeAttribute('aria-busy'); + } + } + + let attempts = 5; + let timer = setInterval(async function () { + attempts--; + + try { + let response = await fetch(url, { cache: 'no-cache' }); + + if (response.status == 200) { + clearInterval(timer); + await onSuccess(response); + + } else if (attempts <= 0) { + await onFailed(response); + } + + } catch (err) { + clearInterval(timer); + onFailed(err); + } + }, 2000); + }; + + form.addEventListener('submit', onSubmitFn); + onSubmitFn(); +} + +function setupRestoreBackupForm(formSelector) { + const form = document.querySelector(formSelector); + if (!form) { + return; + } + + const url = form.action; + let button = form.querySelector('button[type="submit"]'); + let defaultText; + + if (button) { + defaultText = button.textContent; + button.onmouseout = function (event) { + if (button.hasAttribute('aria-busy')) { + return; + } + + button.classList.remove('success', 'failed'); + button.textContent = defaultText; + }; + } + + form.addEventListener('submit', async function (event) { + event.preventDefault(); + + if (button) { + button.textContent = 'Please wait...'; + button.setAttribute('disabled', true); + button.setAttribute('aria-busy', true); + } + + const onSuccess = function (response) { + if (button) { + button.textContent = 'Restored'; + button.removeAttribute('disabled'); + button.classList.add('success'); + button.removeAttribute('aria-busy'); + } + } + + const onFailed = function (response) { + if (button) { + button.textContent = 'Error'; + button.removeAttribute('disabled'); + button.classList.add('failed'); + button.removeAttribute('aria-busy'); + } + } + + const files = form.querySelector('#restore-file').files; + if (files.length <= 0) { + onFailed(false); + return; + } + + let reader = new FileReader(); + reader.readAsText(files[0]); + reader.onload = async function() { + try { + let response = await fetch(url, { + method: 'POST', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json' + }, + body: reader.result + }); + + if (response.ok) { + onSuccess(response); + + } else { + onFailed(response); + } + + } catch (err) { + onFailed(false); + } + }; + reader.onerror = function() { + console.log(reader.error); + }; + }); +} + +function setupUpgradeForm(formSelector) { + const form = document.querySelector(formSelector); + if (!form) { + return; + } + + const url = form.action; + let button = form.querySelector('button[type="submit"]'); + let defaultText; + + if (button) { + defaultText = button.textContent; + button.onmouseout = function (event) { + if (button.hasAttribute('aria-busy')) { + return; + } + + button.classList.remove('success', 'failed'); + button.textContent = defaultText; + }; + } + + const statusToText = function (status) { + switch (status) { + case 0: + return "None"; + case 1: + return "No file"; + case 2: + return "Success"; + case 3: + return "Prohibited"; + case 4: + return "Aborted"; + case 5: + return "Error on start"; + case 6: + return "Error on write"; + case 7: + return "Error on finish"; + default: + return "Unknown"; + } + } + + const onResult = async function (response) { + if (!response) { + return; + } + + const result = await response.json(); + + let resItem = form.querySelector('.upgrade-firmware-result'); + if (resItem && result.firmware.status > 1) { + resItem.textContent = statusToText(result.firmware.status); + resItem.classList.remove('hidden'); + + if (result.firmware.status == 2) { + resItem.classList.remove('failed'); + resItem.classList.add('success'); + } else { + resItem.classList.remove('success'); + resItem.classList.add('failed'); + + if (result.firmware.error != "") { + resItem.textContent += ": " + result.firmware.error; + } + } + } + + resItem = form.querySelector('.upgrade-filesystem-result'); + if (resItem && result.filesystem.status > 1) { + resItem.textContent = statusToText(result.filesystem.status); + resItem.classList.remove('hidden'); + + if (result.filesystem.status == 2) { + resItem.classList.remove('failed'); + resItem.classList.add('success'); + } else { + resItem.classList.remove('success'); + resItem.classList.add('failed'); + + if (result.filesystem.error != "") { + resItem.textContent += ": " + result.filesystem.error; + } + } + } + } + + const onSuccess = function (response) { + onResult(response); + + if (button) { + button.textContent = defaultText; + button.removeAttribute('disabled'); + button.removeAttribute('aria-busy'); + } + } + + const onFailed = function (response) { + if (button) { + button.textContent = 'Error'; + button.removeAttribute('disabled'); + button.classList.add('failed'); + button.removeAttribute('aria-busy'); + } + } + + form.addEventListener('submit', async function (event) { + event.preventDefault(); + + if (button) { + button.textContent = 'Uploading...'; + button.setAttribute('disabled', true); + button.setAttribute('aria-busy', true); + } + + try { + let fd = new FormData(form); + let response = await fetch(url, { + method: 'POST', + cache: 'no-cache', + body: fd + }); + + if (response.status >= 200 && response.status < 500) { + onSuccess(response); + + } else { + onFailed(response); + } + + } catch (err) { + onFailed(false); + } + }); +} + +async function loadNetworkStatus() { + let response = await fetch('/api/network/status', { cache: 'no-cache' }); + let result = await response.json(); + + setValue('.network-hostname', result.hostname); + setValue('.network-mac', result.mac); + setState('.network-connected', result.isConnected); + setValue('.network-ssid', result.ssid); + setValue('.network-signal', result.signalQuality); + setValue('.network-ip', result.ip); + setValue('.network-subnet', result.subnet); + setValue('.network-gateway', result.gateway); + setValue('.network-dns', result.dns); + + setBusy('.main-busy', '.main-table', false); +} + +async function loadNetworkSettings() { + let response = await fetch('/api/network/settings', { cache: 'no-cache' }); + let result = await response.json(); + + setInputValue('.network-hostname', result.hostname); + setCheckboxValue('.network-use-dhcp', result.useDhcp); + setInputValue('.network-static-ip', result.staticConfig.ip); + setInputValue('.network-static-gateway', result.staticConfig.gateway); + setInputValue('.network-static-subnet', result.staticConfig.subnet); + setInputValue('.network-static-dns', result.staticConfig.dns); + setBusy('#network-settings-busy', '#network-settings', false); + + setInputValue('.sta-ssid', result.sta.ssid); + setInputValue('.sta-password', result.sta.password); + setInputValue('.sta-channel', result.sta.channel); + setBusy('#sta-settings-busy', '#sta-settings', false); + + setInputValue('.ap-ssid', result.ap.ssid); + setInputValue('.ap-password', result.ap.password); + setInputValue('.ap-channel', result.ap.channel); + setBusy('#ap-settings-busy', '#ap-settings', false); +} + +async function loadSettings() { + let response = await fetch('/api/settings', { cache: 'no-cache' }); + let result = await response.json(); + + setCheckboxValue('.portal-use-auth', result.portal.useAuth); + setInputValue('.portal-login', result.portal.login); + setInputValue('.portal-password', result.portal.password); + setBusy('#portal-settings-busy', '#portal-settings', false); + + setInputValue('.opentherm-in-pin', result.opentherm.inPin); + setInputValue('.opentherm-out-pin', result.opentherm.outPin); + setInputValue('.opentherm-member-id-code', result.opentherm.memberIdCode); + setCheckboxValue('.opentherm-dhw-present', result.opentherm.dhwPresent); + setCheckboxValue('.opentherm-sw-mode', result.opentherm.summerWinterMode); + setCheckboxValue('.opentherm-heating-ch2-enabled', result.opentherm.heatingCh2Enabled); + setCheckboxValue('.opentherm-heating-ch1-to-ch2', result.opentherm.heatingCh1ToCh2); + setCheckboxValue('.opentherm-dhw-to-ch2', result.opentherm.dhwToCh2); + setCheckboxValue('.opentherm-dhw-blocking', result.opentherm.dhwBlocking); + setCheckboxValue('.opentherm-sync-modulation-with-heating', result.opentherm.modulationSyncWithHeating); + setBusy('#opentherm-settings-busy', '#opentherm-settings', false); + + setInputValue('.mqtt-server', result.mqtt.server); + setInputValue('.mqtt-port', result.mqtt.port); + setInputValue('.mqtt-user', result.mqtt.user); + setInputValue('.mqtt-password', result.mqtt.password); + setInputValue('.mqtt-prefix', result.mqtt.prefix); + setInputValue('.mqtt-interval', result.mqtt.interval); + setBusy('#mqtt-settings-busy', '#mqtt-settings', false); + + setInputValue('.sensors-outdoor-pin', result.sensors.outdoor.pin); + setInputValue('.sensors-outdoor-offset', result.sensors.outdoor.offset); + setInputValue('.sensors-indoor-pin', result.sensors.indoor.pin); + setInputValue('.sensors-indoor-offset', result.sensors.indoor.offset); + setInputValue('.sensors-indoor-ble-addresss', result.sensors.indoor.bleAddresss); + setBusy('#sensors-settings-busy', '#sensors-settings', false); + + setCheckboxValue('.extpump-use', result.externalPump.use); + setInputValue('.extpump-pin', result.externalPump.pin); + setInputValue('.extpump-pc-time', result.externalPump.postCirculationTime); + setInputValue('.extpump-as-interval', result.externalPump.antiStuckInterval); + setInputValue('.extpump-as-time', result.externalPump.antiStuckTime); + setBusy('#extpump-settings-busy', '#extpump-settings', false); +} + +async function loadVars() { + let response = await fetch('/api/vars'); + let result = await response.json(); + + setState('.ot-connected', result.states.otStatus); + setState('.ot-emergency', result.states.emergency); + setState('.ot-heating', result.states.heating); + setState('.ot-dhw', result.states.dhw); + setState('.ot-flame', result.states.flame); + setState('.ot-fault', result.states.fault); + setState('.ot-diagnostic', result.states.diagnostic); + setState('.ot-external-pump', result.states.externalPump); + + setValue('.ot-modulation', result.sensors.modulation); + setValue('.ot-pressure', result.sensors.pressure); + setValue('.ot-dhw-flow-rate', result.sensors.dhwFlowRate); + setValue('.ot-fault-code', result.sensors.faultCode ? ("E" + result.sensors.faultCode) : "-"); + + setValue('.indoor-temp', result.temperatures.indoor); + setValue('.outdoor-temp', result.temperatures.outdoor); + setValue('.heating-temp', result.temperatures.heating); + setValue('.heating-setpoint-temp', result.parameters.heatingSetpoint); + setValue('.dhw-temp', result.temperatures.dhw); + + setBusy('.ot-busy', '.ot-table', false); + + setValue('.version', result.system.version); + setValue('.build-date', result.system.buildDate); + setValue('.uptime', result.system.uptime); + setValue('.free-heap', result.system.freeHeap); + setValue('.total-heap', result.system.totalHeap); + setValue('.max-free-block-heap', result.system.maxFreeBlockHeap); + setValue('.reset-reason', result.system.resetReason); + setState('.mqtt-connected', result.system.mqttConnected); + + setBusy('.system-busy', '.system-table', false); +} + +function setBusy(busySelector, contentSelector, value) { + let busy = document.querySelector(busySelector); + let content = document.querySelector(contentSelector); + if (!busy || !content) { + return; + } + + if (!value) { + busy.classList.add('hidden'); + content.classList.remove('hidden'); + + } else { + busy.classList.remove('hidden'); + content.classList.add('hidden'); + } +} + +function setState(selector, value) { + let item = document.querySelector(selector); + if (!item) { + return; + } + + item.setAttribute('aria-invalid', !value); +} + +function setValue(selector, value) { + let item = document.querySelector(selector); + if (!item) { + return; + } + + item.innerHTML = value; +} + +function setCheckboxValue(selector, value) { + let item = document.querySelector(selector); + if (!item) { + return; + } + + item.checked = value; +} + +function setInputValue(selector, value) { + let item = document.querySelector(selector); + if (!item) { + return; + } + + item.value = value; +} + + +function form2json(data) { + let method = function (object, pair) { + let keys = pair[0].replace(/\]/g, '').split('['); + let key = keys[0]; + let value = pair[1]; + if (value === 'true' || value === 'false') { + value = value === 'true'; + } else if (typeof(value) === 'string' && value.trim() !== '' && !isNaN(value)) { + value = parseFloat(value); + } + + if (keys.length > 1) { + let i, x, segment; + let last = value; + let type = isNaN(keys[1]) ? {} : []; + value = segment = object[key] || type; + + for (i = 1; i < keys.length; i++) { + x = keys[i]; + if (i == keys.length - 1) { + if (Array.isArray(segment)) { + segment.push(last); + } else { + segment[x] = last; + } + } else if (segment[x] == undefined) { + segment[x] = isNaN(keys[i + 1]) ? {} : []; + } + segment = segment[x]; + } + } + + object[key] = value; + return object; + } + + let object = Array.from(data).reduce(method, {}); + return JSON.stringify(object); +} \ No newline at end of file diff --git a/noCompressedData/static/favicon.ico b/noCompressedData/static/favicon.ico new file mode 100644 index 0000000..4034fd7 Binary files /dev/null and b/noCompressedData/static/favicon.ico differ diff --git a/noCompressedData/static/pico.min.css b/noCompressedData/static/pico.min.css new file mode 100644 index 0000000..cc21989 --- /dev/null +++ b/noCompressedData/static/pico.min.css @@ -0,0 +1,4 @@ +@charset "UTF-8";/*! + * Pico CSS ✨ v2.0.0-rc2 (https://picocss.com) + * Copyright 2019-2024 - Licensed under MIT + */:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:calc(var(--pico-spacing) * 2);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:root{--pico-font-size:106.25%}}@media (min-width:768px){:root{--pico-font-size:112.5%}}@media (min-width:1024px){:root{--pico-font-size:118.75%}}@media (min-width:1280px){:root{--pico-font-size:125%}}@media (min-width:1536px){:root{--pico-font-size:131.25%}}:root details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}:root [aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}@media (min-width:576px){body>footer,body>header,body>main,section{--pico-block-spacing-vertical:calc(var(--pico-spacing) * 2.5)}}@media (min-width:768px){body>footer,body>header,body>main,section{--pico-block-spacing-vertical:calc(var(--pico-spacing) * 3)}}@media (min-width:1024px){body>footer,body>header,body>main,section{--pico-block-spacing-vertical:calc(var(--pico-spacing) * 3.5)}}@media (min-width:1280px){body>footer,body>header,body>main,section{--pico-block-spacing-vertical:calc(var(--pico-spacing) * 4)}}@media (min-width:1536px){body>footer,body>header,body>main,section{--pico-block-spacing-vertical:calc(var(--pico-spacing) * 4.5)}}@media (min-width:576px){article{--pico-block-spacing-horizontal:calc(var(--pico-spacing) * 1.25)}}@media (min-width:768px){article{--pico-block-spacing-horizontal:calc(var(--pico-spacing) * 1.5)}}@media (min-width:1024px){article{--pico-block-spacing-horizontal:calc(var(--pico-spacing) * 1.75)}}@media (min-width:1280px){article{--pico-block-spacing-horizontal:calc(var(--pico-spacing) * 2)}}@media (min-width:1536px){article{--pico-block-spacing-horizontal:calc(var(--pico-spacing) * 2.25)}}dialog>article{--pico-block-spacing-vertical:calc(var(--pico-spacing) * 2);--pico-block-spacing-horizontal:var(--pico-spacing)}@media (min-width:576px){dialog>article{--pico-block-spacing-vertical:calc(var(--pico-spacing) * 2.5);--pico-block-spacing-horizontal:calc(var(--pico-spacing) * 1.25)}}@media (min-width:768px){dialog>article{--pico-block-spacing-vertical:calc(var(--pico-spacing) * 3);--pico-block-spacing-horizontal:calc(var(--pico-spacing) * 1.5)}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}:root:not([data-theme=dark]),[data-theme=light]{--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:#e7eaf0;--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:#fde7c0;--pico-mark-color:#0f1114;--pico-ins-color:#1d6a54;--pico-del-color:#883935;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#f3f5f7;--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#fbfcfc;--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-background-color:var(--pico-form-element-background-color);--pico-form-element-disabled-border-color:var(--pico-form-element-border-color);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#b86a6b;--pico-form-element-invalid-active-border-color:#c84f48;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#4c9b8a;--pico-form-element-valid-active-border-color:#279977;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-color:var(--pico-primary-inverse);--pico-switch-checked-background-color:var(--pico-primary-background);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#fbfcfc;--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 155, 138)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200, 79, 72)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:light}:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme]){--pico-background-color:#13171f;--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:#ce7e7b;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#1a1f28;--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#1c212c;--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:#1a1f28;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-background-color:var(--pico-form-element-background-color);--pico-form-element-disabled-border-color:var(--pico-form-element-border-color);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#964a50;--pico-form-element-invalid-active-border-color:#b7403b;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:#16896a;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-color:var(--pico-primary-inverse);--pico-switch-checked-background-color:var(--pico-primary-background);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#1a1f28;--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(8, 9, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:dark}:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{--pico-background-color:#13171f;--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:#ce7e7b;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#1a1f28;--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#1c212c;--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:#1a1f28;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-background-color:var(--pico-form-element-background-color);--pico-form-element-disabled-border-color:var(--pico-form-element-border-color);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#964a50;--pico-form-element-invalid-active-border-color:#b7403b;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:#16896a;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-color:var(--pico-primary-inverse);--pico-switch-checked-background-color:var(--pico-primary-background);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#1a1f28;--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(8, 9, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:dark}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;cursor:default;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding:var(--pico-block-spacing-vertical) 0}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}section{margin-bottom:var(--pico-block-spacing-vertical)}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}figure{display:block;margin:0;padding:0;overflow-x:auto}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:root){overflow:hidden}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:currentColor}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:currentColor}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{--pico-background-color:var(--pico-form-element-disabled-background-color);--pico-border-color:var(--pico-form-element-disabled-border-color);opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;width:calc(1.25em - var(--pico-border-width) * 2);height:100%;border-radius:50%;background-color:var(--pico-color);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(1.125em - var(--pico-border-width))}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left 1.125rem;background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown summary::after,details.dropdown>a::after,details.dropdown>button::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown summary:not([role]):active,details.dropdown summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown summary:not([role]):focus-visible{outline:0}details.dropdown summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown summary+ul[dir=rtl]{right:0;left:auto}details.dropdown summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown summary+ul li a:active,details.dropdown summary+ul li a:focus,details.dropdown summary+ul li a:focus-visible,details.dropdown summary+ul li a:hover,details.dropdown summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown summary+ul li label{width:100%}details.dropdown summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open] summary{margin-bottom:0}details.dropdown[open] summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open] summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}form[role=group],form[role=search]{width:100%}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[role=group],[role=search]{display:inline-flex;position:relative;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[aria-busy=true]:not(input,select,textarea,html){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1rem auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog article{max-width:510px}}@media (min-width:768px){dialog article{max-width:700px}}dialog article>footer,dialog article>header{padding:calc(var(--pico-block-spacing-vertical) * .5) var(--pico-block-spacing-horizontal)}dialog article>header>*{margin-bottom:0}dialog article>header .close,dialog article>header a[rel=prev]{margin:0;margin-left:var(--pico-spacing);float:right}dialog article>footer{text-align:right}dialog article>footer [role=button],dialog article>footer button{margin-bottom:0}dialog article>footer [role=button]:not(:first-of-type),dialog article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog article .close,dialog article a[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;opacity:.5;transition:opacity var(--pico-transition)}dialog article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog article a[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index 186bf77..9672296 100644 --- a/platformio.ini +++ b/platformio.ini @@ -8,45 +8,65 @@ ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html +[platformio] +;extra_configs = secrets.ini +extra_configs = secrets.default.ini + [env] framework = arduino lib_deps = - ;bblanchon/ArduinoJson@^6.21.4 - https://github.com/bblanchon/ArduinoJson/archive/refs/heads/7.x.zip + bblanchon/ArduinoJson@^7.0.0 ;ihormelnyk/OpenTherm Library@^1.1.4 https://github.com/Laxilef/opentherm_library/archive/refs/heads/dev.zip - knolleary/PubSubClient@^2.8 + arduino-libraries/ArduinoMqttClient@^0.1.7 ;lennarthennigs/ESP Telnet@^2.1.2 - https://github.com/Laxilef/ESPTelnet/archive/refs/heads/alt_fix_freeze.zip - gyverlibs/EEManager@^2.0 + https://github.com/LennartHennigs/ESPTelnet/archive/refs/tags/2.2.zip + gyverlibs/FileData@^1.0 ;gyverlibs/GyverPID@^3.3 https://github.com/Laxilef/GyverPID/archive/refs/heads/feat_change_dt_type.zip gyverlibs/GyverBlinker@^1.0 milesburton/DallasTemperature@^3.11.0 - laxilef/TinyLogger@^1.0.9 - https://github.com/Laxilef/WiFiManager/archive/refs/heads/patch-1.zip - ;https://github.com/tzapu/WiFiManager.git#v2.0.16-rc.2 + laxilef/TinyLogger@^1.1.0 build_flags = -D PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY -D PIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305 -mtext-section-literals - -D USE_SERIAL=0 - -D USE_TELNET=1 + -D MQTT_CLIENT_STD_FUNCTION_CALLBACK=1 + ;-D DEBUG_ESP_CORE -D DEBUG_ESP_WIFI -D DEBUG_ESP_PORT=Serial + -D USE_SERIAL=${secrets.use_serial} + -D USE_TELNET=${secrets.use_telnet} + -D DEBUG_BY_DEFAULT=${secrets.debug} + -D HOSTNAME_DEFAULT='"${secrets.hostname}"' + -D AP_SSID_DEFAULT='"${secrets.ap_ssid}"' + -D AP_PASSWORD_DEFAULT='"${secrets.ap_password}"' + -D STA_SSID_DEFAULT='"${secrets.sta_ssid}"' + -D PORTAL_LOGIN_DEFAULT='"${secrets.portal_login}"' + -D PORTAL_PASSWORD_DEFAULT='"${secrets.portal_password}"' + -D MQTT_SERVER_DEFAULT='"${secrets.mqtt_server}"' + -D MQTT_PORT_DEFAULT=${secrets.mqtt_port} + -D MQTT_USER_DEFAULT='"${secrets.mqtt_user}"' + -D MQTT_PASSWORD_DEFAULT='"${secrets.mqtt_password}"' + -D MQTT_PREFIX_DEFAULT='"${secrets.mqtt_prefix}"' upload_speed = 921600 monitor_speed = 115200 -version = 1.4.0-rc.5 +board_build.flash_mode = dio +board_build.filesystem = littlefs +version = 1.4.0-rc.9 ; Defaults [esp8266_defaults] platform = espressif8266 lib_deps = ${env.lib_deps} - ;nrwiersma/ESP8266Scheduler@^1.0 - https://github.com/Laxilef/ESP8266Scheduler/archive/refs/heads/network_fix.zip + nrwiersma/ESP8266Scheduler@^1.1 lib_ignore = extra_scripts = post:tools/build.py build_flags = ${env.build_flags} +board_build.ldscript = eagle.flash.1m256.ld +;board_build.ldscript = eagle.flash.4m1m.ld +platform_packages = + platformio/framework-espressif8266 @ https://github.com/platformio/platform-espressif8266.git [esp32_defaults] platform = espressif32 @@ -60,6 +80,8 @@ extra_scripts = build_flags = ${env.build_flags} -D CORE_DEBUG_LEVEL=0 +platform_packages = + platformio/framework-arduinoespressif32 @ https://github.com/espressif/arduino-esp32.git ; Boards @@ -149,9 +171,11 @@ lib_deps = h2zero/NimBLE-Arduino@^1.4.1 lib_ignore = ${esp32_defaults.lib_ignore} extra_scripts = ${esp32_defaults.extra_scripts} +build_unflags = + -mtext-section-literals build_flags = + ${esp32_defaults.build_flags} -D USE_BLE=1 - -D PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH -D OT_IN_PIN_DEFAULT=8 -D OT_OUT_PIN_DEFAULT=10 -D SENSOR_OUTDOOR_PIN_DEFAULT=0 @@ -177,8 +201,6 @@ build_flags = -D LED_STATUS_PIN=2 ; 18 -D LED_OT_RX_PIN=19 ;-D WOKWI=1 - ;-D DEBUG_BY_DEFAULT=1 - ;-D WM_DEBUG_MODE=3 [env:d1_mini32] platform = ${esp32_defaults.platform} diff --git a/secrets.default.ini b/secrets.default.ini new file mode 100644 index 0000000..88b290a --- /dev/null +++ b/secrets.default.ini @@ -0,0 +1,20 @@ +[secrets] +use_serial = true +use_telnet = true +debug = true +hostname = opentherm + +ap_ssid = OpenTherm Gateway +ap_password = otgateway123456 + +sta_ssid = +sta_password = + +portal_login = admin +portal_password = admin + +mqtt_server = +mqtt_port = 1883 +mqtt_user = +mqtt_password = +mqtt_prefix = opentherm \ No newline at end of file diff --git a/src/MainTask.h b/src/MainTask.h index 0c17f2d..6b260d9 100644 --- a/src/MainTask.h +++ b/src/MainTask.h @@ -1,9 +1,9 @@ #include +extern NetworkTask* tNetwork; extern MqttTask* tMqtt; -extern SensorsTask* tSensors; extern OpenThermTask* tOt; -extern EEManager eeSettings; +extern FileData fsSettings, fsNetworkSettings; #if USE_TELNET extern ESPTelnetStream TelnetStream; #endif @@ -29,7 +29,6 @@ protected: bool blinkerInitialized = false; unsigned long firstFailConnect = 0; unsigned long lastHeapInfo = 0; - unsigned int heapSize = 0; unsigned int minFreeHeapSize = 0; unsigned int minMaxFreeHeapBlockSize = 0; unsigned long restartSignalTime = 0; @@ -37,6 +36,9 @@ protected: unsigned long heatingDisabledTime = 0; byte externalPumpStartReason; unsigned long externalPumpStartTime = 0; +#if USE_TELNET + bool telnetStarted = false; +#endif const char* getTaskName() { return "Main"; @@ -61,29 +63,29 @@ protected: digitalWrite(settings.externalPump.pin, false); } - #if defined(ARDUINO_ARCH_ESP32) - this->heapSize = ESP.getHeapSize(); - #elif defined(ARDUINO_ARCH_ESP8266) - this->heapSize = 81920; - #else - this->heapSize = 99999; - #endif - this->minFreeHeapSize = heapSize; - this->minMaxFreeHeapBlockSize = heapSize; + this->minFreeHeapSize = getTotalHeap(); + this->minMaxFreeHeapBlockSize = getTotalHeap(); } void loop() { - if (eeSettings.tick()) { - Log.sinfoln("MAIN", F("Settings updated (EEPROM)")); + if (fsSettings.tick() == FD_WRITE) { + Log.sinfoln(FPSTR(L_SETTINGS), F("Updated")); + } + + if (fsNetworkSettings.tick() == FD_WRITE) { + Log.sinfoln(FPSTR(L_NETWORK_SETTINGS), F("Updated")); } #if USE_TELNET + if (this->telnetStarted) { TelnetStream.loop(); + } #endif if (vars.actions.restart) { - Log.sinfoln("MAIN", F("Restart signal received. Restart after 10 sec.")); - eeSettings.updateNow(); + Log.sinfoln(FPSTR(L_MAIN), F("Restart signal received. Restart after 10 sec.")); + fsSettings.updateNow(); + fsNetworkSettings.updateNow(); this->restartSignalTime = millis(); vars.actions.restart = false; } @@ -92,7 +94,14 @@ protected: tOt->enable(); } - if (WiFi.status() == WL_CONNECTED) { + if (tNetwork->isConnected()) { + #if USE_TELNET + if (!this->telnetStarted) { + TelnetStream.begin(23, false); + this->telnetStarted = true; + } + #endif + vars.sensors.rssi = WiFi.RSSI(); if (!tMqtt->isEnabled() && strlen(settings.mqtt.server) > 0) { @@ -111,6 +120,13 @@ protected: } } else { + #if USE_TELNET + if (this->telnetStarted) { + TelnetStream.stop(); + this->telnetStarted = false; + } + #endif + if (tMqtt->isEnabled()) { tMqtt->disable(); } @@ -122,7 +138,7 @@ protected: if (millis() - this->firstFailConnect > EMERGENCY_TIME_TRESHOLD) { vars.states.emergency = true; - Log.sinfoln("MAIN", F("Emergency mode enabled")); + Log.sinfoln(FPSTR(L_MAIN), F("Emergency mode enabled")); } } } @@ -150,7 +166,7 @@ protected: } void heap() { - unsigned int freeHeapSize = ESP.getFreeHeap(); + unsigned int freeHeapSize = getFreeHeap(); #if defined(ARDUINO_ARCH_ESP32) unsigned int maxFreeBlockSize = ESP.getMaxAllocHeap(); #else @@ -189,9 +205,9 @@ protected: uint8_t heapFrag = 100 - maxFreeBlockSize * 100.0 / freeHeapSize; if (millis() - this->lastHeapInfo > 20000 || minFreeHeapSizeDiff > 0 || minMaxFreeBlockSizeDiff > 0) { Log.sverboseln( - "MAIN", + FPSTR(L_MAIN), F("Free heap size: %u of %u bytes (min: %u, diff: %u), max free block: %u (min: %u, diff: %u, frag: %hhu%%)"), - freeHeapSize, this->heapSize, this->minFreeHeapSize, minFreeHeapSizeDiff, maxFreeBlockSize, this->minMaxFreeHeapBlockSize, minMaxFreeBlockSizeDiff, heapFrag + freeHeapSize, getTotalHeap(), this->minFreeHeapSize, minFreeHeapSizeDiff, maxFreeBlockSize, this->minMaxFreeHeapBlockSize, minMaxFreeBlockSizeDiff, heapFrag ); this->lastHeapInfo = millis(); } @@ -209,7 +225,7 @@ protected: this->blinkerInitialized = true; } - if (WiFi.status() != WL_CONNECTED) { + if (!tNetwork->isConnected()) { errors[errCount++] = 2; } @@ -270,13 +286,13 @@ protected: } if (!settings.externalPump.use || settings.externalPump.pin == 0) { - if (vars.externalPump.enable) { + if (vars.states.externalPump) { if (settings.externalPump.pin != 0) { digitalWrite(settings.externalPump.pin, false); } - vars.externalPump.enable = false; - vars.externalPump.lastEnableTime = millis(); + vars.states.externalPump = false; + vars.parameters.extPumpLastEnableTime = millis(); Log.sinfoln("EXTPUMP", F("Disabled: use = off")); } @@ -284,29 +300,29 @@ protected: return; } - if (vars.externalPump.enable && !this->heatingEnabled) { + if (vars.states.externalPump && !this->heatingEnabled) { if (this->externalPumpStartReason == MainTask::REASON_PUMP_START_HEATING && millis() - this->heatingDisabledTime > ((unsigned int) settings.externalPump.postCirculationTime * 1000)) { digitalWrite(settings.externalPump.pin, false); - vars.externalPump.enable = false; - vars.externalPump.lastEnableTime = millis(); + vars.states.externalPump = false; + vars.parameters.extPumpLastEnableTime = millis(); Log.sinfoln("EXTPUMP", F("Disabled: expired post circulation time")); } else if (this->externalPumpStartReason == MainTask::REASON_PUMP_START_ANTISTUCK && millis() - this->externalPumpStartTime >= ((unsigned int) settings.externalPump.antiStuckTime * 1000)) { digitalWrite(settings.externalPump.pin, false); - vars.externalPump.enable = false; - vars.externalPump.lastEnableTime = millis(); + vars.states.externalPump = false; + vars.parameters.extPumpLastEnableTime = millis(); Log.sinfoln("EXTPUMP", F("Disabled: expired anti stuck time")); } - } else if (vars.externalPump.enable && this->heatingEnabled && this->externalPumpStartReason == MainTask::REASON_PUMP_START_ANTISTUCK) { + } else if (vars.states.externalPump && this->heatingEnabled && this->externalPumpStartReason == MainTask::REASON_PUMP_START_ANTISTUCK) { this->externalPumpStartReason = MainTask::REASON_PUMP_START_HEATING; - } else if (!vars.externalPump.enable && this->heatingEnabled) { - vars.externalPump.enable = true; + } else if (!vars.states.externalPump && this->heatingEnabled) { + vars.states.externalPump = true; this->externalPumpStartTime = millis(); this->externalPumpStartReason = MainTask::REASON_PUMP_START_HEATING; @@ -314,8 +330,8 @@ protected: Log.sinfoln("EXTPUMP", F("Enabled: heating on")); - } else if (!vars.externalPump.enable && (vars.externalPump.lastEnableTime == 0 || millis() - vars.externalPump.lastEnableTime >= ((unsigned long) settings.externalPump.antiStuckInterval * 1000))) { - vars.externalPump.enable = true; + } else if (!vars.states.externalPump && (vars.parameters.extPumpLastEnableTime == 0 || millis() - vars.parameters.extPumpLastEnableTime >= ((unsigned long) settings.externalPump.antiStuckInterval * 1000))) { + vars.states.externalPump = true; this->externalPumpStartTime = millis(); this->externalPumpStartReason = MainTask::REASON_PUMP_START_ANTISTUCK; diff --git a/src/MqttTask.h b/src/MqttTask.h index adc3fab..b48256d 100644 --- a/src/MqttTask.h +++ b/src/MqttTask.h @@ -1,16 +1,15 @@ -#include +#include #include #include #include "HaHelper.h" -extern EEManager eeSettings; - +extern FileData fsSettings; class MqttTask : public Task { public: MqttTask(bool _enabled = false, unsigned long _interval = 0) : Task(_enabled, _interval) { this->wifiClient = new MqttWiFiClient(); - this->client = new PubSubClient(); + this->client = new MqttClient(this->wifiClient); this->writer = new MqttWriter(this->client, 256); this->haHelper = new HaHelper(); } @@ -22,7 +21,7 @@ public: if (this->client != nullptr) { if (this->client->connected()) { - this->client->disconnect(); + this->client->stop(); } delete this->client; @@ -37,9 +36,27 @@ public: } } + void disable() { + this->client->stop(); + this->wifiClient->stop(); + Task::disable(); + + Log.sinfoln(FPSTR(L_MQTT), F("Disabled")); + } + + void enable() { + Task::enable(); + + Log.sinfoln(FPSTR(L_MQTT), F("Enabled")); + } + + bool isConnected() { + return this->connected; + } + protected: MqttWiFiClient* wifiClient = nullptr; - PubSubClient* client = nullptr; + MqttClient* client = nullptr; HaHelper* haHelper = nullptr; MqttWriter* writer = nullptr; unsigned short readyForSendTime = 15000; @@ -68,7 +85,7 @@ protected: } void setup() { - Log.sinfoln("MQTT", F("Started")); + Log.sinfoln(FPSTR(L_MQTT), F("Started")); // wificlient settings #ifdef ARDUINO_ARCH_ESP8266 @@ -77,19 +94,27 @@ protected: #endif // client settings - this->client->setClient(*this->wifiClient); - this->client->setKeepAlive(15); - + //this->client->setClient(*this->wifiClient); + this->client->setKeepAliveInterval(15000); + this->client->setTxPayloadSize(256); #ifdef ARDUINO_ARCH_ESP8266 - this->client->setSocketTimeout(1); - this->client->setBufferSize(768); + this->client->setConnectionTimeout(1000); #else - this->client->setSocketTimeout(3); - this->client->setBufferSize(1536); + this->client->setConnectionTimeout(3000); #endif - - this->client->setCallback([this] (char* topic, uint8_t* payload, unsigned int length) { - this->onMessage(topic, payload, length); + + this->client->onMessage([this] (void*, size_t length) { + String topic = this->client->messageTopic(); + if (!length || length > 2048 || !topic.length()) { + return; + } + + uint8_t payload[length]; + for (size_t i = 0; i < length && this->client->available(); i++) { + payload[i] = this->client->read(); + } + + this->onMessage(topic.c_str(), payload, length); }); // writer settings @@ -100,13 +125,13 @@ protected: #endif this->writer->setEventPublishCallback([this] (const char* topic, size_t written, size_t length, bool result) { - Log.straceln("MQTT", F("%s publish %u of %u bytes to topic: %s"), result ? F("Successfully") : F("Failed"), written, length, topic); + Log.straceln(FPSTR(L_MQTT), F("%s publish %u of %u bytes to topic: %s"), result ? F("Successfully") : F("Failed"), written, length, topic); #ifdef ARDUINO_ARCH_ESP8266 ::yield(); #endif - this->client->loop(); + //this->client->poll(); this->delay(250); }); @@ -132,7 +157,7 @@ protected: void loop() { if (settings.mqtt.interval > 120) { settings.mqtt.interval = 5; - eeSettings.update(); + fsSettings.update(); } if (!this->client->connected() && this->connected) { @@ -141,10 +166,11 @@ protected: } if (this->wifiClient == nullptr || (!this->client->connected() && millis() - this->lastReconnectTime >= MQTT_RECONNECT_INTERVAL)) { - Log.sinfoln("MQTT", F("Connecting to %s:%u..."), settings.mqtt.server, settings.mqtt.port); + Log.sinfoln(FPSTR(L_MQTT), F("Connecting to %s:%u..."), settings.mqtt.server, settings.mqtt.port); - this->client->setServer(settings.mqtt.server, settings.mqtt.port); - this->client->connect(settings.hostname, settings.mqtt.user, settings.mqtt.password); + this->client->setId(networkSettings.hostname); + this->client->setUsernamePassword(settings.mqtt.user, settings.mqtt.password); + this->client->connect(settings.mqtt.server, settings.mqtt.port); this->lastReconnectTime = millis(); } @@ -158,7 +184,7 @@ protected: if (settings.emergency.enable && !vars.states.emergency) { if (millis() - this->disconnectedTime > EMERGENCY_TIME_TRESHOLD) { vars.states.emergency = true; - Log.sinfoln("MQTT", F("Emergency mode enabled")); + Log.sinfoln(FPSTR(L_MQTT), F("Emergency mode enabled")); } } @@ -168,7 +194,8 @@ protected: #ifdef ARDUINO_ARCH_ESP8266 ::yield(); #endif - this->client->loop(); + + this->client->poll(); // delay for publish data if (!this->isReadyForSend()) { @@ -213,11 +240,11 @@ protected: this->connectedTime = millis(); this->newConnection = true; unsigned long downtime = (millis() - this->disconnectedTime) / 1000; - Log.sinfoln("MQTT", F("Connected (downtime: %u s.)"), downtime); + Log.sinfoln(FPSTR(L_MQTT), F("Connected (downtime: %u s.)"), downtime); if (vars.states.emergency) { vars.states.emergency = false; - Log.sinfoln("MQTT", F("Emergency mode disabled")); + Log.sinfoln(FPSTR(L_MQTT), F("Emergency mode disabled")); } this->client->subscribe(this->haHelper->getDeviceTopic("settings/set").c_str()); @@ -228,16 +255,16 @@ protected: this->disconnectedTime = millis(); unsigned long uptime = (millis() - this->connectedTime) / 1000; - Log.swarningln("MQTT", F("Disconnected (reason: %d uptime: %u s.)"), this->client->state(), uptime); + Log.swarningln(FPSTR(L_MQTT), F("Disconnected (reason: %d uptime: %u s.)"), this->client->connectError(), uptime); } - void onMessage(char* topic, uint8_t* payload, unsigned int length) { + void onMessage(const char* topic, uint8_t* payload, size_t length) { if (!length) { return; } if (settings.debug) { - Log.strace("MQTT.MSG", F("Topic: %s\r\n> "), topic); + Log.strace(FPSTR(L_MQTT_MSG), F("Topic: %s\r\n> "), topic); if (Log.lock()) { for (size_t i = 0; i < length; i++) { if (payload[i] == 0) { @@ -258,8 +285,12 @@ protected: JsonDocument doc; DeserializationError dErr = deserializeJson(doc, payload, length); - if (dErr != DeserializationError::Ok || doc.isNull()) { - Log.swarningln("MQTT.MSG", F("Error on deserialization: %s"), dErr.f_str()); + if (dErr != DeserializationError::Ok) { + Log.swarningln(FPSTR(L_MQTT_MSG), F("Error on deserialization: %s"), dErr.f_str()); + return; + + } else if (doc.isNull() || !doc.size()) { + Log.swarningln(FPSTR(L_MQTT_MSG), F("Not valid json")); return; } @@ -275,254 +306,13 @@ protected: bool updateSettings(JsonDocument& doc) { - bool flag = false; - - if (!doc["debug"].isNull() && doc["debug"].is()) { - settings.debug = doc["debug"].as(); - flag = true; - } - - - // emergency - if (!doc["emergency"]["enable"].isNull() && doc["emergency"]["enable"].is()) { - settings.emergency.enable = doc["emergency"]["enable"].as(); - flag = true; - } - - if (!doc["emergency"]["target"].isNull() && doc["emergency"]["target"].is()) { - if (doc["emergency"]["target"].as() > 0 && doc["emergency"]["target"].as() < 100) { - settings.emergency.target = MqttTask::round(doc["emergency"]["target"].as(), 2); - flag = true; - } - } - - if (!doc["emergency"]["useEquitherm"].isNull() && doc["emergency"]["useEquitherm"].is()) { - if (settings.sensors.outdoor.type != 1) { - settings.emergency.useEquitherm = doc["emergency"]["useEquitherm"].as(); - - } else { - settings.emergency.useEquitherm = false; - } - - if (settings.emergency.useEquitherm && settings.emergency.usePid) { - settings.emergency.usePid = false; - } - - flag = true; - } - - if (!doc["emergency"]["usePid"].isNull() && doc["emergency"]["usePid"].is()) { - if (settings.sensors.indoor.type != 1) { - settings.emergency.usePid = doc["emergency"]["usePid"].as(); - - } else { - settings.emergency.usePid = false; - } - - if (settings.emergency.usePid && settings.emergency.useEquitherm) { - settings.emergency.useEquitherm = false; - } - - flag = true; - } - - - // heating - if (!doc["heating"]["enable"].isNull() && doc["heating"]["enable"].is()) { - settings.heating.enable = doc["heating"]["enable"].as(); - flag = true; - } - - if (!doc["heating"]["turbo"].isNull() && doc["heating"]["turbo"].is()) { - settings.heating.turbo = doc["heating"]["turbo"].as(); - flag = true; - } - - if (!doc["heating"]["target"].isNull() && doc["heating"]["target"].is()) { - if (doc["heating"]["target"].as() > 0 && doc["heating"]["target"].as() < 100) { - settings.heating.target = MqttTask::round(doc["heating"]["target"].as(), 2); - flag = true; - } - } - - if (!doc["heating"]["hysteresis"].isNull() && doc["heating"]["hysteresis"].is()) { - if (doc["heating"]["hysteresis"].as() >= 0 && doc["heating"]["hysteresis"].as() <= 5) { - settings.heating.hysteresis = MqttTask::round(doc["heating"]["hysteresis"].as(), 2); - flag = true; - } - } - - if (!doc["heating"]["maxModulation"].isNull() && doc["heating"]["maxModulation"].is()) { - if (doc["heating"]["maxModulation"].as() > 0 && doc["heating"]["maxModulation"].as() <= 100) { - settings.heating.maxModulation = doc["heating"]["maxModulation"].as(); - flag = true; - } - } - - if (!doc["heating"]["maxTemp"].isNull() && doc["heating"]["maxTemp"].is()) { - if (doc["heating"]["maxTemp"].as() > 0 && doc["heating"]["maxTemp"].as() <= 100) { - settings.heating.maxTemp = doc["heating"]["maxTemp"].as(); - flag = true; - } - } - - if (!doc["heating"]["minTemp"].isNull() && doc["heating"]["minTemp"].is()) { - if (doc["heating"]["minTemp"].as() >= 0 && doc["heating"]["minTemp"].as() < 100) { - settings.heating.minTemp = doc["heating"]["minTemp"].as(); - flag = true; - } - } - - - // dhw - if (!doc["dhw"]["enable"].isNull() && doc["dhw"]["enable"].is()) { - settings.dhw.enable = doc["dhw"]["enable"].as(); - flag = true; - } - - if (!doc["dhw"]["target"].isNull() && doc["dhw"]["target"].is()) { - if (doc["dhw"]["target"].as() >= 0 && doc["dhw"]["target"].as() < 100) { - settings.dhw.target = doc["dhw"]["target"].as(); - flag = true; - } - } - - if (!doc["dhw"]["maxTemp"].isNull() && doc["dhw"]["maxTemp"].is()) { - if (doc["dhw"]["maxTemp"].as() > 0 && doc["dhw"]["maxTemp"].as() <= 100) { - settings.dhw.maxTemp = doc["dhw"]["maxTemp"].as(); - flag = true; - } - } - - if (!doc["dhw"]["minTemp"].isNull() && doc["dhw"]["minTemp"].is()) { - if (doc["dhw"]["minTemp"].as() >= 0 && doc["dhw"]["minTemp"].as() < 100) { - settings.dhw.minTemp = doc["dhw"]["minTemp"].as(); - flag = true; - } - } - - - // pid - if (!doc["pid"]["enable"].isNull() && doc["pid"]["enable"].is()) { - settings.pid.enable = doc["pid"]["enable"].as(); - flag = true; - } - - if (!doc["pid"]["p_factor"].isNull() && doc["pid"]["p_factor"].is()) { - if (doc["pid"]["p_factor"].as() > 0 && doc["pid"]["p_factor"].as() <= 1000) { - settings.pid.p_factor = MqttTask::round(doc["pid"]["p_factor"].as(), 3); - flag = true; - } - } - - if (!doc["pid"]["i_factor"].isNull() && doc["pid"]["i_factor"].is()) { - if (doc["pid"]["i_factor"].as() >= 0 && doc["pid"]["i_factor"].as() <= 100) { - settings.pid.i_factor = MqttTask::round(doc["pid"]["i_factor"].as(), 3); - flag = true; - } - } - - if (!doc["pid"]["d_factor"].isNull() && doc["pid"]["d_factor"].is()) { - if (doc["pid"]["d_factor"].as() >= 0 && doc["pid"]["d_factor"].as() <= 100000) { - settings.pid.d_factor = MqttTask::round(doc["pid"]["d_factor"].as(), 1); - flag = true; - } - } - - if (!doc["pid"]["dt"].isNull() && doc["pid"]["dt"].is()) { - if (doc["pid"]["dt"].as() >= 30 && doc["pid"]["dt"].as() <= 600) { - settings.pid.dt = doc["pid"]["dt"].as(); - flag = true; - } - } - - if (!doc["pid"]["maxTemp"].isNull() && doc["pid"]["maxTemp"].is()) { - if (doc["pid"]["maxTemp"].as() > 0 && doc["pid"]["maxTemp"].as() <= 100 && doc["pid"]["maxTemp"].as() > settings.pid.minTemp) { - settings.pid.maxTemp = doc["pid"]["maxTemp"].as(); - flag = true; - } - } - - if (!doc["pid"]["minTemp"].isNull() && doc["pid"]["minTemp"].is()) { - if (doc["pid"]["minTemp"].as() >= 0 && doc["pid"]["minTemp"].as() < 100 && doc["pid"]["minTemp"].as() < settings.pid.maxTemp) { - settings.pid.minTemp = doc["pid"]["minTemp"].as(); - flag = true; - } - } - - // equitherm - if (!doc["equitherm"]["enable"].isNull() && doc["equitherm"]["enable"].is()) { - settings.equitherm.enable = doc["equitherm"]["enable"].as(); - flag = true; - } - - if (!doc["equitherm"]["n_factor"].isNull() && doc["equitherm"]["n_factor"].is()) { - if (doc["equitherm"]["n_factor"].as() > 0 && doc["equitherm"]["n_factor"].as() <= 10) { - settings.equitherm.n_factor = MqttTask::round(doc["equitherm"]["n_factor"].as(), 3); - flag = true; - } - } - - if (!doc["equitherm"]["k_factor"].isNull() && doc["equitherm"]["k_factor"].is()) { - if (doc["equitherm"]["k_factor"].as() >= 0 && doc["equitherm"]["k_factor"].as() <= 10) { - settings.equitherm.k_factor = MqttTask::round(doc["equitherm"]["k_factor"].as(), 3); - flag = true; - } - } - - if (!doc["equitherm"]["t_factor"].isNull() && doc["equitherm"]["t_factor"].is()) { - if (doc["equitherm"]["t_factor"].as() >= 0 && doc["equitherm"]["t_factor"].as() <= 10) { - settings.equitherm.t_factor = MqttTask::round(doc["equitherm"]["t_factor"].as(), 3); - flag = true; - } - } - - - // sensors - if (!doc["sensors"]["outdoor"]["type"].isNull() && doc["sensors"]["outdoor"]["type"].is()) { - if (doc["sensors"]["outdoor"]["type"].as() >= 0 && doc["sensors"]["outdoor"]["type"].as() <= 2) { - settings.sensors.outdoor.type = doc["sensors"]["outdoor"]["type"].as(); - - if (settings.sensors.outdoor.type == 1) { - settings.emergency.useEquitherm = false; - } - - flag = true; - } - } - - if (!doc["sensors"]["outdoor"]["offset"].isNull() && doc["sensors"]["outdoor"]["offset"].is()) { - if (doc["sensors"]["outdoor"]["offset"].as() >= -10 && doc["sensors"]["outdoor"]["offset"].as() <= 10) { - settings.sensors.outdoor.offset = MqttTask::round(doc["sensors"]["outdoor"]["offset"].as(), 2); - flag = true; - } - } - - if (!doc["sensors"]["indoor"]["type"].isNull() && doc["sensors"]["indoor"]["type"].is()) { - if (doc["sensors"]["indoor"]["type"].as() >= 1 && doc["sensors"]["indoor"]["type"].as() <= 3) { - settings.sensors.indoor.type = doc["sensors"]["indoor"]["type"].as(); - - if (settings.sensors.indoor.type == 1) { - settings.emergency.usePid = false; - } - - flag = true; - } - } - - if (!doc["sensors"]["indoor"]["offset"].isNull() && doc["sensors"]["indoor"]["offset"].is()) { - if (doc["sensors"]["indoor"]["offset"].as() >= -10 && doc["sensors"]["indoor"]["offset"].as() <= 10) { - settings.sensors.indoor.offset = MqttTask::round(doc["sensors"]["indoor"]["offset"].as(), 2); - flag = true; - } - } - + bool changed = safeJsonToSettings(doc, settings); doc.clear(); doc.shrinkToFit(); - if (flag) { + if (changed) { this->prevPubSettingsTime = 0; - eeSettings.update(); + fsSettings.update(); return true; } @@ -530,54 +320,11 @@ protected: } bool updateVariables(JsonDocument& doc) { - bool flag = false; - - if (!doc["ping"].isNull() && doc["ping"]) { - flag = true; - } - - if (!doc["tuning"]["enable"].isNull() && doc["tuning"]["enable"].is()) { - vars.tuning.enable = doc["tuning"]["enable"].as(); - flag = true; - } - - if (!doc["tuning"]["regulator"].isNull() && doc["tuning"]["regulator"].is()) { - if (doc["tuning"]["regulator"].as() >= 0 && doc["tuning"]["regulator"].as() <= 1) { - vars.tuning.regulator = doc["tuning"]["regulator"].as(); - flag = true; - } - } - - if (!doc["temperatures"]["indoor"].isNull() && doc["temperatures"]["indoor"].is()) { - if (settings.sensors.indoor.type == 1 && doc["temperatures"]["indoor"].as() > -100 && doc["temperatures"]["indoor"].as() < 100) { - vars.temperatures.indoor = MqttTask::round(doc["temperatures"]["indoor"].as(), 2); - flag = true; - } - } - - if (!doc["temperatures"]["outdoor"].isNull() && doc["temperatures"]["outdoor"].is()) { - if (settings.sensors.outdoor.type == 1 && doc["temperatures"]["outdoor"].as() > -100 && doc["temperatures"]["outdoor"].as() < 100) { - vars.temperatures.outdoor = MqttTask::round(doc["temperatures"]["outdoor"].as(), 2); - flag = true; - } - } - - if (!doc["actions"]["restart"].isNull() && doc["actions"]["restart"].is() && doc["actions"]["restart"].as()) { - vars.actions.restart = true; - } - - if (!doc["actions"]["resetFault"].isNull() && doc["actions"]["resetFault"].is() && doc["actions"]["resetFault"].as()) { - vars.actions.resetFault = true; - } - - if (!doc["actions"]["resetDiagnostic"].isNull() && doc["actions"]["resetDiagnostic"].is() && doc["actions"]["resetDiagnostic"].as()) { - vars.actions.resetDiagnostic = true; - } - + bool changed = jsonToVars(doc, vars); doc.clear(); doc.shrinkToFit(); - if (flag) { + if (changed) { this->prevPubVarsTime = 0; return true; } @@ -766,97 +513,15 @@ protected: bool publishSettings(const char* topic) { JsonDocument doc; - doc["debug"] = settings.debug; - - doc["emergency"]["enable"] = settings.emergency.enable; - doc["emergency"]["target"] = MqttTask::round(settings.emergency.target, 2); - doc["emergency"]["useEquitherm"] = settings.emergency.useEquitherm; - doc["emergency"]["usePid"] = settings.emergency.usePid; - - doc["heating"]["enable"] = settings.heating.enable; - doc["heating"]["turbo"] = settings.heating.turbo; - doc["heating"]["target"] = MqttTask::round(settings.heating.target, 2); - doc["heating"]["hysteresis"] = MqttTask::round(settings.heating.hysteresis, 2); - doc["heating"]["minTemp"] = settings.heating.minTemp; - doc["heating"]["maxTemp"] = settings.heating.maxTemp; - doc["heating"]["maxModulation"] = settings.heating.maxModulation; - - doc["dhw"]["enable"] = settings.dhw.enable; - doc["dhw"]["target"] = settings.dhw.target; - doc["dhw"]["minTemp"] = settings.dhw.minTemp; - doc["dhw"]["maxTemp"] = settings.dhw.maxTemp; - - doc["pid"]["enable"] = settings.pid.enable; - doc["pid"]["p_factor"] = MqttTask::round(settings.pid.p_factor, 3); - doc["pid"]["i_factor"] = MqttTask::round(settings.pid.i_factor, 3); - doc["pid"]["d_factor"] = MqttTask::round(settings.pid.d_factor, 1); - doc["pid"]["dt"] = settings.pid.dt; - doc["pid"]["minTemp"] = settings.pid.minTemp; - doc["pid"]["maxTemp"] = settings.pid.maxTemp; - - doc["equitherm"]["enable"] = settings.equitherm.enable; - doc["equitherm"]["n_factor"] = MqttTask::round(settings.equitherm.n_factor, 3); - doc["equitherm"]["k_factor"] = MqttTask::round(settings.equitherm.k_factor, 3); - doc["equitherm"]["t_factor"] = MqttTask::round(settings.equitherm.t_factor, 3); - - doc["sensors"]["outdoor"]["type"] = settings.sensors.outdoor.type; - doc["sensors"]["outdoor"]["offset"] = MqttTask::round(settings.sensors.outdoor.offset, 2); - - doc["sensors"]["indoor"]["type"] = settings.sensors.indoor.type; - doc["sensors"]["indoor"]["offset"] = MqttTask::round(settings.sensors.indoor.offset, 2); - - doc.shrinkToFit(); + safeSettingsToJson(settings, doc); return this->writer->publish(topic, doc, true); } bool publishVariables(const char* topic) { JsonDocument doc; - - doc["tuning"]["enable"] = vars.tuning.enable; - doc["tuning"]["regulator"] = vars.tuning.regulator; - - doc["states"]["otStatus"] = vars.states.otStatus; - doc["states"]["heating"] = vars.states.heating; - doc["states"]["dhw"] = vars.states.dhw; - doc["states"]["flame"] = vars.states.flame; - doc["states"]["fault"] = vars.states.fault; - doc["states"]["diagnostic"] = vars.states.diagnostic; - - doc["sensors"]["modulation"] = MqttTask::round(vars.sensors.modulation, 2); - doc["sensors"]["pressure"] = MqttTask::round(vars.sensors.pressure, 2); - doc["sensors"]["dhwFlowRate"] = vars.sensors.dhwFlowRate; - doc["sensors"]["faultCode"] = vars.sensors.faultCode; - doc["sensors"]["rssi"] = vars.sensors.rssi; - doc["sensors"]["uptime"] = millis() / 1000ul; - - doc["temperatures"]["indoor"] = MqttTask::round(vars.temperatures.indoor, 2); - doc["temperatures"]["outdoor"] = MqttTask::round(vars.temperatures.outdoor, 2); - doc["temperatures"]["heating"] = MqttTask::round(vars.temperatures.heating, 2); - doc["temperatures"]["dhw"] = MqttTask::round(vars.temperatures.dhw, 2); - - doc["parameters"]["heatingEnabled"] = vars.parameters.heatingEnabled; - doc["parameters"]["heatingMinTemp"] = vars.parameters.heatingMinTemp; - doc["parameters"]["heatingMaxTemp"] = vars.parameters.heatingMaxTemp; - doc["parameters"]["heatingSetpoint"] = vars.parameters.heatingSetpoint; - doc["parameters"]["dhwMinTemp"] = vars.parameters.dhwMinTemp; - doc["parameters"]["dhwMaxTemp"] = vars.parameters.dhwMaxTemp; - - doc.shrinkToFit(); + varsToJson(vars, doc); return this->writer->publish(topic, doc, true); } - - static double round(double value, uint8_t decimals = 2) { - if (decimals == 0) { - return (int)(value + 0.001); - - } else if (abs(value) < 0.00000001) { - return 0.0; - } - - double multiplier = pow10(decimals); - value += 0.5 / multiplier * (value < 0 ? -1 : 1); - return (int)(value * multiplier) / multiplier; - } }; \ No newline at end of file diff --git a/src/NetworkTask.h b/src/NetworkTask.h new file mode 100644 index 0000000..8821bb8 --- /dev/null +++ b/src/NetworkTask.h @@ -0,0 +1,406 @@ +#if defined(ARDUINO_ARCH_ESP8266) +#include +#include "lwip/etharp.h" +#elif defined(ARDUINO_ARCH_ESP32) +#include +#endif +#include + + +class NetworkTask : public Task { +public: + NetworkTask(bool _enabled = false, unsigned long _interval = 0) : Task(_enabled, _interval) { + Connection::setup(this->useDhcp); + } + + NetworkTask* setHostname(const char* value) { + this->hostname = value; + return this; + } + + NetworkTask* setApCredentials(const char* ssid, const char* password = nullptr, byte channel = 0) { + this->apName = ssid; + this->apPassword = password; + this->apChannel = channel; + + return this; + } + + NetworkTask* setStaCredentials(const char* ssid = nullptr, const char* password = nullptr, byte channel = 0) { + this->staSsid = ssid; + this->staPassword = password; + this->staChannel = channel; + + return this; + } + + NetworkTask* setUseDhcp(bool value) { + this->useDhcp = value; + Connection::setup(this->useDhcp); + + return this; + } + + NetworkTask* setStaticConfig(const char* ip, const char* gateway, const char* subnet, const char* dns) { + this->staticIp.fromString(ip); + this->staticGateway.fromString(gateway); + this->staticSubnet.fromString(subnet); + this->staticDns.fromString(dns); + + return this; + } + + NetworkTask* setStaticConfig(IPAddress &ip, IPAddress &gateway, IPAddress &subnet, IPAddress &dns) { + this->staticIp = ip; + this->staticGateway = gateway; + this->staticSubnet = subnet; + this->staticDns = dns; + + return this; + } + + bool hasStaCredentials() { + return this->staSsid != nullptr; + } + + bool isConnected() { + return this->isStaEnabled() && Connection::getStatus() == Connection::Status::CONNECTED; + } + + bool isConnecting() { + return this->isStaEnabled() && Connection::getStatus() == Connection::Status::CONNECTING; + } + + bool isStaEnabled() { + return (WiFi.getMode() & WIFI_STA) != 0; + } + + bool isApEnabled() { + return (WiFi.getMode() & WIFI_AP) != 0; + } + + bool hasApClients() { + if (!this->isApEnabled()) { + return false; + } + + return WiFi.softAPgetStationNum() > 0; + } + + short int getRssi() { + return WiFi.RSSI(); + } + + IPAddress getApIp() { + return WiFi.softAPIP(); + } + + IPAddress getStaIp() { + return WiFi.localIP(); + } + + IPAddress getStaSubnet() { + return WiFi.subnetMask(); + } + + IPAddress getStaGateway() { + return WiFi.gatewayIP(); + } + + IPAddress getStaDns() { + return WiFi.dnsIP(); + } + + String getStaMac() { + return WiFi.macAddress(); + } + + const char* getStaSsid() { + return this->staSsid; + } + + const char* getStaPassword() { + return this->staPassword; + } + + byte getStaChannel() { + return this->staChannel; + } + + bool resetWifi() { + WiFi.persistent(false); + WiFi.setAutoConnect(false); + WiFi.setAutoReconnect(false); + + #ifdef ARDUINO_ARCH_ESP8266 + WiFi.setSleepMode(WIFI_NONE_SLEEP); + + if (wifi_softap_dhcps_status() == DHCP_STARTED) { + wifi_softap_dhcps_stop(); + } + #elif defined(ARDUINO_ARCH_ESP32) + WiFi.setSleep(WIFI_PS_NONE); + #endif + WiFi.softAPdisconnect(); + + #ifdef ARDUINO_ARCH_ESP8266 + if (wifi_station_dhcpc_status() == DHCP_STARTED) { + wifi_station_dhcpc_stop(); + } + #endif + WiFi.disconnect(false, true); + + return WiFi.mode(WIFI_OFF); + } + + void reconnect() { + this->reconnectFlag = true; + } + + bool connect(bool force = false, unsigned int timeout = 1000u) { + if (this->isConnected() && !force) { + return true; + } + + if (force && !this->isApEnabled()) { + this->resetWifi(); + + } else { + #ifdef ARDUINO_ARCH_ESP8266 + if (wifi_station_dhcpc_status() == DHCP_STARTED) { + wifi_station_dhcpc_stop(); + } + #endif + + WiFi.disconnect(false, true); + } + + if (!this->hasStaCredentials()) { + return false; + } + + this->delay(200); + + #ifdef ARDUINO_ARCH_ESP32 + if (this->setWifiHostname(this->hostname)) { + Log.straceln(FPSTR(L_NETWORK), F("Set hostname '%s': success"), this->hostname); + + } else { + Log.serrorln(FPSTR(L_NETWORK), F("Set hostname '%s': fail"), this->hostname); + } + #endif + + if (!WiFi.mode((WiFiMode_t)(WiFi.getMode() | WIFI_STA))) { + return false; + } + + this->delay(200); + + #ifdef ARDUINO_ARCH_ESP8266 + if (this->setWifiHostname(this->hostname)) { + Log.straceln(FPSTR(L_NETWORK), F("Set hostname '%s': success"), this->hostname); + + } else { + Log.serrorln(FPSTR(L_NETWORK), F("Set hostname '%s': fail"), this->hostname); + } + + this->delay(200); + #endif + + if (!this->useDhcp) { + WiFi.config(this->staticIp, this->staticGateway, this->staticSubnet, this->staticDns); + } + + WiFi.begin(this->staSsid, this->staPassword, this->staChannel); + + unsigned long beginConnectionTime = millis(); + while (millis() - beginConnectionTime < timeout) { + this->delay(100); + + if (WiFi.status() == WL_CONNECTED) { + return true; + } + } + + return false; + } + + static byte rssiToSignalQuality(short int rssi) { + return constrain(map(rssi, -100, -50, 0, 100), 0, 100); + } + +protected: + const unsigned int reconnectInterval = 5000; + const unsigned int failedConnectTimeout = 30000; // 120000 + const unsigned int connectionTimeout = 15000; + const unsigned int resetConnectionTimeout = 60000; + + const char* hostname = "esp"; + const char* apName = "ESP"; + const char* apPassword = nullptr; + byte apChannel = 1; + + const char* staSsid = nullptr; + const char* staPassword = nullptr; + byte staChannel = 0; + + bool useDhcp = true; + IPAddress staticIp; + IPAddress staticGateway; + IPAddress staticSubnet; + IPAddress staticDns; + + bool connected = false; + bool reconnectFlag = false; + unsigned long prevArpGratuitous = 0; + unsigned long prevReconnectingTime = 0; + unsigned long connectedTime = 0; + unsigned long disconnectedTime = 0; + + const char* getTaskName() { + return "Wifi"; + } + + /*int getTaskCore() { + return 1; + }*/ + + int getTaskPriority() { + return 0; + } + + void setup() { + this->resetWifi(); + } + + void loop() { + if (this->isConnected() && !this->hasStaCredentials()) { + Log.sinfoln(FPSTR(L_NETWORK), F("Reset")); + this->resetWifi(); + + } else if (this->isConnected() && !this->reconnectFlag) { + if (!this->connected) { + this->connectedTime = millis(); + this->connected = true; + + Log.sinfoln( + FPSTR(L_NETWORK), + F("Connected, downtime: %lu s., IP: %s, RSSI: %hhd"), + (millis() - this->disconnectedTime) / 1000, + WiFi.localIP().toString().c_str(), + WiFi.RSSI() + ); + } + + if (this->isApEnabled() && millis() - this->connectedTime > this->reconnectInterval && !this->hasApClients()) { + Log.sinfoln(FPSTR(L_NETWORK), F("Stop AP because connected, start only STA")); + + WiFi.mode(WIFI_STA); + return; + } + + #ifdef ARDUINO_ARCH_ESP8266 + if (millis() - this->prevArpGratuitous > 60000) { + this->stationKeepAliveNow(); + this->prevArpGratuitous = millis(); + } + #endif + + } else { + if (this->connected) { + this->disconnectedTime = millis(); + this->connected = false; + + Log.sinfoln( + FPSTR(L_NETWORK), + F("Disconnected, reason: %d, uptime: %lu s."), + Connection::getDisconnectReason(), + (millis() - this->connectedTime) / 1000 + ); + } + + if (!this->hasStaCredentials() && !this->isApEnabled()) { + Log.sinfoln(FPSTR(L_NETWORK), F("No STA credentials, start AP")); + + WiFi.mode(WIFI_AP_STA); + WiFi.softAP(this->apName, this->apPassword, this->apChannel); + + } else if (!this->isApEnabled() && millis() - this->disconnectedTime > this->failedConnectTimeout) { + Log.sinfoln(FPSTR(L_NETWORK), F("Disconnected for a long time, start AP")); + + WiFi.mode(WIFI_AP_STA); + WiFi.softAP(this->apName, this->apPassword, this->apChannel); + + } else if (this->isConnecting() && millis() - this->prevReconnectingTime > this->resetConnectionTimeout) { + Log.swarningln(FPSTR(L_NETWORK), F("Connection timeout, reset wifi...")); + this->resetWifi(); + + } else if (!this->isConnecting() && (!this->prevReconnectingTime || millis() - this->prevReconnectingTime > this->reconnectInterval)) { + if (this->hasStaCredentials()) { + Log.sinfoln(FPSTR(L_NETWORK), F("Try connect...")); + + this->prevReconnectingTime = millis(); + this->connect(true, this->connectionTimeout); + this->reconnectFlag = false; + } + } + } + } + + bool setWifiHostname(const char* hostname) { + if (!this->isHostnameValid(hostname)) { + return false; + } + + if (strcmp(WiFi.getHostname(), hostname) == 0) { + return true; + } + + return WiFi.setHostname(hostname); + } + + #ifdef ARDUINO_ARCH_ESP8266 + /** + * @brief + * https://github.com/arendst/Tasmota/blob/e6515883f0ee5451931b6280ff847b117de5a231/tasmota/tasmota_support/support_wifi.ino#L1196 + */ + static void stationKeepAliveNow(void) { + for (netif* interface = netif_list; interface != nullptr; interface = interface->next) { + if ( + (interface->flags & NETIF_FLAG_LINK_UP) + && (interface->flags & NETIF_FLAG_UP) + && interface->num == STATION_IF + && (!ip4_addr_isany_val(*netif_ip4_addr(interface))) + ) { + etharp_gratuitous(interface); + ::optimistic_yield(1000); + break; + } + } + } + #endif + + /** + * @brief check RFC compliance + * + * @param value + * @return true + * @return false + */ + static bool isHostnameValid(const char* value) { + size_t len = strlen(value); + if (len > 24) { + return false; + } else if (value[len - 1] == '-') { + return false; + } + + for (size_t i = 0; i < len; i++) { + if (!isalnum(value[i]) && value[i] != '-') { + return false; + } + } + + return true; + } +}; \ No newline at end of file diff --git a/src/OpenThermTask.h b/src/OpenThermTask.h index 7823782..b23ab29 100644 --- a/src/OpenThermTask.h +++ b/src/OpenThermTask.h @@ -1,13 +1,7 @@ -#include #include CustomOpenTherm* ot; -extern EEManager eeSettings; - -const char S_OT[] PROGMEM = "OT"; -const char S_OT_DHW[] PROGMEM = "OT.DHW"; -const char S_OT_HEATING[] PROGMEM = "OT.HEATING"; - +extern FileData fsSettings; class OpenThermTask : public Task { public: @@ -46,7 +40,7 @@ protected: } void setup() { - Log.sinfoln(FPSTR(S_OT), F("Started. GPIO IN: %hhu, GPIO OUT: %hhu"), settings.opentherm.inPin, settings.opentherm.outPin); + Log.sinfoln(FPSTR(L_OT), F("Started. GPIO IN: %hhu, GPIO OUT: %hhu"), settings.opentherm.inPin, settings.opentherm.outPin); ot->setHandleSendRequestCallback(OpenThermTask::sendRequestCallback); ot->setYieldCallback([](void* self) { @@ -66,32 +60,32 @@ protected: // Not all boilers support these, only try once when the boiler becomes connected if (updateSlaveVersion()) { - Log.straceln(FPSTR(S_OT), F("Slave version: %u, type: %u"), vars.parameters.slaveVersion, vars.parameters.slaveType); + Log.straceln(FPSTR(L_OT), F("Slave version: %u, type: %u"), vars.parameters.slaveVersion, vars.parameters.slaveType); } else { - Log.swarningln(FPSTR(S_OT), F("Get slave version failed")); + Log.swarningln(FPSTR(L_OT), F("Get slave version failed")); } // 0x013F if (setMasterVersion(0x3F, 0x01)) { - Log.straceln(FPSTR(S_OT), F("Master version: %u, type: %u"), vars.parameters.masterVersion, vars.parameters.masterType); + Log.straceln(FPSTR(L_OT), F("Master version: %u, type: %u"), vars.parameters.masterVersion, vars.parameters.masterType); } else { - Log.swarningln(FPSTR(S_OT), F("Set master version failed")); + Log.swarningln(FPSTR(L_OT), F("Set master version failed")); } if (updateSlaveConfig()) { - Log.straceln(FPSTR(S_OT), F("Slave member id: %u, flags: %u"), vars.parameters.slaveMemberId, vars.parameters.slaveFlags); + Log.straceln(FPSTR(L_OT), F("Slave member id: %u, flags: %u"), vars.parameters.slaveMemberId, vars.parameters.slaveFlags); } else { - Log.swarningln(FPSTR(S_OT), F("Get slave config failed")); + Log.swarningln(FPSTR(L_OT), F("Get slave config failed")); } if (setMasterConfig(settings.opentherm.memberIdCode & 0xFF, (settings.opentherm.memberIdCode & 0xFFFF) >> 8)) { - Log.straceln(FPSTR(S_OT), F("Master member id: %u, flags: %u"), vars.parameters.masterMemberId, vars.parameters.masterFlags); + Log.straceln(FPSTR(L_OT), F("Master member id: %u, flags: %u"), vars.parameters.masterMemberId, vars.parameters.masterFlags); } else { - Log.swarningln(FPSTR(S_OT), F("Set master config failed")); + Log.swarningln(FPSTR(L_OT), F("Set master config failed")); } } @@ -119,18 +113,18 @@ protected: ); if (!ot->isValidResponse(localResponse)) { - Log.swarningln(FPSTR(S_OT), F("Invalid response after setBoilerStatus: %s"), ot->statusToString(ot->getLastResponseStatus())); + Log.swarningln(FPSTR(L_OT), F("Invalid response after setBoilerStatus: %s"), ot->statusToString(ot->getLastResponseStatus())); } if (vars.states.otStatus && !this->prevOtStatus) { this->prevOtStatus = vars.states.otStatus; - Log.sinfoln(FPSTR(S_OT), F("Connected. Initializing")); + Log.sinfoln(FPSTR(L_OT), F("Connected. Initializing")); this->initBoiler(); } else if (!vars.states.otStatus && this->prevOtStatus) { this->prevOtStatus = vars.states.otStatus; - Log.swarningln(FPSTR(S_OT), F("Disconnected")); + Log.swarningln(FPSTR(L_OT), F("Disconnected")); } if (!vars.states.otStatus) { @@ -141,7 +135,7 @@ protected: if (vars.parameters.heatingEnabled != heatingEnabled) { this->prevUpdateNonEssentialVars = 0; vars.parameters.heatingEnabled = heatingEnabled; - Log.sinfoln(FPSTR(S_OT_HEATING), "%s", heatingEnabled ? F("Enabled") : F("Disabled")); + Log.sinfoln(FPSTR(L_OT_HEATING), "%s", heatingEnabled ? F("Enabled") : F("Disabled")); } vars.states.heating = ot->isCentralHeatingActive(localResponse); @@ -154,18 +148,18 @@ protected: if (millis() - this->prevUpdateNonEssentialVars > 60000) { if (!heatingEnabled && settings.opentherm.modulationSyncWithHeating) { if (setMaxModulationLevel(0)) { - Log.snoticeln(FPSTR(S_OT_HEATING), F("Set max modulation 0% (off)")); + Log.snoticeln(FPSTR(L_OT_HEATING), F("Set max modulation 0% (off)")); } else { - Log.swarningln(FPSTR(S_OT_HEATING), F("Failed set max modulation 0% (off)")); + Log.swarningln(FPSTR(L_OT_HEATING), F("Failed set max modulation 0% (off)")); } } else { if (setMaxModulationLevel(settings.heating.maxModulation)) { - Log.snoticeln(FPSTR(S_OT_HEATING), F("Set max modulation %hhu%%"), settings.heating.maxModulation); + Log.snoticeln(FPSTR(L_OT_HEATING), F("Set max modulation %hhu%%"), settings.heating.maxModulation); } else { - Log.swarningln(FPSTR(S_OT_HEATING), F("Failed set max modulation %hhu%%"), settings.heating.maxModulation); + Log.swarningln(FPSTR(L_OT_HEATING), F("Failed set max modulation %hhu%%"), settings.heating.maxModulation); } } @@ -174,24 +168,24 @@ protected: if (updateMinMaxDhwTemp()) { if (settings.dhw.minTemp < vars.parameters.dhwMinTemp) { settings.dhw.minTemp = vars.parameters.dhwMinTemp; - eeSettings.update(); - Log.snoticeln(FPSTR(S_OT_DHW), F("Updated min temp: %hhu"), settings.dhw.minTemp); + fsSettings.update(); + Log.snoticeln(FPSTR(L_OT_DHW), F("Updated min temp: %hhu"), settings.dhw.minTemp); } if (settings.dhw.maxTemp > vars.parameters.dhwMaxTemp) { settings.dhw.maxTemp = vars.parameters.dhwMaxTemp; - eeSettings.update(); - Log.snoticeln(FPSTR(S_OT_DHW), F("Updated max temp: %hhu"), settings.dhw.maxTemp); + fsSettings.update(); + Log.snoticeln(FPSTR(L_OT_DHW), F("Updated max temp: %hhu"), settings.dhw.maxTemp); } } else { - Log.swarningln(FPSTR(S_OT_DHW), F("Failed get min/max temp")); + Log.swarningln(FPSTR(L_OT_DHW), F("Failed get min/max temp")); } if (settings.dhw.minTemp >= settings.dhw.maxTemp) { settings.dhw.minTemp = 30; settings.dhw.maxTemp = 60; - eeSettings.update(); + fsSettings.update(); } } @@ -200,24 +194,24 @@ protected: if (updateMinMaxHeatingTemp()) { if (settings.heating.minTemp < vars.parameters.heatingMinTemp) { settings.heating.minTemp = vars.parameters.heatingMinTemp; - eeSettings.update(); - Log.snoticeln(FPSTR(S_OT_HEATING), F("Updated min temp: %hhu"), settings.heating.minTemp); + fsSettings.update(); + Log.snoticeln(FPSTR(L_OT_HEATING), F("Updated min temp: %hhu"), settings.heating.minTemp); } if (settings.heating.maxTemp > vars.parameters.heatingMaxTemp) { settings.heating.maxTemp = vars.parameters.heatingMaxTemp; - eeSettings.update(); - Log.snoticeln(FPSTR(S_OT_HEATING), F("Updated max temp: %hhu"), settings.heating.maxTemp); + fsSettings.update(); + Log.snoticeln(FPSTR(L_OT_HEATING), F("Updated max temp: %hhu"), settings.heating.maxTemp); } } else { - Log.swarningln(FPSTR(S_OT_HEATING), F("Failed get min/max temp")); + Log.swarningln(FPSTR(L_OT_HEATING), F("Failed get min/max temp")); } if (settings.heating.minTemp >= settings.heating.maxTemp) { settings.heating.minTemp = 20; settings.heating.maxTemp = 90; - eeSettings.update(); + fsSettings.update(); } // force set max CH temp @@ -258,10 +252,10 @@ protected: if (vars.actions.resetFault) { if (vars.states.fault) { if (ot->sendBoilerReset()) { - Log.sinfoln(FPSTR(S_OT), F("Boiler fault reset successfully")); + Log.sinfoln(FPSTR(L_OT), F("Boiler fault reset successfully")); } else { - Log.serrorln(FPSTR(S_OT), F("Boiler fault reset failed")); + Log.serrorln(FPSTR(L_OT), F("Boiler fault reset failed")); } } @@ -272,10 +266,10 @@ protected: if (vars.actions.resetDiagnostic) { if (vars.states.diagnostic) { if (ot->sendServiceReset()) { - Log.sinfoln(FPSTR(S_OT), F("Boiler diagnostic reset successfully")); + Log.sinfoln(FPSTR(L_OT), F("Boiler diagnostic reset successfully")); } else { - Log.serrorln(FPSTR(S_OT), F("Boiler diagnostic reset failed")); + Log.serrorln(FPSTR(L_OT), F("Boiler diagnostic reset failed")); } } @@ -290,7 +284,7 @@ protected: newDhwTemp = constrain(newDhwTemp, settings.dhw.minTemp, settings.dhw.maxTemp); } - Log.sinfoln(FPSTR(S_OT_DHW), F("Set temp = %u"), newDhwTemp); + Log.sinfoln(FPSTR(L_OT_DHW), F("Set temp = %u"), newDhwTemp); // Записываем заданную температуру ГВС if (ot->setDhwTemp(newDhwTemp)) { @@ -298,12 +292,12 @@ protected: this->dhwSetTempTime = millis(); } else { - Log.swarningln(FPSTR(S_OT_DHW), F("Failed set temp")); + Log.swarningln(FPSTR(L_OT_DHW), F("Failed set temp")); } if (settings.opentherm.dhwToCh2) { if (!ot->setHeatingCh2Temp(newDhwTemp)) { - Log.swarningln(FPSTR(S_OT_DHW), F("Failed set ch2 temp")); + Log.swarningln(FPSTR(L_OT_DHW), F("Failed set ch2 temp")); } } } @@ -311,7 +305,7 @@ protected: // // Температура отопления if (heatingEnabled && (needSetHeatingTemp() || fabs(vars.parameters.heatingSetpoint - currentHeatingTemp) > 0.0001)) { - Log.sinfoln(FPSTR(S_OT_HEATING), F("Set temp = %u"), vars.parameters.heatingSetpoint); + Log.sinfoln(FPSTR(L_OT_HEATING), F("Set temp = %u"), vars.parameters.heatingSetpoint); // Записываем заданную температуру if (ot->setHeatingCh1Temp(vars.parameters.heatingSetpoint)) { @@ -319,12 +313,12 @@ protected: this->heatingSetTempTime = millis(); } else { - Log.swarningln(FPSTR(S_OT_HEATING), F("Failed set temp")); + Log.swarningln(FPSTR(L_OT_HEATING), F("Failed set temp")); } if (settings.opentherm.heatingCh1ToCh2) { if (!ot->setHeatingCh2Temp(vars.parameters.heatingSetpoint)) { - Log.swarningln(FPSTR(S_OT_HEATING), F("Failed set ch2 temp")); + Log.swarningln(FPSTR(L_OT_HEATING), F("Failed set ch2 temp")); } } } @@ -394,7 +388,7 @@ protected: } static void printRequestDetail(OpenThermMessageID id, OpenThermResponseStatus status, unsigned long request, unsigned long response, byte attempt) { - Log.straceln(FPSTR(S_OT), F("OT REQUEST ID: %4d Request: %8lx Response: %8lx Attempt: %2d Status: %s"), id, request, response, attempt, ot->statusToString(status)); + Log.straceln(FPSTR(L_OT), F("OT REQUEST ID: %4d Request: %8lx Response: %8lx Attempt: %2d Status: %s"), id, request, response, attempt, ot->statusToString(status)); } bool updateSlaveConfig() { diff --git a/src/PortalTask.h b/src/PortalTask.h new file mode 100644 index 0000000..fa3fd19 --- /dev/null +++ b/src/PortalTask.h @@ -0,0 +1,625 @@ +#define PORTAL_CACHE_TIME "" //"max-age=86400" +#define PORTAL_CACHE settings.debug ? nullptr : PORTAL_CACHE_TIME +#ifdef ARDUINO_ARCH_ESP8266 +#include +#include +using WebServer = ESP8266WebServer; +#else +#include +#include +#endif +#include +#include +#include +#include +#include + +extern NetworkTask* tNetwork; +extern FileData fsSettings, fsNetworkSettings; +extern MqttTask* tMqtt; + + +class PortalTask : public LeanTask { +public: + PortalTask(bool _enabled = false, unsigned long _interval = 0) : LeanTask(_enabled, _interval) { + this->webServer = new WebServer(80); + this->bufferedWebServer = new BufferedWebServer(this->webServer, 32u); + this->dnsServer = new DNSServer(); + } + + ~PortalTask() { + if (this->bufferedWebServer != nullptr) { + delete this->bufferedWebServer; + } + + if (this->webServer != nullptr) { + this->stopWebServer(); + delete this->webServer; + } + + if (this->dnsServer != nullptr) { + this->stopDnsServer(); + delete this->dnsServer; + } + } + +protected: + const unsigned int changeStateInterval = 1000; + + WebServer* webServer = nullptr; + BufferedWebServer* bufferedWebServer = nullptr; + DNSServer* dnsServer = nullptr; + + bool webServerEnabled = false; + bool dnsServerEnabled = false; + unsigned long webServerChangeState = 0; + unsigned long dnsServerChangeState = 0; + + const char* getTaskName() { + return "Portal"; + } + + /*int getTaskCore() { + return 1; + }*/ + + int getTaskPriority() { + return 0; + } + + void setup() { + this->dnsServer->setTTL(0); + this->dnsServer->setErrorReplyCode(DNSReplyCode::NoError); + #ifdef ARDUINO_ARCH_ESP8266 + this->webServer->enableETag(true); + //this->webServer->getServer().setNoDelay(true); + #endif + + // index page + /*auto indexPage = (new DynamicPage("/", &LittleFS, "/index.html")) + ->setTemplateFunction([](const char* var) -> String { + String result; + + if (strcmp(var, "ver") == 0) { + result = PROJECT_VERSION; + } + + return result; + }); + this->webServer->addHandler(indexPage);*/ + this->webServer->addHandler(new StaticPage("/", &LittleFS, "/index.html", PORTAL_CACHE)); + + // restart + this->webServer->on("/restart.html", HTTP_GET, [this]() { + if (this->isNeedAuth()) { + if (!this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + this->webServer->send(401); + return; + } + } + + vars.actions.restart = true; + this->webServer->sendHeader("Location", "/"); + this->webServer->send(302); + }); + + // network settings page + auto networkPage = (new StaticPage("/network.html", &LittleFS, "/network.html", PORTAL_CACHE)) + ->setBeforeSendFunction([this]() { + if (this->isNeedAuth() && !this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + this->webServer->requestAuthentication(DIGEST_AUTH); + return false; + } + + return true; + }); + this->webServer->addHandler(networkPage); + + // settings page + auto settingsPage = (new StaticPage("/settings.html", &LittleFS, "/settings.html", PORTAL_CACHE)) + ->setBeforeSendFunction([this]() { + if (this->isNeedAuth() && !this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + this->webServer->requestAuthentication(DIGEST_AUTH); + return false; + } + + return true; + }); + this->webServer->addHandler(settingsPage); + + // upgrade page + auto upgradePage = (new StaticPage("/upgrade.html", &LittleFS, "/upgrade.html", PORTAL_CACHE)) + ->setBeforeSendFunction([this]() { + if (this->isNeedAuth() && !this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + this->webServer->requestAuthentication(DIGEST_AUTH); + return false; + } + + return true; + }); + this->webServer->addHandler(upgradePage); + + // OTA + auto upgradeHandler = (new UpgradeHandler("/api/upgrade"))->setCanUploadFunction([this](const String& uri) { + if (this->isNeedAuth() && !this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + this->webServer->sendHeader("Connection", "close"); + this->webServer->send(401); + return false; + } + + return true; + })->setBeforeUpgradeFunction([](UpgradeHandler::UpgradeType type) -> bool { + return true; + })->setAfterUpgradeFunction([this](const UpgradeHandler::UpgradeResult& fwResult, const UpgradeHandler::UpgradeResult& fsResult) { + unsigned short status = 200; + if (fwResult.status == UpgradeHandler::UpgradeStatus::SUCCESS || fsResult.status == UpgradeHandler::UpgradeStatus::SUCCESS) { + vars.actions.restart = true; + + } else { + status = 400; + } + + String response = "{\"firmware\": {\"status\": "; + response.concat((short int) fwResult.status); + response.concat(", \"error\": \""); + response.concat(fwResult.error); + response.concat("\"}, \"filesystem\": {\"status\": "); + response.concat((short int) fsResult.status); + response.concat(", \"error\": \""); + response.concat(fsResult.error); + response.concat("\"}}"); + this->webServer->send(status, "application/json", response); + }); + this->webServer->addHandler(upgradeHandler); + + + // backup + this->webServer->on("/api/backup/save", HTTP_GET, [this]() { + if (this->isNeedAuth()) { + if (!this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + return this->webServer->send(401); + } + } + + JsonDocument networkSettingsDoc; + networkSettingsToJson(networkSettings, networkSettingsDoc); + + JsonDocument settingsDoc; + settingsToJson(settings, settingsDoc); + JsonDocument doc; + + doc["network"] = networkSettingsDoc; + doc["settings"] = settingsDoc; + doc.shrinkToFit(); + + this->webServer->sendHeader(F("Content-Disposition"), F("attachment; filename=\"backup.json\"")); + this->bufferedWebServer->send(200, "application/json", doc); + }); + + this->webServer->on("/api/backup/restore", HTTP_POST, [this]() { + if (this->isNeedAuth()) { + if (!this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + return this->webServer->send(401); + } + } + + String plain = this->webServer->arg(0); + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Request /api/backup/restore %d bytes: %s"), plain.length(), plain.c_str()); + + if (plain.length() < 2) { + this->webServer->send(406); + return; + + } else if (plain.length() > 2048) { + this->webServer->send(413); + return; + } + + JsonDocument doc; + DeserializationError dErr = deserializeJson(doc, plain); + plain.clear(); + + if (dErr != DeserializationError::Ok || doc.isNull() || !doc.size()) { + this->webServer->send(400); + return; + } + + bool changed = false; + if (doc["settings"] && jsonToSettings(doc["settings"], settings)) { + fsSettings.update(); + changed = true; + } + + if (doc["network"] && jsonToNetworkSettings(doc["network"], networkSettings)) { + fsNetworkSettings.update(); + tNetwork->setStaCredentials(networkSettings.sta.ssid, networkSettings.sta.password, networkSettings.sta.channel); + tNetwork->setUseDhcp(networkSettings.useDhcp); + tNetwork->setStaticConfig( + networkSettings.staticConfig.ip, + networkSettings.staticConfig.gateway, + networkSettings.staticConfig.subnet, + networkSettings.staticConfig.dns + ); + tNetwork->reconnect(); + changed = true; + } + + this->webServer->send(changed ? 201 : 200); + }); + + // network + this->webServer->on("/api/network/settings", HTTP_GET, [this]() { + if (this->isNeedAuth()) { + if (!this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + return this->webServer->send(401); + } + } + + JsonDocument doc; + networkSettingsToJson(networkSettings, doc); + this->bufferedWebServer->send(200, "application/json", doc); + }); + + this->webServer->on("/api/network/settings", HTTP_POST, [this]() { + if (this->isNeedAuth()) { + if (!this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + return this->webServer->send(401); + } + } + + String plain = this->webServer->arg(0); + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Request /api/network/settings %d bytes: %s"), plain.length(), plain.c_str()); + + if (plain.length() < 2) { + this->webServer->send(406); + return; + + } else if (plain.length() > 512) { + this->webServer->send(413); + return; + } + + JsonDocument doc; + DeserializationError dErr = deserializeJson(doc, plain); + plain.clear(); + + if (dErr != DeserializationError::Ok || doc.isNull() || !doc.size()) { + this->webServer->send(400); + return; + } + + if (jsonToNetworkSettings(doc, networkSettings)) { + this->webServer->send(201); + + fsNetworkSettings.update(); + tNetwork->setStaCredentials(networkSettings.sta.ssid, networkSettings.sta.password, networkSettings.sta.channel); + tNetwork->setUseDhcp(networkSettings.useDhcp); + tNetwork->setStaticConfig( + networkSettings.staticConfig.ip, + networkSettings.staticConfig.gateway, + networkSettings.staticConfig.subnet, + networkSettings.staticConfig.dns + ); + tNetwork->reconnect(); + + } else { + this->webServer->send(200); + } + }); + + this->webServer->on("/api/network/status", HTTP_GET, [this]() { + bool isConnected = tNetwork->isConnected(); + + JsonDocument doc; + doc["hostname"] = networkSettings.hostname; + doc["mac"] = tNetwork->getStaMac(); + doc["isConnected"] = isConnected; + doc["ssid"] = tNetwork->getStaSsid(); + doc["signalQuality"] = isConnected ? NetworkTask::rssiToSignalQuality(tNetwork->getRssi()) : 0; + doc["channel"] = isConnected ? tNetwork->getStaChannel() : 0; + doc["ip"] = isConnected ? tNetwork->getStaIp().toString() : ""; + doc["subnet"] = isConnected ? tNetwork->getStaSubnet().toString() : ""; + doc["gateway"] = isConnected ? tNetwork->getStaGateway().toString() : ""; + doc["dns"] = isConnected ? tNetwork->getStaDns().toString() : ""; + doc.shrinkToFit(); + + this->bufferedWebServer->send(200, "application/json", doc); + }); + + this->webServer->on("/api/network/scan", HTTP_GET, [this]() { + if (this->isNeedAuth()) { + if (!this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + this->webServer->send(401); + return; + } + } + + auto apCount = WiFi.scanComplete(); + if (apCount <= 0) { + WiFi.scanNetworks(true, true); + + if (apCount == WIFI_SCAN_RUNNING || apCount == WIFI_SCAN_FAILED) { + this->webServer->send(202); + + } else if (apCount == 0) { + this->webServer->send(200, "application/json", "[]"); + + } else { + this->webServer->send(500); + } + + + return; + } + + JsonDocument doc; + for (short int i = 0; i < apCount; i++) { + String ssid = WiFi.SSID(i); + doc[i]["ssid"] = ssid; + doc[i]["signalQuality"] = NetworkTask::rssiToSignalQuality(WiFi.RSSI(i)); + doc[i]["channel"] = WiFi.channel(i); + doc[i]["hidden"] = !ssid.length(); + doc[i]["encryptionType"] = WiFi.encryptionType(i); + } + doc.shrinkToFit(); + + this->bufferedWebServer->send(200, "application/json", doc); + + WiFi.scanNetworks(true, true); + }); + + + // settings + this->webServer->on("/api/settings", HTTP_GET, [this]() { + if (this->isNeedAuth()) { + if (!this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + return this->webServer->send(401); + } + } + + JsonDocument doc; + settingsToJson(settings, doc); + this->bufferedWebServer->send(200, "application/json", doc); + }); + + this->webServer->on("/api/settings", HTTP_POST, [this]() { + if (this->isNeedAuth()) { + if (!this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + return this->webServer->send(401); + } + } + + String plain = this->webServer->arg(0); + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Request /api/settings %d bytes: %s"), plain.length(), plain.c_str()); + + if (plain.length() < 2) { + this->webServer->send(406); + return; + + } else if (plain.length() > 2048) { + this->webServer->send(413); + return; + } + + JsonDocument doc; + DeserializationError dErr = deserializeJson(doc, plain); + plain.clear(); + + if (dErr != DeserializationError::Ok || doc.isNull() || !doc.size()) { + this->webServer->send(400); + return; + } + + if (jsonToSettings(doc, settings)) { + fsSettings.update(); + this->webServer->send(201); + + } else { + this->webServer->send(200); + } + }); + + + // vars + this->webServer->on("/api/vars", HTTP_GET, [this]() { + JsonDocument doc; + varsToJson(vars, doc); + + doc["system"]["version"] = PROJECT_VERSION; + doc["system"]["buildDate"] = __DATE__ " " __TIME__; + doc["system"]["uptime"] = millis() / 1000ul; + doc["system"]["freeHeap"] = getFreeHeap(); + doc["system"]["totalHeap"] = getTotalHeap(); + doc["system"]["maxFreeBlockHeap"] = getMaxFreeBlockHeap(); + doc["system"]["resetReason"] = getResetReason(); + doc["system"]["mqttConnected"] = tMqtt->isConnected(); + doc.shrinkToFit(); + + this->bufferedWebServer->send(200, "application/json", doc); + }); + + this->webServer->on("/api/vars", HTTP_POST, [this]() { + if (this->isNeedAuth()) { + if (!this->webServer->authenticate(settings.portal.login, settings.portal.password)) { + return this->webServer->send(401); + } + } + + String plain = this->webServer->arg(0); + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Request /api/vars %d bytes: %s"), plain.length(), plain.c_str()); + + if (plain.length() < 2) { + this->webServer->send(406); + return; + + } else if (plain.length() > 1024) { + this->webServer->send(413); + return; + } + + JsonDocument doc; + DeserializationError dErr = deserializeJson(doc, plain); + plain.clear(); + + if (dErr != DeserializationError::Ok || doc.isNull() || !doc.size()) { + this->webServer->send(400); + return; + } + + if (jsonToVars(doc, vars)) { + this->webServer->send(201); + + } else { + this->webServer->send(200); + } + }); + + + // not found + this->webServer->onNotFound([this]() { + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Page not found, uri: %s"), this->webServer->uri().c_str()); + + if (tNetwork->isApEnabled()) { + this->onCaptivePortal(); + + } else { + this->webServer->send(404, "text/plain", F("Page not found")); + } + }); + + this->webServer->serveStatic("/favicon.ico", LittleFS, "/static/favicon.ico", PORTAL_CACHE); + this->webServer->serveStatic("/static", LittleFS, "/static", PORTAL_CACHE); + } + + void loop() { + // web server + if (!this->stateWebServer()) { + this->startWebServer(); + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Started")); + + #ifdef ARDUINO_ARCH_ESP8266 + ::esp_yield(); + #endif + } + + // dns server + if (!this->stateDnsServer() && this->stateWebServer() && tNetwork->isApEnabled() && tNetwork->hasApClients() && millis() - this->dnsServerChangeState >= this->changeStateInterval) { + this->startDnsServer(); + Log.straceln(FPSTR(L_PORTAL_DNSSERVER), F("Started: AP up")); + + #ifdef ARDUINO_ARCH_ESP8266 + ::esp_yield(); + #endif + + } else if (this->stateDnsServer() && (!tNetwork->isApEnabled() || !this->stateWebServer()) && millis() - this->dnsServerChangeState >= this->changeStateInterval) { + this->stopDnsServer(); + Log.straceln(FPSTR(L_PORTAL_DNSSERVER), F("Stopped: AP down")); + + #ifdef ARDUINO_ARCH_ESP8266 + ::esp_yield(); + #endif + } + + if (this->stateDnsServer()) { + this->dnsServer->processNextRequest(); + #ifdef ARDUINO_ARCH_ESP8266 + ::esp_yield(); + #endif + } + + if (this->stateWebServer()) { + this->webServer->handleClient(); + } + } + + bool isNeedAuth() { + return !tNetwork->isApEnabled() && settings.portal.useAuth && strlen(settings.portal.password); + } + + void onCaptivePortal() { + const String uri = this->webServer->uri(); + + if (uri.equals("/connecttest.txt")) { + this->webServer->sendHeader(F("Location"), F("http://logout.net")); + this->webServer->send(302); + + Log.straceln(FPSTR(L_PORTAL_CAPTIVE), F("Redirect to http://logout.net with 302 code")); + + } else if (uri.equals("/wpad.dat")) { + this->webServer->send(404); + + Log.straceln(FPSTR(L_PORTAL_CAPTIVE), F("Send empty page with 404 code")); + + } else if (uri.equals("/success.txt")) { + this->webServer->send(200); + + Log.straceln(FPSTR(L_PORTAL_CAPTIVE), F("Send empty page with 200 code")); + + } else { + String portalUrl = "http://" + tNetwork->getApIp().toString() + '/'; + + this->webServer->sendHeader("Location", portalUrl.c_str()); + this->webServer->send(302); + + Log.straceln(FPSTR(L_PORTAL_CAPTIVE), F("Redirect to portal page with 302 code")); + } + } + + bool stateWebServer() { + return this->webServerEnabled; + } + + void startWebServer() { + if (this->stateWebServer()) { + return; + } + + this->webServer->begin(); + this->webServerEnabled = true; + this->webServerChangeState = millis(); + + ::yield(); + } + + void stopWebServer() { + if (!this->stateWebServer()) { + return; + } + + this->webServer->handleClient(); + this->webServer->stop(); + this->webServerEnabled = false; + this->webServerChangeState = millis(); + + ::yield(); + } + + bool stateDnsServer() { + return this->dnsServerEnabled; + } + + void startDnsServer() { + if (this->stateDnsServer()) { + return; + } + + this->dnsServer->start(53, "*", tNetwork->getApIp()); + this->dnsServerEnabled = true; + this->dnsServerChangeState = millis(); + + ::yield(); + } + + void stopDnsServer() { + if (!this->stateDnsServer()) { + return; + } + + this->dnsServer->processNextRequest(); + this->dnsServer->stop(); + this->dnsServerEnabled = false; + this->dnsServerChangeState = millis(); + + ::yield(); + } +}; \ No newline at end of file diff --git a/src/Settings.h b/src/Settings.h index 6788e59..02c3e36 100644 --- a/src/Settings.h +++ b/src/Settings.h @@ -1,6 +1,35 @@ +struct NetworkSettings { + char hostname[25] = HOSTNAME_DEFAULT; + bool useDhcp = true; + + struct { + char ip[16] = "192.168.0.100"; + char gateway[16] = "192.168.0.1"; + char subnet[16] = "255.255.255.0"; + char dns[16] = "192.168.0.1"; + } staticConfig; + + struct { + char ssid[33] = AP_SSID_DEFAULT; + char password[65] = AP_PASSWORD_DEFAULT; + byte channel = 1; + } ap; + + struct { + char ssid[33] = STA_SSID_DEFAULT; + char password[65] = STA_PASSWORD_DEFAULT; + byte channel = 0; + } sta; +} networkSettings; + struct Settings { bool debug = DEBUG_BY_DEFAULT; - char hostname[80] = "opentherm"; + + struct { + bool useAuth = false; + char login[13] = PORTAL_LOGIN_DEFAULT; + char password[33] = PORTAL_PASSWORD_DEFAULT; + } portal; struct { byte inPin = OT_IN_PIN_DEFAULT; @@ -16,11 +45,11 @@ struct Settings { } opentherm; struct { - char server[80]; - unsigned short port = 1883; - char user[32]; - char password[32]; - char prefix[80] = "opentherm"; + char server[81] = MQTT_SERVER_DEFAULT; + unsigned short port = MQTT_PORT_DEFAULT; + char user[33] = MQTT_USER_DEFAULT; + char password[33] = MQTT_PASSWORD_DEFAULT; + char prefix[33] = MQTT_PREFIX_DEFAULT; unsigned short interval = 5; } mqtt; @@ -107,6 +136,7 @@ struct Variables { bool flame = false; bool fault = false; bool diagnostic = false; + bool externalPump = false; } states; struct { @@ -124,16 +154,12 @@ struct Variables { float dhw = 0.0f; } temperatures; - struct { - bool enable = false; - unsigned long lastEnableTime = 0; - } externalPump; - struct { bool heatingEnabled = false; byte heatingMinTemp = DEFAULT_HEATING_MIN_TEMP; byte heatingMaxTemp = DEFAULT_HEATING_MAX_TEMP; byte heatingSetpoint = 0; + unsigned long extPumpLastEnableTime = 0; byte dhwMinTemp = DEFAULT_DHW_MIN_TEMP; byte dhwMaxTemp = DEFAULT_DHW_MAX_TEMP; byte maxModulation; diff --git a/src/WifiManagerTask.h b/src/WifiManagerTask.h deleted file mode 100644 index 6d73299..0000000 --- a/src/WifiManagerTask.h +++ /dev/null @@ -1,559 +0,0 @@ -#define WM_MDNS -#include -#include -#include -#include -#include -#ifdef ARDUINO_ARCH_ESP8266 -extern "C" { -#include "lwip/etharp.h" -} -#endif - -WiFiManager wm; -WiFiManagerParameter* wmHostname; -WiFiManagerParameter* wmMqttServer; -UnsignedShortParameter* wmMqttPort; -WiFiManagerParameter* wmMqttUser; -WiFiManagerParameter* wmMqttPassword; -WiFiManagerParameter* wmMqttPrefix; -UnsignedIntParameter* wmMqttPublishInterval; - -UnsignedIntParameter* wmOtInPin; -UnsignedIntParameter* wmOtOutPin; -UnsignedIntParameter* wmOtMemberIdCode; -CheckboxParameter* wmOtDhwPresent; -CheckboxParameter* wmOtSummerWinterMode; -CheckboxParameter* wmOtHeatingCh2Enabled; -CheckboxParameter* wmOtHeatingCh1ToCh2; -CheckboxParameter* wmOtDhwToCh2; -CheckboxParameter* wmOtDhwBlocking; -CheckboxParameter* wmOtModSyncWithHeating; - -UnsignedIntParameter* wmOutdoorSensorPin; -UnsignedIntParameter* wmIndoorSensorPin; -#if USE_BLE -WiFiManagerParameter* wmOutdoorSensorBleAddress; -#endif - -CheckboxParameter* wmExtPumpUse; -UnsignedIntParameter* wmExtPumpPin; -UnsignedShortParameter* wmExtPumpPostCirculationTime; -UnsignedIntParameter* wmExtPumpAntiStuckInterval; -UnsignedShortParameter* wmExtPumpAntiStuckTime; - -HeaderParameter* wmMqttHeader; -HeaderParameter* wmOtHeader; -HeaderParameter* wmOtFlagsHeader; -HeaderParameter* wmSensorsHeader; -HeaderParameter* wmExtPumpHeader; - - -extern EEManager eeSettings; -#if USE_TELNET -extern ESPTelnetStream TelnetStream; -#endif - -const char S_WIFI[] PROGMEM = "WIFI"; -const char S_WIFI_SETTINGS[] PROGMEM = "WIFI.SETTINGS"; - - -class WifiManagerTask : public LeanTask { -public: - WifiManagerTask(bool _enabled = false, unsigned long _interval = 0) : LeanTask(_enabled, _interval) { - wmHostname = new WiFiManagerParameter("hostname", "Hostname", settings.hostname, 80); - wm.addParameter(wmHostname); - - wmMqttHeader = new HeaderParameter("MQTT"); - wm.addParameter(wmMqttHeader); - - wmMqttServer = new WiFiManagerParameter("mqtt_server", "Server", settings.mqtt.server, 80); - wm.addParameter(wmMqttServer); - - wmMqttPort = new UnsignedShortParameter("mqtt_port", "Port", settings.mqtt.port, 6); - wm.addParameter(wmMqttPort); - - wmMqttUser = new WiFiManagerParameter("mqtt_user", "Username", settings.mqtt.user, 32); - wm.addParameter(wmMqttUser); - - wmMqttPassword = new WiFiManagerParameter("mqtt_password", "Password", settings.mqtt.password, 32, "type=\"password\""); - wm.addParameter(wmMqttPassword); - - wmMqttPrefix = new WiFiManagerParameter("mqtt_prefix", "Prefix", settings.mqtt.prefix, 32); - wm.addParameter(wmMqttPrefix); - - wmMqttPublishInterval = new UnsignedIntParameter("mqtt_publish_interval", "Publish interval (sec)", settings.mqtt.interval, 3); - wm.addParameter(wmMqttPublishInterval); - - wmOtHeader = new HeaderParameter("OpenTherm"); - wm.addParameter(wmOtHeader); - - wmOtInPin = new UnsignedIntParameter("ot_in_pin", "GPIO IN", settings.opentherm.inPin, 2); - wm.addParameter(wmOtInPin); - - wmOtOutPin = new UnsignedIntParameter("ot_out_pin", "GPIO OUT", settings.opentherm.outPin, 2); - wm.addParameter(wmOtOutPin); - - wmOtMemberIdCode = new UnsignedIntParameter("ot_member_id_code", "Master Member ID", settings.opentherm.memberIdCode, 5); - wm.addParameter(wmOtMemberIdCode); - - wmOtFlagsHeader = new HeaderParameter("OpenTherm flags"); - wm.addParameter(wmOtFlagsHeader); - - wmOtDhwPresent = new CheckboxParameter("ot_dhw_present", "DHW present", settings.opentherm.dhwPresent); - wm.addParameter(wmOtDhwPresent); - - wmOtSummerWinterMode = new CheckboxParameter("ot_summer_winter_mode", "Summer/winter mode", settings.opentherm.summerWinterMode); - wm.addParameter(wmOtSummerWinterMode); - - wmOtHeatingCh2Enabled = new CheckboxParameter("ot_heating_ch2_enabled", "CH2 enabled", settings.opentherm.heatingCh2Enabled); - wm.addParameter(wmOtHeatingCh2Enabled); - - wmOtHeatingCh1ToCh2 = new CheckboxParameter("ot_heating_ch1_to_ch2", "Heating CH1 to CH2", settings.opentherm.heatingCh1ToCh2); - wm.addParameter(wmOtHeatingCh1ToCh2); - - wmOtDhwToCh2 = new CheckboxParameter("ot_dhw_to_ch2", "DHW to CH2", settings.opentherm.dhwToCh2); - wm.addParameter(wmOtDhwToCh2); - - wmOtDhwBlocking = new CheckboxParameter("ot_dhw_blocking", "DHW blocking", settings.opentherm.dhwBlocking); - wm.addParameter(wmOtDhwBlocking); - - wmOtModSyncWithHeating = new CheckboxParameter("ot_mod_sync_with_heating", "Modulation sync with heating", settings.opentherm.modulationSyncWithHeating); - wm.addParameter(wmOtModSyncWithHeating); - - wmSensorsHeader = new HeaderParameter("Sensors"); - wm.addParameter(wmSensorsHeader); - - wmOutdoorSensorPin = new UnsignedIntParameter("outdoor_sensor_pin", "Outdoor sensor GPIO", settings.sensors.outdoor.pin, 2); - wm.addParameter(wmOutdoorSensorPin); - - wmIndoorSensorPin = new UnsignedIntParameter("indoor_sensor_pin", "Indoor sensor GPIO", settings.sensors.indoor.pin, 2); - wm.addParameter(wmIndoorSensorPin); - -#if USE_BLE - wmOutdoorSensorBleAddress = new WiFiManagerParameter("ble_address", "BLE sensor address", settings.sensors.indoor.bleAddresss, 17); - wm.addParameter(wmOutdoorSensorBleAddress); -#endif - - wmExtPumpHeader = new HeaderParameter("External pump"); - wm.addParameter(wmExtPumpHeader); - - wmExtPumpUse = new CheckboxParameter("ext_pump_use", "Use external pump
", settings.externalPump.use); - wm.addParameter(wmExtPumpUse); - - wmExtPumpPin = new UnsignedIntParameter("ext_pump_pin", "Relay GPIO", settings.externalPump.pin, 2); - wm.addParameter(wmExtPumpPin); - - wmExtPumpPostCirculationTime = new UnsignedShortParameter("ext_pump_ps_time", "Post circulation time (min)", (settings.externalPump.postCirculationTime / 60), 5); - wm.addParameter(wmExtPumpPostCirculationTime); - - wmExtPumpAntiStuckInterval = new UnsignedIntParameter("ext_pump_as_interval", "Anti stuck interval (days)", (settings.externalPump.antiStuckInterval / 86400), 7); - wm.addParameter(wmExtPumpAntiStuckInterval); - - wmExtPumpAntiStuckTime = new UnsignedShortParameter("ext_pump_as_time", "Anti stuck time (min)", (settings.externalPump.antiStuckTime / 60), 5); - wm.addParameter(wmExtPumpAntiStuckTime); - } - - WifiManagerTask* addTaskForDisable(AbstractTask* task) { - this->tasksForDisable.push_back(task); - return this; - } - -protected: - bool connected = false; - unsigned long lastArpGratuitous = 0; - unsigned long lastReconnecting = 0; - std::vector tasksForDisable; - - const char* getTaskName() { - return "WifiManager"; - } - - /*int getTaskCore() { - return 1; - }*/ - - int getTaskPriority() { - return 0; - } - - void setup() { -#ifdef WOKWI - WiFi.begin("Wokwi-GUEST", "", 6); -#endif - - wm.setDebugOutput(settings.debug, (wm_debuglevel_t) WM_DEBUG_MODE); - wm.setTitle(PROJECT_NAME); - wm.setCustomHeadElement(PSTR( - "" - )); - wm.setCustomMenuHTML(PSTR( - "" - "
" - "
" PROJECT_NAME "
" - "
Repo | Issues | Releases | v" PROJECT_VERSION " (" __DATE__ ")
" - "
" - )); - - std::vector menu = {"custom", "wifi", "param", "sep", "info", "update", "restart"}; - wm.setMenu(menu); - - //wm.setCleanConnect(true); - wm.setRestorePersistent(false); - wm.setHostname(settings.hostname); - wm.setWiFiAutoReconnect(false); - wm.setAPClientCheck(true); - wm.setConfigPortalBlocking(false); - wm.setSaveParamsCallback(saveParamsCallback); - wm.setPreOtaUpdateCallback([this] { - for (AbstractTask* task : this->tasksForDisable) { - if (task->isEnabled()) { - task->disable(); - } - } - }); - wm.setConfigPortalTimeout(wm.getWiFiIsSaved() ? 180 : 0); - wm.setDisableConfigPortal(false); - - wm.autoConnect(AP_SSID, AP_PASSWORD); - } - - void loop() { - if (connected && WiFi.status() != WL_CONNECTED) { - connected = false; - - if (wm.getWebPortalActive()) { - wm.stopWebPortal(); - -#ifdef ARDUINO_ARCH_ESP8266 - ::yield(); -#endif - } - - /*wm.setCaptivePortalEnable(true); - - if (!wm.getConfigPortalActive()) { - wm.startConfigPortal(AP_SSID, AP_PASSWORD); - }*/ - -#if USE_TELNET - TelnetStream.stop(); -#ifdef ARDUINO_ARCH_ESP8266 - ::yield(); -#endif -#endif - - Log.sinfoln(FPSTR(S_WIFI), F("Disconnected")); - } - - if (WiFi.status() != WL_CONNECTED && !wm.getConfigPortalActive()) { - if (millis() - this->lastReconnecting > 5000) { - Log.sinfoln(FPSTR(S_WIFI), F("Reconnecting...")); - - WiFi.reconnect(); - this->lastReconnecting = millis(); - } - } - - if (!connected && WiFi.status() == WL_CONNECTED) { - connected = true; - - wm.setConfigPortalTimeout(180); - if (wm.getConfigPortalActive()) { - wm.stopConfigPortal(); -#ifdef ARDUINO_ARCH_ESP8266 - ::yield(); -#endif - } - - wm.setCaptivePortalEnable(false); - if (!wm.getWebPortalActive()) { - wm.startWebPortal(); -#ifdef ARDUINO_ARCH_ESP8266 - ::yield(); -#endif - } - -#if USE_TELNET - TelnetStream.begin(23, false); -#ifdef ARDUINO_ARCH_ESP8266 - ::yield(); -#endif -#endif - - Log.sinfoln(FPSTR(S_WIFI), F("Connected. IP: %s, RSSI: %hhd"), WiFi.localIP().toString().c_str(), WiFi.RSSI()); - } - -#ifdef ARDUINO_ARCH_ESP8266 - if (connected && millis() - lastArpGratuitous > 60000) { - stationKeepAliveNow(); - lastArpGratuitous = millis(); - - ::yield(); - } -#endif - - wm.process(); - } - - static void saveParamsCallback() { - bool changed = false; - bool needRestart = false; - - if (strcmp(wmHostname->getValue(), settings.hostname) != 0) { - changed = true; - needRestart = true; - strcpy(settings.hostname, wmHostname->getValue()); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New hostname: %s"), settings.hostname); - } - - if (strcmp(wmMqttServer->getValue(), settings.mqtt.server) != 0) { - changed = true; - strcpy(settings.mqtt.server, wmMqttServer->getValue()); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New mqtt.server: %s"), settings.mqtt.server); - } - - if (wmMqttPort->getValue() != settings.mqtt.port) { - changed = true; - settings.mqtt.port = wmMqttPort->getValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New mqtt.port: %hu"), settings.mqtt.port); - } - - if (strcmp(wmMqttUser->getValue(), settings.mqtt.user) != 0) { - changed = true; - strcpy(settings.mqtt.user, wmMqttUser->getValue()); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New mqtt.user: %s"), settings.mqtt.user); - } - - if (strcmp(wmMqttPassword->getValue(), settings.mqtt.password) != 0) { - changed = true; - strcpy(settings.mqtt.password, wmMqttPassword->getValue()); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New mqtt.password: %s"), settings.mqtt.password); - } - - if (strcmp(wmMqttPrefix->getValue(), settings.mqtt.prefix) != 0) { - changed = true; - strcpy(settings.mqtt.prefix, wmMqttPrefix->getValue()); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New mqtt.prefix: %s"), settings.mqtt.prefix); - } - - if (wmMqttPublishInterval->getValue() != settings.mqtt.interval) { - changed = true; - settings.mqtt.interval = wmMqttPublishInterval->getValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New mqtt.interval: %du"), settings.mqtt.interval); - } - - if (wmOtInPin->getValue() != settings.opentherm.inPin) { - changed = true; - needRestart = true; - settings.opentherm.inPin = wmOtInPin->getValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.inPin: %hhu"), settings.opentherm.inPin); - } - - if (wmOtOutPin->getValue() != settings.opentherm.outPin) { - changed = true; - needRestart = true; - settings.opentherm.outPin = wmOtOutPin->getValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.outPin: %hhu"), settings.opentherm.outPin); - } - - if (wmOtMemberIdCode->getValue() != settings.opentherm.memberIdCode) { - changed = true; - settings.opentherm.memberIdCode = wmOtMemberIdCode->getValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.memberIdCode: %du"), settings.opentherm.memberIdCode); - } - - if (wmOtDhwPresent->getCheckboxValue() != settings.opentherm.dhwPresent) { - changed = true; - settings.opentherm.dhwPresent = wmOtDhwPresent->getCheckboxValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.dhwPresent: %s"), settings.opentherm.dhwPresent ? "on" : "off"); - } - - if (wmOtSummerWinterMode->getCheckboxValue() != settings.opentherm.summerWinterMode) { - changed = true; - settings.opentherm.summerWinterMode = wmOtSummerWinterMode->getCheckboxValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.summerWinterMode: %s"), settings.opentherm.summerWinterMode ? "on" : "off"); - } - - if (wmOtHeatingCh2Enabled->getCheckboxValue() != settings.opentherm.heatingCh2Enabled) { - changed = true; - settings.opentherm.heatingCh2Enabled = wmOtHeatingCh2Enabled->getCheckboxValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.heatingCh2Enabled: %s"), settings.opentherm.heatingCh2Enabled ? "on" : "off"); - - if (settings.opentherm.heatingCh1ToCh2) { - settings.opentherm.heatingCh1ToCh2 = false; - wmOtHeatingCh1ToCh2->setValue(false); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.heatingCh1ToCh2: %s"), settings.opentherm.heatingCh1ToCh2 ? "on" : "off"); - } - - if (settings.opentherm.dhwToCh2) { - settings.opentherm.dhwToCh2 = false; - wmOtDhwToCh2->setValue(false); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.dhwToCh2: %s"), settings.opentherm.dhwToCh2 ? "on" : "off"); - } - } - - if (wmOtHeatingCh1ToCh2->getCheckboxValue() != settings.opentherm.heatingCh1ToCh2) { - changed = true; - settings.opentherm.heatingCh1ToCh2 = wmOtHeatingCh1ToCh2->getCheckboxValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.heatingCh1ToCh2: %s"), settings.opentherm.heatingCh1ToCh2 ? "on" : "off"); - - if (settings.opentherm.heatingCh2Enabled) { - settings.opentherm.heatingCh2Enabled = false; - wmOtHeatingCh2Enabled->setValue(false); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.heatingCh2Enabled: %s"), settings.opentherm.heatingCh2Enabled ? "on" : "off"); - } - - if (settings.opentherm.dhwToCh2) { - settings.opentherm.dhwToCh2 = false; - wmOtDhwToCh2->setValue(false); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.dhwToCh2: %s"), settings.opentherm.dhwToCh2 ? "on" : "off"); - } - } - - if (wmOtDhwToCh2->getCheckboxValue() != settings.opentherm.dhwToCh2) { - changed = true; - settings.opentherm.dhwToCh2 = wmOtDhwToCh2->getCheckboxValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.dhwToCh2: %s"), settings.opentherm.dhwToCh2 ? "on" : "off"); - - if (settings.opentherm.heatingCh2Enabled) { - settings.opentherm.heatingCh2Enabled = false; - wmOtHeatingCh2Enabled->setValue(false); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.heatingCh2Enabled: %s"), settings.opentherm.heatingCh2Enabled ? "on" : "off"); - } - - if (settings.opentherm.heatingCh1ToCh2) { - settings.opentherm.heatingCh1ToCh2 = false; - wmOtHeatingCh1ToCh2->setValue(false); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.heatingCh1ToCh2: %s"), settings.opentherm.heatingCh1ToCh2 ? "on" : "off"); - } - } - - if (wmOtDhwBlocking->getCheckboxValue() != settings.opentherm.dhwBlocking) { - changed = true; - settings.opentherm.dhwBlocking = wmOtDhwBlocking->getCheckboxValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.dhwBlocking: %s"), settings.opentherm.dhwBlocking ? "on" : "off"); - } - - if (wmOtModSyncWithHeating->getCheckboxValue() != settings.opentherm.modulationSyncWithHeating) { - changed = true; - settings.opentherm.modulationSyncWithHeating = wmOtModSyncWithHeating->getCheckboxValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New opentherm.modulationSyncWithHeating: %s"), settings.opentherm.modulationSyncWithHeating ? "on" : "off"); - } - - if (wmOutdoorSensorPin->getValue() != settings.sensors.outdoor.pin) { - changed = true; - needRestart = true; - settings.sensors.outdoor.pin = wmOutdoorSensorPin->getValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New sensors.outdoor.pin: %hhu"), settings.sensors.outdoor.pin); - } - - if (wmIndoorSensorPin->getValue() != settings.sensors.indoor.pin) { - changed = true; - needRestart = true; - settings.sensors.indoor.pin = wmIndoorSensorPin->getValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New sensors.indoor.pin: %hhu"), settings.sensors.indoor.pin); - } - -#if USE_BLE - if (strcmp(wmOutdoorSensorBleAddress->getValue(), settings.sensors.indoor.bleAddresss) != 0) { - changed = true; - strcpy(settings.sensors.indoor.bleAddresss, wmOutdoorSensorBleAddress->getValue()); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New sensors.indoor.bleAddresss: %s"), settings.sensors.indoor.bleAddresss); - } -#endif - - if (wmExtPumpUse->getCheckboxValue() != settings.externalPump.use) { - changed = true; - settings.externalPump.use = wmExtPumpUse->getCheckboxValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New externalPump.use: %s"), settings.externalPump.use ? "on" : "off"); - } - - if (wmExtPumpPin->getValue() != settings.externalPump.pin) { - changed = true; - needRestart = true; - settings.externalPump.pin = wmExtPumpPin->getValue(); - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New externalPump.pin: %hhu"), settings.externalPump.pin); - } - - if ((wmExtPumpPostCirculationTime->getValue() * 60) != settings.externalPump.postCirculationTime) { - changed = true; - settings.externalPump.postCirculationTime = wmExtPumpPostCirculationTime->getValue() * 60; - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New externalPump.postCirculationTime: %hu"), settings.externalPump.postCirculationTime); - } - - if ((wmExtPumpAntiStuckInterval->getValue() * 86400) != settings.externalPump.antiStuckInterval) { - changed = true; - settings.externalPump.antiStuckInterval = wmExtPumpAntiStuckInterval->getValue() * 86400; - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New externalPump.antiStuckInterval: %du"), settings.externalPump.antiStuckInterval); - } - - if ((wmExtPumpAntiStuckTime->getValue() * 60) != settings.externalPump.antiStuckTime) { - changed = true; - settings.externalPump.antiStuckTime = wmExtPumpAntiStuckTime->getValue() * 60; - - Log.sinfoln(FPSTR(S_WIFI_SETTINGS), F("New externalPump.antiStuckTime: %hu"), settings.externalPump.antiStuckTime); - } - - if (!changed) { - return; - } else if (needRestart) { - vars.actions.restart = true; - } - - eeSettings.update(); - } - -#ifdef ARDUINO_ARCH_ESP8266 - /** - * @brief - * https://github.com/arendst/Tasmota/blob/e6515883f0ee5451931b6280ff847b117de5a231/tasmota/tasmota_support/support_wifi.ino#L1196 - */ - static void stationKeepAliveNow(void) { - for (netif* interface = netif_list; interface != nullptr; interface = interface->next) { - if ( - (interface->flags & NETIF_FLAG_LINK_UP) - && (interface->flags & NETIF_FLAG_UP) - && interface->num == STATION_IF - && (!ip4_addr_isany_val(*netif_ip4_addr(interface))) - ) { - etharp_gratuitous(interface); - break; - } - } - } -#endif -}; \ No newline at end of file diff --git a/src/defines.h b/src/defines.h index d42552e..1e25c85 100644 --- a/src/defines.h +++ b/src/defines.h @@ -1,8 +1,6 @@ #define PROJECT_NAME "OpenTherm Gateway" -#define PROJECT_VERSION "1.4.0-rc.5" +#define PROJECT_VERSION "1.4.0-rc.9" #define PROJECT_REPO "https://github.com/Laxilef/OTGateway" -#define AP_SSID "OpenTherm Gateway" -#define AP_PASSWORD "otgateway123456" #define EMERGENCY_TIME_TRESHOLD 120000 #define MQTT_RECONNECT_INTERVAL 15000 @@ -38,10 +36,58 @@ #define USE_BLE false #endif +#ifndef HOSTNAME_DEFAULT + #define HOSTNAME_DEFAULT "opentherm" +#endif + +#ifndef AP_SSID_DEFAULT + #define AP_SSID_DEFAULT "OpenTherm Gateway" +#endif + +#ifndef AP_PASSWORD_DEFAULT + #define AP_PASSWORD_DEFAULT "otgateway123456" +#endif + +#ifndef STA_SSID_DEFAULT + #define STA_SSID_DEFAULT "" +#endif + +#ifndef STA_PASSWORD_DEFAULT + #define STA_PASSWORD_DEFAULT "" +#endif + #ifndef DEBUG_BY_DEFAULT #define DEBUG_BY_DEFAULT false #endif +#ifndef PORTAL_LOGIN_DEFAULT + #define PORTAL_LOGIN_DEFAULT "" +#endif + +#ifndef PORTAL_PASSWORD_DEFAULT + #define PORTAL_PASSWORD_DEFAULT "" +#endif + +#ifndef MQTT_SERVER_DEFAULT + #define MQTT_SERVER_DEFAULT "" +#endif + +#ifndef MQTT_PORT_DEFAULT + #define MQTT_PORT_DEFAULT 1883 +#endif + +#ifndef MQTT_USER_DEFAULT + #define MQTT_USER_DEFAULT "" +#endif + +#ifndef MQTT_PASSWORD_DEFAULT + #define MQTT_PASSWORD_DEFAULT "" +#endif + +#ifndef MQTT_PREFIX_DEFAULT + #define MQTT_PREFIX_DEFAULT "opentherm" +#endif + #ifndef OT_IN_PIN_DEFAULT #define OT_IN_PIN_DEFAULT 0 #endif diff --git a/src/main.cpp b/src/main.cpp index ba607ff..e71fe41 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,12 @@ #include #include "defines.h" +#include "strings.h" #include -#include +#include +#include #include #include "Settings.h" +#include #if USE_TELNET #include "ESPTelnetStream.h" @@ -19,29 +22,34 @@ #include #include -#include "WifiManagerTask.h" +#include "NetworkTask.h" #include "MqttTask.h" #include "OpenThermTask.h" #include "SensorsTask.h" #include "RegulatorTask.h" +#include "PortalTask.h" #include "MainTask.h" // Vars -EEManager eeSettings(settings, 60000); +FileData fsNetworkSettings(&LittleFS, "/network.conf", 'n', &networkSettings, sizeof(networkSettings), 1000); +FileData fsSettings(&LittleFS, "/settings.conf", 's', &settings, sizeof(settings), 60000); #if USE_TELNET - ESPTelnetStream TelnetStream; +ESPTelnetStream TelnetStream; #endif // Tasks -WifiManagerTask* tWm; +NetworkTask* tNetwork; MqttTask* tMqtt; OpenThermTask* tOt; SensorsTask* tSensors; RegulatorTask* tRegulator; +PortalTask* tPortal; MainTask* tMain; void setup() { + LittleFS.begin(); + Log.setLevel(TinyLogger::Level::VERBOSE); Log.setServiceTemplate("\033[1m[%s]\033[22m"); Log.setLevelTemplate("\033[1m[%s]\033[22m"); @@ -52,46 +60,87 @@ void setup() { int sec = time % 60; int min = time % 3600 / 60; int hour = time / 3600; - + return tm{sec, min, hour}; }); - + #if USE_SERIAL - Serial.begin(115200); - Serial.println("\n\n"); - Log.addStream(&Serial); + Serial.begin(115200); + Log.addStream(&Serial); #endif #if USE_TELNET - TelnetStream.setKeepAliveInterval(500); - Log.addStream(&TelnetStream); + TelnetStream.setKeepAliveInterval(500); + Log.addStream(&TelnetStream); #endif - EEPROM.begin(eeSettings.blockSize()); - uint8_t eeSettingsResult = eeSettings.begin(0, 's'); - if (eeSettingsResult == 0) { - Log.sinfoln("MAIN", F("Settings loaded")); + Log.print("\n\n\r"); - if (strcmp(SETTINGS_VALID_VALUE, settings.validationValue) != 0) { - Log.swarningln("MAIN", F("Settings not valid, reset and restart...")); - eeSettings.reset(); - delay(5000); - ESP.restart(); - } + // network settings + switch (fsNetworkSettings.read()) { + case FD_FS_ERR: + Log.swarningln(FPSTR(L_NETWORK_SETTINGS), F("Filesystem error, load default")); + break; + case FD_FILE_ERR: + Log.swarningln(FPSTR(L_NETWORK_SETTINGS), F("Bad data, load default")); + break; + case FD_WRITE: + Log.sinfoln(FPSTR(L_NETWORK_SETTINGS), F("Not found, load default")); + break; + case FD_ADD: + case FD_READ: + Log.sinfoln(FPSTR(L_NETWORK_SETTINGS), F("Loaded")); + break; + default: + break; + } - } else if (eeSettingsResult == 1) { - Log.sinfoln("MAIN", F("Settings NOT loaded, first start")); + // settings + switch (fsSettings.read()) { + case FD_FS_ERR: + Log.swarningln(FPSTR(L_SETTINGS), F("Filesystem error, load default")); + break; + case FD_FILE_ERR: + Log.swarningln(FPSTR(L_SETTINGS), F("Bad data, load default")); + break; + case FD_WRITE: + Log.sinfoln(FPSTR(L_SETTINGS), F("Not found, load default")); + break; + case FD_ADD: + case FD_READ: + Log.sinfoln(FPSTR(L_SETTINGS), F("Loaded")); - } else if (eeSettingsResult == 2) { - Log.serrorln("MAIN", F("Settings NOT loaded (error)")); + if (strcmp(SETTINGS_VALID_VALUE, settings.validationValue) != 0) { + Log.swarningln(FPSTR(L_SETTINGS), F("Not valid, set default and restart...")); + fsSettings.reset(); + delay(5000); + ESP.restart(); + } + break; + default: + break; } Log.setLevel(settings.debug ? TinyLogger::Level::VERBOSE : TinyLogger::Level::INFO); - - tWm = new WifiManagerTask(true, 0); - Scheduler.start(tWm); - tMqtt = new MqttTask(false, 100); + tNetwork = (new NetworkTask(true, 500)) + ->setHostname(networkSettings.hostname) + ->setStaCredentials( + #ifdef WOKWI + "Wokwi-GUEST", nullptr, 6 + #else + strlen(networkSettings.sta.ssid) ? networkSettings.sta.ssid : nullptr, + strlen(networkSettings.sta.password) ? networkSettings.sta.password : nullptr, + networkSettings.sta.channel + #endif + )->setApCredentials( + strlen(networkSettings.ap.ssid) ? networkSettings.ap.ssid : nullptr, + strlen(networkSettings.ap.password) ? networkSettings.ap.password : nullptr, + networkSettings.ap.channel + ); + Scheduler.start(tNetwork); + + tMqtt = new MqttTask(false, 500); Scheduler.start(tMqtt); tOt = new OpenThermTask(false, 1000); @@ -103,21 +152,17 @@ void setup() { tRegulator = new RegulatorTask(true, 10000); Scheduler.start(tRegulator); + tPortal = new PortalTask(true, 0); + Scheduler.start(tPortal); + tMain = new MainTask(true, 100); Scheduler.start(tMain); - tWm - ->addTaskForDisable(tMain) - ->addTaskForDisable(tMqtt) - ->addTaskForDisable(tOt) - ->addTaskForDisable(tSensors) - ->addTaskForDisable(tRegulator); - Scheduler.begin(); } void loop() { - #if defined(ARDUINO_ARCH_ESP32) - vTaskDelete(NULL); - #endif +#if defined(ARDUINO_ARCH_ESP32) + vTaskDelete(NULL); +#endif } diff --git a/src/strings.h b/src/strings.h new file mode 100644 index 0000000..254b3d9 --- /dev/null +++ b/src/strings.h @@ -0,0 +1,23 @@ +#pragma once +#ifndef PROGMEM + #define PROGMEM +#endif + +const char L_SETTINGS[] PROGMEM = "SETTINGS"; +const char L_NETWORK[] PROGMEM = "NETWORK"; +const char L_NETWORK_SETTINGS[] PROGMEM = "NETWORK.SETTINGS"; +const char L_PORTAL_WEBSERVER[] PROGMEM = "PORTAL.WEBSERVER"; +const char L_PORTAL_DNSSERVER[] PROGMEM = "PORTAL.DNSSERVER"; +const char L_PORTAL_CAPTIVE[] PROGMEM = "PORTAL.CAPTIVE"; +const char L_MAIN[] PROGMEM = "MAIN"; +const char L_MQTT[] PROGMEM = "MQTT"; +const char L_MQTT_MSG[] PROGMEM = "MQTT.MSG"; +const char L_OT[] PROGMEM = "OT"; +const char L_OT_DHW[] PROGMEM = "OT.DHW"; +const char L_OT_HEATING[] PROGMEM = "OT.HEATING"; +const char L_SENSORS_OUTDOOR[] PROGMEM = "SENSORS.OUTDOOR"; +const char L_SENSORS_INDOOR[] PROGMEM = "SENSORS.INDOOR"; +const char L_SENSORS_BLE[] PROGMEM = "SENSORS.BLE"; +const char L_REGULATOR[] PROGMEM = "REGULATOR"; +const char L_REGULATOR_PID[] PROGMEM = "REGULATOR.PID"; +const char L_REGULATOR_EQUITHERM[] PROGMEM = "REGULATOR.EQUITHERM"; \ No newline at end of file diff --git a/src/utils.h b/src/utils.h new file mode 100644 index 0000000..b0321f3 --- /dev/null +++ b/src/utils.h @@ -0,0 +1,939 @@ +#include + +double roundd(double value, uint8_t decimals = 2) { + if (decimals == 0) { + return (int)(value + 0.5); + + } else if (abs(value) < 0.00000001) { + return 0.0; + } + + double multiplier = pow10(decimals); + value += 0.5 / multiplier * (value < 0 ? -1 : 1); + return (int)(value * multiplier) / multiplier; +} + +size_t getFreeHeap() { + return ESP.getFreeHeap(); +} + +size_t getTotalHeap() { + #if defined(ARDUINO_ARCH_ESP32) + return ESP.getHeapSize(); + #elif defined(ARDUINO_ARCH_ESP8266) + return 81920; + #else + return 99999; + #endif +} + +size_t getMaxFreeBlockHeap() { + #if defined(ARDUINO_ARCH_ESP32) + return ESP.getMaxAllocHeap(); + #else + return ESP.getMaxFreeBlockSize(); + #endif +} + +String getResetReason() { + String value; + + #if defined(ARDUINO_ARCH_ESP8266) + value = ESP.getResetReason(); + #elif defined(ARDUINO_ARCH_ESP32) + switch(esp_reset_reason()) { + case ESP_RST_POWERON: + value = F("Reset due to power-on event"); + break; + + case ESP_RST_EXT: + value = F("Reset by external pin"); + break; + + case ESP_RST_SW: + value = F("Software reset via esp_restart"); + break; + + case ESP_RST_PANIC: + value = F("Software reset due to exception/panic"); + break; + + case ESP_RST_INT_WDT: + value = F("Reset (software or hardware) due to interrupt watchdog"); + break; + + case ESP_RST_TASK_WDT: + value = F("Reset due to task watchdog"); + break; + + case ESP_RST_WDT: + value = F("Reset due to other watchdogs"); + break; + + case ESP_RST_DEEPSLEEP: + value = F("Reset after exiting deep sleep mode"); + break; + + case ESP_RST_BROWNOUT: + value = F("Brownout reset (software or hardware)"); + break; + + case ESP_RST_SDIO: + value = F("Reset over SDIO"); + break; + + case ESP_RST_UNKNOWN: + default: + value = F("unknown"); + break; + + } + #else + value = F("unknown"); + #endif + + return value; +} + +void networkSettingsToJson(const NetworkSettings& src, JsonVariant dst) { + dst["hostname"] = src.hostname; + + dst["useDhcp"] = src.useDhcp; + dst["staticConfig"]["ip"] = src.staticConfig.ip; + dst["staticConfig"]["gateway"] = src.staticConfig.gateway; + dst["staticConfig"]["subnet"] = src.staticConfig.subnet; + dst["staticConfig"]["dns"] = src.staticConfig.dns; + + dst["ap"]["ssid"] = src.ap.ssid; + dst["ap"]["password"] = src.ap.password; + dst["ap"]["channel"] = src.ap.channel; + + dst["sta"]["ssid"] = src.sta.ssid; + dst["sta"]["password"] = src.sta.password; + dst["sta"]["channel"] = src.sta.channel; + + //dst.shrinkToFit(); +} + +bool jsonToNetworkSettings(const JsonVariantConst src, NetworkSettings& dst) { + bool changed = false; + + // hostname + if (!src["hostname"].isNull()) { + String value = src["hostname"].as(); + + if (value.length() < sizeof(dst.hostname)) { + strcpy(dst.hostname, value.c_str()); + changed = true; + } + } + + // use dhcp + if (src["useDhcp"].is()) { + dst.useDhcp = src["useDhcp"].as(); + changed = true; + } + + + // static config + if (!src["staticConfig"]["ip"].isNull()) { + String value = src["staticConfig"]["ip"].as(); + + if (value.length() < sizeof(dst.staticConfig.ip)) { + strcpy(dst.staticConfig.ip, value.c_str()); + changed = true; + } + } + + if (!src["staticConfig"]["gateway"].isNull()) { + String value = src["staticConfig"]["gateway"].as(); + + if (value.length() < sizeof(dst.staticConfig.gateway)) { + strcpy(dst.staticConfig.gateway, value.c_str()); + changed = true; + } + } + + if (!src["staticConfig"]["subnet"].isNull()) { + String value = src["staticConfig"]["subnet"].as(); + + if (value.length() < sizeof(dst.staticConfig.subnet)) { + strcpy(dst.staticConfig.subnet, value.c_str()); + changed = true; + } + } + + if (!src["staticConfig"]["dns"].isNull()) { + String value = src["staticConfig"]["dns"].as(); + + if (value.length() < sizeof(dst.staticConfig.dns)) { + strcpy(dst.staticConfig.dns, value.c_str()); + changed = true; + } + } + + + // ap + if (!src["ap"]["ssid"].isNull()) { + String value = src["ap"]["ssid"].as(); + + if (value.length() < sizeof(dst.ap.ssid)) { + strcpy(dst.ap.ssid, value.c_str()); + changed = true; + } + } + + if (!src["ap"]["password"].isNull()) { + String value = src["ap"]["password"].as(); + + if (value.length() < sizeof(dst.ap.password)) { + strcpy(dst.ap.password, value.c_str()); + changed = true; + } + } + + if (!src["ap"]["channel"].isNull()) { + unsigned char value = src["ap"]["channel"].as(); + + if (value >= 0 && value < 12) { + dst.ap.channel = value; + changed = true; + } + } + + + // ap + if (!src["sta"]["ssid"].isNull()) { + String value = src["sta"]["ssid"].as(); + + if (value.length() < sizeof(dst.sta.ssid)) { + strcpy(dst.sta.ssid, value.c_str()); + changed = true; + } + } + + if (!src["sta"]["password"].isNull()) { + String value = src["sta"]["password"].as(); + + if (value.length() < sizeof(dst.sta.password)) { + strcpy(dst.sta.password, value.c_str()); + changed = true; + } + } + + if (!src["sta"]["channel"].isNull()) { + unsigned char value = src["sta"]["channel"].as(); + + if (value >= 0 && value < 12) { + dst.sta.channel = value; + changed = true; + } + } + + return changed; +} + +void settingsToJson(const Settings& src, JsonVariant dst, bool safe = false) { + dst["debug"] = src.debug; + + if (!safe) { + dst["portal"]["useAuth"] = src.portal.useAuth; + dst["portal"]["login"] = src.portal.login; + dst["portal"]["password"] = src.portal.password; + + dst["opentherm"]["inPin"] = src.opentherm.inPin; + dst["opentherm"]["outPin"] = src.opentherm.outPin; + dst["opentherm"]["memberIdCode"] = src.opentherm.memberIdCode; + dst["opentherm"]["dhwPresent"] = src.opentherm.dhwPresent; + dst["opentherm"]["summerWinterMode"] = src.opentherm.summerWinterMode; + dst["opentherm"]["heatingCh2Enabled"] = src.opentherm.heatingCh2Enabled; + dst["opentherm"]["heatingCh1ToCh2"] = src.opentherm.heatingCh1ToCh2; + dst["opentherm"]["dhwToCh2"] = src.opentherm.dhwToCh2; + dst["opentherm"]["dhwBlocking"] = src.opentherm.dhwBlocking; + dst["opentherm"]["modulationSyncWithHeating"] = src.opentherm.modulationSyncWithHeating; + + dst["mqtt"]["server"] = src.mqtt.server; + dst["mqtt"]["port"] = src.mqtt.port; + dst["mqtt"]["user"] = src.mqtt.user; + dst["mqtt"]["password"] = src.mqtt.password; + dst["mqtt"]["prefix"] = src.mqtt.prefix; + dst["mqtt"]["interval"] = src.mqtt.interval; + } + + dst["emergency"]["enable"] = src.emergency.enable; + dst["emergency"]["target"] = roundd(src.emergency.target, 2); + dst["emergency"]["useEquitherm"] = src.emergency.useEquitherm; + dst["emergency"]["usePid"] = src.emergency.usePid; + + dst["heating"]["enable"] = src.heating.enable; + dst["heating"]["turbo"] = src.heating.turbo; + dst["heating"]["target"] = roundd(src.heating.target, 2); + dst["heating"]["hysteresis"] = roundd(src.heating.hysteresis, 2); + dst["heating"]["minTemp"] = src.heating.minTemp; + dst["heating"]["maxTemp"] = src.heating.maxTemp; + dst["heating"]["maxModulation"] = src.heating.maxModulation; + + dst["dhw"]["enable"] = src.dhw.enable; + dst["dhw"]["target"] = src.dhw.target; + dst["dhw"]["minTemp"] = src.dhw.minTemp; + dst["dhw"]["maxTemp"] = src.dhw.maxTemp; + + dst["pid"]["enable"] = src.pid.enable; + dst["pid"]["p_factor"] = roundd(src.pid.p_factor, 3); + dst["pid"]["i_factor"] = roundd(src.pid.i_factor, 3); + dst["pid"]["d_factor"] = roundd(src.pid.d_factor, 1); + dst["pid"]["dt"] = src.pid.dt; + dst["pid"]["minTemp"] = src.pid.minTemp; + dst["pid"]["maxTemp"] = src.pid.maxTemp; + + dst["equitherm"]["enable"] = src.equitherm.enable; + dst["equitherm"]["n_factor"] = roundd(src.equitherm.n_factor, 3); + dst["equitherm"]["k_factor"] = roundd(src.equitherm.k_factor, 3); + dst["equitherm"]["t_factor"] = roundd(src.equitherm.t_factor, 3); + + dst["sensors"]["outdoor"]["type"] = src.sensors.outdoor.type; + dst["sensors"]["outdoor"]["pin"] = src.sensors.outdoor.pin; + dst["sensors"]["outdoor"]["offset"] = roundd(src.sensors.outdoor.offset, 2); + + dst["sensors"]["indoor"]["type"] = src.sensors.indoor.type; + dst["sensors"]["indoor"]["pin"] = src.sensors.indoor.pin; + dst["sensors"]["indoor"]["bleAddresss"] = src.sensors.indoor.bleAddresss; + dst["sensors"]["indoor"]["offset"] = roundd(src.sensors.indoor.offset, 2); + + if (!safe) { + dst["externalPump"]["use"] = src.externalPump.use; + dst["externalPump"]["pin"] = src.externalPump.pin; + dst["externalPump"]["postCirculationTime"] = roundd(src.externalPump.postCirculationTime / 60, 0); + dst["externalPump"]["antiStuckInterval"] = roundd(src.externalPump.antiStuckInterval / 86400, 0); + dst["externalPump"]["antiStuckTime"] = roundd(src.externalPump.antiStuckTime / 60, 0); + } + + //dst.shrinkToFit(); +} + +void safeSettingsToJson(const Settings& src, JsonVariant dst) { + settingsToJson(src, dst, true); +} + +bool jsonToSettings(const JsonVariantConst src, Settings& dst, bool safe = false) { + bool changed = false; + + if (src["debug"].is()) { + dst.debug = src["debug"].as(); + changed = true; + } + + + if (!safe) { + // portal + if (src["portal"]["useAuth"].is()) { + dst.portal.useAuth = src["portal"]["useAuth"].as(); + changed = true; + } + + if (!src["portal"]["login"].isNull()) { + String value = src["portal"]["login"].as(); + + if (value.length() < sizeof(dst.portal.login)) { + strcpy(dst.portal.login, value.c_str()); + changed = true; + } + } + + if (!src["portal"]["password"].isNull()) { + String value = src["portal"]["password"].as(); + + if (value.length() < sizeof(dst.portal.password)) { + strcpy(dst.portal.password, value.c_str()); + changed = true; + } + } + + + // opentherm + if (!src["opentherm"]["inPin"].isNull()) { + unsigned char value = src["opentherm"]["inPin"].as(); + + if (value >= 0 && value < 50) { + dst.opentherm.inPin = value; + changed = true; + } + } + + if (!src["opentherm"]["outPin"].isNull()) { + unsigned char value = src["opentherm"]["outPin"].as(); + + if (value >= 0 && value < 50) { + dst.opentherm.outPin = value; + changed = true; + } + } + + if (!src["opentherm"]["memberIdCode"].isNull()) { + unsigned int value = src["opentherm"]["memberIdCode"].as(); + + if (value >= 0 && value < 65536) { + dst.opentherm.memberIdCode = value; + changed = true; + } + } + + if (src["opentherm"]["dhwPresent"].is()) { + dst.opentherm.dhwPresent = src["opentherm"]["dhwPresent"].as(); + changed = true; + } + + if (src["opentherm"]["summerWinterMode"].is()) { + dst.opentherm.summerWinterMode = src["opentherm"]["summerWinterMode"].as(); + changed = true; + } + + if (src["opentherm"]["heatingCh2Enabled"].is()) { + dst.opentherm.heatingCh2Enabled = src["opentherm"]["heatingCh2Enabled"].as(); + + if (dst.opentherm.heatingCh2Enabled) { + dst.opentherm.heatingCh1ToCh2 = false; + dst.opentherm.dhwToCh2 = false; + } + + changed = true; + } + + if (src["opentherm"]["heatingCh1ToCh2"].is()) { + dst.opentherm.heatingCh1ToCh2 = src["opentherm"]["heatingCh1ToCh2"].as(); + + if (dst.opentherm.heatingCh1ToCh2) { + dst.opentherm.heatingCh2Enabled = false; + dst.opentherm.dhwToCh2 = false; + } + + changed = true; + } + + if (src["opentherm"]["dhwToCh2"].is()) { + dst.opentherm.dhwToCh2 = src["opentherm"]["dhwToCh2"].as(); + + if (dst.opentherm.dhwToCh2) { + dst.opentherm.heatingCh2Enabled = false; + dst.opentherm.heatingCh1ToCh2 = false; + } + + changed = true; + } + + if (src["opentherm"]["dhwBlocking"].is()) { + dst.opentherm.dhwBlocking = src["opentherm"]["dhwBlocking"].as(); + changed = true; + } + + if (src["opentherm"]["modulationSyncWithHeating"].is()) { + dst.opentherm.modulationSyncWithHeating = src["opentherm"]["modulationSyncWithHeating"].as(); + changed = true; + } + + + // mqtt + if (!src["mqtt"]["server"].isNull()) { + String value = src["mqtt"]["server"].as(); + + if (value.length() < sizeof(dst.mqtt.server)) { + strcpy(dst.mqtt.server, value.c_str()); + changed = true; + } + } + + if (!src["mqtt"]["port"].isNull()) { + unsigned short value = src["mqtt"]["port"].as(); + + if (value >= 0 && value <= 65536) { + dst.mqtt.port = value; + changed = true; + } + } + + if (!src["mqtt"]["user"].isNull()) { + String value = src["mqtt"]["user"].as(); + + if (value.length() < sizeof(dst.mqtt.user)) { + strcpy(dst.mqtt.user, value.c_str()); + changed = true; + } + } + + if (!src["mqtt"]["password"].isNull()) { + String value = src["mqtt"]["password"].as(); + + if (value.length() < sizeof(dst.mqtt.password)) { + strcpy(dst.mqtt.password, value.c_str()); + changed = true; + } + } + + if (!src["mqtt"]["prefix"].isNull()) { + String value = src["mqtt"]["prefix"].as(); + + if (value.length() < sizeof(dst.mqtt.prefix)) { + strcpy(dst.mqtt.prefix, value.c_str()); + changed = true; + } + } + + if (!src["mqtt"]["interval"].isNull()) { + unsigned short value = src["mqtt"]["interval"].as(); + + if (value >= 3 && value <= 60) { + dst.mqtt.interval = value; + changed = true; + } + } + } + + + // emergency + if (src["emergency"]["enable"].is()) { + dst.emergency.enable = src["emergency"]["enable"].as(); + changed = true; + } + + if (!src["emergency"]["target"].isNull()) { + double value = src["emergency"]["target"].as(); + + if (value > 0 && value < 100) { + dst.emergency.target = roundd(value, 2); + changed = true; + } + } + + if (src["emergency"]["useEquitherm"].is()) { + if (dst.sensors.outdoor.type != 1) { + dst.emergency.useEquitherm = src["emergency"]["useEquitherm"].as(); + + } else { + dst.emergency.useEquitherm = false; + } + + if (dst.emergency.useEquitherm && dst.emergency.usePid) { + dst.emergency.usePid = false; + } + + changed = true; + } + + if (src["emergency"]["usePid"].is()) { + if (dst.sensors.indoor.type != 1) { + dst.emergency.usePid = src["emergency"]["usePid"].as(); + + } else { + dst.emergency.usePid = false; + } + + if (dst.emergency.usePid && dst.emergency.useEquitherm) { + dst.emergency.useEquitherm = false; + } + + changed = true; + } + + + // heating + if (src["heating"]["enable"].is()) { + dst.heating.enable = src["heating"]["enable"].as(); + changed = true; + } + + if (src["heating"]["turbo"].is()) { + dst.heating.turbo = src["heating"]["turbo"].as(); + changed = true; + } + + if (!src["heating"]["target"].isNull()) { + double value = src["heating"]["target"].as(); + + if (value > 0 && value < 100) { + dst.heating.target = roundd(value, 2); + changed = true; + } + } + + if (!src["heating"]["hysteresis"].isNull()) { + double value = src["heating"]["hysteresis"].as(); + + if (value >= 0 && value <= 5) { + dst.heating.hysteresis = roundd(value, 2); + changed = true; + } + } + + if (!src["heating"]["minTemp"].isNull()) { + unsigned char value = src["heating"]["minTemp"].as(); + + if (value >= vars.parameters.heatingMinTemp && value <= vars.parameters.heatingMaxTemp) { + dst.heating.minTemp = value; + changed = true; + } + } + + if (!src["heating"]["maxTemp"].isNull()) { + unsigned char value = src["heating"]["maxTemp"].as(); + + if (value >= vars.parameters.heatingMinTemp && value <= vars.parameters.heatingMaxTemp) { + dst.heating.maxTemp = value; + changed = true; + } + } + + if (!src["heating"]["maxModulation"].isNull()) { + unsigned char value = src["heating"]["maxModulation"].as(); + + if (value > 0 && value <= 100) { + dst.heating.maxModulation = value; + changed = true; + } + } + + + // dhw + if (src["dhw"]["enable"].is()) { + dst.dhw.enable = src["dhw"]["enable"].as(); + changed = true; + } + + if (!src["dhw"]["target"].isNull()) { + unsigned char value = src["dhw"]["target"].as(); + + if (value >= 0 && value < 100) { + dst.dhw.target = value; + changed = true; + } + } + + if (!src["dhw"]["minTemp"].isNull()) { + unsigned char value = src["dhw"]["minTemp"].as(); + + if (value >= vars.parameters.dhwMinTemp && value <= vars.parameters.dhwMaxTemp) { + dst.dhw.minTemp = value; + changed = true; + } + } + + if (!src["dhw"]["maxTemp"].isNull()) { + unsigned char value = src["dhw"]["maxTemp"].as(); + + if (value >= vars.parameters.dhwMinTemp && value <= vars.parameters.dhwMaxTemp) { + dst.dhw.maxTemp = value; + changed = true; + } + } + + + // pid + if (src["pid"]["enable"].is()) { + dst.pid.enable = src["pid"]["enable"].as(); + changed = true; + } + + if (!src["pid"]["p_factor"].isNull()) { + double value = src["pid"]["p_factor"].as(); + + if (value > 0 && value <= 1000) { + dst.pid.p_factor = roundd(value, 3); + changed = true; + } + } + + if (!src["pid"]["i_factor"].isNull()) { + double value = src["pid"]["i_factor"].as(); + + if (value >= 0 && value <= 100) { + dst.pid.i_factor = roundd(value, 3); + changed = true; + } + } + + if (!src["pid"]["d_factor"].isNull()) { + double value = src["pid"]["d_factor"].as(); + + if (value >= 0 && value <= 100000) { + dst.pid.d_factor = roundd(value, 1); + changed = true; + } + } + + if (!src["pid"]["dt"].isNull()) { + unsigned short value = src["pid"]["dt"].as(); + + if (value >= 30 && value <= 600) { + dst.pid.dt = value; + changed = true; + } + } + + if (!src["pid"]["maxTemp"].isNull()) { + unsigned char value = src["pid"]["maxTemp"].as(); + + if (value > 0 && value <= 100 && value > dst.pid.minTemp) { + dst.pid.maxTemp = value; + changed = true; + } + } + + if (!src["pid"]["minTemp"].isNull()) { + unsigned char value = src["pid"]["minTemp"].as(); + + if (value >= 0 && value < 100 && value < dst.pid.maxTemp) { + dst.pid.minTemp = value; + changed = true; + } + } + + + // equitherm + if (src["equitherm"]["enable"].is()) { + dst.equitherm.enable = src["equitherm"]["enable"].as(); + changed = true; + } + + if (!src["equitherm"]["n_factor"].isNull()) { + double value = src["equitherm"]["n_factor"].as(); + + if (value > 0 && value <= 10) { + dst.equitherm.n_factor = roundd(value, 3); + changed = true; + } + } + + if (!src["equitherm"]["k_factor"].isNull()) { + double value = src["equitherm"]["k_factor"].as(); + + if (value >= 0 && value <= 10) { + dst.equitherm.k_factor = roundd(value, 3); + changed = true; + } + } + + if (!src["equitherm"]["t_factor"].isNull()) { + double value = src["equitherm"]["t_factor"].as(); + + if (value >= 0 && value <= 10) { + dst.equitherm.t_factor = roundd(value, 3); + changed = true; + } + } + + + // sensors + if (!src["sensors"]["outdoor"]["type"].isNull()) { + unsigned char value = src["sensors"]["outdoor"]["type"].as(); + + if (value >= 0 && value <= 2) { + dst.sensors.outdoor.type = value; + + if (dst.sensors.outdoor.type == 1) { + dst.emergency.useEquitherm = false; + } + + changed = true; + } + } + + if (!src["sensors"]["outdoor"]["pin"].isNull()) { + unsigned char value = src["sensors"]["outdoor"]["pin"].as(); + + if (value >= 0 && value <= 50) { + dst.sensors.outdoor.pin = value; + changed = true; + } + } + + if (!src["sensors"]["outdoor"]["offset"].isNull()) { + double value = src["sensors"]["outdoor"]["offset"].as(); + + if (value >= -10 && value <= 10) { + dst.sensors.outdoor.offset = roundd(value, 2); + changed = true; + } + } + + if (!src["sensors"]["indoor"]["type"].isNull()) { + unsigned char value = src["sensors"]["indoor"]["type"].as(); + + if (value >= 1 && value <= 3) { + dst.sensors.indoor.type = value; + + if (dst.sensors.indoor.type == 1) { + dst.emergency.usePid = false; + } + + changed = true; + } + } + + if (!src["sensors"]["indoor"]["pin"].isNull()) { + unsigned char value = src["sensors"]["indoor"]["pin"].as(); + + if (value >= 0 && value <= 50) { + dst.sensors.indoor.pin = value; + changed = true; + } + } + + #if USE_BLE + if (!src["sensors"]["indoor"]["bleAddresss"].isNull()) { + String value = src["sensors"]["indoor"]["bleAddresss"].as(); + + if (value.length() < sizeof(dst.sensors.indoor.bleAddresss)) { + strcpy(dst.sensors.indoor.bleAddresss, value.c_str()); + changed = true; + } + } + #endif + + if (!src["sensors"]["indoor"]["offset"].isNull()) { + double value = src["sensors"]["indoor"]["offset"].as(); + + if (value >= -10 && value <= 10) { + dst.sensors.indoor.offset = roundd(value, 2); + changed = true; + } + } + + + if (!safe) { + // external pump + if (src["externalPump"]["use"].is()) { + dst.externalPump.use = src["externalPump"]["use"].as(); + changed = true; + } + + if (!src["externalPump"]["pin"].isNull()) { + unsigned char value = src["externalPump"]["pin"].as(); + + if (value >= 0 && value <= 50) { + dst.externalPump.pin = value; + changed = true; + } + } + + if (!src["externalPump"]["postCirculationTime"].isNull()) { + unsigned short value = src["externalPump"]["postCirculationTime"].as(); + + if (value >= 0 && value <= 120) { + dst.externalPump.postCirculationTime = value * 60; + changed = true; + } + } + + if (!src["externalPump"]["antiStuckInterval"].isNull()) { + unsigned int value = src["externalPump"]["antiStuckInterval"].as(); + + if (value >= 0 && value <= 366) { + dst.externalPump.antiStuckInterval = value * 86400; + changed = true; + } + } + + if (!src["externalPump"]["antiStuckTime"].isNull()) { + unsigned short value = src["externalPump"]["antiStuckTime"].as(); + + if (value >= 0 && value <= 20) { + dst.externalPump.antiStuckTime = value * 60; + changed = true; + } + } + } + + return changed; +} + +bool safeJsonToSettings(const JsonVariantConst src, Settings& dst) { + return jsonToSettings(src, dst, true); +} + +void varsToJson(const Variables& src, JsonVariant dst) { + dst["tuning"]["enable"] = src.tuning.enable; + dst["tuning"]["regulator"] = src.tuning.regulator; + + dst["states"]["otStatus"] = src.states.otStatus; + dst["states"]["emergency"] = src.states.emergency; + dst["states"]["heating"] = src.states.heating; + dst["states"]["dhw"] = src.states.dhw; + dst["states"]["flame"] = src.states.flame; + dst["states"]["fault"] = src.states.fault; + dst["states"]["diagnostic"] = src.states.diagnostic; + dst["states"]["externalPump"] = src.states.externalPump; + + dst["sensors"]["modulation"] = roundd(src.sensors.modulation, 2); + dst["sensors"]["pressure"] = roundd(src.sensors.pressure, 2); + dst["sensors"]["dhwFlowRate"] = src.sensors.dhwFlowRate; + dst["sensors"]["faultCode"] = src.sensors.faultCode; + dst["sensors"]["rssi"] = src.sensors.rssi; + dst["sensors"]["uptime"] = millis() / 1000ul; + + dst["temperatures"]["indoor"] = roundd(src.temperatures.indoor, 2); + dst["temperatures"]["outdoor"] = roundd(src.temperatures.outdoor, 2); + dst["temperatures"]["heating"] = roundd(src.temperatures.heating, 2); + dst["temperatures"]["dhw"] = roundd(src.temperatures.dhw, 2); + + dst["parameters"]["heatingEnabled"] = src.parameters.heatingEnabled; + dst["parameters"]["heatingMinTemp"] = src.parameters.heatingMinTemp; + dst["parameters"]["heatingMaxTemp"] = src.parameters.heatingMaxTemp; + dst["parameters"]["heatingSetpoint"] = src.parameters.heatingSetpoint; + dst["parameters"]["dhwMinTemp"] = src.parameters.dhwMinTemp; + dst["parameters"]["dhwMaxTemp"] = src.parameters.dhwMaxTemp; + + //dst.shrinkToFit(); +} + +bool jsonToVars(const JsonVariantConst src, Variables& dst) { + bool changed = false; + + // tuning + if (src["tuning"]["enable"].is()) { + dst.tuning.enable = src["tuning"]["enable"].as(); + changed = true; + } + + if (!src["tuning"]["regulator"].isNull()) { + unsigned char value = src["tuning"]["regulator"].as(); + + if (value >= 0 && value <= 1) { + dst.tuning.regulator = value; + changed = true; + } + } + + + // temperatures + if (!src["temperatures"]["indoor"].isNull()) { + double value = src["temperatures"]["indoor"].as(); + + if (settings.sensors.indoor.type == 1 && value > -100 && value < 100) { + dst.temperatures.indoor = roundd(value, 2); + changed = true; + } + } + + if (!src["temperatures"]["outdoor"].isNull()) { + double value = src["temperatures"]["outdoor"].as(); + + if (settings.sensors.outdoor.type == 1 && value > -100 && value < 100) { + dst.temperatures.outdoor = roundd(value, 2); + changed = true; + } + } + + // actions + if (src["actions"]["restart"].is() && src["actions"]["restart"].as()) { + dst.actions.restart = true; + } + + if (src["actions"]["resetFault"].is() && src["actions"]["resetFault"].as()) { + dst.actions.resetFault = true; + } + + if (src["actions"]["resetDiagnostic"].is() && src["actions"]["resetDiagnostic"].as()) { + dst.actions.resetDiagnostic = true; + } + + return changed; +} \ No newline at end of file