feat: per-machine data-item selection (v0.3)

Users pick which data items each machine monitors. Catalog comes from the
protocol probe; selection is opt-in and filters persistence (only selected
items are read/kept/saved). Whole solution green, 138 tests + 2 docker integration.

Domain:
- Machine.MonitoredItemIds (opt-in, empty = monitor nothing); backward-compatible
  optional ctor param + With override.
- DataItemDescriptor (protocol-agnostic catalog entry).
- IProtocolDriver.ProbeAsync → full unfiltered item catalog.

MTConnect:
- MtconnectDriver.ProbeAsync (GET /probe → parser → descriptors).
- ReadCurrentAsync filters snapshot items to selected ids (ConnectionState
  preserved); factory passes selection into driver.

Persistence:
- machines.MonitoredItemIdsJson column + idempotent ALTER-if-missing migration.
- Repository maps selection (System.Text.Json); round-tripped.

Core:
- IMachineMonitor.ProbeAsync(machine) exposes catalog to the UI via the plugin
  factory.

App:
- Config: "Load items" probes the machine, shows a checklist (select all/none),
  pre-selects existing choices in edit mode, offline fallback, saves selection.
- Detail: empty-state hint when a machine has no monitored items.

PAUL: v0.3 Phase 3 shipped; Phase 3.1 (theming/UX) next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dtrentin 2026-07-22 00:28:47 +02:00
parent cf394bab31
commit 53ce8ab4da
22 changed files with 979 additions and 60 deletions

View file

@ -6,7 +6,7 @@
|---------|------|--------|--------|-----------|
| 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 | 📋 Planned | - |
| 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.6 | History & Trends | 6 | 📋 Planned | - |

View file

@ -5,27 +5,30 @@
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.2 + v0.2.1 shipped → starting v0.3 Data-Item Selection
**Current focus:** v0.3 Phase 3 (item selection) shipped → PAUSED. Next: Phase 3.1 theming/UX.
## Current Position
Milestone: v0.2 Machine Management UX (+ 2.1 refinements) — SHIPPED
Phase: 2 ✓, 2.1 ✓ → next Phase 3 (Data-Item Selection)
Plan: 02-A/B/C ✓, 02.1 R1/R2R3/tablefix
Status: v0.2.1 committing; v0.3 backend next
Last activity: 2026-07-22 — v0.2.1 done: shared HttpClient, disconnection UX, delete-confirm overlay, dashboard DataGrid (aligned + Details column). Build 0/0, 127 tests + 2 docker-skip.
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.
Progress:
- v0.2 (+2.1): [██████████] 100%
- Next: Phase 3 Data-Item Selection
- v0.3: Phase 3 done, Phase 3.1 (theming) pending
## Loop Position
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [v0.2.1 complete → PLAN Phase 3]
✓ ✓ ✓ [Phase 3 complete → PAUSED; next PLAN Phase 3.1 theming]
```
## NEXT (on resume)
Phase 3.1 theming/UX (task #34): Fluent light/dark + accent, per-protocol icons (mtconnect/opc/fanuc), curated layout across dashboard/detail/config. App-only. Then v0.4 OPC UA plugin.
## Standing Authorization
Auto-commit + push GREEN chunks (build 0-err + tests pass) to origin/main WITHOUT asking (user-granted 2026-07-22). Never commit red; no force-push; artifact leak-check each time.

View file

@ -1,23 +1,23 @@
{
"name": "Junction",
"version": "0.2.0",
"version": "0.3.0",
"milestone": {
"name": "Machine Management UX",
"version": "0.2.0",
"status": "shipped"
"name": "Data-Item Selection + UX",
"version": "0.3.0",
"status": "in_progress"
},
"phase": {
"number": 2,
"name": "Machine Management UX",
"number": 3,
"name": "Data-Item Selection",
"status": "complete"
},
"loop": {
"plan": "02-C",
"position": "UNIFY"
"plan": "v0.3-appui",
"position": "PAUSED"
},
"timestamps": {
"created_at": "2026-07-21T23:30:00Z",
"updated_at": "2026-07-21T23:45:00Z"
"updated_at": "2026-07-22T00:40:00Z"
},
"satellite": {
"groom": true

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
@ -8,6 +9,7 @@ using CommunityToolkit.Mvvm.Input;
using Junction.Core.Monitoring;
using Junction.Domain.Models;
using Junction.Domain.Persistence;
using Junction.Domain.Protocols;
using Microsoft.Extensions.Logging;
namespace Junction.App.ViewModels
@ -30,6 +32,14 @@ namespace Junction.App.ViewModels
private Guid? _editingId;
/// <summary>
/// MonitoredItemIds the machine had when loaded for EDIT. Used to pre-select the probed
/// catalog and to PRESERVE the selection on save when the user never reloaded the catalog
/// (an empty/never-probed <see cref="AvailableItems"/> must not wipe an existing selection).
/// Empty for ADD.
/// </summary>
private List<string> _existingMonitoredItemIds = new List<string>();
/// <summary>Shell reference used to return to the dashboard after save/cancel.</summary>
public MainWindowViewModel? Navigator { get; set; }
@ -41,12 +51,14 @@ namespace Junction.App.ViewModels
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadItemsCommand))]
[NotifyPropertyChangedFor(nameof(ValidationError))]
[NotifyPropertyChangedFor(nameof(HasValidationError))]
private string _selectedProtocol = "";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadItemsCommand))]
[NotifyPropertyChangedFor(nameof(ValidationError))]
[NotifyPropertyChangedFor(nameof(HasValidationError))]
private string _agentUrl = "";
@ -59,9 +71,29 @@ namespace Junction.App.ViewModels
[ObservableProperty] private bool _isEdit;
/// <summary>True while a probe (LoadItems) is in flight; drives the busy indicator and disables the button.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoadItemsCommand))]
private bool _isProbing;
/// <summary>User-visible status for the item section (probe failures, hints). Empty when nothing to say.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasItemsStatus))]
private string _itemsStatus = "";
public bool HasItemsStatus => !string.IsNullOrEmpty(ItemsStatus);
/// <summary>Protocol ids offered in the dropdown, sourced from the monitor at Initialize.</summary>
public ObservableCollection<string> AvailableProtocols { get; } = new ObservableCollection<string>();
/// <summary>
/// Catalog of selectable data items for the current machine, populated by <see cref="LoadItems"/>.
/// Empty until the user probes (or, in EDIT after a failed probe, seeded from the existing
/// selection as a fallback so the user can still deselect items while the agent is offline).
/// </summary>
public ObservableCollection<SelectableDataItemViewModel> AvailableItems { get; } =
new ObservableCollection<SelectableDataItemViewModel>();
public MachineConfigViewModel(
IMachineRepository repository,
IMachineMonitor monitor,
@ -93,12 +125,16 @@ namespace Junction.App.ViewModels
_editingId = machineId;
IsEdit = machineId.HasValue;
_existingMonitoredItemIds = new List<string>();
AvailableItems.Clear();
if (machineId is null)
{
Name = "";
SelectedProtocol = AvailableProtocols.Count > 0 ? AvailableProtocols[0] : "";
AgentUrl = "";
PollIntervalSeconds = 2;
ItemsStatus = "Load available items to choose what to monitor (requires a reachable agent).";
}
else
{
@ -137,6 +173,123 @@ namespace Junction.App.ViewModels
SelectedProtocol = m.ProtocolId;
AgentUrl = m.ConnectionConfig.TryGetValue("AgentUrl", out var url) ? url : "";
PollIntervalSeconds = m.PollInterval.TotalSeconds > 0 ? (int)m.PollInterval.TotalSeconds : 1;
_existingMonitoredItemIds = m.MonitoredItemIds != null
? m.MonitoredItemIds.ToList()
: new List<string>();
ItemsStatus = _existingMonitoredItemIds.Count > 0
? "Currently monitoring " + _existingMonitoredItemIds.Count + " item(s). Load available items to change the selection."
: "No items selected yet. Load available items to choose what to monitor.";
}
/// <summary>
/// Probes the machine for its full data-item catalog and populates <see cref="AvailableItems"/>.
/// Runs off the button click (async): sets <see cref="IsProbing"/> for the duration, marshals
/// nothing manually — the awaited continuation resumes on the UI thread. On success each item is
/// pre-selected when its id is already in the machine's existing selection. On failure a
/// user-visible message is set and (in EDIT) the existing selection is shown as a fallback so
/// the user can still deselect items while the agent is offline.
/// </summary>
[RelayCommand(CanExecute = nameof(CanLoadItems))]
private async Task LoadItems()
{
IsProbing = true;
ItemsStatus = "Probing agent…";
try
{
var machine = new Machine(
_editingId ?? Guid.NewGuid(),
string.IsNullOrWhiteSpace(Name) ? "(probe)" : Name.Trim(),
SelectedProtocol,
new Dictionary<string, string> { ["AgentUrl"] = AgentUrl.Trim() },
TimeSpan.FromSeconds(PollIntervalSeconds > 0 ? PollIntervalSeconds : 1));
var result = await _monitor.ProbeAsync(machine, CancellationToken.None).ConfigureAwait(true);
if (!result.IsSuccess)
{
var detail = result.WasCancelled
? "cancelled"
: (result.Errors.Count > 0 ? result.Errors[0].Message : "unknown error");
_logger.LogWarning("Probe failed for {MachineId} ({Protocol} @ {Url}): {Detail}",
machine.Id, SelectedProtocol, AgentUrl, detail);
ShowFallbackSelection();
ItemsStatus = "Probe failed: " + detail +
". Check the Agent URL and that the machine is reachable." +
(AvailableItems.Count > 0 ? " Showing the existing selection so you can still deselect items." : "");
return;
}
var existing = new HashSet<string>(_existingMonitoredItemIds, StringComparer.Ordinal);
AvailableItems.Clear();
var catalog = result.Value ?? (IReadOnlyList<DataItemDescriptor>)Array.Empty<DataItemDescriptor>();
foreach (var descriptor in catalog)
{
AvailableItems.Add(new SelectableDataItemViewModel(descriptor, existing.Contains(descriptor.Id)));
}
_logger.LogInformation("Probe returned {Count} item(s) for {MachineId}.", AvailableItems.Count, machine.Id);
ItemsStatus = AvailableItems.Count == 0
? "Probe succeeded but the machine exposes no data items."
: AvailableItems.Count + " item(s) available. Tick the ones to monitor.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Probe threw for {Protocol} @ {Url}.", SelectedProtocol, AgentUrl);
ShowFallbackSelection();
ItemsStatus = "Probe failed: " + ex.Message +
". Check the Agent URL and that the machine is reachable.";
}
finally
{
IsProbing = false;
}
}
private bool CanLoadItems() =>
!IsProbing
&& !string.IsNullOrWhiteSpace(SelectedProtocol)
&& !string.IsNullOrWhiteSpace(AgentUrl)
&& Uri.TryCreate(AgentUrl, UriKind.Absolute, out _);
/// <summary>
/// Seeds <see cref="AvailableItems"/> from the existing selection (EDIT) when a probe could not
/// return a catalog, so the user can still deselect items offline. No-op if already populated or
/// there is no existing selection.
/// </summary>
private void ShowFallbackSelection()
{
if (AvailableItems.Count > 0 || _existingMonitoredItemIds.Count == 0)
{
return;
}
foreach (var id in _existingMonitoredItemIds)
{
AvailableItems.Add(new SelectableDataItemViewModel(
new DataItemDescriptor(id, id, "", "", ""), true));
}
}
[RelayCommand]
private void SelectAll()
{
foreach (var item in AvailableItems)
{
item.IsSelected = true;
}
}
[RelayCommand]
private void SelectNone()
{
foreach (var item in AvailableItems)
{
item.IsSelected = false;
}
}
private string? Validate()
@ -170,12 +323,20 @@ namespace Junction.App.ViewModels
private async Task Save()
{
var id = _editingId ?? Guid.NewGuid();
// Selection source: if the user probed (AvailableItems populated), take the ticked ids.
// Otherwise PRESERVE the existing selection (EDIT with no reload) — empty for ADD (opt-in).
IReadOnlyCollection<string> monitoredItemIds = AvailableItems.Count > 0
? AvailableItems.Where(i => i.IsSelected).Select(i => i.Descriptor.Id).ToList()
: _existingMonitoredItemIds.ToList();
var machine = new Machine(
id,
Name.Trim(),
SelectedProtocol,
new Dictionary<string, string> { ["AgentUrl"] = AgentUrl.Trim() },
TimeSpan.FromSeconds(PollIntervalSeconds));
TimeSpan.FromSeconds(PollIntervalSeconds),
monitoredItemIds);
var upsert = await _repository.UpsertAsync(machine, CancellationToken.None).ConfigureAwait(true);
if (!upsert.IsSuccess)

View file

@ -53,7 +53,16 @@ namespace Junction.App.ViewModels
/// <summary>Timestamp of the last CONNECTED snapshot; "—" until first connect.</summary>
[ObservableProperty] private string _lastSeen = "—";
[ObservableProperty] private int _itemCount;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasItems))]
[NotifyPropertyChangedFor(nameof(HasNoItems))]
private int _itemCount;
/// <summary>True when there is at least one monitored data item to display.</summary>
public bool HasItems => ItemCount > 0;
/// <summary>True when nothing is monitored — drives the empty-state hint instead of a blank table.</summary>
public bool HasNoItems => ItemCount == 0;
/// <summary>True only when currently connected.</summary>
public bool IsOnline => ConnectionState == ConnectionState.Connected;

View file

@ -0,0 +1,35 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Junction.Domain.Protocols;
namespace Junction.App.ViewModels
{
/// <summary>
/// One selectable row in the config screen's monitored-item checklist. Wraps a
/// <see cref="DataItemDescriptor"/> (catalog entry from a probe) and carries the user's
/// <see cref="IsSelected"/> choice. The descriptor is immutable; only the selection mutates.
/// </summary>
public sealed partial class SelectableDataItemViewModel : ObservableObject
{
/// <summary>The underlying catalog entry. Its <c>Id</c> is what gets persisted when selected.</summary>
public DataItemDescriptor Descriptor { get; }
public string Id => Descriptor.Id;
/// <summary>Display name; falls back to the id when the descriptor carries no name.</summary>
public string Name => string.IsNullOrWhiteSpace(Descriptor.Name) ? Descriptor.Id : Descriptor.Name;
public string Type => string.IsNullOrWhiteSpace(Descriptor.Type) ? "—" : Descriptor.Type;
public string Category => string.IsNullOrWhiteSpace(Descriptor.Category) ? "—" : Descriptor.Category;
public string Units => string.IsNullOrWhiteSpace(Descriptor.Units) ? "—" : Descriptor.Units;
[ObservableProperty] private bool _isSelected;
public SelectableDataItemViewModel(DataItemDescriptor descriptor, bool isSelected)
{
Descriptor = descriptor;
_isSelected = isSelected;
}
}
}

View file

@ -57,5 +57,73 @@
</StackPanel>
</StackPanel>
<!-- 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">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Data items to monitor"
FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
<!-- Controls row -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<Button Content="Load available items"
Command="{Binding LoadItemsCommand}" />
<Button Content="Select all"
Command="{Binding SelectAllCommand}" />
<Button Content="Select none"
Command="{Binding SelectNoneCommand}" />
<ProgressBar IsIndeterminate="True"
IsVisible="{Binding IsProbing}"
Width="120" VerticalAlignment="Center" />
</StackPanel>
<!-- Status message -->
<TextBlock DockPanel.Dock="Top"
Text="{Binding ItemsStatus}"
IsVisible="{Binding HasItemsStatus}"
Opacity="0.85" TextWrapping="Wrap" Margin="0,0,0,8" />
<!-- Column header -->
<Border DockPanel.Dock="Top"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
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" />
</Grid>
</Border>
<!-- Scrollable checklist -->
<ScrollViewer MaxHeight="320">
<ItemsControl ItemsSource="{Binding AvailableItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SelectableDataItemViewModel">
<Grid ColumnDefinitions="Auto,2*,1.2*,1.2*,1*" Margin="0,3">
<CheckBox Grid.Column="0" Width="28"
IsChecked="{Binding IsSelected}"
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" />
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding Type}" VerticalAlignment="Center" TextWrapping="Wrap" />
<TextBlock Grid.Column="3" Text="{Binding Category}" VerticalAlignment="Center" TextWrapping="Wrap" />
<TextBlock Grid.Column="4" Text="{Binding Units}" VerticalAlignment="Center" TextWrapping="Wrap" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</DockPanel>
</UserControl>

View file

@ -67,7 +67,8 @@
<Border DockPanel.Dock="Top"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
Padding="0,0,0,6" Margin="0,0,0,4">
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" />
@ -76,8 +77,9 @@
</Grid>
</Border>
<!-- Full data-item table -->
<ScrollViewer>
<!-- Data-item table (fills), or an empty-state hint when nothing is monitored -->
<Grid>
<ScrollViewer IsVisible="{Binding HasItems}">
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DataItemRowViewModel">
@ -94,6 +96,18 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Empty-state -->
<StackPanel IsVisible="{Binding HasNoItems}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Spacing="6" MaxWidth="420">
<TextBlock Text="No monitored items"
FontSize="16" FontWeight="SemiBold"
HorizontalAlignment="Center" />
<TextBlock Text="Edit this machine to select which data items to monitor."
Opacity="0.75" TextWrapping="Wrap" TextAlignment="Center" />
</StackPanel>
</Grid>
</DockPanel>
<!-- Delete-confirmation overlay: dims the screen, names the machine, requires an explicit choice. -->

View file

@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using Junction.Domain;
using Junction.Domain.Models;
using Junction.Domain.Protocols;
namespace Junction.Core.Monitoring
{
@ -84,5 +85,19 @@ namespace Junction.Core.Monitoring
/// monitor to have been started.
/// </summary>
Task<Result> RemoveMachineAsync(Guid machineId, CancellationToken cancellationToken);
/// <summary>
/// Fetches the full (unfiltered) catalog of selectable data items a machine exposes, so the
/// config screen can present the monitored-item selection. Resolves the protocol factory by
/// <see cref="Machine.ProtocolId"/> from the retained factory map, builds a driver via
/// <c>factory.Create(machine)</c>, and delegates to <see cref="IProtocolDriver.ProbeAsync"/>.
/// <para>
/// Requires <see cref="StartAsync"/> to have completed: returns <see cref="Result{T}.Fail"/>
/// ("monitor not started") otherwise. Returns <see cref="Result{T}.Fail"/> when the protocol
/// has no loaded plugin or the driver cannot be created, and <see cref="Result{T}.Cancelled"/>
/// if the token trips first.
/// </para>
/// </summary>
Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(Machine machine, CancellationToken cancellationToken);
}
}

View file

@ -309,6 +309,44 @@ namespace Junction.Core.Monitoring
return Result.Ok();
}
/// <inheritdoc />
public async Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(Machine machine, CancellationToken cancellationToken)
{
if (machine is null)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Fail(OperationError.Of(Source, "machine is null."));
}
if (cancellationToken.IsCancellationRequested)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Cancelled();
}
IProtocolDriverFactory factory;
lock (_lifecycleLock)
{
if (_factories is null)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Fail(
OperationError.Of(Source, "Monitor not started; call StartAsync first."));
}
if (!_factories.TryGetValue(machine.ProtocolId, out factory))
{
return Result<IReadOnlyList<DataItemDescriptor>>.Fail(
OperationError.Of(Source, $"No plugin loaded for protocol '{machine.ProtocolId}'."));
}
}
Result<IProtocolDriver> driverResult = factory.Create(machine);
if (!driverResult.IsSuccess)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Fail(driverResult.Errors);
}
return await driverResult.Value.ProbeAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Removes and returns the tracked loop for <paramref name="machineId"/>, or null.
/// Caller must hold <see cref="_lifecycleLock"/>.

View file

@ -13,6 +13,8 @@ namespace Junction.Domain.Models
private static readonly IReadOnlyDictionary<string, string> EmptyConfig =
new Dictionary<string, string>(0);
private static readonly IReadOnlyCollection<string> EmptyItemIds = new string[0];
/// <summary>Stable unique identifier.</summary>
public Guid Id { get; }
@ -28,18 +30,26 @@ namespace Junction.Domain.Models
/// <summary>How often to poll the machine.</summary>
public TimeSpan PollInterval { get; }
/// <summary>
/// DataItem ids the user selected to monitor for this machine. Never null; empty is allowed
/// and means opt-in "monitor nothing" (drivers keep/persist only the items listed here).
/// </summary>
public IReadOnlyCollection<string> MonitoredItemIds { get; }
public Machine(
Guid id,
string name,
string protocolId,
IReadOnlyDictionary<string, string>? connectionConfig,
TimeSpan pollInterval)
TimeSpan pollInterval,
IReadOnlyCollection<string>? monitoredItemIds = null)
{
Id = id;
Name = name ?? "";
ProtocolId = protocolId ?? "";
ConnectionConfig = connectionConfig ?? EmptyConfig;
PollInterval = pollInterval;
MonitoredItemIds = monitoredItemIds ?? EmptyItemIds;
}
/// <summary>Returns a copy with the given fields overridden; null args keep the current value.</summary>
@ -47,14 +57,16 @@ namespace Junction.Domain.Models
string? name = null,
string? protocolId = null,
IReadOnlyDictionary<string, string>? connectionConfig = null,
TimeSpan? pollInterval = null)
TimeSpan? pollInterval = null,
IReadOnlyCollection<string>? monitoredItemIds = null)
{
return new Machine(
Id,
name ?? Name,
protocolId ?? ProtocolId,
connectionConfig ?? ConnectionConfig,
pollInterval ?? PollInterval);
pollInterval ?? PollInterval,
monitoredItemIds ?? MonitoredItemIds);
}
}
}

View file

@ -0,0 +1,34 @@
namespace Junction.Domain.Protocols
{
/// <summary>
/// Protocol-agnostic catalog entry describing a single selectable data item a machine exposes.
/// Returned (unfiltered) by <see cref="IProtocolDriver.ProbeAsync"/> so the config screen can
/// present the full set of items the user may choose to monitor. Immutable; nulls collapse to "".
/// </summary>
public sealed class DataItemDescriptor
{
/// <summary>Stable identifier of the data item (e.g. MTConnect dataItemId).</summary>
public string Id { get; }
/// <summary>Human-readable name (may be empty).</summary>
public string Name { get; }
/// <summary>Protocol type (e.g. MTConnect POSITION, EXECUTION, AVAILABILITY).</summary>
public string Type { get; }
/// <summary>Category (e.g. MTConnect SAMPLE, EVENT, CONDITION). Kept as string to stay generic.</summary>
public string Category { get; }
/// <summary>Units (may be empty).</summary>
public string Units { get; }
public DataItemDescriptor(string id, string name, string type, string category, string units)
{
Id = id ?? "";
Name = name ?? "";
Type = type ?? "";
Category = category ?? "";
Units = units ?? "";
}
}
}

View file

@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Junction.Domain.Models;
@ -20,5 +21,14 @@ namespace Junction.Domain.Protocols
/// </summary>
/// <param name="cancellationToken">Cancellation token. Mandatory.</param>
Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the FULL (unfiltered) catalog of data items the machine exposes, so the config
/// screen can present the selectable set. Independent of the per-machine monitored-item
/// selection: selection is applied to <see cref="ReadCurrentAsync"/> output, not here.
/// Returns a failed <see cref="Result{T}"/> on error, cancelled when the token trips.
/// </summary>
/// <param name="cancellationToken">Cancellation token. Mandatory.</param>
Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(CancellationToken cancellationToken);
}
}

View file

@ -60,7 +60,7 @@ namespace Junction.Persistence
using (var connection = OpenConnection())
{
var command = new CommandDefinition(
"SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson FROM machines;",
"SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson, MonitoredItemIdsJson FROM machines;",
cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<MachineRow>(command).ConfigureAwait(false);
@ -97,7 +97,7 @@ namespace Junction.Persistence
using (var connection = OpenConnection())
{
var command = new CommandDefinition(
"SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson " +
"SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson, MonitoredItemIdsJson " +
"FROM machines WHERE Id = @Id;",
new { Id = GuidText(id) },
cancellationToken: cancellationToken);
@ -141,20 +141,22 @@ namespace Junction.Persistence
using (var connection = OpenConnection())
{
var command = new CommandDefinition(
"INSERT INTO machines (Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson) " +
"VALUES (@Id, @Name, @ProtocolId, @PollIntervalTicks, @ConnectionConfigJson) " +
"INSERT INTO machines (Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson, MonitoredItemIdsJson) " +
"VALUES (@Id, @Name, @ProtocolId, @PollIntervalTicks, @ConnectionConfigJson, @MonitoredItemIdsJson) " +
"ON CONFLICT(Id) DO UPDATE SET " +
"Name = excluded.Name, " +
"ProtocolId = excluded.ProtocolId, " +
"PollIntervalTicks = excluded.PollIntervalTicks, " +
"ConnectionConfigJson = excluded.ConnectionConfigJson;",
"ConnectionConfigJson = excluded.ConnectionConfigJson, " +
"MonitoredItemIdsJson = excluded.MonitoredItemIdsJson;",
new
{
Id = GuidText(machine.Id),
machine.Name,
machine.ProtocolId,
PollIntervalTicks = machine.PollInterval.Ticks,
ConnectionConfigJson = SerializeConfig(machine.ConnectionConfig)
ConnectionConfigJson = SerializeConfig(machine.ConnectionConfig),
MonitoredItemIdsJson = SerializeItemIds(machine.MonitoredItemIds)
},
cancellationToken: cancellationToken);
@ -376,7 +378,8 @@ namespace Junction.Persistence
row.Name,
row.ProtocolId,
DeserializeConfig(row.ConnectionConfigJson),
TimeSpan.FromTicks(row.PollIntervalTicks));
TimeSpan.FromTicks(row.PollIntervalTicks),
DeserializeItemIds(row.MonitoredItemIdsJson));
}
private static string GuidText(Guid id) => id.ToString("D");
@ -416,6 +419,31 @@ namespace Junction.Persistence
return dict ?? new Dictionary<string, string>(0);
}
private static string SerializeItemIds(IReadOnlyCollection<string> ids)
{
var list = new List<string>(ids?.Count ?? 0);
if (ids != null)
{
foreach (var id in ids)
{
list.Add(id);
}
}
return JsonSerializer.Serialize(list, JsonOptions);
}
private static IReadOnlyCollection<string> DeserializeItemIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return Array.Empty<string>();
}
var list = JsonSerializer.Deserialize<List<string>>(json!, JsonOptions);
return list ?? (IReadOnlyCollection<string>)Array.Empty<string>();
}
// -- row DTOs (private; never cross the boundary) -----------------------------------
private sealed class MachineRow
@ -425,6 +453,7 @@ namespace Junction.Persistence
public string ProtocolId { get; set; } = "";
public long PollIntervalTicks { get; set; }
public string? ConnectionConfigJson { get; set; }
public string? MonitoredItemIdsJson { get; set; }
}
private sealed class SnapshotRow

View file

@ -18,7 +18,8 @@ namespace Junction.Persistence
Name TEXT NOT NULL,
ProtocolId TEXT NOT NULL,
PollIntervalTicks INTEGER NOT NULL,
ConnectionConfigJson TEXT NOT NULL
ConnectionConfigJson TEXT NOT NULL,
MonitoredItemIdsJson TEXT
);";
// latest_snapshots: exactly one row per machine (PK = MachineId). Upsert overwrites.
@ -63,6 +64,11 @@ namespace Junction.Persistence
Execute(connection, CreateLatestSnapshots);
Execute(connection, CreateSnapshotItems);
// Migration for DBs created by an older schema (before MonitoredItemIdsJson existed):
// CREATE TABLE IF NOT EXISTS never alters an existing table, so add the column here if
// missing. Idempotent and safe on both fresh (already has it) and pre-existing DBs.
EnsureColumn(connection, "machines", "MonitoredItemIdsJson", "TEXT");
return Result.Ok();
}
catch (Exception ex)
@ -100,5 +106,38 @@ namespace Junction.Persistence
command.ExecuteNonQuery();
}
}
/// <summary>Adds <paramref name="column"/> to <paramref name="table"/> if not already present. Idempotent.</summary>
private static void EnsureColumn(IDbConnection connection, string table, string column, string sqlType)
{
if (ColumnExists(connection, table, column))
{
return;
}
Execute(connection, "ALTER TABLE " + table + " ADD COLUMN " + column + " " + sqlType + ";");
}
private static bool ColumnExists(IDbConnection connection, string table, string column)
{
using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(" + table + ");";
using (var reader = command.ExecuteReader())
{
while (reader.Read())
{
// PRAGMA table_info columns: cid(0), name(1), type(2), ...
var name = reader.GetValue(1)?.ToString();
if (string.Equals(name, column, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
}
return false;
}
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@ -25,12 +26,17 @@ namespace Junction.Protocols.MTConnect
{
private const string Source = "MtconnectDriver";
private const string CurrentPath = "current";
private const string ProbePath = "probe";
private readonly Guid _machineId;
private readonly Uri _currentUri;
private readonly Uri _probeUri;
private readonly HttpClient _http;
private readonly TimeSpan _requestTimeout;
// 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 => "mtconnect";
@ -48,7 +54,16 @@ namespace Junction.Protocols.MTConnect
/// Per-request timeout enforced via a linked <see cref="CancellationTokenSource"/>. Must be positive
/// or <see cref="Timeout.InfiniteTimeSpan"/>.
/// </param>
public MtconnectDriver(Guid machineId, string agentUrl, HttpClient httpClient, TimeSpan requestTimeout)
/// <param name="monitoredItemIds">
/// DataItem ids the user selected to monitor. <see cref="ReadCurrentAsync"/> keeps only these in the
/// snapshot (so persistence stores only selected items). Empty/null => opt-in "monitor nothing".
/// </param>
public MtconnectDriver(
Guid machineId,
string agentUrl,
HttpClient httpClient,
TimeSpan requestTimeout,
IReadOnlyCollection<string>? monitoredItemIds = null)
{
if (agentUrl is null) throw new ArgumentNullException(nameof(agentUrl));
_http = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
@ -59,18 +74,33 @@ namespace Junction.Protocols.MTConnect
_requestTimeout = requestTimeout;
_machineId = machineId;
// DataItem ids are case-sensitive; use ordinal set membership for the selection filter.
_monitoredItemIds = new HashSet<string>(StringComparer.Ordinal);
if (monitoredItemIds != null)
{
foreach (var id in monitoredItemIds)
{
if (!string.IsNullOrEmpty(id))
{
_monitoredItemIds.Add(id);
}
}
}
if (!Uri.TryCreate(agentUrl, UriKind.Absolute, out var baseUri))
{
throw new ArgumentException("Agent URL must be an absolute URI: '" + agentUrl + "'.", nameof(agentUrl));
}
// Combine base + "current" preserving any base path segment.
// Combine base + endpoint preserving any base path segment.
var basePath = baseUri.AbsoluteUri;
if (!basePath.EndsWith("/", StringComparison.Ordinal))
{
basePath += "/";
}
_currentUri = new Uri(new Uri(basePath, UriKind.Absolute), CurrentPath);
var normalizedBase = new Uri(basePath, UriKind.Absolute);
_currentUri = new Uri(normalizedBase, CurrentPath);
_probeUri = new Uri(normalizedBase, ProbePath);
}
/// <inheritdoc />
@ -139,11 +169,124 @@ namespace Junction.Protocols.MTConnect
}
// Delegate parsing (T15). Parser never throws; returns Fail on malformed input.
return MtconnectCurrentParser.Parse(body, _machineId);
Result<MachineSnapshot> parsed = MtconnectCurrentParser.Parse(body, _machineId);
if (!parsed.IsSuccess)
{
return parsed;
}
// Apply the per-machine selection: keep only monitored items (empty => opt-in nothing).
// ConnectionState/CapturedAt are preserved from the parsed snapshot.
return Result<MachineSnapshot>.Ok(FilterToMonitored(parsed.Value));
}
}
/// <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(_requestTimeout);
HttpResponseMessage response;
try
{
response = await _http.GetAsync(_probeUri, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Cancelled();
}
catch (OperationCanceledException ex)
{
return ProbeFail("PROBE_TIMEOUT", "HTTP request to '" + _probeUri + "' timed out: " + ex.Message);
}
catch (HttpRequestException ex)
{
return ProbeFail("PROBE_HTTP_ERROR", "HTTP request to '" + _probeUri + "' failed: " + ex.Message);
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
return ProbeFail(
"PROBE_HTTP_STATUS",
"Agent returned non-success status " + (int)response.StatusCode + " (" + response.StatusCode + ") for '" + _probeUri + "'.");
}
string body;
try
{
#if NET5_0_OR_GREATER
body = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
#else
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Cancelled();
}
catch (OperationCanceledException ex)
{
return ProbeFail("PROBE_TIMEOUT", "Reading response body from '" + _probeUri + "' timed out: " + ex.Message);
}
catch (HttpRequestException ex)
{
return ProbeFail("PROBE_HTTP_ERROR", "Reading response body from '" + _probeUri + "' failed: " + ex.Message);
}
Result<IReadOnlyList<ProbeDataItemDescriptor>> parsed = MtconnectProbeParser.Parse(body);
if (!parsed.IsSuccess)
{
return Result<IReadOnlyList<DataItemDescriptor>>.Fail(parsed.Errors);
}
var catalog = new List<DataItemDescriptor>(parsed.Value.Count);
foreach (ProbeDataItemDescriptor d in parsed.Value)
{
catalog.Add(new DataItemDescriptor(d.Id, d.Name, d.Type, d.Category, d.Units));
}
return Result<IReadOnlyList<DataItemDescriptor>>.Ok(catalog);
}
}
/// <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.
/// </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

@ -86,7 +86,7 @@ namespace Junction.Protocols.MTConnect
timeout = TimeSpan.FromSeconds(seconds);
}
var driver = new MtconnectDriver(machine.Id, agentUrl, SharedHttpClient, timeout);
var driver = new MtconnectDriver(machine.Id, agentUrl, SharedHttpClient, timeout, machine.MonitoredItemIds);
return Result<IProtocolDriver>.Ok(driver);
}

View file

@ -385,6 +385,72 @@ namespace Junction.Tests.Unit
Assert.Contains(result.Errors, e => e.Message.Contains("not started"));
}
[Fact]
public async Task ProbeAsync_ReturnsCatalog_FromFactoryDriver()
{
var catalog = new List<DataItemDescriptor>
{
new DataItemDescriptor("x1_pos", "X", "POSITION", "SAMPLE", "MILLIMETER"),
new DataItemDescriptor("dev1_avail", "avail", "AVAILABILITY", "EVENT", ""),
};
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
new FakeDriver(
"test",
() => Result<MachineSnapshot>.Ok(Snapshot(m.Id)),
() => Result<IReadOnlyList<DataItemDescriptor>>.Ok(catalog))));
var loader = LoaderReturning(factory);
var repo = RepoReturning();
var monitor = NewMonitor(repo.Object, loader.Object);
await monitor.StartAsync(PluginsDir, CancellationToken.None);
var machine = MachineWith("test");
var result = await monitor.ProbeAsync(machine, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Count);
Assert.Contains(result.Value, d => d.Id == "x1_pos" && d.Type == "POSITION");
await monitor.StopAsync();
}
[Fact]
public async Task ProbeAsync_UnknownProtocol_ReturnsFail()
{
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(m.Id)))));
var loader = LoaderReturning(factory);
var repo = RepoReturning();
var monitor = NewMonitor(repo.Object, loader.Object);
await monitor.StartAsync(PluginsDir, CancellationToken.None);
var bad = MachineWith("nope");
var result = await monitor.ProbeAsync(bad, CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.NotEmpty(result.Errors);
await monitor.StopAsync();
}
[Fact]
public async Task ProbeAsync_BeforeStart_ReturnsFail()
{
var factory = new FakeFactory("test", m => Result<IProtocolDriver>.Ok(
new FakeDriver("test", () => Result<MachineSnapshot>.Ok(Snapshot(m.Id)))));
var loader = LoaderReturning(factory);
var repo = RepoReturning();
var monitor = NewMonitor(repo.Object, loader.Object);
var machine = MachineWith("test");
var result = await monitor.ProbeAsync(machine, CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Message.Contains("not started"));
}
/// <summary>Hand-rolled factory; behavior supplied by a delegate.</summary>
private sealed class FakeFactory : IProtocolDriverFactory
{
@ -401,15 +467,20 @@ namespace Junction.Tests.Unit
public Result<IProtocolDriver> Create(Machine machine) => _create(machine);
}
/// <summary>Hand-rolled driver; behavior supplied by a delegate.</summary>
/// <summary>Hand-rolled driver; behavior supplied by a delegate. Optional canned probe catalog.</summary>
private sealed class FakeDriver : IProtocolDriver
{
private readonly Func<Result<MachineSnapshot>> _read;
private readonly Func<Result<IReadOnlyList<DataItemDescriptor>>>? _probe;
public FakeDriver(string protocolId, Func<Result<MachineSnapshot>> read)
public FakeDriver(
string protocolId,
Func<Result<MachineSnapshot>> read,
Func<Result<IReadOnlyList<DataItemDescriptor>>>? probe = null)
{
ProtocolId = protocolId;
_read = read;
_probe = probe;
}
public string ProtocolId { get; }
@ -419,6 +490,11 @@ namespace Junction.Tests.Unit
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_read());
}
public Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(CancellationToken cancellationToken) =>
Task.FromResult(_probe != null
? _probe()
: Result<IReadOnlyList<DataItemDescriptor>>.Ok(Array.Empty<DataItemDescriptor>()));
}
/// <summary>
@ -454,6 +530,9 @@ namespace Junction.Tests.Unit
_exit();
}
}
public Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(CancellationToken cancellationToken) =>
Task.FromResult(Result<IReadOnlyList<DataItemDescriptor>>.Ok(Array.Empty<DataItemDescriptor>()));
}
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
@ -23,6 +24,22 @@ namespace Junction.Tests.Unit
return File.ReadAllText(path);
}
private static string LoadProbeFixture()
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "mtconnect", "probe.xml");
return File.ReadAllText(path);
}
// All dataItem ids present in current.xml. Used as the default selection so item-agnostic
// tests keep observing a non-empty snapshot despite the opt-in ("monitor nothing") default.
private static readonly string[] AllCurrentItemIds =
{
"dev1_avail", "x1_pos", "x1_pos_cmd", "x1_load", "y1_pos", "z1_pos",
"c1_spindle_speed", "c1_spindle_speed_cmd", "c1_load", "c1_rot_mode", "c1_temp_cond",
"cn1_mode", "cn1_estop", "cn1_system", "path1_exec", "path1_program", "path1_line",
"path1_feed", "path1_logic",
};
/// <summary>Stub handler: canned response or thrown exception, per configuration.</summary>
private sealed class StubHandler : HttpMessageHandler
{
@ -53,12 +70,17 @@ namespace Junction.Tests.Unit
private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30);
// Shared client with infinite global timeout mirrors production; per-request timeout enforced in driver.
private static MtconnectDriver DriverWith(HttpMessageHandler handler, TimeSpan? requestTimeout = null) =>
// Defaults the monitored selection to every current.xml id so item-agnostic tests still see items.
private static MtconnectDriver DriverWith(
HttpMessageHandler handler,
TimeSpan? requestTimeout = null,
IReadOnlyCollection<string>? monitoredItemIds = null) =>
new MtconnectDriver(
MachineId,
AgentUrl,
new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan },
requestTimeout ?? GenerousTimeout);
requestTimeout ?? GenerousTimeout,
monitoredItemIds ?? AllCurrentItemIds);
// ---- ReadCurrentAsync: happy path ----
@ -199,6 +221,93 @@ namespace Junction.Tests.Unit
Assert.True(result.WasCancelled);
}
// ---- ProbeAsync: catalog ----
[Fact]
public async Task ProbeAsync_200WithValidProbeXml_ReturnsFullCatalog()
{
var handler = new StubHandler(HttpStatusCode.OK, LoadProbeFixture());
var driver = DriverWith(handler);
var result = await driver.ProbeAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(result.WasCancelled);
Assert.NotNull(result.Value);
Assert.True(result.Value.Count > 0);
// Probe returns the FULL (unfiltered) catalog. Spot-check a known descriptor.
var pos = result.Value.Single(d => d.Id == "x1_pos");
Assert.Equal("POSITION", pos.Type);
Assert.Equal("SAMPLE", pos.Category);
Assert.Equal("MILLIMETER", pos.Units);
// Driver hits the agent's /probe endpoint.
Assert.NotNull(handler.LastRequestUri);
Assert.EndsWith("/probe", handler.LastRequestUri!.AbsoluteUri);
}
[Fact]
public async Task ProbeAsync_NonSuccessStatus_ReturnsFailNoThrow()
{
var handler = new StubHandler(HttpStatusCode.InternalServerError, "irrelevant");
var driver = DriverWith(handler);
var result = await driver.ProbeAsync(CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Contains(result.Errors, e => e.Code == "PROBE_HTTP_STATUS");
}
[Fact]
public async Task ProbeAsync_CancelledBeforeCall_ReturnsCancelled()
{
var handler = new StubHandler(HttpStatusCode.OK, LoadProbeFixture());
var driver = DriverWith(handler);
using var cts = new CancellationTokenSource();
cts.Cancel();
var result = await driver.ProbeAsync(cts.Token);
Assert.False(result.IsSuccess);
Assert.True(result.WasCancelled);
}
// ---- ReadCurrentAsync: per-machine selection filter (opt-in) ----
[Fact]
public async Task ReadCurrentAsync_SelectionSubset_KeepsOnlySelectedItems_PreservesConnectionState()
{
var handler = new StubHandler(HttpStatusCode.OK, LoadCurrentFixture());
var selection = new[] { "x1_pos", "c1_load" };
var driver = DriverWith(handler, 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 == "x1_pos");
Assert.Contains(result.Value.Items, i => i.Id == "c1_load");
// dev1_avail is not selected: it is filtered out of Items, but still drove the state.
Assert.DoesNotContain(result.Value.Items, i => i.Id == "dev1_avail");
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
Assert.Equal(MachineId, result.Value.MachineId);
}
[Fact]
public async Task ReadCurrentAsync_EmptySelection_ReturnsEmptyItems_PreservesConnectionState()
{
var handler = new StubHandler(HttpStatusCode.OK, LoadCurrentFixture());
var driver = DriverWith(handler, monitoredItemIds: Array.Empty<string>());
var result = await driver.ReadCurrentAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Empty(result.Value.Items);
// Opt-in: nothing kept, but availability-derived state is preserved.
Assert.Equal(ConnectionState.Connected, result.Value.ConnectionState);
}
// ---- Factory: config validation ----
private static Machine MachineWithConfig(IReadOnlyDictionary<string, string>? config) =>

View file

@ -45,6 +45,9 @@ namespace Junction.Tests.Unit
DateTimeOffset.UtcNow,
ConnectionState.Connected,
Array.Empty<DataItem>())));
public Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(CancellationToken cancellationToken) =>
Task.FromResult(Result<IReadOnlyList<DataItemDescriptor>>.Ok(Array.Empty<DataItemDescriptor>()));
}
/// <summary>Type that does NOT implement the factory contract.</summary>

View file

@ -282,6 +282,9 @@ namespace Junction.Tests.Unit
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_behavior(null!));
}
public Task<Result<IReadOnlyList<DataItemDescriptor>>> ProbeAsync(CancellationToken cancellationToken) =>
Task.FromResult(Result<IReadOnlyList<DataItemDescriptor>>.Ok(Array.Empty<DataItemDescriptor>()));
}
}
}

View file

@ -76,6 +76,121 @@ namespace Junction.Tests.Unit
Assert.Equal("M1", loaded.ConnectionConfig["device"]);
}
[Fact]
public async Task Upsert_MonitoredItemIds_GetById_RoundTripsSelection()
{
var id = Guid.NewGuid();
var selection = new[] { "x1_pos", "c1_load", "dev1_avail" };
var machine = new Machine(
id, "Mill 02", "mtconnect", null, TimeSpan.FromSeconds(3), selection);
Result upsert = await _repo.UpsertAsync(machine, CancellationToken.None);
Assert.True(upsert.IsSuccess, Describe(upsert));
Result<Machine> got = await _repo.GetByIdAsync(id, CancellationToken.None);
Assert.True(got.IsSuccess, Describe(got));
Assert.Equal(3, got.Value.MonitoredItemIds.Count);
Assert.Equal(selection.OrderBy(x => x), got.Value.MonitoredItemIds.OrderBy(x => x));
}
[Fact]
public async Task Upsert_DefaultMachine_MonitoredItemIds_IsEmpty_NotNull()
{
var id = Guid.NewGuid();
// Backward-compat ctor (no selection) => empty opt-in set, round-trips as empty.
var machine = new Machine(id, "Mill 03", "mtconnect", null, TimeSpan.FromSeconds(1));
await _repo.UpsertAsync(machine, CancellationToken.None);
Result<Machine> got = await _repo.GetByIdAsync(id, CancellationToken.None);
Assert.True(got.IsSuccess, Describe(got));
Assert.NotNull(got.Value.MonitoredItemIds);
Assert.Empty(got.Value.MonitoredItemIds);
}
[Fact]
public async Task EnsureCreated_OnPreExistingMachinesTable_AddsMonitoredItemIdsColumn_KeepsRows()
{
// Simulate a DB created by the OLD schema (no MonitoredItemIdsJson column).
var oldDbPath = Path.Combine(
Path.GetTempPath(), "junction_migration_test_" + Guid.NewGuid().ToString("N") + ".db");
var connectionString = "Data Source=" + oldDbPath;
var factory = new SqliteConnectionFactory(connectionString);
try
{
var rowId = Guid.NewGuid().ToString("D");
using (var conn = factory.CreateOpenConnection())
{
Exec(conn,
@"CREATE TABLE machines (
Id TEXT NOT NULL PRIMARY KEY,
Name TEXT NOT NULL,
ProtocolId TEXT NOT NULL,
PollIntervalTicks INTEGER NOT NULL,
ConnectionConfigJson TEXT NOT NULL
);");
Exec(conn,
"INSERT INTO machines (Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson) " +
"VALUES ('" + rowId + "', 'Legacy', 'mtconnect', 10000000, '{}');");
Assert.False(HasColumn(conn, "machines", "MonitoredItemIdsJson"));
}
// Migration path: EnsureCreated must ALTER-add the missing column, idempotently.
Result schema = SqliteSchema.EnsureCreated(factory);
Assert.True(schema.IsSuccess, Describe(schema));
using (var conn = factory.CreateOpenConnection())
{
Assert.True(HasColumn(conn, "machines", "MonitoredItemIdsJson"));
}
// Existing row survived and reads back (null selection => empty set).
var repo = new SqliteMachineRepository(factory);
Result<Machine> got = await repo.GetByIdAsync(Guid.Parse(rowId), CancellationToken.None);
Assert.True(got.IsSuccess, Describe(got));
Assert.Equal("Legacy", got.Value.Name);
Assert.Empty(got.Value.MonitoredItemIds);
// Idempotent: running again does not fail.
Assert.True(SqliteSchema.EnsureCreated(factory).IsSuccess);
}
finally
{
try { if (File.Exists(oldDbPath)) File.Delete(oldDbPath); } catch { /* best-effort */ }
}
}
private static void Exec(System.Data.IDbConnection conn, string sql)
{
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
}
private static bool HasColumn(System.Data.IDbConnection conn, string table, string column)
{
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(" + table + ");";
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
if (string.Equals(reader.GetValue(1)?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
}
return false;
}
[Fact]
public async Task Upsert_ExistingId_Updates_GetAllCountStable()
{