【WIP】feat(plc4net): revive the .NET port — net8.0, SPI3 alignment, driver runtime - #2656
【WIP】feat(plc4net): revive the .NET port — net8.0, SPI3 alignment, driver runtime#2656TomNewChao wants to merge 15 commits into
Conversation
There was a problem hiding this comment.
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.0and centralize shared build/package properties viaDirectory.Build.props. - Fix the PLC value model’s virtual dispatch and add a bit-level codec (
BitReader/BitWriter) + repairedReadBuffer/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.
|
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 ;-) ) |
There was a problem hiding this comment.
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_openis set to 0 before any call toClose(), subsequentClose()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 byClose()), or refactorClose()to always dispose the socket even if_openis already 0.
plc4net/transports/tcp/TcpTransportInstance.cs:1 - Prefer
Array.Empty<byte>()overnew byte[0]to avoid an unnecessary allocation and follow common .NET conventions for empty arrays.
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. |
|
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 |
b6bb5ba to
fa0c898
Compare
There was a problem hiding this comment.
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 becauseoffsetnever increases. Consider capturing the return value, and if it is 0, treat it as a connection failure (throwTransportException/ 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/AsyncAutoResetEventthat the consumer signals after draining), or at least use a larger/exponential backoff delay to reduce churn.
e285d48 to
cf0302b
Compare
There was a problem hiding this comment.
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 withplc4net, e.g.plc4netfoo/...). Usingplc4net/**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-javais configured withdistribution: 'adopt', but AdoptOpenJDK has been superseded by Eclipse Temurin and may stop being supported/updated. Switching totemurinkeeps 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.
|
@chrisdutz should that be part of 1.0.0? |
|
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. |
|
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 |
|
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: The implementation is here: 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 On the ConnectionString divergence — it does not hold up The three states side by side: (*) = the full connection-string parser (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
Java has exactly the same limitation on the two-scheme form and lives with it, Counting the consumers settles which module the type belongs in: api/PlcDriverManager.cs:60 ConnectionString.Parse(..) 1 site <- the line I added Revert that one line and the type has no consumers left in the api module. So it On the general approach My intent throughout has been to follow how the Go and Java modules are used so On KNX I did not choose it, I inherited it — plc4net/drivers/knxnetip/src already On tool-native code generation I agree with building it per language. Each language has its own idioms, tooling Where I would like to go next
One request This PR is really me probing for direction rather than proposing something |
|
Sorry, I accidentally hit the close button — reopened immediately. That was not intentional. |
|
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 |
|
the target ist not really a issue, it can stay on develop and it just stay open... |
|
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. |
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 |
PLC4Net targeted net452 (.NET Framework 4.5.2), which went out of support in April 2022 and cannot be built on the Linux and macOS GitHub runners. That is the direct reason PLC4Net has no CI workflow while plc4c, plc4go, plc4j and plc4py all have one. - Retarget all four projects to net8.0 (LTS). - Hoist the properties every project repeated into a shared Directory.Build.props, so the next framework bump is a one-line change. - Correct the package version, which was still pinned at 0.8.0-SNAPSHOT while the project is on 1.0.0-SNAPSHOT. The Maven build already overrides this via -p:Version, so only standalone `dotnet build` was affected. - Replace the deprecated PackageLicenseUrl with the PackageLicenseExpression SPDX form (clears NU5125). - Add Microsoft.NET.Test.Sdk to the KNXnet/IP test project. Without it the xunit suite compiled but was never discovered or executed, so the tests have not actually been running. - Mark the test project non-packable; it was emitting a NuGet package. - Bump NLog 4.7.13 -> 5.3.4 and xunit 2.4.1 -> 2.9.2 for net8.0 support. `dotnet build plc4net.sln` completes with 0 errors on the .NET 9 SDK. Note: Ayx.BitIO 1.0.0 (used by ReadBuffer/WriteBuffer) is a .NET Framework only package and now restores under NU1701 fallback. It still resolves, but it is unmaintained and should be replaced before PLC4Net is relied on cross-platform.
Reading any value through IPlcValue returned 0/false/null regardless of what the value actually held. PlcValueAdapter is the only class implementing IPlcValue, and every one of its members was non-virtual and returned `default`. The subclasses that carry the real data declared their accessors with `new` rather than `override`, so they hid the base member without re-implementing the interface. The interface map therefore still pointed at PlcValueAdapter. Calling through the concrete type looked right, which is presumably why this went unnoticed, but every consumer reaches a value through IPlcValue. IPlcValue v = new PlcDINT(42); v.GetInt(); // 0 v.IsSimple(); // false - Make the PlcValueAdapter members virtual and turn the 30 `new` declarations in PlcSimpleValueAdapter, SimpleNumericValueAdapter, PlcBYTE, PlcWORD, PlcDWORD and PlcLWORD into overrides. - Implement the accessors on PlcBOOL, PlcSTRING, PlcCHAR, PlcWCHAR and PlcWSTRING, which stored a value but never exposed it. Two further defects in SimpleNumericValueAdapter, both reachable once dispatch works: - The narrowing accessors unboxed with a direct `(byte) value` cast on an IComparable holding a boxed Int32, which throws InvalidCastException. They now convert via Convert.ToXxx. - The range checks called IComparable.CompareTo against a bound of a different numeric type, e.g. a boxed Int64 against ulong.MinValue, which throws ArgumentException. They now widen to decimal first, which covers every integral PLC type exactly. Adds plc4net-spi-test with 12 tests, all asserting through IPlcValue rather than the concrete type so the regression cannot come back unnoticed.
Once the KNXnet/IP test actually ran, it failed inside Ayx.BitIO with an ArgumentOutOfRangeException out of StringBuilder.ToString: the package encodes every read as a string of '0'/'1' characters and mishandles a full 32 bit field. PLC4Net could not read a float at all. Ayx.BitIO is a .NET Framework only assembly, unmaintained since 2016, and was being resolved under NU1701 fallback after the net8.0 retarget. Replace it with BitReader/BitWriter in the SPI: plain managed MSB-first bit access, no external dependency, and the same order every mspec-described protocol uses. Repairing the buffers surfaced several more defects, none of which could have worked in production: - ReadUlong/ReadLong read (bitLength - 32) bits and then a further bitLength bits, consuming 96 bits for a 64 bit field. They never round-tripped against the corresponding writes. - WriteFloat/WriteDouble called WriteByte(8, bytes[i]) against a (value, bitLength) signature, so they wrote the constant 8 with a bit width taken from the payload. - The 16 bit KNX branch of WriteFloat was an empty block that emitted nothing. - ReadDouble at 32 bits passed a 4 byte array to BitConverter.ToDouble, which always throws. - ReadFloat/ReadDouble round-tripped through BitConverter.GetBytes, reordering the bytes on a little-endian host. They now reinterpret the raw bits. - Signed narrow fields were cast rather than sign-extended, so a 4 bit -8 read back as +8. - HasMore used < instead of <=, refusing a read that exactly filled the buffer. - ReadString, ReadByteArray and WriteString threw NotImplementedException. S7 and Modbus both need them; they are implemented here. - ParseException was an empty class that did not derive from Exception, so no parse failure was reportable. It now is one, and short reads raise it with the expected and available bit counts. The KNX decode test passed only four bytes for DPT 14.019, which is a reserved byte followed by a float32. Corrected to the full five byte frame. 44 tests pass (43 SPI, 1 KNXnet/IP); `dotnet build` is clean with no warnings.
plc4c, plc4go, plc4j and plc4py each have a platform workflow; plc4net had none, so nothing verified it and it was free to rot. That is the mechanism by which it ended up several years behind: a value model that returned `default` through its own interface, a bit codec that could not read a float, and a test suite that was never executed all survived because no build ever ran them. Mirrors python-platform.yml: same triggers, same Maven and Develocity setup, same cache strategy. Builds across ubuntu, macos and windows, which only became possible once the projects moved off net452. Runs `dotnet test` as an explicit step after the Maven build so individual xunit failures show up in the job log rather than as an opaque exec-plugin non-zero exit.
The existing API carried pre-SPI3 naming (IPlcField) and async-only driver methods. This aligns it with the Java SPI3 contracts: - IPlcField → IPlcTag (plc4j moved from PlcField to PlcTag in SPI3) - IPlcDriver switches from async ConnectAsync() to synchronous Connect(); connection-string dispatch happens in DriverBase, not piecemeal in each driver. - IPlcConnection drops the async pattern; drivers that need async internally use the transport's event/callback model. - PlcDriverManager can now resolve a protocol code from a connection string via ConnectionString.Parse (moved here from an spi package because it carries no SPI dependency). - ConnectionString + RedactSecrets extracted from Java DriverBase.URI_PATTERN and the parameter redaction regex; the same pattern lets a connection string address the same device from Java or .NET.
The .NET port had no driver runtime — no transport layer, no codec, and no driver or connection base. A concrete driver like S7 or Modbus had nothing to build on. This is the foundation, modelled on the Java SPI3 contracts. Transport layer: - ITransport — factory for a kind of transport (tcp, cotp, …) - ITransportInstance — live byte-level connection (open, peek, read, write, close). Mirrors Java TransportInstance. - IAsyncTransportInstance — transport that pushes a data-available callback so the driver does not poll - BaseTransportInstance — shared configuration and open-guard - DefaultTransportManager — in-memory registry; drivers wire transports into it explicitly, avoiding assembly-scanning - RingBuffer — fixed-capacity lock-free producer/consumer for a transport's read loop - TransportException TcpTransport — a concrete transport with async read loop, connect-timeout, IPv6 address parsing, /driver-config extraction Driver layer: - DriverBase — connection-string dispatch, transport lookup, supported-transport enforcement, default transport resolution. Mirrors Java DriverBase. - ConnectionBase — wraps a TransportInstance and exposes the IPlcConnection contract - MessageCodecBase — peek/read/parse framing loop that turns a byte stream into whole protocol messages. Mirrors Java MessageCodecBase. - IMessage — marker for generated/hand-written messages that can self-serialize 77 tests (43 buffer/value + 22 connection-string + 8 ring-buffer + 4 transport address + 4 driver-base + 1 knx). dotnet build clean.
Driver protocol codes are now matched case-insensitively. The manager registered a driver under its ProtocolCode verbatim but looked it up lowercased, and DriverBase compared the two with a case-sensitive ordinal comparison, so a driver whose code was not already lowercase could be registered and then never found. Both now behave the way DefaultTransportManager already did for transport codes. Connection-string parameters are decoded the way Java decodes them. Uri.UnescapeDataString leaves '+' as a literal plus, while Java hands values to URLDecoder.decode(value, UTF_8), where it means a space; and GetIntParameter parsed under the current culture rather than the invariant one, so a connection string could mean different things on different machines. The TCP transport no longer leaks its socket. One flag meant both "the connection is usable" and "our end has been released", so when the read loop cleared it on the way out - which is what happens whenever the peer hangs up - a later Close() saw an already-closed connection and returned without disposing anything, leaving the socket and its CancellationTokenSource to finalization once per disconnect. Releasing is now guarded separately and every exit from the read loop goes through it. A related guard was also ineffective: Close() skips waiting on the read loop when called from inside it, but compared Task.CurrentId against the loop's task id, and an async method's continuations do not run as the task Task.Run returned, so the wait always ran its full timeout. RingBuffer copies in bulk instead of one byte at a time, on a path the read loop walks while holding its lock, and the exception types drop the obsolete binary-serialization constructors that warned under net8.0. Building plc4net under -P with-dotnet runs the module's own RAT check, which flags plc4net.sln. A solution file cannot carry a licence header - its Format Version marker has to stay within the first two lines - so it is excluded in plc4net's own pom. Nothing had caught this before because no CI job built the .Net module. Tests grow from 77 to 106, and the transport now has the first ones that drive a real socket. That gap is why these defects survived: TcpTransportInstance had no coverage at all, and it is where all of them were.
Move ConnectionString from the api module to spi/drivers, next to
DriverBase — the three consumers that actually need the full parser
(transport code, host, port, and parameters) are all in DriverBase.
PlcDriverManager, the one api-layer consumer, only ever needed the
protocol code, and new Uri(url).Scheme provides that the same way
Java DefaultPlcDriverManager.getDriverForUrl() uses URI.getScheme().
Both behave identically on every PLC4X connection-string form,
including the two-scheme case (s7:cotp://host → "s7").
Naming aligned with Java SPI3 per upstream feedback:
IPlcField → IPlcTag (the empty legacy interface is removed)
IPlcFieldRequest → IPlcTagRequest (FieldCount → TagCount, etc.)
IPlcFieldResponse → IPlcTagResponse
AddItem(name, fieldQuery) → AddTagAddress(name, tagAddress) (read)
AddItem(name, fieldQuery, values) → AddTag(name, tagAddress, values) (write)
Three defensive fixes from review:
ReadBuffer.HasMore now rejects a negative bit length instead of
silently returning true.
TcpTransport.CreateConfiguration rejects a non-positive receive-
buffer-size and falls back to the default (81920), so a connection
string like ?receive-buffer-size=0 no longer crashes the
RingBuffer constructor.
RingBuffer.Advance: the comment now describes the code rather
than a subtraction path that was never taken.
Tests grow from 107 to 112.
…amework
Test transport (in-memory loopback):
A new plc4net-transport-test project that mirrors the Java SPI3
TestTransport. It simulates a network with two ring buffers — one
for injecting bytes that the driver reads, and one that captures
what the driver writes. Drivers can now run end-to-end tests
without hardware, and those tests work in CI.
Message infrastructure (plc4net-spi/drivers/messages):
Ports the core of the Java SPI3 message layer to C#:
Items:
PlcTagItem<T>, PlcTagValueItem<T>, PlcResponseItem<T>
DefaultPlcTagItem, DefaultPlcTagValueItem
DefaultPlcResponseItem, DefaultPlcTagErrorItem
Read path:
DefaultPlcReadRequest (IPlcReadRequest + IPlcTagRequest)
DefaultPlcReadRequestBuilder (AddTagAddress)
DefaultPlcReadResponse (explicit IPlcResponse.Request)
Write path:
DefaultPlcWriteRequest (IPlcWriteRequest + IPlcTagRequest)
DefaultPlcWriteRequestBuilder (AddTag, all overloads)
DefaultPlcWriteResponse (explicit IPlcResponse.Request)
IPlcWriteResponse (new api interface)
Driver contracts:
PlcReader — a driver implements this to execute read requests
PlcWriter — a driver implements this to execute write requests
PlcTagHandler — a driver parses its address syntax with this
Tests grow from 112 to 124 (8 test transport + 4 message infrastructure).
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).
The second stage of the pure-.NET mspec toolchain: the type model (IR)
that sits between the ANTLR parse tree and the language output.
Type model:
Terms — IntegerLiteral, StringLiteral, BooleanLiteral, VariableLiteral,
BinaryExpression, UnaryExpression, ScopeTerm
Definitions — TypeDefinition, ComplexTypeDefinition,
DiscriminatedComplexTypeDefinition, EnumTypeDefinition, Argument
Fields — SimpleField, ConstField, ImplicitField, ArrayField,
DiscriminatorField, TypeSwitchField, EnumField, ReservedField,
VirtualField, PaddingField (+ CaseStatement)
MspecModelBuilder:
Direct tree walker that builds the type model from an ANTLR parse
tree. Handles complex types, data-io definitions, constants blocks,
and 10 field types.
CSharpModelGenerator:
Generates C# model classes with type mapping (uint 8→byte,
uint 16→ushort, etc.) and PascalCase properties.
Pipeline tests: mspec → parse tree → type model → C# code.
Tests grow from 131 to 136.
The first real PLC4Net driver. Implements the Modbus TCP protocol end-to-end: address parsing, PDU construction, MBAP framing, and response decoding. ModbusTag: Parses address strings like coil:0, holding:10, discrete:5, input:3. Case-insensitive, with clear error messages. ModbusPDU: Builds and parses all common Modbus function codes: Read Coils (0x01), Read Discrete Inputs (0x02), Read Holding Registers (0x03), Read Input Registers (0x04), Write Single Coil (0x05), Write Single Register (0x06), Write Multiple Registers (0x10). ModbusTcpDriver: Extends DriverBase, registers itself under "modbus-tcp", default transport "tcp". Supports unit-identifier parameter. ModbusConnection: Implements PlcReader and PlcWriter. Direct byte-level I/O with MBAP header framing (transaction ID, protocol ID, length, unit ID). Handles Modbus error responses (function code + 0x80 + exception code). Tests grow from 136 to 146 (10 new Modbus tests).
The second real PLC4Net driver. Implements the Siemens S7 (S7comm)
protocol with TPKT (RFC 1006) framing and COTP transport.
COTP transport (plc4net-transport-cotp):
TpktFrame — 4-byte TPKT header wrapping/unwrapping for ISO-on-TCP
CotpTransport — wraps a TCP connection with TPKT framing
CotpTransportInstance — intercepts read/write to add/strip TPKT
S7 driver (plc4net-driver-s7):
S7Tag — parses Siemens address syntax:
%DBn.DBTm, %DBn.DBXm.b, %Mn.b, %In.b, %Qn.b, %Cn, %Tn
S7PDU (S7Constants) — builds S7comm Read Var and Write Var
request PDUs with correct transport sizes and area codes
S7Driver — extends DriverBase, protocol code "s7", default
transport "cotp"
S7Connection — implements PlcReader with S7 response parsing
Tests grow from 146 to 159 (13 new S7/TKPT tests).
Sprint 6 — Generator Phase C+D:
CSharpModelGenerator now handles the full set of field types:
SimpleField (read/write via ReadByte/ReadUshort etc.),
ConstField (validated on read), ArrayField (array properties),
EnumField (enum type references), DiscriminatorField,
VirtualField, PaddingField (skipped).
Adds StaticParse generation with read-method dispatch,
Serialize with write-method dispatch, and proper constructor
with default+parameterized forms. Type mapping supports all
IEC 61131-3 base types (bool, byte, uint, int, float, string).
Sprint 7 — Engineering:
Plc4NetServiceExtensions:
RegisterDriver — fluent extension for driver registration
ScanAndRegisterDrivers — assembly scanning for DriverBase
subclasses with parameterless constructors
Tests grow from 159 to 161.
cf0302b to
176630d
Compare


Revives
plc4net, which the README currently lists as "not ready for usage - abandoned", to the point where it builds on all three platforms, has an executing test suite, and has a driver runtime aligned with SPI3.Relates to #2655. Follows the
dev@thread from 2026-07-24 where Christofer suggested a PR as the way to get started.Reviewing this
The six commits are independent and each stands alone. Reviewing commit by commit is much easier than reviewing the combined diff.
fade923net452→net8.0; hoist shared properties intoDirectory.Build.props; addMicrosoft.NET.Test.Sdkd3b4632IPlcValuedispatch reach the concrete value types; add missing accessorsf95f216Ayx.BitIOwith an in-houseBitReader/BitWriter; repairReadBuffer/WriteBuffer; fixParseException76cfe5b.github/workflows/dotnet-platform.yml(ubuntu/macos/windows)7285a3fPlcField→PlcTag, synchronousConnect,ConnectionString7486c4dWhy each one is needed
fade923— net8.0.net452builds fine on Windows but cannot build on Linux or macOS runners, which is why plc4net was never in the CI matrix. This commit also addsMicrosoft.NET.Test.Sdkto the test projects; without it the suites compiled but never executed. That single omission is why everything below went unnoticed.d3b4632— the value model was dead through its own interface. Subclasses declaredpublic new bool GetBool()rather thanoverride. In C#newis compile-time name hiding and does not participate in virtual dispatch, so a caller holding anIPlcValue— which is how the API hands values out — landed onPlcValueAdapter's defaults.((IPlcValue) new PlcDINT(42)).GetInt()returned0.PlcBOOL,PlcSTRING,PlcCHAR,PlcWCHARandPlcWSTRINGadditionally stored a value but never exposed it at all.f95f216— the bit codec.Ayx.BitIOis net45-only and unmaintained since 2016; internally it round-trips reads through aStringBuilderof'0'/'1'characters and throws on a 32-bit field, so plc4net could not read a float. It is replaced with an MSB-firstBitReader/BitWriter(~240 lines, no dependencies). Fixing the codec then exposed the layer above it:ReadUlong/ReadLongconsumed2*bitLength-32bits,WriteFloat/WriteDoublepassed arguments in the wrong order, the 16-bit float branch was an empty block,ReadDouble(32)always threw, andReadString/ReadByteArray/WriteStringwereNotImplementedException.ParseExceptiondid not derive fromException.The KNX DPT 9.x 16-bit format is implemented explicitly rather than as IEEE-754 half. One test documents that the format is coarse at the top of its range (step 327.68 at exponent 15) so the next person does not read it as a rounding bug.
7285a3f— SPI3 alignment.PlcField→PlcTag,ConnectAsync→ synchronousConnect, and aConnectionStringparser whose grammar is character-for-character the JavaDriverBase.URI_PATTERN.7486c4d— the driver runtime.DriverBase(connection-string dispatch, transport selection, supported-transport enforcement),ConnectionBase,MessageCodecBase<T>, the transport abstraction, and a TCP transport.Relationship to the Java SPI3 contracts
spi/drivers/DriverBase.csplc4j/spi/drivers/.../spi/drivers/DriverBase.javaspi/drivers/MessageCodecBase.csplc4j/spi/drivers/.../spi/drivers/MessageCodecBase.javaspi/transports/ITransportInstance.csplc4j/transports/api/.../spi/transports/api/TransportInstance.javaapi/api/ConnectionString.csDriverBase.URI_PATTERNplus parameter parsingFour places I deliberately diverged
Flagging these because they are the most likely thing to be objected to, and I would rather discuss them than have them found in review.
ConnectionStringis a public type in the API, not the SPI. It has no SPI dependency, andPlcDriverManager— which lives in the API — needs it to route the protocol code.IsOpen,ProtocolCode). .NET convention.ITransportInstanceextendsIDisposable. .NET resource convention; enablesusing.async/await, not a virtual thread. .NET has no virtual threads; the async socket path is the equivalent.If you would rather have a literal transliteration of the Java API, say so and I will change these — but the .NET ecosystem expectations argued for the above.
What is not here
Verification
dotnet build plc4net/plc4net.sln— 0 errors, 0 warningsdotnet test plc4net/plc4net.sln— 77 passing (76 SPI, 1 KNX)knxnetip.mspec; output is byte-for-byte identical to the checked-in.cs, so this PR does not drift from the protocol descriptionsValue-model tests assert exclusively through
IPlcValuerather than the concrete types. That is deliberate: thenew/overridedefect was invisible to tests bound to concrete types and only visible through the interface.CI is the one thing I cannot verify from here —
dotnet-platform.ymlhas never run in this repository's environment. If it fails I will fix it on the branch.The question I would most like answered
CsLanguageOutput.getComplexTypeTemplates()registers onlymodel-template.cs.ftlh. Anio-template.cs.ftlhexists beside it but is not registered, and its body is still verbatim Java (implements MessageInput<>,@Override,throws ParseException). So generating a protocol for C# yields data classes and nothing that can parse or serialize a frame.Java has since moved to the code-based generators under
org.apache.plc4x.codegeneration.language.java; go, c and python are still on freemarker.Should C# stay on freemarker and get a real
io-template.cs.ftlh, or follow Java to a code-based generator?I will do either. If freemarker is acceptable as an interim, I would start with a minimal KNXnet/IP round-trip as proof — which doubles as the specification for a code-based generator later, so neither path wastes the other's work.