67 Commits

Author SHA1 Message Date
Joseph Doherty f0992394b6 fix(twincat): book a refused registration's backoff before releasing ConnectGate (lmxopcua#773)
RegisterNotificationAsync published a refused registration into
NativeRegistrations under ConnectGate with NextRetryUtc = MinValue; the
SubscribeAsync caller booked the refusal backoff (NoteDeadFailure refused:
true) only after the gate was released and after its WARN. A probe tick in
that window took the gate, saw the registration as due, and retried it at
once - the CI flake in A_rearm_during_an_in_flight_retry_is_not_lost
("AttemptsFor(\"MAIN.T\") should be 1 but was 2").

RegisterNotificationAsync now books the failure (refusal backoff, or the
comms-class every-tick retry) before it publishes the dead registration, still
under the gate. The caller no longer books the refusal a second time (that
would double the backoff); its comms-class booking is idempotent and stays.

Tests: TwinCATCapturingLogger gains an OnWarning hook. A logger that holds
the refusal WARN for ten probe intervals forces the window open, so
A_rearm_during_an_in_flight_retry_is_not_lost exercises it on every run, and
the new A_refused_registration_is_published_with_its_backoff_already_set
pins the backoff (booked once, not doubled).

Pass rate of A_rearm_during_an_in_flight_retry_is_not_lost:
- before, unmodified test: 40/40 runs, plus 200/200 in-process repeats under
  CPU load (the natural window is a few instructions wide; the flake is rare);
- before, window held open: 0/32 (deterministic failure, "should be 1 but
  was 2");
- after, window held open: 40/40.
Full TwinCAT suite 663/663 at AKKA_TEST_TIMEFACTOR 1 and 3.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:29:33 -05:00
Joseph Doherty b8202e1147 test: remaining xUnit1051/1031/1030, CS8602/8604, CS0618, CS0219, CS0649, NU1510 fixes (lmxopcua#725)
v2-ci / unit-tests (push) Has been cancelled
Awaited deadline oracles get the runner token; sync-over-async tests made async;
nullable child models asserted via ShouldNotBeNull() return values; obsolete SDK
ctors replaced with the telemetry overloads; dead locals/fixture and the redundant
DI PackageReference removed. No suppressions.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:22:34 -05:00
Joseph Doherty 1d7a4b7a53 test: pass TestContext.Current.CancellationToken at xUnit1051 call sites (analyzer code fix, 451 sites) (lmxopcua#725)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:19:34 -05:00
Joseph Doherty 44ea6595e5 feat(arrays): advertise AbCip/AbLegacy/TwinCAT array writes in DriverArrayWriteSupport
The Wave-2 lanes gave AbCip, AbLegacy and TwinCAT real whole-array write
encoders (review 07 U10/U9), but the capability table still listed all three
as scalar-only, so every array node materialised read-only and a client write
was BadNotWritable before it reached the driver.

Rows added, each listing only what WriteAsync encodes end to end:
- AbCip: SInt/USInt/Int/UInt/DInt/UDInt/Real/Dt/LInt/ULInt/LReal, declared
  length, not a safetyTag. BOOL/STRING/Structure arrays stay read-only.
- AbLegacy: Int/AnalogInt on N/B/A, Long on L, Float on F (file-width match),
  plain <file><n>:<word> address, <= 256 elements; not /bit, .sub, indirect,
  S:/I:/O:, nor native PLC-5.
- TwinCAT: every codec element type, declared length; not a BOOL bit-in-word
  path.
Elements convert in all three encoders, so the rows never key on an exact
node DataType (ScadaBridge int[] -> Int16 stays writable); they leave out
only node types whose elements never convert (DateTime/String over numeric,
Boolean on AbLegacy, STRING under DateTime on TwinCAT: culture-dependent).

Tests: driver-level parity for each driver (every element type x node type
plus shape cases, against the real write path), the AbLegacy parity tests
un-skipped, Core.Abstractions row tests, a composer test (three drivers
writable, refused shapes read-only), and the existing AbCip negative cases
moved to a BOOL array. Docs: Uns.md table, per-driver docs, data-plane rules.

Refs #737 #738 #739

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:17:48 -04:00
Joseph Doherty a8d313ed5a fix(twincat): review round 2 - teardown confined to its generation; write health; re-arm epoch
- I1: the poll engine belongs to a teardown generation. ShutdownAsync/Dispose
  retire the current engine and install a fresh one in the same swap as the
  device/tag tables, then dispose the retired one (possibly in the background
  after a cancelled or over-budget shutdown). A subscription made after the
  next InitializeAsync can no longer land in an engine whose late dispose tail
  would dispose its CTS without cancelling it (a loop no UnsubscribeAsync could
  find). Poll handles are generation-qualified (TwinCATPollHandle) because each
  engine numbers handles from 1; SubscribeAsync re-homes a subscription that
  raced a retirement.
- M1: a write's status feeds per-device health - Good clears that device's
  comms fault and advances LastSuccessfulRead, a comms status degrades, a
  refusal keeps the state.
- M2: a symbol-version re-arm bumps a per-registration epoch; an in-flight
  retry that fails afterwards no longer re-imposes the backoff.
- M3: a re-arm restarts the refusal backoff at 30 s.
- M4: documented that PLC-not-in-RUN detection needs the probe.
- M5: WSTRING and multi-dimensional array encode-plan tests; the cancelled-
  shutdown test asserts Unknown health and awaits its background teardown.
- M6: an infinity spelled as a string ('Infinity', '-inf') passes like a
  float/double infinity; a finite overflow stays BadOutOfRange.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:00:23 -04:00
Joseph Doherty f179829ca6 fix(twincat): review round 1 - PLC-not-in-RUN degrades, per-device health, retry backoff
- IMPORTANT: a successful probe records the PLC's ADS state; a PLC not in Run
  keeps the driver Degraded with 'PLC in {state}' while LastSuccessfulRead
  still advances (the device answered). RecordDeviceAnswered never restores
  Healthy while any device is out of Run.
- Health is per device, rolled up worst-of: a comms fault is remembered on the
  device it was seen on and only that device answering clears it (device A
  answering no longer clears device B's fault).
- Refused (non-comms) dead registrations back off 30 s doubling to 10 min and
  are re-armed by a symbol-version change or retried by the reconnect replay;
  comms-class failures keep the every-tick retry. A retry whose failure class
  changes republishes the tag's status, once.
- Bit-RMW gates are kept across generations and teardowns (never cleared).
- ShutdownAsync honours its CancellationToken: it stops waiting, finishes the
  bookkeeping and throws OperationCanceledException.
- Remarks on why a superseded InitializeAsync throws OperationCanceledException.
- REAL / LREAL values (scalar or array element) outside the target range are
  OverflowException -> BadOutOfRange instead of being written as Infinity.
- String / array write encoding factored into AdsTwinCATClient.PlanStringWrite
  / PlanArrayWrite; the fake runs them so a driver-level test covers the real
  encode path against a fake resolved symbol.
- Timing-fragile tests use condition waits / measured probe-tick windows; the
  dispose-during-shutdown test asserts ordering with a 1 s bound.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:47:24 -04:00
Joseph Doherty 6201bafea4 test(twincat): pass the test token to the abandoned-init wait
Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:28:53 -04:00
Joseph Doherty 9beeed7a76 feat(twincat): implement IBatchSubscribable (R02:P1)
The poll engine already builds a per-cycle batch; TwinCAT now forwards it as
OnDataChangeBatch, so DriverInstanceActor gets one message per poll cycle
instead of one per changed tag. Because the actor subscribes ONLY the batch
event of a batch-capable driver, every native-path data change (pushes, the
BadNodeIdUnknown / refusal / unavailable snapshots) is raised through a single
RaiseDataChange that fires both the per-tag event and a fresh one-item batch.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:25:20 -04:00
Joseph Doherty cbacaee4ee fix(twincat): bounded, race-safe lifecycle; drop counter; post-unsubscribe purge (L8)
- Device and tag tables are build-and-swap: InitializeAsync builds a fresh
  generation and publishes it by one swap under a lifecycle lock, then
  releases the replaced generation; teardown swaps in empty tables. A
  retained poll loop never sees a table mid-Clear (it used to read
  _tagsByRawPath while a re-init only ever added to it), and each device
  set is released by exactly one caller.
- A lifecycle generation makes an InitializeAsync overtaken by a shutdown
  (or a newer init) release what it built instead of installing it - the
  Lane A abandoned-init-overlaps-shutdown case.
- ShutdownAsync joins all probe loops together within 2 s (it awaited each
  unbounded - a probe wedged in the blocking ADS Connect hung the actor's
  teardown) in parallel with the poll-engine dispose, the whole bounded at
  4.5 s; DisposeAsync may run concurrently and returns at once. A connect
  that completes on a released device disposes its client.
- Releasing a generation disposes its native registrations, so a direct
  re-init no longer leaves live notifications bound to dead DeviceStates.
- Connect gates and RMW semaphores are no longer disposed under in-flight
  users (no kernel handle is ever allocated); a fault on a released device
  is not recorded as driver health.
- The notification channel counts DropWrite overflows
  (ITwinCATClient.DroppedNotificationCount); the probe tick warns with the
  delta. A queued value whose registration was disposed is purged in the
  client dispatch loop and dropped driver-side for any client.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:24:10 -04:00
Joseph Doherty f17ba8e258 feat(twincat): array and STRING writes through the codecs (U9)
Array tags were advertised writable but every write threw InvalidCastException
in ConvertForWrite (reported BadTypeMismatch); STRING writes went through the
Beckhoff marshaller with no length check against STRING(n) and the host's
ANSI code page, which the read side deliberately avoids.

- Array write: TwinCATArrayCodec.ToWriteElements converts exactly ArrayLength
  elements to the declared type (CLR lists convert - ScadaBridge writes int[]
  to Int16 nodes; a length mismatch or out-of-range element is BadOutOfRange, a
  scalar value BadTypeMismatch) before any wire traffic; the ADS client plans
  the layout from the resolved PLC array (TwinCATTypeGuard.CheckArray, as the
  read does), encodes with TwinCATArrayCodec.Encode - the exact inverse of
  Decode - and writes the raw bytes (new ITwinCATClient.WriteArrayAsync).
- STRING / WSTRING write: encoded at the PLC's declared width by
  TwinCATStringCodec.EncodeString/EncodeWString (Windows-1252 or UTF-8 per
  target, NUL-padded, strict: an over-long value or unrepresentable character
  is OverflowException -> BadOutOfRange) and written as raw bytes.
- TwinCAT CLI read/write gain --array-length (+ --separator) for the hardware
  gate.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:15:29 -04:00
Joseph Doherty 40c7493829 fix(twincat): BOOL-within-word writes RMW the parent at its own width (H18)
The bit write read and wrote the parent word as a 32-bit UDInt with no parent
check: a bit beyond a 16-bit parent was written into neighbouring bytes, and a
signed parent with its sign bit set read back >= 2^31, so the checked
conversion turned any bit write into BadOutOfRange.

The write now goes through ITwinCATClient.WriteBitAsync: the ADS client
resolves the parent symbol, checks the bit against it (TwinCATBitAccess.
CheckParent, as the notification path already did), reads exactly
parent.ByteSize raw bytes, flips one bit (TwinCATBitAccess.ApplyBitWrite) and
writes the bytes back by index group/offset or variable handle. A bit beyond
the parent is BadOutOfRange, a non-integer parent BadTypeMismatch, a short
parent read BadCommunicationError (never written back), all without a health
write. The per-parent RMW lock stays in the driver.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:10:12 -04:00
Joseph Doherty 4083b4aada fix(twincat): warn once per bad tag; a device refusal is not a comms fault (P10)
CompleteRead logged a Warning for every non-Good status on every poll and
latched Degraded for all of them. Now a bad tag warns once per bad episode
(re-armed when it reads Good, and on re-initialise), a comms-class status
(BadCommunicationError / BadTimeout) degrades with LastSuccessfulRead
preserved, and any other status - the device answered and refused this tag -
advances LastSuccessfulRead with no state change. Read-path exception
handlers now go through MarkCommsFault, so they never downgrade Faulted.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:07:03 -04:00
Joseph Doherty ecdd6dbecb fix(twincat): native/probe evidence advances LastSuccessfulRead; probe failures degrade (H12)
In native-notification mode LastSuccessfulRead never moved past the init
stamp, so one Degraded write on a connection older than 90 s latched the
comms-loss watchdog into a full reconnect (every notification on every device
deleted and re-registered). Conversely an unreachable AMS router was swallowed
by the probe and never left Healthy.

- a native push and a successful probe now stamp LastSuccessfulRead (throttled
  to one health record per 250 ms) and restore Healthy unless a device is still
  probe-unreachable;
- 3 consecutive failed probe ticks write Degraded with the stamp preserved;
- a NotSupportedException write is BadNotSupported with no health write (#611);
- a reconnect replay the PLC refuses republishes the refusal's status without
  degrading (it used to report BadCommunicationError + Degraded on every
  reconnect for a correctly-isolated type-mismatch tag).

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:03:28 -04:00
Joseph Doherty 17f4bf6d33 fix(twincat): isolate native registrations per tag and per device (H11)
Native subscribe was all-or-nothing: any registration failure other than a
type refusal disposed every registration and rethrew, so one typo'd symbol or
one unreachable PLC in a multi-device driver served zero data, churned
register/delete every reconcile tick, and left health Healthy.

Now each registration fails on its own: a refusal (the device answered: symbol
not found, PLC not ready, type mismatch) publishes its status with no health
write; any other per-tag failure publishes BadCommunicationError and degrades;
a connect failure marks the device unreachable for the rest of the call and
stores its tags as dead intents. Every failed tag is kept as a dead intent that
the probe-tick retry and the reconnect replay re-register. The ADS client now
classifies a failed symbol resolve / AddDeviceNotification by status instead of
throwing InvalidOperationException for all of them. Cancellation still tears
the partial subscription down.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:00:38 -04:00
Joseph Doherty de21a54949 fix(twincat): publish BadNodeIdUnknown for unresolvable native-subscribe references (U12)
The native subscribe path silently skipped a reference it could not resolve
(unknown RawPath, or a tag routed to an unconfigured device), leaving the node
on whatever status the server seeded. It now publishes one BadNodeIdUnknown
snapshot, matching the poll path.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:57:01 -04:00
Joseph Doherty eed7cc7d75 test: guarantee driver fixture cleanup without masking failures 2026-10-13 16:36:38 -04:00
Joseph Doherty 5310a7e07a Discard asserted probe-task return values without joining loops 2026-10-13 16:12:31 -04:00
Joseph Doherty a92fb81f00 fix(twincat): review — a connect failure no longer burns the sum-chunk warning; doc precision
- ReadBucketAsync: the once-per-device "sum-read chunk failed" Warning fires only after a successful
  connect; a connect/backoff failure is the connect path's to report. TwinCATSumChunkWarningTests pins
  it (no warning across connect failures, exactly one across two later failing sum polls; red without
  the guard) plus a source guard that ReadGroupAsync builds its SumRead only via CreateSumRead.
- Docs: diagnostics live-gated via the driver harness (/hosts not exercised); TC2 costs 8 reads on the
  first tick and after each reconnect, then <= 4; read counts attributed to the unit tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 21:26:26 -04:00
Joseph Doherty 664ff75ee2 fix(twincat): #645 live gate — sum-read never worked on hardware; TC2 diagnostics fallback
Read-only live gate on Elma presses Z35474 (TC3 3.1.4024) and Z34834 (TC2 2.11):

- Sum-read: every chunk came back BadCommunicationError. Beckhoff's
  SumRead(IList<IgIoSumReadEntity>) ctor casts to IList<SumDataEntity>, which
  throws InvalidCastException for a List<> (only an array survives via
  covariance). The fake-client tests never built a real SumRead. New
  AdsTwinCATClient.CreateSumRead passes an array; TwinCATSumReadConstructionTests
  pins it (red on the List form) plus a falsifiability control on the library.
  After the fix: 59 tags, status identical everywhere, values identical on every
  non-moving tag, ~0.35-0.66 s vs ~2.7 s per poll; TC2 CE 2.11 answers sum reads.
- An unexpected sum chunk failure now logs one Warning per device (it was
  invisible: the health message was overwritten by the next good read).
- Diagnostics: TC2 has no TwinCAT_SystemInfoVarList; fall back to .SYSTEMINFO /
  .SYSTEMTASKINFOARR[1] once the TC3 name answers not-found (live: all six keys).
- #612 browse: Flat verified on TC3 (10,770 leaves; VirtualTree 1,459 strict
  subset) — comment and docs updated, no load-mode option needed.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 21:17:44 -04:00
Joseph Doherty 802781b645 fix(twincat): read-singly fallback for any non-comms overall sum error, once-per-device warning, wording (#645 re-review)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:33:09 -04:00
Joseph Doherty fbcb521d1b fix(twincat): review fixes for sum-read and diagnostics (#645)
Strict width+kind eligibility for sum-batched scalars (others read singly); latch only on ADS
1793/1794, split 1792/1797/1798 without latching; 32 KiB per-command budget and 500-item ceiling;
short sum response handled per item. Diagnostics: opt-in diagnosticsEnabled (default off), reuse the
probe's ReadState, sample after the host-state transition, JitterMs renamed HeadroomMs, compact
per-device line so many devices fit the 16-entry cap, unavailable-symbol latch cleared on reconnect.
AdminUI: min/max on sum-read max, diagnostics checkbox. Pure planner extracted and unit-tested.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:29:35 -04:00
Joseph Doherty af19db4ed1 feat(twincat): opt-in ADS sum-read and /hosts diagnostics bag; refresh TwinCAT backlog (#645, #612)
Sum-read (#310): batches a poll's reads per device into sum commands capped at sumReadMaxItems,
per-item ADS errors mapped per tag, falls back to individual reads when the target refuses the
command. Diagnostics (#314): ADS state, app name, online-change count, task cycle/exec/jitter via
IDriverDiagnostics. Re-implemented against the current raw-byte read path (stranded auto/driver-gaps
commits predate it). Sum-write not ported. Unit tests only; live gate pending a route.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:18:42 -04:00
Joseph Doherty 2377da3cb4 Merge b626-630 (batch #626+): release prior device/session state on re-Initialize in six drivers
Conflict in S7Driver.ReleaseConnectionAsync resolved: kept the gated Plc swap from #630 and
carried the negotiated-PDU reset from #632 #294 into it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:43:44 -04:00
Joseph Doherty 461133235a fix(drivers): InitializeAsync releases previous device/session state before overwrite (#630)
FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient orphaned the previous
probe/recycle/fixed-tree loops and clients when re-initialised on a live driver.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:28:55 -04:00
Joseph Doherty 05ed039c73 fix(twincat): TOD write bound, historian note, driver-level refused-write test (#306)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:26:32 -04:00
Joseph Doherty 90e3fe1cf7 feat(twincat): TIME/TOD as Double ms, DATE/DT as UTC DateTime with read decode + write encode (#306)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:24:12 -04:00
Joseph Doherty 0fb194a096 Merge #608/#602/#596: stale WaveC TODOs resolved, FOCAS/TwinCAT factory logging, Modbus health asymmetry documented, AbCip browsePageDelayMs field, Device1 by-design
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:53:59 -04:00
Joseph Doherty 9c39f2b6c4 fix(drivers): ILoggerFactory on FOCAS+TwinCAT factories; replace stale TODO(v3 WaveC); AbCip browsePageDelayMs form field; document Device1 + Modbus health (#608 #602 #596)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:53:38 -04:00
Joseph Doherty 7aa7758864 build: scoped OTOPCUA0001 NoWarn in 13 test projects, error under src/, pin SDK via global.json (#595 #606)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:47:40 -04:00
Joseph Doherty 313946e6ea fix(twincat): refuse a scalar tag over a PLC array on the read path too
Live on Z33856 a UInt tag over .ARRFAULTCODE (ARRAY [0..12] OF UINT) made the
Beckhoff read throw - BadCommunicationError and a Degraded driver - while the
native path reported BadTypeMismatch. CheckRead now refuses a scalar declared
over an array (by category, so an alias of an array type is caught), pointing
at the array-tag / element-path alternatives.
2026-10-07 14:50:39 -04:00
Joseph Doherty 1e68121b17 fix(twincat): register a bit-indexed BOOL notification on its parent word (live TC2 gate)
A native notification for MAIN.Flags.3 / .ADSSTATE_RUN.0 was registered on the
literal WORD.N path, which ADS has no symbol for: live on the TC 2.11 Elma
cleaner Z33856 it failed with DeviceSymbolNotFound, and that registration
error tore down the whole subscribe call. The read path already reads the
parent word; the notification now does the same - it registers the parent as
raw bytes at its resolved width and reads the bit (TwinCATBitAccess), refusing
a bit outside the parent or a non-integer parent with BadTypeMismatch.
2026-10-07 14:50:39 -04:00
Joseph Doherty dcc35f3481 feat(twincat): native ADS notifications for array tags, decoded from raw bytes
An array tag subscribed natively used to register only its declared scalar
(publishing element 0 at Good) and was then refused wholesale with
BadTypeMismatch. The driver now hands the authored ArrayLength to the client,
which registers the whole array as raw bytes and decodes every notification.

- TwinCATTypeGuard.CheckArray plans the layout from the RESOLVED array type:
  element checked like a scalar notification (width / float kind / string
  kind); STRING(n)/WSTRING(n) elements stride n+1 / 2(n+1); multi-dim arrays
  flattened in PLC memory order; an authored ArrayLength shorter than the PLC
  array reads the leading sub-range, a longer one is refused; struct / pointer
  / nested-array elements, non-array symbols and VAR_IN_OUT refs are refused.
- CheckNotificationSize caps a native array at 128 KiB (largest registration
  proven on a live TC 2.11 target); above it the tag is refused BadOutOfRange.
- TwinCATArrayCodec decodes the bytes into the element-typed CLR array.
- The poll read uses the same plan + codec instead of the Beckhoff typed array
  read, which failed live for most real arrays (exact CLR element type, rank-N
  for multi-dim, member arrays sliced by the under-reported TC2 symbol size).
- TwinCATNotificationRefusedException carries the refusal's status; the type
  mismatch derives from it and the driver isolates either per tag.
2026-10-07 14:45:35 -04:00
Joseph Doherty 8060cfd2a5 fix(twincat): size strings by the resolved type and refuse VAR_IN_OUT references (live TC2 gate)
Found by the read-only live gate against the TwinCAT 2.11 Elma cleaners
Z33856 / Z33733:

- The Beckhoff library reports a TC2 dotted MEMBER symbol's ByteSize as if the
  PLC's byte count were a bit count (STRING(80) -> 11, INT -> 1, UDINT -> 1)
  while DataType.ByteSize is right (81/2/4, raw SYM_INFOBYNAMEEX says 81). The
  previous commit's typeof(byte[]) read sliced by the symbol size, so
  .OPC_MACHINEDATA.PROCESSSTATUS read "ready for o" at Good; the notification
  width check would also have refused every numeric member. The shape now takes
  the resolved type's size, and string reads go by index group/offset (or a
  fresh handle for 0xF005) at that width.
- P_INITINPUT_ACTION.SLOTID is "STRING(20) (VAR_IN_OUT)" - a 4-byte reference -
  but resolves to a plain STRING(20); a String tag read 21 bytes at the pointer
  as text at Good. The symbol's own VAR_IN_OUT marker now classifies it
  NonScalar -> BadTypeMismatch.
2026-10-07 14:27:22 -04:00
Joseph Doherty 83a314e734 fix(twincat): string subscriptions, Windows-1252 STRING decode, and declared-type kind checks (ignitionoee 0.19.2-0.19.4)
Port of the ignitionoee TwinCAT string + type-reconciliation fixes onto the
Beckhoff-library client:

- STRING/WSTRING subscriptions never worked: AddDeviceNotificationExAsync was
  called with typeof(string) and no length, and the Beckhoff any-type
  marshaller throws ArgumentNullException for a string without one, so one
  string tag tore down its whole subscribe call. A string now registers the
  PLC's own byte width as raw bytes (0.19.2: STRING(n) of any n).
- Strings are read as raw bytes and decoded by TwinCATStringCodec. The
  Beckhoff marshaller already cut a read at the first NUL (0.19.3 / TwinCAT-007
  were not defects on the read path); the codec keeps that rule for both STRING
  and WSTRING and extends it to the new notification path. A STRING now decodes
  as Windows-1252 (TwinCAT-010) - or UTF-8 when the target reports UTF-8
  strings - instead of the host culture's ANSI code page the Beckhoff
  marshaller falls back to (1250 on a Polish host, 1251 Cyrillic, ...).
- TwinCATTypeGuard (TwinCAT-002/-008): a read refuses a numeric tag over a PLC
  string (and vice versa / STRING vs WSTRING / string over a container) with
  BadTypeMismatch instead of handing back a string object at Good; a
  notification - raw bytes decoded as the DECLARED type - additionally refuses
  width and float/integer mismatches (DINT over REAL reinterpreted the bits).
- A refused notification is isolated: that tag publishes BadTypeMismatch with
  one WARN, keeps a dead intent for the probe-tick retry, and the rest of the
  subscription registers.
2026-10-07 14:05:55 -04:00
Joseph Doherty 1a74c3c2e0 fix(twincat): map TwinCAT 2 ADS base-type names in symbol browse
A TC2 PLC reports top-level integer globals as INT16/UINT16 (354 symbols on
the Elma cleaner Z33856) and the Beckhoff type system keeps that spelling as
a built-in primitive, so MapSymbolTypeName returned null and discovery dropped
them silently. INT16/UINT16/INT32/UINT32/UINT8/FLOAT/DOUBLE now map like their
IEC twins; padded member names ("REAL ") are trimmed; a parenthesised
non-string form ("INT (VAR_IN_OUT)", a reference) stays unsupported.
2026-10-07 13:59:40 -04:00
Joseph Doherty b7b754fd98 fix(twincat): accept TwinCAT 2 leading-dot globals in TwinCATSymbolPath (ignitionoee 0.19.1)
A TC2 global is named with ONE leading dot (.OPC_HEARTBEAT) and the PLC
requires it (SYM_HNDBYNAME answers 0x710 without it). TryParse rejected the
dot, so the driver fell back to the raw string and lost bit-index parsing:
.X.WORD.3 went to ADS verbatim instead of reading .X.WORD and extracting bit 3.
The dot now stays on the root segment and round-trips through ToAdsSymbolName;
.., ., .[1] and .9x stay invalid.
2026-10-07 13:58:40 -04:00
Joseph Doherty f5475868f7 fix(inspect): trim before the framing check; stop advisories swallowing the backstop (#556); pin the MQTT payloadFormat mode contract (#557 item 2)
#556 item 1 — leading whitespace defeated every Inspect's pre-filter. Seven
factories (Modbus/S7/AbCip/AbLegacy/TwinCAT/FOCAS/Mqtt) tested
`reference[0] != '{'`, so a padded blob returned EMPTY and a tag the driver
refuses deployed clean. The check now trims first, matching the idiom
Sql/Calculation/MTConnect/OpcUaClient already used. The raw reference is still
what is handed to the mapper — the driver reads the stored blob, not a trimmed
copy — so the gate keeps mirroring the runtime rather than becoming lenient.
Both directions are pinned: a padded blob the mapper accepts still draws
nothing, and one that is not object-framed AFTER trimming still draws nothing
(it is not an equipment-tag TagConfig at all).

Six of the seven mappers hand the blob to a whitespace-tolerant
JsonDocument.Parse, so their verdict is unchanged by padding. MQTT is the
exception: both its readers carry the same framing gate, so a padded blob is
genuinely refused — which is why the untrimmed pre-filter was a real defect
there and not a cosmetic mismatch.

#556 item 2 — FOCAS and MQTT appended advisories unconditionally and then gated
the catch-all rejection message on `warnings.Count == 0`, so an advisory (which
fires on ACCEPTED blobs too, and says nothing about a refusal) silently
swallowed the only message naming a refusal nothing else can. The two classes
now accumulate in SEPARATE lists and the backstop gates on the rejection count
alone — structural, not string matching. MQTT's swallow was reachable in
production (a padded blob its readers refuse while every describe branch finds
the parsed document well-formed); FOCAS's backstop is currently unreachable by
audit, so its guard is protective.

#557 item 2 — decided and pinned: KEEP the union verdict. Evidence:
FromSparkplugTagConfig hard-codes PayloadFormat=Json and never consults the key;
only MqttSubscriptionManager (the Plain ingest path) decodes by it; and
MqttTagConfigModel.ToJson deliberately omits the key entirely in Sparkplug mode
(#513). So a Sparkplug driver genuinely serves the tag and rejecting it would be
stricter than the runtime. The cost — served behaviour depends on the driver's
MqttMode, which no tag row records — is paid by an ADVISORY naming the key and
the mode dependency, not by changing the verdict, so both gates keep agreeing.
The same invalid value on a blob the union REFUSES stays a rejection; the class
is decided by the accept gate, never by the key.

Falsified: reverting Modbus's trim reddens the padded-rejection theory;
neutering MQTT's backstop guard to the pre-#556 shape reddens the
advisory-plus-unnameable-rejection test; disabling the #557 advisory reddens the
contract pin; and for FOCAS, removing a describe branch alone stays green (the
backstop covers it) while additionally neutering the guard goes red.
2026-08-10 03:07:18 -04:00
Joseph Doherty 05b424788f fix(tagconfig): close the four #551 residuals — accept-gate-first Inspect, three authoring rules, CSV/service gate, doc sweep
Gitea #551.

1. CSV import ran no driver-typed config gate. Its typed columns were always
   strictly parsed, but the free-form TagConfigJson column reached the database
   unread, so a CSV could author a tag the driver rejects — one that deploys
   green and reads BadNodeIdUnknown forever. RawTagCsvMapper.Parse now names the
   offending row in the staged review grid, and RawTreeService enforces the same
   gate on CreateTag/UpdateTag/ImportTags: a form cannot be the guarantee (the
   API and any future caller reach none of them, and this repo has no bUnit).
   A rejected import row is an ERROR — the batch inserts nothing and the row is
   named — never a skip.

   Note the brief's premise that manual entry rides ImportTagsAsync is wrong:
   RawManualTagEntryModal commits row-by-row via CreateTagAsync, which is why
   the gate had to land on all three write paths.

2/3. The seven pre-#549 Inspect methods ran a hand-list of DescribeInvalidEnum
   checks, so every rejection cause not on the list was invisible. They are now
   ACCEPT-GATE-FIRST (the MTConnect/Sql/OpcUaClient/Calculation shape): ask the
   driver's own reader for the verdict, then explain the refusal, with a
   catch-all so "the parse rejects but Inspect said nothing" is unreachable.
   That closes S7's absent dataType, AbCip members[], Modbus stringByteOrder and
   the type-dependent guards, plus causes the issue did not name (absent
   region/address/tagPath/symbolPath/topic, TwinCAT Structure, MQTT's Sparkplug
   tuple). FOCAS and MQTT keep advisories about ACCEPTED blobs, so they
   diagnose rather than return empty.

   Authoring half: ModbusTagConfigModel gains stringByteOrder;
   AbCipTagConfigModel asks the new AbCipTagDefinitionFactory.DescribeInvalidMember;
   S7TagConfigModel captures whether the SOURCE blob declared dataType and asks
   S7TagDefinitionFactory.DataTypeKey/DeclaresDataType. One authority per rule.

4. Doc sweep: the six factory Inspect summaries, EquipmentTagConfigInspector's
   class remarks and AdminOperationsActor's comment all claimed invalid enums are
   "silently defaulted by the lenient runtime" — false since R2-11 Phase C made
   the runtime reads strict. They describe rejections now.
2026-08-10 00:08:06 -04:00
Joseph Doherty 6b3da93151 fix(browse): commit the browsed data type, and fix the probe classification the fixture was hiding (#536)
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 16m47s
A browse-committed AB CIP / TwinCAT / FOCAS tag bound with its model's DEFAULT
data type, not the one the device reported. A browsed REAL committed as
`{"tagPath":"Pump_1_Speed","dataType":"DInt"}` — and those three drivers decode
the wire bytes by that field, not by the raw row's OPC UA DataType — so the tag
read four bytes of a float through an integer decoder and published a plausible
wrong number with no error raised anywhere. FOCAS was missed when #536 was filed.

The type is CARRIED, not derived. Reverse-mapping the leaf's driver-agnostic
DriverDataType looks like the smaller fix and is wrong: SInt/Int/DInt/USInt/UInt
all collapse into Int32 on the way out of the driver, so that version would bind
a 16-bit INT as a 32-bit DINT while appearing to fix the REAL. Drivers now state
their own type name on DriverAttributeInfo.NativeDataType, which reaches the
picker as AttributeInfo.AddressFields["dataType"] and the commit mapper from
there. A leaf that states nothing keeps the model default — the pre-#536
behaviour, kept deliberately as the fallback rather than a guess.

Numeric strings are refused before parsing: Enum.TryParse accepts "9" for any
enum and Enum.IsDefined then confirms it, so an unguarded parse would bind a tag
to a type nobody named. A test caught this in the first version of the guard.

--- and the other half, which the fixture was hiding ---

Test Connect on a device with NO authored tags — every AB CIP device at the
moment an operator first presses the button — reported "CIP handshake failed"
against a healthy controller. That was written down as a fixture limitation
("ab_server doesn't implement @raw_cpu_type"). Both halves of that were wrong:

  * @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags and
    @udt/ are), so it goes out as an ordinary symbolic name that no controller
    defines, and the controller's "tag does not exist" IS the successful answer.
  * ab_server answered an unresolvable tag path with CIP 0x03 where a Logix
    controller answers 0x04 — ErrorBadParam vs ErrorNotFound, which the probe
    classifies oppositely. Fixed in vendored patch 0002.
  * With the fixture then telling the truth, the real defect surfaced:
    AbCipDriverProbe.IsReachableException matched the native C status spellings
    ("STATUS_NOT_FOUND"), but libplctag.NET throws with the MANAGED enum name
    ("ErrorNotFound"). Those substrings can never match, so the reachable
    classification could not fire on the exception path — which is the path every
    no-authored-tags probe takes. This was failing on real hardware too, not just
    against the sim. It now recovers the Status and defers to IsReachableStatus,
    so there is one classification rather than two that can drift.

Rejected: switching the fallback to @tags. libplctag builds a tag-listing tag
with NO network I/O, so it reports OK against any TCP listener — measured green
against a Mosquitto broker.

Verification: full solution suite green. Falsification runs confirm exactly the
intended tests go red — 9 for the type carry, 3 (the reachable statuses only) for
the probe classification. Live on docker-dev: browse-committed TestString/
Motor_Run/TestSINT now store String/Bool/SInt, alongside last session's
Pump_1_Speed still holding the wrong DInt. TestSINT is the falsifier — its row
DataType is Int32, identical to TestDINT's, so no reverse map could have produced
"SInt".

Known consequence: pre-existing browse-committed tags keep their wrong dataType;
the fix applies to new commits only.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 15:20:07 -04:00
Joseph Doherty a53be2af65 v3(b1-twincat): apply Modbus exemplar pattern to TwinCAT (multi-device)
- Rename TwinCATEquipmentTagParser -> TwinCATTagDefinitionFactory; TryParse ->
  FromTagConfig(tagConfig, rawPath, out def). Drop leading-'{' heuristic + the
  deviceHostAddress key; def.Name = rawPath. Keep strict-enum + Inspect; add a
  Structure guard (sole tag path now) + inverse ToTagConfig.
- Options: Tags -> RawTags (RawTagEntry); keep Devices + protocol fields.
- Driver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
  _options.RawTags via FromTagConfig, threading WriteIdempotent + DeviceName.
  Multi-device: ResolveDeviceHost matches entry.DeviceName against options.Devices
  (by name, then host) -> def.DeviceHostAddress (TODO(v3 WaveC): live host from the
  Device row's DeviceConfig). DiscoverAsync now emits from the table.
- Factory: retire pre-declared tags[] DTO path; bind rawTags; keep Devices.
- Cli: BuildOptions serialises typed defs -> RawTagEntry via ToTagConfig.
- Tests: TwinCATRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
  FromTagConfig; equipment/resolve-host/array tests delivered via RawTags.

Gate: Contracts + Driver build green; Driver.TwinCAT.Tests 192 pass, Cli.Tests 70 pass.
2026-07-15 20:11:23 -04:00
Joseph Doherty 679484ae78 feat(browse): Wave-0 Batch B — AbCip/TwinCAT/FOCAS opt into online discovery
Task 3/4/5: SupportsOnlineDiscovery => true on AbCipDriver, TwinCATDriver, FocasDriver
(their device enumeration is real, config-gated on EnableControllerBrowse / FixedTree.Enabled
which PatchForBrowse guarantees at open). One gate fact per driver test project.
2026-07-15 17:18:02 -04:00
Joseph Doherty e8db0a683f fix(drivers): flip runtime tag parsers to strict enum parsing (#457)
v2-ci / build (pull_request) Successful in 4m12s
v2-ci / unit-tests (pull_request) Failing after 10m15s
R2-11 Phase C. All six equipment-tag parsers (Modbus/S7/AbCip/AbLegacy/
TwinCAT/Focas) now read enum fields via the new TagConfigJson.TryReadEnumStrict:
absent -> fallback, valid -> parsed, present-but-invalid (typo) -> TryParse
returns false -> EquipmentTagRefResolver.TryResolve false -> driver surfaces
BadNodeIdUnknown, instead of the old lenient path that silently defaulted a
typo to a wrong-width Good.

Modbus flips all three enum fields (region/dataType/byteOrder); the other five
flip dataType. The deploy-time Deployment:TagConfigValidationMode=Error gate is
unchanged and remains the operator pre-flight.

Coverage:
- Six *EquipmentTagParserStrictnessTests inverted from Freeze_typo_* (lenient)
  to Typo_*_rejects_the_tag + Valid_*_still_parses.
- TagConfigJsonTests.TryReadEnumStrict_rejects_only_invalid matrix.
- Driver-level end-to-end proof:
  AbCipEquipmentTagTests.Driver_read_of_a_typod_dataType_ref_surfaces_BadNodeIdUnknown
  drives the real AbCipDriver.ReadAsync through resolve->status.

Golden parity corpus has no typo'd enums, so the flip is a no-op there.
Full solution builds clean; all six driver suites + core + parity + gate green.
2026-07-15 11:28:34 -04:00
Joseph Doherty 6ee74d26fc docs(r2-09): record execution deviations; clear CS8604 nullable warning in the 5 PollErrorHealthTests
v2-ci / build (pull_request) Successful in 3m32s
v2-ci / unit-tests (pull_request) Failing after 9m27s
2026-07-13 12:45:50 -04:00
Joseph Doherty 502a327f25 fix(twincat): probe recycle honors cancellation — no uncancellable gate wait (05/STAB-12 compounded part); B6 complete (TwinCAT suite 189 green) 2026-07-13 12:43:06 -04:00
Joseph Doherty 082ee99b6b fix(twincat): retract the fresh ADS notification when unsubscribe raced the replay (05/STAB-17) 2026-07-13 12:39:39 -04:00
Joseph Doherty de29759643 fix(twincat): retry failed replay intents on the next successful probe tick (05/STAB-16) 2026-07-13 12:37:20 -04:00
Joseph Doherty 4f16620948 test(twincat): failing repro for STAB-16 silent replay failure 2026-07-13 12:33:35 -04:00
Joseph Doherty 8a7701db3f test(drivers): failing repro for CONV-4 equipment-tag ResolveHost mis-keying 2026-07-13 12:23:43 -04:00
Joseph Doherty 962afed241 fix(twincat): per-device connect-attempt backoff, probe-driven recovery (05/STAB-8) 2026-07-13 12:13:23 -04:00
Joseph Doherty b494d09d9b fix(drivers): wire PollGroupEngine onError->health + 30s poll backoff (05/STAB-9) [twincat,focas] 2026-07-13 11:59:49 -04:00