Skip to content

Commit 089f535

Browse files
Merge pull request #185 from ottobolyos/feat/issue-176
feat(http): expose probe device model via Devices accessor
2 parents 0d69283 + 2148107 commit 089f535

4 files changed

Lines changed: 539 additions & 11 deletions

File tree

docs/configure/consumer.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ client.Start();
8282

8383
`CurrentReceived` fires once on stream initialization—`Start()` drives the `/sample` long-poll, not periodic `/current` polling. For periodic snapshots, call `GetCurrent()` directly on a timer; for the streaming case, subscribe to `SampleReceived`.
8484

85-
The client handles reconnects on the consumer's behalf; the `ConnectionError` event fires on transport failures and the client backs off and retries. Parsing or dispatch failures surface through `InternalError`.
85+
The client also exposes the post-probe device model. `DeviceReceived` fires once per parsed device for every `/probe` response, carrying the fully wired [`IDevice`](/api/MTConnect.Devices.IDevice) instance with its DataItems' `Container` and `Device` back-pointers set. The `Devices` property returns a read-only snapshot of every device the most recent probe yielded, keyed by UUID; entries are replaced wholesale on each probe and devices the agent no longer advertises are evicted at the start of the next probe. Both surfaces were added per [issue #176](https://github.com/TrakHound/MTConnect.NET/issues/176).
86+
87+
The client handles reconnects on the consumer's behalf; the `ConnectionError` event fires on transport failures and the client backs off and retries. Parsing or dispatch failures surface through `InternalError`. A `DeviceReceived` handler that throws is isolated: the exception is routed through `InternalError` and the cache fill plus the rest of the per-device fan-out continue normally.
8688

8789
## MQTT—the cppagent-parity broker / relay tree
8890

docs/examples/client-http.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,35 @@ client.CurrentReceived += (s, response) => { /* iterate response.Streams */ };
5050
client.Start();
5151
```
5252

53-
`MTConnectHttpClient` wraps the agent's HTTP endpoints (`/probe`, `/current`, `/sample`, `/asset`) and exposes them as a long-polling subscription with event hooks. The `Interval` property is the consumer-side sample interval — the client requests `/sample?interval=100` and receives each sequence batch through `CurrentReceived` (or `SampleReceived` if you wire that hook up instead).
53+
`MTConnectHttpClient` wraps the agent's HTTP endpoints (`/probe`, `/current`, `/sample`, `/asset`) and exposes them as a long-polling subscription with event hooks. The `Interval` property is the consumer-side sample interval—the client requests `/sample?interval=100` and receives each sequence batch through `CurrentReceived` (or `SampleReceived` if you wire that hook up instead).
54+
55+
### Subscribing to the wired device model
56+
57+
The client maintains a wired snapshot of every device it sees from `/probe` responses, keyed by UUID. Subscribe to `DeviceReceived` to react per-device while the snapshot is filling, and read the `Devices` accessor at any point to consult the post-probe model:
58+
59+
```csharp
60+
client.DeviceReceived += (s, device) =>
61+
{
62+
Console.WriteLine($"Device Received : {device.Uuid} : {device.Name}");
63+
foreach (var dataItem in device.GetDataItems())
64+
{
65+
// Container + Device back-pointers are wired in by the parser.
66+
Console.WriteLine($" {dataItem.Id} : {dataItem.Type} : container={dataItem.Container?.Id}");
67+
}
68+
};
69+
70+
client.Start();
71+
72+
// At any point after the first probe completes:
73+
foreach (var (uuid, device) in client.Devices)
74+
{
75+
Console.WriteLine($"Cached device : {uuid} : {device.Name}");
76+
}
77+
```
78+
79+
`DeviceReceived` fires once per parsed device for every Probe response, carrying the fully wired `IDevice` instance—the same one the `Devices` snapshot accessor returns for that UUID—with the agent's `InstanceId` stamped on each DataItem. A handler that throws is isolated by the client: the exception is forwarded through `InternalError` and the cache fill continues. `Devices` returns a fresh read-only snapshot on every read; cache the reference if you read repeatedly between probes. Devices that disappear between probes are evicted at the start of the next probe, so the snapshot never carries entries the agent no longer advertises.
80+
81+
The `Devices` accessor and the `DeviceReceived` fan-out were added per [issue #176](https://github.com/TrakHound/MTConnect.NET/issues/176) (approved 2026-06-01 by @PatrickRitchie).
5482

5583
### Per-observation validation
5684

libraries/MTConnect.NET-HTTP/Clients/MTConnectHttpClient.cs

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using MTConnect.Streams;
1212
using System;
1313
using System.Collections.Generic;
14+
using System.Collections.ObjectModel;
1415
using System.Linq;
1516
using System.Threading;
1617
using System.Threading.Tasks;
@@ -179,12 +180,66 @@ private void Init()
179180
/// </summary>
180181
public bool UseStreaming { get; set; }
181182

183+
/// <summary>
184+
/// A snapshot of the device model received from the most recent Probe response,
185+
/// keyed by device UUID. The dictionary is empty until the first probe succeeds.
186+
/// Each call returns a fresh snapshot reflecting the most recently completed probe;
187+
/// previously returned snapshots are unaffected by subsequent probes, and devices
188+
/// that disappear between probes are evicted at the start of the next probe so the
189+
/// snapshot never carries entries the agent no longer advertises. Each
190+
/// <see cref="IDevice"/>'s DataItems carry the fully wired
191+
/// <see cref="IDataItem.Container"/> and <see cref="IDataItem.Device"/>
192+
/// back-pointers set during parsing, so consumers can walk the component ancestry
193+
/// of any DataItem without re-parsing the document.
194+
/// </summary>
195+
/// <remarks>
196+
/// <para>
197+
/// The dictionary returned by each call is a fresh allocation independent of the
198+
/// cache, wrapped in a <see cref="ReadOnlyDictionary{TKey, TValue}"/>
199+
/// so consumers cannot mutate the snapshot through a downcast. The <see cref="IDevice"/>
200+
/// values within are shared references to cached instances that the client replaces
201+
/// wholesale on each probe. The accessor acquires the client's internal lock, so
202+
/// callers may enumerate the snapshot without synchronizing against the worker thread
203+
/// that processes subsequent probes.
204+
/// </para>
205+
/// <para>
206+
/// Each access allocates a fresh dictionary. Cache the returned reference if you
207+
/// read repeatedly between probes.
208+
/// </para>
209+
/// <para>
210+
/// Empty probe responses (where the agent answers with no devices) do not clear the
211+
/// cache; the previous snapshot stays presented until the next non-empty probe.
212+
/// </para>
213+
/// </remarks>
214+
public IReadOnlyDictionary<string, IDevice> Devices
215+
{
216+
get
217+
{
218+
lock (_lock)
219+
{
220+
return new ReadOnlyDictionary<string, IDevice>(
221+
new Dictionary<string, IDevice>(_devices));
222+
}
223+
}
224+
}
225+
182226

183227
#region "Events"
184228

185229
/// <summary>
186-
/// Raised when a Device is received
230+
/// Raised once per parsed device for every Probe response the client receives,
231+
/// carrying the fully wired <see cref="IDevice"/> instance—the same instance the
232+
/// <see cref="Devices"/> snapshot accessor would return for that UUID—with its
233+
/// DataItems' <see cref="IDataItem.Container"/> and <see cref="IDataItem.Device"/>
234+
/// back-pointers set, and the agent's <c>InstanceId</c> stamped on each DataItem.
187235
/// </summary>
236+
/// <remarks>
237+
/// Handlers fire in document order while the cache is still being populated.
238+
/// Subscribe to <see cref="ProbeReceived"/> to receive notification after the full
239+
/// probe response has been processed. A handler that throws is isolated by the
240+
/// client: the exception is forwarded through <see cref="InternalError"/> and the
241+
/// cache fill plus subsequent fan-out continue normally.
242+
/// </remarks>
188243
public event EventHandler<IDevice> DeviceReceived;
189244

190245
/// <summary>
@@ -828,14 +883,15 @@ private void ProcessProbeDocument(IDevicesResponseDocument document)
828883
{
829884
if (document != null && !document.Devices.IsNullOrEmpty())
830885
{
831-
// Clear Cached DataItems and Components
886+
// Clear cached DataItems, Components, and the device snapshot so devices
887+
// the agent no longer advertises are evicted before the new probe is loaded.
832888
lock (_lock)
833889
{
834890
_cachedComponents.Clear();
835891
_cachedDataItems.Clear();
892+
_devices.Clear();
836893
}
837894

838-
var outputDevices = new List<IDevice>();
839895
foreach (var device in document.Devices)
840896
{
841897
var outputDevice = ProcessDevice(document.Header, device);
@@ -846,18 +902,48 @@ private void ProcessProbeDocument(IDevicesResponseDocument document)
846902
_devices.Remove(outputDevice.Uuid);
847903
_devices.Add(outputDevice.Uuid, outputDevice);
848904
}
849-
}
850905

851-
foreach (var outputDevice in outputDevices)
852-
{
853-
DeviceReceived?.Invoke(this, outputDevice);
906+
// Fire per-device inside the populate loop so the cache and event stay in lockstep.
907+
// Isolate subscriber exceptions per delegate so one bad handler cannot abort the
908+
// populate loop, suppress ProbeReceived, or short-circuit later subscribers in the
909+
// invocation list; route each fault through InternalError instead.
910+
RaiseDeviceReceived(outputDevice);
854911
}
855912

856913
// Raise ProbeReceived Event
857914
ProbeReceived?.Invoke(this, document);
858915
}
859916
}
860917

918+
// Iterate the invocation list so one throwing subscriber cannot short-circuit the
919+
// multicast and starve later subscribers. Each fault is forwarded through
920+
// InternalError; if InternalError itself faults, swallow that secondary fault so the
921+
// populate loop and remaining DeviceReceived subscribers still get every device.
922+
private void RaiseDeviceReceived(IDevice device)
923+
{
924+
var handler = DeviceReceived;
925+
if (handler == null) return;
926+
927+
foreach (var subscriber in handler.GetInvocationList())
928+
{
929+
try
930+
{
931+
((EventHandler<IDevice>)subscriber).Invoke(this, device);
932+
}
933+
catch (Exception ex)
934+
{
935+
try
936+
{
937+
InternalError?.Invoke(this, ex);
938+
}
939+
catch
940+
{
941+
// A faulting InternalError handler must not break DeviceReceived fan-out.
942+
}
943+
}
944+
}
945+
}
946+
861947
private void ProcessCurrentDocument(IStreamsResponseDocument document, CancellationToken cancel)
862948
{
863949
_lastResponse = UnixDateTime.Now;
@@ -1092,7 +1178,8 @@ private IComponentStream ProcessComponentStream(IMTConnectStreamsHeader header,
10921178
outputComponentStream.NativeName = inputComponentStream.NativeName;
10931179
outputComponentStream.Uuid = inputComponentStream.Uuid;
10941180

1095-
if (inputComponentStream.ComponentType == Agent.TypeId || inputComponentStream.ComponentType == Devices.Device.TypeId)
1181+
// Fully-qualified to disambiguate from the Devices property below; the namespace using above is shadowed inside this class.
1182+
if (inputComponentStream.ComponentType == Agent.TypeId || inputComponentStream.ComponentType == MTConnect.Devices.Device.TypeId)
10961183
{
10971184
outputComponentStream.Component = GetCachedDevice(deviceUuid);
10981185
}
@@ -1148,7 +1235,8 @@ private async void CheckAssetChanged(IEnumerable<IObservation> observations, Can
11481235
{
11491236
if (observations != null && observations.Count() > 0)
11501237
{
1151-
var assetsChanged = observations.Where(o => o.Type.ToUnderscoreUpper() == Devices.DataItems.AssetChangedDataItem.TypeId);
1238+
// Fully-qualified to disambiguate from the Devices property below; the namespace using above is shadowed inside this class.
1239+
var assetsChanged = observations.Where(o => o.Type.ToUnderscoreUpper() == MTConnect.Devices.DataItems.AssetChangedDataItem.TypeId);
11521240
if (assetsChanged != null)
11531241
{
11541242
foreach (var assetChanged in assetsChanged)

0 commit comments

Comments
 (0)