Skip to content

[spike] feat(plc4net): revive the .NET port — net8.0, SPI3 runtime, pure-.NET mspec generator - #2656

Draft
TomNewChao wants to merge 61 commits into
apache:developfrom
openIndu:feature/plc4net-revival
Draft

TomNewChao wants to merge 61 commits into
apache:developfrom
openIndu:feature/plc4net-revival

Conversation

@TomNewChao

@TomNewChao TomNewChao commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What this PR is

An end-to-end revival of plc4net as a buildable, testable .NET 8 implementation
of the PLC4X API/SPI. It replaces the abandoned net452 build, aligns the runtime
with SPI3, adds a pure-.NET .mspec → C# generator, and provides working Modbus,
S7 and KNXnet/IP drivers with generated wire models.

It is deliberately a spike, not a merge candidate. Its job was to prove the
stack works against real hardware before the module boundaries were frozen —
decomposing earlier would have been guesswork. That job is now done, so this PR
stays a draft and serves as the reference, while the work lands as a series of
reviewable PRs against develop.

What I'd like from this thread: agreement on the decomposition list below.
Nothing else is blocking. If the order looks wrong to you, that is much cheaper
to say now than after the first slice is open.

Targets develop / 1.1.0-SNAPSHOT, relates to #2655, follows the dev@
discussion from 2026-07-24. Currently 52 commits ahead of the merge base, 38
behind develop.

Proposed decomposition

Sizes for PR0 and PR1 are measured — I built that reduced tree and ran it. The
rest are estimates from the current file layout.

PR Contents Size
0 Delete the abandoned net452 knxproj driver (drivers/knxnetip + its test) and the build wiring that fed it 187 files, −45.3k lines, 0 insertions
1 Build infrastructure, CI, pom.xml bridge, LICENSE/NOTICE, api/, spi/, the loopback test transport, and the SPI test project — this is what replaces the remaining net452 api/ and spi/ 129 files, ~9.3k lines. Builds clean, 113 tests pass
2 TCP transport ~6 files
3 The .mspec → C# generator — tool only, no generated models ~35 files, ~13.6k lines (the checked-in ANTLR parser dominates)
4 Modbus TCP, migrated onto the generated model ~72 files, ~6.4k lines
5 COTP transport + S7 driver (this is where the hardware evidence lands) ~175 files, ~14.4k lines
6 Serial transport + Modbus RTU, same migration extended to RTU framing ~20 files
7 UDP + KNXnet/IP, or deferred past 1.0 ~194 files, ~33.5k lines

Two notes on the shape of that list:

  • PR0 exists so PR1 can be reviewed. Folding the deletion into PR1 makes its
    diff ~58k lines, which is worse than the status quo. Split out, the deletion is
    reviewable at a glance — --stat plus the fact that nothing outside it
    references the removed tree.
  • PR4 migrates Modbus onto the generated model — S7 and KNXnet/IP already made
    this move, Modbus is the one holdout.
    S7Connection calls the generated
    S7Message.StaticParse/Serialize directly, and so does
    KnxNetIpMessageCodec for KnxNetIpMessage. ModbusConnection /
    ModbusRtuConnection still hand-roll PDU and framing bytes in
    drivers/modbus/messages/ModbusPDU.cs / ModbusCRC.cs, entirely separate from
    the generated ModbusPDU / ModbusTcpADU / ModbusRtuADU types that only
    ModbusGeneratedRoundTripTests exercises today — so the shared-vector proof
    and the hardware evidence currently cover different code. PR4 does for Modbus
    what already happened for the other two, rather than shipping the hand-written
    codec now and deleting it later. This is a real migration, not omitted code —
    a handful of ModbusDriverTests assert directly on the hand-written builders'
    byte output and will need rewriting or retiring, and some of that coverage is
    already redundant with the generated round-trip tests. Since ModbusPDU itself
    doesn't distinguish TCP from RTU (only ModbusTcpADU vs ModbusRtuADU do),
    PR4 should build the PDU layer protocol-neutral so PR6 only swaps the framing
    layer instead of repeating the PDU work.

Generated model trees travel with their driver's PR. They are regenerable from
the .mspec and are not meant to be read line by line; each driver PR will say
so explicitly so reviewers can skip them.

Hardware verification

Full procedures, run logs, troubleshooting tables and PLC-side image evidence:
plc4net/docs/hardware-verification.md.

Protocol Rig Result
S7 Siemens S7-1214C DC/DC/DC, rack 0 / slot 1 PASS 50/50; persistent I/Q/M/DB matrix 43/43 with independent read-back over new connections 25/25
Modbus TCP tools/modbus-tcp-sim.py — no Modbus/TCP device on hand PASS 5/5
Modbus RTU Mitsubishi QJ71C24N in non-procedure mode, as a fixed-response slave PASS — raw frame exchange and driver read
KNXnet/IP none — scripted loopback gateway only not hardware-verified

Being precise about what each of those is worth:

  • S7 is verified in the strong sense. A real CPU: COTP CR/CC, Setup
    Communication (PDU 240), every scalar width from a DB, absolute %I/%Q/%M
    bit/byte/word/dword addressing, read-before → write → read-back for all seven
    types across DB100, M100..M117 and Q0..Q17, then every target read again over
    fresh connections. Plus an error path. The run also found and fixed a real
    driver bug: a bare Ack (ROSCTR 0x02) carries the same 2-byte header error as
    an AckData and must be framed as 12 bytes, otherwise every following response
    desyncs.
  • Modbus RTU is verified at the wire and framing level only. The QJ71C24N has
    no native Modbus slave firmware, so its ladder answers every request with the
    same canned frame regardless of function code, address or quantity. That proves
    the serial transport, RTU framing, CRC and the driver read path against a real
    byte-at-a-time UART. It does not exercise slave-side address-range handling or
    exception codes. An earlier attempt against an S7-1214C + CM 1241 running
    MB_SLAVE was retired after its RS-485 bench link never completed a round trip
    — isolated to the link, not the driver.
  • Modbus TCP was verified against a checked-in stdlib-only fixture
    (tools/modbus-tcp-sim.py), not a physical device — reproducible by anyone
    reading this, but still not hardware.
  • KNXnet/IP has no hardware verification at all. Its only oracle is a fake
    gateway in the test suite, which I wrote from the same reading of the spec as
    the driver — so it cannot catch a shared misunderstanding.

PLC-side evidence, in TIA Portal and GX Works2 rather than from my own tooling —
the S7 absolute addresses after the persistent write, read back independently:

I/Q/M watch table

Also checked in: DB100 online values,
the S7-1214C bench,
the QJ71C24N bench,
and the QJ71C24N ladder
showing the G.INPUT / G.OUTPUT rungs behind the interlock that had been
swallowing the response.

Automated verification

  • dotnet build plc4net/plc4net.sln --no-restore --no-incremental: 0 warnings,
    0 errors
    .
  • dotnet test plc4net/plc4net.sln --no-build: 449/449 passed (404 SPI/driver
    • 45 KNXnet/IP). S7-filtered selection: 103/103.
  • The generated Modbus and S7 models round-trip the shared
    ParserSerializerTestsuite.xml vectors — the same bytes plc4j and plc4go
    validate — byte-identically, plus hand-built IEC-61131 vectors for the
    DataItem dataIo value codec. KNXnet/IP handshake, group read/write and bus
    monitoring run against a scripted UDP gateway.
  • A CI job regenerates the Modbus, S7 and KNXnet/IP models from the .mspec and
    fails on any drift, by staging and then git diff --cached --exit-code.
  • Packaging verified end to end: dotnet pack → local folder feed →
    PackageReference from an independent consumer project.

Caveat I should state rather than let you find: GitHub Actions has never
produced a check run on this PR — the workflow requires authorization for
non-committer PRs. So every number above is from my machine. Getting any one
slice merged clears that gate and makes the rest self-verifying, which is another
reason PR0 is worth doing first.

Known limitations

The ones I would raise if I were reviewing this:

  • The transport SPI is a polling contract. ITransportInstance exposes
    GetNumBytesAvailable() / Read(n), so framing consumers spin with short
    sleeps, and connect paths block on async work. plc4j is netty-driven and plc4go
    channel-driven; this is the piece I would most want your opinion on before it
    is set, because it is the hardest thing to change afterwards.
  • Subscriptions are declared but not implemented. IPlcConnection exposes
    subscription builders and six IPlcSubscription* interfaces with no
    implementation; the KNX bus monitor uses a driver-specific callback instead.
    Dead public API in a published package is a compatibility trap — I would rather
    remove it before any release than keep it.
  • <Nullable>annotations</Nullable>, not enable, so null-flow warnings are
    off and the nullable annotations are currently documentation rather than
    enforcement.
  • Multi-PDU requests are rejected with a clear error rather than split; COTP
    fragmentation of an oversized payload is not implemented.
  • tools/code-gen forks the shared .mspec ANTLR grammar (one lexer predicate
    ported from the Java runtime API to the C# one). Nothing currently detects
    divergence if the shared grammar changes; a CI step diffing the two would fix
    that and I am happy to add it.

Provenance and licensing

plc4net/tools/code-gen/Expression.g4 is a verbatim copy of the shared grammar.
It had the upstream Unlicense dedication replaced with an ASF header, which was
wrong of me — the file is public domain and credits
bkiers/tiny-language-antlr4. It is now byte-identical to the upstream copy
again, both paths are listed in the root LICENSE, and it is excluded from the
ASF header checks that would otherwise demand a header it should not carry.

While fixing that I noticed the root pom.xml RAT exclusion for
**/Expression.g4 is spelled <excinputExcludelude>, so it never applied. That
looks like an unrelated find/replace accident and I have left it alone — flagging
it in case it matters for the next release audit.

Review guide, if you want to look at the spike as-is

655 files changed, of which 422 are generated model or parser code. The
hand-written surface is ~230 files. A focused pass:

  1. .github/workflows/dotnet-platform.yml and plc4net/Directory.Build.props
  2. plc4net/api/ and the hand-written runtime under plc4net/spi/
  3. plc4net/tools/code-gen/ excluding src/generated/
  4. Hand-written driver files under plc4net/drivers/{modbus,s7,knxnetip}/
  5. plc4net/test/, plc4net/docs/design.md, plc4net/docs/hardware-verification.md

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Revives the plc4net (.NET) port by modernizing it to .NET 8, restoring test execution, aligning key API/SPI surfaces with PLC4X SPI3 concepts, and adding a foundational driver runtime plus a TCP transport implementation.

Changes:

  • Retarget projects to net8.0 and centralize shared build/package properties via Directory.Build.props.
  • Fix the PLC value model’s virtual dispatch and add a bit-level codec (BitReader/BitWriter) + repaired ReadBuffer/WriteBuffer.
  • Add SPI3-aligned runtime building blocks (connection-string parsing, driver/connection bases, message codec) and a TCP transport with CI workflow coverage.

Reviewed changes

Copilot reviewed 49 out of 49 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
plc4net/transports/tcp/TcpTransportInstance.cs Adds async TCP transport instance with background read loop + ring buffer.
plc4net/transports/tcp/TcpTransportConfiguration.cs Defines TCP transport configuration defaults and options.
plc4net/transports/tcp/TcpTransport.cs Implements TCP transport factory + address parsing and option parsing.
plc4net/transports/tcp/plc4net-transport-tcp.csproj Introduces TCP transport project.
plc4net/spi/spi/transports/TransportException.cs Adds transport-specific exception type.
plc4net/spi/spi/transports/RingBuffer.cs Adds fixed-capacity ring buffer used by transports/codecs.
plc4net/spi/spi/transports/ITransportInstance.cs Introduces transport instance contracts (sync + async listener variant).
plc4net/spi/spi/transports/ITransport.cs Introduces transport factory + transport manager registry.
plc4net/spi/spi/transports/BaseTransportInstance.cs Adds base transport instance with config + driver-config handling and Dispose.
plc4net/spi/spi/model/values/PlcWSTRING.cs Fixes string value dispatch/exposure.
plc4net/spi/spi/model/values/PlcWORD.cs Fixes overridden bit-accessors for WORD.
plc4net/spi/spi/model/values/PlcWCHAR.cs Fixes string dispatch/exposure for WCHAR.
plc4net/spi/spi/model/values/PlcValueAdapter.cs Makes IPlcValue API virtual to enable correct overriding/dispatch.
plc4net/spi/spi/model/values/PlcSTRING.cs Fixes string dispatch/exposure.
plc4net/spi/spi/model/values/PlcSimpleValueAdapter.cs Fixes overriding for “simple value” classification.
plc4net/spi/spi/model/values/PlcSimpleNumericValueAdapter.cs Fixes numeric conversions/range checks and interface dispatch.
plc4net/spi/spi/model/values/PlcLWORD.cs Fixes overridden bit-accessors for LWORD.
plc4net/spi/spi/model/values/PlcDWORD.cs Fixes overridden bit-accessors for DWORD.
plc4net/spi/spi/model/values/PlcCHAR.cs Fixes string dispatch/exposure for CHAR.
plc4net/spi/spi/model/values/PlcBYTE.cs Fixes overridden bit-accessors for BYTE.
plc4net/spi/spi/model/values/PlcBOOL.cs Fixes BOOL accessor dispatch and adds conversions.
plc4net/spi/spi/generation/WriteBuffer.cs Reworks write buffer to use in-house bit writer + fixes float/string/array writing.
plc4net/spi/spi/generation/ReadBuffer.cs Reworks read buffer to use in-house bit reader + fixes numeric/string/array reading.
plc4net/spi/spi/generation/ParseException.cs Makes ParseException a real Exception type.
plc4net/spi/spi/generation/BitWriter.cs Adds MSB-first bit writer.
plc4net/spi/spi/generation/BitReader.cs Adds MSB-first bit reader.
plc4net/spi/spi/drivers/MessageCodecBase.cs Adds SPI3-like message codec base and IMessage contract.
plc4net/spi/spi/drivers/DriverBase.cs Adds SPI3-like driver base (transport resolution + connection creation).
plc4net/spi/spi/drivers/ConnectionBase.cs Adds SPI3-like connection base wrapping a transport instance.
plc4net/spi/plc4net-spi.csproj Removes net45-only dependency and aligns packaging with shared props.
plc4net/spi-test/test/transports/TcpTransportAddressTests.cs Adds tests for TCP transport address parsing.
plc4net/spi-test/test/transports/RingBufferTests.cs Adds ring buffer unit tests.
plc4net/spi-test/test/model/values/PlcValueTests.cs Adds interface-dispatch-focused value model tests.
plc4net/spi-test/test/generation/BufferTests.cs Adds codec round-trip tests for bit reader/writer and buffers.
plc4net/spi-test/test/drivers/DriverBaseTests.cs Adds driver-base/transport-selection tests.
plc4net/spi-test/test/drivers/ConnectionStringTests.cs Adds tests for SPI3-aligned connection string parsing + secret redaction.
plc4net/spi-test/plc4net-spi-test.csproj Adds dedicated SPI test project with proper test SDK refs.
plc4net/plc4net.sln Updates solution to include new test + transport projects and platforms.
plc4net/drivers/knxnetip/plc4net-driver-knxproj.csproj Updates KNX driver project dependencies (e.g., NLog).
plc4net/drivers/knxnetip-test/test/knxnetip/readwrite/model/KnxDatapointTests.cs Fixes KNX test vector and asserts float parsing.
plc4net/drivers/knxnetip-test/plc4net-driver-knxproj-test.csproj Ensures test suite actually runs (adds Microsoft.NET.Test.Sdk, marks non-packable).
plc4net/Directory.Build.props Centralizes target framework + shared packaging/build properties.
plc4net/api/PlcDriverManager.cs Refactors driver manager to SPI3-style registry and sync connection creation.
plc4net/api/plc4net-api.csproj Aligns API project with centralized build props.
plc4net/api/api/model/IPlcTag.cs Renames Field→Tag concept for SPI3 alignment.
plc4net/api/api/IPlcDriver.cs Updates driver contract to sync SPI3-like Connect() methods.
plc4net/api/api/IPlcConnection.cs Updates connection contract to sync Close() + tag parsing.
plc4net/api/api/ConnectionString.cs Adds SPI3-aligned connection-string parser + secret redaction.
.github/workflows/dotnet-platform.yml Adds cross-platform CI job for building and running .NET tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread plc4net/transports/tcp/TcpTransport.cs
Comment thread plc4net/api/PlcDriverManager.cs
Comment thread plc4net/spi/spi/transports/RingBuffer.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 55 out of 55 changed files in this pull request and generated 3 comments.

Comment thread plc4net/api/api/ConnectionString.cs
Comment thread plc4net/api/api/ConnectionString.cs
Comment thread plc4net/transports/tcp/TcpTransportInstance.cs Outdated
@sruehl
sruehl requested a review from Copilot July 27, 2026 08:47
@chrisdutz

Copy link
Copy Markdown
Contributor

Wow ... quite a bit happened here during my few days off :-)

So one thing I wanted to point out: Even if in Java several external dependencies existed for various things (such as BitBuffers etc), I still rewrote everything from scratch in SPI3. One the one side, this way I could build a perfect fit for PLC4X, but also I thought this would be a perfect template for porting to other languages as there is no need to fins a "suitable but mostly not 100% replacement".

Another thing I noticed: We currently rely on the plc4x build-tools maven plugin to generate code. In my commercial offering I decided to give something else a try: I built an mspec parser and code-generator that fits the target language (in that case Rust) ... possibly it might be worth investigating the options to use a pure dotnet code generation tool ... the antlr4 grammar for mspec should help a lot with that. In the end a pure dotnet toolchain would eliminate the dependency on Java for dotnet developers while still being able to call the build from the overall maven reactor.

I think the API diverged quite a bit since it was created several years ago, but i think I saw you already picked up some of these changes.

If you need any help ...don't hesitate pinging me. I can also invite you in the plc4x slack channel, if you want more instant feedback (during sensible EU times ;-) )

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 55 out of 55 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

plc4net/transports/tcp/TcpTransportInstance.cs:1

  • On orderly remote shutdown (bytesRead == 0), the socket is not disposed here. Because _open is set to 0 before any call to Close(), subsequent Close() calls can become no-ops (due to the CAS guard), leaving the socket/resources to finalization. Consider triggering the normal close/dispose path here (e.g., perform the same CAS-based shutdown/dispose sequence used by Close()), or refactor Close() to always dispose the socket even if _open is already 0.
    plc4net/transports/tcp/TcpTransportInstance.cs:1
  • Prefer Array.Empty<byte>() over new byte[0] to avoid an unnecessary allocation and follow common .NET conventions for empty arrays.

Comment thread .github/workflows/dotnet-platform.yml Outdated
Comment thread .github/workflows/dotnet-platform.yml Outdated
Comment thread plc4net/plc4net.sln
@TomNewChao

Copy link
Copy Markdown
Contributor Author

Wow ... quite a bit happened here during my few days off :-)

So one thing I wanted to point out: Even if in Java several external dependencies existed for various things (such as BitBuffers etc), I still rewrote everything from scratch in SPI3. One the one side, this way I could build a perfect fit for PLC4X, but also I thought this would be a perfect template for porting to other languages as there is no need to fins a "suitable but mostly not 100% replacement".

Another thing I noticed: We currently rely on the plc4x build-tools maven plugin to generate code. In my commercial offering I decided to give something else a try: I built an mspec parser and code-generator that fits the target language (in that case Rust) ... possibly it might be worth investigating the options to use a pure dotnet code generation tool ... the antlr4 grammar for mspec should help a lot with that. In the end a pure dotnet toolchain would eliminate the dependency on Java for dotnet developers while still being able to call the build from the overall maven reactor.

I think the API diverged quite a bit since it was created several years ago, but i think I saw you already picked up some of these changes.

If you need any help ...don't hesitate pinging me. I can also invite you in the plc4x slack channel, if you want more instant feedback (during sensible EU times ;-) )

Thanks for the pointers — the code generation one lands on the open question in the description, and with an option I hadn't considered.

The bit buffers were the same call in miniature: I dropped Ayx.BitIO and wrote the reader/writer rather than hunting for a closer package. That you rewrote that layer in SPI3 for the same reason is useful — I'll take SPI3 as the reference to follow rather than a source to copy line by line, and say so where .NET pushes a different shape.

On a pure .NET toolchain: agreed, and for the reason you give. Removing the Java dependency for .NET developers is worth more than finishing the freemarker templates. I had a look at the antlr4 grammar and the parser side does look straightforward — the work sits above it, in the type model.

Slack sounds like the right place for the rest — yes please, and thanks for the offer. I'm on UTC+8, so your working day runs from my afternoon into my evening; that lands inside sensible hours on both ends.

@chrisdutz

Copy link
Copy Markdown
Contributor

As it's challenging to get github-user-to-email-addresses ... please send me the address I should send the invite to cdutz@apache.org

@TomNewChao
TomNewChao force-pushed the feature/plc4net-revival branch from b6bb5ba to fa0c898 Compare July 27, 2026 14:08
@sruehl
sruehl requested a review from Copilot July 27, 2026 15:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 56 out of 56 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

plc4net/transports/tcp/TcpTransportInstance.cs:1

  • Socket.Send(...) can legally return 0 (e.g., when the connection has been closed), which would make this loop spin forever because offset never increases. Consider capturing the return value, and if it is 0, treat it as a connection failure (throw TransportException / close the connection) to avoid an infinite loop.
    plc4net/transports/tcp/TcpTransportInstance.cs:1
  • When the ring buffer is full, the read loop polls with a 1ms delay. Under sustained backpressure this can cause unnecessary wakeups/CPU usage. Consider replacing this polling with a waitable signal (e.g., a SemaphoreSlim/AsyncAutoResetEvent that the consumer signals after draining), or at least use a larger/exponential backoff delay to reduce churn.

Comment thread plc4net/api/PlcDriverManager.cs Outdated
Comment thread plc4net/api/PlcDriverManager.cs Outdated
Comment thread .github/workflows/dotnet-platform.yml Outdated
@TomNewChao
TomNewChao force-pushed the feature/plc4net-revival branch 3 times, most recently from e285d48 to cf0302b Compare July 28, 2026 01:56
@sruehl
sruehl requested a review from Copilot July 28, 2026 06:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 56 out of 56 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (5)

.github/workflows/dotnet-platform.yml:28

  • The path filter plc4net** is overly broad (it also matches paths that merely start with plc4net, e.g. plc4netfoo/...). Using plc4net/** is the typical and more precise way to scope to the directory tree.
    paths:
      - code-generation/**
      - protocols/**
      - plc4net**
  pull_request:

.github/workflows/dotnet-platform.yml:65

  • actions/setup-java is configured with distribution: 'adopt', but AdoptOpenJDK has been superseded by Eclipse Temurin and may stop being supported/updated. Switching to temurin keeps the workflow on a maintained JDK distribution.
          distribution: 'adopt'

plc4net/spi/spi/transports/RingBuffer.cs:140

  • The comment says a single subtraction replaces a modulo, but the implementation uses a modulo. Either update the comment or change the implementation so the documentation matches the behavior.
    plc4net/spi/spi/generation/ReadBuffer.cs:61
  • HasMore currently returns true for negative bitLength values, which is nonsensical and can mask caller bugs. Consider rejecting negative sizes explicitly.
    plc4net/transports/tcp/TcpTransport.cs:56
  • receive-buffer-size can be set to 0 (or negative) via the connection string, which then crashes TcpTransportInstance when constructing the RingBuffer (capacity must be positive). Consider treating non-positive values as invalid and falling back to the default here.

@sruehl

sruehl commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@chrisdutz should that be part of 1.0.0?

@chrisdutz

chrisdutz commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Well we shouldn't postpone the release too long. If something usable is done soon, sure. Otherwise the next release could be done any time.

Is actually a quite streamlined process (as long as it's part of the monorepo.

@chrisdutz

Copy link
Copy Markdown
Contributor

Also ... as this is considered a significant contribution .... before we can merge this you would need to file an ICLA with apache: https://www.apache.org/licenses/icla.pdf ... if you are doing this work as part of your day-job you should also consider your company signing a CCLA https://www.apache.org/licenses/cla-corporate.pdf
Possibly worth doing that now so it's not going to delay things once your work is ready to merge.

@chrisdutz

Copy link
Copy Markdown
Contributor

Now that I had the time to catch up with everything else after my holiday yesterday, now I had the time to thorougly read your initial message.

So the PLC4Net doesn't have to be identical to the Java version. It was allways our goal to make the libratries feel natural in their normal ecosystem. With Java we're using CompletableFutures, with Go we're using channels ... whatever is the best way to do things in the target language.

What we do try to keep, is the general usage pattern: So if connections are synchronous in Java, it would sort of be bad if it wasn't in other languages. Also if the naming of things was kept ... like the usage of "Tag" and "Query" and that you can add a Tag or a TagString, ....

Admittedly I don't quite understand what you mean with your first divergence: "ConnectionString is a public type in the API, not the SPI. It has no SPI dependency, and PlcDriverManager — which lives in the API — needs it to route the protocol code." Could you please explain that?

I think when I did the first work on PLC4Net, I chose KNX as this used the most of our mspec-functionality ... I thought if I get this driver working, the rest will most probably also work. Usually when starting on a new language I started with Modbus as this is the simplest one of them all for which a test-bench is cheaply available. KNX also had this super odd encoding for 16 bit float which isn't really a full half-precision-iec float. In the new SPI3 java version, I think I used a dedicated float implementation:

        ['PDT_KNX_FLOAT' REAL
            [simple   float 16      value floatEncoding='"KNXFloat"']
        ]

The implementation is here:
plc4j/spi/buffers/byte/src/main/java/org/apache/plc4x/java/spi/buffers/bytebased/encoding/EncodingKnxFloat.java

However, I built things in SPI3 so theoretically a protocol module could bring along it's own Encoding implementation by putting the class in the driver module and registering it via a org.apache.plc4x.java.spi.buffers.bytebased.encoding.Encoding property file.

As you could see I never really got very far with the .Net version as the person that claimed to want to help decided to go away. Usually my process was that I hand wrote some types from Modbus in the target language, then I copied the java templates and started adjusting them till the output matched the hand-written one. the data-io template you found was simply one copied but never touched by me as the work stopped before I had time to do that.

As you saw on the discussion list, we're currently considering splitting out Go and also creating go-native code generators. Possibly this is something you would also like to do. Initially the idea was to have one system for generating code. One system anyone would understand and maintaining would therefore be easier. Also is it one thing to understand a programming language, but it's a totally different thing to understand the tooling available in a language, the best practices and how to nicely integrate this into a smoothly running build. For java and maven we knew how to do this, for the others not so much ;-) So the current template+maven approch was simply a quick win that we knew how to do.

However, have I found out with my own closed-source work, that there are huge benefits for running this in a tool-native fashion ... then I guess only the resolution of the protocol modules would need to be implemented and the code-gen is built per language. The mspec format is documented in an Antlr4 grammar for which there should be tooling in most languages. The core mspec format is described here:
code-generation/protocol-base-mspec/src/main/antlr4/org/apache/plc4x/plugins/codegenerator/language/mspec/MSpec.g4
The expression syntax used in the little expression blocks inside are documented here:
code-generation/protocol-base-mspec/src/main/antlr4/org/apache/plc4x/plugins/codegenerator/language/mspec/expression/Expression.g4

I hope I manged to answer your questions ... if not ... don't hesitate to ask ... ideally here ... if it has to be quickly and you see I'm online in Slack, just ask there.

@sruehl
sruehl marked this pull request as draft July 28, 2026 10:35
@TomNewChao

TomNewChao commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Now that I had the time to catch up with everything else after my holiday yesterday, now I had the time to thorougly read your initial message.

So the PLC4Net doesn't have to be identical to the Java version. It was allways our goal to make the libratries feel natural in their normal ecosystem. With Java we're using CompletableFutures, with Go we're using channels ... whatever is the best way to do things in the target language.

What we do try to keep, is the general usage pattern: So if connections are synchronous in Java, it would sort of be bad if it wasn't in other languages. Also if the naming of things was kept ... like the usage of "Tag" and "Query" and that you can add a Tag or a TagString, ....

Admittedly I don't quite understand what you mean with your first divergence: "ConnectionString is a public type in the API, not the SPI. It has no SPI dependency, and PlcDriverManager — which lives in the API — needs it to route the protocol code." Could you please explain that?

I think when I did the first work on PLC4Net, I chose KNX as this used the most of our mspec-functionality ... I thought if I get this driver working, the rest will most probably also work. Usually when starting on a new language I started with Modbus as this is the simplest one of them all for which a test-bench is cheaply available. KNX also had this super odd encoding for 16 bit float which isn't really a full half-precision-iec float. In the new SPI3 java version, I think I used a dedicated float implementation:

        ['PDT_KNX_FLOAT' REAL
            [simple   float 16      value floatEncoding='"KNXFloat"']
        ]

The implementation is here: plc4j/spi/buffers/byte/src/main/java/org/apache/plc4x/java/spi/buffers/bytebased/encoding/EncodingKnxFloat.java

However, I built things in SPI3 so theoretically a protocol module could bring along it's own Encoding implementation by putting the class in the driver module and registering it via a org.apache.plc4x.java.spi.buffers.bytebased.encoding.Encoding property file.

As you could see I never really got very far with the .Net version as the person that claimed to want to help decided to go away. Usually my process was that I hand wrote some types from Modbus in the target language, then I copied the java templates and started adjusting them till the output matched the hand-written one. the data-io template you found was simply one copied but never touched by me as the work stopped before I had time to do that.

As you saw on the discussion list, we're currently considering splitting out Go and also creating go-native code generators. Possibly this is something you would also like to do. Initially the idea was to have one system for generating code. One system anyone would understand and maintaining would therefore be easier. Also is it one thing to understand a programming language, but it's a totally different thing to understand the tooling available in a language, the best practices and how to nicely integrate this into a smoothly running build. For java and maven we knew how to do this, for the others not so much ;-) So the current template+maven approch was simply a quick win that we knew how to do.

However, have I found out with my own closed-source work, that there are huge benefits for running this in a tool-native fashion ... then I guess only the resolution of the protocol modules would need to be implemented and the code-gen is built per language. The mspec format is documented in an Antlr4 grammar for which there should be tooling in most languages. The core mspec format is described here:

I hope I manged to answer your questions ... if not ... don't hesitate to ask ... ideally here ... if it has to be quickly and you see I'm online in Slack, just ask there.

Thanks — knowing the goal is to make the libraries feel natural in their normal
ecosystem while keeping the usage pattern and the naming settles several things I
was unsure about. Let me answer your question first, then lay out where I would
like to take this.

On the ConnectionString divergence — it does not hold up

The three states side by side:
ScreenShot_2026-07-28_195710_488

(*) = the full connection-string parser
protocol code + transport code + host + port + query params + URL decoding

(1) and (2) are the same shape. I broke that in (3).

What happened: I read "Uri cannot parse the whole s7:cotp://host form" — which
is true, Host comes back empty and Port -1 — as "Uri cannot be used here"
the manager only ever needs the protocol code, and I had never checked what Java
actually does there. Both behave identically:

image

Java has exactly the same limitation on the two-scheme form and lives with it,
because DefaultPlcDriverManager only ever takes the scheme. So the thing I
treated as a blocker was never one.

Counting the consumers settles which module the type belongs in:

api/PlcDriverManager.cs:60 ConnectionString.Parse(..) 1 site <- the line I added
spi/drivers/DriverBase.cs:83 ConnectionString parameter 3 sites <- genuine; these
spi/drivers/DriverBase.cs:101 ConnectionString.Parse(..) need transport code,
spi/drivers/DriverBase.cs:130 ResolveTransportCode(..) host, port, params

Revert that one line and the type has no consumers left in the api module. So it
moves down next to DriverBase and the manager goes back to Uri.Scheme. I will
fix that.

On the general approach

My intent throughout has been to follow how the Go and Java modules are used so
the project keeps one consistent shape. Your point about the usage pattern and
the naming is a fair hit — IPlcReadRequestBuilder still exposes
AddItem(name, fieldQuery), which is both the old "field" wording and missing
the Tag/TagString pair. I will align it with addTag/addTagAddress. Query I
would rather leave until there is browse support behind it — plc4net currently
has no PlcBrowser and no browse request at all, so adding the type on its own
would be the name without the capability.

On KNX

I did not choose it, I inherited it — plc4net/drivers/knxnetip/src already
carries the generated model, so finishing that capability looked like the
first step. My own roadmap is different: Modbus, then S7, then OPC UA, because
what I actually need is to reach PLCs from several vendors and feed them into an
IoT platform. Your note that you normally start with Modbus matches where I was
heading anyway.

On tool-native code generation

I agree with building it per language. Each language has its own idioms, tooling
and best practices, and a tool-native generator fits that far better than one
shared toolchain. Thanks for the two grammar pointers — I have looked at them and
they seem very tractable from .NET, so the part left to work out is the
resolution of the protocol modules you mentioned.

Where I would like to go next

  1. Get the CLA filed.
  2. Start with Modbus and prove the path end to end.
  3. Extend outwards to the protocols the PLCs I work with actually speak.

One request

This PR is really me probing for direction rather than proposing something
finished, and properly absorbing this project is going to take me a while
you consider creating a feature/plc4net branch I could target instead of
develop? contributing.adoc already describes feature branches with that
prefix, and it would let this land in reviewable increments without any of it
touching the 1.0.0 release — which I think also answers @sruehl's question above.
Happy to work that way if it suits you.

Christofer Dutz raised on PR apache#2656 that develop's prerequisite check now
looks for the .NET 7 SDK and that the root README's .NET section is stale.
The revived port targets net8.0 (LTS), so:

- prerequisiteCheck.groovy — checkDotnet() requires 8.0.0 (was 7.0.0 on
  develop, 4.5.2 before that); the inline comment no longer describes the
  removed net452 / LangVersion 11 setup.
- README.md — the language list drops "abandoned"; the PLC4Net build
  prerequisites drop the obsolete ".NET Framework 4.5.2 targeting pack /
  Mono" step and ask for the .NET 8 SDK.
- THREAT-MODEL.md, website/.../users/pages/index.adoc — the four spots
  that quoted the old README wording are synced. The plc4net scope
  carve-out is unchanged: still out of the model, on the independent
  ground that it carries no "supported" mark in the protocols index.

No source or generated-model change; all three .NET CI checks still pass
locally (license headers, generated-code-is-current, 428 tests).
@sruehl

sruehl commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

I wonder if this could utilize https://docs.github.com/en/pull-requests/how-tos/stacked-pull-requests to seperate the generated code from this PR. This way we had the slim base of just the changes and then a stacked pull request adding the generated code. ATM it is for example not possible to do CoPilot reviews as it just bails due to size

tools/s7-verify last ran on 2026-09-03, before develop was merged, the
prerequisite check moved to the .NET 8 SDK and AssemblyVersion was
pinned. Re-ran it against the S7-1214C at 39e3792 to confirm those
build and infra commits did not regress the S7 driver.

- s7-hardware-report.md — new dated section: PASS 12/12 on two
  consecutive runs, negotiated PDU 240 bytes, DB100 layout unchanged
  from 2026-09-03; an added %I0.0 single-read probe (Ok) exercises
  %I-area addressing outside the data block.

Docs only. No source or generated-model change; the S7 driver is
untouched since the 2026-09-03 run.
@TomNewChao

Copy link
Copy Markdown
Contributor Author

I wonder if this could utilize https://docs.github.com/en/pull-requests/how-tos/stacked-pull-requests to seperate the generated code from this PR. This way we had the slim base of just the changes and then a stacked pull request adding the generated code. ATM it is for example not possible to do CoPilot reviews as it just bails due to size

Thanks — the size problem is real. Copilot has refused it since it crossed 20k lines, and 651 files is a lot to put in front of a human.

What this PR is right now — an end-to-end revival spike, not a finished contribution. What I'm driving toward is S7 and Modbus working against real hardware: S7 passes on an S7-1214C now, Modbus is next, both within a week I'd expect. Until that path is proven I don't want to freeze the module boundaries, so decomposing now would be guesswork.

Once it's proven — split into develop-targeted PRs, each Copilot-sized and reviewable on its own: the SPI3 runtime, then the pure-.NET code generator, then the transports, then one per driver. The generated model classes (424 of the 651 files) travel with their driver — regenerable from mspec, not something to read line by line.

@chrisdutz

chrisdutz commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Couldn't you just delete the generated files ... they should be built as part of the built itself, right?

And just be carefull to not re-commit them. ... Could add a temp gitIgnore rule.

@TomNewChao

Copy link
Copy Markdown
Contributor Author

Couldn't you just delete the generated files ... they should be built as part of the built itself, right?

And just be carefull to not re-commit them. ... Could add a temp gitIgnore rule.

Not quite, actually — the build doesn't regenerate the models today. They're committed, and rebuilt only under -Pupdate-generated-code, same as plc4j and the other ports. So dotnet build just compiles what's checked in — there's no generator step to lean on yet.

I'm already set on making this reviewable: the plan I gave sruehl splits it into per-driver PRs, and that's what gets each piece under the bot limits.

Generating at build instead of committing is a bigger move than the gitignore rule suggests. It turns tools/code-gen into a hard build dependency of every driver — today a generator bug only trips the isolated drift check, not the whole build — and it's the generate-on-build model you moved plc4j away from for reproducible builds. It'd also make plc4net the only port not committing its generated code.

There's a real case for it — the drift-check job and the noisy regenerated diffs on generator PRs only exist because the files are committed. I'll start the per-driver split regardless — I'd rather take up build-time generation separately once the split's landed, if it's still needed then. Given the scope and the reproducibility point, I'd like to hear more of your thinking here before deciding.

Running tools/modbus-verify against a scripted Modbus/TCP slave (ahead of
the real S7-1214C run) surfaced two bugs, neither hardware-specific:

- ModbusConnection.Read (TCP) only handled Coil and HoldingRegister;
  DiscreteInput and InputRegister fell through to the default
  AccessDenied branch without ever reaching the wire. ModbusRtuConnection
  already handled all four tag types - the TCP connection was the
  incomplete one. Added the missing cases, mirroring
  ModbusRtuConnection's BuildReadPdu / ParseReadResponse.
- modbus-verify's PrintValue probed IPlcValue.IsBool() first, which
  plc4net's value model answers true for every numeric adapter too
  (matching plc4j's PlcValue coercion semantics) - so a holding-register
  read that correctly produced PlcUINT(0x1000) printed as "True (BOOL)".
  The value was right, the report was wrong. Now renders by the Modbus
  tag type actually read, the way s7-verify already does, instead of
  probing the value.

ModbusDriverTests gets two new TCP round-trip tests pinning the
input-register and discrete-input wire format and decoding.

430/430 tests pass (was 428, +2). Re-verified end-to-end against a
scripted Modbus/TCP slave: holding/input registers report their UINT16
value, coil/discrete their BOOL, an out-of-range read still maps to
InvalidAddress.
@sruehl

sruehl commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

are those then stacked PRs?

@TomNewChao

Copy link
Copy Markdown
Contributor Author

are those then stacked PRs?

Not the GitHub stacked-PR mechanism, no — each targets develop directly, opened one after the previous merges. They're dependency-ordered either way (each needs the last one's code to compile), so a formal stack wouldn't buy much here; it mainly helps when multiple people need to review different layers in parallel, which isn't the situation with one contributor. Happy to switch to literal stacking if you'd rather review them without waiting on each merge.

@TomNewChao

Copy link
Copy Markdown
Contributor Author

Quick update: physical Modbus RTU verification is currently blocked by the RS-485 hardware setup.

An independent raw-serial test sends a valid request, but receives no response either, so there is currently no evidence that this is a plc4net driver issue. I still need a known-good USB-to-RS485 adapter or an oscilloscope to isolate the converter from the CM1241.

My proposal is to keep this limitation documented and proceed with the smaller, dependency-ordered PRs. Physical RTU verification can follow separately once the hardware link is confirmed.

Signed-off-by: TomNewChao <chaotomzhu@gmail.com>
Signed-off-by: TomNewChao <chaotomzhu@gmail.com>
Signed-off-by: TomNewChao <chaotomzhu@gmail.com>
SerialTransportInstance's receive loop awaited
_port.BaseStream.ReadAsync(buf, 0, toRead, ct), which does not observe the
CancellationToken on a real SerialPort. Replaced it with a loop that checks
_port.BytesToRead and does a synchronous _port.Read() when bytes are
available, otherwise a short Task.Delay(1, ct).

modbus-verify gained the diagnostics used to keep chasing the Modbus RTU
hardware round-trip: --quantity/--count (1-125) to read multiple
registers/coils in one raw exchange, a response-shape check against the
requested unit id/function code/byte count with register-value decoding,
a disconnect-listener hookup that logs when the receive loop stops, and a
100ms settle delay after opening the port before the first frame goes out.
When --quantity is not 1 the driver-read step is skipped -- ModbusTag and
ModbusRtuConnection's read path are still single-value.

449 tests green (404 spi-test + 45 knxnetip-test), 0 warnings.
…14C+CM1241 attempt

The Siemens S7-1214C + CM 1241 (RS422/485) Modbus RTU rig never completed a
round trip: request framing was byte-correct and the CM 1241 acknowledged
receiving it, but no response ever reached the master. The fault was
isolated to the RS-485 bench link (a CH340 adapter that repeatedly dropped
off USB, marginal idle bias with no termination fitted, and an edge-
triggered Modbus_Comm_Load whose instance DB carried state across RUN-mode
downloads) rather than the driver.

Switched the verification target to a Mitsubishi QJ71C24N running its
non-procedure ("no-protocol") communication mode as a fixed-response
Modbus RTU slave -- it has no native Modbus slave firmware, so its ladder
program answers every request with the same canned response regardless of
function code or address. It hit the same "request received, nothing sent
back" symptom at first; the cause was a ladder-side M-relay interlock
gating the response-send (G.OUTPUT) rung, not the link. With that cleared:

  -> 01 03 00 00 00 01 84 0A                (unit 1, read holding register 0)
  <- 01 03 08 00 01 00 02 00 03 00 04 0D 14  (13 bytes)

CRC valid, register values 1/2/3/4 (D1000-D1003), and ModbusRtuConnection's
driver read returns 1 for holding:0 -- both the raw wire path and the
driver path verified, repeatably.

Updated modbus-hardware-verification.md (QJ71C24N/GX Works2 setup replaces
the CM 1241/TIA Portal steps, troubleshooting table, --baud 9600 examples),
modbus-hardware-report.md (new run log entry; the CM 1241 link post-mortem
stays as history), design.md (GAP-1 and GAP-2 marked done), and
testing.md's hardware-verification section.
…ale counts

The S7 and Modbus procedures and run logs lived in four files that repeated
each other's "run it" and packaging sections and had to be kept in sync by
hand. They are now one `docs/hardware-verification.md`: a status table, the
shared harness usage, then a section per protocol (setup, coverage,
troubleshooting, dated run log, PLC-side evidence). Content is otherwise
unchanged.

Added PLC-side evidence for the Modbus RTU run: the QJ71C24N bench photo and
the GX Works2 ladder capture showing the G.INPUT and G.OUTPUT rungs with the
interlock that had been blocking the response, and D200 monitoring as 0x0301
- the first two bytes of the canned response.

Stated the scope limits plainly, in the new document and in design.md's gap
list: the QJ71C24N is a fixed-response stand-in that answers every request
identically, so it proves the serial transport, RTU framing, CRC and the
driver read path, but not slave-side address-range or exception-code
handling. Modbus TCP was verified against a software slave, not hardware.
KNXnet/IP has no hardware verification at all.

Corrected counts and claims that had drifted:
- testing.md summary 428 -> 449 total, spi-test 383 -> 404
- design.md project tree 344 -> 404 spi-test cases
- design.md GAP-5 now records the expanded 50/50 run and the persistent
  matrix, not just the original 12/12
- design.md GAP-9 said the version stays 1.0.0-SNAPSHOT; it is 1.1.0-SNAPSHOT
  in Directory.Build.props. Also states plainly that publishing to nuget.org
  is a PMC decision via the ASF release process, not a contributor action.
- design.md's in-progress table listed "PR apache#2656 title / draft removal" as
  the goal; that PR is now a draft umbrella and the work is decomposing it
  into reviewable slices against develop.

Repointed the inbound references in s7-verify.csproj, modbus-verify.csproj
and s7-verify's Program.cs at the merged document.

449 tests green, 0 warnings.
…on.g4

tools/code-gen/Expression.g4 is a verbatim copy of the shared grammar under
code-generation/. Upstream carries the full Unlicense public-domain
dedication in the file itself, crediting bkiers/tiny-language-antlr4, and the
root LICENSE lists that path as Category A "UNLICENSE".

The copy had that dedication replaced with an ASF Apache-2.0 header, leaving
only a one-line "Based on ... (Unlicense)" comment, and the copy was never
added to LICENSE. Stripping a third-party notice and stamping an ASF header
on a public-domain file is not ours to do, and it would not survive a release
audit.

The file is now byte-identical to the upstream grammar again - the body never
diverged, only the header did - and LICENSE names both paths. Since the file
legitimately carries no ASF header, it is excluded from the header checks that
would otherwise demand one: the git-ls-files check in the .NET workflow, and
apache-rat via plc4net/pom.xml (which uses the correct <inputExclude> element;
the root pom's **/Expression.g4 entry is spelled <excinputExcludelude> and so
does not apply - that looks like an unrelated find/replace accident upstream,
left alone here).

Two build-honesty fixes alongside it:

- The update-generated-code profile regenerated only modbus and s7, while the
  generated-code-is-current CI job regenerates knxnetip as well and then fails
  on drift. A contributor running the Maven profile locally could not
  reproduce a CI drift failure. Added the knxnetip execution and a note that
  the two must stay in step. The stale comment claiming knxnetip "is not
  regenerated yet" is gone; it has been generated and drift-checked since the
  generated KNX model replaced the Java-plugin output.

- drivers/knxnetip declared a PackageReference on NLog that no source file in
  the tree uses. It shipped in the package and created a notice obligation for
  nothing. Removed.

449 tests green, 0 warnings. License-header check passes with the exclusion.
@TomNewChao TomNewChao changed the title [WIP] feat(plc4net): revive the .NET port — net8.0, SPI3 runtime, pure-.NET mspec generator [spike] feat(plc4net): revive the .NET port — net8.0, SPI3 runtime, pure-.NET mspec generator Sep 19, 2026
The five PLC-side captures were markdown links, so opening the hardware
verification document showed a list of filenames and none of the evidence.
They are embedded now, each with a caption that says what to look at:

- the S7-1214C bench, and the QJ71C24N bench with the RS-485 wiring that
  presents COM3
- DB100 monitored in TIA Portal, so the non-optimized offsets the procedure
  builds can be checked against the addresses the driver uses
- the I/Q/M watch table after the persistent write
- the GX Works2 ladder, where the M2000 send-side interlock and the
  G.INPUT / G.OUTPUT rungs are visible, along with D100 and D200 both
  monitoring as 0x0301 and D205/D206 holding the response tail and CRC

The S7 test-rig photo was 5.2 MB at 4032x3024, which was tolerable as a link
and is not once it renders inline on every view. Resized to 1600x1200,
345 kB. The changelog now records that resizing is the only processing applied
to any of these images.
The document opened with how to run the harnesses and buried the results in a
reverse-chronological run log, so a reader had to reach the middle before
learning whether anything passed. It also carried an eight-entry changelog,
several of whose lines were timestamped to the minute - a working diary that
duplicated both the run log and git history.

Reordered to conclusion first, then evidence: a Verdict table with the scope
limits stated immediately under it, then one section per protocol with the
same four parts each - hardware under test, how to reproduce, results,
coverage - followed by the PLC-side images and a troubleshooting table.

- The run log is gone as a structure. The current numbers are the headline in
  Results; the earlier passing runs are three sentences; the one blocked run
  became a troubleshooting row, which is where its diagnostic value actually
  lives (a uniform AccessDenied across DB, %I, %Q and %M means access policy,
  not addressing).
- The retired S7-1214C + CM 1241 Modbus rig moved to an appendix. It is kept
  because the failure analysis is the reason the target changed, but it is no
  longer interleaved with results that did pass.
- The s7-verify flags are a table now rather than five paragraphs, with the
  physical-output warning attached to the flag that carries the risk.
- The changelog is gone. The one load-bearing claim in it - that resizing is
  the only processing applied to any image - moved to the evidence section,
  where someone judging the evidence will actually see it.

620 lines to 438, with no verification content dropped.
The evidence section introduced three photographs with a sentence about
confirmation coming "from somewhere other than the tool under test" and a
declaration that no image had been "cropped, annotated or otherwise altered".
That is chain-of-custody language for a bench photo and a pair of TIA Portal
screenshots. It says nothing the reader cannot see, and protesting that hard
about untouched evidence invites the suspicion it was meant to deflect.

They are pictures of the rig the tests ran on, so the section says that now.
The resize note went with it - the commit that resized the photo already
records it, and git has the original.

Also made the four coverage bullets parallel - full / wire and framing only /
no hardware / none - so the verdict can be read at a glance instead of parsed
out of prose.
…CP reproducible

Two troubleshooting entries described ModbusRtuConnection behavior that
9767478 already fixed:

- "reads whatever is available once >= 4 bytes arrive, no expected-length or
  t3.5 check" - SendAndReceiveLocked reads a 3-byte header, computes the
  exact frame length from the function code, and waits for that many bytes
  with a real timeout. This has been true since 2026-09-04.
- "the driver does not strip [the half-duplex echo]" - it does, for reads,
  by comparing the received bytes against the sent request before parsing.

The Appendix carried a third copy of the same claim, suggesting the length
check as future work on the retired CM 1241 rig. That paragraph is dated
2026-09-06, two days after the fix landed - it was wrong the day it was
written, not just stale by the time this document reached it. Removed.

Made the Modbus TCP hardware-verification result reproducible. It was run
against "a minimal raw-socket Modbus/TCP slave" per e86bdd0's own commit
message, but that slave was never checked in, so PASS 5/5 was not something
a reader could rerun - the only other Modbus/TCP fixture in the repo is
plc4j's pymodbus-based test_server.py, built for a 32-type register map and
an RTU/ASCII/TLS/UDP matrix that plc4net's single-table TCP check has no use
for. Added tools/modbus-tcp-sim.py, a ~110-line stdlib-only script serving
exactly the four values and the one out-of-range address the original run
used, and reran modbus-verify against it: all five results match the
document byte for byte. The reproduction commands are now in the doc.

Also: gave the two Exception-code troubleshooting rows their actual scope
(the QJ71C24N RTU rig can't produce either, since it answers every request
identically regardless of address - the TCP fixture is what demonstrates
0x02, at holding:200) rather than the vaguer "only possible against a real
slave," which excluded the fixture that proves it. Spelled out the S7
Verdict-table date range across its three verification dates instead of
naming only the last one. Gave the Modbus RTU Verdict row an honest
"1 raw exchange, 2 driver reads" rather than reaching for a pass/fail ratio
modbus-verify has no counter for.

449 tests green, 0 warnings.
Close() cancelled the token, then closed and disposed the port without ever
waiting for the background read loop to actually stop -- the Task.Run handle
from the constructor was discarded. .NET's SerialPort has documented issues
where Close()/Dispose() racing a concurrent Read() on another thread can hang
rather than cleanly cancel (dotnet/runtime#20362, dotnet/corefx#36040). Close()
now waits on the read-loop task with a bounded 1s timeout after tearing the
port down.

That wait would deadlock if a listener callback invoked synchronously from the
read loop calls Close() itself -- the loop can't finish while its own callback
is still on the stack. Guarded against it the same way TcpTransportInstance
already does: track which thread is currently running a listener callback and
skip the wait when Close() is called from that same thread.

Also:

- The idle poll backed off from a fixed 1ms Task.Delay whenever BytesToRead
  was 0, spinning at up to ~1000 calls/sec for as long as the line stayed
  idle -- realistic for hours on a production connection, not just this
  diagnostic tool's short runs. Backs off from 1ms to a 20ms cap while idle,
  resetting the moment bytes show up.
- The generic catch-all invoked the disconnect listener unconditionally, even
  when the exception was a side effect of Close()'s own teardown.
  TcpTransportInstance's equivalent already guards this with its open flag;
  SerialTransportInstance now checks _closed the same way.
- Restored a comment explaining why BytesToRead-polling replaces
  BaseStream.ReadAsync(ct) (the latter doesn't observe the token on real
  hardware) -- the original comment was deleted by 7ad2d45 with nothing
  taking its place, so the reason not to "simplify" this back was undocumented.
- Documented why a zero-byte Read() retries instead of ending the loop: the
  guards above only ever request a positive count already confirmed available,
  and SerialPort.Read throws TimeoutException rather than returning 0 for a
  positive count, so this path is believed unreachable today.

449 tests green, 0 warnings.
… formula

PrintRawValues re-checked the response shape via
HasExpectedReadShape(frame, frame[0], tag, quantity) -- passing the frame's
own address byte as the expected unit id instead of the real unit id already
validated a few lines earlier. That made the check's frame[0] == unitId
comparison true by construction, so a reply from the wrong slave address on a
shared bus could still have its register values printed. AnalyzeRaw already
reports an address mismatch separately, so this was a redundant check quietly
doing nothing rather than a silent wrong answer, but it should do what it
looks like it does. PrintRawValues now takes the already-computed shapeOk
instead of recomputing a broken version of it.

HasExpectedReadShape's frame.Length == dataBytes + 5 duplicated
ModbusRtuConnection.SendAndReceiveLocked's 3 + head[2] + 2 -- the same
arithmetic for the same four read function codes, maintained independently in
two files. Extracted to ModbusFunctionCodes.ReadResponseRtuFrameLength, used
by both, so a future change to one can't silently leave the other validating
against a stale rule.

Also:

- Named the RTU response's byte offsets (address/function/byteCount/data)
  instead of hand-indexing them a third time in PrintRawValues on top of
  AnalyzeRaw and HasExpectedReadShape.
- --quantity/--count validation ran before VerifyRtu's own try block, so a bad
  value crashed with a raw stack trace instead of this tool's usual Markdown
  "## Error" block that every other failure path produces. Wrapped it.
- The disconnect-listener callback wrote directly to Console.Out from the
  background receive-loop thread, which could interleave with the main
  thread's own sequential report output. It now records the message and the
  main thread prints it once, in the finally block.
- The quantity != 1 summary table had a "Raw response shape valid" row the
  quantity == 1 table didn't, despite both computing the same shared
  rawShapeOk, plus cosmetic drift (Mark(true) vs a hardcoded checkmark, a
  hyphen vs an em dash in an otherwise identical heading). Made the two
  tables consistent.

Verified against the real QJ71C24N frame captured in
hardware-verification.md (01 03 08 00 01 00 02 00 03 00 04 0D 14): still
decodes to register values 1, 2, 3, 4, and now correctly fails the shape
check when checked against the wrong unit id, which the old code could never
do regardless of input.

449 tests green, 0 warnings.
serve() had no exception handling anywhere. A client disconnecting
mid-response -- a reset socket, a killed modbus-verify run, a timeout --
raised BrokenPipeError/ConnectionResetError uncaught, past the per-connection
`with conn:` block and the outer accept loop, killing the entire simulator
process rather than just that connection. Every subsequent hardware-
verification reproduction attempt then failed with connection refused until
someone noticed and restarted it by hand. Each connection's handling is now
wrapped, logging the drop and moving on to the next client.

pdu = conn.recv(length - 1) assumed a single recv() call returns the whole
PDU; socket.recv(n) is documented to return "up to" n bytes, not exactly n.
Added a recv_exact helper, used for both the 7-byte MBAP header and the PDU,
that loops until the requested size arrives or the peer closes early.

Verified directly: the five rows documented in hardware-verification.md still
pass, the simulator survives an abrupt reset mid-request and keeps serving
new connections afterward, and a request sent one byte at a time still
decodes correctly.
The root inputExclude for **/Expression.g4 was spelled
<excinputExcludelude> -- a dead XML element apache-rat-plugin has never
recognized. Present on plain upstream/develop too, so it predates the
plc4net work; cf7a430 added a second Expression.g4 relying on this same
non-functional line and explicitly deferred fixing the typo as unrelated.
Fixing it now that it's been run down.
eb56666 merged four hardware documents into one and said content was
otherwise unchanged, but three pieces of evidence didn't make it across:

- The S7 persistent Q-write table's Before values, proving each write
  actually changed the output rather than the CPU already holding that value.
  Restored for the seven Output rows that genuinely have one (from the
  2026-09-16 persistent run); Marker and DB rows are marked "not logged
  separately" rather than backfilled with an assumed value, since that run's
  own notes say marker writes weren't requested and DB100 is covered by the
  restore-tested suite instead.
- The instruction to promote a working --device-group/--remote-tsap value
  into the driver's own documentation and defaults once found.
- The specific reasoning ruling out the SerialTransportInstance receive-loop
  rewrite as the cause of the earlier Modbus RTU "zero bytes back" failures --
  both the old and new loop hit the identical symptom, pointing at the link/
  interlock rather than the loop. This only existed in
  docs/modbus-hardware-report.md, a file created and deleted within the same
  merge commit, invisible to a plain two-endpoint diff of the branch. Without
  it, the surviving "first hardware confirmation" sentence read as more
  strongly evidenced than what the document actually showed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants