Polling Device State

A FrontPanel application is rarely just reacting to user input. Instrumentation, control, and test-and-measurement applications spend most of their monitoring telemetry — surfacing counter readings, status flags, instrument measurements, queue depths, captured frames. The device doesn’t push this information; the application asks for it on a schedule. That’s polling, and how you structure it shapes both the responsiveness of the UI and the load on the device / communication interface.

Polling looks like one technique, but two distinct patterns sit underneath it:

  • Telemetry — periodic reads of device state for display. Small, cheap, frequent. A handful of registers refreshed several times per second.
  • Acquisition — event-driven capture of a larger payload. Heavier, multi-step, and gated on the device being ready. A trigger, a wait, and a bulk read.

The cadence, cost, and lifecycle of each are different enough that they’re worth treating separately. The building blocks are the same.

Building Blocks

FrontPanel applications implement polling using standard React and JavaScript primitives, plus the WorkQueue from @opalkelly/frontpanel-platform-api:

  • setInterval(callback, ms) — fires callback every ms milliseconds. Returns a handle you’ll need in order to cancel the timer later.
  • React.useEffect(setup, deps) — runs setup when the component mounts, and again whenever any value in deps changes. The function returned by setup runs on cleanup. This is where you start and stop the timer so polling lifetime tracks the component.
  • workQueue.post(asyncTaskFn) — adds asyncTaskFn to a serial queue. Wrapping device calls in workQueue.post() ensures only one device transaction is in flight at a time. See Work Queue for the full rationale.

The general shape of a poll, then, is: a useEffect that starts a setInterval, where each tick posts a device transaction to the work queue, and whose cleanup function clears the interval when the component unmounts.

Telemetry

Telemetry polls are small and frequent. A device handles roughly 1 kHz of round-trips for small operations like wire reads, so a telemetry poll costs about one millisecond of device time per cycle. (See the performance reference for details.) The budget is generous but not unlimited — group your reads sensibly to stay well under it.

updateWireOuts() fetches all 32 wire-out registers in a single round-trip and caches them on the host. getWireOutValue() reads from that cache. Call updateWireOuts() once per poll cycle and read every value you need from the cache — there is no per-wire cost beyond the initial fetch.

A common grouping is by instant: values that must reflect the same moment — voltage, current, and temperature on a power dashboard — should share a poll so they’re sampled together.

function PowerDashboard(props: { fpgaDataPort: IFPGADataPortClassic; workQueue: WorkQueue }) {
    const [voltage, setVoltage] = React.useState(0);
    const [current, setCurrent] = React.useState(0);
    const [temperature, setTemperature] = React.useState(0);

    React.useEffect(() => {
        const id = setInterval(() => {
            props.workQueue.post(async () => {
                await props.fpgaDataPort.updateWireOuts();
                setVoltage(props.fpgaDataPort.getWireOutValue(0x20));
                setCurrent(props.fpgaDataPort.getWireOutValue(0x21));
                setTemperature(props.fpgaDataPort.getWireOutValue(0x22));
            });
        }, 100);
        return () => clearInterval(id);
    }, [props.fpgaDataPort, props.workQueue]);

    return (
        <>
            <Gauge label="Voltage" value={voltage} />
            <Gauge label="Current" value={current} />
            <Gauge label="Temperature" value={temperature} />
        </>
    );
}Code language: TypeScript (typescript)

One updateWireOuts() per tick, three values pulled from the cache, all sampled at the same instant.

AXI Note

On AXI devices, the equivalent is axiLite.readBulk(), which reads a contiguous range of registers in one round-trip. Place your status registers in a contiguous block of gateware addresses so a single readBulk() covers them.

Acquisition

Acquisition is a different shape. Where telemetry is a single read, an acquisition cycle is a transaction: trigger the device, wait for it to signal ready, then read the captured data. Each step is a round-trip, and the read at the end can be much larger than a status poll. A typical cycle is roughly six round-trips — one to trigger, several waiting on a status flag, one (longer) to transfer the data — versus one for a telemetry poll.

The implication is that acquisition cycles are scheduled less aggressively than telemetry, and the cadence is set by how long the device takes to produce and transport the data, not by UI refresh expectations.

function CameraView(props: { fpgaDataPort: IFPGADataPortClassic; workQueue: WorkQueue }) {
    const canvasRef = React.useRef<HTMLCanvasElement>(null);

    React.useEffect(() => {
        const id = setInterval(() => {
            props.workQueue.post(async () => {
                // Trigger acquisition
                await props.fpgaDataPort.activateTriggerIn(0x40, 1);

                // Poll status until the frame-ready flag is set
                while (true) {
                    await props.fpgaDataPort.updateWireOuts();
                    if (props.fpgaDataPort.getWireOutValue(0x60) & 0x1) break;
                }

                // Read frame data
                const frame = new Uint8Array(FRAME_SIZE);
                await props.fpgaDataPort.readFromPipeOut(0xA0, frame);

                if (canvasRef.current) drawFrame(canvasRef.current, frame);
            });
        }, 33);
        return () => clearInterval(id);
    }, [props.fpgaDataPort, props.workQueue]);

    return <canvas ref={canvasRef} />;
}Code language: TypeScript (typescript)

In this case, the entire trigger / wait / read sequence is one work queue ticket. Posting it as a single ticket is what keeps the steps from interleaving with other device traffic — a telemetry poll from another component can’t slip in between the trigger and the read.

If your gateware and application design can accommodate the response latency, you may prefer to break the cycle into separate work queue tickets to avoid blocking telemetry. Incorporating the wait check into telemetry is also an option.

AXI Note

Replace activateTriggerIn with an axiLite.write, the status updateWireOuts / getWireOutValue loop with an axiLite.read loop, and readFromPipeOut with an AXI Stream read.

Cleanup and Unmount

The function returned from useEffect runs when the component unmounts and before the effect re-runs because a dependency changed. Always call clearInterval there. Two things go wrong if you forget: the interval keeps firing after the component is gone, leaking device transactions indefinitely, and React warns about state updates on an unmounted component.

React.useEffect(() => {
    const id = setInterval(/* ... */);
    return () => clearInterval(id);  // ← essential
}, [/* deps */]);Code language: TypeScript (typescript)

This applies equally to telemetry and acquisition. Any setInterval started in a useEffect needs a matching clearInterval in the cleanup.