Captures uncommitted work that lived in the working tree on
v2-mxgw-integration but was orthogonal to the migration. Stashed
during the v2-mxgw merge to master (2026-04-30) and replanted here on
a feature branch off master so it's git-visible rather than living in
the stash list.
Two distinct buckets:
1. Tracked fixture/config refinements (10 files, ~36 lines):
- scripts/e2e/test-opcuaclient.ps1
- src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
- 5 docker-compose.yml under tests/.../IntegrationTests/Docker/
(AbCip, Modbus, OpcUaClient, S7)
- 4 fixture .cs files (AbServerFixture, ModbusSimulatorFixture,
OpcPlcFixture, Snap7ServerFixture)
2. Untracked driver-gaps queue artifacts (~8000 lines):
- docs/plans/{abcip,ablegacy,focas,opcuaclient,s7,twincat}-plan.md
— per-driver gap plans
- docs/featuregaps.md — cross-cutting analysis
- docs/v2/focas-deployment.md, docs/v2/implementation/focas-simulator-plan.md
- followup.md — auto/driver-gaps queue follow-ups
- scripts/queue/ — PR-queue automation tooling (12 files including
pr-manifest.yaml at 1473 lines)
This commit is a snapshot for recoverability — review and split into
focused PRs (or discard) before merging anywhere downstream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
46 KiB
S7 Driver — Implementation Plan
Source of gap analysis: featuregaps.md → S7
Covers Build = Yes items only. Skip-rated rows are noted at the end for context.
Summary
The S7 driver (src/ZB.MOM.WW.OtOpcUa.Driver.S7/) ships a working scaffold over
S7netplus 0.20: ISO-on-TCP / S7comm, single-connection-per-PLC (SemaphoreSlim),
DB / M / I / Q / T / C address parsing, atomic scalar reads/writes for Bool / Byte
/ I16 / U16 / I32 / U32 / F32, polled ISubscribable overlay, IHostConnectivityProbe
via ReadStatusAsync, and a Snap7-server-backed CI fixture on localhost:1102.
The 16 Build = Yes gaps fall into six tractable phases. The hard one is gap #1 (S7-1500 Optimized DB / Symbolic addressing) — S7netplus speaks classic S7comm only and cannot reach optimized DBs at all. Phase 6 calls that out as an explicit architectural decision: ship the constraint as documentation and the rest as S7netplus-compatible features, or fork to a library that supports S7Plus (Sharp7-fork, Snap7 v2, custom S7Plus). Phases 1-5 do not depend on that decision and are landable on the current S7netplus base.
Every PR ships unit-test coverage and — where wire semantics matter — extends the
Snap7-server profile in Docker/server.py so the integration fixture exercises
the new path. PRs that need real S7-1500 firmware features the simulator doesn't
mimic (PUT/GET protection, password-tier auth, SZL diagnostic buffer) call that
out and gate the live-firmware test on the dev-box S7-1500 lab rig.
Architectural invariants we explicitly preserve:
- Single connection per PLC;
_gate(SemaphoreSlim) serializes every PDU. - Strict address-parse-at-init; bad config fails fast with
FormatException. - PUT/GET-disabled mapped to sticky
BadDeviceFailure, not Polly-retried. - 100 ms minimum publishing interval (matches CPU mailbox scan reality).
WriteIdempotentper-tag flag is the only retry-policy lever.
Phased delivery
| Phase | Theme | PRs | Gaps closed |
|---|---|---|---|
| 1 | Data-type correctness | PR-S7-A1..A5 | #7, #8, #9, #19 |
| 2 | Performance — multi-tag PDU packing | PR-S7-B1..B2 | #3, #22 |
| 3 | Operability knobs | PR-S7-C1..C5 | #2, #4, #20, #21, #24 |
| 4 | Workflow — symbol import + UDTs | PR-S7-D1..D3 | #5, #6, #10 |
| 5 | Diagnostics & security | PR-S7-E1..E2 | #11, #14 |
| 6 | S7-1500 Optimized DB / Symbolic | PR-S7-F (decision) | #1 |
Phases 1-3 run sequentially because Phase 2 packing and Phase 3 deadbands are both keyed off the type-decode work in Phase 1. Phase 4 (UDT/symbol import) is parallelizable with Phase 5; Phase 6 is gated on the library-choice decision in Open Questions (a).
Per-PR detail
Phase 1 — Data-type correctness
PR-S7-A1 — 64-bit scalar types (LInt / ULInt / LReal / LWord)
Closes gap #9. Float64/Int64/UInt64 cases in S7Driver.ReadOneAsync/
WriteOneAsync currently throw NotSupportedException.
- Files:
S7Driver.cs(read + write switch),S7DriverOptions.cs(extendS7SizewithLWordfor 8-byte access),S7AddressParser.cs(acceptDBL/LDsize suffix; S7netplus encodes 8-byte access via byte-array reads, so the parser convertsDB1.LD0to a byte-range read internally). - Tests: unit decode tests for the byte-pattern →
long/ulong/doubleconversion; Snap7-server profile getsf64andi64seed types. - Risks: S7netplus's
ReadAsync(string)does not acceptLDnatively; fallback path isPlc.ReadBytes(DataType.DataBlock, db, byteOffset, 8)thenBitConverterwith explicit endian flip (S7 is big-endian on the wire,BitConverteris little-endian on x86/x64). - Effort: M (3-4 days incl. tests).
- Deps: none.
- Docs / fixture / e2e: extends the type-mapping table in
docs/v2/s7.mdwithLInt/ULInt/LReal/LWordrows; adds the new sizes (LInt,ULInt,LReal) to theread/writecookbook indocs/Driver.S7.Cli.md; updatesdocs/drivers/S7-Test-Fixture.md§"What it actually covers" to list the new 64-bit types and removes them from §5 "Data types beyond the scalars"; extends the snap7 seed-type set intests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.pywithi64,u64,f64cases; adds seeds at known offsets (e.g.DB1.DBL40for i64,DB1.DBL48for f64) toDocker/profiles/s7_1500.json; addsS7_1500Profileconstants for the new tags + aDriver_reads_seeded_64bit_batchsmoke test inS7_1500SmokeTests; adds an LInt loopback assertion toscripts/e2e/test-s7.ps1.
PR-S7-A2 — STRING / WSTRING / CHAR / WCHAR
Closes gap #8 (string portion). S7 STRING(n) is [max-len][actual-len][bytes...]
(2-byte header + ASCII). WSTRING(n) is 4-byte header + UTF-16BE bytes. CHAR
is 1 byte; WCHAR is 2 bytes UTF-16BE.
- Files:
S7Driver.cs(newReadStringAsync/WriteStringAsyncprivate helpers usingPlc.ReadBytesfor raw byte-range fetch),S7DriverOptions.cs(already hasStringLength; addS7DataType.WString,Char,WChar). - Tests: unit tests for header parsing including the "actual-len > max-len"
PLC bug case (clamp on read, reject on write); Snap7
asciiseed type already exists, addwstringseed. - Risks: write must respect the configured
StringLengthto avoid overrunning the DB; mismatched max-len is a common field bug. - Effort: M.
- Deps: PR-S7-A1 (byte-range read helper lands there).
- Docs / fixture / e2e: extends the type-mapping section in
docs/v2/s7.mdwithSTRING(n)/WSTRING(n)/CHAR/WCHARlayouts (2-byte vs 4-byte header, UTF-16BE encoding, the "actual-len > max-len" PLC bug); extends theread/writecookbook indocs/Driver.S7.Cli.mdwith--type WString/--type Char/--type WCharexamples and the--string-lengthflag for WString; updatesdocs/drivers/S7-Test-Fixture.md§"What it actually covers" to list ascii/wstring/char/wchar; addswstring,char,wcharseed types toDocker/server.py(existingasciicovers STRING); seeds aDB1.WSTRING[256]and aDB1.CHAR[300]inDocker/profiles/s7_1500.json; addsDriver_round_trips_string_typessmoke test exercising read + write of every variant; adds a string round-trip assertion toscripts/e2e/test-s7.ps1.
PR-S7-A3 — DTL / DATE_AND_TIME / S5TIME / TIME / TOD / DATE
Closes gap #8 (date/time portion).
-
DTL is 12 bytes: year(u16) / month / day / weekday / hour / minute / second / nanos(u32).
-
DATE_AND_TIME (DT) is 8 bytes BCD: yy mm dd hh mm ss msH msL+dow.
-
S5TIME is 16-bit BCD with a 2-bit time-base.
-
TIME is
Int32ms since 1972 (S7-300/400) or signed-ms duration (S7-1200/1500). -
TOD is
UInt32ms since midnight; DATE isUInt16days since 1990-01-01. -
Files:
S7Driver.cs+ newS7DateTimeCodec.csstatic class encapsulating every encode/decode (keep the driver lean; codec is unit-testable in isolation). -
Tests: round-trip tests per type with golden byte vectors taken from the Siemens "STEP 7 V18 — Programming Reference" document. Snap7-server seed profile gains
dtl,dt,s5time,timetypes. -
Risks: BCD parsing must reject invalid month/day combinations; PLC programs occasionally write 0x00 0x00 ... when uninitialized — surface as
BadOutOfRangerather than parsing to year 0. -
Effort: L (4-5 days incl. all six types and the golden-vector suite).
-
Deps: PR-S7-A1.
-
Docs / fixture / e2e: extends
docs/v2/s7.mdwith a new "Date / time types" subsection documenting DTL / DT (BCD) / S5TIME / TIME / TOD / DATE byte layouts and the S7-300/400 vs S7-1200/1500 TIME-encoding split; adds--type Dtl/--type DateAndTime/--type S5Time/--type Time/--type TimeOfDay/--type Dateto thedocs/Driver.S7.Cli.mdcookbook; updatesdocs/drivers/S7-Test-Fixture.md§"What it actually covers" with the new datetime types and removes "DTL / DATE_AND_TIME" from §5 "Data types beyond the scalars"; addsdtl,dt,s5time,time,tod,dateseed types toDocker/server.pywith golden-byte vectors documented in comments; seedsDB1.DTL[260],DB1.DT[272],DB1.S5TIME[280],DB1.TIME[284],DB1.TOD[288],DB1.DATE[292]inDocker/profiles/s7_1500.json; addsS7DateTimeCodecTests(unit) +Driver_round_trips_datetime_typessmoke test; noscripts/e2e/test-s7.ps1change required (CLI cookbook examples cover the manual surface).
PR-S7-A4 — Array tags (ValueRank=1)
Closes gap #7. S7TagDefinition currently has no array dimension; MapDataType
hard-codes IsArray: false.
- Files:
S7DriverOptions.cs(extendS7TagDefinitionwithArrayDimint? andElementCountint?),S7Driver.cs(read path: detect array tag, issue one byte-range read covering N elements, slice client-side; write path: same in reverse),DiscoverAsyncreportsIsArray: true, ArrayDim: [N]. - Tests: unit tests for
Array[0..9] of IntandArray[0..9] of Real; Snap7-server profile adds an array seed type. Round-trip array-write test proves slice ordering. - Risks: S7-1500 supports multi-dim arrays; declare ValueRank=1 only and document multi-dim as a follow-up. Array-of-UDT lands with PR-S7-D2.
- Effort: M.
- Deps: PR-S7-A1 (byte-range reads).
- Docs / fixture / e2e: adds an "Array tags (ValueRank=1)" subsection
to
docs/v2/s7.mddocumentingArray[0..N]syntax + the multi-dim follow-up note; extendsdocs/Driver.S7.Cli.mdwith an--array-count Nflag in theread/writecookbook and worked examples forArray[0..9] of IntandArray[0..9] of Real; updatesdocs/drivers/S7-Test-Fixture.md§"What it actually covers" to list array round-trips and removes "arrays of structs" from §5 (struct arrays land in PR-S7-D2); extendsDocker/server.pywith anarraymeta-seed-type that takes an inner-type + count and lays out N elements contiguously; seedsDB1.ArrayInt[300](10×Int) andDB1.ArrayReal[320](10×Real) inDocker/profiles/s7_1500.json; addsDriver_round_trips_array_int10+Driver_round_trips_array_real10smoke tests proving slice ordering; adds an array round-trip assertion toscripts/e2e/test-s7.ps1.
PR-S7-A5 — LOGO! 8 + S7-200 V-memory area
Closes gap #19. S7AddressParser currently rejects the V area letter.
- Files:
S7AddressParser.cs(addVcase → maps toS7Area.DataBlockwithDbNumber=1for S7-200 / DbNumber per LOGO! VM-mapping table; document the conversion),S7DriverOptions.cs(note CpuType-dependent meaning of V). - Tests: unit tests for
VW0/VD4/V0.0parsing, both S7-200 and LOGO! conventions; document caller responsibility to setCpuType.S7200orS7200Smart. - Risks: LOGO! VM base address differs by firmware (V0=0 vs V0=1024 depending on block); document the offset table rather than auto-detecting.
- Effort: S (1-2 days, mostly parser + tests; no wire changes).
- Deps: none.
- Docs / fixture / e2e: adds a "LOGO! 8 / S7-200 V-memory" subsection
to
docs/v2/s7.mdcovering theVarea letter, theS7200/S7200SmartCpuType pre-requisite, the LOGO! VM-mapping table by firmware band, and the "V0 = DB1.DBX0.0" semantic; extends the address grammar cheat sheet indocs/Driver.S7.Cli.mdwithVW0/VD4/V0.0rows and a-c S7200Smartworked example; updatesdocs/drivers/S7-Test-Fixture.md§"What it does NOT cover" item 4 to note S7-200 / LOGO! parser coverage now exists at unit level; adds unit-onlyS7AddressParserTestscases — no Snap7 fixture change (server.py already exposes DB1, which is where V-memory aliases land); noscripts/e2e/test-s7.ps1change required (live-LOGO! testing is documented as field-only).
Phase 2 — Performance (multi-tag PDU packing + block coalescing)
PR-S7-B1 — Multi-variable PDU packing
Closes gap #3. ReadAsync(IReadOnlyList<string>) currently issues one
plc.ReadAsync per tag inside the semaphore — N PDUs for N tags.
- Files:
S7Driver.cs(replace per-tag loop with a packer that builds a list ofS7.Net.Types.DataItem, callsplc.ReadMultipleVarsAsync, then fans the results back to the per-tag decoder). Keep the existing per-tag decode switch — only the wire fetch becomes batched. - Tests: integration test that subscribes to 100 tags and asserts the packet count seen by the Snap7 server is 1 (or N / packing-budget) rather than 100. Unit-level test covers packer chunking when the negotiated PDU size won't fit all items.
- Risks:
ReadMultipleVarsAsyncerrors are per-item; we must surface per-tag StatusCodes correctly rather than failing the whole batch on one bad tag. Packing budget =negotiatedPduSize - 18 (header) - per_item(12), conservatively cap at 19 items per PDU on a 240-byte PDU. - Effort: L (5-6 days incl. the per-item-error fan-out semantics).
- Deps: Phase 1 PRs do not block this — but conflicts in
S7Driver.csare likely, so land Phase 1 first. - Docs / fixture / e2e: adds a "Performance — multi-variable PDU
packing" subsection to
docs/v2/s7.mddescribingReadMultipleVarsAsync, the negotiated-PDU packing budget formula (pdu - 18 - 12·N), the 19-items-per-240-byte-PDU rule of thumb, and the per-item-error semantics; nodocs/Driver.S7.Cli.mdchange (CLI is single-tag); no Snap7-server seed change required (existing seeds cover the wire path); addsS7MultiVarPduPackingTeststo the unit suite (planner chunking when items don't fit) + a 100-tag perf integration testDriver_packs_100_tags_into_minimum_pdusthat asserts request-count reduction; noscripts/e2e/test-s7.ps1change required.
PR-S7-B2 — Block-read coalescing for contiguous DBs
Closes gap #22. Reading DB1.DBW0, DB1.DBW2, DB1.DBW4 should issue one
6-byte byte-range read against DB1 starting at offset 0, sliced client-side.
- Files:
S7Driver.csadds a planner pass: group same-DB tags by contiguous byte ranges (gap-merge threshold = configurable, default 16 bytes; over-fetching 16 bytes is cheaper than one extra PDU). Merged ranges become a singlePlc.ReadBytescall; the result is sliced per-tag. - Tests: unit tests for the merge planner (input list → expected ranges); integration test with 50 contiguous DB words proves wire-level reduction.
- Risks: STRINGs / arrays should opt out of merging because the per-tag byte size is variable. Add an "opaque-size" flag so the planner skips them.
- Effort: M.
- Deps: PR-S7-B1 (the multi-var packer). The two interact: the planner emits sum-reads, then the packer puts multiple sum-reads on one PDU.
- Docs / fixture / e2e: extends the §"Performance" section in
docs/v2/s7.mdwith a "Block-read coalescing" subsection — the default 16-byte gap-merge threshold, the opaque-size opt-out for STRINGs / arrays, and operator guidance for tuning the threshold per DB; no CLI doc change; no Snap7-server seed change (existing contiguous DB1 seeds — DBW0 / DBW10 / DBD20 — already exercise contiguous-merge); addsS7BlockCoalescingPlannerTests(unit) covering the merge planner + opaque opt-out; adds a 50-contiguous-DBW integration testDriver_coalesces_contiguous_DBWs_into_single_byte_range_readthat asserts wire-level reduction; noscripts/e2e/test-s7.ps1change.
Phase 3 — Operability
PR-S7-C1 — PDU size negotiation surfaced
Closes gap #2. S7netplus's Plc instance exposes the negotiated PDU size after
OpenAsync via Plc.MaxPDUSize.
- Files:
S7Driver.cs(readPlc.MaxPDUSizeafter open, store on_health; expose viaGetHealth().Diagnostics["NegotiatedPduSize"]— this requires adding aDiagnosticsdictionary toDriverHealth, which is a Core change). Operator-visible via the Admin UI driver-diagnostics panel that already renders Modbus diagnostic stats. - Tests: integration test asserts the value is non-zero after init.
- Risks:
DriverHealthextension must be backward-compatible — existing drivers should still compile against the unchanged record. Make the new property nullable with a default ofnull. - Effort: S.
- Deps: Core
DriverHealthshape change (single PR coordinated with the Modbus diagnostic surface). - Docs / fixture / e2e: adds a "Diagnostics surfacing" subsection to
docs/v2/s7.mddocumenting theDiagnostics["NegotiatedPduSize"]surface + how it renders in the Admin UI driver-diagnostics panel; no CLI doc change (CLI doesn't expose diagnostics); updatesdocs/drivers/S7-Test-Fixture.md§"What it actually covers" with a "negotiated PDU size surfaces in driver health" line; no Snap7 seed-type change (snap7's PDU negotiation is fixed at 240 bytes — document the fixture's negotiated size in the README); addsDriver_exposes_negotiated_pdu_size_post_initsmoke test asserting the value is non-zero; noscripts/e2e/test-s7.ps1change.
PR-S7-C2 — TSAP / Connection Type selector
Closes gap #4. S7netplus picks PG-class TSAPs by default; hardened CPUs may require OP / S7-Basic / Other.
- Files:
S7DriverOptions.cs(newTsapModeenum:Auto/Pg/Op/S7Basic/Other;Autopreserves current behavior. OptionalLocalTsap/RemoteTsapushort?for explicit override).S7Driver.csbranches on the mode to pick the S7netplusPlc(CpuType, ...)constructor vs thePlc(string ip, byte rack, byte slot, ushort localTsap, ushort remoteTsap)raw-TSAP overload. Document the raw-TSAP table indocs/v2/s7.md. - Tests: unit test on the mode → TSAP-byte mapping; live-firmware test documented but only runnable against the dev-box S7-1500 lab rig.
- Risks: wrong TSAP causes connection refused at handshake — same failure shape as wrong slot. Document the mapping prominently.
- Effort: M.
- Deps: none.
- Docs / fixture / e2e: adds a "TSAP / Connection Type" section to
docs/v2/s7.mdcovering theTsapModeenum, the raw-TSAP table (PG = 0x0100/0x0102, OP = 0x0200/0x0202, S7-Basic = 0x0300/0x0302, Other = caller-supplied), and the hardened-CPU motivation; adds--tsap-modeand--local-tsap/--remote-tsapflags todocs/Driver.S7.Cli.md's common-flags table with a worked example hitting an OP-class TSAP; no Snap7 seed change (snap7 accepts any TSAP from the CLI, so the unit-level mapping test is sufficient); no smoke test change (live-firmware-only); noscripts/e2e/test-s7.ps1change.
PR-S7-C3 — Per-tag scan group / publish rate
Closes gap #20. SubscribeAsync takes one publishing interval for the whole
list; mixed 100 ms / 1 s / 10 s tags need three subscribe calls today.
- Files:
S7DriverOptions.cs(extendS7TagDefinitionwith optionalScanGroupstring).S7Driver.cs(SubscribeAsyncpartitions the input list into one poll loop per distinct interval;PollGroupEngine-style internal group, but driver-local — same engine the TwinCAT driver uses). - Tests: unit test with three tags at three rates asserts three independent poll-tick streams; integration test asserts no group starves the others.
- Risks: the
_gatesemaphore still serializes — three poll loops can contend. Document the contention as part of the "1 connection / 1 mailbox" invariant; if it bites, follow-up adds a fairness queue. - Effort: M.
- Deps: none.
- Docs / fixture / e2e: adds a "Per-tag scan groups" subsection to
docs/v2/s7.mddocumentingS7TagDefinition.ScanGroup, the multi-rate partitioning semantics, and the_gatecontention caveat; no CLI doc change (CLI is single-tag); no Snap7 seed change required (existing scalar seeds suffice); addsS7ScanGroupPartitioningTests(unit) +Driver_three_scan_groups_publish_independentlysmoke test that subscribes 3 tags at 100 ms / 1 s / 10 s rates and asserts independent tick streams; noscripts/e2e/test-s7.ps1change (subscribe assertion already covers the polling path).
PR-S7-C4 — Deadband / on-change with thresholds
Closes gap #21. PollOnceAsync currently does !Equals(prev, current) only —
no analog deadband.
- Files:
S7DriverOptions.cs(extendS7TagDefinitionwithDeadbandAbsolute double?andDeadbandPercent double?).S7Driver.cs(PollOnceAsyncevaluates per-tag deadband for numeric types; non-numeric types fall through to exact equality). - Tests: unit tests for absolute and percent deadbands at edge cases (NaN, ±Infinity, sign flip, near-zero percent).
- Risks: percent deadband against a zero baseline diverges; document and fall back to absolute when |baseline| < 1e-6.
- Effort: S.
- Deps: PR-S7-C3 helpful but not required.
- Docs / fixture / e2e: adds a "Deadband / on-change" subsection to
docs/v2/s7.mddocumentingDeadbandAbsolute/DeadbandPercentper tag, NaN / ±Infinity / sign-flip / near-zero-percent edge cases, and the |baseline| < 1e-6 fallback; no CLI doc change (CLI'ssubscribealready polls on change); no Snap7 seed change; addsS7DeadbandTests(unit) covering all edge cases — no integration test required since deadband is pre-publish filtering inside the polling loop; noscripts/e2e/test-s7.ps1change.
PR-S7-C5 — Pre-flight PUT/GET enablement test
Closes gap #24. We currently surface BadDeviceFailure only at first read.
Add a pre-flight check during InitializeAsync (after OpenAsync) that issues
one trivial read (MW0 or the configured Probe.ProbeAddress) and surfaces
the dedicated diagnostic message before declaring DriverState.Healthy.
- Files:
S7Driver.cs(InitializeAsyncadds the probe read; onS7.Net.PlcExceptionwith the PUT/GET-disabled error code, throw a typedS7PutGetDisabledExceptionwith a configuration-fix hint). - Tests: integration test toggles a Snap7 simulator quirk that mimics
the PUT/GET-disabled response (Snap7 doesn't model this; gate the test
on a
--with-real-plcopt-in or document as live-firmware-only). - Risks: pre-flight against a real
Probe.ProbeAddressrequires the address to exist in the PLC; document that the defaultMW0is fine for most installs but allownull/ "skip" for sites that haven't wired one. - Effort: S.
- Deps: none.
- Docs / fixture / e2e: extends the "PUT/GET must be enabled" section
of
docs/Driver.S7.Cli.mdwith the new typedS7PutGetDisabledExceptionmessage + the "skip pre-flight" knob; adds the same content as a "Pre-flight PUT/GET enablement" subsection indocs/v2/s7.md; no Snap7 seed change (snap7 doesn't model PUT/GET-disabled — the test for the success path uses the existing MW0 seed); addsDriver_preflight_passes_when_probe_address_seededsmoke test; documents the live-firmware test as gated on a--with-real-plcopt-in flag indocs/drivers/S7-Test-Fixture.md§"Follow-up candidates"; noscripts/e2e/test-s7.ps1change (probe test already runs first).
Phase 4 — Workflow (symbol import + UDTs + instance DBs)
PR-S7-D1 — Symbol-table / TIA Portal export browse
Closes gap #5. Operators currently hand-edit S7TagDefinition JSON. TIA Portal
exports symbols as .s7p archive → External tags → CSV / SDF. The lighter
target is the CSV format used by the "Generate source from blocks" exporter.
- Files: new
src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/directory:TiaCsvImporter.cs— parses TIA Portal "Show all tags" CSV (Name,Address,Data type,Comment,Visible in HMI). Output: list ofS7TagDefinition.AwlImporter.cs— best-effort AWLVAR_GLOBAL/DATA_BLOCKparser for legacy STEP 7 Classic projects.
- Files (Admin UI): a "Import S7 symbols" button on the Driver Tags tab
that POSTs the file to a new
POST /api/drivers/{id}/import-s7-symbolsendpoint and reports the diff. - Tests: unit tests with golden-input CSV / AWL fixtures; round-trip test that imports → produces tags → reads against simulator.
- Risks: TIA Portal CSV is locale-dependent (decimal-comma in DE locale). Detect from the header row and accept both. UDT-typed symbols import as a placeholder until PR-S7-D2.
- Effort: L (5-7 days incl. the Admin UI flow).
- Deps: see Open Question (c) — confirm CSV+AWL is the right scope, or
whether
.s7p/.ziparchive parsing is required. - Docs / fixture / e2e: adds new doc
docs/drivers/S7-TIA-Import.mddocumenting the supported TIA Portal CSV format (column names, locale-comma detection, UDT-typed placeholders) and the AWLVAR_GLOBAL/DATA_BLOCKparser scope; cross-links it fromdocs/v2/s7.md's new "Symbol import" section and fromdocs/Driver.S7.Cli.mdwith a futureimportsubcommand hook; adds golden-input fixturestests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Fixtures/sample_tia_export.csv,sample_tia_export_de_locale.csv, andsample_step7_classic.awl; no Snap7 seed change required (existing DB1 seeds support the import-then-read round-trip); addsTiaCsvImporterTestsandAwlImporterTests(unit) +Driver_imports_csv_then_reads_seeded_tagsintegration test that imports the sample CSV → reads via Snap7; noscripts/e2e/test-s7.ps1change (Admin-UI flow has its own end-to-end coverage in the Admin UI test suite).
PR-S7-D2 — UDT / STRUCT / nested-DB handling
Closes gap #6. Today's tag map is flat scalar-only; UDT-typed DBs are unusable without hand-flattening every member.
- Files:
S7DriverOptions.cs(extendS7TagDefinitionwithUdtName string?; alongside, a newIReadOnlyList<S7UdtDefinition> Udtson the options that declares the layout: name, ordered members(Name, Offset, S7DataType, ArrayDim?)).S7Driver.csfans a UDT-typed tag into per-member sub-tags atInitializeAsync, so the read/write path stays scalar-only. - Tests: unit tests for fan-out with nested UDTs (UDT-of-UDT); integration test with a Snap7 DB seeded as a UDT-shape byte array proves the fan-out decodes correctly.
- Risks: UDT-of-UDT arbitrary nesting depth — cap at 4 levels and reject deeper with a clear error. Optimized DBs would let TIA reorder members, re-introducing gap #1; document that user-defined UDTs require "Optimized block access" off, same as the general DB rule.
- Effort: L (1-2 weeks).
- Deps: PR-S7-D1 (symbol importer drops UDT-typed entries with a placeholder; D2 makes those usable).
- Docs / fixture / e2e: adds a "UDT / STRUCT support" section to
docs/v2/s7.mddocumentingS7UdtDefinition, the fan-out semantics, the 4-level nesting cap, and the "Optimized block access must be off" prerequisite; extendsdocs/drivers/S7-TIA-Import.md(created in PR-S7-D1) with a UDT-typed-entry section showing how the importer +Udtsdeclaration cooperate; updatesdocs/drivers/S7-Test-Fixture.md§"What it does NOT cover" item 5 to remove "UDT fan-out"; extendsDocker/server.pywith audt_layoutmeta-seed-type that lays out per-member offsets within a DB byte range; seeds aDB1.MyUdt[400](e.g. Real + Int + Bool) inDocker/profiles/s7_1500.json; addsS7UdtFanOutTests(unit) +Driver_fans_out_udt_into_member_tagsintegration test covering a nested-UDT case; adds a UDT-member round-trip assertion toscripts/e2e/test-s7.ps1.
PR-S7-D3 — Instance-DB / FB parameter access
Closes gap #10. Multi-instance FBs are addressed symbolically (MyFB_Instance.MyParam)
with no fixed absolute DB byte offset visible without a TIA project export.
- Files: extends PR-S7-D1's importer to recognize "instance DB" entries
(TIA export shows them with a different "DB type" column value); the
importer translates
MyFB_Instance.MyParamto the resolvedDBn.DBW_offsetbased on the FB's interface declaration in the export. - Tests: golden-input test with an FB-instance DB export; resolved addresses match Siemens reference.
- Risks: when the FB interface changes (TIA "online change"), instance-DB layouts shift. Document that re-import is required after any FB-interface edit. Eventually surface this as a startup warning when the symbol-table hash differs from the imported snapshot — out of scope for this PR.
- Effort: M.
- Deps: PR-S7-D1, PR-S7-D2.
- Docs / fixture / e2e: extends
docs/drivers/S7-TIA-Import.mdwith an "Instance DBs / FB parameters" section covering the importer'sMyFB_Instance.MyParam→DBn.DBW_offsetresolution, the "DB type" column convention, and the "re-import on FB-interface edit" caveat; adds the same caveat as a paragraph indocs/v2/s7.md's "UDT / STRUCT" section; adds a golden-input fixtureFixtures/sample_tia_export_with_fb_instance.csvto the integration tests; no Snap7 seed change required (resolved addresses land in DB1 which the existing seeds back); addsInstanceDbResolverTests(unit) +Driver_resolves_fb_instance_then_reads_seeded_memberintegration test; noscripts/e2e/test-s7.ps1change (FB-instance lookup is an import-time concern).
Phase 5 — Diagnostics & security
PR-S7-E1 — CPU diagnostic buffer / SZL reads
Closes gap #11. SZL (System Status List) IDs surface CPU type, firmware version, cycle-time min/avg/max, and the diagnostic-buffer entries.
- Files:
S7Driver.csexposes a small set of "system tags" alongsideTags— virtual addresses prefixed@System.that the read path recognizes and dispatches to S7netplus'sReadSzlAsync(or, if not exposed, a rawPlc.ReadBytesagainst the SZL-via-S7comm sub-protocol):@System.CpuType,@System.Firmware,@System.OrderNo— SZL 0x0011@System.CycleMs.Min/.Max/.Avg— SZL 0x0132 / 0x0432@System.DiagBuffer[0..N]— SZL 0x00A0 ring-buffer entries
- Files (discovery):
DiscoverAsyncadds aDiagnostics/subfolder with the system-tag set whenS7DriverOptions.ExposeSystemTags = true. - Tests: unit tests for the SZL response parser (golden bytes); live- firmware test against the dev-box S7-1500.
- Risks: S7netplus's SZL surface is incomplete; may need a raw
Plc.ReadBytesagainst0x84register or a small SZL-PDU helper. - Effort: M-L.
- Deps: PR-S7-C1 (
DriverHealth.Diagnosticsdictionary already there). - Docs / fixture / e2e: adds a "CPU diagnostics (SZL)" section to
docs/v2/s7.mdlisting the exposed@System.*virtual addresses, the underlying SZL IDs, and theExposeSystemTagsopt-in; extendsdocs/Driver.S7.Cli.mdwith a workedread -a @System.CpuTypeexample in the cookbook; updatesdocs/drivers/S7-Test-Fixture.md§"What it does NOT cover" with a note that snap7 does not implement SZL — golden- byte unit tests cover the parser, live SZL is gated on a real S7-1500; no Snap7 seed change (snap7 returns a fixed handshake banner that the test checks for "SZL not supported on simulator" branch); addsS7SzlParserTests(unit) with golden bytes; documents the live SZL test indocs/drivers/S7-Test-Fixture.md§"Follow-up candidates"; noscripts/e2e/test-s7.ps1change.
PR-S7-E2 — PLC password / protection-level handling
Closes gap #14. S7-300/400 protection levels 1-3 and S7-1200/1500 connection mechanisms can require a password on connect.
- Files:
S7DriverOptions.cs(newPassword string?andProtectionLevelenum).S7Driver.cscalls S7netplus'sSetPassword(if the API surfaces it — newer S7netplus versions shipPlc.SendPassword(string); if not, raw-PDU fallback per Siemens "Communication Function Manual" §5.2). - Tests: live-firmware-gated; password-tier failure modes don't reproduce in Snap7. Unit-level coverage for the options-binding shape only.
- Risks: S7netplus may not expose password auth — fallback is to call into
the lower-level
S7.Net.S7Protocoltypes or to fork. Land the options surface unconditionally, gate the wire path on library support, document the limitation if the library doesn't oblige. - Effort: M (S if S7netplus ships it; L if we need a fallback path).
- Deps: none.
- Docs / fixture / e2e: adds a "PLC password / protection levels"
section to
docs/v2/s7.mddocumenting thePassword/ProtectionLeveloptions + the S7-300/400 levels 1-3 vs S7-1200/1500 connection-mechanism semantics + the "limitation if S7netplus doesn't shipSendPassword" note; adds a--passwordflag todocs/Driver.S7.Cli.md's common-flags table with a hardened-CPU worked example; updatesdocs/drivers/S7-Test-Fixture.md§"What it does NOT cover" with a "password / protection levels not modelled by snap7" note; no Snap7 seed change (snap7 doesn't enforce protection levels); adds options-binding unit tests only — no integration test (live-firmware-only); noscripts/e2e/test-s7.ps1change.
Phase 6 — S7-1500 Optimized DB / Symbolic addressing (decision PR)
PR-S7-F — Optimized DB / S7Plus
Closes gap #1. This is an architectural decision PR, not a code PR.
S7netplus speaks classic S7comm only. Optimized DBs on S7-1500 (default for
new TIA projects) reorder fields and have no fixed byte offsets — absolute
DB1.DBW0 reads return BadDeviceFailure. Three tracks:
- Document the constraint and stay on S7netplus. Operators must uncheck "Optimized block access" in TIA Portal for any DB the driver reads. This is what the test fixture already documents. Effort: S (docs only).
- Migrate to a library that supports S7Plus.
- Snap7 v2 /
Snap7Net— C-library wrapper, supports classic S7comm only (same limitation as S7netplus). Not a fix. - Sharp7 fork — community fork of Snap7 with partial S7-1200/1500 PUT/GET semantics. Still classic S7comm.
- Custom S7Plus implementation — Wireshark dissector exists; reverse engineering is substantial. Effort: ≥ 4 weeks; ongoing protocol-version maintenance. Risk: Siemens has not published S7Plus.
- Snap7 v2 /
- Embed an OPC UA → OPC UA bridge to the S7-1500's onboard OPC UA server.
The S7-1500 V2.5+ exposes its own OPC UA server with full symbolic access.
Our
OPC UA Client driver(already shipping per memory) could read the target CPU's OPC UA server and re-publish — sidesteps S7Plus entirely. Effort: S; semantics: requires the customer to license Siemens OPC UA on the CPU. Most modern S7-1500 deployments already license it.
Recommendation: ship Track 1 docs immediately (closes the operator expectation gap) and Track 3 as the Optimized-DB workflow path (re-uses existing OPC UA Client driver). Track 2 (S7Plus reverse-engineering) is out of scope unless a customer pays for it.
- Files:
docs/v2/s7.md(Optimized DB section + how to disable),docs/featuregaps.mdrow #1 updated to reflect the Track 1+3 decision. - Tests: live-firmware test against the dev-box S7-1500 with optimized
block access toggled both ways, asserting
BadDeviceFailurevs successful read. - Risks: Track 3's OPC-UA-Client-bridging needs Admin UI plumbing to configure; that's a larger workstream tracked separately.
- Effort: S (docs + decision); L if Track 2 is taken.
- Deps: Open Question (a) below.
- Docs / fixture / e2e: rewrites
docs/v2/s7.mdto land a prominent "Optimized DB constraint" section at the top — explicitly documents the S7-1200 V4.0+ / S7-1500 default, theBadDeviceFailureshape on absoluteDB1.DBW0reads against an optimized DB, the "Uncheck Optimized block access in TIA Portal" fix, and the recommended bridge-via-OpcUaClient pattern with a worked example (Siemens S7-1500 V2.5+ onboard OPC UA server →OpcUaClientdriver → re-publish on the OtOpcUa server's address space); updatesdocs/featuregaps.mdrow #1 to reflect the Track 1+3 decision; updates the "Optimized-DB" line ofdocs/drivers/S7-Test-Fixture.md§"What it does NOT cover" item 4 to point at the new doc; no CLI doc change (CLI is a probe tool, not the bridging path); no Snap7 fixture change (snap7 has no Optimized- DB mode); the live-firmware test toggling Optimized block access on / off is recorded as a manual checklist indocs/drivers/S7-Test-Fixture.md§"Follow-up candidates" and gated behind--with-real-plc; if Track 2 is taken later, this PR's doc surface becomes the migration baseline; noscripts/e2e/test-s7.ps1change.
Documentation, fixture, and e2e impact
Consolidated view of every per-PR Docs / fixture / e2e line above, so a
reviewer can see the cross-cutting churn at a glance and so the doc /
fixture / e2e maintainers can sequence their work alongside the code PRs.
User-facing documentation churn
| PR | docs/v2/s7.md |
docs/Driver.S7.Cli.md |
docs/drivers/S7-Test-Fixture.md |
New / cross-cut docs |
|---|---|---|---|---|
| PR-S7-A1 (LInt/ULInt/LReal/LWord) | extend type-mapping table | new sizes in cookbook | remove "no 64-bit types" | — |
| PR-S7-A2 (STRING/WSTRING/CHAR/WCHAR) | string layout subsection | --type WString / --string-length |
list new types | — |
| PR-S7-A3 (DTL/DT/S5TIME/TIME/TOD/DATE) | "Date / time types" subsection | datetime cookbook entries | list new types | — |
| PR-S7-A4 (arrays) | "Array tags (ValueRank=1)" subsection | --array-count flag + examples |
list array round-trips | — |
| PR-S7-A5 (V-memory) | "LOGO! 8 / S7-200 V-memory" subsection | grammar table + S7200Smart example | parser coverage note | — |
| PR-S7-B1 (PDU packing) | "Performance — multi-variable PDU packing" subsection | — | — | — |
| PR-S7-B2 (block coalescing) | "Block-read coalescing" subsection | — | — | — |
| PR-S7-C1 (negotiated PDU diag) | "Diagnostics surfacing" subsection | — | "negotiated PDU size" line | — |
| PR-S7-C2 (TSAP) | "TSAP / Connection Type" section | --tsap-mode / --local-tsap / --remote-tsap flags |
— | — |
| PR-S7-C3 (scan groups) | "Per-tag scan groups" subsection | — | — | — |
| PR-S7-C4 (deadband) | "Deadband / on-change" subsection | — | — | — |
| PR-S7-C5 (PUT/GET pre-flight) | "Pre-flight PUT/GET enablement" subsection | extend "PUT/GET must be enabled" | mark live-firmware test | — |
| PR-S7-D1 (TIA CSV / AWL import) | "Symbol import" cross-link | future import subcommand stub |
— | new docs/drivers/S7-TIA-Import.md |
| PR-S7-D2 (UDT / STRUCT) | "UDT / STRUCT support" section | — | remove "UDT fan-out" | extend S7-TIA-Import.md |
| PR-S7-D3 (instance DB) | re-import-on-FB-edit caveat | — | — | extend S7-TIA-Import.md |
| PR-S7-E1 (SZL diagnostics) | "CPU diagnostics (SZL)" section | read -a @System.CpuType example |
"SZL not modelled by snap7" + Follow-up | — |
| PR-S7-E2 (PLC password) | "PLC password / protection levels" section | --password flag |
"password not modelled by snap7" | — |
| PR-S7-F (Optimized DB / S7Plus) | top-level "Optimized DB constraint" + bridge-via-OpcUaClient worked example | — | point §"What it does NOT cover" at new doc | also updates docs/featuregaps.md row #1 |
Snap7-server fixture seed-type additions per PR
The snap7 simulator at localhost:1102 (driven by
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py +
Docker/profiles/s7_1500.json) has a seed_buffer pump with a fixed type
set — u8 / i8 / u16 / i16 / u32 / i32 / f32 / bool / ascii. New PRs need
new seed-type cases in server.py, new offsets in s7_1500.json, and
matching constants in S7_1500Profile.cs. The table below names the
delta for each Build-Yes PR:
| PR | New server.py seed types |
New s7_1500.json seed offsets |
S7_1500Profile.cs additions |
|---|---|---|---|
| PR-S7-A1 | i64, u64, f64 |
DB1.DBL40 (i64), DB1.DBL48 (f64), DB1.DBL56 (u64) |
SmokeI64Tag / SmokeU64Tag / SmokeF64Tag |
| PR-S7-A2 | wstring, char, wchar (existing ascii covers STRING) |
DB1.WSTRING[256], DB1.CHAR[300] |
SmokeWStringTag / SmokeCharTag |
| PR-S7-A3 | dtl, dt, s5time, time, tod, date (golden-byte vectors in comments) |
DB1.DTL[260], DB1.DT[272], DB1.S5TIME[280], DB1.TIME[284], DB1.TOD[288], DB1.DATE[292] |
SmokeDtl / SmokeDt / SmokeS5Time / SmokeTime / SmokeTod / SmokeDate |
| PR-S7-A4 | array meta-seed (inner-type + count) |
DB1.ArrayInt[300] 10×Int, DB1.ArrayReal[320] 10×Real |
ArrayInt10Tag / ArrayReal10Tag |
| PR-S7-A5 | none (V-memory aliases land in DB1, which server.py already exposes) |
none | unit-only — no profile change |
| PR-S7-B1 | none | none (existing scalar seeds suffice for packing) | none — perf integration test reuses scalar tags |
| PR-S7-B2 | none | none (existing contiguous DBW0 / DBW10 / DBD20 already test merge) | none |
| PR-S7-C1 | none | none | none |
| PR-S7-C2 | none (snap7 accepts any TSAP) | none | none |
| PR-S7-C3 | none | none | none |
| PR-S7-C4 | none | none | none |
| PR-S7-C5 | none (existing MK0 MW0 seed covers success path) |
none | none |
| PR-S7-D1 | none (CSV import lands tags pointing at existing seeds) | none | possibly add fixture-pointer constants |
| PR-S7-D2 | udt_layout meta-seed (per-member offsets) |
DB1.MyUdt[400] (Real + Int + Bool layout) |
MyUdtTag + member tags |
| PR-S7-D3 | none (resolved addresses land in DB1) | none | none |
| PR-S7-E1 | none — snap7 doesn't model SZL; unit-level golden bytes cover the parser | none | none |
| PR-S7-E2 | none — snap7 doesn't enforce protection levels; options-binding unit tests only | none | none |
| PR-S7-F | none — snap7 has no Optimized-DB mode; live-firmware checklist instead | none | none |
E2E scripts/e2e/test-s7.ps1 impact
scripts/e2e/test-s7.ps1 runs the five-assertion CLI loopback (probe /
driver-loopback / forward-bridge / reverse-bridge / subscribe-sees-change)
against DB1.DBW0 Int16. Build-Yes PRs that add CLI surface get a
matching loopback assertion; PRs that touch only internals or admin-UI
flows do not.
| PR | E2E script change |
|---|---|
| PR-S7-A1 | add LInt loopback assertion (write 0x7FFFFFFFFFFFFFFF, read back) |
| PR-S7-A2 | add string round-trip assertion |
| PR-S7-A3 | none (CLI cookbook covers manual surface) |
| PR-S7-A4 | add array round-trip assertion |
| PR-S7-A5 | none (live-LOGO! field-only) |
| PR-S7-B1 | none |
| PR-S7-B2 | none |
| PR-S7-C1 | none |
| PR-S7-C2 | none (live-firmware-only) |
| PR-S7-C3 | none (subscribe assertion already covers polling) |
| PR-S7-C4 | none |
| PR-S7-C5 | none (probe runs first today) |
| PR-S7-D1 | none (Admin UI has its own e2e) |
| PR-S7-D2 | add UDT-member round-trip assertion |
| PR-S7-D3 | none (import-time concern) |
| PR-S7-E1 | none |
| PR-S7-E2 | none (live-firmware-only) |
| PR-S7-F | none (decision PR; live-firmware checklist instead) |
Skip-rated items (for context)
| # | Gap | Skip rationale |
|---|---|---|
| 12 | AS-Alarms / Alarm_S / ProDiag | Alarms are a separate workstream; no IAlarmSource shipped on this driver yet, and the gap analysis flags it as a deferred topic. |
| 13 | CPU Run / Stop control / block download | Security and safety risk. PG-class writes that change CPU state are explicitly out of scope. |
| 15 | S7-1500 Secure Communication / TLS | Significant work; S7netplus has no TLS surface. Reconsider when S7Plus track is taken. |
| 16 | S7-400H redundant H-system support | Rare in our deployment scope. Server-level redundancy (docs/Redundancy.md) covers the OPC UA layer; H-system driver-level failover is a separate axis. |
| 17 | Multi-CPU rack parallel sessions | One session per CPU works for the deployments we target; multi-CPU racks are an S7-400 niche. |
| 18 | MPI / Profibus / RFC1006-routed transports | Declining use; brownfield only. S7netplus is Ethernet-only. |
| 23 | Connection-resource budget / parallel jobs | One connection works; premature optimization until a deployment hits the cap. |
Open questions
(a) Library choice for S7Plus
PR-S7-F gates on this decision. Options:
- Stay on S7netplus + document Optimized-DB constraint (preferred default).
- Fork to Sharp7 / Snap7 v2 — does not solve the S7Plus / Optimized-DB problem; both are classic S7comm only. Adopting them buys nothing for this gap. Reject unless we want it for unrelated reasons.
- Custom S7Plus client over Wireshark-dissected protocol — large effort, ongoing maintenance risk. Only if a customer is paying.
- OPC UA → OPC UA bridge via existing OPC UA Client driver — sidesteps S7Plus by re-using Siemens's onboard OPC UA server. Recommended secondary track.
Decision needed before Phase 6 PR-S7-F kicks off.
(b) WriteIdempotent semantics for new types
The WriteIdempotent per-tag flag (decisions #44, #45, #143) governs replay-
safe writes. New types from Phase 1:
- STRING / WSTRING — typically idempotent (recipe / message text). Replay-safe by default? Need confirmation. Risk: PLC programs that treat a new string write as a "new message" event would double-fire.
- DTL / DT — usually written from a clock master; replay-safe.
- Arrays of UDT — depends on the UDT semantics (recipe = safe, command
block = unsafe). Inherit
WriteIdempotentfrom the parent tag, do not add a per-member flag. - 64-bit types — same rule as 32-bit equivalents.
Default: keep WriteIdempotent = false for everything. Operators flip per
tag based on PLC program semantics. No semantic extension needed, but
document the per-type guidance in docs/v2/s7.md.
(c) Symbol-import file format(s)
PR-S7-D1 ships an importer. Which formats?
- TIA Portal CSV (Show all tags / Export) — preferred entry point; most common. Confirm.
- TIA Portal SDF / Excel — same data; harder to parse. Skip unless customer demand emerges.
- STEP 7 Classic AWL / SCL
.AWL— secondary. Useful for legacy S7-300/400 sites still on Classic. Include in D1? .s7p/.zapproject archive — full TIA project. ZIP-shaped; symbol export would require unpacking and parsing internal XML. Large scope. Defer..udt/.SDFexternal tag library — niche; defer unless asked.
Recommendation: PR-S7-D1 ships TIA CSV + AWL only. Anything else is a follow-up. Decision needed before Phase 4 work begins.