wrolpi/modules/flasher/api.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

55 lines
2.2 KiB
Python

import asyncio
from http import HTTPStatus
from urllib.parse import unquote
from sanic import Blueprint, Request, response
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from modules.flasher import lib, schema
from modules.flasher.config import get_flasher_config
from wrolpi.api_utils import json_response
flasher_bp = Blueprint('Flasher', '/api/flasher')
@flasher_bp.post('/search')
@openapi.definition(
summary='Search .bin firmware and filter to ESP images (optionally for a specific chip)',
body=schema.FlasherSearchRequest,
)
@validate(schema.FlasherSearchRequest)
async def post_flasher_search(_: Request, body: schema.FlasherSearchRequest):
# Reading many firmware headers touches the disk; run off the event loop.
file_groups, total = await asyncio.to_thread(
lib.search_esp_firmware, body.chip, body.path, body.limit)
return json_response(dict(file_groups=file_groups, totals=dict(file_groups=total)), HTTPStatus.OK)
@flasher_bp.get('/configs')
@openapi.definition(summary='List saved firmware flashing configurations')
async def get_flasher_configs(_: Request):
return json_response(dict(configurations=get_flasher_config().configurations), HTTPStatus.OK)
@flasher_bp.post('/configs')
@openapi.definition(
summary='Save (add or replace by name) a firmware flashing configuration',
body=schema.FlasherSaveConfigRequest,
)
@validate(schema.FlasherSaveConfigRequest)
async def save_flasher_config(_: Request, body: schema.FlasherSaveConfigRequest):
try:
configuration = get_flasher_config().save_configuration(body.name, body.files, body.erase_all)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.BAD_REQUEST)
return json_response(dict(configuration=configuration), HTTPStatus.CREATED)
@flasher_bp.delete('/configs/<name:str>')
@openapi.definition(summary='Delete a saved firmware flashing configuration by name')
async def delete_flasher_config(_: Request, name: str):
name = unquote(name)
if get_flasher_config().delete_configuration(name):
return response.empty()
return response.json({'error': f'No saved configuration named {name!r}'}, HTTPStatus.NOT_FOUND)