docs(driver-expansion): resolve all open design concerns from the review pass
v2-ci / build (push) Successful in 3m30s
v2-ci / unit-tests (push) Failing after 8m53s

Every concern the 7-agent review parked is now decided and integrated:

- universal browser: in-flight capture coalescing keyed (driverType,
  config-hash) + global cap of 4 concurrent captures; CanBrowse catches a
  throwing TryCreate and defensively shuts down the throwaway instance;
  cleanup ShutdownAsync bounded at 10s (R2-01); picker Refresh = close +
  re-capture. Coalesced sessions share an immutable tree, so DisposeAsync
  drops the session's reference, not the tree.
- mtconnect: UNAVAILABLE pinned to BadNoCommunication (fleet's
  BadCommunicationError stays reserved for the driver's own transport
  failures); TIME_SERIES sampleCount flows into ArrayDim; Subscribe timeout
  bounds only the stream-start handshake; library version/TFM folded into
  the license checklist.
- mqtt: browse rebirth is an explicit DriverOperator-gated "Request rebirth"
  button (RequestRebirthAsync(scope)) - never fired by open/root/expand;
  hand-rolled reconnect loop committed for P1 (MQTTnet v5 dropped
  ManagedMqttClient); Wave-2-start library re-verification checkbox;
  implement from the Sparkplug v3.0 spec text.
- bacnet: P1 opens with a first-spike checklist (package TFM, BBMD/
  segmented-RPM/COV API smoke, unicast-I-Am fixture behavior); AdminUI-node
  browse registers a second foreign device - BBMD FD-table sizing note.
- sql-poll: split-node browse needs env parity for Sql__ConnectionStrings__
  refs on admin nodes (actionable error + session-only pasted literal);
  one-row-per-key query contract (last-wins + rate-limited warning);
  absent key -> BadNoData; operationTimeout > commandTimeout authoring rule;
  Browser->Driver.Sql SqlClient transitive on AdminUI accepted on record.
- omron: FINS framer gated on W227 + live golden vectors; CIP string layout
  is the first P1 live-gate item; AbCip harness static-init anti-pattern
  note; writable-defaults-true operator warning.
- modbus-rtu: first P1 step confirms pymodbus exposes framer=rtu on a TCP
  server (custom-script fallback otherwise).
- program doc: new cross-cutting rule - driver ctors must be connection-free
  (the universal browser's CanBrowse throwaway instances depend on it).
This commit is contained in:
Joseph Doherty
2026-07-15 16:54:09 -04:00
parent cf03ca279d
commit fd0bec4ffe
8 changed files with 210 additions and 52 deletions
@@ -57,7 +57,7 @@ only (it owns the shared `TagConfigJson` field readers used by the parser).
```xml
<!-- Directory.Packages.props -->
<PackageVersion Include="BACnet" Version="4.0.0" /> <!-- confirm the exact current release when implementing -->
<PackageVersion Include="BACnet" Version="4.0.0" /> <!-- placeholder — the exact version + net10 TFM load is item 1 of the P1 first-spike checklist (§10) -->
<!-- Driver.Bacnet.csproj -->
<PackageReference Include="BACnet" />
```
@@ -238,6 +238,16 @@ open. That is the documented **P-later graduation** to a bespoke lazy browser:
**v1 uses the universal browser. State this clearly in the PR: BACnet ships with `SupportsOnlineDiscovery
= true` and zero browser code.**
**Deployment note — AdminUI node network posture.** The universal browser runs its
`InitializeAsync → DiscoverAsync` capture **in the AdminUI process**, so the **AdminUI node itself needs
UDP reachability to the BACnet network** for the picker to browse — this is the same in-process posture as
the existing OpcUaClient/Galaxy browsers, but BACnet is the **first UDP/broadcast** one, so firewall/VLAN
rules that only whitelist the runtime driver node will silently break browse. When the AdminUI node is
off-segment, its browse capture registers as a **second foreign device** with the site BBMD (in addition
to the runtime driver's standing registration) for the duration of the capture — some BBMDs cap their
foreign-device-table entries, so size the FD table (and any per-source firewall rules) for **both**
registrations, not one.
---
## 5. TagConfig + driver-config JSON
@@ -475,8 +485,10 @@ live-gate discipline:
route** (and bridged docker networks don't forward subnet broadcasts into containers either). So the
suite as run from the dev machine has exactly two working paths: (a) **directed unicast Who-Is** at
the fixture's host IP:port (host-mode `bacserv` answers a unicast Who-Is with a unicast I-Am to the
request's source address — verify this against the pinned `bacserv` build early, since spec-strict
stacks may broadcast the I-Am, which would never arrive); and (b) the **foreign-device/BBMD leg** —
request's source address — **verifying this against the pinned `bacserv`/`BasicServer` builds is item 3
of the P1 first-spike checklist (§10)**, since spec-strict stacks may broadcast the I-Am, which would
never arrive; if they broadcast, this leg collapses to the BBMD/foreign-device leg below); and (b) the
**foreign-device/BBMD leg** —
register with a BBMD container on the docker host; BVLC `Forwarded-NPDU` relay is *unicast* and routes
across subnets, so this leg exercises real broadcast distribution cross-VM and is exactly the field
topology. A genuinely broadcast-only leg (plain Who-Is with no BBMD) can only run **on the docker host
@@ -498,6 +510,26 @@ slightly above OpcUaClient, driven by the callback→async adapter + COV lifetim
Each phase is independently shippable (mirror how OpcUaClient landed across PRs):
- **P1 — Connect + Read + Discover (+ universal browse) + BBMD. (MVP, delivers real value.)**
**P1 opens with a first-spike verification checklist.** The library and fixture facts below came from
research (`docs/research/drivers/bacnet-ip.md`) and were **unverifiable offline** — verify them on the
wire before building anything on top of them:
1. **NuGet package reality.** Confirm the exact current version of the `BACnet` package (ela-compil
fork) and that its TFM set actually restores + loads on **net10.0** (pin the confirmed version in
`Directory.Packages.props` — see the §2 placeholder).
2. **API-surface smoke test.** Exercise the specific API surface this design depends on:
BBMD/foreign-device registration (`Register-Foreign-Device` + TTL), **segmented**
ReadPropertyMultiple (a large `object-list` fetch), and SubscribeCOV callback delivery
(`OnCOVNotification`). Any gap here reshapes §3/§4 before real work starts.
3. **Fixture unicast I-Am behavior.** Verify the pinned `bacserv` and `BasicServer` fixture builds
answer a **unicast** Who-Is with a **unicast** I-Am to the request's source address — a spec-strict
stack may broadcast the I-Am, which would **silently break the hermetic cross-VM test path** §9
relies on (the directed-Who-Is leg from the dev machine). If either fixture broadcasts, adjust the
cross-VM suite to the **BBMD/foreign-device leg only** and keep directed-unicast tests on the
docker host itself.
Then the P1 build proper:
`.Contracts` (`BacnetDriverOptions`, enums, `BacnetTagAddress`, `BacnetEquipmentTagParser`);
`BacnetClientAdapter` (async wrap); `BacnetDriver : IDriver, ITagDiscovery, IReadable,
IHostConnectivityProbe` with `SupportsOnlineDiscovery=true` + `RediscoverPolicy =>
@@ -518,7 +550,9 @@ Each phase is independently shippable (mirror how OpcUaClient landed across PRs)
1. **BBMD / foreign-device registration + UDP broadcast reachability (HIGH — the top risk).** The server
is usually not on the controllers' L2 segment, so correct foreign-device registration + TTL renewal is
load-bearing and is exactly what's hardest to reproduce in docker/CI. Mitigation: build BBMD in from
load-bearing and is exactly what's hardest to reproduce in docker/CI. Note the AdminUI browse capture
adds a **second** foreign-device registration off-segment (§4.2 deployment note) — account for it in
BBMD FD-table sizing. Mitigation: build BBMD in from
P1, test explicitly with the `bacnet-docker` multi-network compose, gate a live suite against a real
site BBMD.
2. **COV lifetime/renewal + non-COV fallback (HIGH, P2).** Subscriptions silently expire if not renewed;