pycontrol-gui · architecture

Desktop frontend on top of the core engine.

Core owns board I/O and workspace parsing. The GUI adds Qt widgets, worker threads, plotting, and tab workflows — connected through one QtBusAdapter.

Runtime shape

One main window, three tabs, shared and per-subject runtimes

flowchart TD
    subgraph gui ["pycontrol-gui · top"]
        MW["MainWindow"]
        WM["WorkspaceModel"]
        RTab["RunTaskTab"]
        STab["SetupsTab"]
        ETab["ExperimentsTab"]
        RunView["ExperimentRunView"]
        Panel["SubjectRunPanel(s)"]
        SharedRT["shared SessionRuntime"]
        SubjectRT["per-subject SessionRuntime(s)"]
        RTI["SessionRuntime internals (per instance)"]
        Cmds["SessionCommands · GUI thread"]
        QBA["QtBusAdapter · GUI-thread QObject"]
        Ctrl["SessionController · worker QThread"]

        MW --> WM
        MW --> RTab
        MW --> STab
        MW --> ETab
        MW --> SharedRT
        ETab --> RunView --> Panel --> SubjectRT
        SharedRT -.-> RTI
        SubjectRT -.-> RTI
        RTI --> Cmds
        RTI --> QBA
        RTI --> Ctrl
        RTab -->|"commands"| Cmds
        STab -->|"setup actions"| Cmds
        Panel -->|"commands"| Cmds
        Cmds -->|"queued command signals"| Ctrl
        Ctrl -->|"status / setup signals"| RTab
        Ctrl -->|"setup signals"| STab
        Ctrl -->|"status signals"| Panel
        QBA -->|"queued bus signals"| RTab
        QBA -->|"queued bus signals"| Panel
    end

    subgraph core ["pycontrol-core · bottom"]
        WS["Workspace"]
        BS["BoardSession"]
        T["SerialTransport"]
        Reg["SubscriberRegistry"]
        Pump["pump thread · _pump_loop"]
        Dec["FrameDecoder"]
        Log["NativeEventLogger"]
        Ext["TaskExtension"]

        BS --- T
        BS --> Reg
        BS -.->|"start()"| Pump
        Pump -->|"read bytes"| T
        Pump --> Dec
        Dec -->|"dispatch"| Reg
        Reg --> Log
        Reg --> Ext
    end

    WM -->|"discover / reload"| WS
    Ctrl -->|"creates + drives"| BS
    Ctrl -->|"add_subscriber(QBA) at connect"| BS
    Ctrl -->|"add log / extension subscribers at start"| BS
    Reg -->|"subscriber calls on pump thread"| QBA
Top = GUI, bottom = core. MainWindow owns one shared SessionRuntime for Run task and Setups; each SubjectRunPanel owns another runtime with the same internals. Inside each runtime, only SessionController is moved to a worker QThreadSessionCommands and QtBusAdapter stay on the GUI thread. The controller creates and drives core BoardSession, registers QtBusAdapter at connect, and adds loggers / TaskExtension at start(). During the run, BoardSession's pump thread reads serial, dispatches bus events, and invokes QtBusAdapter subscriber methods from that pump thread.

WorkspaceModel

GUI wrapper around core Workspace discovery. Emits Qt signals for tasks, hwdefs, setups, ports, and experiments. Reloads once per second on a short-lived worker, applies snapshot on the GUI thread.

SessionRuntime

Bundles SessionCommands, QtBusAdapter, and SessionController + QThread. Shared runtime wired from MainWindow; experiment panels each construct their own.

QtBusAdapter

GUI-thread QObject implementing the core subscriber protocol. Registry invokes it from the pump thread; it emits Qt signals that tabs receive via explicit QueuedConnection.

Threading & wiring

Three threads during a run

While a session is running, each runtime uses three threads. Before start(), only the first two exist — the pump thread is created when the run begins and stops when the run ends.

1 · GUI thread

Qt's main thread. Owns widgets, SessionCommands, and QtBusAdapter (only the controller is moved off this thread).

2 · Controller QThread

SessionController lives here. Connect, upload, start/stop, variable writes, and run-end cleanup.

3 · Pump thread

A plain threading.Thread inside core BoardSession — not a QThread. Reads serial, decodes frames, calls subscribers.

The Qt boundary is not a thread. It is the pair of adapter objects that translate between core and Qt using queued signal delivery: SessionCommands carries commands down (GUI → controller QThread). QtBusAdapter carries bus events up (pump thread → GUI): its QObject lives on the GUI thread, but the registry calls its subscriber methods from the pump thread before queued signal delivery. SessionController also emits status signals (connected, run started/stopped, errors) to tabs from the controller thread.
DirectionFrom threadCrossingTo thread
Commands down GUI SessionCommands signal → controller slot Controller QThread
Bus events up Pump QtBusAdapter emit → tab slot (QueuedConnection) GUI
Controller status Controller QThread SessionController signals → tab slots GUI

The pump loop (BoardSession._pump_loop) is the acquisition path that upstream pyControl used to drive from the Qt event loop:

read bytes from SerialTransport
  → FrameDecoder.feed()        # pure sans-IO codec
  → BoardSession._dispatch()   # typed bus events
  → SubscriberRegistry         # fan-out to all subscribers
      → QtBusAdapter.on_data() → emit Qt signals (still on pump thread)
      → TsvLogger / NativeEventLogger
      → TaskExtension.on_task_data()

QtBusAdapter.on_data() runs on the pump thread; the queued connection is what moves the result to GUI slots for logs, controls, and plots. A buggy subscriber is caught and re-emitted as an Error — it never crashes the pump. Task extensions also run on the pump thread (stay fast, no Qt widgets). Experiment extensions use a ThreadPoolExecutor instead.

FlowPath
Workspace refreshWorkspaceModel → all three tabs
Run task / setup commandsTab → shared SessionCommandsSessionController
Session eventsShared QtBusAdapterRunTaskTab (logs, controls, plots)
Experiment commandsPanel → per-subject SessionCommandsSessionController
Active sessions lock the other tabs and Settings. Workspace root lives in QSettings, not settings.json.
Tabs

Run task, experiments, and setups

Run task

Select setup/task/subject, connect, upload, run. Events feed logs, PlotterHost, and task controls (JSON or Python plugins). Hwdef selector only when the task imports hardware_definition.

Experiments

Edit experiment JSON; ExperimentRunView runs one isolated SubjectRunPanel per rig. Optional ExperimentExtension plugins get lifecycle hooks off the GUI thread.

Setups

Edit setups.json, auto-probe MCU labels, stamp expected_mcu on connect. Bulk board actions route through the shared runtime.

flowchart LR
    BoardSession["BoardSession"] --> QtBusAdapterData["QtBusAdapter.events_batch"]
    QtBusAdapterData --> RunTaskSlot["RunTaskTab.on_events_batch"]
    RunTaskSlot --> PlotterHostBatch["PlotterHost.process_batch"]
    RunTaskSlot --> ControlsData["controls process_data"]
    RunTaskSlot --> LogViewData["LogView rows"]
    PlotTimer["plot_update_timer"] --> PlotterHostUpdate["PlotterHost.update_plot"]
Everything else

Plugins, shutdown, diagnostics

Full detail: architecture.md