mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 22:02:17 +01:00
Merge pull request #44 from cjkas/refactor/split-transports
refactor: split the transports into the modules GLOSSARY.md names
This commit is contained in:
commit
1e4fa74efa
21 changed files with 1296 additions and 1066 deletions
|
|
@ -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.
|
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
|
_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 modules
|
||||||
|
|
||||||
**Domain module**
|
**Domain module**
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Large websocket events are dropped
|
||||||
|
|
||||||
**Status:** open
|
**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
|
### 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.
|
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.
|
Reached from every route that serializes a group: `/controller`, `/discovery`, `/groups`, `/group`, `/groupCommand`, `/addGroup`, `/saveGroup`, `/linkToGroup`, `/unlinkFromGroup` and others.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Plan: domain modules behind the LoopQueue
|
# 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).
|
**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
|
## 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.
|
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
|
### 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:
|
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`.
|
1. Take a backup through `/backup`.
|
||||||
|
|
|
||||||
110
docs/SHADE_BEHAVIOUR_PLAN.md
Normal file
110
docs/SHADE_BEHAVIOUR_PLAN.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Plan: deepen the shade behaviour
|
||||||
|
|
||||||
|
**Status:** design in progress. Round 1 of the design questions is open (below); nothing is built.
|
||||||
|
**Written:** 2026-10-16, on top of `refactor/split-transports` (PR #44, the top of the LoopQueue stack #40–#44, which is not yet merged or run on a device). Line numbers refer to that branch.
|
||||||
|
**Start here:** read [GLOSSARY.md](../GLOSSARY.md), [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md) and [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md) first. Their decisions are not reopened here.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
This is candidate #1 of the architecture review, the one ranked first. The LoopQueue plan (Q7 there) deliberately did it second, so that the bugs in front of the shade could be fixed first. Steps 1–5 rebuilt everything around the shade. The shade's own behaviour is still about 1,000 lines in [src/SomfyShade.cpp](../src/SomfyShade.cpp).
|
||||||
|
|
||||||
|
### The same rule exists in several drifted copies
|
||||||
|
|
||||||
|
What a shade does for Up, Down or My is written out separately in each place it can be triggered from:
|
||||||
|
|
||||||
|
| Function | Triggered by |
|
||||||
|
|---|---|
|
||||||
|
| `processFrame` | a frame received from a remote, and the shade's own echoed frame (see below) |
|
||||||
|
| `processWaitingFrame` | a remote button held (long press, tilt) |
|
||||||
|
| `processInternalCommand` | a group command reaching its member shades |
|
||||||
|
| `sendCommand`, `sendTiltCommand`, `moveToTarget`, `moveToTiltTarget`, `setMyPosition`, `moveToMyPosition` | commands from the web, MQTT and Home Assistant, through ShadeRequests and `SomfyShadeOps` |
|
||||||
|
| `checkMovement` | every `loop()` pass: simulated travel, arriving at the target, sending the stop, storing the favorite |
|
||||||
|
| `processTxEvent` | the radio reporting a job started, finished, aborted or dropped |
|
||||||
|
|
||||||
|
Known drift:
|
||||||
|
- **Up on a tilt-only shade.** `sendCommand` and `processInternalCommand` set target 100, position 100 and tilt target 0. `processFrame` sets only the tilt target.
|
||||||
|
- **My while a tilt-only shade moves.** `processInternalCommand` sets the lift target to 100; `processFrame` leaves the lift alone.
|
||||||
|
- **The four "arrived at target" blocks in `checkMovement`** aren't symmetric. The tilt blocks clear `settingTiltPos`; the lift blocks never clear `settingPos`, which may send an extra stop on a later arrival. This is on the "still open" list in LOOP_QUEUE_PLAN.md, not confirmed on a device. Their stop conditions also differ by hand-written literals.
|
||||||
|
- **`setMyPosition`** has three near-copy branches (tilt-only, tilt, none).
|
||||||
|
|
||||||
|
### None of it is tested
|
||||||
|
|
||||||
|
The native tests can't build it. It reads `millis()`, sends through the radio, and uses the global `somfy`. Only the helpers it calls are tested: `travelPosition`, `stepTargets`, `ShadeSensors`, `TxHold`/`ShadeTxState` and `requestedFavoriteTilt` (`ShadeMotion`, `ShadeSensors`). The fixes just before this work (set-favorite tilt, rolling codes on the main loop) were in this code.
|
||||||
|
|
||||||
|
## What the shade code depends on today
|
||||||
|
|
||||||
|
- **Time:** `millis()` throughout `checkMovement`, `processFrame` and the senders.
|
||||||
|
- **Radio:** every send goes through `SomfyRemote::sendCommandAfter` ([src/SomfyRemote.cpp](../src/SomfyRemote.cpp), around lines 61–108). It:
|
||||||
|
1. takes the next rolling code from NVS;
|
||||||
|
2. encodes the frame and submits it with `somfy.sendFrame()`, getting a ticket, or 0 when the radio refuses;
|
||||||
|
3. commits the rolling code;
|
||||||
|
4. sets `lastFrame`;
|
||||||
|
5. calls `somfy.processFrame(lastFrame, internal = true)`;
|
||||||
|
6. calls `somfy.markTxPending()` for commands that start movement.
|
||||||
|
- **The echo (step 5 above).** `somfy.processFrame()` runs `processFrame` on every shade, the sender included. So part of what a command does to the sending shade happens in `sendCommand`, and part in its own `processFrame(internal)` on the echoed frame. The same echo moves any other shade that has the sender's address as a linked remote. GPIO-driven shades (`GP_Remote`/`GP_Relay`) skip the radio but still echo.
|
||||||
|
- **Groups.** A group command sends the group's frame, then calls `processInternalCommand` on each member shade ([src/SomfyGroup.cpp](../src/SomfyGroup.cpp)).
|
||||||
|
- **The global `somfy`:** `isDirty`/`commit()` for saving positions and the favorite, `updateGroupFlags()` after sensor and sun-flag changes, `transceiver.config` for bit length and protocol, and `groups` in `isInGroup()`.
|
||||||
|
- **GPIO:** `triggerGPIOs`, `setGPIOs` and `usesPin` for dry contacts and GPIO motors.
|
||||||
|
- **Outputs:** only `changed` bits since step 4. The change fan-out publishes them (`ShadeChanges.h`, `SomfyShadeController::sendChanges()`).
|
||||||
|
|
||||||
|
## What makes it easier now
|
||||||
|
|
||||||
|
- **Callers:** ShadeRequests reaches the shade only through the `ShadeOps` seam ([src/ShadeRequests.h](../src/ShadeRequests.h)). The real implementation is `SomfyShadeOps`, and a recording fake exists in `test/test_shaderequests`. Once the behaviour module exists, only `SomfyShadeOps` changes.
|
||||||
|
- **Outputs:** the shade only marks what changed (step 4).
|
||||||
|
- **Concurrency:** only `loop()` touches shades (ADR-0002), so the module is single-threaded code.
|
||||||
|
- **Snapshots:** `shade_snapshot_t` and `snapshotShade()` already describe a shade as clients see it, which is useful for test assertions.
|
||||||
|
|
||||||
|
## The goal
|
||||||
|
|
||||||
|
One module holding the shade's behaviour behind a small interface:
|
||||||
|
- **Inputs:** a received frame, a command, a group command, a radio event, and a tick, each with the current time.
|
||||||
|
- **Sending:** goes out through a seam that returns a ticket or a refusal. On the device that is the radio path; in tests, a fake that records what was sent.
|
||||||
|
- **Inside:** one rule per command and tilt type.
|
||||||
|
- **Tested** natively, by scenarios such as: *an integrated-tilt shade at 0%; a remote sends Down; at 1.5 s it is still tilting; at 4 s it is lifting at about 25%; reaching the 40% target sends a stop; the radio drops the stop and the shade runs on to 100%.*
|
||||||
|
|
||||||
|
## Design questions
|
||||||
|
|
||||||
|
The decisions are the user's. Each question comes with a recommended answer, and they are asked in rounds, as in the LoopQueue plan. Settled answers move to "Decisions".
|
||||||
|
|
||||||
|
### Round 1 (open)
|
||||||
|
|
||||||
|
**Q1. What moves into the module.**
|
||||||
|
- **Into the module:** everything that decides where the shade is and where it is going: `processFrame`, `processWaitingFrame`, `processInternalCommand`, `sendCommand`, `sendTiltCommand`, `moveToTarget`, `moveToTiltTarget`, `setMyPosition`, `moveToMyPosition`, `checkMovement` and `processTxEvent`. Their state goes too: positions, targets, directions, start times, the `setting*` flags, the favorite, `ShadeSensors` and `ShadeTxState`.
|
||||||
|
- **Stays in `SomfyShade`:** identity and settings (id, name, remotes, types, times), persistence, GPIO, JSON and rolling codes.
|
||||||
|
|
||||||
|
Recommended: yes.
|
||||||
|
|
||||||
|
**Q2. Three phases: pin, move, reconcile.**
|
||||||
|
1. **Pin:** native scenario tests of today's behaviour, including where the copies disagree, each labelled "today: …". Every later behaviour change then shows up as a test change.
|
||||||
|
2. **Move:** the code goes into the module unchanged, and the pinning tests stay green. Today's code can't be built natively, so phases 1 and 2 happen together: the seams go in while the tests are written against the moved, unchanged code.
|
||||||
|
3. **Reconcile:** the copies are made to agree, one decision per commit, each checked on real motors.
|
||||||
|
|
||||||
|
Recommended: yes.
|
||||||
|
|
||||||
|
**Q3. Time is passed in.** Every input carries `now` (`onFrame(frame, now)`, `tick(now)`, ...), as `ShadeSensors::check(…, now)` and `TxHold` already do. The alternative is a clock interface the module asks. Recommended: pass it in.
|
||||||
|
|
||||||
|
**Q4. One behaviour object per shade, settings as plain data.** Each `SomfyShade` holds one module object with the motion state. Its settings come in as a plain struct: shade type, tilt type, up/down/tilt times, step size, `simMy`, `flipCommands`. The alternative is functions with no state of their own, over a state struct the caller owns. Recommended: an object per shade.
|
||||||
|
|
||||||
|
### Round 2 (waits for round 1)
|
||||||
|
|
||||||
|
- **The echo.** Should the module apply its own commands directly, instead of reacting to its own echoed frame? This is the biggest decision; it decides what the radio seam looks like, and whether linked shades still learn about the command from the echo.
|
||||||
|
- **The radio seam.** What it takes (command, repeats, step size, delay, priority) and returns (ticket or refused). Rolling codes, NVS and frame encoding stay behind it in `SomfyRemote`. GPIO-driven shades sit behind the same seam.
|
||||||
|
- **Linked remotes and groups.** How a frame from a linked remote and a group command reach the module, and whether `processInternalCommand` stays a separate input.
|
||||||
|
- **The global `somfy`.** Where `isDirty`/`commit()` go (a change bit the caller acts on?), `updateGroupFlags()` (the controller after the pass?), `transceiver.config` (part of the settings struct?), and `isInGroup()` (given by the caller?).
|
||||||
|
|
||||||
|
### Later rounds
|
||||||
|
|
||||||
|
- The names, which go into GLOSSARY.md (for example **ShadeBehaviour**, **MotorLink**). Check for clashes with `ShadeMotion`, `ShadeOps`, `ShadeRequests` and `ShadeChanges`.
|
||||||
|
- The order of the steps and the PR split.
|
||||||
|
- What the reconcile phase decides first: Up/Down/My on tilt-only shades, `settingPos` on arrival.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **[AGENTS.md](../AGENTS.md):** every change keeps `pio test -e native` green and adds or updates tests.
|
||||||
|
- **Flash:** check every commit. `esp32dev` (release, with OTA) has about 180 KB free. The debug build uses one 3 MB slot once #39 is in its history. Every file that uses ArduinoJson costs flash, so keep the new module free of it.
|
||||||
|
- **`ShadeMotion`, `ShadeSensors`, `ShadeTxState` and `stepTargets`** are deep and tested. Reuse them; don't re-derive them.
|
||||||
|
- **Vocabulary:** module, interface, seam, adapter, depth (the `/codebase-design` skill) and the domain terms in GLOSSARY.md.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
None yet.
|
||||||
|
|
@ -14,17 +14,17 @@ static inline void safe_wdt_reset() {
|
||||||
#include "ConfigSettings.h"
|
#include "ConfigSettings.h"
|
||||||
#include "ESPNetwork.h"
|
#include "ESPNetwork.h"
|
||||||
#include "entryPoints/Web.h"
|
#include "entryPoints/Web.h"
|
||||||
#include "outputs/Sockets.h"
|
#include "outputs/SocketEmitter.h"
|
||||||
#include "Utils.h"
|
#include "Utils.h"
|
||||||
#include "SSDP.h"
|
#include "SSDP.h"
|
||||||
#include "entryPoints/MQTT.h"
|
#include "MqttConnection.h"
|
||||||
|
|
||||||
static const char *TAG = "Network";
|
static const char *TAG = "Network";
|
||||||
|
|
||||||
extern ConfigSettings settings;
|
extern ConfigSettings settings;
|
||||||
extern Web webServer;
|
extern Web webServer;
|
||||||
extern SocketEmitter sockEmit;
|
extern SocketEmitter sockEmit;
|
||||||
extern MQTTClass mqtt;
|
extern MqttConnection mqtt;
|
||||||
extern rebootDelay_t rebootDelay;
|
extern rebootDelay_t rebootDelay;
|
||||||
extern ESPNetwork net;
|
extern ESPNetwork net;
|
||||||
extern SomfyShadeController somfy;
|
extern SomfyShadeController somfy;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
#include "ConfigSettings.h"
|
#include "ConfigSettings.h"
|
||||||
#include "GitOTA.h"
|
#include "GitOTA.h"
|
||||||
#include "Utils.h"
|
#include "Utils.h"
|
||||||
#include "outputs/Sockets.h"
|
#include "outputs/SocketEmitter.h"
|
||||||
#include "Somfy.h"
|
#include "Somfy.h"
|
||||||
#include "entryPoints/Web.h"
|
#include "entryPoints/Web.h"
|
||||||
#include "WResp.h"
|
#include "WResp.h"
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
#include <esp_task_wdt.h>
|
#include <esp_task_wdt.h>
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "ConfigSettings.h"
|
#include "ConfigSettings.h"
|
||||||
#include "MQTT.h"
|
#include "MqttConnection.h"
|
||||||
#include "MqttCommands.h"
|
#include "entryPoints/MqttCommands.h"
|
||||||
#include "Somfy.h"
|
#include "Somfy.h"
|
||||||
#include "LoopQueue.h"
|
#include "LoopQueue.h"
|
||||||
#include "ESPNetwork.h"
|
#include "ESPNetwork.h"
|
||||||
|
|
@ -26,21 +26,21 @@ extern rebootDelay_t rebootDelay;
|
||||||
extern LoopQueue loopQueue;
|
extern LoopQueue loopQueue;
|
||||||
|
|
||||||
|
|
||||||
bool MQTTClass::begin() {
|
bool MqttConnection::begin() {
|
||||||
this->suspended = false;
|
this->suspended = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool MQTTClass::end() {
|
bool MqttConnection::end() {
|
||||||
this->suspended = true;
|
this->suspended = true;
|
||||||
this->disconnect();
|
this->disconnect();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
void MQTTClass::reset() {
|
void MqttConnection::reset() {
|
||||||
this->disconnect();
|
this->disconnect();
|
||||||
this->lastConnect = 0;
|
this->lastConnect = 0;
|
||||||
this->connect();
|
this->connect();
|
||||||
}
|
}
|
||||||
bool MQTTClass::loop() {
|
bool MqttConnection::loop() {
|
||||||
if(settings.MQTT.enabled && !rebootDelay.reboot && !this->suspended && !mqttClient.connected()) {
|
if(settings.MQTT.enabled && !rebootDelay.reboot && !this->suspended && !mqttClient.connected()) {
|
||||||
esp_task_wdt_reset();
|
esp_task_wdt_reset();
|
||||||
if(!this->connected() && net.connected()) this->connect();
|
if(!this->connected() && net.connected()) this->connect();
|
||||||
|
|
@ -49,7 +49,7 @@ bool MQTTClass::loop() {
|
||||||
if(settings.MQTT.enabled) mqttClient.loop();
|
if(settings.MQTT.enabled) mqttClient.loop();
|
||||||
return true;
|
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_task_wdt_reset(); // Make sure we do not reboot here.
|
||||||
ESP_LOGD(TAG, "MQTT Topic:%s payload:%.*s", topic, (int)length, (const char *)payload);
|
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
|
// 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);
|
loopQueue.submit(item);
|
||||||
}
|
}
|
||||||
bool MQTTClass::connect() {
|
bool MqttConnection::connect() {
|
||||||
esp_task_wdt_reset(); // Make sure we do not reboot here.
|
esp_task_wdt_reset(); // Make sure we do not reboot here.
|
||||||
if(mqttClient.connected()) {
|
if(mqttClient.connected()) {
|
||||||
if(!settings.MQTT.enabled || this->suspended)
|
if(!settings.MQTT.enabled || this->suspended)
|
||||||
|
|
@ -88,21 +88,8 @@ bool MQTTClass::connect() {
|
||||||
this->publish("serverId", settings.serverId, true);
|
this->publish("serverId", settings.serverId, true);
|
||||||
this->publish("mac", net.mac.c_str());
|
this->publish("mac", net.mac.c_str());
|
||||||
somfy.publish();
|
somfy.publish();
|
||||||
this->subscribe("shades/+/target/set");
|
for(const char *topic : MQTT_COMMAND_TOPICS) this->subscribe(topic);
|
||||||
this->subscribe("shades/+/tiltTarget/set");
|
mqttClient.setCallback(MqttConnection::receive);
|
||||||
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);
|
|
||||||
ESP_LOGI(TAG, "MQTT Startup Completed");
|
ESP_LOGI(TAG, "MQTT Startup Completed");
|
||||||
esp_task_wdt_reset();
|
esp_task_wdt_reset();
|
||||||
this->lastConnect = millis();
|
this->lastConnect = millis();
|
||||||
|
|
@ -119,28 +106,14 @@ bool MQTTClass::connect() {
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool MQTTClass::disconnect() {
|
bool MqttConnection::disconnect() {
|
||||||
if(mqttClient.connected()) {
|
if(mqttClient.connected()) {
|
||||||
this->unsubscribe("shades/+/target/set");
|
for(const char *topic : MQTT_COMMAND_TOPICS) this->unsubscribe(topic);
|
||||||
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");
|
|
||||||
mqttClient.disconnect();
|
mqttClient.disconnect();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool MQTTClass::unsubscribe(const char *topic) {
|
bool MqttConnection::unsubscribe(const char *topic) {
|
||||||
if(mqttClient.connected()) {
|
if(mqttClient.connected()) {
|
||||||
char top[128];
|
char top[128];
|
||||||
if(strlen(settings.MQTT.rootTopic) > 0)
|
if(strlen(settings.MQTT.rootTopic) > 0)
|
||||||
|
|
@ -152,7 +125,7 @@ bool MQTTClass::unsubscribe(const char *topic) {
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool MQTTClass::subscribe(const char *topic) {
|
bool MqttConnection::subscribe(const char *topic) {
|
||||||
if(mqttClient.connected()) {
|
if(mqttClient.connected()) {
|
||||||
esp_task_wdt_reset(); // Make sure we do not reboot here.
|
esp_task_wdt_reset(); // Make sure we do not reboot here.
|
||||||
char top[128];
|
char top[128];
|
||||||
|
|
@ -165,7 +138,7 @@ bool MQTTClass::subscribe(const char *topic) {
|
||||||
}
|
}
|
||||||
return true;
|
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()) {
|
if(mqttClient.connected()) {
|
||||||
char top[128];
|
char top[128];
|
||||||
if(strlen(settings.MQTT.rootTopic) > 0)
|
if(strlen(settings.MQTT.rootTopic) > 0)
|
||||||
|
|
@ -178,11 +151,11 @@ bool MQTTClass::publish(const char *topic, const char *payload, bool retain) {
|
||||||
}
|
}
|
||||||
return false;
|
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);
|
snprintf(g_content, sizeof(g_content), "%u", val);
|
||||||
return this->publish(topic, g_content, retain);
|
return this->publish(topic, g_content, retain);
|
||||||
}
|
}
|
||||||
bool MQTTClass::unpublish(const char *topic) {
|
bool MqttConnection::unpublish(const char *topic) {
|
||||||
if(mqttClient.connected()) {
|
if(mqttClient.connected()) {
|
||||||
char top[128];
|
char top[128];
|
||||||
if(strlen(settings.MQTT.rootTopic) > 0)
|
if(strlen(settings.MQTT.rootTopic) > 0)
|
||||||
|
|
@ -199,7 +172,7 @@ bool MQTTClass::unpublish(const char *topic) {
|
||||||
// mqttClient.endPublish();
|
// 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;
|
size_t res;
|
||||||
uint16_t offset = 0;
|
uint16_t offset = 0;
|
||||||
uint16_t to_write = len;
|
uint16_t to_write = len;
|
||||||
|
|
@ -216,28 +189,28 @@ bool MQTTClass::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bo
|
||||||
mqttClient.endPublish();
|
mqttClient.endPublish();
|
||||||
return true;
|
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));
|
serializeJson(obj, g_content, sizeof(g_content));
|
||||||
this->publishBuffer(topic, (uint8_t *)g_content, strlen(g_content), retain);
|
this->publishBuffer(topic, (uint8_t *)g_content, strlen(g_content), retain);
|
||||||
return true;
|
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);
|
snprintf(g_content, sizeof(g_content), "%d", val);
|
||||||
return this->publish(topic, g_content, retain);
|
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);
|
snprintf(g_content, sizeof(g_content), "%u", val);
|
||||||
return this->publish(topic, g_content, retain);
|
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);
|
snprintf(g_content, sizeof(g_content), "%u", val);
|
||||||
return this->publish(topic, g_content, retain);
|
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");
|
snprintf(g_content, sizeof(g_content), "%s", val ? "true" : "false");
|
||||||
return this->publish(topic, g_content, retain);
|
return this->publish(topic, g_content, retain);
|
||||||
}
|
}
|
||||||
bool MQTTClass::connected() {
|
bool MqttConnection::connected() {
|
||||||
if(settings.MQTT.enabled) return mqttClient.connected();
|
if(settings.MQTT.enabled) return mqttClient.connected();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
#ifndef MQTT_H
|
#ifndef MQTT_CONNECTION_H
|
||||||
#define MQTT_H
|
#define MQTT_CONNECTION_H
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <PubSubClient.h>
|
#include <PubSubClient.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
class MQTTClass {
|
class MqttConnection {
|
||||||
public:
|
public:
|
||||||
uint64_t lastConnect = 0;
|
uint64_t lastConnect = 0;
|
||||||
bool suspended = false;
|
bool suspended = false;
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
#include "ConfigSettings.h"
|
#include "ConfigSettings.h"
|
||||||
#include "ESPNetwork.h"
|
#include "ESPNetwork.h"
|
||||||
#include "entryPoints/Web.h"
|
#include "entryPoints/Web.h"
|
||||||
#include "outputs/Sockets.h"
|
#include "outputs/SocketEmitter.h"
|
||||||
#include "Utils.h"
|
#include "Utils.h"
|
||||||
#include "Somfy.h"
|
#include "Somfy.h"
|
||||||
#include "entryPoints/MQTT.h"
|
#include "MqttConnection.h"
|
||||||
#include "LoopQueue.h"
|
#include "LoopQueue.h"
|
||||||
#include "ShadeRequests.h"
|
#include "ShadeRequests.h"
|
||||||
#include "SomfyShadeOps.h"
|
#include "SomfyShadeOps.h"
|
||||||
|
|
@ -35,7 +35,7 @@ SocketEmitter sockEmit;
|
||||||
ESPNetwork net;
|
ESPNetwork net;
|
||||||
rebootDelay_t rebootDelay;
|
rebootDelay_t rebootDelay;
|
||||||
SomfyShadeController somfy;
|
SomfyShadeController somfy;
|
||||||
MQTTClass mqtt;
|
MqttConnection mqtt;
|
||||||
GitUpdater git;
|
GitUpdater git;
|
||||||
SomfyShadeOps shadeOps;
|
SomfyShadeOps shadeOps;
|
||||||
ShadeRequests shadeRequests(shadeOps);
|
ShadeRequests shadeRequests(shadeOps);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
#include "Utils.h"
|
#include "Utils.h"
|
||||||
#include "ConfigSettings.h"
|
#include "ConfigSettings.h"
|
||||||
#include "Somfy.h"
|
#include "Somfy.h"
|
||||||
#include "outputs/Sockets.h"
|
#include "outputs/SocketEmitter.h"
|
||||||
|
|
||||||
static const char *TAG = "Somfy";
|
static const char *TAG = "Somfy";
|
||||||
|
|
||||||
|
|
|
||||||
52
src/entryPoints/ApiRoutes.cpp
Normal file
52
src/entryPoints/ApiRoutes.cpp
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,23 @@
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include "MqttCommands.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 {
|
namespace {
|
||||||
struct segment_t {
|
struct segment_t {
|
||||||
const char *s = "";
|
const char *s = "";
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,9 @@
|
||||||
// Turns a message on a command topic into a shade request. False for a topic
|
// Turns a message on a command topic into a shade request. False for a topic
|
||||||
// or value it doesn't take, which is dropped.
|
// 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);
|
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
|
#endif
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
45
src/entryPoints/WebRoutes.h
Normal file
45
src/entryPoints/WebRoutes.h
Normal 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
|
||||||
967
src/entryPoints/WebUiRoutes.cpp
Normal file
967
src/entryPoints/WebUiRoutes.cpp
Normal 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");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
#include "ConfigSettings.h"
|
#include "ConfigSettings.h"
|
||||||
#include "Somfy.h"
|
#include "Somfy.h"
|
||||||
#include "SomfySnapshot.h"
|
#include "SomfySnapshot.h"
|
||||||
#include "Sockets.h"
|
#include "SocketEmitter.h"
|
||||||
#include "entryPoints/MQTT.h"
|
#include "MqttConnection.h"
|
||||||
|
|
||||||
extern SomfyShadeController somfy;
|
extern SomfyShadeController somfy;
|
||||||
extern SocketEmitter sockEmit;
|
extern SocketEmitter sockEmit;
|
||||||
extern ConfigSettings settings;
|
extern ConfigSettings settings;
|
||||||
extern MQTTClass mqtt;
|
extern MqttConnection mqtt;
|
||||||
|
|
||||||
void SomfyRoom::publish() {
|
void SomfyRoom::publish() {
|
||||||
if(mqtt.connected()) {
|
if(mqtt.connected()) {
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <esp_task_wdt.h>
|
#include <esp_task_wdt.h>
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "Sockets.h"
|
#include "SocketEmitter.h"
|
||||||
#include "ConfigSettings.h"
|
#include "ConfigSettings.h"
|
||||||
#include "Somfy.h"
|
#include "Somfy.h"
|
||||||
#include "ESPNetwork.h"
|
#include "ESPNetwork.h"
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
#include <esp_task_wdt.h>
|
#include <esp_task_wdt.h>
|
||||||
#include "Somfy.h"
|
#include "Somfy.h"
|
||||||
#include "SomfyEditor.h"
|
#include "SomfyEditor.h"
|
||||||
#include "Sockets.h"
|
#include "SocketEmitter.h"
|
||||||
#include "entryPoints/MQTT.h"
|
#include "MqttConnection.h"
|
||||||
|
|
||||||
extern SomfyShadeController somfy;
|
extern SomfyShadeController somfy;
|
||||||
extern SocketEmitter sockEmit;
|
extern SocketEmitter sockEmit;
|
||||||
extern MQTTClass mqtt;
|
extern MqttConnection mqtt;
|
||||||
|
|
||||||
void SomfyShade::emitState() { this->changed |= SHADE_CHANGED_STATE; }
|
void SomfyShade::emitState() { this->changed |= SHADE_CHANGED_STATE; }
|
||||||
void SomfyShade::emitState(const char *evt) { this->emitState(255, evt); }
|
void SomfyShade::emitState(const char *evt) { this->emitState(255, evt); }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include <unity.h>
|
#include <unity.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <string>
|
||||||
#include "entryPoints/MqttCommands.h"
|
#include "entryPoints/MqttCommands.h"
|
||||||
|
|
||||||
static shade_request_t req;
|
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_TRUE(parse("espsomfy/groups/2/windy/set", "1"));
|
||||||
TEST_ASSERT_EQUAL_INT8(1, req.windy);
|
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() {
|
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/target/set", "30"));
|
||||||
TEST_ASSERT_FALSE(parse("espsomfy/groups/2/mypos/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_direction_and_sun_flag);
|
||||||
RUN_TEST(test_group_sunny_and_windy);
|
RUN_TEST(test_group_sunny_and_windy);
|
||||||
RUN_TEST(test_groups_only_take_their_commands);
|
RUN_TEST(test_groups_only_take_their_commands);
|
||||||
|
RUN_TEST(test_every_subscribed_topic_is_understood);
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue