mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Prevents the wrong-chip flash that bricks a board (e.g. an ESP32-S2 bootloader
in an ESP32 set): after connecting, each selected file's ESP image header is
checked against the device's chip_id and the offending row is flagged (a
warning, not a block). Changing the firmware selection now disconnects so the
check is always re-run against a freshly detected chip.
- "Choose from your WROLPi" is now a table (File / Path / Size / Chip / Kind
/ add), served by a single /flasher/search call
- search_esp_firmware returns every .bin annotated when unfiltered (non-ESP
parts like partition tables, boot_app0, and littlefs stay pickable); a chip
filter still narrows to that chip's ESP images
- Detect-device filter now searches by the canonical chip name (CHIP_NAME,
e.g. "ESP32") instead of the variant description ("ESP32-D0WD-V3"), which
matched nothing
- Chip column uses deterministic, colorblind-safe colors; same-width chip
names always get distinct colors, and the palette has headroom to grow
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
7.6 KiB
Python
160 lines
7.6 KiB
Python
import pathlib
|
|
from http import HTTPStatus
|
|
|
|
import pytest
|
|
|
|
from modules.flasher.lib import read_esp_image_info, search_esp_firmware
|
|
|
|
|
|
@pytest.mark.parametrize('chip_id,kind,expected_chip,expected_kind', [
|
|
(0x0000, 'app', 'ESP32', 'app'),
|
|
(0x0002, 'app', 'ESP32-S2', 'app'),
|
|
(0x0009, 'app', 'ESP32-S3', 'app'),
|
|
(0x0005, 'app', 'ESP32-C3', 'app'),
|
|
(0x000D, 'app', 'ESP32-C6', 'app'),
|
|
(0x0000, 'factory_s2', 'ESP32', 'factory'), # ESP32/S2 layout: bootloader at 0x1000
|
|
(0x0009, 'factory', 'ESP32-S3', 'factory'), # S3 layout: bootloader at 0x0, partition table at 0x8000
|
|
(0x0000, 'bootloader', 'ESP32', 'bootloader'),
|
|
])
|
|
def test_read_esp_image_info(make_esp_image, chip_id, kind, expected_chip, expected_kind):
|
|
"""The header reader identifies the target chip and image kind from realistic header bytes."""
|
|
path = make_esp_image('software/fw.bin', chip_id=chip_id, kind=kind)
|
|
info = read_esp_image_info(path)
|
|
assert info['is_esp_image'] is True
|
|
assert info['chip'] == expected_chip
|
|
assert info['chip_id'] == chip_id
|
|
assert info['kind'] == expected_kind
|
|
|
|
|
|
def test_read_esp_image_info_not_esp(make_esp_image):
|
|
"""A .bin that is not an ESP image (no 0xE9 magic) is reported as such."""
|
|
path = make_esp_image('software/data.bin', kind='not_esp')
|
|
info = read_esp_image_info(path)
|
|
assert info == dict(is_esp_image=False, chip=None, chip_id=None, kind='not_esp_image')
|
|
|
|
|
|
def test_read_esp_image_info_unknown_chip(make_esp_image):
|
|
"""An ESP image with an unrecognized chip_id is still detected as an image, with chip=None."""
|
|
path = make_esp_image('software/fw.bin', chip_id=0x7777, kind='app')
|
|
info = read_esp_image_info(path)
|
|
assert info['is_esp_image'] is True
|
|
assert info['chip'] is None
|
|
assert info['chip_id'] == 0x7777
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_esp_firmware_filters_by_chip(async_client, test_session, make_esp_image, refresh_files):
|
|
"""search_esp_firmware returns only ESP images matching the requested chip, annotated with chip/kind."""
|
|
make_esp_image('software/esp32-app.bin', chip_id=0x0000, kind='app')
|
|
make_esp_image('software/s2-app.bin', chip_id=0x0002, kind='app')
|
|
make_esp_image('software/s3-factory.bin', chip_id=0x0009, kind='factory')
|
|
make_esp_image('software/s3-app.bin', chip_id=0x0009, kind='app')
|
|
# A non-ESP .bin (e.g. a filesystem image or game installer) must never be returned.
|
|
make_esp_image('software/littlefs.bin', kind='not_esp')
|
|
|
|
await refresh_files()
|
|
|
|
def names(results):
|
|
# search_esp_firmware (lib layer) returns primary_path as an absolute Path; compare by basename.
|
|
return sorted(pathlib.Path(r['primary_path']).name for r in results)
|
|
|
|
# Filter to ESP32-S3: only the two S3 images, never the non-ESP file.
|
|
s3, total = search_esp_firmware(chip='ESP32-S3')
|
|
assert total == 2
|
|
assert names(s3) == ['s3-app.bin', 's3-factory.bin']
|
|
assert {r['esp_chip'] for r in s3} == {'ESP32-S3'}
|
|
assert {r['esp_kind'] for r in s3} == {'app', 'factory'}
|
|
|
|
# Filter to ESP32-S2: just the one.
|
|
s2, total = search_esp_firmware(chip='ESP32-S2')
|
|
assert total == 1
|
|
assert names(s2) == ['s2-app.bin']
|
|
|
|
# No chip: every .bin, including non-ESP parts (partition tables, littlefs, boot_app0) annotated with a null
|
|
# chip so a full flash set can still be assembled from the picker.
|
|
all_bin, total = search_esp_firmware()
|
|
assert total == 5
|
|
assert 'littlefs.bin' in names(all_bin)
|
|
littlefs = next(r for r in all_bin if pathlib.Path(r['primary_path']).name == 'littlefs.bin')
|
|
assert littlefs['esp_chip'] is None
|
|
assert littlefs['esp_kind'] == 'not_esp_image'
|
|
|
|
# Filtering to a specific chip still returns only that chip's ESP images (non-ESP parts are shown unfiltered).
|
|
_, total = search_esp_firmware(chip='ESP32-H2')
|
|
assert total == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flasher_search_api(async_client, test_session, make_esp_image, refresh_files):
|
|
"""The /api/flasher/search endpoint filters by chip and supports the path filter."""
|
|
make_esp_image('software/marauder/esp32.bin', chip_id=0x0000, kind='app')
|
|
make_esp_image('software/marauder/s3.bin', chip_id=0x0009, kind='app')
|
|
make_esp_image('software/other/s3.bin', chip_id=0x0009, kind='app')
|
|
|
|
await refresh_files()
|
|
|
|
# Filter by chip.
|
|
request, response = await async_client.post('/api/flasher/search', json=dict(chip='ESP32-S3'))
|
|
assert response.status_code == HTTPStatus.OK
|
|
got = sorted(fg['primary_path'] for fg in response.json['file_groups'])
|
|
assert got == ['software/marauder/s3.bin', 'software/other/s3.bin']
|
|
assert response.json['totals']['file_groups'] == 2
|
|
|
|
# Filter by chip AND path (the flasher's detect + folder filter combined).
|
|
request, response = await async_client.post('/api/flasher/search',
|
|
json=dict(chip='ESP32-S3', path='marauder'))
|
|
assert response.status_code == HTTPStatus.OK
|
|
got = [fg['primary_path'] for fg in response.json['file_groups']]
|
|
assert got == ['software/marauder/s3.bin']
|
|
assert response.json['file_groups'][0]['esp_chip'] == 'ESP32-S3'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flasher_saved_configs_api(async_client, test_session, test_directory):
|
|
"""Saved firmware configurations can be created, listed, replaced, deleted, and are persisted to YAML."""
|
|
# Initially empty.
|
|
request, response = await async_client.get('/api/flasher/configs')
|
|
assert response.status_code == HTTPStatus.OK
|
|
assert response.json['configurations'] == []
|
|
|
|
# Save a multi-part configuration (like a Meshtastic T-Deck).
|
|
body = dict(name='T-Deck MUI', erase_all=True, files=[
|
|
dict(path='software/firmware-t-deck-tft.bin', address='0x0', name='firmware-t-deck-tft.bin', size=100),
|
|
dict(path='software/littlefs-t-deck-tft.bin', address='0xc90000', name='littlefs-t-deck-tft.bin', size=200),
|
|
])
|
|
request, response = await async_client.post('/api/flasher/configs', json=body)
|
|
assert response.status_code == HTTPStatus.CREATED
|
|
|
|
# It is listed with its files and offsets.
|
|
request, response = await async_client.get('/api/flasher/configs')
|
|
configs = response.json['configurations']
|
|
assert len(configs) == 1
|
|
assert configs[0]['name'] == 'T-Deck MUI'
|
|
assert configs[0]['erase_all'] is True
|
|
assert [f['address'] for f in configs[0]['files']] == ['0x0', '0xc90000']
|
|
|
|
# It is persisted to flasher.yaml.
|
|
assert (test_directory / 'config/flasher.yaml').is_file()
|
|
|
|
# Saving the same name replaces it (still one configuration).
|
|
body2 = dict(name='T-Deck MUI', files=[dict(path='software/other.bin', address='0x0')])
|
|
request, response = await async_client.post('/api/flasher/configs', json=body2)
|
|
assert response.status_code == HTTPStatus.CREATED
|
|
request, response = await async_client.get('/api/flasher/configs')
|
|
configs = response.json['configurations']
|
|
assert len(configs) == 1
|
|
assert [f['path'] for f in configs[0]['files']] == ['software/other.bin']
|
|
|
|
# An empty name is rejected.
|
|
request, response = await async_client.post('/api/flasher/configs', json=dict(name=' ', files=[]))
|
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
|
|
|
# Delete it (name is URL-encoded).
|
|
request, response = await async_client.delete('/api/flasher/configs/T-Deck%20MUI')
|
|
assert response.status_code == HTTPStatus.NO_CONTENT
|
|
request, response = await async_client.get('/api/flasher/configs')
|
|
assert response.json['configurations'] == []
|
|
|
|
# Deleting a missing configuration 404s.
|
|
request, response = await async_client.delete('/api/flasher/configs/nope')
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|