ESPSomfy-RTS/src/entryPoints/WebUiRoutes.cpp
cjkas 8fa087aa89 fix: edit ops review findings
From the review of 7723711:
- The assembler is locked: a client closed for a full queue is closed
  by the task that was sending, loop(), whose disconnect dropped the
  buffer while async_tcp could be copying into it. A whole message is
  taken out of it, so a disconnect can't free it while it is read.
- loop() expires a message stalled for 10 s, freeing its buffer and
  telling its client, instead of holding it until another client came.
  A binary message is refused before it can touch another's buffer.
- Each edit takes only the fields its route reads; one it would ignore
  is refused by name. linkRemote's rolling code goes in under the
  route's name, so applyModelEdit is unchanged again.
- An edit refused for an id that doesn't exist replies not found
  (edit_result_t::missing). A reply that doesn't fit its buffer goes
  as an internal error, so the op still gets one.
- The TYPE table says what becomes of each op (proto_op_t), which the
  socket switches on. The reply address is one proto_reply_to_t. The
  routes and the ops start and queue an edit the same way
  (newModelEdit, submitModelEdit). The full-list texts and the offered
  remote address rule live in ModelEditor.
- e2e: the sort order test skips a list that it would renumber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:10:06 +02:00

902 lines
39 KiB
C++

#include <WiFi.h>
#include <LittleFS.h>
#include <Update.h>
#include "TaskWdt.h"
#include "esp_log.h"
#include "ConfigSettings.h"
#include "ConfigFile.h"
#include "Utils.h"
#include "Secrets.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 "ESPNetwork.h"
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
// 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 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) {
bool busy;
model_edit_t *edit = newModelEdit(type, busy);
if(!edit) {
if(busy) request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}"));
else sendQueueFull(request);
return;
}
edit->requestId = nextEditRequestId();
edit->doc.set(body);
const uint32_t requestId = edit->requestId;
if(!submitModelEdit(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<const char *> 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<JsonVariantConst>());
}
void registerWebUiRoutes(AsyncWebServer &server) {
server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) {
AsyncJsonResponse *response = new AsyncJsonResponse();
JsonObject root = response->getRoot().to<JsonObject>();
root["type"] = static_cast<uint8_t>(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("/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<JsonObject>();
opts.fromJSON(obj);
}
}
else {
ESP_LOGD(TAG, "No restore options sent. Using defaults...");
opts.shades = true;
}
// Checked here so the client hears about a bad file. It only reads
// the file; the shades are still changed on loop() alone.
if(!ShadeConfigFile::validateFile("/shades.tmp")) {
LittleFS.remove("/shades.tmp");
request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"This isn't a valid backup file, so nothing was restored.\"}"));
return;
}
// 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\"}");
}
else request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"The upload didn't complete, so nothing was restored.\"}"));
},
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
resetTaskWdt();
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<JsonObject>();
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<int8_t>();
if(!obj["tilt"].isNull()) tilt = obj["tilt"].as<int8_t>();
}
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<JsonObject>();
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<JsonObject>();
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");
}
}
resetTaskWdt();
});
// updateShadeConfig - file upload
server.on("/updateShadeConfig", HTTP_POST,
[](AsyncWebServerRequest *request) {
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");
}
}
resetTaskWdt();
});
server.on("/scanaps", HTTP_GET, [](AsyncWebServerRequest *request) {
const bool wdtPaused = pauseTaskWdt();
if(net.softAPOpened) WiFi.disconnect(false);
int n = WiFi.scanNetworks(false, true);
resumeTaskWdt(wdtPaused);
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("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<JsonObject>();
settings.Security.fromJSON(obj);
settings.Security.save();
char token[65];
webServer.createAPIToken(request->client()->remoteIP(), token);
JsonDocument sdoc;
JsonObject sobj = sdoc.to<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
String scmd;
if(!obj["address"].isNull()) frame.remoteAddress = obj["address"];
if(!obj["command"].isNull()) scmd = obj["command"].as<String>();
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<JsonObject>();
if(!obj["hostname"].isNull() || !obj["ssdpBroadcast"].isNull()) {
settings.fromJSON(obj);
settings.save();
if(!obj["hostname"].isNull()) net.updateHostname();
}
if(!obj["ntpServer"].isNull() || !obj["posixZone"].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<JsonObject>();
bool reboot = false;
if(!obj["connType"].isNull() && obj["connType"].as<uint8_t>() != static_cast<uint8_t>(settings.connType)) {
settings.connType = static_cast<conn_types_t>(obj["connType"].as<uint8_t>());
settings.save();
reboot = true;
}
if(!obj["wifi"].isNull()) {
JsonObject objWifi = obj["wifi"];
// Compared after fromJSON, which decides whether an empty passphrase keeps the stored one.
WifiSettings before = settings.WIFI;
settings.WIFI.fromJSON(objWifi);
settings.WIFI.save();
if(settings.connType == conn_types_t::wifi && WiFi.softAPgetStationNum() == 0 &&
(strcmp(before.ssid, settings.WIFI.ssid) != 0 || strcmp(before.passphrase, settings.WIFI.passphrase) != 0))
reboot = true;
}
if(!obj["ethernet"].isNull()) {
JsonObject objEth = obj["ethernet"];
if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref)
reboot = true;
#if HAS_ETHERNET
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<JsonObject>();
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<JsonObject>();
ESP_LOGD(TAG, "Settings WIFI connection...");
String ssid = "";
if(!obj["ssid"].isNull()) ssid = obj["ssid"].as<String>();
bool newSsid = ssid.compareTo(settings.WIFI.ssid) != 0;
// Clients never receive the stored passphrase: an omitted or empty one keeps it unless the SSID changes.
char passphrase[sizeof(settings.WIFI.passphrase)];
SETCHARPROP(passphrase, settings.WIFI.passphrase, sizeof(passphrase));
bool newPassphrase = applySecret(obj["passphrase"].as<const char *>(), passphrase, sizeof(passphrase), newSsid);
bool reboot = newSsid || newPassphrase;
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, 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<uint8_t>(settings.connType));
resp.addElem("chipModel", settings.chipModel);
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<JsonObject>();
settings.toJSON(obj);
obj["fwVersion"] = settings.fwVersion.name;
// The web UI hides the Ethernet settings when the chip has no MAC.
obj["hasEthernet"] = HAS_ETHERNET == 1;
JsonObject eth = obj.createNestedObject("ethernet");
#if HAS_ETHERNET
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<JsonObject>();
ESP_LOGD(TAG, "Saving MQTT");
mqtt.disconnect();
settings.MQTT.fromJSON(obj);
settings.MQTT.save();
JsonDocument sdoc;
JsonObject sobj = sdoc.to<JsonObject>();
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<JsonObject>();
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.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");
}