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

@ -4956,6 +4956,12 @@ class Firmware {
btnCancel.innerText = 'Close'; btnCancel.innerText = 'Close';
switch (service) { switch (service) {
case '/restore': case '/restore':
if (xhr.status < 200 || xhr.status >= 300) {
let desc = 'The restore failed.';
try { desc = JSON.parse(xhr.responseText).desc || desc; } catch (e) { }
ui.errorMessage(el, desc);
break;
}
(async () => { (async () => {
await somfy.init(); await somfy.init();
if (document.getElementById('divUploadFile')) document.getElementById('divUploadFile').remove(); if (document.getElementById('divUploadFile')) document.getElementById('divUploadFile').remove();

View file

@ -194,65 +194,62 @@ Failed logins aren't throttled. A digits-only 4-character PIN has 10,000 options
## Boot waits 5 s for the clock and then skips the time zone ## Boot waits 5 s for the clock and then skips the time zone
**Status:** open. Seen in the serial logs from 2026-10-18 and 2026-10-19. **Status:** fixed (fix option (b)). `apply()` calls `configTzTime(posixZone, ntpServer)`, which sets the zone, starts SNTP and doesn't wait. `save()` calls `apply()`, so saving the NTP settings doesn't wait either.
**Fixed:** 2026-10-19. The same change makes `/setgeneral` save a request that changes only the time zone: it checked `ntpServer` twice instead of `ntpServer` or `posixZone`.
**Location:** `NTPSettings::apply` in [src/ConfigSettings.cpp](../src/ConfigSettings.cpp), called from `ConfigSettings::begin` in `setup()`. **Location:** `NTPSettings::apply` in [src/ConfigSettings.cpp](../src/ConfigSettings.cpp), called from `ConfigSettings::begin` in `setup()`.
### Summary ### What it was
`apply()` calls `configTime()` and then `getLocalTime(&dt)`. Arduino's `getLocalTime` takes a timeout that defaults to 5000 ms, and polls until the year is past 2016. `setup()` calls it before the network is up, so on a boot whose clock isn't set yet it waits the full 5 s and returns false. In the logs, `File system mounted successfully` is followed 5.1 s later by `Launching web server...`. A soft reboot four hours after boot passed straight through in 90 ms, because the RTC keeps the time across a soft reset. One soft reboot two minutes after a cold boot still waited the full 5 s, presumably because SNTP hadn't set the clock yet. `apply()` called `configTime()` and then `getLocalTime(&dt)`. Arduino's `getLocalTime` takes a timeout that defaults to 5000 ms, and polls until the year is past 2016. `setup()` calls it before the network is up, so on a boot whose clock isn't set yet it waits the full 5 s and returns false. In the logs, `File system mounted successfully` is followed 5.1 s later by `Launching web server...`. A soft reboot four hours after boot passed straight through in 90 ms, because the RTC keeps the time across a soft reset. One soft reboot two minutes after a cold boot still waited the full 5 s, presumably because SNTP hadn't set the clock yet.
`apply()` returns as soon as `getLocalTime` fails, before `setenv("TZ", ...)`. Nothing else sets `TZ`. So after such a boot the configured time zone isn't applied. `apply()` returned as soon as `getLocalTime` failed, before `setenv("TZ", ...)`, and nothing else set `TZ`. So after such a boot the configured time zone wasn't applied: every cold boot was 5 s slower, and local times were worked out as if the zone were UTC. `save()` had the same wait, on the web server task.
### Concrete failure modes ### Options that weren't taken
1. Every cold boot is 5 s slower. The web server, WiFi and the radio all start after the wait. - **(a) Don't wait.** `getLocalTime(&dt, 0)`, then set `TZ` whatever the result.
2. After a cold boot, local times are worked out without the configured time zone, as if it were UTC, until something calls `apply()` again.
### Fix options
- **(a) Don't wait.** Call `getLocalTime(&dt, 0)`, or skip the check, and set `TZ` whatever the result. SNTP sets the clock in the background once the network is up.
- **(b) Use `configTzTime(posixZone, ntpServer)`.** It sets the zone and the server together and doesn't wait.
## A restore reboots even when the backup fails validation ## A restore reboots even when the backup fails validation
**Status:** open. Seen during a restore on 2026-10-18. **Status:** fixed, except that a size mismatch stays a warning (below).
**Location:** the `restore_shades` case in `LoopQueue::drain` in [src/LoopQueue.cpp](../src/LoopQueue.cpp); `ShadeConfigFile::validate` and `restoreFile` in [src/ConfigFile.cpp](../src/ConfigFile.cpp); the `/shades.tmp` route in [src/entryPoints/WebUiRoutes.cpp](../src/entryPoints/WebUiRoutes.cpp). **Fixed:** 2026-10-19.
**Location:** the `/restore` route in [src/entryPoints/WebUiRoutes.cpp](../src/entryPoints/WebUiRoutes.cpp); the `restore_shades` and `load_shades` cases in `LoopQueue::drain` in [src/LoopQueue.cpp](../src/LoopQueue.cpp); `ShadeConfigFile::validate` and `TRANS_REC_SIZE` in [src/ConfigFile.cpp](../src/ConfigFile.cpp).
### What it was
The uploaded backup is written to `/shades.tmp`, and `loop()` restored it with `ShadeConfigFile::restore()` and ignored the result. The device rebooted whether the restore worked or not, and the user was never told. Nothing deleted `/shades.tmp` afterwards, so a full copy of the backup, rolling codes included, stayed in LittleFS where `GET /shades.tmp` served it to anyone.
### What changed
- The `/restore` reply validates the file first. A bad one gets a `400` with a message, which the restore dialog now shows, and nothing is queued. An upload that didn't complete gets a `400` too; before, it got no reply at all.
- `restoreFile()` only fails while it opens or validates the file, before it changes anything. So when it fails, `loop()` logs it and doesn't reboot.
- `/shades.tmp` is deleted after every restore, and after `/updateShadeConfig` loads it.
### Why the size check stays a warning
The backup restored on 2026-10-18 was 6 bytes longer than its header said. Its transceiver record is 80 bytes, but the header declares 74. `TRANS_REC_SIZE` was left at 74 when `noiseDetection` was added to the record, so every backup written since then declares that record 6 bytes short. The reader copes, because `readTransRecord()` skips to the record end. `TRANS_REC_SIZE` is now 80. A strict size check would still reject every backup made before the fix, so a mismatch is only logged, and the record checks catch real damage. The sum lives in `expectedFileBytes()` in [src/ConfigHeader.h](../src/ConfigHeader.h), with tests built from that backup's header.
The repeater record's length is now checked too, but also only logged: `validate()` runs on `shades.cfg` at every boot, and failing it there would start the device without its shades.
## Transmitting a frame stops `loop()` for about 365 ms
**Status:** open, by design for now. Measured in the serial logs from 2026-10-18 and 2026-10-19.
**Location:** `Transceiver::sendFrame` and `startTxTask` in [src/Transceiver.cpp](../src/Transceiver.cpp).
### Summary ### Summary
The uploaded backup is written to `/shades.tmp`, and `loop()` restores it with `ShadeConfigFile::restore()`. The result is ignored: the device logs `Rebooting ESP for restored settings...` and reboots whether the restore worked or not. If validation fails, the device reboots on its old settings and the user is never told. Every shade command that moves a shade is followed by `Loop queue took 364-370ms` in the log. A command whose shade doesn't need to move takes no time. In a burst of 10 commands from Home Assistant, the four that came after a moving shade waited behind it, and that drain took 426 ms. Once, `Loop somfy took 855ms` came with no command near it.
Validation is also looser than it looks. When the file size doesn't match what the header adds up to, `validate()` logs `File size is not correct` but carries on. The repeater records are walked without checking their length. The backup restored on 2026-10-18 was 6 bytes longer than its header said, and was restored anyway. Where the 6 bytes came from wasn't checked; the repeater records are the one section that could hide them. The time isn't spent in `loop()`'s own code. The `radio_tx` task sends each frame by toggling the TX pin with `delayMicroseconds()` busy-waits: the wake-up pulse, the syncs, 56 or 80 bits at 1.28 ms each and the gap after each frame. That's about 130 ms for the first frame and about 140 ms for each repeat. The task runs at priority 5, pinned to the same core as `loop()`, which runs at priority 1, so `loop()` doesn't run until the transmission ends. The timing logged for whatever `loop()` was doing includes that wait.
Nothing deletes `/shades.tmp` afterwards. A full copy of the backup, rolling codes included, stays in LittleFS, and `GET /shades.tmp` serves it without a login (see [Port 80 routes don't check login](#port-80-routes-dont-check-login)). PR A of [SECURITY_PLAN.md](SECURITY_PLAN.md) puts the route behind login and stops `serveStatic` serving `*.tmp`. Even then, the file has no reason to outlive the restore.
### Concrete failure modes ### Concrete failure modes
1. A corrupt or wrong-version backup is rejected, the device reboots anyway, and the UI shows the old shades with no error. 1. While a frame is on air, `loop()` doesn't handle sockets, MQTT or queued commands.
2. A file with extra or missing bytes at the end is restored, as long as the record markers line up. 2. A group command, or a burst of shade commands, adds up to several hundred ms for each shade that moves.
3. Until PR A lands, anyone on the LAN can download the last restored backup long after the restore.
### Fix options ### Fix options
- Only reboot when `restore()` returns true, and send the failure to the client (a `requestResult` event, like the other queued edits). - **Send with the RMT peripheral.** RMT plays out the pulse train in hardware, so no task has to busy-wait. That means rewriting `sendFrame`, and testing the timing against real motors.
- Make the size mismatch fail validation, and check the repeater record length like the other record types. - **Not taken:** a lower priority for `radio_tx` lets `loop()` cut into a frame and breaks its timing. Moving it to the other core holds up WiFi and the web server instead.
- Delete `/shades.tmp` once the restore has run, whatever the result.
## A shade command holds up the main loop for about 365 ms
**Status:** open. Measured in the serial logs from 2026-10-18 and 2026-10-19.
**Location:** the `shade_request` case in `LoopQueue::drain` in [src/LoopQueue.cpp](../src/LoopQueue.cpp).
### Summary
Every shade command that moves a shade is followed by `Loop queue took 364-370ms` in the log. A command whose shade doesn't need to move takes no time. In a burst of 10 commands from Home Assistant, the four that came after a moving shade waited behind it, and that drain took 426 ms. Once, `Loop somfy took 855ms` came about 12 s after the last command, with no command near it.
Where the time goes inside the request wasn't measured.
### Concrete failure modes
1. While the queue drains, `loop()` doesn't receive or handle anything else: radio frames, sockets, MQTT.
2. A group command, or a burst of shade commands, adds up to several hundred ms per shade that moves.
## A read of `/controller` took 2.4 s ## A read of `/controller` took 2.4 s
@ -265,8 +262,8 @@ On 2026-10-19 the log shows `web GET /controller from 192.168.1.197 total took 2
## Log lines that mislead or are missing ## Log lines that mislead or are missing
**Status:** open. All cosmetic. **Status:** two of three fixed 2026-10-19. The `.gz` noise is still open. All cosmetic.
- **A command for an unknown shade leaves no trace.** In `Web::handleShadeCommand` in [src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp), the `handleShadeCommand shade=...` line is only written when the shade exists. On 2026-10-18 Home Assistant sent 10 commands to a device that had no shades yet, and the log shows only the response times. - **Fixed: a command for an unknown shade left no trace.** `handleShadeCommand`, `handleTiltCommand` and `handleGroupCommand` in [src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp) now log a `not found` warning. On 2026-10-18 Home Assistant sent 10 commands to a device that had no shades yet, and the log showed only the response times.
- **`last ping 0ms ago` means the client never pinged.** In the disconnect line in [src/outputs/SocketEmitter.cpp](../src/outputs/SocketEmitter.cpp), a client that never pinged prints 0. Browsers don't ping, so they always show it. - **Fixed: `last ping 0ms ago` meant the client never pinged.** The disconnect line in [src/outputs/SocketEmitter.cpp](../src/outputs/SocketEmitter.cpp) now says `never pinged`. Browsers don't ping, so they always showed it.
- **`.gz does not exist` errors are noise.** `serveStatic` tries `<file>.gz` first. For files stored without one (`appversion`, `icon.png`), LittleFS logs an `E`-level `does not exist, no permits for creation` line on every request. - **Open: `.gz does not exist` errors are noise.** `serveStatic` tries `<file>.gz` first. For files stored without one (`appversion`, `icon.png`), LittleFS logs an `E`-level `does not exist, no permits for creation` line on every request. The fix would be for `minify.py` to gzip the plain files too. `setTryGzipFirst(false)` doesn't help: the error comes from `exists()` probing the missing variant, so it would only move the noise to the gzip-only files.

View file

@ -13,7 +13,10 @@ static const char *TAG = "ConfigFile";
#define SHADE_HDR_SIZE 76 #define SHADE_HDR_SIZE 76
#define SHADE_REC_SIZE 276 #define SHADE_REC_SIZE 276
#define GROUP_REC_SIZE 200 #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 ROOM_REC_SIZE 29
#define REPEATER_REC_SIZE 77 #define REPEATER_REC_SIZE 77
@ -436,20 +439,11 @@ bool ShadeConfigFile::validate() {
return false; return false;
} }
// We should know the file size based upon the record information in the header // Only a warning: older backups declare the transceiver record 6 bytes
uint32_t fsize = this->header.length + (this->header.shadeRecordSize * this->header.shadeRecords); // short (see TRANS_REC_SIZE), and the record checks below catch real damage.
if(this->header.version > 10) fsize += (this->header.groupRecordSize * this->header.groupRecords); const uint32_t fsize = expectedFileBytes(this->header);
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);
}
if(this->file.size() != fsize) { 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. // Next check to see if the records match the header length.
uint8_t recs = 0; uint8_t recs = 0;
@ -499,12 +493,16 @@ bool ShadeConfigFile::validate() {
if(this->header.version >= 21) { if(this->header.version >= 21) {
recs = 0; recs = 0;
while(recs < this->header.repeaterRecords) { 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)) { if(!this->seekChar(CFG_REC_END)) {
ESP_LOGE(TAG, "Failed to find the repeater record end %d", recs); 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++; recs++;
} }
} }
this->file.seek(startPos, SeekSet); this->file.seek(startPos, SeekSet);
@ -519,6 +517,14 @@ bool ShadeConfigFile::load(SomfyShadeController *s, const char *filename) {
} }
return false; 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) { bool ShadeConfigFile::restore(SomfyShadeController *s, const char *filename, restore_options_t &opts) {
ShadeConfigFile file; ShadeConfigFile file;
if(file.begin(filename, true)) { if(file.begin(filename, true)) {

View file

@ -73,6 +73,9 @@ class ShadeConfigFile : public ConfigFile {
static bool exists(); static bool exists();
static bool load(SomfyShadeController *somfy, const char *filename = "/shades.cfg"); static bool load(SomfyShadeController *somfy, const char *filename = "/shades.cfg");
static bool restore(SomfyShadeController *somfy, const char *filename, restore_options_t &opts); 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(const char *filename, bool readOnly = false);
bool begin(bool readOnly = false); bool begin(bool readOnly = false);
bool save(SomfyShadeController *somfy); 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.shadeRecords) * header.shadeRecordSize
+ static_cast<uint32_t>(header.groupRecords) * header.groupRecordSize; + 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 #endif

View file

@ -375,11 +375,7 @@ bool NTPSettings::save() {
pref.putString("ntpServer", this->ntpServer); pref.putString("ntpServer", this->ntpServer);
pref.putString("posixZone", this->posixZone); pref.putString("posixZone", this->posixZone);
pref.end(); pref.end();
struct tm dt; return this->apply();
configTime(0, 0, this->ntpServer);
if(!getLocalTime(&dt)) return false;
setenv("TZ", this->posixZone, 1);
return true;
} }
bool NTPSettings::load() { bool NTPSettings::load() {
Preferences pref; Preferences pref;
@ -403,10 +399,10 @@ bool NTPSettings::toJSON(JsonObject &obj) {
return true; return true;
} }
bool NTPSettings::apply() { bool NTPSettings::apply() {
struct tm dt; // Sets the zone and starts SNTP, which sets the clock once the network is
configTime(0, 0, this->ntpServer); // up. Waiting here with getLocalTime() held up boot by 5 s before the
if(!getLocalTime(&dt)) return false; // network existed, and then returned before the zone was set.
setenv("TZ", this->posixZone, 1); configTzTime(this->posixZone, this->ntpServer);
return true; return true;
} }
IPSettings::IPSettings() {} IPSettings::IPSettings() {}

View file

@ -1,5 +1,6 @@
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/queue.h> #include <freertos/queue.h>
#include <LittleFS.h>
#include "esp_log.h" #include "esp_log.h"
#include "LoopQueue.h" #include "LoopQueue.h"
#include "ConfigFile.h" #include "ConfigFile.h"
@ -55,7 +56,15 @@ void LoopQueue::drain() {
break; break;
case loop_item_type_t::restore_shades: { case loop_item_type_t::restore_shades: {
restore_options_t opts = restoreOptions(item.restoreParts); 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..."); ESP_LOGI(TAG, "Rebooting ESP for restored settings...");
rebootDelay.reboot = true; rebootDelay.reboot = true;
rebootDelay.rebootTime = millis() + 1000; rebootDelay.rebootTime = millis() + 1000;
@ -63,6 +72,7 @@ void LoopQueue::drain() {
} }
case loop_item_type_t::load_shades: case loop_item_type_t::load_shades:
somfy.loadShadesFile("/shades.tmp"); somfy.loadShadesFile("/shades.tmp");
LittleFS.remove("/shades.tmp");
break; break;
case loop_item_type_t::snapshot_fill: case loop_item_type_t::snapshot_fill:
snapshotSlots.fill(item.slot, fillSnapshot); snapshotSlots.fill(item.slot, fillSnapshot);

View file

@ -441,7 +441,10 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json)
resp.endObject(); resp.endObject();
resp.endResponse(); 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) { void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
@ -474,7 +477,10 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json)
resp.endObject(); resp.endObject();
resp.endResponse(); 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) { void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
@ -505,7 +511,10 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
resp.endObject(); resp.endObject();
resp.endResponse(); 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) { void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json) {
if(request->method() == HTTP_OPTIONS) { request->send(200); return; } 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..."); ESP_LOGD(TAG, "No restore options sent. Using defaults...");
opts.shades = true; 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 // The main loop restores, since it owns the shades and their rolling
// codes, and then reboots. // codes, and then reboots.
loop_item_t item; loop_item_t item;
@ -265,6 +272,7 @@ void registerWebUiRoutes(AsyncWebServer &server) {
if(!queueLoopItem(request, item)) return; if(!queueLoopItem(request, item)) return;
request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}"); 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) { [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
esp_task_wdt_reset(); esp_task_wdt_reset();
@ -759,7 +767,7 @@ void registerWebUiRoutes(AsyncWebServer &server) {
if(settings.checkForUpdate != checkForUpdate) git.emitUpdateCheck(); if(settings.checkForUpdate != checkForUpdate) git.emitUpdateCheck();
if(!obj["hostname"].isNull()) net.updateHostname(); 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.fromJSON(obj);
settings.NTP.save(); settings.NTP.save();
} }

View file

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

View file

@ -45,8 +45,47 @@ static void test_a_full_model_does_not_overflow() {
TEST_ASSERT_EQUAL_UINT32(3UL * 255 * 0xFFFF, shadeModelBytes(h)); TEST_ASSERT_EQUAL_UINT32(3UL * 255 * 0xFFFF, shadeModelBytes(h));
} }
// The header of a real backup: version 24, 10 shades, 2 groups, 1 repeater.
static config_header_t backupHeader(uint16_t transRecordSize) {
config_header_t h;
h.version = 24;
h.length = 76;
h.roomRecordSize = 29;
h.shadeRecordSize = 276;
h.shadeRecords = 10;
h.groupRecordSize = 200;
h.groupRecords = 2;
h.repeaterRecordSize = 77;
h.repeaterRecords = 1;
h.settingsRecordSize = 94;
h.netRecordSize = 166;
h.transRecordSize = transRecordSize;
return h;
}
static void test_a_backup_adds_up_to_its_file_size() {
// That file was 3653 bytes: its radio record is 80, the header said 74.
TEST_ASSERT_EQUAL_UINT32(3647, expectedFileBytes(backupHeader(74)));
TEST_ASSERT_EQUAL_UINT32(3653, expectedFileBytes(backupHeader(80)));
}
static void test_old_versions_only_count_their_records() {
config_header_t h = backupHeader(80);
h.roomRecords = 3;
h.version = 10; // header and shades only
TEST_ASSERT_EQUAL_UINT32(76 + 10 * 276, expectedFileBytes(h));
h.version = 13; // + groups
TEST_ASSERT_EQUAL_UINT32(76 + 10 * 276 + 2 * 200, expectedFileBytes(h));
h.version = 14; // + settings, network, radio
TEST_ASSERT_EQUAL_UINT32(76 + 10 * 276 + 2 * 200 + 94 + 166 + 80, expectedFileBytes(h));
h.version = 19; // + rooms
TEST_ASSERT_EQUAL_UINT32(76 + 10 * 276 + 2 * 200 + 94 + 166 + 80 + 3 * 29, expectedFileBytes(h));
h.version = 21; // + repeaters
TEST_ASSERT_EQUAL_UINT32(76 + 10 * 276 + 2 * 200 + 94 + 166 + 80 + 3 * 29 + 77, expectedFileBytes(h));
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_a_backup_adds_up_to_its_file_size);
RUN_TEST(test_old_versions_only_count_their_records);
RUN_TEST(test_shade_model_counts_rooms_shades_and_groups); RUN_TEST(test_shade_model_counts_rooms_shades_and_groups);
RUN_TEST(test_shade_model_leaves_out_the_rest); RUN_TEST(test_shade_model_leaves_out_the_rest);
RUN_TEST(test_an_old_file_without_rooms); RUN_TEST(test_an_old_file_without_rooms);