fix: send socket events only from loop()

Every socket event is formatted into one static buffer by one shared
formatter.  setConnected() runs on the WiFi/Ethernet event task and sent
memStatus, wifiStrength and ethernet, then ran sockEmit.loop(), while
loop() could be building its own event.  _safecat() checks the room and
then writes at _len, so another task moving _len in between could write
past the end of g_response.

setConnected() now sends nothing.  loop() broadcasts the status every
STATUS_EMIT_MS (30 s): memStatus, then ethernet, or wifiStrength when it
moved 3 dBm or changed channel.  A client that connects still gets all
of it at once from initClients(), so needsBroadcast and the 1.5 s tick
go, and so does the 1.5 s memStatus for the config panel.

SocketEmitter records loop()'s task in begin().  Off it, beginEmit()
logs and hands out a formatter that writes nothing, and endEmit(),
endEmitRoom() and loop() do nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-21 15:27:21 +02:00
parent 474c7c0f42
commit 25d548ef07
11 changed files with 153 additions and 117 deletions

View file

@ -22,8 +22,6 @@ extern ESPNetwork net;
extern SomfyShadeController somfy;
extern LoopStats loopStats;
static unsigned long _lastHeapEmit = 0;
static bool _apScanning = false;
int connectRetries = 0;
void ESPNetwork::end() {
@ -144,13 +142,9 @@ void ESPNetwork::loop() {
}
}
}
if(millis() - this->lastEmit > 1500) {
// Post our connection status if needed.
if(millis() - this->lastEmit >= STATUS_EMIT_MS) {
this->lastEmit = millis();
if(this->connected()) {
this->emitSockets();
this->lastEmit = millis();
}
if(this->connected()) this->emitSockets();
resetTaskWdt(); // Make sure we do not reboot here.
}
@ -176,15 +170,13 @@ bool ESPNetwork::changeAP(const uint8_t *bssid, const int32_t channel) {
this->connectStart = millis();
return false;
}
// The status broadcast, every STATUS_EMIT_MS from loop(). A wired link goes
// out every time, the WiFi signal only when it really changed.
void ESPNetwork::emitSockets() {
this->emitHeap();
if(this->needsBroadcast ||
(this->connType == conn_types_t::wifi &&
this->rssiBroadcast.due(WiFi.RSSI(), WiFi.channel(), millis(), RSSI_EMIT_MIN_DBM, RSSI_EMIT_MIN_MS))) {
if(this->connType != conn_types_t::wifi ||
this->rssiBroadcast.due(WiFi.RSSI(), WiFi.channel(), RSSI_EMIT_MIN_DBM))
this->emitSockets(255);
sockEmit.loop();
this->needsBroadcast = false;
}
}
void ESPNetwork::emitSockets(uint8_t num) {
#if HAS_ETHERNET
@ -208,7 +200,7 @@ void ESPNetwork::emitSockets(uint8_t num) {
json->addElem("channel", (int32_t)this->channel);
json->endObject();
sockEmit.endEmit(num);
this->rssiBroadcast.sent(num == 255, WiFi.RSSI(), WiFi.channel(), millis());
this->rssiBroadcast.sent(num == 255, WiFi.RSSI(), WiFi.channel());
}
else {
JsonSockEvent *json = sockEmit.beginEmit("wifiStrength");
@ -226,12 +218,15 @@ void ESPNetwork::emitSockets(uint8_t num) {
json->addElem("fullduplex", false);
json->endObject();
sockEmit.endEmit(num);
this->rssiBroadcast.sent(num == 255, -100, -1, millis());
this->rssiBroadcast.sent(num == 255, -100, -1);
}
}
// A broadcast comes from emitSockets(), which has already sent memStatus.
if(num != 255) this->emitHeap(num);
}
// 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().
void ESPNetwork::setConnected(conn_types_t connType) {
resetTaskWdt();
this->connType = connType;
@ -287,15 +282,6 @@ void ESPNetwork::setConnected(conn_types_t connType) {
settings.IP.dns1 = ETH.dnsIP(0);
settings.IP.dns2 = ETH.dnsIP(1);
}
resetTaskWdt();
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();
resetTaskWdt();
}
#endif
}
@ -347,9 +333,7 @@ void ESPNetwork::setConnected(conn_types_t connType) {
}
else if(SSDP.isStarted) SSDP.end();
resetTaskWdt();
this->emitSockets();
settings.printAvailHeap();
this->needsBroadcast = true;
}
#if HAS_ETHERNET
bool ESPNetwork::connectWired() {
@ -693,8 +677,6 @@ void ESPNetwork::emitHeap(uint8_t num) {
uint32_t freeHeap = ESP.getFreeHeap();
uint32_t maxHeap = ESP.getMaxAllocHeap();
uint32_t minHeap = ESP.getMinFreeHeap();
const heap_emit_t target = heapEmitTarget(num, millis() - _lastHeapEmit, HEAP_EMIT_MS, sockEmit.activeClients(0) > 0);
if(target == heap_emit_t::none) return;
JsonSockEvent *json = sockEmit.beginEmit("memStatus");
json->beginObject();
json->addElem("max", maxHeap);
@ -707,10 +689,6 @@ void ESPNetwork::emitHeap(uint8_t num) {
json->addElem("loopRate", loopStats.rate(millis()));
json->addElem("loopMaxUs", loopStats.maxUs());
json->endObject();
if(target == heap_emit_t::room) sockEmit.endEmitRoom(0);
else sockEmit.endEmit(num);
if(target == heap_emit_t::broadcast) {
_lastHeapEmit = millis();
loopStats.publish(_lastHeapEmit);
}
sockEmit.endEmit(num);
if(num == 255) loopStats.publish(millis());
}

View file

@ -10,12 +10,13 @@
#define CONNECT_TIMEOUT 20000
#define SSID_SCAN_INTERVAL 600000
// A weak signal moves a dBm or two all the time, so only broadcast real
// changes and not more often than this.
// changes.
#define RSSI_EMIT_MIN_DBM 3
#define RSSI_EMIT_MIN_MS 10000
// How often memStatus is broadcast. It is also the one event that is always
// sent, so it must stay well under Home Assistant's 60s receive timeout.
#define HEAP_EMIT_MS 30000
// 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().
#define STATUS_EMIT_MS 30000
// A roaming scan only moves to another AP at least this much stronger.
#define ROAM_MIN_GAIN_DB 6
class ESPNetwork {
@ -31,7 +32,6 @@ class ESPNetwork {
bool wifiFallback = false;
bool softAPOpened = false;
bool openingSoftAP = false;
bool needsBroadcast = true;
conn_types_t connType = conn_types_t::unset;
conn_types_t connTarget = conn_types_t::unset;
bool connected();

View file

@ -16,6 +16,17 @@ void BufferedJsonFormatter::setBuffer(char *b, size_t sz) {
size_t BufferedJsonFormatter::length() const {
return this->_len;
}
void JsonFormatter::discard() {
// With no room every _safecat() takes the overflow path, and as it has
// already overflowed that doesn't log either.
this->buff = nullptr;
this->buffSize = 0;
this->_len = 0;
this->_nocomma = true;
this->_overflow = true;
this->_objects = 0;
this->_arrays = 0;
}
void JsonFormatter::beginObject(const char *name) {
if(name && strlen(name) > 0) this->appendElem(name);
else if(!this->_nocomma) this->_safecat(",");

View file

@ -24,6 +24,9 @@ class JsonFormatter {
void _appendNumber(const char *name);
public:
bool overflowed() const { return _overflow; }
// Drops whatever is written until a buffer is set again. Nothing is
// written anywhere, so tasks can share one discarding formatter.
void discard();
// Writes c as it must appear inside a JSON string (out needs 7 bytes) and
// returns its length.
static size_t escapeChar(char c, char *out);

View file

@ -130,19 +130,21 @@ int Timestamp::tzOffset() {
time(&now);
return Timestamp::calcTZOffset(&now);
}
bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, uint32_t sinceLastMs, int minDelta, uint32_t minMs) {
bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, int minDelta) {
if(channel != lastChannel) return true;
int delta = rssi > lastRssi ? rssi - lastRssi : lastRssi - rssi;
return delta >= minDelta && sinceLastMs >= minMs;
return delta >= minDelta;
}
bool RssiBroadcast::due(int rssi, int channel, uint32_t now, int minDelta, uint32_t minMs) const {
return rssiNeedsEmit(rssi, this->rssi, channel, this->channel, now - this->at, minDelta, minMs);
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, uint32_t now) {
void RssiBroadcast::sent(bool broadcast, int rssi, int channel) {
if(!broadcast) return;
this->rssi = rssi;
this->channel = channel;
this->at = now;
}
bool emitAllowed(const void *current, const void *owner) {
return owner == nullptr || current == owner;
}
bool worthRoaming(bool connected, int currentRssi, int apRssi, int minGain) {
return !connected || apRssi >= currentRssi + minGain;
@ -179,11 +181,6 @@ const char *wifiAuthModeName(uint8_t authMode) {
default: return "UNKNOWN";
}
}
heap_emit_t heapEmitTarget(uint8_t num, uint32_t sinceLastMs, uint32_t intervalMs, bool roomHasClients) {
if(num != 255) return heap_emit_t::client;
if(sinceLastMs >= intervalMs) return heap_emit_t::broadcast;
return roomHasClients ? heap_emit_t::room : heap_emit_t::none;
}
void LoopStats::record(uint32_t passUs) {
_passes++;
if(passUs > _maxUs) _maxUs = passUs;

View file

@ -58,30 +58,27 @@ struct rebootDelay_t {
return def;
}
// Whether a wifiStrength broadcast is worth sending: always on a channel change,
// otherwise only for an RSSI change of at least minDelta dBm and not more often
// than every minMs.
bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, uint32_t sinceLastMs, int minDelta, uint32_t minMs);
// 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.
struct RssiBroadcast {
int rssi = 0;
int channel = 0;
uint32_t at = 0; // millis() of the last broadcast.
bool due(int rssi, int channel, uint32_t now, int minDelta, uint32_t minMs) const;
void sent(bool broadcast, int rssi, int channel, uint32_t now);
bool due(int rssi, int channel, int minDelta) const;
void sent(bool broadcast, 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
// a small swing doesn't make the device hop between APs.
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).
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.
const char *wifiAuthModeName(uint8_t authMode);
// Who gets a memStatus event.
enum class heap_emit_t : uint8_t { none, client, broadcast, room };
// num is the socket client asked for, 255 for everyone. A broadcast goes out
// every intervalMs; in between only the clients in room 0 get one.
heap_emit_t heapEmitTarget(uint8_t num, uint32_t sinceLastMs, uint32_t intervalMs, bool roomHasClients);
// Whether a firmware the bootloader is holding for verification after an
// update has proven itself: it has run for minUptimeMs and the network is
// up. Until then a reset rolls back to the previous firmware.

View file

@ -327,7 +327,6 @@ 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);
net.needsBroadcast = true;
}
else
request->send(500, _encoding_text, "Invalid http method");

View file

@ -8,6 +8,7 @@
#include "ESPNetwork.h"
#include "GitOTA.h"
#include "Timing.h"
#include "Utils.h"
static const char *TAG = "Sockets";
@ -92,20 +93,36 @@ 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 {
@ -115,6 +132,7 @@ void SocketEmitter::endEmit(uint8_t num) {
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++) {

View file

@ -22,6 +22,11 @@ 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;
bool onLoopTask() const;
void delayInit(uint8_t num);
public:
JsonSockEvent json;