feat: DALI configurator: wb-mqtt-dali under Pyodide, blocking Modbus driver, real-hardware verified - #96
Draft
evgeny-boger wants to merge 80 commits into
Draft
feat: DALI configurator: wb-mqtt-dali under Pyodide, blocking Modbus driver, real-hardware verified#96evgeny-boger wants to merge 80 commits into
evgeny-boger wants to merge 80 commits into
Conversation
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu
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>
…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>
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>
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>
# 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Встраивает DALI-конфигуратор — страницу DALI из homeui и немодифицированный демон wb-mqtt-dali под Pyodide — в автономный WASM-редактор устройств.
Как устроено
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 устройства.Подробности:
architecture/dali-in-the-browser.md(протокол, измерения) иarchitecture/dali-wasm-arc42.ru.md(полный arc42-каталог из 20 проектных решений).Проверено на железе (WB-DALI @17, светильники Skydance DT8, датчик Tridonic MSensor)
Тесты
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: вся страница против симулированного шлюза.Связанные: 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: дерево шин после автосканирования новой установки

🤖 Generated with Claude Code
https://claude.ai/code/session_01DPrWsZjKprJZVq5y6oGQfu