fix: stop the 5 s boot wait, tell the client about a bad restore

NTPSettings::apply() waited up to 5 s in getLocalTime() for a clock that
can't be set before the network is up, then returned before it set TZ.
Every cold boot was 5 s slower and ran without its time zone.  It now
calls configTzTime(), which sets the zone and starts SNTP without
waiting, and save() goes through apply().  /setgeneral checked
ntpServer twice, so a request that changed only the zone was ignored.

A restore ignored the result of ShadeConfigFile::restore() and rebooted
either way, so a bad file failed with no word to the user.  The /restore
reply now validates the file and answers 400, which the restore dialog
shows, and an incomplete upload gets a 400 instead of no reply.  A
failed restore changes nothing, so loop() no longer reboots after one.
/shades.tmp, a full copy of the backup with its rolling codes, is
deleted after every restore and after /updateShadeConfig.

The 6 extra bytes in the 2026-10-18 backup: TRANS_REC_SIZE stayed 74
when noiseDetection was added to the 80-byte transceiver record, so
every backup since declares it short.  It is 80 now.  The size check
stays a warning so those backups still restore, and the sum moves to
expectedFileBytes() with tests.  The repeater record length is checked
as a warning only, since validate() also runs on shades.cfg at boot.

Commands for an unknown shade, tilt or group now log a warning, and a
socket that never pinged says so.  KNOWN_ISSUES.md is updated, including
the real cause of the ~365 ms loop stall (radio_tx busy-waits each frame
on loop()'s core).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-19 18:08:16 +02:00
parent 4b75e8fb08
commit 953d4dc6f7
11 changed files with 168 additions and 77 deletions

View file

@ -13,7 +13,10 @@ static const char *TAG = "ConfigFile";
#define SHADE_HDR_SIZE 76
#define SHADE_REC_SIZE 276
#define GROUP_REC_SIZE 200
#define TRANS_REC_SIZE 74
// writeTransRecord: enabled, 8 one-byte fields, 3 floats, txPower and noiseDetection.
// It was left at 74 when noiseDetection was added, so files written before
// the fix declare this record 6 bytes short.
#define TRANS_REC_SIZE 80
#define ROOM_REC_SIZE 29
#define REPEATER_REC_SIZE 77
@ -436,20 +439,11 @@ bool ShadeConfigFile::validate() {
return false;
}
// We should know the file size based upon the record information in the header
uint32_t fsize = this->header.length + (this->header.shadeRecordSize * this->header.shadeRecords);
if(this->header.version > 10) fsize += (this->header.groupRecordSize * this->header.groupRecords);
if(this->header.version >= 19) fsize += (this->header.roomRecordSize * this->header.roomRecords);
if(this->header.version > 13) {
fsize += (this->header.settingsRecordSize);
fsize += (this->header.netRecordSize);
fsize += (this->header.transRecordSize);
}
if(this->header.version >= 21) {
fsize += (this->header.repeaterRecordSize * this->header.repeaterRecords);
}
// Only a warning: older backups declare the transceiver record 6 bytes
// short (see TRANS_REC_SIZE), and the record checks below catch real damage.
const uint32_t fsize = expectedFileBytes(this->header);
if(this->file.size() != fsize) {
ESP_LOGE(TAG, "File size is not correct should be %d and got %d", fsize, this->file.size());
ESP_LOGW(TAG, "File is %u bytes but its header adds up to %u", (unsigned)this->file.size(), (unsigned)fsize);
}
// Next check to see if the records match the header length.
uint8_t recs = 0;
@ -499,12 +493,16 @@ bool ShadeConfigFile::validate() {
if(this->header.version >= 21) {
recs = 0;
while(recs < this->header.repeaterRecords) {
//uint32_t pos = this->file.position();
// Only logged: this also runs on shades.cfg at boot, and failing it
// there would start the device without its shades.
uint32_t pos = this->file.position();
if(!this->seekChar(CFG_REC_END)) {
ESP_LOGE(TAG, "Failed to find the repeater record end %d", recs);
}
else if(this->file.position() - pos != this->header.repeaterRecordSize) {
ESP_LOGW(TAG, "Repeater record length is %d and should be %d", this->file.position() - pos, this->header.repeaterRecordSize);
}
recs++;
}
}
this->file.seek(startPos, SeekSet);
@ -519,6 +517,14 @@ bool ShadeConfigFile::load(SomfyShadeController *s, const char *filename) {
}
return false;
}
bool ShadeConfigFile::validateFile(const char *filename) {
if(!LittleFS.exists(filename)) return false;
ShadeConfigFile file;
file.begin(filename, true);
const bool valid = file.validate();
file.end();
return valid;
}
bool ShadeConfigFile::restore(SomfyShadeController *s, const char *filename, restore_options_t &opts) {
ShadeConfigFile file;
if(file.begin(filename, true)) {

View file

@ -73,6 +73,9 @@ class ShadeConfigFile : public ConfigFile {
static bool exists();
static bool load(SomfyShadeController *somfy, const char *filename = "/shades.cfg");
static bool restore(SomfyShadeController *somfy, const char *filename, restore_options_t &opts);
// Whether the file would pass the checks restore() starts with. Only
// reads the file, so any task can call it.
static bool validateFile(const char *filename);
bool begin(const char *filename, bool readOnly = false);
bool begin(bool readOnly = false);
bool save(SomfyShadeController *somfy);

View file

@ -30,4 +30,16 @@ inline uint32_t shadeModelBytes(const config_header_t &header) {
+ static_cast<uint32_t>(header.shadeRecords) * header.shadeRecordSize
+ static_cast<uint32_t>(header.groupRecords) * header.groupRecordSize;
}
// The file size the header adds up to, counting only the records its version
// has. Backups written before the transceiver record's size was corrected
// declare it 6 bytes short, so a mismatch alone doesn't make a file invalid.
inline uint32_t expectedFileBytes(const config_header_t &header) {
uint32_t bytes = static_cast<uint32_t>(header.length)
+ static_cast<uint32_t>(header.shadeRecords) * header.shadeRecordSize;
if(header.version > 10) bytes += static_cast<uint32_t>(header.groupRecords) * header.groupRecordSize;
if(header.version >= 19) bytes += static_cast<uint32_t>(header.roomRecords) * header.roomRecordSize;
if(header.version > 13) bytes += header.settingsRecordSize + header.netRecordSize + header.transRecordSize;
if(header.version >= 21) bytes += static_cast<uint32_t>(header.repeaterRecords) * header.repeaterRecordSize;
return bytes;
}
#endif

View file

@ -375,11 +375,7 @@ bool NTPSettings::save() {
pref.putString("ntpServer", this->ntpServer);
pref.putString("posixZone", this->posixZone);
pref.end();
struct tm dt;
configTime(0, 0, this->ntpServer);
if(!getLocalTime(&dt)) return false;
setenv("TZ", this->posixZone, 1);
return true;
return this->apply();
}
bool NTPSettings::load() {
Preferences pref;
@ -402,11 +398,11 @@ bool NTPSettings::toJSON(JsonObject &obj) {
obj["posixZone"] = this->posixZone;
return true;
}
bool NTPSettings::apply() {
struct tm dt;
configTime(0, 0, this->ntpServer);
if(!getLocalTime(&dt)) return false;
setenv("TZ", this->posixZone, 1);
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
// network existed, and then returned before the zone was set.
configTzTime(this->posixZone, this->ntpServer);
return true;
}
IPSettings::IPSettings() {}

View file

@ -1,5 +1,6 @@
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <LittleFS.h>
#include "esp_log.h"
#include "LoopQueue.h"
#include "ConfigFile.h"
@ -55,7 +56,15 @@ void LoopQueue::drain() {
break;
case loop_item_type_t::restore_shades: {
restore_options_t opts = restoreOptions(item.restoreParts);
ShadeConfigFile::restore(&somfy, "/shades.tmp", opts);
const bool restored = ShadeConfigFile::restore(&somfy, "/shades.tmp", opts);
// It holds the whole backup, rolling codes included, and has no use now.
LittleFS.remove("/shades.tmp");
// A failed restore stops before it changes anything, so there is
// nothing to reboot for.
if(!restored) {
ESP_LOGE(TAG, "Restore failed, keeping the current settings");
break;
}
ESP_LOGI(TAG, "Rebooting ESP for restored settings...");
rebootDelay.reboot = true;
rebootDelay.rebootTime = millis() + 1000;
@ -63,6 +72,7 @@ void LoopQueue::drain() {
}
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);

View file

@ -441,7 +441,10 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json)
resp.endObject();
resp.endResponse();
}
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}"));
else {
ESP_LOGW(TAG, "handleShadeCommand shade=%u not found", req.id);
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}"));
}
}
void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
@ -474,7 +477,10 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json)
resp.endObject();
resp.endResponse();
}
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group with the specified id not found.\"}"));
else {
ESP_LOGW(TAG, "handleGroupCommand group=%u not found", req.id);
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group with the specified id not found.\"}"));
}
}
void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
@ -505,7 +511,10 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
resp.endObject();
resp.endResponse();
}
else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}"));
else {
ESP_LOGW(TAG, "handleTiltCommand shade=%u not found", req.id);
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; }

View file

@ -257,6 +257,13 @@ void registerWebUiRoutes(AsyncWebServer &server) {
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;
@ -265,6 +272,7 @@ void registerWebUiRoutes(AsyncWebServer &server) {
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) {
esp_task_wdt_reset();
@ -759,7 +767,7 @@ void registerWebUiRoutes(AsyncWebServer &server) {
if(settings.checkForUpdate != checkForUpdate) git.emitUpdateCheck();
if(!obj["hostname"].isNull()) net.updateHostname();
}
if(!obj["ntpServer"].isNull() || !obj["ntpServer"].isNull()) {
if(!obj["ntpServer"].isNull() || !obj["posixZone"].isNull()) {
settings.NTP.fromJSON(obj);
settings.NTP.save();
}

View file

@ -200,8 +200,13 @@ void SocketEmitter::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client
{
uint8_t slot = mapClientId(asyncId);
if(slot != 255) {
ESP_LOGI(TAG, "Socket [%lu] Disconnected (slot %u) after %lums, last ping %lums ago", asyncId, slot,
(unsigned long)(millis() - slots[slot].connectedAt), slots[slot].lastPing ? (unsigned long)(millis() - slots[slot].lastPing) : 0UL);
// 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);
}