diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md
index c8b3102..dd66d08 100644
--- a/.paul/ROADMAP.md
+++ b/.paul/ROADMAP.md
@@ -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 | - |
diff --git a/.paul/STATE.md b/.paul/STATE.md
index 293f478..79d2a08 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -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.
diff --git a/.paul/paul.json b/.paul/paul.json
index 2e8ad49..6c7989c 100644
--- a/.paul/paul.json
+++ b/.paul/paul.json
@@ -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
diff --git a/src/Junction.App/ViewModels/MachineConfigViewModel.cs b/src/Junction.App/ViewModels/MachineConfigViewModel.cs
index d679423..5f0c5be 100644
--- a/src/Junction.App/ViewModels/MachineConfigViewModel.cs
+++ b/src/Junction.App/ViewModels/MachineConfigViewModel.cs
@@ -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;
+ ///
+ /// 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 must not wipe an existing selection).
+ /// Empty for ADD.
+ ///
+ private List _existingMonitoredItemIds = new List();
+
/// Shell reference used to return to the dashboard after save/cancel.
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;
+ /// True while a probe (LoadItems) is in flight; drives the busy indicator and disables the button.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(LoadItemsCommand))]
+ private bool _isProbing;
+
+ /// User-visible status for the item section (probe failures, hints). Empty when nothing to say.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasItemsStatus))]
+ private string _itemsStatus = "";
+
+ public bool HasItemsStatus => !string.IsNullOrEmpty(ItemsStatus);
+
/// Protocol ids offered in the dropdown, sourced from the monitor at Initialize.
public ObservableCollection AvailableProtocols { get; } = new ObservableCollection();
+ ///
+ /// Catalog of selectable data items for the current machine, populated by .
+ /// 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).
+ ///
+ public ObservableCollection AvailableItems { get; } =
+ new ObservableCollection();
+
public MachineConfigViewModel(
IMachineRepository repository,
IMachineMonitor monitor,
@@ -93,12 +125,16 @@ namespace Junction.App.ViewModels
_editingId = machineId;
IsEdit = machineId.HasValue;
+ _existingMonitoredItemIds = new List();
+ 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();
+
+ 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.";
+ }
+
+ ///
+ /// Probes the machine for its full data-item catalog and populates .
+ /// Runs off the button click (async): sets 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.
+ ///
+ [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 { ["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(_existingMonitoredItemIds, StringComparer.Ordinal);
+ AvailableItems.Clear();
+ var catalog = result.Value ?? (IReadOnlyList)Array.Empty();
+ 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 _);
+
+ ///
+ /// Seeds 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.
+ ///
+ 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 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 { ["AgentUrl"] = AgentUrl.Trim() },
- TimeSpan.FromSeconds(PollIntervalSeconds));
+ TimeSpan.FromSeconds(PollIntervalSeconds),
+ monitoredItemIds);
var upsert = await _repository.UpsertAsync(machine, CancellationToken.None).ConfigureAwait(true);
if (!upsert.IsSuccess)
diff --git a/src/Junction.App/ViewModels/MachineDetailViewModel.cs b/src/Junction.App/ViewModels/MachineDetailViewModel.cs
index 3095e3c..3ef1c2f 100644
--- a/src/Junction.App/ViewModels/MachineDetailViewModel.cs
+++ b/src/Junction.App/ViewModels/MachineDetailViewModel.cs
@@ -53,7 +53,16 @@ namespace Junction.App.ViewModels
/// Timestamp of the last CONNECTED snapshot; "β" until first connect.
[ObservableProperty] private string _lastSeen = "β";
- [ObservableProperty] private int _itemCount;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasItems))]
+ [NotifyPropertyChangedFor(nameof(HasNoItems))]
+ private int _itemCount;
+
+ /// True when there is at least one monitored data item to display.
+ public bool HasItems => ItemCount > 0;
+
+ /// True when nothing is monitored β drives the empty-state hint instead of a blank table.
+ public bool HasNoItems => ItemCount == 0;
/// True only when currently connected.
public bool IsOnline => ConnectionState == ConnectionState.Connected;
diff --git a/src/Junction.App/ViewModels/SelectableDataItemViewModel.cs b/src/Junction.App/ViewModels/SelectableDataItemViewModel.cs
new file mode 100644
index 0000000..ae7051f
--- /dev/null
+++ b/src/Junction.App/ViewModels/SelectableDataItemViewModel.cs
@@ -0,0 +1,35 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Junction.Domain.Protocols;
+
+namespace Junction.App.ViewModels
+{
+ ///
+ /// One selectable row in the config screen's monitored-item checklist. Wraps a
+ /// (catalog entry from a probe) and carries the user's
+ /// choice. The descriptor is immutable; only the selection mutates.
+ ///
+ public sealed partial class SelectableDataItemViewModel : ObservableObject
+ {
+ /// The underlying catalog entry. Its Id is what gets persisted when selected.
+ public DataItemDescriptor Descriptor { get; }
+
+ public string Id => Descriptor.Id;
+
+ /// Display name; falls back to the id when the descriptor carries no name.
+ 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;
+ }
+ }
+}
diff --git a/src/Junction.App/Views/MachineConfigView.axaml b/src/Junction.App/Views/MachineConfigView.axaml
index b9fa742..1238c30 100644
--- a/src/Junction.App/Views/MachineConfigView.axaml
+++ b/src/Junction.App/Views/MachineConfigView.axaml
@@ -57,5 +57,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Junction.App/Views/MachineDetailView.axaml b/src/Junction.App/Views/MachineDetailView.axaml
index e4ee199..8fe4de9 100644
--- a/src/Junction.App/Views/MachineDetailView.axaml
+++ b/src/Junction.App/Views/MachineDetailView.axaml
@@ -67,7 +67,8 @@
+ Padding="0,0,0,6" Margin="0,0,0,4"
+ IsVisible="{Binding HasItems}">
@@ -76,24 +77,37 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Junction.Core/Monitoring/IMachineMonitor.cs b/src/Junction.Core/Monitoring/IMachineMonitor.cs
index 64fbc3b..b76a5ae 100644
--- a/src/Junction.Core/Monitoring/IMachineMonitor.cs
+++ b/src/Junction.Core/Monitoring/IMachineMonitor.cs
@@ -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.
///
Task RemoveMachineAsync(Guid machineId, CancellationToken cancellationToken);
+
+ ///
+ /// 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
+ /// from the retained factory map, builds a driver via
+ /// factory.Create(machine), and delegates to .
+ ///
+ /// Requires to have completed: returns
+ /// ("monitor not started") otherwise. Returns when the protocol
+ /// has no loaded plugin or the driver cannot be created, and
+ /// if the token trips first.
+ ///
+ ///
+ Task>> ProbeAsync(Machine machine, CancellationToken cancellationToken);
}
}
diff --git a/src/Junction.Core/Monitoring/MachineMonitor.cs b/src/Junction.Core/Monitoring/MachineMonitor.cs
index 740304d..b8f765e 100644
--- a/src/Junction.Core/Monitoring/MachineMonitor.cs
+++ b/src/Junction.Core/Monitoring/MachineMonitor.cs
@@ -309,6 +309,44 @@ namespace Junction.Core.Monitoring
return Result.Ok();
}
+ ///
+ public async Task>> ProbeAsync(Machine machine, CancellationToken cancellationToken)
+ {
+ if (machine is null)
+ {
+ return Result>.Fail(OperationError.Of(Source, "machine is null."));
+ }
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return Result>.Cancelled();
+ }
+
+ IProtocolDriverFactory factory;
+ lock (_lifecycleLock)
+ {
+ if (_factories is null)
+ {
+ return Result>.Fail(
+ OperationError.Of(Source, "Monitor not started; call StartAsync first."));
+ }
+
+ if (!_factories.TryGetValue(machine.ProtocolId, out factory))
+ {
+ return Result>.Fail(
+ OperationError.Of(Source, $"No plugin loaded for protocol '{machine.ProtocolId}'."));
+ }
+ }
+
+ Result driverResult = factory.Create(machine);
+ if (!driverResult.IsSuccess)
+ {
+ return Result>.Fail(driverResult.Errors);
+ }
+
+ return await driverResult.Value.ProbeAsync(cancellationToken).ConfigureAwait(false);
+ }
+
///
/// Removes and returns the tracked loop for , or null.
/// Caller must hold .
diff --git a/src/Junction.Domain/Models/Machine.cs b/src/Junction.Domain/Models/Machine.cs
index 6135df0..6704bd7 100644
--- a/src/Junction.Domain/Models/Machine.cs
+++ b/src/Junction.Domain/Models/Machine.cs
@@ -13,6 +13,8 @@ namespace Junction.Domain.Models
private static readonly IReadOnlyDictionary EmptyConfig =
new Dictionary(0);
+ private static readonly IReadOnlyCollection EmptyItemIds = new string[0];
+
/// Stable unique identifier.
public Guid Id { get; }
@@ -28,18 +30,26 @@ namespace Junction.Domain.Models
/// How often to poll the machine.
public TimeSpan PollInterval { get; }
+ ///
+ /// 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).
+ ///
+ public IReadOnlyCollection MonitoredItemIds { get; }
+
public Machine(
Guid id,
string name,
string protocolId,
IReadOnlyDictionary? connectionConfig,
- TimeSpan pollInterval)
+ TimeSpan pollInterval,
+ IReadOnlyCollection? monitoredItemIds = null)
{
Id = id;
Name = name ?? "";
ProtocolId = protocolId ?? "";
ConnectionConfig = connectionConfig ?? EmptyConfig;
PollInterval = pollInterval;
+ MonitoredItemIds = monitoredItemIds ?? EmptyItemIds;
}
/// Returns a copy with the given fields overridden; null args keep the current value.
@@ -47,14 +57,16 @@ namespace Junction.Domain.Models
string? name = null,
string? protocolId = null,
IReadOnlyDictionary? connectionConfig = null,
- TimeSpan? pollInterval = null)
+ TimeSpan? pollInterval = null,
+ IReadOnlyCollection? monitoredItemIds = null)
{
return new Machine(
Id,
name ?? Name,
protocolId ?? ProtocolId,
connectionConfig ?? ConnectionConfig,
- pollInterval ?? PollInterval);
+ pollInterval ?? PollInterval,
+ monitoredItemIds ?? MonitoredItemIds);
}
}
}
diff --git a/src/Junction.Domain/Protocols/DataItemDescriptor.cs b/src/Junction.Domain/Protocols/DataItemDescriptor.cs
new file mode 100644
index 0000000..f6f0825
--- /dev/null
+++ b/src/Junction.Domain/Protocols/DataItemDescriptor.cs
@@ -0,0 +1,34 @@
+namespace Junction.Domain.Protocols
+{
+ ///
+ /// Protocol-agnostic catalog entry describing a single selectable data item a machine exposes.
+ /// Returned (unfiltered) by so the config screen can
+ /// present the full set of items the user may choose to monitor. Immutable; nulls collapse to "".
+ ///
+ public sealed class DataItemDescriptor
+ {
+ /// Stable identifier of the data item (e.g. MTConnect dataItemId).
+ public string Id { get; }
+
+ /// Human-readable name (may be empty).
+ public string Name { get; }
+
+ /// Protocol type (e.g. MTConnect POSITION, EXECUTION, AVAILABILITY).
+ public string Type { get; }
+
+ /// Category (e.g. MTConnect SAMPLE, EVENT, CONDITION). Kept as string to stay generic.
+ public string Category { get; }
+
+ /// Units (may be empty).
+ 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 ?? "";
+ }
+ }
+}
diff --git a/src/Junction.Domain/Protocols/IProtocolDriver.cs b/src/Junction.Domain/Protocols/IProtocolDriver.cs
index 1c30196..6ba8980 100644
--- a/src/Junction.Domain/Protocols/IProtocolDriver.cs
+++ b/src/Junction.Domain/Protocols/IProtocolDriver.cs
@@ -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
///
/// Cancellation token. Mandatory.
Task> ReadCurrentAsync(CancellationToken cancellationToken);
+
+ ///
+ /// 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 output, not here.
+ /// Returns a failed on error, cancelled when the token trips.
+ ///
+ /// Cancellation token. Mandatory.
+ Task>> ProbeAsync(CancellationToken cancellationToken);
}
}
diff --git a/src/Junction.Persistence/SqliteMachineRepository.cs b/src/Junction.Persistence/SqliteMachineRepository.cs
index 0c69716..f23d164 100644
--- a/src/Junction.Persistence/SqliteMachineRepository.cs
+++ b/src/Junction.Persistence/SqliteMachineRepository.cs
@@ -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(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(0);
}
+ private static string SerializeItemIds(IReadOnlyCollection ids)
+ {
+ var list = new List(ids?.Count ?? 0);
+ if (ids != null)
+ {
+ foreach (var id in ids)
+ {
+ list.Add(id);
+ }
+ }
+
+ return JsonSerializer.Serialize(list, JsonOptions);
+ }
+
+ private static IReadOnlyCollection DeserializeItemIds(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return Array.Empty();
+ }
+
+ var list = JsonSerializer.Deserialize>(json!, JsonOptions);
+ return list ?? (IReadOnlyCollection)Array.Empty();
+ }
+
// -- 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
diff --git a/src/Junction.Persistence/SqliteSchema.cs b/src/Junction.Persistence/SqliteSchema.cs
index f141432..71d1627 100644
--- a/src/Junction.Persistence/SqliteSchema.cs
+++ b/src/Junction.Persistence/SqliteSchema.cs
@@ -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();
}
}
+
+ /// Adds to if not already present. Idempotent.
+ 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;
+ }
}
}
diff --git a/src/Junction.Protocols.MTConnect/MtconnectDriver.cs b/src/Junction.Protocols.MTConnect/MtconnectDriver.cs
index e6c3ebc..2d94755 100644
--- a/src/Junction.Protocols.MTConnect/MtconnectDriver.cs
+++ b/src/Junction.Protocols.MTConnect/MtconnectDriver.cs
@@ -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 _monitoredItemIds;
+
/// Protocol identifier this driver serves.
public string ProtocolId => "mtconnect";
@@ -48,7 +54,16 @@ namespace Junction.Protocols.MTConnect
/// Per-request timeout enforced via a linked . Must be positive
/// or .
///
- public MtconnectDriver(Guid machineId, string agentUrl, HttpClient httpClient, TimeSpan requestTimeout)
+ ///
+ /// DataItem ids the user selected to monitor. keeps only these in the
+ /// snapshot (so persistence stores only selected items). Empty/null => opt-in "monitor nothing".
+ ///
+ public MtconnectDriver(
+ Guid machineId,
+ string agentUrl,
+ HttpClient httpClient,
+ TimeSpan requestTimeout,
+ IReadOnlyCollection? 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(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);
}
///
@@ -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 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.Ok(FilterToMonitored(parsed.Value));
}
}
+ ///
+ public async Task>> ProbeAsync(CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return Result>.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>.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>.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> parsed = MtconnectProbeParser.Parse(body);
+ if (!parsed.IsSuccess)
+ {
+ return Result>.Fail(parsed.Errors);
+ }
+
+ var catalog = new List(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>.Ok(catalog);
+ }
+ }
+
+ ///
+ /// Returns a copy of 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.
+ ///
+ private MachineSnapshot FilterToMonitored(MachineSnapshot snapshot)
+ {
+ if (_monitoredItemIds.Count == 0)
+ {
+ return new MachineSnapshot(
+ snapshot.MachineId, snapshot.CapturedAt, snapshot.ConnectionState, Array.Empty());
+ }
+
+ var kept = new List();
+ 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 Fail(string code, string message) =>
Result.Fail(new OperationError(code, Source, message));
+
+ private static Result> ProbeFail(string code, string message) =>
+ Result>.Fail(new OperationError(code, Source, message));
}
}
diff --git a/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs b/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs
index d3f63de..a3e1577 100644
--- a/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs
+++ b/src/Junction.Protocols.MTConnect/MtconnectDriverFactory.cs
@@ -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.Ok(driver);
}
diff --git a/tests/Junction.Tests/Unit/MachineMonitorTests.cs b/tests/Junction.Tests/Unit/MachineMonitorTests.cs
index c9bca86..47f253c 100644
--- a/tests/Junction.Tests/Unit/MachineMonitorTests.cs
+++ b/tests/Junction.Tests/Unit/MachineMonitorTests.cs
@@ -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
+ {
+ new DataItemDescriptor("x1_pos", "X", "POSITION", "SAMPLE", "MILLIMETER"),
+ new DataItemDescriptor("dev1_avail", "avail", "AVAILABILITY", "EVENT", ""),
+ };
+
+ var factory = new FakeFactory("test", m => Result.Ok(
+ new FakeDriver(
+ "test",
+ () => Result.Ok(Snapshot(m.Id)),
+ () => Result>.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.Ok(
+ new FakeDriver("test", () => Result.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.Ok(
+ new FakeDriver("test", () => Result.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"));
+ }
+
/// Hand-rolled factory; behavior supplied by a delegate.
private sealed class FakeFactory : IProtocolDriverFactory
{
@@ -401,15 +467,20 @@ namespace Junction.Tests.Unit
public Result Create(Machine machine) => _create(machine);
}
- /// Hand-rolled driver; behavior supplied by a delegate.
+ /// Hand-rolled driver; behavior supplied by a delegate. Optional canned probe catalog.
private sealed class FakeDriver : IProtocolDriver
{
private readonly Func> _read;
+ private readonly Func>>? _probe;
- public FakeDriver(string protocolId, Func> read)
+ public FakeDriver(
+ string protocolId,
+ Func> read,
+ Func>>? 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>> ProbeAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(_probe != null
+ ? _probe()
+ : Result>.Ok(Array.Empty()));
}
///
@@ -454,6 +530,9 @@ namespace Junction.Tests.Unit
_exit();
}
}
+
+ public Task>> ProbeAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(Result>.Ok(Array.Empty()));
}
}
}
diff --git a/tests/Junction.Tests/Unit/MtconnectDriverTests.cs b/tests/Junction.Tests/Unit/MtconnectDriverTests.cs
index 5965b8a..f024d99 100644
--- a/tests/Junction.Tests/Unit/MtconnectDriverTests.cs
+++ b/tests/Junction.Tests/Unit/MtconnectDriverTests.cs
@@ -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",
+ };
+
/// Stub handler: canned response or thrown exception, per configuration.
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? 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());
+
+ 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? config) =>
diff --git a/tests/Junction.Tests/Unit/PluginLoaderTests.cs b/tests/Junction.Tests/Unit/PluginLoaderTests.cs
index b4b1013..8a1ee00 100644
--- a/tests/Junction.Tests/Unit/PluginLoaderTests.cs
+++ b/tests/Junction.Tests/Unit/PluginLoaderTests.cs
@@ -45,6 +45,9 @@ namespace Junction.Tests.Unit
DateTimeOffset.UtcNow,
ConnectionState.Connected,
Array.Empty())));
+
+ public Task>> ProbeAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(Result>.Ok(Array.Empty()));
}
/// Type that does NOT implement the factory contract.
diff --git a/tests/Junction.Tests/Unit/PollingEngineTests.cs b/tests/Junction.Tests/Unit/PollingEngineTests.cs
index 92e4098..1974b2c 100644
--- a/tests/Junction.Tests/Unit/PollingEngineTests.cs
+++ b/tests/Junction.Tests/Unit/PollingEngineTests.cs
@@ -282,6 +282,9 @@ namespace Junction.Tests.Unit
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_behavior(null!));
}
+
+ public Task>> ProbeAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(Result>.Ok(Array.Empty()));
}
}
}
diff --git a/tests/Junction.Tests/Unit/SqliteMachineRepositoryTests.cs b/tests/Junction.Tests/Unit/SqliteMachineRepositoryTests.cs
index e2e38f8..5898f0c 100644
--- a/tests/Junction.Tests/Unit/SqliteMachineRepositoryTests.cs
+++ b/tests/Junction.Tests/Unit/SqliteMachineRepositoryTests.cs
@@ -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 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 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 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()
{