Skip to content

Decisions

Short record of the choices behind the current stack. Facts were checked at the time (September 2026).

The app started as an OPC UA browser and is now a machine data viewer for OPC UA, EtherNet/IP (Logix) and MQTT.

Name: Machine Data Browser (renamed from OPC UA Browser after 0.8)

  • Everything carries the new name: repository, projects and namespaces, app bundle (Machine Data Browser.app, com.zaoralj.machinedatabrowser), Homebrew cask (machine-data-browser), data folder, session files (.mdbsession) and the OPC UA client identity (MachineDataBrowser). An earlier choice kept the old technical names to avoid breaking installs; the rename instead migrates:
  • Homebrew: the tap's cask_renames.json maps opcua-browser to machine-data-browser, so brew upgrade replaces the old cask and app.
  • Data: on first start the old OpcUaBrowser data folder is copied (left in place for older versions); OPCUABROWSER_DATA_DIR is still honoured.
  • Sessions: .opcsession files still open and save back to the same file.
  • Not migrated: the OPC UA application certificate. Its subject and application URI carry the name, so a new one is created and servers that trusted the old one must trust it once.

OPC UA SDK: OPC Foundation UA-.NETStandard

  • Licensed under the OPC Foundation MIT License (LICENSE.txt in the repository; the former GPL-2.0/RCL dual license no longer applies).
  • Mature client with security policies, reconnect/subscription transfer and runtime decoding of custom structures (Opc.Ua.Client.ComplexTypes), which a generic browser needs.
  • Alternatives considered: async-opcua (Rust, MPL-2.0, younger), open62541 (C, MPL-2.0; runtime structure decoding is manual), node-opcua (MIT, heavier runtime), Eclipse Milo (Java). No maintained MIT Rust SDK exists.

EtherNet/IP (Logix): libplctag.NET

  • libplctag (MPL-2.0) wraps the mature C libplctag and ships native binaries for macOS (x64/arm64), Windows and Linux.
  • Scope: Logix only (tag listing via @tags / @udt/<id> exists only there). Writes cover atomics, atomic arrays and STRINGs.
  • The OPC UA information model (NodeId, NodeClass, StatusCode) is kept as the shared vocabulary behind IDeviceClient instead of a new neutral model: the App, recordings and exports work unchanged and the refactor stays small. Tags map to ns=1;s=<tag path>, folders to ns=2.
  • The protocol is chosen by the endpoint URL scheme (eip://), so session files need no new field.
  • CIP has no subscriptions: monitoring polls per refresh time and reports only changes.
  • ab_server (libplctag's simulator) does not implement tag listing, so the repository has its own Logix simulator (simulators/cip, pure Python, see simulators.md) that the CIP integration tests run against.
  • Tags are created with the synchronous Initialize on a pool thread: libplctag.NET 1.5 InitializeAsync leaks the native tag and its callback when creation fails, which crashed the process on a later libplctag event.

MQTT: MQTTnet, with Sparkplug B

  • MQTTnet (MIT) supports MQTT 3.1.1 and 5, TLS and WebSocket on every platform the app targets.
  • The app subscribes, and publishes only when you write a value: a topic is republished with the same payload kind, retain flag and properties; a JSON field republishes the last document with the field changed; a Sparkplug B metric is sent as an NCMD/DCMD. It does not send rebirth requests, so metrics published by alias before the app saw the BIRTH show as alias <n> until the edge node's next birth.
  • Discovery by subscription: MQTT has no browse service, so the tree is built from received messages (topic filter # by default, narrowed by the endpoint path or ?topic=), with a cap of 20 000 topics. The tree refreshes live (IDynamicAddressSpace).
  • Sparkplug B is decoded by a small hand-written protobuf reader (SparkplugB.cs) instead of generated code: only the payload/metric fields the viewer shows are needed, and it avoids a protobuf toolchain in the build.
  • Payloads are decoded as number, boolean, text, JSON (fields become nodes, addressed by JSON pointer) or binary. CloudEvents (structured JSON and binary mode with MQTT 5 user properties) are recognised and labelled.
  • The refresh time stays meaningful for MQTT as a maximum update rate: the latest value at most once per interval. This keeps recordings of fast topics manageable. 0 delivers every message, e.g. for events where each one matters.

Resilience and threading

  • A global handler (AppErrors) logs every unhandled exception to logs/machinedatabrowser.log and shows it in the error bar; library callbacks (OPC UA notifications, reconnect, CIP polling, MQTT messages, recording timers) catch their own errors because an exception on a thread-pool thread would end the process.
  • Connect, disconnect and shutdown work runs off the UI thread; values reach the UI only through a 200 ms flush.

UI: Avalonia 12

  • Only mature .NET UI framework that runs natively on macOS (incl. Apple Silicon), Windows and Linux.
  • .NET MAUI has no official Linux support; a web UI cannot open opc.tcp sockets without a gateway; Qt's OPC UA module is GPL-only in the open-source tier.
  • Avalonia.Controls.TreeDataGrid 12.x requires a paid license key, so the tree is a TreeView and the tables use the MIT DataGrid.

Docking: Dock.Avalonia (MIT)

Rearrangeable panes with persistence. Floating by drag caused accidental pop-outs, so it is disabled and floating is an explicit command; every pane can always be restored from View ▸ Panes.

Distribution: self-contained app + Homebrew cask

No .NET installation required on the target Mac.

Signing and notarization

  • Releases are signed with a Developer ID Application certificate, with the hardened runtime and a secure timestamp, and notarized by Apple; the app has the ticket stapled, so Gatekeeper accepts it offline. The CLI binary is notarized too (a bare binary can't carry a ticket; Gatekeeper checks online on first run).
  • packaging/macos/sign.sh signs inside out: every file in Contents/MacOS (.NET keeps its managed .dll files there, and Apple treats everything in that folder as code), except the main executable, which is signed with the bundle.
  • Hardened runtime entitlements (packaging/macos/entitlements.plist): allow-jit and allow-unsigned-executable-memory for the .NET JIT, disable-library-validation for native libraries not signed by our team (libplctag, SkiaSharp, HarfBuzz).
  • CI imports the certificate into a temporary keychain and notarizes with an App Store Connect API key (secrets APPLE_*); without them (forks, local builds) it signs ad-hoc as before. Locally: SIGN_IDENTITY="Developer ID Application: …" NOTARY_PROFILE=<keychain profile> packaging/macos/package.sh <version>.

Command line: mdbrowser

  • A separate project (MachineDataBrowser.Cli) on top of Core only, so the protocols, ids and value formatting are the app's: endpoints, browse, read, monitor, and run for a session saved in the app (the session file is read with its own small model, ignoring unknown fields, so the CLI doesn't depend on the App).
  • Named mdbrowser, not mdb: short names clash with other tools (e.g. mdbtools) on PATH and in Homebrew.
  • Parsing with System.CommandLine 2 (Microsoft, stable, generated help, no reflection). Output with Spectre.Console (tables, a tree, a live watch table) only when stdout is a terminal and the format is text; pipes, files, --format json and csv stay plain, so scripts see stable output. Spectre.Console.Cli (reflection based) and ConsoleAppFramework were considered; parsing wasn't where they add value.
  • Shares the app's data folder: the same trusted certificates and OPC UA client identity, so servers that trust the app trust the CLI.
  • Shipped as a self-contained single file per OS/architecture (macOS and Linux, arm64 and x64; libplctag's native library is bundled and extracted on first run), not native AOT: the OPC UA SDK relies on reflection. Distributed as a Homebrew formula (Formula/mdbrowser.rb) in the same tap as the app's cask; formulae also install on Linux.

MCP server: mdbrowser mcp

  • Part of the CLI binary, not a separate program: it reuses the connections, node paths, --recursive expansion and typed JSON values, and installs with the same formula.
  • Official C# SDK, ModelContextProtocol.Core only (no hosting/DI packages), stdio transport; tools are plain methods.
  • Read-only first: agents explore machines that often run production, so writes and method calls are left out until they can be opted into explicitly. Tools are annotated read-only so clients can show that.
  • Allowlist: the agent reaches only the endpoints given with --endpoint/--session; logins come from the server's options and environment, never from tool arguments.
  • sample returns a summary (changes, min/max/mean, statuses) instead of a stream: agents work in request/response, and a summary fits their context. Result sizes are capped (browse 500, read 200, sample 100 items / 300 s).
  • Connections stay open between tool calls, so a device sees one session per server, not one per question.

Recordings in SQLite

  • Also write to file and --record write SQLite for .db / .sqlite / .sqlite3, CSV otherwise; the extension decides, so existing CSV recordings behave as before.
  • One file can hold many recordings (recordings, items, samples, view sample_view); values keep their type (value_num, value_text, value_json for arrays and structures), times are ISO 8601 UTC text (SQLite's date functions read it), the status is kept as name and code. A schema_info table versions the layout.
  • Microsoft.Data.Sqlite (MIT) with the bundled native SQLite (SQLitePCLRaw). Samples are queued and written in batches of up to 1000 per transaction, WAL mode: the UI and devices never wait for the disk, the file can be read (and followed by the viewer) while it grows, and a crash loses at most the last batch.
  • Retention (Keep in file, --retention): samples older than the limit are deleted from the whole file, in chunks, when recording starts and at most once a minute; recordings and items left empty are removed (not the running one). New files use auto_vacuum = INCREMENTAL, so freed pages go back to the disk gradually instead of a blocking VACUUM.
  • MCP reads recordings (list_recordings, recording_items, recording_samples, via SqliteRecordingQuery in MachineDataBrowser.Recording.Sqlite) from files allowed with --recording / --recordings-dir only, read-only. No free-form SQL tool: ATTACH would let a query read any SQLite file on the disk. Samples come raw (≤ 1000) or aggregated per time bucket for long ranges.

Deferred

  • Windows/Linux packages – the app builds for win-x64, but no release artifacts yet.
  • CLI write: mdbrowser write asks before writing unless --yes, refuses without a terminal and without --yes, and reads back what was written.
  • CLI follow-ups: call, history/events, check/wait for scripts, a Prometheus or MQTT bridge.
  • MCP follow-ups: history and events tools; write/call_method behind an explicit --allow-writes.