wrolpi/modules/flasher/config.py
Roland c95afc7f62 Add ESP32 firmware flasher
Browser-based ESP32/ESP8266 flasher (esptool-js + Web Serial); nothing is
uploaded to WROLPi, the flash is written straight to the device over USB.

Firmware sources (themed, tabbed): a saved set, the WROLPi media directory,
or a local file. Local files can be dropped anywhere on the page, which jumps
to the "Add from computer" dropzone. The media picker searches .bin files by
path and can detect the connected chip to filter firmware to matching images.

- Indexed file_group.suffix + path filter for fast .bin search (alembic
  migration backfills via split_path_stem_and_suffix)
- modules/flasher: chip detection from ESP image headers, /search endpoint,
  and flasher.yaml saved configurations (/configs CRUD)
- Flash progress modal with ETA, connect timeout, boot-mode hint, auto-
  scrolling logs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:34:04 -06:00

86 lines
3.1 KiB
Python

"""A YAML config storing the user's saved firmware flashing configurations.
Each saved configuration is a named set of firmware files (media-relative paths) and their flash offsets, plus
whether to erase the flash first. This lets a user configure a multi-part flash once (e.g. a Meshtastic T-Deck:
firmware at 0x0, littlefs at 0xc90000) and re-load it later with one click.
"""
from dataclasses import dataclass, field
from typing import List
from wrolpi.common import ConfigFile, logger
logger = logger.getChild(__name__)
@dataclass
class FlasherConfigValidator:
version: int = None
configurations: list = field(default_factory=list)
class FlasherConfig(ConfigFile):
file_name = 'flasher.yaml'
default_config = dict(
version=0,
configurations=[],
)
validator = FlasherConfigValidator
def import_config(self, file=None, send_events=False):
super().import_config(file, send_events)
# YAML-only config (no database); a successful read is a successful import.
self.successful_import = True
@property
def configurations(self) -> List[dict]:
return list(self._config.get('configurations', []))
@configurations.setter
def configurations(self, value: List[dict]):
# ConfigFile.update() validates, stores, and triggers a background save.
self.update({'configurations': value})
def save_configuration(self, name: str, files: List[dict], erase_all: bool = False) -> dict:
"""Add or replace (by name) a saved configuration. Returns the stored configuration."""
name = (name or '').strip()
if not name:
raise ValueError('Configuration name is required')
# Keep only the fields we persist for each file.
stored_files = [
dict(
path=f.get('path'),
address=f.get('address'),
name=f.get('name'),
size=f.get('size'),
)
for f in files
]
configuration = dict(name=name, erase_all=bool(erase_all), files=stored_files)
# Replace any existing configuration with the same name, then keep the list sorted by name.
configurations = [c for c in self.configurations if c.get('name') != name]
configurations.append(configuration)
configurations.sort(key=lambda c: (c.get('name') or '').lower())
self.configurations = configurations
return configuration
def delete_configuration(self, name: str) -> bool:
"""Delete a saved configuration by name. Returns True if one was removed."""
configurations = self.configurations
remaining = [c for c in configurations if c.get('name') != name]
if len(remaining) == len(configurations):
return False
self.configurations = remaining
return True
FLASHER_CONFIG: FlasherConfig = FlasherConfig()
# Test override (see get_flasher_config).
TEST_FLASHER_CONFIG = None
def get_flasher_config() -> FlasherConfig:
global TEST_FLASHER_CONFIG
if isinstance(TEST_FLASHER_CONFIG, ConfigFile):
return TEST_FLASHER_CONFIG
return FLASHER_CONFIG