Skip to content

feat: DALI configurator: wb-mqtt-dali under Pyodide, blocking Modbus driver, real-hardware verified - #96

Draft
evgeny-boger wants to merge 80 commits into
masterfrom
feat/dali-pyodide
Draft

feat: DALI configurator: wb-mqtt-dali under Pyodide, blocking Modbus driver, real-hardware verified#96
evgeny-boger wants to merge 80 commits into
masterfrom
feat/dali-pyodide

Conversation

@evgeny-boger

@evgeny-boger evgeny-boger commented Aug 29, 2026

Copy link
Copy Markdown
Member

Встраивает DALI-конфигуратор — страницу DALI из homeui и немодифицированный демон wb-mqtt-dali под Pyodide — в автономный WASM-редактор устройств.

Как устроено

  • Демон работает в web-воркере (на file:// — инлайном) поверх loopback-брокера MQTT. Страница homeui монтируется без изменений: её модуль mqttClient подменяется на этапе сборки; runtime переживает переключение вкладок и переподключается идемпотентно.
  • Драйвер. Под демоном — BlockingDaliDriver: перемотать указатель очереди шлюза, записать кадры одним fc16 (до 16 слотов), опросить последний регистр ответа, прочитать ответы пачкой. Поведение протокола (потребление в порядке указателя, очистка регистра ответа, rewind не повторяет отправку) промерено на реальном WB-DALI и закреплено тестами на регистровом симуляторе шлюза.
  • Мемо-кэш (memory_cache.py): банки памяти и «настроечные» ответы (сцены, группы, уровни) запоминаются по случайному адресу, которым ответило устройство; восстановленному мемо доверяем только после сверки QUERY RANDOM ADDRESS (3 кадра); инвалидация — командами коммишенинга, а для настроек — любой send-twice записью конфигурации. Все три обхода питаются одним classify(). Открытие страницы устройства из мемо: −54 % кадров на идентификацию, сцены и уровни — целиком из кэша. Цвет DT8 сознательно остаётся «на проводе»: его ответ разделён между backward-кадром и DTR0 устройства.
  • Кольцо монитора шины опрашивается всегда — это единственный источник событий датчиков DALI-2 — в трёх темпах (100 мс при открытом мониторе / 250 мс в простое / 1 с на шине без устройств DALI-2) и обнуляется при инициализации, чтобы кадры прошлой сессии реального модуля не выглядели свежим трафиком.
  • Режим симуляции: сценарий, все шлюзы которого сидят на зарезервированном slave id 250, обслуживается встроенной моделью WB-DALI — e2e-сьют поднимает всю страницу (Pyodide + демон + homeui) за ~7 с на тест, без железа.
  • Живучесть. Результаты сканирования, конфигурация демона, членство в группах и мемо переживают перезагрузку; очищенный профиль браузера восстанавливает запись о шлюзах из снапшота сканирования; неудачная загрузка показывает понятную подсказку (serial или сеть) и Retry; сохранённая установка сбрасывается только после двух неудачных загрузок подряд — с сообщением в панели загрузки.

Подробности: architecture/dali-in-the-browser.md (протокол, измерения) и architecture/dali-wasm-arc42.ru.md (полный arc42-каталог из 20 проектных решений).

Проверено на железе (WB-DALI @17, светильники Skydance DT8, датчик Tridonic MSensor)

  • Modbus-скан → определение шлюза → загрузка DALI → полные сканирования шины, включая коммишенинг DALI-2.
  • Страницы устройств с реальной идентификацией по банкам памяти; панели управления группами и вещанием на шину; Identify; спонтанные LightEvents датчика попадают в контрол «Освещённость» (после исправления схемы адресации событий — SOFT-7398); повторное открытие страницы — из мемо.
  • Производительность: 47,8 мс на запрос с ответом при измеренном на этой прошивке поле 46 мс на стороне DALI; независимость от скорости порта подтверждена повторным замером на 9600.
  • Офлайн-сборка одним файлом (25 МБ; шрифты и каталог прошивок внутри, wheels Pyodide сверяются с sha256 из pyodide-lock) загружается с полностью выключенной сетью.

Тесты

  • cd wasm/python && python3 -m pytest — 119 тестов: протокол драйвера, коммишенинг (DALI и DALI-2), устойчивость к ошибкам шлюза, доверие и инвалидация мемо, базлайн кольца, темп монитора, персистентность и посев, регрессионные тесты на каждый найденный на железе баг (стадия CI «DALI runtime tests»).
  • cd wasm && npm test — 17 vitest-тестов: очередь и переподключение mqtt-клиента, правило двух осечек при загрузке, персистентность шлюзов и восстановление после очистки профиля (стадия CI «Frontend unit tests»).
  • npm run test:e2e — playwright, включая dali-sim.spec.ts: вся страница против симулированного шлюза.
  • По ветке прошло ревью по 9 аспектам (безопасность, стабильность, конвенции, документация, регрессии, покрытие, упрощение, правила проекта, архитектура); все замечания исправлены в ветке.

Связанные: wirenboard/homeui#1223 (половина в homeui), SOFT-7397, SOFT-7398.

Draft: ждёт ревью командой прошивки допущений о протоколе очереди.

Что переехало в wb-mqtt-dali (стек #226–#228)

Симулятор шины, регистровый драйвер (gateway link), мемо банков памяти и швы для встраивающего хоста теперь живут в wb-mqtt-dali (wirenboard/wb-mqtt-dali#226, #227, #228) и приезжают сюда вендорингом. В wbdali_browser осталось только браузерное: транспорт через WebSerial, персистентность, RPC-мост. Вендоринг штампует точные коммиты (vendor/.refs) и перекачивает дерево, когда ветка ушла вперёд; до мержа стека закреплён за soft-dali-host-apis.

Порт стал в ~20 раз быстрее

Каждый Modbus-обмен через WASM-порт стоил ~280 мс: TWASMPort::ReadFrame игнорировал таймауты wb-mqtt-serial и предикат «кадр собран», а serial.js ждала весь свой reply-таймаут и переоткрывала USB-порт на каждую запись. Теперь чтение — цикл по чанкам с таймаутами вызывающего (как в TFileDescriptorPort), порт остаётся открытым, а сбой потока/переключение порта лечится переоткрытием. Замерено на WB-DALI @115200: обмен ~12 мс, страница DALI грузится за 9 с, страница устройства — за 4 с, 374 поля MSensor — за 24 с.

Скриншоты

Интерфейс на русском, симулированная шина (slave 250), сняты e2e-ригом (wasm/e2e/zz-shots.spec.ts).

Редактор: WB-DALI в списке устройств, кнопка «Конфигуратор DALI» в тулбаре
Редактор устройств

Страница DALI: дерево шин после автосканирования новой установки
Дерево устройств DALI

🤖 Generated with Claude Code

https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu

Evgeny Boger and others added 30 commits August 27, 2026 19:59
Groundwork for embedding the homeui DALI configuration UI in the standalone
editor: the daemon has to run in a browser, and there is no MQTT broker, no
wb-mqtt-serial and (here) no hardware.

The daemon reaches the outside world through exactly one object — the
aiomqtt.Client handed to MQTTDispatcher — so that is the only seam used:

- broker.py     an in-process MQTT broker with wildcards, retained messages
                and self-delivery, plus an aiomqtt-shaped client on top
- serial_service.py  serves wb-mqtt-serial's config/Load and port/Load, and
                publishes the device controls the DALI driver reads answers from
- sim/          a WB-DALI module emulated at the Modbus register level, over a
                DALI bus of python-dali fake control gear

Emulating at the register level keeps everything above it production code:
frame encoding, queue batching, the commissioning binary search and short
address assignment all run unmodified. python-dali's fake gear is missing
QUERY SHORT ADDRESS and a factory random address, which commissioning depends
on; both are filled in by a subclass rather than by patching the vendored copy.

Python sources are vendored verbatim by scripts/fetch-python-sources.sh, with
browser stubs for aiomqtt, websockets and wb_common under wasm/python/shims.

56 tests, run under CPython so the stack stays debuggable outside the browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
DaliRuntime starts wb-mqtt-dali the way main.py does, minus what a browser
cannot give it: no broker connection, no signal handlers, no journal. Boot
order is load-bearing — the dispatcher must run before anything subscribes, and
the emulated wb-mqtt-serial must have published its retained endpoint marker
before Gateway.start(), which waits five seconds for it and then gives up.

Paths are root-relative so the daemon's hardcoded /etc and /usr/share layout can
be pre-seeded into Pyodide's empty MEMFS, and so the tests stay out of the
developer's real filesystem. The one path the daemon does not take as an
argument is loaded onto the class attribute it caches in, so its open() never
runs.

Editor/GetList, GetBus, GetGateway, ScanBus and the retained commissioning
progress topic all answer against the simulated bus, and a completed scan is
written back to the config file.

Also fixes the bus monitor ring encoding to the layout BusMonitorSlot actually
parses, and stops echoing the gateway's own frames into it: the control is
`monitor_sporadic_frame`, and the driver already sees its own traffic through
the reply registers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
The DALI configuration page from homeui now runs inside the standalone editor,
reached at #dali or from a button next to "Add device". It is the homeui page
unmodified: it needs one prop and no React context, so the work is in the three
shims it is built from — an MQTT client, an MQTT-RPC proxy and a
`whenMqttReady` promise, all over the in-browser broker.

wb-mqtt-dali runs under Pyodide in a web worker. A bus scan is thousands of
DALI transactions driven by a Python event loop; on the main thread that would
fight React for the same microtask queue.

Packaging notes:

- Pyodide's byte assets are served to it from memory through a fetch shim on a
  sentinel origin, so the same code path works whether the bytes came off the
  network or out of an inlined offline bundle.
- pyodide.asm.mjs has a dead `new URL("pyodide.asm.wasm", import.meta.url)` that
  vite still sees, emitting a second copy of the 9.6 MB wasm; a pre-transform
  rewrites it away.
- The jsonschema stack is not pure Python — rpds-py is a Rust extension — so its
  wheels are taken from the installed pyodide's own lock file, which is what
  keeps the ABI matched. They are cached, so rebuilds are offline.

Verified in Chromium against the simulated installation: the gateway tree
loads, a bus scan finds all four simulated luminaires and assigns short
addresses to the two factory-fresh ones, and the device form renders DT6 type,
GTIN, firmware and hardware versions read from the simulated memory banks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
Pyodide's filesystem lives in memory, so the config the daemon writes after a
bus scan was gone on the next page load and the installation looked untouched.

Two things have to come back together, and only one of them is the config: the
short addresses commissioning programmed into the control gear live in the
simulated bus. Restoring the config alone would describe addressed devices on a
bus that had gone factory-fresh again. A saved config is also only honoured when
it names the same gateways as the scenario, because `_update_gateways` silently
deletes any gateway the serial config does not list.

Rather than polling the file, the runtime watches the two events that can follow
a config write — an `Editor/*` reply and a commissioning state change — and
reports the file when its contents actually differ.

Also:
- route worker messages by the subscription that produced them. Two filters can
  match one topic (the RPC reply wildcard and a device topic), and guessing by
  first match delivered to the wrong handler and starved the other.
- rebuild the Python bundle from `npm run dev`/`build`, and on edits under
  `python/` in dev. The worker loads Python from a tarball, so without this an
  edit surfaced as a stale-code error deep inside Pyodide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
Opening a device in the editor failed: `Editor/GetDevice` reads every parameter
a unit's device types imply, in batches, and a batch fails as a whole if any one
query goes unanswered — so a gap in the simulated gear did not degrade the form,
it emptied it.

python-dali's fake control gear was written for its own tests and never had to
serve a configuration UI, so it leaves out the standard gear variables of IEC
62386-102 (power-on level, system failure level, fade time and rate, fast fade
time), the DT6 dimming curve, and the DT8 queries that report which colour
features a unit has and which colour type is active. All are added in the
subclass, with the reset values from Table 9 and the setters that go with them,
rather than by patching the vendored copy.

Found by tracing which queries the simulated bus left unanswered during a real
`GetDevice`, which turned six round trips of one-at-a-time debugging into one.

A DT6 and a DT8 device now both load in full: identity out of the simulated
memory banks, gear variables, colour temperature limits, groups and scenes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
Three problems a review found, all invisible from the dev server:

The offline single-file build could not have worked. A page opened over
`file://` cannot start a module worker at all, and vite emitted the worker as a
separate 13 MB chunk that vite-plugin-singlefile does not inline and the shipped
artifact does not carry. The runtime is now host-agnostic: a worker normally, on
the main thread when there is no worker to be had. Vite rewrites `new Worker(new
URL(...))` during transform, before dead-code elimination, so the call lives in
its own module that the offline build aliases away — guarding it at runtime is
not enough. Pyodide's assets are inlined by the existing offline-embed plugin,
alongside the ones already there.

Measured: the single file is 24 MB, boots from file:// and completes a full bus
scan in 3.5 s on the main thread.

The service worker precached the whole assets directory, which now included
~13 MB of Python runtime, in the bucket whose install must succeed. Every first
visit to the Modbus editor would have paid for it, and one failed request would
have rejected the install and lost offline support for the editor too. The DALI
runtime moves to the existing best-effort bucket.

`npm run build` needed `wasm/python/vendor`, which is gitignored and so absent in
CI and on a fresh checkout. The bundle step now fetches it.

Also from the review, each with a regression test that fails without its fix:

- A publish issued in the same tick as a subscribe was dropped: the broker
  matches subscriptions as it delivers, and the subscribe was deferred to the
  event loop.
- Simulated writes to one module could be transmitted out of order once bus time
  was charged, because nothing serialised them — so an EnableDeviceType ending
  one batch could be overtaken by the DT command starting the next.
- Two concurrent `DaliRuntime.rpc` calls answered each other: the id was
  hardcoded and correlation was by a shared reply topic.
- A saved installation the runtime could not rebuild failed every subsequent
  load with no way out from inside the page; a boot failure now clears it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
The simulated network and the new WasmSerialTransport are the two
implementations of the same ModbusTransport; everything above them — the daemon,
the emulated wb-mqtt-serial, the web UI — is identical either way, and a
dropdown in the DALI header picks between them.

What is actually new is the polling policy. On a controller, wb-mqtt-serial
subscribes to the gateway's sporadic-event stream and publishes each reply
register as it changes. A browser has one WebSerial link and no event channel,
so after writing a batch into the send queue this reads back exactly the reply
slots that batch occupied. Only those: a reply register keeps its value until
the slot is reused, so republishing one the driver has already consumed would
resolve a later command with an earlier command's answer.

There was no WB-DALI module to test against, so `port/Load` is stubbed in the
tests with one that reads and writes the simulated gateway's registers. That
leaves the slot arithmetic and the polling under test; the Modbus framing below
it is the same C++ code the Modbus editor already uses.

Two things learned by pointing it at nothing, in the browser:

- Hardware mode must not start before a serial port is chosen. Every DALI
  command reopens the port, the browser's chooser needs a user gesture it will
  not get, and the daemon's retries keep new commands coming — the page died
  under ERR_INSUFFICIENT_RESOURCES. A port gate now stands in front, the same
  choice the Modbus editor makes, sharing the same port.
- A module that stops answering is reported unreachable on `/meta/error` after
  three consecutive failures, which is how the driver learns to fail its pending
  traffic at once instead of waiting out 1.5 s per command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
The DALI traffic no longer goes through MQTT at all.

`WBDALIDriver` is built to be fast on a controller: it batches up to sixteen
frames into the gateway's send queue, lets wb-mqtt-serial stream the results
back as sporadic Modbus events, and reassembles them by matching MQTT reply
topics to queued futures. Reproducing that in a browser meant emulating
wb-mqtt-serial's whole MQTT surface — a `port/Load` RPC, sixteen reply-register
topics per bus, a four-slot bus monitor ring, `/meta/error` availability — for a
transport that has one WebSerial link and one request in flight at a time.

`BlockingDaliDriver` is what is left when you take that away: write a frame into
a queue slot, poll the matching reply register, return the answer. It keeps the
interface the daemon uses, so `ApplicationController`, commissioning and every
device class run on it unmodified — swapping the class name the controller
constructs is the entire adaptation.

What went with it: the wb-mqtt-serial emulator (reduced to the one `config/Load`
RPC `Gateway.start()` refuses to boot without), the reply and monitor register
publishing, the availability topic, the queue batching, and the transport's own
reply polling. The loopback broker stays, but only as the daemon's own bus for
the `Editor/*` RPC, the commissioning progress topic and virtual devices —
production code that publishes for a living and must not be forked.

Faster, too: a bus scan in the browser went from about 4 s to 2.5 s.

One firmware behaviour is assumed and could not be checked without hardware:
writing a queue slot clears its reply register until the frame is transmitted.
It is the only reading under which a reply register is usable — one that kept
its previous value would be indistinguishable from a fresh identical answer, and
identical answers are the norm. Slots are used round-robin so a stale value
would at least be sixteen commands old.

Also adds DALI-2 control devices, which the simulator could not represent at
all: python-dali's fake device models instances, DTRs and memory banks but no
addressing, so the input-device half of every scan came back empty. The
commissioning state machine of IEC 62386-103 §11 is implemented alongside the
per-instance settings the editor reads — and its INITIALISE parameter is the
reverse of the control-gear one, which is what made the first attempt find
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
Two problems that only showed up in the offline build, where the runtime runs on
the main thread instead of in a worker. Both made the tab unresponsive for
minutes; neither was visible from the dev server.

A simulated DT8 unit powered up reporting colour temperature 0. That is not a
colour — the editor converts mireds to kelvin by dividing into them — so every
pass of the daemon's poll loop raised, and a failing poll is retried about once
a millisecond. Real gear powers up at a colour, so the simulated gear now does
too, configurable from the scenario. A regression test watches a scanned bus for
two seconds and fails on any error at all, because the symptom of getting this
wrong is not a wrong value, it is a hot loop.

The simulator also yielded to the event loop on every register operation. Under
Pyodide each yield is a `setTimeout`, which browsers clamp to about 4 ms once
nested, so a scan that took seconds in a worker took minutes on the main thread.
Yielding by elapsed time instead — at most one animation frame of uninterrupted
work — keeps the page responsive without paying a clamped timer per register
access.

Measured after the fix: a full bus scan of four luminaires and a wall switch
takes 2.3 s at 59 fps in the worker, and 2.8 s at 47 fps in the offline single
file running inline.

Adds architecture/dali-in-the-browser.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
The boot panel is unmounted once the page loads, so anything the daemon logged
afterwards went nowhere — a feature that fails at runtime, like the Lunatone
websocket bridge a browser cannot host, failed silently with the toggle still
reporting success. Log lines that look like errors now reach the console.

Also: the scenario types in TypeScript described a shape the Python side never
accepted, which was actively misleading to anyone passing one; a diagnostics
round trip nothing requested and nothing handled; an unhandled rejection on a
boot failure; and a translation key nothing rendered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
From a review of the redesigned stack. The first two were real defects in the
offline build, which is the one that runs the daemon inline:

Leaving the DALI view never stopped an inline runtime. Terminating a worker
takes its interpreter with it, but the inline one kept polling the bus — about
27 frames a second per commissioned bus — and its config watcher kept writing
over the next runtime's. Every #dali → back → #dali round left another one
behind. Verified: after leaving the view the page is back to 62 fps and the
stored installation stops changing.

Both transports shared one saved installation. Scanning in simulation and then
switching to hardware had the daemon poll short addresses that only ever existed
in the simulation; the reverse left the config describing devices the simulated
bus does not have, permanently, because a hardware snapshot returns the scenario
unchanged. One slot per transport.

Also restored or corrected, against the driver this replaced:

- Overheat backoff. The original waits out a cooldown before every batch when
  the gateway reports overheating; without it the daemon's poll and retry loops
  hammer a module that is already too hot.
- An unrecognised status now reports `UnknownResponseStatus` rather than being
  folded into "no transmission", and a command with no response class gets its
  backward frame rather than an empty response.
- A single-register write goes out as function 6, which is what the queue
  pointer reset is.
- Retained messages reached a new subscriber twice, and were re-delivered to
  every other subscriber whose filter matched.
- The simulated bus kept every frame it had ever seen.

Line settings and slave ids for hardware mode now come from the scenario instead
of being hardcoded at 9600 8N2 with slave id 1.

Adds a CI stage for the Python suite — it was the stated place where debugging
happens and nothing in the pipeline ran it. Unverified in this environment:
Docker Hub is unreachable from here, so the python:3.13 image could not be
pulled; the dependency list is derived from the actual imports.

Documents what the blocking driver gives up — DALI-2 events and foreign bus
traffic, both of which arrive through the sporadic monitor ring it deliberately
does not read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
Answering "why can't the bus monitor be polled too?" — it can, and now is.

A reply register answers a frame we sent. Traffic we did not send reaches the
gateway's four-slot monitor ring instead, and reading that ring is the same
shape as everything else here: read the registers, hand each new slot to the
daemon's own BusMonitorFrameHandler, which does the decoding, the reordering by
frame counter and the gap reporting exactly as it does on a controller.

Two things follow from polling rather than being pushed. The ring is four frames
deep, so a burst arriving faster than the 100 ms interval overwrites the oldest
— not silently, the handler reports the gap. And reading it competes with DALI
traffic for the one serial link, so it is off unless asked for, wired to the
`bus_monitor_enabled` flag the bus tab already exposes.

The simulated installation gained a wall switch that presses itself every few
seconds; without traffic the daemon did not originate there is nothing for the
monitor to show. Verified in the browser: the panel fills with decoded
`ShortPress(A0, I0)` events and an advancing frame counter.

Also corrects the reason given for round-robin queue slots. It is not a hedge
against a gateway that fails to clear reply registers — that would make such a
failure later and less obvious, not safer. It is that the gateway owns a consume
pointer and transmits slots in increasing order, so always writing slot 0 would
leave it waiting to come back round; rewinding the pointer each time would work
instead, at a third Modbus transaction per frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
The DALI button now appears only when the Modbus scan has found a WB-DALI
module — the three-channel RS485-to-DALI gateway, matched on the same
`WB-DALI` / `WB-MDALI` types wb-mqtt-dali itself looks for when it reads
wb-mqtt-serial's config. Before, it was offered on a machine with nothing
plugged in, which promised a configurator for a bus that did not exist.

What the scan found is also what the daemon is pointed at: the module's Modbus
address becomes its `wb-dali_<slave id>` gateway id, and the line settings it
answered on become the ones the DALI traffic uses. Those were hardcoded at
9600 8N2 with slave id 1, taken from the simulation scenario, which was only
ever right by coincidence.

Arriving from a found gateway now opens connected to it rather than in
simulation, unless the operator has chosen otherwise — which meant separating
"never chose" from "chose simulated" in the stored preference. The port chooser
is skipped when the scan has already opened a port, instead of asking again.

The simulated bus stays reachable at #dali with nothing connected, which is what
the tests and the demo run against; the connected option is simply not offered
until there is something to connect to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
The bus monitor was an ad-hoc panel inside the bus tab because the submodule
was pinned at v2.206.5. On master it is a global console panel: `DaliGlobalStore`
registers each enabled bus as a tab, and the layout mounts `ConsolePanel`
outside the page. This bumps homeui 128 commits to get it.

Master has finished its Vite migration, so the Angular `app/` tree is gone and
the editor had to follow: styles come from `src/assets/styles/index.css` and
homeui's two bootstrap dependencies; locales moved into `src/i18n`, whose config
now initialises the i18next singleton itself, so the editor's own strings are
added to it rather than initialising it a second time; `setReactLocale` was part
of the Angular bridge and is replaced by `changeLanguage`; the device settings
param editor moved under `config-editor/`. The `~`, `~scripts` and `~styles`
aliases are gone with the tree they pointed at.

The DALI page changed shape too. It takes no props now — it reaches its
transport through homeui's own module singletons, `daliProxy` and `mqttClient`.
That turns out to be a better seam than injection: substituting the one
`mqtt-client` module at build time puts homeui's own RPC proxy and stores on the
in-browser broker unchanged. The page also expects app chrome this editor does
not have, so the DALI view now supplies a router and the console panel the
monitor docks into.

Verified in the browser: the Modbus editor renders and behaves as before, a bus
scan finds all five simulated devices in 2.5 s, and enabling Bus Monitor opens
the docked panel with a `wb-mdali_1 / Bus 1` tab listing decoded ShortPress
events tagged `foreign` — traffic the gateway did not send, read from the
monitor ring by polling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
The UI seam is no longer three injected dependencies; homeui's DALI page uses
module singletons, so it is one substituted module. Re-measured a bus scan of
the default installation with a reliable completion signal — the commissioning
progress bar rather than the device tree, which still holds the previous scan's
devices: 1.5 s in the worker, 1.7 s inline, the page at ~55 fps.

An earlier 12.6 s reading for the offline build was a cold Pyodide warm-up
measured against that unreliable signal, not a regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
First run against real hardware — a WB-DALI at slave 17 with five Skydance
devices — found two faults, one of which no amount of simulator testing could
have caught.

`port/Load` answers in a JSON-RPC envelope, `{error, result}`, with the payload
under `result.response`. The transport read `response` at the top level, which
is not an error but an empty string: every register read returned no registers
and every command surfaced as "no response from gateway". The test double had
the same wrong shape, so the suite agreed with the bug. It now returns the real
envelope, and a short read is an error rather than an empty list.

The second fault was the slot protocol. The gateway consumes queue slots
strictly in order and waits at its consume pointer: measured on hardware, a
frame armed in slot 5 with the pointer at 0 sits there indefinitely and the
pointer never moves. A driver picking slots by its own counter therefore has to
stay in lockstep with the firmware forever, and nothing restores that invariant
once it breaks — one dropped frame parks every later write ahead of the pointer
until the counter wraps. On hardware this was 4% frame loss in bursts of
consecutive one-second timeouts. Every frame now goes into slot 0 behind a
pointer rewind, which re-establishes the invariant instead of assuming it. That
is safe because the firmware clears a slot as it consumes it, so a rewind cannot
re-send anything.

The simulator had no consume pointer at all — it transmitted whatever it was
handed — which is exactly why this reached hardware. It now models the pointer,
in-order consumption and slot clearing, so the stall is reproducible in tests.

After the change: 665 frames across an idle period and a full bus rescan, none
lost. The rescan finds all five devices and both groups in under 20 seconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
…simulated mode

A screenshot-by-screenshot walk through the real-hardware flow found four
things, all now fixed and re-verified on the WB-DALI:

The landing page was the Lunatone DALI-2 IoT Gateway emulator — homeui selects
the gateway node on entry, and that tab's only content is a toggle that starts
a WebSocket *server*, which a browser page cannot be. The gateway tab is now
substituted at build time (the same seam as the mqtt-client), showing the
module's type, Modbus address and line settings, and pointing at the bus tree.

Groups vanished on every reload. The page reads its device tree once, at
mount, and only a commissioning run rebuilds it; group membership is not in
the config — it lives on the gear, read during the daemon's device-init pass.
On a controller the snapshot is taken from a daemon that has been up for
weeks; here the daemon starts with the page and always lost the race. Boot now
holds "ready" until the configured devices have initialized (bounded at 30 s,
progress in the boot log), and a paced-transport regression test pins it.

The port gate asked the user to choose a serial port while it was actually
still probing for the already-granted one — up to tens of seconds on a slow
link, and any click raced the probe. The ask now only appears once the silent
probe has failed.

The console panel had no background: homeui scopes its CSS variables to
[data-theme=...], which its uiStore applies and this app never did. The entry
point now sets the attribute from prefers-color-scheme, which also gets the
embedded homeui components a dark mode.

A boot failure also used to erase the saved installation — operator-given
device names included — on the first stumble, though most failures are a busy
port or an interrupted fetch. It now takes two failed boots in a row.

And simulated mode is gone: it existed to develop against before there was
hardware, and with a real gateway required it had become a trap — a mode
switcher offering an empty pretend bus. The page boots exclusively against
found gateways and explains itself when there are none. The simulator stays as
what it had quietly become: the rig the test suite runs the whole stack on.

Verified against the WB-DALI, including the one path no simulation could
reach: with buses 2 and 3 wired together, bus 3's polled monitor ring captured
and decoded the frames bus 2 transmitted, tagged foreign with ring sequence
numbers — plus a backward frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
… make it reopenable

Opening the page popped the browser's print dialog. Recent Emscripten detaches
Module.setStatus before calling it (`var setStatus=Module["setStatus"];
setStatus("Running...")`), so inside our handler `this` was `window` — and its
`this.print(text)` was window.print. Verified with a stub trap: the dialog came
from setStatus("Running...") out of the glue's run(). The handler no longer
touches `this`. Both the dev page and the rebuilt offline bundle now record
zero window.print calls through a full boot.

The DALI console panel scrolled away with the page: every flex container in the
chain is height-bounded except .daliWasm-page, whose implicit min-height:auto
let tall page content push past its bounds; the overflow propagated to the
viewport, the document scrolled, and the panel — laid out at the bottom of a
viewport-sized box — went with it. min-height: 0 (plus an overflow guard) makes
the page scroll internally, as homeui lays it out.

And closing the panel was a one-way door: in homeui it is reopened from the app
navigation, which this editor does not render. The DALI header now carries the
same Debug toggle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
…Modbus scan results

Three complaints from use, three causes found:

"Why do I wait so long opening DALI?" Two reasons. The visible one is the WASM
module download (dev server throttles it to 500 KB/s deliberately; ~20 s of any
hard reload) plus Pyodide. The self-inflicted one was the previous commit's
init wait: boot held "ready" until the daemon had read group membership off
every configured device — tens of seconds of DALI traffic. That wait is now
almost always skipped: the group snapshot is persisted next to the config
(watch_config reports both, and also fires on the device topics init publishes,
since reading groups touches no file and answers no RPC), and boot seeds it
into the daemon before the page can ask. The first tree is correct immediately,
from last session's knowledge; initialization still reads the truth off the bus
behind it. Boot only waits, bounded, for devices that have no seed.

"Show me the interface, don't hide behind a spinner." The boot screen was
designed to stream the daemon's log — and never had: it handed PageLayout
isLoading, which renders its spinner INSTEAD of its children, so the log was
suppressed on every normal boot and only ever appeared on failure. The spinner
is now rendered beside the log, not instead of it, and the longest silent
stretch — the module download — narrates its progress percent.

"Back to Modbus devices, the list is empty." The scan result lived only in
component state, and opening the DALI view unmounts the whole editor. The
result now sits in session storage — matching its lifetime: a snapshot of the
wire, not something to keep across days — so the round trip costs nothing and
the fourteen-second rescan is only for actual rescanning.

Verified against the WB-DALI over CDP: fresh-profile scan → DALI opens in 3 s;
back-navigation restores the list without a rescan; the boot pane narrates the
download. Group seeding is pinned by tests either way (seeded: first GetList
correct with no wait, init corrects from the bus; unseeded: boot waits) —
hardware verification of that path is blocked for now: the module reports all
three DALI buses unpowered with all three supplies switched on, which looks
like the bench wiring, not the software.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
CPython 3.14 warns about `continue`/`return` inside `finally`, and the vendored
daemon and mqttrpc both do it. The warnings fire when the modules are first
imported — during boot, straight into the boot pane, where a user can do
nothing about them.

Full regression against the repowered WB-DALI, all over CDP: Modbus scan list
survives the DALI round trip; DALI boots to a seeded tree — groups included —
in seconds; the rewired bench's Tridonic MSensor PIR is the first real DALI-2
control device this stack has commissioned (full 254-field device page, brand
and timers read over 24-bit frames); the polled monitor catches the sensor's
own spontaneous LightEvent frames, foreign-tagged, frame counter gapless;
IdentifyDevice reaches the gear; the console panel stays pinned and the Debug
toggle reopens it. Asset audit: a full boot makes 484 requests, all to our own
server (the only external calls are the project's analytics); the offline
single file boots with networking hard-disabled, all nine embedded blobs
present, and walks #dali to the port chooser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
…nder homeui master

Returning from the DALI view restored the device list but silently lost the
whole settings pane: the mount-path effect calls loadDeviceSettings inside the
.then that has only just produced the device-types store, and getType read the
state variable — still null in that closure — and threw. The scan path never
hit it because by scan time the store is long set. getType now takes the store
as an argument.

Runtime View then crashed the page: homeui master's Cell component grew a
history link that calls useSearchParams, which requires a Router above it, and
the Modbus editor mounted none — only the DALI view did. The editor now sits
in a MemoryRouter of its own, the same way the DALI page does.

Also from the code review of this branch (all verified findings):
- the group-persistence watcher subscribed to /devices/+/meta/driver, a topic
  the daemon never publishes (meta is one JSON on /devices/+/meta) — so groups
  read off the bus after boot were never re-saved and a stale seed would
  outlive every reload; now covered by a test that changes gear membership
  behind a seeded boot and expects the watcher to fire
- the boot wait keyed progress on device names, which repeat across buses
  ("DALI 0"), stalling every multi-bus boot at the full deadline; keyed on
  mqtt_id now
- a boot that failed partway leaked live tasks — dispatcher, serial stub, and
  possibly a polling loop already driving the port, unreachable by any later
  stop(); a failed start now tears down what got up
- a malformed entry inside saved group state crashed the boot (and two crashed
  boots erase the whole installation); it now costs that device its seed only
- the inline (offline) runtime booted on after dispose() if the view closed
  mid-boot, leaving an unstoppable daemon polling the port
- seeding no longer overwrites membership on a device whose initialization
  already read the truth off the bus

And the first-load convenience the operator asked for: a gateway with nothing
configured now scans all of its buses by itself, sequentially, through the same
Editor/ScanBus surface as the Rescan button — the page shows ordinary
commissioning progress and the tree fills unprompted. Verified on hardware:
open the DALI view on a fresh profile and the full tree, groups and the DALI-2
sensor included, appears twenty seconds later with zero clicks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
…dy has

Measured on the real gateway before touching anything: a Modbus round trip
over WebSerial costs 9.4 ms (median; the wire itself is ~1.5 ms — the rest is
the USB stack), an answered DALI query takes 46 ms back to back on the DALI
wire, an unanswered command 31.7 ms, and the one-frame-at-a-time protocol
delivered 54.8 ms per answered query — 84% of what this firmware can do.

The gateway consumes armed slots strictly in pointer order, so a batch fills
slots 0..n-1 in a single fc16 after one rewind, polls only the LAST reply
register — non-zero there implies every earlier slot was consumed — and
collects all n replies in one bulk read. Modbus overhead amortises across the
queue: 47.8 ms per answered query measured, 98% of the floor. The daemon
already passes command lists everywhere it can (parameter reads, polling,
scan stages), so the batches come for free; a batch degrades exactly like a
single frame — timeout or transport error turns the outstanding commands into
NoResponseFromGateway and the next rewind resynchronises the gateway.

A full bus rescan on real hardware: 20 s before, 13 s after.

While validating, a burst of failing FF24.F32.QueryFeedbackCapability frames
looked like a batching regression; the simulator reproduces it with the stock
daemon — it is the DALI-2 stack probing a feature the device does not
implement, three retries per instance, same as on a controller. Left as is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
…al, paint the chunk load

The UX review caught the SyntaxWarning still reaching the boot pane: the
filter sat in configure_logging, but the warnings fire when the vendored
modules are COMPILED — at pyimport, before any function of ours runs. The
filter now lives at the package root, active before the first vendored
import.

It also timed 4+ seconds of pure white page on a cold #dali load before
anything painted: the lazy DALI chunk is megabytes and its Suspense fallback
was null. The fallback is now a spinner, with its CSS in the eager bundle —
styles inside the chunk it precedes would defeat the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
…pups

Two more from the UX review's must-fix list.

The RU/EN dropdown translated nothing but device templates: the component's
useState setter was named setLanguage, shadowing the i18n helper imported
under the same name — so the choice reloaded templates and never reached
i18next. Renamed the import; the switch now translates the editor and the
DALI page live (both apps share the i18next singleton, and homeui's config
already reads the persisted choice on load).

The DALI monitor's ⋮ menu and the page-title info popover clicked into
nothing: homeui's Popup renders through FloatingPortal into a `.floating`
element that homeui's own app shell provides — and this page didn't, so every
popover mounted into a null root. The element now sits next to #root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
Runtime View no longer mixes firmware internals with telemetry: channels the
template marks hidden — the DALI send queue, reply registers, the monitor
ring — stay off the page, as wb-mqtt-serial keeps them off homeui's. And
read-only booleans render as labeled status badges instead of toggles: a
disabled toggle showing "Overheat: ON" reads as a switched-on feature, not a
fault flag, and invites clicking. Verified on hardware: Powered=Yes,
Overheat=No, with the live values confirmed against a raw register read.

Scan no longer wipes the workspace. The device list, the open device page and
the DALI button used to vanish the moment Scan was pressed — an accidental
click on the header's bright-green primary cost the whole context, with a
rescan as the only way back. The workspace now stays on screen, dimmed and
inert (clicking a device would race the scan for the one serial port), and
the results replace it when they arrive.

The boot pane narrates the stages that used to be silent: "Loading the Python
runtime…", "Starting Python…", "Starting the DALI service…" — several seconds
each on a cold load, previously a dead stop at "module 100%".

The duplicate "Scenes" section on bus/group pages is triaged as upstream:
wb-mqtt-dali's GroupScenesSettings (light levels) and the DT8
ColourGroupScenesSettings both title themselves "Scenes"/"Сцены", so any
controller with DT8 gear shows two identically-named sections. A one-line
title rename in dali_type8_parameters.py belongs upstream, not in the
vendored copy.

The language switch and popup fixes from the previous commit are verified on
hardware through the real UI: EN→RU translates the whole editor live, and the
monitor's ⋮ menu — dead until the portal root existed — now opens, revealing
"Filter by address" and "Save log to file", features that were unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
Polish (review items 16, 17, 18, 23):
- The editor's cold-load progress now says what is downloading ("Downloading
  the device editor engine — 3.1 MB / 9.2 MB"), in both languages, instead of
  bare byte counts on a blank page.
- The ~96px dead band between the DALI content and the docked console panel is
  reclaimed: homeui's page keeps a 48px bottom padding for its own scrolling
  shell, and `.dali`'s height:100% resolved against the padded box for a
  second 48px. Measured after: 24px of breathing room.
- The bootloader-scan checkbox no longer floats centered mid-page during a
  scan; it reads inline with the other scan text.
- "Runtime View" no longer ellipsises to "Runtime Vi…" at narrow widths, and
  scrolled content can clear the fixed bottom-right links chip.

Documents:
- notes/05-wb-mdali-firmware-pacing.md — the firmware source analysis behind
  the measured 46 ms/query: the priority window is applied at its .max and
  after backward frames too, send-twice pairs wait the full window, and the
  backward timeout is marginally tight. Reconstructs the measurement to the
  millisecond and quantifies ~28% headroom for the firmware team.
- notes/06-remaining-ux-findings.md — the deferred UX review items with
  triage: homeui design questions, the upstream wb-mqtt-dali "Scenes" title
  clash, and the identity-caching project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
…out of the repo

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
…page

Bumps the submodule to wirenboard/homeui#1223: each device page now carries
the daemon's runtime controls (levels, commands, colour), rendered from the
ordinary MQTT control topics — which the loopback broker serves here exactly
as a controller's broker does. Verified against the real WB-DALI: dragging
Wanted Level lit the lamp, Actual Level polled back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
…mqtt-dali

The editor's Python runtime shrinks to what is genuinely browser-side. Gone
from wbdali_browser: the register driver, the memo, the register map, the
simulator, the in-process broker, the wb-mqtt-serial stub and the scenario
helpers — all of them now live in wb-mqtt-dali (#226 simulator, #227
gateway link + memo, #228 host seams) and arrive with the vendored daemon,
which is pinned to that stack's branch until it merges.

runtime.py drives the daemon through public seams instead of monkeypatching:
a driver factory builds WBDALIDriver over RegisterLink with a per-bus memo,
the controller forwards the monitor flag and the bus population itself,
groups are seeded through DaliDevice.seed_groups, the data directory is
relocated through WB_MQTT_DALI_DATA_DIR, and boot waits on the daemon's own
awaitables (first init attempts, commissioning end, config writes) instead
of polling private state.

Tests that moved upstream with their code are dropped here; the remaining
runtime tests run over the daemon's driver and simulator: 79 pass, and the
DALI sim + offline e2e pass on the rebuilt bundle. The daemon's log now also
mirrors to console.debug for field diagnosis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
evgeny-boger and others added 11 commits August 31, 2026 15:38
…reply timeout

Every Modbus exchange through the WASM port cost ~280 ms whatever the reply
was, because TWASMPort::ReadFrame ignored the response and frame timeouts
and the frame-complete predicate wb-mqtt-serial passes, and serial.js waited
its own reply timeout (250 ms at 115200) for a byte count that never came:
before each port/Load wb-mqtt-serial sends a broadcast that is not answered,
and that read alone burned the whole timeout. serial.js also closed and
reopened the USB port on every write.

ReadFrame is now the file-descriptor port's loop — first byte within
responseTimeout, then a gap of frameTimeout ends the frame, and a reply of
known size returns the moment it is complete — over a chunked serial read
that takes the caller's timeout and keeps surplus bytes for the next call;
the timeouts are floored at what WebSerial can resolve. Send-time arithmetic
comes from the real baud rate instead of zero, SkipNoise drops what a timed
out request left behind, and the port stays open until the line settings
change. Measured on a WB-DALI at 115200: an exchange takes ~12 ms, a device
page opens in 4 s, the MSensor's 374 fields in 24 s.

The vendoring script stamps the exact upstream commits it took and refetches
when a branch tip moved: a reused CI workspace kept a daemon without the
gateway link and failed the DALI runtime tests on a module it never had.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three holes the code review found in the new keep-the-port-open policy. A
write to a dead stream (USB unplugged, Chrome errored the port) left isOpen
true forever, so every later exchange failed until a page reload — now the
failure marks the port closed and the next request reopens it, which the
old reopen-on-every-write behaviour did by accident. forceSelect() swapped
this.port under an open port for the same reason. And discardPending's
drain is bounded: a noisy line or a second master on the bus delivers bytes
continuously, and the unbounded loop held the whole request pipeline.

The firmware flows' setExtendedTimeout had quietly become inert (only the
legacy read path consulted it); readChunk now floors its timeout at the
extended reply window while the flag is up. Plus two dead imports the
worker refactor left behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The suite is green locally under Playwright's own headless shell, while
this stage loses the browser mid-suite; retain-on-failure traces plus the
archived test-results are the only way to see what kills it here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The E2E stage lost the browser mid-suite ("Target page, context or browser
has been closed" from runtime-view onward) while the identical build — the
CI-built dist included — passes everywhere with a real /dev/shm. The
Pyodide-heavy DALI specs run first in the same browser and fill the Docker
default 64 MB shared memory; --disable-dev-shm-usage is the standard fix
and changes nothing outside a container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The counter was loading on dev servers and in CI, reporting
localhost page views into the production id — and on the CI runner its
webvisor/form-tracking machinery was entangled in the page milliseconds
before the e2e browser page died. Analytics now initializes only on a real
hostname.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…chooser

The CI chromium exposes navigator.serial even headless; the first device
access then reached requestPort() without a user gesture and the page died
300 ms later — the archived trace ends right after "Using native WebSerial
API". The suite assumes no hardware, so the browser now runs with the
Serial/WebUSB features off, the same way the local config always did.

Independently, open() treated a gesture-refused requestPort as retryable
and called the chooser a hundred times back to back; a SecurityError or
NotAllowedError now ends the attempt like NotFoundError does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The e2e browser died with a native stack overflow whenever deviceLoad ran
against a device that never answers: ReadFrame's TResponseTimeoutException
is transient, and wb-mqtt-serial's device-session path (EnableEvents on a
disconnected device) retries transient errors in a way that, under
Asyncify, recursed the renderer to death. Reproduced deterministically with
Playwright's chromium; gone with the plain runtime_error this port has
always thrown — port/Load keeps reporting the field-known "Port IO error:
request timed out". Full e2e suite passes again under the CI browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 500 KB/s dev-server throttle existed to make the loading progress bar
observable, but it made every dev page load wait ~20 s for a 9.6 MB file.
Opt in with THROTTLE_MODULE_DATA=1 when working on the progress UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Without an api object, requestPort cannot conjure a port, and the
100-attempt retry loop below turned every write into a ~150 ms stall —
thousands of them per device load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…FT-7409)

Values served from the memo were indistinguishable from fresh bus reads,
and its invalidation only sees writes that pass through our daemon: a
second master on the same bus makes the cache silently stale, and the
random-address check proves "same device", not "same settings". The
editor now reads everything off the bus each session; the memo returns
only together with a page-level "cached, retrieved at <date>" banner and
a re-read action.

The memo itself stays upstream (wb-mqtt-dali#227), off by default. The
worked-out return design — learned_at stamps, host status API, the
banner, and the rejected daemon-state-snapshot alternative — is in
architecture/dali-memory-cache-postponed.ru.md and SOFT-7409; ADR 4-6
are marked withdrawn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he branch

Stripped from wb-mqtt-dali#227 per review; the archive branch is a
snapshot of soft-dali-driver-link taken before the removal (SOFT-7409).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
evgeny-boger and others added 2 commits September 1, 2026 10:53
READY no longer waits for every configured device's first
initialization — up to 30 s on top of the Python boot, all spent behind
a full-screen log. The tree is servable from the restored config alone,
seeded groups stand in until the bus answers, a group tab re-asks
(ADR 21), and asking about a device pulls its initialization forward
(ADR 22). Measured on the bench: reload with a saved installation shows
the tree in 9.7 s (the Python boot itself); before, the gate and the
first init reads came on top. The boot log also follows its own tail
now instead of staying scrolled to the top; a reader who scrolls up is
left alone.

The old guarantee — groups correct at the first GetList even without a
seed — is deliberately dropped (ADR 27): its test is replaced by
'a restored boot does not wait for device initialization', seeded-boot
groups keep their existing test, and the DALI-2 event tests wait for
sensor init explicitly now that start() no longer implies it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirrors the two review fixes so this branch and #98 keep identical port
sources: write() no longer rejects into Asyncify (which suspended the
C++ call forever) and releases its lock in a finally, and open() closes
the port object instead of trusting the isOpen flag, which every failure
path clears without closing. Also drops the firmware timeout floor from
drain reads and hoists the e2e launch flags to top-level use{}.

Comments trimmed to a line or two while here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@evgeny-boger evgeny-boger changed the title DALI configurator: wb-mqtt-dali under Pyodide, blocking Modbus driver, real-hardware verified feat: DALI configurator: wb-mqtt-dali under Pyodide, blocking Modbus driver, real-hardware verified Sep 2, 2026
evgeny-boger and others added 9 commits September 2, 2026 12:15
Opening a second device right after the first killed the whole WASM module
with "Aborted(RuntimeError: unreachable)", and every request after that was
dead. Reproduced on the bench in five seconds: scan, open WB-MSW @64, switch
to WB-MR6C @46 while it loads.

Asyncify.handleAsync runs the EM_ASM body twice — once on the unwind pass,
where handleSleep has no reply yet and returns the value of the PREVIOUS
wakeUp, and again on the rewind pass with this call's reply. ReadChunk in
wasm_port.cpp copies whatever it gets with no length check:

    let result = Asyncify.handleAsync(async() => await Module.serial.readChunk($1, $2));
    if (!(result instanceof Uint8Array) || result.length == 0) return 0;
    Module.HEAPU8.set(result, $0);

So any read whose buffer is shorter than the previous chunk overflows the
heap, and ReadFrame produces that shape constantly: a 129-byte reply arrives
as 109 bytes, the next ReadChunk asks for the remaining 20, and 109 stale
bytes go into a 20-byte tail. The 89-byte overrun landed on the Asyncify data
block malloc had just handed to that very suspension, zeroed its stack_ptr
and stack_limit (the stale bytes were a device name padded with NULs), and
asyncify_stop_unwind trapped on its bounds check — the "unreachable".

Clearing Asyncify.handleSleepReturnValue as the C++ caller suspends makes the
unwind pass return 0, so ReadChunk copies nothing until the real reply is
there. The proper fix belongs in ReadChunk (`result.length > $1` must return
0), but the module cannot be rebuilt from this side.

The rest makes the layer's other Asyncify invariant structural. handleAsync
is `startAsync => handleSleep(async wakeUp => wakeUp(await startAsync()))`
with no catch: a rejecting Module.serial method leaves the C++ call suspended
for good, and a hanging one lets a late wakeUp rewind a stack that has moved
on. readChunk, write, discardPending, open, close and setOptions now catch
everything and resolve to their neutral value; getReader()/getWriter() moved
inside the try, where a locked stream marks the port dead so the next request
reopens it; the reader and writer are tracked so open() can give the locks
back; and port.close(), reader.cancel() and writer.abort() are bounded, so a
wedged USB call fails the request instead of hanging the module forever.

Mirrors the PR-98 fix on feature/wasm-port-frame-timeouts; this branch also
gets a vitest unit test for the invariant.

Verified on the bench: the crashing sequence now loads both devices, all four
devices open with no leaked stream locks, one Modbus exchange still costs 9 ms
(min 9, max 13) and a scan 4.5 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNC4oayJWjJaHMmvM8YMGC
Emscripten runs an EM_ASM body that contains Asyncify.handleAsync twice:
once on the unwind pass, where handleAsync returns
Asyncify.handleSleepReturnValue — the value the PREVIOUS wakeUp
delivered, i.e. the previous call's chunk — and once on the rewind pass
with this call's real reply. ReadChunk did its work outside that
callback, so on the unwind pass it copied the previous chunk into a
buffer sized for this one. The guard rejected only a non-Uint8Array,
never a length mismatch, and at every frame boundary the new buffer is
the shorter one: 109 stale bytes into a 20-byte tail read overran the
heap into the Asyncify data block malloc had just handed to this very
suspension and zeroed its header, after which asyncify_stop_unwind
trapped with RuntimeError: unreachable.

Reproduced on the bench: scan, open a device tab, then switch to the
next one while the first is still loading — the renderer aborts within
seconds. With this change the same repro survives, both pages load, and
so does every other device tab.

Everything ReadChunk does now happens inside the callback, which runs
exactly once, on the real reply. A reply longer than the buffer returns
0 instead of a truncating copy: serial.js already caps readChunk at
count, so a longer array is a broken contract and must not be written.

serial.js keeps clearing Asyncify.handleSleepReturnValue before it
suspends. That guard leans on an Emscripten internal, so it is not the
fix, but it is what protects a page served against an older module
build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNC4oayJWjJaHMmvM8YMGC
The rule the port layer now follows, and the crash that produced it:
an EM_ASM body containing Asyncify.handleAsync runs twice, so anything
outside the async callback sees the previous suspension's reply.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNC4oayJWjJaHMmvM8YMGC
sw-slow-network.spec.ts fails about one run in three on the CI runner at
the server's 5000 ms delay -- the SW timeout did not fire and the page
came from the network. Retries make it report as `flaky` rather than
reddening the whole build while the timeout is investigated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNC4oayJWjJaHMmvM8YMGC
readChunk took a fresh reader for every call, raced it against a
setTimeout and, when the timeout won, awaited reader.cancel() before
releasing the lock. Two things were wrong with that.

Chrome clamps every timer in a hidden tab to one second. The 5 ms drain
read SkipNoise does before each exchange therefore cost a full second,
twice per request, while the Modbus exchanges inside took 3-9 ms: a
portLoad to a device that answers took 2000 ms flat, and a scan of the
four-device bench line took 48.1 s. The same code in a visible tab, on
the same adapter, took 14 ms and 4.5 s. This was never
adapter-dependent, and reader.cancel() settles in 0 ms here.

Cancelling also discards whatever the port had already buffered, so a
reply landing just after a drain timed out was thrown away mid-frame.

The port now keeps one reader and one outstanding read(). readChunk
races that read against its timeout and, when the timeout wins, simply
stops waiting: the read stays in flight and its bytes land in pending
for the next call, so none are lost and none are read twice.
discardPending drops what has already arrived and yields a task turn
instead of arming a timer, which costs nothing in either kind of tab.
close() and reopen cancel the reader once, bounded at 200 ms.

Bench, WB-MSW at 115200 8N2, tab hidden: portLoad median 2000 -> 1000 ms
with this commit alone, the serial layer down to 3 ms of the request.
The second that is left is the RPC wait loop, fixed in the next commit;
the scan of the bench line still took 48.3 s until then.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNC4oayJWjJaHMmvM8YMGC
_doRequest waited for the WASM call by re-arming setTimeout(check, 1).
Chrome clamps timers in a hidden tab to one second, so every request
paid an extra second after its work had already finished: with the
serial layer down to 3 ms of actual exchange, portLoad still took
1000 ms.

The reply path resolves the waiter directly now. The RPC timeout stays
as a single timer that only fires when the call really is stuck, and a
call that finished without ever suspending is still handled.

Bench, WB-MSW at 115200 8N2, tab hidden: portLoad median 1000 -> 5 ms,
scan 48.1 -> 23.1 s against the 2000 ms and 48.1 s this pair started
from. Tab visible: 14 -> 4 ms, scan 4.5 -> 4.3 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNC4oayJWjJaHMmvM8YMGC
The cases the redesign turns on: a read that lands after its caller
stopped waiting is delivered by the next readChunk, a frame split across
a timeout arrives whole, the reader is taken once rather than per call,
discardPending drops what is there without waiting for what is not, and
close() returns promptly when cancel() never settles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNC4oayJWjJaHMmvM8YMGC
…out type

Three follow-ups from the review of the frame-timeout work.

1. GetSendTimeBytes / GetSendTimeBits now round up exactly as TSerialPort does
   (src/port/serial_port.cpp:217-230): bits-per-byte times bytes, and
   bits * 1e6 / baud, both ceil'd. The old "+ 0.5" rounding and the integer
   division truncated instead, under-estimating the send time and so shortening
   every frame timeout derived from it, RpcPortScan::TRegisterReader among them.
   At 115200 8N2 one byte came out as 95 us instead of 96, and a 3.5-byte gap as
   338 us instead of 339. The divisor stays guarded: unlike TSerialPort's, these
   settings arrive in a request rather than from an already-open port.

2. ReadByte throws TSerialDeviceTransientErrorException("timeout") on timeout,
   the type TFileDescriptorPort::ReadByte uses (file_descriptor_port.cpp:102).
   TSerialDeviceException derives from std::runtime_error and not the other way
   round, so the plain runtime_error thrown before escaped every
   catch (const TSerialDeviceException&) in the drivers. The Uniel device is the
   only ReadByte caller.

3. ResetSerialPortSettings still carried a comment claiming there was nothing to
   restore, which stopped being true once Settings was cached in C++. Only the
   comment changed. The behaviour is deliberately left alone: unlike TSerialPort
   (serial_port.cpp:212-215) this port has no InitialSettings to restore to,
   because Settings only ever holds what the last request asked for.

ReadFrame keeps throwing a plain std::runtime_error on timeout on purpose. The
transient TResponseTimeoutException is retried by wb-mqtt-serial in a way that
recursed under Asyncify and killed the renderer with a native stack overflow.
The price of that choice is that every catch (TResponseTimeoutException) site is
dead code for this port:

  src/rpc/rpc_helpers.cpp:77                              RPC register read retry
  src/rpc/rpc_helpers.cpp:119                             RPC register write retry
  src/rpc/rpc_helpers.cpp:135                             SetContinuousRead warning
  src/rpc/rpc_device_load_config_task.cpp:21              friendly TRPCException conversion
  src/rpc/rpc_port_scan_serial_client_task.cpp:172        GetDeviceDetails SN read
  src/rpc/rpc_port_load_modbus_serial_client_task.cpp:115 E_RPC_REQUEST_TIMEOUT reply
  src/rpc/rpc_fw_get_firmware_info_task.cpp:42            firmware info probe
  src/rpc/rpc_fw_update_serial_client_task.cpp:78         firmware update
  src/rpc/rpc_fw_update_task.cpp:77                       firmware update retry
  src/rpc/rpc_fw_update_task.cpp:359                      firmware update error report
  src/rpc/rpc_fw_restore_task.cpp:110                     firmware restore
  src/modbus_base.cpp:199                                 ForceFrameTimeout extra-data drain
  src/modbus_ext_common.cpp:650                           Fast Modbus ScanStart probe
  src/serial_client_events_reader.cpp:319                 EnableEvents, the crash path

Those callers see the port's timeout as a plain runtime_error instead, and
port/Load reports the field-known "Port IO error: request timed out".

Verified on the WB-DALI bench over a visible tab (WB-MSW 64, WB-MR6C 46,
WB-MAP3E 35, WB-MDM3 57, all at 115200 8N2):

  one portLoad to slave 64    median 5 ms over 10 (min 4, max 5)
  scan of the line            all four devices found, 4.6 s
  all 10 device tabs opened   492 console lines, no Aborted(, no errors
  crash repro tab 0 -> tab 1  clean, no Aborted( in 180 s
  wasm/public/module.wasm     md5 5f910094b741b6e3eff7e0d6626cd497
  npm test                    30 tests in 6 files, green

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNC4oayJWjJaHMmvM8YMGC
Name each one: the substitution point, the daemon's public extension points, one integration point instead of two.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNC4oayJWjJaHMmvM8YMGC
evgeny-boger added a commit that referenced this pull request Sep 3, 2026
… timeout

TWASMPort::ReadFrame now follows the TPort contract the way
TFileDescriptorPort does: responseTimeout to the first byte, frameTimeout
between bytes, frame_complete returning the answer as soon as it is
assembled — instead of waiting out serial.js's 250 ms reply timeout on
every read. serial.js grows readChunk() with a pending buffer and a
bounded drain, stops reopening the USB port on every write (and heals a
dead port on the next one), fails fast in open() when the browser has no
WebSerial API, and treats a gesture-refused requestPort() as terminal.
A timed-out read throws plain runtime_error, deliberately not the
TPort-contract TResponseTimeoutException: the transient type sends
wb-mqtt-serial's device-session retries under Asyncify into a recursion
that crashed the renderer (reproduced and bisected on module builds).

Measured on hardware (see #96): one Modbus exchange ~280 ms -> ~12 ms,
a bus scan ~20 s -> 4.5 s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
evgeny-boger and others added 2 commits September 3, 2026 23:46
# Conflicts:
#	wasm/e2e/playwright.config.ts
#	wasm/public/script.js
#	wasm/public/serial.js
#	wasm/src/wasm_port.cpp
#	wasm/src/wasm_port.h
One case in the suite tested serial.js's stopgap for the Asyncify unwind
pass — it cleared Asyncify.handleSleepReturnValue so ReadChunk could not
copy the previous, longer reply into this call's buffer. 043fda6 said as
much in its own body: "the proper fix belongs in ReadChunk
(`result.length > $1` must return 0), but the module cannot be rebuilt
from this side."

The module has since been rebuilt. 0d16342 put the length check into
wasm_port.cpp's EM_ASM body, and 330a7fa then deleted the JS-side
clearing, so there is no longer a handleSleepReturnValue for the test to
observe — the guard now lives in C++, out of vitest's reach.

Replaced with the JS half of the same contract, which is still testable
and still the thing that made the heap overflow possible: readChunk must
resolve to at most `count` bytes and keep the surplus queued. It uses the
bug's own shape — a 109-byte chunk delivered while the caller asked for
20 — and checks that the remaining 89 come back next, in order.

The other five behaviours are unchanged and still pass against the
rewritten serial.js: a read delivered after the caller timed out, no
bytes lost across a timeout, a prompt close() when cancel() never
settles, a closing stream marking the port dead, and the never-reject
invariant. No test was dropped.

vitest 30/30, pytest 74/74.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant