diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index ce0f95eaa..4e2ddef7b 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -317,6 +317,12 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa every subscribed value `BadNoCommunication` once per outage; the first delivery afterwards republishes all of them from the index. Health/`LastSuccessfulRead` are untouched by it (the stream failure already degraded), so the #527 watchdog still re-inits at 90 s. Timeouts/resets/DNS/pause stay watchdog-only — never widen the definite set to them. + **#852:** the same moment annotates every TOLD native condition quality-only `BadNoCommunication` (state/ack kept, + recorded in the told record; one already UNAVAILABLE-Bad is skipped), and recovery re-diffs ALL subscribed conditions + (`EmitConditionTransitions(null, resync: false)`) so unchanged ones get a quality-only Good. AdminUI field "Comms-loss + confirm (ms)" is ALWAYS written (a negative = off must never be dropped like a non-positive timing knob — that would + re-enable the 2000 default). Live gate: a plainly `docker stop`ped Agent drops out of Docker DNS ⇒ HostNotFound + (ambiguous) — use `docker-dev/docker-compose.mtconnect-gate.yml` (Agent in the adapter's netns ⇒ real refusal). - **MTConnect CONDITION alarms (#626):** a CONDITION raw tag is a native Part 9 condition only when its `TagConfig` carries the standard `alarm` object (opt-in — alarm tags leave the value subscription, so auto-promotion would break existing String tags). Normal→inactive, Warning→500, Fault→800; one condition per DataItem showing the most severe diff --git a/docker-dev/README.md b/docker-dev/README.md index 54b022f48..bd51c1c07 100644 --- a/docker-dev/README.md +++ b/docker-dev/README.md @@ -198,6 +198,25 @@ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Integration Tear down with `docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.questdb.yml rm -sf questdb` (add `docker volume rm otopcua-questdb-data` to drop the data). The `admin`/`quest` credentials are QuestDB's stock defaults, not secrets. +## MTConnect refused-connection gate overlay (#852) + +`docker-compose.mtconnect-gate.yml` is an **optional overlay** that adds a dedicated, throwaway MTConnect Agent +(`mtconnect-gate-agent`, `mtconnect/agent:2.7.0.12`) plus its SHDR adapter (`mtconnect-gate`), reusing the integration +fixture's `agent.cfg` / `Devices.xml` / `adapter.py` — so the #846/#852 fast path can be gated by stopping an Agent +**without** touching the shared fixture on `10.100.0.35`. Driver URI from inside the rig: `http://mtconnect-gate:5000`; +from the Mac: `http://127.0.0.1:5155`. The Agent runs in the adapter's network namespace so that stopping it yields a +genuine connection **refusal** (a plainly stopped container drops out of Docker DNS — an ambiguous DNS failure the fast +path deliberately ignores). + +```bash +docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect-gate.yml up -d --no-deps mtconnect-gate mtconnect-gate-agent +docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect-gate.yml stop mtconnect-gate-agent # refuse +docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect-gate.yml start mtconnect-gate-agent # recover +docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect-gate.yml rm -sf mtconnect-gate-agent mtconnect-gate +``` + +Full gate recipe (driver config, Client CLI, expectations): [`docs/drivers/MTConnect.md`](../docs/drivers/MTConnect.md#live-gate-refused-connection-fast-path-846852--recipe). + ## Tear down ```bash diff --git a/docker-dev/docker-compose.mtconnect-gate.yml b/docker-dev/docker-compose.mtconnect-gate.yml new file mode 100644 index 000000000..7060f049f --- /dev/null +++ b/docker-dev/docker-compose.mtconnect-gate.yml @@ -0,0 +1,66 @@ +# Dedicated, THROWAWAY MTConnect Agent for the #846/#852 refused-connection live gate. +# +# Adds two services to the docker-dev rig's own network so a driver deployed on +# central-1/central-2 can reach an Agent that the gate is free to stop and start — +# never the shared fixture on 10.100.0.35, which other work relies on. It reuses the +# integration fixture's agent.cfg / Devices.xml / adapter.py (read-only binds), so +# the device model is the familiar `OtFixtureCnc` one (fixture_x_pos, fixture_execution, +# the fixture_x_travel / fixture_logic CONDITIONs...). +# +# WHY THE AGENT SHARES THE ADAPTER'S NETWORK NAMESPACE (the whole point of this file): +# the fast path fires ONLY on SocketError.ConnectionRefused. Stopping an ordinary +# compose container also drops its name from Docker's embedded DNS, so the driver +# would see a DNS failure (HostNotFound) instead — an AMBIGUOUS failure that, by +# design, waits for the ~90 s watchdog, and the gate would "fail" for the wrong +# reason. Here the name the driver dials (`mtconnect-gate`) belongs to the ADAPTER +# container, which keeps running; the Agent joins its namespace +# (network_mode: service:...), so stopping the Agent leaves the name resolving to an +# IP with nothing on :5000 — the kernel answers RST, i.e. a genuine refusal. +# (Stopping BOTH services is the ambiguous control leg: DNS failure, watchdog only.) +# +# up: docker compose -p otopcua-dev -f docker-dev/docker-compose.yml \ +# -f docker-dev/docker-compose.mtconnect-gate.yml up -d mtconnect-gate mtconnect-gate-agent +# refuse: docker compose -p otopcua-dev -f docker-dev/docker-compose.yml \ +# -f docker-dev/docker-compose.mtconnect-gate.yml stop mtconnect-gate-agent +# recover: ... start mtconnect-gate-agent +# remove: ... rm -sf mtconnect-gate-agent mtconnect-gate +# +# Driver AgentUri (from inside the rig) -> http://mtconnect-gate:5000 +# From the Mac (curl /probe) -> http://127.0.0.1:5155 (macOS squats :5000 — AirPlay) +# +# Starting only these two services touches nothing else in the rig (no depends_on +# either way). Recipe: docs/drivers/MTConnect.md §"Live gate: refused-connection fast path". +services: + + # Network-namespace owner + SHDR data source. Must be started first and must stay + # up for the whole gate; restarting it orphans the Agent's namespace (then restart both). + mtconnect-gate: + image: python:3.13-alpine + restart: "no" + labels: + project: lmxopcua + purpose: mtconnect-refused-connection-gate + volumes: + - ../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py:/fixtures/adapter.py:ro + command: ["python", "-u", "/fixtures/adapter.py", "--host", "0.0.0.0", "--port", "7878"] + networks: + default: + # agent.cfg dials its adapter as `adapter`; inside the shared namespace that resolves here. + aliases: [adapter] + ports: + # Published on the namespace OWNER — the Agent cannot publish ports of its own in this mode. + - "5155:5000" + + mtconnect-gate-agent: + image: mtconnect/agent:2.7.0.12 + restart: "no" + labels: + project: lmxopcua + purpose: mtconnect-refused-connection-gate + network_mode: "service:mtconnect-gate" + depends_on: + mtconnect-gate: + condition: service_started + volumes: + - ../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg:/mtconnect/config/agent.cfg:ro + - ../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml:/mtconnect/config/Devices.xml:ro diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 8242ca237..bd4edcdae 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -184,7 +184,7 @@ Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto` | `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param | | `SampleCount` | `1000` | The Agent's `/sample?count=` query param. **Caps subscription throughput** at `SampleCount / SampleIntervalMs` — see [Subscribe](#subscribe--the-sample-pump) for the adaptive raise | | `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window | -| `CommsLossConfirmMs` | `2000` | #846: how long the Agent must keep **refusing** connections before every subscribed value goes `BadNoCommunication` (see [Comms loss](#comms-loss-refused-vs-ambiguous-846)). `0` = on the first refusal; negative = fast path off (watchdog only). JSON-only — the AdminUI form preserves it but has no field | +| `CommsLossConfirmMs` | `2000` | #846: how long the Agent must keep **refusing** connections before every subscribed value — and, since #852, every native CONDITION (quality only) — goes `BadNoCommunication` (see [Comms loss](#comms-loss-refused-vs-ambiguous-846)). `0` = on the first refusal; negative = fast path off (watchdog only). AdminUI: "Comms-loss confirm (ms)" in the driver modal's Reconnect section (#852; any integer is saved — a negative is never dropped, which would re-enable the 2000 default; ≥ 90000 shows an advisory, as the watchdog always fires first) | | `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) | | `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream | | `Write.Enabled` | `false` | The driver-side half of the write-back opt-in. An absent block means disabled — see [Write-back](#write-back--observation-injection-off-by-default) | @@ -744,16 +744,67 @@ the `/sample` pump now handles it itself: - **Then every subscribed value is published `BadNoCommunication`** (value null) **once per outage**, with one Warning. Health was already Degraded with `LastSuccessfulRead` frozen by the stream failure, so the watchdog still re-initializes at 90 s exactly as before — this only stops stale values reading Good in between. Reads already fall back to `/current` - on a degraded stream and fail on their own. Native CONDITIONS are untouched (the host annotates them on the - connectivity edge, as before). + on a degraded stream and fail on their own. +- **Native CONDITIONS too (#852):** at the same moment, every subscribed condition the server holds a statement for gets + a quality-only `BadNoCommunication` annotation — exactly what the host's connectivity edge does at the watchdog: Active + state, severity, message and acknowledgement untouched, no `/alerts` row, once per outage (a condition already Bad + because the Agent reported it UNAVAILABLE is skipped). Recorded in the driver's told record, so recovery lifts it. - **Recovery republishes everything:** the first delivered chunk (a heartbeat counts) or re-baseline after a marked outage republishes every subscribed value from the index — a resumed `/sample` carries only what changed, so an unchanged item - would otherwise stay Bad (the (value, status) recovery rule). + would otherwise stay Bad (the (value, status) recovery rule). The same delivery re-diffs **every** subscribed condition: + an unchanged one gets a quality-only Good (never a re-raise — that would un-acknowledge it); one the Agent still reports + UNAVAILABLE stays Bad; one that changed during the outage gets its ordinary Raise/Retrigger/Clear. - Consequence for an Agent **restart** that refuses for longer than the window (the #823 restart refused for ~1 s, inside it): values go Bad for the restart and come back on its first chunk / re-baseline. That is the honest answer — the Agent was down. -Tests: `MTConnectCommsLossTests` (incl. an ambiguous-failure falsification control and the confirmation window). +Tests: `MTConnectCommsLossTests` (incl. an ambiguous-failure falsification control and the confirmation window), +`MTConnectConditionCommsLossTests` (#852 — conditions, incl. the UNAVAILABLE and ambiguous-failure controls). + +### Live gate: refused-connection fast path (#846/#852) — recipe + +**Not yet run.** Uses a dedicated, throwaway Agent on the local docker-dev rig — never the shared fixture on +`10.100.0.35`. `docker-dev/docker-compose.mtconnect-gate.yml` adds two services to the rig's network: `mtconnect-gate` +(the fixture's SHDR adapter, which also owns the network namespace and publishes host `:5155`) and +`mtconnect-gate-agent` (`mtconnect/agent:2.7.0.12` with the fixture's `agent.cfg`/`Devices.xml`, running **inside** +`mtconnect-gate`'s namespace). + +⚠️ **Why not just `docker stop` an ordinary Agent container:** a stopped compose container also drops out of Docker's +embedded DNS, so the driver sees a DNS failure — ambiguous by design, watchdog only — and the gate fails for the wrong +reason. With the shared namespace the name `mtconnect-gate` keeps resolving while the Agent is down, and the connect +is genuinely refused (smoke-checked: Agent stopped ⇒ `Connection refused` from inside the network; both stopped ⇒ +`bad address`). + +```bash +dcg() { docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect-gate.yml "$@"; } # bash + zsh +dcg up -d --no-deps mtconnect-gate mtconnect-gate-agent +curl -s http://127.0.0.1:5155/probe | head -3 # Agent answering (Mac side) +``` + +1. **Driver.** In AdminUI `/raw` (http://localhost:9200, MAIN cluster) add an MTConnect driver, Agent base URI + `http://mtconnect-gate:5000`, **Comms-loss confirm (ms) = 2000** (the default — the new field in the Reconnect + section), one Device. Browse-commit `fixture_x_pos` (Double) and `fixture_execution` (String), and the CONDITION + `fixture_x_travel` with an `alarm` object (optional, for the #852 leg). Optionally reference the two value tags into + a UNS equipment. Deploy. +2. **Watch.** Client CLI against central-1 (`opc.tcp://localhost:4840`): + `dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=" -i 250` + (and a second one on the UNS NodeId, `ns=3;s=///`, if referenced). Values Good and moving. + For the condition leg, `alarms -u opc.tcp://localhost:4840 --refresh` in another shell (or read the condition's + `Quality` child found with `browse`). +3. **Refuse.** Note the wall clock, then `dcg stop mtconnect-gate-agent`. + **Expect:** both NodeIds `BadNoCommunication` (value null) **within ~2–3 s** (2000 ms confirmation + the first + reconnect attempt), not ~90–118 s; host log `has refused every connection for … ms (definite loss); marking N + subscribed value(s) BadNoCommunication`, and with a condition authored `annotated 1 native condition(s) + BadNoCommunication`; the condition stays in its Active/acked state. `/hosts` shows the driver Degraded. If you wait + past 90 s the #527 watchdog also re-initializes the driver as before — so run step 4 within ~60 s to keep the legs separate. +4. **Recover.** `dcg start mtconnect-gate-agent`. **Expect:** values Good again within a few seconds of the + Agent answering (first chunk/heartbeat or re-baseline republishes every value, unchanged ones included); the + condition's quality Good with no re-raise / no new `/alerts` row. The restarted Agent has a new `instanceId`, so a + **RE-BROWSE** chip on `/hosts` is expected and unrelated. +5. **Control (optional):** `dcg stop mtconnect-gate-agent mtconnect-gate` — now the name no longer + resolves (ambiguous): values must stay Good-but-stale until the ~90 s watchdog. Start `mtconnect-gate` FIRST, then the + agent. +6. **Clean up:** remove the driver from `/raw` and deploy, then `dcg rm -sf mtconnect-gate-agent mtconnect-gate`. ### End-to-end gate through a deployed server (#823, 2026-11-11, docker-dev) @@ -766,7 +817,7 @@ NodeIds moved together in every leg. Fixture containers were restored healthy af |---|---| | Adapter stop | Both NodeIds `BadNoCommunication` within 1 s of the adapter dying (Agent marks items UNAVAILABLE); driver stayed Healthy. Adapter start ⇒ Good 1–2 s later. | | Agent restart (`docker restart`, ~12 s) | Stream lost and reconnected ~1 s; the stale cursor was refused `OUT_OF_RANGE`, the re-baseline saw the new `instanceId`, **rediscovery raised** — `/hosts` shows the **RE-BROWSE** chip. Values stayed Good (outage shorter than the comms-loss window). | -| Agent stopped ~2 min | Values held Good-but-stale; **Bad at 118 s** (comms-loss watchdog, connection refused). Agent start ⇒ re-init on the 10 s retry, Good ~8 s after the Agent was back. **Since #846** a refused connection marks the values `BadNoCommunication` after `CommsLossConfirmMs` (2 s) instead — unit-tested, not yet re-gated live. | +| Agent stopped ~2 min | Values held Good-but-stale; **Bad at 118 s** (comms-loss watchdog, connection refused). Agent start ⇒ re-init on the 10 s retry, Good ~8 s after the Agent was back. **Since #846** a refused connection marks the values `BadNoCommunication` after `CommsLossConfirmMs` (2 s) instead — unit-tested, not yet re-gated live (recipe: [Live gate: refused-connection fast path](#live-gate-refused-connection-fast-path-846852--recipe)). | | Agent paused (`docker pause`) | Stream idle watchdog at 24 s (2 × 10000 + 5000); **Bad at 94 s** (watchdog); unpause ⇒ Good within ~3 s. | | Genuine buffer overrun | A stalling TCP proxy froze the driver's transport while 32 000 observations were injected (`AllowPut`) — the Agent's `firstSequence` passed the driver's cursor (75 856 → 77 083). On release the resume was refused `OUT_OF_RANGE`, the driver re-baselined from `/current` within 1 s and resumed; values Good, no restart misdiagnosed. A raised-count stream (count 16, interval 5 s) was **not** lapped by a 266 obs/s burst: chunks are sent back-to-back while data is pending, so `interval` does not cap throughput. | | `SampleCount` vs `bufferSize` (#825) | Test Connect rendered the amber "OK with warning" chip and a deploy logged the Warning (`/hosts` shows `agentBufferSize`). The same gate measured the Agent's real edge — `count = 16384` streams, `16385` is `OUT_OF_RANGE` — so the first build's "at or above" threshold was corrected to "above" (pinned live by `Test_Connect_warns_when_SampleCount_exceeds_the_live_agents_buffer_size`). | diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs index d1f463de4..fa51f6740 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectConditionAlarmMapper.cs @@ -92,7 +92,7 @@ internal static class MTConnectConditionAlarmMapper // State untouched; only the quality moves (or is re-asserted on a resync). var next = new MTConnectToldCondition(statement, QualityBad: true); return !bad || resync - ? (QualityOnly(statement ?? current, current, conditionId, dataItemId, handle, BadNoCommunication), next) + ? (QualityOnly(statement ?? current, current.TimestampUtc, conditionId, dataItemId, handle, BadNoCommunication), next) : (null, next); } @@ -103,7 +103,7 @@ internal static class MTConnectConditionAlarmMapper // Unchanged statement: keep the FIRST text the server was shown. Only a Bad quality is lifted. var kept = told2 with { Statement = statement }; return bad - ? (QualityOnly(statement, current, conditionId, dataItemId, handle, Good), kept) + ? (QualityOnly(statement, current.TimestampUtc, conditionId, dataItemId, handle, Good), kept) : (null, kept); } @@ -123,10 +123,41 @@ internal static class MTConnectConditionAlarmMapper // Normal after nothing / after Normal: nothing to say about the state — the host's connectivity // annotation confirms a materialised condition Good — except lifting a Bad this driver put there. return bad - ? (QualityOnly(current, current, conditionId, dataItemId, handle, Good), told2) + ? (QualityOnly(current, current.TimestampUtc, conditionId, dataItemId, handle, Good), told2) : (null, told2); } + /// + /// lmxopcua#852 — the quality-only BadNoCommunication annotation for a condition whose Agent has been + /// confirmed gone (the #846 refused-connection fast path), or null when the server already holds a Bad + /// quality for it (or was never told a statement). The told STATEMENT is kept, so the recovery diff that + /// follows sees "same statement, quality Bad" and lifts the quality without re-raising — an acked alarm stays + /// acked across the outage, exactly as for the host's own connectivity annotation. + /// + /// What the server was last told for this condition. + /// The condition's ConditionId (RawPath). + /// The Agent DataItem id. + /// The alarm subscription the event belongs to. + /// When the loss was confirmed. + /// The annotation to raise (or null) and what the server has been told afterwards. + public static (AlarmEventArgs? Event, MTConnectToldCondition Told) CommsLost( + MTConnectToldCondition told, + string conditionId, + string dataItemId, + IAlarmSubscriptionHandle handle, + DateTime timestampUtc) + { + ArgumentNullException.ThrowIfNull(told); + + if (told.QualityBad || told.Statement is not { } statement) + { + return (null, told); + } + + return (QualityOnly(statement, timestampUtc, conditionId, dataItemId, handle, BadNoCommunication), + told with { QualityBad = true }); + } + /// The Part 9 severity of an active level. /// An active level. /// The Fault or Warning severity. @@ -163,7 +194,7 @@ internal static class MTConnectConditionAlarmMapper /// private static AlarmEventArgs QualityOnly( MTConnectConditionState shown, - MTConnectConditionState current, + DateTime sourceTimestampUtc, string conditionId, string dataItemId, IAlarmSubscriptionHandle handle, @@ -178,7 +209,7 @@ internal static class MTConnectConditionAlarmMapper AlarmType: shown.Detail.ConditionType ?? "CONDITION", Message: MessageOf(shown), Severity: severity >= FaultSeverity ? AlarmSeverity.High : AlarmSeverity.Medium, - SourceTimestampUtc: current.TimestampUtc, + SourceTimestampUtc: sourceTimestampUtc, Kind: AlarmTransitionKind.Unspecified, OpcUaSeverity: severity, QualityStatusCode: quality); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs index 57c8fb589..f7c9f77ee 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.Alarms.cs @@ -36,10 +36,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// or the lifecycle semaphore, and never takes either. /// /// -/// Comms loss emits nothing here, matching every other native source: a driver that cannot reach -/// its Agent has nothing new to say, and the host annotates every condition it owns -/// BadNoCommunication on the connectivity edge, keeping each one's Active state. On recovery -/// the re-baselined /current is diffed like any other document. +/// Comms loss. An AMBIGUOUS loss emits nothing here: the host annotates every condition it owns +/// BadNoCommunication on the connectivity edge (the ~90 s watchdog), keeping each one's Active +/// state. A CONFIRMED refused-connection loss (#846/#852, MarkConditionsCommsLost) annotates +/// every told condition the same way — quality-only, state and ack untouched — at the same moment the data +/// values go Bad, and the recovery re-diffs every subscribed condition so the unchanged ones get their +/// quality lifted (a resumed /sample carries only what changed). /// /// public sealed partial class MTConnectDriver : IAlarmSource @@ -201,20 +203,67 @@ public sealed partial class MTConnectDriver : IAlarmSource if (evt is null) continue; - foreach (var registration in handlers.GetInvocationList()) - { - try - { - ((EventHandler)registration).Invoke(this, evt); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "MTConnect driver {DriverInstanceId} caught a fault from an alarm subscriber for {ConditionId}; delivery continues.", - _driverInstanceId, conditionId); - } - } + RaiseAlarmEvent(handlers, evt, conditionId); + } + } + } + + /// + /// lmxopcua#852 — the alarm half of the #846 refused-connection fast path: annotates every subscribed + /// condition the server holds a Good-quality statement for BadNoCommunication (quality-only — no state, + /// severity, message or ack change; no /alerts row) and records that it did, so + /// lifts it on recovery. Once per outage per condition: a condition + /// already annotated Bad (by this or by an Agent UNAVAILABLE) is skipped. Like every other emit, it records + /// nothing when no handler is attached — the host has then detached because it is reconnecting, and its own + /// connectivity annotation covers the conditions. + /// + /// How many conditions were annotated. + private int MarkConditionsCommsLost() + { + var subscription = Volatile.Read(ref _alarmSubscription); + if (subscription is not { References.Count: > 0 }) return 0; + + var annotated = 0; + lock (_alarmLock) + { + var handlers = OnAlarmEvent; + if (handlers is null) return 0; + + var binding = Volatile.Read(ref _binding); + var now = DateTime.UtcNow; + + foreach (var conditionId in subscription.References) + { + if (!_deliveredConditions.TryGetValue(conditionId, out var told)) continue; + + var (evt, nowTold) = MTConnectConditionAlarmMapper.CommsLost( + told, conditionId, binding.ToDataItemId(conditionId), subscription.Handle, now); + if (evt is null) continue; + + _deliveredConditions[conditionId] = nowTold; + annotated++; + RaiseAlarmEvent(handlers, evt, conditionId); + } + } + + return annotated; + } + + /// Invokes every alarm handler; a faulting one is logged and the next still runs. Caller holds . + private void RaiseAlarmEvent(EventHandler handlers, AlarmEventArgs evt, string conditionId) + { + foreach (var registration in handlers.GetInvocationList()) + { + try + { + ((EventHandler)registration).Invoke(this, evt); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "MTConnect driver {DriverInstanceId} caught a fault from an alarm subscriber for {ConditionId}; delivery continues.", + _driverInstanceId, conditionId); } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.CommsLoss.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.CommsLoss.cs index 7e1b0578c..0f051c0ad 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.CommsLoss.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.CommsLoss.cs @@ -30,12 +30,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// is already Degraded with LastSuccessfulRead frozen (the stream failure degraded it), so the watchdog /// still re-initializes at 90 s exactly as before — this path only stops serving stale values as Good in the /// meantime. Reads already fall back to /current on a degraded stream and fail Bad on their own. -/// Native CONDITIONS are not touched: the host annotates them on the connectivity edge, as before. +/// Native CONDITIONS (#852) get what the host's connectivity edge gives them: a quality-only +/// BadNoCommunication annotation, Active state and ack untouched (MarkConditionsCommsLost). /// /// /// Recovery republishes everything. The first delivered chunk or re-baseline after a marked outage /// republishes every subscribed value from the index — a resumed /sample only carries what CHANGED, so /// an unchanged data item would otherwise stay Bad until it next moved (the (value, status) recovery rule). +/// Conditions are re-diffed in full for the same reason, which lifts each annotated one's quality. /// /// public sealed partial class MTConnectDriver @@ -147,6 +149,16 @@ public sealed partial class MTConnectDriver "MTConnect driver {DriverInstanceId}: {AgentUri} has refused every connection for {RefusedMs} ms (definite loss); marking {Count} subscribed value(s) BadNoCommunication until it answers again.", _driverInstanceId, AgentDisplay(options), (long)refusedFor.TotalMilliseconds, references.Count); FanOutStatus(references, StatusBadNoCommunication); + + // #852 — and the native conditions, quality-only (state + ack kept), as the host's connectivity edge does. + var conditions = MarkConditionsCommsLost(); + if (conditions > 0) + { + _logger.LogWarning( + "MTConnect driver {DriverInstanceId}: annotated {Count} native condition(s) BadNoCommunication (quality only; state and acknowledgement kept).", + _driverInstanceId, conditions); + } + return true; } @@ -170,6 +182,10 @@ public sealed partial class MTConnectDriver "MTConnect driver {DriverInstanceId}: the Agent answered again; republishing every subscribed value.", _driverInstanceId); FanOut(SubscribedReferences(), ObservationIndex); + + // #852 — every subscribed condition, not just the ones this delivery touched: an unchanged condition the + // outage annotated Bad gets a quality-only Good; one still UNAVAILABLE at the Agent stays Bad. + EmitConditionTransitions(touchedDataItemIds: null, resync: false); } /// Publishes a value-less to every handle subscribing each reference. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor index 9b2d95d68..96a052f95 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor @@ -107,6 +107,16 @@
Default 2.0 (doubles each retry).
+
+ + +
+ Default 2000. How long the Agent must keep refusing connections before every + subscribed value and native condition goes BadNoCommunication. + 0 = on the first refusal; negative = fast path off (only the ~90 s + comms-loss watchdog). Timeouts and a paused Agent always wait for the watchdog. +
+
@@ -299,6 +309,12 @@ /// Multiplier applied to the reconnect backoff each retry. public double ReconnectBackoffMultiplier { get; set; } = 2.0; /// + /// #846/#852 — how long refused connections must persist before values and native conditions go + /// BadNoCommunication. Every integer is meaningful to the driver: 0 = the first refusal, + /// negative = the fast path is off (watchdog only). So, unlike the timing knobs, it is always written. + /// + public int CommsLossConfirmMs { get; set; } = DefaultCommsLossConfirmMs; + /// /// Whether the driver's half of the write-back gate is armed. Default false — the /// driver shipped read-only, and the Agent's own AllowPut is an independent second /// gate that this cannot arm. @@ -329,6 +345,7 @@ ReconnectMinBackoffMs = dto.Reconnect?.MinBackoffMs ?? defaults.ReconnectMinBackoffMs, ReconnectMaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? defaults.ReconnectMaxBackoffMs, ReconnectBackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? defaults.ReconnectBackoffMultiplier, + CommsLossConfirmMs = dto.CommsLossConfirmMs ?? defaults.CommsLossConfirmMs, WriteEnabled = dto.Write?.Enabled ?? defaults.WriteEnabled, }; } @@ -344,6 +361,9 @@ SampleIntervalMs = Positive(SampleIntervalMs), SampleCount = Positive(SampleCount), HeartbeatMs = Positive(HeartbeatMs), + // Written as-is, negative included: a negative is "off", not an unusable value to drop — dropping it + // would silently re-enable the fast path at the driver's 2000 ms default. + CommsLossConfirmMs = CommsLossConfirmMs, Probe = new MTConnectProbeConfigDto { Enabled = ProbeEnabled, @@ -383,11 +403,24 @@ if (ProbeEnabled && ProbeTimeoutMs <= 0) { bad.Add("Probe timeout"); } if (ReconnectMaxBackoffMs <= 0) { bad.Add("Max backoff"); } - return bad.Count == 0 - ? null - : $"Must be greater than zero: {string.Join(", ", bad)}. A non-positive value is dropped on save and the driver's default applies."; + if (bad.Count > 0) + { + return $"Must be greater than zero: {string.Join(", ", bad)}. A non-positive value is dropped on save and the driver's default applies."; + } + + // Advisory only (the value is still saved): the runtime's comms-loss watchdog re-initializes a driver + // whose reads have been frozen for 90 s, so a window that long can never fire first. + return CommsLossConfirmMs >= CommsLossWatchdogMs + ? $"Comms-loss confirm of {CommsLossConfirmMs} ms is at or beyond the ~{CommsLossWatchdogMs / 1000} s comms-loss watchdog, so the fast path never fires before it. Use a negative value to turn the fast path off explicitly." + : null; } + /// The driver's own default for (MTConnectDriverOptions). + public const int DefaultCommsLossConfirmMs = 2_000; + + /// The runtime's hardcoded comms-loss watchdog threshold (DefaultCommsLossTimeout, 90 s). + public const int CommsLossWatchdogMs = 90_000; + private static int? Positive(int value) => value > 0 ? value : null; private static string? Trimmed(string? value) @@ -418,6 +451,8 @@ public int? SampleCount { get; init; } /// The /sample heartbeat query parameter in ms. public int? HeartbeatMs { get; init; } + /// Refused-connection confirmation window in ms (#846; 0 = first refusal, negative = off). + public int? CommsLossConfirmMs { get; init; } /// Background connectivity-probe knobs. public MTConnectProbeConfigDto? Probe { get; init; } /// Reconnect backoff knobs. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionCommsLossTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionCommsLossTests.cs new file mode 100644 index 000000000..37dc5e1f2 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectConditionCommsLossTests.cs @@ -0,0 +1,233 @@ +using System.Collections.Concurrent; +using System.Net.Http; +using System.Net.Sockets; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// lmxopcua#852 — the #846 refused-connection fast path reaches native CONDITIONS too: once the refusals are +/// confirmed every told condition gets a quality-only BadNoCommunication (state and ack untouched, no +/// Raise/Clear), once per outage; the first delivery afterwards lifts it with a quality-only Good — even when that +/// delivery does not mention the condition. Ambiguous failures stay with the watchdog. +/// +/// Every absence is followed by an event that MUST arrive, asserted to be the next one. +public sealed class MTConnectConditionCommsLossTests +{ + private const string ConditionRawPath = "Plant/MT/dev1/SystemCond"; + private const string ConditionId = "dev1_system_cond"; + private const long Instance = 1655000000L; + private const uint BadNoCommunication = 0x80310000u; + + private static readonly DateTime ObservedAt = new(2026, 11, 11, 12, 0, 0, DateTimeKind.Utc); + private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static Exception Refused() => + new HttpRequestException("Connection refused (fixture-agent:5000)", new SocketException((int)SocketError.ConnectionRefused)); + + private static MTConnectStreamsResult Chunk(long first, params MTConnectObservation[] observations) => + new(Instance, first + 1, first, observations); + + private static MTConnectObservation Cond(string state, string? nativeCode = null, string? text = null) => + new(ConditionId, state, ObservedAt, Condition: new MTConnectConditionDetail(text, nativeCode, null, null, "SYSTEM")); + + private static async Task<(MTConnectDriver Driver, CannedAgentClient Agent, ConcurrentQueue Events)> + SubscribedAsync() + { + var agent = CannedAgentClient.FromFixtures(); + var driver = new MTConnectDriver( + new MTConnectDriverOptions + { + AgentUri = "http://fixture-agent:5000", + RequestTimeoutMs = 5000, + CommsLossConfirmMs = 0, + Reconnect = new MTConnectReconnectOptions { MinBackoffMs = 0, MaxBackoffMs = 200 }, + RawTags = [new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1")], + }, + "mt-852", + _ => agent); + await driver.InitializeAsync("{}", Ct); + + var events = new ConcurrentQueue(); + driver.OnAlarmEvent += (_, e) => events.Enqueue(e); + await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct); + await agent.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct); + return (driver, agent, events); + } + + private static async Task WaitUntil(Func condition, int timeoutMs = 10_000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached"); + await Task.Delay(20, Ct); + } + } + + private static async Task RecoverAsync(CannedAgentClient agent) + { + agent.SampleFailure = null; + var calls = agent.SampleCallCount; + await agent.WaitForSampleCallsAsync(calls + 1).WaitAsync(Watchdog, Ct); + } + + [Fact] + public async Task A_refused_agent_annotates_an_active_condition_bad_once_and_the_first_delivery_lifts_it() + { + var (driver, agent, events) = await SubscribedAsync(); + try + { + await agent.PumpAsync(Chunk(108, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct); + events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise); + + agent.SampleFailure = Refused(); + agent.EndStream(); + await WaitUntil(() => events.Count == 2); + var bad = events.Last(); + bad.IsQualityOnly.ShouldBeTrue("the Active state and the ack must not move — quality only"); + bad.QualityStatusCode.ShouldBe(BadNoCommunication); + bad.ConditionId.ShouldBe(ConditionRawPath); + bad.OpcUaSeverity.ShouldBe(800); + driver.CommsLossMarked.ShouldBeTrue(); + + await Task.Delay(400, Ct); // more refusals: annotated ONCE per outage + events.Count.ShouldBe(2); + + // The Agent is back; an observation-free heartbeat does not mention the condition, and must still lift it. + await RecoverAsync(agent); + await agent.PumpAsync(Chunk(109)).WaitAsync(Watchdog, Ct); + await WaitUntil(() => events.Count == 3); + var good = events.Last(); + good.IsQualityOnly.ShouldBeTrue("an unchanged Fault is not re-raised (that would un-acknowledge it)"); + good.QualityStatusCode.ShouldBe(0u); + + // Positive control for "nothing else was emitted": the next real transition is the next event. + await agent.PumpAsync(Chunk(110, Cond("Normal"))).WaitAsync(Watchdog, Ct); + await WaitUntil(() => events.Count == 4); + events.Last().Kind.ShouldBe(AlarmTransitionKind.Clear); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + [Fact] + public async Task An_inactive_told_condition_is_annotated_too() + { + var (driver, agent, events) = await SubscribedAsync(); + try + { + // Raise + Clear: the server holds a told Normal statement for the condition. + await agent.PumpAsync(Chunk(108, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); + await agent.PumpAsync(Chunk(109, Cond("Normal"))).WaitAsync(Watchdog, Ct); + events.Count.ShouldBe(2); + + agent.SampleFailure = Refused(); + agent.EndStream(); + await WaitUntil(() => events.Count == 3); + events.Last().IsQualityOnly.ShouldBeTrue(); + events.Last().QualityStatusCode.ShouldBe(BadNoCommunication); + + await RecoverAsync(agent); + await agent.PumpAsync(Chunk(110)).WaitAsync(Watchdog, Ct); + await WaitUntil(() => events.Count == 4); + events.Last().IsQualityOnly.ShouldBeTrue(); + events.Last().QualityStatusCode.ShouldBe(0u); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + [Fact] + public async Task A_condition_already_unavailable_is_not_re_annotated_and_stays_bad_on_recovery() + { + var (driver, agent, events) = await SubscribedAsync(); + try + { + await agent.PumpAsync(Chunk(108, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); + await agent.PumpAsync(Chunk(109, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct); + events.Count.ShouldBe(2); + events.Last().QualityStatusCode.ShouldBe(BadNoCommunication); + + agent.SampleFailure = Refused(); + agent.EndStream(); + await WaitUntil(() => driver.CommsLossMarked); + + // The Agent answers, but the adapter is still UNAVAILABLE: no Good may be told. + await RecoverAsync(agent); + await agent.PumpAsync(Chunk(110)).WaitAsync(Watchdog, Ct); + await WaitUntil(() => !driver.CommsLossMarked); + + // Positive control: the adapter returns with the same Fault — quality lifted, NOT re-raised — and that + // is the very next event after the UNAVAILABLE annotation. + await agent.PumpAsync(Chunk(111, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); + await WaitUntil(() => events.Count >= 3); + events.Count.ShouldBe(3); + events.Last().IsQualityOnly.ShouldBeTrue(); + events.Last().QualityStatusCode.ShouldBe(0u); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + [Fact] + public async Task An_ambiguous_failure_annotates_no_condition() + { + var (driver, agent, events) = await SubscribedAsync(); + try + { + await agent.PumpAsync(Chunk(108, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct); + events.Count.ShouldBe(1); + + agent.SampleFailure = new TimeoutException("the Agent did not answer /sample in time"); + var calls = agent.SampleCallCount; + agent.EndStream(); + await agent.WaitForSampleCallsAsync(calls + 3).WaitAsync(Watchdog, Ct); + driver.CommsLossMarked.ShouldBeFalse(); + + // Positive control: the next event is the real Clear, not a quality annotation. + await RecoverAsync(agent); + await agent.PumpAsync(Chunk(109, Cond("Normal"))).WaitAsync(Watchdog, Ct); + await WaitUntil(() => events.Count >= 2); + events.Count.ShouldBe(2); + events.Last().Kind.ShouldBe(AlarmTransitionKind.Clear); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + [Fact] + public void CommsLost_keeps_the_told_statement_and_says_nothing_twice() + { + var handle = new MTConnectAlarmSubscriptionHandle(1); + var warning = new MTConnectConditionState( + MTConnectConditionLevel.Warning, new MTConnectConditionDetail("coolant", "W1", null, null, "SYSTEM"), ObservedAt, 1); + var told = new MTConnectToldCondition(warning, QualityBad: false); + var at = ObservedAt.AddMinutes(5); + + var (evt, after) = MTConnectConditionAlarmMapper.CommsLost(told, ConditionRawPath, ConditionId, handle, at); + + evt.ShouldNotBeNull(); + evt!.IsQualityOnly.ShouldBeTrue(); + evt.QualityStatusCode.ShouldBe(BadNoCommunication); + evt.SourceTimestampUtc.ShouldBe(at); + evt.OpcUaSeverity.ShouldBe(500); + after.ShouldBe(told with { QualityBad = true }); + + MTConnectConditionAlarmMapper.CommsLost(after, ConditionRawPath, ConditionId, handle, at).Event.ShouldBeNull(); + MTConnectConditionAlarmMapper.CommsLost( + new MTConnectToldCondition(null, QualityBad: true), ConditionRawPath, ConditionId, handle, at).Event.ShouldBeNull(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs index 97b5ab5bb..c8b35263d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs @@ -394,7 +394,7 @@ public sealed class MTConnectFactoryTests public void The_document_the_AdminUI_form_emits_arms_write_back() { const string formEmitted = - """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":true}}"""; + """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"commsLossConfirmMs":2000,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":true}}"""; var options = MTConnectDriver.ParseOptions("mt1", formEmitted); @@ -407,8 +407,29 @@ public sealed class MTConnectFactoryTests "mt1", formEmitted.Replace("""enabled":true}""", """enabled":false}""", StringComparison.Ordinal)) .Write.Enabled.ShouldBeFalse(); + options.CommsLossConfirmMs.ShouldBe(2000); } + /// + /// lmxopcua#852: the AdminUI form's commsLossConfirmMs field binds through this parse as a plain + /// integer in every meaningful state — default, 0 (first refusal) and negative (fast path off). The literals are + /// the form's own output (byte-pinned on the form side by + /// MTConnectDriverFormModelTests.The_comms_loss_documents_are_the_literals_the_driver_test_parses); an + /// absent key keeps the driver default. + /// + [Theory] + [InlineData(CommsLossOffDocument, -1)] + [InlineData(CommsLossImmediateDocument, 0)] + [InlineData("""{"agentUri":"http://agent:5000"}""", 2000)] + public void The_comms_loss_window_the_AdminUI_form_emits_binds(string formEmitted, int expected) => + MTConnectDriver.ParseOptions("mt1", formEmitted).CommsLossConfirmMs.ShouldBe(expected); + + private const string CommsLossOffDocument = + """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"commsLossConfirmMs":-1,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":false}}"""; + + private const string CommsLossImmediateDocument = + """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"commsLossConfirmMs":0,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":false}}"""; + /// /// A malformed write block fails exactly as its siblings do — the whole /// document is one JsonSerializer.Deserialize, so a non-boolean enabled faults diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs index 0976b3af5..ccf91f194 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs @@ -200,6 +200,75 @@ public sealed class MTConnectDriverFormModelTests form.WriteEnabled.ShouldBeTrue(); } + // ---- comms-loss confirmation window (#846/#852) -------------------------------------------- + + [Fact] + public void Comms_loss_window_defaults_to_the_drivers_2000_and_an_absent_key_loads_as_it() + { + new MTConnectDriverForm.MTConnectFormModel().CommsLossConfirmMs.ShouldBe(2000); + MTConnectDriverForm.MTConnectFormModel.FromJson("""{"agentUri":"http://a:5000"}""") + .CommsLossConfirmMs.ShouldBe(2000); + } + + [Theory] + [InlineData(-1)] // fast path off — must NOT be dropped like a non-positive timing knob, or it re-enables at 2000 + [InlineData(0)] // mark on the first refusal + [InlineData(5000)] + public void Every_comms_loss_window_is_written_as_a_number_and_reloads(int value) + { + var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000", CommsLossConfirmMs = value }); + + // A JSON NUMBER (the driver's DTO is int?) — the class of bug where a form emitted a shape the factory DTO + // could not bind. + o["commsLossConfirmMs"]!.GetValueKind().ShouldBe(JsonValueKind.Number); + o["commsLossConfirmMs"]!.GetValue().ShouldBe(value); + MTConnectDriverForm.MTConnectFormModel.FromJson(o.ToJsonString()).CommsLossConfirmMs.ShouldBe(value); + } + + [Fact] + public void A_hand_authored_comms_loss_window_loads_from_pascal_case_and_survives_an_unrelated_edit() + { + // The pre-#852 JSON-only path (docs/drivers/MTConnect.md shows the PascalCase spelling). + const string existing = """{"AgentUri":"http://a:5000","CommsLossConfirmMs":-5,"tags":[{"fullName":"x1"}]}"""; + + var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing); + form.CommsLossConfirmMs.ShouldBe(-5); + form.SampleCount = 250; + + var o = Emit(form, existing); + o["commsLossConfirmMs"]!.GetValue().ShouldBe(-5); + o["tags"]!.AsArray().Count.ShouldBe(1); + } + + [Fact] + public void Validate_accepts_negative_and_zero_windows_and_warns_at_the_watchdog() + { + new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://a:5000", CommsLossConfirmMs = -1 }.Validate().ShouldBeNull(); + new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://a:5000", CommsLossConfirmMs = 0 }.Validate().ShouldBeNull(); + new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://a:5000", CommsLossConfirmMs = 89_999 }.Validate().ShouldBeNull(); + new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://a:5000", CommsLossConfirmMs = 90_000 } + .Validate().ShouldNotBeNull().ShouldContain("watchdog"); + } + + [Theory] + [InlineData(-1, CommsLossOffDocument)] + [InlineData(0, CommsLossImmediateDocument)] + public void The_comms_loss_documents_are_the_literals_the_driver_test_parses(int value, string expected) + { + // Mirrored in MTConnectFactoryTests.The_comms_loss_window_the_AdminUI_form_emits_binds, which runs these exact + // bytes through MTConnectDriver.ParseOptions — the driver's real options DTO. + MTConnectDriverForm.BuildConfigJson( + new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000", CommsLossConfirmMs = value }, + null) + .ShouldBe(expected); + } + + private const string CommsLossOffDocument = + """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"commsLossConfirmMs":-1,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":false}}"""; + + private const string CommsLossImmediateDocument = + """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"commsLossConfirmMs":0,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":false}}"""; + [Fact] public void The_write_enabled_document_is_the_literal_the_driver_test_parses() { @@ -215,7 +284,7 @@ public sealed class MTConnectDriverFormModelTests /// The form's output for a write-enabled instance, mirrored in the driver test suite. private const string WriteEnabledDocument = - """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":true}}"""; + """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"commsLossConfirmMs":2000,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":true}}"""; // ---- Validate ------------------------------------------------------------------------------ @@ -263,6 +332,7 @@ public sealed class MTConnectDriverFormModelTests ReconnectMinBackoffMs = 100, ReconnectMaxBackoffMs = 60000, ReconnectBackoffMultiplier = 1.5, + CommsLossConfirmMs = -1, }; var json = MTConnectDriverForm.BuildConfigJson(original, null); @@ -280,6 +350,7 @@ public sealed class MTConnectDriverFormModelTests back.ReconnectMinBackoffMs.ShouldBe(100); back.ReconnectMaxBackoffMs.ShouldBe(60000); back.ReconnectBackoffMultiplier.ShouldBe(1.5); + back.CommsLossConfirmMs.ShouldBe(-1); } [Fact] @@ -373,6 +444,7 @@ public sealed class MTConnectDriverFormModelTests dto.SampleIntervalMs.ShouldBe(1000); dto.SampleCount.ShouldBe(1000); dto.HeartbeatMs.ShouldBe(10000); + dto.CommsLossConfirmMs.ShouldBe(2000); dto.Probe.ShouldNotBeNull(); dto.Probe!.Enabled.ShouldBe(true); dto.Probe.IntervalMs.ShouldBe(5000); @@ -393,6 +465,7 @@ public sealed class MTConnectDriverFormModelTests public int? SampleIntervalMs { get; init; } public int? SampleCount { get; init; } public int? HeartbeatMs { get; init; } + public int? CommsLossConfirmMs { get; init; } public WireProbe? Probe { get; init; } public WireReconnect? Reconnect { get; init; } public WireWrite? Write { get; init; }