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:
cjkas 2026-10-27 21:49:38 +01:00
parent c91c20d444
commit 2a954aa158
53 changed files with 613 additions and 3549 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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");
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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();
}

View file

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

View file

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

View file

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

View file

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