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:
cjkas 2026-10-28 22:06:26 +01:00
parent 6db449e878
commit 5721d34a34
10 changed files with 55 additions and 5 deletions

View file

@ -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"

View file

@ -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);