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. Commands reach core through SessionController; bus events return through a per-runtime QtBusAdapter.

Runtime shape

Two levels: app shell and one runtime

Read these as nested diagrams. The first diagram shows where runtime boxes appear in the GUI. The second opens one runtime box and shows how commands and core bus events cross the Qt boundary.

1. App shell

flowchart TD
    MW["MainWindow"] --> WM["WorkspaceModel"]

    subgraph tabs ["tabs"]
        direction LR
        RunTab["Run task"]
        SetupsTab["Setups"]
        ETab["Experiments"]
    end

    MW --> RunTab
    MW --> SetupsTab
    MW --> ETab

    RunTab --> SharedRT["shared SessionRuntime"]
    SetupsTab --> SharedRT

    ETab --> RunView["ExperimentRunView"]
    RunView --> Panels["SubjectRunPanel(s)"]
    Panels --> SubjectRT["per-subject SessionRuntime(s)"]

    WM --> CoreWorkspace["core workspace/config APIs"]
    SharedRT -.-> RuntimeBox["see runtime detail below"]
    SubjectRT -.-> RuntimeBox

2. Inside one SessionRuntime

flowchart TD
    Receiver["RunTaskTab or SubjectRunPanel"]

    subgraph runtime ["one SessionRuntime"]
        direction TB
        Cmds["SessionCommands"]
        Ctrl["SessionController"]
        QBA["QtBusAdapter"]
        Cmds -->|"queued commands"| Ctrl
    end

    CoreSession["core session APIs"]

    Receiver -->|"command signals"| Cmds
    Ctrl -->|"connect/upload/start/stop"| CoreSession
    Ctrl -.->|"adds adapter as core subscriber"| CoreSession
    CoreSession -->|"bus callbacks"| QBA
    QBA -->|"queued Qt signals"| Receiver

3. Host controls during a run

flowchart LR
    Controls["task controls / GUI action"]
    Commands["SessionCommands"]
    Controller["SessionController"]
    BoardAPI["BoardSession.set_variable() / trigger_event()"]
    Adapter["QtBusAdapter"]
    Receiver["RunTaskTab or SubjectRunPanel"]

    Controls -->|"set variable / trigger event"| Commands
    Commands -->|"queued Qt signal"| Controller
    Controller -->|"core API call"| BoardAPI
    BoardAPI -.->|"running task updates"| Adapter
    Adapter -->|"queued Qt signal"| Receiver
Key idea. SessionRuntime is lifecycle and wiring: it owns SessionCommands, SessionController, QtBusAdapter, and the worker QThread. SessionController is the object that performs session operations against core. QtBusAdapter is the event bridge back from core for active runs. Setups shares the runtime for commands and maintenance, but active-run bus events go to Run task and subject panels. Host controls use the command path on the way down; they only involve QtBusAdapter later if the running task emits bus events in response.

Glossary

WorkspaceModel

GUI wrapper around core workspace/config APIs. Discovers tasks, hwdefs, setups, ports, experiments, settings, and data paths, then emits Qt signals for the tabs.

SessionRuntime

Lifecycle wrapper for one controllable board workflow. Owns SessionCommands, SessionController, QtBusAdapter, and the worker QThread.

SessionCommands

GUI-thread signal facade. Tabs and controls emit command signals here so Qt can queue them safely to the controller thread.

SessionController

Worker-thread object that owns the current core BoardSession. Connects, uploads, starts/stops, sets variables, triggers events, syncs firmware, and emits status/error signals.

QtBusAdapter

Event bridge from core back to Qt. Core calls its subscriber methods from the pump thread; it re-emits typed Qt signals for run data, variable changes, warnings, errors, and session start/end.

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