Skip to content

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

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

[WIP] feat(plc4net): revive the .NET port — net8.0, SPI3 runtime, pure-.NET mspec generator#2656
TomNewChao wants to merge 16 commits into
apache:developfrom
openIndu:feature/plc4net-revival

Conversation

@TomNewChao

@TomNewChao TomNewChao commented Jul 26, 2026

Copy link
Copy Markdown

Revives plc4net — the README lists it as "not ready for usage - abandoned". It now builds on Linux/macOS/Windows, has an executing test suite (367 cases), a SPI3-aligned driver runtime, working Modbus (TCP/RTU), S7 and KNXnet/IP drivers, and a pure-.NET .mspec → C# generator that replaces the never-completed Java C# templates — the Modbus, S7 and KNXnet/IP wire models are all generated and CI drift-checked.

Relates to #2655. Follows the dev@ thread from 2026-07-24.

Still [WIP]

No end-to-end verification against a real PLC yet. S7-1200/1500 hardware is on hand (ICLA acknowledged 2026-08-02); the COTP handshake and the S7 Read-Var PDU are checked against reference captures, but the full path through a live device has not been run. The KNXnet/IP driver is tested against a scripted gateway on a loopback socket, not a physical one. Not requesting merge — requesting direction, especially on the generator (see below).

What's in it

Area
Build / CI net452 → net8.0; Directory.Build.props (TreatWarningsAsErrors); .github/workflows/dotnet-platform.yml — .NET SDK only, no JDK/Maven, ubuntu + macos + windows, an apache-rat-equivalent license-header job, a generated-code drift job. pom.xml kept as a thin dotnet bridge for the release reactor. Microsoft.NET.Test.Sdk added — without it the suites compiled but never ran, which is how the defects below went unnoticed.
Value model IPlcValue dispatch fixed — subtypes declared public new instead of override, so ((IPlcValue) new PlcDINT(42)).GetInt() returned 0. PlcBitString / PlcList / PlcStruct / PlcRawByteArray each stored a value but overrode none of the interface accessors. Temporal types re-based on TimeSpan / DateOnly / TimeOnly; PlcLTIME_OF_DAY / PlcDATE_AND_LTIME added (nanosecond-exact).
Bit codec Ayx.BitIO (net45-only, unmaintained, round-trips through a '0'/'1' StringBuilder, throws on a 32-bit field) → in-house MSB-first BitReader / BitWriter, no dependency. ReadBuffer / WriteBuffer repaired: wrong bit counts, reversed arguments, NotImplementedException string/array paths, ReadDouble(32) always threw. ParseException did not derive from Exception.
SPI3 alignment PlcFieldPlcTag, async → synchronous Connect / OnConnect, ConnectionString grammar character-for-character from Java's DriverBase.URI_PATTERN.
Runtime DriverBase, ConnectionBase, MessageCodecBase<T>, transport abstraction, TCP + UDP + COTP + serial + test transports. The UDP transport is a bound datagram socket connected to one peer, a background receive loop into a ring buffer, local endpoint exposed for the KNX HPAI.
Generator plc4net/tools/code-gen/ — checked-in ANTLR-for-C# parsers (the upstream .g4 with one lexer predicate ported Java→C#; tools/code-gen/README.md documents the regen procedure), a type-model IR, and a C# emitter producing StaticParse / Serialize / GetLengthInBits per type and an IPlcValue codec per [dataIo]. Covers every field type Modbus / S7 / KNX use, discriminated types, parameterised and external enums, vstring with an inline length, a dataIo bigEndian byte-order flag, and the [… Struct …] dataIo case (→ PlcStruct). The Java update-generated-code plugin is removed. code-generation/language/cs/ is left intact — plc4net was its only consumer, so it is now an orphan module.
Drivers Modbus TCP + RTU; S7 (COTP, Read-Var, TSAP, seven address forms, Write, DT fragmentation); KNXnet/IP tunnelling (SEARCH / CONNECT / CONNECTIONSTATE heartbeat / DISCONNECT, group Read + Write, a bus-monitor callback, :DPT…-driven decoding over the generated KnxDatapoint), modelled on the Java driver. All three wire models are generated and CI drift-checked; Modbus TCP + S7 round-trip the shared ParserSerializerTestsuite.xml (6 + 11 vectors), the S7 / Modbus DataItem dataIo round-trips hand-built IEC-61131 vectors, and the KNXnet/IP handshake + tunnelling frames round-trip against a scripted gateway on a loopback socket. The KNX model - 180 files, KnxDatapointType's 1882 rows - replaced the Java-plugin output (7 unconsumed KnxDatapoint TIME / DATE cases stubbed).

Reviewing this

~640 files. The bulk is generated model code (drivers/{modbus,s7,knxnetip}/src/…/readwrite/model, regenerated by CI from the .mspec) and the checked-in ANTLR parsers (tools/code-gen/src/generated, ~9.5K). The hand-written surface a reviewer needs to read is ~200 files. The 16 commits are ordered by dependency, each scoped to one concern — git log reads top to bottom as: build → value model → bit codec → API → runtime → transports → generator → drop the Java plugin → Modbus → S7 → verify tools → CI → docs → dataIo byte-order / vstring / KNX groundwork → KNX generates and compiles → KNXnet/IP driver + UDP transport.

Deliberate divergences from the Java SPI3

Flagging these because they are the most likely thing to be objected to.

  1. ConnectionString grammar is identical, but decoding uses Uri.UnescapeDataString + culture-invariant parse (Java uses URLDecoder.decode(…, UTF_8) + Integer.parseInt). Will align if preferred.
  2. Getters became C# properties (IsOpen, ProtocolCode).
  3. ITransportInstance : IDisposable — .NET resource convention, enables using.
  4. The TCP and UDP read loops are async/await — .NET has no virtual threads.
  5. S7 DATE_AND_TIME / DTL dayOfWeek is written Sunday=1..Saturday=7 (matching s7.mspec's own DTL comment and the Siemens doc) where plc4j writes ISO Monday=1..Sunday=7 — a knowing departure; the field is parse-discarded on both sides.

The direction question

CsLanguageOutput registers only model-template.cs.ftlh. An io-template.cs.ftlh exists beside it but is unregistered and its body is still verbatim Java (implements MessageInput<>, @Override). So generating a protocol for C# yields data classes and nothing that can parse or serialise a frame. Java has since moved to code-based generators; go/c/python are still on freemarker.

This PR builds a pure-.NET generator rather than finishing the freemarker template, mirroring where plc4j went. Is a second, C#-only mspec toolchain the right call, and what should happen to code-generation/language/cs? Happy to take this to dev@ as its own thread — flagging it here so the review of the rest is not gated on it.

Known gaps (plc4net/docs/design.md GAP-1..9)

Serial + Modbus RTU have no tests yet. No TLS transport. No request/response correlation or timeout layer at the SPI level, and no PlcSubscriber function interface — the KNX bus monitor is a driver-specific callback instead of a PlcSubscriptionRequest. WriteRequestBuilder implementations are partial. On the generator: the peek / assert / manualArray field families (unused by Modbus / S7 / KNX) are not emitted; a byteOrder type attribute is not honoured (only the dataIo bigEndian flag); 7 of KNX's own TIME / DATE dataIo cases — marked // TODO in the mspec, consumed by nothing — stay stubbed.

Verification

  • dotnet build plc4net/plc4net.sln --no-incremental — 0 errors, 0 warnings
  • dotnet test plc4net/plc4net.sln — 367 passing (344 spi-test, 23 knxnetip-test), 0 failing
  • generated-code-is-current regenerates the Modbus, S7 and KNXnet/IP models and asserts no drift from the .mspec
  • The KNXnet/IP handshake, group Read / Write and bus monitor run against a scripted gateway on a real loopback UDP socket, every frame through the generated model
  • Value-model tests assert exclusively through IPlcValue — the new/override defect was invisible to tests bound to concrete types
  • CI (dotnet-platform.yml) has not run in this repository yet — non-committer PR, workflow is action_required

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
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
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.

@chrisdutz

Copy link
Copy Markdown
Contributor

Good point @sruehl I mean ... currently the part of the code-base nobody is using or working on plc4net ... so generally it would even be safe to work on plc4net on develop directly as long as it doesn't break the build.

@TomNewChao

Copy link
Copy Markdown
Author

Hi Tom,

So you're proposing to build a generic "PLC4X Connection String Parser" component that know how to deal with all types of PLC4X connection strings? I fully agree that's probably the cleanest approach. In my ToddySoft implementation I think I also did it that way. The PLC4J SPI3 was more a migration that a rewrite. So Yeah ... I agree that possibly also for PLC4J such a central component would be a good option.

Nothing I keeping you from creating that branch and I think in the PR can't you simply select that as target? Or would someone with commit rights need to create that branch first? If that is the case, I'll be happy to create it.

Regarding naming inconsistencies: Yes ... we did some cleaning up in the API some time ago and I recall not bothering to update PLC4Net as it was considered abandoned and we generally left it in there as it contained the glue to integrate it into the overall reactor build if someone decided to want to work on this in the future (So we were waiting for you ;-) )

Chris

On the ConnectionString — I was actually proposing something smaller: just moving it from the api module down to the spi module, next to DriverBase, matching how plc4j splits it. But the idea of a central component is worth discussing once the basics are in place.

On the branch @sruehl — understood, and keeping it on develop is simpler. I will keep the build green and keep the PR open as the tracking point.

Thanks

@TomNewChao TomNewChao changed the title feat(plc4net): revive the .NET port — net8.0, SPI3 alignment, driver runtime 【WIP】feat(plc4net): revive the .NET port — net8.0, SPI3 alignment, driver runtime Jul 29, 2026
TomNewChao added a commit to openIndu/plc4x that referenced this pull request Jul 29, 2026
The first stage of the pure-.NET mspec toolchain that Chris Dutz
described on PR apache#2656. This commit adds:

Generated C# parsers (from the upstream .g4 grammars):
  MSpecLexer/Parser/Listener/BaseListener      (from MSpec.g4)
  ExpressionLexer/Parser/Listener/BaseListener  (from Expression.g4)

  The grammars are the shared specification; each language generates
  its own parser. The one Java-specific semantic predicate —
  {getCharPositionInLine() == 0}? — is changed to the C# equivalent
  {Column == 0}?. The generated .cs files are checked in so that
  .NET developers never need Java to build mspec-based drivers.

MspecReader:
  A thin wrapper that turns mspec text (or a file path) into an
  ANTLR parse tree, collecting syntax errors rather than printing.

ParserSerializerTestsuiteRunner:
  A .NET executor for the language-neutral XML test suites under
  protocols/*/src/test/resources/. plc4j and plc4go each run the
  same XML through their own executor; this is the .NET third.
  It loads the XML, decodes hex test vectors, and exposes them
  as a typed model.

Tests grow from 124 to 131 (5 mspec parser + 2 testsuite runner).
@TomNewChao
TomNewChao force-pushed the feature/plc4net-revival branch 2 times, most recently from 8b7a0a0 to 5683bfe Compare August 2, 2026 10:55
TomNewChao added a commit to openIndu/plc4x that referenced this pull request Aug 2, 2026
…port logging

Two additions in one changeset:

1. CotpTransportInstance gains a public DiagnosticOutput property
   (TextWriter?). When set, every raw frame — CR, CC, DT send and DT
   receive — is written as annotated hex. Zero overhead when left null.
   HandshakeTimeout is changed from internal to public so the
   verification tool can shorten the timeout.

2. A new tools/s7-verify console project connects to a real S7-1200 or
   S7-1500, performs the full COTP CR/CC handshake with byte-level
   logging, executes an S7 Read Var request against a user-specified
   address, and prints a markdown verification report.

Usage:
  s7-verify 192.168.0.10 0 1 %DB1.DBW0

The report is designed to be redirected to docs/test_report.md and
attached to PR apache#2656 as hardware evidence.
@sruehl
sruehl requested a balanced review from Copilot August 10, 2026 06:12

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.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@sruehl

sruehl commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

@TomNewChao are you still working on this?

@TomNewChao

Copy link
Copy Markdown
Author

@TomNewChao are you still working on this?

Yeah, sorry, I'm on vacation right now. I'll get back to work as soon as I get back.

net452 cannot build on the Linux and macOS CI runners, which kept plc4net
off the CI matrix. Retargets to net8.0 (LTS), hoists the shared
properties into Directory.Build.props (TreatWarningsAsErrors on - the
module is warning-clean), and adds Microsoft.NET.Test.Sdk to the test
projects, without which the suites compiled but never executed.
Subclasses declared 'public new bool GetBool()' rather than 'override',
so a caller holding an IPlcValue landed on PlcValueAdapter's defaults;
((IPlcValue) new PlcDINT(42)).GetInt() returned 0. PlcBitString and
PlcList stored a value but never overrode the accessors. Adds the
temporal accessors IPlcValue needs (GetDuration / GetDate / GetTime),
re-bases PlcTIME / PlcDATE / PlcTIME_OF_DAY on TimeSpan / DateOnly /
TimeOnly, adds nanosecond-exact PlcLTIME_OF_DAY / PlcDATE_AND_LTIME, and
stops PlcREAL throwing for a NaN / Infinity value.
Ayx.BitIO is net45-only, unmaintained, round-trips through a StringBuilder
of 0/1 characters and throws on a 32-bit field. Replaced with an MSB-first
BitReader / BitWriter, no dependency. That exposed the layer above:
ReadUlong / ReadLong consumed the wrong bit count, WriteFloat /
WriteDouble had the arguments reversed, ReadDouble(32) always threw, and
the string / byte-array paths were NotImplementedException. ParseException
did not derive from Exception. The KNX DPT 9.x 16-bit float is implemented
explicitly - one test documents its coarse top-of-range step.
PlcField -> PlcTag, async ConnectAsync -> synchronous Connect / OnConnect,
and a ConnectionString parser whose grammar is character-for-character
Java's DriverBase.URI_PATTERN. The nested api/api layout is flattened.
DriverBase (connection-string dispatch, transport selection, supported-
transport enforcement), ConnectionBase (the Connect / OnConnect split for
a protocol handshake), MessageCodecBase<T>, the request / response and
tag infrastructure, the transport abstraction + RingBuffer, and the
IServiceCollection DI extension.
TcpTransport over real sockets; CotpTransport (CR/CC handshake, DT
framing, TPDU-size negotiation, and DT fragmentation for blocks over the
negotiated size); a serial transport (RS-232 / RS-485); and an in-memory
loopback for driver tests. COTP frame layouts follow ISO 8073 / RFC 1006;
the CC fixture mirrors an S7-1500 response.
The Java C# generator only ever emitted data classes - its
io-template.cs.ftlh is un-migrated Java and unregistered. This is a
pure-.NET replacement: checked-in ANTLR-for-C# parsers (the upstream .g4
with one lexer predicate ported Java to C#; see tools/code-gen/README.md),
a type-model IR, and a C# emitter producing StaticParse / Serialize /
GetLengthInBits per type and an IPlcValue codec per [dataIo].
The plc4x-maven-plugin (languageName=C#) is gone; the
update-generated-code profile shells out to the .NET generator instead.
pom.xml is now a thin dotnet bridge for the release reactor - day-to-day
build and CI need no JDK. code-generation/language/cs is left intact
(orphaned; plc4net was its only consumer).
Modbus TCP and RTU (over the serial transport). The wire model is
generated from modbus.mspec and round-trips the six shared TCP
ParserSerializerTestsuite.xml vectors byte-identical. The [dataIo]
DataItem parses / serialises one IPlcValue keyed on
dataType / numberOfValues / bigEndian - the scalar and string cases plus
numberOfValues > 1 as a PlcList.
…tation

Read Var, rack / slot addressing, Java-parity TSAP encoding, seven address
forms, Write, and DT fragmentation. The 166-file S7 wire model is
generated from s7.mspec and round-trips all eleven shared
ParserSerializerTestsuite.xml vectors byte-identical. Its [dataIo]
DataItem is fully generated - scalar, string, and the whole TIA
date / time family (S5TIME, the Siemens epoch, BCD, nanosecond-exact
LTIME / DTL); the S7 STATIC_CALL string / date targets get real bodies in
a non-generated S7StaticHelper partial ported from plc4j.
s7-verify and modbus-verify - manual smoke tests against a real PLC with
diagnostic transport logging. Not part of CI.
Builds and tests with the .NET SDK alone - no JDK, no Maven - on ubuntu,
macos and windows. A license-header job reproduces the apache-rat check;
a generated-code-is-current job regenerates the Modbus and S7 models and
fails on drift.
docs/design.md (architecture, capabilities, the GAP-1..9 list, the
deliberate divergences from the Java SPI3) and docs/testing.md (the
coverage matrix, how to run, the CI description).
@TomNewChao
TomNewChao force-pushed the feature/plc4net-revival branch from 1b7960f to 35f7bf0 Compare August 31, 2026 07:00
@TomNewChao TomNewChao changed the title 【WIP】feat(plc4net): revive the .NET port — net8.0, SPI3 alignment, driver runtime [WIP] feat(plc4net): revive the .NET port Aug 31, 2026
@TomNewChao TomNewChao changed the title [WIP] feat(plc4net): revive the .NET port [WIP] feat(plc4net): revive the .NET port — net8.0, SPI3 runtime, pure-.NET mspec generator Aug 31, 2026
…groundwork

Three GAP-8 items in the generator:

- Modbus's DataItem honours its `bigEndian` argument. plc4j leaves byte
  order to the driver's buffer context; plc4net has none, so a
  `bigEndian == false` frame's multi-byte scalar and list values are
  swapped with BinaryPrimitives.ReverseEndianness (a BitConverter round
  trip for float / double). Only kicks in for a dataIo that declares a
  `bit bigEndian` arg, so S7's DataItem is untouched.

- `vstring 'expr'` - a string whose bit length is a run-time expression
  (ADS: `vstring 'stringLength * 8' value`). BuildSimpleType now keeps the
  length expression; the emitter renders it into the ReadString /
  WriteString call and the length sum.

- KNX groundwork: MspecReader rewrites the empty-string literal `''`
  (which the shared expression grammar's `TICK innerExpression TICK` rule
  cannot match) to a sentinel the renderer maps back to "". This is what
  stopped knx-master-data.mspec from parsing at all. With it, KNX now
  generates 181 files - but ~240 of KnxDatapoint's 704 cases are
  struct-valued and hit a throwing stub (needs PlcStruct dataIo emission),
  the hyphenated vstring ids (DPST-1-1) render as a subtraction, and
  KnxDatapointType (1882 rows) does not compile. The checked-in KNX model
  is unchanged; this is a starting point, not a working KNX generator.
  Also: the S7 date/time dataIo bodies now only fire for a dataIo with a
  `dataProtocolId` arg, so they no longer misfire on KNX's DATE / TIME
  case names.

327 spi tests (+6: a vstring pipeline test, four little-endian Modbus
DataItem vectors, one cross-endian value assertion). 0 warnings. S7 and
Modbus (big-endian) regenerate byte-identical.
…nums, hyphenated ids

Finishes the KNX groundwork from the previous commit. The generated
`knxnetip` model (180 files, KnxDatapointType's 1882 rows included) now
compiles with no warnings; the checked-in hand-adapted model is still the
baseline, but swapping it in is now a mechanical step.

Generator:

- `[… Struct …]` dataIo cases (KnxDatapoint's ~74, KnxProperty's) emit a
  `PlcStruct` keyed by field name, mirroring plc4j: read each field, then
  `_map["name"] = new Plc<width>(value)`. A fixed-count `byte` array field
  (KnxProperty's `groupAddress`) becomes a `PlcRawByteArray`. Serialize
  and length follow. 243 KnxDatapoint stubs drop to 21 (7 of KNX's own
  TIME / DATE cases, each marked `// TODO: Check if this is correct` in the
  mspec and consumed by nothing, stay stubbed); KnxProperty drops to 0.

- `external='true'` enums (PlcValueType) are left to the SPI - no file is
  emitted and no accessor that would return one, instead of a shadowing
  empty stub.

- A `vstring` / `string` enum-parameter value that lexes as an expression
  is folded back to its literal: `'DPST-1-1'` (a `-` chain of names and
  ints) to the string "DPST-1-1", a bare `'409'` to "409".

- A dataIo case with no discriminator (KnxProperty's `[* … List]`
  catch-all) compiles to `else { … }`, not `else if (true) { … }`, so the
  fallback `return` after it is not unreachable (CS0162 under
  TreatWarningsAsErrors).

- `float 16` reads and writes as 16 bits (KNX DPT 9.x, via the buffer's
  KNXFloat path) rather than being widened to 32.

SPI value model - both types stored their payload but exposed none of it:

- PlcStruct overrides IsStruct / GetKeys / HasKey / GetValue / GetStruct
  and compares by content, order-independent.
- PlcRawByteArray overrides GetRaw / GetLength and compares by content.

336 spi tests (+9: struct and byte-array dataIo emission, the wildcard
else branch, external-enum skip, hyphenated-id folding, a real-knx.mspec
generation check, PlcStruct and PlcRawByteArray accessors / equality).
0 warnings. S7 and Modbus regenerate byte-identical.
Adds a working KNXnet/IP tunnelling driver, modelled on the Java driver in
this repo. Also swaps the checked-in KNX model for the generator's output -
the driver needs the generated `KnxDatapoint` API and its populated struct
datapoints, and CI now drift-checks the KNX model alongside Modbus and S7.

UDP transport (`plc4net/transports/udp/`):

- A bound datagram socket connected to one peer, a background receive loop
  feeding a ring buffer, the local endpoint exposed for the KNX HPAI. Datagram
  boundaries are not preserved on the buffer, but KNXnet/IP frames are
  length-prefixed so a codec still recovers them. Address parsing shares the
  TCP transport's rules. 8 tests over a loopback socket.

KNXnet/IP driver (`plc4net/drivers/knxnetip/`):

- `KnxNetIpTag` - the three group-address notations (`1/2/3`, `1/2`, `1`),
  `*` wildcards, an optional `:DPT<n>[.<sub>]` suffix, wire encoding, and
  resolution of the hint to a generated `KnxDatapointType`.
- `KnxNetIpMessageCodec` - 6-byte-header framing over `MessageCodecBase`.
- `KnxNetIpConnection` - SEARCH -> CONNECT -> CONNECTIONSTATE (60 s heartbeat)
  -> DISCONNECT; group-value Read (correlates the `GroupValueResponse` a bus
  device sends back), Write, and a `RegisterGroupValueListener` bus monitor
  (the SPI has no subscription path yet - GAP-6). Values decode / encode
  through the generated `KnxDatapoint` when a `:DPT…` hint is present, raw
  bytes otherwise, matching the Java no-project-file path.
- `KnxNetIpDriver` - protocol code `knxnetip`, transport `udp`, port 3671.

Tests: 22 new in `knxnetip-test` - group-address parsing and DPT resolution,
and the whole connection (handshake, typed and raw Write, Read correlation,
read timeout, bus monitor) against a scripted gateway on a loopback UDP
socket that parses and serialises every frame through the generated model.

Not verified against a real gateway (none on hand); stays `[WIP]` like S7.

367 tests (+30: 8 UDP, 22 KNX). 0 warnings. Modbus and S7 regenerate
byte-identical; the KNX model is deterministic on re-run.
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