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

@ -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,8 +58,7 @@ class NTPSettings: BaseSettings {
char ntpServer[65] = "pool.ntp.org";
char posixZone[64] = "";
bool fromJSON(JsonObject &obj);
bool toJSON(JsonObject &obj);
bool apply();
bool begin();
bool save();
@ -82,8 +75,7 @@ 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);
void printNetworks();
@ -107,8 +99,7 @@ class EthernetSettings: BaseSettings {
bool begin();
bool fromJSON(JsonObject &obj);
bool toJSON(JsonObject &obj);
bool load();
bool save();
void print();
@ -126,8 +117,7 @@ class IPSettings: BaseSettings {
IPAddress dns2 = IPAddress(0,0,0,0);
bool begin();
bool fromJSON(JsonObject &obj);
bool toJSON(JsonObject &obj);
bool load();
bool save();
void print();
@ -151,8 +141,7 @@ class SecuritySettings: BaseSettings {
bool save();
bool load();
void print();
bool toJSON(JsonObject &obj);
bool fromJSON(JsonObject &obj);
};
class MQTTSettings: BaseSettings {
@ -169,8 +158,7 @@ class MQTTSettings: BaseSettings {
bool begin();
bool save();
bool load();
bool toJSON(JsonObject &obj);
bool fromJSON(JsonObject &obj);
};
class ConfigSettings: BaseSettings {
@ -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());
}
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();
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();
}
// 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();
loopStats.publish(millis());
}
// 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);
}
}