Compare commits

..

7 commits

Author SHA1 Message Date
dtrentin
9aa31dde1f Merge branch 'v0.3.1-theming' into v0.4-opcua 2026-07-22 08:59:02 +02:00
dtrentin
d84f8a818a fix: dashboard Details button no longer clipped (v0.3.1)
Trailing DataGridTemplateColumn had Width="Auto"; against the 2* star columns
it collapsed on first layout, clipping the Details button to a sliver. Pin to
Width="120" so the button always fits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:58:50 +02:00
dtrentin
2bf108905c Merge branch 'v0.3.1-theming' into v0.4-opcua 2026-07-22 07:47:25 +02:00
dtrentin
ec6bc2f06f fix: config screen uses full window width (v0.3.1)
MachineConfigView was two left-pinned cards (MaxWidth 480/640, HAlign Left)
stacked in a DockPanel — right half of wide window empty, items list capped at
320px. Now root Grid Auto,*,Auto: header top, footer (validation + Save/Cancel)
bottom full-width, body Grid Auto,* — fixed 380px form left, data-items card
stretches to fill width + height (scrollable). Layout only; no binding/VM/command
change. Dashboard/Detail already fill correctly — untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:47:13 +02:00
dtrentin
88dfa74ebe feat: OPC UA protocol plugin (v0.4)
Second protocol plugin proving multi-protocol architecture. New
Junction.Protocols.OpcUa (multi-target net48;net8.0 — SDK has no ns2.0):
- OpcuaDriverFactory: config validation (EndpointUrl/SecurityMode/Policy/
  AuthMode/Username/Password/NodeIds/BrowseRoot/TimeoutSeconds), CONFIG_* codes.
- OpcuaDriver: ReadCurrentAsync (read configured nodes) + ProbeAsync (browse
  address space), linked-CTS timeout, Result mapping, opt-in FilterToMonitored
  — mirrors MtconnectDriver.
- IUaClient seam (SDK-free) injected for testability; UaClient wraps OPC UA
  Session (non-obsolete async API), Directory PKI store, anon/user-pass auth.

SDK: OPCFoundation.NetStandard.Opc.Ua.Client 1.5.378.156 (MIT), pinned exact,
transitive closure copied per-TFM into plugins/opcua/ (CopyLocalLockFileAssemblies
+ CopyOpcuaPlugin target). Host: AutoGenerateBindingRedirects. Core untouched —
protocol auto-discovered by plugin loader.

Projects: +Junction.Protocols.OpcUa, Junction.App (wiring), Junction.sln,
Junction.Tests (+23 tests: 13 factory + 10 driver via Mock<IUaClient>). 171 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:44:23 +02:00
dtrentin
15fd1306c3 chore(paul): v0.3.1 shipped; v0.4 in progress; v0.5 deferred
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:24:31 +02:00
dtrentin
f4abdc0039 feat: themed + iconed UI polish (v0.3.1)
Fluent light/dark theme dictionaries (accent/surface/card/border/danger),
OS-following variant, App-level text/button/card styles. Per-protocol vector
icons via ProtocolIdToIconConverter (mirrors StatusKindToBrushConverter,
view-layer, safe fallback, no external assets). Curated layout across
dashboard/detail/config: consistent spacing scale, card grouping, theme brushes,
accent/danger buttons. App-only; no VM contract change, no new NuGet, no fw bump.

Projects: Junction.App (+ Junction.Tests: +10 converter tests, 148 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:23:47 +02:00
21 changed files with 1731 additions and 211 deletions

View file

@ -6,9 +6,9 @@
|---------|------|--------|--------|-----------|
| v0.1 | Walking Skeleton (MTConnect) | 1 | ✅ Shipped | 2026-07-21 |
| v0.2 | Machine Management UX | 2, 2.1 | ✅ Shipped (2.1 commit pending) | 2026-07-21 |
| v0.3 | Data-Item Selection + UX | 3, 3.1 | 🚧 Phase 3 done, 3.1 (theming) next | - |
| v0.4 | OPC UA Protocol | 4 | 📋 Planned | - |
| v0.5 | Fanuc FOCAS Protocol | 5 | 📋 Planned | - |
| v0.3 | Data-Item Selection + UX | 3, 3.1 | ✅ Shipped (branch v0.3.1-theming) | 2026-07-22 |
| v0.4 | OPC UA Protocol | 4 | 🚧 In progress (branch v0.4-opcua) | - |
| v0.5 | Fanuc FOCAS Protocol | 5 | ⏸️ Deferred (no native/Windows on dev host) | - |
| v0.6 | History & Trends | 6 | 📋 Planned | - |
## 🚧 Active Milestone: v0.2 Machine Management UX

View file

@ -5,18 +5,24 @@
See: .paul/PROJECT.md (updated 2026-07-21)
**Core value:** Operators see live state of every configured machine across heterogeneous protocols in one place, adding machines/protocols without code.
**Current focus:** v0.3 Phase 3 (item selection) shipped → PAUSED. Next: Phase 3.1 theming/UX.
**Current focus:** Autonomous finish run — branch-per-milestone. v0.3.1 theming SHIPPED. Now v0.4 OPC UA.
## Current Position
Milestone: v0.3 Data-Item Selection + UX
Phase: 3 (item selection) ✓ SHIPPED — Phase 3.1 (theming/UX) NOT started
Plan: v0.3-backend ✓, v0.3-appui ✓
Status: PAUSED (user requested). Build 0/0, 138 tests + 2 docker-skip.
Last activity: 2026-07-22 — v0.3 item selection: Machine.MonitoredItemIds (opt-in), IProtocolDriver.ProbeAsync, MTConnect probe+filter, Persistence col+migration, monitor.ProbeAsync, config probe→checklist UI (47 items live), detail empty-state.
Milestone: v0.4 OPC UA Protocol
Phase: 4 — IN PROGRESS (branch v0.4-opcua, from v0.3.1-theming)
Status: Build 0/0, 148 tests + 2 docker-skip (after v0.3.1).
Last activity: 2026-07-22 — v0.3.1 theming shipped on branch v0.3.1-theming (Fluent light/dark, per-protocol icons, curated layout, +10 converter tests). SDK decided for v0.4: OPCFoundation.NetStandard.Opc.Ua.Client 1.5.378.156 (MIT), plugin retargets net48, IUaClient seam for testability.
Progress:
- v0.3: Phase 3 done, Phase 3.1 (theming) pending
- v0.3.1 theming ✓ (branch pushed)
- v0.4 OPC UA: impl starting
- v0.5 FOCAS: DEFERRED (no native fwlib32/Windows on Linux dev host)
## Branch-per-milestone (user directive 2026-07-22)
Chain, each branch from previous; user merges one at a time; push branches to origin, NO auto-merge to main:
v0.3.1-theming (from main) ✓ → v0.4-opcua → v0.6-history (skips deferred v0.5).
## Loop Position

View file

@ -19,6 +19,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{362A84DF
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Tests", "tests\Junction.Tests\Junction.Tests.csproj", "{618E6634-A16C-4806-836A-185ECDEE7312}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Junction.Protocols.OpcUa", "src\Junction.Protocols.OpcUa\Junction.Protocols.OpcUa.csproj", "{93FC27CB-C147-4BE6-8E86-F46192B72099}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -52,6 +54,10 @@ Global
{618E6634-A16C-4806-836A-185ECDEE7312}.Debug|Any CPU.Build.0 = Debug|Any CPU
{618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.ActiveCfg = Release|Any CPU
{618E6634-A16C-4806-836A-185ECDEE7312}.Release|Any CPU.Build.0 = Release|Any CPU
{93FC27CB-C147-4BE6-8E86-F46192B72099}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{93FC27CB-C147-4BE6-8E86-F46192B72099}.Debug|Any CPU.Build.0 = Debug|Any CPU
{93FC27CB-C147-4BE6-8E86-F46192B72099}.Release|Any CPU.ActiveCfg = Release|Any CPU
{93FC27CB-C147-4BE6-8E86-F46192B72099}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{EE2D0A1D-3135-4065-A9EF-AB99F4FCC062} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
@ -60,5 +66,6 @@ Global
{BBAD50B2-18B5-4F4B-B8E3-CE749DB46C83} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
{965918A3-4C29-4F6A-B53D-0A72BDF4376C} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
{618E6634-A16C-4806-836A-185ECDEE7312} = {362A84DF-21D2-4DA7-B8B5-8C5E3C6E8500}
{93FC27CB-C147-4BE6-8E86-F46192B72099} = {6DDDE464-201D-4B4F-A23E-566B5FC111BC}
EndGlobalSection
EndGlobal

View file

@ -2,15 +2,113 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Junction.App"
xmlns:conv="clr-namespace:Junction.App.Converters"
x:Class="Junction.App.App">
x:Class="Junction.App.App"
RequestedThemeVariant="Default">
<!-- RequestedThemeVariant="Default" = follow the OS light/dark preference. -->
<Application.Resources>
<ResourceDictionary>
<!-- Per-variant palette. Fluent supplies the base chrome; these are Junction's
accent + surface + status tokens, adapted for Light and Dark. -->
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#1D4FD7" />
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#F4F6F9" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#E2E6EC" />
<SolidColorBrush x:Key="HeaderForegroundBrush" Color="#161A1F" />
<SolidColorBrush x:Key="SubtleForegroundBrush" Color="#697586" />
<SolidColorBrush x:Key="DangerBrush" Color="#C62828" />
<SolidColorBrush x:Key="OnAccentBrush" Color="#FFFFFF" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="AccentBrush" Color="#4C8DF6" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#6BA1F8" />
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#1B1E23" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#24282E" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#3A3F46" />
<SolidColorBrush x:Key="HeaderForegroundBrush" Color="#F2F4F7" />
<SolidColorBrush x:Key="SubtleForegroundBrush" Color="#9AA3AF" />
<SolidColorBrush x:Key="DangerBrush" Color="#EF5350" />
<SolidColorBrush x:Key="OnAccentBrush" Color="#FFFFFF" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<!-- Spacing scale (variant-invariant). -->
<Thickness x:Key="PageMargin">20</Thickness>
<!-- Variant-invariant converters. -->
<conv:StatusKindToBrushConverter x:Key="StatusKindToBrush" />
<conv:ProtocolIdToIconConverter x:Key="ProtocolIdToIcon" />
</ResourceDictionary>
</Application.Resources>
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
<!-- Inter (bundled via WithInterFont) as the shell default for a consistent, crisp face. -->
<Style Selector="Window">
<Setter Property="FontFamily" Value="avares://Avalonia.Fonts.Inter/Assets#Inter" />
<Setter Property="Background" Value="{DynamicResource AppBackgroundBrush}" />
</Style>
<!-- Reusable text roles. -->
<Style Selector="TextBlock.h1">
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource HeaderForegroundBrush}" />
</Style>
<Style Selector="TextBlock.h2">
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource HeaderForegroundBrush}" />
</Style>
<Style Selector="TextBlock.subtle">
<Setter Property="Foreground" Value="{DynamicResource SubtleForegroundBrush}" />
</Style>
<Style Selector="TextBlock.fieldLabel">
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource SubtleForegroundBrush}" />
</Style>
<!-- Card / section container. -->
<Style Selector="Border.card">
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="16" />
</Style>
<!-- Primary (accent) button. -->
<Style Selector="Button.accent">
<Setter Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.accent:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentHoverBrush}" />
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}" />
</Style>
<!-- Destructive button. -->
<Style Selector="Button.danger">
<Setter Property="Background" Value="{DynamicResource DangerBrush}" />
<Setter Property="Foreground" Value="#FFFFFF" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- Protocol glyph. -->
<Style Selector="PathIcon.protocol">
<Setter Property="Width" Value="16" />
<Setter Property="Height" Value="16" />
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
</Style>
</Application.Styles>
</Application>

View file

@ -0,0 +1,75 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace Junction.App.Converters
{
/// <summary>
/// Maps a machine's <c>ProtocolId</c> ("mtconnect", future "opcua"/"fanuc", …) to a vector
/// glyph (<see cref="Geometry"/>) for a <c>PathIcon</c>. Keeps view-models framework-agnostic:
/// the icon decision lives entirely in the view layer, mirroring
/// <see cref="StatusKindToBrushConverter"/>. Unknown/empty ids fall back to a generic glyph.
/// <para>Glyphs are line-only 24×24 path data (no arcs/curves) so they always parse and read
/// as distinct filled shapes. No external image assets — net48 packaging stays a single exe set.</para>
/// </summary>
public sealed class ProtocolIdToIconConverter : IValueConverter
{
public static readonly ProtocolIdToIconConverter Instance = new ProtocolIdToIconConverter();
// Signal-strength bars — a live telemetry feed.
private const string MtconnectData = "M4,14 H7 V20 H4 Z M10,10 H13 V20 H10 Z M16,5 H19 V20 H16 Z";
// Stacked diamonds — the OPC UA layered address space.
private const string OpcuaData = "M12,2 L20,7 L12,12 L4,7 Z M12,13 L20,18 L12,23 L4,18 Z";
// Plus/cross — an industrial CNC controller marker.
private const string FanucData = "M10,3 H14 V10 H21 V14 H14 V21 H10 V14 H3 V10 H10 Z";
// Octagon node — generic / unknown protocol.
private const string FallbackData = "M8,4 H16 L20,8 V16 L16,20 H8 L4,16 V8 Z";
private static Geometry? _mtconnect;
private static Geometry? _opcua;
private static Geometry? _fanuc;
private static Geometry? _fallback;
/// <summary>
/// Pure lookup of the raw path-data string for a protocol id. View-framework-free and
/// side-effect-free so it is unit-testable without an Avalonia platform. Known ids return a
/// distinct non-empty string; anything else returns the generic fallback. Never throws.
/// </summary>
public static string PathDataFor(string? protocolId)
{
switch (protocolId == null ? null : protocolId.Trim().ToLowerInvariant())
{
case "mtconnect":
return MtconnectData;
case "opcua":
return OpcuaData;
case "fanuc":
return FanucData;
default:
return FallbackData;
}
}
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
switch (PathDataFor(value as string))
{
case MtconnectData:
return _mtconnect ?? (_mtconnect = Geometry.Parse(MtconnectData));
case OpcuaData:
return _opcua ?? (_opcua = Geometry.Parse(OpcuaData));
case FanucData:
return _fanuc ?? (_fanuc = Geometry.Parse(FanucData));
default:
return _fallback ?? (_fallback = Geometry.Parse(FallbackData));
}
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
}

View file

@ -7,6 +7,8 @@
<OutputType>WinExe</OutputType>
<RootNamespace>Junction.App</RootNamespace>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- net48 needs binding redirects for the M.E.Logging/System.* shims the OPC UA SDK drags in. -->
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
</PropertyGroup>
<!-- Avalonia 11.3.x supports net48; Avalonia 12 DROPS net48. PIN 11.3.10 exact everywhere. -->
@ -42,6 +44,8 @@
<!-- Plugin = RUNTIME artifact, NOT a compile reference. Build-order-only so we can copy its output. -->
<ProjectReference Include="..\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj"
ReferenceOutputAssembly="false" Private="false" />
<ProjectReference Include="..\Junction.Protocols.OpcUa\Junction.Protocols.OpcUa.csproj"
ReferenceOutputAssembly="false" Private="false" />
</ItemGroup>
<!-- Copy MTConnect plugin (dll + manifest) into OutDir/plugins/mtconnect/ after build. -->
@ -53,4 +57,15 @@
<Copy SourceFiles="@(MtconnectPluginFiles)" DestinationFolder="$(OutDir)plugins\mtconnect\" SkipUnchangedFiles="true" />
</Target>
<!-- Copy OPC UA plugin (its dll + FULL transitive SDK closure + manifest) into OutDir/plugins/opcua/
after build. Host $(TargetFramework) is net48 or net8.0; the plugin multi-targets the same TFMs,
so copy the matching per-TFM output. -->
<Target Name="CopyOpcuaPlugin" AfterTargets="Build" Condition="'$(TargetFramework)' != ''">
<ItemGroup>
<OpcuaPluginFiles Include="$(MSBuildThisFileDirectory)..\Junction.Protocols.OpcUa\bin\$(Configuration)\$(TargetFramework)\*.dll" />
<OpcuaPluginFiles Include="$(MSBuildThisFileDirectory)..\Junction.Protocols.OpcUa\bin\$(Configuration)\$(TargetFramework)\plugin.manifest.json" />
</ItemGroup>
<Copy SourceFiles="@(OpcuaPluginFiles)" DestinationFolder="$(OutDir)plugins\opcua\" SkipUnchangedFiles="true" />
</Target>
</Project>

View file

@ -5,7 +5,8 @@
x:DataType="vm:MainWindowViewModel"
x:CompileBindings="True"
Title="Junction"
Width="800" Height="600">
Width="900" Height="640"
Background="{DynamicResource AppBackgroundBrush}">
<!-- Shell: hosts the current page; ViewLocator maps the VM to its View. -->
<ContentControl Content="{Binding CurrentPage}" />
</Window>

View file

@ -8,36 +8,49 @@
x:DataType="vm:DashboardViewModel"
x:CompileBindings="True">
<DockPanel Margin="16">
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto" Margin="0,0,0,12">
<DockPanel Margin="20">
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto" Margin="0,0,0,16">
<TextBlock Grid.Column="0"
Classes="h1"
Text="{Binding Title}"
FontSize="22" FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Classes="accent"
Content="+ Add machine"
Command="{Binding AddMachineCommand}"
VerticalAlignment="Center" />
</Grid>
<Border Classes="card" Padding="0">
<DataGrid ItemsSource="{Binding Machines}"
AutoGenerateColumns="False"
IsReadOnly="True"
CanUserReorderColumns="False"
CanUserResizeColumns="True"
GridLinesVisibility="Horizontal"
HeadersVisibility="Column">
HeadersVisibility="Column"
Background="Transparent">
<DataGrid.Columns>
<DataGridTextColumn Header="Machine"
Binding="{Binding Name}"
Width="2*"
x:DataType="vm:MachineRowViewModel" />
<DataGridTemplateColumn Header="Machine" Width="2*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:MachineRowViewModel">
<StackPanel Orientation="Horizontal" Spacing="10"
VerticalAlignment="Center" Margin="12,0">
<PathIcon Classes="protocol"
Data="{Binding ProtocolId, Converter={StaticResource ProtocolIdToIcon}}"
ToolTip.Tip="{Binding ProtocolId}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Status" Width="Auto">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:MachineRowViewModel">
<StackPanel Orientation="Horizontal" Spacing="6"
VerticalAlignment="Center" Margin="8,0">
VerticalAlignment="Center" Margin="12,0">
<Ellipse Width="10" Height="10" VerticalAlignment="Center"
Fill="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}" />
<TextBlock Text="{Binding ConnectionState}"
@ -58,16 +71,19 @@
Width="Auto"
x:DataType="vm:MachineRowViewModel" />
<DataGridTemplateColumn Header="" Width="Auto">
<!-- Fixed width: a trailing Auto column collapses against the 2* star columns,
clipping the Details button to a sliver on first layout. -->
<DataGridTemplateColumn Header="" Width="120">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:MachineRowViewModel">
<Button Content="Details"
Command="{Binding OpenDetailCommand}"
VerticalAlignment="Center" Margin="8,2" />
VerticalAlignment="Center" Margin="12,4" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Border>
</DockPanel>
</UserControl>

View file

@ -8,38 +8,28 @@
x:DataType="vm:MachineConfigViewModel"
x:CompileBindings="True">
<DockPanel Margin="16">
<!-- header / body / footer -->
<Grid Margin="20" RowDefinitions="Auto,*,Auto">
<!-- Header -->
<TextBlock DockPanel.Dock="Top"
<TextBlock Grid.Row="0" Classes="h1"
Text="{Binding Title}"
FontSize="22" FontWeight="SemiBold"
Margin="0,0,0,16" />
<!-- Action bar -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="12" Margin="0,16,0,0">
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" />
<Button Content="Cancel" Command="{Binding CancelCommand}" />
</StackPanel>
<!-- Inline validation hint -->
<TextBlock DockPanel.Dock="Bottom"
Text="{Binding ValidationError}"
IsVisible="{Binding HasValidationError}"
Foreground="#C0392B"
TextWrapping="Wrap"
Margin="0,12,0,0" />
<!-- Body: fixed form column + stretching data-items column -->
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<!-- Form -->
<StackPanel DockPanel.Dock="Top" Spacing="12" MaxWidth="480" HorizontalAlignment="Left">
<Border Grid.Column="0" Classes="card" Width="380" VerticalAlignment="Top">
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="Name" FontWeight="SemiBold" />
<TextBlock Classes="fieldLabel" Text="Name" />
<TextBox Text="{Binding Name}" Watermark="Machine name" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Protocol" FontWeight="SemiBold" />
<TextBlock Classes="fieldLabel" Text="Protocol" />
<ComboBox ItemsSource="{Binding AvailableProtocols}"
SelectedItem="{Binding SelectedProtocol}"
HorizontalAlignment="Stretch"
@ -47,26 +37,24 @@
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Agent URL" FontWeight="SemiBold" />
<TextBlock Classes="fieldLabel" Text="Agent URL" />
<TextBox Text="{Binding AgentUrl}" Watermark="http://host:5000" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Poll Interval (seconds)" FontWeight="SemiBold" />
<TextBlock Classes="fieldLabel" Text="Poll Interval (seconds)" />
<TextBox Text="{Binding PollIntervalSeconds}" Width="120" HorizontalAlignment="Left" />
</StackPanel>
</StackPanel>
</Border>
<!-- Data items to monitor (fills remaining space) -->
<Border BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
CornerRadius="4"
Padding="12" Margin="0,16,0,0" MaxWidth="640" HorizontalAlignment="Left">
<!-- Data items to monitor (fills remaining width + height) -->
<Border Grid.Column="1" Classes="card" Margin="16,0,0,0" HorizontalAlignment="Stretch">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Data items to monitor"
FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock DockPanel.Dock="Top" Classes="h2" Text="Data items to monitor"
Margin="0,0,0,8" />
<!-- Controls row -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
@ -82,27 +70,27 @@
</StackPanel>
<!-- Status message -->
<TextBlock DockPanel.Dock="Top"
<TextBlock DockPanel.Dock="Top" Classes="subtle"
Text="{Binding ItemsStatus}"
IsVisible="{Binding HasItemsStatus}"
Opacity="0.85" TextWrapping="Wrap" Margin="0,0,0,8" />
TextWrapping="Wrap" Margin="0,0,0,8" />
<!-- Column header -->
<Border DockPanel.Dock="Top"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderBrush="{DynamicResource CardBorderBrush}"
Padding="0,0,0,4" Margin="0,0,0,4">
<Grid ColumnDefinitions="Auto,2*,1.2*,1.2*,1*" Margin="0,0,8,0">
<TextBlock Grid.Column="0" Text="" Width="28" />
<TextBlock Grid.Column="1" Text="Name" FontWeight="Bold" />
<TextBlock Grid.Column="2" Text="Type" FontWeight="Bold" />
<TextBlock Grid.Column="3" Text="Category" FontWeight="Bold" />
<TextBlock Grid.Column="4" Text="Units" FontWeight="Bold" />
<TextBlock Grid.Column="1" Classes="fieldLabel" Text="Name" />
<TextBlock Grid.Column="2" Classes="fieldLabel" Text="Type" />
<TextBlock Grid.Column="3" Classes="fieldLabel" Text="Category" />
<TextBlock Grid.Column="4" Classes="fieldLabel" Text="Units" />
</Grid>
</Border>
<!-- Scrollable checklist -->
<ScrollViewer MaxHeight="320">
<!-- Scrollable checklist (fills remaining height) -->
<ScrollViewer>
<ItemsControl ItemsSource="{Binding AvailableItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SelectableDataItemViewModel">
@ -112,7 +100,7 @@
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Text="{Binding Id}" FontSize="11" Opacity="0.6" TextWrapping="Wrap" />
<TextBlock Text="{Binding Id}" Classes="subtle" FontSize="11" TextWrapping="Wrap" />
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding Type}" VerticalAlignment="Center" TextWrapping="Wrap" />
<TextBlock Grid.Column="3" Text="{Binding Category}" VerticalAlignment="Center" TextWrapping="Wrap" />
@ -125,5 +113,23 @@
</DockPanel>
</Border>
</DockPanel>
</Grid>
<!-- Footer: validation hint + action bar, full width -->
<StackPanel Grid.Row="2" Spacing="0">
<!-- Inline validation hint -->
<TextBlock Text="{Binding ValidationError}"
IsVisible="{Binding HasValidationError}"
Foreground="{DynamicResource DangerBrush}"
TextWrapping="Wrap"
Margin="0,12,0,0" />
<!-- Action bar -->
<StackPanel Orientation="Horizontal" Spacing="12" Margin="0,16,0,0">
<Button Classes="accent" Content="Save" Command="{Binding SaveCommand}" IsDefault="True" />
<Button Content="Cancel" Command="{Binding CancelCommand}" />
</StackPanel>
</StackPanel>
</Grid>
</UserControl>

View file

@ -9,71 +9,70 @@
x:CompileBindings="True">
<Grid>
<DockPanel Margin="16">
<DockPanel Margin="20">
<!-- Header bar: Back + machine name + status badge + Edit/Delete -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,Auto,*,Auto,Auto" Margin="0,0,0,12">
<!-- Header bar: Back + protocol glyph + machine name + status badge + Edit/Delete -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="0,0,0,16">
<Button Grid.Column="0" Content="← Back" Command="{Binding BackCommand}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding MachineName}"
FontSize="22" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="12,0" />
<Border Grid.Column="2"
<PathIcon Grid.Column="1" Classes="protocol" Width="20" Height="20"
Data="{Binding ProtocolId, Converter={StaticResource ProtocolIdToIcon}}"
ToolTip.Tip="{Binding ProtocolId}"
VerticalAlignment="Center" Margin="14,0,0,0" />
<TextBlock Grid.Column="2" Classes="h1" Text="{Binding MachineName}"
VerticalAlignment="Center" Margin="10,0" />
<Border Grid.Column="3"
Background="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
CornerRadius="4" Padding="8,3" HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding ConnectionState}" Foreground="White" FontWeight="SemiBold" FontSize="12" />
</Border>
<Button Grid.Column="3" Content="Edit" Command="{Binding EditCommand}"
<Button Grid.Column="4" Content="Edit" Command="{Binding EditCommand}"
VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Grid.Column="4" Content="Delete" Command="{Binding RequestDeleteCommand}"
<Button Grid.Column="5" Classes="danger" Content="Delete" Command="{Binding RequestDeleteCommand}"
VerticalAlignment="Center" />
</Grid>
<!-- Machine info panel -->
<Border DockPanel.Dock="Top"
BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
CornerRadius="4"
Padding="12" Margin="0,0,0,12">
<Border DockPanel.Dock="Top" Classes="card" Margin="0,0,0,16">
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto,Auto" >
<TextBlock Grid.Row="0" Grid.Column="0" Text="Protocol" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ProtocolId}" Margin="0,0,24,4" />
<TextBlock Grid.Row="0" Grid.Column="2" Text="Poll Interval" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="{Binding PollInterval}" Margin="0,0,0,4" />
<TextBlock Grid.Row="0" Grid.Column="0" Classes="fieldLabel" Text="Protocol" Margin="0,0,12,6" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ProtocolId}" Margin="0,0,24,6" />
<TextBlock Grid.Row="0" Grid.Column="2" Classes="fieldLabel" Text="Poll Interval" Margin="0,0,12,6" />
<TextBlock Grid.Row="0" Grid.Column="3" Text="{Binding PollInterval}" Margin="0,0,0,6" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Connection" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="fieldLabel" Text="Connection" Margin="0,0,12,6" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ConnectionState}"
Foreground="{Binding StatusKind, Converter={StaticResource StatusKindToBrush}}"
FontWeight="SemiBold" Margin="0,0,24,4" />
<TextBlock Grid.Row="1" Grid.Column="2" Text="Captured At" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="1" Grid.Column="3" Text="{Binding CapturedAt}" Margin="0,0,0,4" />
FontWeight="SemiBold" Margin="0,0,24,6" />
<TextBlock Grid.Row="1" Grid.Column="2" Classes="fieldLabel" Text="Captured At" Margin="0,0,12,6" />
<TextBlock Grid.Row="1" Grid.Column="3" Text="{Binding CapturedAt}" Margin="0,0,0,6" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Last Seen" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding LastSeen}" Margin="0,0,24,4" />
<TextBlock Grid.Row="2" Grid.Column="2" Text="Machine Id" FontWeight="Bold" Margin="0,0,12,4" />
<TextBlock Grid.Row="2" Grid.Column="3" Text="{Binding MachineIdText}" Margin="0,0,0,4"
<TextBlock Grid.Row="2" Grid.Column="0" Classes="fieldLabel" Text="Last Seen" Margin="0,0,12,6" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding LastSeen}" Margin="0,0,24,6" />
<TextBlock Grid.Row="2" Grid.Column="2" Classes="fieldLabel" Text="Machine Id" Margin="0,0,12,6" />
<TextBlock Grid.Row="2" Grid.Column="3" Text="{Binding MachineIdText}" Margin="0,0,0,6"
FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Agent URL" FontWeight="Bold" Margin="0,0,12,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="fieldLabel" Text="Agent URL" Margin="0,0,12,0" />
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="3" Text="{Binding AgentUrl}"
Margin="0,0,0,0" TextWrapping="Wrap" />
</Grid>
</Border>
<!-- Data-items header -->
<TextBlock DockPanel.Dock="Top"
<TextBlock DockPanel.Dock="Top" Classes="h2"
Text="{Binding ItemCount, StringFormat='Data Items ({0})'}"
FontSize="16" FontWeight="SemiBold" Margin="0,0,0,6" />
Margin="0,0,0,8" />
<Border DockPanel.Dock="Top"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderBrush="{DynamicResource CardBorderBrush}"
Padding="0,0,0,6" Margin="0,0,0,4"
IsVisible="{Binding HasItems}">
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*">
<TextBlock Grid.Column="0" Text="Name" FontWeight="Bold" />
<TextBlock Grid.Column="1" Text="Value" FontWeight="Bold" />
<TextBlock Grid.Column="2" Text="Category" FontWeight="Bold" />
<TextBlock Grid.Column="3" Text="Timestamp" FontWeight="Bold" />
<TextBlock Grid.Column="0" Classes="fieldLabel" Text="Name" />
<TextBlock Grid.Column="1" Classes="fieldLabel" Text="Value" />
<TextBlock Grid.Column="2" Classes="fieldLabel" Text="Category" />
<TextBlock Grid.Column="3" Classes="fieldLabel" Text="Timestamp" />
</Grid>
</Border>
@ -86,7 +85,7 @@
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*" Margin="0,5">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Text="{Binding Id}" FontSize="11" Opacity="0.6" TextWrapping="Wrap" />
<TextBlock Text="{Binding Id}" Classes="subtle" FontSize="11" TextWrapping="Wrap" />
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding Value}" VerticalAlignment="Center" TextWrapping="Wrap" />
<TextBlock Grid.Column="2" Text="{Binding Category}" VerticalAlignment="Center" />
@ -101,11 +100,10 @@
<StackPanel IsVisible="{Binding HasNoItems}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Spacing="6" MaxWidth="420">
<TextBlock Text="No monitored items"
FontSize="16" FontWeight="SemiBold"
<TextBlock Classes="h2" Text="No monitored items"
HorizontalAlignment="Center" />
<TextBlock Text="Edit this machine to select which data items to monitor."
Opacity="0.75" TextWrapping="Wrap" TextAlignment="Center" />
<TextBlock Classes="subtle" Text="Edit this machine to select which data items to monitor."
TextWrapping="Wrap" TextAlignment="Center" />
</StackPanel>
</Grid>
</DockPanel>
@ -113,19 +111,16 @@
<!-- Delete-confirmation overlay: dims the screen, names the machine, requires an explicit choice. -->
<Border IsVisible="{Binding IsDeleteConfirmVisible}"
Background="#99000000">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1" CornerRadius="6"
Padding="20" MaxWidth="420"
<Border Classes="card" MaxWidth="420"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Spacing="14">
<TextBlock Text="{Binding DeleteConfirmPrompt}" FontSize="16" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Text="This permanently removes the machine and stops monitoring it. This cannot be undone."
Opacity="0.75" TextWrapping="Wrap" />
<TextBlock Classes="h2" Text="{Binding DeleteConfirmPrompt}" TextWrapping="Wrap" />
<TextBlock Classes="subtle"
Text="This permanently removes the machine and stops monitoring it. This cannot be undone."
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Cancel" Command="{Binding CancelDeleteCommand}" />
<Button Content="Delete" Command="{Binding ConfirmDeleteCommand}"
Background="#C62828" Foreground="White" FontWeight="SemiBold" />
<Button Classes="danger" Content="Delete" Command="{Binding ConfirmDeleteCommand}" />
</StackPanel>
</StackPanel>
</Border>

View file

@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Junction.Domain.Protocols;
namespace Junction.Protocols.OpcUa
{
/// <summary>
/// Transport seam over an OPC UA session, mirroring the HttpClient injection the MTConnect
/// driver uses. Deliberately keeps all <c>Opc.Ua.*</c> SDK types OUT of its signatures so
/// <see cref="OpcuaDriver"/> can be unit-tested against a mock with no live server.
/// </summary>
public interface IUaClient
{
/// <summary>Establish the session (connect + authenticate). Idempotent: safe to call before each op.</summary>
Task ConnectAsync(CancellationToken ct);
/// <summary>Read the Value attribute of each given node id. Order matches the input order.</summary>
Task<IReadOnlyList<UaReadResult>> ReadAsync(IReadOnlyList<string> nodeIds, CancellationToken ct);
/// <summary>
/// Recursively browse the address space from <paramref name="browseRoot"/>, returning one
/// descriptor per Variable node found.
/// </summary>
Task<IReadOnlyList<DataItemDescriptor>> BrowseAsync(string browseRoot, CancellationToken ct);
}
}

View file

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Multi-target: net48 = shipped fleet (Win7/8); net8.0 = Linux dev host must load plugin too.
MTConnect is ns2.0 (loads on both), but the OPC UA SDK ships no ns2.0 asset, so target the
two host TFMs explicitly. -->
<TargetFrameworks>net48;net8.0</TargetFrameworks>
<RootNamespace>Junction.Protocols.OpcUa</RootNamespace>
<!-- Guarantees the full transitive closure (SDK + its deps) lands in bin per TFM so the App copy
target ships everything the plugin needs at runtime. -->
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.156" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Junction.Domain\Junction.Domain.csproj" />
</ItemGroup>
<!-- Plugin manifest: ships beside built assembly. Condition keeps build green if file is absent. -->
<ItemGroup>
<None Include="plugin.manifest.json" Condition="Exists('plugin.manifest.json')" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Junction.Domain;
using Junction.Domain.Models;
using Junction.Domain.Protocols;
namespace Junction.Protocols.OpcUa
{
/// <summary>
/// Per-machine OPC UA protocol driver. Reads the configured node values and browses the address
/// space through an injected <see cref="IUaClient"/> (the transport seam, mirroring the HttpClient
/// injection in <c>MtconnectDriver</c>). SDK types stay behind <see cref="IUaClient"/> so this
/// driver is unit-tested against a mock with no live server.
/// <para>
/// Never throws for expected transport failures: maps them to <see cref="Result{T}.Fail(OperationError)"/>
/// and honours cancellation via <see cref="Result{T}.Cancelled"/>.
/// </para>
/// </summary>
public sealed class OpcuaDriver : IProtocolDriver
{
private const string Source = "OpcuaDriver";
private readonly IUaClient _client;
private readonly Guid _machineId;
private readonly IReadOnlyList<string> _nodeIds;
private readonly string _browseRoot;
private readonly TimeSpan _timeout;
// Selected data item ids to keep in ReadCurrentAsync snapshots. Empty => opt-in "monitor nothing".
private readonly HashSet<string> _monitoredItemIds;
/// <summary>Protocol identifier this driver serves.</summary>
public string ProtocolId => "opcua";
/// <param name="client">Transport seam; injected for testability, owned by the factory.</param>
/// <param name="machineId">Machine this driver reads for; stamped onto the snapshot.</param>
/// <param name="nodeIds">Configured node ids to read in <see cref="ReadCurrentAsync"/>.</param>
/// <param name="browseRoot">Address-space browse start node for <see cref="ProbeAsync"/>.</param>
/// <param name="timeout">Per-operation timeout enforced via a linked <see cref="CancellationTokenSource"/>.</param>
/// <param name="monitoredItemIds">
/// DataItem ids the user selected to monitor. <see cref="ReadCurrentAsync"/> keeps only these
/// (opt-in); empty/null => "monitor nothing".
/// </param>
public OpcuaDriver(
IUaClient client,
Guid machineId,
IReadOnlyList<string>? nodeIds,
string browseRoot,
TimeSpan timeout,
IReadOnlyCollection<string>? monitoredItemIds = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_machineId = machineId;
_nodeIds = nodeIds ?? Array.Empty<string>();
_browseRoot = browseRoot ?? "";
if (timeout <= TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan)
{
throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Timeout must be positive or Timeout.InfiniteTimeSpan.");
}
_timeout = timeout;
_monitoredItemIds = new HashSet<string>(StringComparer.Ordinal);
if (monitoredItemIds != null)
{
foreach (var id in monitoredItemIds)
{
if (!string.IsNullOrEmpty(id))
{
_monitoredItemIds.Add(id);
}
}
}
}
/// <inheritdoc />
public async Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Result<MachineSnapshot>.Cancelled();
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_timeout);
IReadOnlyList<UaReadResult> reads;
try
{
await _client.ConnectAsync(cts.Token).ConfigureAwait(false);
reads = await _client.ReadAsync(_nodeIds, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return Result<MachineSnapshot>.Cancelled();
}
catch (OperationCanceledException ex)
{
return Fail("CURRENT_TIMEOUT", "OPC UA read timed out: " + ex.Message);
}
catch (Exception ex)
{
return Fail("CURRENT_READ_ERROR", "OPC UA read failed: " + ex.Message);
}
var items = new List<DataItem>(reads.Count);
foreach (UaReadResult r in reads)
{
items.Add(new DataItem(r.NodeId, r.NodeId, r.Value, "VARIABLE", r.SourceTimestamp));
}
var snapshot = new MachineSnapshot(_machineId, DateTimeOffset.UtcNow, ConnectionState.Connected, items);
return Result<MachineSnapshot>.Ok(FilterToMonitored(snapshot));
}
/// <inheritdoc />
public async Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Cancelled();
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_timeout);
IReadOnlyList<DataItemDescriptor> descriptors;
try
{
await _client.ConnectAsync(cts.Token).ConfigureAwait(false);
descriptors = await _client.BrowseAsync(_browseRoot, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Cancelled();
}
catch (OperationCanceledException ex)
{
return ProbeFail("PROBE_TIMEOUT", "OPC UA browse timed out: " + ex.Message);
}
catch (Exception ex)
{
return ProbeFail("PROBE_BROWSE_ERROR", "OPC UA browse failed: " + ex.Message);
}
return Result<IReadOnlyList<DataItemDescriptor>>.Ok(descriptors);
}
/// <summary>
/// Returns a copy of <paramref name="snapshot"/> keeping only items whose id is in the monitored
/// selection. Empty selection yields an empty item set (opt-in). Connection state and capture
/// instant are preserved. Exact semantics as MtconnectDriver.
/// </summary>
private MachineSnapshot FilterToMonitored(MachineSnapshot snapshot)
{
if (_monitoredItemIds.Count == 0)
{
return new MachineSnapshot(
snapshot.MachineId, snapshot.CapturedAt, snapshot.ConnectionState, Array.Empty<DataItem>());
}
var kept = new List<DataItem>();
for (int i = 0; i < snapshot.Items.Count; i++)
{
DataItem item = snapshot.Items[i];
if (_monitoredItemIds.Contains(item.Id))
{
kept.Add(item);
}
}
return new MachineSnapshot(snapshot.MachineId, snapshot.CapturedAt, snapshot.ConnectionState, kept);
}
private static Result<MachineSnapshot> Fail(string code, string message) =>
Result<MachineSnapshot>.Fail(new OperationError(code, Source, message));
private static Result<IReadOnlyList<DataItemDescriptor>> ProbeFail(string code, string message) =>
Result<IReadOnlyList<DataItemDescriptor>>.Fail(new OperationError(code, Source, message));
}
}

View file

@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using Junction.Domain;
using Junction.Domain.Models;
using Junction.Domain.Protocols;
namespace Junction.Protocols.OpcUa
{
/// <summary>
/// Plugin entrypoint for the OPC UA protocol. Resolved by the Core plugin loader from
/// <c>plugin.manifest.json</c> via <see cref="Activator.CreateInstance(Type)"/>, so it MUST
/// keep a public parameterless constructor.
/// <para>
/// Owns OPC UA config validation, mirroring <c>MtconnectDriverFactory</c> (case-insensitive
/// lookup, CONFIG_* fail codes). Builds a real <see cref="UaClient"/> and injects it into the
/// per-machine <see cref="OpcuaDriver"/>.
/// </para>
/// <para>Expected <see cref="Machine.ConnectionConfig"/> keys (case-insensitive):</para>
/// <list type="bullet">
/// <item><description><c>EndpointUrl</c> (required) — absolute opc.tcp:// URL of the server.</description></item>
/// <item><description><c>SecurityMode</c> (optional) — None|Sign|SignAndEncrypt, default None.</description></item>
/// <item><description><c>SecurityPolicy</c> (optional) — default None.</description></item>
/// <item><description><c>AuthMode</c> (optional) — Anonymous|UsernamePassword, default Anonymous.</description></item>
/// <item><description><c>Username</c>/<c>Password</c> — required iff AuthMode=UsernamePassword.</description></item>
/// <item><description><c>NodeIds</c> (optional CSV) — node ids read by ReadCurrentAsync.</description></item>
/// <item><description><c>BrowseRoot</c> (optional) — browse start node, default "i=85".</description></item>
/// <item><description><c>TimeoutSeconds</c> (optional) — positive int, default 10.</description></item>
/// </list>
/// </summary>
public sealed class OpcuaDriverFactory : IProtocolDriverFactory
{
private const string Source = "OpcuaDriverFactory";
private const string EndpointUrlKey = "EndpointUrl";
private const string SecurityModeKey = "SecurityMode";
private const string SecurityPolicyKey = "SecurityPolicy";
private const string AuthModeKey = "AuthMode";
private const string UsernameKey = "Username";
private const string PasswordKey = "Password";
private const string NodeIdsKey = "NodeIds";
private const string BrowseRootKey = "BrowseRoot";
private const string TimeoutKey = "TimeoutSeconds";
private const string DefaultBrowseRoot = "i=85";
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
/// <summary>Protocol identifier this factory produces drivers for.</summary>
public string ProtocolId => "opcua";
/// <summary>Required by the plugin loader (Activator.CreateInstance).</summary>
public OpcuaDriverFactory()
{
}
/// <inheritdoc />
public Result<IProtocolDriver> Create(Machine machine)
{
if (machine is null)
{
return Fail("MACHINE_NULL", "Machine was null.");
}
var config = machine.ConnectionConfig;
// EndpointUrl: required, absolute, opc.tcp scheme.
var endpointUrl = GetValue(config, EndpointUrlKey);
if (string.IsNullOrWhiteSpace(endpointUrl))
{
return Fail("CONFIG_ENDPOINTURL_MISSING",
"Required connection config key '" + EndpointUrlKey + "' is missing or empty.");
}
endpointUrl = endpointUrl!.Trim();
if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri))
{
return Fail("CONFIG_ENDPOINTURL_INVALID",
"Connection config key '" + EndpointUrlKey + "' is not an absolute URI: '" + endpointUrl + "'.");
}
if (!string.Equals(uri.Scheme, "opc.tcp", StringComparison.OrdinalIgnoreCase))
{
return Fail("CONFIG_ENDPOINTURL_SCHEME",
"Connection config key '" + EndpointUrlKey + "' must use the 'opc.tcp' scheme: '" + endpointUrl + "'.");
}
// SecurityMode: optional, one of None|Sign|SignAndEncrypt (default None).
var securityMode = GetValue(config, SecurityModeKey);
if (string.IsNullOrWhiteSpace(securityMode))
{
securityMode = "None";
}
else
{
securityMode = securityMode!.Trim();
if (!IsOneOf(securityMode, "None", "Sign", "SignAndEncrypt"))
{
return Fail("CONFIG_SECURITYMODE_INVALID",
"Connection config key '" + SecurityModeKey + "' must be None, Sign or SignAndEncrypt: '" + securityMode + "'.");
}
}
// SecurityPolicy: optional, default None. Free-form (validated by the server on connect).
var securityPolicy = GetValue(config, SecurityPolicyKey);
if (string.IsNullOrWhiteSpace(securityPolicy))
{
securityPolicy = "None";
}
// AuthMode: optional, one of Anonymous|UsernamePassword (default Anonymous).
var authMode = GetValue(config, AuthModeKey);
if (string.IsNullOrWhiteSpace(authMode))
{
authMode = "Anonymous";
}
else
{
authMode = authMode!.Trim();
if (!IsOneOf(authMode, "Anonymous", "UsernamePassword"))
{
return Fail("CONFIG_AUTHMODE_INVALID",
"Connection config key '" + AuthModeKey + "' must be Anonymous or UsernamePassword: '" + authMode + "'.");
}
}
string? username = GetValue(config, UsernameKey);
string? password = GetValue(config, PasswordKey);
if (string.Equals(authMode, "UsernamePassword", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(username))
{
return Fail("CONFIG_USERNAME_MISSING",
"Connection config key '" + UsernameKey + "' is required when AuthMode=UsernamePassword.");
}
if (string.IsNullOrEmpty(password))
{
return Fail("CONFIG_PASSWORD_MISSING",
"Connection config key '" + PasswordKey + "' is required when AuthMode=UsernamePassword.");
}
}
// BrowseRoot: optional, default i=85.
var browseRoot = GetValue(config, BrowseRootKey);
if (string.IsNullOrWhiteSpace(browseRoot))
{
browseRoot = DefaultBrowseRoot;
}
browseRoot = browseRoot!.Trim();
// NodeIds: optional CSV of node ids to read.
var nodeIds = ParseCsv(GetValue(config, NodeIdsKey));
// TimeoutSeconds: optional positive int (default 10).
var timeout = DefaultTimeout;
var timeoutRaw = GetValue(config, TimeoutKey);
if (!string.IsNullOrWhiteSpace(timeoutRaw))
{
if (!int.TryParse(timeoutRaw, out var seconds) || seconds <= 0)
{
return Fail("CONFIG_TIMEOUT_INVALID",
"Connection config key '" + TimeoutKey + "' must be a positive integer (seconds): '" + timeoutRaw + "'.");
}
timeout = TimeSpan.FromSeconds(seconds);
}
var client = new UaClient(endpointUrl, securityMode!, authMode!, username, password, timeout);
var driver = new OpcuaDriver(client, machine.Id, nodeIds, browseRoot, timeout, machine.MonitoredItemIds);
return Result<IProtocolDriver>.Ok(driver);
}
private static IReadOnlyList<string> ParseCsv(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return Array.Empty<string>();
}
var parts = raw!.Split(',');
var list = new List<string>(parts.Length);
foreach (var p in parts)
{
var trimmed = p.Trim();
if (trimmed.Length > 0)
{
list.Add(trimmed);
}
}
return list;
}
private static bool IsOneOf(string value, params string[] allowed)
{
foreach (var a in allowed)
{
if (string.Equals(value, a, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string? GetValue(IReadOnlyDictionary<string, string> config, string key)
{
if (config is null)
{
return null;
}
foreach (var pair in config)
{
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
{
return pair.Value;
}
}
return null;
}
private static Result<IProtocolDriver> Fail(string code, string message) =>
Result<IProtocolDriver>.Fail(new OperationError(code, Source, message));
}
}

View file

@ -0,0 +1,332 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Junction.Domain.Protocols;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
namespace Junction.Protocols.OpcUa
{
/// <summary>
/// Real <see cref="IUaClient"/> over the OPC Foundation .NET Standard SDK. Owns an
/// <see cref="ApplicationConfiguration"/> with a Directory PKI store under LocalApplicationData,
/// auto-generates the app-instance certificate, opens a <see cref="Session"/> and translates SDK
/// calls into the SDK-free DTOs the driver consumes.
/// <para>
/// This type is NOT unit-tested (it needs a live server); the driver is tested via a mocked
/// <see cref="IUaClient"/>. Exceptions raised here are caught by the driver and mapped to
/// <see cref="Junction.Domain.Result{T}"/> failures.
/// </para>
/// <para>
/// Uses the SDK's non-obsolete telemetry-threaded async surface throughout. The pinned SDK
/// (1.5.378.156) is mid-migration to an <c>ITelemetryContext</c>/DI-first API; a null telemetry
/// context is accepted and the SDK substitutes its default.
/// </para>
/// </summary>
public sealed class UaClient : IUaClient, IDisposable
{
private const string ApplicationName = "Junction";
private readonly string _endpointUrl;
private readonly string _securityMode; // None | Sign | SignAndEncrypt
private readonly string _authMode; // Anonymous | UsernamePassword
private readonly string? _username;
private readonly string? _password;
private readonly TimeSpan _timeout;
// Intentionally null: the SDK substitutes its default telemetry context. Typed non-nullable so
// the non-obsolete telemetry-threaded async overloads bind without nullable-analysis noise.
private readonly ITelemetryContext _telemetry = null!;
private ApplicationConfiguration? _appConfig;
private ISession? _session;
public UaClient(
string endpointUrl,
string securityMode,
string authMode,
string? username,
string? password,
TimeSpan timeout)
{
_endpointUrl = endpointUrl ?? throw new ArgumentNullException(nameof(endpointUrl));
_securityMode = string.IsNullOrWhiteSpace(securityMode) ? "None" : securityMode;
_authMode = string.IsNullOrWhiteSpace(authMode) ? "Anonymous" : authMode;
_username = username;
_password = password;
_timeout = timeout;
}
public async Task ConnectAsync(CancellationToken ct)
{
if (_session != null && _session.Connected)
{
return;
}
ApplicationConfiguration config = await BuildConfigurationAsync(ct).ConfigureAwait(false);
bool useSecurity = !string.Equals(_securityMode, "None", StringComparison.OrdinalIgnoreCase);
// Discover + pick the server endpoint matching the requested security.
EndpointDescription selected =
(await CoreClientUtils.SelectEndpointAsync(config, _endpointUrl, useSecurity, _telemetry, ct).ConfigureAwait(false))!;
var endpointConfiguration = EndpointConfiguration.Create(config);
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfiguration);
IUserIdentity identity = BuildIdentity();
var factory = new DefaultSessionFactory(_telemetry);
_session = await factory.CreateAsync(
config,
endpoint,
updateBeforeConnect: false,
checkDomain: false,
sessionName: ApplicationName,
sessionTimeout: (uint)Math.Max(1000, _timeout.TotalMilliseconds),
identity: identity,
preferredLocales: null,
ct).ConfigureAwait(false);
}
public async Task<IReadOnlyList<UaReadResult>> ReadAsync(IReadOnlyList<string> nodeIds, CancellationToken ct)
{
await ConnectAsync(ct).ConfigureAwait(false);
ISession session = _session ?? throw new InvalidOperationException("OPC UA session not established.");
var results = new List<UaReadResult>(nodeIds.Count);
if (nodeIds.Count == 0)
{
return results;
}
var toRead = new ReadValueIdCollection();
foreach (var id in nodeIds)
{
toRead.Add(new ReadValueId { NodeId = new NodeId(id), AttributeId = Attributes.Value });
}
ReadResponse response =
await session.ReadAsync(null, 0, TimestampsToReturn.Both, toRead, ct).ConfigureAwait(false);
DataValueCollection values = response.Results;
for (int i = 0; i < nodeIds.Count; i++)
{
DataValue dv = (values != null && i < values.Count) ? values[i] : new DataValue(StatusCodes.BadUnexpectedError);
bool ok = StatusCode.IsGood(dv.StatusCode);
string value = dv.Value != null ? dv.Value.ToString() ?? "" : "";
DateTimeOffset ts = dv.SourceTimestamp == DateTime.MinValue
? DateTimeOffset.UtcNow
: new DateTimeOffset(DateTime.SpecifyKind(dv.SourceTimestamp, DateTimeKind.Utc));
results.Add(new UaReadResult(nodeIds[i], value, ok, ts));
}
return results;
}
public async Task<IReadOnlyList<DataItemDescriptor>> BrowseAsync(string browseRoot, CancellationToken ct)
{
await ConnectAsync(ct).ConfigureAwait(false);
ISession session = _session ?? throw new InvalidOperationException("OPC UA session not established.");
NodeId root = string.IsNullOrWhiteSpace(browseRoot) ? ObjectIds.ObjectsFolder : new NodeId(browseRoot);
var descriptors = new List<DataItemDescriptor>();
var visited = new HashSet<string>(StringComparer.Ordinal);
await BrowseRecursiveAsync(session, root, descriptors, visited, ct).ConfigureAwait(false);
return descriptors;
}
private static async Task BrowseRecursiveAsync(
ISession session,
NodeId node,
List<DataItemDescriptor> descriptors,
HashSet<string> visited,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (!visited.Add(node.ToString()))
{
return; // guard against cycles
}
var nodeToBrowse = new BrowseDescription
{
NodeId = node,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
ResultMask = (uint)BrowseResultMask.All,
};
BrowseResponse response = await session.BrowseAsync(
null, null, 0u, new BrowseDescriptionCollection { nodeToBrowse }, ct).ConfigureAwait(false);
if (response.Results == null || response.Results.Count == 0)
{
return;
}
BrowseResult result = response.Results[0];
var references = new ReferenceDescriptionCollection();
if (result.References != null)
{
references.AddRange(result.References);
}
// Follow continuation points to enumerate the full child set.
byte[] continuation = result.ContinuationPoint;
while (continuation != null && continuation.Length > 0)
{
ct.ThrowIfCancellationRequested();
BrowseNextResponse next = await session.BrowseNextAsync(
null, false, new ByteStringCollection { continuation }, ct).ConfigureAwait(false);
if (next.Results == null || next.Results.Count == 0)
{
break;
}
BrowseResult nr = next.Results[0];
if (nr.References != null)
{
references.AddRange(nr.References);
}
continuation = nr.ContinuationPoint;
}
foreach (ReferenceDescription r in references)
{
NodeId? childId = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris);
if (childId == null)
{
continue;
}
if (r.NodeClass == NodeClass.Variable)
{
string displayName = r.DisplayName != null ? r.DisplayName.Text : "";
string dataType = await ReadDataTypeNameAsync(session, childId, ct).ConfigureAwait(false);
descriptors.Add(new DataItemDescriptor(childId.ToString(), displayName, dataType, "VARIABLE", ""));
}
// Recurse into both objects and variables (variables can have variable children).
await BrowseRecursiveAsync(session, childId, descriptors, visited, ct).ConfigureAwait(false);
}
}
/// <summary>Reads a variable's DataType attribute and maps it to a built-in type name; "" on failure.</summary>
private static async Task<string> ReadDataTypeNameAsync(ISession session, NodeId variable, CancellationToken ct)
{
try
{
var toRead = new ReadValueIdCollection
{
new ReadValueId { NodeId = variable, AttributeId = Attributes.DataType },
};
ReadResponse response =
await session.ReadAsync(null, 0, TimestampsToReturn.Neither, toRead, ct).ConfigureAwait(false);
DataValueCollection values = response.Results;
if (values != null && values.Count > 0 && StatusCode.IsGood(values[0].StatusCode) && values[0].Value is NodeId dtId)
{
BuiltInType bt = Opc.Ua.TypeInfo.GetBuiltInType(dtId);
return bt != BuiltInType.Null ? bt.ToString() : dtId.ToString();
}
}
catch
{
// Best-effort enrichment; the descriptor is still valid without a data type.
}
return "";
}
private IUserIdentity BuildIdentity()
{
if (string.Equals(_authMode, "UsernamePassword", StringComparison.OrdinalIgnoreCase))
{
return new UserIdentity(_username ?? "", System.Text.Encoding.UTF8.GetBytes(_password ?? ""));
}
return new UserIdentity();
}
private async Task<ApplicationConfiguration> BuildConfigurationAsync(CancellationToken ct)
{
if (_appConfig != null)
{
return _appConfig;
}
string pkiRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
ApplicationName,
"pki");
string own = Path.Combine(pkiRoot, "own");
string trusted = Path.Combine(pkiRoot, "trusted");
string issuer = Path.Combine(pkiRoot, "issuer");
string rejected = Path.Combine(pkiRoot, "rejected");
Directory.CreateDirectory(own);
Directory.CreateDirectory(trusted);
Directory.CreateDirectory(issuer);
Directory.CreateDirectory(rejected);
bool secure = !string.Equals(_securityMode, "None", StringComparison.OrdinalIgnoreCase);
int operationTimeoutMs = (int)Math.Max(1000, _timeout.TotalMilliseconds);
var config = new ApplicationConfiguration
{
ApplicationName = ApplicationName,
ApplicationUri = "urn:localhost:" + ApplicationName,
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = own,
SubjectName = "CN=" + ApplicationName + ", O=Junction",
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = trusted,
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = issuer,
},
RejectedCertificateStore = new CertificateStoreIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = rejected,
},
// SecurityMode=None has no peer trust to establish, so accept untrusted server certs.
AutoAcceptUntrustedCertificates = !secure,
AddAppCertToTrustedStore = true,
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = operationTimeoutMs },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
TraceConfiguration = new TraceConfiguration(),
};
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
var appInstance = new ApplicationInstance(config, _telemetry);
// Auto-generate the app-instance certificate if absent (silent, default lifetime).
await appInstance.CheckApplicationInstanceCertificatesAsync(true, null, ct).ConfigureAwait(false);
_appConfig = config;
return config;
}
public void Dispose()
{
// Session : IDisposable — disposing closes the channel/session.
_session?.Dispose();
_session = null;
}
}
}

View file

@ -0,0 +1,31 @@
using System;
namespace Junction.Protocols.OpcUa
{
/// <summary>
/// Protocol-agnostic result of reading a single OPC UA node's Value attribute. Keeps SDK types
/// out of <see cref="IUaClient"/> so the driver is testable without a live server.
/// </summary>
public sealed class UaReadResult
{
/// <summary>The node id that was read (as configured, e.g. "ns=2;s=Temperature").</summary>
public string NodeId { get; }
/// <summary>Value rendered as string to stay type-agnostic; empty when null/unreadable.</summary>
public string Value { get; }
/// <summary>True when the read StatusCode was Good.</summary>
public bool StatusOk { get; }
/// <summary>Source timestamp reported by the server for the value.</summary>
public DateTimeOffset SourceTimestamp { get; }
public UaReadResult(string nodeId, string value, bool statusOk, DateTimeOffset sourceTimestamp)
{
NodeId = nodeId ?? "";
Value = value ?? "";
StatusOk = statusOk;
SourceTimestamp = sourceTimestamp;
}
}
}

View file

@ -0,0 +1,7 @@
{
"protocolId": "opcua",
"displayName": "OPC UA",
"assemblyFile": "Junction.Protocols.OpcUa.dll",
"entryTypeName": "Junction.Protocols.OpcUa.OpcuaDriverFactory",
"apiVersion": "1.0"
}

View file

@ -26,6 +26,9 @@
<ProjectReference Include="..\..\src\Junction.Core\Junction.Core.csproj" />
<ProjectReference Include="..\..\src\Junction.Persistence\Junction.Persistence.csproj" />
<ProjectReference Include="..\..\src\Junction.Protocols.MTConnect\Junction.Protocols.MTConnect.csproj" />
<ProjectReference Include="..\..\src\Junction.Protocols.OpcUa\Junction.Protocols.OpcUa.csproj" />
<!-- App reference resolves its net8.0 target (tests are net8.0-only); covers view-layer converters. -->
<ProjectReference Include="..\..\src\Junction.App\Junction.App.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using Junction.Domain.Models;
using Junction.Protocols.OpcUa;
using Xunit;
namespace Junction.Tests.Unit
{
/// <summary>
/// Config-validation tests for <see cref="OpcuaDriverFactory"/>, mirroring
/// MtconnectDriverFactory tests: required/absolute/scheme checks, enum checks, the
/// UsernamePassword credential rule and case-insensitive key lookup.
/// </summary>
public sealed class OpcuaDriverFactoryTests
{
private static readonly Guid MachineId = Guid.Parse("99999999-8888-7777-6666-555555555555");
private const string EndpointUrl = "opc.tcp://opcua-server.test:4840";
private static Machine MachineWithConfig(IReadOnlyDictionary<string, string>? config) =>
new Machine(MachineId, "PLC-01", "opcua", config, TimeSpan.FromSeconds(5));
[Fact]
public void ProtocolId_IsOpcua()
{
Assert.Equal("opcua", new OpcuaDriverFactory().ProtocolId);
}
[Fact]
public void MissingEndpointUrl_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>()));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_MISSING");
}
[Fact]
public void NonAbsoluteEndpointUrl_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
new Dictionary<string, string> { ["EndpointUrl"] = "not a uri" }));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_INVALID");
}
[Fact]
public void WrongScheme_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
new Dictionary<string, string> { ["EndpointUrl"] = "http://opcua-server.test:4840" }));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_ENDPOINTURL_SCHEME");
}
[Fact]
public void ValidEndpointUrl_ReturnsOkDriverWithOpcuaProtocolId()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
new Dictionary<string, string> { ["EndpointUrl"] = EndpointUrl }));
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Equal("opcua", result.Value.ProtocolId);
}
[Fact]
public void EndpointUrlKeyIsCaseInsensitive()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(
new Dictionary<string, string> { ["endpointurl"] = EndpointUrl }));
Assert.True(result.IsSuccess);
}
[Fact]
public void InvalidSecurityMode_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["SecurityMode"] = "Bogus",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_SECURITYMODE_INVALID");
}
[Fact]
public void InvalidAuthMode_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["AuthMode"] = "Kerberos",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_AUTHMODE_INVALID");
}
[Fact]
public void UsernamePasswordWithoutUsername_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["AuthMode"] = "UsernamePassword",
["Password"] = "secret",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_USERNAME_MISSING");
}
[Fact]
public void UsernamePasswordWithoutPassword_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["AuthMode"] = "UsernamePassword",
["Username"] = "admin",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_PASSWORD_MISSING");
}
[Fact]
public void UsernamePasswordWithCredentials_ReturnsOk()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["AuthMode"] = "UsernamePassword",
["Username"] = "admin",
["Password"] = "secret",
}));
Assert.True(result.IsSuccess);
}
[Fact]
public void InvalidTimeout_ReturnsFail()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["TimeoutSeconds"] = "-5",
}));
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "CONFIG_TIMEOUT_INVALID");
}
[Fact]
public void FullValidConfig_ReturnsOk()
{
var result = new OpcuaDriverFactory().Create(MachineWithConfig(new Dictionary<string, string>
{
["EndpointUrl"] = EndpointUrl,
["SecurityMode"] = "SignAndEncrypt",
["SecurityPolicy"] = "Basic256Sha256",
["AuthMode"] = "Anonymous",
["NodeIds"] = "ns=2;s=Temp, ns=2;s=Speed",
["BrowseRoot"] = "i=85",
["TimeoutSeconds"] = "15",
}));
Assert.True(result.IsSuccess);
Assert.Equal("opcua", result.Value.ProtocolId);
}
}
}

View file

@ -0,0 +1,218 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Junction.Domain.Models;
using Junction.Domain.Protocols;
using Junction.Protocols.OpcUa;
using Moq;
using Xunit;
namespace Junction.Tests.Unit
{
/// <summary>
/// Drives <see cref="OpcuaDriver"/> against a mocked <see cref="IUaClient"/> (no live server),
/// mirroring MtconnectDriverTests. Exercises the read/browse happy paths, the opt-in selection
/// filter, transport-failure mapping and cancellation.
/// </summary>
public sealed class OpcuaDriverTests
{
private static readonly Guid MachineId = Guid.Parse("11111111-2222-3333-4444-555555555555");
private const string BrowseRoot = "i=85";
private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30);
// Node ids configured for ReadCurrentAsync.
private static readonly string[] ConfiguredNodes = { "ns=2;s=Temp", "ns=2;s=Speed", "ns=2;s=State" };
private static IReadOnlyList<UaReadResult> FakeReads() => new List<UaReadResult>
{
new UaReadResult("ns=2;s=Temp", "21.5", true, DateTimeOffset.UtcNow),
new UaReadResult("ns=2;s=Speed", "1500", true, DateTimeOffset.UtcNow),
new UaReadResult("ns=2;s=State", "RUNNING", true, DateTimeOffset.UtcNow),
};
private static OpcuaDriver DriverWith(
IUaClient client,
IReadOnlyList<string>? nodeIds = null,
IReadOnlyCollection<string>? monitoredItemIds = null,
TimeSpan? timeout = null) =>
new OpcuaDriver(
client,
MachineId,
nodeIds ?? ConfiguredNodes,
BrowseRoot,
timeout ?? GenerousTimeout,
monitoredItemIds ?? ConfiguredNodes);
[Fact]
public void ProtocolId_IsOpcua()
{
var driver = DriverWith(new Mock<IUaClient>().Object);
Assert.Equal("opcua", driver.ProtocolId);
}
// ---- ReadCurrentAsync: happy path ----
[Fact]
public async Task ReadCurrentAsync_ReadsConfiguredNodes_ReturnsConnectedSnapshotStampedWithMachineId()
{
var mock = new Mock<IUaClient>();
mock.Setup(c => c.ConnectAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(FakeReads());
var driver = DriverWith(mock.Object);
var result = await driver.ReadCurrentAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.WasCancelled);
Assert.Equal(MachineId, result.Value.MachineId);
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
Assert.Equal(3, result.Value.Items.Count);
Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=Temp" && i.Value == "21.5");
mock.Verify(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()), Times.Once);
}
// ---- ReadCurrentAsync: opt-in selection filter ----
[Fact]
public async Task ReadCurrentAsync_SelectionSubset_KeepsOnlySelectedItems_PreservesConnectionState()
{
var mock = new Mock<IUaClient>();
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(FakeReads());
var selection = new[] { "ns=2;s=Temp", "ns=2;s=State" };
var driver = DriverWith(mock.Object, monitoredItemIds: selection);
var result = await driver.ReadCurrentAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Items.Count);
Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=Temp");
Assert.Contains(result.Value.Items, i => i.Id == "ns=2;s=State");
Assert.DoesNotContain(result.Value.Items, i => i.Id == "ns=2;s=Speed");
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
}
[Fact]
public async Task ReadCurrentAsync_EmptySelection_ReturnsEmptyItems_PreservesConnectionState()
{
var mock = new Mock<IUaClient>();
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(FakeReads());
var driver = DriverWith(mock.Object, monitoredItemIds: Array.Empty<string>());
var result = await driver.ReadCurrentAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Empty(result.Value.Items);
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
}
// ---- ReadCurrentAsync: failure + cancellation ----
[Fact]
public async Task ReadCurrentAsync_ClientThrows_ReturnsFailNoThrow()
{
var mock = new Mock<IUaClient>();
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("server unreachable"));
var driver = DriverWith(mock.Object);
var result = await driver.ReadCurrentAsync(CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.False(result.WasCancelled);
Assert.Contains(result.Errors, e => e.Code == "CURRENT_READ_ERROR");
}
[Fact]
public async Task ReadCurrentAsync_CancelledBeforeCall_ReturnsCancelled()
{
var mock = new Mock<IUaClient>();
var driver = DriverWith(mock.Object);
using var cts = new CancellationTokenSource();
cts.Cancel();
var result = await driver.ReadCurrentAsync(cts.Token);
Assert.False(result.IsSuccess);
Assert.True(result.WasCancelled);
}
[Fact]
public async Task ReadCurrentAsync_CancelledDuringCall_ReturnsCancelledNoThrow()
{
var mock = new Mock<IUaClient>();
using var cts = new CancellationTokenSource();
mock.Setup(c => c.ReadAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.Returns<IReadOnlyList<string>, CancellationToken>(async (_, ct) =>
{
cts.Cancel();
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return FakeReads();
});
var driver = DriverWith(mock.Object);
var result = await driver.ReadCurrentAsync(cts.Token);
Assert.False(result.IsSuccess);
Assert.True(result.WasCancelled);
}
// ---- ProbeAsync: catalog ----
[Fact]
public async Task ProbeAsync_BrowsesFromRoot_ReturnsDescriptors()
{
var descriptors = new List<DataItemDescriptor>
{
new DataItemDescriptor("ns=2;s=Temp", "Temperature", "Double", "VARIABLE", ""),
new DataItemDescriptor("ns=2;s=Speed", "Speed", "Int32", "VARIABLE", ""),
};
var mock = new Mock<IUaClient>();
mock.Setup(c => c.BrowseAsync(BrowseRoot, It.IsAny<CancellationToken>()))
.ReturnsAsync(descriptors);
var driver = DriverWith(mock.Object);
var result = await driver.ProbeAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Count);
var temp = result.Value.Single(d => d.Id == "ns=2;s=Temp");
Assert.Equal("Temperature", temp.Name);
Assert.Equal("Double", temp.Type);
Assert.Equal("VARIABLE", temp.Category);
mock.Verify(c => c.BrowseAsync(BrowseRoot, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ProbeAsync_ClientThrows_ReturnsFailNoThrow()
{
var mock = new Mock<IUaClient>();
mock.Setup(c => c.BrowseAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("browse failed"));
var driver = DriverWith(mock.Object);
var result = await driver.ProbeAsync(CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "PROBE_BROWSE_ERROR");
}
[Fact]
public async Task ProbeAsync_CancelledBeforeCall_ReturnsCancelled()
{
var mock = new Mock<IUaClient>();
var driver = DriverWith(mock.Object);
using var cts = new CancellationTokenSource();
cts.Cancel();
var result = await driver.ProbeAsync(cts.Token);
Assert.False(result.IsSuccess);
Assert.True(result.WasCancelled);
}
}
}

View file

@ -0,0 +1,78 @@
using Junction.App.Converters;
using Xunit;
namespace Junction.Tests.Unit
{
/// <summary>
/// Data-level tests for the protocol → glyph mapping. Targets the pure
/// <see cref="ProtocolIdToIconConverter.PathDataFor"/> so no Avalonia platform is required
/// (the geometry parse in Convert needs a render backend). Mirrors the intent of the
/// StatusKind converter: known ids → distinct non-empty data, unknown → fallback, never throws.
/// </summary>
public class ProtocolIdToIconConverterTests
{
[Theory]
[InlineData("mtconnect")]
[InlineData("opcua")]
[InlineData("fanuc")]
public void KnownProtocol_ReturnsNonEmptyData(string protocolId)
{
var data = ProtocolIdToIconConverter.PathDataFor(protocolId);
Assert.False(string.IsNullOrWhiteSpace(data));
}
[Fact]
public void KnownProtocols_AreMutuallyDistinct()
{
var mtc = ProtocolIdToIconConverter.PathDataFor("mtconnect");
var opc = ProtocolIdToIconConverter.PathDataFor("opcua");
var fan = ProtocolIdToIconConverter.PathDataFor("fanuc");
Assert.NotEqual(mtc, opc);
Assert.NotEqual(mtc, fan);
Assert.NotEqual(opc, fan);
}
[Fact]
public void Lookup_IsCaseAndWhitespaceInsensitive()
{
var canonical = ProtocolIdToIconConverter.PathDataFor("mtconnect");
Assert.Equal(canonical, ProtocolIdToIconConverter.PathDataFor(" MtConnect "));
}
[Theory]
[InlineData("unknown")]
[InlineData("modbus")]
[InlineData("")]
public void UnknownOrEmpty_ReturnsFallback(string protocolId)
{
var fallback = ProtocolIdToIconConverter.PathDataFor("definitely-not-a-protocol");
var data = ProtocolIdToIconConverter.PathDataFor(protocolId);
Assert.False(string.IsNullOrWhiteSpace(data));
Assert.Equal(fallback, data);
}
[Fact]
public void Null_ReturnsFallback_DoesNotThrow()
{
var fallback = ProtocolIdToIconConverter.PathDataFor("definitely-not-a-protocol");
var data = ProtocolIdToIconConverter.PathDataFor(null);
Assert.False(string.IsNullOrWhiteSpace(data));
Assert.Equal(fallback, data);
}
[Fact]
public void Fallback_DiffersFromEveryKnownProtocol()
{
var fallback = ProtocolIdToIconConverter.PathDataFor("unknown");
Assert.NotEqual(fallback, ProtocolIdToIconConverter.PathDataFor("mtconnect"));
Assert.NotEqual(fallback, ProtocolIdToIconConverter.PathDataFor("opcua"));
Assert.NotEqual(fallback, ProtocolIdToIconConverter.PathDataFor("fanuc"));
}
}
}