diff --git a/data-src/index.js b/data-src/index.js index 0c3b735..c7a3f3c 100644 --- a/data-src/index.js +++ b/data-src/index.js @@ -4169,9 +4169,14 @@ class Firmware { div.style.display = ''; } 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 inst = div.querySelector('div[id=divInstText]'); - let chip = document.getElementById('divContainer').getAttribute('data-chipmodel') || ''; let html = `
Select a binary file [SomfyController.ino.esp32${chip}.bin] containing the device firmware then press the Upload File button.
`; if (this.isMobile()) { html += `
WARNING
`; diff --git a/data-src/js/model.js b/data-src/js/model.js index 2411996..df80ea1 100644 --- a/data-src/js/model.js +++ b/data-src/js/model.js @@ -140,7 +140,7 @@ ProtoModel.ITEMS = [ // have it. ProtoModel.FIELDS = { 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'], shadeState: ['shadeId', 'position', 'target', 'direction', 'myPos', 'tiltPosition', 'tiltTarget', 'tiltDirection', 'myTiltPos', 'flags', 'lastRollingCode'], diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1937f23..3e5d18d 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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 - `E`: error codes, 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. 5. `syncEnd` follows the last `sync` frame. diff --git a/src/ConfigSettings.cpp b/src/ConfigSettings.cpp index 42def2d..3424e66 100644 --- a/src/ConfigSettings.cpp +++ b/src/ConfigSettings.cpp @@ -7,6 +7,7 @@ #include "Utils.h" #include "Secrets.h" #include "esp_chip_info.h" +#include "esp_ota_ops.h" #include "esp_log.h" static const char *TAG = "Config"; @@ -144,6 +145,7 @@ bool ConfigSettings::begin() { break; } ESP_LOGD(TAG, "Chip Model ESP32-%s", this->chipModel); + this->otaSlot = esp_ota_get_next_update_partition(nullptr) != nullptr; this->fwVersion.parse(FW_VERSION); uint64_t mac = ESP.getEfuseMac(); for(int i=0; i<17; i=i+8) { diff --git a/src/ConfigSettings.h b/src/ConfigSettings.h index 043ebe4..1aa5946 100644 --- a/src/ConfigSettings.h +++ b/src/ConfigSettings.h @@ -163,6 +163,9 @@ class ConfigSettings: BaseSettings { char serverId[10] = ""; char hostname[32] = "ESPSomfyRTS"; 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; appver_t fwVersion; appver_t appVersion; diff --git a/src/ProtoSchema.h b/src/ProtoSchema.h index dc5a853..a46644e 100644 --- a/src/ProtoSchema.h +++ b/src/ProtoSchema.h @@ -169,6 +169,7 @@ FIELD(hostname, "hn", 's', 0) \ FIELD(model, "md", 's', 0) \ FIELD(chipModel, "cp", 's', 0) \ + FIELD(otaSlot, "os", 'b', 1) \ FIELD(connType, "ct", 's', 0) \ /* memory, wifi and ethernet */ \ FIELD(freeHeap, "fr", 'i', 0) \ diff --git a/src/entryPoints/HttpRoutes.cpp b/src/entryPoints/HttpRoutes.cpp index 14f1bac..5379de4 100644 --- a/src/entryPoints/HttpRoutes.cpp +++ b/src/entryPoints/HttpRoutes.cpp @@ -164,6 +164,11 @@ void registerHttpRoutes(AsyncWebServer &server) { // updateFirmware - file upload server.on(AsyncURIMatcher::exact("/updateFirmware"), HTTP_POST, [](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()) request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}"); else @@ -172,7 +177,7 @@ void registerHttpRoutes(AsyncWebServer &server) { rebootDelay.rebootTime = millis() + 500; }, [](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) { webServer.uploadSuccess = false; ESP_LOGI(TAG, "Update: %s", filename.c_str()); diff --git a/src/outputs/ProtoSocket.cpp b/src/outputs/ProtoSocket.cpp index 81c5533..9c381e8 100644 --- a/src/outputs/ProtoSocket.cpp +++ b/src/outputs/ProtoSocket.cpp @@ -77,6 +77,7 @@ static void writeHeader(ProtoWriter &w) { w.add(proto_field_t::hostname, settings.hostname); w.add(proto_field_t::model, "ESPSomfyRTS"); 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::connType, net.connTypeName()); } diff --git a/test/e2e/test_routes.py b/test/e2e/test_routes.py index 8bf4859..d6df206 100644 --- a/test/e2e/test_routes.py +++ b/test/e2e/test_routes.py @@ -11,7 +11,8 @@ import urllib.request 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 # 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() # The device frees the slot when it sees the disconnect. 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" diff --git a/test/js/model.test.js b/test/js/model.test.js index 3d8cd4e..d48fadb 100644 --- a/test/js/model.test.js +++ b/test/js/model.test.js @@ -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)); }); +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', () => { const m = synced(); const orders = m.shadeList().map((s) => s.sortOrder);