diff --git a/GLOSSARY.md b/GLOSSARY.md index d82189f..1a82b28 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -86,6 +86,13 @@ _Avoid_: MQTTController What pushes state events to WebSocket clients on port 8080. A client can only join or leave a room; it sends no commands. _Avoid_: WebSocketController +**MqttConnection** +The link to the MQTT broker that MqttCommands and MqttPublisher share: connecting, the root topic and the subscriptions. +_Avoid_: MQTTClass + +**Change fan-out** +What sends everything that changed about a shade or group in one pass of `loop()` once: its changed MQTT topics, then one state event. + ## Domain modules **Domain module** diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index abb19f4..db0e42a 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -49,7 +49,7 @@ A shade with every linked remote populated is about 1.5 KB today, so nothing ove ## Large websocket events are dropped **Status:** open -**Location:** [`JsonSockEvent` in src/WResp.cpp](../src/WResp.cpp), buffer `g_response[MAX_SOCK_RESPONSE]` (2048 bytes) in [src/outputs/Sockets.cpp](../src/outputs/Sockets.cpp). +**Location:** [`JsonSockEvent` in src/WResp.cpp](../src/WResp.cpp), buffer `g_response[MAX_SOCK_RESPONSE]` (2048 bytes) in [src/outputs/SocketEmitter.cpp](../src/outputs/SocketEmitter.cpp). ### Summary @@ -83,7 +83,7 @@ Unlike the `/controller` HTTP crash, this path does **not** abort — there is n Serializing a group for an HTTP reply first calls `group->updateFlags()`. When the group's flags no longer match its shades', that writes `group->flags`, sends a `groupState` socket event and publishes the group's MQTT topics, all on the async_tcp task, while `loop()` sends events and publishes too. -Every socket event is formatted into the one static buffer `g_response` in [src/outputs/Sockets.cpp](../src/outputs/Sockets.cpp) through the one `JsonSockEvent` in `sockEmit`. Two tasks emitting at once write over each other's event. The MQTT client (PubSubClient) isn't safe to use from two tasks either. +Every socket event is formatted into the one static buffer `g_response` in [src/outputs/SocketEmitter.cpp](../src/outputs/SocketEmitter.cpp) through the one `JsonSockEvent` in `sockEmit`. Two tasks emitting at once write over each other's event. The MQTT client (PubSubClient) isn't safe to use from two tasks either. Reached from every route that serializes a group: `/controller`, `/discovery`, `/groups`, `/group`, `/groupCommand`, `/addGroup`, `/saveGroup`, `/linkToGroup`, `/unlinkFromGroup` and others. diff --git a/docs/LOOP_QUEUE_PLAN.md b/docs/LOOP_QUEUE_PLAN.md index 8ae17c2..c3518a4 100644 --- a/docs/LOOP_QUEUE_PLAN.md +++ b/docs/LOOP_QUEUE_PLAN.md @@ -1,6 +1,6 @@ # Plan: domain modules behind the LoopQueue -**Status:** steps 0–4 built (PRs #39 to #43); step 5 planned. +**Status:** all steps built (PRs #39 to #44). **Written:** 2026-10-15, against commit `e624a41` on `refactor/entry-points-outputs`. Terms are defined in [GLOSSARY.md](../GLOSSARY.md); the threading rule is [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). ## Why @@ -168,6 +168,22 @@ Flash: `esp32devdbg` 1,547,821 → 1,536,337 bytes, `esp32dev` 1,400,401 → 1,3 8 new native tests, 327 in all. Flash: `esp32devdbg` 1,536,337 → 1,536,533 bytes, `esp32dev` 1,393,221 → 1,393,449 bytes. +### Step 5: the file split +Code moved unchanged; only files and names changed. + +| File | Holds | +|---|---| +| `entryPoints/WebUiRoutes.cpp` | `registerWebUiRoutes()`: the port 80 routes and the UI's files, with the helpers only they use | +| `entryPoints/ApiRoutes.cpp` | `registerApiRoutes()`: the port 8081 routes | +| `entryPoints/Web.cpp` | the web servers' lifecycle, auth, and the handlers both route sets call | +| `entryPoints/WebRoutes.h` | what those three share | +| `entryPoints/MqttCommands.*` | + `MQTT_COMMAND_TOPICS`, the topics the connection subscribes to | +| `MqttConnection.*` | was `entryPoints/MQTT.*`; class `MQTTClass` → `MqttConnection` | +| `outputs/MqttPublisher.cpp` | was `outputs/SomfyMqtt.cpp` | +| `outputs/SocketEmitter.*` | was `outputs/Sockets.*` | + +The subscribe and unsubscribe lists, which repeated `groups/+/direction/set`, became the one `MQTT_COMMAND_TOPICS` list, and a native test checks that the parser takes every topic in it. Flash: `esp32devdbg` 1,536,533 → 1,539,645 bytes (+3.1 KB, since the routes are a second file that uses ArduinoJson, as SOMFY_SPLIT_PLAN.md warned), `esp32dev` 1,393,449 → 1,391,113 bytes (−2.3 KB, where `-Os` inlines less). + ### Step 0 on a device NVS stays at `0x9000`, so the rolling codes survive. The file system partition shrinks from `0xE0000` to `0xD0000`, and the existing LittleFS won't mount, because `LittleFS.begin()` doesn't format. So: 1. Take a backup through `/backup`. diff --git a/src/ESPNetwork.cpp b/src/ESPNetwork.cpp index 9bad48e..9925420 100644 --- a/src/ESPNetwork.cpp +++ b/src/ESPNetwork.cpp @@ -14,17 +14,17 @@ static inline void safe_wdt_reset() { #include "ConfigSettings.h" #include "ESPNetwork.h" #include "entryPoints/Web.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" #include "Utils.h" #include "SSDP.h" -#include "entryPoints/MQTT.h" +#include "MqttConnection.h" static const char *TAG = "Network"; extern ConfigSettings settings; extern Web webServer; extern SocketEmitter sockEmit; -extern MQTTClass mqtt; +extern MqttConnection mqtt; extern rebootDelay_t rebootDelay; extern ESPNetwork net; extern SomfyShadeController somfy; diff --git a/src/GitOTA.cpp b/src/GitOTA.cpp index e0913e0..9b13d9c 100644 --- a/src/GitOTA.cpp +++ b/src/GitOTA.cpp @@ -8,7 +8,7 @@ #include "ConfigSettings.h" #include "GitOTA.h" #include "Utils.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" #include "Somfy.h" #include "entryPoints/Web.h" #include "WResp.h" diff --git a/src/entryPoints/MQTT.cpp b/src/MqttConnection.cpp similarity index 70% rename from src/entryPoints/MQTT.cpp rename to src/MqttConnection.cpp index 6cbaf79..cb34f6b 100644 --- a/src/entryPoints/MQTT.cpp +++ b/src/MqttConnection.cpp @@ -4,8 +4,8 @@ #include #include "esp_log.h" #include "ConfigSettings.h" -#include "MQTT.h" -#include "MqttCommands.h" +#include "MqttConnection.h" +#include "entryPoints/MqttCommands.h" #include "Somfy.h" #include "LoopQueue.h" #include "ESPNetwork.h" @@ -26,21 +26,21 @@ extern rebootDelay_t rebootDelay; extern LoopQueue loopQueue; -bool MQTTClass::begin() { +bool MqttConnection::begin() { this->suspended = false; return true; } -bool MQTTClass::end() { +bool MqttConnection::end() { this->suspended = true; this->disconnect(); return true; } -void MQTTClass::reset() { +void MqttConnection::reset() { this->disconnect(); this->lastConnect = 0; this->connect(); } -bool MQTTClass::loop() { +bool MqttConnection::loop() { if(settings.MQTT.enabled && !rebootDelay.reboot && !this->suspended && !mqttClient.connected()) { esp_task_wdt_reset(); if(!this->connected() && net.connected()) this->connect(); @@ -49,7 +49,7 @@ bool MQTTClass::loop() { if(settings.MQTT.enabled) mqttClient.loop(); return true; } -void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) { +void MqttConnection::receive(const char *topic, byte*payload, uint32_t length) { esp_task_wdt_reset(); // Make sure we do not reboot here. ESP_LOGD(TAG, "MQTT Topic:%s payload:%.*s", topic, (int)length, (const char *)payload); // This already runs on loop(), but going through the loop queue keeps MQTT @@ -61,7 +61,7 @@ void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) { } loopQueue.submit(item); } -bool MQTTClass::connect() { +bool MqttConnection::connect() { esp_task_wdt_reset(); // Make sure we do not reboot here. if(mqttClient.connected()) { if(!settings.MQTT.enabled || this->suspended) @@ -88,21 +88,8 @@ bool MQTTClass::connect() { this->publish("serverId", settings.serverId, true); this->publish("mac", net.mac.c_str()); somfy.publish(); - this->subscribe("shades/+/target/set"); - this->subscribe("shades/+/tiltTarget/set"); - this->subscribe("shades/+/direction/set"); - this->subscribe("shades/+/mypos/set"); - this->subscribe("shades/+/myTiltPos/set"); - this->subscribe("shades/+/sunFlag/set"); - this->subscribe("shades/+/sunny/set"); - this->subscribe("shades/+/windy/set"); - this->subscribe("shades/+/position/set"); - this->subscribe("shades/+/tiltPosition/set"); - this->subscribe("groups/+/direction/set"); - this->subscribe("groups/+/sunFlag/set"); - this->subscribe("groups/+/sunny/set"); - this->subscribe("groups/+/windy/set"); - mqttClient.setCallback(MQTTClass::receive); + for(const char *topic : MQTT_COMMAND_TOPICS) this->subscribe(topic); + mqttClient.setCallback(MqttConnection::receive); ESP_LOGI(TAG, "MQTT Startup Completed"); esp_task_wdt_reset(); this->lastConnect = millis(); @@ -119,28 +106,14 @@ bool MQTTClass::connect() { } return true; } -bool MQTTClass::disconnect() { +bool MqttConnection::disconnect() { if(mqttClient.connected()) { - this->unsubscribe("shades/+/target/set"); - this->unsubscribe("shades/+/direction/set"); - this->unsubscribe("shades/+/tiltTarget/set"); - this->unsubscribe("shades/+/mypos/set"); - this->unsubscribe("shades/+/myTiltPos/set"); - this->unsubscribe("shades/+/sunFlag/set"); - this->unsubscribe("groups/+/direction/set"); - this->unsubscribe("shades/+/sunny/set"); - this->unsubscribe("shades/+/windy/set"); - this->unsubscribe("shades/+/position/set"); - this->unsubscribe("shades/+/tiltPosition/set"); - this->unsubscribe("groups/+/direction/set"); - this->unsubscribe("groups/+/sunFlag/set"); - this->unsubscribe("groups/+/sunny/set"); - this->unsubscribe("groups/+/windy/set"); + for(const char *topic : MQTT_COMMAND_TOPICS) this->unsubscribe(topic); mqttClient.disconnect(); } return true; } -bool MQTTClass::unsubscribe(const char *topic) { +bool MqttConnection::unsubscribe(const char *topic) { if(mqttClient.connected()) { char top[128]; if(strlen(settings.MQTT.rootTopic) > 0) @@ -152,7 +125,7 @@ bool MQTTClass::unsubscribe(const char *topic) { } return true; } -bool MQTTClass::subscribe(const char *topic) { +bool MqttConnection::subscribe(const char *topic) { if(mqttClient.connected()) { esp_task_wdt_reset(); // Make sure we do not reboot here. char top[128]; @@ -165,7 +138,7 @@ bool MQTTClass::subscribe(const char *topic) { } return true; } -bool MQTTClass::publish(const char *topic, const char *payload, bool retain) { +bool MqttConnection::publish(const char *topic, const char *payload, bool retain) { if(mqttClient.connected()) { char top[128]; if(strlen(settings.MQTT.rootTopic) > 0) @@ -178,11 +151,11 @@ bool MQTTClass::publish(const char *topic, const char *payload, bool retain) { } return false; } -bool MQTTClass::publish(const char *topic, uint32_t val, bool retain) { +bool MqttConnection::publish(const char *topic, uint32_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%u", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::unpublish(const char *topic) { +bool MqttConnection::unpublish(const char *topic) { if(mqttClient.connected()) { char top[128]; if(strlen(settings.MQTT.rootTopic) > 0) @@ -199,7 +172,7 @@ bool MQTTClass::unpublish(const char *topic) { // mqttClient.endPublish(); } -bool MQTTClass::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bool retain) { +bool MqttConnection::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bool retain) { size_t res; uint16_t offset = 0; uint16_t to_write = len; @@ -216,28 +189,28 @@ bool MQTTClass::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bo mqttClient.endPublish(); return true; } -bool MQTTClass::publishDisco(const char *topic, JsonObject &obj, bool retain) { +bool MqttConnection::publishDisco(const char *topic, JsonObject &obj, bool retain) { serializeJson(obj, g_content, sizeof(g_content)); this->publishBuffer(topic, (uint8_t *)g_content, strlen(g_content), retain); return true; } -bool MQTTClass::publish(const char *topic, int8_t val, bool retain) { +bool MqttConnection::publish(const char *topic, int8_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%d", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::publish(const char *topic, uint8_t val, bool retain) { +bool MqttConnection::publish(const char *topic, uint8_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%u", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::publish(const char *topic, uint16_t val, bool retain) { +bool MqttConnection::publish(const char *topic, uint16_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%u", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::publish(const char *topic, bool val, bool retain) { +bool MqttConnection::publish(const char *topic, bool val, bool retain) { snprintf(g_content, sizeof(g_content), "%s", val ? "true" : "false"); return this->publish(topic, g_content, retain); } -bool MQTTClass::connected() { +bool MqttConnection::connected() { if(settings.MQTT.enabled) return mqttClient.connected(); return false; } diff --git a/src/entryPoints/MQTT.h b/src/MqttConnection.h similarity index 93% rename from src/entryPoints/MQTT.h rename to src/MqttConnection.h index fbc4839..4b27420 100644 --- a/src/entryPoints/MQTT.h +++ b/src/MqttConnection.h @@ -1,9 +1,9 @@ -#ifndef MQTT_H -#define MQTT_H +#ifndef MQTT_CONNECTION_H +#define MQTT_CONNECTION_H #include #include #include -class MQTTClass { +class MqttConnection { public: uint64_t lastConnect = 0; bool suspended = false; diff --git a/src/SomfyController.ino b/src/SomfyController.ino index cb2fdf9..718d602 100644 --- a/src/SomfyController.ino +++ b/src/SomfyController.ino @@ -5,10 +5,10 @@ #include "ConfigSettings.h" #include "ESPNetwork.h" #include "entryPoints/Web.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" #include "Utils.h" #include "Somfy.h" -#include "entryPoints/MQTT.h" +#include "MqttConnection.h" #include "LoopQueue.h" #include "ShadeRequests.h" #include "SomfyShadeOps.h" @@ -35,7 +35,7 @@ SocketEmitter sockEmit; ESPNetwork net; rebootDelay_t rebootDelay; SomfyShadeController somfy; -MQTTClass mqtt; +MqttConnection mqtt; GitUpdater git; SomfyShadeOps shadeOps; ShadeRequests shadeRequests(shadeOps); diff --git a/src/Transceiver.cpp b/src/Transceiver.cpp index 9179a91..a01bdd6 100644 --- a/src/Transceiver.cpp +++ b/src/Transceiver.cpp @@ -10,7 +10,7 @@ #include "Utils.h" #include "ConfigSettings.h" #include "Somfy.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" static const char *TAG = "Somfy"; diff --git a/src/entryPoints/ApiRoutes.cpp b/src/entryPoints/ApiRoutes.cpp new file mode 100644 index 0000000..3350ea0 --- /dev/null +++ b/src/entryPoints/ApiRoutes.cpp @@ -0,0 +1,52 @@ +#include +#include +#include "Web.h" +#include "WebRoutes.h" + +// ApiRoutes: the routes integrations such as Home Assistant call on port 8081. +// The handlers are the ones the config web UI calls too (Web.cpp). + +extern Web webServer; + +void registerApiRoutes(AsyncWebServer &server) { + // GET endpoints + server.on("/discovery", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDiscovery(r); }); + server.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetRooms(r); }); + server.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetShades(r); }); + server.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetGroups(r); }); + server.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleController(r); }); + server.on("/room", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleRoom(r); }); + server.on("/shade", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleShade(r); }); + server.on("/group", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleGroup(r); }); + server.on("/downloadFirmware", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDownloadFirmware(r); }); + server.on("/backup", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleBackup(r); }); + server.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *r) { webServer.handleReboot(r); }); + // JSON body endpoints + server.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleShadeCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleGroupCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleTiltCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleRepeatCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetPositions(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetSensor(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/login", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleLogin(r, j); })); + // GET fallback for command endpoints (query params) + server.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleShadeCommand(r, v); }); + server.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleGroupCommand(r, v); }); + server.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleTiltCommand(r, v); }); + server.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleRepeatCommand(r, v); }); + server.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetPositions(r, v); }); + server.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetSensor(r, v); }); + server.on("/login", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleLogin(r, v); }); + // OPTIONS preflight + not found + server.onNotFound([](AsyncWebServerRequest *r) { + if(r->method() == HTTP_OPTIONS) { r->send(200); return; } + webServer.handleNotFound(r); + }); +} diff --git a/src/entryPoints/MqttCommands.cpp b/src/entryPoints/MqttCommands.cpp index 89d0a1b..528d8ce 100644 --- a/src/entryPoints/MqttCommands.cpp +++ b/src/entryPoints/MqttCommands.cpp @@ -2,6 +2,23 @@ #include #include "MqttCommands.h" +const char *const MQTT_COMMAND_TOPICS[MQTT_COMMAND_TOPIC_COUNT] = { + "shades/+/target/set", + "shades/+/tiltTarget/set", + "shades/+/direction/set", + "shades/+/mypos/set", + "shades/+/myTiltPos/set", + "shades/+/sunFlag/set", + "shades/+/sunny/set", + "shades/+/windy/set", + "shades/+/position/set", + "shades/+/tiltPosition/set", + "groups/+/direction/set", + "groups/+/sunFlag/set", + "groups/+/sunny/set", + "groups/+/windy/set" +}; + namespace { struct segment_t { const char *s = ""; diff --git a/src/entryPoints/MqttCommands.h b/src/entryPoints/MqttCommands.h index 1d1df1b..e4797f3 100644 --- a/src/entryPoints/MqttCommands.h +++ b/src/entryPoints/MqttCommands.h @@ -11,4 +11,9 @@ // Turns a message on a command topic into a shade request. False for a topic // or value it doesn't take, which is dropped. bool parseMqttCommand(const char *topic, const uint8_t *payload, size_t length, shade_request_t &req); + +// The command topics under the root topic, + standing for any id. The MQTT +// connection subscribes to these. +#define MQTT_COMMAND_TOPIC_COUNT 14 +extern const char *const MQTT_COMMAND_TOPICS[MQTT_COMMAND_TOPIC_COUNT]; #endif diff --git a/src/entryPoints/Web.cpp b/src/entryPoints/Web.cpp index 62d6621..14b72d3 100644 --- a/src/entryPoints/Web.cpp +++ b/src/entryPoints/Web.cpp @@ -15,7 +15,8 @@ #include "SomfySnapshot.h" #include "SomfyEditor.h" #include "Web.h" -#include "MQTT.h" +#include "WebRoutes.h" +#include "MqttConnection.h" #include "GitOTA.h" #include "ESPNetwork.h" #include "Timing.h" @@ -29,15 +30,13 @@ extern SSDPClass SSDP; extern rebootDelay_t rebootDelay; extern SomfyShadeController somfy; extern Web webServer; -extern MQTTClass mqtt; +extern MqttConnection mqtt; extern GitUpdater git; extern ESPNetwork net; extern LoopQueue loopQueue; extern SnapshotSlots snapshotSlots; -//#define WEB_MAX_RESPONSE 34768 -#define WEB_MAX_RESPONSE 4096 -static char g_async_content[WEB_MAX_RESPONSE]; +char g_async_content[WEB_MAX_RESPONSE]; // General responses @@ -45,75 +44,31 @@ static const char _response_404[] = "404: Service Not Found"; // Encodings -static const char _encoding_text[] = "text/plain"; -static const char _encoding_html[] = "text/html"; -static const char _encoding_json[] = "application/json"; +const char _encoding_text[] = "text/plain"; +const char _encoding_json[] = "application/json"; static const char *TAG = "Web"; -static void sendQueueFull(AsyncWebServerRequest *request); -static void sendRadioUnavailable(AsyncWebServerRequest *request); // Hands an item to loop(), which owns the shades. When it can't be the // request is answered with the reason and false is returned. -static bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) { +bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) { // Nothing can go on air during a frequency scan so tell the client now rather // than dropping the request once it reaches the radio. if(loopItemUsesRadio(item) && somfy.transceiver.txSuspended()) { sendRadioUnavailable(request); return false; } if(!loopQueue.submit(item)) { sendQueueFull(request); return false; } return true; } -static bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req) { +bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req) { loop_item_t item; item.request = req; return queueLoopItem(request, item); } -static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) { - shade_request_t req; - req.type = shade_request_type_t::set_favorite; - req.id = shade->getShadeId(); - req.position = pos; - req.tiltPosition = tilt; - return queueShadeRequest(request, req); -} -// loop() clears paired and saves it once the Prog is queued. The shade -// belongs to loop() so it isn't changed here. -static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) { - shade_request_t req; - req.type = shade_request_type_t::unpair; - req.id = shade->getShadeId(); - return queueShadeRequest(request, req); -} -// Sending a command reads and commits the rolling code on loop(), so a new -// code is set there too. -static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) { - shade_request_t req; - req.type = shade_request_type_t::set_rolling_code; - req.id = shade->getShadeId(); - req.rollingCode = rollingCode; - return queueShadeRequest(request, req); -} -// A restore's options travel through the loop queue as RESTORE_* bits. -static uint8_t restoreParts(const restore_options_t &opts) { - return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) | - (opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) | - (opts.repeaters ? RESTORE_REPEATERS : 0) | (opts.mqtt ? RESTORE_MQTT : 0); -} -// The radio and MQTT are shut down once an upload starts and only a reboot -// brings them back. The completion handler schedules it but never runs if the -// client goes away mid-upload, so make sure the device reboots then too. -static void onUploadDisconnect() { - if(webServer.uploadSuccess || rebootDelay.reboot) return; - ESP_LOGW(TAG, "Upload abandoned, rebooting"); - if(Update.isRunning()) Update.abort(); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 500; -} // Sent when a command can't be handed to the main loop. -static void sendQueueFull(AsyncWebServerRequest *request) { +void sendQueueFull(AsyncWebServerRequest *request) { request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Command queue full\"}")); } // Sent when the radio can't take a frame (suspended or queue full). -static void sendRadioUnavailable(AsyncWebServerRequest *request) { +void sendRadioUnavailable(AsyncWebServerRequest *request) { request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Radio TX unavailable\"}")); } @@ -121,19 +76,6 @@ AsyncWebServer asyncServer(80); AsyncWebServer asyncApiServer(8081); void Web::startup() { ESP_LOGI(TAG, "Launching web server..."); - - asyncServer.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResponse *response = new AsyncJsonResponse(); - JsonObject root = response->getRoot().to(); - root["type"] = static_cast(settings.Security.type); - root["permissions"] = settings.Security.permissions; - root["serverId"] = settings.serverId; - root["version"] = settings.fwVersion.name; - root["model"] = "ESPSomfyRTS"; - root["hostname"] = settings.hostname; - response->setLength(); - request->send(response); - }); asyncApiServer.begin(); ESP_LOGI(TAG, "Async API server started on port 8081"); } @@ -196,11 +138,11 @@ bool Web::createAPIToken(const IPAddress ipAddress, char *token) { // Async API Handlers // ===================================================== // Helper: get a query param as String, or empty if missing -static String asyncParam(AsyncWebServerRequest *request, const char *name) { +String asyncParam(AsyncWebServerRequest *request, const char *name) { if(request->hasParam(name)) return request->getParam(name)->value(); return String(); } -static bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { +bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { return request->hasParam(name); } @@ -209,7 +151,7 @@ static bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { // replies) copy one item into the ModelSnapshot types and write it the way the // read routes do. The read routes themselves write from a ModelSnapshot that // loop() fills (sendSnapshot). -static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { +void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { shade_snapshot_t s; snapshotShade(*shade, s); writeShadeRef(json, s); @@ -217,14 +159,14 @@ static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { // paired and lastRollingCode are passed in for a response that reports the // change the main loop is about to make. An unpair is held until it goes on // air (holdLoopItem), so the answer comes true. -static void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) { +void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) { shade_snapshot_t s; snapshotShade(*shade, s); s.paired = paired; s.lastRollingCode = lastRollingCode; writeShade(json, s); } -static void serializeShade(SomfyShade *shade, JsonFormatter &json) { serializeShade(shade, json, shade->paired, shade->lastRollingCode); } +void serializeShade(SomfyShade *shade, JsonFormatter &json) { serializeShade(shade, json, shade->paired, shade->lastRollingCode); } static void serializeGroupRef(SomfyGroup *group, JsonFormatter &json) { group_snapshot_t s; snapshotGroup(*group, s); @@ -236,7 +178,7 @@ static void serializeGroup(SomfyGroup *group, JsonFormatter &json) { shade_snapshot_t linked; writeGroup(json, s, liveShade, &linked); } -static void serializeTransceiverConfig(JsonFormatter &json) { +void serializeTransceiverConfig(JsonFormatter &json) { auto &cfg = somfy.transceiver.config; json.addElem("type", cfg.type); json.addElem("TXPin", cfg.TXPin); @@ -254,14 +196,14 @@ static void serializeTransceiverConfig(JsonFormatter &json) { json.addElem("noiseDetection", cfg.noiseDetection); json.addElem("radioInit", cfg.radioInit); } -static void serializeAppVersion(JsonFormatter &json, appver_t &ver) { +void serializeAppVersion(JsonFormatter &json, appver_t &ver) { json.addElem("name", ver.name); json.addElem("major", ver.major); json.addElem("minor", ver.minor); json.addElem("build", ver.build); json.addElem("suffix", ver.suffix); } -static void serializeGitVersion(JsonFormatter &json) { +void serializeGitVersion(JsonFormatter &json) { json.addElem("available", git.updateAvailable); json.addElem("status", git.status); json.addElem("error", (int32_t)git.error); @@ -278,7 +220,7 @@ static void serializeGitVersion(JsonFormatter &json) { serializeAppVersion(json, git.latest); json.endObject(); } -static void serializeGitRelease(GitRelease *rel, JsonFormatter &json) { +void serializeGitRelease(GitRelease *rel, JsonFormatter &json) { Timestamp ts; char buff[20]; sprintf(buff, "%llu", rel->id); @@ -350,7 +292,7 @@ struct SnapshotRead { // The response is gone, whether it finished or the client went away. ~SnapshotRead() { snapshotSlots.release(this->slot); } }; -static void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id = 0, snapshot_header_t header = nullptr) { +void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id, snapshot_header_t header) { const int8_t slot = snapshotSlots.acquire(); if(slot < 0) { request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the read again\"}")); @@ -376,58 +318,6 @@ static void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uin request->send(response); } -// Hands an edit of the shade model to loop() (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). -// The reply only says it was accepted; the result reaches every client as a -// requestResult event carrying the same requestId once loop() has applied it. -static void queueModelEdit(AsyncWebServerRequest *request, model_edit_type_t type, JsonVariantConst body) { - if(!beginModelEdit()) { - request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}")); - return; - } - model_edit_t *edit = new (std::nothrow) model_edit_t(); - if(!edit) { - cancelModelEdit(nullptr); - sendQueueFull(request); - return; - } - edit->type = type; - edit->requestId = nextEditRequestId(); - edit->doc.set(body); - const uint32_t requestId = edit->requestId; - loop_item_t item; - item.type = loop_item_type_t::model_edit; - item.edit = edit; - if(edit->doc.overflowed() || !loopQueue.submit(item)) { - cancelModelEdit(edit); - sendQueueFull(request); - return; - } - char reply[64]; - snprintf(reply, sizeof(reply), "{\"status\":\"accepted\",\"requestId\":%lu}", (unsigned long)requestId); - request->send(200, _encoding_json, reply); -} -// The JSON (PUT/POST) form of an edit route. Bodies over 4 KB are refused with -// 413 before they reach the heap. -static AsyncCallbackJsonWebHandler *editHandler(const char *uri, model_edit_type_t type) { - auto *handler = new AsyncCallbackJsonWebHandler(uri, [type](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No object supplied.\"}")); return; } - queueModelEdit(request, type, json); - }); - handler->setMaxContentLength(4096); - return handler; -} -// The GET form of an edit route: its query parameters become the request. -static void queueModelEditFromParams(AsyncWebServerRequest *request, model_edit_type_t type, std::initializer_list names) { - JsonDocument body; - for(const char *name : names) { - if(!asyncHasParam(request, name)) continue; - const String value = asyncParam(request, name); - if(strcmp(name, "paired") == 0) body[name] = toBoolean(value.c_str(), false); - else body[name] = strtoul(value.c_str(), nullptr, 10); - } - queueModelEdit(request, type, body.as()); -} - // -- Async handler implementations -- void Web::handleDiscovery(AsyncWebServerRequest *request) { if(request->method() == HTTP_POST || request->method() == HTTP_GET) { @@ -907,869 +797,7 @@ void Web::begin() { DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*"); - // GET endpoints - asyncApiServer.on("/discovery", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDiscovery(r); }); - asyncApiServer.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetRooms(r); }); - asyncApiServer.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetShades(r); }); - asyncApiServer.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetGroups(r); }); - asyncApiServer.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleController(r); }); - asyncApiServer.on("/room", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleRoom(r); }); - asyncApiServer.on("/shade", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleShade(r); }); - asyncApiServer.on("/group", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleGroup(r); }); - asyncApiServer.on("/downloadFirmware", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDownloadFirmware(r); }); - asyncApiServer.on("/backup", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleBackup(r); }); - asyncApiServer.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *r) { webServer.handleReboot(r); }); - // JSON body endpoints - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleShadeCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleGroupCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleTiltCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleRepeatCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetPositions(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetSensor(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/login", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleLogin(r, j); })); - // GET fallback for command endpoints (query params) - asyncApiServer.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleShadeCommand(r, v); }); - asyncApiServer.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleGroupCommand(r, v); }); - asyncApiServer.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleTiltCommand(r, v); }); - asyncApiServer.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleRepeatCommand(r, v); }); - asyncApiServer.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetPositions(r, v); }); - asyncApiServer.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetSensor(r, v); }); - asyncApiServer.on("/login", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleLogin(r, v); }); - // OPTIONS preflight + not found - asyncApiServer.onNotFound([](AsyncWebServerRequest *r) { - if(r->method() == HTTP_OPTIONS) { r->send(200); return; } - webServer.handleNotFound(r); - }); - - // Web Interface - - // Web Interface - // Command endpoints - delegate to async handler methods - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleTiltCommand(request, json); })); - asyncServer.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleRepeatCommand(request, json); })); - asyncServer.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleRepeatCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleShadeCommand(request, json); })); - asyncServer.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleShadeCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleGroupCommand(request, json); })); - asyncServer.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleGroupCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetPositions(request, json); })); - asyncServer.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetPositions(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetSensor(request, json); })); - asyncServer.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetSensor(request, v); }); - - asyncServer.on("/upnp.xml", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncResponseStream *response = request->beginResponseStream("text/xml"); - SSDP.schema(*response); - request->send(response); - }); - - // / and /loginContext are already handled by serveStatic and startup() - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/login", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleLogin(request, json); })); - asyncServer.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleLogin(request, v); }); - - asyncServer.on("/shades.cfg", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(LittleFS, "/shades.cfg", _encoding_text); - }); - asyncServer.on("/shades.tmp", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(LittleFS, "/shades.tmp", _encoding_text); - }); - - asyncServer.on("/getReleases", HTTP_GET, [](AsyncWebServerRequest *request) { - GitRepo repo; - repo.getReleases(); - git.setCurrentRelease(repo); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - // Inline GitRepo::toJSON - resp.beginObject("fwVersion"); - serializeAppVersion(resp, settings.fwVersion); - resp.endObject(); - resp.beginObject("appVersion"); - serializeAppVersion(resp, settings.appVersion); - resp.endObject(); - resp.beginArray("releases"); - for(uint8_t i = 0; i < GIT_MAX_RELEASES + 1; i++) { - if(repo.releases[i].id == 0) continue; - resp.beginObject(); - serializeGitRelease(&repo.releases[i], resp); - resp.endObject(); - } - resp.endArray(); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/downloadFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleDownloadFirmware(request); }); - - asyncServer.on("/cancelFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { - if(!git.lockFS) { - git.status = GIT_UPDATE_CANCELLING; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeGitVersion(resp); - resp.endObject(); - resp.endResponse(); - git.cancelled = true; - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Cannot cancel during filesystem update.\"}")); - } - }); - - asyncServer.on("/backup", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); }); - - asyncServer.on("/restore", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(webServer.uploadSuccess) { - restore_options_t opts; - if(asyncHasParam(request, "data")) { - String dataStr = asyncParam(request, "data"); - ESP_LOGD(TAG, "%s", dataStr.c_str()); - JsonDocument doc; - DeserializationError err = deserializeJson(doc, dataStr); - if(err) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - else { - JsonObject obj = doc.as(); - opts.fromJSON(obj); - } - } - else { - ESP_LOGD(TAG, "No restore options sent. Using defaults..."); - opts.shades = true; - } - // The main loop restores, since it owns the shades and their rolling - // codes, and then reboots. - loop_item_t item; - item.type = loop_item_type_t::restore_shades; - item.restoreParts = restoreParts(opts); - if(!queueLoopItem(request, item)) return; - request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}"); - } - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - esp_task_wdt_reset(); - if(index == 0) { - webServer.uploadSuccess = false; - ESP_LOGD(TAG, "Restore: %s", filename.c_str()); - File fup = LittleFS.open("/shades.tmp", "w"); - fup.close(); - } - if(len > 0) { - File fup = LittleFS.open("/shades.tmp", "a"); - fup.write(data, len); - fup.close(); - } - if(final) { - webServer.uploadSuccess = true; - } - }); - - // Static file routes removed - handled by serveStatic in startup() - - asyncServer.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleController(request); }); - asyncServer.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetRooms(request); }); - asyncServer.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetShades(request); }); - asyncServer.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetGroups(request); }); - asyncServer.on("/room", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleRoom(request); }); - asyncServer.on("/shade", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleShade(request); }); - asyncServer.on("/group", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGroup(request); }); - - // What a new room, shade or group would get, from a snapshot. - asyncServer.on("/getNextRoom", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextRoom); }); - asyncServer.on("/getNextShade", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextShade); }); - asyncServer.on("/getNextGroup", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextGroup); }); - asyncServer.on("/groupOptions", HTTP_GET, [](AsyncWebServerRequest *request) { - if(!asyncHasParam(request, "groupId")) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}")); - return; - } - const int groupId = atoi(asyncParam(request, "groupId").c_str()); - // As for /group, existence is checked on the live model. - if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::groupOptions, groupId); - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); - }); - // Adding and saving rooms, shades and groups. Edits of the shade model: - // loop() applies them and sends requestResult. - asyncServer.addHandler(editHandler("/addRoom", model_edit_type_t::add_room)); - asyncServer.addHandler(editHandler("/addShade", model_edit_type_t::add_shade)); - asyncServer.addHandler(editHandler("/addGroup", model_edit_type_t::add_group)); - asyncServer.addHandler(editHandler("/saveRoom", model_edit_type_t::save_room)); - asyncServer.addHandler(editHandler("/saveShade", model_edit_type_t::save_shade)); - asyncServer.addHandler(editHandler("/saveGroup", model_edit_type_t::save_group)); - - - // setMyPosition - supports both GET with query params and POST/PUT with JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setMyPosition", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - int8_t pos = -1; - int8_t tilt = -1; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; } - if(!obj["pos"].isNull()) pos = obj["pos"].as(); - if(!obj["tilt"].isNull()) tilt = obj["tilt"].as(); - } - SomfyShade* shade = somfy.getShadeById(shadeId); - if(shade) { - if(pos >= 0 && pos <= 100) - if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShadeRef(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); - } - })); - asyncServer.on("/setMyPosition", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - int8_t pos = -1; - int8_t tilt = -1; - if(asyncHasParam(request, "shadeId")) { - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - if(asyncHasParam(request, "pos")) pos = atoi(asyncParam(request, "pos").c_str()); - if(asyncHasParam(request, "tilt")) tilt = atoi(asyncParam(request, "tilt").c_str()); - } - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } - SomfyShade* shade = somfy.getShadeById(shadeId); - if(shade) { - if(pos >= 0 && pos <= 100) - if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShadeRef(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); - } - }); - - // setRollingCode - supports both query params and JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setRollingCode", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - uint16_t rollingCode = 0; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - if(!obj["rollingCode"].isNull()) rollingCode = obj["rollingCode"]; - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); - } - else { - if(!queueSetRollingCode(request, shade, rollingCode)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, shade->paired, rollingCode); - resp.endObject(); - resp.endResponse(); - } - })); - asyncServer.on("/setRollingCode", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - uint16_t rollingCode = 0; - if(asyncHasParam(request, "shadeId")) { - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - rollingCode = atoi(asyncParam(request, "rollingCode").c_str()); - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); - } - else { - if(!queueSetRollingCode(request, shade, rollingCode)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, shade->paired, rollingCode); - resp.endObject(); - resp.endResponse(); - } - }); - - asyncServer.addHandler(editHandler("/setPaired", model_edit_type_t::set_paired)); - asyncServer.on("/setPaired", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::set_paired, { "shadeId", "paired" }); - }); - - // unpairShade - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/unpairShade", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); - } - else { - if(!queueUnpairShade(request, shade)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, false, shade->lastRollingCode); - resp.endObject(); - resp.endResponse(); - } - })); - asyncServer.on("/unpairShade", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - if(asyncHasParam(request, "shadeId")) - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); - } - else { - if(!queueUnpairShade(request, shade)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, false, shade->lastRollingCode); - resp.endObject(); - resp.endResponse(); - } - }); - - // Linking repeaters, remotes and group members, and deleting. Edits of the - // shade model: loop() applies them and sends requestResult. - asyncServer.addHandler(editHandler("/linkRepeater", model_edit_type_t::link_repeater)); - asyncServer.on("/linkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::link_repeater, { "address" }); - }); - asyncServer.addHandler(editHandler("/unlinkRepeater", model_edit_type_t::unlink_repeater)); - asyncServer.on("/unlinkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::unlink_repeater, { "address" }); - }); - asyncServer.addHandler(editHandler("/linkRemote", model_edit_type_t::link_remote)); - asyncServer.addHandler(editHandler("/unlinkRemote", model_edit_type_t::unlink_remote)); - asyncServer.addHandler(editHandler("/linkToGroup", model_edit_type_t::link_to_group)); - asyncServer.addHandler(editHandler("/unlinkFromGroup", model_edit_type_t::unlink_from_group)); - asyncServer.addHandler(editHandler("/deleteRoom", model_edit_type_t::delete_room)); - asyncServer.on("/deleteRoom", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::delete_room, { "roomId" }); - }); - asyncServer.addHandler(editHandler("/deleteShade", model_edit_type_t::delete_shade)); - asyncServer.on("/deleteShade", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::delete_shade, { "shadeId" }); - }); - asyncServer.addHandler(editHandler("/deleteGroup", model_edit_type_t::delete_group)); - asyncServer.on("/deleteGroup", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::delete_group, { "groupId" }); - }); - // The repeaters, from a snapshot like the other reads. - asyncServer.on("/repeaters", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::repeaters); }); - - - // updateFirmware - file upload - asyncServer.on("/updateFirmware", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(Update.hasError()) - request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}"); - else - request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated firmware\"}"); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 500; - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - if(index == 0) { - webServer.uploadSuccess = false; - ESP_LOGI(TAG, "Update: %s", filename.c_str()); - if(!Update.begin(UPDATE_SIZE_UNKNOWN)) { - ESP_LOGE(TAG, "Update begin failed"); - } - else { - somfy.transceiver.end(); - mqtt.end(); - request->onDisconnect(onUploadDisconnect); - } - } - if(len > 0) { - if(Update.write(data, len) != len) { - ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); - Update.abort(); - } - } - if(final) { - if(Update.end(true)) { - ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); - webServer.uploadSuccess = true; - } - else { - ESP_LOGE(TAG, "Update end failed"); - } - } - esp_task_wdt_reset(); - }); - - // updateShadeConfig - file upload - asyncServer.on("/updateShadeConfig", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(git.lockFS) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Filesystem update in progress\"}")); - return; - } - request->send(200, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Updating Shade Config: \"}"); - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - if(index == 0) { - ESP_LOGI(TAG, "Update: shades.cfg"); - File fup = LittleFS.open("/shades.tmp", "w"); - fup.close(); - } - if(len > 0) { - File fup = LittleFS.open("/shades.tmp", "a"); - fup.write(data, len); - fup.close(); - } - if(final) { - // The main loop loads it, since it owns the shades. - loop_item_t item; - item.type = loop_item_type_t::load_shades; - loopQueue.submit(item); - } - }); - - // updateApplication - file upload - asyncServer.on("/updateApplication", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(Update.hasError()) - request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}"); - else - request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated application\"}"); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 500; - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - if(index == 0) { - webServer.uploadSuccess = false; - ESP_LOGI(TAG, "Update: %s", filename.c_str()); - if(!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { - ESP_LOGE(TAG, "Update begin failed"); - } - else { - somfy.transceiver.end(); - mqtt.end(); - request->onDisconnect(onUploadDisconnect); - } - } - if(len > 0) { - if(Update.write(data, len) != len) { - ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); - Update.abort(); - } - } - if(final) { - if(Update.end(true)) { - webServer.uploadSuccess = true; - ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); - somfy.commit(); - } - else { - somfy.commit(); - ESP_LOGE(TAG, "Update end failed"); - } - } - esp_task_wdt_reset(); - }); - - asyncServer.on("/scanaps", HTTP_GET, [](AsyncWebServerRequest *request) { - esp_task_wdt_reset(); - esp_task_wdt_delete(NULL); - if(net.softAPOpened) WiFi.disconnect(false); - int n = WiFi.scanNetworks(false, true); - esp_task_wdt_add(NULL); - ESP_LOGI(TAG, "Scanned %d networks", n); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.beginObject("connected"); - resp.addElem("name", settings.WIFI.ssid); - resp.addElem("passphrase", settings.WIFI.passphrase); - resp.addElem("strength", (int32_t)WiFi.RSSI()); - resp.addElem("channel", (int32_t)WiFi.channel()); - resp.endObject(); - resp.beginArray("accessPoints"); - for(int i = 0; i < n; ++i) { - if(WiFi.SSID(i).length() == 0 || WiFi.RSSI(i) < -95) continue; - resp.beginObject(); - resp.addElem("name", WiFi.SSID(i).c_str()); - resp.addElem("channel", (int32_t)WiFi.channel(i)); - resp.addElem("strength", (int32_t)WiFi.RSSI(i)); - resp.addElem("macAddress", WiFi.BSSIDstr(i).c_str()); - resp.endObject(); - } - resp.endArray(); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *request) { webServer.handleReboot(request); }); - - // saveSecurity - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/saveSecurity", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(400, _encoding_html, "Error parsing JSON body"); - return; - } - JsonObject obj = json.as(); - settings.Security.fromJSON(obj); - settings.Security.save(); - char token[65]; - webServer.createAPIToken(request->client()->remoteIP(), token); - JsonDocument sdoc; - JsonObject sobj = sdoc.to(); - settings.Security.toJSON(sobj); - sobj["apiKey"] = token; - serializeJson(sdoc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - })); - - // getSecurity - asyncServer.on("/getSecurity", HTTP_GET, [](AsyncWebServerRequest *request) { - JsonDocument doc; - JsonObject obj = doc.to(); - settings.Security.toJSON(obj); - serializeJson(doc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - }); - - // saveRadio - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/saveRadio", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(400, _encoding_html, "Error parsing JSON body"); - return; - } - JsonObject obj = json.as(); - somfy.transceiver.fromJSON(obj); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.beginObject("config"); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endObject(); - resp.endResponse(); - })); - - // getRadio - asyncServer.on("/getRadio", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endResponse(); - }); - - // sendRemoteCommand - supports GET with query params and POST/PUT with JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/sendRemoteCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { - somfy_frame_t frame; - uint8_t repeats = 0; - if(!json.isNull()) { - JsonObject obj = json.as(); - String scmd; - if(!obj["address"].isNull()) frame.remoteAddress = obj["address"]; - if(!obj["command"].isNull()) scmd = obj["command"].as(); - if(!obj["repeats"].isNull()) repeats = obj["repeats"]; - if(!obj["rcode"].isNull()) frame.rollingCode = obj["rcode"]; - if(!obj["encKey"].isNull()) frame.encKey = obj["encKey"]; - frame.cmd = translateSomfyCommand(scmd.c_str()); - } - if(frame.remoteAddress > 0 && frame.rollingCode > 0) { - if(somfy.sendFrame(frame, repeats)) - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); - else - sendRadioUnavailable(request); - } - else - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); - })); - asyncServer.on("/sendRemoteCommand", HTTP_GET, [](AsyncWebServerRequest *request) { - somfy_frame_t frame; - uint8_t repeats = 0; - if(asyncHasParam(request, "address")) { - frame.remoteAddress = atoi(asyncParam(request, "address").c_str()); - if(asyncHasParam(request, "encKey")) frame.encKey = atoi(asyncParam(request, "encKey").c_str()); - if(asyncHasParam(request, "command")) frame.cmd = translateSomfyCommand(asyncParam(request, "command")); - if(asyncHasParam(request, "rcode")) frame.rollingCode = atoi(asyncParam(request, "rcode").c_str()); - if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str()); - } - if(frame.remoteAddress > 0 && frame.rollingCode > 0) { - if(somfy.sendFrame(frame, repeats)) - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); - else - sendRadioUnavailable(request); - } - else - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); - }); - - // setgeneral - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setgeneral", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonObject obj = json.as(); - if(!obj["hostname"].isNull() || !obj["ssdpBroadcast"].isNull() || !obj["checkForUpdate"].isNull()) { - bool checkForUpdate = settings.checkForUpdate; - settings.fromJSON(obj); - settings.save(); - if(settings.checkForUpdate != checkForUpdate) git.emitUpdateCheck(); - if(!obj["hostname"].isNull()) net.updateHostname(); - } - if(!obj["ntpServer"].isNull() || !obj["ntpServer"].isNull()) { - settings.NTP.fromJSON(obj); - settings.NTP.save(); - } - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set General Settings\"}"); - })); - - // setNetwork - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setNetwork", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(400, _encoding_html, "Error parsing JSON body"); - return; - } - JsonObject obj = json.as(); - bool reboot = false; - if(!obj["connType"].isNull() && obj["connType"].as() != static_cast(settings.connType)) { - settings.connType = static_cast(obj["connType"].as()); - settings.save(); - reboot = true; - } - if(!obj["wifi"].isNull()) { - JsonObject objWifi = obj["wifi"]; - if(settings.connType == conn_types_t::wifi) { - if(!objWifi["ssid"].isNull() && objWifi["ssid"].as().compareTo(settings.WIFI.ssid) != 0) { - if(WiFi.softAPgetStationNum() == 0) reboot = true; - } - if(!objWifi["passphrase"].isNull() && objWifi["passphrase"].as().compareTo(settings.WIFI.passphrase) != 0) { - if(WiFi.softAPgetStationNum() == 0) reboot = true; - } - } - settings.WIFI.fromJSON(objWifi); - settings.WIFI.save(); - } - if(!obj["ethernet"].isNull()) { - JsonObject objEth = obj["ethernet"]; - if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref) - reboot = true; -#ifndef CONFIG_IDF_TARGET_ESP32C6 - settings.Ethernet.fromJSON(objEth); - settings.Ethernet.save(); -#endif - } - if(reboot) { - ESP_LOGI(TAG, "Rebooting ESP for new Network settings..."); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 1000; - } - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); - })); - - // setIP - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setIP", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - ESP_LOGD(TAG, "Setting IP..."); - JsonObject obj = json.as(); - settings.IP.fromJSON(obj); - settings.IP.save(); - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); - })); - - // connectwifi - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/connectwifi", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonObject obj = json.as(); - ESP_LOGD(TAG, "Settings WIFI connection..."); - String ssid = ""; - String passphrase = ""; - if(!obj["ssid"].isNull()) ssid = obj["ssid"].as(); - if(!obj["passphrase"].isNull()) passphrase = obj["passphrase"].as(); - bool reboot = false; - if(ssid.compareTo(settings.WIFI.ssid) != 0) reboot = true; - if(passphrase.compareTo(settings.WIFI.passphrase) != 0) reboot = true; - if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) { - request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}"); - } - else { - SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid)); - SETCHARPROP(settings.WIFI.passphrase, passphrase.c_str(), sizeof(settings.WIFI.passphrase)); - settings.WIFI.save(); - settings.WIFI.print(); - request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}"); - if(reboot) { - ESP_LOGI(TAG, "Rebooting ESP for new WiFi settings..."); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 1000; - } - } - })); - - // modulesettings - asyncServer.on("/modulesettings", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.addElem("fwVersion", settings.fwVersion.name); - resp.addElem("ssdpBroadcast", settings.ssdpBroadcast); - resp.addElem("hostname", settings.hostname); - resp.addElem("connType", static_cast(settings.connType)); - resp.addElem("chipModel", settings.chipModel); - resp.addElem("checkForUpdate", settings.checkForUpdate); - resp.addElem("ntpServer", settings.NTP.ntpServer); - resp.addElem("posixZone", settings.NTP.posixZone); - resp.endObject(); - resp.endResponse(); - }); - - // networksettings - asyncServer.on("/networksettings", HTTP_GET, [](AsyncWebServerRequest *request) { - JsonDocument doc; - JsonObject obj = doc.to(); - settings.toJSON(obj); - obj["fwVersion"] = settings.fwVersion.name; - JsonObject eth = obj.createNestedObject("ethernet"); -#ifndef CONFIG_IDF_TARGET_ESP32C6 - settings.Ethernet.toJSON(eth); -#endif - JsonObject wifi = obj.createNestedObject("wifi"); - settings.WIFI.toJSON(wifi); - JsonObject ip = obj.createNestedObject("ip"); - settings.IP.toJSON(ip); - serializeJson(doc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - }); - - // connectmqtt - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/connectmqtt", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonObject obj = json.as(); - ESP_LOGD(TAG, "Saving MQTT"); - mqtt.disconnect(); - settings.MQTT.fromJSON(obj); - settings.MQTT.save(); - JsonDocument sdoc; - JsonObject sobj = sdoc.to(); - settings.MQTT.toJSON(sobj); - serializeJson(sdoc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - })); - - // mqttsettings - asyncServer.on("/mqttsettings", HTTP_GET, [](AsyncWebServerRequest *request) { - JsonDocument doc; - JsonObject obj = doc.to(); - settings.MQTT.toJSON(obj); - serializeJson(doc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - }); - - // Sort orders: the ids in the order the client lists them. - asyncServer.addHandler(editHandler("/roomSortOrder", model_edit_type_t::room_order)); - asyncServer.addHandler(editHandler("/shadeSortOrder", model_edit_type_t::shade_order)); - asyncServer.addHandler(editHandler("/groupSortOrder", model_edit_type_t::group_order)); - - asyncServer.on("/beginFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { - somfy.transceiver.beginFrequencyScan(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/endFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { - somfy.transceiver.endFrequencyScan(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/recoverFilesystem", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { - if(git.status == GIT_UPDATING) - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Filesystem is updating. Please wait!!!\"}"); - else if(git.status != GIT_STATUS_READY) - request->send(200, "application/json", "{\"status\":\"ERROR\",\"desc\":\"Cannot recover file system at this time.\"}"); - else { - git.recoverFilesystem(); - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Recovering filesystem from github please wait!!!\"}"); - } - }); - - asyncServer.onNotFound([](AsyncWebServerRequest *r) { - if(r->method() == HTTP_OPTIONS) { r->send(200); return; } - webServer.handleNotFound(r); - }); - - // serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them - asyncServer.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); - + registerApiRoutes(asyncApiServer); + registerWebUiRoutes(asyncServer); asyncServer.begin(); } diff --git a/src/entryPoints/WebRoutes.h b/src/entryPoints/WebRoutes.h new file mode 100644 index 0000000..58bd4ef --- /dev/null +++ b/src/entryPoints/WebRoutes.h @@ -0,0 +1,45 @@ +#ifndef WEB_ROUTES_H +#define WEB_ROUTES_H +#include +#include "Somfy.h" +#include "LoopQueue.h" +#include "ModelSnapshot.h" +#include "GitOTA.h" + +// What WebUiRoutes, ApiRoutes and the handlers both of them call (Web.cpp) +// share. Internal to the web servers. + +// Registers the routes the config web UI calls on port 80, and its files. +void registerWebUiRoutes(AsyncWebServer &server); +// Registers the routes integrations such as Home Assistant call on port 8081. +void registerApiRoutes(AsyncWebServer &server); + +#define WEB_MAX_RESPONSE 4096 +// Where replies are formatted before they are sent. async_tcp only. +extern char g_async_content[WEB_MAX_RESPONSE]; +extern const char _encoding_text[]; +extern const char _encoding_json[]; + +// Sent when a request can't be handed to loop(). +void sendQueueFull(AsyncWebServerRequest *request); +// Sent when the radio can't take a frame (suspended or queue full). +void sendRadioUnavailable(AsyncWebServerRequest *request); +// Hands an item to loop(), which owns the shades. When it can't be the +// request is answered with the reason and false is returned. +bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item); +bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req); +// A query parameter, or an empty string when it is missing. +String asyncParam(AsyncWebServerRequest *request, const char *name); +bool asyncHasParam(AsyncWebServerRequest *request, const char *name); +// Serves a read from a ModelSnapshot that loop() fills. +void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id = 0, snapshot_header_t header = nullptr); + +// One item of a reply, copied from the live shade model. +void serializeShadeRef(SomfyShade *shade, JsonFormatter &json); +void serializeShade(SomfyShade *shade, JsonFormatter &json); +void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode); +void serializeTransceiverConfig(JsonFormatter &json); +void serializeAppVersion(JsonFormatter &json, appver_t &ver); +void serializeGitVersion(JsonFormatter &json); +void serializeGitRelease(GitRelease *rel, JsonFormatter &json); +#endif diff --git a/src/entryPoints/WebUiRoutes.cpp b/src/entryPoints/WebUiRoutes.cpp new file mode 100644 index 0000000..e46176b --- /dev/null +++ b/src/entryPoints/WebUiRoutes.cpp @@ -0,0 +1,967 @@ +#include +#include +#include +#include +#include "esp_log.h" +#include "ConfigSettings.h" +#include "ConfigFile.h" +#include "Utils.h" +#include "SSDP.h" +#include "Somfy.h" +#include "WResp.h" +#include "LoopQueue.h" +#include "ModelSnapshot.h" +#include "SomfyEditor.h" +#include "Web.h" +#include "WebRoutes.h" +#include "MqttConnection.h" +#include "GitOTA.h" +#include "ESPNetwork.h" +#include +#include +#include + +// WebUiRoutes: the routes the config web UI calls on port 80, and the UI's own +// files. + +extern ConfigSettings settings; +extern SSDPClass SSDP; +extern rebootDelay_t rebootDelay; +extern SomfyShadeController somfy; +extern Web webServer; +extern MqttConnection mqtt; +extern GitUpdater git; +extern ESPNetwork net; +extern LoopQueue loopQueue; + +static const char *TAG = "Web"; +static const char _encoding_html[] = "text/html"; + +static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) { + shade_request_t req; + req.type = shade_request_type_t::set_favorite; + req.id = shade->getShadeId(); + req.position = pos; + req.tiltPosition = tilt; + return queueShadeRequest(request, req); +} +// loop() clears paired and saves it once the Prog is queued. The shade +// belongs to loop() so it isn't changed here. +static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) { + shade_request_t req; + req.type = shade_request_type_t::unpair; + req.id = shade->getShadeId(); + return queueShadeRequest(request, req); +} +// Sending a command reads and commits the rolling code on loop(), so a new +// code is set there too. +static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) { + shade_request_t req; + req.type = shade_request_type_t::set_rolling_code; + req.id = shade->getShadeId(); + req.rollingCode = rollingCode; + return queueShadeRequest(request, req); +} +// A restore's options travel through the loop queue as RESTORE_* bits. +static uint8_t restoreParts(const restore_options_t &opts) { + return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) | + (opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) | + (opts.repeaters ? RESTORE_REPEATERS : 0) | (opts.mqtt ? RESTORE_MQTT : 0); +} +// The radio and MQTT are shut down once an upload starts and only a reboot +// brings them back. The completion handler schedules it but never runs if the +// client goes away mid-upload, so make sure the device reboots then too. +static void onUploadDisconnect() { + if(webServer.uploadSuccess || rebootDelay.reboot) return; + ESP_LOGW(TAG, "Upload abandoned, rebooting"); + if(Update.isRunning()) Update.abort(); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 500; +} +// Hands an edit of the shade model to loop() (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). +// The reply only says it was accepted; the result reaches every client as a +// requestResult event carrying the same requestId once loop() has applied it. +static void queueModelEdit(AsyncWebServerRequest *request, model_edit_type_t type, JsonVariantConst body) { + if(!beginModelEdit()) { + request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}")); + return; + } + model_edit_t *edit = new (std::nothrow) model_edit_t(); + if(!edit) { + cancelModelEdit(nullptr); + sendQueueFull(request); + return; + } + edit->type = type; + edit->requestId = nextEditRequestId(); + edit->doc.set(body); + const uint32_t requestId = edit->requestId; + loop_item_t item; + item.type = loop_item_type_t::model_edit; + item.edit = edit; + if(edit->doc.overflowed() || !loopQueue.submit(item)) { + cancelModelEdit(edit); + sendQueueFull(request); + return; + } + char reply[64]; + snprintf(reply, sizeof(reply), "{\"status\":\"accepted\",\"requestId\":%lu}", (unsigned long)requestId); + request->send(200, _encoding_json, reply); +} +// The JSON (PUT/POST) form of an edit route. Bodies over 4 KB are refused with +// 413 before they reach the heap. +static AsyncCallbackJsonWebHandler *editHandler(const char *uri, model_edit_type_t type) { + auto *handler = new AsyncCallbackJsonWebHandler(uri, [type](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No object supplied.\"}")); return; } + queueModelEdit(request, type, json); + }); + handler->setMaxContentLength(4096); + return handler; +} +// The GET form of an edit route: its query parameters become the request. +static void queueModelEditFromParams(AsyncWebServerRequest *request, model_edit_type_t type, std::initializer_list names) { + JsonDocument body; + for(const char *name : names) { + if(!asyncHasParam(request, name)) continue; + const String value = asyncParam(request, name); + if(strcmp(name, "paired") == 0) body[name] = toBoolean(value.c_str(), false); + else body[name] = strtoul(value.c_str(), nullptr, 10); + } + queueModelEdit(request, type, body.as()); +} + +void registerWebUiRoutes(AsyncWebServer &server) { + server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncJsonResponse *response = new AsyncJsonResponse(); + JsonObject root = response->getRoot().to(); + root["type"] = static_cast(settings.Security.type); + root["permissions"] = settings.Security.permissions; + root["serverId"] = settings.serverId; + root["version"] = settings.fwVersion.name; + root["model"] = "ESPSomfyRTS"; + root["hostname"] = settings.hostname; + response->setLength(); + request->send(response); + }); + // Web Interface + // Command endpoints - delegate to async handler methods + server.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleTiltCommand(request, json); })); + server.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleRepeatCommand(request, json); })); + server.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleRepeatCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleShadeCommand(request, json); })); + server.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleShadeCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleGroupCommand(request, json); })); + server.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleGroupCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetPositions(request, json); })); + server.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetPositions(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetSensor(request, json); })); + server.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetSensor(request, v); }); + + server.on("/upnp.xml", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncResponseStream *response = request->beginResponseStream("text/xml"); + SSDP.schema(*response); + request->send(response); + }); + + // / and /loginContext are already handled by serveStatic and startup() + + server.addHandler(new AsyncCallbackJsonWebHandler("/login", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleLogin(request, json); })); + server.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleLogin(request, v); }); + + server.on("/shades.cfg", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(LittleFS, "/shades.cfg", _encoding_text); + }); + server.on("/shades.tmp", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(LittleFS, "/shades.tmp", _encoding_text); + }); + + server.on("/getReleases", HTTP_GET, [](AsyncWebServerRequest *request) { + GitRepo repo; + repo.getReleases(); + git.setCurrentRelease(repo); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + // Inline GitRepo::toJSON + resp.beginObject("fwVersion"); + serializeAppVersion(resp, settings.fwVersion); + resp.endObject(); + resp.beginObject("appVersion"); + serializeAppVersion(resp, settings.appVersion); + resp.endObject(); + resp.beginArray("releases"); + for(uint8_t i = 0; i < GIT_MAX_RELEASES + 1; i++) { + if(repo.releases[i].id == 0) continue; + resp.beginObject(); + serializeGitRelease(&repo.releases[i], resp); + resp.endObject(); + } + resp.endArray(); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/downloadFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleDownloadFirmware(request); }); + + server.on("/cancelFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { + if(!git.lockFS) { + git.status = GIT_UPDATE_CANCELLING; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeGitVersion(resp); + resp.endObject(); + resp.endResponse(); + git.cancelled = true; + } + else { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Cannot cancel during filesystem update.\"}")); + } + }); + + server.on("/backup", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); }); + + server.on("/restore", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(webServer.uploadSuccess) { + restore_options_t opts; + if(asyncHasParam(request, "data")) { + String dataStr = asyncParam(request, "data"); + ESP_LOGD(TAG, "%s", dataStr.c_str()); + JsonDocument doc; + DeserializationError err = deserializeJson(doc, dataStr); + if(err) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + else { + JsonObject obj = doc.as(); + opts.fromJSON(obj); + } + } + else { + ESP_LOGD(TAG, "No restore options sent. Using defaults..."); + opts.shades = true; + } + // The main loop restores, since it owns the shades and their rolling + // codes, and then reboots. + loop_item_t item; + item.type = loop_item_type_t::restore_shades; + item.restoreParts = restoreParts(opts); + if(!queueLoopItem(request, item)) return; + request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}"); + } + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + esp_task_wdt_reset(); + if(index == 0) { + webServer.uploadSuccess = false; + ESP_LOGD(TAG, "Restore: %s", filename.c_str()); + File fup = LittleFS.open("/shades.tmp", "w"); + fup.close(); + } + if(len > 0) { + File fup = LittleFS.open("/shades.tmp", "a"); + fup.write(data, len); + fup.close(); + } + if(final) { + webServer.uploadSuccess = true; + } + }); + + // Static file routes removed - handled by serveStatic in startup() + + server.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleController(request); }); + server.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetRooms(request); }); + server.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetShades(request); }); + server.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetGroups(request); }); + server.on("/room", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleRoom(request); }); + server.on("/shade", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleShade(request); }); + server.on("/group", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGroup(request); }); + + // What a new room, shade or group would get, from a snapshot. + server.on("/getNextRoom", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextRoom); }); + server.on("/getNextShade", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextShade); }); + server.on("/getNextGroup", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextGroup); }); + server.on("/groupOptions", HTTP_GET, [](AsyncWebServerRequest *request) { + if(!asyncHasParam(request, "groupId")) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}")); + return; + } + const int groupId = atoi(asyncParam(request, "groupId").c_str()); + // As for /group, existence is checked on the live model. + if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::groupOptions, groupId); + else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); + }); + // Adding and saving rooms, shades and groups. Edits of the shade model: + // loop() applies them and sends requestResult. + server.addHandler(editHandler("/addRoom", model_edit_type_t::add_room)); + server.addHandler(editHandler("/addShade", model_edit_type_t::add_shade)); + server.addHandler(editHandler("/addGroup", model_edit_type_t::add_group)); + server.addHandler(editHandler("/saveRoom", model_edit_type_t::save_room)); + server.addHandler(editHandler("/saveShade", model_edit_type_t::save_shade)); + server.addHandler(editHandler("/saveGroup", model_edit_type_t::save_group)); + + + // setMyPosition - supports both GET with query params and POST/PUT with JSON body + server.addHandler(new AsyncCallbackJsonWebHandler("/setMyPosition", + [](AsyncWebServerRequest *request, JsonVariant &json) { + uint8_t shadeId = 255; + int8_t pos = -1; + int8_t tilt = -1; + if(!json.isNull()) { + JsonObject obj = json.as(); + if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; } + if(!obj["pos"].isNull()) pos = obj["pos"].as(); + if(!obj["tilt"].isNull()) tilt = obj["tilt"].as(); + } + SomfyShade* shade = somfy.getShadeById(shadeId); + if(shade) { + if(pos >= 0 && pos <= 100) + if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShadeRef(shade, resp); + resp.endObject(); + resp.endResponse(); + } + else { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); + } + })); + server.on("/setMyPosition", HTTP_GET, [](AsyncWebServerRequest *request) { + uint8_t shadeId = 255; + int8_t pos = -1; + int8_t tilt = -1; + if(asyncHasParam(request, "shadeId")) { + shadeId = atoi(asyncParam(request, "shadeId").c_str()); + if(asyncHasParam(request, "pos")) pos = atoi(asyncParam(request, "pos").c_str()); + if(asyncHasParam(request, "tilt")) tilt = atoi(asyncParam(request, "tilt").c_str()); + } + else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } + SomfyShade* shade = somfy.getShadeById(shadeId); + if(shade) { + if(pos >= 0 && pos <= 100) + if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShadeRef(shade, resp); + resp.endObject(); + resp.endResponse(); + } + else { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); + } + }); + + // setRollingCode - supports both query params and JSON body + server.addHandler(new AsyncCallbackJsonWebHandler("/setRollingCode", + [](AsyncWebServerRequest *request, JsonVariant &json) { + uint8_t shadeId = 255; + uint16_t rollingCode = 0; + if(!json.isNull()) { + JsonObject obj = json.as(); + if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + if(!obj["rollingCode"].isNull()) rollingCode = obj["rollingCode"]; + } + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); + } + else { + if(!queueSetRollingCode(request, shade, rollingCode)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, shade->paired, rollingCode); + resp.endObject(); + resp.endResponse(); + } + })); + server.on("/setRollingCode", HTTP_GET, [](AsyncWebServerRequest *request) { + uint8_t shadeId = 255; + uint16_t rollingCode = 0; + if(asyncHasParam(request, "shadeId")) { + shadeId = atoi(asyncParam(request, "shadeId").c_str()); + rollingCode = atoi(asyncParam(request, "rollingCode").c_str()); + } + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); + } + else { + if(!queueSetRollingCode(request, shade, rollingCode)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, shade->paired, rollingCode); + resp.endObject(); + resp.endResponse(); + } + }); + + server.addHandler(editHandler("/setPaired", model_edit_type_t::set_paired)); + server.on("/setPaired", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::set_paired, { "shadeId", "paired" }); + }); + + // unpairShade + server.addHandler(new AsyncCallbackJsonWebHandler("/unpairShade", + [](AsyncWebServerRequest *request, JsonVariant &json) { + uint8_t shadeId = 255; + if(!json.isNull()) { + JsonObject obj = json.as(); + if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + } + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); + } + else { + if(!queueUnpairShade(request, shade)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, false, shade->lastRollingCode); + resp.endObject(); + resp.endResponse(); + } + })); + server.on("/unpairShade", HTTP_GET, [](AsyncWebServerRequest *request) { + uint8_t shadeId = 255; + if(asyncHasParam(request, "shadeId")) + shadeId = atoi(asyncParam(request, "shadeId").c_str()); + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); + } + else { + if(!queueUnpairShade(request, shade)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, false, shade->lastRollingCode); + resp.endObject(); + resp.endResponse(); + } + }); + + // Linking repeaters, remotes and group members, and deleting. Edits of the + // shade model: loop() applies them and sends requestResult. + server.addHandler(editHandler("/linkRepeater", model_edit_type_t::link_repeater)); + server.on("/linkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::link_repeater, { "address" }); + }); + server.addHandler(editHandler("/unlinkRepeater", model_edit_type_t::unlink_repeater)); + server.on("/unlinkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::unlink_repeater, { "address" }); + }); + server.addHandler(editHandler("/linkRemote", model_edit_type_t::link_remote)); + server.addHandler(editHandler("/unlinkRemote", model_edit_type_t::unlink_remote)); + server.addHandler(editHandler("/linkToGroup", model_edit_type_t::link_to_group)); + server.addHandler(editHandler("/unlinkFromGroup", model_edit_type_t::unlink_from_group)); + server.addHandler(editHandler("/deleteRoom", model_edit_type_t::delete_room)); + server.on("/deleteRoom", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::delete_room, { "roomId" }); + }); + server.addHandler(editHandler("/deleteShade", model_edit_type_t::delete_shade)); + server.on("/deleteShade", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::delete_shade, { "shadeId" }); + }); + server.addHandler(editHandler("/deleteGroup", model_edit_type_t::delete_group)); + server.on("/deleteGroup", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::delete_group, { "groupId" }); + }); + // The repeaters, from a snapshot like the other reads. + server.on("/repeaters", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::repeaters); }); + + + // updateFirmware - file upload + server.on("/updateFirmware", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(Update.hasError()) + request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}"); + else + request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated firmware\"}"); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 500; + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + if(index == 0) { + webServer.uploadSuccess = false; + ESP_LOGI(TAG, "Update: %s", filename.c_str()); + if(!Update.begin(UPDATE_SIZE_UNKNOWN)) { + ESP_LOGE(TAG, "Update begin failed"); + } + else { + somfy.transceiver.end(); + mqtt.end(); + request->onDisconnect(onUploadDisconnect); + } + } + if(len > 0) { + if(Update.write(data, len) != len) { + ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); + Update.abort(); + } + } + if(final) { + if(Update.end(true)) { + ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); + webServer.uploadSuccess = true; + } + else { + ESP_LOGE(TAG, "Update end failed"); + } + } + esp_task_wdt_reset(); + }); + + // updateShadeConfig - file upload + server.on("/updateShadeConfig", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(git.lockFS) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Filesystem update in progress\"}")); + return; + } + request->send(200, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Updating Shade Config: \"}"); + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + if(index == 0) { + ESP_LOGI(TAG, "Update: shades.cfg"); + File fup = LittleFS.open("/shades.tmp", "w"); + fup.close(); + } + if(len > 0) { + File fup = LittleFS.open("/shades.tmp", "a"); + fup.write(data, len); + fup.close(); + } + if(final) { + // The main loop loads it, since it owns the shades. + loop_item_t item; + item.type = loop_item_type_t::load_shades; + loopQueue.submit(item); + } + }); + + // updateApplication - file upload + server.on("/updateApplication", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(Update.hasError()) + request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}"); + else + request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated application\"}"); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 500; + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + if(index == 0) { + webServer.uploadSuccess = false; + ESP_LOGI(TAG, "Update: %s", filename.c_str()); + if(!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { + ESP_LOGE(TAG, "Update begin failed"); + } + else { + somfy.transceiver.end(); + mqtt.end(); + request->onDisconnect(onUploadDisconnect); + } + } + if(len > 0) { + if(Update.write(data, len) != len) { + ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); + Update.abort(); + } + } + if(final) { + if(Update.end(true)) { + webServer.uploadSuccess = true; + ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); + somfy.commit(); + } + else { + somfy.commit(); + ESP_LOGE(TAG, "Update end failed"); + } + } + esp_task_wdt_reset(); + }); + + server.on("/scanaps", HTTP_GET, [](AsyncWebServerRequest *request) { + esp_task_wdt_reset(); + esp_task_wdt_delete(NULL); + if(net.softAPOpened) WiFi.disconnect(false); + int n = WiFi.scanNetworks(false, true); + esp_task_wdt_add(NULL); + ESP_LOGI(TAG, "Scanned %d networks", n); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + resp.beginObject("connected"); + resp.addElem("name", settings.WIFI.ssid); + resp.addElem("passphrase", settings.WIFI.passphrase); + resp.addElem("strength", (int32_t)WiFi.RSSI()); + resp.addElem("channel", (int32_t)WiFi.channel()); + resp.endObject(); + resp.beginArray("accessPoints"); + for(int i = 0; i < n; ++i) { + if(WiFi.SSID(i).length() == 0 || WiFi.RSSI(i) < -95) continue; + resp.beginObject(); + resp.addElem("name", WiFi.SSID(i).c_str()); + resp.addElem("channel", (int32_t)WiFi.channel(i)); + resp.addElem("strength", (int32_t)WiFi.RSSI(i)); + resp.addElem("macAddress", WiFi.BSSIDstr(i).c_str()); + resp.endObject(); + } + resp.endArray(); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *request) { webServer.handleReboot(request); }); + + // saveSecurity + server.addHandler(new AsyncCallbackJsonWebHandler("/saveSecurity", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(400, _encoding_html, "Error parsing JSON body"); + return; + } + JsonObject obj = json.as(); + settings.Security.fromJSON(obj); + settings.Security.save(); + char token[65]; + webServer.createAPIToken(request->client()->remoteIP(), token); + JsonDocument sdoc; + JsonObject sobj = sdoc.to(); + settings.Security.toJSON(sobj); + sobj["apiKey"] = token; + serializeJson(sdoc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + })); + + // getSecurity + server.on("/getSecurity", HTTP_GET, [](AsyncWebServerRequest *request) { + JsonDocument doc; + JsonObject obj = doc.to(); + settings.Security.toJSON(obj); + serializeJson(doc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + }); + + // saveRadio + server.addHandler(new AsyncCallbackJsonWebHandler("/saveRadio", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(400, _encoding_html, "Error parsing JSON body"); + return; + } + JsonObject obj = json.as(); + somfy.transceiver.fromJSON(obj); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + resp.beginObject("config"); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endObject(); + resp.endResponse(); + })); + + // getRadio + server.on("/getRadio", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endResponse(); + }); + + // sendRemoteCommand - supports GET with query params and POST/PUT with JSON body + server.addHandler(new AsyncCallbackJsonWebHandler("/sendRemoteCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { + somfy_frame_t frame; + uint8_t repeats = 0; + if(!json.isNull()) { + JsonObject obj = json.as(); + String scmd; + if(!obj["address"].isNull()) frame.remoteAddress = obj["address"]; + if(!obj["command"].isNull()) scmd = obj["command"].as(); + if(!obj["repeats"].isNull()) repeats = obj["repeats"]; + if(!obj["rcode"].isNull()) frame.rollingCode = obj["rcode"]; + if(!obj["encKey"].isNull()) frame.encKey = obj["encKey"]; + frame.cmd = translateSomfyCommand(scmd.c_str()); + } + if(frame.remoteAddress > 0 && frame.rollingCode > 0) { + if(somfy.sendFrame(frame, repeats)) + request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + else + sendRadioUnavailable(request); + } + else + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); + })); + server.on("/sendRemoteCommand", HTTP_GET, [](AsyncWebServerRequest *request) { + somfy_frame_t frame; + uint8_t repeats = 0; + if(asyncHasParam(request, "address")) { + frame.remoteAddress = atoi(asyncParam(request, "address").c_str()); + if(asyncHasParam(request, "encKey")) frame.encKey = atoi(asyncParam(request, "encKey").c_str()); + if(asyncHasParam(request, "command")) frame.cmd = translateSomfyCommand(asyncParam(request, "command")); + if(asyncHasParam(request, "rcode")) frame.rollingCode = atoi(asyncParam(request, "rcode").c_str()); + if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str()); + } + if(frame.remoteAddress > 0 && frame.rollingCode > 0) { + if(somfy.sendFrame(frame, repeats)) + request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + else + sendRadioUnavailable(request); + } + else + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); + }); + + // setgeneral + server.addHandler(new AsyncCallbackJsonWebHandler("/setgeneral", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + JsonObject obj = json.as(); + if(!obj["hostname"].isNull() || !obj["ssdpBroadcast"].isNull() || !obj["checkForUpdate"].isNull()) { + bool checkForUpdate = settings.checkForUpdate; + settings.fromJSON(obj); + settings.save(); + if(settings.checkForUpdate != checkForUpdate) git.emitUpdateCheck(); + if(!obj["hostname"].isNull()) net.updateHostname(); + } + if(!obj["ntpServer"].isNull() || !obj["ntpServer"].isNull()) { + settings.NTP.fromJSON(obj); + settings.NTP.save(); + } + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set General Settings\"}"); + })); + + // setNetwork + server.addHandler(new AsyncCallbackJsonWebHandler("/setNetwork", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(400, _encoding_html, "Error parsing JSON body"); + return; + } + JsonObject obj = json.as(); + bool reboot = false; + if(!obj["connType"].isNull() && obj["connType"].as() != static_cast(settings.connType)) { + settings.connType = static_cast(obj["connType"].as()); + settings.save(); + reboot = true; + } + if(!obj["wifi"].isNull()) { + JsonObject objWifi = obj["wifi"]; + if(settings.connType == conn_types_t::wifi) { + if(!objWifi["ssid"].isNull() && objWifi["ssid"].as().compareTo(settings.WIFI.ssid) != 0) { + if(WiFi.softAPgetStationNum() == 0) reboot = true; + } + if(!objWifi["passphrase"].isNull() && objWifi["passphrase"].as().compareTo(settings.WIFI.passphrase) != 0) { + if(WiFi.softAPgetStationNum() == 0) reboot = true; + } + } + settings.WIFI.fromJSON(objWifi); + settings.WIFI.save(); + } + if(!obj["ethernet"].isNull()) { + JsonObject objEth = obj["ethernet"]; + if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref) + reboot = true; +#ifndef CONFIG_IDF_TARGET_ESP32C6 + settings.Ethernet.fromJSON(objEth); + settings.Ethernet.save(); +#endif + } + if(reboot) { + ESP_LOGI(TAG, "Rebooting ESP for new Network settings..."); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 1000; + } + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); + })); + + // setIP + server.addHandler(new AsyncCallbackJsonWebHandler("/setIP", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + ESP_LOGD(TAG, "Setting IP..."); + JsonObject obj = json.as(); + settings.IP.fromJSON(obj); + settings.IP.save(); + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); + })); + + // connectwifi + server.addHandler(new AsyncCallbackJsonWebHandler("/connectwifi", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + JsonObject obj = json.as(); + ESP_LOGD(TAG, "Settings WIFI connection..."); + String ssid = ""; + String passphrase = ""; + if(!obj["ssid"].isNull()) ssid = obj["ssid"].as(); + if(!obj["passphrase"].isNull()) passphrase = obj["passphrase"].as(); + bool reboot = false; + if(ssid.compareTo(settings.WIFI.ssid) != 0) reboot = true; + if(passphrase.compareTo(settings.WIFI.passphrase) != 0) reboot = true; + if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) { + request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}"); + } + else { + SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid)); + SETCHARPROP(settings.WIFI.passphrase, passphrase.c_str(), sizeof(settings.WIFI.passphrase)); + settings.WIFI.save(); + settings.WIFI.print(); + request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}"); + if(reboot) { + ESP_LOGI(TAG, "Rebooting ESP for new WiFi settings..."); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 1000; + } + } + })); + + // modulesettings + server.on("/modulesettings", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + resp.addElem("fwVersion", settings.fwVersion.name); + resp.addElem("ssdpBroadcast", settings.ssdpBroadcast); + resp.addElem("hostname", settings.hostname); + resp.addElem("connType", static_cast(settings.connType)); + resp.addElem("chipModel", settings.chipModel); + resp.addElem("checkForUpdate", settings.checkForUpdate); + resp.addElem("ntpServer", settings.NTP.ntpServer); + resp.addElem("posixZone", settings.NTP.posixZone); + resp.endObject(); + resp.endResponse(); + }); + + // networksettings + server.on("/networksettings", HTTP_GET, [](AsyncWebServerRequest *request) { + JsonDocument doc; + JsonObject obj = doc.to(); + settings.toJSON(obj); + obj["fwVersion"] = settings.fwVersion.name; + JsonObject eth = obj.createNestedObject("ethernet"); +#ifndef CONFIG_IDF_TARGET_ESP32C6 + settings.Ethernet.toJSON(eth); +#endif + JsonObject wifi = obj.createNestedObject("wifi"); + settings.WIFI.toJSON(wifi); + JsonObject ip = obj.createNestedObject("ip"); + settings.IP.toJSON(ip); + serializeJson(doc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + }); + + // connectmqtt + server.addHandler(new AsyncCallbackJsonWebHandler("/connectmqtt", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + JsonObject obj = json.as(); + ESP_LOGD(TAG, "Saving MQTT"); + mqtt.disconnect(); + settings.MQTT.fromJSON(obj); + settings.MQTT.save(); + JsonDocument sdoc; + JsonObject sobj = sdoc.to(); + settings.MQTT.toJSON(sobj); + serializeJson(sdoc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + })); + + // mqttsettings + server.on("/mqttsettings", HTTP_GET, [](AsyncWebServerRequest *request) { + JsonDocument doc; + JsonObject obj = doc.to(); + settings.MQTT.toJSON(obj); + serializeJson(doc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + }); + + // Sort orders: the ids in the order the client lists them. + server.addHandler(editHandler("/roomSortOrder", model_edit_type_t::room_order)); + server.addHandler(editHandler("/shadeSortOrder", model_edit_type_t::shade_order)); + server.addHandler(editHandler("/groupSortOrder", model_edit_type_t::group_order)); + + server.on("/beginFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { + somfy.transceiver.beginFrequencyScan(); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/endFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { + somfy.transceiver.endFrequencyScan(); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/recoverFilesystem", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { + if(git.status == GIT_UPDATING) + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Filesystem is updating. Please wait!!!\"}"); + else if(git.status != GIT_STATUS_READY) + request->send(200, "application/json", "{\"status\":\"ERROR\",\"desc\":\"Cannot recover file system at this time.\"}"); + else { + git.recoverFilesystem(); + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Recovering filesystem from github please wait!!!\"}"); + } + }); + + server.onNotFound([](AsyncWebServerRequest *r) { + if(r->method() == HTTP_OPTIONS) { r->send(200); return; } + webServer.handleNotFound(r); + }); + + // serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them + server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); + +} diff --git a/src/outputs/SomfyMqtt.cpp b/src/outputs/MqttPublisher.cpp similarity index 99% rename from src/outputs/SomfyMqtt.cpp rename to src/outputs/MqttPublisher.cpp index 7fe7cf3..7191c02 100644 --- a/src/outputs/SomfyMqtt.cpp +++ b/src/outputs/MqttPublisher.cpp @@ -2,13 +2,13 @@ #include "ConfigSettings.h" #include "Somfy.h" #include "SomfySnapshot.h" -#include "Sockets.h" -#include "entryPoints/MQTT.h" +#include "SocketEmitter.h" +#include "MqttConnection.h" extern SomfyShadeController somfy; extern SocketEmitter sockEmit; extern ConfigSettings settings; -extern MQTTClass mqtt; +extern MqttConnection mqtt; void SomfyRoom::publish() { if(mqtt.connected()) { diff --git a/src/outputs/Sockets.cpp b/src/outputs/SocketEmitter.cpp similarity index 99% rename from src/outputs/Sockets.cpp rename to src/outputs/SocketEmitter.cpp index 3e887e1..334633f 100644 --- a/src/outputs/Sockets.cpp +++ b/src/outputs/SocketEmitter.cpp @@ -2,7 +2,7 @@ #include #include #include "esp_log.h" -#include "Sockets.h" +#include "SocketEmitter.h" #include "ConfigSettings.h" #include "Somfy.h" #include "ESPNetwork.h" diff --git a/src/outputs/Sockets.h b/src/outputs/SocketEmitter.h similarity index 100% rename from src/outputs/Sockets.h rename to src/outputs/SocketEmitter.h diff --git a/src/outputs/SomfyEvents.cpp b/src/outputs/SomfyEvents.cpp index 116f196..2f9e28d 100644 --- a/src/outputs/SomfyEvents.cpp +++ b/src/outputs/SomfyEvents.cpp @@ -1,12 +1,12 @@ #include #include "Somfy.h" #include "SomfyEditor.h" -#include "Sockets.h" -#include "entryPoints/MQTT.h" +#include "SocketEmitter.h" +#include "MqttConnection.h" extern SomfyShadeController somfy; extern SocketEmitter sockEmit; -extern MQTTClass mqtt; +extern MqttConnection mqtt; void SomfyShade::emitState() { this->changed |= SHADE_CHANGED_STATE; } void SomfyShade::emitState(const char *evt) { this->emitState(255, evt); } diff --git a/test/test_mqttcommands/test_mqttcommands.cpp b/test/test_mqttcommands/test_mqttcommands.cpp index aa5f913..00066e9 100644 --- a/test/test_mqttcommands/test_mqttcommands.cpp +++ b/test/test_mqttcommands/test_mqttcommands.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "entryPoints/MqttCommands.h" static shade_request_t req; @@ -137,6 +138,14 @@ static void test_group_sunny_and_windy() { TEST_ASSERT_TRUE(parse("espsomfy/groups/2/windy/set", "1")); TEST_ASSERT_EQUAL_INT8(1, req.windy); } +static void test_every_subscribed_topic_is_understood() { + // What the connection subscribes to and what the parser takes can't drift. + for(const char *pattern : MQTT_COMMAND_TOPICS) { + std::string topic = std::string("espsomfy/") + pattern; + topic.replace(topic.find('+'), 1, "3"); + TEST_ASSERT_TRUE_MESSAGE(parse(topic.c_str(), "1"), topic.c_str()); + } +} static void test_groups_only_take_their_commands() { TEST_ASSERT_FALSE(parse("espsomfy/groups/2/target/set", "30")); TEST_ASSERT_FALSE(parse("espsomfy/groups/2/mypos/set", "30")); @@ -161,5 +170,6 @@ int main(int argc, char **argv) { RUN_TEST(test_group_direction_and_sun_flag); RUN_TEST(test_group_sunny_and_windy); RUN_TEST(test_groups_only_take_their_commands); + RUN_TEST(test_every_subscribed_topic_is_understood); return UNITY_END(); }