diff --git a/src/OpenThermTask.h b/src/OpenThermTask.h index 02f2d7c..f5d2624 100644 --- a/src/OpenThermTask.h +++ b/src/OpenThermTask.h @@ -9,6 +9,30 @@ public: delete this->instance; } + struct ReadResult{ + bool valid = false; + bool parityValid = false; + bool responseMessageIdValid = false; + const char* responseType = ""; + uint16_t value = 0; + }; + + ReadResult readRequest(byte messageId) { + ReadResult result; + OpenThermMessageID eMessageId = (OpenThermMessageID)messageId; + auto response = this->instance->sendRequest(CustomOpenTherm::buildRequest( + OpenThermRequestType::READ_DATA, + eMessageId, + 0 + )); + result.valid = CustomOpenTherm::isValidResponse(response); + result.parityValid = !CustomOpenTherm::parity(response); + result.responseMessageIdValid = CustomOpenTherm::isValidResponseId(response, eMessageId); + result.responseType = CustomOpenTherm::messageTypeToString(CustomOpenTherm::getMessageType(response)); + result.value = CustomOpenTherm::getUInt(response); + return result; + } + protected: const unsigned short readyTime = 60000u; const unsigned int resetBusInterval = 120000u; diff --git a/src/PortalTask.h b/src/PortalTask.h index 0968de8..b431319 100644 --- a/src/PortalTask.h +++ b/src/PortalTask.h @@ -21,6 +21,7 @@ using namespace NetworkUtils; extern NetworkMgr* network; extern FileData fsNetworkSettings, fsSettings, fsSensorsSettings; extern MqttTask* tMqtt; +extern OpenThermTask* tOt; class PortalTask : public LeanTask { @@ -179,6 +180,18 @@ protected: }); this->webServer->addHandler(upgradePage); + // Opentherm request page + auto openthermRequestPage = (new StaticPage("/opentherm_request.html", &LittleFS, F("/pages/opentherm_request.html"), PORTAL_CACHE)) + ->setBeforeSendCallback([this]() { + if (this->isAuthRequired() && !this->isValidCredentials()) { + this->webServer->requestAuthentication(BASIC_AUTH); + return false; + } + + return true; + }); + this->webServer->addHandler(openthermRequestPage); + // OTA auto upgradeHandler = (new UpgradeHandler("/api/upgrade"))->setCanUploadCallback([this](const String& uri) { if (this->isAuthRequired() && !this->isValidCredentials()) { @@ -843,6 +856,41 @@ protected: this->bufferedWebServer->send(200, F("application/json"), doc, true); }); + this->webServer->on(F("/api/opentherm_request/read"), HTTP_POST, [this]() { + if (this->isAuthRequired() && !this->isValidCredentials()) { + return this->webServer->send(401); + } + + const String& plain = this->webServer->arg(0); + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Request /api/opentherm_request/read %d bytes: %s"), plain.length(), plain.c_str()); + + JsonDocument doc; + DeserializationError dErr = deserializeJson(doc, plain); + + if (dErr != DeserializationError::Ok || doc.isNull() || !doc.size()) { + this->webServer->send(400); + return; + } + + if (doc[FPSTR(S_MESSAGE_ID)].isNull() || !doc[FPSTR(S_MESSAGE_ID)].is()) { + this->webServer->send(400); + return; + } + auto messageId = doc[FPSTR(S_MESSAGE_ID)].as(); + doc.clear(); + doc.shrinkToFit(); + + auto result = tOt->readRequest(messageId); + + doc[FPSTR(S_VALID)] = result.valid; + doc[FPSTR(S_PARITY_VALID)] = result.parityValid; + doc[FPSTR(S_RESPONSE_MESSAGE_ID_VALID)] = result.responseMessageIdValid; + doc[FPSTR(S_RESPONSE_TYPE)] = result.responseType; + doc[FPSTR(S_VALUE)] = result.value; + doc.shrinkToFit(); + + this->bufferedWebServer->send(200, F("application/json"), doc); + }); // not found this->webServer->onNotFound([this]() { diff --git a/src/strings.h b/src/strings.h index 32ad73a..d89ca52 100644 --- a/src/strings.h +++ b/src/strings.h @@ -124,6 +124,7 @@ const char S_MAX_TEMP[] PROGMEM = "maxTemp"; const char S_MAX_TEMP_SYNC_WITH_TARGET_TEMP[] PROGMEM = "maxTempSyncWithTargetTemp"; const char S_MDNS[] PROGMEM = "mdns"; const char S_MEMBER_ID[] PROGMEM = "memberId"; +const char S_MESSAGE_ID[] PROGMEM = "messageId"; const char S_MIN[] PROGMEM = "min"; const char S_MIN_FREE[] PROGMEM = "minFree"; const char S_MIN_MAX_FREE_BLOCK[] PROGMEM = "minMaxFreeBlock"; @@ -148,6 +149,7 @@ const char S_OUTDOOR_TEMP[] PROGMEM = "outdoorTemp"; const char S_OUT_GPIO[] PROGMEM = "outGpio"; const char S_OUTPUT[] PROGMEM = "output"; const char S_PASSWORD[] PROGMEM = "password"; +const char S_PARITY_VALID[] PROGMEM = "parityValid"; const char S_PID[] PROGMEM = "pid"; const char S_PORT[] PROGMEM = "port"; const char S_PORTAL[] PROGMEM = "portal"; @@ -164,6 +166,8 @@ const char S_RESET_DIAGNOSTIC[] PROGMEM = "resetDiagnostic"; const char S_RESET_FAULT[] PROGMEM = "resetFault"; const char S_RESET_REASON[] PROGMEM = "resetReason"; const char S_RESTART[] PROGMEM = "restart"; +const char S_RESPONSE_MESSAGE_ID_VALID[] PROGMEM = "responseMessageIdValid"; +const char S_RESPONSE_TYPE[] PROGMEM = "responseType"; const char S_RETURN_TEMP[] PROGMEM = "returnTemp"; const char S_REV[] PROGMEM = "rev"; const char S_RSSI[] PROGMEM = "rssi"; @@ -204,5 +208,6 @@ const char S_UPTIME[] PROGMEM = "uptime"; const char S_USE[] PROGMEM = "use"; const char S_USE_DHCP[] PROGMEM = "useDhcp"; const char S_USER[] PROGMEM = "user"; +const char S_VALID[] PROGMEM = "valid"; const char S_VALUE[] PROGMEM = "value"; const char S_VERSION[] PROGMEM = "version"; diff --git a/src_data/locales/en.json b/src_data/locales/en.json index 8dcbf79..5ce3e05 100644 --- a/src_data/locales/en.json +++ b/src_data/locales/en.json @@ -29,7 +29,8 @@ "wait": "Please wait...", "uploading": "Uploading...", "success": "Success", - "error": "Error" + "error": "Error", + "send": "Send" }, "index": { @@ -468,6 +469,31 @@ "settingsFile": "Settings file", "fw": "Firmware", "fs": "Filesystem" + }, + + "opentherm_request": { + "title": "Custom requests - OpenTherm Gateway", + "name": "Custom requests", + "section": { + "read": "Read", + "read.desc": "Send a read request with a custom message ID" + }, + "messageId": "Message ID", + "result": { + "valid": "Valid", + "parityValid": "Parity valid", + "responseMessageIdValid": "Response message ID valid", + "responseType": "Response type", + "data": "Data value", + "flags": "As flags", + "hex": "As hex", + "fixedPoint": "As f8.8", + "u8": "As two u8", + "s8": "As two s8", + "u16": "As u16", + "s16": "As s16" + } + } } } \ No newline at end of file diff --git a/src_data/locales/it.json b/src_data/locales/it.json index a0b8e05..637dec1 100644 --- a/src_data/locales/it.json +++ b/src_data/locales/it.json @@ -29,7 +29,8 @@ "wait": "Attendi...", "uploading": "caricamento...", "success": "Riuscito", - "error": "Errore" + "error": "Errore", + "send": "Invia" }, "index": { @@ -468,6 +469,30 @@ "settingsFile": "Settings file", "fw": "Firmware", "fs": "Filesystem" + }, + + "opentherm_request": { + "title": "Richiesta personalizzata - OpenTherm Gateway", + "name": "Richiesta personalizzata", + "section": { + "read": "Lettura", + "read.desc": "Invia una lettura con un ID messaggio personalizzato" + }, + "messageId": "ID messaggio", + "result": { + "valid": "Valido", + "parityValid": "Parità valida", + "responseMessageIdValid": "ID messaggio di risposta valido", + "responseType": "Tipo risposta", + "data": "Valore", + "flags": "Formato flags", + "hex": "Formato esadecimale", + "fixedPoint": "Formato f8.8", + "u8": "Formato due u8", + "s8": "Formato due s8", + "u16": "Formato u16", + "s16": "Formato s16" + } } } } \ No newline at end of file diff --git a/src_data/locales/ru.json b/src_data/locales/ru.json index fa1daa8..c2c29c3 100644 --- a/src_data/locales/ru.json +++ b/src_data/locales/ru.json @@ -29,7 +29,8 @@ "wait": "Пожалуйста, подождите...", "uploading": "Загрузка...", "success": "Успешно", - "error": "Ошибка" + "error": "Ошибка", + "send": "Отправить" }, "index": { @@ -468,6 +469,31 @@ "settingsFile": "Файл настроек", "fw": "Прошивка", "fs": "Файловая система" + }, + + "opentherm_request": { + "title": "Специальные запросы - OpenTherm Gateway", + "name": "Специальные запросы", + "section": { + "read": "Чтение", + "read.desc": "В этом разделе вы можете отправить запрос на чтение с произвольным message ID." + }, + "messageId": "Message ID", + "result": { + "valid": "Ответ верен", + "parityValid": "Чётность верна", + "responseMessageIdValid": "Message ID ответа верно", + "responseType": "Тип ответа", + "data": "Данные", + "flags": "В виде флагов", + "hex": "В шестнадцатеричном виде", + "fixedPoint": "В виде f8.8", + "u8": "В виде двух u8", + "s8": "В виде двух s8", + "u16": "В виде u16", + "s16": "В виде s16" + } + } } } \ No newline at end of file diff --git a/src_data/pages/index.html b/src_data/pages/index.html index 269c2dd..bc1571b 100644 --- a/src_data/pages/index.html +++ b/src_data/pages/index.html @@ -146,6 +146,7 @@ settings.name sensors.name upgrade.name + opentherm_request.name button.restart diff --git a/src_data/pages/opentherm_request.html b/src_data/pages/opentherm_request.html new file mode 100644 index 0000000..5d1f2db --- /dev/null +++ b/src_data/pages/opentherm_request.html @@ -0,0 +1,132 @@ + + + + + + opentherm_request.title + + + + +
+ +
+ +
+
+
+
+

opentherm_request.section.read

+

opentherm_request.section.read.desc

+
+ +
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
opentherm_request.result.valid
opentherm_request.result.parityValid
opentherm_request.result.responseMessageIdValid
opentherm_request.result.responseType
opentherm_request.result.data:
opentherm_request.result.flags
opentherm_request.result.hex
opentherm_request.result.fixedPoint
opentherm_request.result.u8
opentherm_request.result.s8
opentherm_request.result.u16
opentherm_request.result.s16
+
+
+
+ +
+ + + + + + + \ No newline at end of file diff --git a/src_data/scripts/utils.js b/src_data/scripts/utils.js index 25e7571..9df82b2 100644 --- a/src_data/scripts/utils.js +++ b/src_data/scripts/utils.js @@ -523,6 +523,107 @@ const setupUpgradeForm = (formSelector) => { }); } +const setupOTReadForm = (formSelector) => { + const form = document.querySelector(formSelector); + if (!form) { + return; + } + + const url = form.action; + let button = form.querySelector('button[type="submit"]'); + let defaultText; + + hide("#read-result"); + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + + if (button) { + defaultText = button.textContent; + button.textContent = i18n('button.wait'); + button.setAttribute('disabled', true); + button.setAttribute('aria-busy', true); + } + hide("#read-result"); + + const onSuccess = (result) => { + if (button) { + button.textContent = i18n('button.success'); + button.classList.add('success'); + button.removeAttribute('aria-busy'); + + setTimeout(() => { + button.removeAttribute('disabled'); + button.classList.remove('success', 'failed'); + button.textContent = defaultText; + }, 2000); + } + setState(".mValid", result.valid); + setState(".mParityValid", result.parityValid); + setState(".m", ); + setState(".mResponseMessageIdValid", result.responseMessageIdValid); + setValue(".mResponseType", result.responseType); + + const u16 = result.value; + const [flagsHigh, flagsLow] = u16ToFlags(u16); + const hex = u16ToHex(u16); + const fixedPoint = u16ToFixedPoint(u16); + const [u8High, u8Low] = u16ToU8s(u16); + const [s8High, s8Low] = u16ToS8s(u16); + const s16 = u16ToS16(u16); + + setValue(".mDataFlagsHigh", flagsHigh); + setValue(".mDataFlagsLow", flagsLow); + setValue(".mDataHex", hex); + setValue(".mDataFixedPoint", fixedPoint); + setValue(".mDataU8High", u8High); + setValue(".mDataU8Low", u8Low); + setValue(".mDataS8High", s8High); + setValue(".mDataS8Low", s8Low); + setValue(".mDataU16", u16); + setValue(".mDataS16", s16); + + show("#read-result"); + }; + + const onFailed = () => { + if (button) { + button.textContent = i18n('button.error'); + button.classList.add('failed'); + button.removeAttribute('aria-busy'); + + setTimeout(() => { + button.removeAttribute('disabled'); + button.classList.remove('success', 'failed'); + button.textContent = defaultText; + }, 5000); + } + }; + + const messageId = form.querySelector('#message-id').value; + + try { + let fd = new FormData(form); + let response = await fetch(url, { + method: "POST", + cache: "no-cache", + credentials: "include", + body: form2json(fd) + }); + + if (!response.ok) { + throw new Error('Response not valid'); + } + + const result = await response.json(); + onSuccess(result); + + } catch (err) { + onFailed(false); + } + }); + +} const setBusy = (busySelector, contentSelector, value, parent = undefined) => { if (!value) { @@ -849,4 +950,36 @@ function dec2hex(i) { } return hex.toUpperCase(); +} + +function u16ToHex(i) { + return (i >>> 0).toString(16).padStart(4, "0").toUpperCase(); +} + +function u16ToU8s(i) { + let low = (i >>> 0) & 0xFF; + let high = ((i >>> 0) & 0xFF00) >> 8; + return [high, low]; +} + +function u16ToS8s(i) { + let [high, low] = u16ToU8s(i); + return [high << 24 >> 24, low << 24 >> 24]; +} + +function u16ToS16(i) { + return (i >>> 0) << 16 >> 16; +} + +function u16ToFlags(i) { + let [high, low] = u16ToU8s(i); + return [ + high.toString(2).padStart(8, "0"), + low.toString(2).padStart(8, "0") + ]; +} + +function u16ToFixedPoint(i) { + let [high, low] = u16ToU8s(i); + return (high + low / 256).toFixed(3) } \ No newline at end of file