refactor: split the transports into the modules GLOSSARY.md names

Code moves unchanged; only files and names change.

- 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 keeps the servers' lifecycle, auth and the handlers
  both route sets call; entryPoints/WebRoutes.h is what the three share.
- entryPoints/MQTT.* -> MqttConnection.*, class MQTTClass -> MqttConnection.
- outputs/SomfyMqtt.cpp -> outputs/MqttPublisher.cpp.
- outputs/Sockets.* -> outputs/SocketEmitter.*.

The subscribe and unsubscribe lists (the latter repeated
groups/+/direction/set) become MqttCommands' MQTT_COMMAND_TOPICS, and a
native test checks the parser takes every topic in it. GLOSSARY.md gains
MqttConnection and Change fan-out.

328 native tests. Flash: esp32devdbg +3,112 bytes (the routes are a second
ArduinoJson file), esp32dev -2,336 bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-16 13:47:56 +02:00
parent 4c5bc01fe5
commit a0ad301779
20 changed files with 1186 additions and 1066 deletions

View file

@ -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**

View file

@ -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.

View file

@ -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`.

View file

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

View file

@ -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"

View file

@ -4,8 +4,8 @@
#include <esp_task_wdt.h>
#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;
}

View file

@ -1,9 +1,9 @@
#ifndef MQTT_H
#define MQTT_H
#ifndef MQTT_CONNECTION_H
#define MQTT_CONNECTION_H
#include <Arduino.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
class MQTTClass {
class MqttConnection {
public:
uint64_t lastConnect = 0;
bool suspended = false;

View file

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

View file

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

View file

@ -0,0 +1,52 @@
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
#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);
});
}

View file

@ -2,6 +2,23 @@
#include <string.h>
#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 = "";

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
#ifndef WEB_ROUTES_H
#define WEB_ROUTES_H
#include <ESPAsyncWebServer.h>
#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

View file

@ -0,0 +1,967 @@
#include <WiFi.h>
#include <LittleFS.h>
#include <Update.h>
#include <esp_task_wdt.h>
#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 <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 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<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("/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<JsonObject>();
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<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");
}
}
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<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() || !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<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"];
if(settings.connType == conn_types_t::wifi) {
if(!objWifi["ssid"].isNull() && objWifi["ssid"].as<String>().compareTo(settings.WIFI.ssid) != 0) {
if(WiFi.softAPgetStationNum() == 0) reboot = true;
}
if(!objWifi["passphrase"].isNull() && objWifi["passphrase"].as<String>().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<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 = "";
String passphrase = "";
if(!obj["ssid"].isNull()) ssid = obj["ssid"].as<String>();
if(!obj["passphrase"].isNull()) passphrase = obj["passphrase"].as<String>();
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<uint8_t>(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<JsonObject>();
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<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.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");
}

View file

@ -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()) {

View file

@ -2,7 +2,7 @@
#include <ArduinoJson.h>
#include <esp_task_wdt.h>
#include "esp_log.h"
#include "Sockets.h"
#include "SocketEmitter.h"
#include "ConfigSettings.h"
#include "Somfy.h"
#include "ESPNetwork.h"

View file

@ -1,12 +1,12 @@
#include <esp_task_wdt.h>
#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); }

View file

@ -1,5 +1,6 @@
#include <unity.h>
#include <string.h>
#include <string>
#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();
}