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.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.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.4 | OPC UA Protocol | 4 | 📋 Planned | - |
| v0.5 | Fanuc FOCAS Protocol | 5 | 📋 Planned | - | | v0.5 | Fanuc FOCAS Protocol | 5 | 📋 Planned | - |
| v0.6 | History & Trends | 6 | 📋 Planned | - | | v0.6 | History & Trends | 6 | 📋 Planned | - |

View file

@ -5,27 +5,30 @@
See: .paul/PROJECT.md (updated 2026-07-21) 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. **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 ## Current Position
Milestone: v0.2 Machine Management UX (+ 2.1 refinements) — SHIPPED Milestone: v0.3 Data-Item Selection + UX
Phase: 2 ✓, 2.1 ✓ → next Phase 3 (Data-Item Selection) Phase: 3 (item selection) ✓ SHIPPED — Phase 3.1 (theming/UX) NOT started
Plan: 02-A/B/C ✓, 02.1 R1/R2R3/tablefix Plan: v0.3-backend ✓, v0.3-appui
Status: v0.2.1 committing; v0.3 backend next Status: PAUSED (user requested). Build 0/0, 138 tests + 2 docker-skip.
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. 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: Progress:
- v0.2 (+2.1): [██████████] 100% - v0.3: Phase 3 done, Phase 3.1 (theming) pending
- Next: Phase 3 Data-Item Selection
## Loop Position ## Loop Position
``` ```
PLAN ──▶ APPLY ──▶ UNIFY 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 ## 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. 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", "name": "Junction",
"version": "0.2.0", "version": "0.3.0",
"milestone": { "milestone": {
"name": "Machine Management UX", "name": "Data-Item Selection + UX",
"version": "0.2.0", "version": "0.3.0",
"status": "shipped" "status": "in_progress"
}, },
"phase": { "phase": {
"number": 2, "number": 3,
"name": "Machine Management UX", "name": "Data-Item Selection",
"status": "complete" "status": "complete"
}, },
"loop": { "loop": {
"plan": "02-C", "plan": "v0.3-appui",
"position": "UNIFY" "position": "PAUSED"
}, },
"timestamps": { "timestamps": {
"created_at": "2026-07-21T23:30:00Z", "created_at": "2026-07-21T23:30:00Z",
"updated_at": "2026-07-21T23:45:00Z" "updated_at": "2026-07-22T00:40:00Z"
}, },
"satellite": { "satellite": {
"groom": true "groom": true

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@ -8,6 +9,7 @@ using CommunityToolkit.Mvvm.Input;
using Junction.Core.Monitoring; using Junction.Core.Monitoring;
using Junction.Domain.Models; using Junction.Domain.Models;
using Junction.Domain.Persistence; using Junction.Domain.Persistence;
using Junction.Domain.Protocols;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Junction.App.ViewModels namespace Junction.App.ViewModels
@ -30,6 +32,14 @@ namespace Junction.App.ViewModels
private Guid? _editingId; 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> /// <summary>Shell reference used to return to the dashboard after save/cancel.</summary>
public MainWindowViewModel? Navigator { get; set; } public MainWindowViewModel? Navigator { get; set; }
@ -41,12 +51,14 @@ namespace Junction.App.ViewModels
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))] [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadItemsCommand))]
[NotifyPropertyChangedFor(nameof(ValidationError))] [NotifyPropertyChangedFor(nameof(ValidationError))]
[NotifyPropertyChangedFor(nameof(HasValidationError))] [NotifyPropertyChangedFor(nameof(HasValidationError))]
private string _selectedProtocol = ""; private string _selectedProtocol = "";
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))] [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadItemsCommand))]
[NotifyPropertyChangedFor(nameof(ValidationError))] [NotifyPropertyChangedFor(nameof(ValidationError))]
[NotifyPropertyChangedFor(nameof(HasValidationError))] [NotifyPropertyChangedFor(nameof(HasValidationError))]
private string _agentUrl = ""; private string _agentUrl = "";
@ -59,9 +71,29 @@ namespace Junction.App.ViewModels
[ObservableProperty] private bool _isEdit; [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> /// <summary>Protocol ids offered in the dropdown, sourced from the monitor at Initialize.</summary>
public ObservableCollection<string> AvailableProtocols { get; } = new ObservableCollection<string>(); 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( public MachineConfigViewModel(
IMachineRepository repository, IMachineRepository repository,
IMachineMonitor monitor, IMachineMonitor monitor,
@ -93,12 +125,16 @@ namespace Junction.App.ViewModels
_editingId = machineId; _editingId = machineId;
IsEdit = machineId.HasValue; IsEdit = machineId.HasValue;
_existingMonitoredItemIds = new List<string>();
AvailableItems.Clear();
if (machineId is null) if (machineId is null)
{ {
Name = ""; Name = "";
SelectedProtocol = AvailableProtocols.Count > 0 ? AvailableProtocols[0] : ""; SelectedProtocol = AvailableProtocols.Count > 0 ? AvailableProtocols[0] : "";
AgentUrl = ""; AgentUrl = "";
PollIntervalSeconds = 2; PollIntervalSeconds = 2;
ItemsStatus = "Load available items to choose what to monitor (requires a reachable agent).";
} }
else else
{ {
@ -137,6 +173,123 @@ namespace Junction.App.ViewModels
SelectedProtocol = m.ProtocolId; SelectedProtocol = m.ProtocolId;
AgentUrl = m.ConnectionConfig.TryGetValue("AgentUrl", out var url) ? url : ""; AgentUrl = m.ConnectionConfig.TryGetValue("AgentUrl", out var url) ? url : "";
PollIntervalSeconds = m.PollInterval.TotalSeconds > 0 ? (int)m.PollInterval.TotalSeconds : 1; 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() private string? Validate()
@ -170,12 +323,20 @@ namespace Junction.App.ViewModels
private async Task Save() private async Task Save()
{ {
var id = _editingId ?? Guid.NewGuid(); 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( var machine = new Machine(
id, id,
Name.Trim(), Name.Trim(),
SelectedProtocol, SelectedProtocol,
new Dictionary<string, string> { ["AgentUrl"] = AgentUrl.Trim() }, new Dictionary<string, string> { ["AgentUrl"] = AgentUrl.Trim() },
TimeSpan.FromSeconds(PollIntervalSeconds)); TimeSpan.FromSeconds(PollIntervalSeconds),
monitoredItemIds);
var upsert = await _repository.UpsertAsync(machine, CancellationToken.None).ConfigureAwait(true); var upsert = await _repository.UpsertAsync(machine, CancellationToken.None).ConfigureAwait(true);
if (!upsert.IsSuccess) if (!upsert.IsSuccess)

View file

@ -53,7 +53,16 @@ namespace Junction.App.ViewModels
/// <summary>Timestamp of the last CONNECTED snapshot; "—" until first connect.</summary> /// <summary>Timestamp of the last CONNECTED snapshot; "—" until first connect.</summary>
[ObservableProperty] private string _lastSeen = "—"; [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> /// <summary>True only when currently connected.</summary>
public bool IsOnline => ConnectionState == ConnectionState.Connected; 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>
</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> </DockPanel>
</UserControl> </UserControl>

View file

@ -67,7 +67,8 @@
<Border DockPanel.Dock="Top" <Border DockPanel.Dock="Top"
BorderThickness="0,0,0,1" BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" 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*"> <Grid ColumnDefinitions="2*,2*,1.2*,1.2*">
<TextBlock Grid.Column="0" Text="Name" FontWeight="Bold" /> <TextBlock Grid.Column="0" Text="Name" FontWeight="Bold" />
<TextBlock Grid.Column="1" Text="Value" FontWeight="Bold" /> <TextBlock Grid.Column="1" Text="Value" FontWeight="Bold" />
@ -76,24 +77,37 @@
</Grid> </Grid>
</Border> </Border>
<!-- Full data-item table --> <!-- Data-item table (fills), or an empty-state hint when nothing is monitored -->
<ScrollViewer> <Grid>
<ItemsControl ItemsSource="{Binding Items}"> <ScrollViewer IsVisible="{Binding HasItems}">
<ItemsControl.ItemTemplate> <ItemsControl ItemsSource="{Binding Items}">
<DataTemplate x:DataType="vm:DataItemRowViewModel"> <ItemsControl.ItemTemplate>
<Grid ColumnDefinitions="2*,2*,1.2*,1.2*" Margin="0,5"> <DataTemplate x:DataType="vm:DataItemRowViewModel">
<StackPanel Grid.Column="0"> <Grid ColumnDefinitions="2*,2*,1.2*,1.2*" Margin="0,5">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" /> <StackPanel Grid.Column="0">
<TextBlock Text="{Binding Id}" FontSize="11" Opacity="0.6" TextWrapping="Wrap" /> <TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap" />
</StackPanel> <TextBlock Text="{Binding Id}" FontSize="11" Opacity="0.6" TextWrapping="Wrap" />
<TextBlock Grid.Column="1" Text="{Binding Value}" VerticalAlignment="Center" TextWrapping="Wrap" /> </StackPanel>
<TextBlock Grid.Column="2" Text="{Binding Category}" VerticalAlignment="Center" /> <TextBlock Grid.Column="1" Text="{Binding Value}" VerticalAlignment="Center" TextWrapping="Wrap" />
<TextBlock Grid.Column="3" Text="{Binding Timestamp}" VerticalAlignment="Center" /> <TextBlock Grid.Column="2" Text="{Binding Category}" VerticalAlignment="Center" />
</Grid> <TextBlock Grid.Column="3" Text="{Binding Timestamp}" VerticalAlignment="Center" />
</DataTemplate> </Grid>
</ItemsControl.ItemTemplate> </DataTemplate>
</ItemsControl> </ItemsControl.ItemTemplate>
</ScrollViewer> </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> </DockPanel>
<!-- Delete-confirmation overlay: dims the screen, names the machine, requires an explicit choice. --> <!-- 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 System.Threading.Tasks;
using Junction.Domain; using Junction.Domain;
using Junction.Domain.Models; using Junction.Domain.Models;
using Junction.Domain.Protocols;
namespace Junction.Core.Monitoring namespace Junction.Core.Monitoring
{ {
@ -84,5 +85,19 @@ namespace Junction.Core.Monitoring
/// monitor to have been started. /// monitor to have been started.
/// </summary> /// </summary>
Task<Result> RemoveMachineAsync(Guid machineId, CancellationToken cancellationToken); 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(); 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> /// <summary>
/// Removes and returns the tracked loop for <paramref name="machineId"/>, or null. /// Removes and returns the tracked loop for <paramref name="machineId"/>, or null.
/// Caller must hold <see cref="_lifecycleLock"/>. /// Caller must hold <see cref="_lifecycleLock"/>.

View file

@ -13,6 +13,8 @@ namespace Junction.Domain.Models
private static readonly IReadOnlyDictionary<string, string> EmptyConfig = private static readonly IReadOnlyDictionary<string, string> EmptyConfig =
new Dictionary<string, string>(0); new Dictionary<string, string>(0);
private static readonly IReadOnlyCollection<string> EmptyItemIds = new string[0];
/// <summary>Stable unique identifier.</summary> /// <summary>Stable unique identifier.</summary>
public Guid Id { get; } public Guid Id { get; }
@ -28,18 +30,26 @@ namespace Junction.Domain.Models
/// <summary>How often to poll the machine.</summary> /// <summary>How often to poll the machine.</summary>
public TimeSpan PollInterval { get; } 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( public Machine(
Guid id, Guid id,
string name, string name,
string protocolId, string protocolId,
IReadOnlyDictionary<string, string>? connectionConfig, IReadOnlyDictionary<string, string>? connectionConfig,
TimeSpan pollInterval) TimeSpan pollInterval,
IReadOnlyCollection<string>? monitoredItemIds = null)
{ {
Id = id; Id = id;
Name = name ?? ""; Name = name ?? "";
ProtocolId = protocolId ?? ""; ProtocolId = protocolId ?? "";
ConnectionConfig = connectionConfig ?? EmptyConfig; ConnectionConfig = connectionConfig ?? EmptyConfig;
PollInterval = pollInterval; PollInterval = pollInterval;
MonitoredItemIds = monitoredItemIds ?? EmptyItemIds;
} }
/// <summary>Returns a copy with the given fields overridden; null args keep the current value.</summary> /// <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? name = null,
string? protocolId = null, string? protocolId = null,
IReadOnlyDictionary<string, string>? connectionConfig = null, IReadOnlyDictionary<string, string>? connectionConfig = null,
TimeSpan? pollInterval = null) TimeSpan? pollInterval = null,
IReadOnlyCollection<string>? monitoredItemIds = null)
{ {
return new Machine( return new Machine(
Id, Id,
name ?? Name, name ?? Name,
protocolId ?? ProtocolId, protocolId ?? ProtocolId,
connectionConfig ?? ConnectionConfig, 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;
using System.Threading.Tasks; using System.Threading.Tasks;
using Junction.Domain.Models; using Junction.Domain.Models;
@ -20,5 +21,14 @@ namespace Junction.Domain.Protocols
/// </summary> /// </summary>
/// <param name="cancellationToken">Cancellation token. Mandatory.</param> /// <param name="cancellationToken">Cancellation token. Mandatory.</param>
Task<Result<MachineSnapshot>> ReadCurrentAsync(CancellationToken cancellationToken); 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()) using (var connection = OpenConnection())
{ {
var command = new CommandDefinition( var command = new CommandDefinition(
"SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson FROM machines;", "SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson, MonitoredItemIdsJson FROM machines;",
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<MachineRow>(command).ConfigureAwait(false); var rows = await connection.QueryAsync<MachineRow>(command).ConfigureAwait(false);
@ -97,7 +97,7 @@ namespace Junction.Persistence
using (var connection = OpenConnection()) using (var connection = OpenConnection())
{ {
var command = new CommandDefinition( var command = new CommandDefinition(
"SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson " + "SELECT Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson, MonitoredItemIdsJson " +
"FROM machines WHERE Id = @Id;", "FROM machines WHERE Id = @Id;",
new { Id = GuidText(id) }, new { Id = GuidText(id) },
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
@ -141,20 +141,22 @@ namespace Junction.Persistence
using (var connection = OpenConnection()) using (var connection = OpenConnection())
{ {
var command = new CommandDefinition( var command = new CommandDefinition(
"INSERT INTO machines (Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson) " + "INSERT INTO machines (Id, Name, ProtocolId, PollIntervalTicks, ConnectionConfigJson, MonitoredItemIdsJson) " +
"VALUES (@Id, @Name, @ProtocolId, @PollIntervalTicks, @ConnectionConfigJson) " + "VALUES (@Id, @Name, @ProtocolId, @PollIntervalTicks, @ConnectionConfigJson, @MonitoredItemIdsJson) " +
"ON CONFLICT(Id) DO UPDATE SET " + "ON CONFLICT(Id) DO UPDATE SET " +
"Name = excluded.Name, " + "Name = excluded.Name, " +
"ProtocolId = excluded.ProtocolId, " + "ProtocolId = excluded.ProtocolId, " +
"PollIntervalTicks = excluded.PollIntervalTicks, " + "PollIntervalTicks = excluded.PollIntervalTicks, " +
"ConnectionConfigJson = excluded.ConnectionConfigJson;", "ConnectionConfigJson = excluded.ConnectionConfigJson, " +
"MonitoredItemIdsJson = excluded.MonitoredItemIdsJson;",
new new
{ {
Id = GuidText(machine.Id), Id = GuidText(machine.Id),
machine.Name, machine.Name,
machine.ProtocolId, machine.ProtocolId,
PollIntervalTicks = machine.PollInterval.Ticks, PollIntervalTicks = machine.PollInterval.Ticks,
ConnectionConfigJson = SerializeConfig(machine.ConnectionConfig) ConnectionConfigJson = SerializeConfig(machine.ConnectionConfig),
MonitoredItemIdsJson = SerializeItemIds(machine.MonitoredItemIds)
}, },
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
@ -376,7 +378,8 @@ namespace Junction.Persistence
row.Name, row.Name,
row.ProtocolId, row.ProtocolId,
DeserializeConfig(row.ConnectionConfigJson), DeserializeConfig(row.ConnectionConfigJson),
TimeSpan.FromTicks(row.PollIntervalTicks)); TimeSpan.FromTicks(row.PollIntervalTicks),
DeserializeItemIds(row.MonitoredItemIdsJson));
} }
private static string GuidText(Guid id) => id.ToString("D"); private static string GuidText(Guid id) => id.ToString("D");
@ -416,6 +419,31 @@ namespace Junction.Persistence
return dict ?? new Dictionary<string, string>(0); 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) ----------------------------------- // -- row DTOs (private; never cross the boundary) -----------------------------------
private sealed class MachineRow private sealed class MachineRow
@ -425,6 +453,7 @@ namespace Junction.Persistence
public string ProtocolId { get; set; } = ""; public string ProtocolId { get; set; } = "";
public long PollIntervalTicks { get; set; } public long PollIntervalTicks { get; set; }
public string? ConnectionConfigJson { get; set; } public string? ConnectionConfigJson { get; set; }
public string? MonitoredItemIdsJson { get; set; }
} }
private sealed class SnapshotRow private sealed class SnapshotRow

View file

@ -18,7 +18,8 @@ namespace Junction.Persistence
Name TEXT NOT NULL, Name TEXT NOT NULL,
ProtocolId TEXT NOT NULL, ProtocolId TEXT NOT NULL,
PollIntervalTicks INTEGER 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. // latest_snapshots: exactly one row per machine (PK = MachineId). Upsert overwrites.
@ -63,6 +64,11 @@ namespace Junction.Persistence
Execute(connection, CreateLatestSnapshots); Execute(connection, CreateLatestSnapshots);
Execute(connection, CreateSnapshotItems); 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(); return Result.Ok();
} }
catch (Exception ex) catch (Exception ex)
@ -100,5 +106,38 @@ namespace Junction.Persistence
command.ExecuteNonQuery(); 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;
using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -25,12 +26,17 @@ namespace Junction.Protocols.MTConnect
{ {
private const string Source = "MtconnectDriver"; private const string Source = "MtconnectDriver";
private const string CurrentPath = "current"; private const string CurrentPath = "current";
private const string ProbePath = "probe";
private readonly Guid _machineId; private readonly Guid _machineId;
private readonly Uri _currentUri; private readonly Uri _currentUri;
private readonly Uri _probeUri;
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly TimeSpan _requestTimeout; 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> /// <summary>Protocol identifier this driver serves.</summary>
public string ProtocolId => "mtconnect"; 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 /// Per-request timeout enforced via a linked <see cref="CancellationTokenSource"/>. Must be positive
/// or <see cref="Timeout.InfiniteTimeSpan"/>. /// or <see cref="Timeout.InfiniteTimeSpan"/>.
/// </param> /// </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)); if (agentUrl is null) throw new ArgumentNullException(nameof(agentUrl));
_http = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _http = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
@ -59,18 +74,33 @@ namespace Junction.Protocols.MTConnect
_requestTimeout = requestTimeout; _requestTimeout = requestTimeout;
_machineId = machineId; _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)) if (!Uri.TryCreate(agentUrl, UriKind.Absolute, out var baseUri))
{ {
throw new ArgumentException("Agent URL must be an absolute URI: '" + agentUrl + "'.", nameof(agentUrl)); 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; var basePath = baseUri.AbsoluteUri;
if (!basePath.EndsWith("/", StringComparison.Ordinal)) if (!basePath.EndsWith("/", StringComparison.Ordinal))
{ {
basePath += "/"; 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 /> /// <inheritdoc />
@ -139,11 +169,124 @@ namespace Junction.Protocols.MTConnect
} }
// Delegate parsing (T15). Parser never throws; returns Fail on malformed input. // 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) => private static Result<MachineSnapshot> Fail(string code, string message) =>
Result<MachineSnapshot>.Fail(new OperationError(code, Source, 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); 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); 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")); 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> /// <summary>Hand-rolled factory; behavior supplied by a delegate.</summary>
private sealed class FakeFactory : IProtocolDriverFactory private sealed class FakeFactory : IProtocolDriverFactory
{ {
@ -401,15 +467,20 @@ namespace Junction.Tests.Unit
public Result<IProtocolDriver> Create(Machine machine) => _create(machine); 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 sealed class FakeDriver : IProtocolDriver
{ {
private readonly Func<Result<MachineSnapshot>> _read; 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; ProtocolId = protocolId;
_read = read; _read = read;
_probe = probe;
} }
public string ProtocolId { get; } public string ProtocolId { get; }
@ -419,6 +490,11 @@ namespace Junction.Tests.Unit
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_read()); 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> /// <summary>
@ -454,6 +530,9 @@ namespace Junction.Tests.Unit
_exit(); _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;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
@ -23,6 +24,22 @@ namespace Junction.Tests.Unit
return File.ReadAllText(path); 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> /// <summary>Stub handler: canned response or thrown exception, per configuration.</summary>
private sealed class StubHandler : HttpMessageHandler private sealed class StubHandler : HttpMessageHandler
{ {
@ -53,12 +70,17 @@ namespace Junction.Tests.Unit
private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan GenerousTimeout = TimeSpan.FromSeconds(30);
// Shared client with infinite global timeout mirrors production; per-request timeout enforced in driver. // 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( new MtconnectDriver(
MachineId, MachineId,
AgentUrl, AgentUrl,
new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }, new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan },
requestTimeout ?? GenerousTimeout); requestTimeout ?? GenerousTimeout,
monitoredItemIds ?? AllCurrentItemIds);
// ---- ReadCurrentAsync: happy path ---- // ---- ReadCurrentAsync: happy path ----
@ -199,6 +221,93 @@ namespace Junction.Tests.Unit
Assert.True(result.WasCancelled); 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 ---- // ---- Factory: config validation ----
private static Machine MachineWithConfig(IReadOnlyDictionary<string, string>? config) => private static Machine MachineWithConfig(IReadOnlyDictionary<string, string>? config) =>

View file

@ -45,6 +45,9 @@ namespace Junction.Tests.Unit
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
ConnectionState.Connected, ConnectionState.Connected,
Array.Empty<DataItem>()))); 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> /// <summary>Type that does NOT implement the factory contract.</summary>

View file

@ -282,6 +282,9 @@ namespace Junction.Tests.Unit
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_behavior(null!)); 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"]); 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] [Fact]
public async Task Upsert_ExistingId_Updates_GetAllCountStable() public async Task Upsert_ExistingId_Updates_GetAllCountStable()
{ {