mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
feat: say when the firmware is flashed over USB
Builds with one app slot (C6, debug, and the C5 to come) have nowhere for /updateFirmware to write. It answered "Error updating firmware: " and rebooted. - ConfigSettings::otaSlot, from esp_ota_get_next_update_partition(). - The header sends otaSlot, default 1, so only those builds send it. - /updateFirmware refuses with 400 and a reason, and doesn't stop the radio or reboot. - The web UI's Update Firmware says to flash the onboard image over USB, with a backup first. Firmware before the field leaves it undefined, which keeps the uploader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6db449e878
commit
5721d34a34
10 changed files with 55 additions and 5 deletions
|
|
@ -4169,9 +4169,14 @@ class Firmware {
|
||||||
div.style.display = '';
|
div.style.display = '';
|
||||||
}
|
}
|
||||||
updateFirmware() {
|
updateFirmware() {
|
||||||
|
let chip = document.getElementById('divContainer').getAttribute('data-chipmodel') || '';
|
||||||
|
// Firmware from before the field leaves it out: undefined, not false.
|
||||||
|
if (proto.model.header.otaSlot === false) {
|
||||||
|
ui.infoMessage(`This device has no OTA slot, so its firmware is flashed over USB with SomfyController.onboard.esp32${chip}.bin. That image holds the web UI and an empty configuration too, so take a backup first and restore it afterwards.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let div = this.createFileUploader('/updateFirmware');
|
let div = this.createFileUploader('/updateFirmware');
|
||||||
let inst = div.querySelector('div[id=divInstText]');
|
let inst = div.querySelector('div[id=divInstText]');
|
||||||
let chip = document.getElementById('divContainer').getAttribute('data-chipmodel') || '';
|
|
||||||
let html = `<div style="font-size:14px;margin-bottom:20px;">Select a binary file [SomfyController.ino.esp32${chip}.bin] containing the device firmware then press the Upload File button.</div>`;
|
let html = `<div style="font-size:14px;margin-bottom:20px;">Select a binary file [SomfyController.ino.esp32${chip}.bin] containing the device firmware then press the Upload File button.</div>`;
|
||||||
if (this.isMobile()) {
|
if (this.isMobile()) {
|
||||||
html += `<div style="width:100%;color:red;text-align:center;font-weight:bold;"><span style="margin-top:7px;width:100%;background:yellow;padding:3px;display:inline-block;border-radius:5px;background:white;">WARNING<span></div>`;
|
html += `<div style="width:100%;color:red;text-align:center;font-weight:bold;"><span style="margin-top:7px;width:100%;background:yellow;padding:3px;display:inline-block;border-radius:5px;background:white;">WARNING<span></div>`;
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ ProtoModel.ITEMS = [
|
||||||
// have it.
|
// have it.
|
||||||
ProtoModel.FIELDS = {
|
ProtoModel.FIELDS = {
|
||||||
header: ['maxRooms', 'maxShades', 'maxGroups', 'maxGroupedShades', 'maxLinkedRemotes', 'startingAddress', 'bitLength',
|
header: ['maxRooms', 'maxShades', 'maxGroups', 'maxGroupedShades', 'maxLinkedRemotes', 'startingAddress', 'bitLength',
|
||||||
'proto', 'serverId', 'hostname', 'model', 'chipModel', 'fw', 'connType'],
|
'proto', 'serverId', 'hostname', 'model', 'chipModel', 'otaSlot', 'fw', 'connType'],
|
||||||
room: ['roomId', 'name', 'sortOrder'],
|
room: ['roomId', 'name', 'sortOrder'],
|
||||||
shadeState: ['shadeId', 'position', 'target', 'direction', 'myPos', 'tiltPosition', 'tiltTarget', 'tiltDirection',
|
shadeState: ['shadeId', 'position', 'target', 'direction', 'myPos', 'tiltPosition', 'tiltTarget', 'tiltDirection',
|
||||||
'myTiltPos', 'flags', 'lastRollingCode'],
|
'myTiltPos', 'flags', 'lastRollingCode'],
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ A number is never reused. A retired type stays reserved.
|
||||||
- `F`: fields, as `[key, name, kind, default]`, kind being `b` bool, `i` integer, `n` number, `s` string, `l` list, `o` object
|
- `F`: fields, as `[key, name, kind, default]`, kind being `b` bool, `i` integer, `n` number, `s` string, `l` list, `o` object
|
||||||
- `E`: error codes, as `[number, name]`
|
- `E`: error codes, as `[number, name]`
|
||||||
- `Q`: feeds, as `[number, name]`
|
- `Q`: feeds, as `[number, name]`
|
||||||
3. Then the device: `header` (limits, starting address, what a new shade gets, server id, hostname, model, chip, firmware, connection type), `rooms`, `shades`, `groups`, `repeaters` and `status` (memory, and WiFi or Ethernet). A frame is at most 4 KB, an item is never split across frames, and several sections can share one.
|
3. Then the device: `header` (limits, starting address, what a new shade gets, server id, hostname, model, chip, whether it has an OTA slot (`otaSlot`, left out when it has), firmware, connection type), `rooms`, `shades`, `groups`, `repeaters` and `status` (memory, and WiFi or Ethernet). A frame is at most 4 KB, an item is never split across frames, and several sections can share one.
|
||||||
4. The client sends `next` after each `sync` frame, and the device answers with the following one. With no `next` for 30 s the device closes the socket.
|
4. The client sends `next` after each `sync` frame, and the device answers with the following one. With no `next` for 30 s the device closes the socket.
|
||||||
5. `syncEnd` follows the last `sync` frame.
|
5. `syncEnd` follows the last `sync` frame.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include "Utils.h"
|
#include "Utils.h"
|
||||||
#include "Secrets.h"
|
#include "Secrets.h"
|
||||||
#include "esp_chip_info.h"
|
#include "esp_chip_info.h"
|
||||||
|
#include "esp_ota_ops.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
|
|
||||||
static const char *TAG = "Config";
|
static const char *TAG = "Config";
|
||||||
|
|
@ -144,6 +145,7 @@ bool ConfigSettings::begin() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
ESP_LOGD(TAG, "Chip Model ESP32-%s", this->chipModel);
|
ESP_LOGD(TAG, "Chip Model ESP32-%s", this->chipModel);
|
||||||
|
this->otaSlot = esp_ota_get_next_update_partition(nullptr) != nullptr;
|
||||||
this->fwVersion.parse(FW_VERSION);
|
this->fwVersion.parse(FW_VERSION);
|
||||||
uint64_t mac = ESP.getEfuseMac();
|
uint64_t mac = ESP.getEfuseMac();
|
||||||
for(int i=0; i<17; i=i+8) {
|
for(int i=0; i<17; i=i+8) {
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,9 @@ class ConfigSettings: BaseSettings {
|
||||||
char serverId[10] = "";
|
char serverId[10] = "";
|
||||||
char hostname[32] = "ESPSomfyRTS";
|
char hostname[32] = "ESPSomfyRTS";
|
||||||
char chipModel[10] = "ESP32";
|
char chipModel[10] = "ESP32";
|
||||||
|
// Whether the partition table has a second app slot for /updateFirmware
|
||||||
|
// to write. Builds with one 3 MB slot (C5, C6, debug) are flashed over USB.
|
||||||
|
bool otaSlot = true;
|
||||||
conn_types_t connType = conn_types_t::unset;
|
conn_types_t connType = conn_types_t::unset;
|
||||||
appver_t fwVersion;
|
appver_t fwVersion;
|
||||||
appver_t appVersion;
|
appver_t appVersion;
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@
|
||||||
FIELD(hostname, "hn", 's', 0) \
|
FIELD(hostname, "hn", 's', 0) \
|
||||||
FIELD(model, "md", 's', 0) \
|
FIELD(model, "md", 's', 0) \
|
||||||
FIELD(chipModel, "cp", 's', 0) \
|
FIELD(chipModel, "cp", 's', 0) \
|
||||||
|
FIELD(otaSlot, "os", 'b', 1) \
|
||||||
FIELD(connType, "ct", 's', 0) \
|
FIELD(connType, "ct", 's', 0) \
|
||||||
/* memory, wifi and ethernet */ \
|
/* memory, wifi and ethernet */ \
|
||||||
FIELD(freeHeap, "fr", 'i', 0) \
|
FIELD(freeHeap, "fr", 'i', 0) \
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,11 @@ void registerHttpRoutes(AsyncWebServer &server) {
|
||||||
// updateFirmware - file upload
|
// updateFirmware - file upload
|
||||||
server.on(AsyncURIMatcher::exact("/updateFirmware"), HTTP_POST,
|
server.on(AsyncURIMatcher::exact("/updateFirmware"), HTTP_POST,
|
||||||
[](AsyncWebServerRequest *request) {
|
[](AsyncWebServerRequest *request) {
|
||||||
|
// Nothing was written or stopped, so there is nothing to reboot for.
|
||||||
|
if(!settings.otaSlot) {
|
||||||
|
request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"This device has no OTA slot, so its firmware is flashed over USB.\"}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if(Update.hasError())
|
if(Update.hasError())
|
||||||
request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}");
|
request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}");
|
||||||
else
|
else
|
||||||
|
|
@ -172,7 +177,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
|
||||||
rebootDelay.rebootTime = millis() + 500;
|
rebootDelay.rebootTime = millis() + 500;
|
||||||
},
|
},
|
||||||
[](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) {
|
||||||
if(!uploadAllowed(request, index)) return;
|
if(!uploadAllowed(request, index) || !settings.otaSlot) return;
|
||||||
if(index == 0) {
|
if(index == 0) {
|
||||||
webServer.uploadSuccess = false;
|
webServer.uploadSuccess = false;
|
||||||
ESP_LOGI(TAG, "Update: %s", filename.c_str());
|
ESP_LOGI(TAG, "Update: %s", filename.c_str());
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ static void writeHeader(ProtoWriter &w) {
|
||||||
w.add(proto_field_t::hostname, settings.hostname);
|
w.add(proto_field_t::hostname, settings.hostname);
|
||||||
w.add(proto_field_t::model, "ESPSomfyRTS");
|
w.add(proto_field_t::model, "ESPSomfyRTS");
|
||||||
w.add(proto_field_t::chipModel, settings.chipModel);
|
w.add(proto_field_t::chipModel, settings.chipModel);
|
||||||
|
w.add(proto_field_t::otaSlot, settings.otaSlot);
|
||||||
w.add(proto_field_t::fw, settings.fwVersion.name);
|
w.add(proto_field_t::fw, settings.fwVersion.name);
|
||||||
w.add(proto_field_t::connType, net.connTypeName());
|
w.add(proto_field_t::connType, net.connTypeName());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import urllib.request
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from proto_client import ProtoClient
|
from proto_client import ProtoClient, run_sync
|
||||||
|
from test_settings import uptime
|
||||||
|
|
||||||
# Every route that needs the token under Secured, sent without a body or
|
# Every route that needs the token under Secured, sent without a body or
|
||||||
# parameters, so a route that let one through would still change nothing.
|
# parameters, so a route that let one through would still change nothing.
|
||||||
|
|
@ -169,3 +170,22 @@ def test_a_socket_from_the_page_the_device_served_is_accepted(host, token):
|
||||||
c.close()
|
c.close()
|
||||||
# The device frees the slot when it sees the disconnect.
|
# The device frees the slot when it sees the disconnect.
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_firmware_upload_without_an_ota_slot_is_refused(host, token, connect, schema):
|
||||||
|
"""Builds with one app slot (C5, C6, debug) say so in the header, and
|
||||||
|
refuse the upload without stopping the radio or rebooting."""
|
||||||
|
c = connect(schema=schema.hash)
|
||||||
|
_, frames, _ = run_sync(c)
|
||||||
|
key = schema.keys["header"]
|
||||||
|
header = next(schema.expand(f["d"][key]) for f in frames if key in f["d"])
|
||||||
|
if header.get("otaSlot", schema.default("otaSlot")):
|
||||||
|
pytest.skip("the device has an OTA slot: an upload would start an update")
|
||||||
|
uptime_before = uptime(schema, frames)
|
||||||
|
status, _, body = upload(host, "/updateFirmware", auth(token))
|
||||||
|
assert status == 400, body
|
||||||
|
assert json.loads(body)["desc"] == "This device has no OTA slot, so its firmware is flashed over USB."
|
||||||
|
# The old reply rebooted half a second later.
|
||||||
|
time.sleep(2)
|
||||||
|
_, frames, _ = run_sync(connect(schema=schema.hash))
|
||||||
|
assert uptime(schema, frames) > uptime_before, "the device restarted"
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,19 @@ test('a field the sync left out is its default', () => {
|
||||||
for (const g of m.groupList()) assert.ok(Array.isArray(g.linkedShades));
|
for (const g of m.groupList()) assert.ok(Array.isArray(g.linkedShades));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('otaSlot is false only when the device says so', () => {
|
||||||
|
// The recording is from firmware before the field.
|
||||||
|
assert.equal(synced().header.otaSlot, undefined);
|
||||||
|
const schema = recordedSchema();
|
||||||
|
schema.add({ F: [['os', 'otaSlot', 'b', 1]] });
|
||||||
|
const header = deviceSections(schema).find((d) => d.header).header;
|
||||||
|
const m = new ProtoModel(schema);
|
||||||
|
m.applySync({ header: header });
|
||||||
|
assert.equal(m.header.otaSlot, true);
|
||||||
|
m.applySync({ header: schema.expand(Object.assign({ os: 0 }, schema.pack(header))) });
|
||||||
|
assert.equal(m.header.otaSlot, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('the lists come in their sort order', () => {
|
test('the lists come in their sort order', () => {
|
||||||
const m = synced();
|
const m = synced();
|
||||||
const orders = m.shadeList().map((s) => s.sortOrder);
|
const orders = m.shadeList().map((s) => s.sortOrder);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue