mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 22:02:17 +01:00
feat!: remove the old API, ports 8081 and 8080 (step 4a)
Step 4a of docs/SECURITY_PLAN.md. Port 80 keeps the eleven HTTP routes of ADR 0006 and the socket at /ws; everything else moved to protocol 2. - Port 8081 (ApiRoutes) and the socket on 8080 (SocketEmitter) are gone. Protocol 2 owns its frame buffer and loop-task check. - Port 80 drops every route the old web UI called: reads, edits, settings, scans, reboot, /shades.cfg, /discovery. HttpRoutes.cpp holds what stays. - ModelSnapshot, SnapshotSlots and the read documents are gone. The item snapshot types stay, as ItemSnapshot, for events, MQTT and the command replies. - Model edits reply only to their protocol 2 op: requestResult goes. - The settings' toJSON writers, used only by the settings routes, go. - The _espsomfy_rts._tcp mDNS record moves to port 80, with path=/ws. - The memory event reports the loop-rate window it closes. - A deleted room no longer re-publishes its retained MQTT topics. - e2e: the heap check reads the sync's status; the sync is checked on its own instead of against /controller. - README gets an API and ports section. BREAKING CHANGE: ports 8081 and 8080 and the old HTTP routes are gone. Home Assistant needs integration 3.0.0 or newer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c91c20d444
commit
2a954aa158
53 changed files with 613 additions and 3549 deletions
24
GLOSSARY.md
24
GLOSSARY.md
|
|
@ -86,13 +86,9 @@ The seam through which a shade's behaviour reports what happened: changes for th
|
|||
Where a request from outside reaches the firmware: an HTTP route, an MQTT command topic or a frame received from a remote.
|
||||
_Avoid_: controller (it already names the owner of the shade list and the `/controller` read)
|
||||
|
||||
**WebUiRoutes**
|
||||
The HTTP routes on port 80 that the config web UI calls, plus the UI's own files.
|
||||
_Avoid_: WebUiController, Web
|
||||
|
||||
**ApiRoutes**
|
||||
The HTTP routes on port 8081 that integrations such as Home Assistant call.
|
||||
_Avoid_: WebApiController
|
||||
**HttpRoutes**
|
||||
The HTTP routes on port 80: the web UI's files, login, the file transfers and the three commands for scripts. Everything else is an op on the socket.
|
||||
_Avoid_: WebUiRoutes, ApiRoutes, WebApiController
|
||||
|
||||
**MqttCommands**
|
||||
The MQTT topics ending in `/set` that command a shade or group.
|
||||
|
|
@ -102,10 +98,6 @@ _Avoid_: MQTTController
|
|||
What sends the state of rooms, shades and groups, and the Home Assistant discovery config, to the MQTT broker.
|
||||
_Avoid_: MQTTController
|
||||
|
||||
**SocketEmitter**
|
||||
What pushes state events to WebSocket clients on port 8080. A client can only join or leave a room; it sends no commands.
|
||||
_Avoid_: WebSocketController
|
||||
|
||||
**MqttConnection**
|
||||
The link to the MQTT broker that MqttCommands and MqttPublisher share: connecting, the root topic and the subscriptions.
|
||||
_Avoid_: MQTTClass
|
||||
|
|
@ -129,7 +121,7 @@ _Avoid_: increment, push
|
|||
|
||||
**Sync**
|
||||
How a client catches up with the device: the shade model and status, sent item by item at the client's pace, after which events keep it current. It describes no single moment.
|
||||
_Avoid_: snapshot (a ModelSnapshot is a copy of one moment), initial load
|
||||
_Avoid_: snapshot (an item snapshot is a copy of one moment), initial load
|
||||
|
||||
**Feed**
|
||||
A stream of events a client subscribes to on its own, such as received remote frames or a frequency scan.
|
||||
|
|
@ -173,10 +165,10 @@ The domain module that carries out shade requests, whichever entry point they ca
|
|||
The domain module that adds, changes, deletes, links and orders the rooms, shades, groups and repeaters of the shade model.
|
||||
_Avoid_: ShadeConfig, InstallationEditor
|
||||
|
||||
**ModelSnapshot**
|
||||
A copy of the shade model taken for one read, so the reply describes a single moment.
|
||||
_Avoid_: InstallationSnapshot
|
||||
**Item snapshot**
|
||||
A copy of one room, shade or group, which an event, an MQTT topic or a command reply is written from.
|
||||
_Avoid_: ModelSnapshot (the whole-model copy the HTTP reads used, removed in 4.0.0)
|
||||
|
||||
**LoopQueue**
|
||||
The single queue through which shade requests, model edits, reads and snapshot requests reach `loop()` from other tasks, in the order they arrived.
|
||||
The single queue through which shade requests, model edits, socket reads and settings ops, and restores reach `loop()` from other tasks, in the order they arrived.
|
||||
_Avoid_: web command queue
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -85,6 +85,20 @@ While the interface that comes with the ESPSomfy RTS is a huge improvement, the
|
|||
|
||||
You can find the documentation for the interfaces in the [Integrations](https://github.com/rstrouse/ESPSomfy-RTS/wiki/Integrations) wiki. Plenty of stuff there for you folks that make red nodes and stuff.
|
||||
|
||||
### API and ports
|
||||
Since 4.0.0 everything is on port 80. Ports 8081 (HTTP API) and 8080 (events socket) are gone, and so are the HTTP routes the old web UI called.
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Web UI | `http://<device>/` |
|
||||
| Commands for scripts and Node-RED | `/shadeCommand`, `/groupCommand` and `/tiltCommand`, with the same fields as before, as query parameters or a JSON body |
|
||||
| Login | `/loginContext` (the security mode), then `/login`, which returns the token in `apiKey` |
|
||||
| Backup and restore | `/backup`, `/restore` |
|
||||
| Firmware and web UI updates | `/updateFirmware`, `/updateApplication` |
|
||||
| Everything else: state, events, edits, settings | the socket at `ws://<device>/ws`, see [docs/PROTOCOL.md](docs/PROTOCOL.md) |
|
||||
|
||||
A script that called port 8081 only changes the port, if it used one of the three commands. Reads such as `/controller`, `/shades` and `/discovery` are gone: use the socket, or MQTT. The Home Assistant integration needs version 3.0.0 or newer.
|
||||
|
||||
## Sources for this Project
|
||||
I spent some time reading about a myriad of topics but in the end the primary source for this project comes from https://pushstack.wordpress.com/somfy-rts-protocol/. The work done on pushstack regarding the protocol timing made this feasible without burning a bunch of time measuring pulses.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,21 +18,12 @@
|
|||
|
||||
### Summary
|
||||
|
||||
The read routes stream a ModelSnapshot that `loop()` fills, and edits go to `loop()` through the LoopQueue ([ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md)). Command replies (`/shadeCommand`, `/tiltCommand`, `/unpairShade`, `/setRollingCode`, ...) still copy the one item they report from the live shade model on async_tcp, so a reply can show a value from the middle of a change. `/room`, `/shade`, `/group` and `/groupOptions` also check on the live model whether the item exists, because the status goes out before the snapshot is filled.
|
||||
|
||||
## A read of `/controller` took 2.4 s
|
||||
|
||||
**Status:** seen once, not explained.
|
||||
**Location:** the `/controller` route; the warning comes from `logElapsed` in [src/Timing.h](../src/Timing.h).
|
||||
|
||||
### Summary
|
||||
|
||||
On 2026-10-19 the log shows `web GET /controller from 192.168.1.197 total took 2353.274ms (slow)`. That was about 30 s after Home Assistant's socket connected, and 80 s after boot. Every other `/controller` read in the logs took 100-235 ms.
|
||||
The three HTTP commands for scripts (`/shadeCommand`, `/groupCommand`, `/tiltCommand`) copy the shade or group they report from the live shade model on async_tcp, so a reply can show a value from the middle of a change. The socket's ops reply from `loop()` and don't have this problem.
|
||||
|
||||
## `.gz does not exist` errors are log noise
|
||||
|
||||
**Status:** open. Cosmetic.
|
||||
**Location:** `serveStatic` in [src/entryPoints/WebUiRoutes.cpp](../src/entryPoints/WebUiRoutes.cpp); `MINIFY_AND_GZIP` in [minify.py](../minify.py).
|
||||
**Location:** `serveStatic` in [src/entryPoints/HttpRoutes.cpp](../src/entryPoints/HttpRoutes.cpp); `MINIFY_AND_GZIP` in [minify.py](../minify.py).
|
||||
|
||||
`serveStatic` tries `<file>.gz` first. For files stored without one (`appversion`, `icon.png`), LittleFS logs an `E`-level `does not exist, no permits for creation` line on every request. The fix would be for `minify.py` to gzip the plain files too; `appversion` must stay plain as well, because `ConfigSettings` reads it from LittleFS. `setTryGzipFirst(false)` doesn't help: the error comes from `exists()` probing the missing variant, so it would only move the noise to the gzip-only files.
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ Events go out to every client from the moment it connects, so they can arrive du
|
|||
|
||||
## Commands
|
||||
|
||||
Every op carries an `id` and gets one reply. The ops do what the HTTP routes of the same name do, with these arguments:
|
||||
Every op carries an `id` and gets one reply. The ops do what the HTTP routes of the same name did before 4.0.0, with these arguments:
|
||||
|
||||
| Op | Payload | Does |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Plan: authentication on every entry point, one HTTP server
|
||||
|
||||
**Status:** steps 1 to 3, PR B and PR C built; step 4 next.
|
||||
**Status:** steps 1 to 3, PR B and PR C merged; step 4 in progress: 4a removes the old API, 4b adds the auth rules.
|
||||
**Written:** 2026-10-18, on top of PR #64 (stored secrets are write-only). **Revised:** 2026-10-22 for socket protocol 2: two security modes, eleven HTTP routes, the socket on port 80 ([ADR 0005](adr/0005-one-websocket-protocol.md), [ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)). Terms are defined in [GLOSSARY.md](../GLOSSARY.md).
|
||||
|
||||
## Why
|
||||
|
|
@ -86,7 +86,8 @@ Protocol 2 lands as one PR per step, and `main` keeps working throughout because
|
|||
| 1d | firmware | settings ops (`getSettings`/`saveSettings` by section), the WiFi and frequency scans, reboot |
|
||||
| 2 | firmware | web UI on ops; the CSS files bundled; token in `sessionStorage`; banner on a version mismatch |
|
||||
| 3 | HA | protocol 2 with the old-firmware fallback; `/loginContext` setup; runtime login and reauth |
|
||||
| 4 | firmware | removals: the old routes, ports 8081 and 8080, `/discovery`, ModelSnapshot, "Secure Config Only"; static serving limited to web assets; CORS headers; Origin check |
|
||||
| 4a | firmware | removals: the old routes, ports 8081 and 8080, `/discovery`, ModelSnapshot; the mDNS record on port 80 |
|
||||
| 4b | firmware | "Secure Config Only" goes; the token on every non-public route; static serving limited to web assets; CORS headers; Origin check |
|
||||
|
||||
PR B (token key) and PR C (login throttle) are separate firmware PRs that can land at any point. The GitHub update was removed ahead of the rest ([ADR 0007](adr/0007-no-github-update.md)), as protocol 2 didn't fit beside it.
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ build_flags =
|
|||
platform = native
|
||||
test_framework = unity
|
||||
test_build_src = yes
|
||||
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<ShadeBehaviour.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ModelSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp> +<ProtoSchema.cpp> +<ProtoFrame.cpp> +<ProtoSync.cpp> +<ProtoParse.cpp> +<ProtoSlots.cpp> +<ProtoOps.cpp> +<ProtoEdits.cpp> +<ProtoSettings.cpp> +<ProtoAssembly.cpp>
|
||||
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<ShadeBehaviour.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ItemSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp> +<ProtoSchema.cpp> +<ProtoFrame.cpp> +<ProtoSync.cpp> +<ProtoParse.cpp> +<ProtoSlots.cpp> +<ProtoOps.cpp> +<ProtoEdits.cpp> +<ProtoSettings.cpp> +<ProtoAssembly.cpp>
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson@^7.2.2
|
||||
build_flags =
|
||||
|
|
|
|||
|
|
@ -80,21 +80,6 @@ void appver_t::parse(const char *ver) {
|
|||
this->build = static_cast<uint8_t>(atoi(num) & 0xFF);
|
||||
if(strlen(ver) < i) strlcpy(this->suffix, &ver[i], sizeof(this->suffix));
|
||||
}
|
||||
bool appver_t::toJSON(JsonObject &obj) {
|
||||
obj["name"] = this->name;
|
||||
obj["major"] = this->major;
|
||||
obj["minor"] = this->minor;
|
||||
obj["build"] = this->build;
|
||||
obj["suffix"] = this->suffix;
|
||||
return true;
|
||||
}
|
||||
void appver_t::toJSON(JsonSockEvent *json) {
|
||||
json->addElem("name", this->name);
|
||||
json->addElem("major", this->major);
|
||||
json->addElem("minor", this->minor);
|
||||
json->addElem("build", this->build);
|
||||
json->addElem("suffix", this->suffix);
|
||||
}
|
||||
|
||||
bool BaseSettings::load() { return true; }
|
||||
bool BaseSettings::loadFile(const char *filename) {
|
||||
|
|
@ -115,15 +100,6 @@ bool BaseSettings::loadFile(const char *filename) {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
bool BaseSettings::saveFile(const char *filename) {
|
||||
File file = LittleFS.open(filename, "w");
|
||||
JsonDocument doc;
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
this->toJSON(obj);
|
||||
serializeJson(doc, file);
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
bool BaseSettings::parseValueString(JsonObject &obj, const char *prop, char *pdest, size_t size) {
|
||||
if(!obj[prop].isNull()) strlcpy(pdest, obj[prop], size);
|
||||
return true;
|
||||
|
|
@ -234,13 +210,6 @@ bool ConfigSettings::save() {
|
|||
pref.end();
|
||||
return true;
|
||||
}
|
||||
bool ConfigSettings::toJSON(JsonObject &obj) {
|
||||
obj["ssdpBroadcast"] = this->ssdpBroadcast;
|
||||
obj["hostname"] = this->hostname;
|
||||
obj["connType"] = static_cast<uint8_t>(this->connType);
|
||||
obj["chipModel"] = this->chipModel;
|
||||
return true;
|
||||
}
|
||||
bool ConfigSettings::requiresAuth() { return this->Security.type != security_types::None; }
|
||||
bool ConfigSettings::fromJSON(JsonObject &obj) {
|
||||
if(!obj["ssdpBroadcast"].isNull()) this->ssdpBroadcast = obj["ssdpBroadcast"];
|
||||
|
|
@ -257,8 +226,6 @@ void ConfigSettings::print() {
|
|||
if(this->connType == conn_types_t::ethernet || this->connType == conn_types_t::ethernetpref) this->Ethernet.print();
|
||||
#endif
|
||||
}
|
||||
void ConfigSettings::emitSockets() {}
|
||||
void ConfigSettings::emitSockets(uint8_t num) {}
|
||||
uint16_t ConfigSettings::calcSettingsRecSize() {
|
||||
return strlen(this->fwVersion.name) + 3
|
||||
+ strlen(this->hostname) + 3
|
||||
|
|
@ -293,18 +260,6 @@ bool MQTTSettings::begin() {
|
|||
this->load();
|
||||
return true;
|
||||
}
|
||||
bool MQTTSettings::toJSON(JsonObject &obj) {
|
||||
obj["enabled"] = this->enabled;
|
||||
obj["pubDisco"] = this->pubDisco;
|
||||
obj["protocol"] = this->protocol;
|
||||
obj["hostname"] = this->hostname;
|
||||
obj["port"] = this->port;
|
||||
obj["username"] = this->username;
|
||||
obj["hasPassword"] = hasSecret(this->password);
|
||||
obj["rootTopic"] = this->rootTopic;
|
||||
obj["discoTopic"] = this->discoTopic;
|
||||
return true;
|
||||
}
|
||||
bool MQTTSettings::fromJSON(JsonObject &obj) {
|
||||
if(!obj["enabled"].isNull()) this->enabled = obj["enabled"];
|
||||
if(!obj["pubDisco"].isNull()) this->pubDisco = obj["pubDisco"];
|
||||
|
|
@ -351,16 +306,6 @@ bool MQTTSettings::load() {
|
|||
pref.end();
|
||||
return true;
|
||||
}
|
||||
bool ConfigSettings::toJSON(JsonDocument &doc) {
|
||||
doc["fwVersion"] = this->fwVersion.name;
|
||||
JsonObject objWIFI = doc.createNestedObject("WIFI");
|
||||
this->WIFI.toJSON(objWIFI);
|
||||
JsonObject objNTP = doc.createNestedObject("NTP");
|
||||
this->NTP.toJSON(objNTP);
|
||||
JsonObject objMQTT = doc.createNestedObject("MQTT");
|
||||
this->MQTT.toJSON(objMQTT);
|
||||
return true;
|
||||
}
|
||||
bool NTPSettings::begin() {
|
||||
this->load();
|
||||
this->apply();
|
||||
|
|
@ -391,11 +336,6 @@ bool NTPSettings::fromJSON(JsonObject &obj) {
|
|||
this->parseValueString(obj, "posixZone", this->posixZone, sizeof(this->posixZone));
|
||||
return true;
|
||||
}
|
||||
bool NTPSettings::toJSON(JsonObject &obj) {
|
||||
obj["ntpServer"] = this->ntpServer;
|
||||
obj["posixZone"] = this->posixZone;
|
||||
return true;
|
||||
}
|
||||
bool NTPSettings::apply() {
|
||||
// Sets the zone and starts SNTP, which sets the clock once the network is
|
||||
// up. Waiting here with getLocalTime() held up boot by 5 s before the
|
||||
|
|
@ -417,16 +357,6 @@ bool IPSettings::fromJSON(JsonObject &obj) {
|
|||
this->parseIPAddress(obj, "dns2", &this->dns2);
|
||||
return true;
|
||||
}
|
||||
bool IPSettings::toJSON(JsonObject &obj) {
|
||||
IPAddress ipEmpty(0,0,0,0);
|
||||
obj["dhcp"] = this->dhcp;
|
||||
obj["ip"] = this->ip == ipEmpty ? "" : this->ip.toString();
|
||||
obj["gateway"] = this->gateway == ipEmpty ? "" : this->gateway.toString();
|
||||
obj["subnet"] = this->subnet == ipEmpty ? "" : this->subnet.toString();
|
||||
obj["dns1"] = this->dns1 == ipEmpty ? "" : this->dns1.toString();
|
||||
obj["dns2"] = this->dns2 == ipEmpty ? "" : this->dns2.toString();
|
||||
return true;
|
||||
}
|
||||
bool IPSettings::save() {
|
||||
Preferences pref;
|
||||
pref.begin("IP");
|
||||
|
|
@ -481,14 +411,6 @@ bool SecuritySettings::fromJSON(JsonObject &obj) {
|
|||
if(!obj["permissions"].isNull()) this->permissions = obj["permissions"];
|
||||
return true;
|
||||
}
|
||||
bool SecuritySettings::toJSON(JsonObject &obj) {
|
||||
obj["type"] = static_cast<uint8_t>(this->type);
|
||||
obj["username"] = this->username;
|
||||
obj["hasPassword"] = hasSecret(this->password);
|
||||
obj["hasPin"] = hasSecret(this->pin);
|
||||
obj["permissions"] = this->permissions;
|
||||
return true;
|
||||
}
|
||||
bool SecuritySettings::save() {
|
||||
Preferences pref;
|
||||
pref.begin("SEC");
|
||||
|
|
@ -532,13 +454,6 @@ bool WifiSettings::fromJSON(JsonObject &obj) {
|
|||
if(!obj["hidden"].isNull()) this->hidden = obj["hidden"];
|
||||
return true;
|
||||
}
|
||||
bool WifiSettings::toJSON(JsonObject &obj) {
|
||||
obj["ssid"] = this->ssid;
|
||||
obj["hasPassphrase"] = hasSecret(this->passphrase);
|
||||
obj["roaming"] = this->roaming;
|
||||
obj["hidden"] = this->hidden;
|
||||
return true;
|
||||
}
|
||||
bool WifiSettings::save() {
|
||||
Preferences pref;
|
||||
pref.begin("WIFI");
|
||||
|
|
@ -615,16 +530,6 @@ bool EthernetSettings::fromJSON(JsonObject &obj) {
|
|||
if(!obj["MDIOPin"].isNull()) this->MDIOPin = obj["MDIOPin"];
|
||||
return true;
|
||||
}
|
||||
bool EthernetSettings::toJSON(JsonObject &obj) {
|
||||
obj["boardType"] = this->boardType;
|
||||
obj["phyAddress"] = this->phyAddress;
|
||||
obj["CLKMode"] = static_cast<uint8_t>(this->CLKMode);
|
||||
obj["phyType"] = this->phyType;
|
||||
obj["PWRPin"] = this->PWRPin;
|
||||
obj["MDCPin"] = this->MDCPin;
|
||||
obj["MDIOPin"] = this->MDIOPin;
|
||||
return true;
|
||||
}
|
||||
bool EthernetSettings::usesPin(uint8_t pin) {
|
||||
if((this->CLKMode == 0 || this->CLKMode == 1) && pin == 0) return true;
|
||||
else if(this->CLKMode == 2 && pin == 16) return true;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
#include "EthPhy.h"
|
||||
#ifndef configsettings_h
|
||||
#define configsettings_h
|
||||
#include "WResp.h"
|
||||
|
||||
#ifndef FW_VERSION
|
||||
#define FW_VERSION "v4.0.0.beta" // Fallback if app_version.py script fails
|
||||
|
|
@ -37,9 +36,6 @@ struct appver_t {
|
|||
uint8_t build = 0;
|
||||
char suffix[4] = "";
|
||||
void parse(const char *ver);
|
||||
bool toJSON(JsonObject &obj);
|
||||
|
||||
void toJSON(JsonSockEvent *json);
|
||||
int8_t compare(appver_t &ver);
|
||||
void copy(appver_t &ver);
|
||||
};
|
||||
|
|
@ -49,13 +45,11 @@ class BaseSettings {
|
|||
public:
|
||||
bool loadFile(const char* filename);
|
||||
bool fromJSON(JsonObject &obj);
|
||||
bool toJSON(JsonObject &obj);
|
||||
|
||||
bool parseIPAddress(JsonObject &obj, const char *prop, IPAddress *);
|
||||
bool parseValueString(JsonObject &obj, const char *prop, char *dest, size_t size);
|
||||
int parseValueInt(JsonObject &obj, const char *prop, int defVal);
|
||||
double parseValueDouble(JsonObject &obj, const char *prop, double defVal);
|
||||
bool saveFile(const char* filename);
|
||||
bool save();
|
||||
bool load();
|
||||
};
|
||||
|
|
@ -64,7 +58,6 @@ class NTPSettings: BaseSettings {
|
|||
char ntpServer[65] = "pool.ntp.org";
|
||||
char posixZone[64] = "";
|
||||
bool fromJSON(JsonObject &obj);
|
||||
bool toJSON(JsonObject &obj);
|
||||
|
||||
bool apply();
|
||||
bool begin();
|
||||
|
|
@ -82,7 +75,6 @@ class WifiSettings: BaseSettings {
|
|||
//bool ssdpBroadcast = true;
|
||||
bool begin();
|
||||
bool fromJSON(JsonObject &obj);
|
||||
bool toJSON(JsonObject &obj);
|
||||
|
||||
String mapEncryptionType(int type);
|
||||
bool ssidExists(const char *ssid);
|
||||
|
|
@ -107,7 +99,6 @@ class EthernetSettings: BaseSettings {
|
|||
|
||||
bool begin();
|
||||
bool fromJSON(JsonObject &obj);
|
||||
bool toJSON(JsonObject &obj);
|
||||
|
||||
bool load();
|
||||
bool save();
|
||||
|
|
@ -126,7 +117,6 @@ class IPSettings: BaseSettings {
|
|||
IPAddress dns2 = IPAddress(0,0,0,0);
|
||||
bool begin();
|
||||
bool fromJSON(JsonObject &obj);
|
||||
bool toJSON(JsonObject &obj);
|
||||
|
||||
bool load();
|
||||
bool save();
|
||||
|
|
@ -151,7 +141,6 @@ class SecuritySettings: BaseSettings {
|
|||
bool save();
|
||||
bool load();
|
||||
void print();
|
||||
bool toJSON(JsonObject &obj);
|
||||
|
||||
bool fromJSON(JsonObject &obj);
|
||||
};
|
||||
|
|
@ -169,7 +158,6 @@ class MQTTSettings: BaseSettings {
|
|||
bool begin();
|
||||
bool save();
|
||||
bool load();
|
||||
bool toJSON(JsonObject &obj);
|
||||
|
||||
bool fromJSON(JsonObject &obj);
|
||||
};
|
||||
|
|
@ -197,15 +185,11 @@ class ConfigSettings: BaseSettings {
|
|||
SecuritySettings Security;
|
||||
bool requiresAuth();
|
||||
bool fromJSON(JsonObject &obj);
|
||||
bool toJSON(JsonObject &obj);
|
||||
|
||||
bool begin();
|
||||
bool save();
|
||||
bool load();
|
||||
void print();
|
||||
void emitSockets();
|
||||
void emitSockets(uint8_t num);
|
||||
bool toJSON(JsonDocument &doc);
|
||||
uint16_t calcSettingsRecSize();
|
||||
uint16_t calcNetRecSize();
|
||||
bool getAppVersion();
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ void freeSettingsSave(settings_save_t *save) {
|
|||
}
|
||||
|
||||
/*********************************************************************
|
||||
* getSettings: what the settings routes send, under the dictionary's
|
||||
* getSettings: what the settings routes sent, under the dictionary's
|
||||
* keys. Secrets only say whether they are set.
|
||||
********************************************************************/
|
||||
// An address of 0.0.0.0 is sent as "", as IPSettings::toJSON() does.
|
||||
// An address of 0.0.0.0 is sent as "", as the settings routes did.
|
||||
static void addAddress(ProtoWriter &w, proto_field_t field, const IPAddress &ip) {
|
||||
if(ip != IPAddress(0, 0, 0, 0)) w.add(field, ip.toString().c_str());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
#include "ConfigSettings.h"
|
||||
#include "ESPNetwork.h"
|
||||
#include "entryPoints/Web.h"
|
||||
#include "outputs/SocketEmitter.h"
|
||||
#include "outputs/ProtoSocket.h"
|
||||
#include "Utils.h"
|
||||
#include "SSDP.h"
|
||||
|
|
@ -16,7 +15,6 @@ static const char *TAG = "Network";
|
|||
|
||||
extern ConfigSettings settings;
|
||||
extern Web webServer;
|
||||
extern SocketEmitter sockEmit;
|
||||
extern MqttConnection mqtt;
|
||||
extern rebootDelay_t rebootDelay;
|
||||
extern ESPNetwork net;
|
||||
|
|
@ -56,7 +54,6 @@ static void clientScanLoop(ESPNetwork &net) {
|
|||
void ESPNetwork::end() {
|
||||
SSDP.end();
|
||||
mqtt.end();
|
||||
sockEmit.end();
|
||||
protoSocket.end();
|
||||
delay(100);
|
||||
}
|
||||
|
|
@ -74,7 +71,6 @@ bool ESPNetwork::setup() {
|
|||
ESP_LOGI(TAG, "WiFi Mode: %d", WiFi.getMode());
|
||||
WiFi.mode(WIFI_STA);
|
||||
}
|
||||
sockEmit.begin();
|
||||
return true;
|
||||
}
|
||||
conn_types_t ESPNetwork::preferredConnType() {
|
||||
|
|
@ -180,7 +176,6 @@ void ESPNetwork::loop() {
|
|||
resetTaskWdt(); // Make sure we do not reboot here.
|
||||
}
|
||||
|
||||
sockEmit.loop();
|
||||
mqtt.loop();
|
||||
if(settings.ssdpBroadcast && this->connected()) {
|
||||
if(!SSDP.isStarted) SSDP.begin();
|
||||
|
|
@ -192,7 +187,6 @@ bool ESPNetwork::changeAP(const uint8_t *bssid, const int32_t channel) {
|
|||
resetTaskWdt(); // Make sure we do not reboot here.
|
||||
if(SSDP.isStarted) SSDP.end();
|
||||
mqtt.disconnect();
|
||||
//sockEmit.end();
|
||||
WiFi.disconnect(false, true);
|
||||
this->connType = conn_types_t::unset;
|
||||
this->_connecting = true;
|
||||
|
|
@ -206,62 +200,20 @@ bool ESPNetwork::changeAP(const uint8_t *bssid, const int32_t channel) {
|
|||
// out every time, the WiFi signal only when it really changed.
|
||||
void ESPNetwork::emitSockets() {
|
||||
this->emitHeap();
|
||||
if(this->connType != conn_types_t::wifi ||
|
||||
this->rssiBroadcast.due(WiFi.RSSI(), WiFi.channel(), RSSI_EMIT_MIN_DBM))
|
||||
this->emitSockets(255);
|
||||
}
|
||||
void ESPNetwork::emitSockets(uint8_t num) {
|
||||
#if HAS_ETHERNET
|
||||
if(this->connType == conn_types_t::ethernet) {
|
||||
JsonSockEvent *json = sockEmit.beginEmit("ethernet");
|
||||
json->beginObject();
|
||||
json->addElem("connected", this->connected());
|
||||
json->addElem("speed", (uint32_t)ETH.linkSpeed());
|
||||
json->addElem("fullduplex", ETH.fullDuplex());
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
protoSocket.ethernet();
|
||||
return;
|
||||
}
|
||||
else
|
||||
#endif
|
||||
{
|
||||
if(WiFi.status() == WL_CONNECTED) {
|
||||
JsonSockEvent *json = sockEmit.beginEmit("wifiStrength");
|
||||
json->beginObject();
|
||||
json->addElem("ssid", WiFi.SSID().c_str());
|
||||
json->addElem("strength", (int32_t)WiFi.RSSI());
|
||||
json->addElem("channel", (int32_t)this->channel);
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
this->rssiBroadcast.sent(num == 255, WiFi.RSSI(), WiFi.channel());
|
||||
if(this->connType == conn_types_t::wifi && !this->rssiBroadcast.due(WiFi.RSSI(), WiFi.channel(), RSSI_EMIT_MIN_DBM)) return;
|
||||
if(WiFi.status() == WL_CONNECTED) this->rssiBroadcast.sent(WiFi.RSSI(), WiFi.channel());
|
||||
else this->rssiBroadcast.sent(-100, -1);
|
||||
protoSocket.wifi();
|
||||
}
|
||||
else {
|
||||
JsonSockEvent *json = sockEmit.beginEmit("wifiStrength");
|
||||
json->beginObject();
|
||||
json->addElem("ssid", "");
|
||||
json->addElem("strength", (int8_t)-100);
|
||||
json->addElem("channel", (int8_t)-1);
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
|
||||
json = sockEmit.beginEmit("ethernet");
|
||||
json->beginObject();
|
||||
json->addElem("connected", false);
|
||||
json->addElem("speed", (uint8_t)0);
|
||||
json->addElem("fullduplex", false);
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
this->rssiBroadcast.sent(num == 255, -100, -1);
|
||||
}
|
||||
}
|
||||
// A broadcast comes from emitSockets(), which has already sent memStatus.
|
||||
if(num != 255) this->emitHeap(num);
|
||||
// Protocol 2 clients get the status in their sync, so only broadcasts.
|
||||
else if(this->connType == conn_types_t::ethernet) protoSocket.ethernet();
|
||||
else protoSocket.wifi();
|
||||
}
|
||||
// Runs on the WiFi/Ethernet event task, so it sends nothing on the sockets.
|
||||
// loop() broadcasts the new status and a client that reconnects gets it from
|
||||
// SocketEmitter::initClients().
|
||||
// Runs on the WiFi/Ethernet event task, so it sends nothing on the socket.
|
||||
// loop() broadcasts the new status and a client that reconnects gets it in its
|
||||
// sync.
|
||||
void ESPNetwork::setConnected(conn_types_t connType) {
|
||||
resetTaskWdt();
|
||||
this->connType = connType;
|
||||
|
|
@ -292,8 +244,6 @@ void ESPNetwork::setConnected(conn_types_t connType) {
|
|||
this->wifiFallback = false;
|
||||
}
|
||||
#endif // HAS_ETHERNET
|
||||
// NET: Begin this in the startup.
|
||||
//sockEmit.begin();
|
||||
resetTaskWdt();
|
||||
|
||||
if(this->connectAttempts == 1) {
|
||||
|
|
@ -357,7 +307,10 @@ void ESPNetwork::setConnected(conn_types_t connType) {
|
|||
//MDNS.addServiceTxt("http", "tcp", "board", "ESP32");
|
||||
//MDNS.addServiceTxt("http", "tcp", "model", "ESPSomfyRTS");
|
||||
|
||||
MDNS.addService("espsomfy_rts", "tcp", 8080);
|
||||
// Home Assistant's discovery matches this record. The socket is on the web
|
||||
// server's port, at path.
|
||||
MDNS.addService("espsomfy_rts", "tcp", 80);
|
||||
MDNS.addServiceTxt("espsomfy_rts", "tcp", "path", "/ws");
|
||||
MDNS.addServiceTxt("espsomfy_rts", "tcp", "serverId", String(settings.serverId));
|
||||
MDNS.addServiceTxt("espsomfy_rts", "tcp", "model", "ESPSomfyRTS");
|
||||
MDNS.addServiceTxt("espsomfy_rts", "tcp", "version", String(settings.fwVersion.name));
|
||||
|
|
@ -375,7 +328,6 @@ bool ESPNetwork::connectWired() {
|
|||
if(ETH.linkUp()) {
|
||||
// If the ethernet link is re-established then we need to shut down wifi.
|
||||
if(WiFi.status() == WL_CONNECTED) {
|
||||
//sockEmit.end();
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
}
|
||||
|
|
@ -708,25 +660,9 @@ void ESPNetwork::networkEvent(WiFiEvent_t event) {
|
|||
break;
|
||||
}
|
||||
}
|
||||
void ESPNetwork::emitHeap(uint8_t num) {
|
||||
uint32_t freeHeap = ESP.getFreeHeap();
|
||||
uint32_t maxHeap = ESP.getMaxAllocHeap();
|
||||
uint32_t minHeap = ESP.getMinFreeHeap();
|
||||
JsonSockEvent *json = sockEmit.beginEmit("memStatus");
|
||||
json->beginObject();
|
||||
json->addElem("max", maxHeap);
|
||||
json->addElem("free", freeHeap);
|
||||
json->addElem("min", minHeap);
|
||||
json->addElem("total", ESP.getHeapSize());
|
||||
json->addElem("uptime", (uint64_t)millis());
|
||||
// The window in progress. Only a broadcast closes it, so a client
|
||||
// connecting doesn't wipe a slow pass before Home Assistant has seen it.
|
||||
json->addElem("loopRate", loopStats.rate(millis()));
|
||||
json->addElem("loopMaxUs", loopStats.maxUs());
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
if(num == 255) {
|
||||
protoSocket.memory();
|
||||
// memory, the device's heap and loop rate. Each one closes the loop rate's
|
||||
// window and reports it.
|
||||
void ESPNetwork::emitHeap() {
|
||||
loopStats.publish(millis());
|
||||
}
|
||||
protoSocket.memory();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@
|
|||
// A weak signal moves a dBm or two all the time, so only broadcast real
|
||||
// changes.
|
||||
#define RSSI_EMIT_MIN_DBM 3
|
||||
// How often loop() broadcasts the status: memStatus, then wifiStrength or
|
||||
// ethernet. memStatus is the one event that is always sent, so this must stay
|
||||
// well under Home Assistant's 60s receive timeout. A client that connects gets
|
||||
// all of it straight away from SocketEmitter::initClients().
|
||||
// How often loop() broadcasts the status: memory, then wifi or ethernet.
|
||||
// memory is the one event that is always sent, so this must stay well under
|
||||
// Home Assistant's 60s receive timeout. A client that connects gets all of it
|
||||
// straight away in its sync.
|
||||
#define STATUS_EMIT_MS 30000
|
||||
// A roaming scan only moves to another AP at least this much stronger.
|
||||
#define ROAM_MIN_GAIN_DB 6
|
||||
|
|
@ -71,8 +71,7 @@ class ESPNetwork {
|
|||
void loop();
|
||||
void end();
|
||||
void emitSockets();
|
||||
void emitSockets(uint8_t num);
|
||||
void emitHeap(uint8_t num = 255);
|
||||
void emitHeap();
|
||||
uint32_t getChipId();
|
||||
static void networkEvent(WiFiEvent_t event);
|
||||
};
|
||||
|
|
|
|||
32
src/ItemSnapshot.cpp
Normal file
32
src/ItemSnapshot.cpp
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#include "ItemSnapshot.h"
|
||||
|
||||
void writeShadeRef(JsonFormatter &json, const shade_snapshot_t &shade) {
|
||||
json.addElem("shadeId", shade.shadeId);
|
||||
json.addElem("roomId", shade.roomId);
|
||||
json.addElem("name", shade.name);
|
||||
json.addElem("remoteAddress", shade.remoteAddress);
|
||||
json.addElem("paired", shade.paired);
|
||||
json.addElem("shadeType", shade.shadeType);
|
||||
json.addElem("flipCommands", shade.flipCommands);
|
||||
json.addElem("flipPosition", shade.flipPosition);
|
||||
json.addElem("bitLength", shade.bitLength);
|
||||
json.addElem("proto", shade.proto);
|
||||
json.addElem("flags", shade.flags);
|
||||
json.addElem("sunSensor", shade.sunSensor);
|
||||
json.addElem("hasLight", shade.light);
|
||||
json.addElem("repeats", shade.repeats);
|
||||
}
|
||||
void writeGroupRef(JsonFormatter &json, const group_snapshot_t &group) {
|
||||
json.addElem("groupId", group.groupId);
|
||||
json.addElem("roomId", group.roomId);
|
||||
json.addElem("name", group.name);
|
||||
json.addElem("remoteAddress", group.remoteAddress);
|
||||
json.addElem("lastRollingCode", static_cast<uint32_t>(group.lastRollingCode));
|
||||
json.addElem("bitLength", group.bitLength);
|
||||
json.addElem("proto", group.proto);
|
||||
json.addElem("sunSensor", group.sunSensor);
|
||||
json.addElem("flipCommands", group.flipCommands);
|
||||
json.addElem("flags", group.flags);
|
||||
json.addElem("repeats", group.repeats);
|
||||
json.addElem("sortOrder", group.sortOrder);
|
||||
}
|
||||
84
src/ItemSnapshot.h
Normal file
84
src/ItemSnapshot.h
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#ifndef ITEM_SNAPSHOT_H
|
||||
#define ITEM_SNAPSHOT_H
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "ShadeTypes.h"
|
||||
#include "JsonFormatter.h"
|
||||
|
||||
// A copy of one room, shade or group, taken on loop() or for a command reply,
|
||||
// that the socket events, MQTT and the HTTP command replies write from. Kept
|
||||
// free of the shade model and the web server so it can be unit tested on the PC
|
||||
// (pio test -e native).
|
||||
|
||||
// Positions are as clients see them: whole percentages, flipped for a shade
|
||||
// with flipPosition, -1 when unset. The snapshot functions (SomfySnapshot.h)
|
||||
// set every field; anything else starts from {}.
|
||||
struct room_snapshot_t {
|
||||
uint8_t roomId;
|
||||
char name[21];
|
||||
int8_t sortOrder;
|
||||
};
|
||||
struct linked_remote_snapshot_t {
|
||||
uint32_t remoteAddress;
|
||||
uint16_t lastRollingCode;
|
||||
};
|
||||
struct shade_snapshot_t {
|
||||
uint8_t shadeId;
|
||||
uint8_t roomId;
|
||||
char name[21];
|
||||
uint32_t remoteAddress;
|
||||
uint32_t upTime;
|
||||
uint32_t downTime;
|
||||
uint32_t tiltTime;
|
||||
uint16_t stepSize;
|
||||
bool paired;
|
||||
uint16_t lastRollingCode;
|
||||
int8_t position;
|
||||
int8_t target;
|
||||
int8_t myPos;
|
||||
int8_t direction;
|
||||
uint8_t tiltType;
|
||||
int8_t tiltPosition;
|
||||
int8_t tiltTarget;
|
||||
int8_t myTiltPos;
|
||||
int8_t tiltDirection;
|
||||
uint8_t shadeType;
|
||||
uint8_t bitLength;
|
||||
uint8_t proto;
|
||||
uint8_t flags;
|
||||
bool flipCommands;
|
||||
bool flipPosition;
|
||||
bool inGroup;
|
||||
bool sunSensor;
|
||||
bool light;
|
||||
bool simMy;
|
||||
uint8_t repeats;
|
||||
int8_t sortOrder;
|
||||
uint8_t gpioUp;
|
||||
uint8_t gpioDown;
|
||||
uint8_t gpioMy;
|
||||
bool gpioLLTrigger;
|
||||
uint8_t linkedRemoteCount;
|
||||
linked_remote_snapshot_t linkedRemotes[SOMFY_MAX_LINKED_REMOTES];
|
||||
};
|
||||
struct group_snapshot_t {
|
||||
uint8_t groupId;
|
||||
uint8_t roomId;
|
||||
char name[21];
|
||||
uint32_t remoteAddress;
|
||||
uint16_t lastRollingCode;
|
||||
uint8_t bitLength;
|
||||
uint8_t proto;
|
||||
bool sunSensor;
|
||||
bool flipCommands;
|
||||
uint8_t flags; // its linked shades' flags together
|
||||
uint8_t repeats;
|
||||
int8_t sortOrder;
|
||||
uint8_t linkedShadeCount;
|
||||
uint8_t linkedShades[SOMFY_MAX_GROUPED_SHADES];
|
||||
};
|
||||
// The fields the HTTP command replies have always sent about their shade or
|
||||
// group.
|
||||
void writeShadeRef(JsonFormatter &json, const shade_snapshot_t &shade);
|
||||
void writeGroupRef(JsonFormatter &json, const group_snapshot_t &group);
|
||||
#endif
|
||||
|
|
@ -5,7 +5,6 @@
|
|||
#include "LoopQueue.h"
|
||||
#include "ConfigFile.h"
|
||||
#include "Somfy.h"
|
||||
#include "SomfySnapshot.h"
|
||||
#include "SomfyEditor.h"
|
||||
#include "Utils.h"
|
||||
#include "outputs/ProtoSocket.h"
|
||||
|
|
@ -15,7 +14,6 @@ static const char *TAG = "LoopQueue";
|
|||
|
||||
extern SomfyShadeController somfy;
|
||||
extern ShadeRequests shadeRequests;
|
||||
extern SnapshotSlots snapshotSlots;
|
||||
extern rebootDelay_t rebootDelay;
|
||||
|
||||
static restore_options_t restoreOptions(uint8_t parts) {
|
||||
|
|
@ -76,13 +74,6 @@ void LoopQueue::drain() {
|
|||
rebootDelay.rebootTime = millis() + 1000;
|
||||
break;
|
||||
}
|
||||
case loop_item_type_t::load_shades:
|
||||
somfy.loadShadesFile("/shades.tmp");
|
||||
LittleFS.remove("/shades.tmp");
|
||||
break;
|
||||
case loop_item_type_t::snapshot_fill:
|
||||
snapshotSlots.fill(item.slot, fillSnapshot);
|
||||
break;
|
||||
case loop_item_type_t::model_edit:
|
||||
runModelEdit(item.edit);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@
|
|||
enum class loop_item_type_t : uint8_t {
|
||||
shade_request, // run by ShadeRequests
|
||||
restore_shades, // restore from /shades.tmp, then reboot
|
||||
load_shades, // replace the shades with /shades.tmp
|
||||
snapshot_fill, // fill a read's ModelSnapshot
|
||||
model_edit, // apply an edit with ModelEditor
|
||||
proto_read, // answer a protocol 2 read op from the shade model
|
||||
proto_device // run a protocol 2 settings op or the WiFi scan (DeviceOps)
|
||||
|
|
@ -35,7 +33,6 @@ struct loop_item_t {
|
|||
loop_item_type_t type = loop_item_type_t::shade_request;
|
||||
shade_request_t request; // shade_request
|
||||
uint8_t restoreParts = 0; // restore_shades: RESTORE_* bits
|
||||
uint8_t slot = 0; // snapshot_fill: the SnapshotSlots slot
|
||||
model_edit_t *edit = nullptr; // model_edit: loop() runs and frees it
|
||||
// shade_request, proto_read and proto_device from a protocol 2 client:
|
||||
// loop() replies once it has run.
|
||||
|
|
@ -54,8 +51,8 @@ inline bool loopItemUsesRadio(const loop_item_t &item) {
|
|||
// MQTT client was already told it was accepted, so it stays queued until the
|
||||
// radio has room rather than have the radio drop it; a protocol 2 client gets
|
||||
// its reply once it has run. While TX is suspended nothing gets through
|
||||
// anyway so nothing is held back then, except an unpair: an HTTP client was
|
||||
// told the shade is unpaired, so it waits until TX resumes. Everything queued
|
||||
// anyway so nothing is held back then, except an unpair, which waits until TX
|
||||
// resumes rather than leave the shade paired. Everything queued
|
||||
// behind it waits too, protocol 2 replies included, so a frequency scan
|
||||
// started after an unpair delays them until it ends.
|
||||
inline bool holdLoopItem(const loop_item_t &item, bool txSuspended, bool txHasRoom) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ enum class model_edit_type_t : uint8_t {
|
|||
set_paired,
|
||||
room_order, shade_order, group_order
|
||||
};
|
||||
// What an edit did, sent to the clients as requestResult. id is the room,
|
||||
// What an edit did, sent in the edit op's reply. id is the room,
|
||||
// shade or group it added or changed, 0 when none. shadeId is the shade a
|
||||
// group gained or lost. code is the validation error of a shade that wasn't
|
||||
// saved, 0 otherwise. notFound: it was refused for an id that doesn't exist.
|
||||
|
|
|
|||
|
|
@ -1,491 +0,0 @@
|
|||
#include <new>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
#include "ModelSnapshot.h"
|
||||
#include "ModelEditor.h"
|
||||
|
||||
static const char *TAG = "Snapshot";
|
||||
|
||||
const room_snapshot_t *model_snapshot_t::room(uint8_t roomId) const {
|
||||
for(uint8_t i = 0; i < this->roomCount; i++)
|
||||
if(this->rooms[i].roomId == roomId) return &this->rooms[i];
|
||||
return nullptr;
|
||||
}
|
||||
const shade_snapshot_t *model_snapshot_t::shade(uint8_t shadeId) const {
|
||||
for(uint8_t i = 0; i < this->shadeCount; i++)
|
||||
if(this->shades[i].shadeId == shadeId) return &this->shades[i];
|
||||
return nullptr;
|
||||
}
|
||||
const group_snapshot_t *model_snapshot_t::group(uint8_t groupId) const {
|
||||
for(uint8_t i = 0; i < this->groupCount; i++)
|
||||
if(this->groups[i].groupId == groupId) return &this->groups[i];
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* Items
|
||||
********************************************************************/
|
||||
void writeRoom(JsonFormatter &json, const room_snapshot_t &room) {
|
||||
json.addElem("roomId", room.roomId);
|
||||
json.addElem("name", room.name);
|
||||
json.addElem("sortOrder", room.sortOrder);
|
||||
}
|
||||
void writeShadeRef(JsonFormatter &json, const shade_snapshot_t &shade) {
|
||||
json.addElem("shadeId", shade.shadeId);
|
||||
json.addElem("roomId", shade.roomId);
|
||||
json.addElem("name", shade.name);
|
||||
json.addElem("remoteAddress", shade.remoteAddress);
|
||||
json.addElem("paired", shade.paired);
|
||||
json.addElem("shadeType", shade.shadeType);
|
||||
json.addElem("flipCommands", shade.flipCommands);
|
||||
json.addElem("flipPosition", shade.flipPosition);
|
||||
json.addElem("bitLength", shade.bitLength);
|
||||
json.addElem("proto", shade.proto);
|
||||
json.addElem("flags", shade.flags);
|
||||
json.addElem("sunSensor", shade.sunSensor);
|
||||
json.addElem("hasLight", shade.light);
|
||||
json.addElem("repeats", shade.repeats);
|
||||
}
|
||||
void writeShade(JsonFormatter &json, const shade_snapshot_t &shade) {
|
||||
json.addElem("shadeId", shade.shadeId);
|
||||
json.addElem("roomId", shade.roomId);
|
||||
json.addElem("name", shade.name);
|
||||
json.addElem("remoteAddress", shade.remoteAddress);
|
||||
json.addElem("upTime", shade.upTime);
|
||||
json.addElem("downTime", shade.downTime);
|
||||
json.addElem("paired", shade.paired);
|
||||
json.addElem("lastRollingCode", static_cast<uint32_t>(shade.lastRollingCode));
|
||||
json.addElem("position", shade.position);
|
||||
json.addElem("tiltType", shade.tiltType);
|
||||
json.addElem("tiltPosition", shade.tiltPosition);
|
||||
json.addElem("tiltDirection", shade.tiltDirection);
|
||||
json.addElem("tiltTime", shade.tiltTime);
|
||||
json.addElem("stepSize", static_cast<uint32_t>(shade.stepSize));
|
||||
json.addElem("tiltTarget", shade.tiltTarget);
|
||||
json.addElem("target", shade.target);
|
||||
json.addElem("myPos", shade.myPos);
|
||||
json.addElem("myTiltPos", shade.myTiltPos);
|
||||
json.addElem("direction", shade.direction);
|
||||
json.addElem("shadeType", shade.shadeType);
|
||||
json.addElem("bitLength", shade.bitLength);
|
||||
json.addElem("proto", shade.proto);
|
||||
json.addElem("flags", shade.flags);
|
||||
json.addElem("flipCommands", shade.flipCommands);
|
||||
json.addElem("flipPosition", shade.flipPosition);
|
||||
json.addElem("inGroup", shade.inGroup);
|
||||
json.addElem("sunSensor", shade.sunSensor);
|
||||
json.addElem("light", shade.light);
|
||||
json.addElem("repeats", shade.repeats);
|
||||
json.addElem("sortOrder", shade.sortOrder);
|
||||
json.addElem("gpioUp", shade.gpioUp);
|
||||
json.addElem("gpioDown", shade.gpioDown);
|
||||
json.addElem("gpioMy", shade.gpioMy);
|
||||
json.addElem("gpioLLTrigger", shade.gpioLLTrigger);
|
||||
json.addElem("simMy", shade.simMy);
|
||||
json.beginArray("linkedRemotes");
|
||||
for(uint8_t i = 0; i < shade.linkedRemoteCount; i++) {
|
||||
json.beginObject();
|
||||
json.addElem("remoteAddress", shade.linkedRemotes[i].remoteAddress);
|
||||
json.addElem("lastRollingCode", static_cast<uint32_t>(shade.linkedRemotes[i].lastRollingCode));
|
||||
json.endObject();
|
||||
}
|
||||
json.endArray();
|
||||
}
|
||||
void writeGroupRef(JsonFormatter &json, const group_snapshot_t &group) {
|
||||
json.addElem("groupId", group.groupId);
|
||||
json.addElem("roomId", group.roomId);
|
||||
json.addElem("name", group.name);
|
||||
json.addElem("remoteAddress", group.remoteAddress);
|
||||
json.addElem("lastRollingCode", static_cast<uint32_t>(group.lastRollingCode));
|
||||
json.addElem("bitLength", group.bitLength);
|
||||
json.addElem("proto", group.proto);
|
||||
json.addElem("sunSensor", group.sunSensor);
|
||||
json.addElem("flipCommands", group.flipCommands);
|
||||
json.addElem("flags", group.flags);
|
||||
json.addElem("repeats", group.repeats);
|
||||
json.addElem("sortOrder", group.sortOrder);
|
||||
}
|
||||
void writeGroup(JsonFormatter &json, const group_snapshot_t &group, shade_lookup_t lookup, void *ctx) {
|
||||
writeGroupRef(json, group);
|
||||
json.beginArray("linkedShades");
|
||||
for(uint8_t i = 0; i < group.linkedShadeCount; i++) {
|
||||
const shade_snapshot_t *shade = lookup(group.linkedShades[i], ctx);
|
||||
if(!shade) continue;
|
||||
json.beginObject();
|
||||
writeShadeRef(json, *shade);
|
||||
json.endObject();
|
||||
}
|
||||
json.endArray();
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* What the next room, shade or group would get (ModelEditor's rules,
|
||||
* as SomfyShadeController applies them when one is added)
|
||||
********************************************************************/
|
||||
uint8_t nextRoomId(const model_snapshot_t &snap) {
|
||||
uint8_t ids[SOMFY_MAX_ROOMS];
|
||||
for(uint8_t i = 0; i < snap.roomCount; i++) ids[i] = snap.rooms[i].roomId;
|
||||
return firstFreeId(ids, snap.roomCount, SOMFY_MAX_ROOMS);
|
||||
}
|
||||
uint8_t nextShadeId(const model_snapshot_t &snap) {
|
||||
uint8_t ids[SOMFY_MAX_SHADES];
|
||||
for(uint8_t i = 0; i < snap.shadeCount; i++) ids[i] = snap.shades[i].shadeId;
|
||||
const uint8_t id = firstFreeId(ids, snap.shadeCount, SOMFY_MAX_SHADES);
|
||||
return id == 0 ? 255 : id;
|
||||
}
|
||||
uint8_t nextGroupId(const model_snapshot_t &snap) {
|
||||
uint8_t ids[SOMFY_MAX_GROUPS];
|
||||
for(uint8_t i = 0; i < snap.groupCount; i++) ids[i] = snap.groups[i].groupId;
|
||||
const uint8_t id = firstFreeId(ids, snap.groupCount, SOMFY_MAX_GROUPS);
|
||||
return id == 0 ? 255 : id;
|
||||
}
|
||||
uint32_t nextRemoteAddress(const model_snapshot_t &snap, uint8_t id) {
|
||||
uint32_t used[SOMFY_MAX_SHADES + SOMFY_MAX_GROUPS];
|
||||
size_t count = 0;
|
||||
for(uint8_t i = 0; i < snap.shadeCount; i++) used[count++] = snap.shades[i].remoteAddress;
|
||||
for(uint8_t i = 0; i < snap.groupCount; i++) used[count++] = snap.groups[i].remoteAddress;
|
||||
return offeredRemoteAddress(snap.startingAddress, id, used, count);
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* Documents
|
||||
********************************************************************/
|
||||
enum : uint8_t {
|
||||
ST_HEADER, // {<header>,"rooms":[
|
||||
ST_ROOMS,
|
||||
ST_SHADES,
|
||||
ST_GROUPS,
|
||||
ST_REPEATERS,
|
||||
ST_ROOM,
|
||||
ST_SHADE,
|
||||
ST_GROUP,
|
||||
ST_OPEN_ARRAY, // [
|
||||
ST_CLOSE_ARRAY, // ]
|
||||
ST_TO_SHADES, // ],"shades":[
|
||||
ST_TO_GROUPS, // ],"groups":[
|
||||
ST_TO_REPEATERS, // ],"repeaters":[
|
||||
ST_CLOSE_DOC, // ]}
|
||||
ST_NEXT_ROOM,
|
||||
ST_NEXT_SHADE,
|
||||
ST_NEXT_GROUP,
|
||||
ST_GROUP_OPTIONS, // the group, then ],"availShades":[
|
||||
ST_AVAIL_SHADES, // the shades not in that group
|
||||
ST_END
|
||||
};
|
||||
static const uint8_t CONTROLLER_STEPS[] = { ST_HEADER, ST_ROOMS, ST_TO_SHADES, ST_SHADES, ST_TO_GROUPS, ST_GROUPS, ST_TO_REPEATERS, ST_REPEATERS, ST_CLOSE_DOC, ST_END };
|
||||
static const uint8_t DISCOVERY_STEPS[] = { ST_HEADER, ST_ROOMS, ST_TO_SHADES, ST_SHADES, ST_TO_GROUPS, ST_GROUPS, ST_CLOSE_DOC, ST_END };
|
||||
static const uint8_t ROOMS_STEPS[] = { ST_OPEN_ARRAY, ST_ROOMS, ST_CLOSE_ARRAY, ST_END };
|
||||
static const uint8_t SHADES_STEPS[] = { ST_OPEN_ARRAY, ST_SHADES, ST_CLOSE_ARRAY, ST_END };
|
||||
static const uint8_t GROUPS_STEPS[] = { ST_OPEN_ARRAY, ST_GROUPS, ST_CLOSE_ARRAY, ST_END };
|
||||
static const uint8_t ROOM_STEPS[] = { ST_ROOM, ST_END };
|
||||
static const uint8_t SHADE_STEPS[] = { ST_SHADE, ST_END };
|
||||
static const uint8_t GROUP_STEPS[] = { ST_GROUP, ST_END };
|
||||
static const uint8_t REPEATERS_STEPS[] = { ST_OPEN_ARRAY, ST_REPEATERS, ST_CLOSE_ARRAY, ST_END };
|
||||
static const uint8_t NEXT_ROOM_STEPS[] = { ST_NEXT_ROOM, ST_END };
|
||||
static const uint8_t NEXT_SHADE_STEPS[] = { ST_NEXT_SHADE, ST_END };
|
||||
static const uint8_t NEXT_GROUP_STEPS[] = { ST_NEXT_GROUP, ST_END };
|
||||
static const uint8_t GROUP_OPTIONS_STEPS[] = { ST_GROUP_OPTIONS, ST_AVAIL_SHADES, ST_CLOSE_DOC, ST_END };
|
||||
|
||||
static const uint8_t *stepsFor(snapshot_doc_t doc) {
|
||||
switch(doc) {
|
||||
case snapshot_doc_t::controller: return CONTROLLER_STEPS;
|
||||
case snapshot_doc_t::discovery: return DISCOVERY_STEPS;
|
||||
case snapshot_doc_t::rooms: return ROOMS_STEPS;
|
||||
case snapshot_doc_t::shades: return SHADES_STEPS;
|
||||
case snapshot_doc_t::groups: return GROUPS_STEPS;
|
||||
case snapshot_doc_t::room: return ROOM_STEPS;
|
||||
case snapshot_doc_t::shade: return SHADE_STEPS;
|
||||
case snapshot_doc_t::group: return GROUP_STEPS;
|
||||
case snapshot_doc_t::repeaters: return REPEATERS_STEPS;
|
||||
case snapshot_doc_t::nextRoom: return NEXT_ROOM_STEPS;
|
||||
case snapshot_doc_t::nextShade: return NEXT_SHADE_STEPS;
|
||||
case snapshot_doc_t::nextGroup: return NEXT_GROUP_STEPS;
|
||||
case snapshot_doc_t::groupOptions: return GROUP_OPTIONS_STEPS;
|
||||
}
|
||||
return SHADES_STEPS;
|
||||
}
|
||||
static const shade_snapshot_t *snapshotShade(uint8_t shadeId, void *ctx) {
|
||||
return static_cast<const model_snapshot_t *>(ctx)->shade(shadeId);
|
||||
}
|
||||
|
||||
SnapshotWriter::SnapshotWriter(const model_snapshot_t &snap, snapshot_doc_t doc, uint8_t id, snapshot_header_t header, char *unit, size_t unitSize)
|
||||
: snap(snap), steps(stepsFor(doc)), id(id), header(header), unit(unit), unitSize(unitSize) {}
|
||||
|
||||
size_t SnapshotWriter::generate(uint8_t *out, size_t maxLen) {
|
||||
size_t written = 0;
|
||||
while(written < maxLen) {
|
||||
if(this->consumed < this->unitLen) {
|
||||
size_t n = this->unitLen - this->consumed;
|
||||
if(n > maxLen - written) n = maxLen - written;
|
||||
memcpy(out + written, this->unit + this->consumed, n);
|
||||
this->consumed += n;
|
||||
written += n;
|
||||
}
|
||||
else {
|
||||
// A step or item can produce nothing, so go on until something is
|
||||
// produced or the document is done.
|
||||
this->consumed = 0;
|
||||
do this->produceUnit(); while(this->unitLen == 0 && this->steps[this->step] != ST_END);
|
||||
if(this->unitLen == 0) return written;
|
||||
}
|
||||
}
|
||||
return written;
|
||||
}
|
||||
void SnapshotWriter::nextStep() {
|
||||
this->step++;
|
||||
this->idx = 0;
|
||||
this->firstItem = true;
|
||||
this->groupOpen = false;
|
||||
}
|
||||
void SnapshotWriter::literal(const char *text) {
|
||||
strncpy(this->unit, text, this->unitSize - 1);
|
||||
this->unit[this->unitSize - 1] = '\0';
|
||||
this->unitLen = strlen(this->unit);
|
||||
this->nextStep();
|
||||
}
|
||||
// Writes text as the whole rest of the document.
|
||||
void SnapshotWriter::finish(const char *text) {
|
||||
this->literal(text);
|
||||
while(this->steps[this->step] != ST_END) this->step++;
|
||||
}
|
||||
void SnapshotWriter::resetFmt(size_t offset) {
|
||||
this->unit[offset] = '\0';
|
||||
this->fmt.setBuffer(this->unit + offset, this->unitSize - offset);
|
||||
}
|
||||
bool SnapshotWriter::overflowed(const char *what, uint8_t itemId) {
|
||||
if(!this->fmt.overflowed()) return false;
|
||||
ESP_LOGE(TAG, "%s %u doesn't fit in %u bytes, leaving it out", what, itemId, (unsigned)this->unitSize);
|
||||
this->unitLen = 0;
|
||||
return true;
|
||||
}
|
||||
// Starts a list item, after a comma unless it is the first.
|
||||
size_t SnapshotWriter::itemStart() {
|
||||
size_t pos = 0;
|
||||
if(!this->firstItem) this->unit[pos++] = ',';
|
||||
this->resetFmt(pos);
|
||||
return pos;
|
||||
}
|
||||
// One piece of a group: its ref, then each linked shade, then the close.
|
||||
// inList is whether it is an item of a list rather than the whole document.
|
||||
void SnapshotWriter::groupUnit(const group_snapshot_t &group, bool inList, const char *close) {
|
||||
auto done = [this, inList]() {
|
||||
this->groupOpen = false;
|
||||
if(inList) this->idx++;
|
||||
else this->nextStep();
|
||||
};
|
||||
if(!this->groupOpen) {
|
||||
const size_t pos = inList ? this->itemStart() : 0;
|
||||
if(!inList) this->resetFmt(0);
|
||||
this->fmt.beginObject();
|
||||
writeGroupRef(this->fmt, group);
|
||||
this->fmt.beginArray("linkedShades");
|
||||
if(this->overflowed("group", group.groupId)) { done(); return; }
|
||||
this->unitLen = pos + this->fmt.length();
|
||||
this->groupOpen = true;
|
||||
this->lsIdx = 0;
|
||||
this->firstLinked = true;
|
||||
if(inList) this->firstItem = false;
|
||||
return;
|
||||
}
|
||||
const shade_snapshot_t *shade = nullptr;
|
||||
while(this->lsIdx < group.linkedShadeCount && !(shade = this->snap.shade(group.linkedShades[this->lsIdx]))) this->lsIdx++;
|
||||
if(!shade) {
|
||||
strcpy(this->unit, close);
|
||||
this->unitLen = strlen(close);
|
||||
done();
|
||||
return;
|
||||
}
|
||||
size_t pos = 0;
|
||||
if(!this->firstLinked) this->unit[pos++] = ',';
|
||||
this->resetFmt(pos);
|
||||
this->fmt.beginObject();
|
||||
writeShadeRef(this->fmt, *shade);
|
||||
this->fmt.endObject();
|
||||
this->lsIdx++;
|
||||
if(this->overflowed("linked shade", shade->shadeId)) return;
|
||||
this->unitLen = pos + this->fmt.length();
|
||||
this->firstLinked = false;
|
||||
}
|
||||
void SnapshotWriter::produceUnit() {
|
||||
this->unitLen = 0;
|
||||
switch(this->steps[this->step]) {
|
||||
case ST_HEADER:
|
||||
this->resetFmt(0);
|
||||
this->fmt.beginObject();
|
||||
if(this->header) this->header(this->fmt, this->snap);
|
||||
this->fmt.beginArray("rooms");
|
||||
// Only fixed size values so this can't happen short of a bug.
|
||||
if(this->fmt.overflowed()) ESP_LOGE(TAG, "Header doesn't fit in %u bytes", (unsigned)this->unitSize);
|
||||
this->unitLen = this->fmt.length();
|
||||
this->nextStep();
|
||||
return;
|
||||
case ST_ROOMS: {
|
||||
if(this->idx >= this->snap.roomCount) { this->nextStep(); return; }
|
||||
const room_snapshot_t &room = this->snap.rooms[this->idx++];
|
||||
const size_t pos = this->itemStart();
|
||||
this->fmt.beginObject();
|
||||
writeRoom(this->fmt, room);
|
||||
this->fmt.endObject();
|
||||
if(this->overflowed("room", room.roomId)) return;
|
||||
this->unitLen = pos + this->fmt.length();
|
||||
this->firstItem = false;
|
||||
return;
|
||||
}
|
||||
case ST_SHADES: {
|
||||
if(this->idx >= this->snap.shadeCount) { this->nextStep(); return; }
|
||||
const shade_snapshot_t &shade = this->snap.shades[this->idx++];
|
||||
const size_t pos = this->itemStart();
|
||||
this->fmt.beginObject();
|
||||
writeShade(this->fmt, shade);
|
||||
this->fmt.endObject();
|
||||
if(this->overflowed("shade", shade.shadeId)) return;
|
||||
this->unitLen = pos + this->fmt.length();
|
||||
this->firstItem = false;
|
||||
return;
|
||||
}
|
||||
case ST_GROUPS:
|
||||
if(this->idx >= this->snap.groupCount) { this->nextStep(); return; }
|
||||
this->groupUnit(this->snap.groups[this->idx], true);
|
||||
return;
|
||||
case ST_REPEATERS: {
|
||||
if(this->idx >= this->snap.repeaterCount) { this->nextStep(); return; }
|
||||
size_t pos = 0;
|
||||
if(!this->firstItem) this->unit[pos++] = ',';
|
||||
pos += snprintf(this->unit + pos, this->unitSize - pos, "%lu", (unsigned long)this->snap.repeaters[this->idx++]);
|
||||
this->unitLen = pos;
|
||||
this->firstItem = false;
|
||||
return;
|
||||
}
|
||||
case ST_ROOM: {
|
||||
const room_snapshot_t *room = this->snap.room(this->id);
|
||||
if(!room) { this->literal("{\"status\":\"ERROR\",\"desc\":\"Room Id not found.\"}"); return; }
|
||||
this->resetFmt(0);
|
||||
this->fmt.beginObject();
|
||||
writeRoom(this->fmt, *room);
|
||||
this->fmt.endObject();
|
||||
if(!this->overflowed("room", room->roomId)) this->unitLen = this->fmt.length();
|
||||
this->nextStep();
|
||||
return;
|
||||
}
|
||||
case ST_SHADE: {
|
||||
const shade_snapshot_t *shade = this->snap.shade(this->id);
|
||||
if(!shade) { this->literal("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}"); return; }
|
||||
this->resetFmt(0);
|
||||
this->fmt.beginObject();
|
||||
writeShade(this->fmt, *shade);
|
||||
this->fmt.endObject();
|
||||
if(!this->overflowed("shade", shade->shadeId)) this->unitLen = this->fmt.length();
|
||||
this->nextStep();
|
||||
return;
|
||||
}
|
||||
case ST_GROUP: {
|
||||
const group_snapshot_t *group = this->snap.group(this->id);
|
||||
if(!group) { this->literal("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}"); return; }
|
||||
this->groupUnit(*group, false);
|
||||
return;
|
||||
}
|
||||
case ST_NEXT_ROOM:
|
||||
case ST_NEXT_SHADE:
|
||||
case ST_NEXT_GROUP:
|
||||
this->resetFmt(0);
|
||||
this->fmt.beginObject();
|
||||
if(this->steps[this->step] == ST_NEXT_ROOM) this->fmt.addElem("roomId", nextRoomId(this->snap));
|
||||
else if(this->steps[this->step] == ST_NEXT_SHADE) {
|
||||
const uint8_t shadeId = nextShadeId(this->snap);
|
||||
this->fmt.addElem("shadeId", shadeId);
|
||||
this->fmt.addElem("remoteAddress", nextRemoteAddress(this->snap, shadeId));
|
||||
this->fmt.addElem("bitLength", this->snap.radioBitLength);
|
||||
this->fmt.addElem("stepSize", (uint8_t)100);
|
||||
this->fmt.addElem("proto", this->snap.radioProto);
|
||||
}
|
||||
else {
|
||||
const uint8_t groupId = nextGroupId(this->snap);
|
||||
this->fmt.addElem("groupId", groupId);
|
||||
this->fmt.addElem("remoteAddress", nextRemoteAddress(this->snap, groupId));
|
||||
this->fmt.addElem("bitLength", this->snap.radioBitLength);
|
||||
this->fmt.addElem("proto", this->snap.radioProto);
|
||||
}
|
||||
this->fmt.endObject();
|
||||
this->unitLen = this->fmt.length();
|
||||
this->nextStep();
|
||||
return;
|
||||
case ST_GROUP_OPTIONS: {
|
||||
const group_snapshot_t *group = this->snap.group(this->id);
|
||||
if(!group) { this->finish("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}"); return; }
|
||||
this->groupUnit(*group, false, "],\"availShades\":[");
|
||||
return;
|
||||
}
|
||||
case ST_AVAIL_SHADES: {
|
||||
const group_snapshot_t *group = this->snap.group(this->id);
|
||||
if(!group || this->idx >= this->snap.shadeCount) { this->nextStep(); return; }
|
||||
const shade_snapshot_t &shade = this->snap.shades[this->idx++];
|
||||
for(uint8_t i = 0; i < group->linkedShadeCount; i++)
|
||||
if(group->linkedShades[i] == shade.shadeId) return;
|
||||
const size_t pos = this->itemStart();
|
||||
this->fmt.beginObject();
|
||||
writeShadeRef(this->fmt, shade);
|
||||
this->fmt.endObject();
|
||||
if(this->overflowed("shade", shade.shadeId)) return;
|
||||
this->unitLen = pos + this->fmt.length();
|
||||
this->firstItem = false;
|
||||
return;
|
||||
}
|
||||
case ST_OPEN_ARRAY: this->literal("["); return;
|
||||
case ST_CLOSE_ARRAY: this->literal("]"); return;
|
||||
case ST_TO_SHADES: this->literal("],\"shades\":["); return;
|
||||
case ST_TO_GROUPS: this->literal("],\"groups\":["); return;
|
||||
case ST_TO_REPEATERS: this->literal("],\"repeaters\":["); return;
|
||||
case ST_CLOSE_DOC: this->literal("]}"); return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* Slots
|
||||
********************************************************************/
|
||||
int8_t SnapshotSlots::acquire() {
|
||||
for(uint8_t i = 0; i < SNAPSHOT_SLOTS; i++) {
|
||||
uint32_t expected = FREE;
|
||||
if(!this->state[i].compare_exchange_strong(expected, WAITING)) continue;
|
||||
// Only the holder of a WAITING slot touches its pointer until it is queued.
|
||||
this->snaps[i] = new (std::nothrow) model_snapshot_t;
|
||||
if(!this->snaps[i]) {
|
||||
this->state[i].store(FREE);
|
||||
return -1;
|
||||
}
|
||||
return static_cast<int8_t>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
// The snapshot goes before the slot is marked free, so acquire() never sees a
|
||||
// free slot that still holds one.
|
||||
void SnapshotSlots::freeSlot(uint8_t slot) {
|
||||
delete this->snaps[slot];
|
||||
this->snaps[slot] = nullptr;
|
||||
this->state[slot].store(FREE);
|
||||
}
|
||||
void SnapshotSlots::fill(uint8_t slot, void (*fill)(model_snapshot_t &snap)) {
|
||||
uint32_t expected = WAITING;
|
||||
if(!this->state[slot].compare_exchange_strong(expected, FILLING)) {
|
||||
// The read went away before it was filled; nobody else will free it.
|
||||
if(expected == ABANDONED) this->freeSlot(slot);
|
||||
return;
|
||||
}
|
||||
model_snapshot_t &snap = *this->snaps[slot];
|
||||
snap.startingAddress = 0;
|
||||
snap.radioBitLength = snap.radioProto = 0;
|
||||
snap.roomCount = snap.shadeCount = snap.groupCount = snap.repeaterCount = 0;
|
||||
fill(snap);
|
||||
expected = FILLING;
|
||||
// Released while being filled: the read is gone, so free it here.
|
||||
if(!this->state[slot].compare_exchange_strong(expected, FILLED)) this->freeSlot(slot);
|
||||
}
|
||||
bool SnapshotSlots::filled(uint8_t slot) const { return this->state[slot].load() == FILLED; }
|
||||
void SnapshotSlots::cancel(uint8_t slot) { this->freeSlot(slot); }
|
||||
void SnapshotSlots::release(uint8_t slot) {
|
||||
// Still waiting for or being filled by loop(): it frees the slot when it sees
|
||||
// ABANDONED. Filled: nothing else touches it, so free it now.
|
||||
if(this->state[slot].exchange(ABANDONED) == FILLED) this->freeSlot(slot);
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
#ifndef MODEL_SNAPSHOT_H
|
||||
#define MODEL_SNAPSHOT_H
|
||||
#include <atomic>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "ShadeTypes.h"
|
||||
#include "JsonFormatter.h"
|
||||
|
||||
// A copy of the shade model taken on loop() for one read, so the reply
|
||||
// describes a single moment (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md),
|
||||
// and the JSON the read routes write from it. Kept free of the shade model and
|
||||
// the web server so it can be unit tested on the PC (pio test -e native).
|
||||
|
||||
// Positions are as clients see them: whole percentages, flipped for a shade
|
||||
// with flipPosition, -1 when unset.
|
||||
// No default member values: with them the SnapshotSlots would be stored in
|
||||
// flash as initialized data (11 KB). The snapshot functions set every field;
|
||||
// anything else starts from {}.
|
||||
struct room_snapshot_t {
|
||||
uint8_t roomId;
|
||||
char name[21];
|
||||
int8_t sortOrder;
|
||||
};
|
||||
struct linked_remote_snapshot_t {
|
||||
uint32_t remoteAddress;
|
||||
uint16_t lastRollingCode;
|
||||
};
|
||||
struct shade_snapshot_t {
|
||||
uint8_t shadeId;
|
||||
uint8_t roomId;
|
||||
char name[21];
|
||||
uint32_t remoteAddress;
|
||||
uint32_t upTime;
|
||||
uint32_t downTime;
|
||||
uint32_t tiltTime;
|
||||
uint16_t stepSize;
|
||||
bool paired;
|
||||
uint16_t lastRollingCode;
|
||||
int8_t position;
|
||||
int8_t target;
|
||||
int8_t myPos;
|
||||
int8_t direction;
|
||||
uint8_t tiltType;
|
||||
int8_t tiltPosition;
|
||||
int8_t tiltTarget;
|
||||
int8_t myTiltPos;
|
||||
int8_t tiltDirection;
|
||||
uint8_t shadeType;
|
||||
uint8_t bitLength;
|
||||
uint8_t proto;
|
||||
uint8_t flags;
|
||||
bool flipCommands;
|
||||
bool flipPosition;
|
||||
bool inGroup;
|
||||
bool sunSensor;
|
||||
bool light;
|
||||
bool simMy;
|
||||
uint8_t repeats;
|
||||
int8_t sortOrder;
|
||||
uint8_t gpioUp;
|
||||
uint8_t gpioDown;
|
||||
uint8_t gpioMy;
|
||||
bool gpioLLTrigger;
|
||||
uint8_t linkedRemoteCount;
|
||||
linked_remote_snapshot_t linkedRemotes[SOMFY_MAX_LINKED_REMOTES];
|
||||
};
|
||||
struct group_snapshot_t {
|
||||
uint8_t groupId;
|
||||
uint8_t roomId;
|
||||
char name[21];
|
||||
uint32_t remoteAddress;
|
||||
uint16_t lastRollingCode;
|
||||
uint8_t bitLength;
|
||||
uint8_t proto;
|
||||
bool sunSensor;
|
||||
bool flipCommands;
|
||||
uint8_t flags; // its linked shades' flags together
|
||||
uint8_t repeats;
|
||||
int8_t sortOrder;
|
||||
uint8_t linkedShadeCount;
|
||||
uint8_t linkedShades[SOMFY_MAX_GROUPED_SHADES];
|
||||
};
|
||||
// Only the rooms, shades, groups and repeaters that exist, in model order.
|
||||
struct model_snapshot_t {
|
||||
uint32_t startingAddress;
|
||||
uint8_t radioBitLength; // what a new shade or group gets
|
||||
uint8_t radioProto;
|
||||
uint8_t roomCount;
|
||||
uint8_t shadeCount;
|
||||
uint8_t groupCount;
|
||||
uint8_t repeaterCount;
|
||||
room_snapshot_t rooms[SOMFY_MAX_ROOMS];
|
||||
shade_snapshot_t shades[SOMFY_MAX_SHADES];
|
||||
group_snapshot_t groups[SOMFY_MAX_GROUPS];
|
||||
uint32_t repeaters[SOMFY_MAX_REPEATERS];
|
||||
const room_snapshot_t *room(uint8_t roomId) const;
|
||||
const shade_snapshot_t *shade(uint8_t shadeId) const;
|
||||
const group_snapshot_t *group(uint8_t groupId) const;
|
||||
};
|
||||
|
||||
// The fields of one item, as every route has always written them.
|
||||
void writeRoom(JsonFormatter &json, const room_snapshot_t &room);
|
||||
void writeShadeRef(JsonFormatter &json, const shade_snapshot_t &shade);
|
||||
void writeShade(JsonFormatter &json, const shade_snapshot_t &shade);
|
||||
void writeGroupRef(JsonFormatter &json, const group_snapshot_t &group);
|
||||
// The shade a group links to, or nullptr when it has gone.
|
||||
typedef const shade_snapshot_t *(*shade_lookup_t)(uint8_t shadeId, void *ctx);
|
||||
// writeGroupRef plus linkedShades, the refs of the shades it links to.
|
||||
void writeGroup(JsonFormatter &json, const group_snapshot_t &group, shade_lookup_t lookup, void *ctx);
|
||||
|
||||
// The documents the read routes send.
|
||||
enum class snapshot_doc_t : uint8_t {
|
||||
controller, // /controller
|
||||
discovery, // /discovery
|
||||
rooms, // /rooms
|
||||
shades, // /shades
|
||||
groups, // /groups
|
||||
room, // /room?roomId=
|
||||
shade, // /shade?shadeId=
|
||||
group, // /group?groupId=
|
||||
repeaters, // /repeaters
|
||||
nextRoom, // /getNextRoom: the id and defaults a new room would get
|
||||
nextShade, // /getNextShade
|
||||
nextGroup, // /getNextGroup
|
||||
groupOptions // /groupOptions?groupId=: the group and the shades it could link
|
||||
};
|
||||
// Writes the fields of /controller or /discovery that aren't in the snapshot
|
||||
// (device, radio and firmware details) into the open top-level object.
|
||||
typedef void (*snapshot_header_t)(JsonFormatter &json, const model_snapshot_t &snap);
|
||||
|
||||
// The buffer each room, shade, group and linked shade is formatted into on its
|
||||
// own. test_snapshot_limits checks the largest possible item of each fits.
|
||||
#define SNAPSHOT_UNIT_SIZE 3072
|
||||
|
||||
// Writes a document from a snapshot a piece at a time for a chunked response.
|
||||
// Each room, shade, group and linked shade is formatted into unit on its own;
|
||||
// one that doesn't fit is logged and left out so the JSON stays valid.
|
||||
class SnapshotWriter {
|
||||
public:
|
||||
// id picks the item for the single-item documents. header is only called
|
||||
// for controller and discovery. unit must outlive the writer.
|
||||
SnapshotWriter(const model_snapshot_t &snap, snapshot_doc_t doc, uint8_t id, snapshot_header_t header, char *unit, size_t unitSize);
|
||||
// Copies up to maxLen more bytes of the document into out. 0 once it is all
|
||||
// out.
|
||||
size_t generate(uint8_t *out, size_t maxLen);
|
||||
private:
|
||||
const model_snapshot_t &snap;
|
||||
const uint8_t *steps;
|
||||
uint8_t step = 0;
|
||||
uint8_t id;
|
||||
snapshot_header_t header;
|
||||
BufferedJsonFormatter fmt;
|
||||
char *unit;
|
||||
size_t unitSize;
|
||||
size_t unitLen = 0;
|
||||
size_t consumed = 0;
|
||||
uint8_t idx = 0;
|
||||
uint8_t lsIdx = 0; // next linked shade of the group being written
|
||||
bool groupOpen = false; // the group's ref is out, its linked shades are next
|
||||
bool firstItem = true;
|
||||
bool firstLinked = true;
|
||||
void produceUnit();
|
||||
void nextStep();
|
||||
void literal(const char *text);
|
||||
void resetFmt(size_t offset);
|
||||
bool overflowed(const char *what, uint8_t itemId);
|
||||
size_t itemStart();
|
||||
void groupUnit(const group_snapshot_t &group, bool inList, const char *close = "]}");
|
||||
void finish(const char *text);
|
||||
};
|
||||
// What the next room, shade or group added would get: its id (0 for a room and
|
||||
// 255 for a shade or group when there is no room for one) and remote address.
|
||||
uint8_t nextRoomId(const model_snapshot_t &snap);
|
||||
uint8_t nextShadeId(const model_snapshot_t &snap);
|
||||
uint8_t nextGroupId(const model_snapshot_t &snap);
|
||||
uint32_t nextRemoteAddress(const model_snapshot_t &snap, uint8_t id);
|
||||
|
||||
// The snapshots reads are served from. A read takes a slot on the web server
|
||||
// task and queues a fill; loop() fills it; the read streams it and lets it go.
|
||||
// The read may go away before loop() gets to it, so the slot's state decides
|
||||
// who frees it: whichever side is last. A slot's snapshot (about 5.6 KB) is
|
||||
// only allocated while a read holds it.
|
||||
#define SNAPSHOT_SLOTS 2
|
||||
class SnapshotSlots {
|
||||
public:
|
||||
// A free slot with its snapshot allocated, or -1 when every slot is in use
|
||||
// or there is no memory. Any task.
|
||||
int8_t acquire();
|
||||
// Fills the slot with fill, unless its read has gone. loop() only.
|
||||
void fill(uint8_t slot, void (*fill)(model_snapshot_t &snap));
|
||||
// Whether loop() has filled the slot. The read's task.
|
||||
bool filled(uint8_t slot) const;
|
||||
// Valid from acquire() until the read releases the slot.
|
||||
model_snapshot_t &snapshot(uint8_t slot) { return *this->snaps[slot]; }
|
||||
// The read is done with the slot, or gone. The read's task.
|
||||
void release(uint8_t slot);
|
||||
// Frees a slot whose fill was never queued. The read's task.
|
||||
void cancel(uint8_t slot);
|
||||
private:
|
||||
enum : uint32_t { FREE, WAITING, FILLING, FILLED, ABANDONED };
|
||||
model_snapshot_t *snaps[SNAPSHOT_SLOTS] = {};
|
||||
std::atomic<uint32_t> state[SNAPSHOT_SLOTS] = {};
|
||||
void freeSlot(uint8_t slot);
|
||||
};
|
||||
#endif
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
#define PROTO_SYNC_H
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "ModelSnapshot.h"
|
||||
#include "ItemSnapshot.h"
|
||||
#include "ProtoSchema.h"
|
||||
|
||||
// The shade model in socket protocol 2: how each room, shade and group is
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#ifndef SHADE_CHANGES_H
|
||||
#define SHADE_CHANGES_H
|
||||
#include <stdint.h>
|
||||
#include "ModelSnapshot.h"
|
||||
#include "ItemSnapshot.h"
|
||||
|
||||
// What changed about a shade since the change fan-out last sent it out, as
|
||||
// bits. The shade's setters and emitState() set them; once per loop() pass the
|
||||
|
|
|
|||
|
|
@ -289,7 +289,6 @@ bool SomfyShadeController::deleteShade(uint8_t shadeId) {
|
|||
Preferences pref;
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) {
|
||||
if(this->shades[i].getShadeId() == shadeId) {
|
||||
shades[i].emitState("shadeRemoved");
|
||||
shades[i].unpublish();
|
||||
this->shades[i].clear();
|
||||
}
|
||||
|
|
@ -319,7 +318,6 @@ bool SomfyShadeController::deleteRoom(uint8_t roomId) {
|
|||
groups[j].configChanged();
|
||||
}
|
||||
}
|
||||
rooms[i].emitState("roomRemoved");
|
||||
this->rooms[i].clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -334,7 +332,6 @@ bool SomfyShadeController::deleteGroup(uint8_t groupId) {
|
|||
SomfyShade *shade = this->getShadeById(groups[i].linkedShades[j]);
|
||||
if(shade) shade->configChanged();
|
||||
}
|
||||
groups[i].emitState("groupRemoved");
|
||||
groups[i].unpublish();
|
||||
this->groups[i].clear();
|
||||
}
|
||||
|
|
@ -342,7 +339,6 @@ bool SomfyShadeController::deleteGroup(uint8_t groupId) {
|
|||
this->commit();
|
||||
return true;
|
||||
}
|
||||
bool SomfyShadeController::loadShadesFile(const char *filename) { return ShadeConfigFile::load(this, filename); }
|
||||
void SomfyShadeController::loop() {
|
||||
this->transceiver.loop();
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) {
|
||||
|
|
|
|||
14
src/Somfy.h
14
src/Somfy.h
|
|
@ -2,7 +2,6 @@
|
|||
#define SOMFY_H
|
||||
#include <Preferences.h>
|
||||
#include "ConfigSettings.h"
|
||||
#include "WResp.h"
|
||||
|
||||
#include "SomfyFrame.h"
|
||||
#include "TxBuffer.h"
|
||||
|
|
@ -32,8 +31,6 @@ class SomfyRoom {
|
|||
void clear();
|
||||
bool save();
|
||||
bool fromJSON(JsonObject &obj);
|
||||
void emitState(const char *evt = "roomState");
|
||||
void emitState(uint8_t num, const char *evt = "roomState");
|
||||
void publish();
|
||||
void unpublish();
|
||||
};
|
||||
|
|
@ -136,15 +133,11 @@ class SomfyShade : public SomfyRemote, public ShadeSettings, private MotorLink,
|
|||
// Marks the shade as changed; the change fan-out sends shadeState once per
|
||||
// loop() pass.
|
||||
void emitState();
|
||||
// Sends evt about the shade to every client now.
|
||||
void emitState(const char *evt);
|
||||
void emitState(uint8_t num, const char *evt = "shadeState");
|
||||
// What changed since the fan-out last sent the shade (SHADE_CHANGED_* bits).
|
||||
uint16_t changed = 0;
|
||||
// A setting changed: protocol 2 clients get the whole shade.
|
||||
void configChanged() { this->changed |= SHADE_CHANGED_CONFIG; }
|
||||
void emitCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand");
|
||||
void emitCommand(uint8_t num, somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand");
|
||||
void emitCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress);
|
||||
// False when the radio refused a command it sent.
|
||||
bool setMyPosition(int8_t pos, int8_t tilt = -1);
|
||||
bool moveToMyPosition();
|
||||
|
|
@ -213,9 +206,6 @@ class SomfyGroup : public SomfyRemote {
|
|||
// Marks the group as changed; the change fan-out sends groupState once per
|
||||
// loop() pass.
|
||||
void emitState();
|
||||
// Sends evt about the group to every client now.
|
||||
void emitState(const char *evt);
|
||||
void emitState(uint8_t num, const char *evt = "groupState");
|
||||
// What changed since the fan-out last sent the group (GROUP_CHANGED_* bits).
|
||||
uint8_t changed = 0;
|
||||
// A setting or its shades changed: protocol 2 clients get the whole group.
|
||||
|
|
@ -278,7 +268,6 @@ class SomfyShadeController {
|
|||
void processFrame(somfy_frame_t &frame, bool internal = false, const SomfyRemote *skip = nullptr);
|
||||
void markTxPending(uint32_t remoteAddress, uint32_t ticket, const SomfyRemote *skip = nullptr);
|
||||
void processTxEvent(const somfy_tx_event_t &evt);
|
||||
void emitState(uint8_t num = 255);
|
||||
// The change fan-out: sends every shade and group that changed since the
|
||||
// last pass, its changed MQTT topics and one state event. loop() only.
|
||||
void sendChanges();
|
||||
|
|
@ -287,7 +276,6 @@ class SomfyShadeController {
|
|||
void commit();
|
||||
void writeBackup();
|
||||
String backupData;
|
||||
bool loadShadesFile(const char *filename);
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
#include "ConfigSettings.h"
|
||||
#include "ESPNetwork.h"
|
||||
#include "entryPoints/Web.h"
|
||||
#include "outputs/SocketEmitter.h"
|
||||
#include "outputs/ProtoSocket.h"
|
||||
#include "Utils.h"
|
||||
#include "Somfy.h"
|
||||
|
|
@ -13,7 +12,6 @@
|
|||
#include "LoopQueue.h"
|
||||
#include "ShadeRequests.h"
|
||||
#include "SomfyShadeOps.h"
|
||||
#include "ModelSnapshot.h"
|
||||
#include "esp_core_dump.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_app_desc.h"
|
||||
|
|
@ -40,7 +38,6 @@ static bool otaPending = false;
|
|||
|
||||
ConfigSettings settings;
|
||||
Web webServer;
|
||||
SocketEmitter sockEmit;
|
||||
ESPNetwork net;
|
||||
rebootDelay_t rebootDelay;
|
||||
SomfyShadeController somfy;
|
||||
|
|
@ -48,7 +45,6 @@ MqttConnection mqtt;
|
|||
SomfyShadeOps shadeOps;
|
||||
ShadeRequests shadeRequests(shadeOps);
|
||||
LoopQueue loopQueue;
|
||||
SnapshotSlots snapshotSlots;
|
||||
LoopStats loopStats;
|
||||
|
||||
uint32_t oldheap = 0;
|
||||
|
|
@ -187,7 +183,6 @@ void loop() {
|
|||
webServer.loop();
|
||||
logIfSlow(TAG, "Loop web server", timing.lap(), LOOP_SLOW_MS);
|
||||
esp_task_wdt_reset();
|
||||
sockEmit.loop();
|
||||
protoSocket.loop();
|
||||
logIfSlow(TAG, "Loop sockets", timing.lap(), LOOP_SLOW_MS);
|
||||
esp_task_wdt_reset();
|
||||
|
|
|
|||
|
|
@ -5,23 +5,19 @@
|
|||
#include "ProtoFrame.h"
|
||||
|
||||
// ModelEditor on the device: an edit of the shade model handed from the web
|
||||
// server task to loop(), which applies it with the ModelEditor rules and tells
|
||||
// the clients how it went.
|
||||
// server task to loop(), which applies it with the ModelEditor rules and replies
|
||||
// to the client that sent it.
|
||||
|
||||
// One edit on its way to loop(): the edit route and a copy of its request.
|
||||
// From an edit route its result goes to every client as requestResult; from a
|
||||
// protocol 2 edit op, as its reply.
|
||||
// One edit on its way to loop(): the protocol 2 edit op and a copy of its
|
||||
// arguments.
|
||||
struct model_edit_t {
|
||||
model_edit_type_t type = model_edit_type_t::add_room;
|
||||
uint32_t requestId = 0;
|
||||
proto_reply_to_t replyTo;
|
||||
JsonDocument doc;
|
||||
};
|
||||
|
||||
// One edit at a time: false when one is already waiting or running. Any task.
|
||||
bool beginModelEdit();
|
||||
// The id the HTTP reply and the requestResult event carry. Any task.
|
||||
uint32_t nextEditRequestId();
|
||||
// Gives up an edit that was begun but never queued. Any task.
|
||||
void cancelModelEdit(model_edit_t *edit);
|
||||
// Begins an edit of that type: nullptr when one is already waiting or running
|
||||
|
|
@ -30,10 +26,8 @@ model_edit_t *newModelEdit(model_edit_type_t type, bool &busy);
|
|||
// Hands a begun edit to loop(); false when the queue is full, and it is given
|
||||
// up. Any task.
|
||||
bool submitModelEdit(model_edit_t *edit);
|
||||
// Applies the edit, saves it, tells how it went and frees it. loop() only.
|
||||
// Applies the edit, saves it, replies and frees it. loop() only.
|
||||
void runModelEdit(model_edit_t *edit);
|
||||
// Applies the edit to the shade model and saves it. loop() only.
|
||||
edit_result_t applyModelEdit(model_edit_t &edit);
|
||||
// Tells every client how the edit went (SomfyEvents.cpp).
|
||||
void emitRequestResult(uint32_t requestId, const edit_result_t &result);
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -222,10 +222,8 @@ void transceiver_config_t::fromJSON(JsonObject& obj) {
|
|||
* 2.8 KB of flash).
|
||||
********************************************************************/
|
||||
static std::atomic<bool> editBusy(false);
|
||||
static std::atomic<uint32_t> lastRequestId(0);
|
||||
|
||||
bool beginModelEdit() { return !editBusy.exchange(true); }
|
||||
uint32_t nextEditRequestId() { return ++lastRequestId; }
|
||||
void cancelModelEdit(model_edit_t *edit) {
|
||||
delete edit;
|
||||
editBusy.store(false);
|
||||
|
|
@ -271,8 +269,7 @@ void runModelEdit(model_edit_t *edit) {
|
|||
if(!result.ok) ESP_LOGW(TAG, "Edit %u refused: %s", static_cast<uint8_t>(edit->type), result.desc);
|
||||
// The reply first: the edit's events follow it (docs/PROTOCOL.md), so a
|
||||
// client that waits for the reply doesn't read past them.
|
||||
if(edit->replyTo.client) protoSocket.replyEdit(edit->replyTo, edit->type, result);
|
||||
else emitRequestResult(edit->requestId, result);
|
||||
protoSocket.replyEdit(edit->replyTo, edit->type, result);
|
||||
if(result.ok) markEdited(*edit, result);
|
||||
cancelModelEdit(edit);
|
||||
}
|
||||
|
|
@ -302,7 +299,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
if(!room) return edit_result_t::error(EDIT_ROOMS_FULL);
|
||||
room->fromJSON(obj);
|
||||
room->save();
|
||||
room->emitState("roomAdded");
|
||||
room->publish();
|
||||
return done(room->roomId);
|
||||
}
|
||||
case model_edit_type_t::add_shade: {
|
||||
|
|
@ -315,7 +312,6 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
return edit_result_t::error("Data Error.", err);
|
||||
}
|
||||
shade->save();
|
||||
shade->emitState("shadeAdded");
|
||||
return done(shade->getShadeId());
|
||||
}
|
||||
case model_edit_type_t::add_group: {
|
||||
|
|
@ -323,7 +319,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
if(!group) return edit_result_t::error(EDIT_GROUPS_FULL);
|
||||
group->fromJSON(obj);
|
||||
group->save();
|
||||
group->emitState("groupAdded");
|
||||
group->publish();
|
||||
return done(group->getGroupId());
|
||||
}
|
||||
case model_edit_type_t::save_room: {
|
||||
|
|
@ -332,7 +328,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
if(!room) return edit_result_t::missing("Room Id not found.");
|
||||
room->fromJSON(obj);
|
||||
room->save();
|
||||
room->emitState();
|
||||
room->publish();
|
||||
return done(room->roomId);
|
||||
}
|
||||
case model_edit_type_t::save_shade: {
|
||||
|
|
|
|||
|
|
@ -82,23 +82,3 @@ void snapshotGroup(SomfyGroup &group, group_snapshot_t &out) {
|
|||
if(shadeId > 0 && shadeId < 255) out.linkedShades[out.linkedShadeCount++] = shadeId;
|
||||
}
|
||||
}
|
||||
void fillSnapshot(model_snapshot_t &snap) {
|
||||
snap.startingAddress = somfy.startingAddress;
|
||||
snap.radioBitLength = somfy.transceiver.config.type;
|
||||
snap.radioProto = static_cast<uint8_t>(somfy.transceiver.config.proto);
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++)
|
||||
if(somfy.rooms[i].roomId != 0) snapshotRoom(somfy.rooms[i], snap.rooms[snap.roomCount++]);
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++)
|
||||
if(somfy.shades[i].getShadeId() != 255) snapshotShade(somfy.shades[i], snap.shades[snap.shadeCount++]);
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++)
|
||||
if(somfy.groups[i].getGroupId() != 255) snapshotGroup(somfy.groups[i], snap.groups[snap.groupCount++]);
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++)
|
||||
if(somfy.repeaters[i] != 0) snap.repeaters[snap.repeaterCount++] = somfy.repeaters[i];
|
||||
}
|
||||
const shade_snapshot_t *liveShade(uint8_t shadeId, void *ctx) {
|
||||
SomfyShade *shade = somfy.getShadeById(shadeId);
|
||||
if(!shade) return nullptr;
|
||||
shade_snapshot_t *out = static_cast<shade_snapshot_t *>(ctx);
|
||||
snapshotShade(*shade, *out);
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
#ifndef SOMFY_SNAPSHOT_H
|
||||
#define SOMFY_SNAPSHOT_H
|
||||
#include "ModelSnapshot.h"
|
||||
#include "ItemSnapshot.h"
|
||||
#include "Somfy.h"
|
||||
|
||||
// Copies the shade model into the ModelSnapshot types.
|
||||
// Copies a room, shade or group of the shade model into its snapshot.
|
||||
|
||||
void snapshotRoom(SomfyRoom &room, room_snapshot_t &out);
|
||||
void snapshotShade(SomfyShade &shade, shade_snapshot_t &out);
|
||||
// Works the group's flags out from its shades without changing the group.
|
||||
void snapshotGroup(SomfyGroup &group, group_snapshot_t &out);
|
||||
// The whole shade model. loop() only.
|
||||
void fillSnapshot(model_snapshot_t &snap);
|
||||
// A shade_lookup_t over the live shade model for writeGroup. ctx is a
|
||||
// shade_snapshot_t to copy into.
|
||||
const shade_snapshot_t *liveShade(uint8_t shadeId, void *ctx);
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -10,13 +10,12 @@
|
|||
#include "Utils.h"
|
||||
#include "ConfigSettings.h"
|
||||
#include "Somfy.h"
|
||||
#include "outputs/SocketEmitter.h"
|
||||
#include "outputs/ProtoSocket.h"
|
||||
#include "ProtoSync.h"
|
||||
|
||||
static const char *TAG = "Somfy";
|
||||
|
||||
extern SomfyShadeController somfy;
|
||||
extern SocketEmitter sockEmit;
|
||||
extern ConfigSettings settings;
|
||||
|
||||
uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to ensure there isn't more than one interrupt hooked.
|
||||
|
|
@ -243,7 +242,7 @@ int markRSSI = -100;
|
|||
uint32_t lastScan = 0;
|
||||
static uint32_t scanStart = 0;
|
||||
// Scan state changes made off loop() (the web handlers and apply() run on
|
||||
// async_tcp) are only flagged here. The socket event buffer is shared, so
|
||||
// async_tcp) are only flagged here. The socket's frame buffer is shared, so
|
||||
// loop() sends the event.
|
||||
static std::atomic<bool> scanEmitPending{false};
|
||||
// A scan nobody ended (closed browser tab) stops on its own after this long so
|
||||
|
|
@ -310,17 +309,8 @@ void Transceiver::endFrequencyScan() {
|
|||
// Even if something else already ended the scan, nothing may keep TX suspended.
|
||||
this->resumeTx(TX_SUSPEND_SCAN);
|
||||
}
|
||||
void Transceiver::emitFrequencyScan(uint8_t num) {
|
||||
JsonSockEvent *json = sockEmit.beginEmit("frequencyScan");
|
||||
json->beginObject();
|
||||
json->addElem("scanning", rxmode == 3);
|
||||
json->addElem("testFreq", currFreq);
|
||||
json->addElem("testRSSI", (int32_t)currRSSI);
|
||||
json->addElem("frequency", markFreq);
|
||||
json->addElem("RSSI", (int32_t)markRSSI);
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
if(num == 255 && protoSocket.subscribed(proto_feed_t::frequencyScan)) {
|
||||
void Transceiver::emitFrequencyScan() {
|
||||
if(protoSocket.subscribed(proto_feed_t::frequencyScan)) {
|
||||
ProtoWriter w = protoSocket.beginFeed(proto_type_t::frequencyScan);
|
||||
w.add(proto_field_t::scanning, rxmode == 3);
|
||||
w.add(proto_field_t::testFreq, currFreq);
|
||||
|
|
@ -347,34 +337,9 @@ bool Transceiver::receive(somfy_rx_t *rx) {
|
|||
return false;
|
||||
}
|
||||
void Transceiver::emitFrame(somfy_frame_t *frame, somfy_rx_t *rx) {
|
||||
// The largest socket event: every pulse as up to 10 digits and a comma,
|
||||
// plus about 300 bytes of fields and framing.
|
||||
static_assert(MAX_TIMINGS * 11 + 512 <= MAX_SOCK_RESPONSE, "remoteFrame can outgrow the socket buffer");
|
||||
if(sockEmit.activeClients(ROOM_EMIT_FRAME) > 0) {
|
||||
JsonSockEvent *json = sockEmit.beginEmit("remoteFrame");
|
||||
json->beginObject();
|
||||
json->addElem("encKey", frame->encKey);
|
||||
json->addElem("address", (uint32_t)frame->remoteAddress);
|
||||
json->addElem("rcode", (uint32_t)frame->rollingCode);
|
||||
json->addElem("command", translateSomfyCommand(frame->cmd).c_str());
|
||||
json->addElem("rawCmd", frame->rawCmd);
|
||||
json->addElem("rssi", (int32_t)frame->rssi);
|
||||
json->addElem("bits", rx->bit_length);
|
||||
json->addElem("proto", static_cast<uint8_t>(frame->proto));
|
||||
json->addElem("valid", frame->valid);
|
||||
json->addElem("sync", frame->hwsync);
|
||||
if(frame->cmd == somfy_commands::StepUp || frame->cmd == somfy_commands::StepDown)
|
||||
json->addElem("stepSize", frame->stepSize);
|
||||
json->beginArray("pulses");
|
||||
if(rx) {
|
||||
for(uint16_t i = 0; i < rx->pulseCount; i++) {
|
||||
json->addElem((uint32_t)rx->pulses[i]);
|
||||
}
|
||||
}
|
||||
json->endArray();
|
||||
json->endObject();
|
||||
sockEmit.endEmitRoom(ROOM_EMIT_FRAME);
|
||||
}
|
||||
// The largest event: every pulse as up to 10 digits and a comma, plus the
|
||||
// other fields and framing.
|
||||
static_assert(MAX_TIMINGS * 11 + 512 <= PROTO_MAX_FRAME, "a frame event can outgrow the socket's frame buffer");
|
||||
if(protoSocket.subscribed(proto_feed_t::frames)) {
|
||||
ProtoWriter w = protoSocket.beginFeed(proto_type_t::frame);
|
||||
w.add(proto_field_t::encKey, frame->encKey);
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class Transceiver {
|
|||
void beginFrequencyScan();
|
||||
void endFrequencyScan();
|
||||
void processFrequencyScan(bool received = false);
|
||||
void emitFrequencyScan(uint8_t num = 255);
|
||||
void emitFrequencyScan();
|
||||
bool usesPin(uint8_t pin);
|
||||
};
|
||||
// Bit length the radio was last set up for. A frame sent without one
|
||||
|
|
|
|||
|
|
@ -138,8 +138,7 @@ bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, int min
|
|||
bool RssiBroadcast::due(int rssi, int channel, int minDelta) const {
|
||||
return rssiNeedsEmit(rssi, this->rssi, channel, this->channel, minDelta);
|
||||
}
|
||||
void RssiBroadcast::sent(bool broadcast, int rssi, int channel) {
|
||||
if(!broadcast) return;
|
||||
void RssiBroadcast::sent(int rssi, int channel) {
|
||||
this->rssi = rssi;
|
||||
this->channel = channel;
|
||||
}
|
||||
|
|
|
|||
15
src/Utils.h
15
src/Utils.h
|
|
@ -57,16 +57,16 @@ struct rebootDelay_t {
|
|||
else if(str[0] == 'f' || str[0] == 'F' || str[0] == '0') return false;
|
||||
return def;
|
||||
}
|
||||
// Whether a wifiStrength broadcast is worth sending: always on a channel change,
|
||||
// Whether a wifi broadcast is worth sending: always on a channel change,
|
||||
// otherwise only for an RSSI change of at least minDelta dBm.
|
||||
bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, int minDelta);
|
||||
// What the last wifiStrength broadcast told every client. Only broadcasts
|
||||
// move it: an emit to one new client tells the others nothing.
|
||||
// What the last wifi broadcast told every client. A client that connects gets
|
||||
// the signal in its sync, which tells the others nothing, so it isn't moved.
|
||||
struct RssiBroadcast {
|
||||
int rssi = 0;
|
||||
int channel = 0;
|
||||
bool due(int rssi, int channel, int minDelta) const;
|
||||
void sent(bool broadcast, int rssi, int channel);
|
||||
void sent(int rssi, int channel);
|
||||
};
|
||||
// Whether an AP a scan found for our SSID is worth connecting to. While
|
||||
// connected it has to be at least minGain dBm stronger than the current AP so
|
||||
|
|
@ -74,7 +74,7 @@ struct RssiBroadcast {
|
|||
bool worthRoaming(bool connected, int currentRssi, int apRssi, int minGain);
|
||||
// Whether the calling task may send socket events. They are all formatted
|
||||
// into one shared buffer, so only loop()'s task may. owner is that task, or
|
||||
// null before SocketEmitter::begin() has recorded it (setup() runs there too).
|
||||
// null before ProtoSocket::begin() has recorded it (setup() runs there too).
|
||||
bool emitAllowed(const void *current, const void *owner);
|
||||
// A short name for a wifi_auth_mode_t value, for the logs. The values up to
|
||||
// WAPI are the same in ESP-IDF 4.4 and 5.x.
|
||||
|
|
@ -91,10 +91,9 @@ void releaseAssetName(char *buf, size_t size, const char *kind, const char *chip
|
|||
// depth is how many the dump claims are valid.
|
||||
void formatBacktrace(char *buf, size_t size, const uint32_t *bt, size_t maxFrames, uint32_t depth);
|
||||
// How well loop() keeps up: the passes it made per second and its longest
|
||||
// pass, over the window since the last memStatus broadcast. Only the loop
|
||||
// pass, over the window since the last memory broadcast. Only the loop
|
||||
// task calls record(), rate(), maxUs() and publish(). publish() closes the
|
||||
// window and keeps its figures where another task (the /discovery response)
|
||||
// can read them without seeing a half reset window.
|
||||
// window and keeps its figures for the memory event and a client's sync.
|
||||
class LoopStats {
|
||||
uint32_t _passes = 0;
|
||||
uint32_t _maxUs = 0;
|
||||
|
|
|
|||
|
|
@ -1,55 +1,5 @@
|
|||
#include "WResp.h"
|
||||
#include "esp_log.h"
|
||||
#include "Timing.h"
|
||||
|
||||
static const char *TAG = "WResp";
|
||||
#define EMIT_SLOW_MS 20
|
||||
void JsonSockEvent::beginEvent(AsyncWebSocket *server, const char *evt, char *buff, size_t buffSize) {
|
||||
this->server = server;
|
||||
this->_evt = evt;
|
||||
this->buff = buff;
|
||||
this->buffSize = buffSize;
|
||||
this->_nocomma = true;
|
||||
this->_closed = false;
|
||||
this->_overflow = false;
|
||||
const int n = snprintf(this->buff, buffSize, "42[%s,", evt);
|
||||
this->_len = n < 0 ? 0 : ((size_t)n < buffSize ? (size_t)n : buffSize - 1);
|
||||
}
|
||||
void JsonSockEvent::closeEvent() {
|
||||
if(!this->_closed) {
|
||||
// The ] and the terminator both have to fit.
|
||||
if(this->_len + 1 < buffSize) this->_appendAt("]", false, 1);
|
||||
else this->_overflow = true;
|
||||
}
|
||||
this->_nocomma = true;
|
||||
this->_closed = true;
|
||||
}
|
||||
void JsonSockEvent::endEvent(uint32_t clientId) {
|
||||
this->closeEvent();
|
||||
// Part of it is missing so clients would only fail to parse it.
|
||||
if(this->_overflow) {
|
||||
ESP_LOGE(TAG, "Socket event too large for %u bytes, not sent: %.32s", (unsigned)this->buffSize, this->buff);
|
||||
return;
|
||||
}
|
||||
ElapsedTimer timer;
|
||||
if(clientId == 0) this->server->textAll(this->buff);
|
||||
else this->server->text(clientId, this->buff);
|
||||
// Sends happen every few seconds, so only the slow ones are worth a line.
|
||||
const uint32_t us = timer.us();
|
||||
if(us < EMIT_SLOW_MS * 1000) return;
|
||||
char what[80];
|
||||
if(clientId == 0) snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to all", this->_evt, (unsigned)this->_len);
|
||||
else snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to %lu", this->_evt, (unsigned)this->_len, (unsigned long)clientId);
|
||||
logElapsed(TAG, what, us);
|
||||
}
|
||||
void JsonSockEvent::_safecat(const char *val, bool escape) {
|
||||
const size_t vlen = escape ? this->calcEscapedLength(val) + 2 : strlen(val);
|
||||
if(this->_len + vlen >= this->buffSize) {
|
||||
this->_overflow = true;
|
||||
return;
|
||||
}
|
||||
this->_appendAt(val, escape, vlen);
|
||||
}
|
||||
void AsyncJsonResp::beginResponse(AsyncWebServerRequest *request, char *buff, size_t buffSize) {
|
||||
this->_request = request;
|
||||
this->buff = buff;
|
||||
|
|
|
|||
11
src/WResp.h
11
src/WResp.h
|
|
@ -14,15 +14,4 @@ class AsyncJsonResp : public JsonFormatter {
|
|||
void endResponse();
|
||||
void flush();
|
||||
};
|
||||
class JsonSockEvent : public JsonFormatter {
|
||||
protected:
|
||||
bool _closed = false;
|
||||
const char *_evt = "";
|
||||
void _safecat(const char *val, bool escape = false) override;
|
||||
public:
|
||||
AsyncWebSocket *server = nullptr;
|
||||
void beginEvent(AsyncWebSocket *server, const char *evt, char *buff, size_t buffSize);
|
||||
void endEvent(uint32_t clientId = 0);
|
||||
void closeEvent();
|
||||
};
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
#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("/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);
|
||||
});
|
||||
}
|
||||
234
src/entryPoints/HttpRoutes.cpp
Normal file
234
src/entryPoints/HttpRoutes.cpp
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
#include <LittleFS.h>
|
||||
#include <Update.h>
|
||||
#include "TaskWdt.h"
|
||||
#include "esp_log.h"
|
||||
#include "ConfigSettings.h"
|
||||
#include "ConfigFile.h"
|
||||
#include "Utils.h"
|
||||
#include "SSDP.h"
|
||||
#include "Somfy.h"
|
||||
#include "LoopQueue.h"
|
||||
#include "Web.h"
|
||||
#include "WebRoutes.h"
|
||||
#include "MqttConnection.h"
|
||||
#include <AsyncTCP.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <AsyncJson.h>
|
||||
|
||||
// HttpRoutes: the HTTP routes on port 80, the web UI's files and the few that
|
||||
// stay HTTP (docs/adr/0006-two-security-modes-eleven-http-routes.md).
|
||||
// Everything else is an op on the socket at /ws.
|
||||
|
||||
extern ConfigSettings settings;
|
||||
extern SSDPClass SSDP;
|
||||
extern rebootDelay_t rebootDelay;
|
||||
extern SomfyShadeController somfy;
|
||||
extern Web webServer;
|
||||
extern MqttConnection mqtt;
|
||||
|
||||
static const char *TAG = "Web";
|
||||
|
||||
// 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;
|
||||
}
|
||||
void registerHttpRoutes(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);
|
||||
});
|
||||
// Commands for scripts, as a JSON body or query parameters.
|
||||
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("/tiltCommand",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleTiltCommand(request, json); }));
|
||||
server.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); });
|
||||
|
||||
server.on("/upnp.xml", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
AsyncResponseStream *response = request->beginResponseStream("text/xml");
|
||||
SSDP.schema(*response);
|
||||
request->send(response);
|
||||
});
|
||||
|
||||
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("/backup", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); });
|
||||
|
||||
server.on("/restore", HTTP_POST,
|
||||
[](AsyncWebServerRequest *request) {
|
||||
if(webServer.uploadSuccess) {
|
||||
restore_options_t opts;
|
||||
if(asyncHasParam(request, "data")) {
|
||||
String dataStr = asyncParam(request, "data");
|
||||
ESP_LOGD(TAG, "%s", dataStr.c_str());
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, dataStr);
|
||||
if(err) {
|
||||
request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}");
|
||||
return;
|
||||
}
|
||||
else {
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
opts.fromJSON(obj);
|
||||
}
|
||||
}
|
||||
else {
|
||||
ESP_LOGD(TAG, "No restore options sent. Using defaults...");
|
||||
opts.shades = true;
|
||||
}
|
||||
// Checked here so the client hears about a bad file. It only reads
|
||||
// the file; the shades are still changed on loop() alone.
|
||||
if(!ShadeConfigFile::validateFile("/shades.tmp")) {
|
||||
LittleFS.remove("/shades.tmp");
|
||||
request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"This isn't a valid backup file, so nothing was restored.\"}"));
|
||||
return;
|
||||
}
|
||||
// The main loop restores, since it owns the shades and their rolling
|
||||
// codes, and then reboots.
|
||||
loop_item_t item;
|
||||
item.type = loop_item_type_t::restore_shades;
|
||||
item.restoreParts = restoreParts(opts);
|
||||
if(!queueLoopItem(request, item)) return;
|
||||
request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}");
|
||||
}
|
||||
else request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"The upload didn't complete, so nothing was restored.\"}"));
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
resetTaskWdt();
|
||||
if(index == 0) {
|
||||
webServer.uploadSuccess = false;
|
||||
ESP_LOGD(TAG, "Restore: %s", filename.c_str());
|
||||
File fup = LittleFS.open("/shades.tmp", "w");
|
||||
fup.close();
|
||||
}
|
||||
if(len > 0) {
|
||||
File fup = LittleFS.open("/shades.tmp", "a");
|
||||
fup.write(data, len);
|
||||
fup.close();
|
||||
}
|
||||
if(final) {
|
||||
webServer.uploadSuccess = true;
|
||||
}
|
||||
});
|
||||
|
||||
// updateFirmware - file upload
|
||||
server.on("/updateFirmware", HTTP_POST,
|
||||
[](AsyncWebServerRequest *request) {
|
||||
if(Update.hasError())
|
||||
request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}");
|
||||
else
|
||||
request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated firmware\"}");
|
||||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 500;
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
if(index == 0) {
|
||||
webServer.uploadSuccess = false;
|
||||
ESP_LOGI(TAG, "Update: %s", filename.c_str());
|
||||
if(!Update.begin(UPDATE_SIZE_UNKNOWN)) {
|
||||
ESP_LOGE(TAG, "Update begin failed");
|
||||
}
|
||||
else {
|
||||
somfy.transceiver.end();
|
||||
mqtt.end();
|
||||
request->onDisconnect(onUploadDisconnect);
|
||||
}
|
||||
}
|
||||
if(len > 0) {
|
||||
if(Update.write(data, len) != len) {
|
||||
ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len);
|
||||
Update.abort();
|
||||
}
|
||||
}
|
||||
if(final) {
|
||||
if(Update.end(true)) {
|
||||
ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len);
|
||||
webServer.uploadSuccess = true;
|
||||
}
|
||||
else {
|
||||
ESP_LOGE(TAG, "Update end failed");
|
||||
}
|
||||
}
|
||||
resetTaskWdt();
|
||||
});
|
||||
|
||||
// updateApplication - file upload
|
||||
server.on("/updateApplication", HTTP_POST,
|
||||
[](AsyncWebServerRequest *request) {
|
||||
if(Update.hasError())
|
||||
request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}");
|
||||
else
|
||||
request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated application\"}");
|
||||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 500;
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
if(index == 0) {
|
||||
webServer.uploadSuccess = false;
|
||||
ESP_LOGI(TAG, "Update: %s", filename.c_str());
|
||||
if(!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) {
|
||||
ESP_LOGE(TAG, "Update begin failed");
|
||||
}
|
||||
else {
|
||||
somfy.transceiver.end();
|
||||
mqtt.end();
|
||||
request->onDisconnect(onUploadDisconnect);
|
||||
}
|
||||
}
|
||||
if(len > 0) {
|
||||
if(Update.write(data, len) != len) {
|
||||
ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len);
|
||||
Update.abort();
|
||||
}
|
||||
}
|
||||
if(final) {
|
||||
if(Update.end(true)) {
|
||||
webServer.uploadSuccess = true;
|
||||
ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len);
|
||||
somfy.commit();
|
||||
}
|
||||
else {
|
||||
somfy.commit();
|
||||
ESP_LOGE(TAG, "Update end failed");
|
||||
}
|
||||
}
|
||||
resetTaskWdt();
|
||||
});
|
||||
|
||||
server.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");
|
||||
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
#include <WiFi.h>
|
||||
#include <LittleFS.h>
|
||||
#include <Update.h>
|
||||
#include <esp_task_wdt.h>
|
||||
#include <esp_timer.h>
|
||||
#include "esp_log.h"
|
||||
#include "mbedtls/md.h"
|
||||
|
|
@ -9,52 +6,36 @@
|
|||
#include <esp_random.h>
|
||||
#include <bootloader_random.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 "ItemSnapshot.h"
|
||||
#include "SomfySnapshot.h"
|
||||
#include "SomfyEditor.h"
|
||||
#include "Web.h"
|
||||
#include "WebRoutes.h"
|
||||
#include "outputs/ProtoSocket.h"
|
||||
#include "MqttConnection.h"
|
||||
#include "ESPNetwork.h"
|
||||
#include "Timing.h"
|
||||
#include "LoginThrottle.h"
|
||||
#include <AsyncTCP.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <AsyncJson.h>
|
||||
#include <memory>
|
||||
|
||||
extern ConfigSettings settings;
|
||||
extern SSDPClass SSDP;
|
||||
extern rebootDelay_t rebootDelay;
|
||||
extern LoopStats loopStats;
|
||||
extern SomfyShadeController somfy;
|
||||
extern Web webServer;
|
||||
extern MqttConnection mqtt;
|
||||
extern ESPNetwork net;
|
||||
extern LoopQueue loopQueue;
|
||||
extern SnapshotSlots snapshotSlots;
|
||||
|
||||
char g_async_content[WEB_MAX_RESPONSE];
|
||||
|
||||
|
||||
// General responses
|
||||
static const char _response_404[] = "404: Service Not Found";
|
||||
|
||||
|
||||
// Encodings
|
||||
const char _encoding_text[] = "text/plain";
|
||||
const char _encoding_json[] = "application/json";
|
||||
|
||||
static const char *TAG = "Web";
|
||||
|
||||
// Failed logins per client IP, on both web servers.
|
||||
// Failed logins per client IP.
|
||||
static LoginThrottle loginThrottle;
|
||||
// The throttle's key for a client: its IPv4 address. The firmware doesn't
|
||||
// enable IPv6; an IPv6 client would share key 0 with every other one.
|
||||
|
|
@ -65,7 +46,7 @@ static uint64_t loginNow() { return (uint64_t)esp_timer_get_time() / 1000; }
|
|||
// The tokens' HMAC key. It has its own NVS namespace, which no settings
|
||||
// save clears and no backup holds, so only erasing NVS replaces it.
|
||||
static uint8_t tokenKey[API_TOKEN_KEY_SIZE];
|
||||
// Reads the token key, or makes one on first boot. Runs before either web
|
||||
// Reads the token key, or makes one on first boot. Runs before the web
|
||||
// server starts, so no token is made with an empty key, and before WiFi, so
|
||||
// the hardware RNG takes its entropy from the bootloader's source: calling it
|
||||
// once the radio is on would disturb WiFi.
|
||||
|
|
@ -82,6 +63,14 @@ static void loadTokenKey() {
|
|||
pref.end();
|
||||
}
|
||||
|
||||
// Sent when a command can't be handed to the main loop.
|
||||
static void sendQueueFull(AsyncWebServerRequest *request) {
|
||||
request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Command queue full\"}"));
|
||||
}
|
||||
// Sent when the radio can't take a frame (suspended or queue full).
|
||||
static void sendRadioUnavailable(AsyncWebServerRequest *request) {
|
||||
request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Radio TX unavailable\"}"));
|
||||
}
|
||||
// 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) {
|
||||
|
|
@ -91,27 +80,15 @@ bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) {
|
|||
if(!loopQueue.submit(item)) { sendQueueFull(request); return false; }
|
||||
return true;
|
||||
}
|
||||
bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req) {
|
||||
static bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req) {
|
||||
loop_item_t item;
|
||||
item.request = req;
|
||||
return queueLoopItem(request, item);
|
||||
}
|
||||
// Sent when a command can't be handed to the main loop.
|
||||
void sendQueueFull(AsyncWebServerRequest *request) {
|
||||
request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Command queue full\"}"));
|
||||
}
|
||||
// Sent when the radio can't take a frame (suspended or queue full).
|
||||
void sendRadioUnavailable(AsyncWebServerRequest *request) {
|
||||
request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Radio TX unavailable\"}"));
|
||||
}
|
||||
|
||||
AsyncWebServer asyncServer(80);
|
||||
AsyncWebServer asyncApiServer(8081);
|
||||
void Web::startup() {
|
||||
ESP_LOGI(TAG, "Launching web server...");
|
||||
loadTokenKey();
|
||||
asyncApiServer.begin();
|
||||
ESP_LOGI(TAG, "Async API server started on port 8081");
|
||||
}
|
||||
void Web::loop() {
|
||||
delay(1);
|
||||
|
|
@ -187,183 +164,20 @@ bool asyncHasParam(AsyncWebServerRequest *request, const char *name) {
|
|||
}
|
||||
|
||||
// -- Serialization helpers --
|
||||
// Replies that still read the live shade model on this task (command and edit
|
||||
// replies) copy one item into the ModelSnapshot types and write it the way the
|
||||
// read routes do. The read routes themselves write from a ModelSnapshot that
|
||||
// loop() fills (sendSnapshot).
|
||||
void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) {
|
||||
// The command replies read the live shade model on this task: they copy the
|
||||
// one item into the snapshot types and write its reference.
|
||||
static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) {
|
||||
shade_snapshot_t s;
|
||||
snapshotShade(*shade, s);
|
||||
writeShadeRef(json, s);
|
||||
}
|
||||
// paired and lastRollingCode are passed in for a response that reports the
|
||||
// change the main loop is about to make. An unpair is held until it goes on
|
||||
// air (holdLoopItem), so the answer comes true.
|
||||
void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) {
|
||||
shade_snapshot_t s;
|
||||
snapshotShade(*shade, s);
|
||||
s.paired = paired;
|
||||
s.lastRollingCode = lastRollingCode;
|
||||
writeShade(json, s);
|
||||
}
|
||||
void serializeShade(SomfyShade *shade, JsonFormatter &json) { serializeShade(shade, json, shade->paired, shade->lastRollingCode); }
|
||||
static void serializeGroupRef(SomfyGroup *group, JsonFormatter &json) {
|
||||
group_snapshot_t s;
|
||||
snapshotGroup(*group, s);
|
||||
writeGroupRef(json, s);
|
||||
}
|
||||
static void serializeGroup(SomfyGroup *group, JsonFormatter &json) {
|
||||
group_snapshot_t s;
|
||||
snapshotGroup(*group, s);
|
||||
shade_snapshot_t linked;
|
||||
writeGroup(json, s, liveShade, &linked);
|
||||
}
|
||||
void serializeTransceiverConfig(JsonFormatter &json) {
|
||||
auto &cfg = somfy.transceiver.config;
|
||||
json.addElem("type", cfg.type);
|
||||
json.addElem("TXPin", cfg.TXPin);
|
||||
json.addElem("RXPin", cfg.RXPin);
|
||||
json.addElem("SCKPin", cfg.SCKPin);
|
||||
json.addElem("MOSIPin", cfg.MOSIPin);
|
||||
json.addElem("MISOPin", cfg.MISOPin);
|
||||
json.addElem("CSNPin", cfg.CSNPin);
|
||||
json.addElem("rxBandwidth", cfg.rxBandwidth);
|
||||
json.addElem("frequency", cfg.frequency);
|
||||
json.addElem("deviation", cfg.deviation);
|
||||
json.addElem("txPower", cfg.txPower);
|
||||
json.addElem("proto", static_cast<uint8_t>(cfg.proto));
|
||||
json.addElem("enabled", cfg.enabled);
|
||||
json.addElem("noiseDetection", cfg.noiseDetection);
|
||||
json.addElem("radioInit", cfg.radioInit);
|
||||
}
|
||||
void serializeAppVersion(JsonFormatter &json, appver_t &ver) {
|
||||
json.addElem("name", ver.name);
|
||||
json.addElem("major", ver.major);
|
||||
json.addElem("minor", ver.minor);
|
||||
json.addElem("build", ver.build);
|
||||
json.addElem("suffix", ver.suffix);
|
||||
}
|
||||
void serializeVersion(JsonFormatter &json) {
|
||||
json.beginObject("fwVersion");
|
||||
serializeAppVersion(json, settings.fwVersion);
|
||||
json.endObject();
|
||||
json.beginObject("appVersion");
|
||||
serializeAppVersion(json, settings.appVersion);
|
||||
json.endObject();
|
||||
}
|
||||
|
||||
// The parts of /controller that aren't in the shade model.
|
||||
static void controllerHeader(JsonFormatter &json, const model_snapshot_t &snap) {
|
||||
json.addElem("maxRooms", (uint8_t)SOMFY_MAX_ROOMS);
|
||||
json.addElem("maxShades", (uint8_t)SOMFY_MAX_SHADES);
|
||||
json.addElem("maxGroups", (uint8_t)SOMFY_MAX_GROUPS);
|
||||
json.addElem("maxGroupedShades", (uint8_t)SOMFY_MAX_GROUPED_SHADES);
|
||||
json.addElem("maxLinkedRemotes", (uint8_t)SOMFY_MAX_LINKED_REMOTES);
|
||||
json.addElem("startingAddress", snap.startingAddress);
|
||||
json.beginObject("transceiver");
|
||||
json.beginObject("config");
|
||||
serializeTransceiverConfig(json);
|
||||
json.endObject();
|
||||
json.endObject();
|
||||
json.beginObject("version");
|
||||
serializeVersion(json);
|
||||
json.endObject();
|
||||
}
|
||||
// The parts of /discovery that aren't in the shade model.
|
||||
static void discoveryHeader(JsonFormatter &json, const model_snapshot_t &snap) {
|
||||
(void)snap;
|
||||
json.addElem("serverId", settings.serverId);
|
||||
json.addElem("version", settings.fwVersion.name);
|
||||
json.addElem("model", "ESPSomfyRTS");
|
||||
json.addElem("hostname", settings.hostname);
|
||||
json.addElem("authType", static_cast<uint8_t>(settings.Security.type));
|
||||
json.addElem("permissions", settings.Security.permissions);
|
||||
json.addElem("chipModel", settings.chipModel);
|
||||
json.addElem("connType", net.connTypeName());
|
||||
json.beginObject("memory");
|
||||
json.addElem("max", ESP.getMaxAllocHeap());
|
||||
json.addElem("free", ESP.getFreeHeap());
|
||||
json.addElem("min", ESP.getMinFreeHeap());
|
||||
json.addElem("total", ESP.getHeapSize());
|
||||
json.addElem("uptime", (uint64_t)millis());
|
||||
// This runs on the web server's task, so it reports the last window the
|
||||
// loop task closed rather than reading the one it is still filling.
|
||||
json.addElem("loopRate", loopStats.publishedRate());
|
||||
json.addElem("loopMaxUs", loopStats.publishedMaxUs());
|
||||
json.endObject();
|
||||
}
|
||||
|
||||
// A read served from a ModelSnapshot that loop() fills, so the document
|
||||
// describes one moment (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md).
|
||||
// Each item is formatted into a fixed 3 KB unit and streamed as the TCP send
|
||||
// window allows, so there is no growing response buffer either.
|
||||
struct SnapshotRead {
|
||||
uint8_t slot;
|
||||
char unit[SNAPSHOT_UNIT_SIZE];
|
||||
SnapshotWriter writer;
|
||||
SnapshotRead(uint8_t slot, snapshot_doc_t doc, uint8_t id, snapshot_header_t header)
|
||||
: slot(slot), writer(snapshotSlots.snapshot(slot), doc, id, header, unit, sizeof(unit)) {}
|
||||
// The response is gone, whether it finished or the client went away.
|
||||
~SnapshotRead() { snapshotSlots.release(this->slot); }
|
||||
};
|
||||
void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id, snapshot_header_t header) {
|
||||
const int8_t slot = snapshotSlots.acquire();
|
||||
if(slot < 0) {
|
||||
request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the read again\"}"));
|
||||
return;
|
||||
}
|
||||
loop_item_t item;
|
||||
item.type = loop_item_type_t::snapshot_fill;
|
||||
item.slot = static_cast<uint8_t>(slot);
|
||||
if(!loopQueue.submit(item)) {
|
||||
snapshotSlots.cancel(slot);
|
||||
sendQueueFull(request);
|
||||
return;
|
||||
}
|
||||
auto read = std::make_shared<SnapshotRead>(static_cast<uint8_t>(slot), doc, id, header);
|
||||
// The headers go now; the body waits until loop() has filled the snapshot.
|
||||
// The library asks again on the next ACK or poll.
|
||||
AsyncWebServerResponse *response = request->beginChunkedResponse(_encoding_json,
|
||||
[read](uint8_t *buffer, size_t maxLen, size_t index) -> size_t {
|
||||
(void)index;
|
||||
if(!snapshotSlots.filled(read->slot)) return RESPONSE_TRY_AGAIN;
|
||||
return read->writer.generate(buffer, maxLen);
|
||||
});
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
// -- Async handler implementations --
|
||||
void Web::handleDiscovery(AsyncWebServerRequest *request) {
|
||||
if(request->method() == HTTP_POST || request->method() == HTTP_GET) {
|
||||
ESP_LOGD(TAG, "Async Discovery Requested");
|
||||
sendSnapshot(request, snapshot_doc_t::discovery, 0, discoveryHeader);
|
||||
}
|
||||
else
|
||||
request->send(500, _encoding_text, "Invalid http method");
|
||||
}
|
||||
void Web::handleGetRooms(AsyncWebServerRequest *request) {
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
if(request->method() == HTTP_POST || request->method() == HTTP_GET) sendSnapshot(request, snapshot_doc_t::rooms);
|
||||
else request->send(404, _encoding_text, _response_404);
|
||||
}
|
||||
void Web::handleGetShades(AsyncWebServerRequest *request) {
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
if(request->method() == HTTP_POST || request->method() == HTTP_GET) sendSnapshot(request, snapshot_doc_t::shades);
|
||||
else request->send(404, _encoding_text, _response_404);
|
||||
}
|
||||
void Web::handleGetGroups(AsyncWebServerRequest *request) {
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
if(request->method() == HTTP_POST || request->method() == HTTP_GET) sendSnapshot(request, snapshot_doc_t::groups);
|
||||
else request->send(404, _encoding_text, _response_404);
|
||||
}
|
||||
void Web::handleController(AsyncWebServerRequest *request) {
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
if(request->method() == HTTP_POST || request->method() == HTTP_GET) {
|
||||
settings.printAvailHeap();
|
||||
sendSnapshot(request, snapshot_doc_t::controller, 0, controllerHeader);
|
||||
}
|
||||
else request->send(404, _encoding_text, _response_404);
|
||||
}
|
||||
void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
char token[API_TOKEN_SIZE];
|
||||
|
|
@ -534,188 +348,6 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
|
|||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}"));
|
||||
}
|
||||
}
|
||||
void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
uint8_t shadeId = 255;
|
||||
uint8_t groupId = 255;
|
||||
shade_request_t req;
|
||||
if(asyncHasParam(request, "shadeId")) shadeId = asyncParam(request, "shadeId").toInt();
|
||||
else if(asyncHasParam(request, "groupId")) groupId = asyncParam(request, "groupId").toInt();
|
||||
if(asyncHasParam(request, "command")) req.command = translateSomfyCommand(asyncParam(request, "command"));
|
||||
if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt());
|
||||
if(asyncHasParam(request, "stepSize")) req.stepSize = asyncParam(request, "stepSize").toInt();
|
||||
if(shadeId == 255 && groupId == 255 && !json.isNull()) {
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"];
|
||||
if(!obj["groupId"].isNull()) groupId = obj["groupId"];
|
||||
if(!obj["stepSize"].isNull()) req.stepSize = obj["stepSize"];
|
||||
if(!obj["command"].isNull()) { String scmd = obj["command"]; req.command = translateSomfyCommand(scmd); }
|
||||
if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as<int>());
|
||||
}
|
||||
if(shadeId != 255) {
|
||||
ESP_LOGI(TAG, "handleRepeatCommand shade=%u command=%s", shadeId, translateSomfyCommand(req.command).c_str());
|
||||
SomfyShade *shade = somfy.getShadeById(shadeId);
|
||||
if(!shade) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade reference could not be found.\"}")); return; }
|
||||
req.type = shade_request_type_t::shade_repeat;
|
||||
req.id = shadeId;
|
||||
if(!queueShadeRequest(request, req)) return;
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeShadeRef(shade, resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
else if(groupId != 255) {
|
||||
ESP_LOGI(TAG, "handleRepeatCommand group=%u command=%s", groupId, translateSomfyCommand(req.command).c_str());
|
||||
SomfyGroup *group = somfy.getGroupById(groupId);
|
||||
if(!group) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group reference could not be found.\"}")); return; }
|
||||
req.type = shade_request_type_t::group_repeat;
|
||||
req.id = groupId;
|
||||
if(!queueShadeRequest(request, req)) return;
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeGroupRef(group, resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid Http method\"}"));
|
||||
}
|
||||
void Web::handleRoom(AsyncWebServerRequest *request) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
if(request->method() == HTTP_GET) {
|
||||
if(asyncHasParam(request, "roomId")) {
|
||||
int roomId = asyncParam(request, "roomId").toInt();
|
||||
// The status goes out before the snapshot is filled, so whether the room
|
||||
// exists is checked here, on the live model.
|
||||
if(somfy.getRoomById(roomId)) sendSnapshot(request, snapshot_doc_t::room, roomId);
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Room Id not found.\"}"));
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid room id.\"}"));
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid Http method\"}"));
|
||||
}
|
||||
void Web::handleShade(AsyncWebServerRequest *request) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
if(request->method() == HTTP_GET) {
|
||||
if(asyncHasParam(request, "shadeId")) {
|
||||
int shadeId = asyncParam(request, "shadeId").toInt();
|
||||
// As for /room, existence is checked on the live model.
|
||||
if(somfy.getShadeById(shadeId)) sendSnapshot(request, snapshot_doc_t::shade, shadeId);
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}"));
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid shade id.\"}"));
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid Http method\"}"));
|
||||
}
|
||||
void Web::handleGroup(AsyncWebServerRequest *request) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
if(request->method() == HTTP_GET) {
|
||||
if(asyncHasParam(request, "groupId")) {
|
||||
int groupId = asyncParam(request, "groupId").toInt();
|
||||
// As for /room, existence is checked on the live model.
|
||||
if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::group, groupId);
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}"));
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}"));
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Invalid Http method\"}"));
|
||||
}
|
||||
void Web::handleSetPositions(AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
shade_request_t req;
|
||||
req.type = shade_request_type_t::set_positions;
|
||||
if(asyncHasParam(request, "shadeId")) req.id = asyncParam(request, "shadeId").toInt();
|
||||
if(asyncHasParam(request, "position")) req.position = requestTarget(asyncParam(request, "position").toInt());
|
||||
if(asyncHasParam(request, "tiltPosition")) req.tiltPosition = requestTarget(asyncParam(request, "tiltPosition").toInt());
|
||||
if(!json.isNull()) {
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
if(!obj["shadeId"].isNull()) req.id = obj["shadeId"];
|
||||
if(!obj["position"].isNull()) req.position = requestTarget(obj["position"].as<int>());
|
||||
if(!obj["tiltPosition"].isNull()) req.tiltPosition = requestTarget(obj["tiltPosition"].as<int>());
|
||||
}
|
||||
if(req.id != 255) {
|
||||
ESP_LOGI(TAG, "handleSetPositions shade=%u pos=%d tiltPos=%d", req.id, req.position, req.tiltPosition);
|
||||
SomfyShade *shade = somfy.getShadeById(req.id);
|
||||
if(shade) {
|
||||
if(!queueShadeRequest(request, req)) return;
|
||||
// loop() applies the request after we answer so this is the state before
|
||||
// it. Clients get the result from the shadeState socket event.
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeShade(shade, resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"An invalid shadeId was provided\"}"));
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"shadeId was not provided\"}"));
|
||||
}
|
||||
void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
uint8_t shadeId = asyncHasParam(request, "shadeId") ? asyncParam(request, "shadeId").toInt() : 255;
|
||||
uint8_t groupId = asyncHasParam(request, "groupId") ? asyncParam(request, "groupId").toInt() : 255;
|
||||
shade_request_t req;
|
||||
if(asyncHasParam(request, "sunny")) req.sunny = toBoolean(asyncParam(request, "sunny").c_str(), false) ? 1 : 0;
|
||||
if(asyncHasParam(request, "windy")) req.windy = asyncParam(request, "windy").toInt();
|
||||
if(asyncHasParam(request, "repeat")) req.repeat = requestRepeat(asyncParam(request, "repeat").toInt());
|
||||
if(!json.isNull()) {
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"].as<uint8_t>();
|
||||
if(!obj["groupId"].isNull()) groupId = obj["groupId"].as<uint8_t>();
|
||||
if(!obj["sunny"].isNull()) {
|
||||
if(obj["sunny"].is<bool>()) req.sunny = obj["sunny"].as<bool>() ? 1 : 0;
|
||||
else req.sunny = obj["sunny"].as<int8_t>();
|
||||
}
|
||||
if(!obj["windy"].isNull()) {
|
||||
if(obj["windy"].is<bool>()) req.windy = obj["windy"].as<bool>() ? 1 : 0;
|
||||
else req.windy = obj["windy"].as<int8_t>();
|
||||
}
|
||||
if(!obj["repeat"].isNull()) req.repeat = requestRepeat(obj["repeat"].as<int>());
|
||||
}
|
||||
if(shadeId != 255) {
|
||||
SomfyShade *shade = somfy.getShadeById(shadeId);
|
||||
if(shade) {
|
||||
req.type = shade_request_type_t::shade_sensor;
|
||||
req.id = shadeId;
|
||||
if(!queueShadeRequest(request, req)) return;
|
||||
// The main loop applies the command after we answer so this is the state
|
||||
// before it. Clients get the result from the shadeState socket event.
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeShade(shade, resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"An invalid shadeId was provided\"}"));
|
||||
}
|
||||
else if(groupId != 255) {
|
||||
SomfyGroup *group = somfy.getGroupById(groupId);
|
||||
if(group) {
|
||||
req.type = shade_request_type_t::group_sensor;
|
||||
req.id = groupId;
|
||||
if(!queueShadeRequest(request, req)) return;
|
||||
// As above, this is the state before the command and groupState follows.
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeGroup(group, resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"An invalid groupId was provided\"}"));
|
||||
}
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"shadeId was not provided\"}"));
|
||||
}
|
||||
void Web::handleBackup(AsyncWebServerRequest *request) {
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
bool attach = false;
|
||||
|
|
@ -744,17 +376,6 @@ void Web::handleBackup(AsyncWebServerRequest *request) {
|
|||
request->send(200, _encoding_text, somfy.backupData);
|
||||
}
|
||||
}
|
||||
void Web::handleReboot(AsyncWebServerRequest *request) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
if(request->method() == HTTP_POST || request->method() == HTTP_PUT) {
|
||||
ESP_LOGI(TAG, "Async Rebooting ESP...");
|
||||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 500;
|
||||
request->send(200, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully started reboot\"}");
|
||||
}
|
||||
else request->send(201, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Invalid HTTP Method\"}");
|
||||
}
|
||||
void Web::handleNotFound(AsyncWebServerRequest *request) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
snprintf(g_async_content, sizeof(g_async_content), "404 Service Not Found: %s", request->url().c_str());
|
||||
|
|
@ -762,41 +383,37 @@ void Web::handleNotFound(AsyncWebServerRequest *request) {
|
|||
}
|
||||
|
||||
// Logs how long part of a request took, when it is worth logging.
|
||||
static void logRequestTime(const char *server, AsyncWebServerRequest *request, const IPAddress &ip, const char *part, uint32_t us) {
|
||||
static void logRequestTime(AsyncWebServerRequest *request, const IPAddress &ip, const char *part, uint32_t us) {
|
||||
if(!worthLogging(us)) return;
|
||||
char what[112];
|
||||
snprintf(what, sizeof(what), "%s %s %s from %u.%u.%u.%u %s", server, request->methodToString(), request->url().c_str(),
|
||||
snprintf(what, sizeof(what), "web %s %s from %u.%u.%u.%u %s", request->methodToString(), request->url().c_str(),
|
||||
ip[0], ip[1], ip[2], ip[3], part);
|
||||
logElapsed(TAG, what, us);
|
||||
}
|
||||
// Logs how long a request takes to handle and, once the connection closes, how
|
||||
// long the whole exchange took. The total includes chunked bodies like
|
||||
// /controller that are built while they are sent, after the handler returns.
|
||||
// The server name must be a literal as the disconnect handler keeps it.
|
||||
static void timeRequest(AsyncWebServerRequest *request, ArMiddlewareNext next, const char *server) {
|
||||
// long the whole exchange took. The total includes bodies sent after the
|
||||
// handler returns, such as a web UI file.
|
||||
static void timeRequest(AsyncWebServerRequest *request, ArMiddlewareNext next) {
|
||||
ElapsedTimer timer;
|
||||
// Taken now as the client may already be gone when the disconnect runs.
|
||||
IPAddress ip = request->client() ? request->client()->remoteIP() : IPAddress();
|
||||
// Uploads set their own disconnect handler while the body arrives, before
|
||||
// this runs, and replacing it would stop the reboot after an update.
|
||||
if(!request->multipart()) {
|
||||
request->onDisconnect([request, timer, server, ip]() { logRequestTime(server, request, ip, "total", timer.us()); });
|
||||
request->onDisconnect([request, timer, ip]() { logRequestTime(request, ip, "total", timer.us()); });
|
||||
}
|
||||
next();
|
||||
logRequestTime(server, request, ip, "handler", timer.us());
|
||||
logRequestTime(request, ip, "handler", timer.us());
|
||||
}
|
||||
|
||||
void Web::begin() {
|
||||
ESP_LOGI(TAG, "Creating Web MicroServices...");
|
||||
asyncApiServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next, "api"); });
|
||||
asyncServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next, "web"); });
|
||||
// Async API Server (port 8081)
|
||||
asyncServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next); });
|
||||
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
|
||||
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS");
|
||||
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*");
|
||||
registerApiRoutes(asyncApiServer);
|
||||
// Before the routes, so serveStatic never answers /ws.
|
||||
protoSocket.begin(asyncServer);
|
||||
registerWebUiRoutes(asyncServer);
|
||||
registerHttpRoutes(asyncServer);
|
||||
asyncServer.begin();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,24 +18,12 @@ class Web {
|
|||
bool createAPIPasswordToken(const IPAddress ipAddress, const char *username, const char *password, char *token);
|
||||
bool isAuthenticated(AsyncWebServerRequest *request, bool cfg = false);
|
||||
|
||||
// Async API handlers
|
||||
void handleDiscovery(AsyncWebServerRequest *request);
|
||||
void handleGetRooms(AsyncWebServerRequest *request);
|
||||
void handleGetShades(AsyncWebServerRequest *request);
|
||||
void handleGetGroups(AsyncWebServerRequest *request);
|
||||
void handleController(AsyncWebServerRequest *request);
|
||||
void handleRoom(AsyncWebServerRequest *request);
|
||||
void handleShade(AsyncWebServerRequest *request);
|
||||
void handleGroup(AsyncWebServerRequest *request);
|
||||
// HTTP handlers
|
||||
void handleLogin(AsyncWebServerRequest *request, JsonVariant &json);
|
||||
void handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json);
|
||||
void handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json);
|
||||
void handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json);
|
||||
void handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json);
|
||||
void handleSetPositions(AsyncWebServerRequest *request, JsonVariant &json);
|
||||
void handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json);
|
||||
void handleBackup(AsyncWebServerRequest *request);
|
||||
void handleReboot(AsyncWebServerRequest *request);
|
||||
void handleNotFound(AsyncWebServerRequest *request);
|
||||
};
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
#ifndef WEB_ROUTES_H
|
||||
#define WEB_ROUTES_H
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include "Somfy.h"
|
||||
#include "LoopQueue.h"
|
||||
#include "ModelSnapshot.h"
|
||||
|
||||
// What WebUiRoutes, ApiRoutes and the handlers both of them call (Web.cpp)
|
||||
// share. Internal to the web servers.
|
||||
// What HttpRoutes and the handlers it calls (Web.cpp) share. Internal to the
|
||||
// web server.
|
||||
|
||||
// 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);
|
||||
// Registers the HTTP routes on port 80, and the web UI's files.
|
||||
void registerHttpRoutes(AsyncWebServer &server);
|
||||
|
||||
#define WEB_MAX_RESPONSE 4096
|
||||
// Where replies are formatted before they are sent. async_tcp only.
|
||||
|
|
@ -19,26 +15,10 @@ 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);
|
||||
// The installed firmware and web application versions.
|
||||
void serializeVersion(JsonFormatter &json);
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,902 +0,0 @@
|
|||
#include <WiFi.h>
|
||||
#include <LittleFS.h>
|
||||
#include <Update.h>
|
||||
#include "TaskWdt.h"
|
||||
#include "esp_log.h"
|
||||
#include "ConfigSettings.h"
|
||||
#include "ConfigFile.h"
|
||||
#include "Utils.h"
|
||||
#include "Secrets.h"
|
||||
#include "SSDP.h"
|
||||
#include "Somfy.h"
|
||||
#include "WResp.h"
|
||||
#include "LoopQueue.h"
|
||||
#include "ModelSnapshot.h"
|
||||
#include "SomfyEditor.h"
|
||||
#include "Web.h"
|
||||
#include "WebRoutes.h"
|
||||
#include "MqttConnection.h"
|
||||
#include "ESPNetwork.h"
|
||||
#include <AsyncTCP.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <AsyncJson.h>
|
||||
|
||||
// WebUiRoutes: the routes the config web UI calls on port 80, and the UI's own
|
||||
// files.
|
||||
|
||||
extern ConfigSettings settings;
|
||||
extern SSDPClass SSDP;
|
||||
extern rebootDelay_t rebootDelay;
|
||||
extern SomfyShadeController somfy;
|
||||
extern Web webServer;
|
||||
extern MqttConnection mqtt;
|
||||
extern ESPNetwork net;
|
||||
extern LoopQueue loopQueue;
|
||||
|
||||
static const char *TAG = "Web";
|
||||
static const char _encoding_html[] = "text/html";
|
||||
|
||||
static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) {
|
||||
shade_request_t req;
|
||||
req.type = shade_request_type_t::set_favorite;
|
||||
req.id = shade->getShadeId();
|
||||
req.position = pos;
|
||||
req.tiltPosition = tilt;
|
||||
return queueShadeRequest(request, req);
|
||||
}
|
||||
// loop() clears paired and saves it once the Prog is queued. The shade
|
||||
// belongs to loop() so it isn't changed here.
|
||||
static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) {
|
||||
shade_request_t req;
|
||||
req.type = shade_request_type_t::unpair;
|
||||
req.id = shade->getShadeId();
|
||||
return queueShadeRequest(request, req);
|
||||
}
|
||||
// Sending a command reads and commits the rolling code on loop(), so a new
|
||||
// code is set there too.
|
||||
static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) {
|
||||
shade_request_t req;
|
||||
req.type = shade_request_type_t::set_rolling_code;
|
||||
req.id = shade->getShadeId();
|
||||
req.rollingCode = rollingCode;
|
||||
return queueShadeRequest(request, req);
|
||||
}
|
||||
// A restore's options travel through the loop queue as RESTORE_* bits.
|
||||
static uint8_t restoreParts(const restore_options_t &opts) {
|
||||
return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) |
|
||||
(opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) |
|
||||
(opts.repeaters ? RESTORE_REPEATERS : 0) | (opts.mqtt ? RESTORE_MQTT : 0);
|
||||
}
|
||||
// The radio and MQTT are shut down once an upload starts and only a reboot
|
||||
// brings them back. The completion handler schedules it but never runs if the
|
||||
// client goes away mid-upload, so make sure the device reboots then too.
|
||||
static void onUploadDisconnect() {
|
||||
if(webServer.uploadSuccess || rebootDelay.reboot) return;
|
||||
ESP_LOGW(TAG, "Upload abandoned, rebooting");
|
||||
if(Update.isRunning()) Update.abort();
|
||||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 500;
|
||||
}
|
||||
// Hands an edit of the shade model to loop() (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md).
|
||||
// The reply only says it was accepted; the result reaches every client as a
|
||||
// requestResult event carrying the same requestId once loop() has applied it.
|
||||
static void queueModelEdit(AsyncWebServerRequest *request, model_edit_type_t type, JsonVariantConst body) {
|
||||
bool busy;
|
||||
model_edit_t *edit = newModelEdit(type, busy);
|
||||
if(!edit) {
|
||||
if(busy) request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}"));
|
||||
else sendQueueFull(request);
|
||||
return;
|
||||
}
|
||||
edit->requestId = nextEditRequestId();
|
||||
edit->doc.set(body);
|
||||
const uint32_t requestId = edit->requestId;
|
||||
if(!submitModelEdit(edit)) {
|
||||
sendQueueFull(request);
|
||||
return;
|
||||
}
|
||||
char reply[64];
|
||||
snprintf(reply, sizeof(reply), "{\"status\":\"accepted\",\"requestId\":%lu}", (unsigned long)requestId);
|
||||
request->send(200, _encoding_json, reply);
|
||||
}
|
||||
// The JSON (PUT/POST) form of an edit route. Bodies over 4 KB are refused with
|
||||
// 413 before they reach the heap.
|
||||
static AsyncCallbackJsonWebHandler *editHandler(const char *uri, model_edit_type_t type) {
|
||||
auto *handler = new AsyncCallbackJsonWebHandler(uri, [type](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No object supplied.\"}")); return; }
|
||||
queueModelEdit(request, type, json);
|
||||
});
|
||||
handler->setMaxContentLength(4096);
|
||||
return handler;
|
||||
}
|
||||
// The GET form of an edit route: its query parameters become the request.
|
||||
static void queueModelEditFromParams(AsyncWebServerRequest *request, model_edit_type_t type, std::initializer_list<const char *> names) {
|
||||
JsonDocument body;
|
||||
for(const char *name : names) {
|
||||
if(!asyncHasParam(request, name)) continue;
|
||||
const String value = asyncParam(request, name);
|
||||
if(strcmp(name, "paired") == 0) body[name] = toBoolean(value.c_str(), false);
|
||||
else body[name] = strtoul(value.c_str(), nullptr, 10);
|
||||
}
|
||||
queueModelEdit(request, type, body.as<JsonVariantConst>());
|
||||
}
|
||||
|
||||
void registerWebUiRoutes(AsyncWebServer &server) {
|
||||
server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
AsyncJsonResponse *response = new AsyncJsonResponse();
|
||||
JsonObject root = response->getRoot().to<JsonObject>();
|
||||
root["type"] = static_cast<uint8_t>(settings.Security.type);
|
||||
root["permissions"] = settings.Security.permissions;
|
||||
root["serverId"] = settings.serverId;
|
||||
root["version"] = settings.fwVersion.name;
|
||||
root["model"] = "ESPSomfyRTS";
|
||||
root["hostname"] = settings.hostname;
|
||||
response->setLength();
|
||||
request->send(response);
|
||||
});
|
||||
// Web Interface
|
||||
// Command endpoints - delegate to async handler methods
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleTiltCommand(request, json); }));
|
||||
server.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); });
|
||||
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleRepeatCommand(request, json); }));
|
||||
server.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleRepeatCommand(request, v); });
|
||||
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleShadeCommand(request, json); }));
|
||||
server.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleShadeCommand(request, v); });
|
||||
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleGroupCommand(request, json); }));
|
||||
server.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleGroupCommand(request, v); });
|
||||
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/setPositions",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetPositions(request, json); }));
|
||||
server.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetPositions(request, v); });
|
||||
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/setSensor",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetSensor(request, json); }));
|
||||
server.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetSensor(request, v); });
|
||||
|
||||
server.on("/upnp.xml", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
AsyncResponseStream *response = request->beginResponseStream("text/xml");
|
||||
SSDP.schema(*response);
|
||||
request->send(response);
|
||||
});
|
||||
|
||||
// / and /loginContext are already handled by serveStatic and startup()
|
||||
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/login",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleLogin(request, json); }));
|
||||
server.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleLogin(request, v); });
|
||||
|
||||
server.on("/shades.cfg", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
request->send(LittleFS, "/shades.cfg", _encoding_text);
|
||||
});
|
||||
server.on("/shades.tmp", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
request->send(LittleFS, "/shades.tmp", _encoding_text);
|
||||
});
|
||||
|
||||
server.on("/backup", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); });
|
||||
|
||||
server.on("/restore", HTTP_POST,
|
||||
[](AsyncWebServerRequest *request) {
|
||||
if(webServer.uploadSuccess) {
|
||||
restore_options_t opts;
|
||||
if(asyncHasParam(request, "data")) {
|
||||
String dataStr = asyncParam(request, "data");
|
||||
ESP_LOGD(TAG, "%s", dataStr.c_str());
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, dataStr);
|
||||
if(err) {
|
||||
request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}");
|
||||
return;
|
||||
}
|
||||
else {
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
opts.fromJSON(obj);
|
||||
}
|
||||
}
|
||||
else {
|
||||
ESP_LOGD(TAG, "No restore options sent. Using defaults...");
|
||||
opts.shades = true;
|
||||
}
|
||||
// Checked here so the client hears about a bad file. It only reads
|
||||
// the file; the shades are still changed on loop() alone.
|
||||
if(!ShadeConfigFile::validateFile("/shades.tmp")) {
|
||||
LittleFS.remove("/shades.tmp");
|
||||
request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"This isn't a valid backup file, so nothing was restored.\"}"));
|
||||
return;
|
||||
}
|
||||
// The main loop restores, since it owns the shades and their rolling
|
||||
// codes, and then reboots.
|
||||
loop_item_t item;
|
||||
item.type = loop_item_type_t::restore_shades;
|
||||
item.restoreParts = restoreParts(opts);
|
||||
if(!queueLoopItem(request, item)) return;
|
||||
request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}");
|
||||
}
|
||||
else request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"The upload didn't complete, so nothing was restored.\"}"));
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
resetTaskWdt();
|
||||
if(index == 0) {
|
||||
webServer.uploadSuccess = false;
|
||||
ESP_LOGD(TAG, "Restore: %s", filename.c_str());
|
||||
File fup = LittleFS.open("/shades.tmp", "w");
|
||||
fup.close();
|
||||
}
|
||||
if(len > 0) {
|
||||
File fup = LittleFS.open("/shades.tmp", "a");
|
||||
fup.write(data, len);
|
||||
fup.close();
|
||||
}
|
||||
if(final) {
|
||||
webServer.uploadSuccess = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Static file routes removed - handled by serveStatic in startup()
|
||||
|
||||
server.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleController(request); });
|
||||
server.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetRooms(request); });
|
||||
server.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetShades(request); });
|
||||
server.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetGroups(request); });
|
||||
server.on("/room", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleRoom(request); });
|
||||
server.on("/shade", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleShade(request); });
|
||||
server.on("/group", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGroup(request); });
|
||||
|
||||
// What a new room, shade or group would get, from a snapshot.
|
||||
server.on("/getNextRoom", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextRoom); });
|
||||
server.on("/getNextShade", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextShade); });
|
||||
server.on("/getNextGroup", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextGroup); });
|
||||
server.on("/groupOptions", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
if(!asyncHasParam(request, "groupId")) {
|
||||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}"));
|
||||
return;
|
||||
}
|
||||
const int groupId = atoi(asyncParam(request, "groupId").c_str());
|
||||
// As for /group, existence is checked on the live model.
|
||||
if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::groupOptions, groupId);
|
||||
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}"));
|
||||
});
|
||||
// Adding and saving rooms, shades and groups. Edits of the shade model:
|
||||
// loop() applies them and sends requestResult.
|
||||
server.addHandler(editHandler("/addRoom", model_edit_type_t::add_room));
|
||||
server.addHandler(editHandler("/addShade", model_edit_type_t::add_shade));
|
||||
server.addHandler(editHandler("/addGroup", model_edit_type_t::add_group));
|
||||
server.addHandler(editHandler("/saveRoom", model_edit_type_t::save_room));
|
||||
server.addHandler(editHandler("/saveShade", model_edit_type_t::save_shade));
|
||||
server.addHandler(editHandler("/saveGroup", model_edit_type_t::save_group));
|
||||
|
||||
|
||||
// setMyPosition - supports both GET with query params and POST/PUT with JSON body
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/setMyPosition",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
uint8_t shadeId = 255;
|
||||
int8_t pos = -1;
|
||||
int8_t tilt = -1;
|
||||
if(!json.isNull()) {
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"];
|
||||
else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; }
|
||||
if(!obj["pos"].isNull()) pos = obj["pos"].as<int8_t>();
|
||||
if(!obj["tilt"].isNull()) tilt = obj["tilt"].as<int8_t>();
|
||||
}
|
||||
SomfyShade* shade = somfy.getShadeById(shadeId);
|
||||
if(shade) {
|
||||
if(pos >= 0 && pos <= 100)
|
||||
if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return;
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeShadeRef(shade, resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
else {
|
||||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}"));
|
||||
}
|
||||
}));
|
||||
server.on("/setMyPosition", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
uint8_t shadeId = 255;
|
||||
int8_t pos = -1;
|
||||
int8_t tilt = -1;
|
||||
if(asyncHasParam(request, "shadeId")) {
|
||||
shadeId = atoi(asyncParam(request, "shadeId").c_str());
|
||||
if(asyncHasParam(request, "pos")) pos = atoi(asyncParam(request, "pos").c_str());
|
||||
if(asyncHasParam(request, "tilt")) tilt = atoi(asyncParam(request, "tilt").c_str());
|
||||
}
|
||||
else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; }
|
||||
SomfyShade* shade = somfy.getShadeById(shadeId);
|
||||
if(shade) {
|
||||
if(pos >= 0 && pos <= 100)
|
||||
if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return;
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeShadeRef(shade, resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
else {
|
||||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}"));
|
||||
}
|
||||
});
|
||||
|
||||
// setRollingCode - supports both query params and JSON body
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/setRollingCode",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
uint8_t shadeId = 255;
|
||||
uint16_t rollingCode = 0;
|
||||
if(!json.isNull()) {
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"];
|
||||
if(!obj["rollingCode"].isNull()) rollingCode = obj["rollingCode"];
|
||||
}
|
||||
SomfyShade* shade = nullptr;
|
||||
if(shadeId != 255) shade = somfy.getShadeById(shadeId);
|
||||
if(!shade) {
|
||||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}"));
|
||||
}
|
||||
else {
|
||||
if(!queueSetRollingCode(request, shade, rollingCode)) return;
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeShade(shade, resp, shade->paired, rollingCode);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
}));
|
||||
server.on("/setRollingCode", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
uint8_t shadeId = 255;
|
||||
uint16_t rollingCode = 0;
|
||||
if(asyncHasParam(request, "shadeId")) {
|
||||
shadeId = atoi(asyncParam(request, "shadeId").c_str());
|
||||
rollingCode = atoi(asyncParam(request, "rollingCode").c_str());
|
||||
}
|
||||
SomfyShade* shade = nullptr;
|
||||
if(shadeId != 255) shade = somfy.getShadeById(shadeId);
|
||||
if(!shade) {
|
||||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}"));
|
||||
}
|
||||
else {
|
||||
if(!queueSetRollingCode(request, shade, rollingCode)) return;
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeShade(shade, resp, shade->paired, rollingCode);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
});
|
||||
|
||||
server.addHandler(editHandler("/setPaired", model_edit_type_t::set_paired));
|
||||
server.on("/setPaired", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
queueModelEditFromParams(request, model_edit_type_t::set_paired, { "shadeId", "paired" });
|
||||
});
|
||||
|
||||
// unpairShade
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/unpairShade",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
uint8_t shadeId = 255;
|
||||
if(!json.isNull()) {
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"];
|
||||
}
|
||||
SomfyShade* shade = nullptr;
|
||||
if(shadeId != 255) shade = somfy.getShadeById(shadeId);
|
||||
if(!shade) {
|
||||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}"));
|
||||
}
|
||||
else {
|
||||
if(!queueUnpairShade(request, shade)) return;
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeShade(shade, resp, false, shade->lastRollingCode);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
}));
|
||||
server.on("/unpairShade", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
uint8_t shadeId = 255;
|
||||
if(asyncHasParam(request, "shadeId"))
|
||||
shadeId = atoi(asyncParam(request, "shadeId").c_str());
|
||||
SomfyShade* shade = nullptr;
|
||||
if(shadeId != 255) shade = somfy.getShadeById(shadeId);
|
||||
if(!shade) {
|
||||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}"));
|
||||
}
|
||||
else {
|
||||
if(!queueUnpairShade(request, shade)) return;
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeShade(shade, resp, false, shade->lastRollingCode);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}
|
||||
});
|
||||
|
||||
// Linking repeaters, remotes and group members, and deleting. Edits of the
|
||||
// shade model: loop() applies them and sends requestResult.
|
||||
server.addHandler(editHandler("/linkRepeater", model_edit_type_t::link_repeater));
|
||||
server.on("/linkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
queueModelEditFromParams(request, model_edit_type_t::link_repeater, { "address" });
|
||||
});
|
||||
server.addHandler(editHandler("/unlinkRepeater", model_edit_type_t::unlink_repeater));
|
||||
server.on("/unlinkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
queueModelEditFromParams(request, model_edit_type_t::unlink_repeater, { "address" });
|
||||
});
|
||||
server.addHandler(editHandler("/linkRemote", model_edit_type_t::link_remote));
|
||||
server.addHandler(editHandler("/unlinkRemote", model_edit_type_t::unlink_remote));
|
||||
server.addHandler(editHandler("/linkToGroup", model_edit_type_t::link_to_group));
|
||||
server.addHandler(editHandler("/unlinkFromGroup", model_edit_type_t::unlink_from_group));
|
||||
server.addHandler(editHandler("/deleteRoom", model_edit_type_t::delete_room));
|
||||
server.on("/deleteRoom", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
queueModelEditFromParams(request, model_edit_type_t::delete_room, { "roomId" });
|
||||
});
|
||||
server.addHandler(editHandler("/deleteShade", model_edit_type_t::delete_shade));
|
||||
server.on("/deleteShade", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
queueModelEditFromParams(request, model_edit_type_t::delete_shade, { "shadeId" });
|
||||
});
|
||||
server.addHandler(editHandler("/deleteGroup", model_edit_type_t::delete_group));
|
||||
server.on("/deleteGroup", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
queueModelEditFromParams(request, model_edit_type_t::delete_group, { "groupId" });
|
||||
});
|
||||
// The repeaters, from a snapshot like the other reads.
|
||||
server.on("/repeaters", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::repeaters); });
|
||||
|
||||
|
||||
// updateFirmware - file upload
|
||||
server.on("/updateFirmware", HTTP_POST,
|
||||
[](AsyncWebServerRequest *request) {
|
||||
if(Update.hasError())
|
||||
request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}");
|
||||
else
|
||||
request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated firmware\"}");
|
||||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 500;
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
if(index == 0) {
|
||||
webServer.uploadSuccess = false;
|
||||
ESP_LOGI(TAG, "Update: %s", filename.c_str());
|
||||
if(!Update.begin(UPDATE_SIZE_UNKNOWN)) {
|
||||
ESP_LOGE(TAG, "Update begin failed");
|
||||
}
|
||||
else {
|
||||
somfy.transceiver.end();
|
||||
mqtt.end();
|
||||
request->onDisconnect(onUploadDisconnect);
|
||||
}
|
||||
}
|
||||
if(len > 0) {
|
||||
if(Update.write(data, len) != len) {
|
||||
ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len);
|
||||
Update.abort();
|
||||
}
|
||||
}
|
||||
if(final) {
|
||||
if(Update.end(true)) {
|
||||
ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len);
|
||||
webServer.uploadSuccess = true;
|
||||
}
|
||||
else {
|
||||
ESP_LOGE(TAG, "Update end failed");
|
||||
}
|
||||
}
|
||||
resetTaskWdt();
|
||||
});
|
||||
|
||||
// updateShadeConfig - file upload
|
||||
server.on("/updateShadeConfig", HTTP_POST,
|
||||
[](AsyncWebServerRequest *request) {
|
||||
request->send(200, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Updating Shade Config: \"}");
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
if(index == 0) {
|
||||
ESP_LOGI(TAG, "Update: shades.cfg");
|
||||
File fup = LittleFS.open("/shades.tmp", "w");
|
||||
fup.close();
|
||||
}
|
||||
if(len > 0) {
|
||||
File fup = LittleFS.open("/shades.tmp", "a");
|
||||
fup.write(data, len);
|
||||
fup.close();
|
||||
}
|
||||
if(final) {
|
||||
// The main loop loads it, since it owns the shades.
|
||||
loop_item_t item;
|
||||
item.type = loop_item_type_t::load_shades;
|
||||
loopQueue.submit(item);
|
||||
}
|
||||
});
|
||||
|
||||
// updateApplication - file upload
|
||||
server.on("/updateApplication", HTTP_POST,
|
||||
[](AsyncWebServerRequest *request) {
|
||||
if(Update.hasError())
|
||||
request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}");
|
||||
else
|
||||
request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated application\"}");
|
||||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 500;
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
if(index == 0) {
|
||||
webServer.uploadSuccess = false;
|
||||
ESP_LOGI(TAG, "Update: %s", filename.c_str());
|
||||
if(!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) {
|
||||
ESP_LOGE(TAG, "Update begin failed");
|
||||
}
|
||||
else {
|
||||
somfy.transceiver.end();
|
||||
mqtt.end();
|
||||
request->onDisconnect(onUploadDisconnect);
|
||||
}
|
||||
}
|
||||
if(len > 0) {
|
||||
if(Update.write(data, len) != len) {
|
||||
ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len);
|
||||
Update.abort();
|
||||
}
|
||||
}
|
||||
if(final) {
|
||||
if(Update.end(true)) {
|
||||
webServer.uploadSuccess = true;
|
||||
ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len);
|
||||
somfy.commit();
|
||||
}
|
||||
else {
|
||||
somfy.commit();
|
||||
ESP_LOGE(TAG, "Update end failed");
|
||||
}
|
||||
}
|
||||
resetTaskWdt();
|
||||
});
|
||||
|
||||
server.on("/scanaps", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
const bool wdtPaused = pauseTaskWdt();
|
||||
if(net.softAPOpened) WiFi.disconnect(false);
|
||||
int n = WiFi.scanNetworks(false, true);
|
||||
resumeTaskWdt(wdtPaused);
|
||||
ESP_LOGI(TAG, "Scanned %d networks", n);
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
resp.beginObject("connected");
|
||||
resp.addElem("name", settings.WIFI.ssid);
|
||||
resp.addElem("strength", (int32_t)WiFi.RSSI());
|
||||
resp.addElem("channel", (int32_t)WiFi.channel());
|
||||
resp.endObject();
|
||||
resp.beginArray("accessPoints");
|
||||
for(int i = 0; i < n; ++i) {
|
||||
if(WiFi.SSID(i).length() == 0 || WiFi.RSSI(i) < -95) continue;
|
||||
resp.beginObject();
|
||||
resp.addElem("name", WiFi.SSID(i).c_str());
|
||||
resp.addElem("channel", (int32_t)WiFi.channel(i));
|
||||
resp.addElem("strength", (int32_t)WiFi.RSSI(i));
|
||||
resp.addElem("macAddress", WiFi.BSSIDstr(i).c_str());
|
||||
resp.endObject();
|
||||
}
|
||||
resp.endArray();
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
});
|
||||
|
||||
server.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *request) { webServer.handleReboot(request); });
|
||||
|
||||
// saveSecurity
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/saveSecurity",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(json.isNull()) {
|
||||
request->send(400, _encoding_html, "Error parsing JSON body");
|
||||
return;
|
||||
}
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
settings.Security.fromJSON(obj);
|
||||
settings.Security.save();
|
||||
char token[API_TOKEN_SIZE];
|
||||
webServer.createAPIToken(request->client()->remoteIP(), token);
|
||||
JsonDocument sdoc;
|
||||
JsonObject sobj = sdoc.to<JsonObject>();
|
||||
settings.Security.toJSON(sobj);
|
||||
sobj["apiKey"] = token;
|
||||
serializeJson(sdoc, g_async_content, sizeof(g_async_content));
|
||||
request->send(200, _encoding_json, g_async_content);
|
||||
}));
|
||||
|
||||
// getSecurity
|
||||
server.on("/getSecurity", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
JsonDocument doc;
|
||||
JsonObject obj = doc.to<JsonObject>();
|
||||
settings.Security.toJSON(obj);
|
||||
serializeJson(doc, g_async_content, sizeof(g_async_content));
|
||||
request->send(200, _encoding_json, g_async_content);
|
||||
});
|
||||
|
||||
// saveRadio
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/saveRadio",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(json.isNull()) {
|
||||
request->send(400, _encoding_html, "Error parsing JSON body");
|
||||
return;
|
||||
}
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
somfy.transceiver.fromJSON(obj);
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
resp.beginObject("config");
|
||||
serializeTransceiverConfig(resp);
|
||||
resp.endObject();
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
}));
|
||||
|
||||
// getRadio
|
||||
server.on("/getRadio", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeTransceiverConfig(resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
});
|
||||
|
||||
// sendRemoteCommand - supports GET with query params and POST/PUT with JSON body
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/sendRemoteCommand",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
somfy_frame_t frame;
|
||||
uint8_t repeats = 0;
|
||||
if(!json.isNull()) {
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
String scmd;
|
||||
if(!obj["address"].isNull()) frame.remoteAddress = obj["address"];
|
||||
if(!obj["command"].isNull()) scmd = obj["command"].as<String>();
|
||||
if(!obj["repeats"].isNull()) repeats = obj["repeats"];
|
||||
if(!obj["rcode"].isNull()) frame.rollingCode = obj["rcode"];
|
||||
if(!obj["encKey"].isNull()) frame.encKey = obj["encKey"];
|
||||
frame.cmd = translateSomfyCommand(scmd.c_str());
|
||||
}
|
||||
if(frame.remoteAddress > 0 && frame.rollingCode > 0) {
|
||||
if(somfy.sendFrame(frame, repeats))
|
||||
request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}"));
|
||||
else
|
||||
sendRadioUnavailable(request);
|
||||
}
|
||||
else
|
||||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}"));
|
||||
}));
|
||||
server.on("/sendRemoteCommand", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
somfy_frame_t frame;
|
||||
uint8_t repeats = 0;
|
||||
if(asyncHasParam(request, "address")) {
|
||||
frame.remoteAddress = atoi(asyncParam(request, "address").c_str());
|
||||
if(asyncHasParam(request, "encKey")) frame.encKey = atoi(asyncParam(request, "encKey").c_str());
|
||||
if(asyncHasParam(request, "command")) frame.cmd = translateSomfyCommand(asyncParam(request, "command"));
|
||||
if(asyncHasParam(request, "rcode")) frame.rollingCode = atoi(asyncParam(request, "rcode").c_str());
|
||||
if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str());
|
||||
}
|
||||
if(frame.remoteAddress > 0 && frame.rollingCode > 0) {
|
||||
if(somfy.sendFrame(frame, repeats))
|
||||
request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}"));
|
||||
else
|
||||
sendRadioUnavailable(request);
|
||||
}
|
||||
else
|
||||
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}"));
|
||||
});
|
||||
|
||||
// setgeneral
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/setgeneral",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(json.isNull()) {
|
||||
request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}");
|
||||
return;
|
||||
}
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
if(!obj["hostname"].isNull() || !obj["ssdpBroadcast"].isNull()) {
|
||||
settings.fromJSON(obj);
|
||||
settings.save();
|
||||
if(!obj["hostname"].isNull()) net.updateHostname();
|
||||
}
|
||||
if(!obj["ntpServer"].isNull() || !obj["posixZone"].isNull()) {
|
||||
settings.NTP.fromJSON(obj);
|
||||
settings.NTP.save();
|
||||
}
|
||||
request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set General Settings\"}");
|
||||
}));
|
||||
|
||||
// setNetwork
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/setNetwork",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(json.isNull()) {
|
||||
request->send(400, _encoding_html, "Error parsing JSON body");
|
||||
return;
|
||||
}
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
bool reboot = false;
|
||||
if(!obj["connType"].isNull() && obj["connType"].as<uint8_t>() != static_cast<uint8_t>(settings.connType)) {
|
||||
settings.connType = static_cast<conn_types_t>(obj["connType"].as<uint8_t>());
|
||||
settings.save();
|
||||
reboot = true;
|
||||
}
|
||||
if(!obj["wifi"].isNull()) {
|
||||
JsonObject objWifi = obj["wifi"];
|
||||
// Compared after fromJSON, which decides whether an empty passphrase keeps the stored one.
|
||||
WifiSettings before = settings.WIFI;
|
||||
settings.WIFI.fromJSON(objWifi);
|
||||
settings.WIFI.save();
|
||||
if(settings.connType == conn_types_t::wifi && WiFi.softAPgetStationNum() == 0 &&
|
||||
(strcmp(before.ssid, settings.WIFI.ssid) != 0 || strcmp(before.passphrase, settings.WIFI.passphrase) != 0))
|
||||
reboot = true;
|
||||
}
|
||||
if(!obj["ethernet"].isNull()) {
|
||||
JsonObject objEth = obj["ethernet"];
|
||||
if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref)
|
||||
reboot = true;
|
||||
#if HAS_ETHERNET
|
||||
settings.Ethernet.fromJSON(objEth);
|
||||
settings.Ethernet.save();
|
||||
#endif
|
||||
}
|
||||
if(reboot) {
|
||||
ESP_LOGI(TAG, "Rebooting ESP for new Network settings...");
|
||||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 1000;
|
||||
}
|
||||
request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}");
|
||||
}));
|
||||
|
||||
// setIP
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/setIP",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(json.isNull()) {
|
||||
request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}");
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting IP...");
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
settings.IP.fromJSON(obj);
|
||||
settings.IP.save();
|
||||
request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}");
|
||||
}));
|
||||
|
||||
// connectwifi
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/connectwifi",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(json.isNull()) {
|
||||
request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}");
|
||||
return;
|
||||
}
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
ESP_LOGD(TAG, "Settings WIFI connection...");
|
||||
String ssid = "";
|
||||
if(!obj["ssid"].isNull()) ssid = obj["ssid"].as<String>();
|
||||
bool newSsid = ssid.compareTo(settings.WIFI.ssid) != 0;
|
||||
// Clients never receive the stored passphrase: an omitted or empty one keeps it unless the SSID changes.
|
||||
char passphrase[sizeof(settings.WIFI.passphrase)];
|
||||
SETCHARPROP(passphrase, settings.WIFI.passphrase, sizeof(passphrase));
|
||||
bool newPassphrase = applySecret(obj["passphrase"].as<const char *>(), passphrase, sizeof(passphrase), newSsid);
|
||||
bool reboot = newSsid || newPassphrase;
|
||||
if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) {
|
||||
request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}");
|
||||
}
|
||||
else {
|
||||
SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid));
|
||||
SETCHARPROP(settings.WIFI.passphrase, passphrase, sizeof(settings.WIFI.passphrase));
|
||||
settings.WIFI.save();
|
||||
settings.WIFI.print();
|
||||
request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}");
|
||||
if(reboot) {
|
||||
ESP_LOGI(TAG, "Rebooting ESP for new WiFi settings...");
|
||||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 1000;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// modulesettings
|
||||
server.on("/modulesettings", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
resp.addElem("fwVersion", settings.fwVersion.name);
|
||||
resp.addElem("ssdpBroadcast", settings.ssdpBroadcast);
|
||||
resp.addElem("hostname", settings.hostname);
|
||||
resp.addElem("connType", static_cast<uint8_t>(settings.connType));
|
||||
resp.addElem("chipModel", settings.chipModel);
|
||||
resp.addElem("ntpServer", settings.NTP.ntpServer);
|
||||
resp.addElem("posixZone", settings.NTP.posixZone);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
});
|
||||
|
||||
// networksettings
|
||||
server.on("/networksettings", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
JsonDocument doc;
|
||||
JsonObject obj = doc.to<JsonObject>();
|
||||
settings.toJSON(obj);
|
||||
obj["fwVersion"] = settings.fwVersion.name;
|
||||
// The web UI hides the Ethernet settings when the chip has no MAC.
|
||||
obj["hasEthernet"] = HAS_ETHERNET == 1;
|
||||
JsonObject eth = obj.createNestedObject("ethernet");
|
||||
#if HAS_ETHERNET
|
||||
settings.Ethernet.toJSON(eth);
|
||||
#endif
|
||||
JsonObject wifi = obj.createNestedObject("wifi");
|
||||
settings.WIFI.toJSON(wifi);
|
||||
JsonObject ip = obj.createNestedObject("ip");
|
||||
settings.IP.toJSON(ip);
|
||||
serializeJson(doc, g_async_content, sizeof(g_async_content));
|
||||
request->send(200, _encoding_json, g_async_content);
|
||||
});
|
||||
|
||||
// connectmqtt
|
||||
server.addHandler(new AsyncCallbackJsonWebHandler("/connectmqtt",
|
||||
[](AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(json.isNull()) {
|
||||
request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}");
|
||||
return;
|
||||
}
|
||||
JsonObject obj = json.as<JsonObject>();
|
||||
ESP_LOGD(TAG, "Saving MQTT");
|
||||
mqtt.disconnect();
|
||||
settings.MQTT.fromJSON(obj);
|
||||
settings.MQTT.save();
|
||||
JsonDocument sdoc;
|
||||
JsonObject sobj = sdoc.to<JsonObject>();
|
||||
settings.MQTT.toJSON(sobj);
|
||||
serializeJson(sdoc, g_async_content, sizeof(g_async_content));
|
||||
request->send(200, _encoding_json, g_async_content);
|
||||
}));
|
||||
|
||||
// mqttsettings
|
||||
server.on("/mqttsettings", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
JsonDocument doc;
|
||||
JsonObject obj = doc.to<JsonObject>();
|
||||
settings.MQTT.toJSON(obj);
|
||||
serializeJson(doc, g_async_content, sizeof(g_async_content));
|
||||
request->send(200, _encoding_json, g_async_content);
|
||||
});
|
||||
|
||||
// Sort orders: the ids in the order the client lists them.
|
||||
server.addHandler(editHandler("/roomSortOrder", model_edit_type_t::room_order));
|
||||
server.addHandler(editHandler("/shadeSortOrder", model_edit_type_t::shade_order));
|
||||
server.addHandler(editHandler("/groupSortOrder", model_edit_type_t::group_order));
|
||||
|
||||
server.on("/beginFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
somfy.transceiver.beginFrequencyScan();
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeTransceiverConfig(resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
});
|
||||
|
||||
server.on("/endFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
somfy.transceiver.endFrequencyScan();
|
||||
AsyncJsonResp resp;
|
||||
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
|
||||
resp.beginObject();
|
||||
serializeTransceiverConfig(resp);
|
||||
resp.endObject();
|
||||
resp.endResponse();
|
||||
});
|
||||
|
||||
server.onNotFound([](AsyncWebServerRequest *r) {
|
||||
if(r->method() == HTTP_OPTIONS) { r->send(200); return; }
|
||||
webServer.handleNotFound(r);
|
||||
});
|
||||
|
||||
// serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them
|
||||
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
|
||||
|
||||
}
|
||||
|
|
@ -2,11 +2,9 @@
|
|||
#include "ConfigSettings.h"
|
||||
#include "Somfy.h"
|
||||
#include "SomfySnapshot.h"
|
||||
#include "SocketEmitter.h"
|
||||
#include "MqttConnection.h"
|
||||
|
||||
extern SomfyShadeController somfy;
|
||||
extern SocketEmitter sockEmit;
|
||||
extern ConfigSettings settings;
|
||||
extern MqttConnection mqtt;
|
||||
|
||||
|
|
@ -198,7 +196,6 @@ void SomfyShade::publish() {
|
|||
this->publish("flipPosition", this->flipPosition, true);
|
||||
this->publishState();
|
||||
this->publishDisco();
|
||||
sockEmit.loop(); // Keep our socket alive.
|
||||
}
|
||||
}
|
||||
void SomfyGroup::publishState() {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
#include "ConfigSettings.h"
|
||||
#include "ESPNetwork.h"
|
||||
#include "Utils.h"
|
||||
#include "SocketEmitter.h"
|
||||
#include "../entryPoints/Web.h"
|
||||
#if HAS_ETHERNET
|
||||
#include <ETH.h>
|
||||
|
|
@ -34,15 +33,18 @@ extern ConfigSettings settings;
|
|||
extern ESPNetwork net;
|
||||
extern Web webServer;
|
||||
extern LoopStats loopStats;
|
||||
extern SocketEmitter sockEmit;
|
||||
extern LoopQueue loopQueue;
|
||||
|
||||
ProtoSocket protoSocket;
|
||||
static AsyncWebSocket ws("/ws");
|
||||
static_assert(MAX_SOCK_RESPONSE >= PROTO_MAX_FRAME, "protocol 2 frames share the socket event buffer");
|
||||
// What loop() formats events, replies and sync frames into.
|
||||
static char frameBuffer[PROTO_MAX_FRAME];
|
||||
// loop()'s task, recorded by begin(). Only it may use frameBuffer.
|
||||
static TaskHandle_t loopTask = nullptr;
|
||||
static bool onLoopTask() { return emitAllowed(xTaskGetCurrentTaskHandle(), loopTask); }
|
||||
|
||||
static proto_slot_t slots[PROTO_MAX_CLIENTS];
|
||||
// What loop() formats events and syncEnd with, into the socket event buffer.
|
||||
// What loop() formats events and syncEnd with, into frameBuffer.
|
||||
static BufferedJsonFormatter eventJson;
|
||||
// Messages that arrive in pieces. async_tcp adds to it, and a disconnect can
|
||||
// drop from it on another task (a client closed for a full queue is closed by
|
||||
|
|
@ -410,6 +412,7 @@ void ProtoSocket::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
|
|||
}
|
||||
|
||||
void ProtoSocket::begin(AsyncWebServer &server) {
|
||||
loopTask = xTaskGetCurrentTaskHandle();
|
||||
assemblerMutex = xSemaphoreCreateMutexStatic(&assemblerMutexBuffer);
|
||||
ws.handleHandshake(authorized);
|
||||
ws.onEvent(ProtoSocket::wsEvent);
|
||||
|
|
@ -421,7 +424,7 @@ bool ProtoSocket::hasClients() const {
|
|||
if(s.clientId.load()) return true;
|
||||
return false;
|
||||
}
|
||||
bool ProtoSocket::sending() const { return sockEmit.onLoopTask() && this->hasClients(); }
|
||||
bool ProtoSocket::sending() const { return onLoopTask() && this->hasClients(); }
|
||||
|
||||
/*********************************************************************
|
||||
* loop(): syncs
|
||||
|
|
@ -429,7 +432,7 @@ bool ProtoSocket::sending() const { return sockEmit.onLoopTask() && this->hasCli
|
|||
static void closeOnceSent();
|
||||
static void sendSyncFrame(proto_slot_t &s) {
|
||||
LiveSource src;
|
||||
char *buf = socketFrameBuffer();
|
||||
char *buf = frameBuffer;
|
||||
const size_t len = writeSyncFrame(src, s.cur, buf, PROTO_MAX_FRAME);
|
||||
if(len) ws.text(s.loopId, buf, len);
|
||||
syncFrameSent(s, millis());
|
||||
|
|
@ -440,7 +443,7 @@ static void sendSyncFrame(proto_slot_t &s) {
|
|||
ws.text(s.loopId, buf, eventJson.length());
|
||||
}
|
||||
void ProtoSocket::loop() {
|
||||
if(!sockEmit.onLoopTask()) return;
|
||||
if(!onLoopTask()) return;
|
||||
for(proto_slot_t &s : slots) {
|
||||
switch(nextSyncStep(s, millis())) {
|
||||
case sync_step_t::send:
|
||||
|
|
@ -482,7 +485,7 @@ static void sendReply(const proto_reply_to_t &to, BufferedJsonFormatter &json, c
|
|||
ws.text(to.client, buf, json.length());
|
||||
}
|
||||
void ProtoSocket::replyRequest(const proto_reply_to_t &to, const shade_request_t &req, shade_request_result_t result) {
|
||||
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
||||
if(!onLoopTask() || to.id == 0) return;
|
||||
char buf[192];
|
||||
BufferedJsonFormatter json;
|
||||
json.setBuffer(buf, sizeof(buf));
|
||||
|
|
@ -495,7 +498,7 @@ void ProtoSocket::replyRequest(const proto_reply_to_t &to, const shade_request_t
|
|||
// What an edit op's reply says: for an add, the new item's id; for a refused
|
||||
// edit, the route's reason and the shade's validation code when it has one.
|
||||
void ProtoSocket::replyEdit(const proto_reply_to_t &to, model_edit_type_t type, const edit_result_t &result) {
|
||||
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
||||
if(!onLoopTask() || to.id == 0) return;
|
||||
const uint32_t id = to.id;
|
||||
char buf[256];
|
||||
BufferedJsonFormatter json;
|
||||
|
|
@ -517,7 +520,7 @@ void ProtoSocket::replyEdit(const proto_reply_to_t &to, model_edit_type_t type,
|
|||
// The reads an edit form needs: what a new room, shade or group would get,
|
||||
// and the shades a group could take.
|
||||
void ProtoSocket::replyRead(const proto_reply_to_t &to, uint8_t op, uint8_t groupId) {
|
||||
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
||||
if(!onLoopTask() || to.id == 0) return;
|
||||
const uint32_t id = to.id;
|
||||
char buf[256];
|
||||
BufferedJsonFormatter json;
|
||||
|
|
@ -571,19 +574,19 @@ void ProtoSocket::replyRead(const proto_reply_to_t &to, uint8_t op, uint8_t grou
|
|||
}
|
||||
|
||||
ProtoWriter ProtoSocket::beginReply(const proto_reply_to_t &to) {
|
||||
eventJson.setBuffer(socketFrameBuffer(), PROTO_MAX_FRAME);
|
||||
eventJson.setBuffer(frameBuffer, PROTO_MAX_FRAME);
|
||||
beginFrame(eventJson, proto_type_t::reply, to.id);
|
||||
ProtoWriter w(eventJson);
|
||||
w.add(proto_field_t::ok, true);
|
||||
return w;
|
||||
}
|
||||
void ProtoSocket::endReply(const proto_reply_to_t &to) {
|
||||
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
||||
if(!onLoopTask() || to.id == 0) return;
|
||||
endFrame(eventJson);
|
||||
sendReply(to, eventJson, socketFrameBuffer(), PROTO_MAX_FRAME);
|
||||
sendReply(to, eventJson, frameBuffer, PROTO_MAX_FRAME);
|
||||
}
|
||||
void ProtoSocket::replyRefused(const proto_reply_to_t &to, proto_error_t code, const char *desc) {
|
||||
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
||||
if(!onLoopTask() || to.id == 0) return;
|
||||
char buf[192];
|
||||
BufferedJsonFormatter json;
|
||||
json.setBuffer(buf, sizeof(buf));
|
||||
|
|
@ -595,7 +598,7 @@ void ProtoSocket::replyRefused(const proto_reply_to_t &to, proto_error_t code, c
|
|||
* loop(): events
|
||||
********************************************************************/
|
||||
static JsonFormatter &beginEvent(proto_type_t type) {
|
||||
eventJson.setBuffer(socketFrameBuffer(), PROTO_MAX_FRAME);
|
||||
eventJson.setBuffer(frameBuffer, PROTO_MAX_FRAME);
|
||||
beginFrame(eventJson, type);
|
||||
return eventJson;
|
||||
}
|
||||
|
|
@ -607,7 +610,7 @@ static bool finishEvent() {
|
|||
return false;
|
||||
}
|
||||
static void sendEvent() {
|
||||
if(finishEvent()) ws.textAll(socketFrameBuffer(), eventJson.length());
|
||||
if(finishEvent()) ws.textAll(frameBuffer, eventJson.length());
|
||||
}
|
||||
// An item whole, or only its state: a snapshot of it, written by write.
|
||||
template<typename Model, typename Snapshot>
|
||||
|
|
@ -728,7 +731,7 @@ void ProtoSocket::settingsChanged(uint32_t savedBy, const char *section) {
|
|||
if(!finishEvent()) return;
|
||||
for(proto_slot_t &s : slots) {
|
||||
const uint32_t id = s.clientId.load();
|
||||
if(id && id != savedBy) ws.text(id, socketFrameBuffer(), eventJson.length());
|
||||
if(id && id != savedBy) ws.text(id, frameBuffer, eventJson.length());
|
||||
}
|
||||
}
|
||||
// The client that saved the security settings, closed once its reply has
|
||||
|
|
@ -738,7 +741,7 @@ static uint32_t closeWhenSent = 0;
|
|||
static uint32_t closeWhenSentSince = 0;
|
||||
#define CLOSE_WHEN_SENT_MAX_MS 2000
|
||||
void ProtoSocket::closeAll(uint32_t savedBy) {
|
||||
if(!sockEmit.onLoopTask()) return;
|
||||
if(!onLoopTask()) return;
|
||||
for(proto_slot_t &s : slots) {
|
||||
const uint32_t id = s.clientId.load();
|
||||
if(id && id != savedBy) ws.close(id);
|
||||
|
|
@ -758,7 +761,7 @@ static uint32_t scanAskers[PROTO_MAX_CLIENTS];
|
|||
// The most networks wifiNetworks lists, so the event fits a frame.
|
||||
#define PROTO_MAX_NETWORKS 30
|
||||
void ProtoSocket::wantsWifiNetworks(uint32_t clientId) {
|
||||
if(!sockEmit.onLoopTask()) return;
|
||||
if(!onLoopTask()) return;
|
||||
uint32_t *spare = nullptr;
|
||||
for(uint32_t &id : scanAskers) {
|
||||
if(id == clientId) return;
|
||||
|
|
@ -769,7 +772,7 @@ void ProtoSocket::wantsWifiNetworks(uint32_t clientId) {
|
|||
if(spare) *spare = clientId;
|
||||
}
|
||||
void ProtoSocket::wifiNetworks(int16_t count) {
|
||||
if(!sockEmit.onLoopTask()) return;
|
||||
if(!onLoopTask()) return;
|
||||
bool asked = false;
|
||||
for(uint32_t id : scanAskers) asked |= id != 0;
|
||||
if(!asked) return;
|
||||
|
|
@ -796,13 +799,13 @@ void ProtoSocket::wifiNetworks(int16_t count) {
|
|||
fits = !eventJson.overflowed();
|
||||
}
|
||||
for(uint32_t &id : scanAskers) {
|
||||
if(fits && id) ws.text(id, socketFrameBuffer(), eventJson.length());
|
||||
if(fits && id) ws.text(id, frameBuffer, eventJson.length());
|
||||
id = 0;
|
||||
}
|
||||
}
|
||||
bool ProtoSocket::subscribed(proto_feed_t feed) const {
|
||||
// The feed is formatted into the event buffer, which is loop()'s.
|
||||
if(!sockEmit.onLoopTask()) return false;
|
||||
if(!onLoopTask()) return false;
|
||||
const uint8_t bit = 1 << static_cast<uint8_t>(feed);
|
||||
for(const proto_slot_t &s : slots)
|
||||
if(s.clientId.load() && (s.feeds.load() & bit)) return true;
|
||||
|
|
@ -814,6 +817,6 @@ void ProtoSocket::endFeed(proto_feed_t feed) {
|
|||
const uint8_t bit = 1 << static_cast<uint8_t>(feed);
|
||||
for(proto_slot_t &s : slots) {
|
||||
const uint32_t id = s.clientId.load();
|
||||
if(id && (s.feeds.load() & bit)) ws.text(id, socketFrameBuffer(), eventJson.length());
|
||||
if(id && (s.feeds.load() & bit)) ws.text(id, frameBuffer, eventJson.length());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,11 @@ class SomfyGroup;
|
|||
class SomfyRoom;
|
||||
|
||||
// The socket protocol 2 endpoint, ws://host/ws on port 80 (docs/PROTOCOL.md,
|
||||
// docs/adr/0005-one-websocket-protocol.md). It runs next to the old socket on
|
||||
// port 8080 until the web UI and Home Assistant have moved over.
|
||||
// docs/adr/0005-one-websocket-protocol.md).
|
||||
//
|
||||
// async_tcp only reads what clients send and answers with fixed frames that
|
||||
// hold nothing of the shade model (hello, errors, ok). Everything formatted
|
||||
// from the model goes out from loop(), in the socket event buffer. The
|
||||
// from the model goes out from loop(), in one frame buffer. The
|
||||
// clients' slots and syncs are in ProtoSlots.
|
||||
class ProtoSocket {
|
||||
public:
|
||||
|
|
@ -74,7 +73,7 @@ class ProtoSocket {
|
|||
static void wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len);
|
||||
private:
|
||||
// On loop()'s task, with a client to send to: nothing is formatted for
|
||||
// nobody, or off the task that owns the event buffer.
|
||||
// nobody, or off the task that owns the frame buffer.
|
||||
bool sending() const;
|
||||
};
|
||||
extern ProtoSocket protoSocket;
|
||||
|
|
|
|||
|
|
@ -1,293 +0,0 @@
|
|||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include "TaskWdt.h"
|
||||
#include "esp_log.h"
|
||||
#include "SocketEmitter.h"
|
||||
#include "ConfigSettings.h"
|
||||
#include "Somfy.h"
|
||||
#include "ESPNetwork.h"
|
||||
#include "Timing.h"
|
||||
#include "Utils.h"
|
||||
|
||||
static const char *TAG = "Sockets";
|
||||
|
||||
extern ConfigSettings settings;
|
||||
extern ESPNetwork net;
|
||||
extern SomfyShadeController somfy;
|
||||
extern SocketEmitter sockEmit;
|
||||
|
||||
AsyncWebServer wsServer(8080);
|
||||
AsyncWebSocket ws("/");
|
||||
|
||||
#define MAX_WS_CLIENTS 5
|
||||
struct ws_slot_t {
|
||||
uint32_t asyncId; // 0 when the slot is free.
|
||||
// When the client connected and last pinged, so the logs show how long it
|
||||
// stayed and how far apart its pings arrived.
|
||||
uint32_t connectedAt;
|
||||
uint32_t lastPing;
|
||||
};
|
||||
static ws_slot_t slots[MAX_WS_CLIENTS] = {};
|
||||
static uint8_t mapClientId(uint32_t asyncId) {
|
||||
for(uint8_t i = 0; i < MAX_WS_CLIENTS; i++)
|
||||
if(slots[i].asyncId == asyncId) return i;
|
||||
return 255;
|
||||
}
|
||||
static uint32_t getAsyncId(uint8_t slot) {
|
||||
if(slot < MAX_WS_CLIENTS) return slots[slot].asyncId;
|
||||
return 0;
|
||||
}
|
||||
static uint8_t addClient(uint32_t asyncId) {
|
||||
for(uint8_t i = 0; i < MAX_WS_CLIENTS; i++) {
|
||||
if(slots[i].asyncId == 0) { slots[i].asyncId = asyncId; return i; }
|
||||
}
|
||||
return 255;
|
||||
}
|
||||
static void removeClient(uint32_t asyncId) {
|
||||
for(uint8_t i = 0; i < MAX_WS_CLIENTS; i++)
|
||||
if(slots[i].asyncId == asyncId) slots[i].asyncId = 0;
|
||||
}
|
||||
|
||||
static char g_response[MAX_SOCK_RESPONSE];
|
||||
char *socketFrameBuffer() { return g_response; }
|
||||
|
||||
bool room_t::isJoined(uint8_t num) {
|
||||
for(uint8_t i = 0; i < sizeof(this->clients); i++) {
|
||||
if(this->clients[i] == num) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool room_t::join(uint8_t num) {
|
||||
if(this->isJoined(num)) return true;
|
||||
for(uint8_t i = 0; i < sizeof(this->clients); i++) {
|
||||
if(this->clients[i] == 255) {
|
||||
this->clients[i] = num;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool room_t::leave(uint8_t num) {
|
||||
if(!this->isJoined(num)) return false;
|
||||
for(uint8_t i = 0; i < sizeof(this->clients); i++) {
|
||||
if(this->clients[i] == num) this->clients[i] = 255;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void room_t::clear() {
|
||||
memset(this->clients, 255, sizeof(this->clients));
|
||||
}
|
||||
uint8_t room_t::activeClients() {
|
||||
uint8_t n = 0;
|
||||
for(uint8_t i = 0; i < sizeof(this->clients); i++) {
|
||||
if(this->clients[i] != 255) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* SocketEmitter class members
|
||||
********************************************************************/
|
||||
void SocketEmitter::startup() {
|
||||
|
||||
}
|
||||
void SocketEmitter::begin() {
|
||||
// Called from setup(), which runs on loop()'s task.
|
||||
this->_owner = xTaskGetCurrentTaskHandle();
|
||||
ws.onEvent(SocketEmitter::wsEvent);
|
||||
wsServer.addHandler(&ws);
|
||||
wsServer.begin();
|
||||
ESP_LOGI(TAG, "Socket Server Started...");
|
||||
}
|
||||
bool SocketEmitter::onLoopTask() const { return emitAllowed(xTaskGetCurrentTaskHandle(), this->_owner); }
|
||||
void SocketEmitter::loop() {
|
||||
if(!this->onLoopTask()) {
|
||||
ESP_LOGE(TAG, "Socket loop off loop() from %s, skipped", pcTaskGetName(nullptr));
|
||||
return;
|
||||
}
|
||||
ws.cleanupClients();
|
||||
this->initClients();
|
||||
}
|
||||
JsonSockEvent *SocketEmitter::beginEmit(const char *evt) {
|
||||
// Another task would overwrite the event loop() is building in g_response,
|
||||
// and could write past its end.
|
||||
if(!this->onLoopTask()) {
|
||||
ESP_LOGE(TAG, "Socket emit %s off loop() from %s, dropped", evt, pcTaskGetName(nullptr));
|
||||
this->_discard.discard();
|
||||
return &this->_discard;
|
||||
}
|
||||
this->json.beginEvent(&ws, evt, g_response, sizeof(g_response));
|
||||
return &this->json;
|
||||
}
|
||||
void SocketEmitter::endEmit(uint8_t num) {
|
||||
// beginEmit() has logged it.
|
||||
if(!this->onLoopTask()) return;
|
||||
if(num == 255) {
|
||||
this->json.endEvent(0);
|
||||
} else {
|
||||
uint32_t asyncId = getAsyncId(num);
|
||||
this->json.endEvent(asyncId);
|
||||
}
|
||||
resetTaskWdt();
|
||||
}
|
||||
void SocketEmitter::endEmitRoom(uint8_t room) {
|
||||
if(!this->onLoopTask()) return;
|
||||
if(room < SOCK_MAX_ROOMS) {
|
||||
room_t *r = &this->rooms[room];
|
||||
for(uint8_t i = 0; i < sizeof(r->clients); i++) {
|
||||
if(r->clients[i] != 255) {
|
||||
uint32_t asyncId = getAsyncId(r->clients[i]);
|
||||
if(asyncId != 0) this->json.endEvent(asyncId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
uint8_t SocketEmitter::activeClients(uint8_t room) {
|
||||
if(room < SOCK_MAX_ROOMS) return this->rooms[room].activeClients();
|
||||
return 0;
|
||||
}
|
||||
// The installed versions, in the fwStatus event the web UI and Home Assistant
|
||||
// read them from.
|
||||
static void emitFirmwareVersion(uint8_t num) {
|
||||
JsonSockEvent *json = sockEmit.beginEmit("fwStatus");
|
||||
json->beginObject();
|
||||
json->beginObject("fwVersion");
|
||||
settings.fwVersion.toJSON(json);
|
||||
json->endObject();
|
||||
json->beginObject("appVersion");
|
||||
settings.appVersion.toJSON(json);
|
||||
json->endObject();
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
}
|
||||
void SocketEmitter::initClients() {
|
||||
for(uint8_t i = 0; i < sizeof(this->newClients); i++) {
|
||||
uint8_t slot = this->newClients[i];
|
||||
if(slot != 255) {
|
||||
uint32_t asyncId = getAsyncId(slot);
|
||||
if(asyncId != 0 && ws.hasClient(asyncId)) {
|
||||
ESP_LOGD(TAG, "Initializing Socket Client %u (asyncId=%lu)", slot, asyncId);
|
||||
resetTaskWdt();
|
||||
ElapsedTimer total, step;
|
||||
settings.emitSockets(slot);
|
||||
logElapsed(TAG, "Socket init settings", step.lap());
|
||||
if(!ws.hasClient(asyncId)) { this->newClients[i] = 255; continue; }
|
||||
somfy.emitState(slot);
|
||||
logElapsed(TAG, "Socket init somfy state", step.lap());
|
||||
if(!ws.hasClient(asyncId)) { this->newClients[i] = 255; continue; }
|
||||
emitFirmwareVersion(slot);
|
||||
logElapsed(TAG, "Socket init firmware version", step.lap());
|
||||
if(!ws.hasClient(asyncId)) { this->newClients[i] = 255; continue; }
|
||||
net.emitSockets(slot);
|
||||
logElapsed(TAG, "Socket init network", step.lap());
|
||||
logElapsed(TAG, "Socket init total", total.us());
|
||||
resetTaskWdt();
|
||||
}
|
||||
this->newClients[i] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
void SocketEmitter::delayInit(uint8_t num) {
|
||||
for(uint8_t i=0; i < sizeof(this->newClients); i++) {
|
||||
if(this->newClients[i] == num) break;
|
||||
else if(this->newClients[i] == 255) {
|
||||
this->newClients[i] = num;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
void SocketEmitter::end() {
|
||||
ws.closeAll();
|
||||
wsServer.end();
|
||||
for(uint8_t i = 0; i < SOCK_MAX_ROOMS; i++)
|
||||
this->rooms[i].clear();
|
||||
memset(slots, 0, sizeof(slots));
|
||||
}
|
||||
void SocketEmitter::disconnect() {
|
||||
ws.closeAll();
|
||||
memset(slots, 0, sizeof(slots));
|
||||
}
|
||||
void SocketEmitter::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) {
|
||||
uint32_t asyncId = client->id();
|
||||
switch(type) {
|
||||
case WS_EVT_CONNECT:
|
||||
{
|
||||
uint8_t slot = addClient(asyncId);
|
||||
if(slot == 255) {
|
||||
ESP_LOGE(TAG, "Socket: No free client slots, closing %lu", asyncId);
|
||||
client->close();
|
||||
return;
|
||||
}
|
||||
IPAddress ip = client->remoteIP();
|
||||
ESP_LOGI(TAG, "Socket [%lu] Connected from %d.%d.%d.%d (slot %u)", asyncId, ip[0], ip[1], ip[2], ip[3], slot);
|
||||
slots[slot].connectedAt = millis();
|
||||
slots[slot].lastPing = 0;
|
||||
client->text("Connected");
|
||||
// A client that stops reading (a frozen browser tab) keeps its TCP link
|
||||
// up, so nothing else ever closes it, and every message dropped for it
|
||||
// logged a warning that held up loop() for ~10ms. Closing it frees the
|
||||
// queue; the page and Home Assistant reconnect and get the full state
|
||||
// again from delayInit().
|
||||
client->setCloseClientOnQueueFull(true);
|
||||
sockEmit.delayInit(slot);
|
||||
}
|
||||
break;
|
||||
case WS_EVT_DISCONNECT:
|
||||
{
|
||||
uint8_t slot = mapClientId(asyncId);
|
||||
if(slot != 255) {
|
||||
// Browsers don't ping, so "never" is normal for them.
|
||||
if(slots[slot].lastPing)
|
||||
ESP_LOGI(TAG, "Socket [%lu] Disconnected (slot %u) after %lums, last ping %lums ago", asyncId, slot,
|
||||
(unsigned long)(millis() - slots[slot].connectedAt), (unsigned long)(millis() - slots[slot].lastPing));
|
||||
else
|
||||
ESP_LOGI(TAG, "Socket [%lu] Disconnected (slot %u) after %lums, never pinged", asyncId, slot,
|
||||
(unsigned long)(millis() - slots[slot].connectedAt));
|
||||
for(uint8_t i = 0; i < SOCK_MAX_ROOMS; i++)
|
||||
sockEmit.rooms[i].leave(slot);
|
||||
}
|
||||
removeClient(asyncId);
|
||||
}
|
||||
break;
|
||||
case WS_EVT_DATA:
|
||||
{
|
||||
AwsFrameInfo *info = (AwsFrameInfo*)arg;
|
||||
if(info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
|
||||
uint8_t slot = mapClientId(asyncId);
|
||||
data[len] = 0;
|
||||
if(strncmp((char*)data, "join:", 5) == 0) {
|
||||
uint8_t roomNum = atoi((char*)&data[5]);
|
||||
ESP_LOGD(TAG, "Client %u joining room %u", slot, roomNum);
|
||||
if(roomNum < SOCK_MAX_ROOMS && slot != 255) sockEmit.rooms[roomNum].join(slot);
|
||||
}
|
||||
else if(strncmp((char*)data, "leave:", 6) == 0) {
|
||||
uint8_t roomNum = atoi((char*)&data[6]);
|
||||
ESP_LOGD(TAG, "Client %u leaving room %u", slot, roomNum);
|
||||
if(roomNum < SOCK_MAX_ROOMS && slot != 255) sockEmit.rooms[roomNum].leave(slot);
|
||||
}
|
||||
else {
|
||||
ESP_LOGD(TAG, "Socket [%lu] text: %s", asyncId, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case WS_EVT_ERROR:
|
||||
ESP_LOGE(TAG, "Socket [%lu] Error", asyncId);
|
||||
break;
|
||||
case WS_EVT_PING:
|
||||
{
|
||||
// Home Assistant only pings after 20s without a message from us, so
|
||||
// the gap depends on our own traffic and says nothing about the link.
|
||||
// It is kept for the disconnect line.
|
||||
uint8_t slot = mapClientId(asyncId);
|
||||
if(slot != 255) {
|
||||
uint32_t now = millis();
|
||||
if(slots[slot].lastPing) ESP_LOGD(TAG, "Socket [%lu] ping, %lums since the last", asyncId, (unsigned long)(now - slots[slot].lastPing));
|
||||
slots[slot].lastPing = now;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case WS_EVT_PONG:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
#include <ESPAsyncWebServer.h>
|
||||
#include "WResp.h"
|
||||
#ifndef sockets_h
|
||||
#define sockets_h
|
||||
|
||||
#define SOCK_MAX_ROOMS 1
|
||||
#define ROOM_EMIT_FRAME 0
|
||||
// The buffer every socket event is formatted into. An event that doesn't
|
||||
// fit is logged and not sent, so the largest one has to fit: see remoteFrame
|
||||
// in Transceiver.cpp.
|
||||
#define MAX_SOCK_RESPONSE 4096
|
||||
// That buffer, which protocol 2 events and sync frames are formatted into too.
|
||||
// loop() only, and only between an event's start and its send.
|
||||
char *socketFrameBuffer();
|
||||
|
||||
struct room_t {
|
||||
uint8_t clients[5] = {255, 255, 255, 255, 255};
|
||||
uint8_t activeClients();
|
||||
bool isJoined(uint8_t num);
|
||||
bool join(uint8_t num);
|
||||
bool leave(uint8_t num);
|
||||
void clear();
|
||||
};
|
||||
// The old socket on port 8080. It goes with step 4 of docs/SECURITY_PLAN.md,
|
||||
// and with it every sockEmit call that sits next to a protoSocket one.
|
||||
class SocketEmitter {
|
||||
protected:
|
||||
uint8_t newclients = 0;
|
||||
uint8_t newClients[5] = {255,255,255,255,255};
|
||||
// loop()'s task, recorded by begin(). Only it may send; see emitAllowed().
|
||||
TaskHandle_t _owner = nullptr;
|
||||
// What beginEmit() hands any other task, so the event goes nowhere.
|
||||
JsonSockEvent _discard;
|
||||
void delayInit(uint8_t num);
|
||||
public:
|
||||
// Whether this runs on loop()'s task, the only one that may send socket
|
||||
// events: this socket's and protocol 2's.
|
||||
bool onLoopTask() const;
|
||||
JsonSockEvent json;
|
||||
room_t rooms[SOCK_MAX_ROOMS];
|
||||
uint8_t activeClients(uint8_t room);
|
||||
void initClients();
|
||||
void startup();
|
||||
void begin();
|
||||
void loop();
|
||||
void end();
|
||||
void disconnect();
|
||||
JsonSockEvent * beginEmit(const char *evt);
|
||||
void endEmit(uint8_t num = 255);
|
||||
void endEmitRoom(uint8_t num);
|
||||
static void wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len);
|
||||
};
|
||||
#endif
|
||||
|
|
@ -1,111 +1,19 @@
|
|||
#include "TaskWdt.h"
|
||||
#include "Somfy.h"
|
||||
#include "SomfyEditor.h"
|
||||
#include "SocketEmitter.h"
|
||||
#include "ProtoSocket.h"
|
||||
#include "MqttConnection.h"
|
||||
|
||||
extern SomfyShadeController somfy;
|
||||
extern SocketEmitter sockEmit;
|
||||
extern MqttConnection mqtt;
|
||||
|
||||
void SomfyShade::emitState() { this->changed |= SHADE_CHANGED_STATE; }
|
||||
void SomfyShade::emitState(const char *evt) { this->emitState(255, evt); }
|
||||
void SomfyShade::emitState(uint8_t num, const char *evt) {
|
||||
JsonSockEvent *json = sockEmit.beginEmit(evt);
|
||||
json->beginObject();
|
||||
json->addElem("shadeId", this->shadeId);
|
||||
json->addElem("type", static_cast<uint8_t>(this->shadeType));
|
||||
json->addElem("remoteAddress", (uint32_t)this->getRemoteAddress());
|
||||
json->addElem("name", this->name);
|
||||
json->addElem("direction", this->state().direction);
|
||||
json->addElem("position", this->transformPosition(this->state().currentPos));
|
||||
json->addElem("target", this->transformPosition(this->state().target));
|
||||
json->addElem("myPos", this->transformPosition(this->state().myPos));
|
||||
json->addElem("tiltType", static_cast<uint8_t>(this->tiltType));
|
||||
json->addElem("flipCommands", this->flipCommands);
|
||||
json->addElem("flipPosition", this->flipPosition);
|
||||
json->addElem("flags", this->getFlags());
|
||||
json->addElem("sunSensor", this->sunSensor);
|
||||
json->addElem("light", this->light);
|
||||
json->addElem("sortOrder", this->sortOrder);
|
||||
if(this->tiltType != tilt_types::none) {
|
||||
json->addElem("tiltDirection", this->state().tiltDirection);
|
||||
json->addElem("tiltTarget", this->transformPosition(this->state().tiltTarget));
|
||||
json->addElem("tiltPosition", this->transformPosition(this->state().currentTiltPos));
|
||||
json->addElem("myTiltPos", this->transformPosition(this->state().myTiltPos));
|
||||
}
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
}
|
||||
void SomfyShade::emitCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt) { this->emitCommand(255, cmd, source, sourceAddress, evt); }
|
||||
void SomfyShade::emitCommand(uint8_t num, somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt) {
|
||||
JsonSockEvent *json = sockEmit.beginEmit(evt);
|
||||
json->beginObject();
|
||||
json->addElem("shadeId", this->shadeId);
|
||||
json->addElem("remoteAddress", (uint32_t)this->getRemoteAddress());
|
||||
json->addElem("cmd", translateSomfyCommand(cmd).c_str());
|
||||
json->addElem("source", source);
|
||||
json->addElem("rcode", (uint32_t)this->lastRollingCode);
|
||||
json->addElem("sourceAddress", (uint32_t)sourceAddress);
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
if(num == 255) protoSocket.shadeCommanded(*this, translateSomfyCommand(cmd).c_str(), source, sourceAddress);
|
||||
void SomfyShade::emitCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress) {
|
||||
protoSocket.shadeCommanded(*this, translateSomfyCommand(cmd).c_str(), source, sourceAddress);
|
||||
if(mqtt.connected()) {
|
||||
this->publish("cmdSource", source);
|
||||
this->publish("cmdAddress", sourceAddress);
|
||||
this->publish("cmd", translateSomfyCommand(cmd).c_str());
|
||||
}
|
||||
}
|
||||
void SomfyRoom::emitState(const char *evt) { this->emitState(255, evt); }
|
||||
void SomfyRoom::emitState(uint8_t num, const char *evt) {
|
||||
JsonSockEvent *json = sockEmit.beginEmit(evt);
|
||||
json->beginObject();
|
||||
json->addElem("roomId", this->roomId);
|
||||
json->addElem("name", this->name);
|
||||
json->addElem("sortOrder", this->sortOrder);
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
this->publish();
|
||||
}
|
||||
void SomfyGroup::emitState() { this->changed |= GROUP_CHANGED_STATE; }
|
||||
void SomfyGroup::emitState(const char *evt) { this->emitState(255, evt); }
|
||||
void SomfyGroup::emitState(uint8_t num, const char *evt) {
|
||||
uint8_t flags = 0;
|
||||
JsonSockEvent *json = sockEmit.beginEmit(evt);
|
||||
json->beginObject();
|
||||
json->addElem("groupId", this->groupId);
|
||||
json->addElem("remoteAddress", (uint32_t)this->getRemoteAddress());
|
||||
json->addElem("name", this->name);
|
||||
json->addElem("sunSensor", this->hasSunSensor());
|
||||
json->beginArray("shades");
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) {
|
||||
if(this->linkedShades[i] != 255 && this->linkedShades[i] != 0) {
|
||||
SomfyShade *shade = somfy.getShadeById(this->linkedShades[i]);
|
||||
if(shade) {
|
||||
json->addElem(this->linkedShades[i]);
|
||||
flags |= shade->getFlags();
|
||||
}
|
||||
}
|
||||
}
|
||||
json->endArray();
|
||||
json->addElem("flags", flags);
|
||||
json->endObject();
|
||||
sockEmit.endEmit(num);
|
||||
this->publish();
|
||||
}
|
||||
// To every client: the sender picks out its own by requestId.
|
||||
void emitRequestResult(uint32_t requestId, const edit_result_t &result) {
|
||||
JsonSockEvent *json = sockEmit.beginEmit("requestResult");
|
||||
json->beginObject();
|
||||
json->addElem("requestId", requestId);
|
||||
json->addElem("ok", result.ok);
|
||||
json->addElem("id", result.id);
|
||||
json->addElem("code", result.code);
|
||||
json->addElem("desc", result.desc);
|
||||
json->endObject();
|
||||
sockEmit.endEmit();
|
||||
}
|
||||
// The change fan-out. Everything that changed about a shade or group in this
|
||||
// loop() pass goes out once: its MQTT topics, then one state event.
|
||||
void SomfyShadeController::sendChanges() {
|
||||
|
|
@ -115,7 +23,6 @@ void SomfyShadeController::sendChanges() {
|
|||
shade.changed = 0;
|
||||
if(!changed || shade.getShadeId() == 255) continue;
|
||||
if(changed & SHADE_CHANGED_TOPICS) shade.publishTopics(changed);
|
||||
shade.emitState(255, "shadeState");
|
||||
if(changed & SHADE_CHANGED_CONFIG) protoSocket.shade(shade);
|
||||
else protoSocket.shadeState(shade);
|
||||
}
|
||||
|
|
@ -125,16 +32,8 @@ void SomfyShadeController::sendChanges() {
|
|||
group.changed = 0;
|
||||
if(!changed || group.getGroupId() == 255) continue;
|
||||
if(changed & GROUP_CHANGED_DIRECTION) group.publish("direction", group.direction);
|
||||
group.emitState(255, "groupState");
|
||||
group.publish();
|
||||
if(changed & GROUP_CHANGED_CONFIG) protoSocket.group(group);
|
||||
else protoSocket.groupState(group);
|
||||
}
|
||||
}
|
||||
void SomfyShadeController::emitState(uint8_t num) {
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) {
|
||||
SomfyShade *shade = &this->shades[i];
|
||||
if(shade->getShadeId() == 255) continue;
|
||||
resetTaskWdt();
|
||||
shade->emitState(num);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,7 +226,14 @@ def http_json(host, path, port=80, method="GET", body=None, headers=None, timeou
|
|||
return json.loads(r.read())
|
||||
|
||||
|
||||
def heap(host):
|
||||
"""free, min and uptime from /discovery on 8081 (protocol 1, until it goes)."""
|
||||
m = http_json(host, "/discovery", port=8081)["memory"]
|
||||
return {"free": m["free"], "min": m["min"], "max": m["max"], "uptime": m["uptime"]}
|
||||
def heap(host, token=None):
|
||||
"""free, min, max and uptime from the status in a sync."""
|
||||
c = ProtoClient(host, params={"apikey": token} if token else None)
|
||||
try:
|
||||
schema = Schema()
|
||||
_, frames, _ = run_sync(c, schema)
|
||||
finally:
|
||||
c.close()
|
||||
key = schema.keys["status"]
|
||||
status = schema.expand(next(f["d"][key] for f in frames if key in f["d"]))
|
||||
return {"free": status["freeHeap"], "min": status["minHeap"], "max": status["maxHeap"], "uptime": status["uptime"]}
|
||||
|
|
|
|||
|
|
@ -16,16 +16,16 @@ MAX_CLIENTS = 5
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def watch_heap(host):
|
||||
def watch_heap(host, token):
|
||||
"""Fails the test when the device rebooted or its heap fell under the floor.
|
||||
|
||||
The device reports its lowest heap since it booted, so a dip under the
|
||||
floor only shows while that low is still above it."""
|
||||
before = heap(host)
|
||||
before = heap(host, token)
|
||||
if before["min"] < HEAP_FLOOR:
|
||||
pytest.skip("the heap has been down to %u bytes since boot: reboot the device to check the floor" % before["min"])
|
||||
yield before
|
||||
after = heap(host)
|
||||
after = heap(host, token)
|
||||
assert after["uptime"] > before["uptime"], "the device rebooted"
|
||||
assert after["min"] >= HEAP_FLOOR, "the heap fell to %u bytes (floor %u)" % (after["min"], HEAP_FLOOR)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import json
|
||||
import os
|
||||
|
||||
from proto_client import Schema, http_json, run_sync
|
||||
from proto_client import Schema, run_sync
|
||||
|
||||
FIXTURES = os.path.join(os.path.dirname(__file__), "..", "fixtures", "proto2")
|
||||
|
||||
|
|
@ -55,24 +55,21 @@ def test_an_old_schema_hash_gets_the_schema_again(connect):
|
|||
assert schema.complete()
|
||||
|
||||
|
||||
def test_sync_holds_the_same_model_as_controller(connect, schema, host, token):
|
||||
def test_sync_is_whole(connect, schema):
|
||||
"""Every group links shades the sync has, every item's room is in it, and
|
||||
there is one header and one status."""
|
||||
c = connect(schema=schema.hash)
|
||||
_, frames, _ = run_sync(c)
|
||||
headers = {"apikey": token} if token else {}
|
||||
controller = http_json(host, "/controller", headers=headers)
|
||||
shades = [schema.expand(s) for s in items(frames, schema, "shades")]
|
||||
groups = [schema.expand(g) for g in items(frames, schema, "groups")]
|
||||
rooms = items(frames, schema, "rooms")
|
||||
assert sorted(s["shadeId"] for s in shades) == sorted(s["shadeId"] for s in controller["shades"])
|
||||
assert sorted(g["groupId"] for g in groups) == sorted(g["groupId"] for g in controller["groups"])
|
||||
assert len(rooms) == len(controller["rooms"])
|
||||
by_id = {s["shadeId"]: s for s in controller["shades"]}
|
||||
for s in shades:
|
||||
assert s.get("name", "") == by_id[s["shadeId"]]["name"]
|
||||
assert s.get("remoteAddress", 0) == by_id[s["shadeId"]]["remoteAddress"]
|
||||
rooms = [schema.expand(r) for r in items(frames, schema, "rooms")]
|
||||
shade_ids = [s["shadeId"] for s in shades]
|
||||
room_ids = {r["roomId"] for r in rooms} | {0}
|
||||
assert len(shade_ids) == len(set(shade_ids))
|
||||
for g in groups:
|
||||
linked = sorted(x["shadeId"] for x in next(cg for cg in controller["groups"] if cg["groupId"] == g["groupId"])["linkedShades"])
|
||||
assert sorted(g.get("linkedShades", [])) == linked
|
||||
assert set(g.get("linkedShades", [])) <= set(shade_ids), g["groupId"]
|
||||
for item in shades + groups:
|
||||
assert item.get("roomId", 0) in room_ids
|
||||
assert len(items(frames, schema, "header")) == 1
|
||||
assert len(items(frames, schema, "status")) == 1
|
||||
|
||||
|
|
|
|||
79
test/test_itemsnapshot/test_itemsnapshot.cpp
Normal file
79
test/test_itemsnapshot/test_itemsnapshot.cpp
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#include <unity.h>
|
||||
#include <string.h>
|
||||
#include <string>
|
||||
#include "ItemSnapshot.h"
|
||||
|
||||
static shade_snapshot_t shade(uint8_t id, const char *name) {
|
||||
shade_snapshot_t s{};
|
||||
s.shadeId = id;
|
||||
s.roomId = 2;
|
||||
strcpy(s.name, name);
|
||||
s.remoteAddress = 1000 + id;
|
||||
s.paired = true;
|
||||
s.shadeType = 1;
|
||||
s.bitLength = 56;
|
||||
s.repeats = 1;
|
||||
return s;
|
||||
}
|
||||
static group_snapshot_t group(uint8_t id, const char *name) {
|
||||
group_snapshot_t g{};
|
||||
g.groupId = id;
|
||||
strcpy(g.name, name);
|
||||
g.remoteAddress = 2000 + id;
|
||||
g.lastRollingCode = 5;
|
||||
g.bitLength = 80;
|
||||
g.repeats = 2;
|
||||
return g;
|
||||
}
|
||||
static std::string shadeRefJson(const shade_snapshot_t &s) {
|
||||
static char buf[1024];
|
||||
BufferedJsonFormatter fmt;
|
||||
fmt.setBuffer(buf, sizeof(buf));
|
||||
fmt.beginObject();
|
||||
writeShadeRef(fmt, s);
|
||||
fmt.endObject();
|
||||
return std::string(buf, fmt.length());
|
||||
}
|
||||
static std::string groupRefJson(const group_snapshot_t &g) {
|
||||
static char buf[1024];
|
||||
BufferedJsonFormatter fmt;
|
||||
fmt.setBuffer(buf, sizeof(buf));
|
||||
fmt.beginObject();
|
||||
writeGroupRef(fmt, g);
|
||||
fmt.endObject();
|
||||
return std::string(buf, fmt.length());
|
||||
}
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
/*********************************************************************
|
||||
* The command replies, as the routes have always written them
|
||||
********************************************************************/
|
||||
static void test_a_shade_ref() {
|
||||
TEST_ASSERT_EQUAL_STRING(
|
||||
"{\"shadeId\":1,\"roomId\":2,\"name\":\"Kitchen\",\"remoteAddress\":1001,\"paired\":true,\"shadeType\":1,"
|
||||
"\"flipCommands\":false,\"flipPosition\":false,\"bitLength\":56,\"proto\":0,\"flags\":0,\"sunSensor\":false,"
|
||||
"\"hasLight\":false,\"repeats\":1}",
|
||||
shadeRefJson(shade(1, "Kitchen")).c_str());
|
||||
}
|
||||
static void test_a_group_ref() {
|
||||
TEST_ASSERT_EQUAL_STRING(
|
||||
"{\"groupId\":6,\"roomId\":0,\"name\":\"All\",\"remoteAddress\":2006,\"lastRollingCode\":5,\"bitLength\":80,"
|
||||
"\"proto\":0,\"sunSensor\":false,\"flipCommands\":false,\"flags\":0,\"repeats\":2,\"sortOrder\":0}",
|
||||
groupRefJson(group(6, "All")).c_str());
|
||||
}
|
||||
static void test_names_are_escaped() {
|
||||
const std::string json = shadeRefJson(shade(1, "A \"big\" one"));
|
||||
TEST_ASSERT_NOT_EQUAL(std::string::npos, json.find("\"name\":\"A \\\"big\\\" one\""));
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_a_shade_ref);
|
||||
RUN_TEST(test_a_group_ref);
|
||||
RUN_TEST(test_names_are_escaped);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ static const shade_request_type_t OFF_RADIO_REQUESTS[] = {
|
|||
shade_request_type_t::set_positions, shade_request_type_t::set_rolling_code
|
||||
};
|
||||
static const loop_item_type_t OTHER_ITEMS[] = {
|
||||
loop_item_type_t::restore_shades, loop_item_type_t::load_shades, loop_item_type_t::model_edit, loop_item_type_t::proto_read,
|
||||
loop_item_type_t::restore_shades, loop_item_type_t::model_edit, loop_item_type_t::proto_read,
|
||||
// A frequency scan's end among them, so it never waits for the radio.
|
||||
loop_item_type_t::proto_device
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,354 +0,0 @@
|
|||
#include <unity.h>
|
||||
#include <string.h>
|
||||
#include <string>
|
||||
#include "ModelSnapshot.h"
|
||||
|
||||
static model_snapshot_t snap;
|
||||
static char unitBuf[SNAPSHOT_UNIT_SIZE];
|
||||
|
||||
static room_snapshot_t room(uint8_t id, const char *name) {
|
||||
room_snapshot_t r{};
|
||||
r.roomId = id;
|
||||
strcpy(r.name, name);
|
||||
return r;
|
||||
}
|
||||
static shade_snapshot_t shade(uint8_t id, const char *name) {
|
||||
shade_snapshot_t s{};
|
||||
s.shadeId = id;
|
||||
s.roomId = 2;
|
||||
strcpy(s.name, name);
|
||||
s.remoteAddress = 1000 + id;
|
||||
s.upTime = 10000;
|
||||
s.downTime = 11000;
|
||||
s.tiltTime = 7000;
|
||||
s.stepSize = 100;
|
||||
s.paired = true;
|
||||
s.lastRollingCode = 42;
|
||||
s.position = 30;
|
||||
s.target = 30;
|
||||
s.myPos = -1;
|
||||
s.tiltType = 2;
|
||||
s.tiltPosition = 40;
|
||||
s.tiltTarget = 40;
|
||||
s.myTiltPos = -1;
|
||||
s.shadeType = 1;
|
||||
s.bitLength = 56;
|
||||
s.inGroup = true;
|
||||
s.repeats = 1;
|
||||
return s;
|
||||
}
|
||||
static group_snapshot_t group(uint8_t id, const char *name) {
|
||||
group_snapshot_t g{};
|
||||
g.groupId = id;
|
||||
strcpy(g.name, name);
|
||||
g.remoteAddress = 2000 + id;
|
||||
g.lastRollingCode = 5;
|
||||
g.bitLength = 80;
|
||||
g.repeats = 2;
|
||||
return g;
|
||||
}
|
||||
static std::string json(void (*write)(JsonFormatter &)) {
|
||||
static char buf[2048];
|
||||
BufferedJsonFormatter fmt;
|
||||
fmt.setBuffer(buf, sizeof(buf));
|
||||
fmt.beginObject();
|
||||
write(fmt);
|
||||
fmt.endObject();
|
||||
return std::string(buf, fmt.length());
|
||||
}
|
||||
static std::string shadeRefJson(uint8_t id) {
|
||||
static uint8_t which;
|
||||
which = id;
|
||||
return json([](JsonFormatter &f) { writeShadeRef(f, *snap.shade(which)); });
|
||||
}
|
||||
static std::string shadeJson(uint8_t id) {
|
||||
static uint8_t which;
|
||||
which = id;
|
||||
return json([](JsonFormatter &f) { writeShade(f, *snap.shade(which)); });
|
||||
}
|
||||
static std::string roomJson(uint8_t id) {
|
||||
static uint8_t which;
|
||||
which = id;
|
||||
return json([](JsonFormatter &f) { writeRoom(f, *snap.room(which)); });
|
||||
}
|
||||
static std::string groupRefJson(uint8_t id) {
|
||||
static uint8_t which;
|
||||
which = id;
|
||||
return json([](JsonFormatter &f) { writeGroupRef(f, *snap.group(which)); });
|
||||
}
|
||||
static void testHeader(JsonFormatter &json, const model_snapshot_t &s) {
|
||||
json.addElem("startingAddress", s.startingAddress);
|
||||
}
|
||||
// The whole document, taken chunkSize bytes at a time like the web server does.
|
||||
static std::string document(snapshot_doc_t doc, uint8_t id = 0, size_t chunkSize = 7, size_t unitSize = sizeof(unitBuf)) {
|
||||
SnapshotWriter writer(snap, doc, id, testHeader, unitBuf, unitSize);
|
||||
std::string out;
|
||||
uint8_t chunk[64];
|
||||
for(int guard = 0; guard < 100000; guard++) {
|
||||
const size_t n = writer.generate(chunk, chunkSize);
|
||||
if(n == 0) break;
|
||||
out.append(reinterpret_cast<char *>(chunk), n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void setUp() {
|
||||
snap = model_snapshot_t{};
|
||||
snap.startingAddress = 77;
|
||||
snap.rooms[snap.roomCount++] = room(2, "Living");
|
||||
snap.rooms[snap.roomCount++] = room(3, "Bed");
|
||||
snap.shades[snap.shadeCount++] = shade(1, "Kitchen");
|
||||
snap.shades[snap.shadeCount++] = shade(4, "Porch");
|
||||
group_snapshot_t g = group(6, "All");
|
||||
g.linkedShades[g.linkedShadeCount++] = 4;
|
||||
g.linkedShades[g.linkedShadeCount++] = 9; // gone from the model
|
||||
g.linkedShades[g.linkedShadeCount++] = 1;
|
||||
snap.groups[snap.groupCount++] = g;
|
||||
snap.repeaters[snap.repeaterCount++] = 12345;
|
||||
snap.repeaters[snap.repeaterCount++] = 678;
|
||||
}
|
||||
void tearDown() {}
|
||||
|
||||
/*********************************************************************
|
||||
* Items, as the routes have always written them
|
||||
********************************************************************/
|
||||
static void test_a_shade() {
|
||||
snap.shades[0].linkedRemotes[snap.shades[0].linkedRemoteCount++] = { 5555, 7 };
|
||||
TEST_ASSERT_EQUAL_STRING(
|
||||
"{\"shadeId\":1,\"roomId\":2,\"name\":\"Kitchen\",\"remoteAddress\":1001,\"upTime\":10000,\"downTime\":11000,"
|
||||
"\"paired\":true,\"lastRollingCode\":42,\"position\":30,\"tiltType\":2,\"tiltPosition\":40,\"tiltDirection\":0,"
|
||||
"\"tiltTime\":7000,\"stepSize\":100,\"tiltTarget\":40,\"target\":30,\"myPos\":-1,\"myTiltPos\":-1,\"direction\":0,"
|
||||
"\"shadeType\":1,\"bitLength\":56,\"proto\":0,\"flags\":0,\"flipCommands\":false,\"flipPosition\":false,"
|
||||
"\"inGroup\":true,\"sunSensor\":false,\"light\":false,\"repeats\":1,\"sortOrder\":0,\"gpioUp\":0,\"gpioDown\":0,"
|
||||
"\"gpioMy\":0,\"gpioLLTrigger\":false,\"simMy\":false,\"linkedRemotes\":[{\"remoteAddress\":5555,\"lastRollingCode\":7}]}",
|
||||
shadeJson(1).c_str());
|
||||
}
|
||||
static void test_a_shade_ref() {
|
||||
TEST_ASSERT_EQUAL_STRING(
|
||||
"{\"shadeId\":1,\"roomId\":2,\"name\":\"Kitchen\",\"remoteAddress\":1001,\"paired\":true,\"shadeType\":1,"
|
||||
"\"flipCommands\":false,\"flipPosition\":false,\"bitLength\":56,\"proto\":0,\"flags\":0,\"sunSensor\":false,"
|
||||
"\"hasLight\":false,\"repeats\":1}",
|
||||
shadeRefJson(1).c_str());
|
||||
}
|
||||
static void test_a_room() {
|
||||
TEST_ASSERT_EQUAL_STRING("{\"roomId\":2,\"name\":\"Living\",\"sortOrder\":0}", roomJson(2).c_str());
|
||||
}
|
||||
static void test_a_group_ref() {
|
||||
TEST_ASSERT_EQUAL_STRING(
|
||||
"{\"groupId\":6,\"roomId\":0,\"name\":\"All\",\"remoteAddress\":2006,\"lastRollingCode\":5,\"bitLength\":80,"
|
||||
"\"proto\":0,\"sunSensor\":false,\"flipCommands\":false,\"flags\":0,\"repeats\":2,\"sortOrder\":0}",
|
||||
groupRefJson(6).c_str());
|
||||
}
|
||||
static void test_names_are_escaped() {
|
||||
strcpy(snap.rooms[0].name, "A \"big\" room");
|
||||
TEST_ASSERT_EQUAL_STRING("{\"roomId\":2,\"name\":\"A \\\"big\\\" room\",\"sortOrder\":0}", roomJson(2).c_str());
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* Documents
|
||||
********************************************************************/
|
||||
static std::string groupWithLinked() {
|
||||
std::string g = groupRefJson(6);
|
||||
g.pop_back(); // reopen it for linkedShades
|
||||
return g + ",\"linkedShades\":[" + shadeRefJson(4) + "," + shadeRefJson(1) + "]}";
|
||||
}
|
||||
static void test_rooms_list() {
|
||||
TEST_ASSERT_EQUAL_STRING(("[" + roomJson(2) + "," + roomJson(3) + "]").c_str(), document(snapshot_doc_t::rooms).c_str());
|
||||
}
|
||||
static void test_shades_list() {
|
||||
TEST_ASSERT_EQUAL_STRING(("[" + shadeJson(1) + "," + shadeJson(4) + "]").c_str(), document(snapshot_doc_t::shades).c_str());
|
||||
}
|
||||
static void test_groups_list_skips_a_linked_shade_that_is_gone() {
|
||||
TEST_ASSERT_EQUAL_STRING(("[" + groupWithLinked() + "]").c_str(), document(snapshot_doc_t::groups).c_str());
|
||||
}
|
||||
static void test_empty_lists() {
|
||||
snap = model_snapshot_t{};
|
||||
TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::rooms).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::shades).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::groups).c_str());
|
||||
}
|
||||
static void test_controller() {
|
||||
const std::string expected = "{\"startingAddress\":77,\"rooms\":[" + roomJson(2) + "," + roomJson(3) +
|
||||
"],\"shades\":[" + shadeJson(1) + "," + shadeJson(4) +
|
||||
"],\"groups\":[" + groupWithLinked() +
|
||||
"],\"repeaters\":[12345,678]}";
|
||||
TEST_ASSERT_EQUAL_STRING(expected.c_str(), document(snapshot_doc_t::controller).c_str());
|
||||
}
|
||||
static void test_discovery_has_no_repeaters() {
|
||||
const std::string expected = "{\"startingAddress\":77,\"rooms\":[" + roomJson(2) + "," + roomJson(3) +
|
||||
"],\"shades\":[" + shadeJson(1) + "," + shadeJson(4) +
|
||||
"],\"groups\":[" + groupWithLinked() + "]}";
|
||||
TEST_ASSERT_EQUAL_STRING(expected.c_str(), document(snapshot_doc_t::discovery).c_str());
|
||||
}
|
||||
static void test_controller_of_an_empty_model() {
|
||||
snap = model_snapshot_t{};
|
||||
TEST_ASSERT_EQUAL_STRING("{\"startingAddress\":0,\"rooms\":[],\"shades\":[],\"groups\":[],\"repeaters\":[]}",
|
||||
document(snapshot_doc_t::controller).c_str());
|
||||
}
|
||||
static void test_chunk_size_does_not_change_the_document() {
|
||||
const std::string whole = document(snapshot_doc_t::controller, 0, 64);
|
||||
for(size_t n = 1; n < 20; n++)
|
||||
TEST_ASSERT_EQUAL_STRING(whole.c_str(), document(snapshot_doc_t::controller, 0, n).c_str());
|
||||
}
|
||||
static void test_single_items() {
|
||||
TEST_ASSERT_EQUAL_STRING(roomJson(3).c_str(), document(snapshot_doc_t::room, 3).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING(shadeJson(4).c_str(), document(snapshot_doc_t::shade, 4).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING(groupWithLinked().c_str(), document(snapshot_doc_t::group, 6).c_str());
|
||||
}
|
||||
static void test_a_single_item_gone_by_the_snapshot_is_an_error() {
|
||||
TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}", document(snapshot_doc_t::shade, 9).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}", document(snapshot_doc_t::group, 9).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Room Id not found.\"}", document(snapshot_doc_t::room, 9).c_str());
|
||||
}
|
||||
static void test_an_item_too_big_for_the_unit_is_left_out() {
|
||||
// Room JSON is about 40 bytes and a shade's well over 300, so a 200 byte
|
||||
// unit takes the rooms and leaves the shades out, keeping the JSON valid.
|
||||
TEST_ASSERT_EQUAL_STRING(("[" + roomJson(2) + "," + roomJson(3) + "]").c_str(),
|
||||
document(snapshot_doc_t::rooms, 0, 7, 200).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::shades, 0, 7, 200).c_str());
|
||||
}
|
||||
static void test_a_left_out_item_does_not_leave_a_stray_comma() {
|
||||
// Give the first shade every linked remote so it no longer fits a unit the
|
||||
// second one does.
|
||||
snap.shades[0].linkedRemoteCount = SOMFY_MAX_LINKED_REMOTES;
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) snap.shades[0].linkedRemotes[i] = { 4000000000u, 65535 };
|
||||
const size_t fits = shadeJson(4).length() + 8;
|
||||
TEST_ASSERT_TRUE(shadeJson(1).length() > fits);
|
||||
TEST_ASSERT_EQUAL_STRING(("[" + shadeJson(4) + "]").c_str(), document(snapshot_doc_t::shades, 0, 7, fits).c_str());
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* Repeaters, what a new item would get, and group options
|
||||
********************************************************************/
|
||||
static void test_repeaters_list() {
|
||||
TEST_ASSERT_EQUAL_STRING("[12345,678]", document(snapshot_doc_t::repeaters).c_str());
|
||||
}
|
||||
static void test_next_room_fills_the_lowest_gap() {
|
||||
TEST_ASSERT_EQUAL_STRING("{\"roomId\":1}", document(snapshot_doc_t::nextRoom).c_str());
|
||||
}
|
||||
static void test_next_shade() {
|
||||
// Ids 1 and 4 are taken, so 2; its address is startingAddress + id unless taken.
|
||||
snap.radioBitLength = 80;
|
||||
snap.radioProto = 1;
|
||||
TEST_ASSERT_EQUAL_STRING("{\"shadeId\":2,\"remoteAddress\":79,\"bitLength\":80,\"stepSize\":100,\"proto\":1}",
|
||||
document(snapshot_doc_t::nextShade).c_str());
|
||||
}
|
||||
static void test_next_address_skips_one_already_used() {
|
||||
snap.shades[0].remoteAddress = 79; // what shade 2 would get
|
||||
snap.shades[1].remoteAddress = 80;
|
||||
TEST_ASSERT_EQUAL_UINT32(81, nextRemoteAddress(snap, 2));
|
||||
}
|
||||
static void test_next_group() {
|
||||
TEST_ASSERT_EQUAL_STRING("{\"groupId\":1,\"remoteAddress\":78,\"bitLength\":0,\"proto\":0}",
|
||||
document(snapshot_doc_t::nextGroup).c_str());
|
||||
}
|
||||
static void test_the_last_ids_are_given_out() {
|
||||
// Ids used to stop two short: 30 shades where the limit says 32.
|
||||
snap.shadeCount = 0;
|
||||
for(uint8_t id = 1; id <= SOMFY_MAX_SHADES - 2; id++) snap.shades[snap.shadeCount++] = shade(id, "S");
|
||||
TEST_ASSERT_EQUAL_UINT8(SOMFY_MAX_SHADES - 1, nextShadeId(snap));
|
||||
snap.shades[snap.shadeCount++] = shade(SOMFY_MAX_SHADES - 1, "S");
|
||||
TEST_ASSERT_EQUAL_UINT8(SOMFY_MAX_SHADES, nextShadeId(snap));
|
||||
}
|
||||
static void test_no_room_for_another_shade() {
|
||||
snap.shadeCount = 0;
|
||||
for(uint8_t id = 1; id <= SOMFY_MAX_SHADES; id++) snap.shades[snap.shadeCount++] = shade(id, "S");
|
||||
TEST_ASSERT_EQUAL_UINT8(255, nextShadeId(snap));
|
||||
}
|
||||
static void test_group_options_lists_the_shades_not_in_it() {
|
||||
snap.shades[snap.shadeCount++] = shade(7, "Spare");
|
||||
const std::string expected = groupWithLinked().substr(0, groupWithLinked().length() - 1) +
|
||||
",\"availShades\":[" + shadeRefJson(7) + "]}";
|
||||
TEST_ASSERT_EQUAL_STRING(expected.c_str(), document(snapshot_doc_t::groupOptions, 6).c_str());
|
||||
}
|
||||
static void test_group_options_of_a_group_gone_by_the_snapshot() {
|
||||
TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}",
|
||||
document(snapshot_doc_t::groupOptions, 9).c_str());
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* Slots
|
||||
********************************************************************/
|
||||
static SnapshotSlots slots;
|
||||
static int fills = 0;
|
||||
static void countFill(model_snapshot_t &s) {
|
||||
fills++;
|
||||
s.shadeCount = 1;
|
||||
}
|
||||
|
||||
static void test_two_reads_at_once_and_no_more() {
|
||||
const int8_t a = slots.acquire();
|
||||
const int8_t b = slots.acquire();
|
||||
TEST_ASSERT_TRUE(a >= 0 && b >= 0 && a != b);
|
||||
TEST_ASSERT_EQUAL_INT8(-1, slots.acquire());
|
||||
slots.cancel(a);
|
||||
slots.cancel(b);
|
||||
}
|
||||
static void test_a_read_is_filled_then_freed() {
|
||||
fills = 0;
|
||||
const int8_t s = slots.acquire();
|
||||
TEST_ASSERT_FALSE(slots.filled(s));
|
||||
slots.fill(s, countFill);
|
||||
TEST_ASSERT_TRUE(slots.filled(s));
|
||||
TEST_ASSERT_EQUAL_UINT8(1, slots.snapshot(s).shadeCount);
|
||||
slots.release(s);
|
||||
TEST_ASSERT_EQUAL_INT8(s, slots.acquire());
|
||||
slots.cancel(s);
|
||||
TEST_ASSERT_EQUAL_INT(1, fills);
|
||||
}
|
||||
static void test_a_read_gone_before_its_fill_is_not_filled() {
|
||||
fills = 0;
|
||||
const int8_t s = slots.acquire();
|
||||
const int8_t other = slots.acquire();
|
||||
slots.release(s);
|
||||
// Not free yet: loop() still has the fill queued.
|
||||
TEST_ASSERT_EQUAL_INT8(-1, slots.acquire());
|
||||
slots.fill(s, countFill);
|
||||
TEST_ASSERT_EQUAL_INT(0, fills);
|
||||
TEST_ASSERT_EQUAL_INT8(s, slots.acquire());
|
||||
slots.cancel(s);
|
||||
slots.cancel(other);
|
||||
}
|
||||
static void test_a_cancelled_slot_is_free_again() {
|
||||
const int8_t s = slots.acquire();
|
||||
slots.cancel(s);
|
||||
TEST_ASSERT_EQUAL_INT8(s, slots.acquire());
|
||||
slots.cancel(s);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_a_shade);
|
||||
RUN_TEST(test_a_shade_ref);
|
||||
RUN_TEST(test_a_room);
|
||||
RUN_TEST(test_a_group_ref);
|
||||
RUN_TEST(test_names_are_escaped);
|
||||
RUN_TEST(test_rooms_list);
|
||||
RUN_TEST(test_shades_list);
|
||||
RUN_TEST(test_groups_list_skips_a_linked_shade_that_is_gone);
|
||||
RUN_TEST(test_empty_lists);
|
||||
RUN_TEST(test_controller);
|
||||
RUN_TEST(test_discovery_has_no_repeaters);
|
||||
RUN_TEST(test_controller_of_an_empty_model);
|
||||
RUN_TEST(test_chunk_size_does_not_change_the_document);
|
||||
RUN_TEST(test_single_items);
|
||||
RUN_TEST(test_a_single_item_gone_by_the_snapshot_is_an_error);
|
||||
RUN_TEST(test_an_item_too_big_for_the_unit_is_left_out);
|
||||
RUN_TEST(test_a_left_out_item_does_not_leave_a_stray_comma);
|
||||
RUN_TEST(test_repeaters_list);
|
||||
RUN_TEST(test_next_room_fills_the_lowest_gap);
|
||||
RUN_TEST(test_next_shade);
|
||||
RUN_TEST(test_next_address_skips_one_already_used);
|
||||
RUN_TEST(test_next_group);
|
||||
RUN_TEST(test_the_last_ids_are_given_out);
|
||||
RUN_TEST(test_no_room_for_another_shade);
|
||||
RUN_TEST(test_group_options_lists_the_shades_not_in_it);
|
||||
RUN_TEST(test_group_options_of_a_group_gone_by_the_snapshot);
|
||||
RUN_TEST(test_two_reads_at_once_and_no_more);
|
||||
RUN_TEST(test_a_read_is_filled_then_freed);
|
||||
RUN_TEST(test_a_read_gone_before_its_fill_is_not_filled);
|
||||
RUN_TEST(test_a_cancelled_slot_is_free_again);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
#include <unity.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <string>
|
||||
#include "ModelSnapshot.h"
|
||||
|
||||
// The read routes format each room, shade, group and linked shade into a
|
||||
// SNAPSHOT_UNIT_SIZE unit and leave out one that doesn't fit, without telling
|
||||
// the client (KNOWN_ISSUES.md). These tests fill the snapshot with the
|
||||
// largest possible items and check every document comes out whole.
|
||||
|
||||
static model_snapshot_t snap;
|
||||
static char unitBuf[SNAPSHOT_UNIT_SIZE];
|
||||
// Big enough for any item, so nothing is ever left out with it.
|
||||
static char bigUnit[64 * 1024];
|
||||
|
||||
// The widest name: 20 control characters, each escaped to \u00XX.
|
||||
static void widestName(char *name) {
|
||||
memset(name, 0x01, 20);
|
||||
name[20] = '\0';
|
||||
}
|
||||
static void fill() {
|
||||
snap = model_snapshot_t{};
|
||||
snap.startingAddress = 0xFFFFFFFF;
|
||||
snap.radioBitLength = 255;
|
||||
snap.radioProto = 255;
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) {
|
||||
room_snapshot_t &r = snap.rooms[snap.roomCount++];
|
||||
r.roomId = i + 1;
|
||||
widestName(r.name);
|
||||
r.sortOrder = -128;
|
||||
}
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) {
|
||||
shade_snapshot_t &s = snap.shades[snap.shadeCount++];
|
||||
s.shadeId = i + 1;
|
||||
s.roomId = 255;
|
||||
widestName(s.name);
|
||||
s.remoteAddress = s.upTime = s.downTime = s.tiltTime = 0xFFFFFFFF;
|
||||
s.stepSize = s.lastRollingCode = 0xFFFF;
|
||||
s.position = s.target = s.myPos = s.direction = -128;
|
||||
s.tiltPosition = s.tiltTarget = s.myTiltPos = s.tiltDirection = -128;
|
||||
s.tiltType = s.shadeType = s.bitLength = s.proto = s.flags = s.repeats = 255;
|
||||
s.gpioUp = s.gpioDown = s.gpioMy = 255;
|
||||
s.sortOrder = -128;
|
||||
s.paired = s.flipCommands = s.flipPosition = s.inGroup = false;
|
||||
s.sunSensor = s.light = s.simMy = s.gpioLLTrigger = false;
|
||||
s.linkedRemoteCount = SOMFY_MAX_LINKED_REMOTES;
|
||||
for(uint8_t j = 0; j < SOMFY_MAX_LINKED_REMOTES; j++) s.linkedRemotes[j] = {0xFFFFFFFF, 0xFFFF};
|
||||
}
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) {
|
||||
group_snapshot_t &g = snap.groups[snap.groupCount++];
|
||||
g.groupId = i + 1;
|
||||
g.roomId = 255;
|
||||
widestName(g.name);
|
||||
g.remoteAddress = 0xFFFFFFFF;
|
||||
g.lastRollingCode = 0xFFFF;
|
||||
g.bitLength = g.proto = g.flags = g.repeats = 255;
|
||||
g.sortOrder = -128;
|
||||
g.sunSensor = g.flipCommands = false;
|
||||
g.linkedShadeCount = SOMFY_MAX_GROUPED_SHADES;
|
||||
for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) g.linkedShades[j] = j + 1;
|
||||
}
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) snap.repeaters[snap.repeaterCount++] = 0xFFFFFFFF;
|
||||
}
|
||||
// The device details aren't in the snapshot (Web.cpp writes them), so the test
|
||||
// header writes nothing.
|
||||
static void noHeader(JsonFormatter &, const model_snapshot_t &) {}
|
||||
static std::string document(snapshot_doc_t doc, uint8_t id, char *unit, size_t unitSize) {
|
||||
SnapshotWriter writer(snap, doc, id, noHeader, unit, unitSize);
|
||||
std::string out;
|
||||
uint8_t chunk[1460];
|
||||
for(int guard = 0; guard < 100000; guard++) {
|
||||
const size_t n = writer.generate(chunk, sizeof(chunk));
|
||||
if(n == 0) break;
|
||||
out.append(reinterpret_cast<char *>(chunk), n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// The document as the device writes it is the one nothing is left out of.
|
||||
static void assertWhole(snapshot_doc_t doc, uint8_t id = 0) {
|
||||
const std::string full = document(doc, id, bigUnit, sizeof(bigUnit));
|
||||
const std::string device = document(doc, id, unitBuf, sizeof(unitBuf));
|
||||
TEST_ASSERT_TRUE(full.length() > 2);
|
||||
TEST_ASSERT_EQUAL_UINT32(full.length(), device.length());
|
||||
TEST_ASSERT_TRUE(full == device);
|
||||
}
|
||||
// How big an item is, formatted as the writer does on its own.
|
||||
static size_t itemSize(void (*write)(JsonFormatter &)) {
|
||||
BufferedJsonFormatter fmt;
|
||||
fmt.setBuffer(bigUnit, sizeof(bigUnit));
|
||||
write(fmt);
|
||||
return fmt.length();
|
||||
}
|
||||
|
||||
void setUp() { fill(); }
|
||||
void tearDown() {}
|
||||
|
||||
static void test_largest_items_fit_the_unit() {
|
||||
const size_t room = itemSize([](JsonFormatter &j) { j.beginObject(); writeRoom(j, snap.rooms[0]); j.endObject(); });
|
||||
const size_t shade = itemSize([](JsonFormatter &j) { j.beginObject(); writeShade(j, snap.shades[0]); j.endObject(); });
|
||||
const size_t shadeRef = itemSize([](JsonFormatter &j) { j.beginObject(); writeShadeRef(j, snap.shades[0]); j.endObject(); });
|
||||
const size_t group = itemSize([](JsonFormatter &j) { j.beginObject(); writeGroupRef(j, snap.groups[0]); j.beginArray("linkedShades"); });
|
||||
char msg[160];
|
||||
snprintf(msg, sizeof(msg), "largest items in a %u-byte unit: room %u, shade %u, linked shade %u, group %u",
|
||||
(unsigned)SNAPSHOT_UNIT_SIZE, (unsigned)room, (unsigned)shade, (unsigned)shadeRef, (unsigned)group);
|
||||
TEST_MESSAGE(msg);
|
||||
// One byte for the comma before an item, one for the end of the string.
|
||||
TEST_ASSERT_TRUE(room + 2 <= SNAPSHOT_UNIT_SIZE);
|
||||
TEST_ASSERT_TRUE(shade + 2 <= SNAPSHOT_UNIT_SIZE);
|
||||
TEST_ASSERT_TRUE(shadeRef + 2 <= SNAPSHOT_UNIT_SIZE);
|
||||
TEST_ASSERT_TRUE(group + 2 <= SNAPSHOT_UNIT_SIZE);
|
||||
}
|
||||
static void test_controller_is_whole() { assertWhole(snapshot_doc_t::controller); }
|
||||
static void test_discovery_is_whole() { assertWhole(snapshot_doc_t::discovery); }
|
||||
static void test_rooms_are_whole() { assertWhole(snapshot_doc_t::rooms); }
|
||||
static void test_shades_are_whole() { assertWhole(snapshot_doc_t::shades); }
|
||||
static void test_groups_are_whole() { assertWhole(snapshot_doc_t::groups); }
|
||||
static void test_single_items_are_whole() {
|
||||
assertWhole(snapshot_doc_t::room, SOMFY_MAX_ROOMS);
|
||||
assertWhole(snapshot_doc_t::shade, SOMFY_MAX_SHADES);
|
||||
assertWhole(snapshot_doc_t::group, SOMFY_MAX_GROUPS);
|
||||
assertWhole(snapshot_doc_t::groupOptions, SOMFY_MAX_GROUPS);
|
||||
assertWhole(snapshot_doc_t::repeaters);
|
||||
}
|
||||
static void test_a_too_small_unit_is_caught() {
|
||||
// The comparison itself works: a unit too small for a shade loses shades.
|
||||
const std::string full = document(snapshot_doc_t::shades, 0, bigUnit, sizeof(bigUnit));
|
||||
const std::string small = document(snapshot_doc_t::shades, 0, unitBuf, 200);
|
||||
TEST_ASSERT_TRUE(small.length() < full.length());
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_largest_items_fit_the_unit);
|
||||
RUN_TEST(test_controller_is_whole);
|
||||
RUN_TEST(test_discovery_is_whole);
|
||||
RUN_TEST(test_rooms_are_whole);
|
||||
RUN_TEST(test_shades_are_whole);
|
||||
RUN_TEST(test_groups_are_whole);
|
||||
RUN_TEST(test_single_items_are_whole);
|
||||
RUN_TEST(test_a_too_small_unit_is_caught);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
|
@ -199,7 +199,7 @@ static void test_rssiNeedsEmit_either_direction() {
|
|||
********************************************************************/
|
||||
static RssiBroadcast broadcastOf(int rssi, int channel) {
|
||||
RssiBroadcast b;
|
||||
b.sent(true, rssi, channel);
|
||||
b.sent(rssi, channel);
|
||||
return b;
|
||||
}
|
||||
static void test_rssiBroadcast_first_status_is_due() {
|
||||
|
|
@ -208,17 +208,9 @@ static void test_rssiBroadcast_first_status_is_due() {
|
|||
RssiBroadcast b;
|
||||
TEST_ASSERT_TRUE(b.due(-60, 6, 3));
|
||||
}
|
||||
static void test_rssiBroadcast_client_emit_keeps_baseline() {
|
||||
// A new client gets the current RSSI right as it drops 5 dBm. The clients
|
||||
// already connected must still get the change.
|
||||
RssiBroadcast b = broadcastOf(-60, 6);
|
||||
b.sent(false, -65, 6);
|
||||
TEST_ASSERT_EQUAL_INT(-60, b.rssi);
|
||||
TEST_ASSERT_TRUE(b.due(-65, 6, 3));
|
||||
}
|
||||
static void test_rssiBroadcast_broadcast_moves_baseline() {
|
||||
RssiBroadcast b = broadcastOf(-60, 6);
|
||||
b.sent(true, -65, 6);
|
||||
b.sent(-65, 6);
|
||||
TEST_ASSERT_EQUAL_INT(-65, b.rssi);
|
||||
// Measured from the new baseline.
|
||||
TEST_ASSERT_FALSE(b.due(-66, 6, 3));
|
||||
|
|
@ -228,12 +220,6 @@ static void test_rssiBroadcast_channel_change_is_due() {
|
|||
RssiBroadcast b = broadcastOf(-60, 6);
|
||||
TEST_ASSERT_TRUE(b.due(-60, 11, 3));
|
||||
}
|
||||
static void test_rssiBroadcast_client_emit_on_disconnect_keeps_baseline() {
|
||||
RssiBroadcast b = broadcastOf(-60, 6);
|
||||
b.sent(false, -100, -1);
|
||||
TEST_ASSERT_EQUAL_INT(6, b.channel);
|
||||
TEST_ASSERT_FALSE(b.due(-60, 6, 3));
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* emitAllowed
|
||||
|
|
@ -434,10 +420,8 @@ int main(int argc, char **argv) {
|
|||
RUN_TEST(test_rssiNeedsEmit_real_change_emits);
|
||||
RUN_TEST(test_rssiNeedsEmit_either_direction);
|
||||
RUN_TEST(test_rssiBroadcast_first_status_is_due);
|
||||
RUN_TEST(test_rssiBroadcast_client_emit_keeps_baseline);
|
||||
RUN_TEST(test_rssiBroadcast_broadcast_moves_baseline);
|
||||
RUN_TEST(test_rssiBroadcast_channel_change_is_due);
|
||||
RUN_TEST(test_rssiBroadcast_client_emit_on_disconnect_keeps_baseline);
|
||||
RUN_TEST(test_emitAllowed_on_the_loop_task);
|
||||
RUN_TEST(test_emitAllowed_refuses_another_task);
|
||||
RUN_TEST(test_emitAllowed_before_the_owner_is_known);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue