Web UI (ui/) — M5¶
A Preact + TypeScript single-page app built with Vite, served by
espos_httpd from the LittleFS storage partition. It talks only to the
versioned REST API in rest-api.md and gets live state over the SSE
stream — no polling, no coupling to firmware internals.
Pages: Status (WiFi, SignalK, device, access, last crash), WiFi (join /
scan / saved networks / portal), SignalK (token state and actions,
delta stream, discovered servers, manual server), Config (every
namespace rendered from GET /config/schema: types, ranges, enums,
secrets, restart-required marker, export/import JSON, reset section),
Logs (live log ring with filter, follow, download, runtime log level),
OTA (running image and slot state, confirm/rollback, manifest check
with the available build, install with progress, install from a URL,
update-source settings). Plus the login page, which is not a tab: it
replaces the app while the device wants a key this browser does not hold.
Login¶
Authentication is the device's decision (security.md): with
httpd.api_key unset there is no login and the UI behaves as it always did.
Once a key is set, the shell asks GET /api/v1/auth/status at startup and
shows the login page when the answer is "required, not authenticated"; a
401 on any later call (the session expired, the device rebooted, the key
changed) brings the page back. The page posts the key to /api/v1/auth/login
and gets the espos_sid cookie, which fetch() and EventSource then send
by themselves — every call goes out with credentials: "same-origin". After
a login the app is mounted afresh with a new event stream, so nothing has to
retry what it failed to load while logged out. Log out in the header
posts /api/v1/auth/logout.
Two conveniences on the Config page and Status page: httpd.api_key has a
Generate button (20 characters from an alphabet without look-alikes,
shown once in full — write it down; after Save every browser, the designer
and any script need it), and Status has an Access card saying whether the
device is open or keyed and how this very page got in (cookie, Bearer, the
setup portal). A firmware built with CONFIG_ESPOS_HTTPD_AUTH_REQUIRED=y
and no key yet answers 403 auth_unconfigured; the login page then explains
that the key is set from the portal network.
In development the page is not served by the device it talks to — the Vite
dev server proxies /api to a device or to the mock — so Origin never
equals Host and the device would refuse every cookie-authenticated save.
npm run dev therefore keeps the key in sessionStorage and sends it as
Authorization: Bearer on every call as well (the cookie is still taken, for
EventSource). The built bundle on a device uses the cookie only. A bundle
built for another origin sets VITE_ESPOS_BASE to an absolute API URL and
gets the same Bearer behaviour.
Working on it — no hardware needed¶
cd ui
npm ci
npm run dev # http://localhost:5173, API mock started in-process
ESPOS_API=http://192.168.0.118 npm run dev # proxy /api to a real device…
ESPOS_API=http://127.0.0.1:<port> npm run dev # …or to the host harness (test/host/espos_httpd_test)
mock/server.mjs (node, zero deps) implements the API contract with a
simulated WiFi state machine, discovery + token flow, a log ring, SSE, and
the authentication (/auth/*, /system/ping, Bearer and cookie, the
throttle; set httpd.api_key on the Config page to see the login page), and
regenerates the config schema from the real descriptors via
components/espos_config/tools/espos_gen_config.py when python3 is present. It is the reference
"device" for UI development; when the API changes, change the mock and the
docs together.
npm run build type-checks (strict, noUncheckedIndexedAccess), bundles
(~18 KiB gzipped in total) and writes components/espos_httpd/ui-dist/ — every file gzipped as
<name>.gz, nothing else. The root CMakeLists.txt turns components/espos_httpd/ui-dist
into build/storage.bin (littlefs_create_partition_image,
FLASH_IN_PROJECT), so idf.py flash writes it. Without a UI build the
firmware still builds and serves the embedded placeholder page (GET
/system/info → ui_storage, and the boot log says so).
Serving rules (firmware side)¶
<path>.gzfirst, sent withContent-Encoding: gzipregardless ofAccept-Encoding(all browsers accept it;curl --compressed)./assets/*is content-hashed →Cache-Control: immutablefor a year;index.html→no-cache, so a new bundle is picked up on reload.- Unknown extension-less paths →
index.html(SPA routing withhistory.pushState); unknown files with an extension → JSON 404. - Static serving is the 404 fallback for
GET, not a wildcard handler, so API handlers registered later by other components are never shadowed. ..and//in a path are refused.
Design notes¶
- State lives in tiny subscribable stores fed by one
EventSource(src/api.ts); pagesuseStore()what they show.EventSourcereconnects on its own (retry: 3000), the header shows the link state. A stream the device refused (a401after the session ended) is closed by the browser for good;api.tsnotices, asks/auth/status, and either reopens it or hands over to the login page. authStore(open | ok | login | unconfigured) is whatmount.tsxrenders from: the app, keyed by session so a fresh login remounts it, or the login page. Every helper inapi.tsflips it tologinon a401.- No component library, no router package: a 30-line history router and
~120 lines of CSS with light/dark via
prefers-color-scheme. - The Config page is generic: adding a key to a descriptor JSON adds a
field. Secrets render as set/not-set with Set/Change/Clear (the sentinel
********is never sent back as a value). Numbers/enums/booleans get the matching control;x-espos-unit, ranges andmaxLengthare shown as hints;x-espos-restartRequiredkeys carry ↻ and a save of one offers a reboot. - Destructive actions (reboot, factory reset, forget network/token, erase core dump, reset section) confirm first.
Log ring (espos_log)¶
espos_log_init() hooks esp_log_set_vprintf: each console line is also
kept in a byte ring (CONFIG_ESPOS_LOG_RING_SIZE, default 16 KiB) as
[u16 len][text], colour codes stripped, sequence-numbered, oldest
overwritten first; the previous vprintf still runs, so the console is
unchanged. Log v2's prefix/message/newline calls are gathered into one
record; a message longer than CONFIG_ESPOS_LOG_LINE_MAX is truncated
and closed. A FreeRTOS timer (500 ms) publishes the logs SSE event when
new lines arrived — never from inside the logging call, so nothing can
recurse or block a logger on a slow SSE client. main.c calls it before
anything else to catch the boot log; espos_httpd_start() calls it too
(idempotent).
Pages from a firmware¶
The page list is a registry, not a constant: ui/src/routes.ts holds the core
pages and registerPage() adds to it, the same shape as
espos_httpd_register() and espos_config_add_descriptor() on the C side.
A firmware that wants its own page keeps a small Vite project of its own and uses espOS's entry point:
// <firmware>/ui/src/main.tsx
import { registerPage, mount } from "../../espos/ui/src/mount";
import { TanksPage } from "./pages/tanks";
registerPage({ path: "/tanks", title: "Tanks", page: TanksPage, order: 35 });
mount();
order places the tab (core pages sit on 10–70); registering an existing
path replaces that page, which is how a firmware puts its own Status screen
in front. Build it the same way espOS builds its own — npm run build to
its own dist-gz/, then point espos_project_ui_partition(DIR ...) at it.
Pages that are not always there¶
A route may declare available(), awaited once at startup; the tab appears
only if it resolves true. The BLE page uses it:
endpointExists() treats a 404 as "the component is not in this build" and
anything else — a 500, a dropped connection — as no evidence either way, so a
momentarily busy device does not lose a tab. The shell paints the ungated
pages first and adds the rest when they answer, rather than holding a blank
page while it decides.
The Flow page does the same against /flow, so it appears on a firmware that
builds espos_flow and nowhere else — neither of espOS's own consumers links
it, and neither shows the tab. Two pages, two components, no firmware
declaring anything.