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:
parent
cf394bab31
commit
53ce8ab4da
22 changed files with 979 additions and 60 deletions
|
|
@ -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 | - |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
35
src/Junction.App/ViewModels/SelectableDataItemViewModel.cs
Normal file
35
src/Junction.App/ViewModels/SelectableDataItemViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,8 +77,9 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Full data-item table -->
|
<!-- Data-item table (fills), or an empty-state hint when nothing is monitored -->
|
||||||
<ScrollViewer>
|
<Grid>
|
||||||
|
<ScrollViewer IsVisible="{Binding HasItems}">
|
||||||
<ItemsControl ItemsSource="{Binding Items}">
|
<ItemsControl ItemsSource="{Binding Items}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:DataItemRowViewModel">
|
<DataTemplate x:DataType="vm:DataItemRowViewModel">
|
||||||
|
|
@ -94,6 +96,18 @@
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</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. -->
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"/>.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
src/Junction.Domain/Protocols/DataItemDescriptor.cs
Normal file
34
src/Junction.Domain/Protocols/DataItemDescriptor.cs
Normal 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 ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue