Architecture¶
Machine Data Browser (repository MachineDataBrowser) is a machine data viewer. It connects to one device at a time:
- an OPC UA server,
- an Allen-Bradley Logix controller over EtherNet/IP (CIP), or
- an MQTT broker, including Sparkplug B.
Whichever device it is, you browse its address space, inspect attributes, watch live values, record them and export them. It writes to a device only on an explicit user action (Write value…), through IDeviceClient.WriteValueAsync.
Overview¶
flowchart LR
subgraph App["MachineDataBrowser.App (Avalonia)"]
Views["Views<br/>Address Space · Attributes · Watch · Recordings · Recording viewer"]
VM["MainWindowViewModel<br/>(+ .Session, .Recordings)"]
Services["Services<br/>settings · layout · sessions · AppErrors"]
Views --> VM
VM --> Services
end
subgraph Core["MachineDataBrowser.Core (protocol-neutral, no UI or protocol SDKs)"]
IDC{{"IDeviceClient"}}
Rec["Recording contracts<br/>options · samples · target interfaces"]
Fmt["ValueFormatter · NodeTree"]
Snap["Snapshots<br/>store · CSV · diff · read"]
Rec --> IDC
end
subgraph Recording["Recording implementations"]
Engine["MachineDataBrowser.Recording<br/>capture · bounded history"]
Csv["MachineDataBrowser.Recording.Csv<br/>CSV read · write · export"]
Json["MachineDataBrowser.Recording.Json<br/>JSON export"]
Sqlite["MachineDataBrowser.Recording.Sqlite<br/>SQLite read · write · query"]
Formats["MachineDataBrowser.Recording.Formats<br/>target composition"]
Formats --> Engine
Formats --> Csv
Formats --> Json
Formats --> Sqlite
end
subgraph Drivers["Protocol drivers"]
Ua["MachineDataBrowser.Driver.OpcUa<br/>OpcUaClient"]
Cip["MachineDataBrowser.Driver.Cip<br/>CipClient"]
Mqtt["MachineDataBrowser.Driver.Mqtt<br/>MqttDeviceClient"]
end
Composition["MachineDataBrowser.Drivers<br/>DeviceClientFactory"]
Composition --> Ua
Composition --> Cip
Composition --> Mqtt
Ua -. implements .-> IDC
Cip -. implements .-> IDC
Mqtt -. implements .-> IDC
VM --> IDC
VM --> Rec
VM --> Engine
VM --> Formats
Engine --> Rec
VM --> Fmt
VM --> Snap
Ua -- "opc.tcp:// (OPC Foundation UA-.NETStandard)" --> S1[(OPC UA server)]
Cip -- "eip:// (libplctag)" --> S2[(Logix controller)]
Mqtt -- "mqtt:// mqtts:// ws:// wss:// (MQTTnet)" --> S3[(MQTT broker)] | Project | Role |
|---|---|
src/MachineDataBrowser.Core | Protocol-neutral device and recording contracts, value formatting, shared node tree, and snapshots (Snapshots/: the JSON store in the data folder, CSV, the diff and one-off device reads), shared by the app's Compare with Snapshot and the MCP snapshot tools. No UI, protocol SDK, or recording-format dependencies. |
src/MachineDataBrowser.Recording | Format-independent recording lifecycle, monitoring and bounded in-memory history. Depends on Core abstractions. |
src/MachineDataBrowser.Recording.Csv | CSV live writer, export and incremental reader. Depends on Core abstractions. |
src/MachineDataBrowser.Recording.Json | JSON recording export. Depends on Core abstractions. |
src/MachineDataBrowser.Recording.Sqlite | SQLite live writer, incremental reader and read-only query API. Depends on Core abstractions and Microsoft.Data.Sqlite. |
src/MachineDataBrowser.Recording.Formats | Composes recording targets by file extension and exposes common readers and exports to clients. |
src/MachineDataBrowser.Driver.OpcUa | OPC UA client, SDK adapter, and UA-aware value JSON/C# export. References Core. |
src/MachineDataBrowser.Driver.Cip | EtherNet/IP (Logix) client. References Core. |
src/MachineDataBrowser.Driver.Mqtt | MQTT client, topic model and Sparkplug B support. References Core. |
src/MachineDataBrowser.Drivers | Composition project selecting a driver from an endpoint URL; referenced by app and CLI. |
src/MachineDataBrowser.App | Avalonia 12 desktop app (MVVM with CommunityToolkit.Mvvm, docking with Dock.Avalonia); uses the driver composition project. |
src/MachineDataBrowser.Cli | mdbrowser command line (cli.md): System.CommandLine parsing, Spectre.Console output in a terminal; Tui/ is the full-screen browser on Terminal.Gui (a UI-free BrowserModel behind a TuiApp view). References Core and the driver composition project; colour themes come from Core's ColorThemeCatalog, shared with the app. |
tests/MachineDataBrowser.Core.Tests | Integration tests against containers (opc-plc and the simulators/ images, via Testcontainers) plus unit tests. |
tests/MachineDataBrowser.App.Tests | Headless Avalonia UI tests with real rendering (screenshots) against the same containers. |
tests/MachineDataBrowser.Cli.Tests | The CLI's commands end to end against opc-plc and the simulators, plain and terminal output; the TUI's model against every protocol and its screen headlessly (ANSI driver, virtual time). |
simulators/ | Test servers for development and tests: Logix, opc-plc, OPC UA custom types, MQTT. See simulators.md. |
Every project's root namespace is its project name (e.g. MachineDataBrowser.Driver.OpcUa); the recording engine's class is RecordingSession so that it does not clash with the MachineDataBrowser.Recording namespace.
The target is .NET 10. Package versions are pinned in Directory.Packages.props. Warnings are errors and the recommended .NET analyzers are on.
The protocol boundary: IDeviceClient¶
The client implementations share a protocol-neutral tree, value, and monitoring contract. DeviceNodeId, DeviceNodeKind, DeviceStatusCode and DeviceError describe identity, browse kinds and failures without SDK types. DeviceDataType, DeviceArray, ValueParser and ValueFormatter likewise avoid OPC UA BuiltInType and Variant. DeviceArray preserves a protocol's array element type when the CLR representation alone is ambiguous (e.g. Logix USINT[] and OPC UA ByteString both use bytes). Conversion to and from OPC UA SDK types is isolated in the OPC UA driver; CIP and Sparkplug map their native identities, statuses and value types directly to the neutral types. UA-aware value serialization and export also live in the OPC UA driver. The UA adapter retains namespace-URI-based portable IDs, so existing session, bookmark and recording identifiers remain compatible. DeviceClientFactory.Create(url) picks the client from the URL scheme; Core defines the client contract and never constructs a driver. Session files need no protocol field.
classDiagram
class IDeviceClient {
<<interface>>
+State ConnectionState
+Root BrowseItem
+ConnectAsync(options)
+DisconnectAsync()
+BrowseAsync(nodeId)
+GetPathFromRootAsync(nodeId)
+ReadAttributesAsync(nodeId)
+ReadValuesAsync(nodeIds)
+MonitorManyAsync(nodeIds, onUpdate, refreshMs)
+ChangeRefreshAsync(handles, onUpdate, refreshMs)
+ToPortableId(nodeId) / ParsePortableId(text)
+StateChanged event
}
class IDynamicAddressSpace {
<<interface>>
+AddressSpaceChanged event
}
class IPausableDiscovery {
<<interface>>
+SetDiscoveryPausedAsync(paused)
}
class OpcUaClient {
Session · subscriptions per refresh time
ComplexTypeSystem · reconnect handler
}
class CipClient {
@tags / @udt listing · poll groups
}
class MqttDeviceClient {
MqttModel (topics, JSON, Sparkplug B)
reconnect loop
}
IDeviceClient <|.. OpcUaClient
IDeviceClient <|.. CipClient
IDeviceClient <|.. MqttDeviceClient
IDynamicAddressSpace <|.. MqttDeviceClient
IPausableDiscovery <|.. MqttDeviceClient
class RecordingSession {
MonitorManyAsync(items)
bounded buffers · live CSV
}
RecordingSession --> IDeviceClient OPC UA (opc.tcp://) | EtherNet/IP (eip://host[:port]/1,0) | MQTT (mqtt://host[:port][/filter]) | |
|---|---|---|---|
| Library | OPC Foundation UA-.NETStandard | libplctag.NET | MQTTnet |
| Tree | server address space (forward hierarchical references) | Controller tags / Programs ▸ tags ▸ UDT members, array elements | Topics (topic levels, JSON fields) and Sparkplug B (group ▸ edge node ▸ device ▸ metrics) |
| Node ids | nsu=<namespace URI>;… | Logix tag path, e.g. Program:Main.Motor[2].Speed | t:<topic>[#<JSON pointer>], m:<group>\|<edge>\|<device>\|<metric> |
| Values | read and subscriptions (server pushes) | polled per refresh time, only changes are reported | pushed per message; the refresh time is a maximum update rate (0 = every message) |
| Subscriptions | per refresh time | none (polling) | the topic filter; with discovery paused (IPausableDiscovery) only the monitored topics, and for Sparkplug metrics their edge node and device |
| Structures | decoded with the server's type definitions | UDT templates (@udt/<id>) | JSON payloads, Sparkplug B protobuf |
| Status | server status codes | Bad… from libplctag errors | BadNoCommunication while a Sparkplug node or device is dead |
Shared helpers live in DeviceClientExtensions: MonitorAsync, CollectVariablesAsync and ReadTreeAsync. Stopping monitors is part of IDeviceClient, allowing drivers to batch teardown without Core knowing their concrete types.
Values: from the device to the screen¶
Values arrive on library threads, as SDK callbacks, poll loops or MQTT messages. They never touch the UI directly. Each consumer takes what it needs:
sequenceDiagram
participant D as Device / broker
participant C as IDeviceClient
participant W as Watch (MainWindowViewModel)
participant R as RecordingSession
participant UI as UI thread (200 ms flush)
D->>C: notification / poll result / MQTT message
C->>W: onUpdate(ValueUpdate) — latest value per item kept in a buffer
C->>R: onUpdate(ValueUpdate) — appended to the item's bounded history (+ live CSV)
UI->>W: flush timer takes the buffered latest values
W-->>UI: Watch rows update (value, status, since, stale/bad)
Note over R: the recording viewer polls samples every 500 ms<br/>and mirrors the recording's limits (max points, max age) ValueUpdatecarries formatted text, status, source and server timestamps, a numeric value when there is one (for charts) and the raw value (for JSON and copying).ValueFormatter,ValueJsonandNodeExportturn values and subtrees into display text, JSON, and C# classes or records. The neutral formatter handles ordinary CLR values; the UA adapter preserves UA-specific formatting for structures, matrices, and built-in wrapper types without exposingVariantat shared boundaries.
Connection lifecycle¶
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connecting: Connect (off the UI thread)
Connecting --> Connected
Connecting --> Disconnected: error shown in the error bar
Connected --> Reconnecting: keep-alive / poll / broker lost
Reconnecting --> Connected: automatic reconnect
Reconnecting --> Disconnected: could not restore
Connected --> Disconnected: Disconnect / quit - OPC UA: a failed keep-alive starts
SessionReconnectHandler, and subscriptions are transferred to the new session. - EtherNet/IP: failed polls switch the state to
Reconnectinguntil a read succeeds; libplctag reconnects by itself. - MQTT: a lost broker starts a reconnect loop every 2 s, which subscribes again. Monitors stay registered, so Watch and recordings resume.
OPC UA (Ua.OpcUaClient)¶
- Connect:
- Picks the endpoint;
UseSecuritychooses the most secure one. - Anonymous or user/password login.
- Creates the application certificate on first use.
- Server certificates: without auto-trust, an untrusted certificate fails the connect and is kept in
IServerCertificateTrust.LastUntrustedCertificate. The App shows it and connects again with its thumbprint inConnectOptions.AcceptedCertificateThumbprints(once), or afterTrustPermanentlywrote it topki/trusted/certs. - Does not load the complex type definitions up front: on a server with hundreds of controller UDTs that took ~40 s.
OpcUaClient.Structures.csloads a structure's type (ComplexTypeSystem.LoadTypeAsync, field types first) the first time a read, monitored item, history read or method output carries it undecoded, then decodes those bytes. Monitored updates that wait for a type are queued per item, so they stay in order. - Browse: follows hierarchical forward references. The tree uses
BrowseQuickAsync(one round trip, every child gets an expander) and thenProbeHasChildrenAsyncin the background (one batched Browse), which removes the expanders of children that have nothing below them. - Connection tabs (
MainWindow.Tabs.cs,ConnectionTabs): each tab is its ownMainWindowViewModel. The window's DataContext is the active one (header, menu); each connection gets its ownDockControl, created on first show and kept while other tabs are shown, because pane views bind their shortcuts and state to their view model once. - Themes (
Services/ColorThemes.cs): the colours live in named tokens (App*BrushinAppStyles.axamland the FluentColorPaletteResources), each with a Light and a Dark value. A colour theme is data: one set of colours per appearance. Applying it recolours the shared brush instances and the palettes in place, soDynamicResourceusers, Dock and DataGrid follow at once;RequestedThemeVariantstill switches light/dark. - Address space view:
FlatTreeflattens the expanded nodes into rows for a virtualizedListBox(Avalonia'sTreeViewbuilds every expanded node). Browses run on the pool with a 30 s timeout; a failure leaves an error row that retries on click. Folders collapsed for 10 minutes drop their children and are browsed again when expanded. - Monitoring:
MonitorManyAsynccreates many items in one round trip. Items with the same refresh time share a subscription (Watch@<ms>);ChangeRefreshAsyncmoves items between these subscriptions. - Portable ids:
nsu=<namespace URI>, because namespace indexes can change between server restarts. - Methods (
OpcUaClient.Methods.cs,IMethodCaller): the InputArguments/OutputArguments properties are found by browsing the method (not every server resolves the browse path) and read asArgument[]. Text inputs are parsed withValueParserinto each argument's built-in type; a bad call reports the first rejected argument. - Monitoring settings (
OpcUaClient.Monitoring.cs,IMonitoringSettings): sampling interval, queue size, discard policy and a DataChangeFilter deadband applied to existing items in place (ModifyMonitoredItems). Rejected items are restored to their previous settings. The App re-applies a row's settings afterChangeRefreshAsyncand when a session is opened (WatchEntry.Monitoring). - History (
OpcUaClient.History.cs,IHistorySource): HistoryRead with ReadRawModifiedDetails in pages of 1 000, following continuation points up to a value cap and releasing the last one. The App shows the result in the recording viewer (RecordingViewerViewModel.ForRows). - Events (
OpcUaClient.Events.cs,IEventSource): one subscription per Events & Alarms window with an EventNotifier monitored item. The EventFilter selects the BaseEventType fields plus ConditionName, Retain, ActiveState/Id, AckedState/Id and the condition's NodeId;ConditionRefreshresends active alarms. Acknowledge callsAcknowledgeableConditionType.Acknowledgeon the condition with the latest EventId. Notifications are queued on SDK threads and applied every 250 ms on the UI thread (EventsViewModel).
EtherNet/IP (Cip.CipClient)¶
- Scope: Logix only, because tag listing via
@tagsand@udt/<id>exists only there. - Tree:
Controller Tags/Programs▸ program ▸ tags. - Atomics, atomic arrays and STRING-like UDTs are variables.
- Structures and arrays of structures are objects, with member and element children (up to 1000 elements).
- Hidden BOOL host members are skipped.
- Monitoring: one poll loop per refresh time. It only reports changes.
- Tag creation: tags are created synchronously on a pool thread, because libplctag.NET's async creation leaks failed tags (see decisions.md).
MQTT (Mqtt.MqttDeviceClient)¶
flowchart TB
Broker[(Broker)] -- "subscribe <filter> (default #)" --> Client[MqttDeviceClient]
Client --> Model[MqttModel]
Model --> Topics["Topics<br/>t:plant/hall1/press1/temperature<br/>t:machines/m1/status#/temperature/bearing"]
Model --> Spb["Sparkplug B (spBv1.0/…)<br/>group ▸ edge node ▸ device ▸ metric<br/>m:Plant1|Edge1|Press1|Motor/Speed"]
Client -- "AddressSpaceChanged" --> Tree[App refreshes expanded tree nodes] - Discovery: MQTT has no browse service. The client subscribes to one topic filter and builds the tree from the messages it receives.
- The filter is
#by default. Narrow it with the endpoint path (mqtt://broker/plant/#) or with?topic=. - Retained messages show up straight away; other topics appear when they publish.
- At most 20 000 topics are kept (
MaxTopics); messages on new topics beyond that are counted as dropped. - Payloads: each payload is decoded as a number, boolean, text, JSON or binary.
- JSON objects and arrays become child nodes, addressed by JSON pointer.
- A topic can carry its own payload and also have subtopics, e.g.
plant/hall1. - Sparkplug B (
SparkplugB.cs): a small hand-written protobuf reader for the Sparkplug B payload. - BIRTH messages define metric names, aliases and data types; DATA messages update values by name or alias.
- DEATH messages turn all metrics of that node or device
BadNoCommunication. - A
/in a metric name becomes a folder, e.g.Motor/Speed. - Values published under an alias whose BIRTH was missed show as
alias <n>until the next birth. The client does not send rebirth requests. - The raw
spBv1.0/…topics are not listed under Topics. - Live tree: the client implements
IDynamicAddressSpace. The App re-browses expanded nodes about once a second after the address space changes, and merges the result so expansion and selection survive. - Monitoring: the refresh time is a maximum update rate per item. The item gets the first message at once, then at most the latest value once per interval, like an OPC UA sampling interval; 0 delivers every message. The current value (for example a retained message) is delivered as soon as monitoring starts.
- Connection: MQTT 5 over TCP, TLS (
mqtts://) or WebSocket (ws://,wss://). - User name and password come from the connection options or the URL.
- "Auto-trust server certificates" also applies to TLS brokers.
Session file changes (Core/SessionFileWatcher.cs)¶
- The app,
mdbrowser runandmdbrowser tuieach watch their open session file, so a change made elsewhere (another window or instance,session add/remove, an editor) reaches them. - File events are debounced and compared by content hash; a process's own saves (
Accept()) are not reported. - The app and the TUI reload the session, asking first when that would lose unsaved changes or a recording.
runswaps its watch list in place, keeping its connection when the endpoint options didn't change.
Recordings (Core/RecordingContracts.cs, Recording/RecordingSession.cs)¶
- A recording monitors its own items through the client, independently of the Watch list.
- History is bounded per item: max points and max age.
- It can pause, resume, reset, start on a schedule and stop automatically. All timing uses
TimeProviderand is tested withFakeTimeProvider. - Core contains recording options, samples, snapshots and target interfaces. The format-independent recording engine is in
MachineDataBrowser.Recording; it depends on Core rather than concrete storage formats. - Optional live file: every sample, independent of the in-memory limits. The format composition selects CSV or SQLite (
.db,.sqlite,.sqlite3). CSV writing/export/reading, JSON export and SQLite writing/reading/querying live in their respective projects. Samples are queued and written in batches by one task: one flush (CSV) or one transaction (SQLite, WAL mode) per batch. AddItemsAsyncadds items while the recording runs.UpdateOptionsAsyncchanges the refresh time, limits, schedule and live file in place.- CSV and JSON exporters consume a recording snapshot. The format composition's reader factory returns a reader for either CSV or SQLite and returns what was added since the last call, so the viewer can follow a growing file.
App (MachineDataBrowser.App)¶
- View model:
MainWindowViewModelowns the client, the tree (NodeViewModel), the watch list (WatchItemViewModel), recordings, settings and sessions. - Panes: Dock tools for Address Space, Attributes, Watch and Recordings; the recording viewer is its own window.
- Keyboard:
Shortcutsregisters per-pane shortcuts. A pane's shortcuts work when you click in it or point at it. They are also shown in context menus, in tooltips and in Help ▸ Keyboard Shortcuts.GridCopyadds "copy as table" to every grid. - Errors:
AppErrorshandles every unhandled exception, whether on the UI thread, in an unobserved task or on a background thread. It logs the exception tologs/machinedatabrowser.logand shows it in the error bar instead of crashing. Library callbacks catch their own exceptions. - Quit: there is one "unsaved changes" prompt, and nothing stops while it is open. After confirming, a graceful shutdown stops recordings, flushes live files and closes the session (5 s timeout). Holding the quit shortcut skips the prompt.
- Persistence:
.mdbsessionfiles (.opcsessionstill read): endpoint, options, default refresh, watch list and columns. Never passwords.settings.json: theme, zoom, defaults, recent lists.layout.json: the pane arrangement.- All of this lives under the data folder:
~/Library/Application Support/MachineDataBrowseron macOS, orMACHINEDATABROWSER_DATA_DIR(or the olderOPCUABROWSER_DATA_DIR).ClientPathscopies the oldOpcUaBrowserfolder on first start.
Packaging¶
packaging/macos/package.shpublishes a self-contained, ad-hoc signed app bundle for each architecture..github/workflows/release.ymlruns it onv*tags, publishes the GitHub release and updates the Homebrew cask.