Design document, not a status report. The implementation in
src/protoskipper/gui/is built against this document; if the code and the document disagree, the document wins until it is consciously revised.
This document covers the GUI tier of ProtoSkipper. The protocol-agnostic
core is documented separately in ARCHITECTURE.md;
this document explains how the user-facing application sits on top of
that core, what threading and state guarantees it provides, and what
each screen does.
The intended reader is a developer who is going to (a) modify the GUI, (b) write a plugin that contributes GUI panels, or (c) audit the GUI's safety story. It assumes the reader has read the core architecture doc.
ProtoSkipper is a tool a SCADA / BMS engineer carries on a laptop into a substation, a BMS plant room, or a panel-builder's workshop. The GUI must therefore be:
- Honest about state. The operator must always know which device is selected, which session profile is active, where the audit log is being written, and whether a write actually went on the wire. Hidden state causes incidents.
- Cheap to abandon. A click or a keystroke must never silently start a long operation that cannot be cancelled. Every operation surfaces a visible progress indicator and a way to back out.
- Forgiving in LAB, strict in PRODUCTION. The same UI elements behave differently as the session profile escalates. The behaviour is centralised in the safety dialogs so panels do not duplicate the policy.
- Coherent across protocols. A user who has driven the Modbus side should be able to drive the BACnet side without re-learning the conventions. Per-protocol idioms are exposed via contributions, not via per-protocol main views.
- Cross-platform without uncanny-valley styling. Native menu and window-frame behaviour on each OS (Windows, macOS, Linux) but a consistent palette, density, and iconography inside the application frame.
What the GUI is not trying to be:
- A real-time HMI. Watchlist refresh is interactive, not millisecond.
- A configuration editor for production SCADA databases. The tool reads and writes to live devices; configuration export to a SCADA system is a downstream concern.
- A visual programming environment. Scripting belongs in the embedded REPL, not in the main canvas.
+----------------------------------------------------------------------+
| Qt main thread |
| +----------------+ +--------------------+ +------------------+ |
| | MainWindow | | Dialogs | | Panels | |
| | - menu bar | | - NewConnection | | - DeviceTree | |
| | - toolbar | | - WriteConfirm | | - ObjectBrowser| |
| | - dock layout| | - SafetyPrompt | | - Watchlist | |
| | - status bar | | - About | | - PacketView | |
| +-------+--------+ +---------+----------+ +---------+--------+ |
| | | | |
| +---------+------------+------------+-----------+ |
| | | |
| +------v-------+ +-------v-------+ |
| | ApplicationState | Qt Models | |
| | - QObject signals | - DeviceTree| |
| | - sessions[] | - Watchlist | |
| | - devices[] | - PacketLog | |
| | - watchlist items[] +--------------+ |
| | - active_profile | |
| +----------+--------------+ |
| | (signals: device_added, session_opened, |
| | read_completed, packet_received, ...) |
| | |
| v |
| +---------+----------+ |
| | SessionManager | |
| | - dispatches to | |
| | workers | |
| +---------+----------+ |
| | invokeMethod (queued connection) |
+------------------------|----------------------------------------------+
v
+----------------------------------------------------------------------+
| Worker threads (one QThread per active session) |
| |
| +-----------------+ +-----------------+ +----------------+ |
| | DriverWorker #1 | | DriverWorker #2 | | DriverWorker N | |
| | - DriverSession| | - DriverSession| | - ... | |
| | - Modbus TCP | | - BACnet/IP | | | |
| +--------+--------+ +--------+--------+ +-------+--------+ |
| | | | |
+-----------|----------------------|---------------------|-------------+
v v v
+----------------------------------------------------------------------+
| Core (already exists) |
| ProtocolDriver / DriverSession / SafetyContext / AuditLog / |
| plugin_loader |
+----------------------------------------------------------------------+
Three rules govern this picture:
- Panels never call drivers directly. They call
SessionManagermethods or read fromApplicationState. Direct driver access from inside a paint loop is the single most common way Qt apps hang. - DriverSession instances live exclusively on their worker thread. They are never touched from the UI thread. Cross-thread communication is via Qt's signals/slots with queued connections.
- Application state changes are the only way panels learn about the world. No polling. Every change emits a signal; panels reactively update.
We considered qasync (running an asyncio loop inside Qt's event loop)
and rejected it for v1. Reasons:
- Most protocol stacks we depend on (
pymodbus,bacpypes3,pyiec61850) have idiomatic synchronous APIs; their async APIs are either not stable or not portable. - Qt's signal/slot machinery already gives us thread-safe callbacks, cancellation, and back-pressure. Adding asyncio on top doubles the concurrency primitives without doubling the safety.
- QThread + queued connections is well-understood by Qt developers and by tooling. asyncio + Qt is not.
A driver MAY use asyncio internally inside its worker thread (run its own loop, await on its own coroutines) — that is a private detail. From the outside it looks like any other synchronous worker.
Each active session runs on its own QThread. A DriverWorker is a
QObject that lives on that thread, owns the underlying
DriverSession, and exposes slots for the operations the UI can
request:
class DriverWorker(QObject):
# Signals (emitted on the worker thread, received on the UI thread)
discovery_progress = Signal(DeviceRef) # progressively yielded
discovery_finished = Signal(int) # n devices found
objects_enumerated = Signal(list) # list[ObjectRef]
read_completed = Signal(ReadResult)
write_authorized = Signal(WriteIntent) # before transmit
write_completed = Signal(WriteResult)
error_raised = Signal(str, str) # (operation, message)
# Slots (invoked from the UI thread via queued connection)
@Slot(str)
def start_discovery(self, target: str): ...
@Slot(DeviceRef, SessionProfile, str)
def open(self, device, profile, operator): ...
@Slot(ObjectRef)
def read(self, ref): ...
@Slot(ObjectRef, object)
def prepare_write(self, ref, value): ...
@Slot(WriteIntent)
def commit_write(self, intent): ...
@Slot()
def close(self): ...The worker NEVER raises exceptions across the thread boundary. It
catches everything internally and emits error_raised instead. Panels
treat error_raised like any other state change.
A pending discovery, read, or capture must be cancellable. This is done
through a per-worker QAtomicInt cancel flag the worker checks at
yield points:
def start_discovery(self, target):
self._cancel.storeRelease(0)
for device in self._driver.discover(target):
if self._cancel.loadAcquire():
break
self.discovery_progress.emit(device)
self.discovery_finished.emit(...)The UI calls worker.cancel() (a slot that flips the flag). It does
NOT call QThread.terminate() — terminating a thread mid-write would
defeat the safety story, since the audit log would not record the
outcome.
This is subtle. SafetyContext.require_write_authorization() MUST
block the calling thread (the worker), but the confirmation dialog MUST
show on the UI thread. We resolve this by making the confirm callback
do a thread hop:
worker.commit_write(intent)
-> safety.require_write_authorization(intent)
-> confirm_callback(intent, profile) # on worker thread
-> QMetaObject.invokeMethod(
gui_confirm_handler, "ask",
Qt::BlockingQueuedConnection, # blocks worker thread,
Q_ARG(WriteIntent, intent), # runs slot on UI thread,
Q_RETURN_ARG(bool, result)) # returns the answer
-> dialog.exec() on UI thread
-> returns True/False to worker
-> safety records audit row, returns result
-> worker either transmits or raises AuthorizationDenied
BlockingQueuedConnection is the Qt mechanism that makes this work.
It is the only place we use it; everywhere else, queued connections
are non-blocking.
- One thread per device on a multi-drop bus. Modbus RTU and IEC 61850 GOOSE share a transport; the worker for that transport multiplexes internally. The threading model is one worker per session, not per device.
- A thread for the audit log. Audit writes are SQLite calls; they are fast and synchronous on the session's worker thread. The WAL journal is what makes this not block visibly.
ApplicationState is a single QObject instantiated by MainWindow
and passed to every panel that needs it. Panels do not import it as a
module-level singleton; they receive it via constructor injection so
tests can substitute a fake.
class ApplicationState(QObject):
# Discovery / connection lifecycle
device_discovered = Signal(DeviceRef)
device_lost = Signal(DeviceRef)
session_opened = Signal(SessionId, DeviceRef, SessionProfile)
session_closed = Signal(SessionId)
session_failed = Signal(SessionId, str)
# Per-session state
objects_enumerated = Signal(SessionId, list) # list[ObjectRef]
read_completed = Signal(SessionId, ReadResult)
write_intent_prepared = Signal(SessionId, WriteIntent)
write_completed = Signal(SessionId, WriteResult)
write_denied = Signal(SessionId, WriteIntent)
# Watchlist
watchlist_changed = Signal() # add/remove items; fine-grained later
# Capture
frame_captured = Signal(SessionId, CapturedFrame)
# Misc
error_raised = Signal(str, str) # (operation, msg)
profile_changed = Signal(SessionId, SessionProfile)State is held in plain dataclasses on the object; signals announce
mutations. Panels mutate state ONLY by calling SessionManager
methods — they never write to the dataclasses directly. This keeps the
mutation paths auditable and centralises the logging.
Qt's signal model already gives us most of Redux's benefits (single source of truth, observable mutations) without the boilerplate. Adding a Redux-style store would force every action through serialisation that Qt's signal system handles natively. We revisit if and when we introduce undo/redo (which would benefit from a transaction log).
Three categories:
| Persisted | Where | When |
|---|---|---|
| Window geometry, dock layout | QSettings |
App close |
| Recent connections, default profile | QSettings |
App close |
| Watchlist contents per profile | JSON in ~/.config/protoskipper/watchlists/ |
Manual save |
| Audit logs | per-session SQLite WAL | Live |
| Captures | per-session pcapng | When user clicks Save Capture |
We deliberately do NOT auto-persist watchlists or captures. Operators should explicitly choose to save substation telemetry.
We use Qt's Model-View architecture for every list and tree:
| View | Model | Data |
|---|---|---|
| Device tree | DeviceTreeModel(QAbstractItemModel) |
Protocols → Devices → Sessions → Objects |
| Watchlist | WatchlistModel(QAbstractTableModel) |
Tag, Value, Quality, Updated |
| Packet log | PacketLogModel(QAbstractTableModel) |
Time, Direction, Protocol, Length, Decoded |
| Object browser | ObjectBrowserModel(QAbstractTableModel) |
ID, Type, Access, Unit, Label |
Models subscribe to ApplicationState signals and emit
dataChanged / rowsInserted / rowsRemoved accordingly. Views are
stock QTreeView / QTableView instances — no custom painting in v1.
Convenience APIs (QStandardItemModel, QTreeWidget) are tempting but
copy data. With a real QAbstractItemModel the model is a thin
projection over ApplicationState; updates are O(1) and the underlying
datastore stays single-sourced.
Watchlist rows colour by Quality:
| Quality | Colour |
|---|---|
| GOOD | default text |
| UNCERTAIN | amber text |
| BAD / TIMEOUT | red text |
| SIMULATED | blue text + italic |
| UNKNOWN | grey text |
Colours come from a central palette in gui/theme.py, NOT hardcoded in
the model. Themes are swappable.
+--------------------------------------------------------------------------------+
| File Session View Tools Help | menu bar
+--------------------------------------------------------------------------------+
| [+New Connection] [Disconnect] Profile: LAB ▾ Audit: ~/.protoskipper/... | toolbar
+----------------+--------------------------------+------------------------------+
| | Object Browser | Packet View | Watchlist |
| Device | | | ┌────┬───────┬──────┬─────┐ |
| Tree | Tag Type | Time Dir | │Tag │Value │Qual │When │ |
| | ───── ───── | ──── ─── | ├────┼───────┼──────┼─────┤ |
| ▾ Protocols | hold:0 uint16 | 10:00 TX→ | │... │ │ │ │ |
| ▾ Modbus TCP| hold:1 uint16 | 10:00 ←RX | │ │ │ │ │ |
| ▾ 10.0.0.5| | | │ │ │ │ │ |
| SESSION | | | │ │ │ │ │ |
| hold:0| | | │ │ │ │ │ |
| hold:1| | | │ │ │ │ │ |
| | | | │ │ │ │ │ |
| +-----------------+--------------+------------------------------+
| | Scripting REPL |
| | >>> drivers['modbus.tcp'].discover('10.0.0.0/24') |
| | ... |
| | |
+----------------+---------------------------------------------------------------+
| Profile: LAB | 2 driver(s) loaded | Audit: 0 rows this session | status bar
+--------------------------------------------------------------------------------+
Panel docking is user-configurable; the layout above is the default.
The state of every dock is persisted via saveState() / restoreState().
+ New Connection -------------------------------------------+
| Step 1 / 3 - Protocol |
| |
| ( ) Modbus TCP Modbus over TCP/IP, RFC 6815 |
| (•) Modbus RTU Modbus over serial RS-485 / RS-232 |
| ( ) BACnet/IP |
| ( ) IEC 60870-5-104 |
| ( ) IEC 61850 MMS |
| |
| [ Cancel ] [ < Back ] [ Next > ]|
+-----------------------------------------------------------+
+ New Connection -------------------------------------------+
| Step 2 / 3 - Target |
| |
| Address: [ 10.0.0.5:502/unit=1 ] |
| Label: [ Feeder-1 RTU ] |
| |
| [ Test connection ] Status: ✓ responded (12 ms) |
| |
| [ Cancel ] [ < Back ] [ Next > ]|
+-----------------------------------------------------------+
+ New Connection -------------------------------------------+
| Step 3 / 3 - Session |
| |
| Profile: ( ) LAB single-click writes |
| (•) COMMISSIONING confirm dialog per write |
| ( ) PRODUCTION type tag name to confirm |
| |
| Operator: [ kuldeep@datasailors.io ] |
| Audit: [ ~/.protoskipper/audit/2026-05-01.../ ] |
| |
| ⚠ COMMISSIONING profile cannot be relaxed for the life |
| of this session. |
| |
| [ Cancel ] [ < Back ] [ Connect ] |
+-----------------------------------------------------------+
+ Write to holding[40001] ----------------------------------+
| Profile: COMMISSIONING |
| |
| Target: 10.0.0.5:502 / unit=1 / holding[40001] |
| Tag: FEEDER1.SETPT.kV |
| Type: uint16 |
| Unit: kV |
| |
| Current: 228 |
| New: 230 |
| Diff: +2 |
| |
| Wire bytes: 06 01 00 00 00 E6 09 6E |
| |
| ⚠ This write will be transmitted immediately on confirm. |
| It cannot be undone from ProtoSkipper. |
| |
| [ Cancel ] [ Confirm Write ] |
+-----------------------------------------------------------+
In PRODUCTION profile the same dialog has an additional input:
| Type the tag name to confirm: |
| [ FEEDER1.SETPT.kV ] |
+-----------------------------------------------------------+
with the Confirm button disabled until the typed text matches.
File > Capture > Start starts capturing the active session
File > Capture > Stop stops, prompts to save .pcapng
File > Capture > Open Replay… loads a .pcapng for inspection (no transmit)
File > Capture > Verify Audit… runs verify_log() on a chosen audit file
The packet view shows captured frames live during a capture and is also the inspection view for replays. Replays disable all write paths.
User clicks [+New Connection]
→ NewConnectionDialog shows
→ User picks protocol, enters target, picks profile, types operator
→ Dialog calls SessionManager.discover(protocol_id, target)
→ SessionManager creates a transient DriverWorker on a QThread
→ Worker emits discovery_progress signals as devices appear
→ Dialog shows "Test connection" result
→ User clicks Connect
→ SessionManager.open_session(...) creates a permanent worker,
calls open_session() (core), gets a Session object on the worker thread
→ ApplicationState emits session_opened
→ DeviceTreePanel adds a node under Protocols → Modbus TCP → 10.0.0.5
→ ObjectBrowserPanel populates from objects_enumerated signal
User selects an object in the tree
→ ObjectBrowserPanel displays its row in the table
→ User clicks "Read now" (or presses F5)
→ Panel calls SessionManager.read(session_id, ref)
→ Worker calls driver_session.read(ref) on its thread
→ Worker emits read_completed
→ ApplicationState forwards read_completed
→ ObjectBrowserPanel updates the cell
→ PacketLogModel (subscribed to frame_captured) shows the bytes if
capture is on
User right-clicks an object → "Write..."
→ WriteDialog opens with object metadata + a value input
→ User enters value, clicks Prepare
→ Dialog calls SessionManager.prepare_write(session_id, ref, value)
→ Worker calls driver_session.prepare_write — no transmission
→ Worker emits write_intent_prepared
→ Dialog renders the WriteIntent: bytes, target, diff vs current
→ User clicks Confirm Write (or types the tag in PRODUCTION)
→ Dialog calls SessionManager.commit_write(session_id, intent)
→ Worker calls driver_session.commit_write
→ SafetyContext.require_write_authorization()
→ confirm_callback() runs a BlockingQueuedConnection back to the
UI thread, which re-shows the same dialog as a final yes/no
→ audit log records the authorization outcome
→ If authorized, driver transmits
→ Worker emits write_completed
→ ApplicationState updates the object's last-known value
→ Dialog closes
The two-prompt UX (Prepare → Confirm) is intentional. The first prompt shows the operator the full picture; the second is the safety net the audit log records as the explicit authorisation event.
User drags an ObjectRef from the object browser to the watchlist
→ WatchlistPanel adds the row, ApplicationState records the membership
→ User clicks "Refresh" on the watchlist toolbar
→ Panel iterates rows, dispatches read() per row through SessionManager
→ Each read_completed signal updates the corresponding row
→ Quality-coded coloring applied via the role on the model
Live polling (configurable interval) is a follow-up; v1 ships manual refresh only, by design — operators should explicitly trigger reads on production substations.
User clicks the cancel button on a long discovery
→ Toolbar calls SessionManager.cancel(session_id)
→ SessionManager calls worker.cancel() (sets the QAtomicInt flag)
→ Worker exits its yield loop on the next check
→ Worker emits discovery_finished with the partial count
→ Tree panel shows "(cancelled, N found)"
User clicks Disconnect
→ SessionManager.close_session(session_id)
→ Worker.close() runs DriverSession.close() and AuditLog.close()
→ Thread quits, joined within 5s or hard-killed (last resort)
→ ApplicationState emits session_closed
→ Tree panel removes the session node
A protocol plugin MAY ship custom panels. They are registered through a second entry-point group:
[project.entry-points."protoskipper.gui_contributions"]
"iec61850" = "protoskipper_iec61850.gui:Iec61850Contributions"Iec61850Contributions returns a list of PanelContribution objects
the GUI inserts into a "Plugins" menu and into a special "Plugin
Panels" tab area. Contributions get:
- read access to
ApplicationState, - a way to dispatch operations through
SessionManager(the same way built-in panels do), - a stable Qt 6 widget host.
They do NOT get direct access to driver instances (same rule as built-in panels).
A driver call can fail at three layers:
| Layer | Surfaced as | Display |
|---|---|---|
Encoding (EncodingError) |
Returned via error_raised signal |
Inline in the dialog that triggered it |
Transport (ConnectionFailure) |
Returned via error_raised |
Status bar + dialog if user-initiated |
Authorization (AuthorizationDenied) |
Returned via write_denied signal |
Dialog with a clear "denied by safety profile" message |
Generic Exception subclasses that escape a worker (we have a bug)
get caught by the worker's try/except and re-emitted as error_raised
with a "Internal error - please report" prefix.
The status bar always shows the last error with a clickable "View details" that opens a log dialog.
We ship two themes initially (Light, Dark) in gui/theme.py. The
theme defines:
- a 9-step neutral palette,
- a 5-step accent palette (for the toolbar profile chip),
- a quality palette (good/uncertain/bad/simulated/unknown),
- font sizing with two density modes ("Comfortable" and "Compact" - Compact for small laptops in the field).
We do NOT ship custom widget styles. The native style is used on each OS; only colours and densities differ between themes. This is the single biggest deviation from the typical Electron app: ProtoSkipper should look like a native Win32 / Cocoa / GTK app first and a branded app second.
Iconography is from a single icon font (Material Symbols) bundled
with the app. No raster icons.
- All actionable widgets have keyboard shortcuts and proper tab order.
- Menus and toolbar buttons set
setAccessibleName()so screen readers announce the right thing. - Strings are wrapped in
self.tr()from day one. We do not ship translations in v1 but the infrastructure is ready.
| Platform | Tool | Output |
|---|---|---|
| Windows | PyInstaller + Inno Setup | ProtoSkipper-Setup-x64.exe (signed) |
| macOS | PyInstaller + create-dmg + notarytool | ProtoSkipper.dmg (notarized) |
| Linux | Briefcase or AppImage | ProtoSkipper.AppImage + .deb / .rpm |
Plugin packages are pure Python and pip install-able into the same
Python environment the bundled app uses (we expose the bundled
interpreter at protoskipper --bundled-pip install …).
| Layer | Tool | Scope |
|---|---|---|
| Core | pytest |
Already done — driver contract, audit, session |
| GUI logic | pytest-qt |
Models, services, dialogs (without showing windows) |
| GUI smoke | pytest-qt + QApplication |
Construct main window, click buttons, assert state |
| End-to-end | manual + Modbus simulator | Real connection to tests/integration/modbus_simulator.py |
| Visual regression | not in v1 | Add later if we feel pain |
The Modbus simulator (in tests/integration/) is a real pymodbus
slave on localhost:5020 with a small register map. A pytest fixture
starts and stops it; a CLI command (protoskipper sim modbus) exposes
it for manual demos.
We do NOT mock Qt. If a test cannot work without a real Qt event loop,
it runs against a real one. pytest-qt makes this cheap.
- Plugin marketplace / discovery beyond entry points (out of scope until community uptake).
- Mobile or tablet variant (decided against; target is desktop on a field-engineering laptop).
- Localisation pipeline beyond the
tr()wrapping infrastructure. - Visual regression / theming snapshot tests.
These get their own design docs when they become real concerns.
Document owner: ProtoSkipper maintainers (DataSailors Pvt Ltd). Changes to this document are PRs in the same repo.