Merge b640-focas: FOCAS Status/ subtree, PMC range coalescing, parts-count docs (#257 #266 #258, tracker #633)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -1105,6 +1105,9 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types
|
||||
`BadNotSupported`).
|
||||
- **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown.
|
||||
- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed);
|
||||
adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range
|
||||
falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only).
|
||||
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
|
||||
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip/FOCAS alarm
|
||||
projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
|
||||
|
||||
+74
-3
@@ -146,7 +146,7 @@ reference them will throw at startup with a message pointing here.
|
||||
| Capability | Wire path | Notes |
|
||||
|------------|-----------|-------|
|
||||
| `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally |
|
||||
| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented |
|
||||
| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented, and none will be (policy: the stranded write/unlock backlog items #268–#271 are won't-do; a write path on a production CNC is a safety decision, not a feature gap) |
|
||||
| `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device |
|
||||
| `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups |
|
||||
| `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` |
|
||||
@@ -224,15 +224,36 @@ What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
|
||||
| `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick |
|
||||
| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` — a field the CNC did not report (refused sub-read, payload without an O-number) reads `BadNoData`, never `0` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
|
||||
| `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` |
|
||||
| `Status/` | `Auto`, `Run`, `Motion`, `Mstb`, `Emergency`, `Alarm`, `Edit`, `TmMode` (raw FANUC codes, Int32) + `RunText` ("RESET", "STOP", "HOLD", "START", "MSTR") — see [CNC status flags](#cnc-status-flags-status) | `cnc_statinfo` (polled on `ProgramPollInterval`) |
|
||||
| `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 |
|
||||
|
||||
### CNC status flags (`Status/`)
|
||||
|
||||
`cnc_statinfo` (the `ODBST` block) is exposed as read-only `FIXED:Status/<field>` nodes (#257). Values are the
|
||||
control's raw codes, published verbatim as Int32 so a client sees exactly what the CNC reported:
|
||||
|
||||
| Field | Codes |
|
||||
|---|---|
|
||||
| `Run` | 0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR (`RunText` is the label) |
|
||||
| `Auto` | operation mode (0 MDI, 1 MEM, 3 EDIT, 4 HND, 5 JOG, … — control-dependent) |
|
||||
| `Motion` | 0 none, 1 axis motion, 2 dwell |
|
||||
| `Mstb` | 0 idle, 1 executing an M/S/T/B function |
|
||||
| `Emergency` | 0 not, 1 emergency stop, 2 reset |
|
||||
| `Alarm` | 0 none, 1 alarm, … (battery-low etc. are control-dependent) |
|
||||
| `Edit`, `TmMode` | program-edit state; T/M mode |
|
||||
|
||||
Author one the same way as any fixed-tree tag — `FIXED:Status/Run` (Int32), `FIXED:Status/RunText` (String).
|
||||
Like `Program/`, the subtree is gated on a bootstrap probe: a control that refuses `cnc_statinfo` gets no
|
||||
`Status/` nodes and the rest of the tree is unaffected. Reads are served from the poll snapshot, so they
|
||||
issue no wire traffic and go `BadCommunicationError` once the device has stopped answering.
|
||||
|
||||
### Per-series node suppression
|
||||
|
||||
The driver probes each optional call once at bootstrap. If the target CNC
|
||||
returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the
|
||||
corresponding subtree is **not emitted** — the operator doesn't see nodes
|
||||
that will only ever return `BadDeviceFailure`. Capability suppression
|
||||
covers `Spindle/`, `Program/` + `OperationMode/`, `Timers/`, and
|
||||
covers `Spindle/`, `Program/` + `OperationMode/`, `Status/`, `Timers/`, and
|
||||
per-axis `ServoLoad` independently. Identity + `Axes/*` position reads
|
||||
(which every Fanuc CNC supports) are always emitted.
|
||||
|
||||
@@ -265,6 +286,54 @@ fallen back to `PositionDecimalPlaces` and published raw counts.
|
||||
numbers whose meaning is MTB-specific, and which no browse can discover. Those
|
||||
go under the device folder alongside the fixed subtree.
|
||||
|
||||
### Parts count (`PARAM:6711`)
|
||||
|
||||
There is deliberately **no** dedicated parts-count node (#258): the count is an ordinary CNC parameter, so it
|
||||
is already readable as a hand-authored `PARAM:` tag (Int32), and a fixed `Parts/` subtree would hard-code
|
||||
parameter numbers that are control- and MTB-specific. On the standard FANUC 0i / 16i / 30i parameter map:
|
||||
|
||||
| Address | Parameter | Meaning |
|
||||
|---|---|---|
|
||||
| `PARAM:6711` | No. 6711 | Number of parts machined (resets with the operator's clear) |
|
||||
| `PARAM:6712` | No. 6712 | Total number of parts machined (cumulative) |
|
||||
| `PARAM:6713` | No. 6713 | Number of parts required (the target) |
|
||||
|
||||
The count-up trigger (which M code or cycle end increments it) is set by No. 6710 and bit 0 of No. 6700,
|
||||
so confirm in the machine's parameter manual that the counter is actually enabled and that 6711–6713 mean
|
||||
what the MTB says they mean before relying on them. Cycle time per part is not provided; the `Timers/`
|
||||
subtree exposes `CycleSeconds` and a client can difference it across a `PARAM:6711` change.
|
||||
|
||||
## PMC range coalescing
|
||||
|
||||
A poll batch that reads several nearby PMC tags used to issue one `pmc_rdpmcrng` per tag. The driver now
|
||||
merges tags of the same PMC letter and width that sit close together into **one** range read and slices each
|
||||
tag's value out of the result (#266). Single, isolated PMC tags — the common "one tag per area" case — are
|
||||
read exactly as before.
|
||||
|
||||
```jsonc
|
||||
"PmcCoalesce": {
|
||||
"Enabled": true, // default
|
||||
"MaxSpanBytes": 128, // hard cap on one merged range
|
||||
"MaxGapBytes": 8 // bytes nobody asked for tolerated between two merged tags; 0 = only strictly adjacent
|
||||
}
|
||||
```
|
||||
|
||||
Rules worth knowing:
|
||||
|
||||
- Tags merge only within the same letter and width class (Bit and Byte are one class; Word, Long, Float, Double
|
||||
are separate). A multi-byte tag joins a range only when its offset from the range start is a multiple of its
|
||||
width, because the CNC splits a range into width-sized slots from the start.
|
||||
- **Spare gap bytes must be valid PMC addresses.** A gap that crosses a hole in the CNC's PMC map makes the CNC
|
||||
refuse the whole range. That is handled, not fatal: a refused range is remembered per device and its tags are
|
||||
read **individually**, each with its own true status (a refusal is never stamped onto healthy members). If
|
||||
that happens routinely, lower `MaxGapBytes` (0 disables gap-spanning entirely) or set `Enabled` to `false`.
|
||||
- A transport failure or timeout on the merged read fails every member with `BadCommunicationError` and does
|
||||
**not** retry per tag — the link is down, so per-tag retries would only multiply timeouts.
|
||||
- Read-only: this changes `pmc_rdpmcrng` reads only; nothing here can write.
|
||||
|
||||
The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown
|
||||
key edited through that form is dropped), so set it in the raw config.
|
||||
|
||||
## Alarm projection
|
||||
|
||||
Alarm surfacing is **disabled by default** because the polling cost is wasted
|
||||
@@ -324,7 +393,9 @@ latency spike once per cadence.
|
||||
driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
|
||||
clear diffing tests.
|
||||
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
|
||||
hold the Docker simulator scaffold; see
|
||||
hold the Docker simulator scaffold (`OTOPCUA_FOCAS_SIM_ENDPOINT=host:port`; they skip when it is absent;
|
||||
`Series/WireBackendStatusAndPmcTests` covers `Status/` and PMC coalescing against the mock's `statinfo` and
|
||||
`pmc` state); see
|
||||
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)
|
||||
for what the simulator emits vs. real CNC behaviour.
|
||||
- **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real
|
||||
|
||||
@@ -31,6 +31,8 @@ public sealed class FocasDriverOptions
|
||||
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
|
||||
/// <summary>Gets the fixed tree options.</summary>
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
/// <summary>Gets the PMC range-coalescing options (Gitea #266).</summary>
|
||||
public FocasPmcCoalesceOptions PmcCoalesce { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
@@ -41,6 +43,32 @@ public sealed class FocasDriverOptions
|
||||
public int ProbeTimeoutSeconds { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
|
||||
/// On by default: a batch with a single PMC tag per area is unaffected, and a coalesced read the CNC
|
||||
/// rejects falls back to per-tag reads, so enabling it cannot make a readable tag unreadable.
|
||||
/// </summary>
|
||||
public sealed class FocasPmcCoalesceOptions
|
||||
{
|
||||
/// <summary>Gets a value indicating whether PMC coalescing is on.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Largest range, in bytes, one coalesced read may cover. Bounds the response size and the
|
||||
/// blast radius of a rejected range. Default 128.
|
||||
/// </summary>
|
||||
[Range(1, 1024)]
|
||||
public int MaxSpanBytes { get; init; } = 128;
|
||||
|
||||
/// <summary>
|
||||
/// Largest run of bytes nobody asked for that may sit between two merged tags. Reading a few
|
||||
/// spare bytes is cheaper than a second round-trip, but every spare byte must be a valid PMC
|
||||
/// address on the CNC. Default 8; 0 merges only strictly adjacent tags.
|
||||
/// </summary>
|
||||
[Range(0, 1024)]
|
||||
public int MaxGapBytes { get; init; } = 8;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-node tree exposed by FOCAS per <c>docs/v2/driver-specs.md §7</c> —
|
||||
/// <c>Identity/</c>, <c>Axes/{name}/</c>, etc. populated from
|
||||
|
||||
@@ -498,8 +498,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
// #266 — merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it
|
||||
// serves are marked here and skipped by the per-tag loop below; everything else (including every
|
||||
// single-tag range and any range the CNC rejected) takes the unchanged per-tag path.
|
||||
var served = _options.PmcCoalesce.Enabled
|
||||
? await CoalescePmcReadsAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
if (served is not null && served[i]) continue;
|
||||
var reference = fullReferences[i];
|
||||
|
||||
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
|
||||
@@ -581,6 +589,127 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC coalescing pre-pass (Gitea #266). Groups the batch's PMC tags by device, plans merged ranges
|
||||
/// (<see cref="FocasPmcCoalescer"/>) and serves every multi-member range with ONE
|
||||
/// <c>pmc_rdpmcrng</c>, slicing each tag's value out of the returned slots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Failure handling is the point of the design. A rejected range is NOT mis-attributed to its members:
|
||||
/// <list type="bullet">
|
||||
/// <item>a comms-level failure (timeout / transport) fails every member with that status — the
|
||||
/// link is down, so re-reading each member would multiply timeouts;</item>
|
||||
/// <item>any other non-Good status (typically a spare "gap" byte that is not a valid PMC address)
|
||||
/// leaves the members UNSERVED, so the per-tag loop reads them individually and each gets its own
|
||||
/// true status — and the range is remembered so later polls skip the doomed merged read.</item>
|
||||
/// </list>
|
||||
/// Returns null when nothing was served.
|
||||
/// </remarks>
|
||||
private async Task<bool[]?> CoalescePmcReadsAsync(
|
||||
IReadOnlyList<string> references, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
Dictionary<DeviceState, List<FocasPmcReadItem>>? byDevice = null;
|
||||
for (var i = 0; i < references.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reference = references[i];
|
||||
if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue;
|
||||
if (!_resolver.TryResolve(reference, out var def)) continue;
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
|
||||
var parsed = ResolveParsedAddress(def);
|
||||
if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue;
|
||||
(byDevice ??= []).TryAdd(device, []);
|
||||
byDevice[device].Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
/* malformed / unresolvable — the per-tag loop reports it with its usual status */
|
||||
}
|
||||
}
|
||||
if (byDevice is null) return null;
|
||||
|
||||
bool[]? served = null;
|
||||
foreach (var (device, items) in byDevice)
|
||||
{
|
||||
if (items.Count < 2) continue;
|
||||
var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes);
|
||||
if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) continue;
|
||||
|
||||
// One connect attempt per device per batch: if it fails, every PMC tag of the device fails with
|
||||
// that status here instead of paying a connect attempt per plan and again per tag.
|
||||
IFocasClient client;
|
||||
try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
FailMembers(items, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
|
||||
$"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
if (plan.Members.Count < 2) continue;
|
||||
if (device.CoalesceRejected.ContainsKey(plan.Key)) continue;
|
||||
|
||||
var members = plan.Members;
|
||||
try
|
||||
{
|
||||
var (slots, status) = await client
|
||||
.ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (status == FocasStatusMapper.Good && slots is not null)
|
||||
{
|
||||
foreach (var m in members)
|
||||
{
|
||||
var slot = plan.SlotOf(m);
|
||||
results[m.Index] = slot < slots.Count
|
||||
? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now)
|
||||
: new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now);
|
||||
(served ??= new bool[references.Count])[m.Index] = true;
|
||||
}
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
|
||||
}
|
||||
else
|
||||
{
|
||||
// The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which
|
||||
// member is at fault: let the per-tag loop give each its own answer. Remember the range
|
||||
// so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to
|
||||
// BadDeviceFailure), which says nothing about the range.
|
||||
if (status != FocasStatusMapper.BadDeviceFailure)
|
||||
device.CoalesceRejected[plan.Key] = 0;
|
||||
_logger.LogDebug(
|
||||
"FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually",
|
||||
plan.Key, device.Options.HostAddress, status, members.Count);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
// Transport failure / timeout: the link is suspect, so fail the members without per-tag retries.
|
||||
FailMembers(members, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
|
||||
$"FOCAS transport failure reading PMC range {plan.Key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
return served;
|
||||
|
||||
void FailMembers(IEnumerable<FocasPmcReadItem> failed, uint status, IReadOnlyList<string> refs,
|
||||
DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef, string reason)
|
||||
{
|
||||
foreach (var m in failed)
|
||||
{
|
||||
res[m.Index] = new DataValueSnapshot(null, status, null, stamp);
|
||||
(servedRef ??= new bool[refs.Count])[m.Index] = true;
|
||||
}
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
|
||||
Volatile.Read(ref _health).LastSuccessfulRead,
|
||||
$"{reason} (0x{status:X8})"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -751,6 +880,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String);
|
||||
}
|
||||
|
||||
// Fixed-tree T2.5 — Status subtree (cnc_statinfo flags, #257), gated on its own probe.
|
||||
if (cache.Capabilities.Status)
|
||||
{
|
||||
var status = deviceFolder.Folder("Status", "Status");
|
||||
foreach (var field in StatusFields)
|
||||
EmitFixedVariable(status, "Status", field, DriverDataType.Int32);
|
||||
EmitFixedVariable(status, "Status", "RunText", DriverDataType.String);
|
||||
}
|
||||
|
||||
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
|
||||
if (cache.Capabilities.Timers)
|
||||
{
|
||||
@@ -1008,6 +1146,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
|
||||
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
|
||||
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
|
||||
var statusProbe = await SafeTryProbe(() => client.GetStatusAsync(ct));
|
||||
var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);
|
||||
|
||||
var caps = new FocasFixedTreeCapabilities(
|
||||
@@ -1016,7 +1155,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
SpindleMotorSpeed: spindleMotorSpeeds.Count > 0,
|
||||
ServoLoad: servoLoads.Count > 0,
|
||||
ProgramInfo: programInfo is not null,
|
||||
Timers: timer is not null);
|
||||
Timers: timer is not null,
|
||||
Status: statusProbe is not null);
|
||||
state.LastStatus = statusProbe;
|
||||
|
||||
state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps);
|
||||
// The bootstrap IS an answer from the device, and Identity comes entirely from it —
|
||||
@@ -1054,6 +1195,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
var programPollDue = DateTime.MinValue;
|
||||
var timerPollDue = DateTime.MinValue;
|
||||
var statusPollDue = DateTime.MinValue;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
@@ -1129,6 +1271,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
programPollDue = DateTime.UtcNow + programInterval;
|
||||
}
|
||||
|
||||
// Status flags (cnc_statinfo, #257) — same cadence as program/mode: run/emergency/alarm
|
||||
// transitions are operator-timescale events and one call returns all of them.
|
||||
if (cache.Capabilities.Status
|
||||
&& programInterval > TimeSpan.Zero && DateTime.UtcNow >= statusPollDue)
|
||||
{
|
||||
try { state.LastStatus = await client.GetStatusAsync(ct).ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
/* transient — keep the last value; staleness is dated by LastFixedPollUtc */
|
||||
_logger.LogDebug(ex, "FOCAS status poll failed for {Host}", state.Options.HostAddress);
|
||||
}
|
||||
statusPollDue = DateTime.UtcNow + programInterval;
|
||||
}
|
||||
|
||||
// Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind).
|
||||
var timerInterval = _options.FixedTree.TimerPollInterval;
|
||||
if (cache.Capabilities.Timers
|
||||
@@ -1245,6 +1401,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] StatusFields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"];
|
||||
|
||||
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
|
||||
state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null;
|
||||
|
||||
@@ -1385,6 +1543,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
|
||||
"OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode
|
||||
? FocasOpMode.ToText(mode) : null,
|
||||
"Status/Auto" => state.LastStatus?.Auto,
|
||||
"Status/Run" => state.LastStatus?.Run,
|
||||
"Status/Motion" => state.LastStatus?.Motion,
|
||||
"Status/Mstb" => state.LastStatus?.Mstb,
|
||||
"Status/Emergency" => state.LastStatus?.Emergency,
|
||||
"Status/Alarm" => state.LastStatus?.Alarm,
|
||||
"Status/Edit" => state.LastStatus?.Edit,
|
||||
"Status/TmMode" => state.LastStatus?.TmMode,
|
||||
"Status/RunText" => state.LastStatus is { } st ? FocasRunState.ToText(st.Run) : null,
|
||||
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
|
||||
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
|
||||
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
|
||||
@@ -1618,7 +1785,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded
|
||||
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
|
||||
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
|
||||
bool Timers); // cnc_rdtimer works for at least PowerOn
|
||||
bool Timers, // cnc_rdtimer works for at least PowerOn
|
||||
bool Status = false); // cnc_statinfo answered at bootstrap (#257)
|
||||
|
||||
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
|
||||
{
|
||||
@@ -1683,6 +1851,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// holding no value.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>
|
||||
/// PMC ranges (<see cref="FocasPmcRangePlan.Key"/>) the CNC rejected as a merged read, so later polls skip
|
||||
/// straight to per-tag reads instead of paying a doomed round-trip every cycle (#266).
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, byte> CoalesceRejected { get; } = new(StringComparer.Ordinal);
|
||||
/// <summary>Gets or sets the last <c>cnc_statinfo</c> snapshot (#257); null until the first answer.</summary>
|
||||
public FocasStatus? LastStatus { get; set; }
|
||||
/// <summary>Gets or sets the last program information snapshot.</summary>
|
||||
public FocasProgramInfo? LastProgramInfo { get; set; }
|
||||
/// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
|
||||
|
||||
@@ -115,6 +115,7 @@ public static class FocasDriverFactoryExtensions
|
||||
FixedTree = BuildFixedTree(dto.FixedTree),
|
||||
AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
|
||||
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
|
||||
PmcCoalesce = BuildPmcCoalesce(dto.PmcCoalesce),
|
||||
};
|
||||
|
||||
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId, loggerFactory));
|
||||
@@ -206,6 +207,19 @@ public static class FocasDriverFactoryExtensions
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Map the optional <c>PmcCoalesce</c> section; a missing section or key keeps the default.</summary>
|
||||
private static FocasPmcCoalesceOptions BuildPmcCoalesce(FocasPmcCoalesceDto? dto)
|
||||
{
|
||||
var defaults = new FocasPmcCoalesceOptions();
|
||||
if (dto is null) return defaults;
|
||||
return new FocasPmcCoalesceOptions
|
||||
{
|
||||
Enabled = dto.Enabled ?? defaults.Enabled,
|
||||
MaxSpanBytes = dto.MaxSpanBytes is > 0 ? dto.MaxSpanBytes.Value : defaults.MaxSpanBytes,
|
||||
MaxGapBytes = dto.MaxGapBytes is >= 0 ? dto.MaxGapBytes.Value : defaults.MaxGapBytes,
|
||||
};
|
||||
}
|
||||
|
||||
private static FocasCncSeries ParseSeries(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
|
||||
@@ -284,6 +298,9 @@ public static class FocasDriverFactoryExtensions
|
||||
|
||||
/// <summary>Gets or sets the handle recycle configuration options.</summary>
|
||||
public FocasHandleRecycleDto? HandleRecycle { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the PMC range-coalescing configuration options.</summary>
|
||||
public FocasPmcCoalesceDto? PmcCoalesce { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasDeviceDto
|
||||
@@ -348,6 +365,17 @@ public static class FocasDriverFactoryExtensions
|
||||
public TimeSpan? PollInterval { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Optional <c>PmcCoalesce</c> config section (#266).</summary>
|
||||
internal sealed class FocasPmcCoalesceDto
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether coalescing is on.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
/// <summary>Gets or sets the largest coalesced range in bytes.</summary>
|
||||
public int? MaxSpanBytes { get; init; }
|
||||
/// <summary>Gets or sets the largest tolerated gap in bytes.</summary>
|
||||
public int? MaxGapBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Optional <c>HandleRecycle</c> config section.</summary>
|
||||
internal sealed class FocasHandleRecycleDto
|
||||
{
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>One PMC read the coalescer may merge — the caller's batch index plus the parsed address and type.</summary>
|
||||
/// <param name="Index">Position in the caller's batch; results are sliced back to it.</param>
|
||||
/// <param name="Letter">PMC area letter (<c>R</c>, <c>X</c>, ...), upper-case.</param>
|
||||
/// <param name="Number">Byte address.</param>
|
||||
/// <param name="Type">The tag's declared data type (decides slot width and decode).</param>
|
||||
/// <param name="BitIndex">Bit index for a <see cref="FocasDataType.Bit"/> tag.</param>
|
||||
internal readonly record struct FocasPmcReadItem(int Index, string Letter, int Number, FocasDataType Type, int? BitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// One planned <c>pmc_rdpmcrng</c> read covering <see cref="Members"/>. <see cref="End"/> is inclusive and is a
|
||||
/// whole number of <see cref="SlotWidth"/>-byte slots from <see cref="Start"/>.
|
||||
/// </summary>
|
||||
internal sealed record FocasPmcRangePlan(
|
||||
string Letter,
|
||||
FocasDataType SlotType,
|
||||
int SlotWidth,
|
||||
int Start,
|
||||
int End,
|
||||
IReadOnlyList<FocasPmcReadItem> Members)
|
||||
{
|
||||
/// <summary>Stable key for the "this range was rejected, read members individually" memo.</summary>
|
||||
public string Key => $"{Letter}:{SlotType}:{Start}-{End}";
|
||||
|
||||
/// <summary>Slot index (into the decoded range) that holds <paramref name="item"/>'s value.</summary>
|
||||
/// <param name="item">A member of this plan.</param>
|
||||
/// <returns>The zero-based slot index.</returns>
|
||||
public int SlotOf(FocasPmcReadItem item) => (item.Number - Start) / SlotWidth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Items are grouped by <c>(letter, slot width class)</c> and merged in address order while (a) the gap to
|
||||
/// the previous member is at most <c>maxGapBytes</c> and (b) the whole range stays within <c>maxSpanBytes</c>.
|
||||
/// A multi-byte member only joins a range when its offset from the range start is a multiple of its width — the
|
||||
/// CNC splits a range into width-sized slots from the start, so an unaligned member would be decoded from the
|
||||
/// wrong bytes. Members that cannot merge become single-member plans; the driver reads those through the
|
||||
/// ordinary per-tag path, so the common "one tag per area" case is byte-for-byte unchanged.</para>
|
||||
/// <para>Pure and deterministic: no I/O, no clock.</para>
|
||||
/// </remarks>
|
||||
internal static class FocasPmcCoalescer
|
||||
{
|
||||
/// <summary>Plans the reads for a batch.</summary>
|
||||
/// <param name="items">The PMC reads in the batch (any order).</param>
|
||||
/// <param name="maxSpanBytes">Largest range, in bytes, one coalesced read may cover (>= 1).</param>
|
||||
/// <param name="maxGapBytes">Largest run of unrequested bytes tolerated between two members (>= 0).</param>
|
||||
/// <returns>Range plans; every input item appears in exactly one plan.</returns>
|
||||
public static IReadOnlyList<FocasPmcRangePlan> Plan(
|
||||
IEnumerable<FocasPmcReadItem> items, int maxSpanBytes, int maxGapBytes)
|
||||
{
|
||||
maxSpanBytes = Math.Max(1, maxSpanBytes);
|
||||
maxGapBytes = Math.Max(0, maxGapBytes);
|
||||
var plans = new List<FocasPmcRangePlan>();
|
||||
|
||||
var groups = items
|
||||
.GroupBy(i => (Letter: i.Letter.ToUpperInvariant(), Slot: SlotTypeOf(i.Type)))
|
||||
.OrderBy(g => g.Key.Letter, StringComparer.Ordinal).ThenBy(g => g.Key.Slot);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var width = FocasWireClient.PmcByteWidth((short)FocasPmcDataTypeLookup.FromFocasDataType(group.Key.Slot));
|
||||
var sorted = group.OrderBy(i => i.Number).ThenBy(i => i.Index).ToList();
|
||||
|
||||
List<FocasPmcReadItem>? members = null;
|
||||
var start = 0;
|
||||
var end = 0; // inclusive
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (members is null) return;
|
||||
// Round the range up to a whole number of slots so the CNC returns no partial trailing slot.
|
||||
var bytes = end - start + 1;
|
||||
var rounded = (bytes + width - 1) / width * width;
|
||||
plans.Add(new FocasPmcRangePlan(group.Key.Letter, group.Key.Slot, width, start, start + rounded - 1, members));
|
||||
members = null;
|
||||
}
|
||||
|
||||
foreach (var item in sorted)
|
||||
{
|
||||
var itemEnd = item.Number + width - 1;
|
||||
if (members is not null)
|
||||
{
|
||||
var gap = Math.Max(0, item.Number - (end + 1));
|
||||
var aligned = (item.Number - start) % width == 0;
|
||||
var newEnd = Math.Max(end, itemEnd);
|
||||
// The span check is on the ROUNDED length a flush would produce.
|
||||
var roundedSpan = (newEnd - start + 1 + width - 1) / width * width;
|
||||
if (aligned && gap <= maxGapBytes && roundedSpan <= maxSpanBytes)
|
||||
{
|
||||
members.Add(item);
|
||||
end = newEnd;
|
||||
continue;
|
||||
}
|
||||
Flush();
|
||||
}
|
||||
members = [item];
|
||||
start = item.Number;
|
||||
end = itemEnd;
|
||||
}
|
||||
Flush();
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
/// <summary>The data type whose wire width a read of <paramref name="type"/> uses (Bit and Byte share one).</summary>
|
||||
/// <param name="type">The tag's declared type.</param>
|
||||
/// <returns>The representative slot type.</returns>
|
||||
public static FocasDataType SlotTypeOf(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => FocasDataType.Byte,
|
||||
_ => type,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one raw PMC slot (as <c>pmc_rdpmcrng</c> parsed it) into the tag's published value. The single
|
||||
/// decode authority for both the per-tag and the coalesced path.
|
||||
/// </summary>
|
||||
/// <param name="raw">The slot value.</param>
|
||||
/// <param name="type">The tag's declared type.</param>
|
||||
/// <param name="bitIndex">Bit index for a Bit tag.</param>
|
||||
/// <returns>The boxed value.</returns>
|
||||
public static object MapSlot(long raw, FocasDataType type, int? bitIndex) => type switch
|
||||
{
|
||||
FocasDataType.Bit => (object)(((raw >> (bitIndex ?? 0)) & 1L) != 0),
|
||||
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
|
||||
FocasDataType.Int16 => (object)(short)raw,
|
||||
FocasDataType.Int32 => (object)(int)raw,
|
||||
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
|
||||
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
|
||||
_ => (object)raw,
|
||||
};
|
||||
}
|
||||
@@ -73,6 +73,24 @@ public interface IFocasClient : IDisposable
|
||||
/// <returns>The list of currently active alarms; empty when none are active.</returns>
|
||||
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read one PMC range (<c>pmc_rdpmcrng</c>) as raw slots: <c>(end - start + 1)</c> bytes split into
|
||||
/// slots of <paramref name="slotType"/>'s width, from <paramref name="start"/>. The primitive behind PMC
|
||||
/// range coalescing (Gitea #266) — the driver slices individual tag values out of the returned slots with
|
||||
/// the same decode a per-tag read uses.
|
||||
/// </summary>
|
||||
/// <param name="letter">PMC area letter.</param>
|
||||
/// <param name="slotType">Decides the slot width (Bit/Byte = 1, Int16 = 2, Int32/Float32 = 4, Float64 = 8).</param>
|
||||
/// <param name="start">First byte address.</param>
|
||||
/// <param name="end">Last byte address, inclusive; a whole number of slots from <paramref name="start"/>.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The raw slot values (null when not Good) and the mapped OPC UA status. A returned non-Good status is
|
||||
/// always a CNC return-code refusal; a transport failure or timeout throws instead.
|
||||
/// </returns>
|
||||
Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
|
||||
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
|
||||
|
||||
/// <summary>
|
||||
@@ -125,6 +143,17 @@ public interface IFocasClient : IDisposable
|
||||
/// <returns>The aggregate program + operation-mode snapshot.</returns>
|
||||
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T2.5 (CNC status flags, Gitea #257) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read the CNC status flags via <c>cnc_statinfo</c> (<c>ODBST</c>). Populates the
|
||||
/// <c>Status/*</c> subtree. Throws when the CNC refuses the call, which the driver's
|
||||
/// bootstrap probe reads as "this series has no status block" and suppresses the subtree.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The CNC status flag snapshot.</returns>
|
||||
Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3 (timers) ----
|
||||
|
||||
/// <summary>
|
||||
@@ -172,6 +201,38 @@ public interface IFocasClient : IDisposable
|
||||
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CNC status flags from <c>cnc_statinfo</c> (<c>ODBST</c>) — raw FANUC codes, published verbatim as
|
||||
/// Int32 so a client sees exactly what the control reported. <see cref="Run"/> additionally has a
|
||||
/// text projection (<see cref="FocasRunState.ToText"/>).
|
||||
/// </summary>
|
||||
/// <param name="Auto">Automatic-operation mode code.</param>
|
||||
/// <param name="Run">Automatic-operation state (0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR).</param>
|
||||
/// <param name="Motion">Axis motion (0 none, 1 motion, 2 dwell).</param>
|
||||
/// <param name="Mstb">M/S/T/B function state (0 none, 1 executing).</param>
|
||||
/// <param name="Emergency">Emergency stop (0 not, 1 emergency, 2 reset).</param>
|
||||
/// <param name="Alarm">Alarm state (0 none, 1 alarm, 2 battery-low, 3 fan).</param>
|
||||
/// <param name="Edit">Program-edit state.</param>
|
||||
/// <param name="TmMode">T/M mode as reported by the control.</param>
|
||||
public sealed record FocasStatus(int Auto, int Run, int Motion, int Mstb, int Emergency, int Alarm, int Edit, int TmMode);
|
||||
|
||||
/// <summary>Human-readable text for <see cref="FocasStatus.Run"/>.</summary>
|
||||
public static class FocasRunState
|
||||
{
|
||||
/// <summary>Converts the <c>ODBST.run</c> code to its operator-facing label.</summary>
|
||||
/// <param name="run">The run-state code.</param>
|
||||
/// <returns>The label, or <c>Run{n}</c> for a code outside the documented set.</returns>
|
||||
public static string ToText(int run) => run switch
|
||||
{
|
||||
0 => "RESET",
|
||||
1 => "STOP",
|
||||
2 => "HOLD",
|
||||
3 => "START",
|
||||
4 => "MSTR",
|
||||
_ => $"Run{run}",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
|
||||
/// described no usable value (never a fabricated 0).
|
||||
|
||||
@@ -61,6 +61,11 @@ public sealed class SynchronizedFocasClient : IFocasClient
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.ReadAsync(address, type, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
|
||||
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.ReadPmcSlotsAsync(letter, slotType, start, end, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) =>
|
||||
@@ -90,6 +95,10 @@ public sealed class SynchronizedFocasClient : IFocasClient
|
||||
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetStatusAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), cancellationToken);
|
||||
|
||||
@@ -67,6 +67,33 @@ public sealed class WireFocasClient : IFocasClient
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
|
||||
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken)
|
||||
{
|
||||
// Contract: a RETURNED status is always a CNC return code (a refusal); a transport failure THROWS.
|
||||
// The coalescing caller needs that distinction — it falls back per tag on a refusal but must not
|
||||
// re-hit a dead link (#266).
|
||||
RequireConnected();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var area = FocasPmcAreaLookup.FromLetter(letter);
|
||||
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
|
||||
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType);
|
||||
try
|
||||
{
|
||||
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null)
|
||||
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
|
||||
return (result.Value.Values, FocasStatusMapper.Good);
|
||||
}
|
||||
catch (FocasWireException ex) when (ex.Rc is short rc)
|
||||
{
|
||||
return (null, FocasStatusMapper.MapFocasReturn(rc));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
@@ -129,6 +156,18 @@ public sealed class WireFocasClient : IFocasClient
|
||||
AxesCount: axesCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
ThrowIfRcNonZero(result.Rc, "cnc_statinfo", result.IsOk);
|
||||
return MapStatus(result.Value!);
|
||||
}
|
||||
|
||||
internal static FocasStatus MapStatus(WireStatus s) =>
|
||||
new(s.Auto, s.Run, s.Motion, s.Mstb, s.Emergency, s.Alarm, s.Edit, s.TmMode);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -313,17 +352,8 @@ public sealed class WireFocasClient : IFocasClient
|
||||
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
|
||||
var values = result.Value.Values;
|
||||
if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
|
||||
var raw = values[0];
|
||||
var mapped = type switch
|
||||
{
|
||||
FocasDataType.Bit => (object)(((long)raw >> (address.BitIndex ?? 0) & 1L) != 0),
|
||||
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
|
||||
FocasDataType.Int16 => (object)(short)raw,
|
||||
FocasDataType.Int32 => (object)(int)raw,
|
||||
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
|
||||
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
|
||||
_ => (object)raw,
|
||||
};
|
||||
// One decode authority shared with the coalesced path (FocasPmcCoalescer.MapSlot).
|
||||
var mapped = FocasPmcCoalescer.MapSlot(values[0], type, address.BitIndex);
|
||||
return (mapped, FocasStatusMapper.Good);
|
||||
}
|
||||
catch (FocasWireException ex)
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Live-wire coverage (focas-mock) for #257 (<c>cnc_statinfo</c> status flags) and #266 (PMC
|
||||
/// range coalescing). Skips cleanly when <c>OTOPCUA_FOCAS_SIM_ENDPOINT</c> (default
|
||||
/// <c>localhost:8193</c>) is unreachable. Read-only: no write path is exercised.
|
||||
/// </summary>
|
||||
[Collection(FocasSimCollection.Name)]
|
||||
public sealed class WireBackendStatusAndPmcTests
|
||||
{
|
||||
private readonly FocasSimFixture _fx;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="WireBackendStatusAndPmcTests"/> class.</summary>
|
||||
/// <param name="fx">The FOCAS simulator fixture.</param>
|
||||
public WireBackendStatusAndPmcTests(FocasSimFixture fx) => _fx = fx;
|
||||
|
||||
private string DeviceHost => _fx.DeviceUri;
|
||||
|
||||
/// <summary>The mock's <c>cnc_statinfo</c> flags surface through the Status/ subtree and an authored FIXED: tag.</summary>
|
||||
[Fact]
|
||||
public async Task Status_flags_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
sysinfo = new
|
||||
{
|
||||
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
|
||||
series = "30i ", version = "A1.0", axes = "1 ",
|
||||
},
|
||||
axis_names = new[] { "X" },
|
||||
statinfo = new { aut = 1, run = 3, motion = 1, mstb = 0, emergency = 0, alarm = 1, edit = 0 },
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
RawTags = FocasRawTags.Entries(
|
||||
new FocasTagDefinition("RunFlag", DeviceHost, "FIXED:Status/Run", FocasDataType.Int32, Writable: false)),
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
ProgramPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-status", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus: not null }, TimeSpan.FromSeconds(5));
|
||||
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
state.ShouldNotBeNull();
|
||||
state.LastStatus.ShouldNotBeNull();
|
||||
state.LastStatus.Run.ShouldBe(3);
|
||||
state.LastStatus.Alarm.ShouldBe(1);
|
||||
|
||||
var snaps = await driver.ReadAsync(["RunFlag", $"{DeviceHost}/Status/Alarm", $"{DeviceHost}/Status/RunText"], ct);
|
||||
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
snaps[0].Value.ShouldBe(3);
|
||||
snaps[1].Value.ShouldBe(1);
|
||||
snaps[2].Value.ShouldBe("START");
|
||||
|
||||
// The flags follow the machine.
|
||||
await _fx.PatchStateAsync(new { statinfo = new { run = 1, emergency = 1 } }, ct);
|
||||
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus.Run: 1 }, TimeSpan.FromSeconds(5));
|
||||
driver.GetDeviceState(DeviceHost)!.LastStatus!.Emergency.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A poll batch of adjacent PMC bytes is served by ONE range read against the live wire, every tag gets
|
||||
/// its own value, and a tag outside the merge window still reads through the per-tag path. Seeds are
|
||||
/// byte-typed: the mock serves one entry per ADDRESS for every PMC type, which matches the real
|
||||
/// byte-addressed range only for width 1.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Adjacent_pmc_bytes_coalesce_into_one_range_read_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
pmc = new { R = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new { type = "byte", value = 17 },
|
||||
["201"] = new { type = "byte", value = 200 },
|
||||
["202"] = new { type = "byte", value = 0b0000_0100 },
|
||||
["203"] = new { type = "byte", value = 9 },
|
||||
["900"] = new { type = "byte", value = 33 },
|
||||
}},
|
||||
}, ct);
|
||||
|
||||
var counting = new CountingFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
RawTags = FocasRawTags.Entries(
|
||||
new FocasTagDefinition("R200", DeviceHost, "R200", FocasDataType.Byte, Writable: false),
|
||||
new FocasTagDefinition("R201", DeviceHost, "R201", FocasDataType.Byte, Writable: false),
|
||||
new FocasTagDefinition("R202b2", DeviceHost, "R202.2", FocasDataType.Bit, Writable: false),
|
||||
new FocasTagDefinition("R203", DeviceHost, "R203", FocasDataType.Byte, Writable: false),
|
||||
new FocasTagDefinition("R900", DeviceHost, "R900", FocasDataType.Byte, Writable: false)),
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "wire-pmc-coalesce", clientFactory: counting);
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var snaps = await drv.ReadAsync(["R200", "R201", "R202b2", "R203", "R900"], ct);
|
||||
|
||||
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
Convert.ToInt32(snaps[0].Value).ShouldBe(17);
|
||||
Convert.ToInt32(snaps[1].Value).ShouldBe(-56); // 200 as a signed byte
|
||||
snaps[2].Value.ShouldBe(true);
|
||||
Convert.ToInt32(snaps[3].Value).ShouldBe(9);
|
||||
Convert.ToInt32(snaps[4].Value).ShouldBe(33);
|
||||
|
||||
counting.Client.ShouldNotBeNull();
|
||||
counting.Client.RangeReads.ShouldBe(1, "R200..R203 merge into one pmc_rdpmcrng");
|
||||
counting.Client.SingleReads.ShouldBe(1, "R900 is outside the gap window and reads on its own");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingFactory : IFocasClientFactory
|
||||
{
|
||||
private readonly WireFocasClientFactory _inner = new();
|
||||
public CountingClient? Client { get; private set; }
|
||||
public void EnsureUsable() => _inner.EnsureUsable();
|
||||
public IFocasClient Create() => Client = new CountingClient(_inner.Create());
|
||||
}
|
||||
|
||||
private sealed class CountingClient(IFocasClient inner) : IFocasClient
|
||||
{
|
||||
public int RangeReads { get; private set; }
|
||||
public int SingleReads { get; private set; }
|
||||
public bool IsConnected => inner.IsConnected;
|
||||
public Task ConnectAsync(FocasHostAddress a, TimeSpan t, CancellationToken ct) => inner.ConnectAsync(a, t, ct);
|
||||
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct)
|
||||
{ SingleReads++; return inner.ReadAsync(a, t, ct); }
|
||||
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string l, FocasDataType t, int s, int e, CancellationToken ct)
|
||||
{ RangeReads++; return inner.ReadPmcSlotsAsync(l, t, s, e, ct); }
|
||||
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => inner.WriteAsync(a, t, v, ct);
|
||||
public Task<bool> ProbeAsync(CancellationToken ct) => inner.ProbeAsync(ct);
|
||||
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) => inner.ReadAlarmsAsync(ct);
|
||||
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => inner.GetSysInfoAsync(ct);
|
||||
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) => inner.GetAxisNamesAsync(ct);
|
||||
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) => inner.GetSpindleNamesAsync(ct);
|
||||
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int i, CancellationToken ct) => inner.ReadDynamicAsync(i, ct);
|
||||
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) => inner.GetProgramInfoAsync(ct);
|
||||
public Task<FocasStatus> GetStatusAsync(CancellationToken ct) => inner.GetStatusAsync(ct);
|
||||
public Task<FocasTimer> GetTimerAsync(FocasTimerKind k, CancellationToken ct) => inner.GetTimerAsync(k, ct);
|
||||
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) => inner.GetServoLoadsAsync(ct);
|
||||
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) => inner.GetSpindleLoadsAsync(ct);
|
||||
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) => inner.GetSpindleMotorSpeedsAsync(ct);
|
||||
public void Dispose() => inner.Dispose();
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,51 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.FromResult((value, status));
|
||||
}
|
||||
|
||||
/// <summary>PMC byte memory the range read serves, keyed by (upper-case letter, byte address); absent = 0.</summary>
|
||||
public Dictionary<(string Letter, int Address), byte> PmcMemory { get; } = new();
|
||||
/// <summary>Gets the log of range reads issued (letter, slot type, start, end).</summary>
|
||||
public List<(string Letter, FocasDataType SlotType, int Start, int End)> PmcRangeReads { get; } = new();
|
||||
/// <summary>Ranges (letter, start, end) the fake CNC rejects with <see cref="PmcRangeRejectStatus"/>.</summary>
|
||||
public List<Func<string, int, int, bool>> PmcRangeRejectors { get; } = new();
|
||||
/// <summary>The status a rejected range returns.</summary>
|
||||
public uint PmcRangeRejectStatus { get; set; } = FocasStatusMapper.BadOutOfRange;
|
||||
/// <summary>When true every range read throws (transport failure).</summary>
|
||||
public bool ThrowOnPmcRange { get; set; }
|
||||
|
||||
/// <summary>Reads a PMC range as raw big-endian slots from <see cref="PmcMemory"/>.</summary>
|
||||
/// <param name="letter">PMC letter.</param>
|
||||
/// <param name="slotType">Slot type.</param>
|
||||
/// <param name="start">First byte.</param>
|
||||
/// <param name="end">Last byte, inclusive.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
|
||||
string letter, FocasDataType slotType, int start, int end, CancellationToken ct)
|
||||
{
|
||||
PmcRangeReads.Add((letter, slotType, start, end));
|
||||
if (ThrowOnPmcRange) throw new InvalidOperationException("transport down");
|
||||
if (PmcRangeRejectors.Any(r => r(letter, start, end)))
|
||||
return Task.FromResult<(IReadOnlyList<long>?, uint)>((null, PmcRangeRejectStatus));
|
||||
|
||||
var width = slotType switch
|
||||
{
|
||||
FocasDataType.Int16 => 2,
|
||||
FocasDataType.Int32 or FocasDataType.Float32 => 4,
|
||||
FocasDataType.Float64 => 8,
|
||||
_ => 1,
|
||||
};
|
||||
var slots = new List<long>();
|
||||
for (var o = start; o + width - 1 <= end; o += width)
|
||||
{
|
||||
long v = 0;
|
||||
for (var k = 0; k < width; k++)
|
||||
v = (v << 8) | (PmcMemory.TryGetValue((letter, o + k), out var b) ? b : (byte)0);
|
||||
// Sign-extend like the wire parser (Int16/Int32/Int64 big-endian); width 1 stays 0..255.
|
||||
v = width switch { 2 => (short)v, 4 => (int)v, _ => v };
|
||||
slots.Add(v);
|
||||
}
|
||||
return Task.FromResult<(IReadOnlyList<long>?, uint)>((slots, FocasStatusMapper.Good));
|
||||
}
|
||||
|
||||
/// <summary>Writes a value to a FOCAS address asynchronously.</summary>
|
||||
/// <param name="address">The FOCAS address to write to.</param>
|
||||
/// <param name="type">The data type of the value.</param>
|
||||
@@ -121,6 +166,21 @@ internal class FakeFocasClient : IFocasClient
|
||||
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
|
||||
Task.FromResult(ProgramInfo);
|
||||
|
||||
/// <summary>Gets or sets the cnc_statinfo status snapshot.</summary>
|
||||
public FocasStatus Status { get; set; } = new(1, 3, 1, 0, 0, 0, 0, 0);
|
||||
/// <summary>Gets or sets a value indicating whether <see cref="GetStatusAsync"/> throws (older series / refused call).</summary>
|
||||
public bool ThrowOnStatus { get; set; }
|
||||
/// <summary>Gets the number of status reads served.</summary>
|
||||
public int StatusReads { get; private set; }
|
||||
/// <summary>Gets the CNC status flags asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<FocasStatus> GetStatusAsync(CancellationToken ct)
|
||||
{
|
||||
StatusReads++;
|
||||
if (ThrowOnStatus) throw new InvalidOperationException("cnc_statinfo refused");
|
||||
return Task.FromResult(Status);
|
||||
}
|
||||
|
||||
/// <summary>Gets the dictionary of timers keyed by timer kind.</summary>
|
||||
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
|
||||
/// <summary>Gets timer data asynchronously.</summary>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>Gitea #266 — the pure planner behind PMC range coalescing.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasPmcCoalescerTests
|
||||
{
|
||||
private static FocasPmcReadItem B(int index, string letter, int number, FocasDataType t = FocasDataType.Byte, int? bit = null) =>
|
||||
new(index, letter, number, t, bit);
|
||||
|
||||
[Fact]
|
||||
public void Adjacent_bytes_merge_into_one_range()
|
||||
{
|
||||
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101), B(2, "R", 102)], 128, 8);
|
||||
var p = plans.ShouldHaveSingleItem();
|
||||
(p.Letter, p.Start, p.End).ShouldBe(("R", 100, 102));
|
||||
p.Members.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_gap_within_the_threshold_merges_and_beyond_it_splits()
|
||||
{
|
||||
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 108)], 128, 8).Count.ShouldBe(1); // 7 byte gap
|
||||
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 110)], 128, 8).Count.ShouldBe(2); // 9 byte gap
|
||||
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 102)], 128, 0).Count.ShouldBe(2); // gap budget 0
|
||||
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101)], 128, 0).Count.ShouldBe(1); // adjacent still fine
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_span_cap_is_a_hard_limit_on_range_length()
|
||||
{
|
||||
var items = Enumerable.Range(0, 10).Select(i => B(i, "R", 100 + i)).ToList();
|
||||
var plans = FocasPmcCoalescer.Plan(items, maxSpanBytes: 4, maxGapBytes: 8);
|
||||
plans.Select(p => p.End - p.Start + 1).ShouldAllBe(len => len <= 4);
|
||||
plans.Sum(p => p.Members.Count).ShouldBe(10);
|
||||
plans.Count.ShouldBe(3); // 4 + 4 + 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_letters_never_merge()
|
||||
{
|
||||
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "D", 101), B(2, "R", 101)], 128, 8);
|
||||
plans.Count.ShouldBe(2);
|
||||
plans.Single(p => p.Letter == "R").Members.Count.ShouldBe(2);
|
||||
plans.Single(p => p.Letter == "D").Members.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bits_of_one_byte_share_a_slot_and_bits_and_bytes_share_a_range()
|
||||
{
|
||||
var plans = FocasPmcCoalescer.Plan(
|
||||
[B(0, "X", 0, FocasDataType.Bit, 0), B(1, "X", 0, FocasDataType.Bit, 7), B(2, "X", 1)], 128, 8);
|
||||
var p = plans.ShouldHaveSingleItem();
|
||||
(p.Start, p.End).ShouldBe((0, 1));
|
||||
p.SlotOf(p.Members.Single(m => m.Index == 2)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Words_merge_only_when_aligned_to_the_range_start_and_the_range_is_whole_slots()
|
||||
{
|
||||
// 100,102 aligned -> one range [100,103]; 105 is unaligned (offset 5) -> its own range.
|
||||
var plans = FocasPmcCoalescer.Plan(
|
||||
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 102, FocasDataType.Int16), B(2, "D", 105, FocasDataType.Int16)], 128, 8);
|
||||
plans.Count.ShouldBe(2);
|
||||
var first = plans.Single(p => p.Start == 100);
|
||||
(first.End, first.SlotWidth, first.Members.Count).ShouldBe((103, 2, 2));
|
||||
first.SlotOf(first.Members.Single(m => m.Number == 102)).ShouldBe(1);
|
||||
plans.Single(p => p.Start == 105).Members.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_word_range_with_a_gap_is_rounded_up_to_whole_slots()
|
||||
{
|
||||
// 100 (word) + 103 (word): offset 3 is unaligned -> split; 100 + 104 aligned, gap 2 -> merged, end 105.
|
||||
var plans = FocasPmcCoalescer.Plan(
|
||||
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 104, FocasDataType.Int16)], 128, 8);
|
||||
var p = plans.ShouldHaveSingleItem();
|
||||
(p.Start, p.End).ShouldBe((100, 105));
|
||||
((p.End - p.Start + 1) % p.SlotWidth).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_widths_on_one_letter_do_not_merge()
|
||||
{
|
||||
FocasPmcCoalescer.Plan([B(0, "D", 100, FocasDataType.Byte), B(1, "D", 101, FocasDataType.Int16)], 128, 8)
|
||||
.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_input_appears_in_exactly_one_plan_and_input_order_is_irrelevant()
|
||||
{
|
||||
var items = new[] { B(0, "R", 300), B(1, "R", 100), B(2, "R", 102), B(3, "R", 101), B(4, "R", 104), B(5, "R", 300) };
|
||||
var a = FocasPmcCoalescer.Plan(items, 128, 8);
|
||||
var b = FocasPmcCoalescer.Plan(items.Reverse(), 128, 8);
|
||||
a.SelectMany(p => p.Members).Select(m => m.Index).OrderBy(x => x).ShouldBe([0, 1, 2, 3, 4, 5]);
|
||||
a.Select(p => (p.Start, p.End)).ShouldBe(b.Select(p => (p.Start, p.End)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slot_decode_matches_the_per_tag_mapping()
|
||||
{
|
||||
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 2).ShouldBe(true);
|
||||
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 3).ShouldBe(false);
|
||||
FocasPmcCoalescer.MapSlot(0xFF, FocasDataType.Byte, null).ShouldBe((sbyte)-1);
|
||||
FocasPmcCoalescer.MapSlot(-2, FocasDataType.Int16, null).ShouldBe((short)-2);
|
||||
FocasPmcCoalescer.MapSlot(BitConverter.SingleToInt32Bits(1.5f), FocasDataType.Float32, null).ShouldBe(1.5f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>Gitea #266 — driver-level behaviour of PMC range coalescing in a poll batch.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasPmcCoalescingDriverTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasTagDefinition Tag(string name, string addr, FocasDataType t) =>
|
||||
new(name, Host, addr, t, Writable: false);
|
||||
|
||||
private static async Task<(FocasDriver drv, FakeFocasClient client)> StartAsync(
|
||||
FocasPmcCoalesceOptions? opts, params FocasTagDefinition[] tags)
|
||||
{
|
||||
var client = new FakeFocasClient();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
RawTags = FocasRawTags.Entries(tags),
|
||||
PmcCoalesce = opts ?? new FocasPmcCoalesceOptions(),
|
||||
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Adjacent_pmc_tags_are_served_by_one_range_read_and_sliced_back_per_tag()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte),
|
||||
Tag("B", "R101", FocasDataType.Byte),
|
||||
Tag("Bit", "R102.3", FocasDataType.Bit),
|
||||
Tag("Far", "R900", FocasDataType.Byte));
|
||||
c.PmcMemory[("R", 100)] = 0x11;
|
||||
c.PmcMemory[("R", 101)] = 0xFF;
|
||||
c.PmcMemory[("R", 102)] = 0b0000_1000;
|
||||
c.Values["R900"] = (sbyte)7;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B", "Bit", "Far"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 102)]);
|
||||
r[0].Value.ShouldBe((sbyte)0x11);
|
||||
r[1].Value.ShouldBe((sbyte)-1);
|
||||
r[2].Value.ShouldBe(true);
|
||||
r[3].Value.ShouldBe((sbyte)7); // lone tag: ordinary per-tag path, no range read of its own
|
||||
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aligned_words_decode_from_their_own_slot()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("W0", "D200", FocasDataType.Int16),
|
||||
Tag("W1", "D202", FocasDataType.Int16));
|
||||
c.PmcMemory[("D", 200)] = 0x01; c.PmcMemory[("D", 201)] = 0x02; // 0x0102 = 258
|
||||
c.PmcMemory[("D", 202)] = 0xFF; c.PmcMemory[("D", 203)] = 0xFE; // -2
|
||||
|
||||
var r = await drv.ReadAsync(["W0", "W1"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBe([("D", FocasDataType.Int16, 200, 203)]);
|
||||
r[0].Value.ShouldBe((short)258);
|
||||
r[1].Value.ShouldBe((short)-2);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_single_pmc_tag_never_issues_a_range_read()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte));
|
||||
c.Values["R100"] = (sbyte)5;
|
||||
|
||||
var r = await drv.ReadAsync(["A"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBeEmpty();
|
||||
r[0].Value.ShouldBe((sbyte)5);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabling_coalescing_restores_per_tag_reads()
|
||||
{
|
||||
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { Enabled = false },
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBeEmpty();
|
||||
r[0].Value.ShouldBe((sbyte)1);
|
||||
r[1].Value.ShouldBe((sbyte)2);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_span_cap_and_gap_threshold_from_options_are_honoured()
|
||||
{
|
||||
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { MaxSpanBytes = 2, MaxGapBytes = 0 },
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte), Tag("C", "R102", FocasDataType.Byte));
|
||||
c.Values["R102"] = (sbyte)9;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 101)]); // C is alone -> per-tag
|
||||
r[2].Value.ShouldBe((sbyte)9);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_range_the_cnc_rejects_falls_back_to_per_tag_reads_with_their_own_status_and_is_remembered()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R106", FocasDataType.Byte));
|
||||
c.PmcRangeRejectors.Add((_, s, e) => e - s + 1 > 2); // the spare gap bytes are not valid addresses
|
||||
c.Values["R100"] = (sbyte)1;
|
||||
c.Values["R106"] = (sbyte)2;
|
||||
c.ReadStatuses["R106"] = FocasStatusMapper.BadNodeIdUnknown; // B is genuinely bad; A must NOT inherit that
|
||||
|
||||
var r1 = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
r1[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
r1[0].Value.ShouldBe((sbyte)1);
|
||||
r1[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
c.PmcRangeReads.Count.ShouldBe(1);
|
||||
|
||||
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
c.PmcRangeReads.Count.ShouldBe(1, "a rejected range is remembered; later polls go straight to per-tag reads");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_with_an_unmapped_rc_that_maps_to_BadCommunicationError_still_falls_back_per_tag()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
c.PmcRangeRejectors.Add((_, _, _) => true);
|
||||
c.PmcRangeRejectStatus = FocasStatusMapper.BadCommunicationError; // what an unlisted rc / EW_PARITY / EW_SOCKET maps to
|
||||
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
r.Select(s => s.Value).ShouldBe([(object)(sbyte)1, (sbyte)2]);
|
||||
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
|
||||
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
c.PmcRangeReads.Count.ShouldBe(1, "a deterministic rc refusal is memoised");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_transient_busy_refusal_is_not_memoised()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
var busy = true;
|
||||
c.PmcRangeRejectors.Add((_, _, _) => busy);
|
||||
c.PmcRangeRejectStatus = FocasStatusMapper.BadDeviceFailure; // EW_BUSY
|
||||
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
|
||||
|
||||
(await drv.ReadAsync(["A", "B"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
busy = false;
|
||||
c.PmcMemory[("R", 100)] = 5; c.PmcMemory[("R", 101)] = 6;
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.Count.ShouldBe(2, "the range is retried once the CNC is no longer busy");
|
||||
r.Select(s => s.Value).ShouldBe([(object)(sbyte)5, (sbyte)6]);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_down_device_costs_one_connect_attempt_per_batch_not_one_per_plan_and_tag()
|
||||
{
|
||||
var client = new FakeFocasClient { ThrowOnConnect = true };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
RawTags = FocasRawTags.Entries(new[]
|
||||
{
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte),
|
||||
Tag("C", "R900", FocasDataType.Byte), Tag("D", "R2000", FocasDataType.Byte),
|
||||
}),
|
||||
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = client.ConnectCount;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B", "C", "D"], CancellationToken.None);
|
||||
|
||||
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
|
||||
(client.ConnectCount - before).ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_transport_failure_fails_every_member_without_per_tag_retries()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
c.ThrowOnPmcRange = true;
|
||||
c.Values["R100"] = (sbyte)1; // would read Good if the driver wrongly fell back
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
|
||||
r.ShouldAllBe(s => s.Value == null);
|
||||
c.PmcRangeReads.Count.ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Result_order_follows_the_request_when_pmc_and_non_pmc_tags_are_mixed()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("P", "PARAM:6711", FocasDataType.Int32),
|
||||
Tag("A", "R100", FocasDataType.Byte),
|
||||
Tag("B", "R101", FocasDataType.Byte));
|
||||
c.Values["PARAM:6711"] = 1234;
|
||||
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
|
||||
|
||||
var r = await drv.ReadAsync(["B", "P", "A"], CancellationToken.None);
|
||||
|
||||
r[0].Value.ShouldBe((sbyte)4);
|
||||
r[1].Value.ShouldBe(1234);
|
||||
r[2].Value.ShouldBe((sbyte)3);
|
||||
c.PmcRangeReads.Count.ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_references_in_one_batch_each_get_their_value()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B", "A"], CancellationToken.None);
|
||||
|
||||
r.Select(s => s.Value).ShouldBe([(object)(sbyte)3, (sbyte)4, (sbyte)3]);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #257 — <c>cnc_statinfo</c> (Auto / Run / Motion / Mstb / Emergency / Alarm / Edit / TmMode)
|
||||
/// surfaces as a read-only <c>Status/</c> fixed-tree subtree, authorable as <c>FIXED:Status/Run</c>.
|
||||
/// The wire client already decoded the call (<c>FocasWireClient.ReadStatusAsync</c>) but nothing consumed it.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasStatusFlagsTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasDriver NewDriver(FakeFocasClient client, params FocasTagDefinition[] tags) =>
|
||||
new(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
RawTags = FocasRawTags.Entries(tags),
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(10),
|
||||
ProgramPollInterval = TimeSpan.FromMilliseconds(10),
|
||||
},
|
||||
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
|
||||
|
||||
private static async Task WaitAsync(Func<bool> predicate)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && !predicate()) await Task.Delay(15);
|
||||
predicate().ShouldBeTrue("condition never became true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_wire_status_block_maps_field_for_field()
|
||||
{
|
||||
var s = Wire.WireFocasClient.MapStatus(new Wire.WireStatus(Auto: 1, Run: 2, Motion: 3, Mstb: 4, Emergency: 5, Alarm: 6, Edit: 7, TmMode: 8));
|
||||
s.ShouldBe(new FocasStatus(1, 2, 3, 4, 5, 6, 7, 8));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "RESET")]
|
||||
[InlineData(1, "STOP")]
|
||||
[InlineData(2, "HOLD")]
|
||||
[InlineData(3, "START")]
|
||||
[InlineData(4, "MSTR")]
|
||||
[InlineData(9, "Run9")]
|
||||
public void Run_state_text(int run, string text) => FocasRunState.ToText(run).ShouldBe(text);
|
||||
|
||||
[Fact]
|
||||
public async Task Status_flags_read_good_through_the_fixed_tree_references()
|
||||
{
|
||||
var client = new FakeFocasClient { Status = new FocasStatus(Auto: 1, Run: 3, Motion: 1, Mstb: 2, Emergency: 0, Alarm: 1, Edit: 0, TmMode: 4) };
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
|
||||
|
||||
string[] fields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"];
|
||||
int[] expected = [1, 3, 1, 2, 0, 1, 0, 4];
|
||||
var read = await drv.ReadAsync([.. fields.Select(f => $"{Host}/Status/{f}")], CancellationToken.None);
|
||||
for (var i = 0; i < fields.Length; i++)
|
||||
{
|
||||
read[i].StatusCode.ShouldBe(FocasStatusMapper.Good, fields[i]);
|
||||
read[i].Value.ShouldBe(expected[i], fields[i]);
|
||||
}
|
||||
|
||||
var text = await drv.ReadAsync([$"{Host}/Status/RunText"], CancellationToken.None);
|
||||
text[0].Value.ShouldBe("START");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_authored_FIXED_Status_tag_reads_the_flag()
|
||||
{
|
||||
var client = new FakeFocasClient { Status = new FocasStatus(1, 3, 1, 0, 1, 0, 0, 0) };
|
||||
var drv = NewDriver(client,
|
||||
new FocasTagDefinition("EStop", Host, "FIXED:Status/Emergency", FocasDataType.Int32, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
|
||||
|
||||
var snap = (await drv.ReadAsync(["EStop"], CancellationToken.None))[0];
|
||||
snap.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snap.Value.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_updates_follow_the_machine()
|
||||
{
|
||||
var client = new FakeFocasClient { Status = new FocasStatus(1, 0, 0, 0, 0, 0, 0, 0) };
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
|
||||
|
||||
client.Status = new FocasStatus(1, 3, 1, 0, 0, 0, 0, 0);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus?.Run == 3);
|
||||
(await drv.ReadAsync([$"{Host}/Status/Run"], CancellationToken.None))[0].Value.ShouldBe(3);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cnc_that_refuses_statinfo_gets_no_Status_node_and_the_rest_of_the_tree_survives()
|
||||
{
|
||||
var client = new FakeFocasClient { ThrowOnStatus = true };
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "Status");
|
||||
builder.Variables.ShouldContain(v => v.Info.FullName == "FIXED:Identity/SeriesNumber");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discovery_emits_every_status_leaf_with_a_parseable_read_only_address()
|
||||
{
|
||||
var drv = NewDriver(new FakeFocasClient());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Status");
|
||||
var leaves = builder.Variables.Where(v => v.Info.FullName.StartsWith("FIXED:Status/", StringComparison.Ordinal)).ToList();
|
||||
leaves.Select(l => l.BrowseName).ShouldBe(
|
||||
["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode", "RunText"], ignoreOrder: true);
|
||||
foreach (var (_, info) in leaves)
|
||||
{
|
||||
FocasAddress.TryParse(info.FullName).ShouldNotBeNull();
|
||||
info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string name, DriverDataType type, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user